carto-md 2.0.6 → 2.0.8
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.
- package/README.md +297 -130
- package/docs/screenshots/claude-code-supabase.png +0 -0
- package/package.json +7 -3
- package/scripts/postinstall.js +46 -0
- package/src/acp/agent.js +6 -13
- package/src/acp/providers/index.js +4 -12
- package/src/agents/leiden.js +7 -13
- package/src/bitmap/bitset.js +190 -0
- package/src/bitmap/index.js +121 -0
- package/src/bitmap/sidecar.js +545 -0
- package/src/bitmap/tools.js +310 -0
- package/src/cli/check.js +57 -0
- package/src/cli/impact.js +6 -1
- package/src/cli/index.js +14 -2
- package/src/cli/init.js +297 -50
- package/src/cli/inspect.js +295 -0
- package/src/cli/serve.js +1 -1
- package/src/cli/watch.js +6 -0
- package/src/engine/worker.js +24 -4
- package/src/extractors/imports.js +181 -1
- package/src/extractors/languages/html.js +4 -1
- package/src/extractors/languages/javascript.js +5 -0
- package/src/extractors/languages/prisma.js +4 -1
- package/src/extractors/languages/python.js +5 -1
- package/src/extractors/languages/r.js +4 -1
- package/src/extractors/languages/typescript.js +2 -0
- package/src/extractors/tree-sitter-parser.js +15 -0
- package/src/mcp/diff-parser.js +246 -0
- package/src/mcp/server-v2.js +535 -8
- package/src/mcp/validate.js +304 -0
- package/src/security/ignore.js +56 -1
- package/src/store/config-loader.js +77 -0
- package/src/store/path-utils.js +50 -0
- package/src/store/sqlite-store.js +419 -8
- package/src/store/sync-v2.js +422 -96
- package/BENCHMARK_RESULTS.md +0 -34
|
@@ -4,7 +4,11 @@ const Database = require('better-sqlite3');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Schema version 3 — adds the Episodic Memory tables
|
|
8
|
+
// (ai_sessions, decisions, interventions). The full _ensureSchema() body
|
|
9
|
+
// is idempotent (CREATE TABLE IF NOT EXISTS), so existing v1/v2 DBs
|
|
10
|
+
// cleanly pick up the new tables on next open.
|
|
11
|
+
const SCHEMA_VERSION = '3';
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* normalizePath(p) — Canonicalize a relative path for storage and query.
|
|
@@ -18,8 +22,7 @@ const SCHEMA_VERSION = '1';
|
|
|
18
22
|
* downstream join (imports.from_file_id → files.path) stays consistent.
|
|
19
23
|
*
|
|
20
24
|
* Cross-platform invariant: same code, same DB, same query results on macOS,
|
|
21
|
-
* Linux, and Windows.
|
|
22
|
-
* Windows-only "0 dependents" failures in the Store adapter test suite.
|
|
25
|
+
* Linux, and Windows.
|
|
23
26
|
*/
|
|
24
27
|
function normalizePath(p) {
|
|
25
28
|
if (typeof p !== 'string' || p.length === 0) return p;
|
|
@@ -41,14 +44,35 @@ class SQLiteStore {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/**
|
|
44
|
-
* open() — Opens or creates the database. Applies pragmas and schema.
|
|
47
|
+
* open(opts) — Opens or creates the database. Applies pragmas and schema.
|
|
48
|
+
*
|
|
49
|
+
* Options:
|
|
50
|
+
* readonly — Open the DB in read-only mode. Used by the MCP server
|
|
51
|
+
* (`carto serve`) so a malformed/buggy tool can never write
|
|
52
|
+
* through the SQLite layer. Skips mkdir, schema bootstrap, and
|
|
53
|
+
* WAL pragma (WAL needs write capability and would otherwise
|
|
54
|
+
* create `carto.db-wal`/`carto.db-shm` in repos that only run
|
|
55
|
+
* the MCP server). Sets `fileMustExist: true` so a missing DB
|
|
56
|
+
* returns a clear SQLite error instead of silently creating an
|
|
57
|
+
* empty file in the wrong location.
|
|
45
58
|
*/
|
|
46
|
-
open() {
|
|
59
|
+
open(opts = {}) {
|
|
47
60
|
const cartoDir = path.join(this._projectRoot, '.carto');
|
|
48
|
-
fs.mkdirSync(cartoDir, { recursive: true });
|
|
49
|
-
|
|
50
61
|
const dbPath = path.join(cartoDir, 'carto.db');
|
|
51
62
|
|
|
63
|
+
if (opts.readonly) {
|
|
64
|
+
// Read-only path: dir must already exist (a writer process — `carto sync`
|
|
65
|
+
// or `carto init` — created the DB). Don't mkdir, don't ensure schema,
|
|
66
|
+
// don't apply write-only pragmas.
|
|
67
|
+
this._db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
68
|
+
// Read-only safe pragmas only:
|
|
69
|
+
this._db.pragma('busy_timeout = 5000');
|
|
70
|
+
this._db.pragma('cache_size = -64000'); // 64MB cache
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fs.mkdirSync(cartoDir, { recursive: true });
|
|
75
|
+
|
|
52
76
|
try {
|
|
53
77
|
this._db = new Database(dbPath);
|
|
54
78
|
} catch (err) {
|
|
@@ -217,6 +241,84 @@ class SQLiteStore {
|
|
|
217
241
|
);
|
|
218
242
|
`);
|
|
219
243
|
|
|
244
|
+
// ─── extraction_errors ──────────────────────────────────────────
|
|
245
|
+
// One row per (file, phase) extractor failure. Lets `carto check`,
|
|
246
|
+
// the init summary, and MCP get_architecture surface broken parses
|
|
247
|
+
// instead of silently dropping their data. file_id is FK with
|
|
248
|
+
// ON DELETE CASCADE so removeFile() automatically cleans up.
|
|
249
|
+
this._db.exec(`
|
|
250
|
+
CREATE TABLE IF NOT EXISTS extraction_errors (
|
|
251
|
+
id INTEGER PRIMARY KEY,
|
|
252
|
+
file_id INTEGER NOT NULL,
|
|
253
|
+
phase TEXT NOT NULL,
|
|
254
|
+
error_message TEXT NOT NULL,
|
|
255
|
+
timestamp INTEGER NOT NULL,
|
|
256
|
+
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
257
|
+
);
|
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_extraction_errors_file ON extraction_errors(file_id);
|
|
259
|
+
CREATE INDEX IF NOT EXISTS idx_extraction_errors_phase ON extraction_errors(phase);
|
|
260
|
+
`);
|
|
261
|
+
|
|
262
|
+
// ─── Episodic Memory ────────────────────────────────────────────
|
|
263
|
+
// Three append-mostly tables that turn Carto from an amnesiac
|
|
264
|
+
// lookup layer into a durable record of what the AI is doing.
|
|
265
|
+
//
|
|
266
|
+
// ai_sessions — one row per MCP connection or ACP session.
|
|
267
|
+
// Created lazily by getOrCreateActiveSession().
|
|
268
|
+
// decisions — append-only log of validation requests and
|
|
269
|
+
// architectural choices. payload_json holds the
|
|
270
|
+
// structured body (diff hash, violation summary,
|
|
271
|
+
// etc.); kept TEXT so the schema doesn't need to
|
|
272
|
+
// evolve when callers add fields.
|
|
273
|
+
// interventions — Carto's outputs back to the AI: violations and
|
|
274
|
+
// suggestions. `accepted` is a tri-state (NULL =
|
|
275
|
+
// unknown, 0 = rejected, 1 = accepted) so clients
|
|
276
|
+
// that track follow-through can update later.
|
|
277
|
+
//
|
|
278
|
+
// No FK on session_id — sessions are convenience anchors; we don't
|
|
279
|
+
// want a session row that gets purged in a future cleanup to
|
|
280
|
+
// cascade-delete the historical record.
|
|
281
|
+
this._db.exec(`
|
|
282
|
+
CREATE TABLE IF NOT EXISTS ai_sessions (
|
|
283
|
+
id INTEGER PRIMARY KEY,
|
|
284
|
+
started_at INTEGER NOT NULL,
|
|
285
|
+
ended_at INTEGER,
|
|
286
|
+
client_name TEXT,
|
|
287
|
+
metadata_json TEXT
|
|
288
|
+
);
|
|
289
|
+
CREATE INDEX IF NOT EXISTS idx_ai_sessions_started ON ai_sessions(started_at);
|
|
290
|
+
`);
|
|
291
|
+
|
|
292
|
+
this._db.exec(`
|
|
293
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
294
|
+
id INTEGER PRIMARY KEY,
|
|
295
|
+
session_id INTEGER,
|
|
296
|
+
ts INTEGER NOT NULL,
|
|
297
|
+
kind TEXT NOT NULL,
|
|
298
|
+
file TEXT,
|
|
299
|
+
payload_json TEXT
|
|
300
|
+
);
|
|
301
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_ts ON decisions(ts);
|
|
302
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_session ON decisions(session_id);
|
|
303
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_kind ON decisions(kind);
|
|
304
|
+
`);
|
|
305
|
+
|
|
306
|
+
this._db.exec(`
|
|
307
|
+
CREATE TABLE IF NOT EXISTS interventions (
|
|
308
|
+
id INTEGER PRIMARY KEY,
|
|
309
|
+
session_id INTEGER,
|
|
310
|
+
ts INTEGER NOT NULL,
|
|
311
|
+
kind TEXT NOT NULL,
|
|
312
|
+
file TEXT,
|
|
313
|
+
severity TEXT,
|
|
314
|
+
message TEXT,
|
|
315
|
+
accepted INTEGER DEFAULT NULL
|
|
316
|
+
);
|
|
317
|
+
CREATE INDEX IF NOT EXISTS idx_interventions_file ON interventions(file);
|
|
318
|
+
CREATE INDEX IF NOT EXISTS idx_interventions_ts ON interventions(ts);
|
|
319
|
+
CREATE INDEX IF NOT EXISTS idx_interventions_session ON interventions(session_id);
|
|
320
|
+
`);
|
|
321
|
+
|
|
220
322
|
this.setMeta('schema_version', SCHEMA_VERSION);
|
|
221
323
|
}
|
|
222
324
|
|
|
@@ -307,6 +409,11 @@ class SQLiteStore {
|
|
|
307
409
|
this._db.prepare('DELETE FROM models WHERE file_id = ?').run(fileId);
|
|
308
410
|
this._db.prepare('DELETE FROM env_vars WHERE file_id = ?').run(fileId);
|
|
309
411
|
this._db.prepare('DELETE FROM db_tables WHERE file_id = ?').run(fileId);
|
|
412
|
+
// Clear stale extraction errors for this file. We're
|
|
413
|
+
// about to record fresh ones (or none, if the re-extraction
|
|
414
|
+
// succeeded). Without this, a previously-broken file that's now
|
|
415
|
+
// fixed would still show up in `carto check`.
|
|
416
|
+
this._db.prepare('DELETE FROM extraction_errors WHERE file_id = ?').run(fileId);
|
|
310
417
|
|
|
311
418
|
// Insert imports
|
|
312
419
|
if (data.imports && data.imports.length > 0) {
|
|
@@ -379,6 +486,22 @@ class SQLiteStore {
|
|
|
379
486
|
ins.run(fileId, tableName, t.operation || null);
|
|
380
487
|
}
|
|
381
488
|
}
|
|
489
|
+
|
|
490
|
+
// Insert extraction errors
|
|
491
|
+
if (data.errors && data.errors.length > 0) {
|
|
492
|
+
const ins = this._db.prepare(
|
|
493
|
+
'INSERT INTO extraction_errors (file_id, phase, error_message, timestamp) VALUES (?,?,?,?)'
|
|
494
|
+
);
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
for (const err of data.errors) {
|
|
497
|
+
if (!err || !err.phase || !err.message) continue;
|
|
498
|
+
// Truncate enormous error messages so a single corrupt file
|
|
499
|
+
// can never bloat the DB. 2KB is plenty for any stack frame
|
|
500
|
+
// we'd surface in `carto check`.
|
|
501
|
+
const msg = String(err.message).slice(0, 2000);
|
|
502
|
+
ins.run(fileId, String(err.phase), msg, now);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
382
505
|
});
|
|
383
506
|
|
|
384
507
|
tx();
|
|
@@ -550,6 +673,57 @@ class SQLiteStore {
|
|
|
550
673
|
`).all();
|
|
551
674
|
}
|
|
552
675
|
|
|
676
|
+
// ─── Extraction errors ─────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* getExtractionErrorCount() → integer count of all error rows.
|
|
680
|
+
* Used by `carto init` summary, `carto check`, and MCP get_architecture
|
|
681
|
+
* for a fast "did anything fail?" signal without listing rows.
|
|
682
|
+
*/
|
|
683
|
+
getExtractionErrorCount() {
|
|
684
|
+
const row = this._db.prepare('SELECT COUNT(*) as cnt FROM extraction_errors').get();
|
|
685
|
+
return row ? row.cnt : 0;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* getExtractionErrorsTopFiles(limit) → [{ file, errorCount, phases, sample }]
|
|
690
|
+
* Aggregates by file. `phases` is a comma-joined list of distinct phase
|
|
691
|
+
* tokens for the file; `sample` is one error_message (the most recent).
|
|
692
|
+
*/
|
|
693
|
+
getExtractionErrorsTopFiles(limit = 5) {
|
|
694
|
+
return this._db.prepare(`
|
|
695
|
+
SELECT
|
|
696
|
+
f.path as file,
|
|
697
|
+
COUNT(e.id) as errorCount,
|
|
698
|
+
GROUP_CONCAT(DISTINCT e.phase) as phases,
|
|
699
|
+
(
|
|
700
|
+
SELECT error_message FROM extraction_errors e2
|
|
701
|
+
WHERE e2.file_id = e.file_id
|
|
702
|
+
ORDER BY e2.timestamp DESC, e2.id DESC LIMIT 1
|
|
703
|
+
) as sample
|
|
704
|
+
FROM extraction_errors e
|
|
705
|
+
JOIN files f ON e.file_id = f.id
|
|
706
|
+
GROUP BY e.file_id
|
|
707
|
+
ORDER BY errorCount DESC, f.path
|
|
708
|
+
LIMIT ?
|
|
709
|
+
`).all(limit);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* getExtractionErrorsForFile(relPath) → [{ phase, error_message, timestamp }]
|
|
714
|
+
* Returns all error rows for a single file, newest first.
|
|
715
|
+
*/
|
|
716
|
+
getExtractionErrorsForFile(relPath) {
|
|
717
|
+
const file = this.getFileByPath(relPath);
|
|
718
|
+
if (!file) return [];
|
|
719
|
+
return this._db.prepare(`
|
|
720
|
+
SELECT phase, error_message, timestamp
|
|
721
|
+
FROM extraction_errors
|
|
722
|
+
WHERE file_id = ?
|
|
723
|
+
ORDER BY timestamp DESC, id DESC
|
|
724
|
+
`).all(file.id);
|
|
725
|
+
}
|
|
726
|
+
|
|
553
727
|
getDomainsList() {
|
|
554
728
|
return this._db.prepare(`
|
|
555
729
|
SELECT d.name, d.file_count as fileCount,
|
|
@@ -618,7 +792,11 @@ class SQLiteStore {
|
|
|
618
792
|
},
|
|
619
793
|
entryPoints,
|
|
620
794
|
highImpact,
|
|
621
|
-
|
|
795
|
+
// Defensive parse — a corrupt stack_json row must never crash callers.
|
|
796
|
+
stack: (() => {
|
|
797
|
+
if (!stack) return [];
|
|
798
|
+
try { return JSON.parse(stack); } catch { return []; }
|
|
799
|
+
})(),
|
|
622
800
|
domains
|
|
623
801
|
};
|
|
624
802
|
}
|
|
@@ -681,6 +859,39 @@ class SQLiteStore {
|
|
|
681
859
|
|
|
682
860
|
// ─── Reverse deps (blast radius) ──────────────────────────────────────
|
|
683
861
|
|
|
862
|
+
/**
|
|
863
|
+
* resolveUnresolvedImports() → number
|
|
864
|
+
*
|
|
865
|
+
* Post-pass repair for the chicken-and-egg ordering problem during
|
|
866
|
+
* extraction: when file A is processed before file B but imports it,
|
|
867
|
+
* `to_file_id` is null because B isn't yet in the files table. After
|
|
868
|
+
* the full extraction pass completes, every target path that's an
|
|
869
|
+
* indexed file should be resolvable. This single UPDATE catches them.
|
|
870
|
+
*
|
|
871
|
+
* Returns the number of rows newly resolved. Cheap: one UPDATE with a
|
|
872
|
+
* correlated subquery, indexed on imports.to_path implicitly via the
|
|
873
|
+
* files.path uniqueness constraint.
|
|
874
|
+
*/
|
|
875
|
+
resolveUnresolvedImports() {
|
|
876
|
+
const before = this._db.prepare(
|
|
877
|
+
'SELECT COUNT(*) AS n FROM imports WHERE to_file_id IS NULL'
|
|
878
|
+
).get().n;
|
|
879
|
+
if (before === 0) return 0;
|
|
880
|
+
|
|
881
|
+
this._db.prepare(`
|
|
882
|
+
UPDATE imports
|
|
883
|
+
SET to_file_id = (SELECT id FROM files WHERE path = imports.to_path),
|
|
884
|
+
resolved = 1
|
|
885
|
+
WHERE to_file_id IS NULL
|
|
886
|
+
AND EXISTS (SELECT 1 FROM files WHERE path = imports.to_path)
|
|
887
|
+
`).run();
|
|
888
|
+
|
|
889
|
+
const after = this._db.prepare(
|
|
890
|
+
'SELECT COUNT(*) AS n FROM imports WHERE to_file_id IS NULL'
|
|
891
|
+
).get().n;
|
|
892
|
+
return before - after;
|
|
893
|
+
}
|
|
894
|
+
|
|
684
895
|
computeReverseDeps(maxHops = 5) {
|
|
685
896
|
this._db.prepare('DELETE FROM reverse_deps').run();
|
|
686
897
|
|
|
@@ -734,6 +945,206 @@ class SQLiteStore {
|
|
|
734
945
|
`);
|
|
735
946
|
}
|
|
736
947
|
|
|
948
|
+
// ─── Episodic Memory ──────────────────────────────────────────────────
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* getOrCreateActiveSession(clientName, metadata) → { id, started_at, ... }
|
|
952
|
+
*
|
|
953
|
+
* Returns the most recent open session (no `ended_at`) if any exists,
|
|
954
|
+
* otherwise creates a new one. "Active" is loosely defined — sessions
|
|
955
|
+
* stay open until something explicitly calls `endSession`. The MCP
|
|
956
|
+
* server lazily creates a session per process, so a long-lived MCP
|
|
957
|
+
* connection naturally accretes decisions/interventions under one
|
|
958
|
+
* session row.
|
|
959
|
+
*
|
|
960
|
+
* Requires a writable DB connection. Read-only callers should use
|
|
961
|
+
* `getCurrentSession()` (read-only) or pass an explicit session_id.
|
|
962
|
+
*/
|
|
963
|
+
getOrCreateActiveSession(clientName = null, metadata = null) {
|
|
964
|
+
const existing = this._db.prepare(
|
|
965
|
+
'SELECT id, started_at, ended_at, client_name, metadata_json FROM ai_sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1'
|
|
966
|
+
).get();
|
|
967
|
+
if (existing) return existing;
|
|
968
|
+
|
|
969
|
+
const now = Date.now();
|
|
970
|
+
const metaJson = metadata ? JSON.stringify(metadata) : null;
|
|
971
|
+
const info = this._db.prepare(
|
|
972
|
+
'INSERT INTO ai_sessions (started_at, ended_at, client_name, metadata_json) VALUES (?,?,?,?)'
|
|
973
|
+
).run(now, null, clientName || null, metaJson);
|
|
974
|
+
return {
|
|
975
|
+
id: info.lastInsertRowid,
|
|
976
|
+
started_at: now,
|
|
977
|
+
ended_at: null,
|
|
978
|
+
client_name: clientName || null,
|
|
979
|
+
metadata_json: metaJson,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* getCurrentSession() → row | null
|
|
985
|
+
*
|
|
986
|
+
* Read-only lookup of the most recent active session. Used by the MCP
|
|
987
|
+
* episodic tools to default `session_id` when the caller omits it.
|
|
988
|
+
*/
|
|
989
|
+
getCurrentSession() {
|
|
990
|
+
return this._db.prepare(
|
|
991
|
+
'SELECT id, started_at, ended_at, client_name, metadata_json FROM ai_sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1'
|
|
992
|
+
).get() || null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* endSession(sessionId) — stamp `ended_at` so the session stops being
|
|
997
|
+
* "active". Idempotent: re-ending a session is a no-op.
|
|
998
|
+
*/
|
|
999
|
+
endSession(sessionId) {
|
|
1000
|
+
if (!sessionId) return;
|
|
1001
|
+
this._db.prepare(
|
|
1002
|
+
'UPDATE ai_sessions SET ended_at = ? WHERE id = ? AND ended_at IS NULL'
|
|
1003
|
+
).run(Date.now(), sessionId);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* recordDecision({sessionId, kind, file, payload}) → id
|
|
1008
|
+
*
|
|
1009
|
+
* Append-only insert into `decisions`. payload is JSON-serialized
|
|
1010
|
+
* (defensively — callers may pass strings, objects, or null). Returns
|
|
1011
|
+
* the inserted row id so callers can correlate downstream interventions.
|
|
1012
|
+
* Requires a writable connection.
|
|
1013
|
+
*/
|
|
1014
|
+
recordDecision({ sessionId, kind, file, payload }) {
|
|
1015
|
+
const ts = Date.now();
|
|
1016
|
+
let payloadJson = null;
|
|
1017
|
+
if (payload !== undefined && payload !== null) {
|
|
1018
|
+
if (typeof payload === 'string') {
|
|
1019
|
+
payloadJson = payload;
|
|
1020
|
+
} else {
|
|
1021
|
+
try { payloadJson = JSON.stringify(payload); } catch { payloadJson = null; }
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const info = this._db.prepare(
|
|
1025
|
+
'INSERT INTO decisions (session_id, ts, kind, file, payload_json) VALUES (?,?,?,?,?)'
|
|
1026
|
+
).run(sessionId || null, ts, String(kind), file ? normalizePath(file) : null, payloadJson);
|
|
1027
|
+
return info.lastInsertRowid;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* recordIntervention({sessionId, kind, file, severity, message}) → id
|
|
1032
|
+
*
|
|
1033
|
+
* Append-only insert into `interventions`. `accepted` is left NULL —
|
|
1034
|
+
* clients that track follow-through update it later. Requires a
|
|
1035
|
+
* writable connection.
|
|
1036
|
+
*/
|
|
1037
|
+
recordIntervention({ sessionId, kind, file, severity, message }) {
|
|
1038
|
+
const ts = Date.now();
|
|
1039
|
+
const info = this._db.prepare(
|
|
1040
|
+
'INSERT INTO interventions (session_id, ts, kind, file, severity, message, accepted) VALUES (?,?,?,?,?,?,NULL)'
|
|
1041
|
+
).run(
|
|
1042
|
+
sessionId || null,
|
|
1043
|
+
ts,
|
|
1044
|
+
String(kind),
|
|
1045
|
+
file ? normalizePath(file) : null,
|
|
1046
|
+
severity ? String(severity) : null,
|
|
1047
|
+
message ? String(message).slice(0, 4000) : null
|
|
1048
|
+
);
|
|
1049
|
+
return info.lastInsertRowid;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* getRecentDecisions(timeRangeMs, kind?) → [row, ...]
|
|
1054
|
+
*
|
|
1055
|
+
* Decisions in the last `timeRangeMs` ms, newest first. Optional
|
|
1056
|
+
* `kind` filter (e.g. 'validation'). Read-only.
|
|
1057
|
+
*/
|
|
1058
|
+
getRecentDecisions(timeRangeMs, kind) {
|
|
1059
|
+
const since = Date.now() - (timeRangeMs > 0 ? timeRangeMs : 0);
|
|
1060
|
+
if (kind) {
|
|
1061
|
+
return this._db.prepare(
|
|
1062
|
+
'SELECT id, session_id, ts, kind, file, payload_json FROM decisions WHERE ts >= ? AND kind = ? ORDER BY ts DESC, id DESC'
|
|
1063
|
+
).all(since, String(kind));
|
|
1064
|
+
}
|
|
1065
|
+
return this._db.prepare(
|
|
1066
|
+
'SELECT id, session_id, ts, kind, file, payload_json FROM decisions WHERE ts >= ? ORDER BY ts DESC, id DESC'
|
|
1067
|
+
).all(since);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* getSessionContext(sessionId) → { session, decisions, interventions } | null
|
|
1072
|
+
*
|
|
1073
|
+
* Returns full context for a session: the session row plus all its
|
|
1074
|
+
* decisions and interventions ordered by timestamp ascending (so a
|
|
1075
|
+
* caller can replay the session chronologically). Returns null for
|
|
1076
|
+
* unknown ids. Read-only.
|
|
1077
|
+
*/
|
|
1078
|
+
getSessionContext(sessionId) {
|
|
1079
|
+
if (!sessionId) return null;
|
|
1080
|
+
const session = this._db.prepare(
|
|
1081
|
+
'SELECT id, started_at, ended_at, client_name, metadata_json FROM ai_sessions WHERE id = ?'
|
|
1082
|
+
).get(sessionId);
|
|
1083
|
+
if (!session) return null;
|
|
1084
|
+
const decisions = this._db.prepare(
|
|
1085
|
+
'SELECT id, session_id, ts, kind, file, payload_json FROM decisions WHERE session_id = ? ORDER BY ts ASC, id ASC'
|
|
1086
|
+
).all(sessionId);
|
|
1087
|
+
const interventions = this._db.prepare(
|
|
1088
|
+
'SELECT id, session_id, ts, kind, file, severity, message, accepted FROM interventions WHERE session_id = ? ORDER BY ts ASC, id ASC'
|
|
1089
|
+
).all(sessionId);
|
|
1090
|
+
return { session, decisions, interventions };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* searchDecisions(topic) → [row, ...]
|
|
1095
|
+
*
|
|
1096
|
+
* Substring search over decisions: matches `kind`, `file`, or
|
|
1097
|
+
* `payload_json`. Case-insensitive (LIKE with lowercase). Newest
|
|
1098
|
+
* first. Read-only.
|
|
1099
|
+
*/
|
|
1100
|
+
searchDecisions(topic) {
|
|
1101
|
+
if (!topic || typeof topic !== 'string') return [];
|
|
1102
|
+
const pattern = `%${topic.toLowerCase()}%`;
|
|
1103
|
+
return this._db.prepare(`
|
|
1104
|
+
SELECT id, session_id, ts, kind, file, payload_json
|
|
1105
|
+
FROM decisions
|
|
1106
|
+
WHERE LOWER(kind) LIKE ? OR LOWER(IFNULL(file, '')) LIKE ? OR LOWER(IFNULL(payload_json, '')) LIKE ?
|
|
1107
|
+
ORDER BY ts DESC, id DESC
|
|
1108
|
+
LIMIT 100
|
|
1109
|
+
`).all(pattern, pattern, pattern);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* searchInterventions(topic) → [row, ...]
|
|
1114
|
+
*
|
|
1115
|
+
* Substring search over interventions for `did_we_discuss_this`. Newest
|
|
1116
|
+
* first. Read-only.
|
|
1117
|
+
*/
|
|
1118
|
+
searchInterventions(topic) {
|
|
1119
|
+
if (!topic || typeof topic !== 'string') return [];
|
|
1120
|
+
const pattern = `%${topic.toLowerCase()}%`;
|
|
1121
|
+
return this._db.prepare(`
|
|
1122
|
+
SELECT id, session_id, ts, kind, file, severity, message, accepted
|
|
1123
|
+
FROM interventions
|
|
1124
|
+
WHERE LOWER(kind) LIKE ? OR LOWER(IFNULL(file, '')) LIKE ? OR LOWER(IFNULL(message, '')) LIKE ?
|
|
1125
|
+
ORDER BY ts DESC, id DESC
|
|
1126
|
+
LIMIT 100
|
|
1127
|
+
`).all(pattern, pattern, pattern);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* getInterventionsForFile(file?) → [row, ...]
|
|
1132
|
+
*
|
|
1133
|
+
* If `file` provided, returns interventions for that path (normalized).
|
|
1134
|
+
* If null/undefined, returns all interventions. Newest first.
|
|
1135
|
+
* Read-only.
|
|
1136
|
+
*/
|
|
1137
|
+
getInterventionsForFile(file) {
|
|
1138
|
+
if (file) {
|
|
1139
|
+
return this._db.prepare(
|
|
1140
|
+
'SELECT id, session_id, ts, kind, file, severity, message, accepted FROM interventions WHERE file = ? ORDER BY ts DESC, id DESC LIMIT 200'
|
|
1141
|
+
).all(normalizePath(file));
|
|
1142
|
+
}
|
|
1143
|
+
return this._db.prepare(
|
|
1144
|
+
'SELECT id, session_id, ts, kind, file, severity, message, accepted FROM interventions ORDER BY ts DESC, id DESC LIMIT 200'
|
|
1145
|
+
).all();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
737
1148
|
// ─── Lifecycle ─────────────────────────────────────────────────────────
|
|
738
1149
|
|
|
739
1150
|
close() {
|