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.
@@ -4,7 +4,11 @@ const Database = require('better-sqlite3');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
 
7
- const SCHEMA_VERSION = '1';
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. Closes Bug 2 (carto impact path normalization) and the
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() {