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,684 @@
1
+ """SQLite session metadata store — fast queries for session listing and stats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import os
7
+ import sqlite3
8
+ import threading
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from crabcode_core.logging_utils import get_logger
14
+ from crabcode_core.session.storage import get_config_home
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ def _db_path() -> Path:
20
+ return get_config_home() / "sessions.db"
21
+
22
+
23
+ def _coerce_epoch(value: Any, fallback: int) -> int:
24
+ """Normalize JSONL ISO timestamps and legacy SQLite values to epoch seconds."""
25
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
26
+ return int(value)
27
+ if isinstance(value, str) and value:
28
+ try:
29
+ return int(value)
30
+ except ValueError:
31
+ try:
32
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
33
+ if parsed.tzinfo is None:
34
+ parsed = parsed.replace(tzinfo=timezone.utc)
35
+ return int(parsed.timestamp())
36
+ except (TypeError, ValueError, OverflowError):
37
+ pass
38
+ return fallback
39
+
40
+
41
+ def _persist_archive_marker(session_id: str, cwd: str) -> None:
42
+ """Write the JSONL tombstone before SQLite archive state can disappear."""
43
+ if not cwd:
44
+ return
45
+ from crabcode_core.session.storage import SessionStorage
46
+
47
+ SessionStorage(cwd, session_id).persist_archive_marker()
48
+
49
+
50
+ _SCHEMA = """\
51
+ CREATE TABLE IF NOT EXISTS session_meta (
52
+ id TEXT PRIMARY KEY,
53
+ title TEXT NOT NULL DEFAULT '',
54
+ cwd TEXT NOT NULL DEFAULT '',
55
+ model TEXT NOT NULL DEFAULT '',
56
+ provider TEXT NOT NULL DEFAULT '',
57
+ first_user_message TEXT NOT NULL DEFAULT '',
58
+ tokens_used INTEGER NOT NULL DEFAULT 0,
59
+ git_branch TEXT,
60
+ git_sha TEXT,
61
+ created_at INTEGER NOT NULL,
62
+ updated_at INTEGER NOT NULL,
63
+ is_archived INTEGER NOT NULL DEFAULT 0,
64
+ message_count INTEGER NOT NULL DEFAULT 0,
65
+ forked_from_session_id TEXT,
66
+ forked_from_message_uuid TEXT,
67
+ forked_from_title TEXT
68
+ );
69
+ CREATE INDEX IF NOT EXISTS idx_session_meta_updated
70
+ ON session_meta(updated_at DESC, id DESC);
71
+ CREATE INDEX IF NOT EXISTS idx_session_meta_cwd
72
+ ON session_meta(cwd, updated_at DESC);
73
+ CREATE TABLE IF NOT EXISTS session_tombstones (
74
+ id TEXT PRIMARY KEY,
75
+ cwd TEXT NOT NULL DEFAULT '',
76
+ archived_at INTEGER NOT NULL
77
+ );
78
+ CREATE TRIGGER IF NOT EXISTS prevent_tombstoned_session_insert
79
+ BEFORE INSERT ON session_meta
80
+ WHEN EXISTS (SELECT 1 FROM session_tombstones WHERE id = NEW.id)
81
+ BEGIN
82
+ SELECT RAISE(IGNORE);
83
+ END;
84
+ """
85
+
86
+
87
+ _MIGRATIONS = [
88
+ "ALTER TABLE session_meta ADD COLUMN summary TEXT NOT NULL DEFAULT ''",
89
+ "ALTER TABLE session_meta ADD COLUMN forked_from_session_id TEXT",
90
+ "ALTER TABLE session_meta ADD COLUMN forked_from_message_uuid TEXT",
91
+ "ALTER TABLE session_meta ADD COLUMN forked_from_title TEXT",
92
+ """\
93
+ CREATE TABLE IF NOT EXISTS checkpoints (
94
+ id TEXT PRIMARY KEY,
95
+ session_id TEXT NOT NULL,
96
+ message_uuid TEXT NOT NULL,
97
+ message_index INTEGER NOT NULL,
98
+ label TEXT NOT NULL DEFAULT '',
99
+ created_at INTEGER NOT NULL
100
+ )""",
101
+ "CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id, created_at DESC)",
102
+ "ALTER TABLE checkpoints ADD COLUMN snapshot_id TEXT",
103
+ ]
104
+
105
+
106
+ # One connection per thread and database file, reused by every
107
+ # ``SessionMetaStore`` created on that thread. Keeping threads separate avoids
108
+ # interleaving the execute/commit pairs of multi-statement operations while
109
+ # still removing repeated connection and schema-convergence work.
110
+ _SHARED_GUARD = threading.Lock()
111
+ _ConnectionKey = tuple[int, Path]
112
+ _SHARED_CONNECTIONS: dict[_ConnectionKey, sqlite3.Connection] = {}
113
+ _SHARED_IDENTITIES: dict[_ConnectionKey, tuple[int, int] | None] = {}
114
+
115
+
116
+ def _get_conn(db_path: Path) -> sqlite3.Connection:
117
+ """Return this thread's cached connection for *db_path*.
118
+
119
+ Session metadata is read through many short-lived ``SessionMetaStore``
120
+ instances, each of which used to open (and close) a private connection.
121
+ A fresh SQLite connection to an existing WAL database costs ~335 us: the
122
+ ``connect`` itself, the WAL pragma, the schema script, the migration probe
123
+ (whose failed ``ALTER TABLE`` still takes a write lock), the full
124
+ ``typeof()`` scan and the tombstone sync. Reusing one handle per thread
125
+ and database drops a warm query to ~2 us and runs that convergence once per
126
+ worker instead of once per store object.
127
+
128
+ The connection is validated against the file's (device, inode) so a
129
+ database replaced underneath us — an explicit purge, or a test that
130
+ recreated the config home — is reopened instead of silently reading the
131
+ unlinked old file.
132
+ """
133
+ path = Path(db_path)
134
+ key = (threading.get_ident(), path)
135
+ with _SHARED_GUARD:
136
+ cached = _SHARED_CONNECTIONS.get(key)
137
+ if cached is not None:
138
+ if _file_identity(path) == _SHARED_IDENTITIES.get(key):
139
+ return cached
140
+ _SHARED_CONNECTIONS.pop(key, None)
141
+ _SHARED_IDENTITIES.pop(key, None)
142
+ _silent_close(cached)
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ # The connection is queried only by this thread, but shutdown closes
145
+ # every cached handle centrally from the main thread.
146
+ conn = sqlite3.connect(str(path), check_same_thread=False)
147
+ try:
148
+ _initialize_conn(conn)
149
+ except BaseException:
150
+ _silent_close(conn)
151
+ raise
152
+ _SHARED_CONNECTIONS[key] = conn
153
+ _SHARED_IDENTITIES[key] = _file_identity(path)
154
+ return conn
155
+
156
+
157
+ def _file_identity(path: Path) -> tuple[int, int] | None:
158
+ """Return a cheap (device, inode) fingerprint, or None when unavailable."""
159
+ try:
160
+ stat = os.stat(path)
161
+ except OSError:
162
+ return None
163
+ return (stat.st_dev, stat.st_ino)
164
+
165
+
166
+ def _silent_close(conn: sqlite3.Connection) -> None:
167
+ try:
168
+ conn.close()
169
+ except sqlite3.Error:
170
+ logger.debug("Failed to close session metadata connection", exc_info=True)
171
+
172
+
173
+ def reset_shared_connections() -> None:
174
+ """Close every cached connection and drop the cache.
175
+
176
+ Callers that need the database file to be releasable immediately (tests
177
+ using a temporary config home, and shutdown paths that remove it) can call
178
+ this instead of waiting for process exit.
179
+ """
180
+ with _SHARED_GUARD:
181
+ connections = list(_SHARED_CONNECTIONS.values())
182
+ _SHARED_CONNECTIONS.clear()
183
+ _SHARED_IDENTITIES.clear()
184
+ for conn in connections:
185
+ _silent_close(conn)
186
+
187
+
188
+ atexit.register(reset_shared_connections)
189
+
190
+
191
+ def _initialize_conn(conn: sqlite3.Connection) -> None:
192
+ """Apply connection pragmas plus schema and migration convergence."""
193
+ # ``journal_mode=WAL`` persists in the database header, so switching it on
194
+ # is a one-time cost per database rather than a per-connection one. The
195
+ # mode is not read back first: that read costs a file lock of its own.
196
+ try:
197
+ conn.execute("PRAGMA journal_mode=WAL")
198
+ except sqlite3.DatabaseError:
199
+ # A malformed or read-only database still deserves a usable
200
+ # connection; the offending statement surfaces on the caller's query.
201
+ pass
202
+ conn.execute("PRAGMA foreign_keys=ON")
203
+ conn.executescript(_SCHEMA)
204
+ for stmt in _MIGRATIONS:
205
+ try:
206
+ conn.execute(stmt)
207
+ conn.commit()
208
+ except sqlite3.OperationalError:
209
+ pass # column/table already exists
210
+ now = int(datetime.now(timezone.utc).timestamp())
211
+ legacy_rows = conn.execute(
212
+ "SELECT id, created_at FROM session_meta WHERE typeof(created_at) != 'integer'"
213
+ ).fetchall()
214
+ for session_id, created_at in legacy_rows:
215
+ conn.execute(
216
+ "UPDATE session_meta SET created_at = ? WHERE id = ?",
217
+ (_coerce_epoch(created_at, now), session_id),
218
+ )
219
+ if legacy_rows:
220
+ conn.commit()
221
+ conn.execute(
222
+ "INSERT OR IGNORE INTO session_tombstones (id, cwd, archived_at) "
223
+ "SELECT id, cwd, updated_at FROM session_meta WHERE is_archived = 1"
224
+ )
225
+ conn.commit()
226
+
227
+
228
+ class SessionMetaStore:
229
+ """SQLite-backed session metadata for fast listing and querying."""
230
+
231
+ def __init__(self, db_path: Path | None = None):
232
+ self._db_path = db_path or _db_path()
233
+ self._conn: sqlite3.Connection | None = None
234
+
235
+ def _conn_or_create(self) -> sqlite3.Connection:
236
+ if self._conn is None:
237
+ self._conn = _get_conn(self._db_path)
238
+ return self._conn
239
+
240
+ def close(self) -> None:
241
+ """Release this store's reference to the shared connection.
242
+
243
+ The underlying per-thread connection stays open so later stores skip
244
+ the connect and schema-convergence cost. ``close()`` keeps its contract
245
+ of leaving the store reusable: the next access re-resolves that handle.
246
+ """
247
+ self._conn = None
248
+
249
+ def upsert(self, meta: dict[str, Any]) -> None:
250
+ """Insert or update a session metadata row."""
251
+ conn = self._conn_or_create()
252
+ now = int(datetime.now(timezone.utc).timestamp())
253
+ meta["created_at"] = _coerce_epoch(meta.get("created_at"), now)
254
+ # Activity time belongs to the record being saved. Conversation writes
255
+ # pass a new timestamp; model restores and index backfills keep the
256
+ # existing one. Only a missing value means "now".
257
+ meta["updated_at"] = _coerce_epoch(meta.get("updated_at"), now)
258
+
259
+ # ``summary`` was added after the original upsert statement. Keep an
260
+ # explicit flag so the conflict update can preserve an existing value
261
+ # atomically when older callers omit the field.
262
+ summary = meta.get("summary")
263
+ summary_provided = summary is not None
264
+
265
+ conn.execute(
266
+ """INSERT INTO session_meta
267
+ (id, title, cwd, model, provider, first_user_message,
268
+ tokens_used, git_branch, git_sha,
269
+ created_at, updated_at, is_archived, message_count, summary,
270
+ forked_from_session_id, forked_from_message_uuid, forked_from_title)
271
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
272
+ ON CONFLICT(id) DO UPDATE SET
273
+ title = excluded.title,
274
+ cwd = excluded.cwd,
275
+ model = excluded.model,
276
+ provider = excluded.provider,
277
+ first_user_message = excluded.first_user_message,
278
+ tokens_used = excluded.tokens_used,
279
+ git_branch = excluded.git_branch,
280
+ git_sha = excluded.git_sha,
281
+ created_at = session_meta.created_at,
282
+ updated_at = excluded.updated_at,
283
+ -- Archiving is a terminal metadata transition. A late
284
+ -- session write must not make the row visible again.
285
+ is_archived = MAX(session_meta.is_archived, excluded.is_archived),
286
+ message_count = excluded.message_count,
287
+ forked_from_session_id = excluded.forked_from_session_id,
288
+ forked_from_message_uuid = excluded.forked_from_message_uuid,
289
+ forked_from_title = excluded.forked_from_title,
290
+ summary = CASE
291
+ WHEN ? THEN excluded.summary
292
+ ELSE session_meta.summary
293
+ END""",
294
+ (
295
+ meta["id"],
296
+ meta.get("title", ""),
297
+ meta.get("cwd", ""),
298
+ meta.get("model", ""),
299
+ meta.get("provider", ""),
300
+ meta.get("first_user_message", ""),
301
+ meta.get("tokens_used", 0),
302
+ meta.get("git_branch"),
303
+ meta.get("git_sha"),
304
+ meta["created_at"],
305
+ meta["updated_at"],
306
+ 1 if meta.get("is_archived") else 0,
307
+ meta.get("message_count", 0),
308
+ summary or "",
309
+ meta.get("forked_from_session_id"),
310
+ meta.get("forked_from_message_uuid"),
311
+ meta.get("forked_from_title"),
312
+ 1 if summary_provided else 0,
313
+ ),
314
+ )
315
+ conn.commit()
316
+
317
+ def update_tokens(self, session_id: str, tokens: int) -> None:
318
+ """Accumulate token usage for a session."""
319
+ conn = self._conn_or_create()
320
+ conn.execute(
321
+ "UPDATE session_meta SET tokens_used = tokens_used + ?, updated_at = ? WHERE id = ?",
322
+ (tokens, int(datetime.now(timezone.utc).timestamp()), session_id),
323
+ )
324
+ conn.commit()
325
+
326
+ def update_title(self, session_id: str, title: str) -> None:
327
+ """Update the title for a session."""
328
+ conn = self._conn_or_create()
329
+ conn.execute(
330
+ "UPDATE session_meta SET title = ?, updated_at = ? WHERE id = ?",
331
+ (title, int(datetime.now(timezone.utc).timestamp()), session_id),
332
+ )
333
+ conn.commit()
334
+
335
+ def update_model(self, session_id: str, model: str, provider: str) -> bool:
336
+ """Update only model fields, preserving accumulated session stats.
337
+
338
+ The active model is runtime state. Saving it does not refresh the
339
+ activity time that orders the session list.
340
+ """
341
+ conn = self._conn_or_create()
342
+ cur = conn.execute(
343
+ "UPDATE session_meta SET model = ?, provider = ? WHERE id = ?",
344
+ (model, provider, session_id),
345
+ )
346
+ conn.commit()
347
+ return cur.rowcount > 0
348
+
349
+ def update_summary(self, session_id: str, summary: str) -> None:
350
+ """Update the summary for a session."""
351
+ conn = self._conn_or_create()
352
+ conn.execute(
353
+ "UPDATE session_meta SET summary = ?, updated_at = ? WHERE id = ?",
354
+ (summary, int(datetime.now(timezone.utc).timestamp()), session_id),
355
+ )
356
+ conn.commit()
357
+
358
+ def update_message_count(self, session_id: str, count: int) -> None:
359
+ """Update message count and updated_at for a session."""
360
+ conn = self._conn_or_create()
361
+ conn.execute(
362
+ "UPDATE session_meta SET message_count = ?, updated_at = ? WHERE id = ?",
363
+ (count, int(datetime.now(timezone.utc).timestamp()), session_id),
364
+ )
365
+ conn.commit()
366
+
367
+ def get(self, session_id: str) -> dict[str, Any] | None:
368
+ """Get metadata for a single session."""
369
+ conn = self._conn_or_create()
370
+ row = conn.execute(
371
+ "SELECT * FROM session_meta WHERE id = ?", (session_id,)
372
+ ).fetchone()
373
+ if not row:
374
+ return None
375
+ cols = [d[0] for d in conn.execute("SELECT * FROM session_meta LIMIT 0").description]
376
+ return dict(zip(cols, row))
377
+
378
+ def list_by_cwd(self, cwd: str, limit: int = 50) -> list[dict[str, Any]]:
379
+ """List sessions for a project directory, most recent first."""
380
+ conn = self._conn_or_create()
381
+ rows = conn.execute(
382
+ "SELECT * FROM session_meta WHERE cwd = ? AND is_archived = 0 "
383
+ "ORDER BY updated_at DESC, id DESC LIMIT ?",
384
+ (cwd, limit),
385
+ ).fetchall()
386
+ cols = [d[0] for d in conn.execute("SELECT * FROM session_meta LIMIT 0").description]
387
+ return [dict(zip(cols, r)) for r in rows]
388
+
389
+ def has_any_by_cwd(self, cwd: str) -> bool:
390
+ """Return whether the index knows this project, including archived rows."""
391
+ conn = self._conn_or_create()
392
+ row = conn.execute(
393
+ "SELECT 1 FROM session_meta WHERE cwd = ? LIMIT 1",
394
+ (cwd,),
395
+ ).fetchone()
396
+ return row is not None
397
+
398
+ def list_states_by_cwd(self, cwd: str) -> dict[str, bool]:
399
+ """Return every indexed session ID mapped to its archived state."""
400
+ conn = self._conn_or_create()
401
+ rows = conn.execute(
402
+ "SELECT id, is_archived FROM session_meta WHERE cwd = ?",
403
+ (cwd,),
404
+ ).fetchall()
405
+ return {str(session_id): bool(is_archived) for session_id, is_archived in rows}
406
+
407
+ def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
408
+ """List all recent sessions across all projects."""
409
+ conn = self._conn_or_create()
410
+ rows = conn.execute(
411
+ "SELECT * FROM session_meta WHERE is_archived = 0 "
412
+ "ORDER BY updated_at DESC, id DESC LIMIT ?",
413
+ (limit,),
414
+ ).fetchall()
415
+ cols = [d[0] for d in conn.execute("SELECT * FROM session_meta LIMIT 0").description]
416
+ return [dict(zip(cols, r)) for r in rows]
417
+
418
+ def find_active_by_prefix(
419
+ self,
420
+ prefix: str,
421
+ *,
422
+ limit: int = 2,
423
+ ) -> list[dict[str, Any]]:
424
+ """Find active sessions whose IDs start with *prefix*.
425
+
426
+ Selector resolution only needs to distinguish no match, one match,
427
+ and ambiguity. Querying by ``substr`` avoids treating ``%`` or ``_``
428
+ in an untrusted selector as SQL wildcard characters and, unlike
429
+ ``list_recent()``, does not silently miss older sessions.
430
+ """
431
+ value = str(prefix or "")
432
+ if not value:
433
+ return []
434
+ bounded_limit = max(1, min(100, int(limit)))
435
+ conn = self._conn_or_create()
436
+ rows = conn.execute(
437
+ "SELECT * FROM session_meta "
438
+ "WHERE is_archived = 0 AND substr(id, 1, ?) = ? "
439
+ "ORDER BY updated_at DESC, id DESC LIMIT ?",
440
+ (len(value), value, bounded_limit),
441
+ ).fetchall()
442
+ cols = [d[0] for d in conn.execute("SELECT * FROM session_meta LIMIT 0").description]
443
+ return [dict(zip(cols, row)) for row in rows]
444
+
445
+ def search(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
446
+ """Search sessions by title or first_user_message."""
447
+ conn = self._conn_or_create()
448
+ like = f"%{query}%"
449
+ rows = conn.execute(
450
+ "SELECT * FROM session_meta "
451
+ "WHERE (title LIKE ? OR first_user_message LIKE ?) AND is_archived = 0 "
452
+ "ORDER BY updated_at DESC LIMIT ?",
453
+ (like, like, limit),
454
+ ).fetchall()
455
+ cols = [d[0] for d in conn.execute("SELECT * FROM session_meta LIMIT 0").description]
456
+ return [dict(zip(cols, r)) for r in rows]
457
+
458
+ def delete(self, session_id: str) -> None:
459
+ """Delete a session metadata row."""
460
+ conn = self._conn_or_create()
461
+ conn.execute("DELETE FROM session_meta WHERE id = ?", (session_id,))
462
+ conn.commit()
463
+
464
+ def archive(self, session_id: str) -> None:
465
+ """Mark a session as archived."""
466
+ conn = self._conn_or_create()
467
+ row = conn.execute(
468
+ "SELECT cwd FROM session_meta WHERE id = ?",
469
+ (session_id,),
470
+ ).fetchone()
471
+ if not row:
472
+ return
473
+ _persist_archive_marker(session_id, str(row[0] or ""))
474
+ now = int(datetime.now(timezone.utc).timestamp())
475
+ conn.execute(
476
+ "UPDATE session_meta SET is_archived = 1, updated_at = ? WHERE id = ?",
477
+ (now, session_id),
478
+ )
479
+ conn.execute(
480
+ "INSERT OR IGNORE INTO session_tombstones (id, cwd, archived_at) VALUES (?, ?, ?)",
481
+ (session_id, str(row[0] or ""), now),
482
+ )
483
+ conn.commit()
484
+
485
+ def restore(self, session_id: str) -> None:
486
+ """Clear archive state after an explicit session recovery."""
487
+ conn = self._conn_or_create()
488
+ conn.execute(
489
+ "UPDATE session_meta SET is_archived = 0, updated_at = ? WHERE id = ?",
490
+ (int(datetime.now(timezone.utc).timestamp()), session_id),
491
+ )
492
+ conn.execute("DELETE FROM session_tombstones WHERE id = ?", (session_id,))
493
+ conn.commit()
494
+
495
+ def auto_archive(
496
+ self,
497
+ days: int = 30,
498
+ *,
499
+ exclude_ids: set[str] | None = None,
500
+ ) -> int:
501
+ """Archive sessions not updated in the last *days* days. Returns count archived."""
502
+ conn = self._conn_or_create()
503
+ cutoff = int(datetime.now(timezone.utc).timestamp()) - days * 86400
504
+ candidates = conn.execute(
505
+ "SELECT id, cwd FROM session_meta "
506
+ "WHERE is_archived = 0 AND updated_at < ?",
507
+ (cutoff,),
508
+ ).fetchall()
509
+ excluded = exclude_ids or set()
510
+ if excluded:
511
+ candidates = [row for row in candidates if str(row[0]) not in excluded]
512
+ archived_ids: list[str] = []
513
+ for session_id, cwd in candidates:
514
+ try:
515
+ _persist_archive_marker(str(session_id), str(cwd or ""))
516
+ except Exception:
517
+ # Keep the index row active when the durable tombstone cannot
518
+ # be committed; a later prune can retry safely.
519
+ continue
520
+ archived_ids.append(str(session_id))
521
+ now = int(datetime.now(timezone.utc).timestamp())
522
+ if archived_ids:
523
+ conn.executemany(
524
+ "UPDATE session_meta SET is_archived = 1, updated_at = ? WHERE id = ?",
525
+ ((now, session_id) for session_id in archived_ids),
526
+ )
527
+ cwd_by_id = {str(session_id): str(cwd or "") for session_id, cwd in candidates}
528
+ conn.executemany(
529
+ "INSERT OR IGNORE INTO session_tombstones (id, cwd, archived_at) "
530
+ "VALUES (?, ?, ?)",
531
+ (
532
+ (session_id, cwd_by_id.get(session_id, ""), now)
533
+ for session_id in archived_ids
534
+ ),
535
+ )
536
+ conn.commit()
537
+ return len(archived_ids)
538
+
539
+ def purge_archived(
540
+ self,
541
+ *,
542
+ delete_rows: bool = True,
543
+ exclude_ids: set[str] | None = None,
544
+ ) -> list[dict[str, Any]]:
545
+ """Prepare archived rows for purge and optionally delete their index rows."""
546
+ conn = self._conn_or_create()
547
+ rows = conn.execute(
548
+ "SELECT id, cwd FROM session_meta WHERE is_archived = 1"
549
+ ).fetchall()
550
+ excluded = exclude_ids or set()
551
+ if excluded:
552
+ rows = [row for row in rows if str(row[0]) not in excluded]
553
+ purgeable: list[tuple[str, str]] = []
554
+ for session_id, cwd in rows:
555
+ try:
556
+ # Upgrade old archived rows before removing their SQLite
557
+ # tombstone. A failed transcript unlink then remains harmless.
558
+ _persist_archive_marker(str(session_id), str(cwd or ""))
559
+ except Exception:
560
+ continue
561
+ purgeable.append((str(session_id), str(cwd or "")))
562
+ if purgeable:
563
+ now = int(datetime.now(timezone.utc).timestamp())
564
+ conn.executemany(
565
+ "INSERT OR IGNORE INTO session_tombstones (id, cwd, archived_at) "
566
+ "VALUES (?, ?, ?)",
567
+ ((session_id, cwd, now) for session_id, cwd in purgeable),
568
+ )
569
+ if purgeable and delete_rows:
570
+ conn.executemany(
571
+ "DELETE FROM session_meta WHERE id = ?",
572
+ ((session_id,) for session_id, _cwd in purgeable),
573
+ )
574
+ if purgeable:
575
+ conn.commit()
576
+ return [{"id": session_id, "cwd": cwd} for session_id, cwd in purgeable]
577
+
578
+ # --- Checkpoints ---
579
+
580
+ def create_checkpoint(
581
+ self,
582
+ session_id: str,
583
+ message_uuid: str,
584
+ message_index: int,
585
+ label: str = "",
586
+ snapshot_id: str | None = None,
587
+ ) -> str:
588
+ """Create a checkpoint at the given message position. Returns checkpoint ID."""
589
+ import uuid
590
+ cp_id = str(uuid.uuid4())
591
+ conn = self._conn_or_create()
592
+ conn.execute(
593
+ "INSERT INTO checkpoints (id, session_id, message_uuid, message_index, label, snapshot_id, created_at) "
594
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
595
+ (cp_id, session_id, message_uuid, message_index, label, snapshot_id,
596
+ int(datetime.now(timezone.utc).timestamp())),
597
+ )
598
+ conn.commit()
599
+ return cp_id
600
+
601
+ def list_checkpoints(self, session_id: str) -> list[dict[str, Any]]:
602
+ """List checkpoints for a session, newest first."""
603
+ conn = self._conn_or_create()
604
+ rows = conn.execute(
605
+ "SELECT id, session_id, message_uuid, message_index, label, snapshot_id, created_at "
606
+ "FROM checkpoints WHERE session_id = ? ORDER BY created_at DESC",
607
+ (session_id,),
608
+ ).fetchall()
609
+ cols = ["id", "session_id", "message_uuid", "message_index", "label", "snapshot_id", "created_at"]
610
+ return [dict(zip(cols, r)) for r in rows]
611
+
612
+ def delete_checkpoint(self, checkpoint_id: str) -> None:
613
+ conn = self._conn_or_create()
614
+ conn.execute("DELETE FROM checkpoints WHERE id = ?", (checkpoint_id,))
615
+ conn.commit()
616
+
617
+ def get_checkpoint(self, checkpoint_id: str) -> dict[str, Any] | None:
618
+ conn = self._conn_or_create()
619
+ row = conn.execute(
620
+ "SELECT id, session_id, message_uuid, message_index, label, snapshot_id, created_at "
621
+ "FROM checkpoints WHERE id = ?",
622
+ (checkpoint_id,),
623
+ ).fetchone()
624
+ if not row:
625
+ return None
626
+ cols = ["id", "session_id", "message_uuid", "message_index", "label", "snapshot_id", "created_at"]
627
+ return dict(zip(cols, row))
628
+
629
+ # --- Statistics ---
630
+
631
+ def stats_global(self) -> dict[str, Any]:
632
+ """Aggregate statistics across all sessions."""
633
+ conn = self._conn_or_create()
634
+ row = conn.execute(
635
+ "SELECT COUNT(*) as total, "
636
+ "COALESCE(SUM(tokens_used), 0) as total_tokens, "
637
+ "COALESCE(SUM(message_count), 0) as total_messages, "
638
+ "COUNT(DISTINCT cwd) as active_projects "
639
+ "FROM session_meta WHERE is_archived = 0"
640
+ ).fetchone()
641
+ now = int(datetime.now(timezone.utc).timestamp())
642
+ week_ago = now - 7 * 86400
643
+ week_row = conn.execute(
644
+ "SELECT COUNT(*) as week_sessions, "
645
+ "COALESCE(SUM(tokens_used), 0) as week_tokens "
646
+ "FROM session_meta WHERE is_archived = 0 AND created_at > ?",
647
+ (week_ago,),
648
+ ).fetchone()
649
+ return {
650
+ "total_sessions": row[0],
651
+ "total_tokens": row[1],
652
+ "total_messages": row[2],
653
+ "active_projects": row[3],
654
+ "week_sessions": week_row[0] if week_row else 0,
655
+ "week_tokens": week_row[1] if week_row else 0,
656
+ }
657
+
658
+ def stats_by_project(self, cwd: str) -> dict[str, Any]:
659
+ """Statistics for a specific project directory."""
660
+ conn = self._conn_or_create()
661
+ row = conn.execute(
662
+ "SELECT COUNT(*) as total, "
663
+ "COALESCE(SUM(tokens_used), 0) as total_tokens, "
664
+ "COALESCE(SUM(message_count), 0) as total_messages "
665
+ "FROM session_meta WHERE cwd = ? AND is_archived = 0",
666
+ (cwd,),
667
+ ).fetchone()
668
+ return {
669
+ "total_sessions": row[0],
670
+ "total_tokens": row[1],
671
+ "total_messages": row[2],
672
+ }
673
+
674
+ def stats_by_model(self, limit: int = 10) -> list[dict[str, Any]]:
675
+ """Token usage aggregated by model."""
676
+ conn = self._conn_or_create()
677
+ rows = conn.execute(
678
+ "SELECT model, COUNT(*) as sessions, "
679
+ "COALESCE(SUM(tokens_used), 0) as tokens "
680
+ "FROM session_meta WHERE is_archived = 0 AND model != '' "
681
+ "GROUP BY model ORDER BY tokens DESC LIMIT ?",
682
+ (limit,),
683
+ ).fetchall()
684
+ return [{"model": r[0], "sessions": r[1], "tokens": r[2]} for r in rows]