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.
Files changed (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. 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