carto-md 2.0.7 → 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 +147 -23
- package/package.json +8 -4
- package/scripts/postinstall.js +46 -0
- package/src/acp/agent.js +5 -5
- package/src/acp/providers/index.js +2 -2
- 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/index.js +14 -2
- package/src/cli/init.js +297 -65
- 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 +176 -0
- 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 +489 -8
- package/src/mcp/validate.js +304 -0
- package/src/store/config-loader.js +77 -0
- package/src/store/sqlite-store.js +389 -4
- 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;
|
|
@@ -238,6 +241,84 @@ class SQLiteStore {
|
|
|
238
241
|
);
|
|
239
242
|
`);
|
|
240
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
|
+
|
|
241
322
|
this.setMeta('schema_version', SCHEMA_VERSION);
|
|
242
323
|
}
|
|
243
324
|
|
|
@@ -328,6 +409,11 @@ class SQLiteStore {
|
|
|
328
409
|
this._db.prepare('DELETE FROM models WHERE file_id = ?').run(fileId);
|
|
329
410
|
this._db.prepare('DELETE FROM env_vars WHERE file_id = ?').run(fileId);
|
|
330
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);
|
|
331
417
|
|
|
332
418
|
// Insert imports
|
|
333
419
|
if (data.imports && data.imports.length > 0) {
|
|
@@ -400,6 +486,22 @@ class SQLiteStore {
|
|
|
400
486
|
ins.run(fileId, tableName, t.operation || null);
|
|
401
487
|
}
|
|
402
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
|
+
}
|
|
403
505
|
});
|
|
404
506
|
|
|
405
507
|
tx();
|
|
@@ -571,6 +673,57 @@ class SQLiteStore {
|
|
|
571
673
|
`).all();
|
|
572
674
|
}
|
|
573
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
|
+
|
|
574
727
|
getDomainsList() {
|
|
575
728
|
return this._db.prepare(`
|
|
576
729
|
SELECT d.name, d.file_count as fileCount,
|
|
@@ -640,7 +793,6 @@ class SQLiteStore {
|
|
|
640
793
|
entryPoints,
|
|
641
794
|
highImpact,
|
|
642
795
|
// Defensive parse — a corrupt stack_json row must never crash callers.
|
|
643
|
-
// Spec 7 Bug 5f.
|
|
644
796
|
stack: (() => {
|
|
645
797
|
if (!stack) return [];
|
|
646
798
|
try { return JSON.parse(stack); } catch { return []; }
|
|
@@ -707,6 +859,39 @@ class SQLiteStore {
|
|
|
707
859
|
|
|
708
860
|
// ─── Reverse deps (blast radius) ──────────────────────────────────────
|
|
709
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
|
+
|
|
710
895
|
computeReverseDeps(maxHops = 5) {
|
|
711
896
|
this._db.prepare('DELETE FROM reverse_deps').run();
|
|
712
897
|
|
|
@@ -760,6 +945,206 @@ class SQLiteStore {
|
|
|
760
945
|
`);
|
|
761
946
|
}
|
|
762
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
|
+
|
|
763
1148
|
// ─── Lifecycle ─────────────────────────────────────────────────────────
|
|
764
1149
|
|
|
765
1150
|
close() {
|