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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|