devsmind-mcp 4.0.1 → 4.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.
@@ -295,6 +295,8 @@ export declare class DevMindDatabase {
295
295
  nodes: number;
296
296
  connections: number;
297
297
  history: number;
298
+ vectors: number;
299
+ workflows: number;
298
300
  };
299
301
  vacuum(): void;
300
302
  /**
@@ -970,6 +972,27 @@ export declare class DevMindDatabase {
970
972
  syncFromDisk(onProgress?: (phase: string, done: number, total: number) => void): void;
971
973
  /** Escape LIKE metacharacters so a path is matched literally (use with ESCAPE '\\'). */
972
974
  private likeEscape;
975
+ /**
976
+ * True when `targetPath` (a computed `graph/`/`vectors/` JSON path) does NOT land strictly
977
+ * inside `containerDir` as a real file — i.e. writing there would be writing to a directory,
978
+ * not a file inside one. Guards `writeGraphToDisk`/`writeVectorsToDisk` against a malformed
979
+ * node whose `file_path` IS a directory (the workspace root, or a repo root itself): observed
980
+ * in production as an EISDIR crash on `devsmind sync`, because `toRepoRelativePath` collapses
981
+ * to `''` (workspace root) or `'{repo}/'` (a repo root) for those, which `diskRelPath` then
982
+ * turns into `''`/a trailing-slash string/`'..'` depending on which branch collapsed — every
983
+ * one of which makes `path.join(containerDir, diskRelPath)` resolve to `containerDir` itself
984
+ * or some directory already inside it, never a fresh `.json` file. Multiple checks on purpose:
985
+ * the empty/trailing-slash/`..`-relative cases catch it structurally (works even on a brand
986
+ * new brain where nothing exists on disk yet, where an EISDIR would instead be a silent
987
+ * file-where-directory-belongs corruption); the existsSync+isDirectory check is a catch-all
988
+ * for any other collapse shape not foreseen above (existsSync is checked first specifically so
989
+ * a normal not-yet-written target — the common case — never reaches statSync at all). Callers
990
+ * already wrap their whole body in a try/catch that logs and returns, so nothing extra is
991
+ * needed here for a statSync that throws for some other reason (a permission error, a race).
992
+ * `devsmind analyze --fix` deprecates the node actually causing this; this only ever refuses
993
+ * the write, never touches the node.
994
+ */
995
+ private isDegenerateDiskJsonPath;
973
996
  writeGraphToDisk(filePath: string): void;
974
997
  /**
975
998
  * Mirrors `writeGraphToDisk` exactly (same file-matching logic, same directory shape) but into
@@ -979,6 +1002,16 @@ export declare class DevMindDatabase {
979
1002
  * node's vector, so writing one is pure dead weight.
980
1003
  */
981
1004
  writeVectorsToDisk(filePath: string): void;
982
- /** Force-syncs all database nodes and workflows to disk JSON files. */
1005
+ /**
1006
+ * Force-syncs all database nodes, vectors, and workflows to disk JSON files. This is the
1007
+ * write-back half of `devsmind sync` (paired with `syncFromDisk`, which reads `vectors/*.json`
1008
+ * into `node_vectors` — see its comment). Vectors are re-written here for the same reason
1009
+ * graph JSON is: normal operation already keeps `vectors/` current (`writeVectorsToDisk` runs
1010
+ * immediately alongside every embedding write), but `sync` exists precisely for the abnormal
1011
+ * case — recovering a `.devmind` after `vectors/*.json` was deleted/corrupted independently of
1012
+ * the DB, or after a past write silently failed (e.g. the directory-typed file_path bug
1013
+ * `isDegenerateDiskJsonPath` now catches) — so leaving vectors out of the force-resync
1014
+ * defeated half the point of running it.
1015
+ */
983
1016
  syncToDisk(): void;
984
1017
  }
@@ -381,7 +381,7 @@ class DevMindDatabase {
381
381
  /* istanbul ignore next -- every call site here is a `SELECT COUNT(*) AS c FROM ...`,
382
382
  which always returns exactly one row; `row` can only be undefined if this helper is
383
383
  ever repurposed for a query that can return zero rows. Kept as a real guard, not
384
- because today's three call sites can hit it. */
384
+ because today's call sites can hit it. */
385
385
  return row ? row.c : 0;
386
386
  }
387
387
  catch {
@@ -392,6 +392,8 @@ class DevMindDatabase {
392
392
  nodes: one('SELECT COUNT(*) AS c FROM nodes WHERE deprecated = 0'),
393
393
  connections: one('SELECT COUNT(*) AS c FROM node_connections'),
394
394
  history: one('SELECT COUNT(*) AS c FROM history'),
395
+ vectors: one('SELECT COUNT(*) AS c FROM node_vectors'),
396
+ workflows: one('SELECT COUNT(*) AS c FROM workflows'),
395
397
  };
396
398
  }
397
399
  vacuum() {
@@ -2797,9 +2799,21 @@ class DevMindDatabase {
2797
2799
  if (node.file_path) {
2798
2800
  const paths = node.file_path.split(',').map(p => p.trim()).filter(Boolean);
2799
2801
  if (paths.length > 0) {
2802
+ // "Missing" also covers a file_path that resolves to a DIRECTORY rather than a real
2803
+ // file — plain fs.existsSync() alone says a directory "exists", so a node corrupted
2804
+ // this way (its file_path IS a repo or workspace root, from some earlier bug) sailed
2805
+ // through this check indefinitely; it only ever surfaced as an EISDIR crash when
2806
+ // writeGraphToDisk/writeVectorsToDisk later tried to write a JSON file at that same
2807
+ // path. statSync().isFile() catches both "doesn't exist" and "exists but isn't a file"
2808
+ // in one check — a node deprecated for either reason has no real source to hold anyway.
2800
2809
  const allMissing = paths.every(p => {
2801
2810
  const resolvedPath = path.isAbsolute(p) ? p : path.resolve(workspaceRoot, p);
2802
- return !fs.existsSync(resolvedPath);
2811
+ try {
2812
+ return !fs.statSync(resolvedPath).isFile();
2813
+ }
2814
+ catch {
2815
+ return true;
2816
+ }
2803
2817
  });
2804
2818
  if (allMissing)
2805
2819
  missingFile.push(node);
@@ -3351,6 +3365,34 @@ class DevMindDatabase {
3351
3365
  likeEscape(s) {
3352
3366
  return s.replace(/[\\%_]/g, ch => '\\' + ch);
3353
3367
  }
3368
+ /**
3369
+ * True when `targetPath` (a computed `graph/`/`vectors/` JSON path) does NOT land strictly
3370
+ * inside `containerDir` as a real file — i.e. writing there would be writing to a directory,
3371
+ * not a file inside one. Guards `writeGraphToDisk`/`writeVectorsToDisk` against a malformed
3372
+ * node whose `file_path` IS a directory (the workspace root, or a repo root itself): observed
3373
+ * in production as an EISDIR crash on `devsmind sync`, because `toRepoRelativePath` collapses
3374
+ * to `''` (workspace root) or `'{repo}/'` (a repo root) for those, which `diskRelPath` then
3375
+ * turns into `''`/a trailing-slash string/`'..'` depending on which branch collapsed — every
3376
+ * one of which makes `path.join(containerDir, diskRelPath)` resolve to `containerDir` itself
3377
+ * or some directory already inside it, never a fresh `.json` file. Multiple checks on purpose:
3378
+ * the empty/trailing-slash/`..`-relative cases catch it structurally (works even on a brand
3379
+ * new brain where nothing exists on disk yet, where an EISDIR would instead be a silent
3380
+ * file-where-directory-belongs corruption); the existsSync+isDirectory check is a catch-all
3381
+ * for any other collapse shape not foreseen above (existsSync is checked first specifically so
3382
+ * a normal not-yet-written target — the common case — never reaches statSync at all). Callers
3383
+ * already wrap their whole body in a try/catch that logs and returns, so nothing extra is
3384
+ * needed here for a statSync that throws for some other reason (a permission error, a race).
3385
+ * `devsmind analyze --fix` deprecates the node actually causing this; this only ever refuses
3386
+ * the write, never touches the node.
3387
+ */
3388
+ isDegenerateDiskJsonPath(containerDir, diskRelPath, targetPath) {
3389
+ if (!diskRelPath || diskRelPath.endsWith('/') || diskRelPath.endsWith('\\'))
3390
+ return true;
3391
+ const relToContainer = path.relative(containerDir, targetPath);
3392
+ if (relToContainer === '' || relToContainer.startsWith('..'))
3393
+ return true;
3394
+ return fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory();
3395
+ }
3354
3396
  writeGraphToDisk(filePath) {
3355
3397
  try {
3356
3398
  if (!filePath)
@@ -3361,7 +3403,12 @@ class DevMindDatabase {
3361
3403
  const repoRelPath = this.toRepoRelativePath(absPath);
3362
3404
  // E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
3363
3405
  const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
3364
- const graphJsonPath = path.join(workspaceRoot, 'graph', diskRelPath);
3406
+ const graphDir = path.join(workspaceRoot, 'graph');
3407
+ const graphJsonPath = path.join(graphDir, diskRelPath);
3408
+ if (this.isDegenerateDiskJsonPath(graphDir, diskRelPath, graphJsonPath)) {
3409
+ console.warn(`⚠️ Skipped writing graph JSON: a node's file_path resolves to a directory, not a file (${absPath}) — run \`devsmind analyze --fix\` to clean it up.`);
3410
+ return;
3411
+ }
3365
3412
  // Get all nodes in this file (active AND deprecated). A node's file_path is either
3366
3413
  // exactly this absolute path, or (for the rare node spanning multiple files) a ", "-joined
3367
3414
  // list containing it. We anchor on the FULL absolute path with ", " boundaries and escape
@@ -3442,7 +3489,14 @@ class DevMindDatabase {
3442
3489
  const absPath = (0, config_1.canonicalizePath)(filePath);
3443
3490
  const repoRelPath = this.toRepoRelativePath(absPath);
3444
3491
  const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
3445
- const vectorsJsonPath = path.join(workspaceRoot, 'vectors', diskRelPath);
3492
+ const vectorsDir = path.join(workspaceRoot, 'vectors');
3493
+ const vectorsJsonPath = path.join(vectorsDir, diskRelPath);
3494
+ // Same guard as writeGraphToDisk — see isDegenerateDiskJsonPath's comment for why this
3495
+ // happens and what it means.
3496
+ if (this.isDegenerateDiskJsonPath(vectorsDir, diskRelPath, vectorsJsonPath)) {
3497
+ console.warn(`⚠️ Skipped writing vectors JSON: a node's file_path resolves to a directory, not a file (${absPath}) — run \`devsmind analyze --fix\` to clean it up.`);
3498
+ return;
3499
+ }
3446
3500
  const absLower = absPath.toLowerCase();
3447
3501
  const absEscLower = this.likeEscape(absPath).toLowerCase();
3448
3502
  const stmt = this.db.prepare(`
@@ -3474,7 +3528,17 @@ class DevMindDatabase {
3474
3528
  console.warn('⚠️ SQLite warning: Failed to write vectors JSON to disk:', err);
3475
3529
  }
3476
3530
  }
3477
- /** Force-syncs all database nodes and workflows to disk JSON files. */
3531
+ /**
3532
+ * Force-syncs all database nodes, vectors, and workflows to disk JSON files. This is the
3533
+ * write-back half of `devsmind sync` (paired with `syncFromDisk`, which reads `vectors/*.json`
3534
+ * into `node_vectors` — see its comment). Vectors are re-written here for the same reason
3535
+ * graph JSON is: normal operation already keeps `vectors/` current (`writeVectorsToDisk` runs
3536
+ * immediately alongside every embedding write), but `sync` exists precisely for the abnormal
3537
+ * case — recovering a `.devmind` after `vectors/*.json` was deleted/corrupted independently of
3538
+ * the DB, or after a past write silently failed (e.g. the directory-typed file_path bug
3539
+ * `isDegenerateDiskJsonPath` now catches) — so leaving vectors out of the force-resync
3540
+ * defeated half the point of running it.
3541
+ */
3478
3542
  syncToDisk() {
3479
3543
  try {
3480
3544
  const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
@@ -3488,6 +3552,7 @@ class DevMindDatabase {
3488
3552
  }
3489
3553
  for (const filePath of filePaths) {
3490
3554
  this.writeGraphToDisk(filePath);
3555
+ this.writeVectorsToDisk(filePath);
3491
3556
  }
3492
3557
  const workflowRows = this.db.prepare('SELECT id FROM workflows').all();
3493
3558
  for (const row of workflowRows) {