@4da/mcp-server 6.0.1 → 6.1.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/README.md +19 -13
  3. package/dist/db.d.ts +88 -7
  4. package/dist/db.js +248 -63
  5. package/dist/doctor.d.ts +1 -1
  6. package/dist/doctor.js +15 -9
  7. package/dist/embeddings.js +19 -7
  8. package/dist/gitignore.js +9 -3
  9. package/dist/index.js +54 -7
  10. package/dist/live/cache.d.ts +31 -2
  11. package/dist/live/cache.js +113 -1
  12. package/dist/live/changelog-classify.js +4 -1
  13. package/dist/live/changelog.d.ts +15 -0
  14. package/dist/live/changelog.js +67 -11
  15. package/dist/live/dev-scope.d.ts +2 -2
  16. package/dist/live/fix-paths.d.ts +118 -0
  17. package/dist/live/fix-paths.js +498 -0
  18. package/dist/live/go-registry.d.ts +5 -0
  19. package/dist/live/go-registry.js +1 -1
  20. package/dist/live/index.d.ts +30 -3
  21. package/dist/live/index.js +59 -6
  22. package/dist/live/lockfile-parents.d.ts +61 -0
  23. package/dist/live/lockfile-parents.js +360 -0
  24. package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
  25. package/dist/live/lockfile-parsers-pygo.js +178 -28
  26. package/dist/live/lockfile-parsers.d.ts +4 -1
  27. package/dist/live/lockfile-parsers.js +57 -24
  28. package/dist/live/lockfile-types.d.ts +12 -3
  29. package/dist/live/lockfile-types.js +2 -1
  30. package/dist/live/osv-ranges.d.ts +79 -0
  31. package/dist/live/osv-ranges.js +133 -0
  32. package/dist/live/osv-scanner.d.ts +1 -0
  33. package/dist/live/osv-scanner.js +85 -78
  34. package/dist/live/range-satisfies.d.ts +21 -0
  35. package/dist/live/range-satisfies.js +190 -0
  36. package/dist/live/rate-limiter.d.ts +6 -0
  37. package/dist/live/rate-limiter.js +5 -0
  38. package/dist/live/registry-health.js +2 -0
  39. package/dist/live/resolution.d.ts +11 -1
  40. package/dist/live/resolution.js +16 -6
  41. package/dist/live/semver-utils.d.ts +8 -0
  42. package/dist/live/semver-utils.js +10 -2
  43. package/dist/live/types.d.ts +17 -1
  44. package/dist/live/upgrade-sources.d.ts +2 -0
  45. package/dist/live/upgrade-sources.js +7 -2
  46. package/dist/memory-store.d.ts +19 -0
  47. package/dist/memory-store.js +8 -0
  48. package/dist/native-bindings.d.ts +7 -2
  49. package/dist/native-bindings.js +4 -0
  50. package/dist/project-tree.d.ts +50 -8
  51. package/dist/project-tree.js +121 -29
  52. package/dist/schema-registry.js +2 -2
  53. package/dist/setup.d.ts +76 -3
  54. package/dist/setup.js +313 -130
  55. package/dist/sqlite-driver.d.ts +134 -0
  56. package/dist/sqlite-driver.js +359 -0
  57. package/dist/tool-dispatch.d.ts +10 -1
  58. package/dist/tool-dispatch.js +37 -2
  59. package/dist/tools/agent-memory.js +10 -2
  60. package/dist/tools/app-plan.d.ts +11 -1
  61. package/dist/tools/app-plan.js +32 -6
  62. package/dist/tools/briefing-task-scope.d.ts +21 -2
  63. package/dist/tools/briefing-task-scope.js +97 -4
  64. package/dist/tools/decision-memory.js +9 -3
  65. package/dist/tools/dependency-check-signals.d.ts +7 -0
  66. package/dist/tools/dependency-check-signals.js +17 -2
  67. package/dist/tools/dependency-check.d.ts +1 -1
  68. package/dist/tools/dependency-check.js +1 -1
  69. package/dist/tools/dependency-health.d.ts +1 -1
  70. package/dist/tools/dependency-health.js +5 -0
  71. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  72. package/dist/tools/knowledge-gap-ranges.js +1 -1
  73. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  74. package/dist/tools/project-scope.d.ts +45 -0
  75. package/dist/tools/project-scope.js +119 -0
  76. package/dist/tools/record-feedback.js +1 -1
  77. package/dist/tools/upgrade-impact-literals.js +1 -1
  78. package/dist/tools/upgrade-impact-report.d.ts +10 -0
  79. package/dist/tools/upgrade-impact-report.js +32 -2
  80. package/dist/tools/upgrade-impact.js +1 -1
  81. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  82. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  83. package/dist/tools/upgrade-planner.d.ts +31 -3
  84. package/dist/tools/upgrade-planner.js +74 -19
  85. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  86. package/dist/tools/vulnerability-scan-concise.js +15 -3
  87. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  88. package/dist/tools/vulnerability-scan-format.js +107 -11
  89. package/dist/tools/vulnerability-scan.d.ts +104 -13
  90. package/dist/tools/vulnerability-scan.js +27 -3
  91. package/dist/tools/what-should-i-know.d.ts +22 -2
  92. package/dist/tools/what-should-i-know.js +121 -11
  93. package/package.json +10 -31
package/dist/db.js CHANGED
@@ -2,59 +2,67 @@
2
2
  /**
3
3
  * Database module for 4DA MCP Server
4
4
  *
5
- * Read-only access to the 4DA SQLite database.
6
- * Only the record_feedback function performs writes.
5
+ * The desktop app's database (`4da.db`) is opened READ-ONLY
6
+ * (`{ readonly: true, fileMustExist: true }`) for everything the server reads.
7
+ * The app owns that file and its migrations: the server never creates or
8
+ * alters anything in it. It writes rows into exactly the app-owned tables its
9
+ * tools exist to write (APP_WRITABLE_TABLES: decisions, agent memory, feedback
10
+ * and interactions, which the app's calibration and decision UI read), through
11
+ * a separate write connection opened only for those statements (writerFor).
12
+ *
13
+ * Everything else the server writes goes to files it owns, under
14
+ * serverDataDir():
15
+ * - `cache.db`: registry, OSV and changelog responses (LiveCache).
16
+ * - `standalone.db`: the standalone database when no app is installed, and
17
+ * the fallback for a record the app's schema cannot hold (schema drift).
18
+ *
19
+ * Until 6.0.2 the server created a `live_cache` table inside the app's
20
+ * database (2,774 rows on one machine, 2026-10-07) and added `embedding`
21
+ * columns to two app tables when an embedding provider was set.
7
22
  */
8
23
  import path from "path";
9
24
  import * as fs from "fs";
10
25
  import * as os from "os";
11
- import { fileURLToPath } from "node:url";
12
- import { dirname } from "node:path";
13
- import { checkNativeBindings, isNativeBindingError, nativeBindingMessage } from "./native-bindings.js";
14
- let Database;
15
- try {
16
- Database = (await import("better-sqlite3")).default;
17
- }
18
- catch (err) {
19
- console.error(`\n [4DA] ${nativeBindingMessage(err)}\n`);
20
- process.exit(1);
21
- }
22
- /** Null when better-sqlite3 can open a database here; otherwise why not, with the fix. */
26
+ import { openDatabase, sqliteDriverStatus, SqliteUnavailableError, } from "./sqlite-driver.js";
27
+ import { isNativeBindingError, nativeBindingMessage } from "./native-bindings.js";
28
+ /**
29
+ * Null when a SQLite driver (node:sqlite, or better-sqlite3 with a working
30
+ * native module) can open a database here; otherwise why not, with the fix.
31
+ */
23
32
  export function nativeBindingProblem() {
24
- return checkNativeBindings(Database);
33
+ return sqliteDriverStatus().problem;
25
34
  }
26
- const __filename = fileURLToPath(import.meta.url);
27
- const __dirname = dirname(__filename);
28
35
  import { dbRecoveryNote, readDbRecoveredMarker, readEngineBlockMarker } from "./freshness-markers.js";
29
36
  /**
30
37
  * Resolve the database path by checking multiple locations in priority order:
31
38
  * 1. FOURDA_DB_PATH env var
32
- * 2. data/4da.db relative to cwd (development)
33
- * 3. data/4da.db relative to project root (mcp-4da-server is inside project root)
34
- * 4. Platform-specific Tauri app data dirs (production)
35
- * 5. Final fallback: data/4da.db relative to cwd
39
+ * 2. data/4da.db relative to cwd: the desktop app's development database,
40
+ * when the server is started in a checkout of the app's repository
41
+ * 3. The deployed desktop app's database (desktopAppDbPaths)
42
+ * 4. Otherwise the standalone database (standaloneDbPath), created on first use
43
+ *
44
+ * Until 6.0.2 a step between 2 and 3 looked for data/4da.db two directories
45
+ * above this file, which found the app's database while this server was the
46
+ * app repository's mcp-4da-server/ folder. Since the server moved to its own
47
+ * repository (6.0.1) that resolves to node_modules/@4da/ or to the folder
48
+ * holding a clone, never to an app database, so it was removed.
36
49
  */
37
50
  function getDefaultDbPath() {
38
51
  // 1. Environment variable (highest priority)
39
52
  if (process.env.FOURDA_DB_PATH) {
40
53
  return process.env.FOURDA_DB_PATH;
41
54
  }
42
- // 2. Relative to cwd (development)
55
+ // 2. Relative to cwd (the app repository's development database)
43
56
  const cwdPath = path.resolve(process.cwd(), "data", "4da.db");
44
57
  if (fs.existsSync(cwdPath)) {
45
58
  return cwdPath;
46
59
  }
47
- // 3. Relative to project root (mcp-4da-server is inside project root)
48
- const projectRootPath = path.resolve(__dirname, "..", "..", "data", "4da.db");
49
- if (fs.existsSync(projectRootPath)) {
50
- return projectRootPath;
51
- }
52
- // 4. The desktop app's own database, where the deployed app keeps it.
60
+ // 3. The desktop app's own database, where the deployed app keeps it.
53
61
  for (const appDataPath of desktopAppDbPaths()) {
54
62
  if (fs.existsSync(appDataPath))
55
63
  return appDataPath;
56
64
  }
57
- // 5. No desktop database: the standalone database, in the user's data dir.
65
+ // 4. No desktop database: the standalone database, in the user's data dir.
58
66
  return standaloneDbPath();
59
67
  }
60
68
  /**
@@ -96,13 +104,60 @@ export function desktopAppDbPaths(platform = process.platform, env = process.env
96
104
  * in, so agent memory and decisions did not follow the user between projects.
97
105
  */
98
106
  export function standaloneDbPath() {
107
+ return path.join(serverDataDir(), "standalone.db");
108
+ }
109
+ /**
110
+ * The directory holding every file this server writes: a per-user data
111
+ * directory (`%LOCALAPPDATA%\4da-mcp`, `~/Library/Application Support/4da-mcp`,
112
+ * `$XDG_DATA_HOME/4da-mcp`). FOURDA_MCP_HOME overrides it (tests use a temp dir).
113
+ */
114
+ export function serverDataDir() {
115
+ const override = process.env.FOURDA_MCP_HOME?.trim();
116
+ if (override)
117
+ return override;
99
118
  const home = os.homedir();
100
119
  const base = process.platform === "win32"
101
120
  ? process.env.LOCALAPPDATA || path.join(home, "AppData", "Local")
102
121
  : process.platform === "darwin"
103
122
  ? path.join(home, "Library", "Application Support")
104
123
  : process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
105
- return path.join(base, "4da-mcp", "standalone.db");
124
+ return path.join(base, "4da-mcp");
125
+ }
126
+ /** The server's response cache (registry, OSV, changelogs). Disposable: deleting it costs refetches only. */
127
+ export function serverCacheDbPath() {
128
+ return path.join(serverDataDir(), "cache.db");
129
+ }
130
+ /**
131
+ * Whether an error says the database file itself is unreadable: not SQLite at
132
+ * all, or damaged. SQLite reports these as SQLITE_NOTADB ("file is not
133
+ * a database") and SQLITE_CORRUPT ("database disk image is malformed").
134
+ */
135
+ export function isUnreadableDbError(error) {
136
+ const code = error?.code;
137
+ if (code === "SQLITE_NOTADB" || code === "SQLITE_CORRUPT")
138
+ return true;
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ return /file is not a database|database disk image is malformed/i.test(message);
141
+ }
142
+ /**
143
+ * What to tell the user about an unreadable database. Every tool call used to
144
+ * answer with the bare SQLite text ("file is not a database"), with no way
145
+ * out. Nothing is deleted or moved automatically: the desktop app's database
146
+ * can be the user's whole corpus, and the standalone one holds their recorded
147
+ * decisions and agent memory.
148
+ */
149
+ export function unreadableDbMessage(absolutePath, error) {
150
+ const detail = error instanceof Error ? error.message : String(error);
151
+ const isStandaloneFile = path.resolve(absolutePath) === path.resolve(standaloneDbPath());
152
+ const remedy = isStandaloneFile
153
+ ? "It is this server's standalone database: move it aside (keep it if it holds decisions or agent " +
154
+ "memory you want) and the next call creates a new one."
155
+ : process.env.FOURDA_DB_PATH
156
+ ? "FOURDA_DB_PATH points at it: point it at a valid 4DA database, or unset it to use the default."
157
+ : "If it is the 4DA desktop app's database, start the app, which checks its database on startup. " +
158
+ "Do not delete it: it may be your only copy of the app's data.";
159
+ return (`The 4DA database at ${absolutePath} cannot be read (${detail}): the file is damaged or is not ` +
160
+ `a SQLite database. ${remedy} Run \`npx @4da/mcp-server --doctor\` for details.`);
106
161
  }
107
162
  /** Marker table written into every standalone database. */
108
163
  const STANDALONE_MARKER = "mcp_standalone";
@@ -193,6 +248,15 @@ export function dedupeByTitle(items, threshold = 0.6) {
193
248
  * the `is_direct` standalone-schema gap slipped through precisely because the
194
249
  * two modes were only ever tested separately.
195
250
  */
251
+ /**
252
+ * The app-owned tables this server is designed to write when the desktop app
253
+ * is installed: decisions (decision_memory), agent memory (agent_memory) and
254
+ * feedback (record_feedback -> the app's calibration). Nothing else in the
255
+ * app's database is ever written, and nothing in it is created or altered.
256
+ */
257
+ export const APP_WRITABLE_TABLES = ["developer_decisions", "agent_memory", "interactions", "feedback"];
258
+ /** How long a write into the app's database waits for the app's own lock. */
259
+ const APP_WRITE_BUSY_TIMEOUT_MS = 3000;
196
260
  export const DEPENDENCY_GROUP_QUERY = "SELECT DISTINCT package_name, language, project_path, is_dev, is_direct FROM project_dependencies";
197
261
  /**
198
262
  * 4DA Database accessor
@@ -200,6 +264,8 @@ export const DEPENDENCY_GROUP_QUERY = "SELECT DISTINCT package_name, language, p
200
264
  export class FourDADatabase {
201
265
  db;
202
266
  _isStandalone = false;
267
+ /** Absolute path of the open database file. */
268
+ dbPath;
203
269
  constructor(dbPath) {
204
270
  const resolvedPath = dbPath || getDefaultDbPath();
205
271
  // Resolve path - if relative, resolve from cwd
@@ -214,34 +280,64 @@ export class FourDADatabase {
214
280
  fs.mkdirSync(dir, { recursive: true });
215
281
  }
216
282
  }
283
+ let opened = null;
284
+ let standalone = isNew;
217
285
  try {
218
- this.db = new Database(absolutePath, { readonly: false }); // Need write for feedback
219
- this.db.pragma("journal_mode = WAL");
286
+ if (isNew) {
287
+ opened = openDatabase(absolutePath, { readonly: false });
288
+ opened.pragma("journal_mode = WAL");
289
+ }
290
+ else {
291
+ // An existing file is opened read-only first. Reading the schema here
292
+ // makes a damaged or non-SQLite file fail now, with the message below.
293
+ opened = openDatabase(absolutePath, { readonly: true, fileMustExist: true });
294
+ opened.prepare("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").all();
295
+ standalone = detectStandalone(opened);
296
+ if (standalone) {
297
+ // This server's own database: reopen it writable.
298
+ opened.close();
299
+ opened = null;
300
+ opened = openDatabase(absolutePath, { readonly: false, fileMustExist: true });
301
+ opened.pragma("journal_mode = WAL");
302
+ }
303
+ }
220
304
  }
221
305
  catch (error) {
306
+ // Close the handle a failed pragma leaves open: every tool call retries
307
+ // the open, and on Windows each leaked handle also keeps the file locked
308
+ // against the move-aside the message below suggests.
309
+ try {
310
+ opened?.close();
311
+ }
312
+ catch {
313
+ // Nothing to close when the open itself failed.
314
+ }
315
+ if (error instanceof SqliteUnavailableError)
316
+ throw error;
222
317
  throw new Error(isNativeBindingError(error)
223
318
  ? nativeBindingMessage(error)
224
- : `Failed to open 4DA database at ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
319
+ : isUnreadableDbError(error)
320
+ ? unreadableDbMessage(absolutePath, error)
321
+ : `Failed to open 4DA database at ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
225
322
  }
323
+ this.db = opened;
324
+ this.dbPath = absolutePath;
325
+ this._isStandalone = standalone;
326
+ // The desktop app's database: read-only, and nothing below runs against it.
327
+ if (!standalone)
328
+ return;
226
329
  // Standalone mode: create schema for a brand-new database, and recognise
227
330
  // an existing standalone database as one on every later session.
228
- if (isNew) {
331
+ if (isNew)
229
332
  this.createMinimalSchema();
230
- this._isStandalone = true;
231
- }
232
- else {
233
- this._isStandalone = detectStandalone(this.db);
234
- }
235
- if (this._isStandalone) {
236
- this.db.exec(`CREATE TABLE IF NOT EXISTS ${STANDALONE_MARKER} (created_at TEXT DEFAULT (datetime('now')))`);
237
- }
333
+ this.db.exec(`CREATE TABLE IF NOT EXISTS ${STANDALONE_MARKER} (created_at TEXT DEFAULT (datetime('now')))`);
238
334
  // Schema upgrade for standalone databases created before `is_direct` was
239
335
  // added to the minimal schema: the full-DB init branch queries that column
240
336
  // (DEPENDENCY_GROUP_QUERY), and without it session 2+ of a standalone
241
337
  // install throws, gets caught, and silently disables vulnerability_scan /
242
338
  // dependency_health / upgrade_planner. Scanner-inserted manifest deps are
243
- // direct by definition, so DEFAULT 1 backfills correctly. The desktop
244
- // app's database already has the column — no-op there.
339
+ // direct by definition, so DEFAULT 1 backfills correctly. Standalone
340
+ // databases only: the desktop app's database is never altered.
245
341
  try {
246
342
  const hasDepsTable = this.db
247
343
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='project_dependencies'")
@@ -261,6 +357,67 @@ export class FourDADatabase {
261
357
  get isStandalone() {
262
358
  return this._isStandalone;
263
359
  }
360
+ /** True for the desktop app's database, which this connection only reads. */
361
+ get isReadOnly() {
362
+ return this.db.readonly;
363
+ }
364
+ memoryStore = null;
365
+ appWriter = null;
366
+ /**
367
+ * The connection a record into one of the app-owned tables this server is
368
+ * designed to write (APP_WRITABLE_TABLES) goes through, and where it lands.
369
+ *
370
+ * - Standalone database: the database itself.
371
+ * - The desktop app's database: a SEPARATE writable connection, opened on
372
+ * first use for these statements only (the main connection stays
373
+ * read-only), with a busy timeout so a write waits briefly for the app
374
+ * instead of failing. Only when the app's table has every column the
375
+ * statement names; nothing is ever created or altered in the app's file.
376
+ * - Schema drift (table or column missing in the app): the record goes to
377
+ * this server's own store (standaloneDbPath()) and `note` says so.
378
+ */
379
+ writerFor(table, columns) {
380
+ if (!this.isReadOnly)
381
+ return { raw: this.db, target: "database" };
382
+ const missing = columns.filter((c) => !this.hasColumn(table, c));
383
+ if (missing.length === 0) {
384
+ if (!this.appWriter?.open) {
385
+ const writer = openDatabase(this.dbPath, { fileMustExist: true });
386
+ writer.pragma(`busy_timeout = ${APP_WRITE_BUSY_TIMEOUT_MS}`);
387
+ this.appWriter = writer;
388
+ }
389
+ return { raw: this.appWriter, target: "app" };
390
+ }
391
+ const store = this.getMemoryStore();
392
+ return {
393
+ raw: store.db,
394
+ target: "server_store",
395
+ note: `The desktop app's ${table} table ${this.hasTable(table) ? `has no ${missing.join(", ")} column` : "does not exist"} ` +
396
+ `in this app version, so this was recorded in the server's own store (${store.dbPath}) instead; the app will not see it. ` +
397
+ `The server never changes the app's schema.`,
398
+ };
399
+ }
400
+ /**
401
+ * The server's own store: its standalone database (standaloneDbPath()).
402
+ * Used for records the app's schema cannot hold (see writerFor). A
403
+ * standalone database is its own store.
404
+ */
405
+ getMemoryStore() {
406
+ if (!this.isReadOnly)
407
+ return this;
408
+ if (!this.memoryStore) {
409
+ const storePath = standaloneDbPath();
410
+ if (path.resolve(storePath) === path.resolve(this.dbPath)) {
411
+ throw new Error(`The server's store ${storePath} is open read-only; it cannot record.`);
412
+ }
413
+ const store = new FourDADatabase(storePath);
414
+ // Rows here reference the app's items and decisions by id; those rows
415
+ // live in the other file, so foreign keys cannot be enforced here.
416
+ store.db.pragma("foreign_keys = OFF");
417
+ this.memoryStore = store;
418
+ }
419
+ return this.memoryStore;
420
+ }
264
421
  /**
265
422
  * Validate that a database file exists and is a readable SQLite database.
266
423
  *
@@ -269,7 +426,7 @@ export class FourDADatabase {
269
426
  * By default this runs a CHEAP probe (open + list tables, ~0.1s) which surfaces
270
427
  * a missing, locked, or corrupt-header file. It deliberately does NOT run a full
271
428
  * `PRAGMA integrity_check`: that is an O(database-size) scan (~11s on a 1.7 GB DB,
272
- * and growing) and, because better-sqlite3 is synchronous, it blocks the event
429
+ * and growing) and, because the SQLite drivers are synchronous, it blocks the event
273
430
  * loop — running it on the startup path stalls the MCP stdio handshake until it
274
431
  * finishes, so the host's connect timeout can fire and the server looks like it
275
432
  * "failed to start". Pass `{ deep: true }` for the full integrity_check on the
@@ -294,7 +451,7 @@ export class FourDADatabase {
294
451
  }
295
452
  let testDb = null;
296
453
  try {
297
- testDb = new Database(absolutePath, { readonly: true });
454
+ testDb = openDatabase(absolutePath, { readonly: true, fileMustExist: true });
298
455
  // Deep, opt-in integrity check (slow: full-database scan). Only on --doctor.
299
456
  if (opts?.deep) {
300
457
  const integrityResult = testDb.pragma("integrity_check");
@@ -302,8 +459,10 @@ export class FourDADatabase {
302
459
  if (integrityStatus !== "ok") {
303
460
  return {
304
461
  valid: false,
305
- error: `Database integrity check failed: ${integrityStatus}. `
306
- + "The database file may be corrupt. Try deleting data/4da.db and restarting 4DA.",
462
+ // It used to say "Try deleting data/4da.db": a path that is not
463
+ // where the app keeps its database, and advice that destroys the
464
+ // user's only copy of it.
465
+ error: unreadableDbMessage(absolutePath, new Error(`integrity check failed: ${integrityStatus}`)),
307
466
  };
308
467
  }
309
468
  }
@@ -322,8 +481,9 @@ export class FourDADatabase {
322
481
  catch (error) {
323
482
  return {
324
483
  valid: false,
325
- error: `Failed to open database at ${absolutePath}: `
326
- + (error instanceof Error ? error.message : String(error)),
484
+ error: isUnreadableDbError(error)
485
+ ? unreadableDbMessage(absolutePath, error)
486
+ : `Failed to open database at ${absolutePath}: ` + (error instanceof Error ? error.message : String(error)),
327
487
  };
328
488
  }
329
489
  finally {
@@ -336,7 +496,7 @@ export class FourDADatabase {
336
496
  }
337
497
  }
338
498
  /**
339
- * Get the raw better-sqlite3 database instance for custom queries
499
+ * Get the raw database connection (sqlite-driver.ts) for custom queries
340
500
  */
341
501
  getRawDb() {
342
502
  return this.db;
@@ -345,6 +505,11 @@ export class FourDADatabase {
345
505
  * Close the database connection
346
506
  */
347
507
  close() {
508
+ this.memoryStore?.close();
509
+ this.memoryStore = null;
510
+ if (this.appWriter?.open)
511
+ this.appWriter.close();
512
+ this.appWriter = null;
348
513
  this.db.close();
349
514
  }
350
515
  // ===========================================================================
@@ -860,6 +1025,14 @@ export class FourDADatabase {
860
1025
  return null;
861
1026
  }
862
1027
  }
1028
+ hasTable(table) {
1029
+ try {
1030
+ return this.db.pragma(`table_info(${table})`).length > 0;
1031
+ }
1032
+ catch {
1033
+ return false;
1034
+ }
1035
+ }
863
1036
  hasColumn(table, column) {
864
1037
  try {
865
1038
  const cols = this.db.pragma(`table_info(${table})`);
@@ -878,6 +1051,9 @@ export class FourDADatabase {
878
1051
  ensureColumn(table, column, type) {
879
1052
  if (this.hasColumn(table, column))
880
1053
  return;
1054
+ if (this.isReadOnly) {
1055
+ throw new Error(`${this.dbPath} is the desktop app's database, which this server never alters (${table}.${column}).`);
1056
+ }
881
1057
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
882
1058
  }
883
1059
  /**
@@ -1226,29 +1402,38 @@ export class FourDADatabase {
1226
1402
  };
1227
1403
  const label = relevanceLabel[action];
1228
1404
  try {
1229
- if (label !== undefined)
1405
+ // Into the app's own interactions/feedback tables (the app's calibration
1406
+ // reads them) through the separate write connection; on schema drift,
1407
+ // into the server's store with a note. See writerFor.
1408
+ if (!this.isReadOnly && label !== undefined)
1230
1409
  this.ensureFeedbackTable();
1231
- const insertInteraction = this.db.prepare(`
1410
+ const interactionsTo = this.writerFor("interactions", ["item_id", "action_type", "item_source", "signal_strength", "timestamp"]);
1411
+ const feedbackTo = label !== undefined ? this.writerFor("feedback", ["source_item_id", "relevant"]) : null;
1412
+ if (interactionsTo.target === "server_store" || feedbackTo?.target === "server_store") {
1413
+ this.getMemoryStore().ensureFeedbackTable();
1414
+ }
1415
+ const insertInteraction = interactionsTo.raw.prepare(`
1232
1416
  INSERT INTO interactions (item_id, action_type, item_source, signal_strength, timestamp)
1233
1417
  VALUES (?, ?, ?, ?, datetime('now'))
1234
1418
  `);
1235
- const write = this.db.transaction(() => {
1419
+ const insertFeedback = feedbackTo?.raw.prepare(`INSERT INTO feedback (source_item_id, relevant) VALUES (?, ?)`);
1420
+ // One short transaction when both rows go to the same file.
1421
+ const write = () => {
1236
1422
  const r = insertInteraction.run(itemId, action, row.source_type, signalStrength[action]);
1237
- if (label !== undefined) {
1238
- this.db
1239
- .prepare(`INSERT INTO feedback (source_item_id, relevant) VALUES (?, ?)`)
1240
- .run(itemId, label);
1241
- }
1423
+ insertFeedback?.run(itemId, label);
1242
1424
  return r;
1243
- });
1244
- const result = write();
1425
+ };
1426
+ const result = feedbackTo && feedbackTo.raw !== interactionsTo.raw ? write() : interactionsTo.raw.transaction(write)();
1427
+ const notes = [interactionsTo.note, feedbackTo?.note].filter((n) => Boolean(n));
1428
+ const where = notes.length > 0 ? ` (${[...new Set(notes)].join(" ")})` : "";
1245
1429
  return {
1246
1430
  success: true,
1247
- message: label === undefined
1431
+ message: (label === undefined
1248
1432
  ? `Recorded ${action} feedback for item ${itemId}`
1249
- : `Recorded ${action} feedback for item ${itemId} (relevance label: ${label === 1 ? "relevant" : "not relevant"})`,
1433
+ : `Recorded ${action} feedback for item ${itemId} (relevance label: ${label === 1 ? "relevant" : "not relevant"})`) + where,
1250
1434
  interaction_id: result.lastInsertRowid,
1251
1435
  relevance_label: label === undefined ? null : label === 1,
1436
+ ...(notes.length > 0 ? { _meta: { written_to: "server_store", note: [...new Set(notes)].join(" ") } } : {}),
1252
1437
  };
1253
1438
  }
1254
1439
  catch (error) {
package/dist/doctor.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Validates that the MCP server can run correctly:
5
5
  * 1. Node.js version is sufficient
6
- * 2. Native bindings (better-sqlite3) load successfully
6
+ * 2. A SQLite driver opens a database (node:sqlite, else better-sqlite3)
7
7
  * 3. Database is found and readable
8
8
  * 4. LLM providers are configured (optional)
9
9
  *
package/dist/doctor.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Validates that the MCP server can run correctly:
6
6
  * 1. Node.js version is sufficient
7
- * 2. Native bindings (better-sqlite3) load successfully
7
+ * 2. A SQLite driver opens a database (node:sqlite, else better-sqlite3)
8
8
  * 3. Database is found and readable
9
9
  * 4. LLM providers are configured (optional)
10
10
  *
@@ -13,7 +13,8 @@
13
13
  * 4da-mcp --doctor
14
14
  */
15
15
  import { existsSync } from "node:fs";
16
- import { FourDADatabase, nativeBindingProblem } from "./db.js";
16
+ import { FourDADatabase } from "./db.js";
17
+ import { NODE_SQLITE_MIN, sqliteDriverStatus } from "./sqlite-driver.js";
17
18
  import { checkBuildStaleness } from "./build-staleness.js";
18
19
  export function runDoctor() {
19
20
  console.log("\n 4DA MCP Server — Doctor\n");
@@ -32,14 +33,19 @@ export function runDoctor() {
32
33
  ? `${nodeVersion} (>= ${nodeRequired} required)`
33
34
  : `${nodeVersion} — Node.js ${nodeRequired}+ required`,
34
35
  });
35
- // 2. Native bindings — opening a database, not just importing the package:
36
- // the import succeeds without the compiled module (npm 12 blocking its
37
- // install script left none, and this check used to say "loaded").
38
- const bindingProblem = nativeBindingProblem();
36
+ // 2. SQLite driver — opening a database, not just importing a package:
37
+ // better-sqlite3's import succeeds without its compiled module (npm 12
38
+ // blocking its install script left none, and this check used to say "loaded").
39
+ const sqlite = sqliteDriverStatus();
40
+ const passedOver = sqlite.rejected.map((r) => `${r.name} not used: ${r.reason}`).join("; ");
39
41
  checks.push({
40
- name: "SQLite native bindings",
41
- status: bindingProblem ? "fail" : "pass",
42
- detail: bindingProblem ?? "better-sqlite3 opened an in-memory database",
42
+ name: "SQLite driver",
43
+ status: sqlite.driver ? "pass" : "fail",
44
+ detail: !sqlite.driver
45
+ ? (sqlite.problem ?? "no SQLite driver")
46
+ : sqlite.driver.name === "node:sqlite"
47
+ ? `node:sqlite (built into Node ${process.versions.node}) opened an in-memory database`
48
+ : `better-sqlite3 opened an in-memory database (${passedOver || `node:sqlite needs Node ${NODE_SQLITE_MIN}+`})`,
43
49
  });
44
50
  // 3. Database discovery — deep integrity_check is appropriate here: --doctor is
45
51
  // an explicit, user-invoked diagnostic where a slow full scan is expected. The
@@ -238,12 +238,24 @@ export async function semanticScores(db, table, query, items, config, opts = {})
238
238
  if (!queryVec) {
239
239
  return { semanticById: new Map(), queryEmbedded: false, embeddedCount: 0, newlyEmbedded: 0, model: tag };
240
240
  }
241
- // Cache columns are optional and added lazily for databases that predate them.
242
- db.ensureColumn(table, "embedding", "BLOB");
243
- db.ensureColumn(table, "embedding_model", "TEXT");
244
- const rawDb = db.getRawDb();
245
- const stored = loadStoredEmbeddings(rawDb, table, items.map((i) => i.id), tag);
246
- const updateStmt = rawDb.prepare(`UPDATE ${table} SET embedding = ?, embedding_model = ? WHERE id = ?`);
241
+ // Cache columns are optional. This server's own database gets them lazily;
242
+ // the desktop app's database is never altered, so there the cache is used
243
+ // only when the app's table already has both columns (written through the
244
+ // separate write connection, see FourDADatabase.writerFor), and vectors are
245
+ // otherwise computed per call and not stored.
246
+ const readOnly = db.isReadOnly === true;
247
+ let cacheDb = null;
248
+ if (!readOnly) {
249
+ db.ensureColumn(table, "embedding", "BLOB");
250
+ db.ensureColumn(table, "embedding_model", "TEXT");
251
+ cacheDb = db.getRawDb();
252
+ }
253
+ else if (db.hasColumn(table, "embedding") && db.hasColumn(table, "embedding_model")) {
254
+ const to = db.writerFor(table, ["id", "embedding", "embedding_model"]);
255
+ cacheDb = to.target === "app" ? to.raw : null;
256
+ }
257
+ const stored = cacheDb ? loadStoredEmbeddings(db.getRawDb(), table, items.map((i) => i.id), tag) : new Map();
258
+ const updateStmt = cacheDb?.prepare(`UPDATE ${table} SET embedding = ?, embedding_model = ? WHERE id = ?`);
247
259
  const maxNew = opts.maxNewEmbeds ?? DEFAULT_MAX_NEW_EMBEDS;
248
260
  const semanticById = new Map();
249
261
  let embeddedCount = 0;
@@ -254,7 +266,7 @@ export async function semanticScores(db, table, query, items, config, opts = {})
254
266
  vec = await embedText(item.text, config);
255
267
  if (vec) {
256
268
  try {
257
- updateStmt.run(vectorToBlob(vec), tag, item.id);
269
+ updateStmt?.run(vectorToBlob(vec), tag, item.id);
258
270
  }
259
271
  catch {
260
272
  // Persisting the cache is best-effort; scoring still proceeds.
package/dist/gitignore.js CHANGED
@@ -116,9 +116,15 @@ export class IgnoreRules {
116
116
  if (rule.base && !relPath.startsWith(`${rule.base}/`))
117
117
  continue;
118
118
  const local = rule.base ? relPath.slice(rule.base.length + 1) : relPath;
119
- if (rule.dirOnly && !isDir && !local.includes("/"))
120
- continue;
121
- if (!rule.regex.test(local))
119
+ if (rule.dirOnly && !isDir) {
120
+ // A `dir/` rule ignores a file only through a directory above it:
121
+ // `docs/api_reference/*/` matched docs/api_reference/requirements.txt
122
+ // itself, so langchain's docs requirements were never read.
123
+ const parent = local.includes("/") ? local.slice(0, local.lastIndexOf("/")) : null;
124
+ if (parent === null || !rule.regex.test(parent))
125
+ continue;
126
+ }
127
+ else if (!rule.regex.test(local))
122
128
  continue;
123
129
  if (rule.negated)
124
130
  reincludable = true;