local-coding-agent 1.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 (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,684 @@
1
+ """Event-Sourced Session Engine.
2
+
3
+ Implements typed immutable session events, append-only monotonic event logging,
4
+ model-visible message derivation (Model-Visible ⟺ Logged invariant),
5
+ and session branching/forking.
6
+
7
+ Adapted from DeepSeek Harness @deepseek-ai/dsh-session and @deepseek-ai/dsh-session-persistence-jsonl.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import json
14
+ import os
15
+ from dataclasses import asdict, dataclass, field
16
+ from pathlib import Path
17
+ import threading
18
+ from typing import Any, Mapping, Sequence, Union
19
+
20
+
21
+ def _utc_now_iso() -> str:
22
+ """Generate ISO 8601 UTC timestamp."""
23
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
24
+
25
+
26
+ # -----------------------------------------------------------------------------
27
+ # Typed Immutable Session Events
28
+ # -----------------------------------------------------------------------------
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SessionCreatedEvent:
33
+ """Emitted when a new session is initialized or branched from a parent."""
34
+
35
+ session_id: str
36
+ seq: int
37
+ timestamp: str
38
+ parent_session_id: str | None = None
39
+ fork_seq: int | None = None
40
+ metadata: dict[str, Any] = field(default_factory=dict)
41
+ event_type: str = "session_created"
42
+
43
+ def as_dict(self) -> dict[str, Any]:
44
+ return asdict(self)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class UserPromptEvent:
49
+ """Emitted when a user submits a prompt or goal."""
50
+
51
+ session_id: str
52
+ seq: int
53
+ timestamp: str
54
+ content: str
55
+ metadata: dict[str, Any] = field(default_factory=dict)
56
+ event_type: str = "user_prompt"
57
+
58
+ def as_dict(self) -> dict[str, Any]:
59
+ return asdict(self)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ModelTurnEvent:
64
+ """Emitted when the language model completes an assistant turn."""
65
+
66
+ session_id: str
67
+ seq: int
68
+ timestamp: str
69
+ content: str = ""
70
+ model: str = ""
71
+ tool_calls: tuple[dict[str, Any], ...] = ()
72
+ metadata: dict[str, Any] = field(default_factory=dict)
73
+ event_type: str = "model_turn"
74
+
75
+ def __post_init__(self) -> None:
76
+ if isinstance(self.tool_calls, list):
77
+ object.__setattr__(self, "tool_calls", tuple(self.tool_calls))
78
+
79
+ def as_dict(self) -> dict[str, Any]:
80
+ data = asdict(self)
81
+ data["tool_calls"] = [dict(tc) for tc in self.tool_calls]
82
+ return data
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ToolCallEvent:
87
+ """Emitted when a specific tool is invoked during a turn."""
88
+
89
+ session_id: str
90
+ seq: int
91
+ timestamp: str
92
+ tool_call_id: str
93
+ tool_name: str
94
+ arguments: dict[str, Any] = field(default_factory=dict)
95
+ metadata: dict[str, Any] = field(default_factory=dict)
96
+ event_type: str = "tool_call"
97
+
98
+ def as_dict(self) -> dict[str, Any]:
99
+ return asdict(self)
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class ToolResultEvent:
104
+ """Emitted when a tool invocation completes and produces output or error."""
105
+
106
+ session_id: str
107
+ seq: int
108
+ timestamp: str
109
+ tool_call_id: str
110
+ tool_name: str
111
+ result: Any = None
112
+ error: str | None = None
113
+ metadata: dict[str, Any] = field(default_factory=dict)
114
+ event_type: str = "tool_result"
115
+
116
+ def as_dict(self) -> dict[str, Any]:
117
+ return asdict(self)
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class PrescriptionEvent:
122
+ """Emitted when the controller prescribes a deterministic correction to the model."""
123
+
124
+ session_id: str
125
+ seq: int
126
+ timestamp: str
127
+ kind: str
128
+ instruction: str
129
+ details: dict[str, Any] = field(default_factory=dict)
130
+ metadata: dict[str, Any] = field(default_factory=dict)
131
+ event_type: str = "prescription"
132
+
133
+ def as_dict(self) -> dict[str, Any]:
134
+ return asdict(self)
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class SessionCompletedEvent:
139
+ """Emitted when a session reaches terminal completion."""
140
+
141
+ session_id: str
142
+ seq: int
143
+ timestamp: str
144
+ status: str # "success", "failed", "cancelled", "interrupted"
145
+ summary: str = ""
146
+ result: dict[str, Any] | None = None
147
+ metadata: dict[str, Any] = field(default_factory=dict)
148
+ event_type: str = "session_completed"
149
+
150
+ def as_dict(self) -> dict[str, Any]:
151
+ return asdict(self)
152
+
153
+
154
+ SessionEvent = Union[
155
+ SessionCreatedEvent,
156
+ UserPromptEvent,
157
+ ModelTurnEvent,
158
+ ToolCallEvent,
159
+ ToolResultEvent,
160
+ PrescriptionEvent,
161
+ SessionCompletedEvent,
162
+ ]
163
+
164
+ _EVENT_TYPE_MAP: dict[str, type[SessionEvent]] = {
165
+ "session_created": SessionCreatedEvent,
166
+ "user_prompt": UserPromptEvent,
167
+ "model_turn": ModelTurnEvent,
168
+ "tool_call": ToolCallEvent,
169
+ "tool_result": ToolResultEvent,
170
+ "prescription": PrescriptionEvent,
171
+ "session_completed": SessionCompletedEvent,
172
+ }
173
+
174
+
175
+ def event_to_dict(event: SessionEvent) -> dict[str, Any]:
176
+ """Convert any SessionEvent to a JSON-serializable dictionary."""
177
+ return event.as_dict()
178
+
179
+
180
+ def event_from_dict(data: Mapping[str, Any]) -> SessionEvent:
181
+ """Reconstruct a typed SessionEvent from dictionary payload."""
182
+ event_type = data.get("event_type")
183
+ if not event_type or event_type not in _EVENT_TYPE_MAP:
184
+ raise ValueError(f"Unknown or missing event_type in data: {event_type!r}")
185
+
186
+ cls = _EVENT_TYPE_MAP[event_type]
187
+ session_id = str(data["session_id"])
188
+ seq = int(data["seq"])
189
+ timestamp = str(data.get("timestamp") or _utc_now_iso())
190
+ metadata = dict(data.get("metadata", {}))
191
+
192
+ if cls is SessionCreatedEvent:
193
+ return SessionCreatedEvent(
194
+ session_id=session_id,
195
+ seq=seq,
196
+ timestamp=timestamp,
197
+ parent_session_id=data.get("parent_session_id"),
198
+ fork_seq=data.get("fork_seq"),
199
+ metadata=metadata,
200
+ )
201
+ elif cls is UserPromptEvent:
202
+ return UserPromptEvent(
203
+ session_id=session_id,
204
+ seq=seq,
205
+ timestamp=timestamp,
206
+ content=str(data.get("content", "")),
207
+ metadata=metadata,
208
+ )
209
+ elif cls is ModelTurnEvent:
210
+ raw_tc = data.get("tool_calls", ())
211
+ tool_calls = tuple(dict(tc) for tc in raw_tc) if isinstance(raw_tc, (list, tuple)) else ()
212
+ return ModelTurnEvent(
213
+ session_id=session_id,
214
+ seq=seq,
215
+ timestamp=timestamp,
216
+ content=str(data.get("content", "")),
217
+ model=str(data.get("model", "")),
218
+ tool_calls=tool_calls,
219
+ metadata=metadata,
220
+ )
221
+ elif cls is ToolCallEvent:
222
+ return ToolCallEvent(
223
+ session_id=session_id,
224
+ seq=seq,
225
+ timestamp=timestamp,
226
+ tool_call_id=str(data["tool_call_id"]),
227
+ tool_name=str(data["tool_name"]),
228
+ arguments=dict(data.get("arguments", {})),
229
+ metadata=metadata,
230
+ )
231
+ elif cls is ToolResultEvent:
232
+ return ToolResultEvent(
233
+ session_id=session_id,
234
+ seq=seq,
235
+ timestamp=timestamp,
236
+ tool_call_id=str(data["tool_call_id"]),
237
+ tool_name=str(data["tool_name"]),
238
+ result=data.get("result"),
239
+ error=data.get("error"),
240
+ metadata=metadata,
241
+ )
242
+ elif cls is PrescriptionEvent:
243
+ return PrescriptionEvent(
244
+ session_id=session_id,
245
+ seq=seq,
246
+ timestamp=timestamp,
247
+ kind=str(data.get("kind", "")),
248
+ instruction=str(data.get("instruction", "")),
249
+ details=dict(data.get("details", {})),
250
+ metadata=metadata,
251
+ )
252
+ elif cls is SessionCompletedEvent:
253
+ res = data.get("result")
254
+ return SessionCompletedEvent(
255
+ session_id=session_id,
256
+ seq=seq,
257
+ timestamp=timestamp,
258
+ status=str(data.get("status", "unknown")),
259
+ summary=str(data.get("summary", "")),
260
+ result=dict(res) if isinstance(res, Mapping) else None,
261
+ metadata=metadata,
262
+ )
263
+
264
+ raise ValueError(f"Unsupported event type class for: {event_type}")
265
+
266
+
267
+ # -----------------------------------------------------------------------------
268
+ # Model-Visible ⟺ Logged Invariant: Message Derivation
269
+ # -----------------------------------------------------------------------------
270
+
271
+
272
+ def derive_messages(events: Sequence[SessionEvent]) -> list[dict[str, Any]]:
273
+ """Derive standard LLM messages array strictly from recorded session events.
274
+
275
+ Enforces the core architectural invariant: Model-Visible ⟺ Logged.
276
+ Anything that reaches a model request must be reconstructable from the log.
277
+ """
278
+ messages: list[dict[str, Any]] = []
279
+
280
+ for event in events:
281
+ if isinstance(event, UserPromptEvent):
282
+ messages.append({"role": "user", "content": event.content})
283
+
284
+ elif isinstance(event, ModelTurnEvent):
285
+ msg: dict[str, Any] = {"role": "assistant"}
286
+ if event.content:
287
+ msg["content"] = event.content
288
+ else:
289
+ msg["content"] = ""
290
+ if event.tool_calls:
291
+ msg["tool_calls"] = [dict(tc) for tc in event.tool_calls]
292
+ messages.append(msg)
293
+
294
+ elif isinstance(event, ToolResultEvent):
295
+ if isinstance(event.result, str):
296
+ content = event.result
297
+ elif event.result is not None:
298
+ content = json.dumps(event.result, ensure_ascii=False)
299
+ else:
300
+ content = ""
301
+
302
+ if event.error:
303
+ content = f"Error: {event.error}\n{content}".strip()
304
+
305
+ messages.append(
306
+ {
307
+ "role": "tool",
308
+ "tool_call_id": event.tool_call_id,
309
+ "name": event.tool_name,
310
+ "content": content,
311
+ }
312
+ )
313
+
314
+ elif isinstance(event, PrescriptionEvent):
315
+ # Prescriptions are pinpointed corrective feedback rendered as a model-visible user message
316
+ messages.append(
317
+ {
318
+ "role": "user",
319
+ "content": f"[PRESCRIPTION: {event.kind}] {event.instruction}",
320
+ }
321
+ )
322
+
323
+ return messages
324
+
325
+
326
+ # -----------------------------------------------------------------------------
327
+ # Append-Only Session Log Container & Persistence
328
+ # -----------------------------------------------------------------------------
329
+
330
+
331
+ class SessionLog:
332
+ """Thread-safe append-only session event log with JSONL persistence and monotonic sequencing."""
333
+
334
+ def __init__(
335
+ self,
336
+ session_id: str,
337
+ *,
338
+ storage_dir: str | Path | None = None,
339
+ log_path: str | Path | None = None,
340
+ ) -> None:
341
+ self.session_id = session_id
342
+ self._lock = threading.RLock()
343
+ self._events: list[SessionEvent] = []
344
+
345
+ if log_path is not None:
346
+ self._log_path: Path | None = Path(log_path)
347
+ self._log_path.parent.mkdir(parents=True, exist_ok=True)
348
+ elif storage_dir is not None:
349
+ dir_path = Path(storage_dir)
350
+ dir_path.mkdir(parents=True, exist_ok=True)
351
+ self._log_path = dir_path / f"{session_id}.jsonl"
352
+ else:
353
+ self._log_path = None
354
+
355
+ @property
356
+ def log_path(self) -> Path | None:
357
+ return self._log_path
358
+
359
+ @property
360
+ def events(self) -> tuple[SessionEvent, ...]:
361
+ with self._lock:
362
+ return tuple(self._events)
363
+
364
+ @property
365
+ def last_event(self) -> SessionEvent | None:
366
+ with self._lock:
367
+ return self._events[-1] if self._events else None
368
+
369
+ @property
370
+ def parent_session_id(self) -> str | None:
371
+ with self._lock:
372
+ if self._events and isinstance(self._events[0], SessionCreatedEvent):
373
+ return self._events[0].parent_session_id
374
+ return None
375
+
376
+ @property
377
+ def fork_seq(self) -> int | None:
378
+ with self._lock:
379
+ if self._events and isinstance(self._events[0], SessionCreatedEvent):
380
+ return self._events[0].fork_seq
381
+ return None
382
+
383
+ def __len__(self) -> int:
384
+ with self._lock:
385
+ return len(self._events)
386
+
387
+ def __iter__(self):
388
+ with self._lock:
389
+ return iter(list(self._events))
390
+
391
+ def __getitem__(self, index: int) -> SessionEvent:
392
+ with self._lock:
393
+ return self._events[index]
394
+
395
+ def get_event(self, seq: int) -> SessionEvent | None:
396
+ with self._lock:
397
+ if 0 <= seq < len(self._events):
398
+ return self._events[seq]
399
+ return None
400
+
401
+ def append(self, event: SessionEvent) -> SessionEvent:
402
+ """Append an event to the log, enforcing monotonic sequencing."""
403
+ with self._lock:
404
+ if event.session_id != self.session_id:
405
+ raise ValueError(
406
+ f"Event session_id mismatch: expected {self.session_id!r}, got {event.session_id!r}"
407
+ )
408
+
409
+ expected_seq = len(self._events)
410
+ if event.seq != expected_seq:
411
+ raise ValueError(
412
+ f"Non-monotonic event sequence: expected seq={expected_seq}, got seq={event.seq}"
413
+ )
414
+
415
+ self._events.append(event)
416
+
417
+ if self._log_path is not None:
418
+ self._persist_event(event)
419
+
420
+ return event
421
+
422
+ def _persist_event(self, event: SessionEvent) -> None:
423
+ """Write single event line to JSONL file."""
424
+ if self._log_path is None:
425
+ return
426
+ line = json.dumps(event_to_dict(event), ensure_ascii=False) + "\n"
427
+ with open(self._log_path, "a", encoding="utf-8") as f:
428
+ f.write(line)
429
+ f.flush()
430
+
431
+ # --- Convenience Recording Methods ---
432
+
433
+ def record_created(
434
+ self,
435
+ parent_session_id: str | None = None,
436
+ fork_seq: int | None = None,
437
+ metadata: dict[str, Any] | None = None,
438
+ ) -> SessionCreatedEvent:
439
+ with self._lock:
440
+ event = SessionCreatedEvent(
441
+ session_id=self.session_id,
442
+ seq=len(self._events),
443
+ timestamp=_utc_now_iso(),
444
+ parent_session_id=parent_session_id,
445
+ fork_seq=fork_seq,
446
+ metadata=metadata or {},
447
+ )
448
+ self.append(event)
449
+ return event
450
+
451
+ def record_user_prompt(
452
+ self,
453
+ content: str,
454
+ metadata: dict[str, Any] | None = None,
455
+ ) -> UserPromptEvent:
456
+ with self._lock:
457
+ event = UserPromptEvent(
458
+ session_id=self.session_id,
459
+ seq=len(self._events),
460
+ timestamp=_utc_now_iso(),
461
+ content=content,
462
+ metadata=metadata or {},
463
+ )
464
+ self.append(event)
465
+ return event
466
+
467
+ def record_model_turn(
468
+ self,
469
+ content: str = "",
470
+ model: str = "",
471
+ tool_calls: Sequence[dict[str, Any]] | None = None,
472
+ metadata: dict[str, Any] | None = None,
473
+ ) -> ModelTurnEvent:
474
+ with self._lock:
475
+ event = ModelTurnEvent(
476
+ session_id=self.session_id,
477
+ seq=len(self._events),
478
+ timestamp=_utc_now_iso(),
479
+ content=content,
480
+ model=model,
481
+ tool_calls=tuple(tool_calls or ()),
482
+ metadata=metadata or {},
483
+ )
484
+ self.append(event)
485
+ return event
486
+
487
+ def record_tool_call(
488
+ self,
489
+ tool_call_id: str,
490
+ tool_name: str,
491
+ arguments: dict[str, Any],
492
+ metadata: dict[str, Any] | None = None,
493
+ ) -> ToolCallEvent:
494
+ with self._lock:
495
+ event = ToolCallEvent(
496
+ session_id=self.session_id,
497
+ seq=len(self._events),
498
+ timestamp=_utc_now_iso(),
499
+ tool_call_id=tool_call_id,
500
+ tool_name=tool_name,
501
+ arguments=dict(arguments),
502
+ metadata=metadata or {},
503
+ )
504
+ self.append(event)
505
+ return event
506
+
507
+ def record_tool_result(
508
+ self,
509
+ tool_call_id: str,
510
+ tool_name: str,
511
+ result: Any = None,
512
+ error: str | None = None,
513
+ metadata: dict[str, Any] | None = None,
514
+ ) -> ToolResultEvent:
515
+ with self._lock:
516
+ event = ToolResultEvent(
517
+ session_id=self.session_id,
518
+ seq=len(self._events),
519
+ timestamp=_utc_now_iso(),
520
+ tool_call_id=tool_call_id,
521
+ tool_name=tool_name,
522
+ result=result,
523
+ error=error,
524
+ metadata=metadata or {},
525
+ )
526
+ self.append(event)
527
+ return event
528
+
529
+ def record_prescription(
530
+ self,
531
+ kind: str,
532
+ instruction: str,
533
+ details: dict[str, Any] | None = None,
534
+ metadata: dict[str, Any] | None = None,
535
+ ) -> PrescriptionEvent:
536
+ with self._lock:
537
+ event = PrescriptionEvent(
538
+ session_id=self.session_id,
539
+ seq=len(self._events),
540
+ timestamp=_utc_now_iso(),
541
+ kind=kind,
542
+ instruction=instruction,
543
+ details=details or {},
544
+ metadata=metadata or {},
545
+ )
546
+ self.append(event)
547
+ return event
548
+
549
+ def record_completed(
550
+ self,
551
+ status: str,
552
+ summary: str = "",
553
+ result: dict[str, Any] | None = None,
554
+ metadata: dict[str, Any] | None = None,
555
+ ) -> SessionCompletedEvent:
556
+ with self._lock:
557
+ event = SessionCompletedEvent(
558
+ session_id=self.session_id,
559
+ seq=len(self._events),
560
+ timestamp=_utc_now_iso(),
561
+ status=status,
562
+ summary=summary,
563
+ result=result,
564
+ metadata=metadata or {},
565
+ )
566
+ self.append(event)
567
+ return event
568
+
569
+ def to_jsonl(self) -> str:
570
+ """Render all events as formatted JSONL string."""
571
+ with self._lock:
572
+ return "".join(
573
+ json.dumps(event_to_dict(ev), ensure_ascii=False) + "\n"
574
+ for ev in self._events
575
+ )
576
+
577
+ def save(self, path: str | Path | None = None) -> Path:
578
+ """Write entire session log to file."""
579
+ target = Path(path) if path is not None else self._log_path
580
+ if target is None:
581
+ raise ValueError("No path specified and no log_path configured for session log")
582
+ target.parent.mkdir(parents=True, exist_ok=True)
583
+ with self._lock:
584
+ content = self.to_jsonl()
585
+ temp_file = target.with_suffix(".tmp")
586
+ temp_file.write_text(content, encoding="utf-8")
587
+ temp_file.replace(target)
588
+ self._log_path = target
589
+ return target
590
+
591
+ @classmethod
592
+ def load_from_jsonl(cls, path: str | Path) -> SessionLog:
593
+ """Load and reconstruct a SessionLog from an existing JSONL file."""
594
+ file_path = Path(path)
595
+ if not file_path.exists():
596
+ raise FileNotFoundError(f"Session log file not found: {file_path}")
597
+
598
+ lines = file_path.read_text(encoding="utf-8").strip().splitlines()
599
+ if not lines:
600
+ raise ValueError(f"Session log file is empty: {file_path}")
601
+
602
+ first_data = json.loads(lines[0])
603
+ session_id = str(first_data["session_id"])
604
+ log = cls(session_id=session_id, log_path=file_path)
605
+
606
+ for line_num, line in enumerate(lines):
607
+ line = line.strip()
608
+ if not line:
609
+ continue
610
+ data = json.loads(line)
611
+ event = event_from_dict(data)
612
+ if event.session_id != session_id:
613
+ raise ValueError(
614
+ f"Line {line_num} has unexpected session_id: expected {session_id}, got {event.session_id}"
615
+ )
616
+ if event.seq != len(log._events):
617
+ raise ValueError(
618
+ f"Line {line_num} violates sequence monotonicity: expected {len(log._events)}, got {event.seq}"
619
+ )
620
+ log._events.append(event)
621
+
622
+ return log
623
+
624
+
625
+ # -----------------------------------------------------------------------------
626
+ # Session Forking & Branching
627
+ # -----------------------------------------------------------------------------
628
+
629
+
630
+ def fork_session(
631
+ original_session_id: str,
632
+ step_index: int,
633
+ new_session_id: str,
634
+ storage_dir: str | Path | None = None,
635
+ original_log: SessionLog | None = None,
636
+ ) -> SessionLog:
637
+ """Fork an existing session up to step_index into a new independent session.
638
+
639
+ Reconstructs history up to step_index under new_session_id with a SessionCreatedEvent
640
+ recording lineage (parent_session_id and fork_seq).
641
+ """
642
+ if step_index < 0:
643
+ raise ValueError(f"step_index must be non-negative, got {step_index}")
644
+
645
+ source_log = original_log
646
+ if source_log is None and storage_dir is not None:
647
+ src_path = Path(storage_dir) / f"{original_session_id}.jsonl"
648
+ if src_path.exists():
649
+ source_log = SessionLog.load_from_jsonl(src_path)
650
+
651
+ if source_log is None:
652
+ raise ValueError(
653
+ f"Original session {original_session_id!r} could not be resolved from storage_dir or memory"
654
+ )
655
+
656
+ sliced_events = [ev for ev in source_log.events if ev.seq <= step_index]
657
+ if not sliced_events:
658
+ raise ValueError(f"No events found in original session up to step_index {step_index}")
659
+
660
+ new_log = SessionLog(session_id=new_session_id, storage_dir=storage_dir)
661
+
662
+ # 1. Record SessionCreatedEvent pointing to parent
663
+ new_log.record_created(
664
+ parent_session_id=original_session_id,
665
+ fork_seq=step_index,
666
+ metadata={"forked_at": _utc_now_iso(), "source_events_count": len(sliced_events)},
667
+ )
668
+
669
+ # 2. Replay all subsequent events up to step_index
670
+ for ev in sliced_events:
671
+ # Skip the original session_created event if it was at seq 0
672
+ if isinstance(ev, SessionCreatedEvent) and ev.seq == 0:
673
+ continue
674
+
675
+ raw = ev.as_dict()
676
+ raw["session_id"] = new_session_id
677
+ raw["seq"] = len(new_log)
678
+ reconstructed = event_from_dict(raw)
679
+ new_log.append(reconstructed)
680
+
681
+ if new_log.log_path is not None:
682
+ new_log.save()
683
+
684
+ return new_log