crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,2032 @@
|
|
|
1
|
+
"""Session storage — JSONL-based conversation persistence with SQLite metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import uuid
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from crabcode_core.file_lock import file_lock
|
|
18
|
+
from crabcode_core.filesystem import replace_with_retry
|
|
19
|
+
from crabcode_core.logging_utils import get_logger
|
|
20
|
+
from crabcode_core.path_validation import validate_path_component
|
|
21
|
+
from crabcode_core.paths import get_config_home
|
|
22
|
+
from crabcode_core.subprocess_utils import subprocess_group_options
|
|
23
|
+
from crabcode_core.types.message import Message
|
|
24
|
+
from crabcode_core.utf8_sanitize import safe_utf8_json_tree
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionArchivedError(RuntimeError):
|
|
30
|
+
"""Raised when a writer targets a durably archived session."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def _transcript_file_lock(path: Path, *, exclusive: bool):
|
|
35
|
+
"""Serialize transcript access across threads and cooperating processes."""
|
|
36
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
37
|
+
with file_lock(lock_path, exclusive=exclusive):
|
|
38
|
+
yield
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_projects_dir() -> Path:
|
|
42
|
+
"""Get the projects directory for session storage."""
|
|
43
|
+
return get_config_home() / "projects"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sanitize_path(path: str) -> str:
|
|
47
|
+
"""Sanitize a filesystem path for use as a directory name."""
|
|
48
|
+
sanitized = re.sub(r'[^\w\-.]', '_', path)
|
|
49
|
+
if len(sanitized) > 200:
|
|
50
|
+
sanitized = sanitized[:200]
|
|
51
|
+
return sanitized
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _windows_extended_path(path: Path) -> Path:
|
|
55
|
+
"""Return an extended-length path for legacy Windows session locations."""
|
|
56
|
+
if os.name != "nt":
|
|
57
|
+
return path
|
|
58
|
+
raw = os.path.abspath(str(path))
|
|
59
|
+
if raw.startswith("\\\\?\\"):
|
|
60
|
+
return Path(raw)
|
|
61
|
+
if raw.startswith("\\\\"):
|
|
62
|
+
return Path("\\\\?\\UNC\\" + raw[2:])
|
|
63
|
+
return Path("\\\\?\\" + raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _project_path_hash(cwd: str) -> str:
|
|
67
|
+
"""Return a stable collision-resistant key for an absolute project path."""
|
|
68
|
+
identity = os.path.normcase(os.path.abspath(cwd))
|
|
69
|
+
return hashlib.sha256(os.fsencode(identity)).hexdigest()[:20]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _legacy_project_dir(cwd: str) -> Path:
|
|
73
|
+
"""Return the pre-hash project directory used by older CrabCode builds."""
|
|
74
|
+
path = get_projects_dir() / _sanitize_path(os.path.abspath(cwd))
|
|
75
|
+
return _windows_extended_path(path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _hashed_project_dir(cwd: str, prefix_length: int) -> Path:
|
|
79
|
+
identity = os.path.normcase(os.path.abspath(cwd))
|
|
80
|
+
prefix = _sanitize_path(identity)[:prefix_length]
|
|
81
|
+
return get_projects_dir() / f"{prefix}--{_project_path_hash(identity)}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _case_sensitive_hashed_project_dir(cwd: str, prefix_length: int) -> Path:
|
|
85
|
+
"""Return the pre-Windows-normalization hashed directory."""
|
|
86
|
+
absolute = os.path.abspath(cwd)
|
|
87
|
+
prefix = _sanitize_path(absolute)[:prefix_length]
|
|
88
|
+
digest = hashlib.sha256(os.fsencode(absolute)).hexdigest()[:20]
|
|
89
|
+
return get_projects_dir() / f"{prefix}--{digest}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _validate_component(value: str, label: str) -> str:
|
|
93
|
+
"""Validate an identifier before interpolating it into a file name.
|
|
94
|
+
|
|
95
|
+
Session and agent IDs normally come from UUIDs, but resume/import APIs can
|
|
96
|
+
receive values from an untrusted client. Sanitizing here would make two
|
|
97
|
+
distinct IDs collide, so reject path separators and other filesystem
|
|
98
|
+
control components at the storage boundary instead.
|
|
99
|
+
"""
|
|
100
|
+
return validate_path_component(value, label)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _dump_jsonl_line(obj: Any) -> str:
|
|
104
|
+
return json.dumps(safe_utf8_json_tree(obj), ensure_ascii=False) + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _decode_transcript(raw: bytes) -> str:
|
|
108
|
+
"""Decode transcript bytes, tolerating undecodable data in edited files."""
|
|
109
|
+
try:
|
|
110
|
+
return raw.decode("utf-8")
|
|
111
|
+
except UnicodeDecodeError:
|
|
112
|
+
logger.warning("Transcript contains invalid UTF-8; undecodable bytes replaced")
|
|
113
|
+
return raw.decode("utf-8", errors="replace")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _complete_prefix_length(raw: bytes) -> int:
|
|
117
|
+
"""Return the byte length of *raw* up to and including its last newline."""
|
|
118
|
+
end = raw.rfind(b"\n")
|
|
119
|
+
return end + 1 if end >= 0 else 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _message_to_entry(message: Message) -> dict[str, Any]:
|
|
123
|
+
"""Serialize all message state needed to reconstruct active context."""
|
|
124
|
+
entry: dict[str, Any] = {
|
|
125
|
+
"type": message.role.value,
|
|
126
|
+
"uuid": message.uuid,
|
|
127
|
+
"parent_uuid": message.parent_uuid,
|
|
128
|
+
"timestamp": message.timestamp,
|
|
129
|
+
"is_compact_summary": message.is_compact_summary,
|
|
130
|
+
"content": message.content if isinstance(message.content, str) else [
|
|
131
|
+
block.model_dump() for block in message.content
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
if message.origin is not None:
|
|
135
|
+
entry["origin"] = message.origin
|
|
136
|
+
for field in (
|
|
137
|
+
"tool_use_result",
|
|
138
|
+
"source_tool_assistant_uuid",
|
|
139
|
+
"reply_to_uuid",
|
|
140
|
+
"api_error",
|
|
141
|
+
"usage",
|
|
142
|
+
"request_id",
|
|
143
|
+
"turn_details",
|
|
144
|
+
):
|
|
145
|
+
value = getattr(message, field, None)
|
|
146
|
+
if value is not None:
|
|
147
|
+
entry[field] = value
|
|
148
|
+
return entry
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_message_entry(entry: dict[str, Any]) -> bool:
|
|
152
|
+
return entry.get("type") in {"user", "assistant", "system"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_project_dir(cwd: str) -> Path:
|
|
156
|
+
"""Get the collision-resistant project-specific session directory."""
|
|
157
|
+
return _hashed_project_dir(cwd, 64)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _previous_hashed_project_dir(cwd: str) -> Path:
|
|
161
|
+
"""Return the longer hashed directory used by CrabCode 0.1.4 and earlier."""
|
|
162
|
+
return _windows_extended_path(_case_sensitive_hashed_project_dir(cwd, 175))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _case_sensitive_project_dir(cwd: str) -> Path:
|
|
166
|
+
"""Return the previous 64-character, case-sensitive project directory."""
|
|
167
|
+
return _windows_extended_path(_case_sensitive_hashed_project_dir(cwd, 64))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _project_dirs_for_read(cwd: str) -> list[Path]:
|
|
171
|
+
"""Return the current project directory plus its legacy compatibility path."""
|
|
172
|
+
directories = [
|
|
173
|
+
get_project_dir(cwd),
|
|
174
|
+
_case_sensitive_project_dir(cwd),
|
|
175
|
+
_previous_hashed_project_dir(cwd),
|
|
176
|
+
_legacy_project_dir(cwd),
|
|
177
|
+
]
|
|
178
|
+
return list(dict.fromkeys(directories))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _transcript_declared_cwd(path: Path) -> str | None:
|
|
182
|
+
"""Read a transcript's durable cwd without trusting its directory name."""
|
|
183
|
+
declared: str | None = None
|
|
184
|
+
try:
|
|
185
|
+
with _transcript_file_lock(path, exclusive=False):
|
|
186
|
+
with open(path, encoding="utf-8") as transcript:
|
|
187
|
+
for raw_line in transcript:
|
|
188
|
+
try:
|
|
189
|
+
entry = json.loads(raw_line)
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
continue
|
|
192
|
+
if not isinstance(entry, dict) or entry.get("type") != "session_meta":
|
|
193
|
+
continue
|
|
194
|
+
value = entry.get("cwd")
|
|
195
|
+
if isinstance(value, str) and value:
|
|
196
|
+
declared = os.path.abspath(value)
|
|
197
|
+
except (OSError, ValueError):
|
|
198
|
+
return None
|
|
199
|
+
return declared
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _session_project_dir(cwd: str, session_id: str) -> Path:
|
|
203
|
+
"""Resolve an existing legacy transcript, while placing new sessions safely."""
|
|
204
|
+
validated_id = _validate_component(session_id, "session id")
|
|
205
|
+
directories = _project_dirs_for_read(cwd)
|
|
206
|
+
current = directories[0]
|
|
207
|
+
legacy = _legacy_project_dir(cwd)
|
|
208
|
+
for directory in directories:
|
|
209
|
+
transcript = directory / f"{validated_id}.jsonl"
|
|
210
|
+
if not transcript.exists():
|
|
211
|
+
continue
|
|
212
|
+
if directory != legacy:
|
|
213
|
+
return directory
|
|
214
|
+
declared_cwd = _transcript_declared_cwd(transcript)
|
|
215
|
+
if declared_cwd is None or os.path.normcase(declared_cwd) == os.path.normcase(
|
|
216
|
+
os.path.abspath(cwd)
|
|
217
|
+
):
|
|
218
|
+
return directory
|
|
219
|
+
return current
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_session_tombstone_path(_cwd: str, session_id: str) -> Path:
|
|
223
|
+
"""Return the durable archive fence, stored outside purgeable artifacts."""
|
|
224
|
+
validated_id = _validate_component(session_id, "session id")
|
|
225
|
+
shard = hashlib.sha256(validated_id.encode("utf-8")).hexdigest()[:2]
|
|
226
|
+
return (
|
|
227
|
+
get_config_home()
|
|
228
|
+
/ "session-tombstones"
|
|
229
|
+
/ shard
|
|
230
|
+
/ f"{validated_id}.json"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _session_lifecycle_lock_path(_cwd: str, session_id: str) -> Path:
|
|
235
|
+
validated_id = _validate_component(session_id, "session id")
|
|
236
|
+
shard = hashlib.sha256(validated_id.encode("utf-8")).hexdigest()[:2]
|
|
237
|
+
return (
|
|
238
|
+
get_config_home()
|
|
239
|
+
/ "session-locks"
|
|
240
|
+
/ shard
|
|
241
|
+
/ validated_id
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@contextmanager
|
|
246
|
+
def _session_lifecycle_lock(cwd: str, session_id: str, *, exclusive: bool = True):
|
|
247
|
+
"""Serialize archive, purge, and writes independently of directory migration."""
|
|
248
|
+
with _transcript_file_lock(
|
|
249
|
+
_session_lifecycle_lock_path(cwd, session_id),
|
|
250
|
+
exclusive=exclusive,
|
|
251
|
+
):
|
|
252
|
+
yield
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_transcript_path(cwd: str, session_id: str) -> Path:
|
|
256
|
+
"""Get the path for a session transcript file."""
|
|
257
|
+
validated_id = _validate_component(session_id, "session id")
|
|
258
|
+
return _session_project_dir(cwd, validated_id) / f"{validated_id}.jsonl"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_agent_meta_path(cwd: str, session_id: str) -> Path:
|
|
262
|
+
"""Get the path for a session's managed-agent metadata."""
|
|
263
|
+
validated_id = _validate_component(session_id, "session id")
|
|
264
|
+
return _session_project_dir(cwd, validated_id) / f"{validated_id}.agents.json"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_callback_deliveries_path(cwd: str, session_id: str) -> Path:
|
|
268
|
+
"""Return the small append-only receipt file for agent callbacks."""
|
|
269
|
+
validated_id = _validate_component(session_id, "session id")
|
|
270
|
+
return _session_project_dir(cwd, validated_id) / (
|
|
271
|
+
f"{validated_id}.callbacks.jsonl"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_agent_transcript_dir(cwd: str, session_id: str) -> Path:
|
|
276
|
+
"""Get the directory for managed-agent transcripts for a session."""
|
|
277
|
+
validated_id = _validate_component(session_id, "session id")
|
|
278
|
+
return _session_project_dir(cwd, validated_id) / f"{validated_id}.agents"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_agent_transcript_path(cwd: str, session_id: str, agent_id: str) -> Path:
|
|
282
|
+
"""Get the transcript path for a managed agent."""
|
|
283
|
+
return get_agent_transcript_dir(cwd, session_id) / (
|
|
284
|
+
f"{_validate_component(agent_id, 'agent id')}.jsonl"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_task_output_path(cwd: str, session_id: str, task_id: str) -> Path:
|
|
289
|
+
"""Get the output path for a session-scoped background task."""
|
|
290
|
+
return (
|
|
291
|
+
_session_project_dir(cwd, session_id)
|
|
292
|
+
/ f"{_validate_component(session_id, 'session id')}.tasks"
|
|
293
|
+
/ f"{_validate_component(task_id, 'task id')}.log"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def generate_session_id() -> str:
|
|
298
|
+
return str(uuid.uuid4())
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _get_git_info(cwd: str) -> dict[str, str | None]:
|
|
302
|
+
"""Get git branch and SHA for the cwd."""
|
|
303
|
+
info: dict[str, str | None] = {"git_branch": None, "git_sha": None}
|
|
304
|
+
try:
|
|
305
|
+
result = subprocess.run(
|
|
306
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
307
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=cwd, timeout=5,
|
|
308
|
+
**subprocess_group_options(),
|
|
309
|
+
)
|
|
310
|
+
if result.stdout.strip() != "true":
|
|
311
|
+
return info
|
|
312
|
+
branch = subprocess.run(
|
|
313
|
+
["git", "branch", "--show-current"],
|
|
314
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=cwd, timeout=5,
|
|
315
|
+
**subprocess_group_options(),
|
|
316
|
+
).stdout.strip()
|
|
317
|
+
sha = subprocess.run(
|
|
318
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
319
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=cwd, timeout=5,
|
|
320
|
+
**subprocess_group_options(),
|
|
321
|
+
).stdout.strip()
|
|
322
|
+
if branch:
|
|
323
|
+
info["git_branch"] = branch
|
|
324
|
+
if sha:
|
|
325
|
+
info["git_sha"] = sha
|
|
326
|
+
except Exception:
|
|
327
|
+
logger.debug("Failed to read git info for %s", cwd, exc_info=True)
|
|
328
|
+
return info
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class SessionStorage:
|
|
332
|
+
"""Manages session persistence using JSONL files + SQLite metadata."""
|
|
333
|
+
|
|
334
|
+
def __init__(self, cwd: str, session_id: str | None = None):
|
|
335
|
+
self.cwd = os.path.abspath(cwd)
|
|
336
|
+
self.session_id = session_id or generate_session_id()
|
|
337
|
+
self._transcript_path = get_transcript_path(self.cwd, self.session_id)
|
|
338
|
+
self._initialized = False
|
|
339
|
+
self._written_uuids: set[str] = set()
|
|
340
|
+
self._callback_deliveries: dict[tuple[str, int, str], dict[str, Any]] = {}
|
|
341
|
+
self._callback_deliveries_loaded = False
|
|
342
|
+
self._meta_written = False
|
|
343
|
+
self._meta: dict[str, Any] = {}
|
|
344
|
+
self.last_context_token_source: str = "estimated"
|
|
345
|
+
self.last_context_token_baseline: dict | None = None
|
|
346
|
+
self.last_context_used_tokens: int = 0
|
|
347
|
+
self.last_context_window_tokens: int = 0
|
|
348
|
+
self.compact_count: int = 0
|
|
349
|
+
self._archive_state_checked = False
|
|
350
|
+
# Incremental transcript cursor: byte offset and inode of the prefix
|
|
351
|
+
# already reflected in ``_meta_index``/``_written_uuids``.
|
|
352
|
+
self._scan_offset = 0
|
|
353
|
+
self._scan_inode: int | None = None
|
|
354
|
+
self._scan_cursor_valid = False
|
|
355
|
+
self._meta_index: dict[str, Any] = {}
|
|
356
|
+
self._lifecycle_entry_types: set[str] = set()
|
|
357
|
+
|
|
358
|
+
def _ensure_dir(self) -> None:
|
|
359
|
+
if not self._initialized:
|
|
360
|
+
self._transcript_path.parent.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
self._initialized = True
|
|
362
|
+
|
|
363
|
+
def _transcript_has_archive_marker_locked(self) -> bool:
|
|
364
|
+
"""Return the latest inline archive lifecycle state."""
|
|
365
|
+
if not self._transcript_path.exists():
|
|
366
|
+
return False
|
|
367
|
+
archived = False
|
|
368
|
+
try:
|
|
369
|
+
with _transcript_file_lock(self._transcript_path, exclusive=False):
|
|
370
|
+
with open(self._transcript_path, encoding="utf-8") as transcript:
|
|
371
|
+
for raw_line in transcript:
|
|
372
|
+
try:
|
|
373
|
+
entry = json.loads(raw_line)
|
|
374
|
+
except json.JSONDecodeError:
|
|
375
|
+
continue
|
|
376
|
+
if not isinstance(entry, dict):
|
|
377
|
+
continue
|
|
378
|
+
if entry.get("type") == "session_archive":
|
|
379
|
+
archived = True
|
|
380
|
+
elif entry.get("type") == "session_restore":
|
|
381
|
+
archived = False
|
|
382
|
+
except FileNotFoundError:
|
|
383
|
+
return False
|
|
384
|
+
return archived
|
|
385
|
+
|
|
386
|
+
def _write_tombstone_locked(self) -> None:
|
|
387
|
+
"""Commit the external archive marker while the session lock is held."""
|
|
388
|
+
marker = get_session_tombstone_path(self.cwd, self.session_id)
|
|
389
|
+
if marker.exists():
|
|
390
|
+
return
|
|
391
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
self._atomic_write_text(
|
|
393
|
+
marker,
|
|
394
|
+
json.dumps(
|
|
395
|
+
{
|
|
396
|
+
"session_id": self.session_id,
|
|
397
|
+
"cwd": self.cwd,
|
|
398
|
+
"archived_at": datetime.now(timezone.utc).isoformat(),
|
|
399
|
+
},
|
|
400
|
+
ensure_ascii=False,
|
|
401
|
+
)
|
|
402
|
+
+ "\n",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _assert_writable_locked(self) -> None:
|
|
406
|
+
"""Reject writes after archive, including legacy inline-only archives."""
|
|
407
|
+
if get_session_tombstone_path(self.cwd, self.session_id).exists():
|
|
408
|
+
raise SessionArchivedError(f"Session {self.session_id} is archived")
|
|
409
|
+
if not self._archive_state_checked:
|
|
410
|
+
self._archive_state_checked = True
|
|
411
|
+
if self._transcript_has_archive_marker_locked():
|
|
412
|
+
self._write_tombstone_locked()
|
|
413
|
+
raise SessionArchivedError(f"Session {self.session_id} is archived")
|
|
414
|
+
|
|
415
|
+
def _append_transcript_line_locked(self, entry: Any) -> None:
|
|
416
|
+
"""Append one JSONL record while the session lifecycle lock is held."""
|
|
417
|
+
self._ensure_dir()
|
|
418
|
+
line = _dump_jsonl_line(entry)
|
|
419
|
+
with _transcript_file_lock(self._transcript_path, exclusive=True):
|
|
420
|
+
self._apply_index_entries(self._scan_new_entries_locked())
|
|
421
|
+
with open(self._transcript_path, "a", encoding="utf-8") as f:
|
|
422
|
+
f.write(line)
|
|
423
|
+
f.flush()
|
|
424
|
+
os.fsync(f.fileno())
|
|
425
|
+
self._apply_index_entries([entry])
|
|
426
|
+
self._mark_scan_eof_locked(f)
|
|
427
|
+
|
|
428
|
+
def _append_transcript_line(self, entry: Any) -> None:
|
|
429
|
+
"""Append one durable JSONL record under the cross-process locks."""
|
|
430
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
431
|
+
self._assert_writable_locked()
|
|
432
|
+
self._append_transcript_line_locked(entry)
|
|
433
|
+
|
|
434
|
+
def _append_transcript_line_if_absent_locked(
|
|
435
|
+
self,
|
|
436
|
+
entry: Any,
|
|
437
|
+
matcher: Callable[[dict[str, Any]], bool],
|
|
438
|
+
) -> bool:
|
|
439
|
+
"""Check and append one record while the session lock is held."""
|
|
440
|
+
self._ensure_dir()
|
|
441
|
+
line = _dump_jsonl_line(entry)
|
|
442
|
+
with _transcript_file_lock(self._transcript_path, exclusive=True):
|
|
443
|
+
# The cursor is already at EOF after a normal load. If another
|
|
444
|
+
# storage instance appended meanwhile, scan only that new tail.
|
|
445
|
+
entries = self._scan_new_entries_locked()
|
|
446
|
+
self._apply_index_entries(entries)
|
|
447
|
+
for existing in entries:
|
|
448
|
+
if matcher(existing):
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
with open(self._transcript_path, "a", encoding="utf-8") as transcript:
|
|
452
|
+
transcript.write(line)
|
|
453
|
+
transcript.flush()
|
|
454
|
+
os.fsync(transcript.fileno())
|
|
455
|
+
self._apply_index_entries([entry])
|
|
456
|
+
self._mark_scan_eof_locked(transcript)
|
|
457
|
+
return True
|
|
458
|
+
|
|
459
|
+
def _append_transcript_line_if_absent(
|
|
460
|
+
self,
|
|
461
|
+
entry: Any,
|
|
462
|
+
matcher: Callable[[dict[str, Any]], bool],
|
|
463
|
+
) -> bool:
|
|
464
|
+
"""Append a record only when no matching record exists.
|
|
465
|
+
|
|
466
|
+
The check and append must share the same exclusive lock. Checking
|
|
467
|
+
``_written_uuids`` alone is insufficient because another
|
|
468
|
+
``SessionStorage`` instance (or process) can append between that
|
|
469
|
+
in-memory check and our write.
|
|
470
|
+
"""
|
|
471
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
472
|
+
self._assert_writable_locked()
|
|
473
|
+
return self._append_transcript_line_if_absent_locked(entry, matcher)
|
|
474
|
+
|
|
475
|
+
def _read_transcript_text(self) -> str:
|
|
476
|
+
"""Read the transcript without racing an append or marker write."""
|
|
477
|
+
raw, _inode = self._read_transcript_bytes()
|
|
478
|
+
return _decode_transcript(raw)
|
|
479
|
+
|
|
480
|
+
def _read_transcript_bytes(self) -> tuple[bytes, int | None]:
|
|
481
|
+
"""Read the whole transcript plus its inode under the shared locks."""
|
|
482
|
+
try:
|
|
483
|
+
with _session_lifecycle_lock(self.cwd, self.session_id, exclusive=False):
|
|
484
|
+
if not self._transcript_path.exists():
|
|
485
|
+
return b"", None
|
|
486
|
+
with _transcript_file_lock(self._transcript_path, exclusive=False):
|
|
487
|
+
with open(self._transcript_path, "rb") as transcript:
|
|
488
|
+
raw = transcript.read()
|
|
489
|
+
inode = os.fstat(transcript.fileno()).st_ino
|
|
490
|
+
return raw, inode
|
|
491
|
+
except FileNotFoundError:
|
|
492
|
+
# A concurrent archive can remove the file between the existence
|
|
493
|
+
# check and lock acquisition.
|
|
494
|
+
return b"", None
|
|
495
|
+
except OSError:
|
|
496
|
+
logger.warning("Failed to read transcript: %s", self._transcript_path, exc_info=True)
|
|
497
|
+
return b"", None
|
|
498
|
+
|
|
499
|
+
# --- Incremental transcript scanning ---
|
|
500
|
+
#
|
|
501
|
+
# The transcript is append-only, so a record written by another process or
|
|
502
|
+
# ``SessionStorage`` instance after our last scan is necessarily located at
|
|
503
|
+
# or beyond ``_scan_offset``. Re-reading only that tail is enough to
|
|
504
|
+
# observe it, which turns the per-call cost of metadata merges and
|
|
505
|
+
# duplicate-UUID checks from O(file size) into O(bytes appended since the
|
|
506
|
+
# last look). The cursor is validated against the file's inode and size so
|
|
507
|
+
# an atomic rewrite (fork) or truncation falls back to a full reparse.
|
|
508
|
+
|
|
509
|
+
def _reset_scan_state(self) -> None:
|
|
510
|
+
self._scan_offset = 0
|
|
511
|
+
self._scan_inode: int | None = None
|
|
512
|
+
self._scan_cursor_valid = False
|
|
513
|
+
self._meta_index = {}
|
|
514
|
+
self._written_uuids = set()
|
|
515
|
+
self._lifecycle_entry_types = set()
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _parse_transcript_lines(text: str) -> list[dict[str, Any]]:
|
|
519
|
+
"""Parse newline-delimited JSON, skipping blank and malformed lines."""
|
|
520
|
+
entries: list[dict[str, Any]] = []
|
|
521
|
+
for line in text.splitlines():
|
|
522
|
+
line = line.strip()
|
|
523
|
+
if not line:
|
|
524
|
+
continue
|
|
525
|
+
try:
|
|
526
|
+
entry = json.loads(line)
|
|
527
|
+
except json.JSONDecodeError:
|
|
528
|
+
continue
|
|
529
|
+
if isinstance(entry, dict):
|
|
530
|
+
entries.append(entry)
|
|
531
|
+
return entries
|
|
532
|
+
|
|
533
|
+
def _mark_scan_eof_locked(self, transcript: Any) -> None:
|
|
534
|
+
"""Advance the incremental cursor after a durable append."""
|
|
535
|
+
try:
|
|
536
|
+
stat = os.fstat(transcript.fileno())
|
|
537
|
+
self._scan_inode = stat.st_ino
|
|
538
|
+
self._scan_offset = stat.st_size
|
|
539
|
+
self._scan_cursor_valid = True
|
|
540
|
+
except (OSError, AttributeError):
|
|
541
|
+
# A later operation can safely rebuild the index from byte zero.
|
|
542
|
+
self._reset_scan_state()
|
|
543
|
+
|
|
544
|
+
def _scan_new_entries_locked(self) -> list[dict[str, Any]]:
|
|
545
|
+
"""Return transcript entries recorded since the last scan.
|
|
546
|
+
|
|
547
|
+
The caller must already hold the transcript file lock so that the tail
|
|
548
|
+
read cannot race the append it is validating.
|
|
549
|
+
"""
|
|
550
|
+
try:
|
|
551
|
+
stat = os.stat(self._transcript_path)
|
|
552
|
+
except OSError:
|
|
553
|
+
self._reset_scan_state()
|
|
554
|
+
return []
|
|
555
|
+
if (
|
|
556
|
+
not self._scan_cursor_valid
|
|
557
|
+
or self._scan_inode != stat.st_ino
|
|
558
|
+
or stat.st_size < self._scan_offset
|
|
559
|
+
):
|
|
560
|
+
# No trusted prefix, a replaced file (atomic rewrite changes the
|
|
561
|
+
# inode), or a truncated one: whatever was cached describes a file
|
|
562
|
+
# that is no longer on disk.
|
|
563
|
+
self._reset_scan_state()
|
|
564
|
+
self._scan_inode = stat.st_ino
|
|
565
|
+
self._scan_cursor_valid = True
|
|
566
|
+
if stat.st_size == self._scan_offset:
|
|
567
|
+
return []
|
|
568
|
+
with open(self._transcript_path, "rb") as transcript:
|
|
569
|
+
transcript.seek(self._scan_offset)
|
|
570
|
+
raw = transcript.read()
|
|
571
|
+
consumed = _complete_prefix_length(raw)
|
|
572
|
+
if consumed == 0:
|
|
573
|
+
# Only a partial trailing line is present; leave it for the writer
|
|
574
|
+
# to finish so we never parse a torn record.
|
|
575
|
+
return []
|
|
576
|
+
self._scan_offset += consumed
|
|
577
|
+
return self._parse_transcript_lines(_decode_transcript(raw[:consumed]))
|
|
578
|
+
|
|
579
|
+
def _apply_index_entries(self, entries: list[dict[str, Any]]) -> None:
|
|
580
|
+
"""Merge new records into the metadata and UUID indexes."""
|
|
581
|
+
for entry in entries:
|
|
582
|
+
kind = entry.get("type")
|
|
583
|
+
if kind == "session_meta":
|
|
584
|
+
self._meta_index.update(
|
|
585
|
+
{key: value for key, value in entry.items() if key != "type"}
|
|
586
|
+
)
|
|
587
|
+
elif kind == "session_archive":
|
|
588
|
+
self._meta_index["is_archived"] = True
|
|
589
|
+
elif kind == "session_restore":
|
|
590
|
+
self._meta_index["is_archived"] = False
|
|
591
|
+
if kind in {"session_archive", "session_restore"}:
|
|
592
|
+
self._lifecycle_entry_types.add(str(kind))
|
|
593
|
+
uuid_value = entry.get("uuid")
|
|
594
|
+
if isinstance(uuid_value, str) and uuid_value:
|
|
595
|
+
self._written_uuids.add(uuid_value)
|
|
596
|
+
if kind in {"compact_boundary", "projection_boundary"}:
|
|
597
|
+
snapshot = entry.get("messages")
|
|
598
|
+
if isinstance(snapshot, list):
|
|
599
|
+
self._written_uuids.update(
|
|
600
|
+
str(item["uuid"])
|
|
601
|
+
for item in snapshot
|
|
602
|
+
if isinstance(item, dict) and item.get("uuid")
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def _set_meta(self, meta: dict[str, Any]) -> None:
|
|
606
|
+
"""Keep the in-memory metadata and the scan cache in step."""
|
|
607
|
+
self._meta = meta
|
|
608
|
+
self._meta_index = dict(meta)
|
|
609
|
+
self._meta_written = True
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _atomic_write_text(path: Path, content: str) -> None:
|
|
613
|
+
temp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
614
|
+
try:
|
|
615
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
616
|
+
f.write(content)
|
|
617
|
+
f.flush()
|
|
618
|
+
os.fsync(f.fileno())
|
|
619
|
+
replace_with_retry(temp_path, path)
|
|
620
|
+
finally:
|
|
621
|
+
try:
|
|
622
|
+
temp_path.unlink()
|
|
623
|
+
except FileNotFoundError:
|
|
624
|
+
pass
|
|
625
|
+
|
|
626
|
+
def write_agent_snapshots(self, snapshots: list[dict[str, Any]]) -> None:
|
|
627
|
+
"""Persist managed-agent metadata for this session."""
|
|
628
|
+
path = get_agent_meta_path(self.cwd, self.session_id)
|
|
629
|
+
try:
|
|
630
|
+
# Agent state can be persisted by several detached run tasks and
|
|
631
|
+
# by a concurrent gateway resume. Serialize the replace with the
|
|
632
|
+
# same sidecar lock used for the main transcript so readers never
|
|
633
|
+
# observe a half-written metadata file and writers cannot race on
|
|
634
|
+
# the temporary path.
|
|
635
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
636
|
+
self._assert_writable_locked()
|
|
637
|
+
self._ensure_dir()
|
|
638
|
+
with _transcript_file_lock(path, exclusive=True):
|
|
639
|
+
self._atomic_write_text(
|
|
640
|
+
path,
|
|
641
|
+
json.dumps(snapshots, ensure_ascii=False, indent=2) + "\n",
|
|
642
|
+
)
|
|
643
|
+
except Exception:
|
|
644
|
+
logger.warning("Failed to write agent snapshots: %s", path, exc_info=True)
|
|
645
|
+
|
|
646
|
+
def append_agent_messages(
|
|
647
|
+
self,
|
|
648
|
+
agent_id: str,
|
|
649
|
+
messages: list[Message],
|
|
650
|
+
) -> None:
|
|
651
|
+
"""Persist a managed agent's transcript."""
|
|
652
|
+
if not messages:
|
|
653
|
+
return
|
|
654
|
+
path = get_agent_transcript_path(self.cwd, self.session_id, agent_id)
|
|
655
|
+
lines = [
|
|
656
|
+
_dump_jsonl_line(_message_to_entry(message))
|
|
657
|
+
for message in messages
|
|
658
|
+
]
|
|
659
|
+
# A cancellation/finalization callback may race a normal transcript
|
|
660
|
+
# checkpoint for the same agent. Keep the full-file replace under a
|
|
661
|
+
# path lock, matching the main session transcript's cross-process
|
|
662
|
+
# discipline.
|
|
663
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
664
|
+
self._assert_writable_locked()
|
|
665
|
+
self._ensure_dir()
|
|
666
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
667
|
+
with _transcript_file_lock(path, exclusive=True):
|
|
668
|
+
self._atomic_write_text(path, "".join(lines))
|
|
669
|
+
|
|
670
|
+
def load_agent_messages(self, agent_id: str) -> list[dict[str, Any]]:
|
|
671
|
+
"""Load a managed agent transcript."""
|
|
672
|
+
path = get_agent_transcript_path(self.cwd, self.session_id, agent_id)
|
|
673
|
+
if not path.exists():
|
|
674
|
+
return []
|
|
675
|
+
messages: list[dict[str, Any]] = []
|
|
676
|
+
try:
|
|
677
|
+
with _transcript_file_lock(path, exclusive=False):
|
|
678
|
+
with open(path, encoding="utf-8") as f:
|
|
679
|
+
for line in f:
|
|
680
|
+
line = line.strip()
|
|
681
|
+
if not line:
|
|
682
|
+
continue
|
|
683
|
+
try:
|
|
684
|
+
entry = json.loads(line)
|
|
685
|
+
except json.JSONDecodeError:
|
|
686
|
+
continue
|
|
687
|
+
if isinstance(entry, dict):
|
|
688
|
+
messages.append(entry)
|
|
689
|
+
except Exception:
|
|
690
|
+
logger.warning("Failed to load agent messages: %s", path, exc_info=True)
|
|
691
|
+
return []
|
|
692
|
+
return messages
|
|
693
|
+
|
|
694
|
+
def load_agent_snapshots(self) -> list[dict[str, Any]]:
|
|
695
|
+
"""Load managed-agent metadata for this session."""
|
|
696
|
+
path = get_agent_meta_path(self.cwd, self.session_id)
|
|
697
|
+
if not path.exists():
|
|
698
|
+
return []
|
|
699
|
+
try:
|
|
700
|
+
with _transcript_file_lock(path, exclusive=False):
|
|
701
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
702
|
+
except Exception:
|
|
703
|
+
logger.warning("Failed to load agent snapshots: %s", path, exc_info=True)
|
|
704
|
+
return []
|
|
705
|
+
if isinstance(raw, list):
|
|
706
|
+
return [item for item in raw if isinstance(item, dict)]
|
|
707
|
+
return []
|
|
708
|
+
|
|
709
|
+
def write_meta(
|
|
710
|
+
self,
|
|
711
|
+
*,
|
|
712
|
+
model: str = "",
|
|
713
|
+
provider: str = "",
|
|
714
|
+
first_user_message: str = "",
|
|
715
|
+
model_profile: str | None = None,
|
|
716
|
+
) -> None:
|
|
717
|
+
"""Write the session_meta line to the JSONL file and upsert into SQLite.
|
|
718
|
+
|
|
719
|
+
Called when the session starts and again when the first user message
|
|
720
|
+
is known (to set first_user_message / title).
|
|
721
|
+
"""
|
|
722
|
+
git_info = _get_git_info(self.cwd)
|
|
723
|
+
now = datetime.now(timezone.utc)
|
|
724
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
725
|
+
self._assert_writable_locked()
|
|
726
|
+
current = self._read_latest_meta_locked()
|
|
727
|
+
if current:
|
|
728
|
+
self._set_meta(current)
|
|
729
|
+
if not first_user_message:
|
|
730
|
+
return
|
|
731
|
+
fields = {
|
|
732
|
+
"first_user_message": first_user_message[:500],
|
|
733
|
+
"title": first_user_message[:200],
|
|
734
|
+
"updated_at": now.isoformat(),
|
|
735
|
+
}
|
|
736
|
+
self._meta.update(fields)
|
|
737
|
+
self._meta_index.update(fields)
|
|
738
|
+
self._append_transcript_line_locked({"type": "session_meta", **fields})
|
|
739
|
+
else:
|
|
740
|
+
self._set_meta(
|
|
741
|
+
self._new_meta(
|
|
742
|
+
model=model,
|
|
743
|
+
provider=provider,
|
|
744
|
+
first_user_message=first_user_message,
|
|
745
|
+
git_info=git_info,
|
|
746
|
+
now=now,
|
|
747
|
+
model_profile=model_profile,
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
self._append_transcript_line_locked({"type": "session_meta", **self._meta})
|
|
751
|
+
self._upsert_meta_locked()
|
|
752
|
+
|
|
753
|
+
def _new_meta(
|
|
754
|
+
self,
|
|
755
|
+
*,
|
|
756
|
+
model: str = "",
|
|
757
|
+
provider: str = "",
|
|
758
|
+
first_user_message: str = "",
|
|
759
|
+
git_info: dict[str, str | None] | None = None,
|
|
760
|
+
now: datetime | None = None,
|
|
761
|
+
model_profile: str | None = None,
|
|
762
|
+
) -> dict[str, Any]:
|
|
763
|
+
now = now or datetime.now(timezone.utc)
|
|
764
|
+
git_info = git_info or _get_git_info(self.cwd)
|
|
765
|
+
meta = {
|
|
766
|
+
"id": self.session_id,
|
|
767
|
+
"title": first_user_message[:200] if first_user_message else "",
|
|
768
|
+
"cwd": self.cwd,
|
|
769
|
+
"model": model,
|
|
770
|
+
"provider": provider,
|
|
771
|
+
"first_user_message": first_user_message[:500] if first_user_message else "",
|
|
772
|
+
"tokens_used": 0,
|
|
773
|
+
"git_branch": git_info["git_branch"],
|
|
774
|
+
"git_sha": git_info["git_sha"],
|
|
775
|
+
"created_at": now.isoformat(),
|
|
776
|
+
"updated_at": now.isoformat(),
|
|
777
|
+
"message_count": 0,
|
|
778
|
+
"is_archived": False,
|
|
779
|
+
}
|
|
780
|
+
# Absent means an older transcript. An empty string means the session
|
|
781
|
+
# was using the unnamed base API rather than a named profile.
|
|
782
|
+
if model_profile is not None:
|
|
783
|
+
meta["model_profile"] = model_profile
|
|
784
|
+
return meta
|
|
785
|
+
|
|
786
|
+
def _read_latest_meta_locked(self) -> dict[str, Any]:
|
|
787
|
+
"""Read merged metadata while the lifecycle lock prevents concurrent writes.
|
|
788
|
+
|
|
789
|
+
Only bytes appended since the previous scan are parsed; the result is
|
|
790
|
+
the same merge over every ``session_meta`` record that a full read
|
|
791
|
+
produced, including records written by another process or before this
|
|
792
|
+
instance existed (which ``load_messages`` folds into the index).
|
|
793
|
+
"""
|
|
794
|
+
if not self._transcript_path.exists():
|
|
795
|
+
self._reset_scan_state()
|
|
796
|
+
return {}
|
|
797
|
+
with _transcript_file_lock(self._transcript_path, exclusive=False):
|
|
798
|
+
try:
|
|
799
|
+
entries = self._scan_new_entries_locked()
|
|
800
|
+
except FileNotFoundError:
|
|
801
|
+
self._reset_scan_state()
|
|
802
|
+
return {}
|
|
803
|
+
self._apply_index_entries(entries)
|
|
804
|
+
return dict(self._meta_index)
|
|
805
|
+
|
|
806
|
+
def _upsert_meta_locked(self) -> None:
|
|
807
|
+
"""Mirror the current JSONL metadata into SQLite before releasing the lock."""
|
|
808
|
+
store = None
|
|
809
|
+
try:
|
|
810
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
811
|
+
|
|
812
|
+
store = SessionMetaStore()
|
|
813
|
+
store.upsert(dict(self._meta))
|
|
814
|
+
except Exception:
|
|
815
|
+
logger.warning("Failed to persist session metadata to SQLite", exc_info=True)
|
|
816
|
+
finally:
|
|
817
|
+
if store is not None:
|
|
818
|
+
try:
|
|
819
|
+
store.close()
|
|
820
|
+
except Exception:
|
|
821
|
+
logger.debug("Failed to close session metadata store", exc_info=True)
|
|
822
|
+
|
|
823
|
+
def _commit_metadata_update(
|
|
824
|
+
self,
|
|
825
|
+
update: Callable[[dict[str, Any]], dict[str, Any]],
|
|
826
|
+
) -> None:
|
|
827
|
+
"""Atomically merge one metadata change into JSONL and its SQLite index."""
|
|
828
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
829
|
+
self._assert_writable_locked()
|
|
830
|
+
current = self._read_latest_meta_locked()
|
|
831
|
+
initialized = not current
|
|
832
|
+
if initialized:
|
|
833
|
+
current = self._new_meta()
|
|
834
|
+
fields = update(current)
|
|
835
|
+
current.update(fields)
|
|
836
|
+
self._set_meta(current)
|
|
837
|
+
self._meta_index.update(fields)
|
|
838
|
+
entry = self._meta if initialized else fields
|
|
839
|
+
self._append_transcript_line_locked({"type": "session_meta", **entry})
|
|
840
|
+
self._upsert_meta_locked()
|
|
841
|
+
|
|
842
|
+
def append_message(self, message: Message) -> None:
|
|
843
|
+
"""Append a message to the session transcript (skips duplicates by uuid)."""
|
|
844
|
+
if not self._scan_cursor_valid and self._transcript_path.exists():
|
|
845
|
+
self.load_messages(full_history=True)
|
|
846
|
+
if message.uuid in self._written_uuids:
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
entry = _message_to_entry(message)
|
|
850
|
+
message_uuid = message.uuid
|
|
851
|
+
|
|
852
|
+
# Keep the duplicate check under the same lifecycle and transcript
|
|
853
|
+
# locks as the append. The incremental tail scan handles a writer in
|
|
854
|
+
# another storage instance without rescanning the historical prefix.
|
|
855
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
856
|
+
self._assert_writable_locked()
|
|
857
|
+
with _transcript_file_lock(self._transcript_path, exclusive=True):
|
|
858
|
+
entries = self._scan_new_entries_locked()
|
|
859
|
+
self._apply_index_entries(entries)
|
|
860
|
+
if message_uuid in self._written_uuids:
|
|
861
|
+
return
|
|
862
|
+
self._ensure_dir()
|
|
863
|
+
with open(self._transcript_path, "a", encoding="utf-8") as transcript:
|
|
864
|
+
transcript.write(_dump_jsonl_line(entry))
|
|
865
|
+
transcript.flush()
|
|
866
|
+
os.fsync(transcript.fileno())
|
|
867
|
+
self._apply_index_entries([entry])
|
|
868
|
+
self._mark_scan_eof_locked(transcript)
|
|
869
|
+
self._written_uuids.add(message_uuid)
|
|
870
|
+
|
|
871
|
+
def record_callback_delivery(
|
|
872
|
+
self,
|
|
873
|
+
*,
|
|
874
|
+
agent_id: str,
|
|
875
|
+
callback_epoch: int,
|
|
876
|
+
callback_message_id: str,
|
|
877
|
+
assistant_uuid: str,
|
|
878
|
+
) -> bool:
|
|
879
|
+
"""Persist an idempotent callback receipt outside the compacted context."""
|
|
880
|
+
key = (agent_id, callback_epoch, callback_message_id)
|
|
881
|
+
if not self._callback_deliveries_loaded:
|
|
882
|
+
self.load_callback_deliveries()
|
|
883
|
+
if key in self._callback_deliveries:
|
|
884
|
+
return True
|
|
885
|
+
|
|
886
|
+
entry = {
|
|
887
|
+
"type": "callback_delivery",
|
|
888
|
+
"session_id": self.session_id,
|
|
889
|
+
"agent_id": agent_id,
|
|
890
|
+
"callback_epoch": callback_epoch,
|
|
891
|
+
"callback_message_id": callback_message_id,
|
|
892
|
+
"assistant_uuid": assistant_uuid,
|
|
893
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
894
|
+
}
|
|
895
|
+
try:
|
|
896
|
+
path = get_callback_deliveries_path(self.cwd, self.session_id)
|
|
897
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
898
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
899
|
+
with _transcript_file_lock(path, exclusive=True):
|
|
900
|
+
if path.exists():
|
|
901
|
+
for existing in self._parse_transcript_lines(
|
|
902
|
+
_decode_transcript(path.read_bytes())
|
|
903
|
+
):
|
|
904
|
+
if (
|
|
905
|
+
existing.get("agent_id") == agent_id
|
|
906
|
+
and existing.get("callback_epoch") == callback_epoch
|
|
907
|
+
and existing.get("callback_message_id") == callback_message_id
|
|
908
|
+
):
|
|
909
|
+
self._callback_deliveries[key] = existing
|
|
910
|
+
return True
|
|
911
|
+
with open(path, "a", encoding="utf-8") as transcript:
|
|
912
|
+
transcript.write(_dump_jsonl_line(entry))
|
|
913
|
+
transcript.flush()
|
|
914
|
+
os.fsync(transcript.fileno())
|
|
915
|
+
except Exception:
|
|
916
|
+
logger.warning(
|
|
917
|
+
"Failed to record callback delivery for agent %s epoch %d",
|
|
918
|
+
agent_id,
|
|
919
|
+
callback_epoch,
|
|
920
|
+
exc_info=True,
|
|
921
|
+
)
|
|
922
|
+
return False
|
|
923
|
+
self._callback_deliveries[key] = entry
|
|
924
|
+
return True
|
|
925
|
+
|
|
926
|
+
def has_callback_delivery(
|
|
927
|
+
self,
|
|
928
|
+
*,
|
|
929
|
+
agent_id: str,
|
|
930
|
+
callback_epoch: int,
|
|
931
|
+
callback_message_id: str,
|
|
932
|
+
) -> bool:
|
|
933
|
+
if not self._callback_deliveries_loaded:
|
|
934
|
+
self.load_callback_deliveries()
|
|
935
|
+
return (
|
|
936
|
+
agent_id,
|
|
937
|
+
callback_epoch,
|
|
938
|
+
callback_message_id,
|
|
939
|
+
) in self._callback_deliveries
|
|
940
|
+
|
|
941
|
+
def load_callback_deliveries(self) -> list[dict[str, Any]]:
|
|
942
|
+
"""Load durable callback receipts without affecting active message projection."""
|
|
943
|
+
deliveries: dict[tuple[str, int, str], dict[str, Any]] = {}
|
|
944
|
+
path = get_callback_deliveries_path(self.cwd, self.session_id)
|
|
945
|
+
raw = b""
|
|
946
|
+
sidecar_exists = False
|
|
947
|
+
legacy_source = False
|
|
948
|
+
try:
|
|
949
|
+
with _transcript_file_lock(path, exclusive=False):
|
|
950
|
+
sidecar_exists = path.exists()
|
|
951
|
+
if sidecar_exists:
|
|
952
|
+
raw = path.read_bytes()
|
|
953
|
+
except OSError:
|
|
954
|
+
logger.warning("Failed to read callback deliveries: %s", path, exc_info=True)
|
|
955
|
+
# Callback receipts were historically mixed into the main transcript.
|
|
956
|
+
# Read that legacy location only when the sidecar does not exist; new
|
|
957
|
+
# sessions never pay the cost of scanning their (potentially huge)
|
|
958
|
+
# conversation file.
|
|
959
|
+
if not raw and not sidecar_exists and self._transcript_path.exists():
|
|
960
|
+
raw = self._read_transcript_text().encode("utf-8")
|
|
961
|
+
legacy_source = True
|
|
962
|
+
if raw:
|
|
963
|
+
try:
|
|
964
|
+
for entry in self._parse_transcript_lines(_decode_transcript(raw)):
|
|
965
|
+
if entry.get("type") != "callback_delivery":
|
|
966
|
+
continue
|
|
967
|
+
agent_id = str(entry.get("agent_id") or "")
|
|
968
|
+
message_id = str(entry.get("callback_message_id") or "")
|
|
969
|
+
try:
|
|
970
|
+
epoch = int(entry.get("callback_epoch", 0))
|
|
971
|
+
except (TypeError, ValueError):
|
|
972
|
+
continue
|
|
973
|
+
if agent_id and message_id:
|
|
974
|
+
deliveries.setdefault((agent_id, epoch, message_id), entry)
|
|
975
|
+
except Exception:
|
|
976
|
+
logger.warning(
|
|
977
|
+
"Failed to load callback deliveries: %s",
|
|
978
|
+
self._transcript_path,
|
|
979
|
+
exc_info=True,
|
|
980
|
+
)
|
|
981
|
+
if legacy_source and deliveries:
|
|
982
|
+
# Migrate once so future resumes never need to re-read the large
|
|
983
|
+
# conversation transcript. Preserve only callback records in the
|
|
984
|
+
# sidecar; the historical originals remain available for audit.
|
|
985
|
+
try:
|
|
986
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
987
|
+
with _transcript_file_lock(path, exclusive=True):
|
|
988
|
+
self._atomic_write_text(
|
|
989
|
+
path,
|
|
990
|
+
"".join(_dump_jsonl_line(entry) for entry in deliveries.values()),
|
|
991
|
+
)
|
|
992
|
+
except OSError:
|
|
993
|
+
logger.warning("Failed to migrate callback deliveries: %s", path, exc_info=True)
|
|
994
|
+
self._callback_deliveries = deliveries
|
|
995
|
+
self._callback_deliveries_loaded = True
|
|
996
|
+
return list(deliveries.values())
|
|
997
|
+
|
|
998
|
+
def append_compaction(
|
|
999
|
+
self,
|
|
1000
|
+
messages: list[Message],
|
|
1001
|
+
*,
|
|
1002
|
+
trigger: str,
|
|
1003
|
+
messages_before: int,
|
|
1004
|
+
estimated_tokens_before: int = 0,
|
|
1005
|
+
estimated_tokens_after: int = 0,
|
|
1006
|
+
) -> str | None:
|
|
1007
|
+
"""Durably commit an active-context checkpoint in one JSONL record."""
|
|
1008
|
+
if not messages or not messages[0].is_compact_summary:
|
|
1009
|
+
return None
|
|
1010
|
+
checkpoint_id = str(uuid.uuid4())
|
|
1011
|
+
snapshot = [_message_to_entry(message) for message in messages]
|
|
1012
|
+
entry = {
|
|
1013
|
+
"type": "compact_boundary",
|
|
1014
|
+
"checkpoint_id": checkpoint_id,
|
|
1015
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1016
|
+
"trigger": trigger,
|
|
1017
|
+
"messages_before": messages_before,
|
|
1018
|
+
"messages_after": len(messages),
|
|
1019
|
+
"estimated_tokens_before": max(0, estimated_tokens_before),
|
|
1020
|
+
"estimated_tokens_after": max(0, estimated_tokens_after),
|
|
1021
|
+
"tail_start_uuid": messages[1].uuid if len(messages) > 1 else None,
|
|
1022
|
+
"summary_uuid": messages[0].uuid,
|
|
1023
|
+
"messages": snapshot,
|
|
1024
|
+
}
|
|
1025
|
+
self._append_transcript_line(entry)
|
|
1026
|
+
for message in messages:
|
|
1027
|
+
self._written_uuids.add(message.uuid)
|
|
1028
|
+
self.compact_count += 1
|
|
1029
|
+
return checkpoint_id
|
|
1030
|
+
|
|
1031
|
+
def append_projection(
|
|
1032
|
+
self,
|
|
1033
|
+
messages: list[Message],
|
|
1034
|
+
*,
|
|
1035
|
+
trigger: str,
|
|
1036
|
+
messages_before: int,
|
|
1037
|
+
) -> str | None:
|
|
1038
|
+
"""Durably replace active context without claiming a model summary.
|
|
1039
|
+
|
|
1040
|
+
This is used when oversized historical tool output can be pruned enough
|
|
1041
|
+
to avoid a summarization request. The rewritten messages retain their
|
|
1042
|
+
UUIDs, so ordinary append de-duplication cannot persist the new content.
|
|
1043
|
+
"""
|
|
1044
|
+
if not messages:
|
|
1045
|
+
return None
|
|
1046
|
+
projection_id = str(uuid.uuid4())
|
|
1047
|
+
self._append_transcript_line(
|
|
1048
|
+
{
|
|
1049
|
+
"type": "projection_boundary",
|
|
1050
|
+
"projection_id": projection_id,
|
|
1051
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1052
|
+
"trigger": trigger,
|
|
1053
|
+
"messages_before": messages_before,
|
|
1054
|
+
"messages_after": len(messages),
|
|
1055
|
+
"messages": [_message_to_entry(message) for message in messages],
|
|
1056
|
+
}
|
|
1057
|
+
)
|
|
1058
|
+
for message in messages:
|
|
1059
|
+
self._written_uuids.add(message.uuid)
|
|
1060
|
+
return projection_id
|
|
1061
|
+
|
|
1062
|
+
def append_clear_boundary(self, *, messages_before: int) -> str:
|
|
1063
|
+
"""Durably replace the active conversation projection with an empty one.
|
|
1064
|
+
|
|
1065
|
+
Ordinary transcript messages remain available to audit/export readers,
|
|
1066
|
+
while future session resumes start from an empty active context.
|
|
1067
|
+
"""
|
|
1068
|
+
clear_id = str(uuid.uuid4())
|
|
1069
|
+
self._append_transcript_line(
|
|
1070
|
+
{
|
|
1071
|
+
"type": "clear_boundary",
|
|
1072
|
+
"clear_id": clear_id,
|
|
1073
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1074
|
+
"messages_before": max(0, int(messages_before)),
|
|
1075
|
+
}
|
|
1076
|
+
)
|
|
1077
|
+
self._written_uuids.clear()
|
|
1078
|
+
return clear_id
|
|
1079
|
+
|
|
1080
|
+
def persist_archive_marker(self) -> None:
|
|
1081
|
+
"""Persist terminal external and inline archive tombstones."""
|
|
1082
|
+
entry = {
|
|
1083
|
+
"type": "session_archive",
|
|
1084
|
+
"session_id": self.session_id,
|
|
1085
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1086
|
+
}
|
|
1087
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
1088
|
+
self._write_tombstone_locked()
|
|
1089
|
+
if self._transcript_path.exists():
|
|
1090
|
+
if "session_archive" not in self._lifecycle_entry_types:
|
|
1091
|
+
self._append_transcript_line_if_absent_locked(
|
|
1092
|
+
entry,
|
|
1093
|
+
lambda existing: existing.get("type") == "session_archive",
|
|
1094
|
+
)
|
|
1095
|
+
self._archive_state_checked = True
|
|
1096
|
+
self._meta["is_archived"] = True
|
|
1097
|
+
|
|
1098
|
+
def restore_from_archive(self) -> None:
|
|
1099
|
+
"""Remove archive fences so an explicitly recovered session is writable."""
|
|
1100
|
+
with _session_lifecycle_lock(self.cwd, self.session_id):
|
|
1101
|
+
marker = get_session_tombstone_path(self.cwd, self.session_id)
|
|
1102
|
+
marker.unlink(missing_ok=True)
|
|
1103
|
+
if self._transcript_path.exists():
|
|
1104
|
+
# Keep the audit record, but make the durable session active
|
|
1105
|
+
# again for a user-requested recovery.
|
|
1106
|
+
if "session_restore" not in self._lifecycle_entry_types:
|
|
1107
|
+
self._append_transcript_line_if_absent_locked(
|
|
1108
|
+
{
|
|
1109
|
+
"type": "session_restore",
|
|
1110
|
+
"session_id": self.session_id,
|
|
1111
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1112
|
+
},
|
|
1113
|
+
lambda existing: existing.get("type") == "session_restore",
|
|
1114
|
+
)
|
|
1115
|
+
try:
|
|
1116
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1117
|
+
store = SessionMetaStore()
|
|
1118
|
+
try:
|
|
1119
|
+
store.restore(self.session_id)
|
|
1120
|
+
finally:
|
|
1121
|
+
store.close()
|
|
1122
|
+
except Exception:
|
|
1123
|
+
logger.warning("Failed to clear restored session metadata", exc_info=True)
|
|
1124
|
+
self._archive_state_checked = False
|
|
1125
|
+
self._meta["is_archived"] = False
|
|
1126
|
+
|
|
1127
|
+
def load_messages(
|
|
1128
|
+
self,
|
|
1129
|
+
*,
|
|
1130
|
+
full_history: bool = False,
|
|
1131
|
+
_transcript_text: str | None = None,
|
|
1132
|
+
) -> list[dict[str, Any]]:
|
|
1133
|
+
"""Load the active context, or the durable audit history when requested.
|
|
1134
|
+
|
|
1135
|
+
A completed ``compact_boundary`` atomically replaces the active projection
|
|
1136
|
+
with its embedded checkpoint snapshot. Earlier ordinary messages remain in
|
|
1137
|
+
the transcript for export and audit, but are not replayed to the model.
|
|
1138
|
+
"""
|
|
1139
|
+
# These counters describe this transcript read. Reset them even when
|
|
1140
|
+
# the file is missing or has been truncated so a reused storage object
|
|
1141
|
+
# cannot expose values from an earlier load.
|
|
1142
|
+
self.last_context_token_source = "estimated"
|
|
1143
|
+
self.last_context_token_baseline = None
|
|
1144
|
+
self.last_context_used_tokens = 0
|
|
1145
|
+
self.last_context_window_tokens = 0
|
|
1146
|
+
self.compact_count = 0
|
|
1147
|
+
self._written_uuids = set()
|
|
1148
|
+
self._reset_scan_state()
|
|
1149
|
+
self._meta = {}
|
|
1150
|
+
self._meta_written = False
|
|
1151
|
+
transcript_bytes: bytes | None = None
|
|
1152
|
+
transcript_inode: int | None = None
|
|
1153
|
+
if _transcript_text is None:
|
|
1154
|
+
transcript_bytes, transcript_inode = self._read_transcript_bytes()
|
|
1155
|
+
transcript = _decode_transcript(transcript_bytes)
|
|
1156
|
+
else:
|
|
1157
|
+
transcript = _transcript_text
|
|
1158
|
+
if not transcript:
|
|
1159
|
+
self._reset_scan_state()
|
|
1160
|
+
if get_session_tombstone_path(self.cwd, self.session_id).exists():
|
|
1161
|
+
self._set_meta({
|
|
1162
|
+
"id": self.session_id,
|
|
1163
|
+
"cwd": self.cwd,
|
|
1164
|
+
"is_archived": True,
|
|
1165
|
+
})
|
|
1166
|
+
return []
|
|
1167
|
+
|
|
1168
|
+
all_messages: list[dict[str, Any]] = []
|
|
1169
|
+
active_messages: list[dict[str, Any]] = []
|
|
1170
|
+
seen: set[str] = set()
|
|
1171
|
+
active_seen: set[str] = set()
|
|
1172
|
+
boundary_seen = False
|
|
1173
|
+
rollback_seen = False
|
|
1174
|
+
archived = False
|
|
1175
|
+
meta: dict[str, Any] = {}
|
|
1176
|
+
checkpoints: dict[str, dict[str, Any]] = {}
|
|
1177
|
+
checkpoint_projections: dict[str, list[dict[str, Any]]] = {}
|
|
1178
|
+
try:
|
|
1179
|
+
for line in transcript.splitlines():
|
|
1180
|
+
line = line.strip()
|
|
1181
|
+
if not line:
|
|
1182
|
+
continue
|
|
1183
|
+
try:
|
|
1184
|
+
entry = json.loads(line)
|
|
1185
|
+
except json.JSONDecodeError:
|
|
1186
|
+
continue
|
|
1187
|
+
|
|
1188
|
+
if not isinstance(entry, dict):
|
|
1189
|
+
continue
|
|
1190
|
+
|
|
1191
|
+
if entry.get("type") == "session_archive":
|
|
1192
|
+
archived = True
|
|
1193
|
+
self._lifecycle_entry_types.add("session_archive")
|
|
1194
|
+
continue
|
|
1195
|
+
if entry.get("type") == "session_restore":
|
|
1196
|
+
archived = False
|
|
1197
|
+
self._lifecycle_entry_types.add("session_restore")
|
|
1198
|
+
continue
|
|
1199
|
+
|
|
1200
|
+
# Capture the session_meta line but don't add it as a message.
|
|
1201
|
+
if entry.get("type") == "session_meta":
|
|
1202
|
+
# Metadata updates may intentionally contain only the
|
|
1203
|
+
# fields that changed (for example model/provider).
|
|
1204
|
+
# Merge them over the previous record so counters and
|
|
1205
|
+
# titles are not lost when rebuilding from JSONL.
|
|
1206
|
+
meta.update({k: v for k, v in entry.items() if k != "type"})
|
|
1207
|
+
continue
|
|
1208
|
+
|
|
1209
|
+
# Capture context_usage lines (written at turn_complete) but don't add as message
|
|
1210
|
+
if entry.get("type") == "context_usage":
|
|
1211
|
+
source = entry.get("source", "estimated")
|
|
1212
|
+
self.last_context_token_source = source if isinstance(source, str) and source in {"server", "calibrated", "estimated"} else "estimated"
|
|
1213
|
+
self.last_context_token_baseline = entry.get("baseline")
|
|
1214
|
+
# A truncated or hand-edited transcript must not prevent
|
|
1215
|
+
# later messages from loading. Keep each counter's last
|
|
1216
|
+
# valid value when its counterpart is malformed.
|
|
1217
|
+
try:
|
|
1218
|
+
self.last_context_used_tokens = int(entry.get("used_tokens", 0))
|
|
1219
|
+
except (TypeError, ValueError, OverflowError):
|
|
1220
|
+
pass
|
|
1221
|
+
try:
|
|
1222
|
+
self.last_context_window_tokens = int(entry.get("window_tokens", 0))
|
|
1223
|
+
except (TypeError, ValueError, OverflowError):
|
|
1224
|
+
pass
|
|
1225
|
+
# Old anchors trusted every positive provider count. They
|
|
1226
|
+
# can contain a tiny proxy placeholder for a large request.
|
|
1227
|
+
# Invalidate the display together with the anchor, leaving
|
|
1228
|
+
# the original transcript/billing usage intact for audit.
|
|
1229
|
+
baseline = self.last_context_token_baseline
|
|
1230
|
+
if isinstance(baseline, dict) and baseline.get("version") == 1:
|
|
1231
|
+
self.last_context_used_tokens = 0
|
|
1232
|
+
self.last_context_token_source = "estimated"
|
|
1233
|
+
self.last_context_token_baseline = None
|
|
1234
|
+
continue
|
|
1235
|
+
|
|
1236
|
+
if entry.get("type") in {"compact_boundary", "projection_boundary"}:
|
|
1237
|
+
is_compaction = entry.get("type") == "compact_boundary"
|
|
1238
|
+
self.last_context_token_source = "estimated"
|
|
1239
|
+
self.last_context_token_baseline = None
|
|
1240
|
+
self.last_context_used_tokens = 0
|
|
1241
|
+
snapshot = entry.get("messages")
|
|
1242
|
+
if not isinstance(snapshot, list):
|
|
1243
|
+
continue
|
|
1244
|
+
restored = [
|
|
1245
|
+
item for item in snapshot
|
|
1246
|
+
if isinstance(item, dict) and _is_message_entry(item)
|
|
1247
|
+
]
|
|
1248
|
+
if not restored or (
|
|
1249
|
+
is_compaction and not restored[0].get("is_compact_summary")
|
|
1250
|
+
):
|
|
1251
|
+
continue
|
|
1252
|
+
active_messages = []
|
|
1253
|
+
active_seen = set()
|
|
1254
|
+
for item in restored:
|
|
1255
|
+
item_uuid = str(item.get("uuid") or "")
|
|
1256
|
+
was_seen = bool(item_uuid and item_uuid in seen)
|
|
1257
|
+
if item_uuid and item_uuid in active_seen:
|
|
1258
|
+
continue
|
|
1259
|
+
if item_uuid:
|
|
1260
|
+
active_seen.add(item_uuid)
|
|
1261
|
+
seen.add(item_uuid)
|
|
1262
|
+
active_messages.append(item)
|
|
1263
|
+
# A boundary can contain messages produced earlier in
|
|
1264
|
+
# the current agentic turn. They may not have ordinary
|
|
1265
|
+
# JSONL records yet, so retain their first appearance in
|
|
1266
|
+
# the audit/export view as well as in active context.
|
|
1267
|
+
if full_history and (not item_uuid or not was_seen):
|
|
1268
|
+
all_messages.append(item)
|
|
1269
|
+
boundary_seen = True
|
|
1270
|
+
if is_compaction:
|
|
1271
|
+
self.compact_count += 1
|
|
1272
|
+
continue
|
|
1273
|
+
|
|
1274
|
+
if entry.get("type") == "clear_boundary":
|
|
1275
|
+
active_messages = []
|
|
1276
|
+
active_seen = set()
|
|
1277
|
+
boundary_seen = True
|
|
1278
|
+
self.last_context_used_tokens = 0
|
|
1279
|
+
self.last_context_token_source = "estimated"
|
|
1280
|
+
self.last_context_token_baseline = None
|
|
1281
|
+
self.last_context_window_tokens = 0
|
|
1282
|
+
self.compact_count = 0
|
|
1283
|
+
continue
|
|
1284
|
+
|
|
1285
|
+
if entry.get("type") == "checkpoint":
|
|
1286
|
+
checkpoint_id = str(entry.get("checkpoint_id") or "")
|
|
1287
|
+
if checkpoint_id:
|
|
1288
|
+
checkpoints[checkpoint_id] = entry
|
|
1289
|
+
snapshot = entry.get("messages")
|
|
1290
|
+
if isinstance(snapshot, list):
|
|
1291
|
+
restored = [
|
|
1292
|
+
item
|
|
1293
|
+
for item in snapshot
|
|
1294
|
+
if isinstance(item, dict) and _is_message_entry(item)
|
|
1295
|
+
]
|
|
1296
|
+
target_uuid = str(entry.get("message_uuid") or "")
|
|
1297
|
+
if restored and (
|
|
1298
|
+
not target_uuid
|
|
1299
|
+
or any(item.get("uuid") == target_uuid for item in restored)
|
|
1300
|
+
):
|
|
1301
|
+
checkpoint_projections[checkpoint_id] = restored
|
|
1302
|
+
continue
|
|
1303
|
+
if checkpoint_id not in checkpoint_projections:
|
|
1304
|
+
# Older markers do not embed their projection. Keep
|
|
1305
|
+
# the active state observed at marker time as the
|
|
1306
|
+
# compatibility and malformed-snapshot fallback.
|
|
1307
|
+
checkpoint_projections[checkpoint_id] = list(active_messages)
|
|
1308
|
+
continue
|
|
1309
|
+
|
|
1310
|
+
if entry.get("type") == "rollback":
|
|
1311
|
+
self.last_context_token_source = "estimated"
|
|
1312
|
+
self.last_context_token_baseline = None
|
|
1313
|
+
self.last_context_used_tokens = 0
|
|
1314
|
+
# Rollback markers are durable state transitions, not
|
|
1315
|
+
# messages. Apply the UUID target when available; this is
|
|
1316
|
+
# stable even if unrelated metadata records were appended
|
|
1317
|
+
# after the checkpoint. Older markers only carried an
|
|
1318
|
+
# index, so retain the checkpoint/index fallback.
|
|
1319
|
+
checkpoint_id = str(entry.get("checkpoint_id") or "")
|
|
1320
|
+
rollback_seen = True
|
|
1321
|
+
checkpoint = checkpoints.get(checkpoint_id, {})
|
|
1322
|
+
checkpoint_projection = checkpoint_projections.get(checkpoint_id)
|
|
1323
|
+
if checkpoint_projection is not None:
|
|
1324
|
+
active_messages = list(checkpoint_projection)
|
|
1325
|
+
active_seen = {
|
|
1326
|
+
str(item.get("uuid"))
|
|
1327
|
+
for item in active_messages
|
|
1328
|
+
if item.get("uuid")
|
|
1329
|
+
}
|
|
1330
|
+
continue
|
|
1331
|
+
target_uuid = str(
|
|
1332
|
+
entry.get("message_uuid")
|
|
1333
|
+
or checkpoint.get("message_uuid")
|
|
1334
|
+
or ""
|
|
1335
|
+
)
|
|
1336
|
+
target_position: int | None = None
|
|
1337
|
+
if target_uuid:
|
|
1338
|
+
target_position = next(
|
|
1339
|
+
(
|
|
1340
|
+
index
|
|
1341
|
+
for index, item in enumerate(active_messages)
|
|
1342
|
+
if item.get("uuid") == target_uuid
|
|
1343
|
+
),
|
|
1344
|
+
None,
|
|
1345
|
+
)
|
|
1346
|
+
if target_uuid:
|
|
1347
|
+
# A UUID is stable across metadata records and compact
|
|
1348
|
+
# boundaries. If it is no longer in the active
|
|
1349
|
+
# projection, the checkpoint cannot be replayed safely;
|
|
1350
|
+
# never guess using a stale pre-compaction index.
|
|
1351
|
+
if target_position is not None:
|
|
1352
|
+
active_messages = active_messages[: target_position + 1]
|
|
1353
|
+
else:
|
|
1354
|
+
continue
|
|
1355
|
+
else:
|
|
1356
|
+
try:
|
|
1357
|
+
rollback_index = int(
|
|
1358
|
+
entry.get(
|
|
1359
|
+
"rollback_to_index",
|
|
1360
|
+
checkpoint.get("message_index", -1),
|
|
1361
|
+
)
|
|
1362
|
+
)
|
|
1363
|
+
except (TypeError, ValueError, OverflowError):
|
|
1364
|
+
rollback_index = None
|
|
1365
|
+
if rollback_index is not None:
|
|
1366
|
+
active_messages = active_messages[: rollback_index + 1]
|
|
1367
|
+
active_seen = {
|
|
1368
|
+
str(item.get("uuid"))
|
|
1369
|
+
for item in active_messages
|
|
1370
|
+
if item.get("uuid")
|
|
1371
|
+
}
|
|
1372
|
+
continue
|
|
1373
|
+
|
|
1374
|
+
# Future metadata records must never be reconstructed as empty
|
|
1375
|
+
# user messages.
|
|
1376
|
+
if not _is_message_entry(entry):
|
|
1377
|
+
continue
|
|
1378
|
+
|
|
1379
|
+
msg_uuid = entry.get("uuid", "")
|
|
1380
|
+
if msg_uuid and msg_uuid in seen:
|
|
1381
|
+
# A normal record written after a boundary can duplicate a
|
|
1382
|
+
# message embedded in that boundary; keep only one active copy.
|
|
1383
|
+
if msg_uuid not in active_seen:
|
|
1384
|
+
active_seen.add(msg_uuid)
|
|
1385
|
+
active_messages.append(entry)
|
|
1386
|
+
continue
|
|
1387
|
+
if msg_uuid:
|
|
1388
|
+
seen.add(msg_uuid)
|
|
1389
|
+
all_messages.append(entry)
|
|
1390
|
+
if not msg_uuid or msg_uuid not in active_seen:
|
|
1391
|
+
if msg_uuid:
|
|
1392
|
+
active_seen.add(msg_uuid)
|
|
1393
|
+
active_messages.append(entry)
|
|
1394
|
+
except Exception:
|
|
1395
|
+
logger.warning("Failed to load transcript: %s", self._transcript_path, exc_info=True)
|
|
1396
|
+
|
|
1397
|
+
self._written_uuids = seen
|
|
1398
|
+
if archived or get_session_tombstone_path(self.cwd, self.session_id).exists():
|
|
1399
|
+
meta["is_archived"] = True
|
|
1400
|
+
if meta:
|
|
1401
|
+
self._set_meta(meta)
|
|
1402
|
+
# ``_transcript_text`` is used by fork/import callers and does not
|
|
1403
|
+
# necessarily represent this storage object's on-disk file. Only mark
|
|
1404
|
+
# the cursor complete when the bytes came from the actual transcript.
|
|
1405
|
+
if _transcript_text is None:
|
|
1406
|
+
if transcript_inode is not None and transcript_bytes is not None:
|
|
1407
|
+
self._scan_inode = transcript_inode
|
|
1408
|
+
self._scan_offset = _complete_prefix_length(transcript_bytes)
|
|
1409
|
+
self._scan_cursor_valid = True
|
|
1410
|
+
else:
|
|
1411
|
+
self._reset_scan_state()
|
|
1412
|
+
if full_history:
|
|
1413
|
+
return all_messages
|
|
1414
|
+
return active_messages if boundary_seen or rollback_seen else all_messages
|
|
1415
|
+
|
|
1416
|
+
@classmethod
|
|
1417
|
+
def fork_from(
|
|
1418
|
+
cls,
|
|
1419
|
+
source_cwd: str,
|
|
1420
|
+
source_session_id: str,
|
|
1421
|
+
message_uuid: str | None = None,
|
|
1422
|
+
*,
|
|
1423
|
+
title: str | None = None,
|
|
1424
|
+
) -> "SessionStorage":
|
|
1425
|
+
"""Atomically clone durable state up to an assistant reply.
|
|
1426
|
+
|
|
1427
|
+
When ``message_uuid`` is omitted, use the latest assistant reply in the
|
|
1428
|
+
active transcript. This supports a whole-conversation fork without
|
|
1429
|
+
forcing callers to load the transcript merely to discover that UUID.
|
|
1430
|
+
"""
|
|
1431
|
+
source = cls(os.path.abspath(source_cwd), source_session_id)
|
|
1432
|
+
if message_uuid is not None:
|
|
1433
|
+
_validate_component(message_uuid, "message uuid")
|
|
1434
|
+
with _session_lifecycle_lock(source.cwd, source.session_id, exclusive=True):
|
|
1435
|
+
if not source._transcript_path.exists():
|
|
1436
|
+
raise ValueError(f"Session {source.session_id} not found")
|
|
1437
|
+
with _transcript_file_lock(source._transcript_path, exclusive=False):
|
|
1438
|
+
transcript = source._transcript_path.read_text(encoding="utf-8")
|
|
1439
|
+
messages = source.load_messages(_transcript_text=transcript)
|
|
1440
|
+
if source.meta.get("is_archived"):
|
|
1441
|
+
raise ValueError(f"Session {source.session_id} is archived")
|
|
1442
|
+
if message_uuid is None:
|
|
1443
|
+
target_index = next(
|
|
1444
|
+
(
|
|
1445
|
+
index for index in range(len(messages) - 1, -1, -1)
|
|
1446
|
+
if messages[index].get("type") == "assistant"
|
|
1447
|
+
),
|
|
1448
|
+
-1,
|
|
1449
|
+
)
|
|
1450
|
+
else:
|
|
1451
|
+
target_index = next(
|
|
1452
|
+
(
|
|
1453
|
+
index for index, item in enumerate(messages)
|
|
1454
|
+
if item.get("uuid") == message_uuid
|
|
1455
|
+
and item.get("type") == "assistant"
|
|
1456
|
+
),
|
|
1457
|
+
-1,
|
|
1458
|
+
)
|
|
1459
|
+
if target_index < 0:
|
|
1460
|
+
raise ValueError("Assistant message not found in active session history")
|
|
1461
|
+
cloned_messages = messages[: target_index + 1]
|
|
1462
|
+
forked_from_message_uuid = str(cloned_messages[-1].get("uuid") or "")
|
|
1463
|
+
source_meta = dict(source.meta)
|
|
1464
|
+
|
|
1465
|
+
new_id = generate_session_id()
|
|
1466
|
+
destination = cls(source.cwd, new_id)
|
|
1467
|
+
source_title = str(
|
|
1468
|
+
source_meta.get("title")
|
|
1469
|
+
or source_meta.get("first_user_message")
|
|
1470
|
+
or ""
|
|
1471
|
+
)[:200]
|
|
1472
|
+
fork_title = str(title or "").strip()[:200] or (
|
|
1473
|
+
f"{source_title} · 分叉" if source_title else "分叉会话"
|
|
1474
|
+
)
|
|
1475
|
+
first_user = str(source_meta.get("first_user_message") or "")[:500]
|
|
1476
|
+
now = datetime.now(timezone.utc)
|
|
1477
|
+
metadata = destination._new_meta(
|
|
1478
|
+
model=str(source_meta.get("model") or ""),
|
|
1479
|
+
provider=str(source_meta.get("provider") or ""),
|
|
1480
|
+
first_user_message=first_user,
|
|
1481
|
+
now=now,
|
|
1482
|
+
)
|
|
1483
|
+
metadata.update(
|
|
1484
|
+
{
|
|
1485
|
+
"title": fork_title,
|
|
1486
|
+
"updated_at": now.isoformat(),
|
|
1487
|
+
"forked_from_session_id": source.session_id,
|
|
1488
|
+
"forked_from_message_uuid": forked_from_message_uuid,
|
|
1489
|
+
"forked_from_title": source_title,
|
|
1490
|
+
"message_count": len(cloned_messages),
|
|
1491
|
+
}
|
|
1492
|
+
)
|
|
1493
|
+
for key in ("summary", "goal", "git_branch", "git_sha", "loaded_tools", "model_profile"):
|
|
1494
|
+
if source_meta.get(key) is not None:
|
|
1495
|
+
metadata[key] = source_meta[key]
|
|
1496
|
+
lines = [_dump_jsonl_line({"type": "session_meta", **metadata})]
|
|
1497
|
+
lines.extend(_dump_jsonl_line(item) for item in cloned_messages)
|
|
1498
|
+
with _session_lifecycle_lock(destination.cwd, destination.session_id):
|
|
1499
|
+
destination._ensure_dir()
|
|
1500
|
+
destination._atomic_write_text(destination._transcript_path, "".join(lines))
|
|
1501
|
+
destination._meta = metadata
|
|
1502
|
+
destination._meta_written = True
|
|
1503
|
+
destination._upsert_meta_locked()
|
|
1504
|
+
return destination
|
|
1505
|
+
|
|
1506
|
+
def update_title(self, title: str) -> None:
|
|
1507
|
+
"""Update the session title in JSONL, SQLite, and in-memory metadata."""
|
|
1508
|
+
try:
|
|
1509
|
+
self._commit_metadata_update(
|
|
1510
|
+
lambda _meta: {
|
|
1511
|
+
"title": title,
|
|
1512
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1513
|
+
}
|
|
1514
|
+
)
|
|
1515
|
+
except Exception:
|
|
1516
|
+
logger.warning("Failed to persist session title for %s", self.session_id, exc_info=True)
|
|
1517
|
+
|
|
1518
|
+
def update_loaded_tools(self, names: list[str]) -> None:
|
|
1519
|
+
"""Persist discovery state without replacing unrelated session metadata."""
|
|
1520
|
+
try:
|
|
1521
|
+
self._commit_metadata_update(lambda _meta: {"loaded_tools": list(names)})
|
|
1522
|
+
except OSError:
|
|
1523
|
+
logger.warning("Failed to persist tool discovery state for %s", self.session_id, exc_info=True)
|
|
1524
|
+
|
|
1525
|
+
def update_model(
|
|
1526
|
+
self,
|
|
1527
|
+
*,
|
|
1528
|
+
model: str = "",
|
|
1529
|
+
provider: str = "",
|
|
1530
|
+
model_profile: str | None = None,
|
|
1531
|
+
) -> None:
|
|
1532
|
+
"""Persist the active model/provider for an existing session.
|
|
1533
|
+
|
|
1534
|
+
Model selection can change after the first turn. The update is merged
|
|
1535
|
+
with the latest on-disk metadata so another storage instance cannot
|
|
1536
|
+
overwrite counters or titles with a stale in-memory snapshot.
|
|
1537
|
+
``model_profile`` is the named configuration resume should restore.
|
|
1538
|
+
Omit it to leave an older record unchanged.
|
|
1539
|
+
|
|
1540
|
+
This records runtime state, not conversation activity, so the
|
|
1541
|
+
session's activity time and list position stay unchanged.
|
|
1542
|
+
"""
|
|
1543
|
+
def _fields(_meta: dict[str, Any]) -> dict[str, Any]:
|
|
1544
|
+
fields: dict[str, Any] = {
|
|
1545
|
+
"model": model,
|
|
1546
|
+
"provider": provider,
|
|
1547
|
+
}
|
|
1548
|
+
if model_profile is not None:
|
|
1549
|
+
fields["model_profile"] = model_profile
|
|
1550
|
+
return fields
|
|
1551
|
+
|
|
1552
|
+
try:
|
|
1553
|
+
self._commit_metadata_update(_fields)
|
|
1554
|
+
except Exception:
|
|
1555
|
+
logger.warning(
|
|
1556
|
+
"Failed to persist model metadata: %s",
|
|
1557
|
+
self._transcript_path,
|
|
1558
|
+
exc_info=True,
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
def update_summary(self, summary: str) -> None:
|
|
1562
|
+
"""Update the session summary in JSONL, SQLite, and in-memory metadata."""
|
|
1563
|
+
try:
|
|
1564
|
+
self._commit_metadata_update(
|
|
1565
|
+
lambda _meta: {
|
|
1566
|
+
"summary": summary,
|
|
1567
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1568
|
+
}
|
|
1569
|
+
)
|
|
1570
|
+
except Exception:
|
|
1571
|
+
logger.warning("Failed to persist session summary for %s", self.session_id, exc_info=True)
|
|
1572
|
+
|
|
1573
|
+
def update_goal(self, goal: dict[str, Any] | None) -> None:
|
|
1574
|
+
"""Persist the session goal in the append-only JSONL metadata."""
|
|
1575
|
+
try:
|
|
1576
|
+
self._commit_metadata_update(
|
|
1577
|
+
lambda _meta: {
|
|
1578
|
+
"goal": goal,
|
|
1579
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1580
|
+
}
|
|
1581
|
+
)
|
|
1582
|
+
except Exception:
|
|
1583
|
+
logger.warning("Failed to persist session goal for %s", self.session_id, exc_info=True)
|
|
1584
|
+
|
|
1585
|
+
def record_tokens(self, tokens: int) -> None:
|
|
1586
|
+
"""Accumulate token usage in JSONL and SQLite."""
|
|
1587
|
+
def accumulate(meta: dict[str, Any]) -> dict[str, Any]:
|
|
1588
|
+
try:
|
|
1589
|
+
previous_tokens = int(meta.get("tokens_used", 0) or 0)
|
|
1590
|
+
except (TypeError, ValueError, OverflowError):
|
|
1591
|
+
previous_tokens = 0
|
|
1592
|
+
return {
|
|
1593
|
+
"tokens_used": previous_tokens + int(tokens),
|
|
1594
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
try:
|
|
1598
|
+
self._commit_metadata_update(accumulate)
|
|
1599
|
+
except Exception:
|
|
1600
|
+
logger.warning("Failed to persist token usage for %s", self.session_id, exc_info=True)
|
|
1601
|
+
|
|
1602
|
+
def record_context_usage(
|
|
1603
|
+
self, used_tokens: int, window_tokens: int, *,
|
|
1604
|
+
source: str = "estimated", baseline: dict | None = None,
|
|
1605
|
+
) -> None:
|
|
1606
|
+
"""Persist context window usage to JSONL so it survives session restore."""
|
|
1607
|
+
entry = {
|
|
1608
|
+
"type": "context_usage", "used_tokens": used_tokens,
|
|
1609
|
+
"window_tokens": window_tokens, "source": source, "baseline": baseline,
|
|
1610
|
+
}
|
|
1611
|
+
self._append_transcript_line(entry)
|
|
1612
|
+
self.last_context_token_source = source
|
|
1613
|
+
self.last_context_token_baseline = baseline
|
|
1614
|
+
self.last_context_used_tokens = used_tokens
|
|
1615
|
+
self.last_context_window_tokens = window_tokens
|
|
1616
|
+
|
|
1617
|
+
def record_message_count(self, count: int) -> None:
|
|
1618
|
+
"""Update message count in JSONL and SQLite."""
|
|
1619
|
+
try:
|
|
1620
|
+
self._commit_metadata_update(
|
|
1621
|
+
lambda _meta: {
|
|
1622
|
+
"message_count": int(count),
|
|
1623
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1624
|
+
}
|
|
1625
|
+
)
|
|
1626
|
+
except Exception:
|
|
1627
|
+
logger.warning("Failed to persist message count for %s", self.session_id, exc_info=True)
|
|
1628
|
+
|
|
1629
|
+
def create_checkpoint(self, messages: list, label: str = "", snapshot_id: str | None = None) -> str | None:
|
|
1630
|
+
"""Create a checkpoint at the current message position.
|
|
1631
|
+
|
|
1632
|
+
If *snapshot_id* is provided, it is stored alongside the checkpoint
|
|
1633
|
+
so that ``/revert`` can also restore file-system state.
|
|
1634
|
+
"""
|
|
1635
|
+
if not messages:
|
|
1636
|
+
return None
|
|
1637
|
+
last_msg = messages[-1]
|
|
1638
|
+
msg_uuid = getattr(last_msg, "uuid", "") or ""
|
|
1639
|
+
msg_index = len(messages) - 1
|
|
1640
|
+
# Initialize before entering the try block: importing or constructing
|
|
1641
|
+
# the metadata store can fail, and the finally block must still be
|
|
1642
|
+
# able to close a partially-created store without masking the failure.
|
|
1643
|
+
store = None
|
|
1644
|
+
cp_id: str | None = None
|
|
1645
|
+
try:
|
|
1646
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1647
|
+
store = SessionMetaStore()
|
|
1648
|
+
cp_id = store.create_checkpoint(
|
|
1649
|
+
session_id=self.session_id,
|
|
1650
|
+
message_uuid=msg_uuid,
|
|
1651
|
+
message_index=msg_index,
|
|
1652
|
+
label=label,
|
|
1653
|
+
snapshot_id=snapshot_id,
|
|
1654
|
+
)
|
|
1655
|
+
# Write a marker line to JSONL for auditability
|
|
1656
|
+
entry = {
|
|
1657
|
+
"type": "checkpoint",
|
|
1658
|
+
"checkpoint_id": cp_id,
|
|
1659
|
+
"message_uuid": msg_uuid,
|
|
1660
|
+
"message_index": msg_index,
|
|
1661
|
+
"label": label,
|
|
1662
|
+
"snapshot_id": snapshot_id,
|
|
1663
|
+
# Keep the projection captured by the caller rather than
|
|
1664
|
+
# reconstructing it from whatever records happen to precede
|
|
1665
|
+
# this marker. A compaction can commit between the SQLite
|
|
1666
|
+
# row and this JSONL append; without the embedded projection
|
|
1667
|
+
# that race changes what rollback restores.
|
|
1668
|
+
"messages": [_message_to_entry(message) for message in messages],
|
|
1669
|
+
}
|
|
1670
|
+
self._append_transcript_line(entry)
|
|
1671
|
+
return cp_id
|
|
1672
|
+
except Exception:
|
|
1673
|
+
# The SQLite row is created before the JSONL audit marker. If the
|
|
1674
|
+
# marker cannot be committed, compensate while the same store is
|
|
1675
|
+
# still open so callers never receive None for a checkpoint that
|
|
1676
|
+
# remains active in list/rollback APIs.
|
|
1677
|
+
if cp_id is not None and store is not None:
|
|
1678
|
+
try:
|
|
1679
|
+
store.delete_checkpoint(cp_id)
|
|
1680
|
+
except Exception:
|
|
1681
|
+
logger.warning(
|
|
1682
|
+
"Failed to remove partial checkpoint %s",
|
|
1683
|
+
cp_id,
|
|
1684
|
+
exc_info=True,
|
|
1685
|
+
)
|
|
1686
|
+
logger.warning("Failed to create checkpoint for session %s", self.session_id, exc_info=True)
|
|
1687
|
+
return None
|
|
1688
|
+
finally:
|
|
1689
|
+
if store is not None:
|
|
1690
|
+
try:
|
|
1691
|
+
store.close()
|
|
1692
|
+
except Exception:
|
|
1693
|
+
logger.debug("Failed to close session metadata store", exc_info=True)
|
|
1694
|
+
|
|
1695
|
+
def list_checkpoints(self) -> list[dict[str, Any]]:
|
|
1696
|
+
"""List checkpoints for this session."""
|
|
1697
|
+
store = None
|
|
1698
|
+
try:
|
|
1699
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1700
|
+
store = SessionMetaStore()
|
|
1701
|
+
cps = store.list_checkpoints(self.session_id)
|
|
1702
|
+
return cps
|
|
1703
|
+
except Exception:
|
|
1704
|
+
logger.debug("Failed to list checkpoints for session %s", self.session_id, exc_info=True)
|
|
1705
|
+
return []
|
|
1706
|
+
finally:
|
|
1707
|
+
if store is not None:
|
|
1708
|
+
try:
|
|
1709
|
+
store.close()
|
|
1710
|
+
except Exception:
|
|
1711
|
+
logger.debug("Failed to close session metadata store", exc_info=True)
|
|
1712
|
+
|
|
1713
|
+
def rollback_to_checkpoint(self, checkpoint_id: str) -> int | None:
|
|
1714
|
+
"""Get the message index to rollback to. Returns index or None if not found."""
|
|
1715
|
+
store = None
|
|
1716
|
+
try:
|
|
1717
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1718
|
+
store = SessionMetaStore()
|
|
1719
|
+
cp = store.get_checkpoint(checkpoint_id)
|
|
1720
|
+
if cp and cp["session_id"] == self.session_id:
|
|
1721
|
+
target_uuid = str(cp.get("message_uuid") or "")
|
|
1722
|
+
# Write rollback marker to JSONL
|
|
1723
|
+
entry = {
|
|
1724
|
+
"type": "rollback",
|
|
1725
|
+
"checkpoint_id": checkpoint_id,
|
|
1726
|
+
"rollback_to_index": cp["message_index"],
|
|
1727
|
+
# New markers carry the stable target so a resumed session
|
|
1728
|
+
# can replay the rollback even after other records or a
|
|
1729
|
+
# compaction boundary changed the active list length.
|
|
1730
|
+
"message_uuid": cp.get("message_uuid"),
|
|
1731
|
+
}
|
|
1732
|
+
self._append_transcript_line(entry)
|
|
1733
|
+
if target_uuid:
|
|
1734
|
+
# Replaying the marker also handles checkpoints that
|
|
1735
|
+
# predate a compact boundary (the parser retains the
|
|
1736
|
+
# checkpoint-time projection). If the projection cannot
|
|
1737
|
+
# be reconstructed, report failure instead of handing the
|
|
1738
|
+
# caller a stale integer index.
|
|
1739
|
+
active = self.load_messages()
|
|
1740
|
+
if not any(item.get("uuid") == target_uuid for item in active):
|
|
1741
|
+
return None
|
|
1742
|
+
return cp["message_index"]
|
|
1743
|
+
except Exception:
|
|
1744
|
+
logger.warning("Failed to rollback checkpoint %s", checkpoint_id, exc_info=True)
|
|
1745
|
+
finally:
|
|
1746
|
+
if store is not None:
|
|
1747
|
+
try:
|
|
1748
|
+
store.close()
|
|
1749
|
+
except Exception:
|
|
1750
|
+
logger.debug("Failed to close session metadata store", exc_info=True)
|
|
1751
|
+
return None
|
|
1752
|
+
|
|
1753
|
+
@property
|
|
1754
|
+
def meta(self) -> dict[str, Any]:
|
|
1755
|
+
"""Return the session metadata (from JSONL or empty dict)."""
|
|
1756
|
+
return self._meta
|
|
1757
|
+
|
|
1758
|
+
@staticmethod
|
|
1759
|
+
def list_sessions(cwd: str) -> list[dict[str, Any]]:
|
|
1760
|
+
"""List all sessions for a given working directory.
|
|
1761
|
+
|
|
1762
|
+
SQLite supplies indexed rows, while JSONL scanning reconciles active
|
|
1763
|
+
sessions whose index write was missed. Durable archive state always
|
|
1764
|
+
wins over either source.
|
|
1765
|
+
"""
|
|
1766
|
+
abs_cwd = os.path.abspath(cwd)
|
|
1767
|
+
sessions_by_id: dict[str, dict[str, Any]] = {}
|
|
1768
|
+
indexed_states: dict[str, bool] = {}
|
|
1769
|
+
store = None
|
|
1770
|
+
try:
|
|
1771
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1772
|
+
|
|
1773
|
+
store = SessionMetaStore()
|
|
1774
|
+
rows = store.list_by_cwd(abs_cwd, limit=-1)
|
|
1775
|
+
list_states = getattr(store, "list_states_by_cwd", None)
|
|
1776
|
+
if callable(list_states):
|
|
1777
|
+
indexed_states = dict(list_states(abs_cwd))
|
|
1778
|
+
else:
|
|
1779
|
+
indexed_states = {str(row["id"]): bool(row.get("is_archived")) for row in rows}
|
|
1780
|
+
for row in rows:
|
|
1781
|
+
session_id = str(row["id"])
|
|
1782
|
+
if get_session_tombstone_path(abs_cwd, session_id).exists():
|
|
1783
|
+
continue
|
|
1784
|
+
timestamp = row.get("updated_at", 0)
|
|
1785
|
+
try:
|
|
1786
|
+
sort_timestamp = float(timestamp or 0)
|
|
1787
|
+
except (TypeError, ValueError, OverflowError):
|
|
1788
|
+
sort_timestamp = 0.0
|
|
1789
|
+
sessions_by_id[session_id] = {
|
|
1790
|
+
"session_id": session_id,
|
|
1791
|
+
"title": row.get("title", ""),
|
|
1792
|
+
"model": row.get("model", ""),
|
|
1793
|
+
"provider": row.get("provider", ""),
|
|
1794
|
+
"tokens_used": row.get("tokens_used", 0),
|
|
1795
|
+
"git_branch": row.get("git_branch"),
|
|
1796
|
+
"git_sha": row.get("git_sha"),
|
|
1797
|
+
"message_count": row.get("message_count", 0),
|
|
1798
|
+
"created_at": row.get("created_at", 0),
|
|
1799
|
+
"modified": (
|
|
1800
|
+
datetime.fromtimestamp(sort_timestamp, tz=timezone.utc).isoformat()
|
|
1801
|
+
if sort_timestamp
|
|
1802
|
+
else ""
|
|
1803
|
+
),
|
|
1804
|
+
"summary": row.get("summary", ""),
|
|
1805
|
+
"forked_from_session_id": row.get("forked_from_session_id"),
|
|
1806
|
+
"forked_from_message_uuid": row.get("forked_from_message_uuid"),
|
|
1807
|
+
"forked_from_title": row.get("forked_from_title"),
|
|
1808
|
+
"preview": (
|
|
1809
|
+
row.get("summary", "")[:100]
|
|
1810
|
+
or row.get("first_user_message", "")[:100]
|
|
1811
|
+
),
|
|
1812
|
+
"_sort_timestamp": sort_timestamp,
|
|
1813
|
+
}
|
|
1814
|
+
except Exception:
|
|
1815
|
+
logger.debug("SQLite session listing failed for cwd %s", abs_cwd, exc_info=True)
|
|
1816
|
+
finally:
|
|
1817
|
+
if store is not None:
|
|
1818
|
+
try:
|
|
1819
|
+
store.close()
|
|
1820
|
+
except Exception:
|
|
1821
|
+
logger.debug("Failed to close session metadata store", exc_info=True)
|
|
1822
|
+
|
|
1823
|
+
transcript_paths: list[Path] = []
|
|
1824
|
+
for project_dir in _project_dirs_for_read(abs_cwd):
|
|
1825
|
+
if project_dir.exists():
|
|
1826
|
+
transcript_paths.extend(project_dir.glob("*.jsonl"))
|
|
1827
|
+
|
|
1828
|
+
def transcript_mtime(path: Path) -> float:
|
|
1829
|
+
try:
|
|
1830
|
+
return path.stat().st_mtime
|
|
1831
|
+
except OSError:
|
|
1832
|
+
return 0.0
|
|
1833
|
+
|
|
1834
|
+
for path in sorted(transcript_paths, key=transcript_mtime, reverse=True):
|
|
1835
|
+
session_id = path.stem
|
|
1836
|
+
if session_id in sessions_by_id or indexed_states.get(session_id) is True:
|
|
1837
|
+
continue
|
|
1838
|
+
if get_session_tombstone_path(abs_cwd, session_id).exists():
|
|
1839
|
+
continue
|
|
1840
|
+
try:
|
|
1841
|
+
stat = path.stat()
|
|
1842
|
+
first_user_msg = ""
|
|
1843
|
+
meta_info: dict[str, Any] = {}
|
|
1844
|
+
archived = False
|
|
1845
|
+
with _transcript_file_lock(path, exclusive=False):
|
|
1846
|
+
with open(path, encoding="utf-8") as f:
|
|
1847
|
+
for line in f:
|
|
1848
|
+
line = line.strip()
|
|
1849
|
+
if not line:
|
|
1850
|
+
continue
|
|
1851
|
+
try:
|
|
1852
|
+
entry = json.loads(line)
|
|
1853
|
+
except json.JSONDecodeError:
|
|
1854
|
+
continue
|
|
1855
|
+
|
|
1856
|
+
if not isinstance(entry, dict):
|
|
1857
|
+
continue
|
|
1858
|
+
if entry.get("type") == "session_meta":
|
|
1859
|
+
meta_info.update({k: v for k, v in entry.items() if k != "type"})
|
|
1860
|
+
continue
|
|
1861
|
+
if entry.get("type") == "session_archive":
|
|
1862
|
+
archived = True
|
|
1863
|
+
continue
|
|
1864
|
+
if entry.get("type") == "session_restore":
|
|
1865
|
+
archived = False
|
|
1866
|
+
continue
|
|
1867
|
+
|
|
1868
|
+
# First non-meta user message is the preview
|
|
1869
|
+
if entry.get("type") == "user" and not first_user_msg:
|
|
1870
|
+
first_user_msg = _extract_preview(entry)
|
|
1871
|
+
# Continue scanning: incremental title/model/count
|
|
1872
|
+
# updates are appended as later session_meta records,
|
|
1873
|
+
# and the final record is authoritative when SQLite is
|
|
1874
|
+
# unavailable.
|
|
1875
|
+
|
|
1876
|
+
declared_cwd = meta_info.get("cwd")
|
|
1877
|
+
if (
|
|
1878
|
+
isinstance(declared_cwd, str)
|
|
1879
|
+
and declared_cwd
|
|
1880
|
+
and os.path.normcase(os.path.abspath(declared_cwd))
|
|
1881
|
+
!= os.path.normcase(abs_cwd)
|
|
1882
|
+
):
|
|
1883
|
+
continue
|
|
1884
|
+
if archived or meta_info.get("is_archived"):
|
|
1885
|
+
continue
|
|
1886
|
+
sessions_by_id[session_id] = {
|
|
1887
|
+
"session_id": session_id,
|
|
1888
|
+
"title": meta_info.get("title", ""),
|
|
1889
|
+
"model": meta_info.get("model", ""),
|
|
1890
|
+
"provider": meta_info.get("provider", ""),
|
|
1891
|
+
"tokens_used": meta_info.get("tokens_used", 0),
|
|
1892
|
+
"git_branch": meta_info.get("git_branch"),
|
|
1893
|
+
"git_sha": meta_info.get("git_sha"),
|
|
1894
|
+
"message_count": meta_info.get("message_count", 0),
|
|
1895
|
+
"created_at": meta_info.get("created_at", 0),
|
|
1896
|
+
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
1897
|
+
"summary": meta_info.get("summary", ""),
|
|
1898
|
+
"forked_from_session_id": meta_info.get("forked_from_session_id"),
|
|
1899
|
+
"forked_from_message_uuid": meta_info.get("forked_from_message_uuid"),
|
|
1900
|
+
"forked_from_title": meta_info.get("forked_from_title"),
|
|
1901
|
+
"preview": meta_info.get("first_user_message", "")[:100] or first_user_msg,
|
|
1902
|
+
"_sort_timestamp": stat.st_mtime,
|
|
1903
|
+
}
|
|
1904
|
+
except Exception:
|
|
1905
|
+
logger.warning("Failed to inspect session transcript: %s", path, exc_info=True)
|
|
1906
|
+
sessions_by_id.setdefault(session_id, {
|
|
1907
|
+
"session_id": session_id,
|
|
1908
|
+
"title": "",
|
|
1909
|
+
"model": "",
|
|
1910
|
+
"provider": "",
|
|
1911
|
+
"tokens_used": 0,
|
|
1912
|
+
"git_branch": None,
|
|
1913
|
+
"git_sha": None,
|
|
1914
|
+
"message_count": 0,
|
|
1915
|
+
"modified": "",
|
|
1916
|
+
"preview": "",
|
|
1917
|
+
"_sort_timestamp": 0.0,
|
|
1918
|
+
})
|
|
1919
|
+
|
|
1920
|
+
sessions = sorted(
|
|
1921
|
+
sessions_by_id.values(),
|
|
1922
|
+
key=lambda item: (float(item.get("_sort_timestamp", 0.0)), item["session_id"]),
|
|
1923
|
+
reverse=True,
|
|
1924
|
+
)
|
|
1925
|
+
for session in sessions:
|
|
1926
|
+
session.pop("_sort_timestamp", None)
|
|
1927
|
+
return sessions
|
|
1928
|
+
|
|
1929
|
+
@classmethod
|
|
1930
|
+
def from_session_id(cls, session_id: str) -> "SessionStorage | None":
|
|
1931
|
+
"""Resolve a session by ID using SQLite metadata (cross-project).
|
|
1932
|
+
|
|
1933
|
+
Returns a SessionStorage pointed at the original cwd, or None if not found.
|
|
1934
|
+
"""
|
|
1935
|
+
store = None
|
|
1936
|
+
try:
|
|
1937
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1938
|
+
store = SessionMetaStore()
|
|
1939
|
+
row = store.get(session_id)
|
|
1940
|
+
if row and row.get("cwd"):
|
|
1941
|
+
return cls(cwd=row["cwd"], session_id=session_id)
|
|
1942
|
+
except Exception:
|
|
1943
|
+
logger.debug("from_session_id lookup failed for %s", session_id, exc_info=True)
|
|
1944
|
+
finally:
|
|
1945
|
+
if store is not None:
|
|
1946
|
+
try:
|
|
1947
|
+
store.close()
|
|
1948
|
+
except Exception:
|
|
1949
|
+
logger.debug("Failed to close session metadata store", exc_info=True)
|
|
1950
|
+
return None
|
|
1951
|
+
|
|
1952
|
+
@staticmethod
|
|
1953
|
+
def search_sessions(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
1954
|
+
"""Search sessions across all projects by title or first message."""
|
|
1955
|
+
store = None
|
|
1956
|
+
try:
|
|
1957
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
1958
|
+
store = SessionMetaStore()
|
|
1959
|
+
rows = store.search(query, limit=limit)
|
|
1960
|
+
return rows
|
|
1961
|
+
except Exception:
|
|
1962
|
+
logger.warning("Session search failed for query %r", query, exc_info=True)
|
|
1963
|
+
return []
|
|
1964
|
+
finally:
|
|
1965
|
+
if store is not None:
|
|
1966
|
+
try:
|
|
1967
|
+
store.close()
|
|
1968
|
+
except Exception:
|
|
1969
|
+
logger.debug("Failed to close session metadata store", exc_info=True)
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def _remove_session_artifact(path: Path, *, directory: bool) -> None:
|
|
1973
|
+
"""Remove one exact session artifact without following directory symlinks."""
|
|
1974
|
+
try:
|
|
1975
|
+
path.lstat()
|
|
1976
|
+
except FileNotFoundError:
|
|
1977
|
+
return
|
|
1978
|
+
if path.is_symlink() or not directory:
|
|
1979
|
+
path.unlink()
|
|
1980
|
+
return
|
|
1981
|
+
if path.is_dir():
|
|
1982
|
+
shutil.rmtree(path)
|
|
1983
|
+
else:
|
|
1984
|
+
path.unlink()
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def purge_session_artifacts(cwd: str, session_id: str) -> None:
|
|
1988
|
+
"""Delete explicit session data artifacts while retaining its archive fence."""
|
|
1989
|
+
absolute_cwd = os.path.abspath(cwd)
|
|
1990
|
+
validated_id = _validate_component(session_id, "session id")
|
|
1991
|
+
storage = SessionStorage(absolute_cwd, validated_id)
|
|
1992
|
+
failures: list[OSError] = []
|
|
1993
|
+
|
|
1994
|
+
with _session_lifecycle_lock(absolute_cwd, validated_id):
|
|
1995
|
+
storage._write_tombstone_locked()
|
|
1996
|
+
legacy_dir = _legacy_project_dir(absolute_cwd)
|
|
1997
|
+
for project_dir in _project_dirs_for_read(absolute_cwd):
|
|
1998
|
+
if project_dir == legacy_dir:
|
|
1999
|
+
legacy_transcript = project_dir / f"{validated_id}.jsonl"
|
|
2000
|
+
declared_cwd = _transcript_declared_cwd(legacy_transcript)
|
|
2001
|
+
if (
|
|
2002
|
+
declared_cwd is not None
|
|
2003
|
+
and os.path.normcase(declared_cwd) != os.path.normcase(absolute_cwd)
|
|
2004
|
+
):
|
|
2005
|
+
continue
|
|
2006
|
+
artifacts = (
|
|
2007
|
+
(project_dir / f"{validated_id}.jsonl", False),
|
|
2008
|
+
(project_dir / f"{validated_id}.agents.json", False),
|
|
2009
|
+
(project_dir / f"{validated_id}.callbacks.jsonl", False),
|
|
2010
|
+
(project_dir / f"{validated_id}.agents", True),
|
|
2011
|
+
(project_dir / f"{validated_id}.tasks", True),
|
|
2012
|
+
)
|
|
2013
|
+
for path, is_directory in artifacts:
|
|
2014
|
+
try:
|
|
2015
|
+
_remove_session_artifact(path, directory=is_directory)
|
|
2016
|
+
except OSError as exc:
|
|
2017
|
+
failures.append(exc)
|
|
2018
|
+
|
|
2019
|
+
if failures:
|
|
2020
|
+
raise failures[0]
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _extract_preview(msg: dict[str, Any]) -> str:
|
|
2024
|
+
"""Extract a short preview from a message entry."""
|
|
2025
|
+
content = msg.get("content", "")
|
|
2026
|
+
if isinstance(content, str):
|
|
2027
|
+
return content[:100]
|
|
2028
|
+
if isinstance(content, list) and content:
|
|
2029
|
+
first = content[0]
|
|
2030
|
+
if isinstance(first, dict) and "text" in first:
|
|
2031
|
+
return first["text"][:100]
|
|
2032
|
+
return ""
|