@4da/mcp-server 6.0.2 → 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 (91) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +13 -10
  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.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/sqlite-driver.d.ts +134 -0
  54. package/dist/sqlite-driver.js +359 -0
  55. package/dist/tool-dispatch.d.ts +8 -1
  56. package/dist/tool-dispatch.js +17 -2
  57. package/dist/tools/agent-memory.js +10 -2
  58. package/dist/tools/app-plan.d.ts +11 -1
  59. package/dist/tools/app-plan.js +32 -6
  60. package/dist/tools/briefing-task-scope.d.ts +21 -2
  61. package/dist/tools/briefing-task-scope.js +97 -4
  62. package/dist/tools/decision-memory.js +9 -3
  63. package/dist/tools/dependency-check-signals.d.ts +7 -0
  64. package/dist/tools/dependency-check-signals.js +17 -2
  65. package/dist/tools/dependency-check.d.ts +1 -1
  66. package/dist/tools/dependency-check.js +1 -1
  67. package/dist/tools/dependency-health.d.ts +1 -1
  68. package/dist/tools/dependency-health.js +5 -0
  69. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  70. package/dist/tools/knowledge-gap-ranges.js +1 -1
  71. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  72. package/dist/tools/project-scope.d.ts +45 -0
  73. package/dist/tools/project-scope.js +119 -0
  74. package/dist/tools/record-feedback.js +1 -1
  75. package/dist/tools/upgrade-impact-literals.js +1 -1
  76. package/dist/tools/upgrade-impact-report.d.ts +10 -0
  77. package/dist/tools/upgrade-impact-report.js +32 -2
  78. package/dist/tools/upgrade-impact.js +1 -1
  79. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  80. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  81. package/dist/tools/upgrade-planner.d.ts +31 -3
  82. package/dist/tools/upgrade-planner.js +74 -19
  83. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  84. package/dist/tools/vulnerability-scan-concise.js +15 -3
  85. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  86. package/dist/tools/vulnerability-scan-format.js +107 -11
  87. package/dist/tools/vulnerability-scan.d.ts +104 -13
  88. package/dist/tools/vulnerability-scan.js +27 -3
  89. package/dist/tools/what-should-i-know.d.ts +22 -2
  90. package/dist/tools/what-should-i-know.js +121 -11
  91. package/package.json +5 -27
@@ -0,0 +1,359 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * The SQLite driver: Node's built-in `node:sqlite` when the runtime has it,
4
+ * otherwise better-sqlite3, behind one interface.
5
+ *
6
+ * Why: npm 12 blocks dependency install scripts unless allowed, so a default
7
+ * `npx @4da/mcp-server` or `npm install` left better-sqlite3's native module
8
+ * unbuilt and every tool failed (measured 2026-10-03 on npm 12.2.0, see
9
+ * native-bindings.ts). `node:sqlite` ships inside Node: no install script, no
10
+ * native download. It is available without a flag from Node 22.13.0 (and
11
+ * 23.4.0); npm 12 itself requires Node ^22.22.2 || ^24.15.0 || >=26, so every
12
+ * npm 12 install has it. better-sqlite3 is an optional dependency now, used on
13
+ * Node 22.0-22.12 where it builds (npm 10 and 11 run its install script).
14
+ *
15
+ * The interface is the subset of better-sqlite3 this server uses: open
16
+ * options `readonly` / `fileMustExist`, prepare().get/all/run (positional
17
+ * parameters, arrays flattened, or one object of named parameters), exec,
18
+ * pragma (incl. `{ simple: true }`), transaction (nested calls become
19
+ * savepoints), close, and the `open` / `readonly` / `name` properties.
20
+ * better-sqlite3's Database satisfies it as is; node:sqlite is adapted
21
+ * (NodeSqliteDatabase) so that rows, BLOBs, error codes and the 5 s busy
22
+ * timeout match what the server's code already expects.
23
+ *
24
+ * `FOURDA_SQLITE_DRIVER=node|better` forces one driver (tests, diagnostics):
25
+ * a forced driver that cannot load is reported, never silently replaced.
26
+ * `none` simulates a host where neither works.
27
+ */
28
+ import * as fs from "node:fs";
29
+ import { createRequire } from "node:module";
30
+ import { checkNativeBindings, NATIVE_BINDING_FIX } from "./native-bindings.js";
31
+ /** Thrown by sqliteDriver() when neither driver can open a database here. */
32
+ export class SqliteUnavailableError extends Error {
33
+ code = "FOURDA_NO_SQLITE";
34
+ }
35
+ /** The first Node release with `node:sqlite` unflagged on the 22 line. */
36
+ export const NODE_SQLITE_MIN = "22.13.0";
37
+ // ---------------------------------------------------------------------------
38
+ // node:sqlite adapter
39
+ // ---------------------------------------------------------------------------
40
+ /** Primary and extended SQLite result codes, by number, as better-sqlite3 names them. */
41
+ const SQLITE_CODES = {
42
+ 1: "SQLITE_ERROR", 2: "SQLITE_INTERNAL", 3: "SQLITE_PERM", 4: "SQLITE_ABORT", 5: "SQLITE_BUSY",
43
+ 6: "SQLITE_LOCKED", 7: "SQLITE_NOMEM", 8: "SQLITE_READONLY", 9: "SQLITE_INTERRUPT", 10: "SQLITE_IOERR",
44
+ 11: "SQLITE_CORRUPT", 12: "SQLITE_NOTFOUND", 13: "SQLITE_FULL", 14: "SQLITE_CANTOPEN", 15: "SQLITE_PROTOCOL",
45
+ 16: "SQLITE_EMPTY", 17: "SQLITE_SCHEMA", 18: "SQLITE_TOOBIG", 19: "SQLITE_CONSTRAINT", 20: "SQLITE_MISMATCH",
46
+ 21: "SQLITE_MISUSE", 22: "SQLITE_NOLFS", 23: "SQLITE_AUTH", 24: "SQLITE_FORMAT", 25: "SQLITE_RANGE",
47
+ 26: "SQLITE_NOTADB",
48
+ 261: "SQLITE_BUSY_RECOVERY", 517: "SQLITE_BUSY_SNAPSHOT", 773: "SQLITE_BUSY_TIMEOUT",
49
+ 262: "SQLITE_LOCKED_SHAREDCACHE", 264: "SQLITE_READONLY_RECOVERY", 520: "SQLITE_READONLY_CANTLOCK",
50
+ 776: "SQLITE_READONLY_ROLLBACK", 1032: "SQLITE_READONLY_DBMOVED", 267: "SQLITE_CORRUPT_VTAB",
51
+ 275: "SQLITE_CONSTRAINT_CHECK", 787: "SQLITE_CONSTRAINT_FOREIGNKEY", 1299: "SQLITE_CONSTRAINT_NOTNULL",
52
+ 1555: "SQLITE_CONSTRAINT_PRIMARYKEY", 2067: "SQLITE_CONSTRAINT_UNIQUE", 2579: "SQLITE_CONSTRAINT_ROWID",
53
+ };
54
+ /** better-sqlite3's error code for a SQLite result code (extended when known, else primary). */
55
+ export function sqliteCodeName(errcode) {
56
+ return SQLITE_CODES[errcode] ?? SQLITE_CODES[errcode & 0xff] ?? `SQLITE_UNKNOWN_${errcode}`;
57
+ }
58
+ /**
59
+ * node:sqlite errors carry `code: "ERR_SQLITE_ERROR"` and the SQLite result
60
+ * code in `errcode`; the server's checks (unreadable database, BUSY/LOCKED
61
+ * retry) read better-sqlite3's `code: "SQLITE_BUSY"` form. Rewrite it in place.
62
+ */
63
+ function normaliseError(error) {
64
+ const e = error;
65
+ if (e && typeof e === "object" && typeof e.errcode === "number") {
66
+ try {
67
+ Object.defineProperty(e, "code", { value: sqliteCodeName(e.errcode), writable: true, enumerable: true, configurable: true });
68
+ }
69
+ catch {
70
+ // A frozen error keeps its own code.
71
+ }
72
+ }
73
+ return error;
74
+ }
75
+ function guarded(fn) {
76
+ try {
77
+ return fn();
78
+ }
79
+ catch (error) {
80
+ throw normaliseError(error);
81
+ }
82
+ }
83
+ function isPlainParamObject(value) {
84
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !ArrayBuffer.isView(value) && !(value instanceof ArrayBuffer);
85
+ }
86
+ /** better-sqlite3's argument forms -> node:sqlite's (named object first, then positionals). */
87
+ function bindArgs(params) {
88
+ let named = null;
89
+ const positional = [];
90
+ const add = (value) => {
91
+ if (Array.isArray(value))
92
+ value.forEach(add);
93
+ else if (isPlainParamObject(value))
94
+ named = { ...(named ?? {}), ...value };
95
+ else
96
+ positional.push(value);
97
+ };
98
+ params.forEach(add);
99
+ return named ? [named, ...positional] : positional;
100
+ }
101
+ function toNumber(value) {
102
+ // better-sqlite3 returns integers past 2^53 as (inexact) numbers; so do we.
103
+ return typeof value === "bigint" ? Number(value) : value;
104
+ }
105
+ /** A row as better-sqlite3 returns it: a plain object, BLOBs as Buffers. */
106
+ function plainRow(row, bigints) {
107
+ if (row === undefined || row === null || typeof row !== "object")
108
+ return row;
109
+ const out = {};
110
+ for (const [key, value] of Object.entries(row)) {
111
+ out[key] =
112
+ value instanceof Uint8Array && !Buffer.isBuffer(value)
113
+ ? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
114
+ : bigints
115
+ ? toNumber(value)
116
+ : value;
117
+ }
118
+ return out;
119
+ }
120
+ function isOutOfRange(error) {
121
+ return error?.code === "ERR_OUT_OF_RANGE";
122
+ }
123
+ class NodeSqliteStatement {
124
+ stmt;
125
+ constructor(stmt) {
126
+ this.stmt = stmt;
127
+ }
128
+ /** Read once as numbers; an integer past 2^53 makes node:sqlite throw, so re-read as BigInts and convert. */
129
+ read(fn, args) {
130
+ const bound = bindArgs(args);
131
+ try {
132
+ return { value: fn(bound), bigints: false };
133
+ }
134
+ catch (error) {
135
+ if (!isOutOfRange(error))
136
+ throw normaliseError(error);
137
+ this.stmt.setReadBigInts(true);
138
+ try {
139
+ return { value: guarded(() => fn(bound)), bigints: true };
140
+ }
141
+ finally {
142
+ this.stmt.setReadBigInts(false);
143
+ }
144
+ }
145
+ }
146
+ run(...params) {
147
+ const result = guarded(() => this.stmt.run(...bindArgs(params)));
148
+ return { changes: Number(result.changes), lastInsertRowid: result.lastInsertRowid };
149
+ }
150
+ get(...params) {
151
+ const { value, bigints } = this.read((a) => this.stmt.get(...a), params);
152
+ return plainRow(value, bigints);
153
+ }
154
+ all(...params) {
155
+ const { value, bigints } = this.read((a) => this.stmt.all(...a), params);
156
+ return value.map((row) => plainRow(row, bigints));
157
+ }
158
+ }
159
+ /** better-sqlite3's default busy timeout, which the server's retry logic assumes. */
160
+ const BUSY_TIMEOUT_MS = 5000;
161
+ export class NodeSqliteDatabase {
162
+ name;
163
+ readonly;
164
+ db;
165
+ isOpen = true;
166
+ depth = 0;
167
+ savepoints = 0;
168
+ constructor(Ctor, file, options = {}) {
169
+ this.name = file;
170
+ this.readonly = options.readonly === true;
171
+ const inMemory = file === ":memory:" || file === "";
172
+ if (!inMemory && (options.fileMustExist || this.readonly) && !fs.existsSync(file)) {
173
+ // better-sqlite3's error for the same case.
174
+ throw Object.assign(new Error("unable to open database file"), { code: "SQLITE_CANTOPEN", errcode: 14 });
175
+ }
176
+ this.db = guarded(() => new Ctor(file, { readOnly: this.readonly }));
177
+ try {
178
+ this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
179
+ // Belt and braces for the read-only contract on the desktop app's
180
+ // database: refused writes even if a runtime ignored `readOnly`.
181
+ if (this.readonly)
182
+ this.db.exec("PRAGMA query_only = ON");
183
+ }
184
+ catch (error) {
185
+ this.db.close();
186
+ throw normaliseError(error);
187
+ }
188
+ }
189
+ get open() {
190
+ return this.isOpen;
191
+ }
192
+ assertOpen() {
193
+ if (!this.isOpen)
194
+ throw new TypeError("The database connection is not open");
195
+ }
196
+ prepare(source) {
197
+ this.assertOpen();
198
+ return new NodeSqliteStatement(guarded(() => this.db.prepare(source)));
199
+ }
200
+ exec(source) {
201
+ this.assertOpen();
202
+ guarded(() => this.db.exec(source));
203
+ return this;
204
+ }
205
+ pragma(source, options) {
206
+ const rows = this.prepare(`PRAGMA ${source}`).all();
207
+ if (options?.simple) {
208
+ const first = rows[0];
209
+ return first ? Object.values(first)[0] : undefined;
210
+ }
211
+ return rows;
212
+ }
213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
+ transaction(fn) {
215
+ return (...args) => {
216
+ this.assertOpen();
217
+ const nested = this.depth > 0;
218
+ const savepoint = nested ? `fourda_sp_${++this.savepoints}` : null;
219
+ this.exec(savepoint ? `SAVEPOINT ${savepoint}` : "BEGIN");
220
+ this.depth++;
221
+ try {
222
+ const result = fn(...args);
223
+ this.exec(savepoint ? `RELEASE ${savepoint}` : "COMMIT");
224
+ return result;
225
+ }
226
+ catch (error) {
227
+ try {
228
+ this.exec(savepoint ? `ROLLBACK TO ${savepoint}; RELEASE ${savepoint}` : "ROLLBACK");
229
+ }
230
+ catch {
231
+ // SQLite already rolled back (e.g. after SQLITE_FULL); the original error matters.
232
+ }
233
+ throw error;
234
+ }
235
+ finally {
236
+ this.depth--;
237
+ }
238
+ };
239
+ }
240
+ close() {
241
+ if (this.isOpen) {
242
+ this.isOpen = false;
243
+ this.db.close();
244
+ }
245
+ return this;
246
+ }
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // Selection
250
+ // ---------------------------------------------------------------------------
251
+ const require = createRequire(import.meta.url);
252
+ /** Load node:sqlite without its one-time "SQLite is an experimental feature" warning; every other warning passes. */
253
+ export function loadNodeSqlite() {
254
+ const original = process.emitWarning;
255
+ process.emitWarning = function (warning, ...rest) {
256
+ const message = typeof warning === "string" ? warning : warning?.message;
257
+ const opt = rest[0];
258
+ const type = typeof opt === "string" ? opt : opt?.type ?? warning?.name;
259
+ if (type === "ExperimentalWarning" && /^SQLite is an experimental feature/.test(String(message)))
260
+ return;
261
+ return original.call(process, warning, ...rest);
262
+ };
263
+ try {
264
+ const mod = require("node:sqlite");
265
+ if (typeof mod.DatabaseSync !== "function")
266
+ throw new Error("node:sqlite has no DatabaseSync");
267
+ return mod.DatabaseSync;
268
+ }
269
+ finally {
270
+ process.emitWarning = original;
271
+ }
272
+ }
273
+ export function loadBetterSqlite3() {
274
+ const mod = require("better-sqlite3");
275
+ return typeof mod === "function" ? mod : mod.default;
276
+ }
277
+ function firstLine(error) {
278
+ return (error instanceof Error ? error.message : String(error)).split("\n")[0].trim();
279
+ }
280
+ function forcedDriver(env) {
281
+ const raw = env.FOURDA_SQLITE_DRIVER?.trim().toLowerCase();
282
+ if (!raw)
283
+ return null;
284
+ if (raw === "node" || raw === "node:sqlite")
285
+ return "node:sqlite";
286
+ if (raw === "better" || raw === "better-sqlite3")
287
+ return "better-sqlite3";
288
+ if (raw === "none")
289
+ return "none";
290
+ return null;
291
+ }
292
+ /**
293
+ * Pick the driver: node:sqlite, then better-sqlite3 if its native module
294
+ * actually opens a database (importing it succeeds without one). Pure apart
295
+ * from the loaders, so tests can make either one fail.
296
+ */
297
+ export function resolveSqliteDriver(loaders = { node: loadNodeSqlite, better: loadBetterSqlite3 }, env = process.env) {
298
+ const forced = forcedDriver(env);
299
+ // "none" simulates a host where neither driver works (tests, diagnostics).
300
+ const order = forced === "none" ? [] : forced ? [forced] : ["node:sqlite", "better-sqlite3"];
301
+ const rejected = [];
302
+ for (const name of order) {
303
+ if (name === "node:sqlite") {
304
+ try {
305
+ const Ctor = loaders.node();
306
+ new NodeSqliteDatabase(Ctor, ":memory:").close();
307
+ return { driver: { name, open: (file, options) => new NodeSqliteDatabase(Ctor, file, options) }, rejected, problem: null };
308
+ }
309
+ catch (error) {
310
+ rejected.push({ name, reason: `not available in Node ${process.versions.node} (${firstLine(error)})` });
311
+ }
312
+ }
313
+ else {
314
+ let Ctor;
315
+ try {
316
+ Ctor = loaders.better();
317
+ }
318
+ catch (error) {
319
+ rejected.push({ name, reason: `not installed (${firstLine(error)})` });
320
+ continue;
321
+ }
322
+ const problem = checkNativeBindings(Ctor);
323
+ if (problem) {
324
+ rejected.push({ name, reason: problem });
325
+ continue;
326
+ }
327
+ return { driver: { name, open: (file, options) => new Ctor(file, options) }, rejected, problem: null };
328
+ }
329
+ }
330
+ const tried = rejected.map((r) => `${r.name}: ${r.reason}`).join("; ");
331
+ const problem = forced === "none"
332
+ ? "FOURDA_SQLITE_DRIVER=none disables SQLite: the database tools are unavailable. Unset it to let the server choose a driver."
333
+ : forced
334
+ ? `FOURDA_SQLITE_DRIVER=${env.FOURDA_SQLITE_DRIVER} forces ${forced}, which cannot be used here (${tried}). Unset it to let the server choose.`
335
+ : `No SQLite driver is usable (${tried}). Fix either one: upgrade Node.js to ${NODE_SQLITE_MIN} or later ` +
336
+ `(its built-in node:sqlite needs no install step), or let better-sqlite3 build: ${NATIVE_BINDING_FIX}`;
337
+ return { driver: null, rejected, problem };
338
+ }
339
+ let resolved = null;
340
+ /** The resolution for this process (computed once). */
341
+ export function sqliteDriverStatus() {
342
+ resolved ??= resolveSqliteDriver();
343
+ return resolved;
344
+ }
345
+ /** The driver in use; throws SqliteUnavailableError with the fix when there is none. */
346
+ export function sqliteDriver() {
347
+ const status = sqliteDriverStatus();
348
+ if (!status.driver)
349
+ throw new SqliteUnavailableError(status.problem ?? "No SQLite driver is usable.");
350
+ return status.driver;
351
+ }
352
+ /** Open a database with the driver in use. */
353
+ export function openDatabase(file, options) {
354
+ return sqliteDriver().open(file, options);
355
+ }
356
+ /** Forget the cached resolution (tests that change FOURDA_SQLITE_DRIVER). */
357
+ export function resetSqliteDriver() {
358
+ resolved = null;
359
+ }
@@ -11,6 +11,13 @@ import type { FourDADatabase } from "./db.js";
11
11
  * Awaiting a sync return resolves immediately, so we can await uniformly.
12
12
  */
13
13
  export type ToolExecutor = (db: FourDADatabase, params: any) => unknown | Promise<unknown>;
14
+ /**
15
+ * Tools that answer from the project's lockfiles and the registries alone, so
16
+ * they still run when no SQLite driver is usable at all (sqlite-driver.ts:
17
+ * Node < 22.13 with better-sqlite3 unbuilt). Their executors accept a null
18
+ * database; every other tool needs one.
19
+ */
20
+ export declare const DATABASE_FREE_TOOLS: ReadonlySet<string>;
14
21
  /**
15
22
  * Dispatch a tool call by name. Awaits uniformly (no-op for sync executors).
16
23
  * Returns MCP-formatted response with JSON-serialized result.
@@ -19,7 +26,7 @@ export type ToolExecutor = (db: FourDADatabase, params: any) => unknown | Promis
19
26
  * every tool call passes through. Stdio callers are unaffected (no auth
20
27
  * context; the host already owns the process).
21
28
  */
22
- export declare function dispatchTool(name: string, db: FourDADatabase, args: Record<string, unknown> | undefined): Promise<CallToolResult>;
29
+ export declare function dispatchTool(name: string, db: FourDADatabase | null, args: Record<string, unknown> | undefined): Promise<CallToolResult>;
23
30
  /** What a desktop-only tool says when no desktop app database was found. */
24
31
  export declare function desktopAppNote(name: string): string;
25
32
  /** Check if a tool exists in the dispatch map */
@@ -37,6 +37,18 @@ const DISPATCH_MAP = {
37
37
  // Identity
38
38
  developer_dna: executeDeveloperDna,
39
39
  };
40
+ /**
41
+ * Tools that answer from the project's lockfiles and the registries alone, so
42
+ * they still run when no SQLite driver is usable at all (sqlite-driver.ts:
43
+ * Node < 22.13 with better-sqlite3 unbuilt). Their executors accept a null
44
+ * database; every other tool needs one.
45
+ */
46
+ export const DATABASE_FREE_TOOLS = new Set([
47
+ "vulnerability_scan",
48
+ "dependency_health",
49
+ "dependency_check",
50
+ "upgrade_impact",
51
+ ]);
40
52
  /**
41
53
  * DB-backed tools whose payload reflects the curated feed and can therefore go stale. The MCP
42
54
  * server reads `4da.db` but cannot fetch or score, so these get a `data_freshness` annotation that
@@ -62,6 +74,9 @@ export async function dispatchTool(name, db, args) {
62
74
  if (!executor) {
63
75
  throw new Error(`Unknown tool: ${name}`);
64
76
  }
77
+ if (!db && !DATABASE_FREE_TOOLS.has(name)) {
78
+ throw new Error(`${name} needs a database, and none is open.`);
79
+ }
65
80
  assertToolPermission(name);
66
81
  // BUSY/LOCKED retry at the single choke point every tool passes through.
67
82
  // The desktop engine writes to the same database every 30 minutes; a read
@@ -86,11 +101,11 @@ export async function dispatchTool(name, db, args) {
86
101
  }
87
102
  // Feed freshness describes the desktop app's feed; a standalone install has
88
103
  // none, and telling it to "run fourda-engine" is noise.
89
- const withFreshness = FRESHNESS_TOOLS.has(name) && !db.isStandalone;
104
+ const withFreshness = db !== null && FRESHNESS_TOOLS.has(name) && !db.isStandalone;
90
105
  // A desktop-only tool called without the desktop app's database (tools/list
91
106
  // hides these in standalone mode, but a host can call one by name) used to
92
107
  // answer `[]`, which reads as "nothing relevant" rather than "no app".
93
- const desktopOnly = db.isStandalone && TOOL_REGISTRY[name]?.standalone === false;
108
+ const desktopOnly = db !== null && db.isStandalone && TOOL_REGISTRY[name]?.standalone === false;
94
109
  const payload = cleanStrings(withFreshness ? attachFreshness(db, result) : desktopOnly ? attachDesktopAppNote(name, result) : result);
95
110
  // An executor that returns `{ error: "<message>" }` failed: say so in the
96
111
  // protocol, not only in the body, so the host and model treat it as an error.
@@ -5,6 +5,7 @@
5
5
  * Cross-agent persistent memory. What Claude Code learns, Cursor can access.
6
6
  * Enables AI agents to store and recall memories across sessions and tools.
7
7
  */
8
+ import { writeMeta, writerOf } from "../memory-store.js";
8
9
  import { rankRowsByRecall } from "./recall.js";
9
10
  import { getEmbeddingConfig, semanticScores, } from "../embeddings.js";
10
11
  // ============================================================================
@@ -149,8 +150,14 @@ export function executeAgentMemory(db, params) {
149
150
  }
150
151
  // --- Storage quota: max 1000 entries per agent ---
151
152
  const agentType = params.agent_type || "unknown";
153
+ // The app's own agent_memory table (separate write connection), or the
154
+ // server's store when the app's schema cannot hold the row.
155
+ const to = writerOf(db, "agent_memory", [
156
+ "session_id", "agent_type", "memory_type", "subject", "content", "context_tags", "expires_at",
157
+ ]);
152
158
  try {
153
- const countRow = rawDb
159
+ // Counted where the row will go.
160
+ const countRow = (to.target === "server_store" ? to.raw : rawDb)
154
161
  .prepare(`SELECT COUNT(*) as cnt FROM agent_memory WHERE agent_type = ?`)
155
162
  .get(agentType);
156
163
  const currentCount = countRow?.cnt ?? 0;
@@ -166,7 +173,7 @@ export function executeAgentMemory(db, params) {
166
173
  };
167
174
  }
168
175
  try {
169
- const stmt = rawDb.prepare(`INSERT INTO agent_memory
176
+ const stmt = to.raw.prepare(`INSERT INTO agent_memory
170
177
  (session_id, agent_type, memory_type, subject, content, context_tags, expires_at)
171
178
  VALUES (?, ?, ?, ?, ?, ?, ?)`);
172
179
  const result = stmt.run(params.session_id || "unknown", params.agent_type || "unknown", params.memory_type || "context", params.subject, params.content, JSON.stringify(params.context_tags || []), params.expires_at || null);
@@ -174,6 +181,7 @@ export function executeAgentMemory(db, params) {
174
181
  success: true,
175
182
  id: result.lastInsertRowid,
176
183
  message: `Memory stored: ${params.subject}`,
184
+ ...writeMeta(to),
177
185
  };
178
186
  }
179
187
  catch (error) {
@@ -76,12 +76,22 @@ export interface AppPlanResult {
76
76
  totalSteps: number;
77
77
  steps: AppPlanStepView[];
78
78
  summary: string;
79
+ /** The project the steps were narrowed to, or null for every project in the plan. */
80
+ projectScope: string | null;
81
+ /** Steps of the full plan that only touch other projects (left out by projectScope). */
82
+ otherProjectSteps?: number;
79
83
  snapshot: Omit<AppPlanSnapshot, "steps" | "generatedAt" | "expiresAt">;
80
84
  provenance: {
81
85
  mode: "app_plan";
82
86
  note: string;
83
87
  };
84
88
  }
89
+ /**
90
+ * The steps that touch `scope` (a project directory): each step keeps only the
91
+ * installed lines with a site in that project, and those lines keep only
92
+ * those sites. Null keeps everything.
93
+ */
94
+ export declare function scopeAppPlanSteps(steps: AppPlanStepView[], scope: string | null): AppPlanStepView[];
85
95
  /** Shape the app's plan as the tool result; `risk_threshold` / `max_recommendations` narrow it. */
86
96
  export declare function formatAppPlan(read: Extract<AppPlanRead, {
87
97
  kind: "plan";
@@ -89,4 +99,4 @@ export declare function formatAppPlan(read: Extract<AppPlanRead, {
89
99
  risk_threshold?: string;
90
100
  max_recommendations?: number;
91
101
  package?: string;
92
- }): AppPlanResult;
102
+ }, scope?: string | null): AppPlanResult;
@@ -14,6 +14,7 @@
14
14
  * APP_PLAN_SCHEMA_VERSION is treated as absent and the caller falls back to
15
15
  * its standalone heuristic.
16
16
  */
17
+ import { isWithin } from "./project-scope.js";
17
18
  /** The snapshot schema this reader understands (Rust `PLAN_SCHEMA_VERSION`). */
18
19
  export const APP_PLAN_SCHEMA_VERSION = 4;
19
20
  const PLAN_KV_KEY = "upgrade_plan_snapshot";
@@ -93,13 +94,32 @@ export function readAppPlan(db, now = new Date()) {
93
94
  }
94
95
  const URGENCY_RISK = { critical: 3, high: 2, medium: 1, watch: 0 };
95
96
  const RISK_LEVELS = { low: 0, medium: 1, high: 2, critical: 3 };
97
+ /**
98
+ * The steps that touch `scope` (a project directory): each step keeps only the
99
+ * installed lines with a site in that project, and those lines keep only
100
+ * those sites. Null keeps everything.
101
+ */
102
+ export function scopeAppPlanSteps(steps, scope) {
103
+ if (!scope)
104
+ return steps;
105
+ const out = [];
106
+ for (const step of steps) {
107
+ const lines = step.lines
108
+ .map((line) => ({ ...line, sites: line.sites.filter((s) => isWithin(s.project, scope)) }))
109
+ .filter((line) => line.sites.length > 0);
110
+ if (lines.length > 0)
111
+ out.push({ ...step, lines });
112
+ }
113
+ return out;
114
+ }
96
115
  const APP_PROVENANCE_NOTE = "The 4DA desktop app's own cross-project, version-confirmed Upgrade Plan, read from its database " +
97
116
  "(the same plan the app shows and `4da plan --json` prints). Each step is keyed by the app's item id.";
98
117
  /** Shape the app's plan as the tool result; `risk_threshold` / `max_recommendations` narrow it. */
99
- export function formatAppPlan(read, params) {
118
+ export function formatAppPlan(read, params, scope = null) {
100
119
  const { snapshot, stale } = read;
101
120
  const threshold = params.risk_threshold ?? "all";
102
- let steps = snapshot.steps;
121
+ const scoped = scopeAppPlanSteps(snapshot.steps, scope);
122
+ let steps = scoped;
103
123
  // `package` narrows the app's plan the same way it narrows the standalone one.
104
124
  const wanted = params.package?.trim().toLowerCase();
105
125
  if (wanted)
@@ -113,8 +133,9 @@ export function formatAppPlan(read, params) {
113
133
  const count = (m) => selected.filter((s) => s.mechanism === m).length;
114
134
  const majors = selected.filter((s) => s.lines.some((l) => l.upgrade_type === "major")).length;
115
135
  const parts = [
116
- `${selected.length} step${selected.length !== 1 ? "s" : ""} from the 4DA app's plan (generated ${snapshot.generatedAt})`,
136
+ `${selected.length} step${selected.length !== 1 ? "s" : ""} from the 4DA app's plan (generated ${snapshot.generatedAt})${scope ? ` for ${scope}` : " across every project it tracks"}`,
117
137
  ];
138
+ const otherProjectSteps = snapshot.steps.length - scoped.length;
118
139
  if (count("manifest_bump") > 0)
119
140
  parts.push(`${count("manifest_bump")} manifest bump(s)`);
120
141
  if (count("lockfile_or_parent_update") > 0) {
@@ -126,8 +147,11 @@ export function formatAppPlan(read, params) {
126
147
  parts.push(`${count("no_fix")} with no fix published`);
127
148
  if (majors > 0)
128
149
  parts.push(`${majors} cross a major version`);
129
- if (selected.length < snapshot.steps.length) {
130
- parts.push(`${snapshot.steps.length} in the full plan (raise max_recommendations for the rest)`);
150
+ if (selected.length < scoped.length) {
151
+ parts.push(`${scoped.length} in ${scope ? "the plan for this project" : "the full plan"} (raise max_recommendations for the rest)`);
152
+ }
153
+ if (scope && otherProjectSteps > 0) {
154
+ parts.push(`${otherProjectSteps} step${otherProjectSteps !== 1 ? "s" : ""} only touch other projects (project_path "*" lists every project)`);
131
155
  }
132
156
  const staleness = stale
133
157
  ? `STALE: past its freshness horizon (${snapshot.expiresAt}). The advisories or installs may have changed since; open the 4DA app to recompute.`
@@ -139,9 +163,11 @@ export function formatAppPlan(read, params) {
139
163
  expiresAt: snapshot.expiresAt,
140
164
  stale,
141
165
  staleness,
142
- totalSteps: snapshot.steps.length,
166
+ totalSteps: scoped.length,
143
167
  steps: selected,
144
168
  summary: parts.join(". ") + ".",
169
+ projectScope: scope,
170
+ ...(scope ? { otherProjectSteps } : {}),
145
171
  snapshot: {
146
172
  schemaVersion: snapshot.schemaVersion,
147
173
  generatorVersion: snapshot.generatorVersion,
@@ -23,6 +23,8 @@ export interface TaskPackage {
23
23
  /** Versions the task text names after the package ("5.x to 7.x" -> from 5.x, to 7.x). */
24
24
  requested_from: string | null;
25
25
  requested_to: string | null;
26
+ /** Set when the package was found through a family the task names ("all tauri plugins" -> "tauri"). */
27
+ family?: string;
26
28
  }
27
29
  /**
28
30
  * Package names that are also everyday words. One of these counts as named
@@ -36,8 +38,25 @@ export declare const COMMON_WORDS: Set<string>;
36
38
  * Direct dependencies first; at most `limit` packages.
37
39
  */
38
40
  export declare function detectTaskPackages(task: string, files: string[], deps: ResolvedDependency[], limit?: number): TaskPackage[];
39
- /** Major version of a version or a `7.x` / `v7` request; null when unreadable. */
40
- export declare function majorOf(version: string | null): number | null;
41
+ /**
42
+ * The package families a task names, as base names: "all tauri plugins",
43
+ * "tauri and its plugins", "@tauri-apps packages", "tauri-plugin-*",
44
+ * "@tauri-apps/*". A name alone ("bump tauri") is not a family.
45
+ */
46
+ export declare function detectFamilyBases(task: string, files?: string[]): string[];
47
+ /** True when `name` belongs to the family of `base`: base-*, base_*, @base/*, @base-apps/*. */
48
+ export declare function inFamily(name: string, base: string): boolean;
49
+ /**
50
+ * The direct dependencies in the families the task names, other than the
51
+ * packages it names exactly. Only packages this project actually declares:
52
+ * a family is expanded against the lockfiles, never guessed. Measured
53
+ * 2026-10-07: "bump tauri and all tauri plugins" matched only `tauri`.
54
+ */
55
+ export declare function detectFamilyPackages(task: string, files: string[], deps: ResolvedDependency[], exact: TaskPackage[], limit?: number): TaskPackage[];
56
+ /** True when the task asks for the newest release ("to latest", "newest major"). */
57
+ export declare function wantsLatest(task: string): boolean;
58
+ /** The caret line a version sits on: "2" for 2.12.1, "0.13" for 0.13.5. */
59
+ export declare function caretLine(version: string | null): string | null;
41
60
  /** Actionable advisories on the installed copies of one package, highest presented severity first. */
42
61
  export declare function advisoriesFor(pkg: TaskPackage, vulns: VulnerabilityEntry[]): {
43
62
  id: string;