@giamme/forge 0.8.0 → 0.10.0

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 (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,608 @@
1
+ """What an agent CLI said and did in one execution: its log read as one list of events.
2
+
3
+ forge sends each dispatch's output to `<role>.log` (forge-dispatch.sh). What that holds depends on the
4
+ harness and how forge called it:
5
+ - codex `--json`: one JSON event per line (thread, items, turns);
6
+ - grok `--output-format streaming-messages-json`, claude and openclaude `--output-format stream-json`: one
7
+ message per line in Claude's shape (system, assistant, user, result);
8
+ - opencode `--format json`: one event per line (text, reasoning, tool_use, step_finish, error);
9
+ - claude `--output-format json`: only the final result, with the session id; the conversation itself is
10
+ then read from Claude's session file, `<config dir>/projects/<project>/<session id>.jsonl`;
11
+ - anything else (agy, plain text runs): the log as it is.
12
+ The format is told from the log itself; the harness only says where a session file would be.
13
+
14
+ An event is a dict with `kind` and the keys that kind uses:
15
+ prompt text the task as the agent got it (a session file's first message)
16
+ message text what the agent wrote
17
+ reasoning text its thinking, when the harness shows it
18
+ command command, output, exit, error a shell command and what it printed
19
+ edit files, tool, error files the agent wrote or changed
20
+ tool tool, target, input, output, error any other tool call (target: the file, pattern or url it is about)
21
+ result text, tokens, error the final answer (and the run's totals)
22
+ error text an error the harness reported
23
+ text text, stream lines that are not structured (a plain log, stderr)
24
+ gap count events left out of the middle of a very long transcript
25
+ Every event may carry `t` (epoch seconds), `id` (the harness's own id for a tool call), `error` (true for a call
26
+ that failed, false for one answered without an error) and, while the execution runs, `running` (a call not
27
+ answered yet). Texts are capped.
28
+ Nothing here opens any path but the log given and, for a Claude result, that one session file.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from datetime import datetime
33
+ import json
34
+ import os
35
+ from pathlib import Path
36
+ import re
37
+ import stat
38
+
39
+ TEXT_CAP = 20000 # characters kept of one message, reasoning or result
40
+ OUTPUT_CAP = 8000 # of a command's or tool's output: the head and the tail
41
+ LOG_CAP = 32 << 20 # bytes of a log read; a longer one is read from its end
42
+ EVENTS_CAP = 5000 # events answered, and characters in them: past either, the middle is left out
43
+ CHARS_CAP = 4_000_000
44
+ _SESSION_ID = re.compile(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')
45
+ _ANSI = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]')
46
+ _SHELL = re.compile(r"(?:/\S+/)?(?:ba|z)?sh -l?c '((?:[^']|'\\'')*)'", re.S) # codex's wrapper around a command
47
+ _TARGETS = ('file_path', 'filePath', 'target_file', 'notebook_path', 'path', 'target_directory', 'pattern', 'query', 'url')
48
+ _SHELL_TOOLS = frozenset(('bash', 'shell', 'run_terminal_command', 'run_shell_command', 'exec_command', 'terminal'))
49
+ _EDIT_TOOLS = frozenset(('edit', 'write', 'multiedit', 'notebookedit', 'search_replace', 'write_file', 'edit_file',
50
+ 'create_file', 'str_replace_editor', 'apply_patch', 'patch', 'replace', 'write_to_file'))
51
+ _CACHE: dict = {} # (path, size, mtime, ...) → (answer, session file it read); a live log is read again when it grew
52
+
53
+
54
+ def cap(text, limit):
55
+ """`text` as a string of at most `limit` characters: its head and tail around a marker, and how much was cut."""
56
+ s = text if isinstance(text, str) else json.dumps(text, ensure_ascii=False, indent=1) if text is not None else ''
57
+ if len(s) <= limit:
58
+ return s, 0
59
+ head = limit * 2 // 3
60
+ tail = limit - head
61
+ return s[:head] + f'\n… {len(s) - limit} characters …\n' + s[-tail:], len(s) - limit
62
+
63
+
64
+ def _put(event, key, value, limit):
65
+ if value is None or value == '':
66
+ return
67
+ text, cut = cap(value, limit)
68
+ event[key] = text
69
+ if cut:
70
+ event[key + '_cut'] = cut
71
+
72
+
73
+ def _event(kind, **fields):
74
+ out = {'kind': kind}
75
+ for key, value in fields.items():
76
+ if key in ('text', 'output', 'input', 'command') and value is not None:
77
+ _put(out, key, value, OUTPUT_CAP if key in ('output', 'input') else TEXT_CAP)
78
+ elif value is not None:
79
+ out[key] = value
80
+ return out
81
+
82
+
83
+ def _unwrap(command):
84
+ """A command without the `bash -lc '…'` codex runs it in."""
85
+ m = _SHELL.fullmatch(command) if isinstance(command, str) else None
86
+ return m.group(1).replace("'\\''", "'") if m else command
87
+
88
+
89
+ def _tool_event(name, args, ident=None, t=None):
90
+ """A tool call as the event it is: a shell command, an edit, or any other tool."""
91
+ low = (name or '').lower()
92
+ args = args if isinstance(args, dict) else {'input': args} if args is not None else {}
93
+ if low in _SHELL_TOOLS:
94
+ return _event('command', command=args.get('command') or args.get('cmd') or args.get('input'), tool=name, id=ident, t=t)
95
+ if low in _EDIT_TOOLS:
96
+ files = [args.get(k) for k in ('file_path', 'filePath', 'path', 'target_file', 'notebook_path') if isinstance(args.get(k), str)]
97
+ if not files and isinstance(args.get('edits'), list):
98
+ files = [e.get('file_path') or e.get('path') for e in args['edits'] if isinstance(e, dict)]
99
+ patch = _patch(args)
100
+ ev = _event('edit', tool=name, files=[f for f in files if f] or None, input=None if patch else args, id=ident, t=t)
101
+ _put(ev, 'patch', patch, OUTPUT_CAP)
102
+ return ev
103
+ # what the call is about (a file, a pattern, a url) heads it; the other arguments follow
104
+ target = next((k for k in _TARGETS if isinstance(args.get(k), str)), None)
105
+ rest = {k: v for k, v in args.items() if k != target}
106
+ return _event('tool', tool=name or 'tool', target=args[target] if target else None, input=rest or None, id=ident, t=t)
107
+
108
+
109
+ def _patch(args):
110
+ """An edit's arguments as -/+ lines: old and new strings (each edit of a multi-edit), or a written file's content."""
111
+ def pair(a):
112
+ old, new = a.get('old_string', a.get('oldString')), a.get('new_string', a.get('newString'))
113
+ if not isinstance(old, str) and not isinstance(new, str):
114
+ return None
115
+ return '\n'.join(['-' + line for line in (old or '').splitlines()] + ['+' + line for line in (new or '').splitlines()])
116
+ parts = [pair(args)] + [pair(e) for e in args.get('edits') or [] if isinstance(e, dict)]
117
+ parts = [part for part in parts if part]
118
+ if parts:
119
+ return '\n…\n'.join(parts)
120
+ content = args.get('content')
121
+ return '\n'.join('+' + line for line in content.splitlines()) if isinstance(content, str) else None
122
+
123
+
124
+ def _when(value):
125
+ """Epoch seconds from an ISO timestamp or epoch milliseconds/seconds, else None."""
126
+ if isinstance(value, (int, float)):
127
+ return value / 1000 if value > 1e11 else float(value)
128
+ if isinstance(value, str) and value:
129
+ try:
130
+ return datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp()
131
+ except ValueError:
132
+ return None
133
+ return None
134
+
135
+
136
+ def _tokens(total, usage):
137
+ """Adds a usage block (Claude, codex, opencode or grok naming) to the running totals."""
138
+ if not isinstance(usage, dict):
139
+ return
140
+ def num(*keys):
141
+ for k in keys:
142
+ v = usage.get(k)
143
+ if isinstance(v, (int, float)):
144
+ return v
145
+ return 0
146
+ cache = usage.get('cache') if isinstance(usage.get('cache'), dict) else {}
147
+ total['input'] = total.get('input', 0) + num('input_tokens', 'input') + num('cache_creation_input_tokens') + num('cache_read_input_tokens') + (cache.get('read') or 0) + (cache.get('write') or 0)
148
+ total['cached'] = total.get('cached', 0) + num('cached_input_tokens', 'cache_read_input_tokens') + (cache.get('read') or 0)
149
+ total['output'] = total.get('output', 0) + num('output_tokens', 'output') + num('reasoning')
150
+
151
+
152
+ # ---------- the formats ----------
153
+
154
+ def _lines(text):
155
+ """(json or None, raw line) for each non-empty line."""
156
+ for raw in text.splitlines():
157
+ line = raw.strip()
158
+ if not line:
159
+ continue
160
+ value = None
161
+ if line[0] == '{':
162
+ try:
163
+ value = json.loads(line)
164
+ except ValueError:
165
+ value = None
166
+ yield (value if isinstance(value, dict) else None), raw
167
+
168
+
169
+ def detect(text):
170
+ """The log's format: codex, claude (Claude's message shape), claude-result, opencode, json or text."""
171
+ seen = 0
172
+ for value, _ in _lines(text[:200000]):
173
+ if value is None:
174
+ continue
175
+ seen += 1
176
+ kind = value.get('type')
177
+ if 'item' in value or kind in ('thread.started', 'turn.started', 'turn.completed', 'turn.failed'):
178
+ return 'codex'
179
+ if kind in ('step_start', 'step_finish', 'tool_use', 'text', 'reasoning') and ('part' in value or 'sessionID' in value):
180
+ return 'opencode'
181
+ if kind in ('system', 'assistant', 'user') and ('message' in value or 'subtype' in value or 'session_id' in value):
182
+ return 'claude'
183
+ if kind == 'result' and seen == 1 and 'session_id' in value:
184
+ rest = [v for v, _ in _lines(text) if v is not None]
185
+ return 'claude-result' if len(rest) == 1 else 'claude'
186
+ if seen >= 50:
187
+ break
188
+ if not seen and (_whole(text) or {}).get('type') == 'result':
189
+ return 'claude-result'
190
+ return 'json' if seen else 'text'
191
+
192
+
193
+ def _whole(text):
194
+ """The log as one JSON object (a result printed over several lines), or None."""
195
+ body = text.strip()
196
+ if body[:1] != '{':
197
+ return None
198
+ try:
199
+ value = json.loads(body)
200
+ except ValueError:
201
+ return None
202
+ return value if isinstance(value, dict) else None
203
+
204
+
205
+ def _stderr(events, pending):
206
+ if pending:
207
+ events.append(_event('text', text=_ANSI.sub('', '\n'.join(pending)), stream='stderr'))
208
+ pending.clear()
209
+
210
+
211
+ def parse_codex(text):
212
+ events, by_id, tokens, meta, pending = [], {}, {}, {}, []
213
+ for value, raw in _lines(text):
214
+ if value is None:
215
+ pending.append(raw)
216
+ continue
217
+ _stderr(events, pending)
218
+ kind = value.get('type')
219
+ if kind == 'thread.started':
220
+ meta['session'] = value.get('thread_id')
221
+ elif kind in ('item.started', 'item.updated', 'item.completed'):
222
+ item = value.get('item') or {}
223
+ ev = _codex_item(item)
224
+ if ev is None:
225
+ continue
226
+ if kind != 'item.completed':
227
+ ev['running'] = True
228
+ ident = item.get('id')
229
+ if ident in by_id: # started, then completed: the same place in the list
230
+ events[by_id[ident]] = ev
231
+ else:
232
+ if ident:
233
+ by_id[ident] = len(events)
234
+ events.append(ev)
235
+ elif kind == 'turn.completed':
236
+ _tokens(tokens, value.get('usage'))
237
+ elif kind == 'turn.failed':
238
+ events.append(_event('error', text=(value.get('error') or {}).get('message') or 'the turn failed'))
239
+ elif kind == 'error':
240
+ events.append(_event('error', text=value.get('message') or 'error'))
241
+ _stderr(events, pending)
242
+ return events, tokens, meta
243
+
244
+
245
+ def _codex_item(item):
246
+ kind = item.get('type') or item.get('item_type')
247
+ if kind in ('agent_message', 'assistant_message'):
248
+ return _event('message', text=item.get('text'))
249
+ if kind == 'reasoning':
250
+ return _event('reasoning', text=item.get('text') or item.get('summary'))
251
+ if kind == 'command_execution':
252
+ ev = _event('command', command=_unwrap(item.get('command')), output=item.get('aggregated_output'), exit=item.get('exit_code'))
253
+ if item.get('status') == 'failed' or (isinstance(item.get('exit_code'), int) and item['exit_code'] != 0):
254
+ ev['error'] = True
255
+ return ev
256
+ if kind == 'file_change':
257
+ changes = item.get('changes') or []
258
+ return _event('edit', files=[c.get('path') for c in changes if isinstance(c, dict) and c.get('path')] or None,
259
+ change=[c.get('kind') for c in changes if isinstance(c, dict)] or None, error=item.get('status') == 'failed' or None)
260
+ if kind == 'mcp_tool_call':
261
+ ev = _event('tool', tool=f"{item.get('server') or 'mcp'}.{item.get('tool') or 'tool'}", input=item.get('arguments'),
262
+ output=item.get('result') if item.get('result') is not None else item.get('error'))
263
+ if item.get('error') or item.get('status') == 'failed':
264
+ ev['error'] = True
265
+ return ev
266
+ if kind == 'web_search':
267
+ return _event('tool', tool='web_search', input=item.get('query'))
268
+ if kind == 'todo_list':
269
+ return _event('tool', tool='todo', input=item.get('items'))
270
+ if kind == 'error':
271
+ return _event('error', text=item.get('message'))
272
+ if kind:
273
+ return _event('tool', tool=kind, input={k: v for k, v in item.items() if k not in ('id', 'type')} or None)
274
+ return None
275
+
276
+
277
+ def parse_claude(text, session_file=False):
278
+ """Claude's message shape: grok's and claude's streams, and Claude's session files (one block per line there)."""
279
+ events, by_id, tokens, meta, pending, counted = [], {}, {}, {}, [], set()
280
+ for value, raw in _lines(text):
281
+ if value is None:
282
+ if not session_file:
283
+ pending.append(raw)
284
+ continue
285
+ _stderr(events, pending)
286
+ kind, t = value.get('type'), _when(value.get('timestamp'))
287
+ if value.get('isSidechain') or value.get('isMeta'):
288
+ continue
289
+ if kind == 'system':
290
+ if value.get('subtype') == 'init':
291
+ meta.update(session=value.get('session_id'), model=value.get('model'))
292
+ continue
293
+ message = value.get('message') if isinstance(value.get('message'), dict) else None
294
+ if kind == 'assistant' and message:
295
+ mid = message.get('id')
296
+ if mid is None or mid not in counted: # a message's usage is repeated on each of its blocks' lines
297
+ counted.add(mid)
298
+ _tokens(tokens, message.get('usage'))
299
+ for block in _blocks(message.get('content')):
300
+ bt = block.get('type')
301
+ if bt == 'text' and block.get('text', '').strip():
302
+ events.append(_event('message', text=block['text'], t=t))
303
+ elif bt in ('thinking', 'redacted_thinking') and block.get('thinking'):
304
+ events.append(_event('reasoning', text=block['thinking'], t=t))
305
+ elif bt in ('tool_use', 'server_tool_use'):
306
+ ev = _tool_event(block.get('name'), block.get('input'), block.get('id'), t)
307
+ if block.get('id'):
308
+ by_id[block['id']] = ev
309
+ ev['running'] = True # until its result comes
310
+ events.append(ev)
311
+ elif bt.endswith('_tool_result') and block.get('tool_use_id') in by_id: # a server tool's result
312
+ by_id[block['tool_use_id']].pop('running', None)
313
+ _put(by_id[block['tool_use_id']], 'output', _payload(block.get('content'))[0], OUTPUT_CAP)
314
+ elif kind == 'user' and message:
315
+ for block in _blocks(message.get('content')):
316
+ bt = block.get('type')
317
+ if bt == 'tool_result':
318
+ ev = by_id.get(block.get('tool_use_id'))
319
+ out, code = _payload(block.get('content'))
320
+ if ev is None:
321
+ events.append(_event('tool', tool='result', output=out, t=t))
322
+ continue
323
+ ev.pop('running', None)
324
+ _put(ev, 'output', out, OUTPUT_CAP)
325
+ if code is not None and ev['kind'] == 'command':
326
+ ev['exit'] = code
327
+ ev['error'] = bool(block.get('is_error') or (ev['kind'] == 'command' and code not in (None, 0)))
328
+ elif bt == 'text' and not events:
329
+ # a session file starts with the task as the agent got it
330
+ events.append(_event('prompt', text=block.get('text'), t=t))
331
+ elif kind == 'result':
332
+ usage = value.get('usage')
333
+ if isinstance(usage, dict):
334
+ tokens.clear() # the result holds the run's totals
335
+ _tokens(tokens, usage)
336
+ meta['session'] = meta.get('session') or value.get('session_id')
337
+ if value.get('total_cost_usd') is not None:
338
+ meta['cost'] = value.get('total_cost_usd')
339
+ res = value.get('result')
340
+ error = value.get('is_error') or value.get('subtype') not in (None, 'success')
341
+ if error and not res:
342
+ res = '; '.join(str(e) for e in value.get('errors') or []) or value.get('subtype') or 'error'
343
+ _close(events, _event('result', text=res, error=True if error else None, tokens=dict(tokens) or None))
344
+ _stderr(events, pending)
345
+ return events, tokens, meta
346
+
347
+
348
+ def _close(events, result):
349
+ """Adds the final result; its text is left out when the agent's last message already said it."""
350
+ last = next((ev for ev in reversed(events) if ev['kind'] == 'message'), None)
351
+ if last is not None and result.get('text') and last.get('text', '').strip() == result['text'].strip():
352
+ result.pop('text')
353
+ result['same'] = True
354
+ events.append(result)
355
+
356
+
357
+ def _blocks(content):
358
+ if isinstance(content, str):
359
+ return [{'type': 'text', 'text': content}]
360
+ return [b for b in content or [] if isinstance(b, dict)]
361
+
362
+
363
+ def _payload(content):
364
+ """(text, exit code or None) of a tool result's content: a string, Claude's list of text blocks, or grok's
365
+ JSON string ({type, output: [bytes], exit_code, ...}, or {type, FileContent: {content, ...}})."""
366
+ if isinstance(content, list):
367
+ content = '\n'.join(b.get('text', '') if isinstance(b, dict) else str(b) for b in content)
368
+ if not (isinstance(content, str) and content[:1] == '{'):
369
+ return content, None
370
+ try:
371
+ data = json.loads(content)
372
+ except ValueError:
373
+ return content, None
374
+ if not isinstance(data, dict):
375
+ return content, None
376
+ inner = [v for k, v in data.items() if k != 'type']
377
+ if len(inner) == 1 and isinstance(inner[0], dict):
378
+ data = inner[0]
379
+ code = data.get('exit_code') if isinstance(data.get('exit_code'), int) else None
380
+ parts = []
381
+ for key in ('output', 'stdout', 'content', 'tool_output_for_prompt', 'message', 'result', 'text', 'summary', 'stderr'):
382
+ value = data.get(key)
383
+ if isinstance(value, list) and all(isinstance(x, int) for x in value):
384
+ value = bytes(x & 0xff for x in value).decode('utf-8', 'replace')
385
+ if isinstance(value, str) and value.strip():
386
+ parts.append(value)
387
+ if key not in ('output', 'stdout'): # a command's output and its stderr; else the first text
388
+ break
389
+ return ('\n'.join(parts) if parts else json.dumps(data, ensure_ascii=False, indent=1)), code
390
+
391
+
392
+ def parse_opencode(text):
393
+ events, tokens, meta, pending = [], {}, {}, []
394
+ for value, raw in _lines(text):
395
+ if value is None:
396
+ pending.append(raw)
397
+ continue
398
+ _stderr(events, pending)
399
+ kind, part, t = value.get('type'), value.get('part') if isinstance(value.get('part'), dict) else {}, _when(value.get('timestamp'))
400
+ meta['session'] = meta.get('session') or value.get('sessionID')
401
+ if kind == 'text':
402
+ events.append(_event('message', text=part.get('text'), t=t))
403
+ elif kind == 'reasoning':
404
+ events.append(_event('reasoning', text=part.get('text'), t=t))
405
+ elif kind == 'tool_use':
406
+ state = part.get('state') if isinstance(part.get('state'), dict) else {}
407
+ ev = _tool_event(part.get('tool'), state.get('input'), part.get('callID'), t)
408
+ _put(ev, 'output', state.get('output') if state.get('status') != 'error' else state.get('error'), OUTPUT_CAP)
409
+ meta_ = state.get('metadata') if isinstance(state.get('metadata'), dict) else {}
410
+ if ev['kind'] == 'command' and isinstance(meta_.get('exit'), int):
411
+ ev['exit'] = meta_['exit']
412
+ if state.get('status') in ('completed', 'error'):
413
+ ev['error'] = state.get('status') == 'error' or ev.get('exit') not in (None, 0)
414
+ events.append(ev)
415
+ elif kind == 'step_finish':
416
+ _tokens(tokens, part.get('tokens'))
417
+ if part.get('cost') is not None:
418
+ meta['cost'] = (meta.get('cost') or 0) + part['cost']
419
+ elif kind == 'error':
420
+ err = value.get('error') if isinstance(value.get('error'), dict) else {}
421
+ data = err.get('data') if isinstance(err.get('data'), dict) else {}
422
+ events.append(_event('error', text=data.get('message') or err.get('name') or 'error', t=t))
423
+ _stderr(events, pending)
424
+ return events, tokens, meta
425
+
426
+
427
+ def parse_json(text):
428
+ """JSON lines of a shape not known here (agy's stream, say): each shown as it is, text kept readable."""
429
+ events = []
430
+ for value, raw in _lines(text):
431
+ if value is None:
432
+ events.append(_event('text', text=_ANSI.sub('', raw)))
433
+ continue
434
+ kind = str(value.get('type') or value.get('event') or 'event')
435
+ res = value.get('result')
436
+ if kind == 'result' and isinstance(res, dict): # agy --output-format stream-json's last event
437
+ usage = res.get('usage') if isinstance(res.get('usage'), dict) else {}
438
+ error = res.get('status') == 'ERROR' or bool(res.get('error'))
439
+ events.append(_event('result', text=res.get('response') or res.get('error'), error=True if error else None,
440
+ tokens={'input': usage.get('input_tokens') or 0, 'cached': usage.get('cache_read_tokens') or 0,
441
+ 'output': (usage.get('output_tokens') or 0) + (usage.get('thinking_tokens') or 0)} if usage else None))
442
+ continue
443
+ body = value.get('text') or value.get('content') or value.get('message') or value.get('result')
444
+ if isinstance(body, str) and body.strip():
445
+ events.append(_event('error' if 'error' in kind else 'message', text=body, source=kind))
446
+ else:
447
+ events.append(_event('tool', tool=kind, input=value))
448
+ return _merge_text(events), {}, {}
449
+
450
+
451
+ def _merge_text(events):
452
+ out = []
453
+ for ev in events:
454
+ if ev['kind'] == 'text' and out and out[-1]['kind'] == 'text':
455
+ joined, cut = cap(out[-1]['text'] + '\n' + ev['text'], TEXT_CAP)
456
+ out[-1]['text'] = joined
457
+ if cut:
458
+ out[-1]['text_cut'] = out[-1].get('text_cut', 0) + cut
459
+ else:
460
+ out.append(ev)
461
+ return out
462
+
463
+
464
+ def parse_text(text):
465
+ return ([_event('text', text=_ANSI.sub('', text).strip())] if text.strip() else []), {}, {}
466
+
467
+
468
+ PARSERS = {'codex': parse_codex, 'claude': parse_claude, 'opencode': parse_opencode, 'json': parse_json, 'text': parse_text}
469
+
470
+
471
+ # ---------- a Claude session file ----------
472
+
473
+ def session_file(session_id, home=None, environ=None):
474
+ """The session file claude left for `session_id`, or None. Only `<config dir>/projects/<one dir>/<session
475
+ id>.jsonl` is looked at, the config dir being $CLAUDE_CONFIG_DIR, then ~/.claude."""
476
+ if not session_id or not _SESSION_ID.fullmatch(str(session_id)):
477
+ return None
478
+ env = os.environ if environ is None else environ
479
+ home = Path(home or Path.home())
480
+ roots = ([Path(env['CLAUDE_CONFIG_DIR'])] if env.get('CLAUDE_CONFIG_DIR') else []) + [home / '.claude']
481
+ for root in roots:
482
+ projects = root / 'projects'
483
+ try:
484
+ dirs = [d for d in os.scandir(projects) if d.is_dir(follow_symlinks=False)]
485
+ except OSError:
486
+ continue
487
+ for d in dirs:
488
+ path = Path(d.path) / f'{session_id}.jsonl'
489
+ try:
490
+ if stat.S_ISREG(path.stat().st_mode):
491
+ return path
492
+ except OSError:
493
+ continue
494
+ return None
495
+
496
+
497
+ # ---------- one execution's transcript ----------
498
+
499
+ def _read(path):
500
+ """The log's text (its end when it is over LOG_CAP) and whether it was cut."""
501
+ try:
502
+ size = path.stat().st_size
503
+ with open(path, 'rb') as fh:
504
+ if size > LOG_CAP:
505
+ fh.seek(size - LOG_CAP)
506
+ data = fh.read()
507
+ data = data[data.find(b'\n') + 1:]
508
+ else:
509
+ data = fh.read()
510
+ except OSError:
511
+ return None, False
512
+ return data.decode('utf-8', 'replace'), size > LOG_CAP
513
+
514
+
515
+ def transcript(log, harness=None, running=False, home=None, environ=None):
516
+ """The transcript of the execution whose output is the file `log` (a Path, or None when there is none).
517
+
518
+ {format, source: log|session|none, session, model, events, tokens, cost, truncated, warnings}
519
+ """
520
+ out = dict(format=None, source='none', session=None, model=None, events=[], tokens=None, cost=None, truncated=False, warnings=[])
521
+ if log is None:
522
+ out['warnings'].append('this execution has no log')
523
+ return out
524
+ try:
525
+ st = log.stat()
526
+ except OSError:
527
+ st = None
528
+ if st is None or not stat.S_ISREG(st.st_mode):
529
+ out['warnings'].append(f'{log.name} is not readable')
530
+ return out
531
+ key = (str(log), st.st_size, st.st_mtime_ns, harness, running)
532
+ if key in _CACHE:
533
+ answer, sid, seen = _CACHE[key]
534
+ if sid is None or _signature(session_file(sid, home, environ)) == seen:
535
+ return json.loads(answer)
536
+ text, cut = _read(log)
537
+ if text is None:
538
+ out['warnings'].append(f'{log.name} is not readable')
539
+ return out
540
+ if running:
541
+ # a line still being written is not an event yet
542
+ end = text.rfind('\n')
543
+ text = text[:end + 1] if end >= 0 else ''
544
+ fmt = detect(text)
545
+ out.update(format=fmt, source='log', truncated=cut)
546
+ if cut:
547
+ out['warnings'].append(f'{log.name} is over {LOG_CAP >> 20} MB: only its end is shown')
548
+ if fmt == 'claude-result':
549
+ result = next((v for v, _ in _lines(text) if v is not None), None) or _whole(text)
550
+ sid = result.get('session_id')
551
+ path = session_file(sid, home, environ)
552
+ tail = parse_claude(json.dumps(result))[0]
553
+ events, tokens, meta = tail, {}, {}
554
+ if path is not None:
555
+ stext, scut = _read(path)
556
+ if stext is not None:
557
+ events, tokens, meta = parse_claude(stext, session_file=True)
558
+ _close(events, tail[-1]) # the run's own result closes it
559
+ out.update(format='claude', source='session', truncated=cut or scut)
560
+ if out['source'] != 'session':
561
+ out['warnings'].append(f"{harness or 'claude'} logged only its final answer, and its session file "
562
+ f"({sid or 'no session id'}) is not on this machine any more")
563
+ if isinstance(result.get('usage'), dict):
564
+ tokens = {}
565
+ _tokens(tokens, result['usage'])
566
+ meta.setdefault('session', sid)
567
+ if result.get('total_cost_usd') is not None:
568
+ meta['cost'] = result['total_cost_usd']
569
+ else:
570
+ events, tokens, meta = PARSERS[fmt](text)
571
+ if not running:
572
+ for ev in events: # a call cut off when the agent stopped
573
+ ev.pop('running', None)
574
+ events, left_out = _fit(events)
575
+ if left_out:
576
+ out['warnings'].append(f'{left_out} events in the middle are left out')
577
+ out['truncated'] = True
578
+ out.update(events=events, tokens=tokens or None, session=meta.get('session'), model=meta.get('model'), cost=meta.get('cost'))
579
+ if len(_CACHE) > 16: # answers can be megabytes each
580
+ _CACHE.clear()
581
+ sid = result.get('session_id') if fmt == 'claude-result' else None
582
+ _CACHE[key] = (json.dumps(out), sid, _signature(path) if sid else None)
583
+ return out
584
+
585
+
586
+ def _signature(path):
587
+ try:
588
+ st = path.stat() if path is not None else None
589
+ except OSError:
590
+ return None
591
+ return st and (str(path), st.st_size, st.st_mtime_ns)
592
+
593
+
594
+ def _fit(events):
595
+ """(events, how many were left out): within EVENTS_CAP and CHARS_CAP, keeping the start and more of the end."""
596
+ sizes = [sum(len(v) for v in ev.values() if isinstance(v, str)) for ev in events]
597
+ if len(events) <= EVENTS_CAP and sum(sizes) <= CHARS_CAP:
598
+ return events, 0
599
+ head = used = 0
600
+ while head < len(events) and head < EVENTS_CAP // 5 and used + sizes[head] <= CHARS_CAP // 5:
601
+ used += sizes[head]
602
+ head += 1
603
+ tail = 0
604
+ while tail < len(events) - head and tail < EVENTS_CAP - EVENTS_CAP // 5 and used + sizes[-1 - tail] <= CHARS_CAP:
605
+ used += sizes[-1 - tail]
606
+ tail += 1
607
+ gap = len(events) - head - tail
608
+ return events[:head] + [_event('gap', count=gap)] + events[len(events) - tail:], gap