agent-decoder 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- clau_decode/watcher.py +63 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""BTW modal capture — pure extraction functions for /btw PTY output.
|
|
2
|
+
|
|
3
|
+
The /btw command opens an inline modal inside the existing PTY screen buffer
|
|
4
|
+
(no alt-screen). The response is rendered via cursor-relative in-place writes.
|
|
5
|
+
Each position goes through a loading animation before the final text is written.
|
|
6
|
+
|
|
7
|
+
Full spike findings: docs/pty-runner-phase2-spike.md
|
|
8
|
+
|
|
9
|
+
Mechanics summary
|
|
10
|
+
-----------------
|
|
11
|
+
- Modal open: text ``without interrupting the main conversation`` appears as a
|
|
12
|
+
continuous byte sequence within ~300 bytes after /btw is echoed. NOTE: the
|
|
13
|
+
surrounding words ("Ask a quick side question") may be separated by column-
|
|
14
|
+
positioning CSI sequences in some TUI versions, so only the tail fragment is
|
|
15
|
+
used as the stable marker.
|
|
16
|
+
- Response region: bounded by the "Answering…" status marker (start) and the
|
|
17
|
+
``↑/↓`` arrow bytes (``\\xe2\\x86\\x91/\\xe2\\x86\\x93``) that open the footer
|
|
18
|
+
(end). The full footer text ``↑/↓scroll · f to fork · Esc to close`` may
|
|
19
|
+
have column-positioning escapes interspersed, so only the leading ``↑/↓``
|
|
20
|
+
bytes are used as the marker.
|
|
21
|
+
- Two rendering variants observed in the field:
|
|
22
|
+
|
|
23
|
+
*Variant A (single-line, old TUI)*: final write uses cursor-relative
|
|
24
|
+
positioning with ESC[K suffix::
|
|
25
|
+
|
|
26
|
+
ESC [ <N> C ESC [ <M> A <text> ESC [ K
|
|
27
|
+
|
|
28
|
+
*Variant B (multi-line, newer TUI)*: the last ``ESC[NC ESC[MA`` sequence
|
|
29
|
+
that is NOT followed by a colour escape starts the response block. Words are
|
|
30
|
+
placed with absolute column jumps ``ESC[NG``; lines separated by ``\\r``
|
|
31
|
+
or ``\\r\\r\\n`` followed by re-positioning sequences.
|
|
32
|
+
|
|
33
|
+
- Both variants: animation frames use colour wrappers
|
|
34
|
+
(``ESC[38;2;…m <char> ESC[39m``) and are distinguished from final writes by
|
|
35
|
+
the ABSENCE of a trailing ESC[K (variant A) or by being followed immediately
|
|
36
|
+
by another colour-wrapped frame or by being a single character (variant B).
|
|
37
|
+
- Dismiss: send ESC after the footer appears; allow ~2 s for TUI redraw.
|
|
38
|
+
|
|
39
|
+
Multi-line addendum (2026-05-28): see docs/pty-runner-phase2-spike.md.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import re
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Public constants
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
# Stable tail fragment of the modal header text. Both TUI variants render
|
|
53
|
+
# "without interrupting the main conversation" as a continuous byte sequence.
|
|
54
|
+
# The leading "Ask a quick side question" may have column-jump escapes between
|
|
55
|
+
# words in some versions, so we anchor on this suffix only.
|
|
56
|
+
BTW_MODAL_OPEN_MARKER: bytes = b"without interrupting the main conversation"
|
|
57
|
+
|
|
58
|
+
# The ``↑/↓`` arrow bytes that open the footer line. The full text
|
|
59
|
+
# "↑/↓scroll · f to fork · Esc to close" may have CSI column-jumps between
|
|
60
|
+
# words; only the leading arrow pair is stable across TUI versions.
|
|
61
|
+
BTW_RESPONSE_COMPLETE_MARKER: bytes = b"\xe2\x86\x91/\xe2\x86\x93" # "↑/↓"
|
|
62
|
+
|
|
63
|
+
BTW_DISMISS_SEQUENCE: bytes = b"\x1b" # ESC
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Pre-compiled patterns
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# Variant A: cursor-relative final write with ESC[K suffix.
|
|
70
|
+
# Animation frames are excluded because they lack the trailing ESC[K.
|
|
71
|
+
_FINAL_WRITE_RE = re.compile(rb"\x1b\[\d+C\x1b\[\d+A([^\x1b]+)\x1b\[K")
|
|
72
|
+
|
|
73
|
+
# Any cursor-relative positioning sequence ESC[NC ESC[MA (move right N, up M).
|
|
74
|
+
# Used to find the start of the response block in Variant B.
|
|
75
|
+
_CURSOR_REL_RE = re.compile(rb"\x1b\[\d+C\x1b\[\d+A")
|
|
76
|
+
|
|
77
|
+
# Case-insensitive word-boundary match for /btw at start of string.
|
|
78
|
+
# Mirrors claude's own regex: /^\\/btw\\b/gi
|
|
79
|
+
_BTW_INPUT_RE = re.compile(r"^/btw\b", re.IGNORECASE)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Input detection
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_btw_input(content: str) -> bool:
|
|
88
|
+
"""Return True if *content* is a /btw command.
|
|
89
|
+
|
|
90
|
+
Matches ``/btw`` at the start of the string followed by a word boundary
|
|
91
|
+
(space, newline, or end-of-string). Case-insensitive, mirroring claude's
|
|
92
|
+
own regex ``/^\\/btw\\b/gi``.
|
|
93
|
+
|
|
94
|
+
Edge cases::
|
|
95
|
+
|
|
96
|
+
/btw → True
|
|
97
|
+
/btw foo → True
|
|
98
|
+
/btw\\n → True
|
|
99
|
+
/BTW foo → True (claude's regex is /gi)
|
|
100
|
+
/btw_foo → False (word boundary fails before _)
|
|
101
|
+
/btw-foo → True (\\b matches before -)
|
|
102
|
+
" /btw foo" → False (leading whitespace not stripped)
|
|
103
|
+
"x/btw foo" → False
|
|
104
|
+
"""
|
|
105
|
+
return bool(_BTW_INPUT_RE.match(content))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def extract_btw_input(content: str) -> str:
|
|
109
|
+
"""Strip the leading ``/btw`` prefix (and following whitespace) from *content*.
|
|
110
|
+
|
|
111
|
+
Returns the bare side-question text. If *content* does not start with
|
|
112
|
+
/btw, returns *content* unchanged.
|
|
113
|
+
"""
|
|
114
|
+
stripped = _BTW_INPUT_RE.sub("", content, count=1)
|
|
115
|
+
return stripped.lstrip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Marker search helpers
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def find_modal_open(raw: bytes, start: int = 0) -> int:
|
|
124
|
+
"""Return the byte offset of :data:`BTW_MODAL_OPEN_MARKER` in *raw[start:]*.
|
|
125
|
+
|
|
126
|
+
Returns -1 if not found.
|
|
127
|
+
"""
|
|
128
|
+
return raw.find(BTW_MODAL_OPEN_MARKER, start)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def find_response_complete(raw: bytes, start: int = 0) -> int:
|
|
132
|
+
"""Return the byte offset of :data:`BTW_RESPONSE_COMPLETE_MARKER` in *raw[start:]*.
|
|
133
|
+
|
|
134
|
+
Returns the offset of the START of the marker, or -1 if not found.
|
|
135
|
+
"""
|
|
136
|
+
return raw.find(BTW_RESPONSE_COMPLETE_MARKER, start)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Internal: column-aware text reconstruction (Variant B)
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _reconstruct_columnar(raw_fragment: bytes) -> str:
|
|
145
|
+
"""Reconstruct human-readable text from a byte sequence that uses
|
|
146
|
+
absolute column positioning (``ESC[NG``) to place words and ``\\r`` /
|
|
147
|
+
``\\r\\r\\n`` as line separators.
|
|
148
|
+
|
|
149
|
+
This handles the multi-line TUI rendering variant where words are
|
|
150
|
+
placed at absolute terminal columns rather than written sequentially.
|
|
151
|
+
Gaps between words are filled with a single space.
|
|
152
|
+
|
|
153
|
+
Colour/attribute CSI sequences (``ESC[…m``) and unrecognised sequences
|
|
154
|
+
are silently dropped.
|
|
155
|
+
|
|
156
|
+
Returns the reconstructed text with leading/trailing whitespace stripped
|
|
157
|
+
on each line, and empty lines removed.
|
|
158
|
+
"""
|
|
159
|
+
lines: list[str] = []
|
|
160
|
+
current_line: list[str] = []
|
|
161
|
+
current_col: int = 0
|
|
162
|
+
|
|
163
|
+
_TOKEN_RE = re.compile(
|
|
164
|
+
rb"""
|
|
165
|
+
\r\r\n # CR LF LF
|
|
166
|
+
| \r\n # CR LF
|
|
167
|
+
| \r # bare CR
|
|
168
|
+
| \x1b\[(\d+)G # absolute column ESC[NG
|
|
169
|
+
| \x1b\[\d*[ABCD] # cursor move (ignore)
|
|
170
|
+
| \x1b\[\d+;\d+[Hf] # cursor position (ignore)
|
|
171
|
+
| \x1b\[[\d;]*[a-zA-Z] # other CSI (colour, attrs, etc.)
|
|
172
|
+
| \x1b[^[] # other ESC
|
|
173
|
+
| ([^\x1b\r\n]+) # printable text
|
|
174
|
+
""",
|
|
175
|
+
re.VERBOSE,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
for m in _TOKEN_RE.finditer(raw_fragment):
|
|
179
|
+
raw_tok = m.group(0)
|
|
180
|
+
abs_col = m.group(1)
|
|
181
|
+
text = m.group(2)
|
|
182
|
+
|
|
183
|
+
if raw_tok in (b"\r\r\n", b"\r\n", b"\r"):
|
|
184
|
+
lines.append("".join(current_line))
|
|
185
|
+
current_line = []
|
|
186
|
+
current_col = 0
|
|
187
|
+
elif abs_col is not None:
|
|
188
|
+
target = int(abs_col)
|
|
189
|
+
if target > current_col:
|
|
190
|
+
current_line.append(" ") # one space for any column gap
|
|
191
|
+
current_col = target
|
|
192
|
+
elif text is not None:
|
|
193
|
+
decoded = text.decode("utf-8", errors="replace")
|
|
194
|
+
current_line.append(decoded)
|
|
195
|
+
current_col += len(decoded)
|
|
196
|
+
|
|
197
|
+
if current_line:
|
|
198
|
+
lines.append("".join(current_line))
|
|
199
|
+
|
|
200
|
+
cleaned = [ln.strip() for ln in lines if ln.strip()]
|
|
201
|
+
return "\n".join(cleaned)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Response extractor
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def extract_btw_response(
|
|
210
|
+
raw: bytes,
|
|
211
|
+
open_offset: Optional[int] = None,
|
|
212
|
+
) -> Optional[str]:
|
|
213
|
+
"""Extract assistant text from a /btw modal capture.
|
|
214
|
+
|
|
215
|
+
If *open_offset* is ``None``, scans for :data:`BTW_MODAL_OPEN_MARKER`
|
|
216
|
+
from the start of *raw*. Bounds the search at
|
|
217
|
+
:data:`BTW_RESPONSE_COMPLETE_MARKER` so the footer text and post-modal
|
|
218
|
+
redraws do not contaminate the result.
|
|
219
|
+
|
|
220
|
+
Two rendering variants are handled:
|
|
221
|
+
|
|
222
|
+
*Variant A (single-line)*: Collect ALL matches of
|
|
223
|
+
``\\x1b[\\d+C\\x1b[\\d+A([^\\x1b]+)\\x1b[K`` in the region (final writes;
|
|
224
|
+
animation frames lack the trailing ``\\x1b[K``). Decode each match,
|
|
225
|
+
strip, join with ``"\\n"``.
|
|
226
|
+
|
|
227
|
+
*Variant B (multi-line)*: Find the last ``\\x1b[\\d+C\\x1b[\\d+A``
|
|
228
|
+
sequence in the region that is NOT immediately followed by ``\\x1b[``
|
|
229
|
+
(i.e., not an animation frame's colour wrapper). Everything from that
|
|
230
|
+
cursor sequence onward is the response block; reconstruct human-readable
|
|
231
|
+
text using column-aware parsing.
|
|
232
|
+
|
|
233
|
+
If both variants yield results, Variant A takes precedence (it is the
|
|
234
|
+
more precise pattern when applicable).
|
|
235
|
+
|
|
236
|
+
Returns the extracted text or ``None`` if extraction failed.
|
|
237
|
+
"""
|
|
238
|
+
# Resolve open offset.
|
|
239
|
+
#
|
|
240
|
+
# The modal-open text marker isn't always rendered as a contiguous byte
|
|
241
|
+
# sequence — newer zai TUI versions intersperse cursor-positioning
|
|
242
|
+
# escapes between the header words, breaking ``bytes.find``. When the
|
|
243
|
+
# marker is missing, fall back to scanning the entire buffer: the live
|
|
244
|
+
# PTY accumulator only feeds bytes while ``expecting_btw_response=True``,
|
|
245
|
+
# which is set immediately after the /btw input is written. The buffer
|
|
246
|
+
# therefore starts at-or-after the /btw bytes, so scanning from offset 0
|
|
247
|
+
# won't pick up unrelated assistant content from before the modal.
|
|
248
|
+
# Variant A's pattern (``\x1b[\d+C\x1b[\d+A...\x1b[K``) is specific to
|
|
249
|
+
# the modal's per-character final-write rendering and won't false-match
|
|
250
|
+
# on regular TUI output.
|
|
251
|
+
if open_offset is None:
|
|
252
|
+
found = find_modal_open(raw)
|
|
253
|
+
open_offset = found if found >= 0 else 0
|
|
254
|
+
if open_offset < 0 or open_offset >= len(raw):
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
# Bound the search region at the response-complete marker
|
|
258
|
+
end_offset = find_response_complete(raw, open_offset)
|
|
259
|
+
if end_offset >= 0:
|
|
260
|
+
region = raw[open_offset:end_offset]
|
|
261
|
+
else:
|
|
262
|
+
# Graceful degradation: no footer seen yet (truncated capture)
|
|
263
|
+
region = raw[open_offset:]
|
|
264
|
+
|
|
265
|
+
# --- Variant A: single-line ESC[K-terminated final writes ----------------
|
|
266
|
+
lines_a: list[str] = []
|
|
267
|
+
for m in _FINAL_WRITE_RE.finditer(region):
|
|
268
|
+
text = m.group(1).decode("utf-8", errors="replace").strip()
|
|
269
|
+
if text:
|
|
270
|
+
lines_a.append(text)
|
|
271
|
+
|
|
272
|
+
if lines_a:
|
|
273
|
+
return "\n".join(lines_a)
|
|
274
|
+
|
|
275
|
+
# --- Variant B: multi-line columnar rendering ----------------------------
|
|
276
|
+
# Find the last cursor-relative sequence NOT followed by a colour escape.
|
|
277
|
+
# Animation: ESC[NC ESC[MA ESC[38;...m (colour immediately after)
|
|
278
|
+
# Final write: ESC[NC ESC[MA <printable> (no immediate ESC)
|
|
279
|
+
all_cursor = list(_CURSOR_REL_RE.finditer(region))
|
|
280
|
+
for m in reversed(all_cursor):
|
|
281
|
+
after = region[m.end() : m.end() + 2]
|
|
282
|
+
if not after.startswith(b"\x1b["):
|
|
283
|
+
# This is the start of the response block
|
|
284
|
+
response_raw = region[m.end() :]
|
|
285
|
+
result = _reconstruct_columnar(response_raw)
|
|
286
|
+
if result:
|
|
287
|
+
return result
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
return None
|