symphony-code 0.0.1__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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
"""JSONL persistence for coding-agent sessions.
|
|
2
|
+
|
|
3
|
+
One append-only transcript per session under ``<root>/<session_id>/transcript.jsonl``.
|
|
4
|
+
The transcript is the source of truth: conversation messages are stored as
|
|
5
|
+
typed events and compaction stores a snapshot event. Runtime context is
|
|
6
|
+
reconstructed from those events; no separate context file is written.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import threading
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Union
|
|
23
|
+
|
|
24
|
+
from coding_agent.addons.persistence.artifacts import (
|
|
25
|
+
append_bytes,
|
|
26
|
+
publish,
|
|
27
|
+
refresh_snapshot,
|
|
28
|
+
safe_path,
|
|
29
|
+
snapshot,
|
|
30
|
+
)
|
|
31
|
+
from coding_agent.addons.persistence.images import archive, hydrate
|
|
32
|
+
from core_ai.content import text_from_content
|
|
33
|
+
from core_ai.types import Message
|
|
34
|
+
from core_harness import Checkpoint
|
|
35
|
+
from core_harness.context import COMPACTED_CONTEXT_MARK
|
|
36
|
+
from coding_agent.addons.persistence.collection import (
|
|
37
|
+
COLLECTABLE_EVENT_TYPES,
|
|
38
|
+
TERMINAL_EVENT_TYPES,
|
|
39
|
+
apply_collection,
|
|
40
|
+
event_key,
|
|
41
|
+
is_collected,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_SESSION_ID = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
45
|
+
_SKIP_EVENTS = frozenset({"text_delta", "reasoning_delta", "tool_call_delta"})
|
|
46
|
+
_MESSAGE_FIELDS = ("role", "content", "tool_calls", "tool_call_id", "tool_call_metadata")
|
|
47
|
+
_MESSAGE_TYPES = frozenset({"system", "user", "assistant", "tool_result", "message"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _move_client_sessions(source: Path, target: Path, client: str) -> None:
|
|
51
|
+
"""Move bundles already tagged for ``client`` into that product's directory.
|
|
52
|
+
|
|
53
|
+
Untagged chats stay where they are. A TUI cannot tell an old Zeron chat
|
|
54
|
+
from an old TUI chat once they share a folder, so only an explicit tag moves.
|
|
55
|
+
"""
|
|
56
|
+
if not source.is_dir():
|
|
57
|
+
return
|
|
58
|
+
for bundle in source.iterdir():
|
|
59
|
+
if not bundle.is_dir() or bundle.is_symlink():
|
|
60
|
+
continue
|
|
61
|
+
meta_path = bundle / "metadata.json"
|
|
62
|
+
if not meta_path.is_file():
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
continue
|
|
68
|
+
if not isinstance(meta, dict) or meta.get("client") != client:
|
|
69
|
+
continue
|
|
70
|
+
destination = target / bundle.name
|
|
71
|
+
if destination.exists():
|
|
72
|
+
continue
|
|
73
|
+
shutil.move(str(bundle), str(destination))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _utc_now() -> str:
|
|
77
|
+
return datetime.now(timezone.utc).isoformat()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sessions_dir(
|
|
81
|
+
workspace: Union[str, Path] | None = None,
|
|
82
|
+
*,
|
|
83
|
+
client: str = "",
|
|
84
|
+
) -> Path:
|
|
85
|
+
"""Return the session directory for one product, migrating old sessions.
|
|
86
|
+
|
|
87
|
+
The TUI keeps its own directory so resume cannot see Zeron chats. Other
|
|
88
|
+
clients stay in the shared directory. Older releases stored sessions in
|
|
89
|
+
``<workspace>/.sessions``; those files move into the shared directory on
|
|
90
|
+
first use. Migration is file-by-file and only removes the legacy directory
|
|
91
|
+
after every move has succeeded, so an interrupted migration remains resumable.
|
|
92
|
+
"""
|
|
93
|
+
root = (Path.home() / ".symphony").expanduser().resolve()
|
|
94
|
+
if client == "tui":
|
|
95
|
+
target = root / "tui" / "sessions"
|
|
96
|
+
shared = root / "sessions"
|
|
97
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
_move_client_sessions(shared, target, "tui")
|
|
99
|
+
return target
|
|
100
|
+
target = root / "sessions"
|
|
101
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
if workspace is not None:
|
|
103
|
+
legacy = Path(workspace).expanduser().resolve() / ".sessions"
|
|
104
|
+
if legacy.is_dir() and not legacy.is_symlink() and legacy != target:
|
|
105
|
+
try:
|
|
106
|
+
for source in legacy.iterdir():
|
|
107
|
+
destination = target / source.name
|
|
108
|
+
if source.is_file() and not source.is_symlink() and not destination.exists() and not destination.is_symlink():
|
|
109
|
+
shutil.move(str(source), str(destination))
|
|
110
|
+
if not any(legacy.iterdir()):
|
|
111
|
+
legacy.rmdir()
|
|
112
|
+
except OSError:
|
|
113
|
+
# Leave the legacy directory in place; resume can retry later.
|
|
114
|
+
pass
|
|
115
|
+
return target
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _entry_seq(entry: Mapping[str, Any]) -> int:
|
|
119
|
+
try:
|
|
120
|
+
return int(entry.get("seq", 0) or 0)
|
|
121
|
+
except (TypeError, ValueError):
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class SessionSummary:
|
|
127
|
+
"""Small displayable summary of a persisted session."""
|
|
128
|
+
|
|
129
|
+
session_id: str
|
|
130
|
+
updated_at: str
|
|
131
|
+
message_count: int = 0
|
|
132
|
+
first_message: str = ""
|
|
133
|
+
client: str = ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _SessionIndex:
|
|
137
|
+
"""Incremental view of one journal. Reads are O(1) or O(result)."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, directory: Path) -> None:
|
|
140
|
+
# Archived images resolve against the session directory when a
|
|
141
|
+
# message is validated, as the full loaders did before the index.
|
|
142
|
+
self.directory = directory
|
|
143
|
+
self.entries: List[Dict[str, Any]] = []
|
|
144
|
+
self.size = -1
|
|
145
|
+
self.high_seq = 0
|
|
146
|
+
self.event_keys: set[tuple[str, int]] = set()
|
|
147
|
+
self.collected: set[tuple[str, int]] = set()
|
|
148
|
+
self.collectable: Dict[str, List[tuple[str, int]]] = {}
|
|
149
|
+
self.run_started: set[str] = set()
|
|
150
|
+
self.latest_terminal_run = ""
|
|
151
|
+
self.messages: List[Dict[str, Any]] = []
|
|
152
|
+
self.hydrated: List[Message] = []
|
|
153
|
+
self.context: List[Dict[str, Any]] = []
|
|
154
|
+
self.hydrated_context: List[Message] = []
|
|
155
|
+
self.context_through = -1
|
|
156
|
+
self.latest_compaction: Optional[Dict[str, Any]] = None
|
|
157
|
+
self.compactions: List[Dict[str, Any]] = []
|
|
158
|
+
self.latest_checkpoint: Optional[Dict[str, Any]] = None
|
|
159
|
+
self.children: Dict[str, dict[str, Any]] = {}
|
|
160
|
+
self.child_order: List[str] = []
|
|
161
|
+
self.message_count = 0
|
|
162
|
+
self.first_message = ""
|
|
163
|
+
self.child_ids: set[str] = set()
|
|
164
|
+
|
|
165
|
+
def reset(self, entries: List[Dict[str, Any]], size: int) -> None:
|
|
166
|
+
fresh = _SessionIndex(self.directory)
|
|
167
|
+
self.__dict__.update(fresh.__dict__)
|
|
168
|
+
self.entries = entries
|
|
169
|
+
self.size = size
|
|
170
|
+
self._ingest(entries)
|
|
171
|
+
|
|
172
|
+
def extend(self, entries: List[Dict[str, Any]], size: int) -> None:
|
|
173
|
+
self.entries.extend(entries)
|
|
174
|
+
self.size = size
|
|
175
|
+
self._ingest(entries)
|
|
176
|
+
|
|
177
|
+
def _ingest(self, entries: Sequence[Dict[str, Any]]) -> None:
|
|
178
|
+
for entry in entries:
|
|
179
|
+
seq = _entry_seq(entry)
|
|
180
|
+
if seq > self.high_seq:
|
|
181
|
+
self.high_seq = seq
|
|
182
|
+
self._note_event(entry)
|
|
183
|
+
self._note_message(entry, seq)
|
|
184
|
+
self._note_compaction(entry)
|
|
185
|
+
self._note_checkpoint(entry)
|
|
186
|
+
self._note_child(entry)
|
|
187
|
+
self._note_listing(entry)
|
|
188
|
+
|
|
189
|
+
def _note_event(self, entry: Dict[str, Any]) -> None:
|
|
190
|
+
event_type = str(entry.get("event_type") or "")
|
|
191
|
+
if not event_type or entry.get("type") == "message":
|
|
192
|
+
return
|
|
193
|
+
payload = entry.get("payload")
|
|
194
|
+
if not isinstance(payload, dict):
|
|
195
|
+
return
|
|
196
|
+
key = event_key(payload)
|
|
197
|
+
if key is not None:
|
|
198
|
+
self.event_keys.add(key)
|
|
199
|
+
if event_type == "collected" or is_collected(payload):
|
|
200
|
+
if key is not None:
|
|
201
|
+
self.collected.add(key)
|
|
202
|
+
return
|
|
203
|
+
run_id = str(payload.get("run_id") or "")
|
|
204
|
+
if not run_id or key is None:
|
|
205
|
+
return
|
|
206
|
+
if event_type == "run_started":
|
|
207
|
+
self.run_started.add(run_id)
|
|
208
|
+
return
|
|
209
|
+
if event_type in TERMINAL_EVENT_TYPES:
|
|
210
|
+
self.latest_terminal_run = run_id
|
|
211
|
+
return
|
|
212
|
+
if run_id in self.run_started and event_type in COLLECTABLE_EVENT_TYPES:
|
|
213
|
+
bucket = self.collectable.setdefault(run_id, [])
|
|
214
|
+
if key not in self.collected and key not in set(bucket):
|
|
215
|
+
bucket.append(key)
|
|
216
|
+
|
|
217
|
+
def _note_message(self, entry: Dict[str, Any], seq: int) -> None:
|
|
218
|
+
payload = JsonlPersistence._message_payload_from_entry(entry)
|
|
219
|
+
if payload is None:
|
|
220
|
+
return
|
|
221
|
+
self.messages.append(payload)
|
|
222
|
+
self.hydrated.append(Message.model_validate(hydrate(payload, self.directory)))
|
|
223
|
+
if self.latest_compaction is None or seq > self.context_through:
|
|
224
|
+
self.context.append(payload)
|
|
225
|
+
self.hydrated_context.append(self.hydrated[-1])
|
|
226
|
+
|
|
227
|
+
def _note_compaction(self, entry: Dict[str, Any]) -> None:
|
|
228
|
+
if entry.get("type") not in {"compaction", "compacted"}:
|
|
229
|
+
return
|
|
230
|
+
self.compactions.append(entry)
|
|
231
|
+
self.latest_compaction = entry
|
|
232
|
+
self.context = []
|
|
233
|
+
self.hydrated_context = []
|
|
234
|
+
self.context_through = int(entry.get("through_seq", 0) or 0)
|
|
235
|
+
system = entry.get("system")
|
|
236
|
+
if not isinstance(system, dict):
|
|
237
|
+
system = next((item.get("message") for item in self.entries
|
|
238
|
+
if item.get("type") == "message"
|
|
239
|
+
and isinstance(item.get("message"), dict)
|
|
240
|
+
and item["message"].get("role") == "system"), None)
|
|
241
|
+
if isinstance(system, dict):
|
|
242
|
+
item = dict(system)
|
|
243
|
+
self.context.append(item)
|
|
244
|
+
self.hydrated_context.append(Message.model_validate(hydrate(item, self.directory)))
|
|
245
|
+
summary = entry.get("summary")
|
|
246
|
+
if isinstance(summary, dict):
|
|
247
|
+
item = dict(summary)
|
|
248
|
+
self.context.append(item)
|
|
249
|
+
self.hydrated_context.append(Message.model_validate(hydrate(item, self.directory)))
|
|
250
|
+
elif isinstance(summary, str) and summary:
|
|
251
|
+
item = {"role": str(entry.get("summary_role") or "user"), "content": summary}
|
|
252
|
+
self.context.append(item)
|
|
253
|
+
self.hydrated_context.append(Message.model_validate(item))
|
|
254
|
+
|
|
255
|
+
def _note_checkpoint(self, entry: Dict[str, Any]) -> None:
|
|
256
|
+
if entry.get("type") == "checkpoint":
|
|
257
|
+
self.latest_checkpoint = entry
|
|
258
|
+
|
|
259
|
+
def _note_child(self, entry: Dict[str, Any]) -> None:
|
|
260
|
+
kind = entry.get("type")
|
|
261
|
+
if kind == "spawn":
|
|
262
|
+
child_id = str(entry.get("child_id") or "")
|
|
263
|
+
if not child_id:
|
|
264
|
+
return
|
|
265
|
+
metadata = dict(entry.get("metadata") or {})
|
|
266
|
+
self.children[child_id] = {
|
|
267
|
+
**metadata,
|
|
268
|
+
"status": str(entry.get("status") or "running"),
|
|
269
|
+
"output_text": str(entry.get("output_text") or ""),
|
|
270
|
+
}
|
|
271
|
+
if child_id not in self.child_order:
|
|
272
|
+
self.child_order.append(child_id)
|
|
273
|
+
elif kind == "spawn_status":
|
|
274
|
+
child_id = str(entry.get("child_id") or "")
|
|
275
|
+
child = self.children.get(child_id)
|
|
276
|
+
if child is None:
|
|
277
|
+
return
|
|
278
|
+
child["status"] = str(entry.get("status") or child["status"])
|
|
279
|
+
child["output_text"] = str(entry.get("output_text") or child.get("output_text") or "")
|
|
280
|
+
|
|
281
|
+
def _note_listing(self, entry: Dict[str, Any]) -> None:
|
|
282
|
+
if entry.get("type") == "spawn":
|
|
283
|
+
child_session = str(entry.get("session_id") or "")
|
|
284
|
+
if child_session:
|
|
285
|
+
self.child_ids.add(child_session)
|
|
286
|
+
payload = JsonlPersistence._message_payload_from_entry(entry)
|
|
287
|
+
if payload is None:
|
|
288
|
+
return
|
|
289
|
+
self.message_count += 1
|
|
290
|
+
if self.first_message:
|
|
291
|
+
return
|
|
292
|
+
text = text_from_content(payload.get("content")).strip()
|
|
293
|
+
if payload.get("role") == "user" and text and not text.startswith(COMPACTED_CONTEXT_MARK):
|
|
294
|
+
self.first_message = text
|
|
295
|
+
|
|
296
|
+
def context_payloads(self) -> List[Dict[str, Any]]:
|
|
297
|
+
if self.latest_compaction is None:
|
|
298
|
+
return list(self.messages)
|
|
299
|
+
return list(self.context)
|
|
300
|
+
|
|
301
|
+
def listing(self, size: int) -> Dict[str, Any]:
|
|
302
|
+
return {
|
|
303
|
+
"version": 1,
|
|
304
|
+
"transcript_size": size,
|
|
305
|
+
"message_count": self.message_count,
|
|
306
|
+
"first_message": self.first_message,
|
|
307
|
+
"child_ids": sorted(self.child_ids),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class JsonlPersistence:
|
|
312
|
+
"""Append-only JSONL store for harness conversations, checkpoints, and journal."""
|
|
313
|
+
|
|
314
|
+
def __init__(self, root: Union[str, Path]) -> None:
|
|
315
|
+
self.root = Path(root).expanduser().resolve()
|
|
316
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
self._lock = threading.Lock()
|
|
318
|
+
self._indexes: Dict[str, _SessionIndex] = {}
|
|
319
|
+
self._listing_sizes: Dict[str, int] = {}
|
|
320
|
+
self._held: Dict[str, list[int]] = {}
|
|
321
|
+
# Product that owns new sessions ("tui", "zeron", ...). Empty means unknown.
|
|
322
|
+
self.client: str = ""
|
|
323
|
+
# Product fields (goal, todo, live background jobs) merged into every
|
|
324
|
+
# checkpoint so an interrupted run can be continued later.
|
|
325
|
+
self.checkpoint_metadata: Callable[[], Dict[str, Any]] = dict
|
|
326
|
+
|
|
327
|
+
def session_dir(self, session_id: str) -> Path:
|
|
328
|
+
"""Return a validated session artifact directory (without creating it)."""
|
|
329
|
+
if session_id in {".", ".."} or not _SESSION_ID.fullmatch(session_id):
|
|
330
|
+
raise ValueError(f"invalid session_id: {session_id!r}")
|
|
331
|
+
return safe_path(self.root, session_id)
|
|
332
|
+
|
|
333
|
+
def _path(self, session_id: str) -> Path:
|
|
334
|
+
return safe_path(self.session_dir(session_id), "transcript.jsonl")
|
|
335
|
+
|
|
336
|
+
def _acquire_session(self, session_id: str) -> None:
|
|
337
|
+
held = self._held.get(session_id)
|
|
338
|
+
if held is not None:
|
|
339
|
+
held[1] += 1
|
|
340
|
+
return
|
|
341
|
+
directory = self.session_dir(session_id)
|
|
342
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
lock_path = safe_path(directory, ".lock")
|
|
344
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
|
|
345
|
+
try:
|
|
346
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
347
|
+
except BaseException:
|
|
348
|
+
os.close(fd)
|
|
349
|
+
raise
|
|
350
|
+
self._held[session_id] = [fd, 1]
|
|
351
|
+
|
|
352
|
+
def _release_session(self, session_id: str) -> None:
|
|
353
|
+
held = self._held[session_id]
|
|
354
|
+
held[1] -= 1
|
|
355
|
+
if held[1] == 0:
|
|
356
|
+
fd = held[0]
|
|
357
|
+
del self._held[session_id]
|
|
358
|
+
try:
|
|
359
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
360
|
+
finally:
|
|
361
|
+
os.close(fd)
|
|
362
|
+
|
|
363
|
+
@contextmanager
|
|
364
|
+
def _session_lock(self, session_id: str) -> Iterator[None]:
|
|
365
|
+
"""Exclusive cross-process lock for migrate and append."""
|
|
366
|
+
self._acquire_session(session_id)
|
|
367
|
+
try:
|
|
368
|
+
yield
|
|
369
|
+
finally:
|
|
370
|
+
self._release_session(session_id)
|
|
371
|
+
|
|
372
|
+
def _metadata_path(self, session_id: str) -> Path:
|
|
373
|
+
return safe_path(self.session_dir(session_id), "metadata.json")
|
|
374
|
+
|
|
375
|
+
def _read_metadata(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
376
|
+
path = self._metadata_path(session_id)
|
|
377
|
+
if not path.exists():
|
|
378
|
+
return None
|
|
379
|
+
try:
|
|
380
|
+
saved = json.loads(path.read_text(encoding="utf-8"))
|
|
381
|
+
except (OSError, ValueError) as exc:
|
|
382
|
+
raise ValueError(f"session bundle metadata unreadable: {session_id}") from exc
|
|
383
|
+
if not isinstance(saved, dict):
|
|
384
|
+
raise ValueError(f"session bundle metadata unreadable: {session_id}")
|
|
385
|
+
return saved
|
|
386
|
+
|
|
387
|
+
def _require_version(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
388
|
+
"""Refuse a bundle whose version is missing or not 1."""
|
|
389
|
+
meta = self._read_metadata(session_id)
|
|
390
|
+
transcript = self._path(session_id)
|
|
391
|
+
has_body = transcript.is_file() and not transcript.is_symlink() and transcript.stat().st_size > 0
|
|
392
|
+
if meta is None:
|
|
393
|
+
if has_body:
|
|
394
|
+
raise ValueError(f"session bundle version missing: {session_id}")
|
|
395
|
+
return None
|
|
396
|
+
if meta.get("version") != 1:
|
|
397
|
+
raise ValueError(f"unsupported session bundle version: {meta.get('version')!r}")
|
|
398
|
+
return meta
|
|
399
|
+
|
|
400
|
+
def _write_metadata(self, session_id: str, metadata: Dict[str, Any]) -> None:
|
|
401
|
+
snapshot(self._metadata_path(session_id), {**metadata, "version": 1, "session_id": session_id})
|
|
402
|
+
|
|
403
|
+
def _ensure_metadata(self, session_id: str) -> Dict[str, Any]:
|
|
404
|
+
meta = self._require_version(session_id)
|
|
405
|
+
if meta is not None:
|
|
406
|
+
return meta
|
|
407
|
+
created = _utc_now()
|
|
408
|
+
meta = {
|
|
409
|
+
"version": 1,
|
|
410
|
+
"session_id": session_id,
|
|
411
|
+
"created_at": created,
|
|
412
|
+
"updated_at": created,
|
|
413
|
+
"client": self.client or "",
|
|
414
|
+
}
|
|
415
|
+
self._write_metadata(session_id, meta)
|
|
416
|
+
return meta
|
|
417
|
+
|
|
418
|
+
def _legacy_path(self, session_id: str) -> Path:
|
|
419
|
+
return safe_path(self.root, f"{session_id}.jsonl")
|
|
420
|
+
|
|
421
|
+
def _mark(self, path: Path) -> int:
|
|
422
|
+
if not path.is_file() or path.is_symlink():
|
|
423
|
+
return -1
|
|
424
|
+
return path.stat().st_size
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _parse_legacy(raw: bytes) -> tuple[int, List[Dict[str, Any]]]:
|
|
428
|
+
skipped = 0
|
|
429
|
+
entries: List[Dict[str, Any]] = []
|
|
430
|
+
for raw_line in raw.decode("utf-8", errors="replace").splitlines():
|
|
431
|
+
line = raw_line.strip()
|
|
432
|
+
if not line:
|
|
433
|
+
continue
|
|
434
|
+
try:
|
|
435
|
+
item = json.loads(line)
|
|
436
|
+
except json.JSONDecodeError:
|
|
437
|
+
skipped += 1
|
|
438
|
+
continue
|
|
439
|
+
if isinstance(item, dict):
|
|
440
|
+
entries.append(item)
|
|
441
|
+
else:
|
|
442
|
+
skipped += 1
|
|
443
|
+
return skipped, entries
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _strip_imported_jobs(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
447
|
+
"""Drop background job pids that arrived in an unvalidated legacy file."""
|
|
448
|
+
if entry.get("type") != "checkpoint":
|
|
449
|
+
return entry
|
|
450
|
+
metadata = entry.get("metadata")
|
|
451
|
+
if not isinstance(metadata, dict) or "background_jobs" not in metadata:
|
|
452
|
+
return entry
|
|
453
|
+
return {**entry, "metadata": {key: value for key, value in metadata.items() if key != "background_jobs"}}
|
|
454
|
+
|
|
455
|
+
def _quarantine_legacy(self, session_id: str, raw: bytes, skipped: int) -> None:
|
|
456
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
457
|
+
dest = safe_path(self.session_dir(session_id), "quarantine", f"{digest}.jsonl")
|
|
458
|
+
if not dest.is_file():
|
|
459
|
+
publish(dest, raw)
|
|
460
|
+
meta = self._read_metadata(session_id) or {
|
|
461
|
+
"version": 1, "session_id": session_id, "created_at": _utc_now(),
|
|
462
|
+
}
|
|
463
|
+
meta["quarantine"] = {"sha256": digest, "skipped_lines": skipped, "bytes": len(raw)}
|
|
464
|
+
self._write_metadata(session_id, meta)
|
|
465
|
+
|
|
466
|
+
def _remember_legacy(self, session_id: str, raw: bytes) -> None:
|
|
467
|
+
meta = self._ensure_metadata(session_id)
|
|
468
|
+
meta["legacy_import"] = {"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
|
|
469
|
+
self._write_metadata(session_id, meta)
|
|
470
|
+
|
|
471
|
+
@staticmethod
|
|
472
|
+
def _entry_key(entry: Dict[str, Any]) -> tuple[Any, ...]:
|
|
473
|
+
seq = entry.get("seq")
|
|
474
|
+
if isinstance(seq, int) and not isinstance(seq, bool):
|
|
475
|
+
return ("seq", entry.get("type"), seq)
|
|
476
|
+
return ("json", json.dumps(entry, sort_keys=True, ensure_ascii=False))
|
|
477
|
+
|
|
478
|
+
def _encode(self, entries: List[Dict[str, Any]]) -> bytes:
|
|
479
|
+
return "".join(json.dumps(item, ensure_ascii=False) + "\n" for item in entries).encode("utf-8")
|
|
480
|
+
|
|
481
|
+
def _publish_transcript(self, session_id: str, entries: List[Dict[str, Any]]) -> None:
|
|
482
|
+
"""Publish a transcript only while it is still empty. Never replace appended bytes."""
|
|
483
|
+
path = self._path(session_id)
|
|
484
|
+
if path.is_symlink():
|
|
485
|
+
raise ValueError(f"unsafe artifact symlink: {path}")
|
|
486
|
+
if path.is_file() and path.stat().st_size > 0:
|
|
487
|
+
return
|
|
488
|
+
self._ensure_metadata(session_id)
|
|
489
|
+
publish(path, self._encode(entries))
|
|
490
|
+
self._indexes.pop(session_id, None)
|
|
491
|
+
|
|
492
|
+
def _sync_legacy(self, session_id: str) -> None:
|
|
493
|
+
"""Fold a newer flat JSONL into the bundle. Do not replace an appended transcript."""
|
|
494
|
+
legacy = self._legacy_path(session_id)
|
|
495
|
+
if not legacy.exists():
|
|
496
|
+
return
|
|
497
|
+
if legacy.is_symlink() or not legacy.is_file():
|
|
498
|
+
return
|
|
499
|
+
raw = legacy.read_bytes()
|
|
500
|
+
path = self._path(session_id)
|
|
501
|
+
transcript_size = path.stat().st_size if path.is_file() and not path.is_symlink() else 0
|
|
502
|
+
meta = self._read_metadata(session_id)
|
|
503
|
+
if transcript_size > 0:
|
|
504
|
+
if meta is None or meta.get("version") != 1:
|
|
505
|
+
raise ValueError(
|
|
506
|
+
f"session bundle version missing: {session_id}" if meta is None
|
|
507
|
+
else f"unsupported session bundle version: {meta.get('version')!r}"
|
|
508
|
+
)
|
|
509
|
+
recorded = meta.get("legacy_import") if isinstance(meta, dict) else None
|
|
510
|
+
if not isinstance(recorded, dict) or not isinstance(recorded.get("bytes"), int):
|
|
511
|
+
if transcript_size <= 0:
|
|
512
|
+
self._import_legacy_bytes(session_id, raw, raw)
|
|
513
|
+
elif legacy.stat().st_mtime > path.stat().st_mtime:
|
|
514
|
+
self._import_legacy_bytes(session_id, raw, raw, only_missing=True)
|
|
515
|
+
else:
|
|
516
|
+
self._remember_legacy(session_id, raw)
|
|
517
|
+
return
|
|
518
|
+
cursor = recorded["bytes"]
|
|
519
|
+
if cursor < 0 or cursor > len(raw):
|
|
520
|
+
self._note_rejected_legacy(session_id, raw)
|
|
521
|
+
return
|
|
522
|
+
prefix = raw[:cursor]
|
|
523
|
+
if hashlib.sha256(prefix).hexdigest() != recorded.get("sha256"):
|
|
524
|
+
self._note_rejected_legacy(session_id, raw)
|
|
525
|
+
return
|
|
526
|
+
if len(raw) == cursor:
|
|
527
|
+
return
|
|
528
|
+
self._import_legacy_bytes(session_id, raw, raw[cursor:])
|
|
529
|
+
|
|
530
|
+
def _note_rejected_legacy(self, session_id: str, raw: bytes) -> None:
|
|
531
|
+
skipped, _entries = self._parse_legacy(raw)
|
|
532
|
+
if skipped:
|
|
533
|
+
self._quarantine_legacy(session_id, raw, skipped)
|
|
534
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
535
|
+
meta = self._ensure_metadata(session_id)
|
|
536
|
+
legacy_import = dict(meta.get("legacy_import") or {})
|
|
537
|
+
legacy_import["rejected_sha256"] = digest
|
|
538
|
+
meta["legacy_import"] = legacy_import
|
|
539
|
+
self._write_metadata(session_id, meta)
|
|
540
|
+
|
|
541
|
+
def _import_legacy_bytes(
|
|
542
|
+
self, session_id: str, raw: bytes, chunk: bytes, *, only_missing: bool = False
|
|
543
|
+
) -> None:
|
|
544
|
+
skipped, entries = self._parse_legacy(chunk)
|
|
545
|
+
if skipped:
|
|
546
|
+
self._quarantine_legacy(session_id, raw, skipped)
|
|
547
|
+
entries = [self._strip_imported_jobs(entry) for entry in entries]
|
|
548
|
+
entries = archive(entries, self.session_dir(session_id))
|
|
549
|
+
path = self._path(session_id)
|
|
550
|
+
transcript_size = path.stat().st_size if path.is_file() and not path.is_symlink() else 0
|
|
551
|
+
if transcript_size <= 0 and not only_missing:
|
|
552
|
+
self._publish_transcript(session_id, entries)
|
|
553
|
+
elif entries:
|
|
554
|
+
if only_missing:
|
|
555
|
+
existing = {self._entry_key(entry) for entry in self._load_transcript(path)}
|
|
556
|
+
entries = [entry for entry in entries if self._entry_key(entry) not in existing]
|
|
557
|
+
if entries:
|
|
558
|
+
self._append_entries(session_id, entries)
|
|
559
|
+
self._remember_legacy(session_id, raw)
|
|
560
|
+
|
|
561
|
+
def _load_transcript(self, path: Path) -> List[Dict[str, Any]]:
|
|
562
|
+
if not path.is_file() or path.is_symlink():
|
|
563
|
+
return []
|
|
564
|
+
entries: List[Dict[str, Any]] = []
|
|
565
|
+
with path.open(encoding="utf-8") as handle:
|
|
566
|
+
for raw in handle:
|
|
567
|
+
line = raw.strip()
|
|
568
|
+
if not line:
|
|
569
|
+
continue
|
|
570
|
+
try:
|
|
571
|
+
item = json.loads(line)
|
|
572
|
+
except json.JSONDecodeError:
|
|
573
|
+
continue
|
|
574
|
+
if isinstance(item, dict):
|
|
575
|
+
entries.append(item)
|
|
576
|
+
return entries
|
|
577
|
+
|
|
578
|
+
def _artifacts(
|
|
579
|
+
self, session_id: str, entries: List[Dict[str, Any]], *, updated: bool = True
|
|
580
|
+
) -> None:
|
|
581
|
+
"""Publish metadata, compaction and checkpoint snapshots for ``entries``.
|
|
582
|
+
|
|
583
|
+
``updated`` is the append path: ``entries`` are the lines just written
|
|
584
|
+
and every artifact is published. Otherwise ``entries`` is a whole
|
|
585
|
+
journal that was just read, and only artifacts that are missing or
|
|
586
|
+
differ from what the journal implies are rewritten (crash repair).
|
|
587
|
+
"""
|
|
588
|
+
write = snapshot if updated else refresh_snapshot
|
|
589
|
+
metadata = self._require_version(session_id) or {}
|
|
590
|
+
directory = self.session_dir(session_id)
|
|
591
|
+
created = next((item.get("created_at") for item in entries
|
|
592
|
+
if item.get("type") == "header"), None) or _utc_now()
|
|
593
|
+
write(self._metadata_path(session_id), {
|
|
594
|
+
**metadata, "version": 1, "session_id": session_id,
|
|
595
|
+
"created_at": metadata.get("created_at") or created,
|
|
596
|
+
"updated_at": _utc_now() if updated else metadata.get("updated_at", created),
|
|
597
|
+
})
|
|
598
|
+
checkpoint: Optional[Dict[str, Any]] = None
|
|
599
|
+
for entry in entries:
|
|
600
|
+
if entry.get("type") in {"compaction", "compacted"}:
|
|
601
|
+
seq = entry.get("seq", 0)
|
|
602
|
+
if isinstance(seq, bool) or not isinstance(seq, int):
|
|
603
|
+
continue
|
|
604
|
+
write(safe_path(directory, "compactions", f"{seq}.json"), {**entry, "version": 1})
|
|
605
|
+
elif entry.get("type") == "checkpoint":
|
|
606
|
+
# Each checkpoint replaces the file; only the last one survives.
|
|
607
|
+
checkpoint = entry
|
|
608
|
+
if checkpoint is not None:
|
|
609
|
+
write(safe_path(directory, "checkpoint.json"),
|
|
610
|
+
{key: value for key, value in {**checkpoint, "version": 1}.items()
|
|
611
|
+
if key != "messages"})
|
|
612
|
+
|
|
613
|
+
def _read_entries(self, session_id: str) -> List[Dict[str, Any]]:
|
|
614
|
+
"""The journal, parsed once per process and then kept current by appends.
|
|
615
|
+
|
|
616
|
+
The transcript only grows, and every append in this process updates
|
|
617
|
+
the cache and its size mark under the session lock. A size that still
|
|
618
|
+
matches the mark means nothing else wrote, so the cached list is
|
|
619
|
+
returned without re-reading or republishing artifacts. Any other size
|
|
620
|
+
(another process appended, a torn write, first use) re-reads the file
|
|
621
|
+
and repairs the artifacts.
|
|
622
|
+
"""
|
|
623
|
+
with self._session_lock(session_id):
|
|
624
|
+
self._sync_legacy(session_id)
|
|
625
|
+
self._require_version(session_id)
|
|
626
|
+
path = self._path(session_id)
|
|
627
|
+
mark = self._mark(path)
|
|
628
|
+
index = self._indexes.get(session_id)
|
|
629
|
+
if index is not None and index.size == mark:
|
|
630
|
+
return index.entries
|
|
631
|
+
entries = self._load_transcript(path) if mark >= 0 else []
|
|
632
|
+
if mark >= 0:
|
|
633
|
+
self._artifacts(session_id, entries, updated=False)
|
|
634
|
+
index = self._indexes.setdefault(session_id, _SessionIndex(self.session_dir(session_id)))
|
|
635
|
+
index.reset(entries, mark)
|
|
636
|
+
return entries
|
|
637
|
+
|
|
638
|
+
def _cached_entries(self, session_id: str) -> List[Dict[str, Any]]:
|
|
639
|
+
"""Return the journal without reading it when its cached size is current."""
|
|
640
|
+
path = self._path(session_id)
|
|
641
|
+
mark = self._mark(path)
|
|
642
|
+
index = self._indexes.get(session_id)
|
|
643
|
+
if index is not None and index.size == mark:
|
|
644
|
+
return index.entries
|
|
645
|
+
return self._read_entries(session_id)
|
|
646
|
+
|
|
647
|
+
def _stamp_client(self, session_id: str) -> None:
|
|
648
|
+
"""Record the product once. A later client must not overwrite it."""
|
|
649
|
+
client = self.client or ""
|
|
650
|
+
if not client:
|
|
651
|
+
return
|
|
652
|
+
meta = self._ensure_metadata(session_id)
|
|
653
|
+
if meta.get("client"):
|
|
654
|
+
return
|
|
655
|
+
self._write_metadata(session_id, {**meta, "client": client, "updated_at": _utc_now()})
|
|
656
|
+
|
|
657
|
+
def _append_entries(self, session_id: str, entries: List[Dict[str, Any]]) -> None:
|
|
658
|
+
if not entries:
|
|
659
|
+
return
|
|
660
|
+
with self._session_lock(session_id):
|
|
661
|
+
self._stamp_client(session_id)
|
|
662
|
+
self._ensure_metadata(session_id)
|
|
663
|
+
path = self._path(session_id)
|
|
664
|
+
mark = self._mark(path)
|
|
665
|
+
index = self._indexes.get(session_id)
|
|
666
|
+
if index is None or index.size != mark:
|
|
667
|
+
index = self._indexes.setdefault(session_id, _SessionIndex(self.session_dir(session_id)))
|
|
668
|
+
index.reset(self._load_transcript(path) if mark >= 0 else [], mark)
|
|
669
|
+
# A crash mid-append leaves a torn last line. Start on a fresh line
|
|
670
|
+
# so the fragment stays one skipped line and does not swallow this
|
|
671
|
+
# record. This costs a one-byte read per append.
|
|
672
|
+
separator = b"" if self._ends_with_newline(path, mark) else b"\n"
|
|
673
|
+
entries = archive(entries, self.session_dir(session_id))
|
|
674
|
+
append_bytes(path, separator + self._encode(entries))
|
|
675
|
+
index.extend(entries, path.stat().st_size)
|
|
676
|
+
self._artifacts(session_id, entries)
|
|
677
|
+
self._refresh_listing(session_id)
|
|
678
|
+
|
|
679
|
+
@staticmethod
|
|
680
|
+
def _ends_with_newline(path: Path, size: int) -> bool:
|
|
681
|
+
if size <= 0:
|
|
682
|
+
return True
|
|
683
|
+
with path.open("rb") as handle:
|
|
684
|
+
handle.seek(size - 1)
|
|
685
|
+
return handle.read(1) == b"\n"
|
|
686
|
+
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _max_seq(entries: List[Dict[str, Any]]) -> int:
|
|
689
|
+
return max((_entry_seq(entry) for entry in entries), default=0)
|
|
690
|
+
|
|
691
|
+
def _next_seq(self, session_id: str) -> int:
|
|
692
|
+
"""One past the highest seq in the cached journal of ``session_id``."""
|
|
693
|
+
return self._indexes[session_id].high_seq + 1
|
|
694
|
+
|
|
695
|
+
def _header(self, session_id: str) -> Dict[str, Any]:
|
|
696
|
+
return {"type": "header", "session_id": session_id, "seq": 1, "created_at": _utc_now()}
|
|
697
|
+
|
|
698
|
+
def _message_entry(self, payload: Dict[str, Any], *, seq: int) -> Dict[str, Any]:
|
|
699
|
+
role = str(payload.get("role") or "message")
|
|
700
|
+
event_type = "tool_result" if role == "tool" else role
|
|
701
|
+
return {"type": "message", "event_type": event_type, "seq": seq, "message": payload, **payload}
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def _message_payload_from_entry(entry: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
705
|
+
if entry.get("type") == "message": # legacy JSONL
|
|
706
|
+
return {field: entry.get(field) for field in _MESSAGE_FIELDS}
|
|
707
|
+
if entry.get("type") in {"system", "user", "assistant", "tool_result"}:
|
|
708
|
+
return {field: entry.get(field) for field in _MESSAGE_FIELDS}
|
|
709
|
+
payload = entry.get("message")
|
|
710
|
+
if isinstance(payload, dict):
|
|
711
|
+
return {field: payload.get(field) for field in _MESSAGE_FIELDS}
|
|
712
|
+
return None
|
|
713
|
+
|
|
714
|
+
def _message_payload(self, message: Message) -> Dict[str, Any]:
|
|
715
|
+
dumped = message.model_dump()
|
|
716
|
+
return {field: dumped.get(field) for field in _MESSAGE_FIELDS}
|
|
717
|
+
|
|
718
|
+
def _canonical_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
719
|
+
return {field: payload.get(field) for field in _MESSAGE_FIELDS}
|
|
720
|
+
|
|
721
|
+
def _is_compact_mark(self, payload: Dict[str, Any]) -> bool:
|
|
722
|
+
content = payload.get("content")
|
|
723
|
+
text = content if isinstance(content, str) else text_from_content(content or "")
|
|
724
|
+
return text.startswith(COMPACTED_CONTEXT_MARK)
|
|
725
|
+
|
|
726
|
+
def _is_compact_rewrite(
|
|
727
|
+
self,
|
|
728
|
+
stored: List[Dict[str, Any]],
|
|
729
|
+
incoming: List[Dict[str, Any]],
|
|
730
|
+
) -> bool:
|
|
731
|
+
"""True only when the runtime conversation actually dropped history."""
|
|
732
|
+
if len(incoming) < len(stored):
|
|
733
|
+
return True
|
|
734
|
+
stored_marks = [item for item in stored if self._is_compact_mark(item)]
|
|
735
|
+
incoming_marks = [item for item in incoming if self._is_compact_mark(item)]
|
|
736
|
+
return bool(incoming_marks) and (
|
|
737
|
+
not stored_marks or stored_marks[-1] != incoming_marks[-1]
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
def _messages_from(self, entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
741
|
+
"""Build the model context; never use this for rendering history."""
|
|
742
|
+
dumped: List[Dict[str, Any]] = []
|
|
743
|
+
latest = next(
|
|
744
|
+
(
|
|
745
|
+
entry
|
|
746
|
+
for entry in reversed(entries)
|
|
747
|
+
if entry.get("type") in {"compaction", "compacted"}
|
|
748
|
+
),
|
|
749
|
+
None,
|
|
750
|
+
)
|
|
751
|
+
if latest is None:
|
|
752
|
+
return [payload for entry in entries
|
|
753
|
+
if (payload := self._message_payload_from_entry(entry)) is not None]
|
|
754
|
+
|
|
755
|
+
system = latest.get("system")
|
|
756
|
+
if not isinstance(system, dict):
|
|
757
|
+
system = next((entry.get("message") for entry in entries
|
|
758
|
+
if entry.get("type") == "message"
|
|
759
|
+
and entry.get("message", {}).get("role") == "system"), None)
|
|
760
|
+
if isinstance(system, dict):
|
|
761
|
+
dumped.append(dict(system))
|
|
762
|
+
summary = latest.get("summary")
|
|
763
|
+
if isinstance(summary, dict):
|
|
764
|
+
dumped.append(dict(summary))
|
|
765
|
+
elif isinstance(summary, str) and summary:
|
|
766
|
+
dumped.append({
|
|
767
|
+
"role": str(latest.get("summary_role") or "user"),
|
|
768
|
+
"content": summary,
|
|
769
|
+
})
|
|
770
|
+
through = int(latest.get("through_seq", 0) or 0)
|
|
771
|
+
for entry in entries:
|
|
772
|
+
try:
|
|
773
|
+
seq = int(entry.get("seq", 0))
|
|
774
|
+
except (TypeError, ValueError):
|
|
775
|
+
seq = 0
|
|
776
|
+
if seq <= through:
|
|
777
|
+
continue
|
|
778
|
+
payload = self._message_payload_from_entry(entry)
|
|
779
|
+
if payload is not None:
|
|
780
|
+
dumped.append(payload)
|
|
781
|
+
return dumped
|
|
782
|
+
|
|
783
|
+
def _listing_path(self, session_id: str) -> Path:
|
|
784
|
+
return safe_path(self.session_dir(session_id), "listing.json")
|
|
785
|
+
|
|
786
|
+
def _listing_from_entries(self, entries: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
|
787
|
+
"""Extract the few fields the session picker displays."""
|
|
788
|
+
message_count = 0
|
|
789
|
+
first_message = ""
|
|
790
|
+
child_ids: set[str] = set()
|
|
791
|
+
for entry in entries:
|
|
792
|
+
if entry.get("type") == "spawn":
|
|
793
|
+
child_session = str(entry.get("session_id") or "")
|
|
794
|
+
if child_session:
|
|
795
|
+
child_ids.add(child_session)
|
|
796
|
+
payload = self._message_payload_from_entry(entry)
|
|
797
|
+
if payload is None:
|
|
798
|
+
continue
|
|
799
|
+
message_count += 1
|
|
800
|
+
if first_message:
|
|
801
|
+
continue
|
|
802
|
+
text = text_from_content(payload.get("content")).strip()
|
|
803
|
+
if payload.get("role") == "user" and text and not text.startswith(COMPACTED_CONTEXT_MARK):
|
|
804
|
+
first_message = text
|
|
805
|
+
return {
|
|
806
|
+
"version": 1,
|
|
807
|
+
"transcript_size": 0,
|
|
808
|
+
"message_count": message_count,
|
|
809
|
+
"first_message": first_message,
|
|
810
|
+
"child_ids": sorted(child_ids),
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
def _read_listing(self, session_id: str, transcript_size: int) -> Optional[Dict[str, Any]]:
|
|
814
|
+
path = self._listing_path(session_id)
|
|
815
|
+
try:
|
|
816
|
+
saved = json.loads(path.read_text(encoding="utf-8"))
|
|
817
|
+
except (OSError, ValueError):
|
|
818
|
+
return None
|
|
819
|
+
if (
|
|
820
|
+
isinstance(saved, dict)
|
|
821
|
+
and saved.get("version") == 1
|
|
822
|
+
and saved.get("transcript_size") == transcript_size
|
|
823
|
+
and isinstance(saved.get("message_count"), int)
|
|
824
|
+
and isinstance(saved.get("first_message"), str)
|
|
825
|
+
and isinstance(saved.get("child_ids"), list)
|
|
826
|
+
):
|
|
827
|
+
return saved
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
def _refresh_listing(self, session_id: str) -> Dict[str, Any]:
|
|
831
|
+
index = self._indexes[session_id]
|
|
832
|
+
size = index.size
|
|
833
|
+
if self._listing_sizes.get(session_id) == size:
|
|
834
|
+
saved = self._read_listing(session_id, size)
|
|
835
|
+
if saved is not None:
|
|
836
|
+
return saved
|
|
837
|
+
listing = index.listing(size)
|
|
838
|
+
refresh_snapshot(self._listing_path(session_id), listing)
|
|
839
|
+
self._listing_sizes[session_id] = size
|
|
840
|
+
return listing
|
|
841
|
+
|
|
842
|
+
def _session_listing(self, session_id: str) -> Dict[str, Any]:
|
|
843
|
+
size = self._mark(self._path(session_id))
|
|
844
|
+
index = self._indexes.get(session_id)
|
|
845
|
+
if index is not None and index.size == size:
|
|
846
|
+
return self._refresh_listing(session_id)
|
|
847
|
+
saved = self._read_listing(session_id, size)
|
|
848
|
+
if saved is not None:
|
|
849
|
+
return saved
|
|
850
|
+
self._read_entries(session_id)
|
|
851
|
+
return self._refresh_listing(session_id)
|
|
852
|
+
|
|
853
|
+
def _transcript_messages(self, entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
854
|
+
"""Return every persisted message, including messages before compaction."""
|
|
855
|
+
return [payload for entry in entries
|
|
856
|
+
if (payload := self._message_payload_from_entry(entry)) is not None]
|
|
857
|
+
|
|
858
|
+
async def append_event(self, *, event_type: str, payload: dict[str, Any]) -> None:
|
|
859
|
+
if event_type in _SKIP_EVENTS:
|
|
860
|
+
return
|
|
861
|
+
session_id = str(payload.get("session_id") or "")
|
|
862
|
+
if not session_id:
|
|
863
|
+
return
|
|
864
|
+
with self._lock, self._session_lock(session_id):
|
|
865
|
+
entries = self._cached_entries(session_id)
|
|
866
|
+
index = self._indexes[session_id]
|
|
867
|
+
run_id = payload.get("run_id")
|
|
868
|
+
seq = payload.get("seq")
|
|
869
|
+
if run_id and isinstance(seq, int) and (str(run_id), seq) in index.event_keys:
|
|
870
|
+
return
|
|
871
|
+
outgoing: List[Dict[str, Any]] = []
|
|
872
|
+
if not entries:
|
|
873
|
+
outgoing.append(self._header(session_id))
|
|
874
|
+
outgoing.append(
|
|
875
|
+
{
|
|
876
|
+
"type": event_type,
|
|
877
|
+
"event_type": event_type,
|
|
878
|
+
"seq": seq if isinstance(seq, int) else self._next_seq(session_id),
|
|
879
|
+
"payload": dict(payload),
|
|
880
|
+
}
|
|
881
|
+
)
|
|
882
|
+
if event_type == "agent_spawned":
|
|
883
|
+
outgoing.append(
|
|
884
|
+
{
|
|
885
|
+
"type": "spawn",
|
|
886
|
+
"child_id": payload.get("child_id"),
|
|
887
|
+
"parent_id": payload.get("parent_id") or payload.get("agent_id"),
|
|
888
|
+
"parent_session_id": payload.get("session_id"),
|
|
889
|
+
"session_id": payload.get("child_session_id"),
|
|
890
|
+
"status": "running",
|
|
891
|
+
"output_text": "",
|
|
892
|
+
"metadata": dict(payload),
|
|
893
|
+
}
|
|
894
|
+
)
|
|
895
|
+
elif event_type in {"agent_completed", "agent_failed"}:
|
|
896
|
+
status = "completed" if event_type == "agent_completed" else (
|
|
897
|
+
"cancelled" if payload.get("error_type") == "HarnessCancelled" else "failed"
|
|
898
|
+
)
|
|
899
|
+
outgoing.append(
|
|
900
|
+
{
|
|
901
|
+
"type": "spawn_status",
|
|
902
|
+
"child_id": payload.get("child_id"),
|
|
903
|
+
"status": status,
|
|
904
|
+
"output_text": payload.get("output_text") or payload.get("message") or "",
|
|
905
|
+
}
|
|
906
|
+
)
|
|
907
|
+
self._append_entries(session_id, outgoing)
|
|
908
|
+
if event_type in TERMINAL_EVENT_TYPES:
|
|
909
|
+
self._append_collection_overlays(
|
|
910
|
+
session_id,
|
|
911
|
+
run_id=str(run_id) if run_id else None,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
async def collect_run_events(
|
|
915
|
+
self,
|
|
916
|
+
*,
|
|
917
|
+
session_id: str,
|
|
918
|
+
run_id: str | None = None,
|
|
919
|
+
keys: Sequence[tuple[str, int]] | None = None,
|
|
920
|
+
) -> list[tuple[str, int]]:
|
|
921
|
+
"""Tag mid-run events as collected without rewriting historical lines.
|
|
922
|
+
|
|
923
|
+
Appends one ``collected`` overlay per untagged event. The original
|
|
924
|
+
journal entry stays as-is; ``load_events`` overlays ``collected: true``.
|
|
925
|
+
"""
|
|
926
|
+
if not session_id:
|
|
927
|
+
return []
|
|
928
|
+
with self._lock, self._session_lock(session_id):
|
|
929
|
+
self._read_entries(session_id)
|
|
930
|
+
return self._append_collection_overlays(session_id, run_id=run_id, keys=keys)
|
|
931
|
+
|
|
932
|
+
def _append_collection_overlays(
|
|
933
|
+
self,
|
|
934
|
+
session_id: str,
|
|
935
|
+
*,
|
|
936
|
+
run_id: str | None = None,
|
|
937
|
+
keys: Sequence[tuple[str, int]] | None = None,
|
|
938
|
+
) -> list[tuple[str, int]]:
|
|
939
|
+
index = self._indexes[session_id]
|
|
940
|
+
selected = run_id or index.latest_terminal_run
|
|
941
|
+
targets = list(keys) if keys is not None else index.collectable.get(selected, [])
|
|
942
|
+
pending = [key for key in targets if key not in index.collected]
|
|
943
|
+
if not pending:
|
|
944
|
+
return []
|
|
945
|
+
next_seq = index.high_seq + 1
|
|
946
|
+
overlays: List[Dict[str, Any]] = []
|
|
947
|
+
for offset, (event_run_id, event_seq) in enumerate(pending):
|
|
948
|
+
overlays.append(
|
|
949
|
+
{
|
|
950
|
+
"type": "collected",
|
|
951
|
+
"event_type": "collected",
|
|
952
|
+
"seq": next_seq + offset,
|
|
953
|
+
"payload": {
|
|
954
|
+
"run_id": event_run_id,
|
|
955
|
+
"session_id": session_id,
|
|
956
|
+
"seq": event_seq,
|
|
957
|
+
"collected": True,
|
|
958
|
+
},
|
|
959
|
+
}
|
|
960
|
+
)
|
|
961
|
+
self._append_entries(session_id, overlays)
|
|
962
|
+
return pending
|
|
963
|
+
|
|
964
|
+
async def annotate_run_completed(
|
|
965
|
+
self,
|
|
966
|
+
*,
|
|
967
|
+
session_id: str,
|
|
968
|
+
run_id: str,
|
|
969
|
+
completion_verb: str,
|
|
970
|
+
duration: str = "",
|
|
971
|
+
elapsed_seconds: float | None = None,
|
|
972
|
+
) -> None:
|
|
973
|
+
"""Persist completed-run display fields without rewriting journal lines."""
|
|
974
|
+
if not session_id or not run_id or not completion_verb:
|
|
975
|
+
return
|
|
976
|
+
payload: dict[str, Any] = {
|
|
977
|
+
"session_id": session_id,
|
|
978
|
+
"run_id": run_id,
|
|
979
|
+
"completion_verb": completion_verb,
|
|
980
|
+
}
|
|
981
|
+
if duration:
|
|
982
|
+
payload["duration"] = duration
|
|
983
|
+
if elapsed_seconds is not None:
|
|
984
|
+
payload["elapsed_seconds"] = elapsed_seconds
|
|
985
|
+
await self.append_event(event_type="run_completed_meta", payload=payload)
|
|
986
|
+
|
|
987
|
+
async def load_events(self, *, session_id: str) -> list[tuple[str, dict[str, Any]]]:
|
|
988
|
+
with self._lock:
|
|
989
|
+
entries = self._cached_entries(session_id)
|
|
990
|
+
directory = self.session_dir(session_id)
|
|
991
|
+
collected = self._indexes[session_id].collected
|
|
992
|
+
events = [
|
|
993
|
+
(str(entry.get("event_type") or ""), entry.get("payload"))
|
|
994
|
+
for entry in entries
|
|
995
|
+
if entry.get("event_type") and entry.get("type") != "message" and isinstance(entry.get("payload"), dict)
|
|
996
|
+
]
|
|
997
|
+
return apply_collection([(kind, hydrate(payload, directory)) for kind, payload in events], collected=collected)
|
|
998
|
+
|
|
999
|
+
async def load_children(self, *, parent_session_id: str) -> list[dict[str, Any]]:
|
|
1000
|
+
with self._lock:
|
|
1001
|
+
self._cached_entries(parent_session_id)
|
|
1002
|
+
index = self._indexes[parent_session_id]
|
|
1003
|
+
return [dict(index.children[child_id]) for child_id in index.child_order]
|
|
1004
|
+
|
|
1005
|
+
async def save_conversation(
|
|
1006
|
+
self,
|
|
1007
|
+
*,
|
|
1008
|
+
session_id: str,
|
|
1009
|
+
messages: List[Message],
|
|
1010
|
+
) -> None:
|
|
1011
|
+
with self._lock, self._session_lock(session_id):
|
|
1012
|
+
self._stamp_client(session_id)
|
|
1013
|
+
entries = self._read_entries(session_id)
|
|
1014
|
+
incoming = archive([self._message_payload(message) for message in messages],
|
|
1015
|
+
self.session_dir(session_id))
|
|
1016
|
+
index = self._indexes[session_id]
|
|
1017
|
+
stored = index.context_payloads()
|
|
1018
|
+
if not entries:
|
|
1019
|
+
header = self._header(session_id)
|
|
1020
|
+
self._append_entries(
|
|
1021
|
+
session_id,
|
|
1022
|
+
[header, *[
|
|
1023
|
+
self._message_entry(item, seq=header["seq"] + index)
|
|
1024
|
+
for index, item in enumerate(incoming, 1)
|
|
1025
|
+
]],
|
|
1026
|
+
)
|
|
1027
|
+
return
|
|
1028
|
+
if len(incoming) >= len(stored) and incoming[: len(stored)] == stored:
|
|
1029
|
+
extra = incoming[len(stored) :]
|
|
1030
|
+
if extra:
|
|
1031
|
+
next_seq = self._next_seq(session_id)
|
|
1032
|
+
self._append_entries(
|
|
1033
|
+
session_id,
|
|
1034
|
+
[self._message_entry(item, seq=next_seq + index) for index, item in enumerate(extra)],
|
|
1035
|
+
)
|
|
1036
|
+
return
|
|
1037
|
+
if not self._is_compact_rewrite(stored, incoming):
|
|
1038
|
+
# In-place edits (including legacy tool stubs or injected memory) must
|
|
1039
|
+
# not mint a compaction boundary. Re-emitting the whole
|
|
1040
|
+
# conversation after through_seq is what ballooned session
|
|
1041
|
+
# files to thousands of duplicate message lines.
|
|
1042
|
+
extra = incoming[len(stored) :] if len(incoming) > len(stored) else []
|
|
1043
|
+
if extra:
|
|
1044
|
+
next_seq = self._next_seq(session_id)
|
|
1045
|
+
self._append_entries(
|
|
1046
|
+
session_id,
|
|
1047
|
+
[self._message_entry(item, seq=next_seq + index) for index, item in enumerate(extra)],
|
|
1048
|
+
)
|
|
1049
|
+
return
|
|
1050
|
+
# A rewrite means the harness compacted its runtime context. Keep
|
|
1051
|
+
# the complete transcript and append only a checkpoint plus the
|
|
1052
|
+
# retained suffix; compaction must never erase TUI history.
|
|
1053
|
+
previous_seq = self._next_seq(session_id) - 1
|
|
1054
|
+
candidates = list(incoming)
|
|
1055
|
+
system = next((item for item in candidates if item.get("role") == "system"), None)
|
|
1056
|
+
if system is not None:
|
|
1057
|
+
candidates.remove(system)
|
|
1058
|
+
summary = candidates.pop(0) if candidates else {"role": "user", "content": ""}
|
|
1059
|
+
next_seq = self._next_seq(session_id)
|
|
1060
|
+
boundary = {
|
|
1061
|
+
"type": "compaction",
|
|
1062
|
+
"version": 1,
|
|
1063
|
+
"seq": next_seq,
|
|
1064
|
+
"through_seq": previous_seq,
|
|
1065
|
+
"summary": summary,
|
|
1066
|
+
"summary_role": str(summary.get("role") or "user"),
|
|
1067
|
+
"system": system,
|
|
1068
|
+
"created_at": _utc_now(),
|
|
1069
|
+
}
|
|
1070
|
+
additions = [
|
|
1071
|
+
self._message_entry(item, seq=next_seq + index)
|
|
1072
|
+
for index, item in enumerate(candidates, 1)
|
|
1073
|
+
]
|
|
1074
|
+
self._append_entries(session_id, [boundary, *additions])
|
|
1075
|
+
|
|
1076
|
+
def _load_messages(self, session_id: str, *, transcript: bool) -> tuple[List[Message], List[Message]]:
|
|
1077
|
+
"""Return both histories from the incremental index, hydrating once."""
|
|
1078
|
+
with self._lock:
|
|
1079
|
+
self._cached_entries(session_id)
|
|
1080
|
+
index = self._indexes[session_id]
|
|
1081
|
+
if not transcript:
|
|
1082
|
+
return [], list(index.hydrated_context)
|
|
1083
|
+
return list(index.hydrated), list(index.hydrated_context)
|
|
1084
|
+
|
|
1085
|
+
async def load_conversation(self, *, session_id: str) -> List[Message]:
|
|
1086
|
+
"""Load the reconstructed model context (runtime view)."""
|
|
1087
|
+
_, context = self._load_messages(session_id, transcript=False)
|
|
1088
|
+
return context
|
|
1089
|
+
|
|
1090
|
+
async def load_transcript(self, *, session_id: str) -> List[Message]:
|
|
1091
|
+
"""Load the complete persisted transcript for the TUI/history view."""
|
|
1092
|
+
history, _ = self._load_messages(session_id, transcript=True)
|
|
1093
|
+
return history
|
|
1094
|
+
|
|
1095
|
+
async def load_resume(self, *, session_id: str) -> tuple[List[Message], List[Message]]:
|
|
1096
|
+
"""Load visible history and model context from one journal read."""
|
|
1097
|
+
return self._load_messages(session_id, transcript=True)
|
|
1098
|
+
|
|
1099
|
+
async def list_sessions(self) -> List[SessionSummary]:
|
|
1100
|
+
"""List parent sessions, newest first. Child session files are omitted."""
|
|
1101
|
+
with self._lock:
|
|
1102
|
+
child_ids: set[str] = set()
|
|
1103
|
+
summaries: List[tuple[float, SessionSummary]] = []
|
|
1104
|
+
paths: Dict[str, Path] = {}
|
|
1105
|
+
for candidate in self.root.iterdir():
|
|
1106
|
+
if candidate.is_symlink():
|
|
1107
|
+
continue
|
|
1108
|
+
if candidate.is_file() and candidate.name.endswith(".jsonl") and not candidate.name.endswith(".tmp"):
|
|
1109
|
+
paths.setdefault(candidate.name[: -len(".jsonl")], candidate)
|
|
1110
|
+
elif candidate.is_dir():
|
|
1111
|
+
transcript = candidate / "transcript.jsonl"
|
|
1112
|
+
if transcript.is_file() and not transcript.is_symlink():
|
|
1113
|
+
paths[candidate.name] = transcript
|
|
1114
|
+
for session_id, path in paths.items():
|
|
1115
|
+
if session_id in {".", ".."} or not _SESSION_ID.fullmatch(session_id):
|
|
1116
|
+
continue
|
|
1117
|
+
if not path.is_file() or path.is_symlink():
|
|
1118
|
+
continue
|
|
1119
|
+
try:
|
|
1120
|
+
self.session_dir(session_id)
|
|
1121
|
+
safe_path(self.root, str(path.relative_to(self.root)))
|
|
1122
|
+
except ValueError:
|
|
1123
|
+
continue
|
|
1124
|
+
try:
|
|
1125
|
+
meta = self._read_metadata(session_id)
|
|
1126
|
+
except ValueError:
|
|
1127
|
+
meta = None
|
|
1128
|
+
client = str(meta.get("client") or "") if isinstance(meta, dict) else ""
|
|
1129
|
+
listing = self._session_listing(session_id)
|
|
1130
|
+
message_count = int(listing["message_count"])
|
|
1131
|
+
first_message = str(listing["first_message"])
|
|
1132
|
+
child_ids.update(str(child_id) for child_id in listing["child_ids"])
|
|
1133
|
+
updated = datetime.fromtimestamp(
|
|
1134
|
+
path.stat().st_mtime, tz=timezone.utc
|
|
1135
|
+
).isoformat()
|
|
1136
|
+
summaries.append(
|
|
1137
|
+
(
|
|
1138
|
+
path.stat().st_mtime,
|
|
1139
|
+
SessionSummary(
|
|
1140
|
+
session_id=session_id,
|
|
1141
|
+
updated_at=updated,
|
|
1142
|
+
message_count=message_count,
|
|
1143
|
+
first_message=first_message,
|
|
1144
|
+
client=client,
|
|
1145
|
+
),
|
|
1146
|
+
)
|
|
1147
|
+
)
|
|
1148
|
+
return [
|
|
1149
|
+
summary
|
|
1150
|
+
for _, summary in sorted(summaries, key=lambda item: item[0], reverse=True)
|
|
1151
|
+
if summary.session_id not in child_ids
|
|
1152
|
+
]
|
|
1153
|
+
|
|
1154
|
+
async def save_checkpoint(self, *, checkpoint: Checkpoint) -> None:
|
|
1155
|
+
# The journal already holds the messages; do not dump them every turn.
|
|
1156
|
+
payload = checkpoint.model_dump(exclude={"messages"})
|
|
1157
|
+
payload["metadata"] = {**self.checkpoint_metadata(), **payload["metadata"]}
|
|
1158
|
+
with self._lock, self._session_lock(checkpoint.session_id):
|
|
1159
|
+
entries = self._read_entries(checkpoint.session_id)
|
|
1160
|
+
outgoing: List[Dict[str, Any]] = []
|
|
1161
|
+
if not entries:
|
|
1162
|
+
outgoing.append(self._header(checkpoint.session_id))
|
|
1163
|
+
outgoing.append({"type": "checkpoint", "version": 1, **payload})
|
|
1164
|
+
self._append_entries(checkpoint.session_id, outgoing)
|
|
1165
|
+
|
|
1166
|
+
def checkpoint_goal(self, session_id: str) -> str:
|
|
1167
|
+
"""Return the latest saved goal without rebuilding the conversation."""
|
|
1168
|
+
with self._lock:
|
|
1169
|
+
self._cached_entries(session_id)
|
|
1170
|
+
latest = self._indexes[session_id].latest_checkpoint
|
|
1171
|
+
metadata = latest.get("metadata") if isinstance(latest, dict) else None
|
|
1172
|
+
return str(metadata.get("goal") or "") if isinstance(metadata, dict) else ""
|
|
1173
|
+
|
|
1174
|
+
async def load_checkpoint(self, *, session_id: str) -> Optional[Checkpoint]:
|
|
1175
|
+
with self._lock:
|
|
1176
|
+
self._cached_entries(session_id)
|
|
1177
|
+
index = self._indexes[session_id]
|
|
1178
|
+
latest = index.latest_checkpoint
|
|
1179
|
+
if latest is None:
|
|
1180
|
+
return None
|
|
1181
|
+
directory = self.session_dir(session_id)
|
|
1182
|
+
payload = hydrate({key: value for key, value in latest.items() if key != "type"}, directory)
|
|
1183
|
+
payload.setdefault("session_id", session_id)
|
|
1184
|
+
payload["messages"] = [
|
|
1185
|
+
Message.model_validate(hydrate(message, directory)) for message in index.context_payloads()
|
|
1186
|
+
]
|
|
1187
|
+
return Checkpoint.model_validate(payload)
|
|
1188
|
+
|
|
1189
|
+
async def load_compactions(self, session_id: str) -> List[Dict[str, Any]]:
|
|
1190
|
+
"""Return compaction boundaries in transcript order, with hydrated content."""
|
|
1191
|
+
with self._lock:
|
|
1192
|
+
self._read_entries(session_id)
|
|
1193
|
+
index = self._indexes[session_id]
|
|
1194
|
+
directory = self.session_dir(session_id)
|
|
1195
|
+
return [hydrate(entry, directory) for entry in index.compactions]
|