gdmcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/memory/db.py
ADDED
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
"""GdmDatabase — SQLite wrapper around .context-memory/gdm.db.
|
|
2
|
+
|
|
3
|
+
Single point of access for all persistent state. Creates the schema on first
|
|
4
|
+
init. Thread-safe via connection-per-call pattern. Use as a context manager
|
|
5
|
+
or call close() explicitly.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
14
|
+
|
|
15
|
+
from src._internal.constants import _CONTEXT_MEMORY_DIR, _DB_FILENAME
|
|
16
|
+
from src.exceptions import DatabaseError
|
|
17
|
+
|
|
18
|
+
__all__ = ["GdmDatabase"]
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Schema DDL — bump _SCHEMA_VERSION in constants.py when adding tables/columns
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_DDL = """
|
|
27
|
+
PRAGMA journal_mode=WAL;
|
|
28
|
+
PRAGMA foreign_keys=ON;
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
31
|
+
version INTEGER PRIMARY KEY
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
35
|
+
project_id TEXT PRIMARY KEY,
|
|
36
|
+
root_path TEXT UNIQUE NOT NULL,
|
|
37
|
+
name TEXT NOT NULL,
|
|
38
|
+
tech_stack TEXT NOT NULL DEFAULT '[]',
|
|
39
|
+
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
43
|
+
session_id TEXT PRIMARY KEY,
|
|
44
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
updated_at TEXT NOT NULL,
|
|
47
|
+
cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
48
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
49
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
50
|
+
CHECK(status IN ('active', 'complete', 'crashed'))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS memory (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
56
|
+
turn_index INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
role TEXT NOT NULL CHECK(role IN ('user','assistant','tool','system')),
|
|
58
|
+
content TEXT NOT NULL,
|
|
59
|
+
tokens INTEGER NOT NULL DEFAULT 0,
|
|
60
|
+
tool_name TEXT,
|
|
61
|
+
tool_call_id TEXT,
|
|
62
|
+
tool_calls_json TEXT,
|
|
63
|
+
UNIQUE(session_id, turn_index)
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_memory_session ON memory(session_id);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS file_cache (
|
|
68
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
70
|
+
path TEXT NOT NULL,
|
|
71
|
+
mtime REAL NOT NULL,
|
|
72
|
+
summary TEXT,
|
|
73
|
+
last_read_at TEXT NOT NULL,
|
|
74
|
+
UNIQUE(project_id, path)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS conventions (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
80
|
+
key TEXT NOT NULL,
|
|
81
|
+
value TEXT NOT NULL,
|
|
82
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
83
|
+
last_updated TEXT NOT NULL,
|
|
84
|
+
UNIQUE(project_id, key)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS code_index (
|
|
88
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
89
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
90
|
+
file TEXT NOT NULL,
|
|
91
|
+
symbol TEXT NOT NULL,
|
|
92
|
+
kind TEXT NOT NULL,
|
|
93
|
+
line INTEGER NOT NULL,
|
|
94
|
+
signature TEXT
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_code_index_project ON code_index(project_id);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_code_index_symbol ON code_index(symbol);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS errors (
|
|
100
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
101
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
102
|
+
pattern TEXT NOT NULL,
|
|
103
|
+
fix TEXT NOT NULL,
|
|
104
|
+
seen_count INTEGER NOT NULL DEFAULT 1,
|
|
105
|
+
last_seen TEXT NOT NULL,
|
|
106
|
+
UNIQUE(project_id, pattern)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
CREATE TABLE IF NOT EXISTS permissions (
|
|
110
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
111
|
+
session_id TEXT REFERENCES sessions(session_id),
|
|
112
|
+
tool TEXT NOT NULL UNIQUE,
|
|
113
|
+
path_glob TEXT,
|
|
114
|
+
decision TEXT NOT NULL CHECK(decision IN ('allow','deny','allow_session','allow_always','deny_session')),
|
|
115
|
+
expires_at TEXT
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
121
|
+
branch TEXT NOT NULL,
|
|
122
|
+
sha TEXT NOT NULL,
|
|
123
|
+
files_json TEXT NOT NULL DEFAULT '[]',
|
|
124
|
+
cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
125
|
+
ts TEXT NOT NULL
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
129
|
+
task_id TEXT PRIMARY KEY,
|
|
130
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
131
|
+
title TEXT NOT NULL,
|
|
132
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
133
|
+
CHECK(status IN ('pending','in_progress','done','blocked')),
|
|
134
|
+
subtasks TEXT NOT NULL DEFAULT '[]'
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
138
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
140
|
+
ts TEXT NOT NULL,
|
|
141
|
+
tool TEXT NOT NULL,
|
|
142
|
+
args TEXT NOT NULL DEFAULT '{}',
|
|
143
|
+
model TEXT,
|
|
144
|
+
decision TEXT NOT NULL DEFAULT 'allowed'
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
|
|
147
|
+
|
|
148
|
+
CREATE TABLE IF NOT EXISTS btw_queue (
|
|
149
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
150
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
151
|
+
message TEXT NOT NULL,
|
|
152
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
153
|
+
read_at TEXT
|
|
154
|
+
);
|
|
155
|
+
CREATE INDEX IF NOT EXISTS idx_btw_session ON btw_queue(session_id, read_at);
|
|
156
|
+
|
|
157
|
+
CREATE TABLE IF NOT EXISTS spinner_state (
|
|
158
|
+
key TEXT PRIMARY KEY,
|
|
159
|
+
value TEXT NOT NULL
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
163
|
+
event_id TEXT PRIMARY KEY,
|
|
164
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
165
|
+
turn_index INTEGER NOT NULL,
|
|
166
|
+
checkpoint_id INTEGER REFERENCES checkpoints(id),
|
|
167
|
+
model TEXT NOT NULL,
|
|
168
|
+
provider TEXT NOT NULL,
|
|
169
|
+
tier TEXT NOT NULL,
|
|
170
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
171
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
172
|
+
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
|
173
|
+
cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
174
|
+
user_message TEXT,
|
|
175
|
+
assistant_text TEXT,
|
|
176
|
+
annotation TEXT,
|
|
177
|
+
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
178
|
+
UNIQUE(session_id, turn_index)
|
|
179
|
+
);
|
|
180
|
+
CREATE INDEX IF NOT EXISTS idx_session_events_session ON session_events(session_id, turn_index);
|
|
181
|
+
|
|
182
|
+
CREATE TABLE IF NOT EXISTS tool_call_log (
|
|
183
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
184
|
+
event_id TEXT NOT NULL REFERENCES session_events(event_id) ON DELETE CASCADE,
|
|
185
|
+
call_index INTEGER NOT NULL,
|
|
186
|
+
tool_name TEXT NOT NULL,
|
|
187
|
+
call_id TEXT NOT NULL,
|
|
188
|
+
args_json TEXT NOT NULL DEFAULT '{}',
|
|
189
|
+
result_json TEXT,
|
|
190
|
+
duration_ms INTEGER,
|
|
191
|
+
ok INTEGER NOT NULL DEFAULT 1 CHECK(ok IN (0, 1)),
|
|
192
|
+
error TEXT,
|
|
193
|
+
ts TEXT NOT NULL DEFAULT (datetime('now'))
|
|
194
|
+
);
|
|
195
|
+
CREATE INDEX IF NOT EXISTS idx_tool_call_log_event ON tool_call_log(event_id, call_index);
|
|
196
|
+
|
|
197
|
+
CREATE TABLE IF NOT EXISTS patch_log (
|
|
198
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
199
|
+
event_id TEXT NOT NULL REFERENCES session_events(event_id) ON DELETE CASCADE,
|
|
200
|
+
file_path TEXT NOT NULL,
|
|
201
|
+
patch_text TEXT NOT NULL,
|
|
202
|
+
before_sha TEXT,
|
|
203
|
+
after_sha TEXT,
|
|
204
|
+
lines_added INTEGER NOT NULL DEFAULT 0,
|
|
205
|
+
lines_removed INTEGER NOT NULL DEFAULT 0,
|
|
206
|
+
ts TEXT NOT NULL DEFAULT (datetime('now'))
|
|
207
|
+
);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_patch_log_event ON patch_log(event_id);
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_patch_log_file ON patch_log(file_path);
|
|
210
|
+
|
|
211
|
+
CREATE TABLE IF NOT EXISTS cost_events (
|
|
212
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
213
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
214
|
+
event_id TEXT REFERENCES session_events(event_id),
|
|
215
|
+
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
216
|
+
provider TEXT NOT NULL,
|
|
217
|
+
tier TEXT NOT NULL,
|
|
218
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
219
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
220
|
+
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
|
221
|
+
tool_calls_json TEXT NOT NULL DEFAULT '{}',
|
|
222
|
+
cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
223
|
+
scope_project TEXT,
|
|
224
|
+
scope_team TEXT
|
|
225
|
+
);
|
|
226
|
+
CREATE INDEX IF NOT EXISTS idx_cost_events_session ON cost_events(session_id, ts);
|
|
227
|
+
CREATE INDEX IF NOT EXISTS idx_cost_events_ts ON cost_events(ts);
|
|
228
|
+
|
|
229
|
+
CREATE TABLE IF NOT EXISTS budget_limits (
|
|
230
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
231
|
+
scope_type TEXT NOT NULL CHECK(scope_type IN ('session','project','user-global','team')),
|
|
232
|
+
scope_id TEXT NOT NULL,
|
|
233
|
+
period TEXT NOT NULL CHECK(period IN ('session','daily','monthly','total')),
|
|
234
|
+
limit_usd REAL NOT NULL,
|
|
235
|
+
alert_pct_50 INTEGER NOT NULL DEFAULT 1 CHECK(alert_pct_50 IN (0,1)),
|
|
236
|
+
alert_pct_80 INTEGER NOT NULL DEFAULT 1 CHECK(alert_pct_80 IN (0,1)),
|
|
237
|
+
hard_stop INTEGER NOT NULL DEFAULT 1 CHECK(hard_stop IN (0,1)),
|
|
238
|
+
UNIQUE(scope_type, scope_id, period)
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
CREATE TABLE IF NOT EXISTS budget_usage (
|
|
242
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
243
|
+
scope_type TEXT NOT NULL,
|
|
244
|
+
scope_id TEXT NOT NULL,
|
|
245
|
+
period_start TEXT NOT NULL,
|
|
246
|
+
period_end TEXT NOT NULL,
|
|
247
|
+
spent_usd REAL NOT NULL DEFAULT 0.0,
|
|
248
|
+
last_updated TEXT NOT NULL DEFAULT (datetime('now')),
|
|
249
|
+
UNIQUE(scope_type, scope_id, period_start)
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
CREATE TABLE IF NOT EXISTS daemon_jobs (
|
|
253
|
+
job_id TEXT PRIMARY KEY,
|
|
254
|
+
job_type TEXT NOT NULL,
|
|
255
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
256
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
257
|
+
CHECK(status IN ('pending','claimed','done','failed','dead')),
|
|
258
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
259
|
+
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
260
|
+
lease_until TEXT,
|
|
261
|
+
result_json TEXT,
|
|
262
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
263
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
264
|
+
error TEXT
|
|
265
|
+
);
|
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_daemon_jobs_status ON daemon_jobs(status, created_at);
|
|
267
|
+
|
|
268
|
+
CREATE TABLE IF NOT EXISTS developer_decisions (
|
|
269
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
270
|
+
session_id TEXT NOT NULL,
|
|
271
|
+
file_context TEXT,
|
|
272
|
+
decision_type TEXT NOT NULL,
|
|
273
|
+
value TEXT NOT NULL,
|
|
274
|
+
turn_index INTEGER,
|
|
275
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
276
|
+
);
|
|
277
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_file ON developer_decisions (file_context);
|
|
278
|
+
|
|
279
|
+
CREATE TABLE IF NOT EXISTS file_hotspots (
|
|
280
|
+
file_path TEXT NOT NULL,
|
|
281
|
+
project_id TEXT NOT NULL,
|
|
282
|
+
edit_session_count INTEGER DEFAULT 1,
|
|
283
|
+
total_edit_count INTEGER DEFAULT 1,
|
|
284
|
+
last_edited_at TEXT,
|
|
285
|
+
PRIMARY KEY (file_path, project_id)
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
CREATE TABLE IF NOT EXISTS batch_jobs (
|
|
289
|
+
id TEXT PRIMARY KEY,
|
|
290
|
+
provider TEXT NOT NULL,
|
|
291
|
+
batch_id TEXT,
|
|
292
|
+
job_type TEXT NOT NULL,
|
|
293
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
294
|
+
request_count INTEGER NOT NULL DEFAULT 0,
|
|
295
|
+
created_at TEXT NOT NULL,
|
|
296
|
+
submitted_at TEXT,
|
|
297
|
+
completed_at TEXT,
|
|
298
|
+
error TEXT,
|
|
299
|
+
result_path TEXT
|
|
300
|
+
);
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class GdmDatabase:
|
|
305
|
+
"""Manages the SQLite database at `.context-memory/gdm.db`.
|
|
306
|
+
|
|
307
|
+
Usage::
|
|
308
|
+
|
|
309
|
+
db = GdmDatabase(project_root=Path("."))
|
|
310
|
+
with db:
|
|
311
|
+
db.upsert_convention(project_id, "naming", "snake_case", 0.95)
|
|
312
|
+
|
|
313
|
+
Or without context manager::
|
|
314
|
+
|
|
315
|
+
db = GdmDatabase(project_root=Path("."))
|
|
316
|
+
db.spinner_state_mark_seen("2026-04-08", "Gedeoning")
|
|
317
|
+
db.close()
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
def __init__(self, project_root: Path) -> None:
|
|
321
|
+
self._db_path = project_root / _CONTEXT_MEMORY_DIR / _DB_FILENAME
|
|
322
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
self._lock = threading.RLock() # serialise cross-thread access to _conn
|
|
324
|
+
self._conn: sqlite3.Connection = self._connect()
|
|
325
|
+
self._ensure_schema()
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
# Connection management
|
|
329
|
+
# ------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def _connect(self) -> sqlite3.Connection:
|
|
332
|
+
try:
|
|
333
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
334
|
+
conn.row_factory = sqlite3.Row
|
|
335
|
+
conn.execute("PRAGMA busy_timeout = 2000")
|
|
336
|
+
return conn
|
|
337
|
+
except sqlite3.Error as exc:
|
|
338
|
+
raise DatabaseError(f"Cannot open database at {self._db_path}: {exc}") from exc
|
|
339
|
+
|
|
340
|
+
def _ensure_schema(self) -> None:
|
|
341
|
+
from src.db.migrations import registry # local import avoids circular at module level
|
|
342
|
+
try:
|
|
343
|
+
self._conn.executescript(_DDL)
|
|
344
|
+
registry.apply_pending(self._conn)
|
|
345
|
+
except sqlite3.Error as exc:
|
|
346
|
+
raise DatabaseError(f"Schema init failed: {exc}") from exc
|
|
347
|
+
|
|
348
|
+
def close(self) -> None:
|
|
349
|
+
"""Close the database connection."""
|
|
350
|
+
self._conn.close()
|
|
351
|
+
|
|
352
|
+
def __enter__(self) -> GdmDatabase:
|
|
353
|
+
return self
|
|
354
|
+
|
|
355
|
+
def __exit__(self, *_: object) -> None:
|
|
356
|
+
self.close()
|
|
357
|
+
|
|
358
|
+
from contextlib import contextmanager
|
|
359
|
+
|
|
360
|
+
@contextmanager # type: ignore[misc]
|
|
361
|
+
def transaction(self): # type: ignore[return]
|
|
362
|
+
"""Context manager for an explicit SQLite transaction.
|
|
363
|
+
|
|
364
|
+
All statements inside the block run in one atomic transaction.
|
|
365
|
+
Rolls back on any exception. Thread-safe: acquires _lock for the
|
|
366
|
+
full duration so concurrent threads cannot interleave statements.
|
|
367
|
+
"""
|
|
368
|
+
with self._lock:
|
|
369
|
+
try:
|
|
370
|
+
self._conn.execute("BEGIN")
|
|
371
|
+
yield
|
|
372
|
+
self._conn.execute("COMMIT")
|
|
373
|
+
except Exception:
|
|
374
|
+
try:
|
|
375
|
+
self._conn.execute("ROLLBACK")
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
raise
|
|
379
|
+
|
|
380
|
+
# ------------------------------------------------------------------
|
|
381
|
+
# Spinner state
|
|
382
|
+
# ------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
def spinner_state_get(self, date: str) -> list[str]:
|
|
385
|
+
"""Return list of priority verbs already seen on `date`."""
|
|
386
|
+
cur = self._conn.execute(
|
|
387
|
+
"SELECT value FROM spinner_state WHERE key = ?", (f"seen:{date}",)
|
|
388
|
+
)
|
|
389
|
+
row = cur.fetchone()
|
|
390
|
+
if row is None:
|
|
391
|
+
return []
|
|
392
|
+
import json
|
|
393
|
+
return json.loads(row["value"])
|
|
394
|
+
|
|
395
|
+
def spinner_state_mark_seen(self, date: str, verb: str) -> None:
|
|
396
|
+
"""Record that `verb` was shown on `date`."""
|
|
397
|
+
import json
|
|
398
|
+
seen = self.spinner_state_get(date)
|
|
399
|
+
if verb not in seen:
|
|
400
|
+
seen.append(verb)
|
|
401
|
+
self._conn.execute(
|
|
402
|
+
"INSERT OR REPLACE INTO spinner_state (key, value) VALUES (?, ?)",
|
|
403
|
+
(f"seen:{date}", json.dumps(seen)),
|
|
404
|
+
)
|
|
405
|
+
self._conn.commit()
|
|
406
|
+
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
# Audit log
|
|
409
|
+
# ------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
def audit_log_write(
|
|
412
|
+
self,
|
|
413
|
+
session_id: str,
|
|
414
|
+
tool: str,
|
|
415
|
+
args: dict, # type: ignore[type-arg]
|
|
416
|
+
model: str | None = None,
|
|
417
|
+
decision: str = "allowed",
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Append an entry to the audit log."""
|
|
420
|
+
import json
|
|
421
|
+
from datetime import datetime, timezone
|
|
422
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
423
|
+
try:
|
|
424
|
+
self._conn.execute(
|
|
425
|
+
"INSERT INTO audit_log (session_id, ts, tool, args, model, decision) "
|
|
426
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
427
|
+
(session_id, ts, tool, json.dumps(args), model, decision),
|
|
428
|
+
)
|
|
429
|
+
self._conn.commit()
|
|
430
|
+
except sqlite3.Error as exc:
|
|
431
|
+
raise DatabaseError(f"audit_log write failed: {exc}") from exc
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
# Conventions
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def upsert_convention(
|
|
438
|
+
self, project_id: str, key: str, value: str, confidence: float = 1.0
|
|
439
|
+
) -> None:
|
|
440
|
+
from datetime import datetime, timezone
|
|
441
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
442
|
+
try:
|
|
443
|
+
self._conn.execute(
|
|
444
|
+
"""
|
|
445
|
+
INSERT INTO conventions (project_id, key, value, confidence, last_updated)
|
|
446
|
+
VALUES (?, ?, ?, ?, ?)
|
|
447
|
+
ON CONFLICT(project_id, key) DO UPDATE SET
|
|
448
|
+
value = excluded.value,
|
|
449
|
+
confidence = excluded.confidence,
|
|
450
|
+
last_updated = excluded.last_updated
|
|
451
|
+
""",
|
|
452
|
+
(project_id, key, value, confidence, ts),
|
|
453
|
+
)
|
|
454
|
+
self._conn.commit()
|
|
455
|
+
except sqlite3.Error as exc:
|
|
456
|
+
raise DatabaseError(f"upsert_convention failed: {exc}") from exc
|
|
457
|
+
|
|
458
|
+
def get_conventions(self, project_id: str) -> list[sqlite3.Row]:
|
|
459
|
+
cur = self._conn.execute(
|
|
460
|
+
"SELECT key, value, confidence FROM conventions WHERE project_id = ? "
|
|
461
|
+
"ORDER BY confidence DESC",
|
|
462
|
+
(project_id,),
|
|
463
|
+
)
|
|
464
|
+
return cur.fetchall()
|
|
465
|
+
|
|
466
|
+
# ------------------------------------------------------------------
|
|
467
|
+
# File cache
|
|
468
|
+
# ------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
def upsert_file_cache(
|
|
471
|
+
self, project_id: str, path: str, mtime: float, summary: str | None
|
|
472
|
+
) -> None:
|
|
473
|
+
from datetime import datetime, timezone
|
|
474
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
475
|
+
try:
|
|
476
|
+
self._conn.execute(
|
|
477
|
+
"""
|
|
478
|
+
INSERT INTO file_cache (project_id, path, mtime, summary, last_read_at)
|
|
479
|
+
VALUES (?, ?, ?, ?, ?)
|
|
480
|
+
ON CONFLICT(project_id, path) DO UPDATE SET
|
|
481
|
+
mtime = excluded.mtime,
|
|
482
|
+
summary = excluded.summary,
|
|
483
|
+
last_read_at = excluded.last_read_at
|
|
484
|
+
""",
|
|
485
|
+
(project_id, path, mtime, summary, ts),
|
|
486
|
+
)
|
|
487
|
+
self._conn.commit()
|
|
488
|
+
except sqlite3.Error as exc:
|
|
489
|
+
raise DatabaseError(f"upsert_file_cache failed: {exc}") from exc
|
|
490
|
+
|
|
491
|
+
def get_file_cache(self, project_id: str, path: str) -> sqlite3.Row | None:
|
|
492
|
+
cur = self._conn.execute(
|
|
493
|
+
"SELECT mtime, summary, last_read_at FROM file_cache "
|
|
494
|
+
"WHERE project_id = ? AND path = ?",
|
|
495
|
+
(project_id, path),
|
|
496
|
+
)
|
|
497
|
+
return cur.fetchone()
|
|
498
|
+
|
|
499
|
+
# ------------------------------------------------------------------
|
|
500
|
+
# Memory / transcript persistence (crash recovery)
|
|
501
|
+
# ------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
def memory_save_turns(
|
|
504
|
+
self, session_id: str, turns: list[dict] # type: ignore[type-arg]
|
|
505
|
+
) -> None:
|
|
506
|
+
"""Replace all persisted turns for a session (idempotent checkpoint).
|
|
507
|
+
|
|
508
|
+
Enumerates turns to write a stable ``turn_index`` so the UNIQUE
|
|
509
|
+
constraint on ``(session_id, turn_index)`` prevents duplicate rows.
|
|
510
|
+
The DELETE step cleans up any stale rows beyond the current transcript
|
|
511
|
+
length (e.g. after eviction/compression shortened the history).
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
session_id: active session identifier
|
|
515
|
+
turns: list of turn dicts with keys role/content/tokens/
|
|
516
|
+
tool_name/tool_call_id/tool_calls
|
|
517
|
+
"""
|
|
518
|
+
import json as _json
|
|
519
|
+
try:
|
|
520
|
+
with self.transaction():
|
|
521
|
+
# Remove rows beyond the new transcript length (eviction shrinkage).
|
|
522
|
+
self._conn.execute(
|
|
523
|
+
"DELETE FROM memory WHERE session_id = ? AND turn_index >= ?",
|
|
524
|
+
(session_id, len(turns)),
|
|
525
|
+
)
|
|
526
|
+
for idx, t in enumerate(turns):
|
|
527
|
+
self._conn.execute(
|
|
528
|
+
"INSERT OR REPLACE INTO memory "
|
|
529
|
+
"(session_id, turn_index, role, content, tokens, "
|
|
530
|
+
"tool_name, tool_call_id, tool_calls_json) "
|
|
531
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
532
|
+
(
|
|
533
|
+
session_id,
|
|
534
|
+
idx,
|
|
535
|
+
t["role"],
|
|
536
|
+
t.get("content") or "",
|
|
537
|
+
t.get("tokens", 0),
|
|
538
|
+
t.get("tool_name"),
|
|
539
|
+
t.get("tool_call_id"),
|
|
540
|
+
_json.dumps(t["tool_calls"]) if t.get("tool_calls") else None,
|
|
541
|
+
),
|
|
542
|
+
)
|
|
543
|
+
except sqlite3.Error as exc:
|
|
544
|
+
raise DatabaseError(f"memory_save_turns failed: {exc}") from exc
|
|
545
|
+
|
|
546
|
+
def memory_load_turns(
|
|
547
|
+
self, session_id: str
|
|
548
|
+
) -> list[dict]: # type: ignore[type-arg]
|
|
549
|
+
"""Load persisted transcript turns for session restore.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
List of turn dicts ordered by ``turn_index``, ready for
|
|
553
|
+
``Turn.from_message()`` or ``TranscriptStore.from_turns()``.
|
|
554
|
+
"""
|
|
555
|
+
import json as _json
|
|
556
|
+
try:
|
|
557
|
+
cur = self._conn.execute(
|
|
558
|
+
"SELECT role, content, tokens, tool_name, tool_call_id, tool_calls_json "
|
|
559
|
+
"FROM memory WHERE session_id = ? ORDER BY turn_index",
|
|
560
|
+
(session_id,),
|
|
561
|
+
)
|
|
562
|
+
return [
|
|
563
|
+
{
|
|
564
|
+
"role": r["role"],
|
|
565
|
+
"content": r["content"],
|
|
566
|
+
"tokens": r["tokens"],
|
|
567
|
+
"tool_name": r["tool_name"],
|
|
568
|
+
"tool_call_id": r["tool_call_id"],
|
|
569
|
+
"tool_calls": _json.loads(r["tool_calls_json"])
|
|
570
|
+
if r["tool_calls_json"]
|
|
571
|
+
else None,
|
|
572
|
+
}
|
|
573
|
+
for r in cur.fetchall()
|
|
574
|
+
]
|
|
575
|
+
except sqlite3.Error as exc:
|
|
576
|
+
raise DatabaseError(f"memory_load_turns failed: {exc}") from exc
|
|
577
|
+
|
|
578
|
+
def session_set_status(self, session_id: str, status: str) -> None:
|
|
579
|
+
"""Update the lifecycle status of a session.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
session_id: session to update
|
|
583
|
+
status: one of 'active', 'complete', 'crashed'
|
|
584
|
+
"""
|
|
585
|
+
try:
|
|
586
|
+
self._conn.execute(
|
|
587
|
+
"UPDATE sessions SET status = ?, updated_at = datetime('now') "
|
|
588
|
+
"WHERE session_id = ?",
|
|
589
|
+
(status, session_id),
|
|
590
|
+
)
|
|
591
|
+
self._conn.commit()
|
|
592
|
+
except sqlite3.Error as exc:
|
|
593
|
+
raise DatabaseError(f"session_set_status failed: {exc}") from exc
|
|
594
|
+
|
|
595
|
+
def list_incomplete_sessions(
|
|
596
|
+
self, limit: int = 10
|
|
597
|
+
) -> list[dict]: # type: ignore[type-arg]
|
|
598
|
+
"""Return active sessions that have at least one turn, most recently updated first.
|
|
599
|
+
|
|
600
|
+
Returns an empty list if the sessions table predates core-002
|
|
601
|
+
migration (no ``status`` column) — callers should handle empty gracefully.
|
|
602
|
+
|
|
603
|
+
Each dict has keys: ``session_id``, ``created_at``, ``updated_at``,
|
|
604
|
+
``turn_count``, ``cost_usd``, ``status``.
|
|
605
|
+
"""
|
|
606
|
+
try:
|
|
607
|
+
with self._lock:
|
|
608
|
+
rows = self._conn.execute(
|
|
609
|
+
"SELECT session_id, created_at, updated_at, turn_count, cost_usd, status "
|
|
610
|
+
"FROM sessions WHERE status = 'active' AND turn_count > 0 "
|
|
611
|
+
"ORDER BY updated_at DESC LIMIT ?",
|
|
612
|
+
(limit,),
|
|
613
|
+
).fetchall()
|
|
614
|
+
return [dict(r) for r in rows]
|
|
615
|
+
except sqlite3.OperationalError:
|
|
616
|
+
# Pre-migration: sessions table may not have the status column yet.
|
|
617
|
+
return []
|
|
618
|
+
except sqlite3.Error as exc:
|
|
619
|
+
raise DatabaseError(f"list_incomplete_sessions failed: {exc}") from exc
|
|
620
|
+
|
|
621
|
+
# ------------------------------------------------------------------
|
|
622
|
+
# Raw access for advanced queries
|
|
623
|
+
# ------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
_MAX_RETRY: int = 5
|
|
626
|
+
_RETRY_BASE_MS: float = 50.0
|
|
627
|
+
|
|
628
|
+
def _execute_with_retry(self, fn: "Callable[[], Any]", sql: str = "") -> "Any":
|
|
629
|
+
"""Run fn with exponential backoff on SQLITE_BUSY / 'database is locked'."""
|
|
630
|
+
import time as _time
|
|
631
|
+
for attempt in range(self._MAX_RETRY):
|
|
632
|
+
try:
|
|
633
|
+
return fn()
|
|
634
|
+
except sqlite3.OperationalError as exc:
|
|
635
|
+
if "database is locked" not in str(exc).lower() or attempt == self._MAX_RETRY - 1:
|
|
636
|
+
raise DatabaseError(str(exc) + (f"\nSQL: {sql}" if sql else "")) from exc
|
|
637
|
+
wait = self._RETRY_BASE_MS * (2 ** attempt) / 1000.0
|
|
638
|
+
log.debug("SQLITE_BUSY attempt %d/%d, sleeping %.3fs", attempt + 1, self._MAX_RETRY, wait)
|
|
639
|
+
_time.sleep(wait)
|
|
640
|
+
raise DatabaseError("Unreachable") # mypy guard
|
|
641
|
+
|
|
642
|
+
def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
|
|
643
|
+
"""Execute raw SQL. Use sparingly — prefer typed methods above."""
|
|
644
|
+
def _run() -> sqlite3.Cursor:
|
|
645
|
+
with self._lock:
|
|
646
|
+
cur = self._conn.execute(sql, params)
|
|
647
|
+
self._conn.commit()
|
|
648
|
+
return cur
|
|
649
|
+
return self._execute_with_retry(_run, sql)
|
|
650
|
+
|
|
651
|
+
def execute_one(self, sql: str, params: tuple = ()) -> "sqlite3.Row | None":
|
|
652
|
+
"""Execute a SELECT and return at most one row, or None."""
|
|
653
|
+
def _run() -> "sqlite3.Row | None":
|
|
654
|
+
with self._lock:
|
|
655
|
+
cur = self._conn.execute(sql, params)
|
|
656
|
+
return cur.fetchone()
|
|
657
|
+
return self._execute_with_retry(_run, sql)
|
|
658
|
+
|
|
659
|
+
def execute_all(self, sql: str, params: tuple = ()) -> "list[sqlite3.Row]":
|
|
660
|
+
"""Execute a SELECT and return all rows."""
|
|
661
|
+
def _run() -> "list[sqlite3.Row]":
|
|
662
|
+
with self._lock:
|
|
663
|
+
cur = self._conn.execute(sql, params)
|
|
664
|
+
return cur.fetchall()
|
|
665
|
+
return self._execute_with_retry(_run, sql)
|
|
666
|
+
|
|
667
|
+
# ------------------------------------------------------------------
|
|
668
|
+
# Event log (session_events / tool_call_log / patch_log)
|
|
669
|
+
# ------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
def event_log_begin_turn(
|
|
672
|
+
self,
|
|
673
|
+
session_id: str,
|
|
674
|
+
turn_index: int,
|
|
675
|
+
model: str,
|
|
676
|
+
provider: str,
|
|
677
|
+
tier: str,
|
|
678
|
+
user_message: str | None = None,
|
|
679
|
+
) -> str:
|
|
680
|
+
"""Insert a session_events row for a new turn; returns event_id (UUID)."""
|
|
681
|
+
import uuid as _uuid
|
|
682
|
+
from datetime import datetime, timezone
|
|
683
|
+
event_id = str(_uuid.uuid4())
|
|
684
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
685
|
+
self.execute(
|
|
686
|
+
"""
|
|
687
|
+
INSERT INTO session_events
|
|
688
|
+
(event_id, session_id, turn_index, model, provider, tier, user_message, ts)
|
|
689
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
690
|
+
""",
|
|
691
|
+
(event_id, session_id, turn_index, model, provider, tier, user_message, ts),
|
|
692
|
+
)
|
|
693
|
+
return event_id
|
|
694
|
+
|
|
695
|
+
def event_log_complete_turn(
|
|
696
|
+
self,
|
|
697
|
+
event_id: str,
|
|
698
|
+
assistant_text: str | None,
|
|
699
|
+
input_tokens: int,
|
|
700
|
+
output_tokens: int,
|
|
701
|
+
cached_tokens: int,
|
|
702
|
+
cost_usd: float,
|
|
703
|
+
checkpoint_id: int | None = None,
|
|
704
|
+
) -> None:
|
|
705
|
+
"""Update session_events row with turn completion data."""
|
|
706
|
+
self.execute(
|
|
707
|
+
"""
|
|
708
|
+
UPDATE session_events
|
|
709
|
+
SET assistant_text=?, input_tokens=?, output_tokens=?, cached_tokens=?,
|
|
710
|
+
cost_usd=?, checkpoint_id=?
|
|
711
|
+
WHERE event_id=?
|
|
712
|
+
""",
|
|
713
|
+
(assistant_text, input_tokens, output_tokens, cached_tokens,
|
|
714
|
+
cost_usd, checkpoint_id, event_id),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def event_log_record_tool_call(
|
|
718
|
+
self,
|
|
719
|
+
event_id: str,
|
|
720
|
+
call_index: int,
|
|
721
|
+
tool_name: str,
|
|
722
|
+
call_id: str,
|
|
723
|
+
args: dict,
|
|
724
|
+
result: dict | None = None,
|
|
725
|
+
duration_ms: int | None = None,
|
|
726
|
+
ok: bool = True,
|
|
727
|
+
error: str | None = None,
|
|
728
|
+
) -> None:
|
|
729
|
+
"""Insert one tool_call_log row."""
|
|
730
|
+
import json as _json
|
|
731
|
+
self.execute(
|
|
732
|
+
"""
|
|
733
|
+
INSERT INTO tool_call_log
|
|
734
|
+
(event_id, call_index, tool_name, call_id, args_json,
|
|
735
|
+
result_json, duration_ms, ok, error)
|
|
736
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
737
|
+
""",
|
|
738
|
+
(event_id, call_index, tool_name, call_id,
|
|
739
|
+
_json.dumps(args),
|
|
740
|
+
_json.dumps(result) if result is not None else None,
|
|
741
|
+
duration_ms, int(ok), error),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
def event_log_record_patch(
|
|
745
|
+
self,
|
|
746
|
+
event_id: str,
|
|
747
|
+
file_path: str,
|
|
748
|
+
patch_text: str,
|
|
749
|
+
before_sha: str | None = None,
|
|
750
|
+
after_sha: str | None = None,
|
|
751
|
+
) -> None:
|
|
752
|
+
"""Insert one patch_log row with line-count computed from unified diff."""
|
|
753
|
+
added = max(0, patch_text.count("\n+") - patch_text.count("\n+++"))
|
|
754
|
+
removed = max(0, patch_text.count("\n-") - patch_text.count("\n---"))
|
|
755
|
+
self.execute(
|
|
756
|
+
"""
|
|
757
|
+
INSERT INTO patch_log
|
|
758
|
+
(event_id, file_path, patch_text, before_sha, after_sha,
|
|
759
|
+
lines_added, lines_removed)
|
|
760
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
761
|
+
""",
|
|
762
|
+
(event_id, file_path, patch_text, before_sha, after_sha, added, removed),
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def event_log_load_session(self, session_id: str) -> list[dict]:
|
|
766
|
+
"""Load full event log for a session ordered by turn_index.
|
|
767
|
+
|
|
768
|
+
Returns list of dicts with keys: event_id, turn_index, model, provider,
|
|
769
|
+
tier, input_tokens, output_tokens, cached_tokens, cost_usd, user_message,
|
|
770
|
+
assistant_text, ts, checkpoint_id, tool_calls, patches.
|
|
771
|
+
"""
|
|
772
|
+
import json as _json
|
|
773
|
+
events = self.execute_all(
|
|
774
|
+
"SELECT * FROM session_events WHERE session_id=? ORDER BY turn_index",
|
|
775
|
+
(session_id,),
|
|
776
|
+
)
|
|
777
|
+
result = []
|
|
778
|
+
for ev in events:
|
|
779
|
+
ev_dict = dict(ev)
|
|
780
|
+
tc_rows = self.execute_all(
|
|
781
|
+
"SELECT * FROM tool_call_log WHERE event_id=? ORDER BY call_index",
|
|
782
|
+
(ev["event_id"],),
|
|
783
|
+
)
|
|
784
|
+
ev_dict["tool_calls"] = [
|
|
785
|
+
{**dict(r),
|
|
786
|
+
"args": _json.loads(r["args_json"]) if r["args_json"] else {},
|
|
787
|
+
"result": _json.loads(r["result_json"]) if r["result_json"] else None}
|
|
788
|
+
for r in tc_rows
|
|
789
|
+
]
|
|
790
|
+
ev_dict["patches"] = [
|
|
791
|
+
dict(r) for r in self.execute_all(
|
|
792
|
+
"SELECT * FROM patch_log WHERE event_id=? ORDER BY id",
|
|
793
|
+
(ev["event_id"],),
|
|
794
|
+
)
|
|
795
|
+
]
|
|
796
|
+
result.append(ev_dict)
|
|
797
|
+
return result
|
|
798
|
+
|
|
799
|
+
# ------------------------------------------------------------------
|
|
800
|
+
# Cost events
|
|
801
|
+
# ------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
def cost_event_insert(
|
|
804
|
+
self,
|
|
805
|
+
session_id: str,
|
|
806
|
+
provider: str,
|
|
807
|
+
tier: str,
|
|
808
|
+
input_tokens: int,
|
|
809
|
+
output_tokens: int,
|
|
810
|
+
cached_tokens: int,
|
|
811
|
+
cost_usd: float,
|
|
812
|
+
tool_calls_json: str = "{}",
|
|
813
|
+
event_id: str | None = None,
|
|
814
|
+
scope_project: str | None = None,
|
|
815
|
+
scope_team: str | None = None,
|
|
816
|
+
) -> None:
|
|
817
|
+
"""Append one row to the cost_events append-only ledger."""
|
|
818
|
+
from datetime import datetime, timezone
|
|
819
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
820
|
+
self.execute(
|
|
821
|
+
"""
|
|
822
|
+
INSERT INTO cost_events
|
|
823
|
+
(session_id, event_id, ts, provider, tier,
|
|
824
|
+
input_tokens, output_tokens, cached_tokens,
|
|
825
|
+
tool_calls_json, cost_usd, scope_project, scope_team)
|
|
826
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
827
|
+
""",
|
|
828
|
+
(session_id, event_id, ts, provider, tier,
|
|
829
|
+
input_tokens, output_tokens, cached_tokens,
|
|
830
|
+
tool_calls_json, cost_usd, scope_project, scope_team),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
def cost_monthly_spend(
|
|
834
|
+
self, project_id: str | None = None
|
|
835
|
+
) -> float:
|
|
836
|
+
"""Sum cost_usd for the rolling 30-day window ending now."""
|
|
837
|
+
from datetime import datetime, timedelta, timezone
|
|
838
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
|
839
|
+
if project_id:
|
|
840
|
+
row = self.execute_one(
|
|
841
|
+
"""
|
|
842
|
+
SELECT COALESCE(SUM(ce.cost_usd), 0.0) AS total
|
|
843
|
+
FROM cost_events ce
|
|
844
|
+
JOIN sessions s ON ce.session_id = s.session_id
|
|
845
|
+
WHERE s.project_id = ? AND ce.ts >= ?
|
|
846
|
+
""",
|
|
847
|
+
(project_id, cutoff),
|
|
848
|
+
)
|
|
849
|
+
else:
|
|
850
|
+
row = self.execute_one(
|
|
851
|
+
"SELECT COALESCE(SUM(cost_usd), 0.0) AS total FROM cost_events WHERE ts >= ?",
|
|
852
|
+
(cutoff,),
|
|
853
|
+
)
|
|
854
|
+
return float(row["total"]) if row else 0.0
|
|
855
|
+
|
|
856
|
+
# ------------------------------------------------------------------
|
|
857
|
+
# Daemon job queue
|
|
858
|
+
# ------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
def daemon_job_submit(
|
|
861
|
+
self, job_type: str, payload: dict, max_retries: int = 3
|
|
862
|
+
) -> str:
|
|
863
|
+
"""Insert a new pending daemon job; return job_id."""
|
|
864
|
+
import json as _json
|
|
865
|
+
import uuid as _uuid
|
|
866
|
+
job_id = str(_uuid.uuid4())[:8]
|
|
867
|
+
self.execute(
|
|
868
|
+
"""
|
|
869
|
+
INSERT INTO daemon_jobs (job_id, job_type, payload_json, max_retries)
|
|
870
|
+
VALUES (?, ?, ?, ?)
|
|
871
|
+
""",
|
|
872
|
+
(job_id, job_type, _json.dumps(payload), max_retries),
|
|
873
|
+
)
|
|
874
|
+
return job_id
|
|
875
|
+
|
|
876
|
+
def daemon_job_claim(self, lease_secs: float = 120.0) -> "sqlite3.Row | None":
|
|
877
|
+
"""Atomically claim the oldest pending/expired job. Returns full row or None."""
|
|
878
|
+
from datetime import datetime, timedelta, timezone
|
|
879
|
+
now = datetime.now(timezone.utc)
|
|
880
|
+
expires = (now + timedelta(seconds=lease_secs)).isoformat()
|
|
881
|
+
now_iso = now.isoformat()
|
|
882
|
+
try:
|
|
883
|
+
with self.transaction():
|
|
884
|
+
row = self.execute_one(
|
|
885
|
+
"""
|
|
886
|
+
SELECT job_id FROM daemon_jobs
|
|
887
|
+
WHERE status = 'pending'
|
|
888
|
+
OR (status = 'claimed' AND lease_until < ?)
|
|
889
|
+
ORDER BY created_at ASC
|
|
890
|
+
LIMIT 1
|
|
891
|
+
""",
|
|
892
|
+
(now_iso,),
|
|
893
|
+
)
|
|
894
|
+
if row is None:
|
|
895
|
+
return None
|
|
896
|
+
self._conn.execute(
|
|
897
|
+
"""
|
|
898
|
+
UPDATE daemon_jobs
|
|
899
|
+
SET status='claimed', lease_until=?, updated_at=?
|
|
900
|
+
WHERE job_id=?
|
|
901
|
+
""",
|
|
902
|
+
(expires, now_iso, row["job_id"]),
|
|
903
|
+
)
|
|
904
|
+
return self.execute_one(
|
|
905
|
+
"SELECT * FROM daemon_jobs WHERE job_id=?", (row["job_id"],)
|
|
906
|
+
)
|
|
907
|
+
except Exception as exc:
|
|
908
|
+
raise DatabaseError(f"daemon_job_claim failed: {exc}") from exc
|
|
909
|
+
|
|
910
|
+
def daemon_job_complete(self, job_id: str, result: dict) -> None:
|
|
911
|
+
"""Mark job as done and store result."""
|
|
912
|
+
import json as _json
|
|
913
|
+
from datetime import datetime, timezone
|
|
914
|
+
self.execute(
|
|
915
|
+
"UPDATE daemon_jobs SET status='done', result_json=?, updated_at=? WHERE job_id=?",
|
|
916
|
+
(_json.dumps(result), datetime.now(timezone.utc).isoformat(), job_id),
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
def daemon_job_fail(self, job_id: str, error: str) -> None:
|
|
920
|
+
"""Increment retry_count; promote to 'dead' when max_retries exhausted."""
|
|
921
|
+
from datetime import datetime, timezone
|
|
922
|
+
try:
|
|
923
|
+
with self.transaction():
|
|
924
|
+
row = self.execute_one(
|
|
925
|
+
"SELECT retry_count, max_retries FROM daemon_jobs WHERE job_id=?", (job_id,)
|
|
926
|
+
)
|
|
927
|
+
if row is None:
|
|
928
|
+
return
|
|
929
|
+
new_count = row["retry_count"] + 1
|
|
930
|
+
new_status = "dead" if new_count >= row["max_retries"] else "pending"
|
|
931
|
+
self._conn.execute(
|
|
932
|
+
"""
|
|
933
|
+
UPDATE daemon_jobs
|
|
934
|
+
SET status=?, retry_count=?, error=?, lease_until=NULL, updated_at=?
|
|
935
|
+
WHERE job_id=?
|
|
936
|
+
""",
|
|
937
|
+
(new_status, new_count, error, datetime.now(timezone.utc).isoformat(), job_id),
|
|
938
|
+
)
|
|
939
|
+
except Exception as exc:
|
|
940
|
+
raise DatabaseError(f"daemon_job_fail failed: {exc}") from exc
|
|
941
|
+
|
|
942
|
+
def daemon_job_result(self, job_id: str) -> "dict | None":
|
|
943
|
+
"""Return result dict for a done/dead job, or None if still pending/claimed."""
|
|
944
|
+
import json as _json
|
|
945
|
+
row = self.execute_one(
|
|
946
|
+
"SELECT result_json, status, error FROM daemon_jobs WHERE job_id=?", (job_id,)
|
|
947
|
+
)
|
|
948
|
+
if row is None or row["status"] not in ("done", "dead"):
|
|
949
|
+
return None
|
|
950
|
+
if row["result_json"]:
|
|
951
|
+
return _json.loads(row["result_json"])
|
|
952
|
+
return {"ok": False, "error": row["error"]}
|
|
953
|
+
|
|
954
|
+
def daemon_job_pending_count(self) -> int:
|
|
955
|
+
"""Count pending daemon jobs."""
|
|
956
|
+
row = self.execute_one("SELECT COUNT(*) AS n FROM daemon_jobs WHERE status='pending'")
|
|
957
|
+
return int(row["n"]) if row else 0
|
|
958
|
+
|
|
959
|
+
def daemon_job_failed_count(self) -> int:
|
|
960
|
+
"""Count dead (permanently failed) daemon jobs."""
|
|
961
|
+
row = self.execute_one("SELECT COUNT(*) AS n FROM daemon_jobs WHERE status='dead'")
|
|
962
|
+
return int(row["n"]) if row else 0
|
|
963
|
+
|
|
964
|
+
def commit(self) -> None:
|
|
965
|
+
self._conn.commit()
|
|
966
|
+
|
|
967
|
+
# ------------------------------------------------------------------
|
|
968
|
+
# BTW (out-of-band) queue
|
|
969
|
+
# ------------------------------------------------------------------
|
|
970
|
+
|
|
971
|
+
def btw_dequeue_pending(self, session_id: str) -> list[dict]: # type: ignore[type-arg]
|
|
972
|
+
"""Return all unread BTW messages for *session_id*.
|
|
973
|
+
|
|
974
|
+
Does NOT mark them as read — call :meth:`btw_mark_delivered` after
|
|
975
|
+
injecting them into the agent transcript.
|
|
976
|
+
|
|
977
|
+
Returns:
|
|
978
|
+
List of dicts with keys: ``id``, ``message``, ``created_at``.
|
|
979
|
+
"""
|
|
980
|
+
try:
|
|
981
|
+
rows = self.execute_all(
|
|
982
|
+
"SELECT id, message, created_at FROM btw_queue "
|
|
983
|
+
"WHERE session_id = ? AND read_at IS NULL "
|
|
984
|
+
"ORDER BY created_at ASC",
|
|
985
|
+
(session_id,),
|
|
986
|
+
)
|
|
987
|
+
return [dict(r) for r in rows]
|
|
988
|
+
except sqlite3.Error as exc:
|
|
989
|
+
raise DatabaseError(f"btw_dequeue_pending failed: {exc}") from exc
|
|
990
|
+
|
|
991
|
+
def btw_mark_delivered(self, ids: list[int]) -> None:
|
|
992
|
+
"""Mark BTW messages as delivered (sets ``read_at`` to now).
|
|
993
|
+
|
|
994
|
+
Args:
|
|
995
|
+
ids: list of ``btw_queue.id`` values to mark.
|
|
996
|
+
"""
|
|
997
|
+
if not ids:
|
|
998
|
+
return
|
|
999
|
+
from datetime import datetime, timezone
|
|
1000
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
1001
|
+
placeholders = ",".join("?" * len(ids))
|
|
1002
|
+
try:
|
|
1003
|
+
self.execute(
|
|
1004
|
+
f"UPDATE btw_queue SET read_at = ? WHERE id IN ({placeholders})",
|
|
1005
|
+
(now, *ids),
|
|
1006
|
+
)
|
|
1007
|
+
except sqlite3.Error as exc:
|
|
1008
|
+
raise DatabaseError(f"btw_mark_delivered failed: {exc}") from exc
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
# --- VerificationGraph helpers (v9) ---
|
|
1013
|
+
|
|
1014
|
+
def add_edit_node(self, session_id, turn_index, file_path, patch_ref=None):
|
|
1015
|
+
import uuid as _u; from datetime import datetime, timezone
|
|
1016
|
+
from src.artifacts.verification_graph import EditNode
|
|
1017
|
+
nid = str(_u.uuid4()); ts = datetime.now(timezone.utc).isoformat()
|
|
1018
|
+
self.execute(
|
|
1019
|
+
"INSERT INTO edit_nodes (node_id,session_id,turn_index,file_path,patch_ref,created_at) VALUES (?,?,?,?,?,?)",
|
|
1020
|
+
(nid, session_id, turn_index, str(file_path), patch_ref, ts))
|
|
1021
|
+
return EditNode(node_id=nid, session_id=session_id, turn_index=turn_index,
|
|
1022
|
+
file_path=str(file_path), patch_ref=patch_ref, created_at=ts)
|
|
1023
|
+
|
|
1024
|
+
def add_evidence_node(self, node_id, kind, verdict, detail=None, tool=None, duration_ms=None):
|
|
1025
|
+
import json as _j, uuid as _u; from datetime import datetime, timezone
|
|
1026
|
+
from src.artifacts.verification_graph import EvidenceKind, EvidenceNode, Verdict
|
|
1027
|
+
eid = str(_u.uuid4()); ts = datetime.now(timezone.utc).isoformat()
|
|
1028
|
+
kv = kind.value if isinstance(kind, EvidenceKind) else str(kind)
|
|
1029
|
+
vv = verdict.value if isinstance(verdict, Verdict) else str(verdict)
|
|
1030
|
+
dj = _j.dumps(detail if isinstance(detail, dict) else {})
|
|
1031
|
+
self.execute(
|
|
1032
|
+
"INSERT INTO evidence_nodes (evidence_id,node_id,kind,verdict,detail_json,tool,duration_ms,created_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
1033
|
+
(eid, node_id, kv, vv, dj, tool, duration_ms, ts))
|
|
1034
|
+
return EvidenceNode(evidence_id=eid, node_id=node_id, kind=EvidenceKind(kv),
|
|
1035
|
+
verdict=Verdict(vv), detail=_j.loads(dj),
|
|
1036
|
+
tool=tool, duration_ms=duration_ms, created_at=ts)
|
|
1037
|
+
|
|
1038
|
+
def add_graph_edge(self, from_node_id, to_node_id):
|
|
1039
|
+
self.execute("INSERT OR IGNORE INTO graph_edges (from_node_id,to_node_id) VALUES (?,?)",
|
|
1040
|
+
(from_node_id, to_node_id))
|
|
1041
|
+
|
|
1042
|
+
def get_edit_node(self, node_id):
|
|
1043
|
+
from src.artifacts.verification_graph import EditNode
|
|
1044
|
+
r = self.execute_one("SELECT * FROM edit_nodes WHERE node_id=?", (node_id,))
|
|
1045
|
+
if r is None: return None
|
|
1046
|
+
return EditNode(node_id=r["node_id"], session_id=r["session_id"],
|
|
1047
|
+
turn_index=r["turn_index"], file_path=r["file_path"],
|
|
1048
|
+
patch_ref=r["patch_ref"], created_at=r["created_at"])
|
|
1049
|
+
|
|
1050
|
+
def get_evidence_for_node(self, node_id):
|
|
1051
|
+
import json as _j
|
|
1052
|
+
from src.artifacts.verification_graph import EvidenceKind, EvidenceNode, Verdict
|
|
1053
|
+
rows = self.execute_all(
|
|
1054
|
+
"SELECT * FROM evidence_nodes WHERE node_id=? ORDER BY created_at ASC", (node_id,))
|
|
1055
|
+
return [EvidenceNode(evidence_id=r["evidence_id"], node_id=r["node_id"],
|
|
1056
|
+
kind=EvidenceKind(r["kind"]), verdict=Verdict(r["verdict"]),
|
|
1057
|
+
detail=_j.loads(r["detail_json"]),
|
|
1058
|
+
tool=r["tool"], duration_ms=r["duration_ms"],
|
|
1059
|
+
created_at=r["created_at"]) for r in rows]
|
|
1060
|
+
|
|
1061
|
+
def get_dependents(self, node_id):
|
|
1062
|
+
rows = self.execute_all(
|
|
1063
|
+
"SELECT to_node_id FROM graph_edges WHERE from_node_id=?", (node_id,))
|
|
1064
|
+
return [r["to_node_id"] for r in rows]
|
|
1065
|
+
|
|
1066
|
+
def get_edit_nodes_for_session_file(self, session_id, file_path):
|
|
1067
|
+
from src.artifacts.verification_graph import EditNode
|
|
1068
|
+
rows = self.execute_all(
|
|
1069
|
+
"SELECT * FROM edit_nodes WHERE session_id=? AND file_path=? ORDER BY created_at ASC",
|
|
1070
|
+
(session_id, str(file_path)))
|
|
1071
|
+
return [EditNode(node_id=r["node_id"], session_id=r["session_id"],
|
|
1072
|
+
turn_index=r["turn_index"], file_path=r["file_path"],
|
|
1073
|
+
patch_ref=r["patch_ref"], created_at=r["created_at"]) for r in rows]
|
|
1074
|
+
|
|
1075
|
+
# --- Confidence outcomes (v11 / ide-003) ---
|
|
1076
|
+
|
|
1077
|
+
def confidence_outcome_insert(
|
|
1078
|
+
self,
|
|
1079
|
+
session_id: str,
|
|
1080
|
+
hunk_hash: str,
|
|
1081
|
+
score: int,
|
|
1082
|
+
reasons: list,
|
|
1083
|
+
verified: bool = False,
|
|
1084
|
+
) -> int:
|
|
1085
|
+
"""Insert a confidence outcome record and return its row id."""
|
|
1086
|
+
import json as _j
|
|
1087
|
+
from datetime import datetime, timezone
|
|
1088
|
+
|
|
1089
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
1090
|
+
self.execute(
|
|
1091
|
+
"INSERT INTO confidence_outcomes "
|
|
1092
|
+
"(session_id, hunk_hash, score, reasons_json, verified, created_at) "
|
|
1093
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
1094
|
+
(session_id, hunk_hash, score, _j.dumps(reasons), int(verified), now),
|
|
1095
|
+
)
|
|
1096
|
+
row = self.execute_one("SELECT last_insert_rowid() AS rid")
|
|
1097
|
+
return int(row["rid"]) if row else -1
|
|
1098
|
+
|
|
1099
|
+
def confidence_outcomes_for_session(self, session_id: str) -> list:
|
|
1100
|
+
"""Return all confidence outcomes for *session_id*, newest first."""
|
|
1101
|
+
import json as _j
|
|
1102
|
+
|
|
1103
|
+
rows = self.execute_all(
|
|
1104
|
+
"SELECT id, session_id, hunk_hash, score, reasons_json, verified, created_at "
|
|
1105
|
+
"FROM confidence_outcomes WHERE session_id = ? ORDER BY id DESC",
|
|
1106
|
+
(session_id,),
|
|
1107
|
+
)
|
|
1108
|
+
result = []
|
|
1109
|
+
for r in rows:
|
|
1110
|
+
result.append({
|
|
1111
|
+
"id": r["id"],
|
|
1112
|
+
"session_id": r["session_id"],
|
|
1113
|
+
"hunk_hash": r["hunk_hash"],
|
|
1114
|
+
"score": r["score"],
|
|
1115
|
+
"reasons": _j.loads(r["reasons_json"]),
|
|
1116
|
+
"verified": bool(r["verified"]),
|
|
1117
|
+
"created_at": r["created_at"],
|
|
1118
|
+
})
|
|
1119
|
+
return result
|