File size: 11,388 Bytes
09f0d09 c5df7fa 09f0d09 23f15f0 09f0d09 23f15f0 09f0d09 c5df7fa 09f0d09 c5df7fa 09f0d09 23f15f0 eb00edf 09f0d09 eb00edf c5df7fa 09f0d09 c5df7fa eb00edf 09f0d09 c5df7fa 09f0d09 23f15f0 c5df7fa 09f0d09 eb00edf 09f0d09 c5df7fa 09f0d09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | """Canopy reader: read one AI coding agent terminal screen and build a status card from its own lines.
Self-contained: needs only `onnxruntime`, `tokenizers` and `numpy`. Put this file next to (or point it at)
a model folder holding `reader.json`, `tokenizer.json` and `model.int8.onnx`.
from canopy_reader import CanopyReader
reader = CanopyReader("path/to/canopy-reader-17m")
reading = reader.read(state, lines) # every head, as line indices and classes
card = reader.card(reading, lines) # the card as text
`lines` is the screen as a list of lines, bottom last (wrapping undone, chrome stripped: see the model
card). `state` is a small dict: {"agent_program": "claude", "terminal_title": "...", "daintree_observed":
{"agent_state": "waiting", "waiting_reason": "prompt", "seconds_in_state": 12}}; only `agent_program` is
required.
"""
from __future__ import annotations
import json
import math
import re
from pathlib import Path
import numpy as np
def _strip(s: str) -> str:
return s.strip()
def line_candidates(lines: list[str], rows: int) -> list[int]:
"""The bottom `rows` lines longer than three characters."""
start = max(0, len(lines) - rows)
return [i for i in range(start, len(lines)) if len(lines[i].strip()) > 3]
def _sigmoid(x: float) -> float:
return 1 / (1 + math.exp(-x))
def _softmax(xs) -> list[float]:
m = max(xs)
e = [math.exp(x - m) for x in xs]
s = sum(e)
return [x / s for x in e]
class CanopyReader:
def __init__(self, folder: str | Path, variant: str = "int8", threads: int = 1):
import onnxruntime as ort
from tokenizers import Tokenizer
folder = Path(folder)
# config.json first: Hugging Face counts a download per fetch of it (reader.json is the same file).
meta_file = folder / "config.json" if (folder / "config.json").exists() else folder / "reader.json"
self.meta = json.loads(meta_file.read_text(encoding="utf-8"))
self.tok = Tokenizer.from_file(str(folder / "tokenizer.json"))
opts = ort.SessionOptions()
opts.intra_op_num_threads = threads
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
self.session = ort.InferenceSession(str(folder / f"model.{variant}.onnx"), opts, providers=["CPUExecutionProvider"])
self.outputs = [o.name for o in self.session.get_outputs()]
self.cls, self.sep, self.pad = (self.tok.token_to_id(t) for t in ("[CLS]", "[SEP]", "[PAD]"))
def _ids(self, text: str) -> list[int]:
return self.tok.encode(text, add_special_tokens=False).ids
def encode(self, state: dict, lines: list[str]) -> dict:
"""`[CLS] state-json [SEP]`, then `[i] line\\n` for each non-blank line, filled from the bottom of the
screen up until `max_len` tokens, then `[SEP]`. Each line's first token is where its heads read."""
m = self.meta
header = self._ids(json.dumps(state, ensure_ascii=False, separators=(",", ":")))[: m["header_tokens"]]
budget = m["max_len"] - len(header) - 3
pieces = []
for i in range(len(lines) - 1, -1, -1):
if not lines[i].strip():
continue
ids = self._ids(f"[{i}] {lines[i]}\n")[: m["line_tokens"]]
if len(ids) > budget:
break
budget -= len(ids)
pieces.append((i, ids))
pieces.reverse()
ids = [self.cls, *header, self.sep]
index, pos = [], []
for i, p in pieces:
index.append(i)
pos.append(len(ids))
ids.extend(p)
ids.append(self.sep)
q = set(line_candidates(lines, m["question_candidate_rows"]))
s = set(line_candidates(lines, m["status_candidate_rows"]))
return {"ids": ids, "index": index, "pos": pos, "q": [i in q for i in index], "s": [i in s for i in index]}
def read(self, state: dict, lines: list[str]) -> dict:
e = self.encode(state, lines)
k = max(1, len(e["index"]))
pad = lambda xs, v: xs + [v] * (k - len(xs)) # noqa: E731
feed = {
"input_ids": np.array([e["ids"]], dtype=np.int64),
"attention_mask": np.ones((1, len(e["ids"])), dtype=np.int64),
"line_pos": np.array([pad(e["pos"], 0)], dtype=np.int64),
"line_mask": np.array([pad([True] * len(e["index"]), False)]),
"q_mask": np.array([pad(e["q"], False)]),
"s_mask": np.array([pad(e["s"], False)]),
}
out = dict(zip(self.outputs, self.session.run(self.outputs, feed)))
return self.decode({n: v[0].astype(np.float64).tolist() for n, v in out.items()}, e)
def decode(self, out: dict, e: dict) -> dict:
m, idx, t = len(e["index"]), e["index"], self.meta["thresholds"]
def pick(name):
scores = out[name]
cand = scores[:m] + [scores[-1]] # the last column is "none"
j = max(range(len(cand)), key=lambda x: (cand[x], -x))
return -1 if j == m else idx[j]
logit = out["summary_lines"][:m]
p_sum = [_sigmoid(x) for x in logit]
summary = []
for j in sorted(range(m), key=lambda j: (-logit[j], j))[:3]: # rank by logit: no saturation ties
if p_sum[j] >= (t["summary_more"] if summary else t["summary_first"]):
summary.append(idx[j])
else:
break
first = pick("status_line")
second_th = t.get("second")
card_lines = ([first] if first >= 0 else [])
if second_th is not None: # the summary-only card: line two is the best other line scoring about a 2
card_lines += [idx[j] for j in sorted(range(m), key=lambda j: (-logit[j], j)) if idx[j] != first and p_sum[j] >= second_th][:1]
probs = _softmax(out["category"])
cats = self.meta["categories"]
raw = _sigmoid(out["needs_attention"])
g = max(raw, sum(probs[cats.index(c)] for c in t["gate_categories"]))
gate = 0.5 * g / t["gate"] if g < t["gate"] else 0.5 + 0.5 * (g - t["gate"]) / (1 - t["gate"])
cls = lambda n: self.meta["classes"][n][max(range(len(out[n])), key=lambda i: (out[n][i], -i))] # noqa: E731
return {
"category": cats[max(range(len(probs)), key=lambda i: (probs[i], -i))], "category_probs": probs,
"needs_attention": gate, "needs_attention_raw": raw,
"blocked_on_user": _sigmoid(out["blocked_on_user"]), "notable": _sigmoid(out["notable"]),
"question_line": pick("question_line"), "status_line": pick("status_line"),
"action_line": pick("action_line"), "task_line": pick("task_line"),
"summary_lines": summary, "card_lines": card_lines,
"option_lines": [idx[j] for j in range(m) if e["q"][j] and _sigmoid(out["option_lines"][j]) >= t["option"]],
"key_lines": [idx[j] for j in range(m) if out["key_lines"][j] > 0],
**{n: cls(n) for n in self.meta["classes"]},
}
# --- the card as text ---------------------------------------------------------------------------
# A leading glyph is stripped only when whitespace (or the end) follows it, so `**bold**` and `>file` survive.
LEAD = re.compile(r"^(?:[⏺•●✦⎿└├│┃║■✻✶✢✳✽✺·*❯›>▸▶◆◇-](?:\s+|$))+")
BOX_TAIL = re.compile(r"\s*[│┃║]\s*$")
TIMER = re.compile(r"\s*\((?:\d[^)]*)?(?:esc to \w+|thinking|thought for|ctrl\+\w to \w+)[^)]*\)\s*$", re.I)
END = re.compile(r"""[.!?:;)"'`]$""")
@classmethod
def text(cls, line: str | None) -> str | None:
"""A screen line as card text: bullets, tree and box glyphs, spinners and timers off."""
if line is None:
return None
t = cls.TIMER.sub("", cls.BOX_TAIL.sub("", cls.LEAD.sub("", line.strip()))).strip()
return t if len(t) >= 2 else None
@staticmethod
def _short(line: str) -> str | None:
"""A one-character fragment that text() drops for length (a lone "1"), not text the cleanup removed."""
t = line.strip()
return t if len(t) == 1 else None
def _continues(self, prev: str, cur: str) -> bool:
"""Does line `cur` carry on the sentence of `prev` (the agent wrapped it onto a new row)?"""
if not prev.strip() or not cur.strip() or self.LEAD.match(cur.strip()):
return False
pt = self.text(prev)
if not pt or self.END.search(pt):
return False
return bool(re.match(r"^\s{2,}\S", cur) or cur.strip()[:1].islower() or cur.strip()[:1] in "([{'\"`")
def _sentence(self, lines, i, picks, used):
# A pick in the middle of a wrapped sentence starts where the sentence starts (up to two rows back).
k = i
while k > 0 and i - k < 4 and k - 1 not in used and self._continues(lines[k - 1], lines[k]):
k -= 1
for x in range(k, i):
used.add(x)
t = " ".join(filter(None, (self.text(lines[x]) or (self._short(lines[x]) if x != i else None) for x in range(k, i + 1))))
used.add(i)
j = i + 1
while t and j <= i + 4 and j < len(lines) and not self.END.search(t) and j not in used:
nxt = lines[j]
if not nxt.strip() or self.LEAD.match(nxt.strip()):
break # a new bullet is a new item, even when it was picked too
if j not in picks and not self._continues(lines[j - 1], nxt):
break
used.add(j)
more = self.text(nxt) or self._short(nxt) # keep a one-character fragment ("1") the sentence carries on into
if more:
t += " " + more
j += 1
return t
def card(self, r: dict, lines: list[str]) -> dict:
asks = r["category"] in ("approval", "question")
used: set[int] = set()
line = lambda i: self.text(lines[i]) if i >= 0 else None # noqa: E731
if "card_lines" in r and self.meta["thresholds"].get("card") == "summary-only":
# The summary-only card: no headline (the terminal title is the title), up to two lines.
headline = None
picks = list(r["card_lines"])
if len(picks) == 2 and abs(picks[0] - picks[1]) <= 3:
picks.sort() # two lines of one paragraph read in screen order
summary = [s for i in picks if i not in used for s in [self._sentence(lines, i, picks, used)] if s]
else:
headline = self._sentence(lines, r["status_line"], r["summary_lines"], used) if r["status_line"] >= 0 else None
summary = [s for i in r["summary_lines"] if i not in used for s in [self._sentence(lines, i, r["summary_lines"], used)] if s]
return {
"category": r["category"], "needs_attention": r["needs_attention"] >= 0.5, "score": int(r["score"]),
"headline": headline, "summary": summary,
"question": line(r["question_line"]) if asks else None,
"options": [x for x in (self.text(lines[i]) for i in r["option_lines"]) if x] if asks else [],
"action": line(r["action_line"]) if r["category"] == "approval" else None,
"task": line(r["task_line"]),
"tests": r["tests"], "changes": r["changes"],
"progress": None if r["progress"] == "none" else int(r["progress"]),
}
|