devsmind-mcp 2.2.0 → 2.3.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 +234 -527
- package/dist/cli/analyze.d.ts +13 -0
- package/dist/cli/analyze.js +143 -0
- package/dist/cli/analyze.js.map +1 -0
- package/dist/cli/index.js +76 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/integrations/memory.d.ts +11 -0
- package/dist/cli/integrations/memory.js +156 -0
- package/dist/cli/integrations/memory.js.map +1 -0
- package/dist/cli/integrations/prompt.d.ts +5 -1
- package/dist/cli/integrations/prompt.js +29 -7
- package/dist/cli/integrations/prompt.js.map +1 -1
- package/dist/cli/integrations/registry.d.ts +35 -0
- package/dist/cli/integrations/registry.js +59 -0
- package/dist/cli/integrations/registry.js.map +1 -1
- package/dist/cli/rule.js +37 -46
- package/dist/cli/rule.js.map +1 -1
- package/dist/cli/sync.d.ts +7 -0
- package/dist/cli/sync.js +40 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/workflow.d.ts +8 -0
- package/dist/cli/workflow.js +156 -0
- package/dist/cli/workflow.js.map +1 -0
- package/dist/db/analyze.d.ts +67 -0
- package/dist/db/analyze.js +163 -0
- package/dist/db/analyze.js.map +1 -0
- package/dist/db/database.d.ts +166 -3
- package/dist/db/database.js +700 -64
- package/dist/db/database.js.map +1 -1
- package/dist/db/schema.d.ts +28 -1
- package/dist/db/schema.js +34 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/db/staging.d.ts +4 -0
- package/dist/db/staging.js +16 -2
- package/dist/db/staging.js.map +1 -1
- package/dist/db/workflow-import.d.ts +22 -0
- package/dist/db/workflow-import.js +116 -0
- package/dist/db/workflow-import.js.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +417 -27
- package/dist/mcp/server.js.map +1 -1
- package/dist/utils/config.d.ts +2 -0
- package/dist/utils/config.js +11 -0
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/git.d.ts +14 -0
- package/dist/utils/git.js +43 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/scanner.d.ts +7 -0
- package/dist/utils/scanner.js +9 -3
- package/dist/utils/scanner.js.map +1 -1
- package/package.json +1 -1
package/dist/db/database.js
CHANGED
|
@@ -114,6 +114,9 @@ class DevMindDatabase {
|
|
|
114
114
|
)
|
|
115
115
|
`);
|
|
116
116
|
}
|
|
117
|
+
getContext() {
|
|
118
|
+
return this.context;
|
|
119
|
+
}
|
|
117
120
|
getSystemMeta(key) {
|
|
118
121
|
try {
|
|
119
122
|
const stmt = this.db.prepare('SELECT value FROM system_meta WHERE key = ?');
|
|
@@ -183,6 +186,9 @@ class DevMindDatabase {
|
|
|
183
186
|
}
|
|
184
187
|
fs.mkdirSync(p, { recursive: true });
|
|
185
188
|
}
|
|
189
|
+
// NOTE: 'workflows/' is intentionally NOT wiped — workflow data is long-lived
|
|
190
|
+
// cross-session state that survives a node/history reindex. It will be
|
|
191
|
+
// restored from workflows/*/workflow.json on the next syncFromDisk().
|
|
186
192
|
this.vacuum();
|
|
187
193
|
}
|
|
188
194
|
/**
|
|
@@ -232,11 +238,12 @@ class DevMindDatabase {
|
|
|
232
238
|
}
|
|
233
239
|
// --- Node Operations ---
|
|
234
240
|
upsertNode(node) {
|
|
241
|
+
const canonicalFp = (0, config_1.canonicalizePath)(node.file_path);
|
|
235
242
|
const existing = this.getNode(node.id);
|
|
236
243
|
if (existing) {
|
|
237
244
|
let finalPath = existing.file_path;
|
|
238
245
|
const paths = existing.file_path.split(',').map(p => p.trim()).filter(Boolean);
|
|
239
|
-
const incoming =
|
|
246
|
+
const incoming = canonicalFp.trim();
|
|
240
247
|
if (!paths.includes(incoming)) {
|
|
241
248
|
paths.push(incoming);
|
|
242
249
|
finalPath = paths.join(', ');
|
|
@@ -257,9 +264,9 @@ class DevMindDatabase {
|
|
|
257
264
|
INSERT INTO nodes (id, type, name, file_path, signature)
|
|
258
265
|
VALUES (?, ?, ?, ?, ?)
|
|
259
266
|
`);
|
|
260
|
-
stmt.run(node.id, node.type, node.name,
|
|
267
|
+
stmt.run(node.id, node.type, node.name, canonicalFp, node.signature || null);
|
|
261
268
|
}
|
|
262
|
-
this.writeGraphToDisk(
|
|
269
|
+
this.writeGraphToDisk(canonicalFp);
|
|
263
270
|
}
|
|
264
271
|
getNode(id) {
|
|
265
272
|
const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
|
|
@@ -267,8 +274,8 @@ class DevMindDatabase {
|
|
|
267
274
|
if (direct)
|
|
268
275
|
return direct;
|
|
269
276
|
if (!id.includes('#')) {
|
|
270
|
-
const suffixStmt = this.db.prepare(
|
|
271
|
-
const matches = suffixStmt.all(`%#${id}`);
|
|
277
|
+
const suffixStmt = this.db.prepare("SELECT * FROM nodes WHERE id LIKE ? ESCAPE '\\' AND deprecated = 0");
|
|
278
|
+
const matches = suffixStmt.all(`%#${this.likeEscape(id)}`);
|
|
272
279
|
if (matches.length === 1) {
|
|
273
280
|
return matches[0];
|
|
274
281
|
}
|
|
@@ -314,20 +321,22 @@ class DevMindDatabase {
|
|
|
314
321
|
this.writeGraphToDisk(p);
|
|
315
322
|
}
|
|
316
323
|
}
|
|
317
|
-
|
|
324
|
+
/** `newFilePath`: pass when the rename is a file move (analyze's rename migration), leave undefined for a pure symbol-id rename where the file itself is unchanged. */
|
|
325
|
+
renameNode(oldId, newId, newName, newFilePath) {
|
|
318
326
|
const node = this.getNode(oldId);
|
|
319
327
|
if (!node) {
|
|
320
328
|
throw new Error(`Node not found: ${oldId}`);
|
|
321
329
|
}
|
|
322
330
|
const name = newName || (node.name === oldId ? newId : node.name);
|
|
331
|
+
const filePath = newFilePath || node.file_path;
|
|
323
332
|
this.db.pragma('foreign_keys = OFF');
|
|
324
333
|
try {
|
|
325
334
|
const runTx = this.db.transaction(() => {
|
|
326
335
|
const insertStmt = this.db.prepare(`
|
|
327
|
-
INSERT INTO nodes (id, type, name, file_path, signature, created_at)
|
|
328
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
336
|
+
INSERT INTO nodes (id, type, name, file_path, signature, created_at, deprecated)
|
|
337
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
329
338
|
`);
|
|
330
|
-
insertStmt.run(newId, node.type, name, node.
|
|
339
|
+
insertStmt.run(newId, node.type, name, filePath, node.signature, node.created_at, node.deprecated ? 1 : 0);
|
|
331
340
|
const updateSourceStmt = this.db.prepare(`
|
|
332
341
|
UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
|
|
333
342
|
`);
|
|
@@ -345,8 +354,13 @@ class DevMindDatabase {
|
|
|
345
354
|
});
|
|
346
355
|
runTx();
|
|
347
356
|
if (node.file_path) {
|
|
357
|
+
// Rewrite the OLD file's graph JSON too when the file itself moved, so the
|
|
358
|
+
// stale node entry doesn't linger under the old path's JSON on disk.
|
|
348
359
|
this.writeGraphToDisk(node.file_path);
|
|
349
360
|
}
|
|
361
|
+
if (filePath && filePath !== node.file_path) {
|
|
362
|
+
this.writeGraphToDisk(filePath);
|
|
363
|
+
}
|
|
350
364
|
// Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
|
|
351
365
|
// (which still reference oldId on disk). The DB was already repointed to newId above,
|
|
352
366
|
// so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
|
|
@@ -357,7 +371,7 @@ class DevMindDatabase {
|
|
|
357
371
|
// longer exists in the DB) and re-insert it right back, undoing the rename.
|
|
358
372
|
const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
|
|
359
373
|
for (const row of historyIds) {
|
|
360
|
-
this.patchHistoryDiskIdentity(row.id, newId, name, node.type,
|
|
374
|
+
this.patchHistoryDiskIdentity(row.id, newId, name, node.type, filePath, node.signature);
|
|
361
375
|
}
|
|
362
376
|
}
|
|
363
377
|
finally {
|
|
@@ -741,7 +755,17 @@ class DevMindDatabase {
|
|
|
741
755
|
return result;
|
|
742
756
|
}
|
|
743
757
|
updateHistory(params) {
|
|
744
|
-
const { node_id, code_snapshot
|
|
758
|
+
const { node_id, code_snapshot } = params;
|
|
759
|
+
let reasoning = params.reasoning;
|
|
760
|
+
// The calling AI has no reliable way to know who the human running this
|
|
761
|
+
// machine actually is -- it can only guess ("Claude Code", "AI Assistant",
|
|
762
|
+
// etc). Whenever this project has a configured developer identity (from
|
|
763
|
+
// .env's DEVELOPER_NAME, set by `devsmind init`), that's authoritative and
|
|
764
|
+
// always overrides whatever the agent supplied, so history is attributed
|
|
765
|
+
// to the real developer regardless of what the agent wrote in this field.
|
|
766
|
+
if (typeof reasoning === 'object' && this.context?.developer?.name) {
|
|
767
|
+
reasoning = { ...reasoning, developer: this.context.developer.name };
|
|
768
|
+
}
|
|
745
769
|
const node = this.getNode(node_id);
|
|
746
770
|
const resolvedId = node ? node.id : node_id;
|
|
747
771
|
const formattedReasoning = formatReasoning(reasoning);
|
|
@@ -753,20 +777,30 @@ class DevMindDatabase {
|
|
|
753
777
|
const lastUpdate = new Date(latest.updated_at).getTime();
|
|
754
778
|
const nowTime = new Date(nowStr).getTime();
|
|
755
779
|
const diffMs = nowTime - lastUpdate;
|
|
756
|
-
// If updated < 1 hour ago, update same record
|
|
780
|
+
// If updated < 1 hour ago, update the same record IN PLACE (no new row — this is what
|
|
781
|
+
// keeps db/graph/history from bloating with one entry per commit during an active editing
|
|
782
|
+
// session). code_snapshot is always the latest state (git already owns version history for
|
|
783
|
+
// code). reasoning is APPENDED, not overwritten — an earlier commit's "why" in this same
|
|
784
|
+
// 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.
|
|
757
787
|
if (diffMs < 3600000) {
|
|
788
|
+
const previousReasoning = typeof latest.reasoning === 'string' ? latest.reasoning : '';
|
|
789
|
+
const mergedReasoning = previousReasoning.trim().length > 0
|
|
790
|
+
? `${previousReasoning}\n\n── Update @ ${nowStr} ──\n${formattedReasoning}`
|
|
791
|
+
: formattedReasoning;
|
|
758
792
|
const updateStmt = this.db.prepare(`
|
|
759
793
|
UPDATE history
|
|
760
794
|
SET code_snapshot = '', reasoning = ?, updated_at = ?
|
|
761
795
|
WHERE id = ?
|
|
762
796
|
`);
|
|
763
|
-
updateStmt.run(
|
|
797
|
+
updateStmt.run(mergedReasoning, nowStr, latest.id);
|
|
764
798
|
// Write/Update on disk
|
|
765
|
-
this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot,
|
|
799
|
+
this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, mergedReasoning);
|
|
766
800
|
return {
|
|
767
801
|
...latest,
|
|
768
802
|
code_snapshot,
|
|
769
|
-
reasoning:
|
|
803
|
+
reasoning: mergedReasoning,
|
|
770
804
|
updated_at: nowStr
|
|
771
805
|
};
|
|
772
806
|
}
|
|
@@ -792,7 +826,14 @@ class DevMindDatabase {
|
|
|
792
826
|
};
|
|
793
827
|
}
|
|
794
828
|
// --- Search Operations ---
|
|
795
|
-
|
|
829
|
+
/**
|
|
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.
|
|
835
|
+
*/
|
|
836
|
+
searchNodes(query, opts = {}) {
|
|
796
837
|
const stmt = this.db.prepare(`
|
|
797
838
|
SELECT DISTINCT n.* FROM nodes n
|
|
798
839
|
LEFT JOIN history h ON n.id = h.node_id
|
|
@@ -800,7 +841,16 @@ class DevMindDatabase {
|
|
|
800
841
|
LIMIT 50
|
|
801
842
|
`);
|
|
802
843
|
const wildcard = `%${query}%`;
|
|
803
|
-
|
|
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
|
|
852
|
+
});
|
|
853
|
+
return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
|
|
804
854
|
}
|
|
805
855
|
getRecentChanges(hours = 24, analyzeImpact = true) {
|
|
806
856
|
const stmt = this.db.prepare(`
|
|
@@ -842,11 +892,11 @@ class DevMindDatabase {
|
|
|
842
892
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
843
893
|
FROM history h
|
|
844
894
|
JOIN nodes n ON h.node_id = n.id
|
|
845
|
-
WHERE h.reasoning LIKE ?
|
|
895
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
846
896
|
ORDER BY h.updated_at DESC
|
|
847
897
|
LIMIT ?
|
|
848
898
|
`);
|
|
849
|
-
const query = `%Developer: %${developer}%`;
|
|
899
|
+
const query = `%Developer: %${this.likeEscape(developer)}%`;
|
|
850
900
|
return stmt.all(query, limit);
|
|
851
901
|
}
|
|
852
902
|
getChangesByRequirement(requirementId) {
|
|
@@ -854,10 +904,10 @@ class DevMindDatabase {
|
|
|
854
904
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
855
905
|
FROM history h
|
|
856
906
|
JOIN nodes n ON h.node_id = n.id
|
|
857
|
-
WHERE h.reasoning LIKE ?
|
|
907
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
858
908
|
ORDER BY h.updated_at DESC
|
|
859
909
|
`);
|
|
860
|
-
const query = `%Requirement: %${requirementId}%`;
|
|
910
|
+
const query = `%Requirement: %${this.likeEscape(requirementId)}%`;
|
|
861
911
|
return stmt.all(query);
|
|
862
912
|
}
|
|
863
913
|
searchDecisions(query) {
|
|
@@ -865,10 +915,10 @@ class DevMindDatabase {
|
|
|
865
915
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
866
916
|
FROM history h
|
|
867
917
|
JOIN nodes n ON h.node_id = n.id
|
|
868
|
-
WHERE h.reasoning LIKE ?
|
|
918
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
869
919
|
ORDER BY h.updated_at DESC
|
|
870
920
|
`);
|
|
871
|
-
const wildcard = `%Decision: %${query}%`;
|
|
921
|
+
const wildcard = `%Decision: %${this.likeEscape(query)}%`;
|
|
872
922
|
return stmt.all(wildcard);
|
|
873
923
|
}
|
|
874
924
|
searchCode(params) {
|
|
@@ -941,7 +991,8 @@ class DevMindDatabase {
|
|
|
941
991
|
getOrphanedNodes() {
|
|
942
992
|
const stmt = this.db.prepare(`
|
|
943
993
|
SELECT * FROM nodes
|
|
944
|
-
WHERE
|
|
994
|
+
WHERE deprecated = 0
|
|
995
|
+
AND id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
|
|
945
996
|
AND id NOT IN (SELECT DISTINCT target_node_id FROM node_connections)
|
|
946
997
|
`);
|
|
947
998
|
return stmt.all();
|
|
@@ -976,48 +1027,469 @@ class DevMindDatabase {
|
|
|
976
1027
|
const rows = stmt.all();
|
|
977
1028
|
return rows.map(row => this.populateHistoryFromDisk(row));
|
|
978
1029
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1030
|
+
// ─── `devsmind analyze` read-only health checks ────────────────────────────
|
|
1031
|
+
// All pure queries/graph traversal, no mutation, no LLM calls.
|
|
1032
|
+
/** Nodes whose total (in + out) connection degree meets/exceeds `threshold` — architectural bottleneck candidates. */
|
|
1033
|
+
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
|
|
1045
|
+
`);
|
|
1046
|
+
return stmt.all(threshold);
|
|
1047
|
+
}
|
|
1048
|
+
/** DFS cycle detection over the connection graph, capped at `maxCycles` reported paths. */
|
|
1049
|
+
getCircularDependencies(maxCycles = 50) {
|
|
1050
|
+
const edges = this.getAllConnections();
|
|
1051
|
+
const adjacency = new Map();
|
|
1052
|
+
for (const e of edges) {
|
|
1053
|
+
if (!adjacency.has(e.source_node_id))
|
|
1054
|
+
adjacency.set(e.source_node_id, []);
|
|
1055
|
+
adjacency.get(e.source_node_id).push(e.target_node_id);
|
|
1056
|
+
}
|
|
1057
|
+
const cycles = [];
|
|
1058
|
+
const visited = new Set();
|
|
1059
|
+
const stack = [];
|
|
1060
|
+
const onStack = new Set();
|
|
1061
|
+
const dfs = (node) => {
|
|
1062
|
+
if (cycles.length >= maxCycles)
|
|
1063
|
+
return;
|
|
1064
|
+
if (onStack.has(node)) {
|
|
1065
|
+
const start = stack.indexOf(node);
|
|
1066
|
+
cycles.push([...stack.slice(start), node]);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (visited.has(node))
|
|
1070
|
+
return;
|
|
1071
|
+
visited.add(node);
|
|
1072
|
+
stack.push(node);
|
|
1073
|
+
onStack.add(node);
|
|
1074
|
+
for (const next of adjacency.get(node) || []) {
|
|
1075
|
+
if (cycles.length >= maxCycles)
|
|
1076
|
+
break;
|
|
1077
|
+
dfs(next);
|
|
1078
|
+
}
|
|
1079
|
+
stack.pop();
|
|
1080
|
+
onStack.delete(node);
|
|
1081
|
+
};
|
|
1082
|
+
for (const node of adjacency.keys()) {
|
|
1083
|
+
if (cycles.length >= maxCycles)
|
|
1084
|
+
break;
|
|
1085
|
+
if (!visited.has(node))
|
|
1086
|
+
dfs(node);
|
|
1087
|
+
}
|
|
1088
|
+
return cycles;
|
|
1089
|
+
}
|
|
1090
|
+
/** node_connections rows whose source or target no longer exists in `nodes` (broken by a non-transactional delete, or a sync race). */
|
|
1091
|
+
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)
|
|
1096
|
+
`);
|
|
1097
|
+
return stmt.all();
|
|
1098
|
+
}
|
|
1099
|
+
/** Deletes a single dangling `node_connections` row. The edge itself is invalid data — no history/graph JSON to rewrite. */
|
|
1100
|
+
deleteDanglingEdge(sourceId, targetId) {
|
|
1101
|
+
this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? AND target_node_id = ?').run(sourceId, targetId);
|
|
1102
|
+
}
|
|
1103
|
+
/** Node ids that differ only by case — a real collision risk on Windows's case-insensitive filesystem. */
|
|
1104
|
+
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
|
|
1111
|
+
`);
|
|
1112
|
+
const rows = stmt.all();
|
|
1113
|
+
return rows.map(r => ({ lowerId: r.lower_id, ids: r.ids.split('|') }));
|
|
1114
|
+
}
|
|
1115
|
+
/** History rows whose flattened `reasoning` text has no non-empty `Developer:` line — can't be attributed to anyone. */
|
|
1116
|
+
getHistoryMissingDeveloper() {
|
|
1117
|
+
const stmt = this.db.prepare('SELECT id, node_id, updated_at, reasoning FROM history');
|
|
1118
|
+
const rows = stmt.all();
|
|
1119
|
+
return rows
|
|
1120
|
+
.filter(r => {
|
|
1121
|
+
// [ \t]* (not \s*) so the match can't swallow the newline and bleed into the
|
|
1122
|
+
// next "Model:" line when Developer is blank, which would wrongly capture
|
|
1123
|
+
// "Model: <value>" as if it were the developer's name.
|
|
1124
|
+
const match = /Developer:[ \t]*([^\n]*)/i.exec(r.reasoning || '');
|
|
1125
|
+
return !match || !match[1].trim();
|
|
1126
|
+
})
|
|
1127
|
+
.map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* History rows with a blank code snapshot — usually a silent AST extraction failure.
|
|
1131
|
+
* The `history.code_snapshot` DB column is always written as `''` (the real content
|
|
1132
|
+
* lives only in the per-row JSON on disk, see `populateHistoryFromDisk`), so this
|
|
1133
|
+
* must read through the populated rows rather than querying the column directly.
|
|
1134
|
+
*/
|
|
1135
|
+
getEmptyCodeSnapshots() {
|
|
1136
|
+
return this.getAllHistory()
|
|
1137
|
+
.filter(h => !h.code_snapshot || h.code_snapshot.trim() === '')
|
|
1138
|
+
.map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
|
|
1139
|
+
}
|
|
1140
|
+
// ─── Workflow Context Vault ─────────────────────────────────────────────
|
|
1141
|
+
// Persistent, cross-session feature memory. Steps link to existing `history`
|
|
1142
|
+
// rows rather than duplicating code/reasoning; artifacts are plain files on
|
|
1143
|
+
// disk under `.devmind/workflows/<id>/`, with only the path stored in the DB.
|
|
1144
|
+
workflowsDir() {
|
|
1145
|
+
return path.join(path.dirname(this.dbPath), 'workflows');
|
|
1146
|
+
}
|
|
1147
|
+
/** Serializes the workflow + its steps + artifact index to disk so teammates can sync it via git. */
|
|
1148
|
+
writeWorkflowToDisk(workflowId) {
|
|
1149
|
+
try {
|
|
1150
|
+
const workflow = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(workflowId);
|
|
1151
|
+
if (!workflow)
|
|
1152
|
+
return;
|
|
1153
|
+
const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
|
|
1154
|
+
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
|
+
const data = {
|
|
1157
|
+
id: workflow.id,
|
|
1158
|
+
name: workflow.name,
|
|
1159
|
+
description: workflow.description,
|
|
1160
|
+
status: workflow.status,
|
|
1161
|
+
created_at: workflow.created_at,
|
|
1162
|
+
updated_at: workflow.updated_at,
|
|
1163
|
+
is_active: activeId === workflowId,
|
|
1164
|
+
steps: steps.map(s => ({
|
|
1165
|
+
id: s.id,
|
|
1166
|
+
step_index: s.step_index,
|
|
1167
|
+
summary: s.summary,
|
|
1168
|
+
pending_tasks: s.pending_tasks,
|
|
1169
|
+
history_ids: s.history_ids,
|
|
1170
|
+
session_id: s.session_id,
|
|
1171
|
+
created_at: s.created_at
|
|
1172
|
+
})),
|
|
1173
|
+
artifact_index: artifacts.map(a => ({
|
|
1174
|
+
id: a.id,
|
|
1175
|
+
step_id: a.step_id,
|
|
1176
|
+
type: a.type,
|
|
1177
|
+
source_name: a.source_name,
|
|
1178
|
+
file_path: a.file_path,
|
|
1179
|
+
created_at: a.created_at
|
|
1180
|
+
}))
|
|
1181
|
+
};
|
|
1182
|
+
const dir = path.join(this.workflowsDir(), workflowId);
|
|
1183
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1184
|
+
fs.writeFileSync(path.join(dir, 'workflow.json'), JSON.stringify(data, null, 2), 'utf-8');
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
console.warn('⚠️ DevsMind: Failed to write workflow JSON to disk:', err);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
createWorkflow(name, description) {
|
|
1191
|
+
const id = `wf_${crypto.randomUUID()}`;
|
|
1192
|
+
const now = new Date().toISOString();
|
|
1193
|
+
this.db.prepare(`
|
|
1194
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1195
|
+
VALUES (?, ?, ?, 'active', ?, ?)
|
|
1196
|
+
`).run(id, name, description, now, now);
|
|
1197
|
+
this.setSystemMeta('active_workflow_id', id);
|
|
1198
|
+
this.writeWorkflowToDisk(id);
|
|
1199
|
+
return { id, name, description, status: 'active', created_at: now, updated_at: now };
|
|
1200
|
+
}
|
|
1201
|
+
getWorkflow(id) {
|
|
1202
|
+
const row = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(id);
|
|
1203
|
+
return row || null;
|
|
1204
|
+
}
|
|
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);
|
|
1212
|
+
}
|
|
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 };
|
|
1239
|
+
}
|
|
1240
|
+
completeWorkflow(id) {
|
|
1241
|
+
const workflow = this.getWorkflow(id);
|
|
1242
|
+
if (!workflow)
|
|
1243
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1244
|
+
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', '');
|
|
1248
|
+
this.writeWorkflowToDisk(id);
|
|
1249
|
+
return { ...workflow, status: 'completed', updated_at: now };
|
|
1250
|
+
}
|
|
1251
|
+
addWorkflowStep(workflowId, opts) {
|
|
1252
|
+
if (!this.getWorkflow(workflowId))
|
|
1253
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1254
|
+
const id = crypto.randomUUID();
|
|
1255
|
+
const now = new Date().toISOString();
|
|
1256
|
+
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);
|
|
1262
|
+
this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
|
|
1263
|
+
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 };
|
|
1265
|
+
}
|
|
1266
|
+
/** Writes `content` to `.devmind/workflows/<workflowId>/<artifactId>_<sourceName>` and records the DB row. */
|
|
1267
|
+
addWorkflowArtifact(workflowId, opts) {
|
|
1268
|
+
if (!this.getWorkflow(workflowId))
|
|
1269
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1270
|
+
const id = crypto.randomUUID();
|
|
1271
|
+
const now = new Date().toISOString();
|
|
1272
|
+
const safeName = opts.sourceName.replace(/[^a-zA-Z0-9._-]/g, '_') || 'artifact.md';
|
|
1273
|
+
const dir = path.join(this.workflowsDir(), workflowId);
|
|
1274
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1275
|
+
const filePath = path.join(dir, `${id}_${safeName}`);
|
|
1276
|
+
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 (?, ?, ?, ?, ?, ?, ?)
|
|
1280
|
+
`).run(id, workflowId, opts.stepId || null, opts.type, opts.sourceName, filePath, now);
|
|
1281
|
+
this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
|
|
1282
|
+
this.writeWorkflowToDisk(workflowId);
|
|
1283
|
+
return { id, workflow_id: workflowId, step_id: opts.stepId || null, type: opts.type, source_name: opts.sourceName, file_path: filePath, created_at: now };
|
|
1284
|
+
}
|
|
1285
|
+
getWorkflowContext(id, opts) {
|
|
1286
|
+
const workflow = this.getWorkflow(id);
|
|
1287
|
+
if (!workflow)
|
|
1288
|
+
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 };
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Returns steps for a workflow with optional pagination.
|
|
1306
|
+
* Use `last_n` to get only the most recent N steps (tail), or `limit`/`offset` for
|
|
1307
|
+
* arbitrary pagination. Without any option, all steps are returned.
|
|
1308
|
+
*/
|
|
1309
|
+
getWorkflowSteps(workflowId, opts) {
|
|
1310
|
+
if (!this.getWorkflow(workflowId))
|
|
1311
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1312
|
+
if (opts?.last_n && opts.last_n > 0) {
|
|
1313
|
+
// Fetch the last N steps by descending step_index, then reverse to chronological
|
|
1314
|
+
const rows = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index DESC LIMIT ?').all(workflowId, opts.last_n);
|
|
1315
|
+
return rows.reverse();
|
|
1316
|
+
}
|
|
1317
|
+
if (opts?.limit) {
|
|
1318
|
+
return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC LIMIT ? OFFSET ?').all(workflowId, opts.limit, opts.offset ?? 0);
|
|
1319
|
+
}
|
|
1320
|
+
return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
|
|
1321
|
+
}
|
|
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)
|
|
984
1392
|
]);
|
|
985
|
-
|
|
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
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Imports an existing flow/architecture doc as a paused workflow (not active — importing
|
|
1415
|
+
* a doc isn't the same as declaring active work). Idempotent on `name`: re-importing the
|
|
1416
|
+
* same doc overwrites its existing `imported_doc` artifact file in place instead of
|
|
1417
|
+
* creating a duplicate workflow every time the source docs are re-imported.
|
|
1418
|
+
*/
|
|
1419
|
+
importWorkflowDoc(name, description, content, sourceFileName) {
|
|
1420
|
+
const existing = this.db.prepare('SELECT * FROM workflows WHERE name = ?').get(name);
|
|
1421
|
+
const now = new Date().toISOString();
|
|
1422
|
+
if (existing) {
|
|
1423
|
+
this.db.prepare(`UPDATE workflows SET description = ?, updated_at = ? WHERE id = ?`).run(description, now, existing.id);
|
|
1424
|
+
const existingArtifact = this.db.prepare(`SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND type = 'imported_doc' ORDER BY created_at ASC LIMIT 1`).get(existing.id);
|
|
1425
|
+
if (existingArtifact) {
|
|
1426
|
+
fs.writeFileSync(existingArtifact.file_path, content, 'utf-8');
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
this.addWorkflowArtifact(existing.id, { type: 'imported_doc', sourceName: sourceFileName, content });
|
|
1430
|
+
}
|
|
1431
|
+
this.writeWorkflowToDisk(existing.id);
|
|
1432
|
+
return { workflow: { ...existing, description, updated_at: now }, created: false };
|
|
1433
|
+
}
|
|
1434
|
+
const id = `wf_${crypto.randomUUID()}`;
|
|
1435
|
+
this.db.prepare(`
|
|
1436
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1437
|
+
VALUES (?, ?, ?, 'paused', ?, ?)
|
|
1438
|
+
`).run(id, name, description, now, now);
|
|
1439
|
+
this.addWorkflowStep(id, { summary: `Imported existing flow documentation: ${sourceFileName}` });
|
|
1440
|
+
this.addWorkflowArtifact(id, { type: 'imported_doc', sourceName: sourceFileName, content });
|
|
1441
|
+
// writeWorkflowToDisk is already called inside addWorkflowArtifact/addWorkflowStep above
|
|
1442
|
+
return { workflow: { id, name, description, status: 'paused', created_at: now, updated_at: now }, created: true };
|
|
1443
|
+
}
|
|
1444
|
+
static SPURIOUS_NODE_NAMES = new Set([
|
|
1445
|
+
'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
|
|
1446
|
+
'any', 'void', 'unknown', 'never', 'null', 'undefined', 'dict', 'list',
|
|
1447
|
+
'data', 'useeffect', 'val', 'temp', 'result', 'item', 'key', 'value', 'err', 'req', 'res', 'args', 'params', 'response', 'request'
|
|
1448
|
+
]);
|
|
1449
|
+
/**
|
|
1450
|
+
* Read-only detection shared by `pruneSpuriousNodes` (which acts on it) and `devsmind
|
|
1451
|
+
* analyze`'s dry-run report (which just lists it). Never mutates the DB.
|
|
1452
|
+
*/
|
|
1453
|
+
findSpuriousAndMissingFileNodes(workspaceRoot) {
|
|
986
1454
|
const stmt = this.db.prepare(`
|
|
987
1455
|
SELECT id, name, file_path FROM nodes
|
|
988
1456
|
WHERE deprecated = 0
|
|
989
1457
|
`);
|
|
990
1458
|
const candidates = stmt.all();
|
|
991
|
-
const
|
|
992
|
-
const
|
|
993
|
-
const affectedFilePaths = new Set();
|
|
1459
|
+
const spurious = [];
|
|
1460
|
+
const missingFile = [];
|
|
994
1461
|
for (const node of candidates) {
|
|
995
1462
|
const lowerName = node.name.toLowerCase();
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1463
|
+
if (DevMindDatabase.SPURIOUS_NODE_NAMES.has(lowerName)) {
|
|
1464
|
+
spurious.push(node);
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1000
1467
|
if (node.file_path) {
|
|
1001
1468
|
const paths = node.file_path.split(',').map(p => p.trim()).filter(Boolean);
|
|
1002
1469
|
if (paths.length > 0) {
|
|
1003
1470
|
const allMissing = paths.every(p => {
|
|
1004
|
-
const resolvedPath = path.isAbsolute(p)
|
|
1005
|
-
? p
|
|
1006
|
-
: path.resolve(workspaceRoot, p);
|
|
1471
|
+
const resolvedPath = path.isAbsolute(p) ? p : path.resolve(workspaceRoot, p);
|
|
1007
1472
|
return !fs.existsSync(resolvedPath);
|
|
1008
1473
|
});
|
|
1009
|
-
if (allMissing)
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1474
|
+
if (allMissing)
|
|
1475
|
+
missingFile.push(node);
|
|
1012
1476
|
}
|
|
1013
1477
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1478
|
+
}
|
|
1479
|
+
return { spurious, missingFile };
|
|
1480
|
+
}
|
|
1481
|
+
pruneSpuriousNodes(workspaceRoot) {
|
|
1482
|
+
const { spurious, missingFile } = this.findSpuriousAndMissingFileNodes(workspaceRoot);
|
|
1483
|
+
const candidates = [...spurious, ...missingFile];
|
|
1484
|
+
const idsToDelete = [];
|
|
1485
|
+
const namesDeleted = [];
|
|
1486
|
+
const affectedFilePaths = new Set();
|
|
1487
|
+
for (const node of candidates) {
|
|
1488
|
+
idsToDelete.push(node.id);
|
|
1489
|
+
namesDeleted.push(`${node.name} (${node.id})`);
|
|
1490
|
+
if (node.file_path) {
|
|
1491
|
+
for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
1492
|
+
affectedFilePaths.add(p);
|
|
1021
1493
|
}
|
|
1022
1494
|
}
|
|
1023
1495
|
}
|
|
@@ -1110,41 +1582,120 @@ class DevMindDatabase {
|
|
|
1110
1582
|
toRepoRelativePath(absolutePath) {
|
|
1111
1583
|
if (!absolutePath || !this.context)
|
|
1112
1584
|
return absolutePath;
|
|
1113
|
-
const abs =
|
|
1585
|
+
const abs = (0, config_1.canonicalizePath)(absolutePath).replace(/\\/g, '/');
|
|
1586
|
+
const absLower = abs.toLowerCase();
|
|
1114
1587
|
for (const repo of this.context.config.repos) {
|
|
1115
1588
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1116
1589
|
if (repoPath) {
|
|
1117
|
-
const normalizedRepoPath =
|
|
1118
|
-
|
|
1590
|
+
const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath).replace(/\\/g, '/');
|
|
1591
|
+
const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
|
|
1592
|
+
if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + '/')) {
|
|
1119
1593
|
const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
|
|
1120
1594
|
return `{${repo.name}}/${relative}`;
|
|
1121
1595
|
}
|
|
1122
1596
|
}
|
|
1123
1597
|
}
|
|
1124
1598
|
// Fallback: resolve relative to workspace root
|
|
1125
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1599
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1126
1600
|
return path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
|
|
1127
1601
|
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Rejects a resolved path that escapes its expected root (e.g. via a stored
|
|
1604
|
+
* `{repo}/../../..` path traveling outside the repo) by clamping it back to
|
|
1605
|
+
* the root itself. node_id/file_path values flow in from AI-supplied tool
|
|
1606
|
+
* calls, so a resolve must never be trusted to stay inside root on its own.
|
|
1607
|
+
*/
|
|
1608
|
+
clampToRoot(root, resolved) {
|
|
1609
|
+
const normalizedRoot = (0, config_1.canonicalizePath)(root);
|
|
1610
|
+
const normalizedResolved = (0, config_1.canonicalizePath)(resolved);
|
|
1611
|
+
const rootLower = normalizedRoot.toLowerCase();
|
|
1612
|
+
const resolvedLower = normalizedResolved.toLowerCase();
|
|
1613
|
+
if (resolvedLower === rootLower || resolvedLower.startsWith(rootLower + path.sep)) {
|
|
1614
|
+
return normalizedResolved;
|
|
1615
|
+
}
|
|
1616
|
+
console.warn(`⚠️ Path traversal blocked: "${resolved}" escapes root "${root}"`);
|
|
1617
|
+
return normalizedRoot;
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* True if `absPath` sits inside a configured repo root or the workspace root itself.
|
|
1621
|
+
* Used to reject `stage_change`/`update_history` file paths that would otherwise let a
|
|
1622
|
+
* tool call read/write any file on disk (absolute path, or a `../` escape) instead of
|
|
1623
|
+
* just repo source — nothing upstream of this validates that the AI-supplied path is
|
|
1624
|
+
* actually inside the project.
|
|
1625
|
+
*/
|
|
1626
|
+
isPathAllowed(absPath) {
|
|
1627
|
+
const abs = (0, config_1.canonicalizePath)(absPath);
|
|
1628
|
+
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;
|
|
1633
|
+
if (this.context) {
|
|
1634
|
+
for (const repo of this.context.config.repos) {
|
|
1635
|
+
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1636
|
+
if (repoPath) {
|
|
1637
|
+
const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath);
|
|
1638
|
+
const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
|
|
1639
|
+
if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + path.sep))
|
|
1640
|
+
return true;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return false;
|
|
1645
|
+
}
|
|
1128
1646
|
toAbsolutePath(repoRelativePath) {
|
|
1129
1647
|
if (!repoRelativePath)
|
|
1130
1648
|
return repoRelativePath;
|
|
1131
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1649
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1132
1650
|
const match = repoRelativePath.match(/^\{([^}]+)\}\/(.*)$/);
|
|
1133
1651
|
if (match && this.context) {
|
|
1134
1652
|
const repoName = match[1];
|
|
1135
1653
|
const relativePath = match[2];
|
|
1136
1654
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repoName);
|
|
1137
1655
|
if (repoPath) {
|
|
1138
|
-
return path.resolve(repoPath, relativePath);
|
|
1656
|
+
return (0, config_1.canonicalizePath)(this.clampToRoot(repoPath, path.resolve(repoPath, relativePath)));
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
// Heuristic: if it doesn't start with {repoName} but contains a configured repo name in the path
|
|
1660
|
+
if (this.context) {
|
|
1661
|
+
for (const repo of this.context.config.repos) {
|
|
1662
|
+
// Find if repo.name appears as a folder in the path, e.g. "harrir-express-backend/tests/..."
|
|
1663
|
+
const escapedRepoName = repo.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1664
|
+
const regex = new RegExp('(?:^|/|\\\\)' + escapedRepoName + '(?:/|\\\\)(.*)$', 'i');
|
|
1665
|
+
const m = repoRelativePath.match(regex);
|
|
1666
|
+
if (m) {
|
|
1667
|
+
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1668
|
+
if (repoPath) {
|
|
1669
|
+
const relativePath = m[1];
|
|
1670
|
+
return (0, config_1.canonicalizePath)(path.resolve(repoPath, relativePath));
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1139
1673
|
}
|
|
1140
1674
|
}
|
|
1141
1675
|
// Fallback: resolve relative to workspace root
|
|
1142
|
-
return path.resolve(workspaceRoot, repoRelativePath);
|
|
1676
|
+
return (0, config_1.canonicalizePath)(this.clampToRoot(workspaceRoot, path.resolve(workspaceRoot, repoRelativePath)));
|
|
1143
1677
|
}
|
|
1144
1678
|
syncFromDisk() {
|
|
1145
1679
|
this.db.pragma('foreign_keys = OFF');
|
|
1146
1680
|
try {
|
|
1147
1681
|
const workspaceRoot = path.dirname(this.dbPath);
|
|
1682
|
+
// 0. Auto-heal any legacy relative path records in SQLite
|
|
1683
|
+
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();
|
|
1685
|
+
if (legacyNodes.length > 0) {
|
|
1686
|
+
const updateStmt = this.db.prepare('UPDATE nodes SET file_path = ? WHERE id = ?');
|
|
1687
|
+
const healTx = this.db.transaction(() => {
|
|
1688
|
+
for (const n of legacyNodes) {
|
|
1689
|
+
const abs = this.toAbsolutePath(n.file_path);
|
|
1690
|
+
updateStmt.run(abs, n.id);
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
healTx();
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
// ignore legacy errors
|
|
1698
|
+
}
|
|
1148
1699
|
// 1. Sync History JSONs
|
|
1149
1700
|
const historyDir = path.join(workspaceRoot, 'history');
|
|
1150
1701
|
if (fs.existsSync(historyDir)) {
|
|
@@ -1249,6 +1800,65 @@ class DevMindDatabase {
|
|
|
1249
1800
|
syncGraphTx();
|
|
1250
1801
|
}
|
|
1251
1802
|
}
|
|
1803
|
+
// 3. Sync Workflow JSONs
|
|
1804
|
+
const workflowsDir = this.workflowsDir();
|
|
1805
|
+
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
|
|
1814
|
+
`);
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1818
|
+
`);
|
|
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 (?, ?, ?, ?, ?, ?, ?)
|
|
1822
|
+
`);
|
|
1823
|
+
// Track which workflow.json has is_active:true with the latest updated_at
|
|
1824
|
+
let bestActiveId = null;
|
|
1825
|
+
let bestActiveUpdatedAt = '';
|
|
1826
|
+
const syncWorkflowsTx = this.db.transaction(() => {
|
|
1827
|
+
const subdirs = fs.readdirSync(workflowsDir);
|
|
1828
|
+
for (const subdir of subdirs) {
|
|
1829
|
+
const jsonPath = path.join(workflowsDir, subdir, 'workflow.json');
|
|
1830
|
+
if (!fs.existsSync(jsonPath))
|
|
1831
|
+
continue;
|
|
1832
|
+
try {
|
|
1833
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
1834
|
+
if (!data.id || !data.name)
|
|
1835
|
+
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());
|
|
1837
|
+
for (const s of (data.steps || [])) {
|
|
1838
|
+
if (!s.id)
|
|
1839
|
+
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());
|
|
1841
|
+
}
|
|
1842
|
+
for (const a of (data.artifact_index || [])) {
|
|
1843
|
+
if (!a.id)
|
|
1844
|
+
continue;
|
|
1845
|
+
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
|
+
}
|
|
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
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
catch { /* skip malformed */ }
|
|
1854
|
+
}
|
|
1855
|
+
});
|
|
1856
|
+
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
|
+
}
|
|
1252
1862
|
}
|
|
1253
1863
|
catch (err) {
|
|
1254
1864
|
console.warn('⚠️ SQLite warning: Failed to sync from disk:', err);
|
|
@@ -1265,9 +1875,9 @@ class DevMindDatabase {
|
|
|
1265
1875
|
try {
|
|
1266
1876
|
if (!filePath)
|
|
1267
1877
|
return;
|
|
1268
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1878
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1269
1879
|
// Clean/resolve the file path
|
|
1270
|
-
const absPath =
|
|
1880
|
+
const absPath = (0, config_1.canonicalizePath)(filePath);
|
|
1271
1881
|
const repoRelPath = this.toRepoRelativePath(absPath);
|
|
1272
1882
|
// E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
|
|
1273
1883
|
const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
|
|
@@ -1281,16 +1891,18 @@ class DevMindDatabase {
|
|
|
1281
1891
|
// is durable across a syncFromDisk() restart and propagates to teammates via git —
|
|
1282
1892
|
// otherwise the node's history JSON would resurrect it as active on the next start.
|
|
1283
1893
|
const absEsc = this.likeEscape(absPath);
|
|
1894
|
+
const absLower = absPath.toLowerCase();
|
|
1895
|
+
const absEscLower = absEsc.toLowerCase();
|
|
1284
1896
|
const stmtNodes = this.db.prepare(`
|
|
1285
1897
|
SELECT * FROM nodes
|
|
1286
1898
|
WHERE (
|
|
1287
|
-
file_path = ? OR
|
|
1288
|
-
file_path LIKE ? ESCAPE '\\' OR
|
|
1289
|
-
file_path LIKE ? ESCAPE '\\' OR
|
|
1290
|
-
file_path LIKE ? ESCAPE '\\'
|
|
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 '\\'
|
|
1291
1903
|
)
|
|
1292
1904
|
`);
|
|
1293
|
-
const nodes = stmtNodes.all(
|
|
1905
|
+
const nodes = stmtNodes.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`);
|
|
1294
1906
|
if (nodes.length === 0) {
|
|
1295
1907
|
// If no nodes left, delete the JSON file if it exists
|
|
1296
1908
|
if (fs.existsSync(graphJsonPath)) {
|
|
@@ -1333,6 +1945,30 @@ class DevMindDatabase {
|
|
|
1333
1945
|
console.warn('⚠️ SQLite warning: Failed to write graph JSON to disk:', err);
|
|
1334
1946
|
}
|
|
1335
1947
|
}
|
|
1948
|
+
/** Force-syncs all database nodes and workflows to disk JSON files. */
|
|
1949
|
+
syncToDisk() {
|
|
1950
|
+
try {
|
|
1951
|
+
const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
|
|
1952
|
+
const filePaths = new Set();
|
|
1953
|
+
for (const row of rows) {
|
|
1954
|
+
if (row.file_path) {
|
|
1955
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
1956
|
+
filePaths.add(p);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
for (const filePath of filePaths) {
|
|
1961
|
+
this.writeGraphToDisk(filePath);
|
|
1962
|
+
}
|
|
1963
|
+
const workflowRows = this.db.prepare('SELECT id FROM workflows').all();
|
|
1964
|
+
for (const row of workflowRows) {
|
|
1965
|
+
this.writeWorkflowToDisk(row.id);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
catch (err) {
|
|
1969
|
+
console.warn('⚠️ DevsMind: Failed to sync database to disk:', err);
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1336
1972
|
}
|
|
1337
1973
|
exports.DevMindDatabase = DevMindDatabase;
|
|
1338
1974
|
//# sourceMappingURL=database.js.map
|