@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.
- package/CHANGELOG.md +316 -0
- package/README.md +19 -13
- package/dist/db.d.ts +88 -7
- package/dist/db.js +248 -63
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +15 -9
- package/dist/embeddings.js +19 -7
- package/dist/gitignore.js +9 -3
- package/dist/index.js +54 -7
- package/dist/live/cache.d.ts +31 -2
- package/dist/live/cache.js +113 -1
- package/dist/live/changelog-classify.js +4 -1
- package/dist/live/changelog.d.ts +15 -0
- package/dist/live/changelog.js +67 -11
- package/dist/live/dev-scope.d.ts +2 -2
- package/dist/live/fix-paths.d.ts +118 -0
- package/dist/live/fix-paths.js +498 -0
- package/dist/live/go-registry.d.ts +5 -0
- package/dist/live/go-registry.js +1 -1
- package/dist/live/index.d.ts +30 -3
- package/dist/live/index.js +59 -6
- package/dist/live/lockfile-parents.d.ts +61 -0
- package/dist/live/lockfile-parents.js +360 -0
- package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
- package/dist/live/lockfile-parsers-pygo.js +178 -28
- package/dist/live/lockfile-parsers.d.ts +4 -1
- package/dist/live/lockfile-parsers.js +57 -24
- package/dist/live/lockfile-types.d.ts +12 -3
- package/dist/live/lockfile-types.js +2 -1
- package/dist/live/osv-ranges.d.ts +79 -0
- package/dist/live/osv-ranges.js +133 -0
- package/dist/live/osv-scanner.d.ts +1 -0
- package/dist/live/osv-scanner.js +85 -78
- package/dist/live/range-satisfies.d.ts +21 -0
- package/dist/live/range-satisfies.js +190 -0
- package/dist/live/rate-limiter.d.ts +6 -0
- package/dist/live/rate-limiter.js +5 -0
- package/dist/live/registry-health.js +2 -0
- package/dist/live/resolution.d.ts +11 -1
- package/dist/live/resolution.js +16 -6
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +10 -2
- package/dist/live/types.d.ts +17 -1
- package/dist/live/upgrade-sources.d.ts +2 -0
- package/dist/live/upgrade-sources.js +7 -2
- package/dist/memory-store.d.ts +19 -0
- package/dist/memory-store.js +8 -0
- package/dist/native-bindings.d.ts +7 -2
- package/dist/native-bindings.js +4 -0
- package/dist/project-tree.d.ts +50 -8
- package/dist/project-tree.js +121 -29
- package/dist/schema-registry.js +2 -2
- package/dist/setup.d.ts +76 -3
- package/dist/setup.js +313 -130
- package/dist/sqlite-driver.d.ts +134 -0
- package/dist/sqlite-driver.js +359 -0
- package/dist/tool-dispatch.d.ts +10 -1
- package/dist/tool-dispatch.js +37 -2
- package/dist/tools/agent-memory.js +10 -2
- package/dist/tools/app-plan.d.ts +11 -1
- package/dist/tools/app-plan.js +32 -6
- package/dist/tools/briefing-task-scope.d.ts +21 -2
- package/dist/tools/briefing-task-scope.js +97 -4
- package/dist/tools/decision-memory.js +9 -3
- package/dist/tools/dependency-check-signals.d.ts +7 -0
- package/dist/tools/dependency-check-signals.js +17 -2
- package/dist/tools/dependency-check.d.ts +1 -1
- package/dist/tools/dependency-check.js +1 -1
- package/dist/tools/dependency-health.d.ts +1 -1
- package/dist/tools/dependency-health.js +5 -0
- package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
- package/dist/tools/knowledge-gap-ranges.js +1 -1
- package/dist/tools/knowledge-gap-scope.d.ts +2 -2
- package/dist/tools/project-scope.d.ts +45 -0
- package/dist/tools/project-scope.js +119 -0
- package/dist/tools/record-feedback.js +1 -1
- package/dist/tools/upgrade-impact-literals.js +1 -1
- package/dist/tools/upgrade-impact-report.d.ts +10 -0
- package/dist/tools/upgrade-impact-report.js +32 -2
- package/dist/tools/upgrade-impact.js +1 -1
- package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +31 -3
- package/dist/tools/upgrade-planner.js +74 -19
- package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
- package/dist/tools/vulnerability-scan-concise.js +15 -3
- package/dist/tools/vulnerability-scan-format.d.ts +58 -1
- package/dist/tools/vulnerability-scan-format.js +107 -11
- package/dist/tools/vulnerability-scan.d.ts +104 -13
- package/dist/tools/vulnerability-scan.js +27 -3
- package/dist/tools/what-should-i-know.d.ts +22 -2
- package/dist/tools/what-should-i-know.js +121 -11
- 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
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
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"
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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.
|
|
244
|
-
// app's database
|
|
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
|
|
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 =
|
|
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
|
-
|
|
306
|
-
|
|
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:
|
|
326
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
36
|
-
//
|
|
37
|
-
// install script left none, and this check used to say "loaded").
|
|
38
|
-
const
|
|
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
|
|
41
|
-
status:
|
|
42
|
-
detail:
|
|
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
|
package/dist/embeddings.js
CHANGED
|
@@ -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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
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
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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;
|