devsmind-mcp 2.4.0 → 4.0.0

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.
Files changed (135) hide show
  1. package/README.md +317 -234
  2. package/dist/cli/activity.d.ts +9 -0
  3. package/dist/cli/activity.js +69 -0
  4. package/dist/cli/activity.js.map +1 -0
  5. package/dist/cli/describe.d.ts +31 -0
  6. package/dist/cli/describe.js +201 -0
  7. package/dist/cli/describe.js.map +1 -0
  8. package/dist/cli/diff.d.ts +14 -0
  9. package/dist/cli/diff.js +161 -0
  10. package/dist/cli/diff.js.map +1 -0
  11. package/dist/cli/embed.d.ts +6 -0
  12. package/dist/cli/embed.js +109 -0
  13. package/dist/cli/embed.js.map +1 -0
  14. package/dist/cli/extract-agent.d.ts +66 -0
  15. package/dist/cli/extract-agent.js +192 -0
  16. package/dist/cli/extract-agent.js.map +1 -0
  17. package/dist/cli/feedback.d.ts +11 -0
  18. package/dist/cli/feedback.js +90 -0
  19. package/dist/cli/feedback.js.map +1 -0
  20. package/dist/cli/index.js +139 -9
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/init.d.ts +30 -0
  23. package/dist/cli/init.js +89 -18
  24. package/dist/cli/init.js.map +1 -1
  25. package/dist/cli/integrations/memory-topics.d.ts +55 -0
  26. package/dist/cli/integrations/memory-topics.js +318 -0
  27. package/dist/cli/integrations/memory-topics.js.map +1 -0
  28. package/dist/cli/integrations/memory.d.ts +17 -6
  29. package/dist/cli/integrations/memory.js +52 -139
  30. package/dist/cli/integrations/memory.js.map +1 -1
  31. package/dist/cli/integrations/prompt.d.ts +8 -0
  32. package/dist/cli/integrations/prompt.js +24 -3
  33. package/dist/cli/integrations/prompt.js.map +1 -1
  34. package/dist/cli/integrations/registry.d.ts +26 -9
  35. package/dist/cli/integrations/registry.js +61 -30
  36. package/dist/cli/integrations/registry.js.map +1 -1
  37. package/dist/cli/llm-client.d.ts +116 -0
  38. package/dist/cli/llm-client.js +322 -0
  39. package/dist/cli/llm-client.js.map +1 -0
  40. package/dist/cli/rule.d.ts +22 -2
  41. package/dist/cli/rule.js +110 -34
  42. package/dist/cli/rule.js.map +1 -1
  43. package/dist/cli/runner.d.ts +21 -0
  44. package/dist/cli/runner.js +94 -276
  45. package/dist/cli/runner.js.map +1 -1
  46. package/dist/cli/sync-progress.d.ts +9 -0
  47. package/dist/cli/sync-progress.js +41 -0
  48. package/dist/cli/sync-progress.js.map +1 -0
  49. package/dist/cli/sync.js +5 -2
  50. package/dist/cli/sync.js.map +1 -1
  51. package/dist/cli/view.js +3 -2
  52. package/dist/cli/view.js.map +1 -1
  53. package/dist/cli/workflow.js +26 -20
  54. package/dist/cli/workflow.js.map +1 -1
  55. package/dist/db/activity-graph.d.ts +55 -0
  56. package/dist/db/activity-graph.js +314 -0
  57. package/dist/db/activity-graph.js.map +1 -0
  58. package/dist/db/activity.d.ts +221 -0
  59. package/dist/db/activity.js +348 -0
  60. package/dist/db/activity.js.map +1 -0
  61. package/dist/db/analyze.js +18 -6
  62. package/dist/db/analyze.js.map +1 -1
  63. package/dist/db/database.d.ts +654 -102
  64. package/dist/db/database.js +1911 -558
  65. package/dist/db/database.js.map +1 -1
  66. package/dist/db/edges.d.ts +24 -0
  67. package/dist/db/edges.js +84 -0
  68. package/dist/db/edges.js.map +1 -1
  69. package/dist/db/embedder.d.ts +39 -0
  70. package/dist/db/embedder.js +0 -0
  71. package/dist/db/embedder.js.map +1 -0
  72. package/dist/db/feedback.d.ts +128 -0
  73. package/dist/db/feedback.js +182 -0
  74. package/dist/db/feedback.js.map +1 -0
  75. package/dist/db/file-diff.d.ts +32 -0
  76. package/dist/db/file-diff.js +110 -0
  77. package/dist/db/file-diff.js.map +1 -0
  78. package/dist/db/grep.d.ts +78 -0
  79. package/dist/db/grep.js +475 -0
  80. package/dist/db/grep.js.map +1 -0
  81. package/dist/db/index-build.d.ts +75 -0
  82. package/dist/db/index-build.js +177 -0
  83. package/dist/db/index-build.js.map +1 -0
  84. package/dist/db/message-revert.d.ts +63 -0
  85. package/dist/db/message-revert.js +258 -0
  86. package/dist/db/message-revert.js.map +1 -0
  87. package/dist/db/revert.d.ts +31 -0
  88. package/dist/db/revert.js +108 -0
  89. package/dist/db/revert.js.map +1 -0
  90. package/dist/db/schema.d.ts +49 -4
  91. package/dist/db/schema.js +125 -73
  92. package/dist/db/schema.js.map +1 -1
  93. package/dist/db/search-index.d.ts +65 -0
  94. package/dist/db/search-index.js +74 -0
  95. package/dist/db/search-index.js.map +1 -0
  96. package/dist/db/staging.d.ts +91 -5
  97. package/dist/db/staging.js +148 -22
  98. package/dist/db/staging.js.map +1 -1
  99. package/dist/mcp/server.d.ts +29 -7
  100. package/dist/mcp/server.js +2637 -878
  101. package/dist/mcp/server.js.map +1 -1
  102. package/dist/mcp/vendor/3d-force-graph.min.js +5 -0
  103. package/dist/mcp/vendor/force-graph.min.js +5 -0
  104. package/dist/mcp/vendor/model/model_int8.onnx +0 -0
  105. package/dist/mcp/vendor/model/vocab.txt +30522 -0
  106. package/dist/mcp/vendor/three.min.js +7 -0
  107. package/dist/mcp/view.css +419 -0
  108. package/dist/mcp/view.html +161 -0
  109. package/dist/mcp/view.js +245 -0
  110. package/dist/mcp/view_chat.js +382 -0
  111. package/dist/mcp/view_graph.js +576 -0
  112. package/dist/mcp/visualizer.d.ts +25 -2
  113. package/dist/mcp/visualizer.js +31 -4
  114. package/dist/mcp/visualizer.js.map +1 -1
  115. package/dist/utils/ast.d.ts +89 -0
  116. package/dist/utils/ast.js +378 -10
  117. package/dist/utils/ast.js.map +1 -1
  118. package/dist/utils/diff.d.ts +44 -0
  119. package/dist/utils/diff.js +78 -0
  120. package/dist/utils/diff.js.map +1 -0
  121. package/dist/utils/edit.d.ts +6 -0
  122. package/dist/utils/edit.js +47 -14
  123. package/dist/utils/edit.js.map +1 -1
  124. package/dist/utils/scanner.d.ts +6 -4
  125. package/dist/utils/scanner.js +18 -8
  126. package/dist/utils/scanner.js.map +1 -1
  127. package/dist/utils/tokenize.d.ts +45 -0
  128. package/dist/utils/tokenize.js +129 -0
  129. package/dist/utils/tokenize.js.map +1 -0
  130. package/dist/utils/version.d.ts +14 -0
  131. package/dist/utils/version.js +61 -0
  132. package/dist/utils/version.js.map +1 -0
  133. package/package.json +18 -5
  134. package/dist/mcp/visualizer_2d.html +0 -635
  135. package/dist/mcp/visualizer_3d.html +0 -613
@@ -36,30 +36,96 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.DevMindDatabase = void 0;
39
+ exports.DevMindDatabase = exports.NO_STATIC_CALLERS_NOTE = exports.WORKFLOW_SIDECAR_FILE = exports.WORKFLOW_SCHEMA_VERSION = void 0;
40
+ exports.toCompactSearchResult = toCompactSearchResult;
40
41
  exports.formatReasoning = formatReasoning;
41
42
  exports.parseReasoningBlocks = parseReasoningBlocks;
43
+ exports.parseReasoningBlocksTimed = parseReasoningBlocksTimed;
42
44
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
43
45
  const crypto = __importStar(require("crypto"));
44
46
  const fs = __importStar(require("fs"));
45
47
  const path = __importStar(require("path"));
46
- const zlib = __importStar(require("zlib"));
47
48
  const schema_1 = require("./schema");
48
49
  const config_1 = require("../utils/config");
49
50
  const ast_1 = require("../utils/ast");
50
- function compressText(text) {
51
- return zlib.deflateSync(Buffer.from(text, 'utf-8'));
52
- }
53
- function decompressText(val) {
54
- if (val instanceof Buffer || Buffer.isBuffer(val)) {
55
- try {
56
- return zlib.inflateSync(val).toString('utf-8');
57
- }
58
- catch {
59
- return val.toString('utf-8');
60
- }
61
- }
62
- return String(val);
51
+ const tokenize_1 = require("../utils/tokenize");
52
+ const search_index_1 = require("./search-index");
53
+ const embedder_1 = require("./embedder");
54
+ const grep_1 = require("./grep");
55
+ /** A confident "0 callers" is a false negative on dynamic-dispatch/generated-binding code (RTK
56
+ * hooks, DI containers) — the AST resolver can't see those calls at all. Shared between
57
+ * `search_nodes`' drill-in hooks and `get_node_code`'s `used_by_note` so the two tools can never
58
+ * say this in two slightly different ways. */
59
+ /** How many budget-dropped node ids `getGraph` will name before it stops listing them — enough to
60
+ * act on, not so many that the omission list becomes its own oversized payload. */
61
+ const OMITTED_NODE_ID_CAP = 20;
62
+ /** Bumped when the shape written to `.devmind/workflows/<id>/workflow.json` changes. v2 replaced
63
+ * per-step `history_ids`/`pending_tasks` with `reasoning`/`node_ids`/`doc_paths`, and workflow
64
+ * `status`/`is_active` with `archived`. */
65
+ exports.WORKFLOW_SCHEMA_VERSION = 2;
66
+ /** The half of a workflow a v1 client has no field for, and therefore cannot overwrite when it
67
+ * rewrites `workflow.json` from its own columns. See `writeWorkflowToDisk`. */
68
+ exports.WORKFLOW_SIDECAR_FILE = 'v2.json';
69
+ exports.NO_STATIC_CALLERS_NOTE = 'no static callers found — may be used via dynamic dispatch or a generated binding; verify before assuming unused';
70
+ /** Tier 1 keeps a couple of sample lines; anything past this is bulk, not evidence. */
71
+ const COMPACT_SAMPLE_CAP = 2;
72
+ /** Tier 1 line truncation. 200 chars is enough to read a matching line in context; the full
73
+ * 400-char lines are the single biggest contributor to an oversized files bucket. */
74
+ const COMPACT_LINE_CAP = 200;
75
+ /**
76
+ * Shrink a search result to fit, in two tiers.
77
+ *
78
+ * Why two rather than an on/off switch: the sample lines and `code_matches` are genuinely the
79
+ * most useful part of a result — real agent feedback credits them with catching a live bug — so
80
+ * throwing all of them away at the first byte over a threshold overcorrects. Tier 1 drops what is
81
+ * bulk-without-signal (`match_counts`, `matched_terms`, `aliases`, `created_at`, `deprecated`, and
82
+ * the repeated `used_by_note` boilerplate) and thins the rest; only tier 2 gives up the evidence
83
+ * lines entirely and becomes a pure triage list.
84
+ *
85
+ * `confidence`/`relevance`/`found_by` and the `uses`/`used_by`/`history_count` drill-in hooks
86
+ * survive BOTH tiers on purpose. They are a handful of bytes each and they are precisely what a
87
+ * caller uses to decide which result to open next — dropping them would make a compact response
88
+ * smaller and useless at the same time.
89
+ *
90
+ * Pure: no DB access, no I/O. Kept here rather than in the MCP handler so it is unit-testable
91
+ * directly, and so it sits inside the coverage gate.
92
+ */
93
+ function toCompactSearchResult(result, tier) {
94
+ const keepEvidence = tier === 1;
95
+ const trimLines = (lines) => lines.slice(0, COMPACT_SAMPLE_CAP).map(l => ({ ...l, line_content: l.line_content.slice(0, COMPACT_LINE_CAP) }));
96
+ return {
97
+ nodes: result.nodes.map(n => {
98
+ // Cast rather than narrow the RankedNode union: `code_matches` exists only on the fuzzy
99
+ // variant, and the identifier variant simply has no evidence lines to trim.
100
+ const src = n;
101
+ return {
102
+ id: src.id,
103
+ name: src.name,
104
+ type: src.type,
105
+ confidence: src.confidence,
106
+ relevance: src.relevance,
107
+ found_by: src.found_by,
108
+ file_path: src.file_path,
109
+ signature: src.signature,
110
+ description: src.description,
111
+ uses: src.uses,
112
+ used_by: src.used_by,
113
+ history_count: src.history_count,
114
+ code_matches: keepEvidence && src.code_matches ? trimLines(src.code_matches) : undefined
115
+ };
116
+ }),
117
+ files: result.files.map(f => ({
118
+ file_path: f.file_path,
119
+ total_matches: f.total_matches,
120
+ sample_lines: keepEvidence ? trimLines(f.sample_lines) : undefined
121
+ })),
122
+ files_total: result.files_total,
123
+ files_offset: result.files_offset,
124
+ nodes_total: result.nodes_total,
125
+ hint: result.hint,
126
+ truncated: result.truncated,
127
+ scope_note: result.scope_note
128
+ };
63
129
  }
64
130
  function formatReasoning(r) {
65
131
  if (typeof r === 'string') {
@@ -77,6 +143,43 @@ function formatReasoning(r) {
77
143
  ];
78
144
  return lines.join('\n');
79
145
  }
146
+ /** Matches the `── Update @ … ──` separator `updateHistory` appends each same-session update under. */
147
+ const REASONING_SEPARATOR = /(\n*── Update @ [^\n]*──\n)/g;
148
+ /**
149
+ * Removes one block from an accumulated reasoning log, matched by its exact text and searched
150
+ * from the newest end.
151
+ *
152
+ * Not simply "drop the last block": an update that carries no code change (a bare
153
+ * `update_history`, or an initial index snapshot) appends reasoning without recording an edit,
154
+ * so blocks and edits are not one-to-one and positional removal would take the wrong one.
155
+ * Matching on content is exact when
156
+ * the block is there, and when it isn't the log is returned untouched — leaving a stale line is
157
+ * recoverable, mangling someone else's reasoning is not.
158
+ *
159
+ * Splitting on a capturing group keeps the separators in the result, so every block that stays
160
+ * keeps its original timestamp rather than being re-stamped on the way out.
161
+ */
162
+ function dropReasoningBlock(raw, block) {
163
+ if (!raw || !block)
164
+ return raw;
165
+ const target = block.trim();
166
+ if (!target)
167
+ return raw;
168
+ // [block, sep, block, sep, block, …] — blocks at even indices, separators at odd.
169
+ const parts = raw.split(REASONING_SEPARATOR);
170
+ for (let i = parts.length - 1; i >= 0; i -= 2) {
171
+ if (parts[i].trim() !== target)
172
+ continue;
173
+ // Drop the block with the separator that introduced it. The first block has none, so it
174
+ // takes the separator that follows instead — whatever came after now leads the log.
175
+ if (i > 0)
176
+ parts.splice(i - 1, 2);
177
+ else
178
+ parts.splice(0, 2);
179
+ return parts.join('').replace(/^\n+/, '');
180
+ }
181
+ return raw;
182
+ }
80
183
  /**
81
184
  * Inverse of `formatReasoning`. A single history row accumulates every later update appended
82
185
  * under a `── Update @ … ──` separator, so one stored blob can hold several changes — this
@@ -118,11 +221,50 @@ function parseReasoningBlocks(raw) {
118
221
  });
119
222
  return parsed.reverse();
120
223
  }
224
+ /**
225
+ * {@link parseReasoningBlocks} plus the one thing it throws away: each block's timestamp.
226
+ *
227
+ * The separator that `updateHistory` writes between merged blocks embeds the moment of the update,
228
+ * so an accumulated blob is already a dated log — but the existing parser splits on a
229
+ * non-capturing pattern and returns bare objects, which is all `get_node_code` ever needed.
230
+ * Reconstructing per-commit activity from shared history does need the dates (that's the whole
231
+ * time-filter), hence a sibling rather than a breaking change to a parser with other callers.
232
+ *
233
+ * Returned OLDEST FIRST — accumulation order, the opposite of `parseReasoningBlocks` — because the
234
+ * caller here regroups across rows by timestamp rather than reading "the latest" off the front.
235
+ */
236
+ function parseReasoningBlocksTimed(raw, createdAt) {
237
+ if (!raw || typeof raw !== 'string')
238
+ return [];
239
+ // Capturing the timestamp (not the whole separator) means split() interleaves it into the
240
+ // result: [block0, ts1, block1, ts2, block2, …] — blocks even, timestamps odd.
241
+ const parts = raw.split(/\n*── Update @ ([^\n]*?)\s*──\n/g);
242
+ const out = [];
243
+ for (let i = 0; i < parts.length; i += 2) {
244
+ const text = (parts[i] || '').trim();
245
+ if (!text)
246
+ continue;
247
+ const at = i === 0 ? createdAt : ((parts[i - 1] || '').trim() || createdAt);
248
+ // parseReasoningBlocks on a separator-free chunk always yields exactly one entry; the fallback
249
+ // mirrors its own "unlabelled text is still reasoning" rule rather than dropping the block.
250
+ const parsed = parseReasoningBlocks(text)[0] || { what_changed: text, why: '', goal: '' };
251
+ out.push({ text, at, parsed });
252
+ }
253
+ return out;
254
+ }
121
255
  class DevMindDatabase {
122
256
  db;
123
257
  dbPath;
124
258
  context = null;
125
- constructor(dbPath) {
259
+ /**
260
+ * `onSyncProgress`: optional, fires during the constructor's initial `syncFromDisk()` pass —
261
+ * the one silent stretch every CLI command pays on `new DevMindDatabase(...)` before it can
262
+ * print anything else. Large `.devmind` folders (mainly `history/`, which grows one file per
263
+ * edit — much faster than node count) can make that pass take minutes; without this, a caller
264
+ * has no way to tell "still working" from "hung". Omit it for silent construction (the MCP
265
+ * server's normal path, where per-open console spam would be noise, not signal).
266
+ */
267
+ constructor(dbPath, opts) {
126
268
  this.dbPath = dbPath;
127
269
  // Open SQLite database
128
270
  this.db = new better_sqlite3_1.default(dbPath);
@@ -138,7 +280,14 @@ class DevMindDatabase {
138
280
  // Ignore context errors (e.g. running from scratch scripts)
139
281
  }
140
282
  // Auto-sync history and graph from disk JSONs
141
- this.syncFromDisk();
283
+ this.syncFromDisk(opts?.onSyncProgress);
284
+ }
285
+ /** Throttles progress callbacks to ~100 updates across `total` items, regardless of scale —
286
+ * so a 500-file sync and a 500,000-file sync both report about as often, and the callback
287
+ * itself (a stdout write) never becomes the bottleneck it was being added to diagnose. */
288
+ static shouldReport(done, total) {
289
+ const every = Math.max(1, Math.floor(total / 100));
290
+ return done === total || done % every === 0;
142
291
  }
143
292
  initSchema() {
144
293
  this.db.exec(schema_1.INIT_SCHEMA_SQL);
@@ -148,12 +297,45 @@ class DevMindDatabase {
148
297
  catch {
149
298
  // Column already exists, ignore
150
299
  }
151
- this.db.exec(`
152
- CREATE TABLE IF NOT EXISTS system_meta (
153
- key TEXT PRIMARY KEY,
154
- value TEXT NOT NULL,
155
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
156
- )
300
+ try {
301
+ this.db.exec('ALTER TABLE nodes ADD COLUMN description TEXT');
302
+ }
303
+ catch {
304
+ // Column already exists, ignore
305
+ }
306
+ try {
307
+ this.db.exec("ALTER TABLE nodes ADD COLUMN aliases TEXT DEFAULT '[]'");
308
+ }
309
+ catch {
310
+ // Column already exists, ignore
311
+ }
312
+ // Workflow v2 columns. Same additive, idempotent shape as the three above: on a fresh brain
313
+ // INIT_SCHEMA_SQL already created them so the ALTER throws and the catch absorbs it; on a brain
314
+ // created before v2 the ALTER is what actually adds them. Deliberately NOT paired with a
315
+ // DROP COLUMN for the fields they replace — `status`/`pending_tasks`/`history_ids` stay as
316
+ // vestigial columns, because dropping them buys nothing (they are nullable or defaulted, so
317
+ // nothing has to write them) and would break an older globally-installed CLI opening the same
318
+ // brain.db with "no such column".
319
+ for (const ddl of [
320
+ 'ALTER TABLE workflow_steps ADD COLUMN reasoning TEXT',
321
+ 'ALTER TABLE workflow_steps ADD COLUMN node_ids TEXT',
322
+ 'ALTER TABLE workflow_steps ADD COLUMN doc_paths TEXT',
323
+ 'ALTER TABLE workflows ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'
324
+ ]) {
325
+ try {
326
+ this.db.exec(ddl);
327
+ }
328
+ catch {
329
+ // Column already exists, ignore
330
+ }
331
+ }
332
+ this.backfillWorkflowStepNodeIds();
333
+ this.db.exec(`
334
+ CREATE TABLE IF NOT EXISTS system_meta (
335
+ key TEXT PRIMARY KEY,
336
+ value TEXT NOT NULL,
337
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
338
+ )
157
339
  `);
158
340
  }
159
341
  getContext() {
@@ -170,10 +352,10 @@ class DevMindDatabase {
170
352
  }
171
353
  }
172
354
  setSystemMeta(key, value) {
173
- const stmt = this.db.prepare(`
174
- INSERT INTO system_meta (key, value, updated_at)
175
- VALUES (?, ?, CURRENT_TIMESTAMP)
176
- ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
355
+ const stmt = this.db.prepare(`
356
+ INSERT INTO system_meta (key, value, updated_at)
357
+ VALUES (?, ?, CURRENT_TIMESTAMP)
358
+ ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
177
359
  `);
178
360
  stmt.run(key, value, value);
179
361
  }
@@ -186,7 +368,7 @@ class DevMindDatabase {
186
368
  */
187
369
  getNodesByFilePath(filePath) {
188
370
  const stmt = this.db.prepare(`SELECT * FROM nodes WHERE deprecated = 0 AND REPLACE(LOWER(file_path), '\\', '/') = ?`);
189
- return stmt.all((0, ast_1.normalizeFsPath)(filePath));
371
+ return DevMindDatabase.parseNodeRows(stmt.all((0, ast_1.normalizeFsPath)(filePath)));
190
372
  }
191
373
  close() {
192
374
  this.db.close();
@@ -196,6 +378,10 @@ class DevMindDatabase {
196
378
  const one = (sql) => {
197
379
  try {
198
380
  const row = this.db.prepare(sql).get();
381
+ /* istanbul ignore next -- every call site here is a `SELECT COUNT(*) AS c FROM ...`,
382
+ which always returns exactly one row; `row` can only be undefined if this helper is
383
+ ever repurposed for a query that can return zero rows. Kept as a real guard, not
384
+ because today's three call sites can hit it. */
199
385
  return row ? row.c : 0;
200
386
  }
201
387
  catch {
@@ -226,9 +412,10 @@ class DevMindDatabase {
226
412
  this.db.exec('DELETE FROM node_connections');
227
413
  this.db.exec('DELETE FROM history');
228
414
  this.db.exec('DELETE FROM nodes');
415
+ this.db.exec('DELETE FROM node_vectors');
229
416
  this.db.exec('DELETE FROM system_meta');
230
417
  const workspaceRoot = path.dirname(this.dbPath);
231
- for (const dir of ['graph', 'history']) {
418
+ for (const dir of ['graph', 'history', 'vectors']) {
232
419
  const p = path.join(workspaceRoot, dir);
233
420
  if (fs.existsSync(p)) {
234
421
  fs.rmSync(p, { recursive: true, force: true });
@@ -289,6 +476,11 @@ class DevMindDatabase {
289
476
  upsertNode(node) {
290
477
  const canonicalFp = (0, config_1.canonicalizePath)(node.file_path);
291
478
  const existing = this.getNode(node.id);
479
+ // aliases JSON is only computed when the caller actually passed some — an ordinary
480
+ // edit/re-index of a node that ISN'T alias-bearing must not blank out aliases a prior
481
+ // detector pass (or Phase E's record_alias) already attached, same COALESCE idiom as
482
+ // description/signature below.
483
+ const aliasesJson = node.aliases ? JSON.stringify(Array.from(new Set(node.aliases))) : null;
292
484
  if (existing) {
293
485
  let finalPath = existing.file_path;
294
486
  const paths = existing.file_path.split(',').map(p => p.trim()).filter(Boolean);
@@ -297,36 +489,143 @@ class DevMindDatabase {
297
489
  paths.push(incoming);
298
490
  finalPath = paths.join(', ');
299
491
  }
300
- const stmt = this.db.prepare(`
301
- UPDATE nodes
302
- SET type = ?,
303
- name = ?,
304
- file_path = ?,
305
- signature = COALESCE(?, signature),
306
- deprecated = 0
307
- WHERE id = ?
492
+ // description follows the same COALESCE idiom as signature: an unspecified description
493
+ // on an ordinary edit must never blank out one already written — only an explicit new
494
+ // value (from add_description, or a description passed alongside this edit) overwrites it.
495
+ const stmt = this.db.prepare(`
496
+ UPDATE nodes
497
+ SET type = ?,
498
+ name = ?,
499
+ file_path = ?,
500
+ signature = COALESCE(?, signature),
501
+ description = COALESCE(?, description),
502
+ aliases = COALESCE(?, aliases),
503
+ deprecated = 0
504
+ WHERE id = ?
308
505
  `);
309
- stmt.run(node.type, node.name, finalPath, node.signature || null, node.id);
506
+ stmt.run(node.type, node.name, finalPath, node.signature || null, node.description || null, aliasesJson, node.id);
310
507
  }
311
508
  else {
312
- const stmt = this.db.prepare(`
313
- INSERT INTO nodes (id, type, name, file_path, signature)
314
- VALUES (?, ?, ?, ?, ?)
509
+ const stmt = this.db.prepare(`
510
+ INSERT INTO nodes (id, type, name, file_path, signature, description, aliases)
511
+ VALUES (?, ?, ?, ?, ?, ?, ?)
315
512
  `);
316
- stmt.run(node.id, node.type, node.name, canonicalFp, node.signature || null);
513
+ stmt.run(node.id, node.type, node.name, canonicalFp, node.signature || null, node.description || null, aliasesJson ?? '[]');
317
514
  }
318
515
  this.writeGraphToDisk(canonicalFp);
319
516
  }
517
+ /**
518
+ * Adds one alias to a node WITHOUT touching any it already has — the merge-safe counterpart to
519
+ * `upsertNode`'s replace-if-given aliases. This is what the batch graph-fix session's
520
+ * `record_alias` correction tool (Phase E) uses: it should never be able to accidentally drop an
521
+ * alias a deterministic detector pass already attached.
522
+ */
523
+ addAlias(nodeId, alias) {
524
+ const node = this.getNode(nodeId);
525
+ if (!node)
526
+ return;
527
+ if (node.aliases.includes(alias))
528
+ return;
529
+ const next = [...node.aliases, alias];
530
+ this.db.prepare('UPDATE nodes SET aliases = ? WHERE id = ?').run(JSON.stringify(next), node.id);
531
+ this.writeGraphToDisk(node.file_path);
532
+ }
533
+ /**
534
+ * Stores a node's semantic vector and writes it to the committed `vectors/*.json` tree.
535
+ * `vector` must already be int8-quantized (`embedTextInt8`/`embedTextsInt8` in embedder.ts) and
536
+ * `descriptionHash` must be `hashDescription()` of the exact description it was computed from —
537
+ * this is the staleness key `getNodesNeedingEmbedding` checks against.
538
+ */
539
+ upsertNodeVector(nodeId, vector, descriptionHash) {
540
+ const node = this.getNode(nodeId);
541
+ if (!node)
542
+ return;
543
+ const stmt = this.db.prepare(`
544
+ INSERT INTO node_vectors (node_id, model_id, dim, description_hash, vector)
545
+ VALUES (?, ?, ?, ?, ?)
546
+ ON CONFLICT(node_id) DO UPDATE SET
547
+ model_id = excluded.model_id,
548
+ dim = excluded.dim,
549
+ description_hash = excluded.description_hash,
550
+ vector = excluded.vector
551
+ `);
552
+ stmt.run(node.id, embedder_1.EMBEDDING_MODEL_ID, embedder_1.EMBEDDING_DIM, descriptionHash, Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength));
553
+ this.writeVectorsToDisk(node.file_path);
554
+ }
555
+ getNodeVector(nodeId) {
556
+ const stmt = this.db.prepare('SELECT model_id, dim, description_hash, vector FROM node_vectors WHERE node_id = ?');
557
+ const row = stmt.get(nodeId);
558
+ if (!row)
559
+ return null;
560
+ return {
561
+ modelId: row.model_id,
562
+ dim: row.dim,
563
+ descriptionHash: row.description_hash,
564
+ vector: new Int8Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength)
565
+ };
566
+ }
567
+ /**
568
+ * Every non-deprecated, described node whose vector is missing, from a different model
569
+ * (`model_id` mismatch — e.g. a mismatched vector ignored during `syncFromDisk`), or stale
570
+ * (its description changed since the vector was computed). This is the work queue for both
571
+ * `devsmind embed` and the auto-embed hooks in `describe`/`add_description` — resumable and
572
+ * idempotent by construction, same shape as `describe.ts`'s own `WHERE description IS NULL`.
573
+ * `force: true` returns every described node regardless of vector state (model upgrades).
574
+ */
575
+ getNodesNeedingEmbedding(force = false) {
576
+ const described = this.getAllNodes().filter(n => !n.deprecated && n.description);
577
+ if (force)
578
+ return described;
579
+ const vecRows = this.db.prepare('SELECT node_id, model_id, description_hash FROM node_vectors').all();
580
+ const vecMap = new Map(vecRows.map(r => [r.node_id, r]));
581
+ return described.filter(n => {
582
+ const v = vecMap.get(n.id);
583
+ if (!v)
584
+ return true;
585
+ if (v.model_id !== embedder_1.EMBEDDING_MODEL_ID)
586
+ return true;
587
+ if (v.description_hash !== (0, embedder_1.hashDescription)(n.description))
588
+ return true;
589
+ return false;
590
+ });
591
+ }
592
+ /**
593
+ * The `nodes.aliases` column is a JSON-array-in-TEXT blob — better-sqlite3 hands it back as a
594
+ * raw string, not a parsed array, on every `stmt.all()`/`stmt.get()`. A JSON string masquerading
595
+ * as `string[]` is a silent-wrong-answer hazard (both have `.length`, so a bug here would not
596
+ * throw, just quietly misbehave — e.g. counting characters instead of aliases). EVERY raw SQL
597
+ * read of the `nodes` table must route its rows through {@link parseNodeRow}/{@link
598
+ * parseNodeRows}, never cast `as DbNode`/`as DbNode[]` directly.
599
+ */
600
+ static parseNodeAliases(raw) {
601
+ if (Array.isArray(raw))
602
+ return raw.filter(a => typeof a === 'string');
603
+ if (typeof raw !== 'string' || raw.length === 0)
604
+ return [];
605
+ try {
606
+ const parsed = JSON.parse(raw);
607
+ return Array.isArray(parsed) ? parsed.filter(a => typeof a === 'string') : [];
608
+ }
609
+ catch {
610
+ return [];
611
+ }
612
+ }
613
+ static parseNodeRow(row) {
614
+ return { ...row, aliases: DevMindDatabase.parseNodeAliases(row.aliases) };
615
+ }
616
+ static parseNodeRows(rows) {
617
+ return rows.map(DevMindDatabase.parseNodeRow);
618
+ }
320
619
  getNode(id) {
321
620
  const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
322
621
  const direct = stmt.get(id);
323
622
  if (direct)
324
- return direct;
623
+ return DevMindDatabase.parseNodeRow(direct);
325
624
  if (!id.includes('#')) {
326
625
  const suffixStmt = this.db.prepare("SELECT * FROM nodes WHERE id LIKE ? ESCAPE '\\' AND deprecated = 0");
327
626
  const matches = suffixStmt.all(`%#${this.likeEscape(id)}`);
328
627
  if (matches.length === 1) {
329
- return matches[0];
628
+ return DevMindDatabase.parseNodeRow(matches[0]);
330
629
  }
331
630
  }
332
631
  return null;
@@ -341,8 +640,13 @@ class DevMindDatabase {
341
640
  this.deleteHistoryFilesForNode(resolvedId);
342
641
  const stmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
343
642
  stmt.run(resolvedId);
643
+ // node_vectors has no FK to nodes (see schema.ts) so this doesn't cascade — delete it
644
+ // explicitly rather than waiting for the next syncFromDisk() orphan sweep, or a vector
645
+ // search could keep surfacing a node that no longer exists until the next server restart.
646
+ this.db.prepare('DELETE FROM node_vectors WHERE node_id = ?').run(resolvedId);
344
647
  if (node && node.file_path) {
345
648
  this.writeGraphToDisk(node.file_path);
649
+ this.writeVectorsToDisk(node.file_path);
346
650
  }
347
651
  for (const p of inboundSourceFiles) {
348
652
  this.writeGraphToDisk(p);
@@ -356,14 +660,19 @@ class DevMindDatabase {
356
660
  const inboundSourceFiles = this.collectInboundSourceFiles(resolvedId);
357
661
  const updateStmt = this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?');
358
662
  const deleteConnStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?');
663
+ // A deprecated node's vector is pure dead weight (writeVectorsToDisk already excludes
664
+ // deprecated nodes from the JSON) — drop it now rather than leaving an unused row behind.
665
+ const deleteVectorStmt = this.db.prepare('DELETE FROM node_vectors WHERE node_id = ?');
359
666
  const tx = this.db.transaction(() => {
360
667
  updateStmt.run(resolvedId);
361
668
  deleteConnStmt.run(resolvedId, resolvedId);
669
+ deleteVectorStmt.run(resolvedId);
362
670
  });
363
671
  tx();
364
672
  // Rewrite the node's own file (now carrying deprecated:1) and every caller file (so their
365
673
  // stale inbound edges don't resurrect the connection on the next syncFromDisk()).
366
674
  if (node && node.file_path) {
675
+ this.writeVectorsToDisk(node.file_path);
367
676
  this.writeGraphToDisk(node.file_path);
368
677
  }
369
678
  for (const p of inboundSourceFiles) {
@@ -388,23 +697,29 @@ class DevMindDatabase {
388
697
  this.db.pragma('foreign_keys = OFF');
389
698
  try {
390
699
  const runTx = this.db.transaction(() => {
391
- const insertStmt = this.db.prepare(`
392
- INSERT INTO nodes (id, type, name, file_path, signature, created_at, deprecated)
393
- VALUES (?, ?, ?, ?, ?, ?, ?)
700
+ const insertStmt = this.db.prepare(`
701
+ INSERT INTO nodes (id, type, name, file_path, signature, description, aliases, created_at, deprecated)
702
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
394
703
  `);
395
- insertStmt.run(newId, node.type, name, filePath, node.signature, node.created_at, node.deprecated ? 1 : 0);
396
- const updateSourceStmt = this.db.prepare(`
397
- UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
704
+ insertStmt.run(newId, node.type, name, filePath, node.signature, node.description, JSON.stringify(node.aliases), node.created_at, node.deprecated ? 1 : 0);
705
+ const updateSourceStmt = this.db.prepare(`
706
+ UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
398
707
  `);
399
708
  updateSourceStmt.run(newId, resolvedOldId);
400
- const updateTargetStmt = this.db.prepare(`
401
- UPDATE node_connections SET target_node_id = ? WHERE target_node_id = ?
709
+ const updateTargetStmt = this.db.prepare(`
710
+ UPDATE node_connections SET target_node_id = ? WHERE target_node_id = ?
402
711
  `);
403
712
  updateTargetStmt.run(newId, resolvedOldId);
404
- const updateHistoryStmt = this.db.prepare(`
405
- UPDATE history SET node_id = ? WHERE node_id = ?
713
+ const updateHistoryStmt = this.db.prepare(`
714
+ UPDATE history SET node_id = ? WHERE node_id = ?
406
715
  `);
407
716
  updateHistoryStmt.run(newId, resolvedOldId);
717
+ // Carry any existing vector row to the new id — same reasoning as description above:
718
+ // a rename shouldn't force a re-embed. No-op if the node had no vector yet.
719
+ const updateVectorStmt = this.db.prepare(`
720
+ UPDATE node_vectors SET node_id = ? WHERE node_id = ?
721
+ `);
722
+ updateVectorStmt.run(newId, resolvedOldId);
408
723
  const deleteOldStmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
409
724
  deleteOldStmt.run(resolvedOldId);
410
725
  });
@@ -413,9 +728,11 @@ class DevMindDatabase {
413
728
  // Rewrite the OLD file's graph JSON too when the file itself moved, so the
414
729
  // stale node entry doesn't linger under the old path's JSON on disk.
415
730
  this.writeGraphToDisk(node.file_path);
731
+ this.writeVectorsToDisk(node.file_path);
416
732
  }
417
733
  if (filePath && filePath !== node.file_path) {
418
734
  this.writeGraphToDisk(filePath);
735
+ this.writeVectorsToDisk(filePath);
419
736
  }
420
737
  // Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
421
738
  // (which still reference oldId on disk). The DB was already repointed to newId above,
@@ -434,6 +751,69 @@ class DevMindDatabase {
434
751
  this.db.pragma('foreign_keys = ON');
435
752
  }
436
753
  }
754
+ /**
755
+ * Merges `fromId` into `intoId` — the batch graph-fix session's `merge_nodes` correction, for
756
+ * when curation (or a human reviewing feedback) decides two node candidates were never really
757
+ * distinct entities. Unlike `renameNode` (which moves everything to a FRESH id), `intoId`
758
+ * already exists with its own rows here: `fromId`'s connections (both directions) and history
759
+ * are reassigned onto it, `fromId`'s aliases (plus its own name, so old references by that name
760
+ * still resolve) are folded into `intoId`'s alias set, and `fromId` is deprecated — not hard
761
+ * deleted, so its history stays reachable and the merge itself stays a reversible correction,
762
+ * not a destructive one.
763
+ */
764
+ mergeNodes(fromId, intoId) {
765
+ const fromNode = this.getNode(fromId);
766
+ const intoNode = this.getNode(intoId);
767
+ if (!fromNode)
768
+ throw new Error(`mergeNodes: source node not found: ${fromId}`);
769
+ if (!intoNode)
770
+ throw new Error(`mergeNodes: target node not found: ${intoId}`);
771
+ const resolvedFrom = fromNode.id;
772
+ const resolvedInto = intoNode.id;
773
+ if (resolvedFrom === resolvedInto)
774
+ return; // already the same node — no-op, not an error
775
+ this.db.pragma('foreign_keys = OFF');
776
+ try {
777
+ const runTx = this.db.transaction(() => {
778
+ // Reassign fromId's connections onto intoId in both directions. INSERT OR IGNORE
779
+ // absorbs a duplicate-PK conflict when both nodes already shared a target/source; the
780
+ // `!== resolvedInto` guard drops what would otherwise become a self-referencing edge.
781
+ const outRows = this.db.prepare('SELECT target_node_id FROM node_connections WHERE source_node_id = ?').all(resolvedFrom);
782
+ for (const r of outRows) {
783
+ if (r.target_node_id === resolvedInto)
784
+ continue;
785
+ this.db.prepare('INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id) VALUES (?, ?)').run(resolvedInto, r.target_node_id);
786
+ }
787
+ const inRows = this.db.prepare('SELECT source_node_id FROM node_connections WHERE target_node_id = ?').all(resolvedFrom);
788
+ for (const r of inRows) {
789
+ if (r.source_node_id === resolvedInto)
790
+ continue;
791
+ this.db.prepare('INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id) VALUES (?, ?)').run(r.source_node_id, resolvedInto);
792
+ }
793
+ this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?').run(resolvedFrom, resolvedFrom);
794
+ this.db.prepare('UPDATE history SET node_id = ? WHERE node_id = ?').run(resolvedInto, resolvedFrom);
795
+ const mergedAliases = Array.from(new Set([...intoNode.aliases, ...fromNode.aliases, fromNode.name]));
796
+ this.db.prepare('UPDATE nodes SET aliases = ? WHERE id = ?').run(JSON.stringify(mergedAliases), resolvedInto);
797
+ this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?').run(resolvedFrom);
798
+ });
799
+ runTx();
800
+ if (fromNode.file_path)
801
+ this.writeGraphToDisk(fromNode.file_path);
802
+ if (intoNode.file_path && intoNode.file_path !== fromNode.file_path)
803
+ this.writeGraphToDisk(intoNode.file_path);
804
+ // Edges that pointed INTO fromId now point at intoId in the DB, but the SOURCE files'
805
+ // on-disk JSON still says fromId until rewritten — same reasoning as renameNode's own use
806
+ // of this helper. Queried AFTER the transaction, so it reflects the already-repointed state.
807
+ this.rewriteInboundSourceFiles(resolvedInto);
808
+ const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(resolvedInto);
809
+ for (const row of historyIds) {
810
+ this.patchHistoryDiskIdentity(row.id, resolvedInto, intoNode.name, intoNode.type, intoNode.file_path, intoNode.signature);
811
+ }
812
+ }
813
+ finally {
814
+ this.db.pragma('foreign_keys = ON');
815
+ }
816
+ }
437
817
  /**
438
818
  * Rewrites a history/[id].json file's identifying fields (node_id, node_metadata) in
439
819
  * place, leaving code_snapshot/reasoning/timestamps untouched. Used after a rename so
@@ -468,10 +848,10 @@ class DevMindDatabase {
468
848
  * callers that merely repoint them (rename) can rewrite after the fact.
469
849
  */
470
850
  collectInboundSourceFiles(nodeId) {
471
- const rows = this.db.prepare(`
472
- SELECT DISTINCT n.file_path AS file_path
473
- FROM node_connections c JOIN nodes n ON n.id = c.source_node_id
474
- WHERE c.target_node_id = ?
851
+ const rows = this.db.prepare(`
852
+ SELECT DISTINCT n.file_path AS file_path
853
+ FROM node_connections c JOIN nodes n ON n.id = c.source_node_id
854
+ WHERE c.target_node_id = ?
475
855
  `).all(nodeId);
476
856
  const files = new Set();
477
857
  for (const row of rows) {
@@ -525,15 +905,15 @@ class DevMindDatabase {
525
905
  // caller to add the source node first (the two-phase indexing protocol already does this).
526
906
  if (!srcNode) {
527
907
  console.warn(`⚠️ DevsMind: connection skipped — source node "${sourceNodeId}" does not exist in ` +
528
- `the graph. Add it (stage_change / update_history) before connecting it, otherwise the edge ` +
908
+ `the graph. Add it (edit_node / update_history) before connecting it, otherwise the edge ` +
529
909
  `cannot be persisted to disk and would not survive a restart.`);
530
910
  return;
531
911
  }
532
912
  this.db.pragma('foreign_keys = OFF');
533
913
  try {
534
- const stmt = this.db.prepare(`
535
- INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id)
536
- VALUES (?, ?)
914
+ const stmt = this.db.prepare(`
915
+ INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id)
916
+ VALUES (?, ?)
537
917
  `);
538
918
  stmt.run(resolvedSrc, resolvedTgt);
539
919
  if (srcNode.file_path) {
@@ -549,42 +929,119 @@ class DevMindDatabase {
549
929
  const tgtNode = this.getNode(targetNodeId);
550
930
  const resolvedSrc = srcNode ? srcNode.id : sourceNodeId;
551
931
  const resolvedTgt = tgtNode ? tgtNode.id : targetNodeId;
552
- const stmt = this.db.prepare(`
553
- DELETE FROM node_connections
554
- WHERE source_node_id = ? AND target_node_id = ?
932
+ const stmt = this.db.prepare(`
933
+ DELETE FROM node_connections
934
+ WHERE source_node_id = ? AND target_node_id = ?
555
935
  `);
556
936
  stmt.run(resolvedSrc, resolvedTgt);
557
937
  if (srcNode && srcNode.file_path) {
558
938
  this.writeGraphToDisk(srcNode.file_path);
559
939
  }
560
940
  }
561
- getConnections(nodeId) {
941
+ /**
942
+ * `opts.limit`/`opts.offset` page a hub node's caller/callee list deterministically —
943
+ * `ORDER BY file_path, name` so a repeated call with the same offset returns the same slice,
944
+ * and so the local/nearby callers a reader actually wants tend to sort ahead of a scattered
945
+ * cross-repo tail (same file_path groups together). Omitting `opts` returns every row, exactly
946
+ * as before — every pre-existing call site keeps working unchanged.
947
+ */
948
+ getConnections(nodeId, opts = {}) {
562
949
  const node = this.getNode(nodeId);
563
950
  const resolvedId = node ? node.id : nodeId;
564
- const usesStmt = this.db.prepare(`
565
- SELECT n.* FROM nodes n
566
- JOIN node_connections c ON n.id = c.target_node_id
567
- WHERE c.source_node_id = ?
951
+ const paging = opts.limit !== undefined ? ' ORDER BY n.file_path, n.name LIMIT ? OFFSET ?' : '';
952
+ const usesStmt = this.db.prepare(`
953
+ SELECT n.* FROM nodes n
954
+ JOIN node_connections c ON n.id = c.target_node_id
955
+ WHERE c.source_node_id = ?${paging}
568
956
  `);
569
- const usedByStmt = this.db.prepare(`
570
- SELECT n.* FROM nodes n
571
- JOIN node_connections c ON n.id = c.source_node_id
572
- WHERE c.target_node_id = ?
957
+ const usedByStmt = this.db.prepare(`
958
+ SELECT n.* FROM nodes n
959
+ JOIN node_connections c ON n.id = c.source_node_id
960
+ WHERE c.target_node_id = ?${paging}
573
961
  `);
962
+ const args = opts.limit !== undefined ? [resolvedId, opts.limit, opts.offset ?? 0] : [resolvedId];
574
963
  return {
575
- uses: usesStmt.all(resolvedId),
576
- usedBy: usedByStmt.all(resolvedId)
964
+ uses: DevMindDatabase.parseNodeRows(usesStmt.all(...args)),
965
+ usedBy: DevMindDatabase.parseNodeRows(usedByStmt.all(...args))
577
966
  };
578
967
  }
968
+ /**
969
+ * Batched, COUNT-only connection degree for many nodes at once — the search-result drill-in
970
+ * hooks need this for ~20 nodes per call, and `getConnections` per-node would mean 20 pairs of
971
+ * full-row-fetching queries. One grouped COUNT each way instead (same shape as the degree
972
+ * subquery in {@link getGodEntities}). IDs not present in `node_connections` still get a
973
+ * `{uses:0, usedBy:0}` entry so callers never need an existence check.
974
+ */
975
+ getConnectionCounts(ids) {
976
+ const result = new Map();
977
+ if (ids.length === 0)
978
+ return result;
979
+ for (const id of ids)
980
+ result.set(id, { uses: 0, usedBy: 0 });
981
+ const placeholders = ids.map(() => '?').join(',');
982
+ const usesStmt = this.db.prepare(`
983
+ SELECT source_node_id AS id, COUNT(*) AS n FROM node_connections
984
+ WHERE source_node_id IN (${placeholders}) GROUP BY source_node_id
985
+ `);
986
+ const usedByStmt = this.db.prepare(`
987
+ SELECT target_node_id AS id, COUNT(*) AS n FROM node_connections
988
+ WHERE target_node_id IN (${placeholders}) GROUP BY target_node_id
989
+ `);
990
+ for (const row of usesStmt.all(...ids)) {
991
+ const e = result.get(row.id);
992
+ if (e)
993
+ e.uses = row.n;
994
+ }
995
+ for (const row of usedByStmt.all(...ids)) {
996
+ const e = result.get(row.id);
997
+ if (e)
998
+ e.usedBy = row.n;
999
+ }
1000
+ return result;
1001
+ }
1002
+ /** Batched history-entry count for many nodes at once — metadata only, no disk reads. */
1003
+ getHistoryCounts(ids) {
1004
+ const result = new Map();
1005
+ if (ids.length === 0)
1006
+ return result;
1007
+ const placeholders = ids.map(() => '?').join(',');
1008
+ const stmt = this.db.prepare(`
1009
+ SELECT node_id AS id, COUNT(*) AS n FROM history
1010
+ WHERE node_id IN (${placeholders}) GROUP BY node_id
1011
+ `);
1012
+ for (const row of stmt.all(...ids)) {
1013
+ result.set(row.id, row.n);
1014
+ }
1015
+ return result;
1016
+ }
1017
+ /**
1018
+ * Batched most-recent history timestamp for many nodes at once. Deliberately SQL-only (`MAX`
1019
+ * over the indexed `updated_at` column) — unlike {@link getLatestHistory}, this never touches
1020
+ * `populateHistoryFromDisk`, so it costs nothing beyond the query itself.
1021
+ */
1022
+ getLastUpdatedMap(ids) {
1023
+ const result = new Map();
1024
+ if (ids.length === 0)
1025
+ return result;
1026
+ const placeholders = ids.map(() => '?').join(',');
1027
+ const stmt = this.db.prepare(`
1028
+ SELECT node_id AS id, MAX(updated_at) AS last FROM history
1029
+ WHERE node_id IN (${placeholders}) GROUP BY node_id
1030
+ `);
1031
+ for (const row of stmt.all(...ids)) {
1032
+ result.set(row.id, row.last);
1033
+ }
1034
+ return result;
1035
+ }
579
1036
  // --- History Operations ---
580
1037
  getLatestHistory(nodeId) {
581
1038
  const node = this.getNode(nodeId);
582
1039
  const resolvedId = node ? node.id : nodeId;
583
- const stmt = this.db.prepare(`
584
- SELECT id, node_id, session_id, created_at, updated_at FROM history
585
- WHERE node_id = ?
586
- ORDER BY updated_at DESC
587
- LIMIT 1
1040
+ const stmt = this.db.prepare(`
1041
+ SELECT id, node_id, session_id, created_at, updated_at FROM history
1042
+ WHERE node_id = ?
1043
+ ORDER BY updated_at DESC
1044
+ LIMIT 1
588
1045
  `);
589
1046
  const row = stmt.get(resolvedId);
590
1047
  if (!row)
@@ -594,11 +1051,11 @@ class DevMindDatabase {
594
1051
  listHistory(nodeId) {
595
1052
  const node = this.getNode(nodeId);
596
1053
  const resolvedId = node ? node.id : nodeId;
597
- const stmt = this.db.prepare(`
598
- SELECT id, node_id, session_id, created_at, updated_at
599
- FROM history
600
- WHERE node_id = ?
601
- ORDER BY updated_at DESC
1054
+ const stmt = this.db.prepare(`
1055
+ SELECT id, node_id, session_id, created_at, updated_at
1056
+ FROM history
1057
+ WHERE node_id = ?
1058
+ ORDER BY updated_at DESC
602
1059
  `);
603
1060
  return stmt.all(resolvedId);
604
1061
  }
@@ -612,15 +1069,110 @@ class DevMindDatabase {
612
1069
  getFullHistory(nodeId) {
613
1070
  const node = this.getNode(nodeId);
614
1071
  const resolvedId = node ? node.id : nodeId;
615
- const stmt = this.db.prepare(`
616
- SELECT id, node_id, session_id, created_at, updated_at
617
- FROM history
618
- WHERE node_id = ?
619
- ORDER BY updated_at DESC
1072
+ const stmt = this.db.prepare(`
1073
+ SELECT id, node_id, session_id, created_at, updated_at
1074
+ FROM history
1075
+ WHERE node_id = ?
1076
+ ORDER BY updated_at DESC
620
1077
  `);
621
1078
  const rows = stmt.all(resolvedId);
622
1079
  return rows.map(row => this.populateHistoryFromDisk(row));
623
1080
  }
1081
+ /**
1082
+ * The last `limit` history entries' reasoning + timestamps only — no `code_snapshot`/`edits`.
1083
+ * Built for `get_node_code`'s default `history:"recent"` mode, which already returns the
1084
+ * CURRENT code: repeating past snapshots inline would just duplicate what's already in the
1085
+ * response. The full trail (snapshots + diffable edits) is `history:"full"`, served by
1086
+ * {@link getHistoryPage}. This answers "why does this look the way it does" cheaply enough to
1087
+ * attach to every get_node_code call by default, instead of leaving that as a round trip an AI
1088
+ * has to remember to make (or skip, and re-break a decision it never saw).
1089
+ */
1090
+ getRecentHistorySummaries(nodeId, limit) {
1091
+ const node = this.getNode(nodeId);
1092
+ const resolvedId = node ? node.id : nodeId;
1093
+ const stmt = this.db.prepare(`
1094
+ SELECT id, node_id, session_id, created_at, updated_at
1095
+ FROM history
1096
+ WHERE node_id = ?
1097
+ ORDER BY updated_at DESC
1098
+ LIMIT ?
1099
+ `);
1100
+ const rows = stmt.all(resolvedId, limit);
1101
+ return rows.map(row => {
1102
+ const full = this.populateHistoryFromDisk(row);
1103
+ return { id: full.id, session_id: full.session_id, created_at: full.created_at, updated_at: full.updated_at, reasoning: full.reasoning };
1104
+ });
1105
+ }
1106
+ /**
1107
+ * `history:"full"`'s backing query — the same full-fidelity payload as {@link getFullHistory}
1108
+ * (code_snapshot + diffable edits per entry), but LIMIT/OFFSET applied IN SQL before any disk
1109
+ * read happens, not by slicing an already-fully-loaded array. `getFullHistory` reads every
1110
+ * revision's JSON off disk unconditionally; for a node with dozens of revisions that is dozens
1111
+ * of synchronous file reads to serve a request for the newest 5. `total` is the true count
1112
+ * before paging, same honesty contract as `nodes_total`/`files_total` elsewhere.
1113
+ */
1114
+ getHistoryPage(nodeId, limit, offset) {
1115
+ const node = this.getNode(nodeId);
1116
+ const resolvedId = node ? node.id : nodeId;
1117
+ const totalRow = this.db.prepare('SELECT COUNT(*) AS c FROM history WHERE node_id = ?').get(resolvedId);
1118
+ const stmt = this.db.prepare(`
1119
+ SELECT id, node_id, session_id, created_at, updated_at
1120
+ FROM history
1121
+ WHERE node_id = ?
1122
+ ORDER BY updated_at DESC
1123
+ LIMIT ? OFFSET ?
1124
+ `);
1125
+ const rows = stmt.all(resolvedId, limit, offset);
1126
+ return { entries: rows.map(row => this.populateHistoryFromDisk(row)), total: totalRow.c };
1127
+ }
1128
+ /**
1129
+ * Every history row in a time/session window, joined to its node's file path — the SHARED
1130
+ * counterpart to the local activity log, and the backing query for `get_activity_log`'s graph
1131
+ * fallback (see db/activity-graph.ts).
1132
+ *
1133
+ * Deliberately reads SQLite ONLY, never `populateHistoryFromDisk`. Everything the fallback needs
1134
+ * — reasoning (which carries developer + requirement, see formatReasoning), both timestamps,
1135
+ * session_id, and the file path — is already in columns; the disk JSON adds only `code_snapshot`
1136
+ * and `edits`, neither of which an activity listing reports. That matters because this scans
1137
+ * ROWS, not one node's history: routing it through the per-row file read would turn a single
1138
+ * indexed query into one synchronous readFileSync per revision in the window.
1139
+ *
1140
+ * The date bounds test the row's [created_at, updated_at] span against the window rather than a
1141
+ * single point. One row accumulates blocks for up to an hour past `created_at` (the merge rule),
1142
+ * so `created_at >= since` would silently drop a row whose in-window blocks were appended to an
1143
+ * out-of-window row. This over-selects instead, and the caller filters per block, where the real
1144
+ * timestamps live.
1145
+ *
1146
+ * `file_path` is null when the node is gone (history outlives its node — a hard delete leaves
1147
+ * rows behind). Nulls are the caller's to skip; dropping them here would silently shrink the
1148
+ * edit counts a fallback entry reports.
1149
+ */
1150
+ queryHistoryForActivity(opts = {}) {
1151
+ const where = [];
1152
+ const params = [];
1153
+ if (opts.sessionId) {
1154
+ where.push('h.session_id = ?');
1155
+ params.push(opts.sessionId);
1156
+ }
1157
+ if (opts.since) {
1158
+ where.push('h.updated_at >= ?');
1159
+ params.push(opts.since);
1160
+ }
1161
+ if (opts.until) {
1162
+ where.push('h.created_at <= ?');
1163
+ params.push(opts.until);
1164
+ }
1165
+ const sql = `
1166
+ SELECT h.id, h.node_id, h.session_id, h.created_at, h.updated_at, h.reasoning, n.file_path
1167
+ FROM history h
1168
+ LEFT JOIN nodes n ON n.id = h.node_id
1169
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
1170
+ ORDER BY h.updated_at DESC
1171
+ LIMIT ?
1172
+ `;
1173
+ params.push(opts.limit ?? 5000);
1174
+ return this.db.prepare(sql).all(...params);
1175
+ }
624
1176
  /** Distinct source node ids of edges pointing INTO this node (its "used-by" callers). */
625
1177
  getInboundSources(nodeId) {
626
1178
  const rows = this.db
@@ -671,15 +1223,22 @@ class DevMindDatabase {
671
1223
  if (node) {
672
1224
  const live = this.extractLiveCode(node);
673
1225
  if (live !== null) {
1226
+ const outdated = snapshot ? snapshot.code_snapshot !== live : undefined;
674
1227
  return {
675
1228
  exists: true,
676
1229
  node_id: node.id,
677
1230
  file_path: node.file_path,
678
1231
  code: live,
679
1232
  source: 'live',
680
- // Snapshot exists but disagrees with disk → the graph has drifted.
681
- snapshot_outdated: snapshot ? snapshot.code_snapshot !== live : undefined,
682
- updated_at: snapshot?.updated_at
1233
+ // Snapshot exists but disagrees with disk → the RECORDED HISTORY has drifted, not the
1234
+ // `code` above — that was just read fresh from disk this call. Worth a message, not
1235
+ // just a bare flag: without it, this reads as "don't trust what you were just handed,"
1236
+ // which is backwards — it's the opposite thing (the graph's history) that's behind.
1237
+ snapshot_outdated: outdated,
1238
+ updated_at: snapshot?.updated_at,
1239
+ message: outdated
1240
+ ? 'The code above is current — read live from disk this call. Only the recorded history snapshot is stale (it predates this edit); no need to re-read the file to double-check.'
1241
+ : undefined
683
1242
  };
684
1243
  }
685
1244
  }
@@ -699,13 +1258,13 @@ class DevMindDatabase {
699
1258
  return {
700
1259
  exists: false,
701
1260
  node_id: resolvedId,
702
- message: 'No code found on disk or in cache. Read the source file, then stage_change + commit_changes so future agents skip the file read entirely.'
1261
+ message: 'No code found on disk or in cache. Read the source file, then edit_node + commit_changes so future agents skip the file read entirely.'
703
1262
  };
704
1263
  }
705
1264
  getGraph(nodeId, maxDepth = 6, opts = {}) {
706
1265
  const direction = opts.direction ?? 'both';
707
1266
  const codeCharBudget = opts.codeCharBudget ?? 60_000;
708
- const maxNodesLimit = 500;
1267
+ const maxNodesLimit = opts.maxNodes ?? 500;
709
1268
  const visited = new Set();
710
1269
  const nodes = [];
711
1270
  const connections = [];
@@ -719,12 +1278,19 @@ class DevMindDatabase {
719
1278
  // argument would find zero edges and return a lone root.
720
1279
  const queue = [{ id: rootNode.id, depth: 0 }];
721
1280
  visited.add(rootNode.id);
1281
+ rootNode.depth = 0;
722
1282
  nodes.push(rootNode);
723
- const usesStmt = this.db.prepare(`
724
- SELECT target_node_id FROM node_connections WHERE source_node_id = ?
1283
+ // ORDER BY on both directions so the walk is REPRODUCIBLE: without it, sibling order within a
1284
+ // depth is whatever physical row order SQLite happens to return, which shifts after inserts,
1285
+ // deletes, or a VACUUM. That made two identical getGraph calls able to disagree about which
1286
+ // nodes got code when the budget ran out — the same query returning a different answer for no
1287
+ // visible reason. Both are index-ordered already (the PK autoindex outbound,
1288
+ // idx_node_connections_target inbound), so the planner elides the sort and this costs nothing.
1289
+ const usesStmt = this.db.prepare(`
1290
+ SELECT target_node_id FROM node_connections WHERE source_node_id = ? ORDER BY target_node_id
725
1291
  `);
726
- const usedByStmt = this.db.prepare(`
727
- SELECT source_node_id FROM node_connections WHERE target_node_id = ?
1292
+ const usedByStmt = this.db.prepare(`
1293
+ SELECT source_node_id FROM node_connections WHERE target_node_id = ? ORDER BY source_node_id
728
1294
  `);
729
1295
  while (queue.length > 0 && nodes.length < maxNodesLimit) {
730
1296
  const current = queue.shift();
@@ -745,10 +1311,16 @@ class DevMindDatabase {
745
1311
  visited.add(targetId);
746
1312
  const targetNode = this.getNode(targetId);
747
1313
  if (targetNode) {
1314
+ targetNode.depth = current.depth + 1;
748
1315
  nodes.push(targetNode);
749
1316
  if (nodes.length >= maxNodesLimit)
750
1317
  break;
751
1318
  }
1319
+ // targetNode === null means `target_node_id` no longer resolves to a real node (the
1320
+ // node was deleted/renamed but the connections row survived). It still goes on the
1321
+ // queue/visited so a stale id is never re-processed, but — deliberately — no entry
1322
+ // is added to `nodes` for it. The edge pushed above therefore references an id that
1323
+ // will never appear in `nodes`; the dedup pass after the loop (see below) strips it.
752
1324
  queue.push({ id: targetId, depth: current.depth + 1 });
753
1325
  }
754
1326
  }
@@ -769,6 +1341,7 @@ class DevMindDatabase {
769
1341
  visited.add(sourceId);
770
1342
  const sourceNode = this.getNode(sourceId);
771
1343
  if (sourceNode) {
1344
+ sourceNode.depth = current.depth + 1;
772
1345
  nodes.push(sourceNode);
773
1346
  if (nodes.length >= maxNodesLimit)
774
1347
  break;
@@ -778,24 +1351,50 @@ class DevMindDatabase {
778
1351
  }
779
1352
  }
780
1353
  }
781
- const result = { nodes, connections };
1354
+ // Two kinds of "more exists than was returned", reported honestly instead of silently:
1355
+ // 1. The node cap cut the walk short with the queue still non-empty.
1356
+ // 2. A connections row survives referencing a node this walk never added (deleted/renamed
1357
+ // node, or — same shape — a node dropped for being past the cap on a LATER queue entry
1358
+ // than the one whose edge pointed at it). Filtering here, once, after the walk, is
1359
+ // simpler and more certainly correct than trying to prevent every path that could create
1360
+ // one during the BFS itself.
1361
+ const nodesTruncated = nodes.length >= maxNodesLimit && queue.length > 0;
1362
+ const nodeIds = new Set(nodes.map(n => n.id));
1363
+ const cleanConnections = connections.filter(c => nodeIds.has(c.source_node_id) && nodeIds.has(c.target_node_id));
1364
+ const connectionsTruncated = cleanConnections.length < connections.length;
1365
+ const result = { nodes, connections: cleanConnections };
1366
+ if (nodesTruncated)
1367
+ result.nodes_truncated = true;
1368
+ if (connectionsTruncated)
1369
+ result.connections_truncated = true;
782
1370
  if (opts.includeCode) {
783
1371
  let spent = 0;
784
- let withoutCode = 0;
1372
+ let noCodeAvailable = 0;
1373
+ const omittedForBudget = [];
785
1374
  // `nodes` is in BFS order (nearest the root first), so the budget is spent on the most
786
1375
  // relevant code before anything is dropped.
787
1376
  for (const [i, n] of nodes.entries()) {
788
1377
  const live = this.extractLiveCode(n);
789
1378
  const code = live ?? this.getLatestCode(n.id)?.code_snapshot ?? null;
790
1379
  if (!code) {
791
- withoutCode++;
1380
+ // Nothing to attach — a node whose symbol no longer resolves on disk and has no cached
1381
+ // snapshot. Counted SEPARATELY from a budget drop: these two used to share one
1382
+ // `nodes_without_code` counter, which meant a graph full of unresolvable nodes reported
1383
+ // `code_truncated: true` on a completely unspent budget. Raising the budget would then
1384
+ // do nothing, and the caller had no way to tell that from the response.
1385
+ noCodeAvailable++;
792
1386
  continue;
793
1387
  }
794
1388
  // The root always gets its code — it is what was asked for, and dropping it would make
795
1389
  // the response useless. Every other node must fit in the REMAINING budget, so a single
796
1390
  // large node can't blow past the cap (it is skipped and counted, not truncated).
797
1391
  if (i > 0 && spent + code.length > codeCharBudget) {
798
- withoutCode++;
1392
+ // Recorded BY ID, not merely counted. An id is a valid argument to get_node_code, so a
1393
+ // caller who needs the rest can fetch exactly those nodes — where a bare count, or an
1394
+ // array index into a graph that gets re-derived on every call, tells them nothing they
1395
+ // can act on. Capped so a wide graph can't turn the omission list into its own payload.
1396
+ if (omittedForBudget.length < OMITTED_NODE_ID_CAP)
1397
+ omittedForBudget.push(n.id);
799
1398
  continue;
800
1399
  }
801
1400
  n.code = code;
@@ -803,10 +1402,14 @@ class DevMindDatabase {
803
1402
  spent += code.length;
804
1403
  }
805
1404
  result.code_chars = spent;
806
- if (withoutCode > 0) {
1405
+ if (noCodeAvailable > 0 || omittedForBudget.length > 0) {
807
1406
  result.code_truncated = true;
808
- result.nodes_without_code = withoutCode;
1407
+ result.nodes_without_code = noCodeAvailable + omittedForBudget.length;
809
1408
  }
1409
+ if (noCodeAvailable > 0)
1410
+ result.nodes_no_code_available = noCodeAvailable;
1411
+ if (omittedForBudget.length > 0)
1412
+ result.code_omitted_node_ids = omittedForBudget;
810
1413
  }
811
1414
  return result;
812
1415
  }
@@ -826,7 +1429,9 @@ class DevMindDatabase {
826
1429
  const resolvedId = node ? node.id : node_id;
827
1430
  const formattedReasoning = formatReasoning(reasoning);
828
1431
  const nowStr = new Date().toISOString();
829
- const compressedCode = compressText(code_snapshot);
1432
+ const newEdit = params.code_before === undefined
1433
+ ? null
1434
+ : { at: nowStr, before: params.code_before ?? '', after: code_snapshot, reasoning: formattedReasoning };
830
1435
  // 1-hour session boundary rule check
831
1436
  const latest = this.getLatestHistory(resolvedId);
832
1437
  if (latest) {
@@ -838,25 +1443,34 @@ class DevMindDatabase {
838
1443
  // session). code_snapshot is always the latest state (git already owns version history for
839
1444
  // code). reasoning is APPENDED, not overwritten — an earlier commit's "why" in this same
840
1445
  // session is still real and still worth keeping; losing it silently is worse than a few
841
- // extra lines in one file. This also keeps any workflow step whose history_ids point at
842
- // this row valid: it never loses what it originally linked to, only gains more below it.
1446
+ // extra lines in one file.
843
1447
  if (diffMs < 3600000) {
1448
+ /* istanbul ignore next -- `latest` came from `getLatestHistory()` -> `populateHistoryFromDisk()`,
1449
+ which always returns a STRING `reasoning` (either the disk JSON's string field, or the
1450
+ result of `formatReasoning(...)`, itself always a string) — so the `: ''` fallback here
1451
+ can never actually run. Kept as a real guard against a future change to
1452
+ `populateHistoryFromDisk`'s return shape, not because today's flow can reach it. */
844
1453
  const previousReasoning = typeof latest.reasoning === 'string' ? latest.reasoning : '';
845
1454
  const mergedReasoning = previousReasoning.trim().length > 0
846
1455
  ? `${previousReasoning}\n\n── Update @ ${nowStr} ──\n${formattedReasoning}`
847
1456
  : formattedReasoning;
848
- const updateStmt = this.db.prepare(`
849
- UPDATE history
850
- SET code_snapshot = '', reasoning = ?, updated_at = ?
851
- WHERE id = ?
1457
+ const updateStmt = this.db.prepare(`
1458
+ UPDATE history
1459
+ SET code_snapshot = '', reasoning = ?, updated_at = ?
1460
+ WHERE id = ?
852
1461
  `);
853
1462
  updateStmt.run(mergedReasoning, nowStr, latest.id);
1463
+ // The edit trail APPENDS for the same reason reasoning does: this row now covers several
1464
+ // edits, and only a per-edit before/after lets a revert undo the last one rather than the
1465
+ // whole session's work — the window slides off updated_at, so one row can span hours.
1466
+ const mergedEdits = newEdit ? [...latest.edits, newEdit] : latest.edits;
854
1467
  // Write/Update on disk
855
- this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, mergedReasoning);
1468
+ this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, mergedReasoning, mergedEdits);
856
1469
  return {
857
1470
  ...latest,
858
1471
  code_snapshot,
859
1472
  reasoning: mergedReasoning,
1473
+ edits: mergedEdits,
860
1474
  updated_at: nowStr
861
1475
  };
862
1476
  }
@@ -864,13 +1478,14 @@ class DevMindDatabase {
864
1478
  // Otherwise (or if no record exists), insert new history block
865
1479
  const newId = crypto.randomUUID();
866
1480
  const sessionId = params.session_id || crypto.randomUUID();
867
- const insertStmt = this.db.prepare(`
868
- INSERT INTO history (id, node_id, session_id, created_at, updated_at, code_snapshot, reasoning)
869
- VALUES (?, ?, ?, ?, ?, '', ?)
1481
+ const insertStmt = this.db.prepare(`
1482
+ INSERT INTO history (id, node_id, session_id, created_at, updated_at, code_snapshot, reasoning)
1483
+ VALUES (?, ?, ?, ?, ?, '', ?)
870
1484
  `);
871
1485
  insertStmt.run(newId, resolvedId, sessionId, nowStr, nowStr, formattedReasoning);
1486
+ const newEdits = newEdit ? [newEdit] : [];
872
1487
  // Write to disk
873
- this.writeHistoryToDisk(newId, resolvedId, sessionId, nowStr, nowStr, code_snapshot, formattedReasoning);
1488
+ this.writeHistoryToDisk(newId, resolvedId, sessionId, nowStr, nowStr, code_snapshot, formattedReasoning, newEdits);
874
1489
  return {
875
1490
  id: newId,
876
1491
  node_id: resolvedId,
@@ -878,195 +1493,688 @@ class DevMindDatabase {
878
1493
  created_at: nowStr,
879
1494
  updated_at: nowStr,
880
1495
  code_snapshot,
881
- reasoning: formattedReasoning
1496
+ reasoning: formattedReasoning,
1497
+ edits: newEdits
882
1498
  };
883
1499
  }
1500
+ /**
1501
+ * Removes the newest recorded edit from a history entry, leaving no trace of it.
1502
+ *
1503
+ * There used to be a citation guard here: a history row cited by a workflow step was emptied
1504
+ * rather than deleted, so the step was not left pointing at nothing. Workflow steps record
1505
+ * `node_ids` now, not history ids, so nothing cites a history row any more and the guard had
1506
+ * nothing left to check. Keeping it would have meant keeping the `history_ids` column alive
1507
+ * purely to protect a reference nothing makes.
1508
+ *
1509
+ * Callers are expected to have restored the file already; this only unwinds what was written
1510
+ * about it.
1511
+ */
1512
+ eraseLastEdit(historyId) {
1513
+ const entry = this.getHistoryEntry(historyId);
1514
+ if (!entry)
1515
+ return { erased: false, entry_deleted: false, reason: 'history entry not found' };
1516
+ if (!entry.edits.length)
1517
+ return { erased: false, entry_deleted: false, reason: 'entry has no recorded edits' };
1518
+ const remaining = entry.edits.slice(0, -1);
1519
+ const dropped = entry.edits[entry.edits.length - 1];
1520
+ if (!remaining.length) {
1521
+ // Nothing left to keep the row for — no workflow step references a history id any more.
1522
+ this.db.prepare('DELETE FROM history WHERE id = ?').run(historyId);
1523
+ try {
1524
+ const f = path.join(path.dirname(this.dbPath), 'history', `${historyId}.json`);
1525
+ if (fs.existsSync(f))
1526
+ fs.unlinkSync(f);
1527
+ }
1528
+ catch { /* ignore */ }
1529
+ return { erased: true, entry_deleted: true };
1530
+ }
1531
+ const nowStr = new Date().toISOString();
1532
+ const newSnapshot = remaining[remaining.length - 1].after;
1533
+ const newReasoning = dropReasoningBlock(entry.reasoning, dropped.reasoning);
1534
+ this.db.prepare('UPDATE history SET reasoning = ?, updated_at = ? WHERE id = ?')
1535
+ .run(newReasoning, nowStr, historyId);
1536
+ this.writeHistoryToDisk(historyId, entry.node_id, entry.session_id, entry.created_at, nowStr, newSnapshot, newReasoning, remaining);
1537
+ return { erased: true, entry_deleted: false };
1538
+ }
884
1539
  // --- Search Operations ---
885
1540
  /**
886
- * Search for nodes by name/id/reasoning first (cheap, SQL-only). If that finds
887
- * nothing, transparently fall back to a code-content search (same engine as
888
- * {@link searchCode}) so a query like "alipay" still succeeds even when no
889
- * node's name/id/reasoning mentions it but the code itself does. Every result
890
- * is tagged `matched_via` so the caller knows which path found it.
1541
+ * The one search tool, covering both worlds in a single call:
1542
+ * - a primary `nodes` bucket the indexed graph, found by exact identifier, then by three
1543
+ * fused rankers (BM25 over metadata, vector over descriptions, and code-body match), and
1544
+ * - a last-resort `files` bucket a real filesystem grep of the configured repos, so files
1545
+ * the graph never models (CSS, JSON, config, markup, un-indexed code) are finally covered
1546
+ * in the same call instead of sending the caller off to an external grep.
1547
+ *
1548
+ * Inputs play to each layer's strength: the natural-language `query` drives the semantic vector
1549
+ * layer (and BM25); `opts.keywords` (literal, OR) drive grep and the code-body match, and also
1550
+ * feed BM25. If no keywords are given they're derived from the query's significant tokens, so a
1551
+ * natural-only call still gets code + file coverage.
1552
+ *
1553
+ * Speed is the point — the two slow layers (vector, grep) run concurrently, and the old ~9k
1554
+ * per-node snapshot read (the tool's former ~10-30s cost) is gone: code-body matching now rides
1555
+ * the single grep walk. See `grep.ts` and Phase 4 of the plan.
891
1556
  */
892
- searchNodes(query, opts = {}) {
893
- const stmt = this.db.prepare(`
894
- SELECT DISTINCT n.* FROM nodes n
895
- LEFT JOIN history h ON n.id = h.node_id
896
- WHERE n.name LIKE ? ESCAPE '\\' OR n.id LIKE ? ESCAPE '\\' OR h.reasoning LIKE ? ESCAPE '\\'
897
- LIMIT 50
898
- `);
899
- // Escaped so a query containing '%'/'_' (a real identifier fragment like "CartService_addItem"
900
- // matches the literal underscore, not "any single character") searches for those characters
901
- // rather than acting as SQL LIKE wildcards — sibling methods (getDeveloperActivity,
902
- // searchDecisions) already do this; this one didn't.
903
- const wildcard = `%${this.likeEscape(query)}%`;
904
- const identifierMatches = stmt.all(wildcard, wildcard, wildcard);
905
- if (identifierMatches.length > 0) {
906
- return identifierMatches.map(n => ({ ...n, matched_via: 'identifier' }));
907
- }
908
- const codeMatches = this.searchCode({
909
- query,
910
- is_regex: opts.is_regex,
911
- case_insensitive: opts.case_insensitive
912
- });
913
- if (codeMatches.length > 0) {
914
- return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
1557
+ /**
1558
+ * Attaches the drill-in hooks (`uses`/`used_by`/`history_count`/`last_updated`) to a batch of
1559
+ * search results in place, via ONE grouped query per hook instead of per-node fetches — see
1560
+ * {@link getConnectionCounts}/{@link getHistoryCounts}/{@link getLastUpdatedMap}. This is the
1561
+ * signal that turns a search result from a dead end into something worth drilling into with
1562
+ * `get_node_graph`/`get_node_history` — without it, nothing hints there's more to find.
1563
+ */
1564
+ attachDrillInHooks(nodes) {
1565
+ if (nodes.length === 0)
1566
+ return nodes;
1567
+ const ids = nodes.map(n => n.id);
1568
+ const connCounts = this.getConnectionCounts(ids);
1569
+ const historyCounts = this.getHistoryCounts(ids);
1570
+ const lastUpdated = this.getLastUpdatedMap(ids);
1571
+ for (const n of nodes) {
1572
+ /* istanbul ignore next -- `connCounts` is built from `getConnectionCounts(ids)` on this
1573
+ SAME `ids` array a few lines up, and that helper pre-seeds a {uses:0,usedBy:0} entry for
1574
+ every id it's given before querying — so `connCounts.get(n.id)` can never miss here.
1575
+ Kept as a real guard against a future refactor decoupling the two, not because today's
1576
+ flow can reach the fallback. */
1577
+ const conn = connCounts.get(n.id) ?? { uses: 0, usedBy: 0 };
1578
+ n.uses = conn.uses;
1579
+ n.used_by = conn.usedBy;
1580
+ n.history_count = historyCounts.get(n.id) ?? 0;
1581
+ const lu = lastUpdated.get(n.id);
1582
+ if (lu)
1583
+ n.last_updated = lu;
1584
+ // Say so instead of asserting "unused", or this hook actively misleads instead of helping.
1585
+ if (conn.usedBy === 0) {
1586
+ n.used_by_note = exports.NO_STATIC_CALLERS_NOTE;
1587
+ }
915
1588
  }
916
- const tokens = this.tokenizeQuery(query);
917
- if (tokens.length === 0) {
1589
+ return nodes;
1590
+ }
1591
+ async searchNodes(query, opts = {}) {
1592
+ const trimmedQuery = query?.trim();
1593
+ const hasQuery = !!trimmedQuery;
1594
+ const trimmedPattern = opts.pattern?.trim();
1595
+ const hasPattern = !!trimmedPattern;
1596
+ if (!hasQuery && !hasPattern) {
1597
+ throw new Error('searchNodes requires at least one of `query` or `pattern`.');
1598
+ }
1599
+ const caseInsensitive = opts.case_insensitive !== false;
1600
+ const scopePath = this.resolveSearchScopePath(opts.path);
1601
+ // Scoping `path` straight AT a lockfile or build artifact is honored, not overridden — the
1602
+ // same rule that has always applied to scoping at an ignored directory. What makes that
1603
+ // confusing is the SILENCE, not the exclusion: an empty result is indistinguishable from
1604
+ // "the pattern isn't in that file". Say so instead, so the agent stops rather than retrying
1605
+ // variations of a search that can never return anything.
1606
+ const scopeNote = scopePath && (0, grep_1.isDefaultIgnoredFile)(scopePath)
1607
+ ? `path "${scopePath}" is a lockfile or generated artifact, excluded from search by default — no file content was scanned. Read the file directly if you genuinely need it.`
1608
+ : undefined;
1609
+ const filesOffset = opts.offset ?? 0;
1610
+ const filesLimit = opts.limit ?? 25;
1611
+ // `compact` is not just a projection flag — it SKIPS work. `annotateSampleLinesWithSymbol` is
1612
+ // the AST path (the same per-node span resolution that was 8.2s of a 9.6s query before its
1613
+ // file cap), and its only product is the `symbol` field ON the sample lines. A compact result
1614
+ // drops those lines entirely, so resolving them first would be pure waste. This is exactly
1615
+ // why compaction can't live wholly in the MCP handler: a projection applied after the fact
1616
+ // can trim the response, but it cannot un-spend the time that produced it.
1617
+ const compact = opts.compact === true;
1618
+ const grepOpts = { ignoredPaths: this.context?.config.ignored_paths, caseInsensitive, scopePath };
1619
+ // The pattern actually handed to the filesystem grep: the caller's own regex when given, else
1620
+ // a literal (escaped) OR of the query's own significant tokens — the same "derive from query"
1621
+ // fallback as before, just built from a real regex now instead of a keyword array. This is the
1622
+ // ONLY layer that ever runs — everything meaning-driven below is gated on `hasQuery`.
1623
+ const grepPattern = hasPattern
1624
+ ? trimmedPattern
1625
+ : Array.from(new Set((0, tokenize_1.tokenizeText)(trimmedQuery))).map(grep_1.escapeRegExp).join('|');
1626
+ // BM25/vector/the identifier short-circuit are all driven by natural-language MEANING — a bare
1627
+ // regex has no meaning for them to tokenize or embed, so none of them run without a `query`.
1628
+ // Grep (and the code-match nodes it feeds via mapGrepHitsToNodes) is unconditional.
1629
+ const bm25Tokens = hasQuery ? Array.from(new Set((0, tokenize_1.tokenizeText)(trimmedQuery))) : [];
1630
+ if (hasQuery) {
1631
+ // Deliberately name/id ONLY — NOT description/reasoning. Those are free-text natural-language
1632
+ // fields (a description is a whole sentence); matching the query as a raw substring against
1633
+ // them turns "any short natural-language query that happens to appear inside some node's
1634
+ // description" into a false "exact identifier hit," which then skipped vector search AND the
1635
+ // grep-derived code layer entirely (see the comment below) for what was never actually an
1636
+ // identifier lookup. description/reasoning are already covered properly — with real ranking,
1637
+ // not a blind substring guess — by BM25 (`tokenSearchNodes`/`search-index.ts`) on the path
1638
+ // below, so nothing is lost by dropping them here; queries that used to wrongly short-circuit
1639
+ // now correctly fall through to BM25 + vector + code instead.
1640
+ //
1641
+ // Gated on `hasQuery` for a second, sharper reason too: this LIKE builds `%<query>%`, and an
1642
+ // ABSENT query would make that `%%` — matching every node in the database. A regex-only
1643
+ // search on a small repo (≤10 nodes) would then wrongly "short-circuit" and hand back the
1644
+ // ENTIRE graph as high-confidence identifier hits. Never reachable when `query` is empty.
1645
+ const stmt = this.db.prepare(`
1646
+ SELECT DISTINCT n.* FROM nodes n
1647
+ WHERE n.name LIKE ? ESCAPE '\\' OR n.id LIKE ? ESCAPE '\\'
1648
+ LIMIT 50
1649
+ `);
1650
+ const wildcard = `%${this.likeEscape(trimmedQuery)}%`;
1651
+ const identifierMatches = DevMindDatabase.parseNodeRows(stmt.all(wildcard, wildcard));
1652
+ // Exact-identifier short-circuit: a small, unambiguous set of literal name/id hits is trusted
1653
+ // outright and skips the rankers (semantic blur only hurts an exact symbol lookup). Grep
1654
+ // always runs here too (using `grepPattern`, the same derive-from-query-if-absent value the
1655
+ // main path below uses), so an agent that only ever sends `query` still gets a populated
1656
+ // `files` bucket instead of an empty one for no reason.
1657
+ if (identifierMatches.length > 0 && identifierMatches.length <= 10) {
1658
+ // An exact identifier hit is the most trustworthy result there is → high confidence, top
1659
+ // relevance, found by name.
1660
+ // Trust signals FIRST, before `description`. JSON.stringify preserves insertion order and
1661
+ // an agent reads a result top-down; with the plain `{...n, confidence}` spread these
1662
+ // landed after a full sentence of description, and real session feedback was that they got
1663
+ // skipped in favour of eyeballing node names. Ordering is the whole fix — the fields were
1664
+ // always there. Applied at BOTH construction sites (see the fused path below); doing only
1665
+ // one would leave the two paths disagreeing, and this identifier path is the more common
1666
+ // one for a symbol lookup.
1667
+ const nodes = identifierMatches.map(({ id, name, type, ...rest }) => ({
1668
+ id, name, type,
1669
+ confidence: 'high', relevance: 100, found_by: ['name'], matched_via: 'identifier',
1670
+ ...rest,
1671
+ uses: 0, used_by: 0, history_count: 0
1672
+ }));
1673
+ this.attachDrillInHooks(nodes);
1674
+ const grep = await (0, grep_1.grepRepos)(this.repoRoots(), grepPattern, grepOpts);
1675
+ const filesResult = (0, grep_1.rankGrepHits)(grep.hits, { offset: filesOffset, maxFiles: filesLimit });
1676
+ if (!compact)
1677
+ this.annotateSampleLinesWithSymbol(filesResult.files);
1678
+ return {
1679
+ nodes,
1680
+ files: filesResult.files,
1681
+ files_total: filesResult.total,
1682
+ files_offset: filesOffset,
1683
+ nodes_total: nodes.length,
1684
+ truncated: grep.truncated || undefined,
1685
+ scope_note: scopeNote
1686
+ };
1687
+ }
1688
+ }
1689
+ // The two slow layers run CONCURRENTLY — vector inference and the filesystem grep walk. BM25
1690
+ // is synchronous indexed SQL, so it runs inline for free. Wall-clock ≈ max(vector, grep).
1691
+ //
1692
+ // Timed at every stage, opt-in via DEVSMIND_PERF_DEBUG — kept permanently (not stripped after
1693
+ // this pass) because it's how the mapGrepHitsToNodes bottleneck below was actually found: on a
1694
+ // real 8-repo query it was 8.2s of a 9.6s total, invisible from the outside since the tool
1695
+ // just looked uniformly slow. Zero cost when unset (`process.hrtime.bigint()` calls only;
1696
+ // the env check gates the one string-building/console.error).
1697
+ const perfDebug = !!process.env.DEVSMIND_PERF_DEBUG;
1698
+ const ms = (a, b) => (Number(b - a) / 1e6).toFixed(0);
1699
+ const perfStart = process.hrtime.bigint();
1700
+ const bm25Ranked = this.tokenSearchNodes(bm25Tokens);
1701
+ const perfAfterBm25 = process.hrtime.bigint();
1702
+ // Timed individually (not just the combined Promise.all) so a slow run can be attributed to
1703
+ // ONE of the two instead of leaving both under suspicion.
1704
+ let vectorMs = '?', grepMs = '?';
1705
+ const [vectorIds, grep] = await Promise.all([
1706
+ (hasQuery ? this.vectorSearchNodes(trimmedQuery) : Promise.resolve([])).then(r => { if (perfDebug)
1707
+ vectorMs = hasQuery ? ms(perfAfterBm25, process.hrtime.bigint()) : 'skipped(no query)'; return r; }),
1708
+ (0, grep_1.grepRepos)(this.repoRoots(), grepPattern, grepOpts).then(r => { if (perfDebug)
1709
+ grepMs = ms(perfAfterBm25, process.hrtime.bigint()); return r; })
1710
+ ]);
1711
+ const perfAfterVectorGrep = process.hrtime.bigint();
1712
+ // The single grep walk feeds BOTH buckets: raw hits → the files bucket, and hits landing in
1713
+ // indexed source files → code-match nodes (this is the "code search that returns nodes").
1714
+ const filesResult = (0, grep_1.rankGrepHits)(grep.hits, { offset: filesOffset, maxFiles: filesLimit });
1715
+ const perfAfterRank = process.hrtime.bigint();
1716
+ const codeMatches = this.mapGrepHitsToNodes(grep.hits);
1717
+ const perfAfterCodeMatch = process.hrtime.bigint();
1718
+ const codeLinesById = new Map(codeMatches.map(c => [c.nodeId, c.lines]));
1719
+ // Bounded annotation (only the page actually returned — see the doc comment on the helper for
1720
+ // why this must NOT run over every raw hit). Timed and logged BEFORE the [perf] line below,
1721
+ // not after — it shares `locateNodeInFile`, the exact primitive mapGrepHitsToNodes' own doc
1722
+ // comment identifies as the historical bottleneck, so leaving it unmeasured would silently
1723
+ // exempt a real cost from the one line this file's instrumentation exists to catch it with.
1724
+ if (!compact)
1725
+ this.annotateSampleLinesWithSymbol(filesResult.files);
1726
+ const perfAfterAnnotate = process.hrtime.bigint();
1727
+ if (perfDebug) {
1728
+ console.error(`[perf] bm25=${ms(perfStart, perfAfterBm25)}ms vector=${vectorMs}ms grep=${grepMs}ms (combined=${ms(perfAfterBm25, perfAfterVectorGrep)}ms) rankGrepHits=${ms(perfAfterVectorGrep, perfAfterRank)}ms mapGrepHitsToNodes=${ms(perfAfterRank, perfAfterCodeMatch)}ms annotateSampleLinesWithSymbol=${ms(perfAfterCodeMatch, perfAfterAnnotate)}ms grepHits=${grep.hits.length} truncated=${grep.truncated}`);
1729
+ }
1730
+ if (bm25Ranked.length === 0 && vectorIds.length === 0 && codeMatches.length === 0) {
1731
+ // Neither the graph (metadata, meaning, code body) nor grep found anything meaningful.
1732
+ const base = {
1733
+ nodes: [],
1734
+ files: filesResult.files,
1735
+ files_total: filesResult.total,
1736
+ files_offset: filesOffset,
1737
+ nodes_total: 0,
1738
+ truncated: grep.truncated || undefined,
1739
+ scope_note: scopeNote
1740
+ };
1741
+ if (filesResult.files.length === 0) {
1742
+ base.hint = 'No meaningful match anywhere — not in any node\'s name, description, reasoning, or code body, and no file on disk contains this pattern. If you expected this to exist, retry with a broader pattern or different query terms; if it genuinely isn\'t in this codebase, that\'s a real answer — don\'t keep re-querying variations.';
1743
+ }
1744
+ return base;
1745
+ }
1746
+ // Fuse the THREE node rankings by rank (RRF) — BM25 (metadata), vector (meaning), code-match
1747
+ // (body). Each is strong at something the others miss; rank-fusion needs no score calibration.
1748
+ // But the RAW RRF float is a terrible thing to hand back (a #1-ranked hit tops out near 0.03,
1749
+ // which reads like "3% confident"), so it drives ORDER only — every node is then re-described
1750
+ // with human-meaningful signals: which layers found it, a high/medium/low confidence, and a
1751
+ // 0-100 relevance relative to the top hit.
1752
+ const bm25ById = new Map(bm25Ranked.map(n => [n.id, n]));
1753
+ const simById = new Map(vectorIds.map(v => [v.id, v.sim]));
1754
+ const fused = (0, search_index_1.reciprocalRankFusion)([bm25Ranked.map(n => n.id), vectorIds.map(v => v.id), codeMatches.map(c => c.nodeId)]);
1755
+ /* istanbul ignore next -- unreachable here: reaching this line already required at least one
1756
+ of bm25Ranked/vectorIds/codeMatches to be non-empty (the `if (... .length === 0 && ...)`
1757
+ guard above returns early otherwise), and reciprocalRankFusion's output is the union of its
1758
+ input rankings — so `fused` is always non-empty by the time this runs. Kept as a real guard
1759
+ against a future change decoupling that invariant, not because today's flow can reach it. */
1760
+ const topScore = fused.length ? fused[0].score : 1;
1761
+ const nodes = [];
1762
+ for (const { id, score } of fused.slice(0, 20)) {
1763
+ const bm25Hit = bm25ById.get(id);
1764
+ const sim = simById.get(id);
1765
+ const codeLines = codeLinesById.get(id);
1766
+ // Reconstruct a clean DbNode — never spread bm25Hit wholesale, or its internal fields leak.
1767
+ const src = bm25Hit ?? this.getNode(id);
1768
+ /* istanbul ignore next -- `fused`'s ids are drawn only from tokenSearchNodes/vectorSearchNodes/
1769
+ mapGrepHitsToNodes, all three of which query `nodes` directly (deprecated = 0) at call time
1770
+ a few lines above; nothing mutates the DB between those queries and this synchronous loop,
1771
+ so `this.getNode(id)` (the fallback when `bm25Hit` is absent) always finds a row here today.
1772
+ Kept as a real guard against a future async gap or query decoupling, not because today's
1773
+ flow can reach it. */
1774
+ if (!src)
1775
+ continue; // orphaned vector row surviving between sweeps — skip, don't crash
1776
+ /* istanbul ignore next -- `src` is always either a `tokenSearchNodes` row or a
1777
+ `getNode()` result, both of which route through `parseNodeRow`/`parseNodeAliases` and
1778
+ so always carry a real `string[]` aliases — the `?? []` fallback is unreachable in
1779
+ practice, kept only as a type-level safety net. */
1780
+ const srcAliases = src.aliases ?? [];
1781
+ const node = {
1782
+ id: src.id, type: src.type, name: src.name, file_path: src.file_path,
1783
+ signature: src.signature, description: src.description,
1784
+ aliases: srcAliases,
1785
+ deprecated: src.deprecated, created_at: src.created_at
1786
+ };
1787
+ const found_by = [];
1788
+ if (bm25Hit)
1789
+ found_by.push('keyword');
1790
+ if (sim !== undefined)
1791
+ found_by.push('meaning');
1792
+ if (codeLines)
1793
+ found_by.push('code');
1794
+ // Confidence from real evidence, not the fused float. Corroboration across ≥2 independent
1795
+ // layers is the strongest signal there is → high. A lone semantic match is graded by its
1796
+ // actual cosine (0.6+ is genuinely close; 0.35-0.45 is borderline). A lone keyword or code
1797
+ // hit already cleared its own floor, so it's real-but-uncorroborated → medium.
1798
+ let confidence;
1799
+ if (found_by.length >= 2) {
1800
+ confidence = 'high';
1801
+ }
1802
+ else if (found_by.length === 1 && found_by[0] === 'meaning' && sim !== undefined) {
1803
+ confidence = sim >= 0.6 ? 'high' : sim >= 0.45 ? 'medium' : 'low';
1804
+ }
1805
+ else {
1806
+ confidence = 'medium';
1807
+ }
1808
+ const matched_via = bm25Hit ? 'fuzzy' : (codeLines ? 'code' : 'semantic');
1809
+ const relevance = Math.max(1, Math.round((score / topScore) * 100));
1810
+ // Trust signals ahead of `description` — see the identifier short-circuit above for why.
1811
+ const { id: nodeId, name: nodeName, type: nodeType, ...nodeRest } = node;
1812
+ nodes.push({
1813
+ id: nodeId, name: nodeName, type: nodeType,
1814
+ confidence,
1815
+ relevance,
1816
+ found_by,
1817
+ matched_via,
1818
+ ...nodeRest,
1819
+ matched_terms: bm25Hit ? bm25Hit.matched_terms : [],
1820
+ code_matches: codeLines,
1821
+ uses: 0, used_by: 0, history_count: 0
1822
+ });
1823
+ }
1824
+ this.attachDrillInHooks(nodes);
1825
+ return {
1826
+ nodes,
1827
+ files: filesResult.files,
1828
+ files_total: filesResult.total,
1829
+ files_offset: filesOffset,
1830
+ nodes_total: fused.length,
1831
+ truncated: grep.truncated || undefined,
1832
+ scope_note: scopeNote
1833
+ };
1834
+ }
1835
+ /**
1836
+ * The token-ranked (BM25) half of {@link searchNodes}. Looks up every query/keyword token in
1837
+ * the local `node_tokens` index (rebuilding it first if stale — see {@link ensureSearchIndexFresh}),
1838
+ * scores each candidate with {@link scoreCandidate} (IDF-weighted, saturating TF, per-field
1839
+ * weights favoring `description`), and applies the noise floor. Metadata only — identifier / id
1840
+ * / path / description / reasoning. Code-body matching is NO LONGER folded in here: it used to
1841
+ * read one history-JSON per node (~9k serial reads = the tool's ~10-30s cost), and is now
1842
+ * served far faster by the real filesystem grep in `searchNodes` (see `mapGrepHitsToNodes`).
1843
+ * Returns nodes tagged `matched_via:'fuzzy'`, ranked; `searchNodes` re-fuses them by RRF.
1844
+ */
1845
+ tokenSearchNodes(tokens) {
1846
+ const uniq = Array.from(new Set(tokens));
1847
+ if (uniq.length === 0)
1848
+ return [];
1849
+ this.ensureSearchIndexFresh();
1850
+ const totalNodesRow = this.db.prepare('SELECT COUNT(*) as c FROM nodes WHERE deprecated = 0').get();
1851
+ const totalNodes = Math.max(totalNodesRow.c, 1);
1852
+ const placeholders = uniq.map(() => '?').join(',');
1853
+ // Document frequency per (token, field) computed ONCE via a grouped aggregate, not as a
1854
+ // per-ROW correlated subquery — that used to re-scan node_tokens once for EVERY matching row
1855
+ // (thousands, for a common word across an 8k-node/468k-token real corpus), which alone
1856
+ // measured at 9-10 SECONDS on a real production graph. One aggregate query + an in-memory
1857
+ // lookup does the identical computation in a few milliseconds.
1858
+ const docFreqRows = this.db.prepare(`
1859
+ SELECT token, field, COUNT(DISTINCT node_id) AS doc_freq
1860
+ FROM node_tokens
1861
+ WHERE token IN (${placeholders})
1862
+ GROUP BY token, field
1863
+ `).all(...uniq);
1864
+ const docFreqByKey = new Map();
1865
+ for (const r of docFreqRows)
1866
+ docFreqByKey.set(`${r.token}${r.field}`, r.doc_freq);
1867
+ const rows = this.db.prepare(`
1868
+ SELECT node_id, token, field, tf
1869
+ FROM node_tokens
1870
+ WHERE token IN (${placeholders})
1871
+ `).all(...uniq);
1872
+ const byNode = new Map();
1873
+ for (const row of rows) {
1874
+ const entry = byNode.get(row.node_id) || { matches: [], terms: new Set() };
1875
+ /* istanbul ignore next -- `rows` and `docFreqRows` are both filtered from `node_tokens` by
1876
+ the exact same `WHERE token IN (...)`, and `docFreqRows` is a `GROUP BY token, field` over
1877
+ that identical row set — so every (token, field) pair appearing in `rows` necessarily has
1878
+ a matching aggregate entry already. The `?? 1` fallback is unreachable in practice, kept
1879
+ only as a defensive default if the two queries are ever edited out of lockstep. */
1880
+ const docFreq = docFreqByKey.get(`${row.token}${row.field}`) ?? 1;
1881
+ entry.matches.push({ field: row.field, tf: row.tf, docFreq, totalNodes });
1882
+ entry.terms.add(row.token);
1883
+ byNode.set(row.node_id, entry);
1884
+ }
1885
+ if (byNode.size === 0)
918
1886
  return [];
1887
+ // Reject the thinnest possible "match" instead of letting anything with one shared token
1888
+ // through: a query like "pending-orders/process-order" sharing only the generic word "order"
1889
+ // with an unrelated node is weak evidence, not a real hit. Two tunable gates:
1890
+ // (1) coverage — a multi-token query must match more than a single one of its distinct tokens
1891
+ // (a single-token query has nothing more to require, so this is a no-op for it);
1892
+ // (2) a minimum absolute score, since even one match CAN be strong (a rare word in the
1893
+ // description field) and shouldn't be discarded just for being one token.
1894
+ const minCoverage = Math.min(2, uniq.length);
1895
+ const MIN_BM25_SCORE = 0.75;
1896
+ const out = [];
1897
+ const nodeIds = Array.from(byNode.keys());
1898
+ const nodePlaceholders = nodeIds.map(() => '?').join(',');
1899
+ const nodeRows = DevMindDatabase.parseNodeRows(this.db.prepare(`SELECT * FROM nodes WHERE id IN (${nodePlaceholders}) AND deprecated = 0`).all(...nodeIds));
1900
+ for (const node of nodeRows) {
1901
+ const entry = byNode.get(node.id);
1902
+ if (entry.terms.size < minCoverage)
1903
+ continue;
1904
+ const score = (0, search_index_1.scoreCandidate)(entry.matches, search_index_1.DEFAULT_FIELD_WEIGHTS);
1905
+ if (score < MIN_BM25_SCORE)
1906
+ continue;
1907
+ out.push({ ...node, matched_via: 'fuzzy', matched_terms: Array.from(entry.terms), score, low_confidence: true });
919
1908
  }
920
- return this.fuzzySearchNodes(tokens);
1909
+ return out.sort((a, b) => b.score - a.score).slice(0, 20);
1910
+ }
1911
+ /** Absolute filesystem roots for every configured repo — the search space for `grepRepos`. */
1912
+ repoRoots() {
1913
+ if (!this.context)
1914
+ return [];
1915
+ const roots = [];
1916
+ for (const repo of this.context.config.repos) {
1917
+ const root = (0, config_1.resolveRepoPath)(this.context, repo.name);
1918
+ if (root)
1919
+ roots.push(root);
1920
+ }
1921
+ return roots;
921
1922
  }
922
1923
  /**
923
- * Splits a query string into lowercase word tokens for the fuzzy fallback
924
- * stage of {@link searchNodes}. This is request-scoped tokenization only
925
- * nothing is persisted or indexed; the result is discarded after the call.
1924
+ * Resolves and validates `search_nodes`' optional `path` scope a single folder or file the
1925
+ * grep walk restricts to, instead of always walking every configured repo. A path outside every
1926
+ * repo root is REJECTED (thrown, not silently widened to "search everything") a scope that
1927
+ * can't be honored should fail loudly, since silently ignoring it would search far more than
1928
+ * the caller asked for. `canonicalizePath` (already used throughout this file for path
1929
+ * comparisons) lowercases the Windows drive letter so `C:\...` and `c:\...` compare equal.
926
1930
  */
927
- tokenizeQuery(query) {
928
- const seen = new Set();
929
- for (const raw of query.toLowerCase().split(/[^a-z0-9]+/i)) {
930
- if (raw.length >= 2)
931
- seen.add(raw);
1931
+ resolveSearchScopePath(rawPath) {
1932
+ if (!rawPath || !rawPath.trim())
1933
+ return undefined;
1934
+ const resolved = (0, config_1.canonicalizePath)(rawPath.trim());
1935
+ const roots = this.repoRoots().map(config_1.canonicalizePath);
1936
+ const contained = roots.some(root => resolved === root || resolved.startsWith(root + path.sep));
1937
+ if (!contained) {
1938
+ throw new Error(`search_nodes: path "${rawPath}" is outside every configured repo (${roots.join(', ') || 'none configured'}).`);
932
1939
  }
933
- return Array.from(seen);
1940
+ return resolved;
934
1941
  }
935
1942
  /**
936
- * Word-split relevance-ranked fallback for {@link searchNodes}. Runs only
937
- * when the exact identifier and code stages both return nothing. Scores
938
- * every non-deprecated node by how many distinct query tokens appear as a
939
- * substring of its file_path/name/id (highest signal), latest reasoning,
940
- * or code content (lowest signal, one point per matching line). No new
941
- * data is written or synced this is a plain in-memory scan reusing the
942
- * same node/history sources searchCode already reads.
1943
+ * Maps raw grep hits back to graph nodes — this is "code search that returns nodes", rebuilt
1944
+ * on top of the single filesystem walk instead of the old ~9k per-node snapshot reads. For each
1945
+ * file that had hits AND contains indexed nodes: an AST-parseable file is resolved precisely
1946
+ * (each hit line the one node whose line-range contains it, via {@link locateNodeInFile}), so
1947
+ * a keyword hitting one method in a 10-method file surfaces THAT node, not all ten. A non-AST
1948
+ * indexed file (a staged `.py`/`.go` node) falls back to coarse file→node. Returns node ids in
1949
+ * match-strength order (files with more hits first) plus the matching lines, for RRF + display.
1950
+ *
1951
+ * Only the top {@link CODE_MATCH_FILE_CAP} files by hit count are AST-resolved — measured on a
1952
+ * real 8-repo, 15k-grep-hit query, this step alone was 8.2s of a 9.6s total before the cap, one
1953
+ * `locateNodeInFile` AST walk per node per matched file with no bound on how many files that
1954
+ * could be. It doesn't cost result quality: the fused ranking downstream keeps only the top ~20
1955
+ * nodes anyway, and files with more hits are exactly the ones most likely to place there — files
1956
+ * past the cap would almost never have survived to the final result even fully resolved.
943
1957
  */
944
- fuzzySearchNodes(tokens) {
945
- const historyDir = path.join(path.dirname(this.dbPath), 'history');
946
- const stmt = this.db.prepare(`
947
- SELECT n.*, h.id AS latest_history_id, h.reasoning AS reasoning
948
- FROM nodes n
949
- LEFT JOIN history h ON h.id = (
950
- SELECT id FROM history WHERE node_id = n.id ORDER BY updated_at DESC LIMIT 1
951
- )
952
- WHERE n.deprecated = 0
953
- `);
954
- const rows = stmt.all();
955
- const FIELD_WEIGHT = { path: 3, identifier: 3, reasoning: 2, code: 1 };
956
- const scored = [];
957
- for (const row of rows) {
958
- const { latest_history_id, reasoning, ...node } = row;
959
- const matchedTerms = new Set();
960
- let score = 0;
961
- const filePathLower = (node.file_path || '').toLowerCase();
962
- const nameLower = (node.name || '').toLowerCase();
963
- const idLower = (node.id || '').toLowerCase();
964
- const reasoningLower = (reasoning || '').toLowerCase();
965
- let code = '';
966
- if (latest_history_id) {
967
- const historyFile = path.join(historyDir, `${latest_history_id}.json`);
968
- if (fs.existsSync(historyFile)) {
969
- try {
970
- const data = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
971
- code = (data.code_snapshot || '').toLowerCase();
972
- }
973
- catch {
974
- // Skip corrupted or unreadable history files
1958
+ mapGrepHitsToNodes(hits) {
1959
+ if (hits.length === 0)
1960
+ return [];
1961
+ // Group hit line numbers (+ content) by file, and rank files by hit count so the strongest
1962
+ // code matches come first in the returned order (which becomes their RRF rank).
1963
+ const byFile = new Map();
1964
+ for (const h of hits) {
1965
+ let arr = byFile.get(h.file_path);
1966
+ if (!arr) {
1967
+ arr = [];
1968
+ byFile.set(h.file_path, arr);
1969
+ }
1970
+ arr.push({ line_number: h.line_number, line_content: h.line_content });
1971
+ }
1972
+ const CODE_MATCH_FILE_CAP = 30;
1973
+ const filesByStrength = Array.from(byFile.entries())
1974
+ .sort((a, b) => b[1].length - a[1].length)
1975
+ .slice(0, CODE_MATCH_FILE_CAP);
1976
+ const perNode = new Map();
1977
+ for (const [filePath, lines] of filesByStrength) {
1978
+ const nodes = this.getNodesByFilePath(filePath);
1979
+ if (nodes.length === 0)
1980
+ continue; // an un-indexed file — lives in the files bucket only
1981
+ if ((0, ast_1.isAstParseable)(filePath)) {
1982
+ // Precise: resolve each node's line span once, assign every hit line to its container.
1983
+ const spans = this.computeSymbolSpans(filePath, nodes);
1984
+ for (const line of lines) {
1985
+ const containing = spans.find(s => line.line_number >= s.startLine && line.line_number <= s.endLine);
1986
+ if (!containing)
1987
+ continue; // hit was between symbols (an import, a top-level const) — file bucket has it
1988
+ let acc = perNode.get(containing.id);
1989
+ if (!acc) {
1990
+ acc = [];
1991
+ perNode.set(containing.id, acc);
975
1992
  }
1993
+ if (acc.length < 5)
1994
+ acc.push(line);
976
1995
  }
977
1996
  }
978
- for (const token of tokens) {
979
- let tokenMatched = false;
980
- if (filePathLower.includes(token)) {
981
- score += FIELD_WEIGHT.path;
982
- tokenMatched = true;
983
- }
984
- if (nameLower.includes(token) || idLower.includes(token)) {
985
- score += FIELD_WEIGHT.identifier;
986
- tokenMatched = true;
987
- }
988
- if (reasoningLower.includes(token)) {
989
- score += FIELD_WEIGHT.reasoning;
990
- tokenMatched = true;
991
- }
992
- if (code && code.includes(token)) {
993
- score += FIELD_WEIGHT.code;
994
- tokenMatched = true;
1997
+ else {
1998
+ // Coarse fallback for indexed but non-AST files (staged .py/.go/etc): every node in the file.
1999
+ for (const n of nodes) {
2000
+ if (!perNode.has(n.id))
2001
+ perNode.set(n.id, lines.slice(0, 5));
995
2002
  }
996
- if (tokenMatched)
997
- matchedTerms.add(token);
998
2003
  }
999
- if (score > 0) {
1000
- scored.push({ ...node, matched_via: 'fuzzy', matched_terms: Array.from(matchedTerms), score });
2004
+ }
2005
+ // perNode preserves insertion order = file-strength order, which is the ranking we want.
2006
+ return Array.from(perNode.entries()).map(([nodeId, lines]) => ({ nodeId, lines }));
2007
+ }
2008
+ /**
2009
+ * Resolves every indexed node's line span within one AST-parseable file — extracted out of
2010
+ * `mapGrepHitsToNodes` so `annotateSampleLinesWithSymbol` can reuse the identical span logic
2011
+ * instead of re-deriving it. Computed ONCE per file (not per line/hit): a file with several
2012
+ * sample lines would otherwise repeat the same `locateNodeInFile` AST walk once per line.
2013
+ * `nodes` is accepted rather than re-fetched so an existing `getNodesByFilePath` result (as
2014
+ * `mapGrepHitsToNodes` already has) isn't queried twice.
2015
+ */
2016
+ computeSymbolSpans(filePath, nodes) {
2017
+ return nodes.map(n => {
2018
+ const parsed = (0, ast_1.parseNodeId)(n.id);
2019
+ const symbol = parsed ? parsed.symbolName : (n.id.split('#').pop() || n.name);
2020
+ const loc = symbol ? (0, ast_1.locateNodeInFile)(filePath, symbol) : null;
2021
+ return loc ? { id: n.id, name: n.name, startLine: loc.startLine, endLine: loc.endLine } : null;
2022
+ }).filter((s) => s !== null);
2023
+ }
2024
+ /**
2025
+ * Mutates each `RankedFile`'s `sample_lines` in place, tagging every line with the function/class
2026
+ * that contains it — the insight a plain filesystem grep can never give: not just "line 87
2027
+ * matched" but "line 87, inside `onLikeTap`". This is the annotation `search_nodes` offers that
2028
+ * makes it worth more than grep for a hit that lands inside real source.
2029
+ *
2030
+ * Deliberately bounded to the ALREADY-CAPPED page (`files`, ≤`maxFiles` entries × ≤5 sample
2031
+ * lines each — at most ~125 lookups) rather than every raw grep hit. `mapGrepHitsToNodes` is
2032
+ * measured at 8.2s of a 9.6s query on a real 8-repo search (see its doc comment) precisely from
2033
+ * unbounded per-hit AST resolution; annotating only what's actually returned keeps this at a
2034
+ * small, constant added cost regardless of how broad the pattern was or how many total hits it
2035
+ * produced. Silently no-ops for a file that isn't indexed or isn't AST-parseable — a `symbol` on
2036
+ * a sample line is a bonus, never a requirement.
2037
+ */
2038
+ annotateSampleLinesWithSymbol(files) {
2039
+ for (const file of files) {
2040
+ if (!(0, ast_1.isAstParseable)(file.file_path))
2041
+ continue;
2042
+ const nodes = this.getNodesByFilePath(file.file_path);
2043
+ if (nodes.length === 0)
2044
+ continue;
2045
+ const spans = this.computeSymbolSpans(file.file_path, nodes);
2046
+ if (spans.length === 0)
2047
+ continue;
2048
+ for (const line of file.sample_lines) {
2049
+ const containing = spans.find(s => line.line_number >= s.startLine && line.line_number <= s.endLine);
2050
+ if (containing)
2051
+ line.symbol = containing.name;
1001
2052
  }
1002
2053
  }
1003
- return scored.sort((a, b) => b.score - a.score).slice(0, 20);
1004
2054
  }
1005
- getRecentChanges(hours = 24, analyzeImpact = true) {
1006
- const stmt = this.db.prepare(`
1007
- SELECT h.id, h.node_id, n.name as node_name, n.file_path, h.updated_at, h.reasoning
1008
- FROM history h
1009
- JOIN nodes n ON h.node_id = n.id
1010
- WHERE h.updated_at >= datetime('now', ?)
1011
- ORDER BY h.updated_at DESC
1012
- `);
1013
- const recentChanges = stmt.all(`-${hours} hours`);
1014
- for (const change of recentChanges) {
1015
- const populated = this.populateHistoryFromDisk({ id: change.id, reasoning: change.reasoning });
1016
- change.reasoning = populated.reasoning;
1017
- delete change.id;
1018
- }
1019
- if (!analyzeImpact) {
1020
- return recentChanges;
1021
- }
1022
- const modifiedSet = new Set(recentChanges.map(c => c.node_id));
1023
- const callersStmt = this.db.prepare(`
1024
- SELECT n.id as node_id, n.name as node_name, n.file_path
1025
- FROM nodes n
1026
- JOIN node_connections c ON n.id = c.source_node_id
1027
- WHERE c.target_node_id = ?
1028
- `);
1029
- for (const change of recentChanges) {
1030
- const callers = callersStmt.all(change.node_id);
1031
- change.downstream_impact = callers.map(caller => ({
1032
- node_id: caller.node_id,
1033
- node_name: caller.node_name,
1034
- file_path: caller.file_path,
1035
- status: modifiedSet.has(caller.node_id) ? 'already_updated' : 'stale_warning'
1036
- }));
1037
- }
1038
- return recentChanges;
1039
- }
1040
- getDeveloperActivity(developer, limit = 50) {
1041
- const stmt = this.db.prepare(`
1042
- SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
1043
- FROM history h
1044
- JOIN nodes n ON h.node_id = n.id
1045
- WHERE h.reasoning LIKE ? ESCAPE '\\'
1046
- ORDER BY h.updated_at DESC
1047
- LIMIT ?
1048
- `);
1049
- const query = `%Developer: %${this.likeEscape(developer)}%`;
1050
- return stmt.all(query, limit);
1051
- }
1052
- getChangesByRequirement(requirementId) {
1053
- const stmt = this.db.prepare(`
1054
- SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
1055
- FROM history h
1056
- JOIN nodes n ON h.node_id = n.id
1057
- WHERE h.reasoning LIKE ? ESCAPE '\\'
1058
- ORDER BY h.updated_at DESC
1059
- `);
1060
- const query = `%Requirement: %${this.likeEscape(requirementId)}%`;
1061
- return stmt.all(query);
2055
+ /**
2056
+ * The semantic half of hybrid search: embeds the query with the same vendored ONNX model used
2057
+ * to embed every node's description (see embedder.ts), then linear-scans `node_vectors` for
2058
+ * cosine similarity. Linear is fine at this scale — ~9k nodes × 384 int8 dims is a few million
2059
+ * integer multiplications, well under a millisecond, far below the model's own inference time
2060
+ * for the query itself; revisit only past ~100k nodes. Returns each surviving node's raw cosine
2061
+ * alongside its id — the ORDER drives RRF, but the cosine value is kept so `searchNodes` can turn
2062
+ * it into a human-meaningful confidence (a 0.7 cosine is a strong match; a 0.36 is a weak one —
2063
+ * the fused RRF float can't express that). Returns [] — never throws — if the embedder is
2064
+ * unavailable, so `searchNodes` degrades to BM25+grep exactly as before.
2065
+ */
2066
+ async vectorSearchNodes(query) {
2067
+ const queryVector = await (0, embedder_1.embedTextInt8)(query);
2068
+ if (!queryVector)
2069
+ return [];
2070
+ const rows = this.db.prepare(`
2071
+ SELECT nv.node_id AS node_id, nv.vector AS vector
2072
+ FROM node_vectors nv
2073
+ JOIN nodes n ON n.id = nv.node_id
2074
+ WHERE n.deprecated = 0 AND nv.model_id = ?
2075
+ `).all(embedder_1.EMBEDDING_MODEL_ID);
2076
+ if (rows.length === 0)
2077
+ return [];
2078
+ const scored = rows.map(r => ({
2079
+ id: r.node_id,
2080
+ sim: (0, embedder_1.cosineInt8)(queryVector, new Int8Array(r.vector.buffer, r.vector.byteOffset, r.vector.byteLength))
2081
+ }));
2082
+ scored.sort((a, b) => b.sim - a.sim);
2083
+ // A floor, not just a top-N cut: padding the ranking with genuinely unrelated nodes just to
2084
+ // fill a quota would inject noise into the RRF fusion. A low cosine here means "nothing is
2085
+ // semantically close" — that's a real signal that vector search found nothing, not a reason
2086
+ // to return its least-bad guesses. 0.35, not 0.2: MiniLM-class sentence embeddings commonly
2087
+ // put two UNRELATED short texts around 0.1-0.25 just from shared sentence structure/English
2088
+ // baseline, not real similarity — 0.2 was letting that noise floor through as if it meant
2089
+ // something. Tunable; revisit against real query/result pairs if this starts rejecting
2090
+ // matches that should have passed.
2091
+ const MIN_COSINE_SIMILARITY = 0.35;
2092
+ return scored.filter(s => s.sim > MIN_COSINE_SIMILARITY).slice(0, 50);
2093
+ }
2094
+ /**
2095
+ * Cheap, single-pass signal for "has the graph changed since node_tokens was last built":
2096
+ * how many non-deprecated nodes exist, the total length of every description (catches a
2097
+ * description being added OR edited — length almost never stays identical), and how many
2098
+ * history rows exist (catches new reasoning). Deliberately NOT based on an `updated_at`
2099
+ * column on `nodes` — there isn't one, and `syncFromDisk`'s destructive graph-rebuild pass
2100
+ * writes nodes via raw SQL, not through a single method that could easily be hooked, so a
2101
+ * write-path-by-write-path invalidation scheme would silently miss that path (and did, in an
2102
+ * earlier draft of this). A fingerprint checked lazily at query time can't be missed the same
2103
+ * way it doesn't matter HOW the data changed, only THAT it did.
2104
+ */
2105
+ searchIndexFingerprint() {
2106
+ const row = this.db.prepare(`
2107
+ SELECT COUNT(*) as node_count, COALESCE(SUM(LENGTH(description)), 0) as desc_len_sum
2108
+ FROM nodes WHERE deprecated = 0
2109
+ `).get();
2110
+ const historyRow = this.db.prepare('SELECT COUNT(*) as c FROM history').get();
2111
+ return `${row.node_count}:${row.desc_len_sum}:${historyRow.c}`;
2112
+ }
2113
+ /** Rebuilds `node_tokens` from scratch for every non-deprecated node's current
2114
+ * identifier/id/path/description and EVERY revision's reasoning (not just the latest — a
2115
+ * decision recorded three revisions ago must stay findable by `search_nodes`, since there is
2116
+ * no separate decisions-only search tool anymore; `GROUP_CONCAT` folds every history row's
2117
+ * reasoning into one field before tokenizing, so an older "Decision: …" is searchable exactly
2118
+ * like the newest one). Cheap at realistic node counts (hundreds to low-thousands) — a single
2119
+ * scan plus one batched transaction, not something that needs to be avoided; simplicity here
2120
+ * is worth more than incremental upkeep that a bypassed write path could silently defeat. */
2121
+ rebuildSearchIndex() {
2122
+ const nodes = this.db.prepare(`
2123
+ SELECT n.*, (
2124
+ SELECT GROUP_CONCAT(h.reasoning, ' ') FROM history h WHERE h.node_id = n.id
2125
+ ) AS all_reasoning
2126
+ FROM nodes n
2127
+ WHERE n.deprecated = 0
2128
+ `).all();
2129
+ const del = this.db.prepare('DELETE FROM node_tokens');
2130
+ const ins = this.db.prepare('INSERT OR REPLACE INTO node_tokens (node_id, token, field, tf) VALUES (?, ?, ?, ?)');
2131
+ const tx = this.db.transaction(() => {
2132
+ del.run();
2133
+ for (const node of nodes) {
2134
+ const rows = [
2135
+ ...(0, search_index_1.tokenizeNodeField)(node.name, 'identifier'),
2136
+ ...(0, search_index_1.tokenizeNodeField)(node.id, 'identifier'),
2137
+ ...(0, search_index_1.tokenizeNodeField)(node.file_path, 'path'),
2138
+ ...(0, search_index_1.tokenizeNodeField)(node.description, 'description'),
2139
+ ...(0, search_index_1.tokenizeNodeField)(node.all_reasoning, 'reasoning')
2140
+ ];
2141
+ // Merge duplicate (field,token) pairs from the multiple sources above (e.g. name and id
2142
+ // both contributing the same word) into one summed tf, rather than letting the LATER
2143
+ // INSERT OR REPLACE silently discard the earlier one's count.
2144
+ const merged = new Map();
2145
+ for (const r of rows) {
2146
+ const key = `${r.field}:${r.token}`;
2147
+ const existing = merged.get(key);
2148
+ if (existing)
2149
+ existing.tf += r.tf;
2150
+ else
2151
+ merged.set(key, { ...r });
2152
+ }
2153
+ for (const r of merged.values())
2154
+ ins.run(node.id, r.token, r.field, r.tf);
2155
+ }
2156
+ });
2157
+ tx();
2158
+ }
2159
+ /** Compares the current fingerprint against what `node_tokens` was last built from, stored in
2160
+ * `system_meta`; rebuilds and updates the stored fingerprint only on a mismatch. A momentarily
2161
+ * stale index (between a real change and the next search call) only costs ranking quality on
2162
+ * that one call, never correctness — the next call rebuilds it. */
2163
+ ensureSearchIndexFresh() {
2164
+ const current = this.searchIndexFingerprint();
2165
+ const row = this.db.prepare(`SELECT value FROM system_meta WHERE key = 'search_index_fingerprint'`).get();
2166
+ if (row && row.value === current)
2167
+ return;
2168
+ this.rebuildSearchIndex();
2169
+ this.db.prepare(`INSERT OR REPLACE INTO system_meta (key, value, updated_at) VALUES ('search_index_fingerprint', ?, CURRENT_TIMESTAMP)`).run(current);
1062
2170
  }
1063
2171
  searchDecisions(query) {
1064
- const stmt = this.db.prepare(`
1065
- SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
1066
- FROM history h
1067
- JOIN nodes n ON h.node_id = n.id
1068
- WHERE h.reasoning LIKE ? ESCAPE '\\'
1069
- ORDER BY h.updated_at DESC
2172
+ const stmt = this.db.prepare(`
2173
+ SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
2174
+ FROM history h
2175
+ JOIN nodes n ON h.node_id = n.id
2176
+ WHERE h.reasoning LIKE ? ESCAPE '\\'
2177
+ ORDER BY h.updated_at DESC
1070
2178
  `);
1071
2179
  const wildcard = `%Decision: %${this.likeEscape(query)}%`;
1072
2180
  return stmt.all(wildcard);
@@ -1074,17 +2182,17 @@ class DevMindDatabase {
1074
2182
  searchCode(params) {
1075
2183
  const { query, is_regex = false, case_insensitive = true } = params;
1076
2184
  const historyDir = path.join(path.dirname(this.dbPath), 'history');
1077
- const stmt = this.db.prepare(`
1078
- SELECT h.id, n.id AS node_id, n.name AS node_name, n.file_path
1079
- FROM nodes n
1080
- JOIN history h ON h.node_id = n.id
1081
- WHERE n.deprecated = 0
1082
- AND h.id = (
1083
- SELECT id FROM history
1084
- WHERE node_id = n.id
1085
- ORDER BY updated_at DESC
1086
- LIMIT 1
1087
- )
2185
+ const stmt = this.db.prepare(`
2186
+ SELECT h.id, n.id AS node_id, n.name AS node_name, n.file_path
2187
+ FROM nodes n
2188
+ JOIN history h ON h.node_id = n.id
2189
+ WHERE n.deprecated = 0
2190
+ AND h.id = (
2191
+ SELECT id FROM history
2192
+ WHERE node_id = n.id
2193
+ ORDER BY updated_at DESC
2194
+ LIMIT 1
2195
+ )
1088
2196
  `);
1089
2197
  const rows = stmt.all();
1090
2198
  let matcher;
@@ -1139,20 +2247,25 @@ class DevMindDatabase {
1139
2247
  return results.sort((a, b) => b.match_count - a.match_count);
1140
2248
  }
1141
2249
  getOrphanedNodes() {
1142
- const stmt = this.db.prepare(`
1143
- SELECT * FROM nodes
1144
- WHERE deprecated = 0
1145
- AND id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
1146
- AND id NOT IN (SELECT DISTINCT target_node_id FROM node_connections)
2250
+ const stmt = this.db.prepare(`
2251
+ SELECT * FROM nodes
2252
+ WHERE deprecated = 0
2253
+ AND id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
2254
+ AND id NOT IN (SELECT DISTINCT target_node_id FROM node_connections)
1147
2255
  `);
1148
- return stmt.all();
2256
+ return DevMindDatabase.parseNodeRows(stmt.all());
1149
2257
  }
1150
2258
  getAllNodes() {
1151
2259
  const stmt = this.db.prepare('SELECT * FROM nodes');
1152
- return stmt.all();
2260
+ return DevMindDatabase.parseNodeRows(stmt.all());
1153
2261
  }
1154
- listNodes(filter) {
1155
- let sql = 'SELECT * FROM nodes WHERE 1=1';
2262
+ /**
2263
+ * The shared WHERE clause behind {@link listNodes} and {@link countNodes}. Extracted so the page
2264
+ * and its total can never drift apart — a `total` computed from even slightly different criteria
2265
+ * than the rows it describes is worse than no total at all, since it reads as authoritative.
2266
+ */
2267
+ buildNodeFilterSql(filter) {
2268
+ let sql = ' WHERE 1=1';
1156
2269
  const params = [];
1157
2270
  if (filter?.type) {
1158
2271
  sql += ' AND type = ?';
@@ -1172,8 +2285,34 @@ class DevMindDatabase {
1172
2285
  if (!filter?.include_deprecated) {
1173
2286
  sql += ' AND deprecated = 0';
1174
2287
  }
1175
- const stmt = this.db.prepare(sql);
1176
- return stmt.all(...params);
2288
+ return { where: sql, params };
2289
+ }
2290
+ /**
2291
+ * Total nodes matching a filter, independent of any page. Exists so `list_nodes` can tell
2292
+ * "that's everything" from "there is more" — the same honesty contract `nodes_total` and
2293
+ * `files_total` already keep for search.
2294
+ */
2295
+ countNodes(filter) {
2296
+ const { where, params } = this.buildNodeFilterSql(filter);
2297
+ const row = this.db.prepare(`SELECT COUNT(*) AS c FROM nodes${where}`).get(...params);
2298
+ return row.c;
2299
+ }
2300
+ /**
2301
+ * `limit`/`offset` are OPTIONAL and, when omitted, this returns every matching row exactly as
2302
+ * before — the internal analysis callers (`analyze.ts`, `edges.ts`) legitimately need the whole
2303
+ * graph, so paging is opt-in rather than a default that would silently truncate them. Ordering
2304
+ * is applied only when paging, since an unordered LIMIT is a lottery: without it, "page 2" is
2305
+ * not guaranteed to exclude what "page 1" already returned.
2306
+ */
2307
+ listNodes(filter) {
2308
+ const { where, params } = this.buildNodeFilterSql(filter);
2309
+ let sql = `SELECT * FROM nodes${where}`;
2310
+ const args = [...params];
2311
+ if (filter?.limit !== undefined) {
2312
+ sql += ' ORDER BY file_path, name LIMIT ? OFFSET ?';
2313
+ args.push(filter.limit, filter.offset ?? 0);
2314
+ }
2315
+ return DevMindDatabase.parseNodeRows(this.db.prepare(sql).all(...args));
1177
2316
  }
1178
2317
  getAllConnections() {
1179
2318
  const stmt = this.db.prepare('SELECT * FROM node_connections');
@@ -1188,17 +2327,17 @@ class DevMindDatabase {
1188
2327
  // All pure queries/graph traversal, no mutation, no LLM calls.
1189
2328
  /** Nodes whose total (in + out) connection degree meets/exceeds `threshold` — architectural bottleneck candidates. */
1190
2329
  getGodEntities(threshold = 15) {
1191
- const stmt = this.db.prepare(`
1192
- SELECT * FROM (
1193
- SELECT n.id, n.name, n.file_path, (
1194
- (SELECT COUNT(*) FROM node_connections c WHERE c.source_node_id = n.id) +
1195
- (SELECT COUNT(*) FROM node_connections c WHERE c.target_node_id = n.id)
1196
- ) AS degree
1197
- FROM nodes n
1198
- WHERE n.deprecated = 0
1199
- )
1200
- WHERE degree >= ?
1201
- ORDER BY degree DESC
2330
+ const stmt = this.db.prepare(`
2331
+ SELECT * FROM (
2332
+ SELECT n.id, n.name, n.file_path, (
2333
+ (SELECT COUNT(*) FROM node_connections c WHERE c.source_node_id = n.id) +
2334
+ (SELECT COUNT(*) FROM node_connections c WHERE c.target_node_id = n.id)
2335
+ ) AS degree
2336
+ FROM nodes n
2337
+ WHERE n.deprecated = 0
2338
+ )
2339
+ WHERE degree >= ?
2340
+ ORDER BY degree DESC
1202
2341
  `);
1203
2342
  return stmt.all(threshold);
1204
2343
  }
@@ -1216,6 +2355,12 @@ class DevMindDatabase {
1216
2355
  const stack = [];
1217
2356
  const onStack = new Set();
1218
2357
  const dfs = (node) => {
2358
+ /* istanbul ignore if -- both of dfs's call sites (the outer `for` loop below, and the
2359
+ `for (const next of ...)` loop a few lines down) already check `cycles.length >=
2360
+ maxCycles` immediately before every single call to `dfs(...)`, so this repeats a guard
2361
+ that has always already passed by the time control reaches here. Kept as a real guard
2362
+ against a future call site that skips that pre-check, not because today's two call
2363
+ sites can reach it. */
1219
2364
  if (cycles.length >= maxCycles)
1220
2365
  return;
1221
2366
  if (onStack.has(node)) {
@@ -1246,10 +2391,10 @@ class DevMindDatabase {
1246
2391
  }
1247
2392
  /** node_connections rows whose source or target no longer exists in `nodes` (broken by a non-transactional delete, or a sync race). */
1248
2393
  getDanglingEdges() {
1249
- const stmt = this.db.prepare(`
1250
- SELECT * FROM node_connections
1251
- WHERE source_node_id NOT IN (SELECT id FROM nodes)
1252
- OR target_node_id NOT IN (SELECT id FROM nodes)
2394
+ const stmt = this.db.prepare(`
2395
+ SELECT * FROM node_connections
2396
+ WHERE source_node_id NOT IN (SELECT id FROM nodes)
2397
+ OR target_node_id NOT IN (SELECT id FROM nodes)
1253
2398
  `);
1254
2399
  return stmt.all();
1255
2400
  }
@@ -1259,12 +2404,12 @@ class DevMindDatabase {
1259
2404
  }
1260
2405
  /** Node ids that differ only by case — a real collision risk on Windows's case-insensitive filesystem. */
1261
2406
  getDuplicateNodeIds() {
1262
- const stmt = this.db.prepare(`
1263
- SELECT LOWER(id) AS lower_id, GROUP_CONCAT(id, '|') AS ids
1264
- FROM nodes
1265
- WHERE deprecated = 0
1266
- GROUP BY lower_id
1267
- HAVING COUNT(*) > 1
2407
+ const stmt = this.db.prepare(`
2408
+ SELECT LOWER(id) AS lower_id, GROUP_CONCAT(id, '|') AS ids
2409
+ FROM nodes
2410
+ WHERE deprecated = 0
2411
+ GROUP BY lower_id
2412
+ HAVING COUNT(*) > 1
1268
2413
  `);
1269
2414
  const rows = stmt.all();
1270
2415
  return rows.map(r => ({ lowerId: r.lower_id, ids: r.ids.split('|') }));
@@ -1301,7 +2446,76 @@ class DevMindDatabase {
1301
2446
  workflowsDir() {
1302
2447
  return path.join(path.dirname(this.dbPath), 'workflows');
1303
2448
  }
1304
- /** Serializes the workflow + its steps + artifact index to disk so teammates can sync it via git. */
2449
+ /**
2450
+ * One-time backfill of `node_ids`/`reasoning` for steps written before v2, resolving each old
2451
+ * `history_ids` entry to the node it belongs to. Runs at open, right after the ALTERs, and is
2452
+ * self-limiting: it only touches rows where `node_ids IS NULL AND history_ids IS NOT NULL`, so
2453
+ * the second open finds nothing and the query costs one indexed scan.
2454
+ *
2455
+ * Best-effort by nature. Because of the 1-hour history merge, an old step's `history_ids` can
2456
+ * include rows an ADJACENT commit created, so a backfilled node list can be broader than what
2457
+ * that step actually touched. That is acceptable for pre-v2 rows — they were already imprecise,
2458
+ * which is exactly why the format changed — but it is why nothing presents backfilled data as
2459
+ * exact. A step whose history rows have since been pruned keeps its `summary` and gets an empty
2460
+ * list rather than being skipped, so it still appears on the timeline.
2461
+ */
2462
+ backfillWorkflowStepNodeIds() {
2463
+ try {
2464
+ const stale = this.db
2465
+ .prepare(`SELECT id, history_ids FROM workflow_steps WHERE node_ids IS NULL AND history_ids IS NOT NULL`)
2466
+ .all();
2467
+ if (stale.length === 0)
2468
+ return;
2469
+ const lookup = this.db.prepare('SELECT node_id, reasoning FROM history WHERE id = ?');
2470
+ const update = this.db.prepare('UPDATE workflow_steps SET node_ids = ?, reasoning = COALESCE(reasoning, ?) WHERE id = ?');
2471
+ const run = this.db.transaction((rows) => {
2472
+ for (const row of rows) {
2473
+ let ids;
2474
+ try {
2475
+ ids = JSON.parse(row.history_ids);
2476
+ }
2477
+ catch {
2478
+ ids = [];
2479
+ }
2480
+ const nodeIds = [];
2481
+ let reasoning = null;
2482
+ if (Array.isArray(ids)) {
2483
+ for (const historyId of ids) {
2484
+ const hit = lookup.get(String(historyId));
2485
+ if (!hit)
2486
+ continue;
2487
+ if (!nodeIds.includes(hit.node_id))
2488
+ nodeIds.push(hit.node_id);
2489
+ if (reasoning === null && hit.reasoning)
2490
+ reasoning = hit.reasoning;
2491
+ }
2492
+ }
2493
+ update.run(JSON.stringify(nodeIds), reasoning, row.id);
2494
+ }
2495
+ });
2496
+ run(stale);
2497
+ }
2498
+ catch {
2499
+ // A brain mid-migration (or a workflow_steps table that predates history_ids entirely)
2500
+ // must not block opening the DB — the steps simply stay on their old shape.
2501
+ }
2502
+ }
2503
+ /**
2504
+ * Serializes the workflow + its steps + artifact index to disk so teammates can sync it via git.
2505
+ *
2506
+ * Written as TWO files, and the split is the whole point:
2507
+ *
2508
+ * - `workflow.json` keeps the shape a v1 client understands, so an older build reading it loses
2509
+ * nothing it ever had.
2510
+ * - `v2.json` holds everything v1 has no field for (`archived`, and per-step `reasoning` /
2511
+ * `node_ids` / `doc_paths`).
2512
+ *
2513
+ * A single file could not be made safe. `devsmind sync` calls `syncToDisk`, which re-serializes
2514
+ * every workflow.json from whatever columns the local build knows about — so a teammate who
2515
+ * pulls on an older version and syncs would rewrite every workflow and silently strip the new
2516
+ * fields, then commit that loss for everyone. An older build has no idea `v2.json` exists, so it
2517
+ * cannot rewrite it; the data survives the round trip and is merged back on the next read.
2518
+ */
1305
2519
  writeWorkflowToDisk(workflowId) {
1306
2520
  try {
1307
2521
  const workflow = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(workflowId);
@@ -1309,21 +2523,21 @@ class DevMindDatabase {
1309
2523
  return;
1310
2524
  const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
1311
2525
  const artifacts = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(workflowId);
1312
- const activeId = this.getSystemMeta('active_workflow_id');
1313
2526
  const data = {
2527
+ schema_version: exports.WORKFLOW_SCHEMA_VERSION,
1314
2528
  id: workflow.id,
1315
2529
  name: workflow.name,
1316
2530
  description: workflow.description,
1317
- status: workflow.status,
2531
+ archived: workflow.archived ? 1 : 0,
1318
2532
  created_at: workflow.created_at,
1319
2533
  updated_at: workflow.updated_at,
1320
- is_active: activeId === workflowId,
1321
2534
  steps: steps.map(s => ({
1322
2535
  id: s.id,
1323
2536
  step_index: s.step_index,
1324
2537
  summary: s.summary,
1325
- pending_tasks: s.pending_tasks,
1326
- history_ids: s.history_ids,
2538
+ reasoning: s.reasoning,
2539
+ node_ids: s.node_ids,
2540
+ doc_paths: s.doc_paths,
1327
2541
  session_id: s.session_id,
1328
2542
  created_at: s.created_at
1329
2543
  })),
@@ -1339,86 +2553,123 @@ class DevMindDatabase {
1339
2553
  const dir = path.join(this.workflowsDir(), workflowId);
1340
2554
  fs.mkdirSync(dir, { recursive: true });
1341
2555
  fs.writeFileSync(path.join(dir, 'workflow.json'), JSON.stringify(data, null, 2), 'utf-8');
2556
+ // The v1-invisible half. Keyed by step id rather than positional, so it still merges
2557
+ // correctly onto a workflow.json an older build reordered or rewrote.
2558
+ const sidecar = {
2559
+ schema_version: exports.WORKFLOW_SCHEMA_VERSION,
2560
+ archived: workflow.archived ? 1 : 0,
2561
+ steps: Object.fromEntries(steps
2562
+ .filter(s => s.reasoning || s.node_ids || s.doc_paths)
2563
+ .map(s => [s.id, { reasoning: s.reasoning, node_ids: s.node_ids, doc_paths: s.doc_paths }]))
2564
+ };
2565
+ fs.writeFileSync(path.join(dir, exports.WORKFLOW_SIDECAR_FILE), JSON.stringify(sidecar, null, 2), 'utf-8');
1342
2566
  }
1343
2567
  catch (err) {
1344
2568
  console.warn('⚠️ DevsMind: Failed to write workflow JSON to disk:', err);
1345
2569
  }
1346
2570
  }
2571
+ /**
2572
+ * Creates a workflow. Note what it deliberately does NOT do any more: set a global "active"
2573
+ * pointer. Which workflow you are working on is a property of YOUR session, held locally — a
2574
+ * workflow is a shared record, and one shared pointer meant two sessions (or two teammates,
2575
+ * since the pointer synced through git) silently stole it from each other mid-work.
2576
+ */
1347
2577
  createWorkflow(name, description) {
1348
2578
  const id = `wf_${crypto.randomUUID()}`;
1349
2579
  const now = new Date().toISOString();
1350
- this.db.prepare(`
1351
- INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1352
- VALUES (?, ?, ?, 'active', ?, ?)
2580
+ this.db.prepare(`
2581
+ INSERT INTO workflows (id, name, description, archived, created_at, updated_at)
2582
+ VALUES (?, ?, ?, 0, ?, ?)
1353
2583
  `).run(id, name, description, now, now);
1354
- this.setSystemMeta('active_workflow_id', id);
1355
2584
  this.writeWorkflowToDisk(id);
1356
- return { id, name, description, status: 'active', created_at: now, updated_at: now };
2585
+ return { id, name, description, archived: 0, created_at: now, updated_at: now };
1357
2586
  }
1358
2587
  getWorkflow(id) {
1359
2588
  const row = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(id);
1360
2589
  return row || null;
1361
2590
  }
1362
- getActiveWorkflow() {
1363
- const id = this.getSystemMeta('active_workflow_id');
1364
- return id ? this.getWorkflow(id) : null;
1365
- }
1366
- listWorkflows(status) {
1367
- if (status) {
1368
- return this.db.prepare('SELECT * FROM workflows WHERE status = ? ORDER BY updated_at DESC').all(status);
2591
+ /**
2592
+ * Workflows newest-touched first — which is the ordering that replaces the old `status` field.
2593
+ * Live work floats up and abandoned threads sink on their own, so nothing has to be marked
2594
+ * "completed" by hand (nobody ever did, and a lifecycle field nobody maintains just lies).
2595
+ *
2596
+ * `query` matches name AND description, the search `searchWorkflows` never actually did: it
2597
+ * scanned step summaries and artifact names only, so looking a workflow up by its own name
2598
+ * returned nothing. Paging mirrors `listNodes` — `total` is the true count before the page.
2599
+ */
2600
+ listWorkflows(opts) {
2601
+ const { where, params } = this.buildWorkflowFilterSql(opts);
2602
+ let sql = `SELECT * FROM workflows${where} ORDER BY updated_at DESC`;
2603
+ const args = [...params];
2604
+ if (opts?.limit !== undefined) {
2605
+ sql += ' LIMIT ? OFFSET ?';
2606
+ args.push(opts.limit, opts.offset ?? 0);
2607
+ }
2608
+ return this.db.prepare(sql).all(...args);
2609
+ }
2610
+ countWorkflows(opts) {
2611
+ const { where, params } = this.buildWorkflowFilterSql(opts);
2612
+ const row = this.db.prepare(`SELECT COUNT(*) AS c FROM workflows${where}`).get(...params);
2613
+ return row.c;
2614
+ }
2615
+ /** Shared WHERE builder, so a page and its `total` can never describe different criteria. */
2616
+ buildWorkflowFilterSql(opts) {
2617
+ let where = ' WHERE 1=1';
2618
+ const params = [];
2619
+ if (!opts?.includeArchived)
2620
+ where += ' AND archived = 0';
2621
+ if (opts?.query && opts.query.trim()) {
2622
+ where += " AND (LOWER(name) LIKE ? ESCAPE '\\' OR LOWER(description) LIKE ? ESCAPE '\\')";
2623
+ const like = `%${this.likeEscape(opts.query.trim().toLowerCase())}%`;
2624
+ params.push(like, like);
1369
2625
  }
1370
- return this.db.prepare('SELECT * FROM workflows ORDER BY updated_at DESC').all();
2626
+ return { where, params };
1371
2627
  }
1372
- /** Pauses the currently active workflow (if any) and clears the active pointer. */
1373
- pauseWorkflow() {
1374
- const active = this.getActiveWorkflow();
1375
- if (!active)
1376
- return null;
1377
- const now = new Date().toISOString();
1378
- this.db.prepare(`UPDATE workflows SET status = 'paused', updated_at = ? WHERE id = ?`).run(now, active.id);
1379
- this.setSystemMeta('active_workflow_id', '');
1380
- this.writeWorkflowToDisk(active.id);
1381
- return { ...active, status: 'paused', updated_at: now };
1382
- }
1383
- /** Resumes `id`, auto-pausing whatever was previously active (only one workflow is active at a time). */
1384
- resumeWorkflow(id) {
1385
- const workflow = this.getWorkflow(id);
1386
- if (!workflow)
1387
- throw new Error(`Workflow not found: ${id}`);
1388
- const currentActive = this.getActiveWorkflow();
1389
- if (currentActive && currentActive.id !== id)
1390
- this.pauseWorkflow();
1391
- const now = new Date().toISOString();
1392
- this.db.prepare(`UPDATE workflows SET status = 'active', updated_at = ? WHERE id = ?`).run(now, id);
1393
- this.setSystemMeta('active_workflow_id', id);
1394
- this.writeWorkflowToDisk(id);
1395
- return { ...workflow, status: 'active', updated_at: now };
1396
- }
1397
- completeWorkflow(id) {
2628
+ /**
2629
+ * Hides a workflow from the default listing. Deliberately NOT called "complete": a feature is
2630
+ * never finished, it just stops being worked on, and the old `completed` status promised a
2631
+ * lifecycle nobody maintained. Archiving claims only what it delivers, and is reversible.
2632
+ */
2633
+ setWorkflowArchived(id, archived) {
1398
2634
  const workflow = this.getWorkflow(id);
1399
2635
  if (!workflow)
1400
2636
  throw new Error(`Workflow not found: ${id}`);
1401
2637
  const now = new Date().toISOString();
1402
- this.db.prepare(`UPDATE workflows SET status = 'completed', updated_at = ? WHERE id = ?`).run(now, id);
1403
- if (this.getSystemMeta('active_workflow_id') === id)
1404
- this.setSystemMeta('active_workflow_id', '');
2638
+ this.db.prepare('UPDATE workflows SET archived = ?, updated_at = ? WHERE id = ?').run(archived ? 1 : 0, now, id);
1405
2639
  this.writeWorkflowToDisk(id);
1406
- return { ...workflow, status: 'completed', updated_at: now };
2640
+ return { ...workflow, archived: archived ? 1 : 0, updated_at: now };
1407
2641
  }
2642
+ /**
2643
+ * Appends one step. A step is either a COMMIT (summary + reasoning + the node ids it touched) or
2644
+ * a RESEARCH finding (summary + reasoning + the docs behind it, no nodes) — the second is the
2645
+ * only record of work that produced a decision but no code, which nothing else in DevsMind
2646
+ * captures: git has the diff and history has the per-node reasoning, but neither can tell you
2647
+ * what was evaluated and rejected.
2648
+ *
2649
+ * `reasoning` is stored, not joined from `history`, deliberately — see DbWorkflowStep.
2650
+ */
1408
2651
  addWorkflowStep(workflowId, opts) {
1409
2652
  if (!this.getWorkflow(workflowId))
1410
2653
  throw new Error(`Workflow not found: ${workflowId}`);
1411
2654
  const id = crypto.randomUUID();
1412
2655
  const now = new Date().toISOString();
1413
2656
  const nextIndex = (this.db.prepare('SELECT MAX(step_index) AS m FROM workflow_steps WHERE workflow_id = ?').get(workflowId).m ?? 0) + 1;
1414
- const historyIdsJson = opts.historyIds && opts.historyIds.length ? JSON.stringify(opts.historyIds) : null;
1415
- this.db.prepare(`
1416
- INSERT INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
1417
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1418
- `).run(id, workflowId, nextIndex, opts.summary, opts.pendingTasks || null, historyIdsJson, opts.sessionId || null, now);
2657
+ // Empty arrays store as NULL rather than "[]" so "this step touched nothing" and "this step
2658
+ // predates the column" read the same downstream — neither is a list worth rendering.
2659
+ const nodeIdsJson = opts.nodeIds && opts.nodeIds.length ? JSON.stringify(opts.nodeIds) : null;
2660
+ const docPathsJson = opts.docPaths && opts.docPaths.length ? JSON.stringify(opts.docPaths) : null;
2661
+ const reasoning = opts.reasoning || null;
2662
+ this.db.prepare(`
2663
+ INSERT INTO workflow_steps (id, workflow_id, step_index, summary, reasoning, node_ids, doc_paths, session_id, created_at)
2664
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
2665
+ `).run(id, workflowId, nextIndex, opts.summary, reasoning, nodeIdsJson, docPathsJson, opts.sessionId || null, now);
1419
2666
  this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
1420
2667
  this.writeWorkflowToDisk(workflowId);
1421
- return { id, workflow_id: workflowId, step_index: nextIndex, summary: opts.summary, pending_tasks: opts.pendingTasks || null, history_ids: historyIdsJson, session_id: opts.sessionId || null, created_at: now };
2668
+ return {
2669
+ id, workflow_id: workflowId, step_index: nextIndex, summary: opts.summary,
2670
+ reasoning, node_ids: nodeIdsJson, doc_paths: docPathsJson,
2671
+ session_id: opts.sessionId || null, created_at: now
2672
+ };
1422
2673
  }
1423
2674
  /** Writes `content` to `.devmind/workflows/<workflowId>/<artifactId>_<sourceName>` and records the DB row. */
1424
2675
  addWorkflowArtifact(workflowId, opts) {
@@ -1431,32 +2682,38 @@ class DevMindDatabase {
1431
2682
  fs.mkdirSync(dir, { recursive: true });
1432
2683
  const filePath = path.join(dir, `${id}_${safeName}`);
1433
2684
  fs.writeFileSync(filePath, opts.content, 'utf-8');
1434
- this.db.prepare(`
1435
- INSERT INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
1436
- VALUES (?, ?, ?, ?, ?, ?, ?)
2685
+ this.db.prepare(`
2686
+ INSERT INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
2687
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1437
2688
  `).run(id, workflowId, opts.stepId || null, opts.type, opts.sourceName, filePath, now);
1438
2689
  this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
1439
2690
  this.writeWorkflowToDisk(workflowId);
1440
2691
  return { id, workflow_id: workflowId, step_id: opts.stepId || null, type: opts.type, source_name: opts.sourceName, file_path: filePath, created_at: now };
1441
2692
  }
2693
+ /**
2694
+ * The workflow's story: its steps in order, plus the docs attached to it.
2695
+ *
2696
+ * Paged, because this is now the ONLY read (it absorbed the old `workflow_get_steps`) and steps
2697
+ * carry their own reasoning, so an unbounded version of it would be the largest response the
2698
+ * server can produce. `steps_total` is exact regardless of the page — a short page must never
2699
+ * read as "that is the whole story".
2700
+ *
2701
+ * Artifact CONTENT is deliberately not returned. It used to be inlined whole, which on an
2702
+ * imported architecture doc is trivially tens of KB; the file path is enough, since the file is
2703
+ * on disk and the caller can read exactly the part it needs.
2704
+ */
1442
2705
  getWorkflowContext(id, opts) {
1443
2706
  const workflow = this.getWorkflow(id);
1444
2707
  if (!workflow)
1445
2708
  throw new Error(`Workflow not found: ${id}`);
1446
- const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(id);
1447
- const artifactRows = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(id);
1448
- const artifacts = artifactRows.map(a => {
1449
- if (!opts?.includeArtifactContent)
1450
- return a;
1451
- try {
1452
- const content = fs.existsSync(a.file_path) ? fs.readFileSync(a.file_path, 'utf-8') : undefined;
1453
- return { ...a, content };
1454
- }
1455
- catch {
1456
- return a;
1457
- }
1458
- });
1459
- return { workflow, steps, artifacts };
2709
+ const steps_total = this.db.prepare('SELECT COUNT(*) AS c FROM workflow_steps WHERE workflow_id = ?').get(id).c;
2710
+ const steps = this.getWorkflowSteps(id, opts);
2711
+ // `last_n` walks backwards from the end, so its offset is wherever that tail begins.
2712
+ const steps_offset = opts?.last_n && opts.last_n > 0
2713
+ ? Math.max(0, steps_total - steps.length)
2714
+ : (opts?.offset ?? 0);
2715
+ const artifacts = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(id);
2716
+ return { workflow, steps, steps_total, steps_offset, artifacts };
1460
2717
  }
1461
2718
  /**
1462
2719
  * Returns steps for a workflow with optional pagination.
@@ -1476,99 +2733,13 @@ class DevMindDatabase {
1476
2733
  }
1477
2734
  return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
1478
2735
  }
1479
- /**
1480
- * Reads a single workflow artifact's file content from disk.
1481
- * Accepts either an artifact_id or a source_name (first match used).
1482
- */
1483
- readWorkflowArtifact(workflowId, artifactId) {
1484
- if (!this.getWorkflow(workflowId))
1485
- throw new Error(`Workflow not found: ${workflowId}`);
1486
- const row = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND id = ?').get(workflowId, artifactId);
1487
- if (!row)
1488
- throw new Error(`Artifact not found: ${artifactId} in workflow ${workflowId}`);
1489
- if (!fs.existsSync(row.file_path))
1490
- throw new Error(`Artifact file missing on disk: ${row.file_path}`);
1491
- const content = fs.readFileSync(row.file_path, 'utf-8');
1492
- return { artifact: row, content };
1493
- }
1494
- /**
1495
- * Full-text keyword search across all workflows' step summaries, pending_tasks,
1496
- * and artifact source names. Optionally also searches artifact file content.
1497
- * Returns a list of matches grouped by workflow.
1498
- */
1499
- searchWorkflows(query, opts) {
1500
- // Escaped so a query containing '%' or '_' matches those characters literally instead of
1501
- // acting as SQL LIKE wildcards — otherwise `query: "%"` matches every row in the project.
1502
- const lq = `%${this.likeEscape(query.toLowerCase())}%`;
1503
- // Find matching steps
1504
- const matchedStepRows = this.db.prepare(`
1505
- SELECT ws.* FROM workflow_steps ws
1506
- JOIN workflows w ON w.id = ws.workflow_id
1507
- WHERE (LOWER(ws.summary) LIKE ? ESCAPE '\\' OR LOWER(IFNULL(ws.pending_tasks,'')) LIKE ? ESCAPE '\\')
1508
- ${opts?.status ? 'AND w.status = ?' : ''}
1509
- ORDER BY ws.workflow_id, ws.step_index ASC
1510
- `).all(...(opts?.status ? [lq, lq, opts.status] : [lq, lq]));
1511
- // Find matching artifacts by source_name
1512
- const matchedArtifactRows = this.db.prepare(`
1513
- SELECT wa.* FROM workflow_artifacts wa
1514
- JOIN workflows w ON w.id = wa.workflow_id
1515
- WHERE LOWER(wa.source_name) LIKE ? ESCAPE '\\'
1516
- ${opts?.status ? 'AND w.status = ?' : ''}
1517
- ORDER BY wa.workflow_id, wa.created_at ASC
1518
- `).all(...(opts?.status ? [lq, opts.status] : [lq]));
1519
- // If content search requested, also scan artifact files
1520
- const contentMatchedArtifactIds = new Set();
1521
- const artifactContentSnippets = new Map();
1522
- if (opts?.include_artifact_content) {
1523
- const allArtifacts = this.db.prepare(`SELECT wa.* FROM workflow_artifacts wa JOIN workflows w ON w.id = wa.workflow_id${opts.status ? ' WHERE w.status = ?' : ''}`).all(...(opts.status ? [opts.status] : []));
1524
- const lqPlain = query.toLowerCase();
1525
- for (const a of allArtifacts) {
1526
- if (contentMatchedArtifactIds.has(a.id))
1527
- continue;
1528
- try {
1529
- if (fs.existsSync(a.file_path)) {
1530
- const text = fs.readFileSync(a.file_path, 'utf-8');
1531
- const idx = text.toLowerCase().indexOf(lqPlain);
1532
- if (idx !== -1) {
1533
- contentMatchedArtifactIds.add(a.id);
1534
- const start = Math.max(0, idx - 80);
1535
- const end = Math.min(text.length, idx + query.length + 80);
1536
- artifactContentSnippets.set(a.id, (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''));
1537
- }
1538
- }
1539
- }
1540
- catch { /* skip unreadable */ }
1541
- }
1542
- }
1543
- // Collect all relevant workflow IDs
1544
- const workflowIdSet = new Set([
1545
- ...matchedStepRows.map(s => s.workflow_id),
1546
- ...matchedArtifactRows.map(a => a.workflow_id),
1547
- ...Array.from(contentMatchedArtifactIds).map(id => {
1548
- const r = this.db.prepare('SELECT workflow_id FROM workflow_artifacts WHERE id = ?').get(id);
1549
- return r?.workflow_id || '';
1550
- }).filter(Boolean)
1551
- ]);
1552
- const results = [];
1553
- for (const wid of workflowIdSet) {
1554
- const workflow = this.getWorkflow(wid);
1555
- if (!workflow)
1556
- continue;
1557
- const steps = matchedStepRows.filter(s => s.workflow_id === wid);
1558
- const artByName = matchedArtifactRows.filter(a => a.workflow_id === wid);
1559
- const artByContent = opts?.include_artifact_content
1560
- ? this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ?').all(wid).filter(a => contentMatchedArtifactIds.has(a.id) && !artByName.find(x => x.id === a.id))
1561
- : [];
1562
- const allArtifacts = [
1563
- ...artByName.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) })),
1564
- ...artByContent.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) }))
1565
- ];
1566
- results.push({ workflow, matched_steps: steps, matched_artifacts: allArtifacts });
1567
- }
1568
- // Sort by most recently updated workflow first
1569
- results.sort((a, b) => b.workflow.updated_at.localeCompare(a.workflow.updated_at));
1570
- return results;
1571
- }
2736
+ // NOTE: `readWorkflowArtifact` and `searchWorkflows` were removed here.
2737
+ // Artifacts are referenced by PATH now (workflow_add_step's doc_paths, plus the file paths
2738
+ // getWorkflowContext already returns), so nothing needs the DB to read a file back for it —
2739
+ // and inlining whole imported docs was the single largest thing a workflow response could emit.
2740
+ // searchWorkflows was replaced by listWorkflows({ query }): it scanned step summaries and
2741
+ // artifact names but NOT workflow name/description, so looking a workflow up by its own name
2742
+ // returned nothing the one search anybody actually tries.
1572
2743
  /**
1573
2744
  * Imports an existing flow/architecture doc as a paused workflow (not active — importing
1574
2745
  * a doc isn't the same as declaring active work). Idempotent on `name`: re-importing the
@@ -1591,14 +2762,14 @@ class DevMindDatabase {
1591
2762
  return { workflow: { ...existing, description, updated_at: now }, created: false };
1592
2763
  }
1593
2764
  const id = `wf_${crypto.randomUUID()}`;
1594
- this.db.prepare(`
1595
- INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1596
- VALUES (?, ?, ?, 'paused', ?, ?)
2765
+ this.db.prepare(`
2766
+ INSERT INTO workflows (id, name, description, archived, created_at, updated_at)
2767
+ VALUES (?, ?, ?, 0, ?, ?)
1597
2768
  `).run(id, name, description, now, now);
1598
2769
  this.addWorkflowStep(id, { summary: `Imported existing flow documentation: ${sourceFileName}` });
1599
2770
  this.addWorkflowArtifact(id, { type: 'imported_doc', sourceName: sourceFileName, content });
1600
2771
  // writeWorkflowToDisk is already called inside addWorkflowArtifact/addWorkflowStep above
1601
- return { workflow: { id, name, description, status: 'paused', created_at: now, updated_at: now }, created: true };
2772
+ return { workflow: { id, name, description, archived: 0, created_at: now, updated_at: now }, created: true };
1602
2773
  }
1603
2774
  static SPURIOUS_NODE_NAMES = new Set([
1604
2775
  'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
@@ -1610,9 +2781,9 @@ class DevMindDatabase {
1610
2781
  * analyze`'s dry-run report (which just lists it). Never mutates the DB.
1611
2782
  */
1612
2783
  findSpuriousAndMissingFileNodes(workspaceRoot) {
1613
- const stmt = this.db.prepare(`
1614
- SELECT id, name, file_path FROM nodes
1615
- WHERE deprecated = 0
2784
+ const stmt = this.db.prepare(`
2785
+ SELECT id, name, file_path FROM nodes
2786
+ WHERE deprecated = 0
1616
2787
  `);
1617
2788
  const candidates = stmt.all();
1618
2789
  const spurious = [];
@@ -1695,7 +2866,10 @@ class DevMindDatabase {
1695
2866
  return {
1696
2867
  ...row,
1697
2868
  code_snapshot: data.code_snapshot || '',
1698
- reasoning: typeof data.reasoning === 'string' ? data.reasoning : formatReasoning(data.reasoning || '')
2869
+ reasoning: typeof data.reasoning === 'string' ? data.reasoning : formatReasoning(data.reasoning || ''),
2870
+ // Absent in every entry written before the edit trail existed — an empty trail is the
2871
+ // honest answer there: nothing to diff, nothing to revert.
2872
+ edits: Array.isArray(data.edits) ? data.edits : []
1699
2873
  };
1700
2874
  }
1701
2875
  }
@@ -1705,10 +2879,11 @@ class DevMindDatabase {
1705
2879
  return {
1706
2880
  ...row,
1707
2881
  code_snapshot: '',
1708
- reasoning: ''
2882
+ reasoning: '',
2883
+ edits: []
1709
2884
  };
1710
2885
  }
1711
- writeHistoryToDisk(id, nodeId, sessionId, createdAt, updatedAt, codeSnapshot, reasoning) {
2886
+ writeHistoryToDisk(id, nodeId, sessionId, createdAt, updatedAt, codeSnapshot, reasoning, edits = []) {
1712
2887
  try {
1713
2888
  const historyDir = path.join(path.dirname(this.dbPath), 'history');
1714
2889
  if (!fs.existsSync(historyDir)) {
@@ -1729,7 +2904,8 @@ class DevMindDatabase {
1729
2904
  created_at: createdAt,
1730
2905
  updated_at: updatedAt,
1731
2906
  code_snapshot: codeSnapshot,
1732
- reasoning
2907
+ reasoning,
2908
+ edits
1733
2909
  };
1734
2910
  const filePath = path.join(historyDir, `${id}.json`);
1735
2911
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
@@ -1738,6 +2914,10 @@ class DevMindDatabase {
1738
2914
  console.warn('⚠️ SQLite warning: Failed to write history JSON to disk:', err);
1739
2915
  }
1740
2916
  }
2917
+ /** The configured developer identity (`.devmind/.env`'s DEVELOPER_NAME), or null if unset. */
2918
+ getDeveloperName() {
2919
+ return this.context?.developer?.name || null;
2920
+ }
1741
2921
  toRepoRelativePath(absolutePath) {
1742
2922
  if (!absolutePath || !this.context)
1743
2923
  return absolutePath;
@@ -1777,14 +2957,14 @@ class DevMindDatabase {
1777
2957
  }
1778
2958
  /**
1779
2959
  * True if `absPath` sits inside a configured repo root or the workspace root itself.
1780
- * Used to reject `stage_change`/`update_history` file paths that would otherwise let a
2960
+ * Used to reject `edit_node`/`update_history` file paths that would otherwise let a
1781
2961
  * tool call read/write any file on disk (absolute path, or a `../` escape) instead of
1782
2962
  * just repo source — nothing upstream of this validates that the AI-supplied path is
1783
2963
  * actually inside the project.
1784
2964
  */
1785
2965
  /**
1786
- * Gate for every AI-facing write (edit_node, stage_change, the legacy update_history):
1787
- * true only for paths inside a configured repo. `.devmind` itself — this project's OWN
2966
+ * Gate for every AI-facing write (edit_node, the legacy update_history): true only for
2967
+ * paths inside a configured repo. `.devmind` itself — this project's OWN
1788
2968
  * config, brain.db, and cached graph JSON — is never writable through these tools, even
1789
2969
  * though it sits next to (and, before this check, was indistinguishable from) real source:
1790
2970
  * without this, a write tool built to "never refuse a file type" would just as happily
@@ -1841,8 +3021,16 @@ class DevMindDatabase {
1841
3021
  // Fallback: resolve relative to workspace root
1842
3022
  return (0, config_1.canonicalizePath)(this.clampToRoot(workspaceRoot, path.resolve(workspaceRoot, repoRelativePath)));
1843
3023
  }
1844
- syncFromDisk() {
3024
+ syncFromDisk(onProgress) {
1845
3025
  this.db.pragma('foreign_keys = OFF');
3026
+ // Captured BEFORE the walk starts (not after it finishes) so a file touched WHILE this sync
3027
+ // is running is still mtime >= this timestamp and gets correctly picked up on the NEXT sync,
3028
+ // rather than silently missed by a checkpoint that raced ahead of it.
3029
+ const syncStartedAtMs = Date.now();
3030
+ // 0 (never synced before, or system_meta was wiped by resetAll) disables every mtime skip
3031
+ // below unconditionally — every real file's mtimeMs is a large positive epoch value, so
3032
+ // `mtimeMs < 0` is never true and the first pass always processes everything, as before.
3033
+ const lastSyncedAtMs = Number(this.getSystemMeta('last_sync_checkpoint_ms')) || 0;
1846
3034
  try {
1847
3035
  const workspaceRoot = path.dirname(this.dbPath);
1848
3036
  // 0. Auto-heal any legacy relative path records in SQLite.
@@ -1879,21 +3067,37 @@ class DevMindDatabase {
1879
3067
  if (files.length > 0) {
1880
3068
  const checkHistoryStmt = this.db.prepare('SELECT id FROM history WHERE id = ?');
1881
3069
  const checkNodeStmt = this.db.prepare('SELECT id FROM nodes WHERE id = ?');
1882
- const insertNodeStmt = this.db.prepare(`
1883
- INSERT INTO nodes (id, type, name, file_path, signature, deprecated)
1884
- VALUES (?, ?, ?, ?, ?, 0)
3070
+ const insertNodeStmt = this.db.prepare(`
3071
+ INSERT INTO nodes (id, type, name, file_path, signature, deprecated)
3072
+ VALUES (?, ?, ?, ?, ?, 0)
1885
3073
  `);
1886
- const insertHistoryStmt = this.db.prepare(`
1887
- INSERT INTO history (id, node_id, session_id, created_at, updated_at, code_snapshot, reasoning)
1888
- VALUES (?, ?, ?, ?, ?, '', ?)
3074
+ const insertHistoryStmt = this.db.prepare(`
3075
+ INSERT INTO history (id, node_id, session_id, created_at, updated_at, code_snapshot, reasoning)
3076
+ VALUES (?, ?, ?, ?, ?, '', ?)
1889
3077
  `);
1890
3078
  const syncHistoryTx = this.db.transaction(() => {
3079
+ let done = 0;
1891
3080
  for (const file of files) {
3081
+ done++;
3082
+ if (onProgress && DevMindDatabase.shouldReport(done, files.length))
3083
+ onProgress('history', done, files.length);
1892
3084
  try {
3085
+ // History files are immutable and always named `${id}.json` (every writer uses
3086
+ // this convention — see updateHistory/eraseLastEdit/etc.), so an already-synced
3087
+ // file can be identified from its NAME alone, before ever reading it. This is the
3088
+ // single biggest cost in syncFromDisk: history/ grows one file per edit forever,
3089
+ // so on a mature repo the overwhelming majority of files here are already synced
3090
+ // and this check-before-read turns them from a readFileSync+JSON.parse into a
3091
+ // single indexed SQLite lookup.
3092
+ if (checkHistoryStmt.get(file.slice(0, -'.json'.length)))
3093
+ continue;
1893
3094
  const filePath = path.join(historyDir, file);
1894
3095
  const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1895
3096
  if (!data.id || !data.node_id)
1896
3097
  continue;
3098
+ // Kept as a safety net in case a hand-written/legacy file's internal `id` ever
3099
+ // differs from its filename — the fast path above is an optimization, not a
3100
+ // replacement for this correctness check.
1897
3101
  if (checkHistoryStmt.get(data.id))
1898
3102
  continue;
1899
3103
  if (!checkNodeStmt.get(data.node_id) && data.node_metadata) {
@@ -1933,18 +3137,31 @@ class DevMindDatabase {
1933
3137
  if (jsonFiles.length > 0) {
1934
3138
  const deleteNodesForFileStmt = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
1935
3139
  const deleteConnsForNodesStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ?');
1936
- const insertNodeStmt = this.db.prepare(`
1937
- INSERT OR REPLACE INTO nodes (id, type, name, file_path, signature, deprecated)
1938
- VALUES (?, ?, ?, ?, ?, ?)
3140
+ const insertNodeStmt = this.db.prepare(`
3141
+ INSERT OR REPLACE INTO nodes (id, type, name, file_path, signature, description, aliases, deprecated)
3142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1939
3143
  `);
1940
- const insertConnStmt = this.db.prepare(`
1941
- INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id)
1942
- VALUES (?, ?)
3144
+ const insertConnStmt = this.db.prepare(`
3145
+ INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id)
3146
+ VALUES (?, ?)
1943
3147
  `);
1944
3148
  // Transaction for fast batch syncing
1945
3149
  const syncGraphTx = this.db.transaction(() => {
3150
+ let done = 0;
1946
3151
  for (const file of jsonFiles) {
3152
+ done++;
3153
+ if (onProgress && DevMindDatabase.shouldReport(done, jsonFiles.length))
3154
+ onProgress('graph', done, jsonFiles.length);
1947
3155
  try {
3156
+ // Unlike history/, a graph JSON gets REWRITTEN in place on every edit to its
3157
+ // source file (same path, new content) — so identity alone can't tell us "already
3158
+ // synced." mtime can: if this file hasn't changed since the last successful sync,
3159
+ // the DB is already current for it (a `git pull`/checkout always bumps the mtime
3160
+ // of every file it actually changed, so this correctly still processes exactly
3161
+ // what came in on a pull). Skips the readFileSync+JSON.parse+delete+reinsert
3162
+ // entirely for the — typically overwhelming — majority of untouched files.
3163
+ if (fs.statSync(file).mtimeMs < lastSyncedAtMs)
3164
+ continue;
1948
3165
  const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
1949
3166
  if (!data.file_path)
1950
3167
  continue;
@@ -1960,7 +3177,8 @@ class DevMindDatabase {
1960
3177
  const nodes = data.nodes || [];
1961
3178
  for (const n of nodes) {
1962
3179
  deleteConnsForNodesStmt.run(n.id);
1963
- insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null, n.deprecated ? 1 : 0);
3180
+ const aliasesJson = Array.isArray(n.aliases) ? JSON.stringify(n.aliases) : '[]';
3181
+ insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null, n.description || null, aliasesJson, n.deprecated ? 1 : 0);
1964
3182
  }
1965
3183
  // Insert connections
1966
3184
  const connections = data.connections || [];
@@ -1976,29 +3194,99 @@ class DevMindDatabase {
1976
3194
  syncGraphTx();
1977
3195
  }
1978
3196
  }
3197
+ // 2.5. Sync Vector JSONs — MUST run after the graph pass (2), since that pass just
3198
+ // deleted and re-inserted the current node set; vectors are reconciled against nodes as
3199
+ // they now stand, not as they stood before this sync. Model-mismatched vectors (a
3200
+ // teammate on a different devsmind version) are ignored on import, never partially
3201
+ // trusted — those nodes simply fall back into the local `devsmind embed` queue. Then an
3202
+ // explicit orphan sweep, since node_vectors has no FK (this whole method runs with
3203
+ // foreign_keys=OFF, so a cascade would silently never fire during the graph pass anyway).
3204
+ const vectorsDir = path.join(workspaceRoot, 'vectors');
3205
+ if (fs.existsSync(vectorsDir)) {
3206
+ const walkSyncVec = (dir, fileList = []) => {
3207
+ const files = fs.readdirSync(dir);
3208
+ for (const file of files) {
3209
+ const filePath = path.join(dir, file);
3210
+ if (fs.statSync(filePath).isDirectory()) {
3211
+ walkSyncVec(filePath, fileList);
3212
+ }
3213
+ else if (file.endsWith('.json')) {
3214
+ fileList.push(filePath);
3215
+ }
3216
+ }
3217
+ return fileList;
3218
+ };
3219
+ const vectorJsonFiles = walkSyncVec(vectorsDir);
3220
+ if (vectorJsonFiles.length > 0) {
3221
+ const insertVectorStmt = this.db.prepare(`
3222
+ INSERT OR REPLACE INTO node_vectors (node_id, model_id, dim, description_hash, vector)
3223
+ VALUES (?, ?, ?, ?, ?)
3224
+ `);
3225
+ const syncVectorsTx = this.db.transaction(() => {
3226
+ let done = 0;
3227
+ for (const file of vectorJsonFiles) {
3228
+ done++;
3229
+ if (onProgress && DevMindDatabase.shouldReport(done, vectorJsonFiles.length))
3230
+ onProgress('vectors', done, vectorJsonFiles.length);
3231
+ try {
3232
+ // Same reasoning as the graph pass above: a vectors/*.json is rewritten in place
3233
+ // whenever its node's vector changes, so mtime — not identity — is what tells us
3234
+ // whether the DB is still current for this file.
3235
+ if (fs.statSync(file).mtimeMs < lastSyncedAtMs)
3236
+ continue;
3237
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
3238
+ if (!data.model_id || data.model_id !== embedder_1.EMBEDDING_MODEL_ID)
3239
+ continue;
3240
+ const vectors = data.vectors || {};
3241
+ for (const nodeId of Object.keys(vectors)) {
3242
+ const entry = vectors[nodeId];
3243
+ if (!entry || !entry.v || !entry.h)
3244
+ continue;
3245
+ const buf = Buffer.from(entry.v, 'base64');
3246
+ insertVectorStmt.run(nodeId, embedder_1.EMBEDDING_MODEL_ID, data.dim || embedder_1.EMBEDDING_DIM, entry.h, buf);
3247
+ }
3248
+ }
3249
+ catch (err) {
3250
+ // ignore malformed vectors JSON
3251
+ }
3252
+ }
3253
+ });
3254
+ syncVectorsTx();
3255
+ }
3256
+ }
3257
+ // Orphan sweep — always runs, even with no vectors/ dir, to catch nodes deleted or
3258
+ // renamed by the graph pass above that still had a (now-dangling) vector row.
3259
+ this.db.exec('DELETE FROM node_vectors WHERE node_id NOT IN (SELECT id FROM nodes)');
1979
3260
  // 3. Sync Workflow JSONs
1980
3261
  const workflowsDir = this.workflowsDir();
1981
3262
  if (fs.existsSync(workflowsDir)) {
1982
- const upsertWorkflow = this.db.prepare(`
1983
- INSERT INTO workflows (id, name, description, status, created_at, updated_at)
1984
- VALUES (?, ?, ?, ?, ?, ?)
1985
- ON CONFLICT(id) DO UPDATE SET
1986
- name = excluded.name,
1987
- description = excluded.description,
1988
- status = excluded.status,
1989
- updated_at = excluded.updated_at
3263
+ const upsertWorkflow = this.db.prepare(`
3264
+ INSERT INTO workflows (id, name, description, archived, created_at, updated_at)
3265
+ VALUES (?, ?, ?, ?, ?, ?)
3266
+ ON CONFLICT(id) DO UPDATE SET
3267
+ name = excluded.name,
3268
+ description = excluded.description,
3269
+ archived = excluded.archived,
3270
+ updated_at = excluded.updated_at
1990
3271
  `);
1991
- const upsertStep = this.db.prepare(`
1992
- INSERT OR IGNORE INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
1993
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3272
+ // DO UPDATE, not INSERT OR IGNORE. A teammate who already has a step row would otherwise
3273
+ // never pick up `reasoning`/`node_ids`/`doc_paths` from a newer workflow.json the row
3274
+ // exists, so the insert is ignored, and their brain stays permanently half-migrated with
3275
+ // no sign anything went wrong. Safe to overwrite because steps are append-only: nothing
3276
+ // edits one locally after it is written, so incoming disk state is always authoritative.
3277
+ const upsertStep = this.db.prepare(`
3278
+ INSERT INTO workflow_steps (id, workflow_id, step_index, summary, reasoning, node_ids, doc_paths, session_id, created_at)
3279
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3280
+ ON CONFLICT(id) DO UPDATE SET
3281
+ summary = excluded.summary,
3282
+ reasoning = COALESCE(excluded.reasoning, workflow_steps.reasoning),
3283
+ node_ids = COALESCE(excluded.node_ids, workflow_steps.node_ids),
3284
+ doc_paths = COALESCE(excluded.doc_paths, workflow_steps.doc_paths)
1994
3285
  `);
1995
- const upsertArtifact = this.db.prepare(`
1996
- INSERT OR IGNORE INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
1997
- VALUES (?, ?, ?, ?, ?, ?, ?)
3286
+ const upsertArtifact = this.db.prepare(`
3287
+ INSERT OR IGNORE INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
3288
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1998
3289
  `);
1999
- // Track which workflow.json has is_active:true with the latest updated_at
2000
- let bestActiveId = null;
2001
- let bestActiveUpdatedAt = '';
2002
3290
  const syncWorkflowsTx = this.db.transaction(() => {
2003
3291
  const subdirs = fs.readdirSync(workflowsDir);
2004
3292
  for (const subdir of subdirs) {
@@ -2009,32 +3297,48 @@ class DevMindDatabase {
2009
3297
  const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
2010
3298
  if (!data.id || !data.name)
2011
3299
  continue;
2012
- upsertWorkflow.run(data.id, data.name, data.description || '', data.status || 'paused', data.created_at || new Date().toISOString(), data.updated_at || new Date().toISOString());
3300
+ // The sidecar is authoritative for the fields v1 has no place for. It matters most
3301
+ // in exactly the case that looks fine: a teammate on an older build rewrote
3302
+ // workflow.json from their own columns, so it came back v1-shaped — but they could
3303
+ // not touch v2.json, so `archived` and every step's reasoning/node_ids/doc_paths are
3304
+ // still here to merge back on top.
3305
+ let sidecar = {};
3306
+ try {
3307
+ const sidecarPath = path.join(workflowsDir, subdir, exports.WORKFLOW_SIDECAR_FILE);
3308
+ if (fs.existsSync(sidecarPath))
3309
+ sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf-8')) || {};
3310
+ }
3311
+ catch { /* a corrupt sidecar degrades to the v1 shape rather than losing the workflow */ }
3312
+ upsertWorkflow.run(data.id, data.name, data.description || '', (sidecar.archived ?? data.archived) ? 1 : 0, data.created_at || new Date().toISOString(), data.updated_at || new Date().toISOString());
2013
3313
  for (const s of (data.steps || [])) {
2014
3314
  if (!s.id)
2015
3315
  continue;
2016
- upsertStep.run(s.id, data.id, s.step_index, s.summary || '', s.pending_tasks || null, s.history_ids || null, s.session_id || null, s.created_at || new Date().toISOString());
3316
+ // A genuinely v1 workflow (never written by this version) has no sidecar entry
3317
+ // either — those fields land as NULL and the step shows its summary alone, which
3318
+ // is all v1 ever stored. Nothing is lost that the old format ever held.
3319
+ const extra = sidecar.steps?.[s.id] || {};
3320
+ upsertStep.run(s.id, data.id, s.step_index, s.summary || '', extra.reasoning ?? s.reasoning ?? null, extra.node_ids ?? s.node_ids ?? null, extra.doc_paths ?? s.doc_paths ?? null, s.session_id || null, s.created_at || new Date().toISOString());
2017
3321
  }
2018
3322
  for (const a of (data.artifact_index || [])) {
2019
3323
  if (!a.id)
2020
3324
  continue;
2021
3325
  upsertArtifact.run(a.id, data.id, a.step_id || null, a.type || 'unknown', a.source_name || '', a.file_path || '', a.created_at || new Date().toISOString());
2022
3326
  }
2023
- // Track which workflow declared itself active most recently
2024
- if (data.is_active && data.updated_at > bestActiveUpdatedAt) {
2025
- bestActiveId = data.id;
2026
- bestActiveUpdatedAt = data.updated_at;
2027
- }
3327
+ // A v1 JSON's `is_active` is deliberately ignored. That flag is exactly how one
3328
+ // developer's "currently working on" state used to travel through git and take over
3329
+ // everyone else's — which workflow you are on is now local to your session and never
3330
+ // synced.
2028
3331
  }
2029
3332
  catch { /* skip malformed */ }
2030
3333
  }
2031
3334
  });
2032
3335
  syncWorkflowsTx();
2033
- // Restore active_workflow_id if not already set and a JSON claims active status
2034
- if (bestActiveId && !this.getSystemMeta('active_workflow_id')) {
2035
- this.setSystemMeta('active_workflow_id', bestActiveId);
2036
- }
2037
3336
  }
3337
+ // Only advance the checkpoint after every step above completed without throwing — an
3338
+ // exception anywhere earlier jumps straight to `catch` below, so this line is never
3339
+ // reached, and the NEXT sync correctly retries a full pass from the old (or absent)
3340
+ // checkpoint instead of wrongly believing a failed run succeeded.
3341
+ this.setSystemMeta('last_sync_checkpoint_ms', String(syncStartedAtMs));
2038
3342
  }
2039
3343
  catch (err) {
2040
3344
  console.warn('⚠️ SQLite warning: Failed to sync from disk:', err);
@@ -2069,16 +3373,16 @@ class DevMindDatabase {
2069
3373
  const absEsc = this.likeEscape(absPath);
2070
3374
  const absLower = absPath.toLowerCase();
2071
3375
  const absEscLower = absEsc.toLowerCase();
2072
- const stmtNodes = this.db.prepare(`
2073
- SELECT * FROM nodes
2074
- WHERE (
2075
- LOWER(file_path) = ? OR
2076
- LOWER(file_path) LIKE ? ESCAPE '\\' OR
2077
- LOWER(file_path) LIKE ? ESCAPE '\\' OR
2078
- LOWER(file_path) LIKE ? ESCAPE '\\'
2079
- )
3376
+ const stmtNodes = this.db.prepare(`
3377
+ SELECT * FROM nodes
3378
+ WHERE (
3379
+ LOWER(file_path) = ? OR
3380
+ LOWER(file_path) LIKE ? ESCAPE '\\' OR
3381
+ LOWER(file_path) LIKE ? ESCAPE '\\' OR
3382
+ LOWER(file_path) LIKE ? ESCAPE '\\'
3383
+ )
2080
3384
  `);
2081
- const nodes = stmtNodes.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`);
3385
+ const nodes = DevMindDatabase.parseNodeRows(stmtNodes.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`));
2082
3386
  if (nodes.length === 0) {
2083
3387
  // If no nodes left, delete the JSON file if it exists
2084
3388
  if (fs.existsSync(graphJsonPath)) {
@@ -2090,9 +3394,9 @@ class DevMindDatabase {
2090
3394
  const nodeIds = nodes.map(n => n.id);
2091
3395
  const connections = [];
2092
3396
  if (nodeIds.length > 0) {
2093
- const stmtConn = this.db.prepare(`
2094
- SELECT * FROM node_connections
2095
- WHERE source_node_id = ?
3397
+ const stmtConn = this.db.prepare(`
3398
+ SELECT * FROM node_connections
3399
+ WHERE source_node_id = ?
2096
3400
  `);
2097
3401
  for (const id of nodeIds) {
2098
3402
  const conns = stmtConn.all(id);
@@ -2107,6 +3411,8 @@ class DevMindDatabase {
2107
3411
  name: n.name,
2108
3412
  type: n.type,
2109
3413
  signature: n.signature,
3414
+ description: n.description || undefined,
3415
+ aliases: n.aliases.length > 0 ? n.aliases : undefined,
2110
3416
  deprecated: n.deprecated ? 1 : 0
2111
3417
  })),
2112
3418
  connections: connections.map(c => ({
@@ -2121,6 +3427,53 @@ class DevMindDatabase {
2121
3427
  console.warn('⚠️ SQLite warning: Failed to write graph JSON to disk:', err);
2122
3428
  }
2123
3429
  }
3430
+ /**
3431
+ * Mirrors `writeGraphToDisk` exactly (same file-matching logic, same directory shape) but into
3432
+ * a separate `vectors/` tree rather than inside `graph/*.json` — deliberately, so opaque base64
3433
+ * blobs never pollute the human-readable, merge-friendly graph JSON. Deprecated nodes are
3434
+ * skipped here (unlike the graph, which keeps them): `searchNodes` never queries a deprecated
3435
+ * node's vector, so writing one is pure dead weight.
3436
+ */
3437
+ writeVectorsToDisk(filePath) {
3438
+ try {
3439
+ if (!filePath)
3440
+ return;
3441
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
3442
+ const absPath = (0, config_1.canonicalizePath)(filePath);
3443
+ const repoRelPath = this.toRepoRelativePath(absPath);
3444
+ const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
3445
+ const vectorsJsonPath = path.join(workspaceRoot, 'vectors', diskRelPath);
3446
+ const absLower = absPath.toLowerCase();
3447
+ const absEscLower = this.likeEscape(absPath).toLowerCase();
3448
+ const stmt = this.db.prepare(`
3449
+ SELECT nv.node_id AS node_id, nv.description_hash AS description_hash, nv.vector AS vector
3450
+ FROM node_vectors nv
3451
+ JOIN nodes n ON n.id = nv.node_id
3452
+ WHERE n.deprecated = 0 AND (
3453
+ LOWER(n.file_path) = ? OR
3454
+ LOWER(n.file_path) LIKE ? ESCAPE '\\' OR
3455
+ LOWER(n.file_path) LIKE ? ESCAPE '\\' OR
3456
+ LOWER(n.file_path) LIKE ? ESCAPE '\\'
3457
+ ) AND nv.model_id = ?
3458
+ `);
3459
+ const rows = stmt.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`, embedder_1.EMBEDDING_MODEL_ID);
3460
+ if (rows.length === 0) {
3461
+ if (fs.existsSync(vectorsJsonPath))
3462
+ fs.unlinkSync(vectorsJsonPath);
3463
+ return;
3464
+ }
3465
+ const vectors = {};
3466
+ for (const r of rows) {
3467
+ vectors[r.node_id] = { h: r.description_hash, v: Buffer.from(r.vector).toString('base64') };
3468
+ }
3469
+ const data = { file_path: repoRelPath, model_id: embedder_1.EMBEDDING_MODEL_ID, dim: embedder_1.EMBEDDING_DIM, vectors };
3470
+ fs.mkdirSync(path.dirname(vectorsJsonPath), { recursive: true });
3471
+ fs.writeFileSync(vectorsJsonPath, JSON.stringify(data, null, 2), 'utf-8');
3472
+ }
3473
+ catch (err) {
3474
+ console.warn('⚠️ SQLite warning: Failed to write vectors JSON to disk:', err);
3475
+ }
3476
+ }
2124
3477
  /** Force-syncs all database nodes and workflows to disk JSON files. */
2125
3478
  syncToDisk() {
2126
3479
  try {