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