vamp-cli 0.1.0__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 (57) hide show
  1. vamp_cli/__init__.py +0 -0
  2. vamp_cli/agent/__init__.py +0 -0
  3. vamp_cli/agent/context_sync.py +72 -0
  4. vamp_cli/agent/graph.py +159 -0
  5. vamp_cli/agent/instructions.py +18 -0
  6. vamp_cli/agent/nodes.py +375 -0
  7. vamp_cli/agent/roles.py +8 -0
  8. vamp_cli/agent/runtime/__init__.py +0 -0
  9. vamp_cli/agent/runtime/runtime.py +133 -0
  10. vamp_cli/agent/state.py +55 -0
  11. vamp_cli/agent/status.py +17 -0
  12. vamp_cli/config/__init__.py +0 -0
  13. vamp_cli/config/loader.py +152 -0
  14. vamp_cli/config/schema.py +82 -0
  15. vamp_cli/llm/__init__.py +0 -0
  16. vamp_cli/llm/adapter.py +52 -0
  17. vamp_cli/llm/adapter_registry.py +26 -0
  18. vamp_cli/llm/debug_callbacks.py +18 -0
  19. vamp_cli/llm/router.py +247 -0
  20. vamp_cli/llm/usage.py +170 -0
  21. vamp_cli/logging_setup/logging_setup.py +37 -0
  22. vamp_cli/main.py +1023 -0
  23. vamp_cli/persistence/__init__.py +29 -0
  24. vamp_cli/persistence/compaction.py +372 -0
  25. vamp_cli/persistence/emitter.py +371 -0
  26. vamp_cli/persistence/event_log.py +303 -0
  27. vamp_cli/persistence/fork.py +81 -0
  28. vamp_cli/persistence/loader.py +616 -0
  29. vamp_cli/persistence/transcript.py +160 -0
  30. vamp_cli/prompts/prompts.py +182 -0
  31. vamp_cli/sandbox/__init__.py +0 -0
  32. vamp_cli/sandbox/base.py +22 -0
  33. vamp_cli/sandbox/config.py +16 -0
  34. vamp_cli/sandbox/docker.py +293 -0
  35. vamp_cli/sandbox/manager.py +31 -0
  36. vamp_cli/sandbox/result.py +14 -0
  37. vamp_cli/sandbox/terminal.py +151 -0
  38. vamp_cli/tools/__init__.py +0 -0
  39. vamp_cli/tools/base.py +44 -0
  40. vamp_cli/tools/diagnostic.py +120 -0
  41. vamp_cli/tools/execute_cmd.py +16 -0
  42. vamp_cli/tools/files.py +652 -0
  43. vamp_cli/tools/registry.py +163 -0
  44. vamp_cli/tools/tools_classes.py +622 -0
  45. vamp_cli/utils/__init__.py +0 -0
  46. vamp_cli/utils/error_codes.py +7 -0
  47. vamp_cli/utils/ignore.py +36 -0
  48. vamp_cli/utils/sha_256.py +32 -0
  49. vamp_cli/utils/token_tracker.py +31 -0
  50. vamp_cli/workspace/__init__.py +0 -0
  51. vamp_cli/workspace/workspace.py +16 -0
  52. vamp_cli-0.1.0.dist-info/METADATA +317 -0
  53. vamp_cli-0.1.0.dist-info/RECORD +57 -0
  54. vamp_cli-0.1.0.dist-info/WHEEL +5 -0
  55. vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
  56. vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. vamp_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,81 @@
1
+ """
2
+ Fork a session: write a `fork` event, seed a new checkpointer thread with the reconstructed state at the fork point.
3
+
4
+ The parent branch is untouched. No file is rewritten. The fork event's parentUuid is the fork point, so walking back from any future head naturally includes only the current branch.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from langgraph.graph import START
13
+
14
+ from vamp_cli.persistence.event_log import EventLog, uuid7
15
+ from vamp_cli.persistence.loader import load_chain, reconstruct_agent_state
16
+
17
+
18
+ async def fork_at_event(
19
+ log: EventLog,
20
+ *,
21
+ session_id: str,
22
+ fork_point_uuid: str,
23
+ graph,
24
+ reason: str = "user_rewind",
25
+ ) -> dict[str, Any]:
26
+ """
27
+ Rewind the head to fork_point_uuid and seed a new checkpointer thread.
28
+
29
+ Returns:
30
+ {
31
+ "fork_uuid": the uuid of the new fork event (new head),
32
+ "thread_id": the new checkpointer thread id,
33
+ "state": the reconstructed AgentState at the fork point,
34
+ "events_kept": number of events in the fork-point chain,
35
+ }
36
+ """
37
+ # 1. Drain pending writes so we know exactly what's on disk.
38
+ await log.flush()
39
+
40
+ old_head = log.head_uuid
41
+ new_thread_id = f"{session_id}:{uuid7()}"
42
+
43
+ # 2. Reconstruct state at the fork point BEFORE we write the fork event.
44
+ # (After the fork, the fork-point chain is a strict prefix of the
45
+ # current branch, so this is the correct snapshot.)
46
+ sessions_dir = log.path.parent
47
+ chain = load_chain(sessions_dir, session_id, leaf_uuid=fork_point_uuid)
48
+
49
+ start_ev = next((e for e in chain if e.get("type") == "session_start"), None)
50
+ prompts = (start_ev or {}).get("data", {}).get("system_prompts")
51
+
52
+ state = reconstruct_agent_state(chain, system_prompts=prompts)
53
+
54
+ # 3. Write the fork event. Its parentUuid is the fork point, so it
55
+ # becomes a sibling of whatever the old branch continued to.
56
+ fork_uuid = await log.append(
57
+ "fork",
58
+ {
59
+ "fork_point": fork_point_uuid,
60
+ "from_head": old_head,
61
+ "thread_id": new_thread_id,
62
+ "reason": reason,
63
+ },
64
+ parent_uuid=fork_point_uuid,
65
+ )
66
+
67
+ # 4. Seed a fresh checkpointer thread with the reconstructed state.
68
+ # as_node=START tells LangGraph to compute `next` from START's
69
+ # outgoing edges - i.e. re-enter the graph from the top.
70
+ new_config = {
71
+ "configurable": {"thread_id": new_thread_id},
72
+ "recursion_limit": 200,
73
+ }
74
+ await graph.aupdate_state(new_config, state, as_node=START)
75
+
76
+ return {
77
+ "fork_uuid": fork_uuid,
78
+ "thread_id": new_thread_id,
79
+ "state": state,
80
+ "events_kept": len(chain),
81
+ }
@@ -0,0 +1,616 @@
1
+ """
2
+ Rehydrate a session from its JSONL event log + LangGraph checkpointer.
3
+
4
+ Responsibilities:
5
+ - Walk the append-only chain from session_start to the current leaf.
6
+ - Derive CLI-side display state (mode, "have we run yet").
7
+ - Detect a parked interrupt (request without response).
8
+ - Load authoritative graph state from the checkpointer.
9
+ - Fall back to reconstructing graph state from the log if the
10
+ checkpointer is missing or empty.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import json
17
+ import time
18
+ from pathlib import Path
19
+ import concurrent.futures
20
+ from typing import Any
21
+ from dataclasses import dataclass, field
22
+ try:
23
+ import orjson as fast_json
24
+ except ImportError:
25
+ fast_json = json
26
+
27
+ from langchain_core.messages import (
28
+ AIMessage,
29
+ BaseMessage,
30
+ HumanMessage,
31
+ SystemMessage,
32
+ ToolMessage,
33
+ )
34
+ from langchain_core.runnables import RunnableConfig
35
+
36
+
37
+ # Chain loading
38
+ def load_chain(
39
+ sessions_dir: Path,
40
+ session_id: str,
41
+ *,
42
+ leaf_uuid: str | None = None,
43
+ ) -> list[dict]:
44
+ """
45
+ Return events in chain order (oldest first).
46
+
47
+ If leaf_uuid is None, read the .head sidecar; if that's missing, find
48
+ the leaf by scanning. Walk parentUuid backward from the leaf to the
49
+ session_start event, then reverse.
50
+
51
+ Malformed lines are skipped silently - the log is never assumed clean.
52
+ """
53
+ path = sessions_dir / f"{session_id}.jsonl"
54
+ if not path.exists():
55
+ raise FileNotFoundError(f"session log not found: {path}")
56
+
57
+ by_uuid: dict[str, dict] = {}
58
+ order: list[str] = []
59
+ with open(path, "r", encoding="utf-8") as f:
60
+ for line in f:
61
+ line = line.strip()
62
+ if not line:
63
+ continue
64
+ try:
65
+ ev = json.loads(line)
66
+ except json.JSONDecodeError:
67
+ continue
68
+ u = ev.get("uuid")
69
+ if u:
70
+ by_uuid[u] = ev
71
+ order.append(u)
72
+
73
+ if not order:
74
+ return []
75
+
76
+ if leaf_uuid is None:
77
+ head_path = sessions_dir / f"{session_id}.head"
78
+ if head_path.exists():
79
+ leaf_uuid = head_path.read_text(encoding="utf-8").strip() or None
80
+
81
+ if leaf_uuid is None or leaf_uuid not in by_uuid:
82
+ # No sidecar / stale sidecar - pick newest leaf.
83
+ parents = {ev.get("parentUuid") for ev in by_uuid.values() if ev.get("parentUuid")}
84
+ leaves = [u for u in order if u not in parents]
85
+ if not leaves:
86
+ return []
87
+ leaf_uuid = max(leaves)
88
+
89
+ chain: list[dict] = []
90
+ cursor: str | None = leaf_uuid
91
+ while cursor is not None:
92
+ ev = by_uuid.get(cursor)
93
+ if ev is None:
94
+ break
95
+ chain.append(ev)
96
+ cursor = ev.get("parentUuid")
97
+ chain.reverse()
98
+ return chain
99
+
100
+
101
+
102
+ # Display-state derivation
103
+ def derive_display_state(events: list[dict]) -> dict[str, Any]:
104
+ """
105
+ Replay events into the small amount of state the CLI loop cares about.
106
+ - selected_mode: last mode_switch.to, default 'normal'
107
+ - executed_mode: the mode of the most recent user_message
108
+ - is_session_started: True once any turn has been sent
109
+ """
110
+ selected_mode = "normal"
111
+ executed_mode = "normal"
112
+ is_session_started = False
113
+
114
+ for ev in events:
115
+ t = ev.get("type")
116
+ d = ev.get("data") or {}
117
+ if t == "mode_switch":
118
+ selected_mode = d.get("to", selected_mode)
119
+ elif t == "user_message":
120
+ executed_mode = d.get("mode", executed_mode)
121
+ is_session_started = True
122
+
123
+ return {
124
+ "selected_mode": selected_mode,
125
+ "executed_mode": executed_mode,
126
+ "is_session_started": is_session_started,
127
+ }
128
+
129
+
130
+ def detect_pending_interrupt(events: list[dict]) -> dict | None:
131
+ """
132
+ Return the payload of an interrupt_request that has no matching
133
+ interrupt_response after it. Returns None if the session is not
134
+ parked at an interrupt.
135
+ """
136
+ pending: dict | None = None
137
+ for ev in events:
138
+ t = ev.get("type")
139
+ if t == "interrupt_request":
140
+ d = ev.get("data") or {}
141
+ pending = {
142
+ "kind": d.get("kind"),
143
+ "payload": d.get("payload") or {},
144
+ "event_uuid": ev.get("uuid"),
145
+ }
146
+ elif t == "interrupt_response":
147
+ pending = None
148
+ elif t in ("session_end",):
149
+ # A cleanly-ended session can't be parked at an interrupt.
150
+ pending = None
151
+ return pending
152
+
153
+
154
+ # Best-effort AgentState reconstruction
155
+ def reconstruct_agent_state(
156
+ events: list[dict],
157
+ system_prompts: dict[str, str] | None = None,
158
+ ) -> dict[str, Any]:
159
+ """
160
+ Rebuild an AgentState-shaped dict from the JSONL.
161
+ Only used when the SQLite checkpointer is missing or empty. Lossy:
162
+ - reasoning_content preserved; usage / metadata dropped.
163
+ - Tool-call routing by node name (falls back to 'coder_messages').
164
+ - _pending_* pipeline fields reset to safe defaults.
165
+ """
166
+ coder_msgs: list[BaseMessage] = []
167
+ planner_msgs: list[BaseMessage] = []
168
+
169
+ if system_prompts:
170
+ if system_prompts.get("coder"):
171
+ coder_msgs.append(SystemMessage(
172
+ content=system_prompts["coder"], id="coder_system_prompt",
173
+ ))
174
+ if system_prompts.get("planner"):
175
+ planner_msgs.append(SystemMessage(
176
+ content=system_prompts["planner"], id="planner_system_prompt",
177
+ ))
178
+
179
+ shadowed, _ = build_shadow_map(events)
180
+
181
+ task: str | None = None
182
+ mode = "normal"
183
+ plan: str | None = None
184
+ todo: list[dict] = []
185
+ iteration = 0
186
+
187
+ def _channel_for(node: str | None) -> list[BaseMessage]:
188
+ return planner_msgs if node == "planner" else coder_msgs
189
+
190
+ for ev in events:
191
+ u = ev.get("uuid")
192
+ t = ev.get("type")
193
+ d = ev.get("data") or {}
194
+
195
+ # compaction event: inject summary, never process as a message
196
+ if t == "compaction":
197
+ summary = (d.get("summary") or "").strip()
198
+ if summary:
199
+ marker = SystemMessage(
200
+ content=f"[COMPACTED HISTORY - summary of earlier turns]\n{summary}",
201
+ id=f"compaction_{u}",
202
+ )
203
+ channel = d.get("channel", "coder_messages")
204
+ if channel == "planner_messages":
205
+ planner_msgs.append(marker)
206
+ elif channel == "both":
207
+ coder_msgs.append(marker)
208
+ planner_msgs.append(marker)
209
+ else:
210
+ coder_msgs.append(marker)
211
+ continue
212
+
213
+ # shadowed event: skip entirely (already in a summary)
214
+ if u in shadowed:
215
+ continue
216
+
217
+ if t == "user_message":
218
+ mode = d.get("mode", mode)
219
+ msg = HumanMessage(content=d.get("content", ""))
220
+ if mode == "plan":
221
+ planner_msgs.append(msg)
222
+ else:
223
+ coder_msgs.append(msg)
224
+ task = d.get("content", task)
225
+
226
+ elif t == "assistant_message":
227
+ node = d.get("node")
228
+ tool_calls: list[dict] = []
229
+ for tc in d.get("tool_calls") or []:
230
+ tool_calls.append({
231
+ "id": tc.get("id") or "",
232
+ "name": tc.get("name") or "",
233
+ "args": tc.get("args") or {},
234
+ "type": "tool_call",
235
+ })
236
+ ai = AIMessage(
237
+ content=d.get("content", "") or "",
238
+ tool_calls=tool_calls,
239
+ )
240
+ if d.get("reasoning_content"):
241
+ ai.additional_kwargs = {"reasoning_content": d["reasoning_content"]}
242
+ _channel_for(node).append(ai)
243
+ iteration += 1
244
+
245
+ elif t == "tool_result":
246
+ node = d.get("node")
247
+ tm = ToolMessage(
248
+ content=d.get("content", "") or "",
249
+ tool_call_id=d.get("tool_call_id") or "",
250
+ name=d.get("tool_name") or "",
251
+ status=d.get("status") or "success",
252
+ )
253
+ _channel_for(node).append(tm)
254
+
255
+ # submit_plan produces a plan text we can recover from the
256
+ # tool_result payload - this is what makes plan mode work on
257
+ # a reconstructed session.
258
+ if d.get("tool_name") == "submit_plan":
259
+ raw = d.get("content") or ""
260
+ try:
261
+ parsed = json.loads(raw) if isinstance(raw, str) else raw
262
+ if isinstance(parsed, dict) and "plan" in parsed:
263
+ plan = parsed["plan"]
264
+ except (json.JSONDecodeError, TypeError):
265
+ pass
266
+
267
+ elif t == "todo_update":
268
+ todo = d.get("todos") or d.get("todo") or todo
269
+
270
+ elif t == "mode_switch":
271
+ mode = d.get("to", mode)
272
+
273
+ elif t == "plan_submitted":
274
+ plan = d.get("plan", plan)
275
+
276
+ return {
277
+ "coder_messages": coder_msgs,
278
+ "planner_messages": planner_msgs,
279
+ "task": task,
280
+ "mode": mode,
281
+ "plan": plan,
282
+ "todo": todo,
283
+ "iteration": iteration,
284
+ "requires_execution": False,
285
+ "_pending_tool_channel": None,
286
+ "_pending_tool_calls": [],
287
+ "_dangerous_tool_call_ids": [],
288
+ "_approved_tool_call_ids": [],
289
+ "_denied_tool_call_ids": [],
290
+ }
291
+
292
+ # ResumedSession
293
+ @dataclass
294
+ class ResumedSession:
295
+ session_id: str
296
+ events: list[dict]
297
+ session_start: dict | None
298
+ thread_id: str = ""
299
+
300
+ # CLI display state
301
+ selected_mode: str = "normal"
302
+ executed_mode: str = "normal"
303
+ is_session_started: bool = False
304
+
305
+ # Graph state
306
+ last_state: dict | None = None
307
+ checkpoint_thread_id: str | None = None
308
+ checkpoint_source: str = "sqlite" # "sqlite" | "reconstructed" | "missing"
309
+
310
+ # Parked interrupt
311
+ pending_interrupt: dict | None = None
312
+
313
+ # Any warnings worth surfacing to the user
314
+ warnings: list[str] = field(default_factory=list)
315
+
316
+
317
+ # Public entry
318
+ async def load_session(
319
+ *,
320
+ sessions_dir: Path,
321
+ session_id: str,
322
+ graph,
323
+ session_config: dict | RunnableConfig,
324
+ reconstruct_if_missing: bool = True,
325
+ ) -> ResumedSession:
326
+ """
327
+ Load a session: event chain + CLI state + graph state + interrupt.
328
+ """
329
+ events = load_chain(sessions_dir, session_id)
330
+ if not events:
331
+ raise ValueError(f"session {session_id} has no events")
332
+
333
+
334
+ session_start_ev = next(
335
+ (e for e in events if e.get("type") == "session_start"), None
336
+ )
337
+ prompts = (session_start_ev or {}).get("data", {}).get("system_prompts")
338
+
339
+ display = derive_display_state(events)
340
+ pending = detect_pending_interrupt(events)
341
+
342
+ resumed = ResumedSession(
343
+ session_id=session_id,
344
+ events=events,
345
+ session_start=session_start_ev,
346
+ selected_mode=display["selected_mode"],
347
+ executed_mode=display["executed_mode"],
348
+ is_session_started=display["is_session_started"],
349
+ pending_interrupt=pending,
350
+ checkpoint_thread_id=session_config.get("configurable", {}).get("thread_id"),
351
+ )
352
+ resumed.thread_id = current_thread_id(
353
+ events,
354
+ default=session_config.get("configurable", {}).get("thread_id") or session_id,
355
+ )
356
+
357
+
358
+
359
+ # graph state via checkpointer (authoritative)
360
+ try:
361
+ snapshot = await graph.aget_state(session_config)
362
+ values = snapshot.values if snapshot is not None else None
363
+ # LangGraph returns an empty snapshot object rather than None when
364
+ # the thread exists but has no checkpoint; treat empty as missing.
365
+ if values and any(values.get(k) for k in ("coder_messages", "planner_messages")):
366
+ resumed.last_state = values
367
+ resumed.checkpoint_source = "sqlite"
368
+ return resumed
369
+ except Exception as e:
370
+ resumed.warnings.append(f"checkpointer read failed: {e!r}")
371
+
372
+ # fallback: rebuild from log
373
+ if reconstruct_if_missing:
374
+ resumed.warnings.append(
375
+ "checkpointer had no state for this thread; "
376
+ "reconstructed from event log (best-effort, lossy)."
377
+ )
378
+ try:
379
+ resumed.last_state = reconstruct_agent_state(events, system_prompts=prompts)
380
+ resumed.checkpoint_source = "reconstructed"
381
+ except Exception as e:
382
+ resumed.warnings.append(f"reconstruction failed: {e!r}")
383
+ resumed.last_state = None
384
+ resumed.checkpoint_source = "missing"
385
+ else:
386
+ resumed.checkpoint_source = "missing"
387
+
388
+ return resumed
389
+
390
+ def _extract_tail_timestamp(f, file_size: int, chunk_size: int = 4096) -> Any | None:
391
+ """Seek to the end of the file and find the last timestamp without reading the whole file."""
392
+ if file_size == 0:
393
+ return None
394
+
395
+ read_size = min(file_size, chunk_size)
396
+ f.seek(file_size - read_size, os.SEEK_SET)
397
+ chunk = f.read().splitlines()
398
+
399
+ # Iterate backwards through lines in the tail chunk
400
+ for line in reversed(chunk):
401
+ if not line.strip() or b'"timestamp"' not in line:
402
+ continue
403
+ try:
404
+ ev = fast_json.loads(line)
405
+ if ts := ev.get("timestamp"):
406
+ return ts
407
+ except Exception:
408
+ continue
409
+ return None
410
+
411
+ def _parse_session_file(entry: os.DirEntry) -> dict[str, Any] | None:
412
+ session_id = entry.name.removesuffix(".jsonl")
413
+ size_bytes = entry.stat().st_size
414
+
415
+ if size_bytes == 0:
416
+ return {
417
+ "session_id": session_id,
418
+ "started_at": None,
419
+ "last_event_at": None,
420
+ "first_message": None,
421
+ "size_bytes": 0,
422
+ }
423
+
424
+ first_user = None
425
+ started_at = None
426
+ last_ts = None
427
+
428
+ try:
429
+ # Read in binary mode to avoid text-decoding overhead during seeks
430
+ with open(entry.path, "rb") as f:
431
+ # 1. Head scan: read until we find started_at and first_user
432
+ lines_checked = 0
433
+ for raw_line in f:
434
+ lines_checked += 1
435
+ if not raw_line.strip():
436
+ continue
437
+
438
+ # Fast string checks before running JSON parser
439
+ is_start = b'"session_start"' in raw_line
440
+ is_user = b'"user_message"' in raw_line
441
+
442
+ if is_start or is_user:
443
+ try:
444
+ ev = fast_json.loads(raw_line)
445
+ except Exception:
446
+ continue
447
+
448
+ if is_start and started_at is None and ev.get("type") == "session_start":
449
+ started_at = ev.get("timestamp")
450
+
451
+ if is_user and first_user is None and ev.get("type") == "user_message":
452
+ first_user = (ev.get("data") or {}).get("content")
453
+
454
+ # Early exit once both fields are located or threshold reached
455
+ if started_at is not None and first_user is not None:
456
+ break
457
+ if lines_checked >= 100:
458
+ break
459
+
460
+ # 2. Tail scan: jump straight to the end for the last timestamp
461
+ last_ts = _extract_tail_timestamp(f, size_bytes)
462
+
463
+ except OSError:
464
+ return None
465
+
466
+ return {
467
+ "session_id": session_id,
468
+ "started_at": started_at,
469
+ "last_event_at": last_ts,
470
+ "first_message": first_user,
471
+ "size_bytes": size_bytes,
472
+ }
473
+
474
+
475
+ # CLI helper: list sessions in a workspace
476
+ def list_sessions(sessions_dir: Path, max_workers: int = 8) -> list[dict[str, Any]]:
477
+
478
+ """Scan the workspace session directory efficiently using head/tail seeking and thread concurrency."""
479
+ if not sessions_dir.exists():
480
+ return []
481
+
482
+ # os.scandir avoids creating heavy Path objects and caches stat results on Linux/macOS/Windows
483
+ try:
484
+ entries = [
485
+ e for e in os.scandir(sessions_dir)
486
+ if e.is_file() and e.name.endswith(".jsonl")
487
+ ]
488
+ except OSError:
489
+ return []
490
+
491
+ entries.sort(key=lambda e: e.name, reverse=True)
492
+ if not entries:
493
+ return []
494
+
495
+ # Parallelize file reading across threads (pure I/O bound)
496
+ results: list[dict[str, Any]] = []
497
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(max_workers, len(entries))) as executor:
498
+ for res in executor.map(_parse_session_file, entries):
499
+ if res is not None:
500
+ results.append(res)
501
+
502
+ return results
503
+
504
+ def current_thread_id(events: list[dict], default: str) -> str:
505
+ """Walk events; the last fork event's thread_id wins."""
506
+ tid = default
507
+ for ev in events:
508
+ if ev.get("type") == "fork":
509
+ t = (ev.get("data") or {}).get("thread_id")
510
+ if t:
511
+ tid = t
512
+ return tid
513
+
514
+ def list_user_messages(events: list[dict]) -> list[dict]:
515
+ """
516
+ Enumerate user messages in the chain, tagging each with a `shadowed`
517
+ flag if it has been compacted away by a later compaction event.
518
+ """
519
+ shadowed, _ = build_shadow_map(events)
520
+
521
+ out: list[dict] = []
522
+ idx = 0
523
+ for ev in events:
524
+ if ev.get("type") != "user_message":
525
+ continue
526
+ idx += 1
527
+ d = ev.get("data") or {}
528
+ out.append({
529
+ "index": idx,
530
+ "uuid": ev.get("uuid"),
531
+ "parent_uuid": ev.get("parentUuid"),
532
+ "content": (d.get("content") or "").strip(),
533
+ "mode": d.get("mode"),
534
+ "timestamp": ev.get("timestamp"),
535
+ "shadowed": ev.get("uuid") in shadowed,
536
+ })
537
+ return out
538
+
539
+
540
+ def list_leaves(events: list[dict]) -> list[str]:
541
+ """All uuids that never appear as a parent - i.e. current branch tips."""
542
+ used_as_parent = {ev.get("parentUuid") for ev in events if ev.get("parentUuid")}
543
+ leaves = [ev["uuid"] for ev in events if ev.get("uuid") and ev["uuid"] not in used_as_parent]
544
+ # uuid7 sorts lexicographically by creation time
545
+ return sorted(leaves, reverse=True)
546
+
547
+ def load_all_events(sessions_dir: Path, session_id: str) -> list[dict]:
548
+ """Read every event in the file (all branches), in file order."""
549
+ path = sessions_dir / f"{session_id}.jsonl"
550
+ if not path.exists():
551
+ return []
552
+ events: list[dict] = []
553
+ with open(path, "r", encoding="utf-8") as f:
554
+ for line in f:
555
+ line = line.strip()
556
+ if not line:
557
+ continue
558
+ try:
559
+ events.append(json.loads(line))
560
+ except json.JSONDecodeError:
561
+ continue
562
+ return events
563
+
564
+ def build_shadow_map(events: list[dict]) -> tuple[set[str], dict[str, dict]]:
565
+ """
566
+ Return (shadowed_uuids, compaction_by_uuid).
567
+
568
+ shadowed_uuids: every uuid covered by some compaction event on this chain.
569
+ compaction_by_uuid: compaction event uuid -> its data payload.
570
+ """
571
+ shadowed: set[str] = set()
572
+ compaction_by_uuid: dict[str, dict] = {}
573
+ for ev in events:
574
+ if ev.get("type") != "compaction":
575
+ continue
576
+ d = ev.get("data") or {}
577
+ for u in d.get("range_uuids") or []:
578
+ shadowed.add(u)
579
+ compaction_by_uuid[ev["uuid"]] = d
580
+ return shadowed, compaction_by_uuid
581
+
582
+
583
+
584
+ def _repair_orphaned_tool_calls(msgs: list[BaseMessage]) -> None:
585
+ """Ensure every AIMessage.tool_calls entry has a matching ToolMessage."""
586
+ call_ids = set()
587
+ result_ids = set()
588
+ for m in msgs:
589
+ if isinstance(m, AIMessage):
590
+ for tc in (getattr(m, "tool_calls", None) or []):
591
+ if tc.get("id"):
592
+ call_ids.add(tc["id"])
593
+ elif isinstance(m, ToolMessage):
594
+ if getattr(m, "tool_call_id", None):
595
+ result_ids.add(m.tool_call_id)
596
+
597
+ orphaned = call_ids - result_ids
598
+ if not orphaned:
599
+ return
600
+
601
+ rebuilt: list[BaseMessage] = []
602
+ for m in msgs:
603
+ rebuilt.append(m)
604
+ if isinstance(m, AIMessage) and m.tool_calls:
605
+ for tc in m.tool_calls:
606
+ if tc.get("id") in orphaned:
607
+ rebuilt.append(ToolMessage(
608
+ content=(
609
+ "[Tool execution was interrupted before completion — "
610
+ "no result was recorded. Do not assume the tool ran.]"
611
+ ),
612
+ tool_call_id=tc["id"],
613
+ name=tc.get("name") or "unknown",
614
+ status="error",
615
+ ))
616
+ msgs[:] = rebuilt