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"]),
        }