@4da/mcp-server 6.0.2 → 6.2.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 (119) hide show
  1. package/CHANGELOG.md +433 -0
  2. package/README.md +26 -18
  3. package/dist/db.d.ts +73 -8
  4. package/dist/db.js +180 -43
  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 +43 -4
  10. package/dist/live/cache.d.ts +31 -2
  11. package/dist/live/cache.js +113 -1
  12. package/dist/live/changelog-classify.d.ts +9 -3
  13. package/dist/live/changelog-classify.js +125 -12
  14. package/dist/live/changelog.d.ts +22 -3
  15. package/dist/live/changelog.js +132 -38
  16. package/dist/live/dev-scope.d.ts +2 -2
  17. package/dist/live/fix-paths.d.ts +118 -0
  18. package/dist/live/fix-paths.js +498 -0
  19. package/dist/live/github-releases.d.ts +69 -0
  20. package/dist/live/github-releases.js +252 -0
  21. package/dist/live/go-registry.d.ts +5 -0
  22. package/dist/live/go-registry.js +1 -1
  23. package/dist/live/go-releases.d.ts +48 -0
  24. package/dist/live/go-releases.js +196 -0
  25. package/dist/live/index.d.ts +34 -8
  26. package/dist/live/index.js +66 -7
  27. package/dist/live/lockfile-parents.d.ts +61 -0
  28. package/dist/live/lockfile-parents.js +360 -0
  29. package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
  30. package/dist/live/lockfile-parsers-pygo.js +178 -28
  31. package/dist/live/lockfile-parsers.d.ts +4 -1
  32. package/dist/live/lockfile-parsers.js +57 -24
  33. package/dist/live/lockfile-types.d.ts +12 -3
  34. package/dist/live/lockfile-types.js +2 -1
  35. package/dist/live/osv-ranges.d.ts +85 -0
  36. package/dist/live/osv-ranges.js +136 -0
  37. package/dist/live/osv-scanner.d.ts +1 -0
  38. package/dist/live/osv-scanner.js +85 -78
  39. package/dist/live/package-archive.d.ts +2 -0
  40. package/dist/live/package-archive.js +7 -4
  41. package/dist/live/pypi-releases.d.ts +44 -0
  42. package/dist/live/pypi-releases.js +103 -0
  43. package/dist/live/range-satisfies.d.ts +21 -0
  44. package/dist/live/range-satisfies.js +190 -0
  45. package/dist/live/rate-limiter.d.ts +6 -0
  46. package/dist/live/rate-limiter.js +5 -0
  47. package/dist/live/registry-health.js +2 -0
  48. package/dist/live/release-metadata.d.ts +17 -8
  49. package/dist/live/release-metadata.js +6 -3
  50. package/dist/live/resolution.d.ts +11 -1
  51. package/dist/live/resolution.js +16 -6
  52. package/dist/live/semver-utils.d.ts +8 -0
  53. package/dist/live/semver-utils.js +10 -2
  54. package/dist/live/types.d.ts +17 -1
  55. package/dist/live/upgrade-go.d.ts +62 -0
  56. package/dist/live/upgrade-go.js +130 -0
  57. package/dist/live/upgrade-pypi.d.ts +42 -0
  58. package/dist/live/upgrade-pypi.js +85 -0
  59. package/dist/live/upgrade-sources.d.ts +30 -11
  60. package/dist/live/upgrade-sources.js +93 -21
  61. package/dist/live/zip-archive.d.ts +22 -0
  62. package/dist/live/zip-archive.js +98 -0
  63. package/dist/memory-store.d.ts +19 -0
  64. package/dist/memory-store.js +8 -0
  65. package/dist/native-bindings.d.ts +7 -2
  66. package/dist/native-bindings.js +4 -0
  67. package/dist/project-tree.d.ts +50 -8
  68. package/dist/project-tree.js +121 -29
  69. package/dist/schema-registry.js +5 -5
  70. package/dist/server-instructions.d.ts +6 -0
  71. package/dist/server-instructions.js +20 -15
  72. package/dist/sqlite-driver.d.ts +134 -0
  73. package/dist/sqlite-driver.js +359 -0
  74. package/dist/tool-dispatch.d.ts +8 -1
  75. package/dist/tool-dispatch.js +17 -2
  76. package/dist/tools/agent-memory.js +10 -2
  77. package/dist/tools/app-plan.d.ts +11 -1
  78. package/dist/tools/app-plan.js +32 -6
  79. package/dist/tools/briefing-task-scope.d.ts +21 -2
  80. package/dist/tools/briefing-task-scope.js +97 -4
  81. package/dist/tools/decision-memory.js +9 -3
  82. package/dist/tools/dependency-check-signals.d.ts +23 -5
  83. package/dist/tools/dependency-check-signals.js +62 -19
  84. package/dist/tools/dependency-check-versions.d.ts +94 -0
  85. package/dist/tools/dependency-check-versions.js +213 -0
  86. package/dist/tools/dependency-check.d.ts +27 -11
  87. package/dist/tools/dependency-check.js +321 -74
  88. package/dist/tools/dependency-health.d.ts +1 -1
  89. package/dist/tools/dependency-health.js +5 -0
  90. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  91. package/dist/tools/knowledge-gap-ranges.js +1 -1
  92. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  93. package/dist/tools/project-scope.d.ts +45 -0
  94. package/dist/tools/project-scope.js +119 -0
  95. package/dist/tools/record-feedback.js +1 -1
  96. package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
  97. package/dist/tools/upgrade-impact-callsites.js +23 -5
  98. package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
  99. package/dist/tools/upgrade-impact-changelog.js +79 -0
  100. package/dist/tools/upgrade-impact-imports.d.ts +42 -0
  101. package/dist/tools/upgrade-impact-imports.js +260 -0
  102. package/dist/tools/upgrade-impact-literals.js +1 -1
  103. package/dist/tools/upgrade-impact-report.d.ts +23 -0
  104. package/dist/tools/upgrade-impact-report.js +45 -6
  105. package/dist/tools/upgrade-impact.d.ts +13 -4
  106. package/dist/tools/upgrade-impact.js +236 -85
  107. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  108. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  109. package/dist/tools/upgrade-planner.d.ts +31 -3
  110. package/dist/tools/upgrade-planner.js +74 -19
  111. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  112. package/dist/tools/vulnerability-scan-concise.js +15 -3
  113. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  114. package/dist/tools/vulnerability-scan-format.js +107 -11
  115. package/dist/tools/vulnerability-scan.d.ts +104 -13
  116. package/dist/tools/vulnerability-scan.js +27 -3
  117. package/dist/tools/what-should-i-know.d.ts +22 -2
  118. package/dist/tools/what-should-i-know.js +122 -12
  119. package/package.json +10 -32
package/dist/db.d.ts CHANGED
@@ -1,11 +1,29 @@
1
1
  /**
2
2
  * Database module for 4DA MCP Server
3
3
  *
4
- * Read-only access to the 4DA SQLite database.
5
- * Only the record_feedback function performs writes.
4
+ * The desktop app's database (`4da.db`) is opened READ-ONLY
5
+ * (`{ readonly: true, fileMustExist: true }`) for everything the server reads.
6
+ * The app owns that file and its migrations: the server never creates or
7
+ * alters anything in it. It writes rows into exactly the app-owned tables its
8
+ * tools exist to write (APP_WRITABLE_TABLES: decisions, agent memory, feedback
9
+ * and interactions, which the app's calibration and decision UI read), through
10
+ * a separate write connection opened only for those statements (writerFor).
11
+ *
12
+ * Everything else the server writes goes to files it owns, under
13
+ * serverDataDir():
14
+ * - `cache.db`: registry, OSV and changelog responses (LiveCache).
15
+ * - `standalone.db`: the standalone database when no app is installed, and
16
+ * the fallback for a record the app's schema cannot hold (schema drift).
17
+ *
18
+ * Until 6.0.2 the server created a `live_cache` table inside the app's
19
+ * database (2,774 rows on one machine, 2026-10-07) and added `embedding`
20
+ * columns to two app tables when an embedding provider was set.
21
+ */
22
+ import { type SqliteDatabase } from "./sqlite-driver.js";
23
+ /**
24
+ * Null when a SQLite driver (node:sqlite, or better-sqlite3 with a working
25
+ * native module) can open a database here; otherwise why not, with the fix.
6
26
  */
7
- import type BetterSqlite3 from "better-sqlite3";
8
- /** Null when better-sqlite3 can open a database here; otherwise why not, with the fix. */
9
27
  export declare function nativeBindingProblem(): string | null;
10
28
  import type { RelevantItem, UserContext, FeedbackAction, FeedbackResult } from "./types.js";
11
29
  import type { ProjectScanResult } from "./project-scanner.js";
@@ -30,9 +48,17 @@ export declare function desktopAppDbPaths(platform?: NodeJS.Platform, env?: Node
30
48
  * in, so agent memory and decisions did not follow the user between projects.
31
49
  */
32
50
  export declare function standaloneDbPath(): string;
51
+ /**
52
+ * The directory holding every file this server writes: a per-user data
53
+ * directory (`%LOCALAPPDATA%\4da-mcp`, `~/Library/Application Support/4da-mcp`,
54
+ * `$XDG_DATA_HOME/4da-mcp`). FOURDA_MCP_HOME overrides it (tests use a temp dir).
55
+ */
56
+ export declare function serverDataDir(): string;
57
+ /** The server's response cache (registry, OSV, changelogs). Disposable: deleting it costs refetches only. */
58
+ export declare function serverCacheDbPath(): string;
33
59
  /**
34
60
  * Whether an error says the database file itself is unreadable: not SQLite at
35
- * all, or damaged. better-sqlite3 reports these as SQLITE_NOTADB ("file is not
61
+ * all, or damaged. SQLite reports these as SQLITE_NOTADB ("file is not
36
62
  * a database") and SQLITE_CORRUPT ("database disk image is malformed").
37
63
  */
38
64
  export declare function isUnreadableDbError(error: unknown): boolean;
@@ -105,6 +131,20 @@ export declare function dedupeByTitle<T extends {
105
131
  * the `is_direct` standalone-schema gap slipped through precisely because the
106
132
  * two modes were only ever tested separately.
107
133
  */
134
+ /**
135
+ * The app-owned tables this server is designed to write when the desktop app
136
+ * is installed: decisions (decision_memory), agent memory (agent_memory) and
137
+ * feedback (record_feedback -> the app's calibration). Nothing else in the
138
+ * app's database is ever written, and nothing in it is created or altered.
139
+ */
140
+ export declare const APP_WRITABLE_TABLES: readonly ["developer_decisions", "agent_memory", "interactions", "feedback"];
141
+ export type AppWritableTable = (typeof APP_WRITABLE_TABLES)[number];
142
+ export interface WriteTarget {
143
+ raw: SqliteDatabase;
144
+ /** database = the open (standalone) database; app = the app's table; server_store = fallback on schema drift. */
145
+ target: "database" | "app" | "server_store";
146
+ note?: string;
147
+ }
108
148
  export declare const DEPENDENCY_GROUP_QUERY = "SELECT DISTINCT package_name, language, project_path, is_dev, is_direct FROM project_dependencies";
109
149
  /**
110
150
  * 4DA Database accessor
@@ -120,6 +160,30 @@ export declare class FourDADatabase {
120
160
  * (no pre-existing 4DA desktop app database found).
121
161
  */
122
162
  get isStandalone(): boolean;
163
+ /** True for the desktop app's database, which this connection only reads. */
164
+ get isReadOnly(): boolean;
165
+ private memoryStore;
166
+ private appWriter;
167
+ /**
168
+ * The connection a record into one of the app-owned tables this server is
169
+ * designed to write (APP_WRITABLE_TABLES) goes through, and where it lands.
170
+ *
171
+ * - Standalone database: the database itself.
172
+ * - The desktop app's database: a SEPARATE writable connection, opened on
173
+ * first use for these statements only (the main connection stays
174
+ * read-only), with a busy timeout so a write waits briefly for the app
175
+ * instead of failing. Only when the app's table has every column the
176
+ * statement names; nothing is ever created or altered in the app's file.
177
+ * - Schema drift (table or column missing in the app): the record goes to
178
+ * this server's own store (standaloneDbPath()) and `note` says so.
179
+ */
180
+ writerFor(table: AppWritableTable, columns: readonly string[]): WriteTarget;
181
+ /**
182
+ * The server's own store: its standalone database (standaloneDbPath()).
183
+ * Used for records the app's schema cannot hold (see writerFor). A
184
+ * standalone database is its own store.
185
+ */
186
+ getMemoryStore(): FourDADatabase;
123
187
  /**
124
188
  * Validate that a database file exists and is a readable SQLite database.
125
189
  *
@@ -128,7 +192,7 @@ export declare class FourDADatabase {
128
192
  * By default this runs a CHEAP probe (open + list tables, ~0.1s) which surfaces
129
193
  * a missing, locked, or corrupt-header file. It deliberately does NOT run a full
130
194
  * `PRAGMA integrity_check`: that is an O(database-size) scan (~11s on a 1.7 GB DB,
131
- * and growing) and, because better-sqlite3 is synchronous, it blocks the event
195
+ * and growing) and, because the SQLite drivers are synchronous, it blocks the event
132
196
  * loop — running it on the startup path stalls the MCP stdio handshake until it
133
197
  * finishes, so the host's connect timeout can fire and the server looks like it
134
198
  * "failed to start". Pass `{ deep: true }` for the full integrity_check on the
@@ -142,9 +206,9 @@ export declare class FourDADatabase {
142
206
  deep?: boolean;
143
207
  }): DatabaseValidationResult;
144
208
  /**
145
- * Get the raw better-sqlite3 database instance for custom queries
209
+ * Get the raw database connection (sqlite-driver.ts) for custom queries
146
210
  */
147
- getRawDb(): BetterSqlite3.Database;
211
+ getRawDb(): SqliteDatabase;
148
212
  /**
149
213
  * Close the database connection
150
214
  */
@@ -192,6 +256,7 @@ export declare class FourDADatabase {
192
256
  getFreshness(): DataFreshness;
193
257
  /** `MAX(sources.last_fetch)`, tolerant of a missing `sources` table (returns null). */
194
258
  private safeMaxLastFetch;
259
+ hasTable(table: string): boolean;
195
260
  hasColumn(table: string, column: string): boolean;
196
261
  /**
197
262
  * Idempotently add a column to a table if it does not already exist.
package/dist/db.js CHANGED
@@ -2,24 +2,35 @@
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 { checkNativeBindings, isNativeBindingError, nativeBindingMessage } from "./native-bindings.js";
12
- let Database;
13
- try {
14
- Database = (await import("better-sqlite3")).default;
15
- }
16
- catch (err) {
17
- console.error(`\n [4DA] ${nativeBindingMessage(err)}\n`);
18
- process.exit(1);
19
- }
20
- /** 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
+ */
21
32
  export function nativeBindingProblem() {
22
- return checkNativeBindings(Database);
33
+ return sqliteDriverStatus().problem;
23
34
  }
24
35
  import { dbRecoveryNote, readDbRecoveredMarker, readEngineBlockMarker } from "./freshness-markers.js";
25
36
  /**
@@ -93,17 +104,32 @@ export function desktopAppDbPaths(platform = process.platform, env = process.env
93
104
  * in, so agent memory and decisions did not follow the user between projects.
94
105
  */
95
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;
96
118
  const home = os.homedir();
97
119
  const base = process.platform === "win32"
98
120
  ? process.env.LOCALAPPDATA || path.join(home, "AppData", "Local")
99
121
  : process.platform === "darwin"
100
122
  ? path.join(home, "Library", "Application Support")
101
123
  : process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
102
- 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");
103
129
  }
104
130
  /**
105
131
  * Whether an error says the database file itself is unreadable: not SQLite at
106
- * all, or damaged. better-sqlite3 reports these as SQLITE_NOTADB ("file is not
132
+ * all, or damaged. SQLite reports these as SQLITE_NOTADB ("file is not
107
133
  * a database") and SQLITE_CORRUPT ("database disk image is malformed").
108
134
  */
109
135
  export function isUnreadableDbError(error) {
@@ -222,6 +248,15 @@ export function dedupeByTitle(items, threshold = 0.6) {
222
248
  * the `is_direct` standalone-schema gap slipped through precisely because the
223
249
  * two modes were only ever tested separately.
224
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;
225
260
  export const DEPENDENCY_GROUP_QUERY = "SELECT DISTINCT package_name, language, project_path, is_dev, is_direct FROM project_dependencies";
226
261
  /**
227
262
  * 4DA Database accessor
@@ -246,9 +281,26 @@ export class FourDADatabase {
246
281
  }
247
282
  }
248
283
  let opened = null;
284
+ let standalone = isNew;
249
285
  try {
250
- opened = new Database(absolutePath, { readonly: false }); // Need write for feedback
251
- opened.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
+ }
252
304
  }
253
305
  catch (error) {
254
306
  // Close the handle a failed pragma leaves open: every tool call retries
@@ -260,6 +312,8 @@ export class FourDADatabase {
260
312
  catch {
261
313
  // Nothing to close when the open itself failed.
262
314
  }
315
+ if (error instanceof SqliteUnavailableError)
316
+ throw error;
263
317
  throw new Error(isNativeBindingError(error)
264
318
  ? nativeBindingMessage(error)
265
319
  : isUnreadableDbError(error)
@@ -268,25 +322,22 @@ export class FourDADatabase {
268
322
  }
269
323
  this.db = opened;
270
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;
271
329
  // Standalone mode: create schema for a brand-new database, and recognise
272
330
  // an existing standalone database as one on every later session.
273
- if (isNew) {
331
+ if (isNew)
274
332
  this.createMinimalSchema();
275
- this._isStandalone = true;
276
- }
277
- else {
278
- this._isStandalone = detectStandalone(this.db);
279
- }
280
- if (this._isStandalone) {
281
- this.db.exec(`CREATE TABLE IF NOT EXISTS ${STANDALONE_MARKER} (created_at TEXT DEFAULT (datetime('now')))`);
282
- }
333
+ this.db.exec(`CREATE TABLE IF NOT EXISTS ${STANDALONE_MARKER} (created_at TEXT DEFAULT (datetime('now')))`);
283
334
  // Schema upgrade for standalone databases created before `is_direct` was
284
335
  // added to the minimal schema: the full-DB init branch queries that column
285
336
  // (DEPENDENCY_GROUP_QUERY), and without it session 2+ of a standalone
286
337
  // install throws, gets caught, and silently disables vulnerability_scan /
287
338
  // dependency_health / upgrade_planner. Scanner-inserted manifest deps are
288
- // direct by definition, so DEFAULT 1 backfills correctly. The desktop
289
- // 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.
290
341
  try {
291
342
  const hasDepsTable = this.db
292
343
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='project_dependencies'")
@@ -306,6 +357,67 @@ export class FourDADatabase {
306
357
  get isStandalone() {
307
358
  return this._isStandalone;
308
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
+ }
309
421
  /**
310
422
  * Validate that a database file exists and is a readable SQLite database.
311
423
  *
@@ -314,7 +426,7 @@ export class FourDADatabase {
314
426
  * By default this runs a CHEAP probe (open + list tables, ~0.1s) which surfaces
315
427
  * a missing, locked, or corrupt-header file. It deliberately does NOT run a full
316
428
  * `PRAGMA integrity_check`: that is an O(database-size) scan (~11s on a 1.7 GB DB,
317
- * 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
318
430
  * loop — running it on the startup path stalls the MCP stdio handshake until it
319
431
  * finishes, so the host's connect timeout can fire and the server looks like it
320
432
  * "failed to start". Pass `{ deep: true }` for the full integrity_check on the
@@ -339,7 +451,7 @@ export class FourDADatabase {
339
451
  }
340
452
  let testDb = null;
341
453
  try {
342
- testDb = new Database(absolutePath, { readonly: true });
454
+ testDb = openDatabase(absolutePath, { readonly: true, fileMustExist: true });
343
455
  // Deep, opt-in integrity check (slow: full-database scan). Only on --doctor.
344
456
  if (opts?.deep) {
345
457
  const integrityResult = testDb.pragma("integrity_check");
@@ -384,7 +496,7 @@ export class FourDADatabase {
384
496
  }
385
497
  }
386
498
  /**
387
- * Get the raw better-sqlite3 database instance for custom queries
499
+ * Get the raw database connection (sqlite-driver.ts) for custom queries
388
500
  */
389
501
  getRawDb() {
390
502
  return this.db;
@@ -393,6 +505,11 @@ export class FourDADatabase {
393
505
  * Close the database connection
394
506
  */
395
507
  close() {
508
+ this.memoryStore?.close();
509
+ this.memoryStore = null;
510
+ if (this.appWriter?.open)
511
+ this.appWriter.close();
512
+ this.appWriter = null;
396
513
  this.db.close();
397
514
  }
398
515
  // ===========================================================================
@@ -908,6 +1025,14 @@ export class FourDADatabase {
908
1025
  return null;
909
1026
  }
910
1027
  }
1028
+ hasTable(table) {
1029
+ try {
1030
+ return this.db.pragma(`table_info(${table})`).length > 0;
1031
+ }
1032
+ catch {
1033
+ return false;
1034
+ }
1035
+ }
911
1036
  hasColumn(table, column) {
912
1037
  try {
913
1038
  const cols = this.db.pragma(`table_info(${table})`);
@@ -926,6 +1051,9 @@ export class FourDADatabase {
926
1051
  ensureColumn(table, column, type) {
927
1052
  if (this.hasColumn(table, column))
928
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
+ }
929
1057
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
930
1058
  }
931
1059
  /**
@@ -1274,29 +1402,38 @@ export class FourDADatabase {
1274
1402
  };
1275
1403
  const label = relevanceLabel[action];
1276
1404
  try {
1277
- 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)
1278
1409
  this.ensureFeedbackTable();
1279
- 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(`
1280
1416
  INSERT INTO interactions (item_id, action_type, item_source, signal_strength, timestamp)
1281
1417
  VALUES (?, ?, ?, ?, datetime('now'))
1282
1418
  `);
1283
- 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 = () => {
1284
1422
  const r = insertInteraction.run(itemId, action, row.source_type, signalStrength[action]);
1285
- if (label !== undefined) {
1286
- this.db
1287
- .prepare(`INSERT INTO feedback (source_item_id, relevant) VALUES (?, ?)`)
1288
- .run(itemId, label);
1289
- }
1423
+ insertFeedback?.run(itemId, label);
1290
1424
  return r;
1291
- });
1292
- 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(" ")})` : "";
1293
1429
  return {
1294
1430
  success: true,
1295
- message: label === undefined
1431
+ message: (label === undefined
1296
1432
  ? `Recorded ${action} feedback for item ${itemId}`
1297
- : `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,
1298
1434
  interaction_id: result.lastInsertRowid,
1299
1435
  relevance_label: label === undefined ? null : label === 1,
1436
+ ...(notes.length > 0 ? { _meta: { written_to: "server_store", note: [...new Set(notes)].join(" ") } } : {}),
1300
1437
  };
1301
1438
  }
1302
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;