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.
- package/README.md +2 -2
- package/dist/cli/init.js +9 -0
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/integrations/memory.js +2 -1
- package/dist/cli/integrations/memory.js.map +1 -1
- package/dist/cli/prune.js +4 -3
- package/dist/cli/prune.js.map +1 -1
- package/dist/cli/rule.js +14 -35
- package/dist/cli/rule.js.map +1 -1
- package/dist/db/analyze.js +7 -3
- package/dist/db/analyze.js.map +1 -1
- package/dist/db/database.d.ts +44 -0
- package/dist/db/database.js +197 -21
- package/dist/db/database.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +297 -62
- package/dist/mcp/server.js.map +1 -1
- package/dist/utils/ast.d.ts +98 -0
- package/dist/utils/ast.js +262 -10
- package/dist/utils/ast.js.map +1 -1
- package/dist/utils/edit.d.ts +41 -0
- package/dist/utils/edit.js +163 -0
- package/dist/utils/edit.js.map +1 -0
- package/package.json +1 -1
package/dist/db/database.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
407
|
+
updateHistoryStmt.run(newId, resolvedOldId);
|
|
352
408
|
const deleteOldStmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
|
|
353
|
-
deleteOldStmt.run(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
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
|
|
1630
|
-
|
|
1631
|
-
|
|
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(
|
|
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(() => {
|