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,371 @@
1
+ """
2
+ Translate LangChain `astream_events(version='v2')` frames into events
3
+ appended to an EventLog.
4
+
5
+ Covers:
6
+ assistant_message from on_chat_model_start/stream/end
7
+ tool_result from on_tool_start/end/error
8
+
9
+ Design rules:
10
+ - The emitter is a passive translator. It never mutates LangGraph state,
11
+ never raises past its own boundary, and never blocks the display path.
12
+ - All appends go through EventLog.append, which is already lock-serialized.
13
+ - Streaming chunks are accumulated locally using AIMessageChunk.__add__,
14
+ which correctly merges content, tool_call_chunks, usage, and
15
+ additional_kwargs (incl. reasoning_content for reasoning models).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ import time
22
+ from typing import Any
23
+
24
+ from langchain_core.messages import (
25
+ AIMessage,
26
+ AIMessageChunk,
27
+ BaseMessage,
28
+ ToolMessage,
29
+ )
30
+
31
+ from vamp_cli.persistence.event_log import EventLog
32
+
33
+
34
+ # helpers
35
+ def _content_to_str(content: Any) -> str:
36
+ """Normalize message content to plain text."""
37
+ if content is None:
38
+ return ""
39
+ if isinstance(content, str):
40
+ return content
41
+ if isinstance(content, list):
42
+ parts: list[str] = []
43
+ for block in content:
44
+ if isinstance(block, str):
45
+ parts.append(block)
46
+ elif isinstance(block, dict):
47
+ # Anthropic / OpenAI content blocks
48
+ if block.get("type") in ("text", "output_text") and "text" in block:
49
+ parts.append(str(block["text"]))
50
+ elif block.get("type") == "thinking" and "thinking" in block:
51
+ parts.append(str(block["thinking"]))
52
+ # other block types (image, tool_use, etc) skipped
53
+ return "".join(parts)
54
+ return str(content)
55
+
56
+
57
+ def _extract_reasoning(msg: Any) -> str:
58
+ """Pull reasoning text out of whatever field the provider used."""
59
+ if msg is None:
60
+ return ""
61
+ direct = getattr(msg, "reasoning_content", None)
62
+ if isinstance(direct, str) and direct:
63
+ return direct
64
+ extra = getattr(msg, "additional_kwargs", None) or {}
65
+ for key in ("reasoning_content", "reasoning", "thinking"):
66
+ val = extra.get(key)
67
+ if isinstance(val, str) and val:
68
+ return val
69
+ return ""
70
+
71
+
72
+ def _extract_tool_calls(msg: Any) -> list[dict]:
73
+ """
74
+ Return normalized tool_calls as plain dicts.
75
+
76
+ LangChain normalizes them into {name, args, id, type} on AIMessage.
77
+ Chunks may instead expose `tool_call_chunks`, which merge into the
78
+ same shape as a best-effort.
79
+ """
80
+ if msg is None:
81
+ return []
82
+
83
+ calls = getattr(msg, "tool_calls", None)
84
+ if calls:
85
+ out: list[dict] = []
86
+ for tc in calls:
87
+ if isinstance(tc, dict):
88
+ out.append({
89
+ "id": tc.get("id"),
90
+ "name": tc.get("name"),
91
+ "args": tc.get("args") or {},
92
+ })
93
+ else:
94
+ out.append({
95
+ "id": getattr(tc, "id", None),
96
+ "name": getattr(tc, "name", None),
97
+ "args": getattr(tc, "args", {}) or {},
98
+ })
99
+ return out
100
+
101
+ chunks = getattr(msg, "tool_call_chunks", None) or []
102
+ merged: dict[int, dict] = {}
103
+ for i, c in enumerate(chunks):
104
+ idx = c.get("index", i) if isinstance(c, dict) else i
105
+ bucket = merged.setdefault(idx, {"id": None, "name": None, "args_str": ""})
106
+ if isinstance(c, dict):
107
+ bucket["id"] = bucket["id"] or c.get("id")
108
+ bucket["name"] = bucket["name"] or c.get("name")
109
+ if c.get("args"):
110
+ bucket["args_str"] += str(c["args"])
111
+ out2: list[dict] = []
112
+ for idx in sorted(merged):
113
+ b = merged[idx]
114
+ args: Any = {}
115
+ if b["args_str"]:
116
+ try:
117
+ import json
118
+ args = json.loads(b["args_str"])
119
+ except Exception:
120
+ args = {"_raw": b["args_str"]}
121
+ out2.append({"id": b["id"], "name": b["name"], "args": args})
122
+ return out2
123
+
124
+
125
+ def _unwrap_chat_output(output: Any) -> BaseMessage | None:
126
+ """
127
+ on_chat_model_end.data.output is either an AIMessage/AIMessageChunk
128
+ or an LLMResult wrapping one. Unwrap both.
129
+ """
130
+ if output is None:
131
+ return None
132
+ if hasattr(output, "generations"):
133
+ gens = getattr(output, "generations") or []
134
+ if gens and gens[0]:
135
+ return getattr(gens[0][0], "message", None)
136
+ return None
137
+ if isinstance(output, (AIMessage, AIMessageChunk)):
138
+ return output
139
+ return output if isinstance(output, BaseMessage) else None
140
+
141
+
142
+ def _truncate(text: str, limit: int) -> tuple[str, bool]:
143
+ if len(text) <= limit:
144
+ return text, False
145
+ # keep head and tail so both the context and the error message survive
146
+ head = limit * 2 // 3
147
+ tail = limit - head
148
+ return f"{text[:head]}\n...[{len(text) - limit} bytes elided]...\n{text[-tail:]}", True
149
+
150
+
151
+ # EventEmitter
152
+ class EventEmitter:
153
+ """
154
+ Consumes astream_events frames and appends persistence events.
155
+
156
+ Usage:
157
+ emitter = EventEmitter(log)
158
+ async for event in graph.astream_events(payload, config=cfg, version="v2"):
159
+ await emitter.consume(event)
160
+ display logic unchanged
161
+ """
162
+
163
+ def __init__(self, log: EventLog, *, tool_output_limit: int = 16_000) -> None:
164
+ self.log = log
165
+ self._tool_output_limit = tool_output_limit
166
+
167
+ # run_id -> in-flight chat state
168
+ self._chat: dict[str, dict[str, Any]] = {}
169
+
170
+ # run_id -> in-flight tool state
171
+ self._tools: dict[str, dict[str, Any]] = {}
172
+
173
+ # tool_call_id -> originating node ("coder" | "planner" | "tool")
174
+ # Populated when an assistant_message carrying tool_calls is emitted,
175
+ # consumed when the matching tool_result is emitted.
176
+ self._tool_call_to_node: dict[str, str | None] = {}
177
+
178
+ # last input_tokens reported by any model call in this turn
179
+ self.last_input_tokens: int = 0
180
+
181
+ # public entry point
182
+ async def consume(self, event: dict) -> None:
183
+ """Route one astream_events frame. Never raises."""
184
+ kind = event.get("event", "")
185
+ handler = _HANDLERS.get(kind)
186
+ if handler is None:
187
+ return
188
+ try:
189
+ await handler(self, event)
190
+ except Exception as e:
191
+ # Persistence must never break the run. Report and move on.
192
+ print(f"[persistence] failed to emit '{kind}': {e!r}", file=sys.stderr)
193
+
194
+ # chat model
195
+ async def _on_chat_start(self, event: dict) -> None:
196
+ run_id = event["run_id"]
197
+ self._chat[run_id] = {
198
+ "chunk": None, # accumulated AIMessageChunk
199
+ "meta": event.get("metadata") or {},
200
+ "started_at": time.time(),
201
+ }
202
+
203
+ async def _on_chat_stream(self, event: dict) -> None:
204
+ run_id = event.get("run_id")
205
+ buf = self._chat.get(run_id) # type: ignore
206
+ if buf is None:
207
+ return
208
+ chunk = (event.get("data") or {}).get("chunk")
209
+ if chunk is None:
210
+ return
211
+ try:
212
+ buf["chunk"] = chunk if buf["chunk"] is None else buf["chunk"] + chunk
213
+ except Exception:
214
+ # merge failure is rare and non-fatal - keep what we have
215
+ pass
216
+
217
+ async def _on_chat_end(self, event: dict) -> None:
218
+ run_id = event.get("run_id")
219
+ buf = self._chat.pop(run_id, None) #type: ignore
220
+ if buf is None:
221
+ return
222
+
223
+ accumulated: AIMessageChunk | None = buf["chunk"]
224
+ final_msg = _unwrap_chat_output((event.get("data") or {}).get("output"))
225
+
226
+ # Prefer the accumulated stream (it has reasoning_content that the
227
+ # final message often drops). Supplement with final_msg when needed.
228
+ content = _content_to_str(getattr(accumulated, "content", "")) if accumulated else ""
229
+ reasoning = _extract_reasoning(accumulated) if accumulated else ""
230
+ tool_calls = _extract_tool_calls(accumulated) if accumulated else []
231
+
232
+ if final_msg is not None:
233
+ if not content:
234
+ content = _content_to_str(getattr(final_msg, "content", ""))
235
+ if not reasoning:
236
+ reasoning = _extract_reasoning(final_msg)
237
+ if not tool_calls:
238
+ tool_calls = _extract_tool_calls(final_msg)
239
+
240
+ usage = None
241
+ if final_msg is not None:
242
+ um = getattr(final_msg, "usage_metadata", None)
243
+ if um:
244
+ usage = dict(um)
245
+
246
+ if usage and isinstance(usage.get("input_tokens"), int):
247
+ self.last_input_tokens = usage["input_tokens"]
248
+
249
+ meta = buf.get("meta") or {}
250
+ node = meta.get("langgraph_node")
251
+
252
+ # Remember which node each tool call came from so tool_result
253
+ # events can carry the same attribution.
254
+ for tc in tool_calls:
255
+ tid = tc.get("id")
256
+ if tid:
257
+ self._tool_call_to_node[tid] = node
258
+
259
+ self.log.emit("assistant_message", {
260
+ "node": node,
261
+ "content": content,
262
+ "reasoning_content": reasoning or None,
263
+ "tool_calls": tool_calls,
264
+ "usage": usage,
265
+ "model": meta.get("ls_model_name"),
266
+ "duration_ms": int((time.time() - buf["started_at"]) * 1000),
267
+ })
268
+
269
+ # tools ---------------------------------------------------------
270
+ async def _on_tool_start(self, event: dict) -> None:
271
+ run_id = event.get("run_id")
272
+ data = event.get("data") or {}
273
+ meta = event.get("metadata") or {}
274
+
275
+ # LangChain names the tool in `event["name"]`, the args in data["input"].
276
+ self._tools[run_id] = { # type: ignore
277
+ "tool_name": event.get("name"),
278
+ "tool_call_id": data.get("tool_call_id"),
279
+ "input": data.get("input"),
280
+ "node_hint": meta.get("langgraph_node"),
281
+ "started_at": time.time(),
282
+ }
283
+
284
+ async def _on_tool_end(self, event: dict) -> None:
285
+ run_id = event.get("run_id")
286
+ meta = self._tools.pop(run_id, {}) # type: ignore
287
+ output = (event.get("data") or {}).get("output")
288
+
289
+ tool_call_id = meta.get("tool_call_id")
290
+ tool_name = meta.get("tool_name")
291
+ status = "success"
292
+ content = ""
293
+
294
+ if isinstance(output, ToolMessage):
295
+ tool_call_id = getattr(output, "tool_call_id", None) or tool_call_id
296
+ tool_name = getattr(output, "name", None) or tool_name
297
+ content = _content_to_str(getattr(output, "content", ""))
298
+ status = getattr(output, "status", None) or "success"
299
+ else:
300
+ content = _content_to_str(output)
301
+
302
+ text, truncated = _truncate(content, self._tool_output_limit)
303
+
304
+ # Prefer the model-node when the assistant emitted the call.
305
+ # Fall back to langgraph_node from the tool event,
306
+ # which will usually be "execute_tools" and thus less useful.
307
+ node = self._tool_call_to_node.get(tool_call_id) or meta.get("node_hint")
308
+
309
+ self.log.emit("tool_result", {
310
+ "tool_call_id": tool_call_id,
311
+ "tool_name": tool_name,
312
+ "node": node,
313
+ "status": status,
314
+ "content": text,
315
+ "truncated": truncated,
316
+ "content_len": len(content),
317
+ "duration_ms": int((time.time() - meta.get("started_at", time.time())) * 1000),
318
+ })
319
+
320
+ async def _on_tool_error(self, event: dict) -> None:
321
+ run_id = event.get("run_id")
322
+ meta = self._tools.pop(run_id, {}) # type: ignore
323
+ err = (event.get("data") or {}).get("error")
324
+
325
+ tool_call_id = meta.get("tool_call_id")
326
+ node = self._tool_call_to_node.get(tool_call_id) or meta.get("node_hint")
327
+
328
+ self.log.emit("tool_result", {
329
+ "tool_call_id": tool_call_id,
330
+ "tool_name": meta.get("tool_name"),
331
+ "node": node,
332
+ "status": "error",
333
+ "content": f"{type(err).__name__}: {err}" if err else "unknown error",
334
+ "truncated": False,
335
+ "content_len": 0,
336
+ "duration_ms": int((time.time() - meta.get("started_at", time.time())) * 1000),
337
+ })
338
+
339
+ def emit_tool_result(
340
+ self,
341
+ *,
342
+ tool_call_id: str,
343
+ tool_name: str,
344
+ node: str | None,
345
+ status: str,
346
+ content: str,
347
+ duration_ms: int,
348
+ ) -> None:
349
+ """Public hook for callers that bypass LangChain's callback stream."""
350
+ text, truncated = _truncate(content or "", self._tool_output_limit)
351
+ self.log.emit("tool_result", {
352
+ "tool_call_id": tool_call_id,
353
+ "tool_name": tool_name,
354
+ "node": node,
355
+ "status": status,
356
+ "content": text,
357
+ "truncated": truncated,
358
+ "content_len": len(content or ""),
359
+ "duration_ms": duration_ms,
360
+ })
361
+
362
+
363
+ # dispatch table - populated after the class to keep the definition clean
364
+ _HANDLERS = {
365
+ "on_chat_model_start": EventEmitter._on_chat_start,
366
+ "on_chat_model_stream": EventEmitter._on_chat_stream,
367
+ "on_chat_model_end": EventEmitter._on_chat_end,
368
+ "on_tool_start": EventEmitter._on_tool_start,
369
+ "on_tool_end": EventEmitter._on_tool_end,
370
+ "on_tool_error": EventEmitter._on_tool_error,
371
+ }
@@ -0,0 +1,303 @@
1
+ """
2
+ Append-only JSONL event log for a single agent session.
3
+
4
+ File layout (per workspace):
5
+ <user-home-dir>/.vamp/sessions/<sessionId>.jsonl # append-only event log
6
+ <user-home-dir>/.vamp/sessions/<sessionId>.head # sidecar: current leaf uuid
7
+
8
+ Concurrency model:
9
+ - One background writer task drains an asyncio.Queue in FIFO order.
10
+ - `emit()` is fire-and-forget: enqueue and return. Display path is free.
11
+ - `append()` enqueues and awaits the write. Use for durability-critical
12
+ events (user_message, interrupt_request, interrupt_response, ...).
13
+ - `parentUuid` chaining happens *inside* the writer task, so it is
14
+ race-free even under bursts of concurrent emit() calls.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import time
24
+ import uuid
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Iterable, Iterator
28
+
29
+
30
+
31
+ # uuid7 - time-sortable, canonical-string form
32
+ def uuid7() -> str:
33
+ ms = int(time.time() * 1000) & ((1 << 48) - 1)
34
+ rand_a = int.from_bytes(os.urandom(2), "big") & 0x0FFF
35
+ rand_b = int.from_bytes(os.urandom(8), "big") & ((1 << 62) - 1)
36
+ value = (ms << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
37
+ return str(uuid.UUID(int=value))
38
+
39
+
40
+ # git branch
41
+ def _read_git_branch(cwd: str) -> str | None:
42
+ try:
43
+ result = subprocess.run(
44
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
45
+ cwd=cwd, capture_output=True, text=True, timeout=1,
46
+ )
47
+ if result.returncode == 0:
48
+ branch = result.stdout.strip()
49
+ return branch or None
50
+ except Exception:
51
+ pass
52
+ return None
53
+
54
+
55
+ # redaction hook
56
+ Redactor = Callable[[str, dict[str, Any]], dict[str, Any]]
57
+
58
+
59
+ def identity_redactor(event_type: str, data: dict[str, Any]) -> dict[str, Any]:
60
+ return data
61
+
62
+
63
+ # fsync policy
64
+ DEFAULT_FSYNC_TYPES = frozenset({
65
+ "session_start",
66
+ "session_end",
67
+ "user_message",
68
+ "mode_switch",
69
+ "interrupt_request",
70
+ "interrupt_response",
71
+ })
72
+
73
+
74
+ # Queue item: (event_type, data, done_future_or_None)
75
+ # _QueueItem type
76
+ _QueueItem = tuple[str, dict[str, Any], "asyncio.Future[str] | None", "str | None"]
77
+
78
+
79
+ # EventLog
80
+ class EventLog:
81
+ """Async append-only writer for one session, backed by a writer task."""
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ session_id: str,
87
+ path: Path,
88
+ head_path: Path,
89
+ cwd: str,
90
+ version: str,
91
+ git_branch: str | None,
92
+ redactor: Redactor,
93
+ fsync_types: frozenset[str],
94
+ ) -> None:
95
+ self.session_id = session_id
96
+ self.path = path
97
+ self.head_path = head_path
98
+ self.cwd = cwd
99
+ self.version = version
100
+ self.git_branch = git_branch
101
+ self._redactor = redactor
102
+ self._fsync_types = fsync_types
103
+
104
+ self._file = None
105
+ self._last_uuid: str | None = None
106
+ self._queue: asyncio.Queue[_QueueItem | None] = asyncio.Queue()
107
+ self._writer_task: asyncio.Task[None] | None = None
108
+ self._closed = False
109
+
110
+ # construction
111
+ @classmethod
112
+ async def open(
113
+ cls,
114
+ session_id: str,
115
+ *,
116
+ workspace_root: str | os.PathLike[str],
117
+ version: str,
118
+ redactor: Redactor | None = None,
119
+ fsync_on: Iterable[str] | None = None,
120
+ cwd: str | None = None,
121
+ ) -> "EventLog":
122
+ sessions_dir = Path.home() / ".vamp" / "sessions"
123
+ sessions_dir.mkdir(parents=True, exist_ok=True)
124
+
125
+ path = sessions_dir / f"{session_id}.jsonl"
126
+ head_path = sessions_dir / f"{session_id}.head"
127
+ cwd = cwd or str(Path.cwd())
128
+
129
+ log = cls(
130
+ session_id=session_id,
131
+ path=path,
132
+ head_path=head_path,
133
+ cwd=cwd,
134
+ version=version,
135
+ git_branch=_read_git_branch(cwd),
136
+ redactor=redactor or identity_redactor,
137
+ fsync_types=(
138
+ frozenset(fsync_on) if fsync_on is not None
139
+ else DEFAULT_FSYNC_TYPES
140
+ ),
141
+ )
142
+
143
+ if head_path.exists():
144
+ log._last_uuid = head_path.read_text(encoding="utf-8").strip() or None
145
+ elif path.exists():
146
+ log._last_uuid = _scan_leaf_uuid(path)
147
+
148
+ log._file = open(path, "a", encoding="utf-8", buffering=1)
149
+ log._writer_task = asyncio.create_task(log._writer_loop())
150
+ return log
151
+
152
+
153
+ @property
154
+ def head_uuid(self) -> str | None:
155
+ return self._last_uuid
156
+
157
+ def refresh_branch(self) -> None:
158
+ self.git_branch = _read_git_branch(self.cwd)
159
+
160
+ # writer task
161
+ async def _writer_loop(self) -> None:
162
+ while True:
163
+ item = await self._queue.get()
164
+ if item is None:
165
+ self._queue.task_done()
166
+ return
167
+ event_type, data, done, parent_uuid = item
168
+ try:
169
+ event_uuid = self._write_event(event_type, data, parent_uuid)
170
+ if done is not None and not done.done():
171
+ done.set_result(event_uuid)
172
+ except Exception as e:
173
+ if done is not None and not done.done():
174
+ done.set_exception(e)
175
+ finally:
176
+ self._queue.task_done()
177
+
178
+ def _write_event(
179
+ self,
180
+ event_type: str,
181
+ data: dict[str, Any],
182
+ parent_uuid: str | None = None,
183
+ ) -> str:
184
+ """Synchronous - runs only inside the writer task."""
185
+ payload = self._redactor(event_type, dict(data))
186
+
187
+ event_uuid = uuid7()
188
+ envelope = {
189
+ "type": event_type,
190
+ "uuid": event_uuid,
191
+ "parentUuid": parent_uuid if parent_uuid is not None else self._last_uuid,
192
+ "sessionId": self.session_id,
193
+ "timestamp": _iso_now(),
194
+ "cwd": self.cwd,
195
+ "version": self.version,
196
+ "gitBranch": self.git_branch,
197
+ "data": payload,
198
+ }
199
+
200
+ line = json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
201
+ self._file.write(line + "\n") # type: ignore
202
+ self._file.flush() # type: ignore
203
+ if event_type in self._fsync_types:
204
+ os.fsync(self._file.fileno()) # type: ignore
205
+
206
+ self._last_uuid = event_uuid
207
+ self.head_path.write_text(event_uuid, encoding="utf-8")
208
+ return event_uuid
209
+
210
+ # public API
211
+ def emit(self, event_type: str, data: dict[str, Any] | None = None, *, parent_uuid=None) -> None:
212
+ """Fire-and-forget. Non-blocking. FIFO ordering guaranteed."""
213
+ if self._closed:
214
+ raise RuntimeError("EventLog is closed")
215
+ self._queue.put_nowait((event_type, dict(data or {}), None, parent_uuid))
216
+
217
+ async def append(
218
+ self,
219
+ event_type: str,
220
+ data: dict[str, Any] | None = None,
221
+ *,
222
+ parent_uuid=None,
223
+ ) -> str:
224
+ """Enqueue and wait for the durable write to complete."""
225
+ if self._closed:
226
+ raise RuntimeError("EventLog is closed")
227
+ done: asyncio.Future[str] = asyncio.get_running_loop().create_future()
228
+ await self._queue.put((event_type, dict(data or {}), done, parent_uuid))
229
+ return await done
230
+
231
+ async def flush(self) -> None:
232
+ """Wait until every queued write has been processed."""
233
+ await self._queue.join()
234
+
235
+ async def close(self, reason: str = "user_exit") -> None:
236
+ if self._closed:
237
+ return
238
+ try:
239
+ await self.append("session_end", {"reason": reason})
240
+ await self.flush()
241
+ finally:
242
+ self._closed = True
243
+ await self._queue.put(None) # sentinel
244
+ if self._writer_task is not None:
245
+ try:
246
+ await self._writer_task
247
+ except Exception:
248
+ pass
249
+ if self._file is not None:
250
+ self._file.flush()
251
+ try:
252
+ os.fsync(self._file.fileno())
253
+ except Exception:
254
+ pass
255
+ self._file.close()
256
+ self._file = None
257
+
258
+ # branch control (fork; no writes, no truncation)
259
+ async def set_head(self, event_uuid: str) -> None:
260
+ """Repoint the head. Flushes first so pending writes are durable."""
261
+ await self.flush()
262
+ self._last_uuid = event_uuid
263
+ self.head_path.write_text(event_uuid, encoding="utf-8")
264
+
265
+
266
+
267
+ # helpers
268
+ def _iso_now() -> str:
269
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
270
+
271
+
272
+ def _scan_leaf_uuid(path: Path) -> str | None:
273
+ all_uuids: list[str] = []
274
+ parents: set[str] = set()
275
+ with open(path, "r", encoding="utf-8") as f:
276
+ for line in f:
277
+ line = line.strip()
278
+ if not line:
279
+ continue
280
+ try:
281
+ event = json.loads(line)
282
+ except json.JSONDecodeError:
283
+ continue
284
+ u = event.get("uuid")
285
+ p = event.get("parentUuid")
286
+ if u:
287
+ all_uuids.append(u)
288
+ if p:
289
+ parents.add(p)
290
+ leaves = [u for u in all_uuids if u not in parents]
291
+ return max(leaves) if leaves else None
292
+
293
+
294
+ def iter_events(path: Path) -> Iterator[dict[str, Any]]:
295
+ with open(path, "r", encoding="utf-8") as f:
296
+ for line in f:
297
+ line = line.strip()
298
+ if not line:
299
+ continue
300
+ try:
301
+ yield json.loads(line)
302
+ except json.JSONDecodeError:
303
+ continue