devsmind-mcp 2.3.0 → 2.4.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.
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.DevMindDatabase = void 0;
40
40
  exports.formatReasoning = formatReasoning;
41
+ exports.parseReasoningBlocks = parseReasoningBlocks;
41
42
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
42
43
  const crypto = __importStar(require("crypto"));
43
44
  const fs = __importStar(require("fs"));
@@ -76,6 +77,47 @@ function formatReasoning(r) {
76
77
  ];
77
78
  return lines.join('\n');
78
79
  }
80
+ /**
81
+ * Inverse of `formatReasoning`. A single history row accumulates every later update appended
82
+ * under a `── Update @ … ──` separator, so one stored blob can hold several changes — this
83
+ * splits them back apart and returns them NEWEST FIRST.
84
+ *
85
+ * Reasoning written before the structured format (or by a caller passing a bare string) has no
86
+ * labels to read; rather than drop it, the whole chunk is surfaced as `what_changed`.
87
+ */
88
+ function parseReasoningBlocks(raw) {
89
+ if (!raw || typeof raw !== 'string')
90
+ return [];
91
+ const chunks = raw
92
+ .split(/\n*── Update @ [^\n]*──\n/g)
93
+ .map(c => c.trim())
94
+ .filter(Boolean);
95
+ const parsed = chunks.map(chunk => {
96
+ const field = (label) => {
97
+ const m = chunk.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'));
98
+ const v = m?.[1]?.trim();
99
+ return v ? v : undefined;
100
+ };
101
+ const what = field('What changed');
102
+ const why = field('Why');
103
+ const goal = field('Goal');
104
+ // No recognised labels → free-text reasoning; keep it rather than return an empty shell.
105
+ if (!what && !why && !goal) {
106
+ return { what_changed: chunk, why: '', goal: '' };
107
+ }
108
+ return {
109
+ what_changed: what || '',
110
+ why: why || '',
111
+ goal: goal || '',
112
+ requirement: field('Requirement'),
113
+ previous_state: field('Previous state'),
114
+ decision: field('Decision'),
115
+ developer: field('Developer'),
116
+ model: field('Model')
117
+ };
118
+ });
119
+ return parsed.reverse();
120
+ }
79
121
  class DevMindDatabase {
80
122
  db;
81
123
  dbPath;
@@ -135,9 +177,16 @@ class DevMindDatabase {
135
177
  `);
136
178
  stmt.run(key, value, value);
137
179
  }
180
+ /**
181
+ * Nodes declared in one file. Both sides are folded to a canonical form before comparing:
182
+ * a stored `c:\x\y.ts` and a caller's `C:/x/y.ts` are the same file on Windows, and a raw
183
+ * `=` match silently returns nothing — which reads as "this file has no nodes" rather than
184
+ * as an error. There is no index on file_path, so this was already a full scan; normalizing
185
+ * in SQL costs nothing extra.
186
+ */
138
187
  getNodesByFilePath(filePath) {
139
- const stmt = this.db.prepare('SELECT * FROM nodes WHERE file_path = ? AND deprecated = 0');
140
- return stmt.all(filePath);
188
+ const stmt = this.db.prepare(`SELECT * FROM nodes WHERE deprecated = 0 AND REPLACE(LOWER(file_path), '\\', '/') = ?`);
189
+ return stmt.all((0, ast_1.normalizeFsPath)(filePath));
141
190
  }
142
191
  close() {
143
192
  this.db.close();
@@ -327,7 +376,14 @@ class DevMindDatabase {
327
376
  if (!node) {
328
377
  throw new Error(`Node not found: ${oldId}`);
329
378
  }
330
- const name = newName || (node.name === oldId ? newId : node.name);
379
+ // getNode() resolves a bare/unqualified id (e.g. "createCart") to the node's fully-qualified
380
+ // one via a suffix match — but node_connections/history are keyed by the FULLY-QUALIFIED id
381
+ // only. Every statement below must use node.id, not the raw oldId parameter: using oldId
382
+ // directly makes each UPDATE a silent no-op whenever the caller passed a bare id (matching
383
+ // no rows, throwing no error), leaving the new id's row empty/disconnected while the old
384
+ // node's history and edges stay put under the id that was supposedly just renamed away.
385
+ const resolvedOldId = node.id;
386
+ const name = newName || (node.name === resolvedOldId ? newId : node.name);
331
387
  const filePath = newFilePath || node.file_path;
332
388
  this.db.pragma('foreign_keys = OFF');
333
389
  try {
@@ -340,17 +396,17 @@ class DevMindDatabase {
340
396
  const updateSourceStmt = this.db.prepare(`
341
397
  UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
342
398
  `);
343
- updateSourceStmt.run(newId, oldId);
399
+ updateSourceStmt.run(newId, resolvedOldId);
344
400
  const updateTargetStmt = this.db.prepare(`
345
401
  UPDATE node_connections SET target_node_id = ? WHERE target_node_id = ?
346
402
  `);
347
- updateTargetStmt.run(newId, oldId);
403
+ updateTargetStmt.run(newId, resolvedOldId);
348
404
  const updateHistoryStmt = this.db.prepare(`
349
405
  UPDATE history SET node_id = ? WHERE node_id = ?
350
406
  `);
351
- updateHistoryStmt.run(newId, oldId);
407
+ updateHistoryStmt.run(newId, resolvedOldId);
352
408
  const deleteOldStmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
353
- deleteOldStmt.run(oldId);
409
+ deleteOldStmt.run(resolvedOldId);
354
410
  });
355
411
  runTx();
356
412
  if (node.file_path) {
@@ -837,10 +893,14 @@ class DevMindDatabase {
837
893
  const stmt = this.db.prepare(`
838
894
  SELECT DISTINCT n.* FROM nodes n
839
895
  LEFT JOIN history h ON n.id = h.node_id
840
- WHERE n.name LIKE ? OR n.id LIKE ? OR h.reasoning LIKE ?
896
+ WHERE n.name LIKE ? ESCAPE '\\' OR n.id LIKE ? ESCAPE '\\' OR h.reasoning LIKE ? ESCAPE '\\'
841
897
  LIMIT 50
842
898
  `);
843
- const wildcard = `%${query}%`;
899
+ // Escaped so a query containing '%'/'_' (a real identifier fragment like "CartService_addItem"
900
+ // matches the literal underscore, not "any single character") searches for those characters
901
+ // rather than acting as SQL LIKE wildcards — sibling methods (getDeveloperActivity,
902
+ // searchDecisions) already do this; this one didn't.
903
+ const wildcard = `%${this.likeEscape(query)}%`;
844
904
  const identifierMatches = stmt.all(wildcard, wildcard, wildcard);
845
905
  if (identifierMatches.length > 0) {
846
906
  return identifierMatches.map(n => ({ ...n, matched_via: 'identifier' }));
@@ -850,7 +910,97 @@ class DevMindDatabase {
850
910
  is_regex: opts.is_regex,
851
911
  case_insensitive: opts.case_insensitive
852
912
  });
853
- return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
913
+ if (codeMatches.length > 0) {
914
+ return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
915
+ }
916
+ const tokens = this.tokenizeQuery(query);
917
+ if (tokens.length === 0) {
918
+ return [];
919
+ }
920
+ return this.fuzzySearchNodes(tokens);
921
+ }
922
+ /**
923
+ * Splits a query string into lowercase word tokens for the fuzzy fallback
924
+ * stage of {@link searchNodes}. This is request-scoped tokenization only —
925
+ * nothing is persisted or indexed; the result is discarded after the call.
926
+ */
927
+ tokenizeQuery(query) {
928
+ const seen = new Set();
929
+ for (const raw of query.toLowerCase().split(/[^a-z0-9]+/i)) {
930
+ if (raw.length >= 2)
931
+ seen.add(raw);
932
+ }
933
+ return Array.from(seen);
934
+ }
935
+ /**
936
+ * Word-split relevance-ranked fallback for {@link searchNodes}. Runs only
937
+ * when the exact identifier and code stages both return nothing. Scores
938
+ * every non-deprecated node by how many distinct query tokens appear as a
939
+ * substring of its file_path/name/id (highest signal), latest reasoning,
940
+ * or code content (lowest signal, one point per matching line). No new
941
+ * data is written or synced — this is a plain in-memory scan reusing the
942
+ * same node/history sources searchCode already reads.
943
+ */
944
+ fuzzySearchNodes(tokens) {
945
+ const historyDir = path.join(path.dirname(this.dbPath), 'history');
946
+ const stmt = this.db.prepare(`
947
+ SELECT n.*, h.id AS latest_history_id, h.reasoning AS reasoning
948
+ FROM nodes n
949
+ LEFT JOIN history h ON h.id = (
950
+ SELECT id FROM history WHERE node_id = n.id ORDER BY updated_at DESC LIMIT 1
951
+ )
952
+ WHERE n.deprecated = 0
953
+ `);
954
+ const rows = stmt.all();
955
+ const FIELD_WEIGHT = { path: 3, identifier: 3, reasoning: 2, code: 1 };
956
+ const scored = [];
957
+ for (const row of rows) {
958
+ const { latest_history_id, reasoning, ...node } = row;
959
+ const matchedTerms = new Set();
960
+ let score = 0;
961
+ const filePathLower = (node.file_path || '').toLowerCase();
962
+ const nameLower = (node.name || '').toLowerCase();
963
+ const idLower = (node.id || '').toLowerCase();
964
+ const reasoningLower = (reasoning || '').toLowerCase();
965
+ let code = '';
966
+ if (latest_history_id) {
967
+ const historyFile = path.join(historyDir, `${latest_history_id}.json`);
968
+ if (fs.existsSync(historyFile)) {
969
+ try {
970
+ const data = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
971
+ code = (data.code_snapshot || '').toLowerCase();
972
+ }
973
+ catch {
974
+ // Skip corrupted or unreadable history files
975
+ }
976
+ }
977
+ }
978
+ for (const token of tokens) {
979
+ let tokenMatched = false;
980
+ if (filePathLower.includes(token)) {
981
+ score += FIELD_WEIGHT.path;
982
+ tokenMatched = true;
983
+ }
984
+ if (nameLower.includes(token) || idLower.includes(token)) {
985
+ score += FIELD_WEIGHT.identifier;
986
+ tokenMatched = true;
987
+ }
988
+ if (reasoningLower.includes(token)) {
989
+ score += FIELD_WEIGHT.reasoning;
990
+ tokenMatched = true;
991
+ }
992
+ if (code && code.includes(token)) {
993
+ score += FIELD_WEIGHT.code;
994
+ tokenMatched = true;
995
+ }
996
+ if (tokenMatched)
997
+ matchedTerms.add(token);
998
+ }
999
+ if (score > 0) {
1000
+ scored.push({ ...node, matched_via: 'fuzzy', matched_terms: Array.from(matchedTerms), score });
1001
+ }
1002
+ }
1003
+ return scored.sort((a, b) => b.score - a.score).slice(0, 20);
854
1004
  }
855
1005
  getRecentChanges(hours = 24, analyzeImpact = true) {
856
1006
  const stmt = this.db.prepare(`
@@ -1009,8 +1159,15 @@ class DevMindDatabase {
1009
1159
  params.push(filter.type);
1010
1160
  }
1011
1161
  if (filter?.file_path) {
1012
- sql += ' AND file_path LIKE ?';
1013
- params.push(`%${filter.file_path}%`);
1162
+ // file_path is stored with OS-native separators (backslashes on Windows), but the tool's
1163
+ // own schema example is forward-slash ("src/components") — a raw LIKE against the
1164
+ // unmodified column means that exact example returns nothing on Windows unless the
1165
+ // caller happens to pass backslashes instead. Normalize both sides to forward slashes
1166
+ // (getNodesByFilePath a few hundred lines up already does the equivalent for exact
1167
+ // matches; this just extends the same fix to the substring-filter path) and escape LIKE
1168
+ // metacharacters so a literal '%' or '_' in a path segment can't be misread as a wildcard.
1169
+ sql += " AND REPLACE(file_path, '\\', '/') LIKE ? ESCAPE '\\'";
1170
+ params.push(`%${this.likeEscape(filter.file_path.replace(/\\/g, '/'))}%`);
1014
1171
  }
1015
1172
  if (!filter?.include_deprecated) {
1016
1173
  sql += ' AND deprecated = 0';
@@ -1340,12 +1497,14 @@ class DevMindDatabase {
1340
1497
  * Returns a list of matches grouped by workflow.
1341
1498
  */
1342
1499
  searchWorkflows(query, opts) {
1343
- const lq = `%${query.toLowerCase()}%`;
1500
+ // Escaped so a query containing '%' or '_' matches those characters literally instead of
1501
+ // acting as SQL LIKE wildcards — otherwise `query: "%"` matches every row in the project.
1502
+ const lq = `%${this.likeEscape(query.toLowerCase())}%`;
1344
1503
  // Find matching steps
1345
1504
  const matchedStepRows = this.db.prepare(`
1346
1505
  SELECT ws.* FROM workflow_steps ws
1347
1506
  JOIN workflows w ON w.id = ws.workflow_id
1348
- WHERE (LOWER(ws.summary) LIKE ? OR LOWER(IFNULL(ws.pending_tasks,'')) LIKE ?)
1507
+ WHERE (LOWER(ws.summary) LIKE ? ESCAPE '\\' OR LOWER(IFNULL(ws.pending_tasks,'')) LIKE ? ESCAPE '\\')
1349
1508
  ${opts?.status ? 'AND w.status = ?' : ''}
1350
1509
  ORDER BY ws.workflow_id, ws.step_index ASC
1351
1510
  `).all(...(opts?.status ? [lq, lq, opts.status] : [lq, lq]));
@@ -1353,7 +1512,7 @@ class DevMindDatabase {
1353
1512
  const matchedArtifactRows = this.db.prepare(`
1354
1513
  SELECT wa.* FROM workflow_artifacts wa
1355
1514
  JOIN workflows w ON w.id = wa.workflow_id
1356
- WHERE LOWER(wa.source_name) LIKE ?
1515
+ WHERE LOWER(wa.source_name) LIKE ? ESCAPE '\\'
1357
1516
  ${opts?.status ? 'AND w.status = ?' : ''}
1358
1517
  ORDER BY wa.workflow_id, wa.created_at ASC
1359
1518
  `).all(...(opts?.status ? [lq, opts.status] : [lq]));
@@ -1623,13 +1782,20 @@ class DevMindDatabase {
1623
1782
  * just repo source — nothing upstream of this validates that the AI-supplied path is
1624
1783
  * actually inside the project.
1625
1784
  */
1785
+ /**
1786
+ * Gate for every AI-facing write (edit_node, stage_change, the legacy update_history):
1787
+ * true only for paths inside a configured repo. `.devmind` itself — this project's OWN
1788
+ * config, brain.db, and cached graph JSON — is never writable through these tools, even
1789
+ * though it sits next to (and, before this check, was indistinguishable from) real source:
1790
+ * without this, a write tool built to "never refuse a file type" would just as happily
1791
+ * rewrite devsmind's own config.json as it would application source.
1792
+ */
1626
1793
  isPathAllowed(absPath) {
1627
1794
  const abs = (0, config_1.canonicalizePath)(absPath);
1628
1795
  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;
1796
+ const devmindDirLower = (0, config_1.canonicalizePath)(path.dirname(this.dbPath)).toLowerCase();
1797
+ if (absLower === devmindDirLower || absLower.startsWith(devmindDirLower + path.sep))
1798
+ return false;
1633
1799
  if (this.context) {
1634
1800
  for (const repo of this.context.config.repos) {
1635
1801
  const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
@@ -1679,9 +1845,19 @@ class DevMindDatabase {
1679
1845
  this.db.pragma('foreign_keys = OFF');
1680
1846
  try {
1681
1847
  const workspaceRoot = path.dirname(this.dbPath);
1682
- // 0. Auto-heal any legacy relative path records in SQLite
1848
+ // 0. Auto-heal any legacy relative path records in SQLite.
1849
+ //
1850
+ // Runs on every server start, so getting "already absolute" wrong is not a one-time
1851
+ // migration slip — it recurs forever. The original check only recognized the C: drive
1852
+ // and POSIX roots ('c:%'/'C:%'/'/%'); SQL LIKE has no character-range syntax, so it could
1853
+ // not express "any drive letter" or a UNC path (\\server\share\...) in one pattern. Every
1854
+ // node on a D:, E:, ... drive or a UNC path was misclassified as relative, run through
1855
+ // toAbsolutePath() -> clampToRoot(), and silently rewritten to the workspace root — i.e.
1856
+ // real file_paths for an entire class of valid Windows paths got destroyed on restart.
1857
+ // path.isAbsolute() classifies all of these correctly in one call.
1683
1858
  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();
1859
+ const legacyNodes = this.db.prepare('SELECT id, file_path FROM nodes').all()
1860
+ .filter(n => n.file_path && !path.isAbsolute(n.file_path));
1685
1861
  if (legacyNodes.length > 0) {
1686
1862
  const updateStmt = this.db.prepare('UPDATE nodes SET file_path = ? WHERE id = ?');
1687
1863
  const healTx = this.db.transaction(() => {