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.
@@ -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;
@@ -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
- stack: stack ? JSON.parse(stack) : [],
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() {