devsmind-mcp 2.0.4 → 2.1.1
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/LICENSE +21 -21
- package/README.md +178 -73
- package/dist/cli/index.js +46 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.js +2 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/rule.js +35 -15
- package/dist/cli/rule.js.map +1 -1
- package/dist/cli/runner.d.ts +24 -0
- package/dist/cli/runner.js +502 -376
- package/dist/cli/runner.js.map +1 -1
- package/dist/db/database.d.ts +113 -4
- package/dist/db/database.js +453 -53
- package/dist/db/database.js.map +1 -1
- package/dist/db/edges.d.ts +42 -0
- package/dist/db/edges.js +162 -0
- package/dist/db/edges.js.map +1 -0
- package/dist/db/indexer.d.ts +3 -3
- package/dist/db/indexer.js +8 -8
- package/dist/db/indexer.js.map +1 -1
- package/dist/db/schema.js +39 -39
- package/dist/db/staging.d.ts +39 -0
- package/dist/db/staging.js +137 -0
- package/dist/db/staging.js.map +1 -0
- package/dist/mcp/server.js +192 -218
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/visualizer_2d.html +635 -635
- package/dist/mcp/visualizer_3d.html +613 -628
- package/dist/utils/ast.d.ts +38 -0
- package/dist/utils/ast.js +1079 -0
- package/dist/utils/ast.js.map +1 -0
- package/package.json +4 -4
package/dist/db/database.js
CHANGED
|
@@ -45,6 +45,7 @@ const path = __importStar(require("path"));
|
|
|
45
45
|
const zlib = __importStar(require("zlib"));
|
|
46
46
|
const schema_1 = require("./schema");
|
|
47
47
|
const config_1 = require("../utils/config");
|
|
48
|
+
const ast_1 = require("../utils/ast");
|
|
48
49
|
function compressText(text) {
|
|
49
50
|
return zlib.deflateSync(Buffer.from(text, 'utf-8'));
|
|
50
51
|
}
|
|
@@ -146,6 +147,72 @@ class DevMindDatabase {
|
|
|
146
147
|
console.warn('⚠️ SQLite VACUUM failed:', err);
|
|
147
148
|
}
|
|
148
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Wipes all nodes, connections, history, and system_meta from the DB, and clears
|
|
152
|
+
* the committed graph/ and history/ JSON directories on disk. Used by `--from-scratch`
|
|
153
|
+
* reindexing. This is destructive and irreversible from within the app — callers are
|
|
154
|
+
* responsible for confirming with the user first.
|
|
155
|
+
*/
|
|
156
|
+
resetAll() {
|
|
157
|
+
this.db.exec('DELETE FROM node_connections');
|
|
158
|
+
this.db.exec('DELETE FROM history');
|
|
159
|
+
this.db.exec('DELETE FROM nodes');
|
|
160
|
+
this.db.exec('DELETE FROM system_meta');
|
|
161
|
+
const workspaceRoot = path.dirname(this.dbPath);
|
|
162
|
+
for (const dir of ['graph', 'history']) {
|
|
163
|
+
const p = path.join(workspaceRoot, dir);
|
|
164
|
+
if (fs.existsSync(p)) {
|
|
165
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
fs.mkdirSync(p, { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
this.vacuum();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Deletes every connection from both the DB and (by rewriting each affected file's
|
|
173
|
+
* graph JSON) from disk. Used by `--edges-only` to rebuild the edge graph from
|
|
174
|
+
* scratch without touching nodes or history.
|
|
175
|
+
*/
|
|
176
|
+
clearAllConnections() {
|
|
177
|
+
const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes WHERE deprecated = 0').all();
|
|
178
|
+
const affectedFilePaths = new Set();
|
|
179
|
+
for (const row of rows) {
|
|
180
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
181
|
+
affectedFilePaths.add(p);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.db.exec('DELETE FROM node_connections');
|
|
185
|
+
for (const filePath of affectedFilePaths) {
|
|
186
|
+
this.writeGraphToDisk(filePath);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Deletes only the OUTGOING connections of the given source nodes (and re-syncs the
|
|
191
|
+
* affected files' graph JSON). Used by repo-scoped `--edges-only` so that rebuilding
|
|
192
|
+
* one repo's edges doesn't wipe every other repo's edges.
|
|
193
|
+
*/
|
|
194
|
+
clearConnectionsForSources(nodeIds) {
|
|
195
|
+
if (nodeIds.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
const affectedFilePaths = new Set();
|
|
198
|
+
const del = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ?');
|
|
199
|
+
const getFp = this.db.prepare('SELECT file_path FROM nodes WHERE id = ?');
|
|
200
|
+
const tx = this.db.transaction((ids) => {
|
|
201
|
+
for (const id of ids) {
|
|
202
|
+
const row = getFp.get(id);
|
|
203
|
+
if (row?.file_path) {
|
|
204
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
205
|
+
affectedFilePaths.add(p);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
del.run(id);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
tx(nodeIds);
|
|
212
|
+
for (const filePath of affectedFilePaths) {
|
|
213
|
+
this.writeGraphToDisk(filePath);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
149
216
|
// --- Node Operations ---
|
|
150
217
|
upsertNode(node) {
|
|
151
218
|
const existing = this.getNode(node.id);
|
|
@@ -194,15 +261,26 @@ class DevMindDatabase {
|
|
|
194
261
|
deleteNode(id) {
|
|
195
262
|
const node = this.getNode(id);
|
|
196
263
|
const resolvedId = node ? node.id : id;
|
|
264
|
+
// Capture caller files, and delete the node's history JSONs, BEFORE the row (and its
|
|
265
|
+
// cascade-deleted history rows / edges) is gone. Without the JSON cleanup, syncFromDisk()
|
|
266
|
+
// would resurrect the node from its lingering history/[id].json on the next server start.
|
|
267
|
+
const inboundSourceFiles = this.collectInboundSourceFiles(resolvedId);
|
|
268
|
+
this.deleteHistoryFilesForNode(resolvedId);
|
|
197
269
|
const stmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
|
|
198
270
|
stmt.run(resolvedId);
|
|
199
271
|
if (node && node.file_path) {
|
|
200
272
|
this.writeGraphToDisk(node.file_path);
|
|
201
273
|
}
|
|
274
|
+
for (const p of inboundSourceFiles) {
|
|
275
|
+
this.writeGraphToDisk(p);
|
|
276
|
+
}
|
|
202
277
|
}
|
|
203
278
|
deprecateNode(id) {
|
|
204
279
|
const node = this.getNode(id);
|
|
205
280
|
const resolvedId = node ? node.id : id;
|
|
281
|
+
// Capture the caller files BEFORE we delete the inbound edges — afterwards the join
|
|
282
|
+
// that finds them returns nothing.
|
|
283
|
+
const inboundSourceFiles = this.collectInboundSourceFiles(resolvedId);
|
|
206
284
|
const updateStmt = this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?');
|
|
207
285
|
const deleteConnStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?');
|
|
208
286
|
const tx = this.db.transaction(() => {
|
|
@@ -210,9 +288,14 @@ class DevMindDatabase {
|
|
|
210
288
|
deleteConnStmt.run(resolvedId, resolvedId);
|
|
211
289
|
});
|
|
212
290
|
tx();
|
|
291
|
+
// Rewrite the node's own file (now carrying deprecated:1) and every caller file (so their
|
|
292
|
+
// stale inbound edges don't resurrect the connection on the next syncFromDisk()).
|
|
213
293
|
if (node && node.file_path) {
|
|
214
294
|
this.writeGraphToDisk(node.file_path);
|
|
215
295
|
}
|
|
296
|
+
for (const p of inboundSourceFiles) {
|
|
297
|
+
this.writeGraphToDisk(p);
|
|
298
|
+
}
|
|
216
299
|
}
|
|
217
300
|
renameNode(oldId, newId, newName) {
|
|
218
301
|
const node = this.getNode(oldId);
|
|
@@ -247,17 +330,118 @@ class DevMindDatabase {
|
|
|
247
330
|
if (node.file_path) {
|
|
248
331
|
this.writeGraphToDisk(node.file_path);
|
|
249
332
|
}
|
|
333
|
+
// Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
|
|
334
|
+
// (which still reference oldId on disk). The DB was already repointed to newId above,
|
|
335
|
+
// so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
|
|
336
|
+
// the renamed node silently loses all its inbound ("used-by") edges.
|
|
337
|
+
this.rewriteInboundSourceFiles(newId);
|
|
338
|
+
// Keep the committed history/*.json files in sync with the rename. Without this,
|
|
339
|
+
// syncFromDisk() on the next server start would find the old node_id (which no
|
|
340
|
+
// longer exists in the DB) and re-insert it right back, undoing the rename.
|
|
341
|
+
const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
|
|
342
|
+
for (const row of historyIds) {
|
|
343
|
+
this.patchHistoryDiskIdentity(row.id, newId, name, node.type, node.file_path, node.signature);
|
|
344
|
+
}
|
|
250
345
|
}
|
|
251
346
|
finally {
|
|
252
347
|
this.db.pragma('foreign_keys = ON');
|
|
253
348
|
}
|
|
254
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Rewrites a history/[id].json file's identifying fields (node_id, node_metadata) in
|
|
352
|
+
* place, leaving code_snapshot/reasoning/timestamps untouched. Used after a rename so
|
|
353
|
+
* disk stays consistent with the DB without needing the full code_snapshot/reasoning
|
|
354
|
+
* to be re-passed in.
|
|
355
|
+
*/
|
|
356
|
+
patchHistoryDiskIdentity(historyId, nodeId, name, type, filePath, signature) {
|
|
357
|
+
try {
|
|
358
|
+
const historyDir = path.join(path.dirname(this.dbPath), 'history');
|
|
359
|
+
const filePathOnDisk = path.join(historyDir, `${historyId}.json`);
|
|
360
|
+
if (!fs.existsSync(filePathOnDisk))
|
|
361
|
+
return;
|
|
362
|
+
const data = JSON.parse(fs.readFileSync(filePathOnDisk, 'utf-8'));
|
|
363
|
+
data.node_id = nodeId;
|
|
364
|
+
data.node_metadata = {
|
|
365
|
+
name,
|
|
366
|
+
type,
|
|
367
|
+
file_path: this.toRepoRelativePath(filePath),
|
|
368
|
+
signature
|
|
369
|
+
};
|
|
370
|
+
fs.writeFileSync(filePathOnDisk, JSON.stringify(data, null, 2), 'utf-8');
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
console.warn('⚠️ SQLite warning: Failed to patch history JSON identity on disk:', err);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Collects the distinct file paths of every SOURCE node that has an OUTGOING edge pointing
|
|
378
|
+
* INTO `nodeId` (i.e. this node's "used-by" callers). Those inbound edges live on disk in the
|
|
379
|
+
* source nodes' files, not in the target's own file. Callers that DELETE the inbound edges
|
|
380
|
+
* (deprecate/delete) must call this BEFORE the deletion to capture the affected files;
|
|
381
|
+
* callers that merely repoint them (rename) can rewrite after the fact.
|
|
382
|
+
*/
|
|
383
|
+
collectInboundSourceFiles(nodeId) {
|
|
384
|
+
const rows = this.db.prepare(`
|
|
385
|
+
SELECT DISTINCT n.file_path AS file_path
|
|
386
|
+
FROM node_connections c JOIN nodes n ON n.id = c.source_node_id
|
|
387
|
+
WHERE c.target_node_id = ?
|
|
388
|
+
`).all(nodeId);
|
|
389
|
+
const files = new Set();
|
|
390
|
+
for (const row of rows) {
|
|
391
|
+
if (!row.file_path)
|
|
392
|
+
continue;
|
|
393
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
394
|
+
files.add(p);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return Array.from(files);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Re-syncs each given source file's graph JSON. Used after the DB has been mutated so that
|
|
401
|
+
* syncFromDisk() won't reload a stale inbound ("used-by") edge on the next server start.
|
|
402
|
+
*/
|
|
403
|
+
rewriteInboundSourceFiles(nodeId) {
|
|
404
|
+
for (const p of this.collectInboundSourceFiles(nodeId)) {
|
|
405
|
+
this.writeGraphToDisk(p);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Deletes the committed history/[id].json files for every history record of `nodeId`.
|
|
410
|
+
* Used on HARD delete so that syncFromDisk()'s history pass can't resurrect the node
|
|
411
|
+
* (and its metadata) from a lingering JSON on the next server start. Reads the history
|
|
412
|
+
* ids BEFORE the DB rows are removed, so call this while they still exist (or pass ids in).
|
|
413
|
+
*/
|
|
414
|
+
deleteHistoryFilesForNode(nodeId) {
|
|
415
|
+
try {
|
|
416
|
+
const historyDir = path.join(path.dirname(this.dbPath), 'history');
|
|
417
|
+
const rows = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(nodeId);
|
|
418
|
+
for (const row of rows) {
|
|
419
|
+
const filePath = path.join(historyDir, `${row.id}.json`);
|
|
420
|
+
if (fs.existsSync(filePath))
|
|
421
|
+
fs.unlinkSync(filePath);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
console.warn('⚠️ SQLite warning: Failed to delete history JSON(s) on disk:', err);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
255
428
|
// --- Connection Operations ---
|
|
256
429
|
addConnection(sourceNodeId, targetNodeId) {
|
|
257
430
|
const srcNode = this.getNode(sourceNodeId);
|
|
258
431
|
const tgtNode = this.getNode(targetNodeId);
|
|
259
432
|
const resolvedSrc = srcNode ? srcNode.id : sourceNodeId;
|
|
260
433
|
const resolvedTgt = tgtNode ? tgtNode.id : targetNodeId;
|
|
434
|
+
// The on-disk graph format is node-anchored: each file's JSON lists its nodes and their
|
|
435
|
+
// OUTGOING edges. An edge whose SOURCE node doesn't exist has nowhere to be written on
|
|
436
|
+
// disk, so it would live only in brain.db and be silently dropped by syncFromDisk() on the
|
|
437
|
+
// next server start. Rather than leak that DB-only orphan, refuse the edge and tell the
|
|
438
|
+
// caller to add the source node first (the two-phase indexing protocol already does this).
|
|
439
|
+
if (!srcNode) {
|
|
440
|
+
console.warn(`⚠️ DevsMind: connection skipped — source node "${sourceNodeId}" does not exist in ` +
|
|
441
|
+
`the graph. Add it (stage_change / update_history) before connecting it, otherwise the edge ` +
|
|
442
|
+
`cannot be persisted to disk and would not survive a restart.`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
261
445
|
this.db.pragma('foreign_keys = OFF');
|
|
262
446
|
try {
|
|
263
447
|
const stmt = this.db.prepare(`
|
|
@@ -265,7 +449,7 @@ class DevMindDatabase {
|
|
|
265
449
|
VALUES (?, ?)
|
|
266
450
|
`);
|
|
267
451
|
stmt.run(resolvedSrc, resolvedTgt);
|
|
268
|
-
if (srcNode
|
|
452
|
+
if (srcNode.file_path) {
|
|
269
453
|
this.writeGraphToDisk(srcNode.file_path);
|
|
270
454
|
}
|
|
271
455
|
}
|
|
@@ -350,6 +534,13 @@ class DevMindDatabase {
|
|
|
350
534
|
const rows = stmt.all(resolvedId);
|
|
351
535
|
return rows.map(row => this.populateHistoryFromDisk(row));
|
|
352
536
|
}
|
|
537
|
+
/** Distinct source node ids of edges pointing INTO this node (its "used-by" callers). */
|
|
538
|
+
getInboundSources(nodeId) {
|
|
539
|
+
const rows = this.db
|
|
540
|
+
.prepare('SELECT DISTINCT source_node_id FROM node_connections WHERE target_node_id = ?')
|
|
541
|
+
.all(nodeId);
|
|
542
|
+
return rows.map(r => r.source_node_id);
|
|
543
|
+
}
|
|
353
544
|
getLatestCode(nodeId) {
|
|
354
545
|
const node = this.getNode(nodeId);
|
|
355
546
|
const resolvedId = node ? node.id : nodeId;
|
|
@@ -361,10 +552,74 @@ class DevMindDatabase {
|
|
|
361
552
|
code_snapshot: history.code_snapshot
|
|
362
553
|
};
|
|
363
554
|
}
|
|
364
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Parse a node's CURRENT source straight off disk via the AST, bypassing the stored snapshot.
|
|
557
|
+
* `nodes.file_path` is already absolute, and may be a ", "-joined list when a symbol spans
|
|
558
|
+
* files — try each until one resolves. Returns null for non-TS/JS files, or when the symbol
|
|
559
|
+
* no longer exists in the file (renamed / moved / deleted).
|
|
560
|
+
*/
|
|
561
|
+
extractLiveCode(node) {
|
|
562
|
+
const parsed = (0, ast_1.parseNodeId)(node.id);
|
|
563
|
+
// Pass the FULL symbol name ("Foo.bar") — extractNodeFromFile re-derives the class itself.
|
|
564
|
+
const symbol = parsed ? parsed.symbolName : node.id.split('#').pop() || node.name;
|
|
565
|
+
if (!symbol)
|
|
566
|
+
return null;
|
|
567
|
+
for (const p of String(node.file_path).split(',').map(s => s.trim()).filter(Boolean)) {
|
|
568
|
+
const derived = (0, ast_1.extractNodeFromFile)(p, symbol);
|
|
569
|
+
if (derived)
|
|
570
|
+
return derived.codeSnapshot;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Current code for a node, read from the file on disk (the source of truth) rather than the
|
|
576
|
+
* cached snapshot. Falls back to the snapshot only when the file can't be parsed for this
|
|
577
|
+
* symbol, and flags that fallback as unverified. When live code IS available, comparing it to
|
|
578
|
+
* the snapshot is free — so drift between the graph and disk is reported rather than hidden.
|
|
579
|
+
*/
|
|
580
|
+
getLiveCode(nodeId) {
|
|
581
|
+
const node = this.getNode(nodeId);
|
|
582
|
+
const resolvedId = node ? node.id : nodeId;
|
|
583
|
+
const snapshot = this.getLatestCode(resolvedId);
|
|
584
|
+
if (node) {
|
|
585
|
+
const live = this.extractLiveCode(node);
|
|
586
|
+
if (live !== null) {
|
|
587
|
+
return {
|
|
588
|
+
exists: true,
|
|
589
|
+
node_id: node.id,
|
|
590
|
+
file_path: node.file_path,
|
|
591
|
+
code: live,
|
|
592
|
+
source: 'live',
|
|
593
|
+
// Snapshot exists but disagrees with disk → the graph has drifted.
|
|
594
|
+
snapshot_outdated: snapshot ? snapshot.code_snapshot !== live : undefined,
|
|
595
|
+
updated_at: snapshot?.updated_at
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (snapshot) {
|
|
600
|
+
return {
|
|
601
|
+
exists: true,
|
|
602
|
+
node_id: resolvedId,
|
|
603
|
+
file_path: node?.file_path,
|
|
604
|
+
code: snapshot.code_snapshot,
|
|
605
|
+
source: 'cached',
|
|
606
|
+
// Could not confirm against disk (non-TS/JS file, or symbol gone) — treat as suspect.
|
|
607
|
+
snapshot_outdated: true,
|
|
608
|
+
updated_at: snapshot.updated_at,
|
|
609
|
+
message: 'Could not locate this symbol in its source file — the file may not be TS/JS, or the symbol was renamed, moved, or deleted. Returning the last cached snapshot, which may be out of date. Verify against the file before relying on it.'
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
exists: false,
|
|
614
|
+
node_id: resolvedId,
|
|
615
|
+
message: 'No code found on disk or in cache. Read the source file, then stage_change + commit_changes so future agents skip the file read entirely.'
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
getGraph(nodeId, maxDepth = 6, opts = {}) {
|
|
619
|
+
const direction = opts.direction ?? 'both';
|
|
620
|
+
const codeCharBudget = opts.codeCharBudget ?? 60_000;
|
|
365
621
|
const maxNodesLimit = 500;
|
|
366
622
|
const visited = new Set();
|
|
367
|
-
const queue = [{ id: nodeId, depth: 0 }];
|
|
368
623
|
const nodes = [];
|
|
369
624
|
const connections = [];
|
|
370
625
|
const connSet = new Set();
|
|
@@ -372,7 +627,11 @@ class DevMindDatabase {
|
|
|
372
627
|
if (!rootNode) {
|
|
373
628
|
return { nodes, connections };
|
|
374
629
|
}
|
|
375
|
-
|
|
630
|
+
// Seed with the CANONICAL id. getNode() resolves a bare, unqualified symbol name, but
|
|
631
|
+
// node_connections is keyed by the fully-qualified id — seeding the queue with the raw
|
|
632
|
+
// argument would find zero edges and return a lone root.
|
|
633
|
+
const queue = [{ id: rootNode.id, depth: 0 }];
|
|
634
|
+
visited.add(rootNode.id);
|
|
376
635
|
nodes.push(rootNode);
|
|
377
636
|
const usesStmt = this.db.prepare(`
|
|
378
637
|
SELECT target_node_id FROM node_connections WHERE source_node_id = ?
|
|
@@ -385,50 +644,84 @@ class DevMindDatabase {
|
|
|
385
644
|
if (current.depth >= maxDepth) {
|
|
386
645
|
continue;
|
|
387
646
|
}
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
connSet.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
visited.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
647
|
+
// Outbound — what this node uses (callees). Skipped when tracing callers only.
|
|
648
|
+
if (direction !== 'in') {
|
|
649
|
+
const outbound = usesStmt.all(current.id);
|
|
650
|
+
for (const row of outbound) {
|
|
651
|
+
const targetId = row.target_node_id;
|
|
652
|
+
const connKey = `${current.id}->${targetId}`;
|
|
653
|
+
if (!connSet.has(connKey)) {
|
|
654
|
+
connSet.add(connKey);
|
|
655
|
+
connections.push({ source_node_id: current.id, target_node_id: targetId });
|
|
656
|
+
}
|
|
657
|
+
if (!visited.has(targetId)) {
|
|
658
|
+
visited.add(targetId);
|
|
659
|
+
const targetNode = this.getNode(targetId);
|
|
660
|
+
if (targetNode) {
|
|
661
|
+
nodes.push(targetNode);
|
|
662
|
+
if (nodes.length >= maxNodesLimit)
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
queue.push({ id: targetId, depth: current.depth + 1 });
|
|
404
666
|
}
|
|
405
|
-
queue.push({ id: targetId, depth: current.depth + 1 });
|
|
406
667
|
}
|
|
407
668
|
}
|
|
408
669
|
if (nodes.length >= maxNodesLimit)
|
|
409
670
|
break;
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
connSet.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
visited.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
671
|
+
// Inbound — what uses this node (callers). Skipped when tracing a call flow outward.
|
|
672
|
+
if (direction !== 'out') {
|
|
673
|
+
const inbound = usedByStmt.all(current.id);
|
|
674
|
+
for (const row of inbound) {
|
|
675
|
+
const sourceId = row.source_node_id;
|
|
676
|
+
const connKey = `${sourceId}->${current.id}`;
|
|
677
|
+
if (!connSet.has(connKey)) {
|
|
678
|
+
connSet.add(connKey);
|
|
679
|
+
connections.push({ source_node_id: sourceId, target_node_id: current.id });
|
|
680
|
+
}
|
|
681
|
+
if (!visited.has(sourceId)) {
|
|
682
|
+
visited.add(sourceId);
|
|
683
|
+
const sourceNode = this.getNode(sourceId);
|
|
684
|
+
if (sourceNode) {
|
|
685
|
+
nodes.push(sourceNode);
|
|
686
|
+
if (nodes.length >= maxNodesLimit)
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
queue.push({ id: sourceId, depth: current.depth + 1 });
|
|
426
690
|
}
|
|
427
|
-
queue.push({ id: sourceId, depth: current.depth + 1 });
|
|
428
691
|
}
|
|
429
692
|
}
|
|
430
693
|
}
|
|
431
|
-
|
|
694
|
+
const result = { nodes, connections };
|
|
695
|
+
if (opts.includeCode) {
|
|
696
|
+
let spent = 0;
|
|
697
|
+
let withoutCode = 0;
|
|
698
|
+
// `nodes` is in BFS order (nearest the root first), so the budget is spent on the most
|
|
699
|
+
// relevant code before anything is dropped.
|
|
700
|
+
for (const [i, n] of nodes.entries()) {
|
|
701
|
+
const live = this.extractLiveCode(n);
|
|
702
|
+
const code = live ?? this.getLatestCode(n.id)?.code_snapshot ?? null;
|
|
703
|
+
if (!code) {
|
|
704
|
+
withoutCode++;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
// The root always gets its code — it is what was asked for, and dropping it would make
|
|
708
|
+
// the response useless. Every other node must fit in the REMAINING budget, so a single
|
|
709
|
+
// large node can't blow past the cap (it is skipped and counted, not truncated).
|
|
710
|
+
if (i > 0 && spent + code.length > codeCharBudget) {
|
|
711
|
+
withoutCode++;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
n.code = code;
|
|
715
|
+
n.code_source = live !== null ? 'live' : 'cached';
|
|
716
|
+
spent += code.length;
|
|
717
|
+
}
|
|
718
|
+
result.code_chars = spent;
|
|
719
|
+
if (withoutCode > 0) {
|
|
720
|
+
result.code_truncated = true;
|
|
721
|
+
result.nodes_without_code = withoutCode;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return result;
|
|
432
725
|
}
|
|
433
726
|
updateHistory(params) {
|
|
434
727
|
const { node_id, code_snapshot, reasoning } = params;
|
|
@@ -561,6 +854,73 @@ class DevMindDatabase {
|
|
|
561
854
|
const wildcard = `%Decision: %${query}%`;
|
|
562
855
|
return stmt.all(wildcard);
|
|
563
856
|
}
|
|
857
|
+
searchCode(params) {
|
|
858
|
+
const { query, is_regex = false, case_insensitive = true } = params;
|
|
859
|
+
const historyDir = path.join(path.dirname(this.dbPath), 'history');
|
|
860
|
+
const stmt = this.db.prepare(`
|
|
861
|
+
SELECT h.id, n.id AS node_id, n.name AS node_name, n.file_path
|
|
862
|
+
FROM nodes n
|
|
863
|
+
JOIN history h ON h.node_id = n.id
|
|
864
|
+
WHERE n.deprecated = 0
|
|
865
|
+
AND h.id = (
|
|
866
|
+
SELECT id FROM history
|
|
867
|
+
WHERE node_id = n.id
|
|
868
|
+
ORDER BY updated_at DESC
|
|
869
|
+
LIMIT 1
|
|
870
|
+
)
|
|
871
|
+
`);
|
|
872
|
+
const rows = stmt.all();
|
|
873
|
+
let matcher;
|
|
874
|
+
if (is_regex) {
|
|
875
|
+
try {
|
|
876
|
+
matcher = new RegExp(query, case_insensitive ? 'i' : '');
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
throw new Error(`Invalid regex pattern: ${err.message}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
884
|
+
matcher = new RegExp(escaped, case_insensitive ? 'i' : '');
|
|
885
|
+
}
|
|
886
|
+
const results = [];
|
|
887
|
+
for (const row of rows) {
|
|
888
|
+
const filePath = path.join(historyDir, `${row.id}.json`);
|
|
889
|
+
if (!fs.existsSync(filePath))
|
|
890
|
+
continue;
|
|
891
|
+
try {
|
|
892
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
893
|
+
const code = data.code_snapshot || '';
|
|
894
|
+
if (!code)
|
|
895
|
+
continue;
|
|
896
|
+
const lines = code.split('\n');
|
|
897
|
+
const nodeMatches = [];
|
|
898
|
+
lines.forEach((line, idx) => {
|
|
899
|
+
if (matcher.test(line)) {
|
|
900
|
+
nodeMatches.push({
|
|
901
|
+
line_number: idx + 1,
|
|
902
|
+
line_content: line
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
if (nodeMatches.length > 0) {
|
|
907
|
+
results.push({
|
|
908
|
+
node_id: row.node_id,
|
|
909
|
+
node_name: row.node_name,
|
|
910
|
+
file_path: row.file_path,
|
|
911
|
+
matches: nodeMatches,
|
|
912
|
+
match_count: nodeMatches.length,
|
|
913
|
+
total_lines: lines.length,
|
|
914
|
+
match_ratio: parseFloat((nodeMatches.length / lines.length).toFixed(4))
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
// Skip corrupted or unreadable history files
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return results.sort((a, b) => b.match_count - a.match_count);
|
|
923
|
+
}
|
|
564
924
|
getOrphanedNodes() {
|
|
565
925
|
const stmt = this.db.prepare(`
|
|
566
926
|
SELECT * FROM nodes
|
|
@@ -613,6 +973,7 @@ class DevMindDatabase {
|
|
|
613
973
|
const candidates = stmt.all();
|
|
614
974
|
const idsToDelete = [];
|
|
615
975
|
const namesDeleted = [];
|
|
976
|
+
const affectedFilePaths = new Set();
|
|
616
977
|
for (const node of candidates) {
|
|
617
978
|
const lowerName = node.name.toLowerCase();
|
|
618
979
|
// 1. Check if name is in the spurious list
|
|
@@ -636,9 +997,24 @@ class DevMindDatabase {
|
|
|
636
997
|
if (isSpurious || fileMissing) {
|
|
637
998
|
idsToDelete.push(node.id);
|
|
638
999
|
namesDeleted.push(`${node.name} (${node.id})`);
|
|
1000
|
+
if (node.file_path) {
|
|
1001
|
+
for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
1002
|
+
affectedFilePaths.add(p);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
639
1005
|
}
|
|
640
1006
|
}
|
|
641
1007
|
if (idsToDelete.length > 0) {
|
|
1008
|
+
// Capture caller files and drop the pruned nodes' history JSONs BEFORE the tx: the
|
|
1009
|
+
// inbound-edge join goes empty once edges are deleted, and the history rows (whose ids
|
|
1010
|
+
// name the JSON files) are removed by deleteHistoryStmt. Without the JSON cleanup, a
|
|
1011
|
+
// pruned node would resurrect from its history/[id].json on the next syncFromDisk().
|
|
1012
|
+
for (const id of idsToDelete) {
|
|
1013
|
+
for (const p of this.collectInboundSourceFiles(id)) {
|
|
1014
|
+
affectedFilePaths.add(p);
|
|
1015
|
+
}
|
|
1016
|
+
this.deleteHistoryFilesForNode(id);
|
|
1017
|
+
}
|
|
642
1018
|
const updateStmt = this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?');
|
|
643
1019
|
const deleteConnStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?');
|
|
644
1020
|
const deleteHistoryStmt = this.db.prepare('DELETE FROM history WHERE node_id = ?');
|
|
@@ -650,6 +1026,12 @@ class DevMindDatabase {
|
|
|
650
1026
|
}
|
|
651
1027
|
});
|
|
652
1028
|
deprecateTx(idsToDelete);
|
|
1029
|
+
// Keep the committed graph/*.json files in sync with the DB. Pruned nodes are written
|
|
1030
|
+
// with deprecated:1 (so they don't come back as active), and every caller file is
|
|
1031
|
+
// rewritten so its stale inbound edge doesn't resurrect the connection on next start.
|
|
1032
|
+
for (const filePath of affectedFilePaths) {
|
|
1033
|
+
this.writeGraphToDisk(filePath);
|
|
1034
|
+
}
|
|
653
1035
|
}
|
|
654
1036
|
return {
|
|
655
1037
|
prunedCount: idsToDelete.length,
|
|
@@ -716,7 +1098,7 @@ class DevMindDatabase {
|
|
|
716
1098
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
717
1099
|
if (repoPath) {
|
|
718
1100
|
const normalizedRepoPath = path.resolve(repoPath).replace(/\\/g, '/');
|
|
719
|
-
if (abs.startsWith(normalizedRepoPath)) {
|
|
1101
|
+
if (abs === normalizedRepoPath || abs.startsWith(normalizedRepoPath + '/')) {
|
|
720
1102
|
const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
|
|
721
1103
|
return `{${repo.name}}/${relative}`;
|
|
722
1104
|
}
|
|
@@ -805,11 +1187,11 @@ class DevMindDatabase {
|
|
|
805
1187
|
};
|
|
806
1188
|
const jsonFiles = walkSync(graphDir);
|
|
807
1189
|
if (jsonFiles.length > 0) {
|
|
808
|
-
const deleteNodesForFileStmt = this.db.prepare('DELETE FROM nodes WHERE file_path = ?
|
|
1190
|
+
const deleteNodesForFileStmt = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
|
|
809
1191
|
const deleteConnsForNodesStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ?');
|
|
810
1192
|
const insertNodeStmt = this.db.prepare(`
|
|
811
1193
|
INSERT OR REPLACE INTO nodes (id, type, name, file_path, signature, deprecated)
|
|
812
|
-
VALUES (?, ?, ?, ?, ?,
|
|
1194
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
813
1195
|
`);
|
|
814
1196
|
const insertConnStmt = this.db.prepare(`
|
|
815
1197
|
INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id)
|
|
@@ -824,16 +1206,17 @@ class DevMindDatabase {
|
|
|
824
1206
|
continue;
|
|
825
1207
|
const fileRelPath = data.file_path; // E.g. "{harrir-web}/app/page.tsx" or relative path
|
|
826
1208
|
const fileAbsPath = this.toAbsolutePath(fileRelPath);
|
|
827
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
|
|
1209
|
+
// Clean existing nodes in SQLite for this file BEFORE re-inserting from
|
|
1210
|
+
// the JSON (so removed/renamed symbols are cleared). Match ONLY the exact
|
|
1211
|
+
// absolute path: the previous suffix `LIKE '%<relpath>'` matched the same
|
|
1212
|
+
// relative path in EVERY repo, so syncing one repo's file deleted another
|
|
1213
|
+
// repo's same-named file nodes (cross-repo data loss).
|
|
1214
|
+
deleteNodesForFileStmt.run(fileAbsPath);
|
|
832
1215
|
// Insert nodes
|
|
833
1216
|
const nodes = data.nodes || [];
|
|
834
1217
|
for (const n of nodes) {
|
|
835
1218
|
deleteConnsForNodesStmt.run(n.id);
|
|
836
|
-
insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null);
|
|
1219
|
+
insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null, n.deprecated ? 1 : 0);
|
|
837
1220
|
}
|
|
838
1221
|
// Insert connections
|
|
839
1222
|
const connections = data.connections || [];
|
|
@@ -857,6 +1240,10 @@ class DevMindDatabase {
|
|
|
857
1240
|
this.db.pragma('foreign_keys = ON');
|
|
858
1241
|
}
|
|
859
1242
|
}
|
|
1243
|
+
/** Escape LIKE metacharacters so a path is matched literally (use with ESCAPE '\\'). */
|
|
1244
|
+
likeEscape(s) {
|
|
1245
|
+
return s.replace(/[\\%_]/g, ch => '\\' + ch);
|
|
1246
|
+
}
|
|
860
1247
|
writeGraphToDisk(filePath) {
|
|
861
1248
|
try {
|
|
862
1249
|
if (!filePath)
|
|
@@ -864,17 +1251,29 @@ class DevMindDatabase {
|
|
|
864
1251
|
const workspaceRoot = path.dirname(this.dbPath);
|
|
865
1252
|
// Clean/resolve the file path
|
|
866
1253
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
|
|
867
|
-
const relPath = path.relative(workspaceRoot, absPath).replace(/\\/g, '/');
|
|
868
1254
|
const repoRelPath = this.toRepoRelativePath(absPath);
|
|
869
1255
|
// E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
|
|
870
1256
|
const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
|
|
871
1257
|
const graphJsonPath = path.join(workspaceRoot, 'graph', diskRelPath);
|
|
872
|
-
// Get all
|
|
1258
|
+
// Get all nodes in this file (active AND deprecated). A node's file_path is either
|
|
1259
|
+
// exactly this absolute path, or (for the rare node spanning multiple files) a ", "-joined
|
|
1260
|
+
// list containing it. We anchor on the FULL absolute path with ", " boundaries and escape
|
|
1261
|
+
// LIKE metacharacters — the old `%<relpath>%` / `%<relpath>` matched short relative
|
|
1262
|
+
// suffixes shared across repos, pulling in (and later corrupting) other repos' nodes.
|
|
1263
|
+
// Deprecated nodes are INCLUDED (and carry deprecated:1 in the JSON) so that deprecation
|
|
1264
|
+
// is durable across a syncFromDisk() restart and propagates to teammates via git —
|
|
1265
|
+
// otherwise the node's history JSON would resurrect it as active on the next start.
|
|
1266
|
+
const absEsc = this.likeEscape(absPath);
|
|
873
1267
|
const stmtNodes = this.db.prepare(`
|
|
874
1268
|
SELECT * FROM nodes
|
|
875
|
-
WHERE
|
|
1269
|
+
WHERE (
|
|
1270
|
+
file_path = ? OR
|
|
1271
|
+
file_path LIKE ? ESCAPE '\\' OR
|
|
1272
|
+
file_path LIKE ? ESCAPE '\\' OR
|
|
1273
|
+
file_path LIKE ? ESCAPE '\\'
|
|
1274
|
+
)
|
|
876
1275
|
`);
|
|
877
|
-
const nodes = stmtNodes.all(absPath,
|
|
1276
|
+
const nodes = stmtNodes.all(absPath, `${absEsc}, %`, `%, ${absEsc}`, `%, ${absEsc}, %`);
|
|
878
1277
|
if (nodes.length === 0) {
|
|
879
1278
|
// If no nodes left, delete the JSON file if it exists
|
|
880
1279
|
if (fs.existsSync(graphJsonPath)) {
|
|
@@ -902,7 +1301,8 @@ class DevMindDatabase {
|
|
|
902
1301
|
id: n.id,
|
|
903
1302
|
name: n.name,
|
|
904
1303
|
type: n.type,
|
|
905
|
-
signature: n.signature
|
|
1304
|
+
signature: n.signature,
|
|
1305
|
+
deprecated: n.deprecated ? 1 : 0
|
|
906
1306
|
})),
|
|
907
1307
|
connections: connections.map(c => ({
|
|
908
1308
|
source_node_id: c.source_node_id,
|