crabcode 0.1.6__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,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]
|