File size: 9,064 Bytes
85cd9f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6fc6edb
 
85cd9f9
6fc6edb
85cd9f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6fc6edb
 
85cd9f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6fc6edb
 
 
85cd9f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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)
        self.meta = json.loads((folder / "reader.json").read_text())
        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
        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,
            "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

    def _sentence(self, lines, i, picks, used):
        t = self.text(lines[i])
        used.add(i)
        j = i + 1
        while t and j <= i + 2 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 (re.match(r"^\s{2,}\S", nxt) or nxt.strip()[:1].islower()):
                break
            used.add(j)
            more = self.text(nxt)
            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
        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"]),
        }