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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. 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 ""