@4da/mcp-server 5.0.3 → 6.0.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 +667 -0
- package/README.md +66 -35
- package/dist/build-staleness.d.ts +35 -0
- package/dist/build-staleness.js +95 -0
- package/dist/db.d.ts +53 -2
- package/dist/db.js +242 -36
- package/dist/doctor.js +27 -7
- package/dist/embeddings.js +7 -1
- package/dist/freshness-markers.d.ts +34 -0
- package/dist/freshness-markers.js +74 -0
- package/dist/gitignore.d.ts +37 -0
- package/dist/gitignore.js +130 -0
- package/dist/index.js +127 -44
- package/dist/live/bun-lockfile.d.ts +26 -0
- package/dist/live/bun-lockfile.js +123 -0
- package/dist/live/cargo-platform.d.ts +38 -0
- package/dist/live/cargo-platform.js +178 -0
- package/dist/live/changelog-classify.d.ts +41 -0
- package/dist/live/changelog-classify.js +207 -0
- package/dist/live/changelog.d.ts +64 -0
- package/dist/live/changelog.js +318 -0
- package/dist/live/crates-registry.js +3 -1
- package/dist/live/crates-versions.d.ts +47 -0
- package/dist/live/crates-versions.js +201 -0
- package/dist/live/dependency-set.d.ts +13 -0
- package/dist/live/dependency-set.js +95 -0
- package/dist/live/dev-scope.d.ts +32 -0
- package/dist/live/dev-scope.js +102 -0
- package/dist/live/file-signature.d.ts +18 -0
- package/dist/live/file-signature.js +43 -0
- package/dist/live/go-registry.js +3 -1
- package/dist/live/hn-fetcher.js +9 -3
- package/dist/live/index.d.ts +105 -2
- package/dist/live/index.js +266 -139
- package/dist/live/install-state.d.ts +43 -0
- package/dist/live/install-state.js +150 -0
- package/dist/live/js-lockfile-readers.d.ts +46 -0
- package/dist/live/js-lockfile-readers.js +181 -0
- package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
- package/dist/live/lockfile-parsers-pygo.js +274 -0
- package/dist/live/lockfile-parsers.d.ts +69 -0
- package/dist/live/lockfile-parsers.js +311 -0
- package/dist/live/lockfile-types.d.ts +34 -0
- package/dist/live/lockfile-types.js +42 -0
- package/dist/live/maintenance.d.ts +33 -0
- package/dist/live/maintenance.js +37 -0
- package/dist/live/npm-packument.d.ts +47 -0
- package/dist/live/npm-packument.js +150 -0
- package/dist/live/npm-registry.js +67 -53
- package/dist/live/osv-scanner.d.ts +31 -1
- package/dist/live/osv-scanner.js +119 -10
- package/dist/live/package-archive.d.ts +49 -0
- package/dist/live/package-archive.js +186 -0
- package/dist/live/pypi-registry.js +3 -1
- package/dist/live/registry-health.d.ts +16 -0
- package/dist/live/registry-health.js +74 -0
- package/dist/live/release-metadata.d.ts +67 -0
- package/dist/live/release-metadata.js +16 -0
- package/dist/live/resolution.d.ts +43 -0
- package/dist/live/resolution.js +62 -0
- package/dist/live/semver-precedence.d.ts +30 -0
- package/dist/live/semver-precedence.js +83 -0
- package/dist/live/severity-scope.d.ts +57 -0
- package/dist/live/severity-scope.js +89 -0
- package/dist/live/types.d.ts +83 -0
- package/dist/live/upgrade-sources.d.ts +71 -0
- package/dist/live/upgrade-sources.js +198 -0
- package/dist/live/version-compare.d.ts +39 -0
- package/dist/live/version-compare.js +130 -0
- package/dist/live/version-resolver.d.ts +14 -1
- package/dist/live/version-resolver.js +93 -303
- package/dist/native-bindings.d.ts +19 -0
- package/dist/native-bindings.js +39 -0
- package/dist/project-manifests.d.ts +40 -0
- package/dist/project-manifests.js +193 -0
- package/dist/project-scanner.js +38 -48
- package/dist/project-tree.d.ts +41 -0
- package/dist/project-tree.js +110 -0
- package/dist/prompts.d.ts +28 -0
- package/dist/prompts.js +54 -0
- package/dist/schema-registry.d.ts +41 -29
- package/dist/schema-registry.js +98 -105
- package/dist/server-instructions.d.ts +11 -0
- package/dist/server-instructions.js +36 -0
- package/dist/tool-args.d.ts +28 -0
- package/dist/tool-args.js +93 -0
- package/dist/tool-dispatch.d.ts +1 -1
- package/dist/tool-dispatch.js +42 -8
- package/dist/tools/app-plan.d.ts +92 -0
- package/dist/tools/app-plan.js +155 -0
- package/dist/tools/briefing-stored-advisories.d.ts +20 -0
- package/dist/tools/briefing-stored-advisories.js +58 -0
- package/dist/tools/briefing-task-scope.d.ts +73 -0
- package/dist/tools/briefing-task-scope.js +197 -0
- package/dist/tools/briefing-wisdom.d.ts +31 -0
- package/dist/tools/briefing-wisdom.js +148 -0
- package/dist/tools/decision-enforcement.d.ts +0 -5
- package/dist/tools/decision-enforcement.js +0 -4
- package/dist/tools/decision-memory.js +18 -66
- package/dist/tools/dependency-check-signals.d.ts +74 -0
- package/dist/tools/dependency-check-signals.js +264 -0
- package/dist/tools/dependency-check.d.ts +98 -0
- package/dist/tools/dependency-check.js +295 -0
- package/dist/tools/dependency-health.js +74 -21
- package/dist/tools/developer-dna.d.ts +3 -0
- package/dist/tools/developer-dna.js +16 -15
- package/dist/tools/ecosystem-pulse.d.ts +6 -2
- package/dist/tools/ecosystem-pulse.js +53 -14
- package/dist/tools/get-actionable-signals.d.ts +26 -4
- package/dist/tools/get-actionable-signals.js +214 -251
- package/dist/tools/get-context.d.ts +2 -0
- package/dist/tools/get-context.js +9 -1
- package/dist/tools/get-relevant-content.d.ts +3 -1
- package/dist/tools/get-relevant-content.js +34 -3
- package/dist/tools/index.d.ts +4 -2
- package/dist/tools/index.js +4 -2
- package/dist/tools/install-drift-notes.d.ts +51 -0
- package/dist/tools/install-drift-notes.js +172 -0
- package/dist/tools/knowledge-gap-citations.d.ts +55 -0
- package/dist/tools/knowledge-gap-citations.js +132 -0
- package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
- package/dist/tools/knowledge-gap-exposure.js +160 -0
- package/dist/tools/knowledge-gap-grading.d.ts +91 -0
- package/dist/tools/knowledge-gap-grading.js +157 -0
- package/dist/tools/knowledge-gap-installs.d.ts +65 -0
- package/dist/tools/knowledge-gap-installs.js +134 -0
- package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
- package/dist/tools/knowledge-gap-ranges.js +146 -0
- package/dist/tools/knowledge-gap-scope.d.ts +63 -0
- package/dist/tools/knowledge-gap-scope.js +118 -0
- package/dist/tools/knowledge-gaps.d.ts +12 -3
- package/dist/tools/knowledge-gaps.js +172 -121
- package/dist/tools/package-presence.d.ts +24 -0
- package/dist/tools/package-presence.js +35 -0
- package/dist/tools/record-feedback.d.ts +0 -1
- package/dist/tools/record-feedback.js +7 -8
- package/dist/tools/signal-classifier.d.ts +25 -0
- package/dist/tools/signal-classifier.js +40 -0
- package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
- package/dist/tools/upgrade-impact-callsites.js +226 -0
- package/dist/tools/upgrade-impact-literals.d.ts +45 -0
- package/dist/tools/upgrade-impact-literals.js +118 -0
- package/dist/tools/upgrade-impact-report.d.ts +80 -0
- package/dist/tools/upgrade-impact-report.js +178 -0
- package/dist/tools/upgrade-impact.d.ts +84 -0
- package/dist/tools/upgrade-impact.js +305 -0
- package/dist/tools/upgrade-planner.d.ts +41 -8
- package/dist/tools/upgrade-planner.js +201 -53
- package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
- package/dist/tools/vulnerability-scan-concise.js +97 -0
- package/dist/tools/vulnerability-scan-format.d.ts +137 -0
- package/dist/tools/vulnerability-scan-format.js +276 -0
- package/dist/tools/vulnerability-scan.d.ts +338 -18
- package/dist/tools/vulnerability-scan.js +84 -125
- package/dist/tools/what-should-i-know.d.ts +86 -19
- package/dist/tools/what-should-i-know.js +277 -283
- package/dist/types.d.ts +4 -1
- package/package.json +27 -30
- package/dist/schemas/agent-memory.json +0 -49
- package/dist/schemas/decision-enforcement.json +0 -22
- package/dist/schemas/decision-memory.json +0 -54
- package/dist/schemas/dependency-health.json +0 -27
- package/dist/schemas/developer-dna.json +0 -219
- package/dist/schemas/ecosystem-pulse.json +0 -17
- package/dist/schemas/get-actionable-signals.json +0 -29
- package/dist/schemas/get-context.json +0 -17
- package/dist/schemas/get-relevant-content.json +0 -26
- package/dist/schemas/knowledge-gaps.json +0 -14
- package/dist/schemas/record-feedback.json +0 -24
- package/dist/schemas/upgrade-planner.json +0 -22
- package/dist/schemas/vulnerability-scan.json +0 -26
- package/dist/schemas/what-should-i-know.json +0 -19
package/dist/db.js
CHANGED
|
@@ -10,24 +10,22 @@ import * as fs from "fs";
|
|
|
10
10
|
import * as os from "os";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
|
+
import { checkNativeBindings, isNativeBindingError, nativeBindingMessage } from "./native-bindings.js";
|
|
13
14
|
let Database;
|
|
14
15
|
try {
|
|
15
16
|
Database = (await import("better-sqlite3")).default;
|
|
16
17
|
}
|
|
17
18
|
catch (err) {
|
|
18
|
-
|
|
19
|
-
console.error("\n [4DA] Failed to load better-sqlite3 native bindings.\n\n" +
|
|
20
|
-
" This usually means your system is missing C++ build tools.\n" +
|
|
21
|
-
" Install the appropriate tools for your platform:\n\n" +
|
|
22
|
-
" macOS: xcode-select --install\n" +
|
|
23
|
-
" Ubuntu: sudo apt install build-essential python3\n" +
|
|
24
|
-
" Windows: npm install -g windows-build-tools\n\n" +
|
|
25
|
-
` Error: ${msg}\n\n` +
|
|
26
|
-
" After installing build tools, run: npm rebuild better-sqlite3\n");
|
|
19
|
+
console.error(`\n [4DA] ${nativeBindingMessage(err)}\n`);
|
|
27
20
|
process.exit(1);
|
|
28
21
|
}
|
|
22
|
+
/** Null when better-sqlite3 can open a database here; otherwise why not, with the fix. */
|
|
23
|
+
export function nativeBindingProblem() {
|
|
24
|
+
return checkNativeBindings(Database);
|
|
25
|
+
}
|
|
29
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
30
27
|
const __dirname = dirname(__filename);
|
|
28
|
+
import { dbRecoveryNote, readDbRecoveredMarker, readEngineBlockMarker } from "./freshness-markers.js";
|
|
31
29
|
/**
|
|
32
30
|
* Resolve the database path by checking multiple locations in priority order:
|
|
33
31
|
* 1. FOURDA_DB_PATH env var
|
|
@@ -51,23 +49,85 @@ function getDefaultDbPath() {
|
|
|
51
49
|
if (fs.existsSync(projectRootPath)) {
|
|
52
50
|
return projectRootPath;
|
|
53
51
|
}
|
|
54
|
-
// 4.
|
|
55
|
-
const
|
|
56
|
-
|
|
52
|
+
// 4. The desktop app's own database, where the deployed app keeps it.
|
|
53
|
+
for (const appDataPath of desktopAppDbPaths()) {
|
|
54
|
+
if (fs.existsSync(appDataPath))
|
|
55
|
+
return appDataPath;
|
|
56
|
+
}
|
|
57
|
+
// 5. No desktop database: the standalone database, in the user's data dir.
|
|
58
|
+
return standaloneDbPath();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where the deployed desktop app keeps its database, mirroring
|
|
62
|
+
* `src-tauri/src/state.rs::get_db_path` / `get_platform_data_dir`:
|
|
63
|
+
* FOURDA_DATA_DIR overrides; Windows `%APPDATA%\com.4da.app\data`, macOS
|
|
64
|
+
* `~/Library/Application Support/com.4da.app/data`, Linux
|
|
65
|
+
* `$XDG_DATA_HOME/4da/data` (default `~/.local/share/4da/data`).
|
|
66
|
+
*
|
|
67
|
+
* Measured 2026-10-03 (pre-publish verification): this looked in
|
|
68
|
+
* `~/.local/share/com.4da.app/data` on Linux, a directory the app never
|
|
69
|
+
* writes, so a Linux desktop user's server never found the app's database and
|
|
70
|
+
* never offered the desktop tools. The old path is still checked last, in case
|
|
71
|
+
* a database was ever placed there by hand.
|
|
72
|
+
*/
|
|
73
|
+
export function desktopAppDbPaths(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
74
|
+
const join = platform === "win32" ? path.win32.join : path.posix.join;
|
|
75
|
+
const paths = [];
|
|
76
|
+
const dataDir = env.FOURDA_DATA_DIR?.trim();
|
|
77
|
+
if (dataDir)
|
|
78
|
+
paths.push(join(dataDir, "4da.db"));
|
|
57
79
|
if (platform === "win32") {
|
|
58
|
-
|
|
80
|
+
paths.push(join(env.APPDATA || join(home, "AppData", "Roaming"), "com.4da.app", "data", "4da.db"));
|
|
59
81
|
}
|
|
60
82
|
else if (platform === "darwin") {
|
|
61
|
-
|
|
83
|
+
paths.push(join(home, "Library", "Application Support", "com.4da.app", "data", "4da.db"));
|
|
62
84
|
}
|
|
63
85
|
else {
|
|
64
|
-
|
|
86
|
+
const xdg = env.XDG_DATA_HOME?.trim() || join(home, ".local", "share");
|
|
87
|
+
paths.push(join(xdg, "4da", "data", "4da.db"));
|
|
88
|
+
paths.push(join(home, ".local", "share", "com.4da.app", "data", "4da.db"));
|
|
89
|
+
}
|
|
90
|
+
return paths;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Where a standalone install keeps its database: a per-user data directory.
|
|
94
|
+
* It used to be `<cwd>/data/4da.db` — inside the user's own repository, where
|
|
95
|
+
* it could be committed, and one per directory the server happened to start
|
|
96
|
+
* in, so agent memory and decisions did not follow the user between projects.
|
|
97
|
+
*/
|
|
98
|
+
export function standaloneDbPath() {
|
|
99
|
+
const home = os.homedir();
|
|
100
|
+
const base = process.platform === "win32"
|
|
101
|
+
? process.env.LOCALAPPDATA || path.join(home, "AppData", "Local")
|
|
102
|
+
: process.platform === "darwin"
|
|
103
|
+
? path.join(home, "Library", "Application Support")
|
|
104
|
+
: process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
105
|
+
return path.join(base, "4da-mcp", "standalone.db");
|
|
106
|
+
}
|
|
107
|
+
/** Marker table written into every standalone database. */
|
|
108
|
+
const STANDALONE_MARKER = "mcp_standalone";
|
|
109
|
+
/**
|
|
110
|
+
* Whether an EXISTING database is a standalone one. The marker decides; a
|
|
111
|
+
* database created before the marker existed is recognised by its minimal
|
|
112
|
+
* schema (schema_version 1, a few dozen tables) — the desktop app's database
|
|
113
|
+
* carries 140+ tables and a schema version past 100. Getting this wrong was a
|
|
114
|
+
* bug: from the second session on, a standalone database read as the desktop
|
|
115
|
+
* app's, so tools/list advertised five tools with no data behind them and the
|
|
116
|
+
* project was never rescanned.
|
|
117
|
+
*/
|
|
118
|
+
function detectStandalone(db) {
|
|
119
|
+
try {
|
|
120
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((t) => t.name);
|
|
121
|
+
if (tables.includes(STANDALONE_MARKER))
|
|
122
|
+
return true;
|
|
123
|
+
if (!tables.includes("schema_version") || tables.length >= 40)
|
|
124
|
+
return false;
|
|
125
|
+
const row = db.prepare("SELECT MAX(version) AS v FROM schema_version").get();
|
|
126
|
+
return row?.v === 1;
|
|
65
127
|
}
|
|
66
|
-
|
|
67
|
-
return
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
68
130
|
}
|
|
69
|
-
// 5. Final fallback
|
|
70
|
-
return path.resolve(process.cwd(), "data", "4da.db");
|
|
71
131
|
}
|
|
72
132
|
/** Minutes a feed may go without a fetch before DB-backed tools flag it stale. */
|
|
73
133
|
const STALE_AFTER_MINUTES = 60;
|
|
@@ -159,13 +219,22 @@ export class FourDADatabase {
|
|
|
159
219
|
this.db.pragma("journal_mode = WAL");
|
|
160
220
|
}
|
|
161
221
|
catch (error) {
|
|
162
|
-
throw new Error(
|
|
222
|
+
throw new Error(isNativeBindingError(error)
|
|
223
|
+
? nativeBindingMessage(error)
|
|
224
|
+
: `Failed to open 4DA database at ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
225
|
}
|
|
164
|
-
// Standalone mode: create schema for a brand-new database
|
|
226
|
+
// Standalone mode: create schema for a brand-new database, and recognise
|
|
227
|
+
// an existing standalone database as one on every later session.
|
|
165
228
|
if (isNew) {
|
|
166
229
|
this.createMinimalSchema();
|
|
167
230
|
this._isStandalone = true;
|
|
168
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
|
+
}
|
|
169
238
|
// Schema upgrade for standalone databases created before `is_direct` was
|
|
170
239
|
// added to the minimal schema: the full-DB init branch queries that column
|
|
171
240
|
// (DEPENDENCY_GROUP_QUERY), and without it session 2+ of a standalone
|
|
@@ -247,6 +316,7 @@ export class FourDADatabase {
|
|
|
247
316
|
return {
|
|
248
317
|
valid: true,
|
|
249
318
|
tables: tables.map((t) => t.name),
|
|
319
|
+
standalone: detectStandalone(testDb),
|
|
250
320
|
};
|
|
251
321
|
}
|
|
252
322
|
catch (error) {
|
|
@@ -400,6 +470,17 @@ export class FourDADatabase {
|
|
|
400
470
|
CREATE INDEX IF NOT EXISTS idx_interactions_timestamp ON interactions(timestamp);
|
|
401
471
|
CREATE INDEX IF NOT EXISTS idx_interactions_item ON interactions(source_item_id);
|
|
402
472
|
|
|
473
|
+
-- Explicit relevance labels — written by record_feedback (save / mark_irrelevant);
|
|
474
|
+
-- the desktop calibration fitter reads this table as ground truth. Same DDL as desktop.
|
|
475
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
476
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
477
|
+
source_item_id INTEGER NOT NULL,
|
|
478
|
+
relevant INTEGER NOT NULL,
|
|
479
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
480
|
+
FOREIGN KEY (source_item_id) REFERENCES source_items(id)
|
|
481
|
+
);
|
|
482
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_item ON feedback(source_item_id);
|
|
483
|
+
|
|
403
484
|
-- Project dependencies — queried by project_health, tech_radar, developer_dna, knowledge_gaps
|
|
404
485
|
CREATE TABLE IF NOT EXISTS project_dependencies (
|
|
405
486
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -610,8 +691,11 @@ export class FourDADatabase {
|
|
|
610
691
|
const insertDep = this.db.prepare("INSERT OR IGNORE INTO project_dependencies (project_path, manifest_type, package_name, version, is_dev, language) VALUES (?, ?, ?, ?, ?, ?)");
|
|
611
692
|
const insertInterest = this.db.prepare("INSERT OR IGNORE INTO explicit_interests (topic, weight, source) VALUES (?, ?, ?)");
|
|
612
693
|
const insertTechStack = this.db.prepare("INSERT OR IGNORE INTO tech_stack (technology) VALUES (?)");
|
|
613
|
-
// Run all inserts in a single transaction for speed
|
|
694
|
+
// Run all inserts in a single transaction for speed. A rescan replaces
|
|
695
|
+
// this project's dependency rows rather than accumulating removed ones.
|
|
696
|
+
const clearDeps = this.db.prepare("DELETE FROM project_dependencies WHERE project_path = ?");
|
|
614
697
|
const populate = this.db.transaction(() => {
|
|
698
|
+
clearDeps.run(scan.projectPath);
|
|
615
699
|
// Languages -> detected_tech + tech_stack + active_topics
|
|
616
700
|
for (const lang of scan.languages) {
|
|
617
701
|
insertTech.run(lang, "language", 0.95, "project_scan");
|
|
@@ -683,15 +767,25 @@ export class FourDADatabase {
|
|
|
683
767
|
* Get relevant content items scored by the Rust pipeline (preferred) or
|
|
684
768
|
* TypeScript fallback scoring when Rust scores are unavailable.
|
|
685
769
|
*/
|
|
686
|
-
getRelevantContent(minScore = 0.35, sourceType, limit = 20, sinceHours = 24, requireCurrentVersion = false
|
|
770
|
+
getRelevantContent(minScore = 0.35, sourceType, limit = 20, sinceHours = 24, requireCurrentVersion = false,
|
|
771
|
+
/**
|
|
772
|
+
* Restrict to items the scoring pipeline stamped with this signal_type.
|
|
773
|
+
* Lets a type-specific read (a security-only pass) fill its limit with
|
|
774
|
+
* matches instead of competing for the top-N slots across every type.
|
|
775
|
+
* Only meaningful over pipeline-stamped rows: without Rust scores there
|
|
776
|
+
* are no stored types, so the read returns nothing rather than pretend.
|
|
777
|
+
*/
|
|
778
|
+
signalType) {
|
|
687
779
|
const sinceDate = new Date(Date.now() - sinceHours * 60 * 60 * 1000)
|
|
688
780
|
.toISOString()
|
|
689
781
|
.replace("T", " ")
|
|
690
782
|
.slice(0, 19);
|
|
691
783
|
const hasRustScores = this.hasColumn("source_items", "relevance_score");
|
|
692
784
|
if (hasRustScores) {
|
|
693
|
-
return this.getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion);
|
|
785
|
+
return this.getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion, signalType);
|
|
694
786
|
}
|
|
787
|
+
if (signalType)
|
|
788
|
+
return [];
|
|
695
789
|
// TypeScript fallback computes scores fresh per call — current by construction.
|
|
696
790
|
return this.getRelevantContentFallback(minScore, sourceType, limit, sinceDate);
|
|
697
791
|
}
|
|
@@ -722,10 +816,30 @@ export class FourDADatabase {
|
|
|
722
816
|
}
|
|
723
817
|
const ageMinutes = minutesSince(lastFetchAt);
|
|
724
818
|
const isStale = ageMinutes === null || ageMinutes > STALE_AFTER_MINUTES;
|
|
725
|
-
|
|
819
|
+
let note = isStale
|
|
726
820
|
? `Feed data ${ageMinutes === null ? "has never been fetched" : `is ~${ageMinutes} min old`} and may be stale: the MCP server reads the database but does not fetch. Run ` +
|
|
727
821
|
"`fourda-engine --once` (or open the 4DA app) to refresh."
|
|
728
822
|
: `Feed data is ~${ageMinutes} min old (fresh).`;
|
|
823
|
+
// Engine-block marker: a scheduled refresh that can only REFUSE (binary
|
|
824
|
+
// older than the DB schema) cannot record its failure in the database, so
|
|
825
|
+
// the Rust engine leaves `data/.engine-blocked` beside it. That exact
|
|
826
|
+
// failure froze the feed for two days (2026-08-28→30) with ERROR-log-only
|
|
827
|
+
// symptoms; this reader is the one that caught it, so it now names the
|
|
828
|
+
// cause instead of just the staleness.
|
|
829
|
+
const engineBlock = readEngineBlockMarker(this.db.name);
|
|
830
|
+
if (engineBlock) {
|
|
831
|
+
note += ` ENGINE BLOCKED since ${engineBlock.at}: ${engineBlock.error} — a rebuilt/updated 4DA binary is required; a plain refresh will keep refusing.`;
|
|
832
|
+
}
|
|
833
|
+
// Database-recovery marker: when the headless refresh engine restores the
|
|
834
|
+
// database from a backup, or quarantines it and starts a fresh empty one,
|
|
835
|
+
// every answer read from it may be incomplete or empty, and a fresh
|
|
836
|
+
// database looks "fresh" by every other field here. The engine leaves
|
|
837
|
+
// `data/.db-recovered` beside the DB; it is surfaced on every DB-backed
|
|
838
|
+
// answer until the desktop app has shown it and deleted it. Read-only.
|
|
839
|
+
const recovered = readDbRecoveredMarker(this.db.name);
|
|
840
|
+
if (recovered) {
|
|
841
|
+
note += ` ${dbRecoveryNote(recovered)}`;
|
|
842
|
+
}
|
|
729
843
|
return {
|
|
730
844
|
source_items_total: total,
|
|
731
845
|
newest_item_at: newestItemAt,
|
|
@@ -736,6 +850,16 @@ export class FourDADatabase {
|
|
|
736
850
|
age_minutes: ageMinutes,
|
|
737
851
|
is_stale: isStale,
|
|
738
852
|
note,
|
|
853
|
+
...(engineBlock
|
|
854
|
+
? { engine_blocked_at: engineBlock.at, engine_blocked_error: engineBlock.error }
|
|
855
|
+
: {}),
|
|
856
|
+
...(recovered
|
|
857
|
+
? {
|
|
858
|
+
db_recovered_at: recovered.at,
|
|
859
|
+
db_recovery_kind: recovered.kind,
|
|
860
|
+
db_recovery_detail: recovered.detail,
|
|
861
|
+
}
|
|
862
|
+
: {}),
|
|
739
863
|
};
|
|
740
864
|
}
|
|
741
865
|
/** `MAX(sources.last_fetch)`, tolerant of a missing `sources` table (returns null). */
|
|
@@ -771,7 +895,7 @@ export class FourDADatabase {
|
|
|
771
895
|
* Use Rust-computed relevance_score from the desktop app's scoring pipeline.
|
|
772
896
|
* These scores are from the full 5-axis PASIFA engine — much more accurate.
|
|
773
897
|
*/
|
|
774
|
-
getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion = false) {
|
|
898
|
+
getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion = false, signalType) {
|
|
775
899
|
// Grounding subquery — guarded: older/partial 4DA DBs may have relevance_score
|
|
776
900
|
// (so the Rust path runs) but lack source_item_dependencies. Fall back to 0
|
|
777
901
|
// (→ evidence_class "semantic_only") rather than throwing.
|
|
@@ -822,6 +946,17 @@ export class FourDADatabase {
|
|
|
822
946
|
const curationGuard = this.hasColumn("source_items", "feed_relevant")
|
|
823
947
|
? ` AND (feed_relevant IS NULL OR feed_relevant = 1)`
|
|
824
948
|
: ``;
|
|
949
|
+
// Historical-advisory guard: OSV/CVE backfills ingest decades-old advisories
|
|
950
|
+
// whose created_at (discovery time) is days old but whose published_at is
|
|
951
|
+
// ancient — and the scoring brain rates them 0.9 because they name the
|
|
952
|
+
// user's deps. Live 2026-08-30: half the top-20 was React XSS fixed in 0.14
|
|
953
|
+
// (2015) and tokio races fixed in 2021, on a react 19 / tokio 1.52 stack.
|
|
954
|
+
// A reading feed is about what is CURRENT; version-aware exposure checking
|
|
955
|
+
// is vulnerability_scan / knowledge_gaps territory. NULL published_at is
|
|
956
|
+
// kept — absence of a date is not evidence of age.
|
|
957
|
+
const advisoryFreshnessGuard = this.hasColumn("source_items", "published_at")
|
|
958
|
+
? ` AND NOT (source_type IN ('osv', 'cve') AND published_at IS NOT NULL AND datetime(published_at) < datetime('now', '-90 days'))`
|
|
959
|
+
: ``;
|
|
825
960
|
let query = `
|
|
826
961
|
SELECT id, source_type, source_id, url, title, content, content_hash,
|
|
827
962
|
created_at, last_seen, relevance_score, content_type,
|
|
@@ -829,14 +964,32 @@ export class FourDADatabase {
|
|
|
829
964
|
${depCount} AS dep_match_count
|
|
830
965
|
FROM source_items
|
|
831
966
|
WHERE relevance_score >= ?
|
|
832
|
-
AND datetime(created_at) >= datetime(?)${versionGuard}${curationGuard}
|
|
967
|
+
AND datetime(created_at) >= datetime(?)${versionGuard}${curationGuard}${advisoryFreshnessGuard}
|
|
833
968
|
`;
|
|
834
969
|
const params = [minScore, sinceDate];
|
|
835
970
|
if (sourceType) {
|
|
836
971
|
query += ` AND source_type = ?`;
|
|
837
972
|
params.push(sourceType);
|
|
838
973
|
}
|
|
839
|
-
|
|
974
|
+
if (signalType && this.hasColumn("source_items", "signal_type")) {
|
|
975
|
+
query += ` AND signal_type = ?`;
|
|
976
|
+
params.push(signalType);
|
|
977
|
+
}
|
|
978
|
+
// Ranked read (desktop audit items 12+26, schema 110): order by the
|
|
979
|
+
// batch-relative rank when the analysis cycle has ranked the item, falling
|
|
980
|
+
// back to the evidence score. This mirrors the Rust source of truth —
|
|
981
|
+
// `RANKED_ORDER_EXPR` in `src-tauri/src/db/scoring_queries.rs` — and must
|
|
982
|
+
// stay textually in sync with it. The `relevance_score >= ?` membership
|
|
983
|
+
// filter above deliberately stays on evidence: evidence decides membership,
|
|
984
|
+
// rank decides order. Guarded on column existence for pre-110 DBs.
|
|
985
|
+
// `id DESC` breaks ties deterministically: a hard score cap parks many
|
|
986
|
+
// items at exactly the same value (100 of 651 surfaced items sat at
|
|
987
|
+
// 0.5000), and without a tie-break their order was whatever SQLite
|
|
988
|
+
// emitted. The pre-110 fallback gets the same tie-break.
|
|
989
|
+
const rankedOrder = this.hasColumn("source_items", "rank_score")
|
|
990
|
+
? `COALESCE(rank_score, relevance_score) DESC, id DESC`
|
|
991
|
+
: `relevance_score DESC, id DESC`;
|
|
992
|
+
query += ` ORDER BY ${rankedOrder} LIMIT ?`;
|
|
840
993
|
// Over-fetch so near-duplicate collapsing below can still fill `limit`
|
|
841
994
|
// (the same story arrives via reddit + rss + mastodon: live feeds showed
|
|
842
995
|
// three copies of "Rust 1.98.0" in one 20-item response).
|
|
@@ -887,10 +1040,15 @@ export class FourDADatabase {
|
|
|
887
1040
|
*/
|
|
888
1041
|
getRelevantContentFallback(minScore, sourceType, limit, sinceDate) {
|
|
889
1042
|
const context = this.getUserContext(true, true);
|
|
1043
|
+
// Same historical-advisory guard as the Rust-score path: a backfilled
|
|
1044
|
+
// 2015 advisory is not current reading, whatever its keyword score.
|
|
1045
|
+
const advisoryFreshnessGuard = this.hasColumn("source_items", "published_at")
|
|
1046
|
+
? ` AND NOT (source_type IN ('osv', 'cve') AND published_at IS NOT NULL AND datetime(published_at) < datetime('now', '-90 days'))`
|
|
1047
|
+
: ``;
|
|
890
1048
|
let query = `
|
|
891
1049
|
SELECT id, source_type, source_id, url, title, content, content_hash, created_at, last_seen
|
|
892
1050
|
FROM source_items
|
|
893
|
-
WHERE datetime(created_at) >= datetime(?)
|
|
1051
|
+
WHERE datetime(created_at) >= datetime(?)${advisoryFreshnessGuard}
|
|
894
1052
|
`;
|
|
895
1053
|
const params = [sinceDate];
|
|
896
1054
|
if (sourceType) {
|
|
@@ -1146,12 +1304,19 @@ export class FourDADatabase {
|
|
|
1146
1304
|
* Record user feedback on an item
|
|
1147
1305
|
*/
|
|
1148
1306
|
recordFeedback(itemId, sourceType, action) {
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1307
|
+
// Look the item up by id. source_type is optional and, when given, must
|
|
1308
|
+
// match. It used to be a required filter restricted to three sources
|
|
1309
|
+
// (hackernews / arxiv / reddit), so an agent could not rate an item from
|
|
1310
|
+
// any of the other sources — about 75% of the corpus (measured 2026-09-24).
|
|
1311
|
+
const row = this.db
|
|
1312
|
+
.prepare(`SELECT id, source_type FROM source_items WHERE id = ?`)
|
|
1313
|
+
.get(itemId);
|
|
1314
|
+
if (!row || (sourceType && row.source_type !== sourceType)) {
|
|
1152
1315
|
return {
|
|
1153
1316
|
success: false,
|
|
1154
|
-
message:
|
|
1317
|
+
message: sourceType
|
|
1318
|
+
? `Item ${itemId} of type ${sourceType} not found`
|
|
1319
|
+
: `Item ${itemId} not found`,
|
|
1155
1320
|
};
|
|
1156
1321
|
}
|
|
1157
1322
|
// Map action to signal strength — v19: unified onto the canonical ACE
|
|
@@ -1166,16 +1331,39 @@ export class FourDADatabase {
|
|
|
1166
1331
|
dismiss: -0.8,
|
|
1167
1332
|
mark_irrelevant: -1.0,
|
|
1168
1333
|
};
|
|
1334
|
+
// Only an explicit relevance statement becomes a label in `feedback` (the
|
|
1335
|
+
// table the desktop calibration fitter treats as ground truth). A click or
|
|
1336
|
+
// a dismiss is interaction history, not a relevance judgment. Mirrors the
|
|
1337
|
+
// desktop rule in src/store/feedback-slice.ts.
|
|
1338
|
+
const relevanceLabel = {
|
|
1339
|
+
save: 1,
|
|
1340
|
+
mark_irrelevant: 0,
|
|
1341
|
+
};
|
|
1342
|
+
const label = relevanceLabel[action];
|
|
1169
1343
|
try {
|
|
1170
|
-
|
|
1344
|
+
if (label !== undefined)
|
|
1345
|
+
this.ensureFeedbackTable();
|
|
1346
|
+
const insertInteraction = this.db.prepare(`
|
|
1171
1347
|
INSERT INTO interactions (item_id, action_type, item_source, signal_strength, timestamp)
|
|
1172
1348
|
VALUES (?, ?, ?, ?, datetime('now'))
|
|
1173
1349
|
`);
|
|
1174
|
-
const
|
|
1350
|
+
const write = this.db.transaction(() => {
|
|
1351
|
+
const r = insertInteraction.run(itemId, action, row.source_type, signalStrength[action]);
|
|
1352
|
+
if (label !== undefined) {
|
|
1353
|
+
this.db
|
|
1354
|
+
.prepare(`INSERT INTO feedback (source_item_id, relevant) VALUES (?, ?)`)
|
|
1355
|
+
.run(itemId, label);
|
|
1356
|
+
}
|
|
1357
|
+
return r;
|
|
1358
|
+
});
|
|
1359
|
+
const result = write();
|
|
1175
1360
|
return {
|
|
1176
1361
|
success: true,
|
|
1177
|
-
message:
|
|
1362
|
+
message: label === undefined
|
|
1363
|
+
? `Recorded ${action} feedback for item ${itemId}`
|
|
1364
|
+
: `Recorded ${action} feedback for item ${itemId} (relevance label: ${label === 1 ? "relevant" : "not relevant"})`,
|
|
1178
1365
|
interaction_id: result.lastInsertRowid,
|
|
1366
|
+
relevance_label: label === undefined ? null : label === 1,
|
|
1179
1367
|
};
|
|
1180
1368
|
}
|
|
1181
1369
|
catch (error) {
|
|
@@ -1185,6 +1373,24 @@ export class FourDADatabase {
|
|
|
1185
1373
|
};
|
|
1186
1374
|
}
|
|
1187
1375
|
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Ensure the `feedback` table exists. The desktop app always has it; a
|
|
1378
|
+
* standalone database created before record_feedback wrote labels does not
|
|
1379
|
+
* (createMinimalSchema runs only for brand-new files). Same DDL as desktop,
|
|
1380
|
+
* so against the desktop database this is a no-op.
|
|
1381
|
+
*/
|
|
1382
|
+
ensureFeedbackTable() {
|
|
1383
|
+
this.db.exec(`
|
|
1384
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
1385
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1386
|
+
source_item_id INTEGER NOT NULL,
|
|
1387
|
+
relevant INTEGER NOT NULL,
|
|
1388
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1389
|
+
FOREIGN KEY (source_item_id) REFERENCES source_items(id)
|
|
1390
|
+
);
|
|
1391
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_item ON feedback(source_item_id);
|
|
1392
|
+
`);
|
|
1393
|
+
}
|
|
1188
1394
|
// ===========================================================================
|
|
1189
1395
|
// Agent Feedback
|
|
1190
1396
|
// ===========================================================================
|
package/dist/doctor.js
CHANGED
|
@@ -13,14 +13,18 @@
|
|
|
13
13
|
* 4da-mcp --doctor
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from "node:fs";
|
|
16
|
-
import { FourDADatabase } from "./db.js";
|
|
16
|
+
import { FourDADatabase, nativeBindingProblem } from "./db.js";
|
|
17
|
+
import { checkBuildStaleness } from "./build-staleness.js";
|
|
17
18
|
export function runDoctor() {
|
|
18
19
|
console.log("\n 4DA MCP Server — Doctor\n");
|
|
19
20
|
const checks = [];
|
|
20
21
|
// 1. Node.js version
|
|
21
22
|
const nodeVersion = process.version;
|
|
22
23
|
const major = parseInt(nodeVersion.slice(1).split(".")[0], 10);
|
|
23
|
-
|
|
24
|
+
// 22, matching package.json engines: better-sqlite3 >= 12.10 ships no Node 20
|
|
25
|
+
// prebuild, so a Node 20 install compiles from source and fails without a
|
|
26
|
+
// C++ toolchain (measured 2026-10-03 on node:20-bookworm-slim and alpine).
|
|
27
|
+
const nodeRequired = 22;
|
|
24
28
|
checks.push({
|
|
25
29
|
name: "Node.js version",
|
|
26
30
|
status: major >= nodeRequired ? "pass" : "fail",
|
|
@@ -28,12 +32,14 @@ export function runDoctor() {
|
|
|
28
32
|
? `${nodeVersion} (>= ${nodeRequired} required)`
|
|
29
33
|
: `${nodeVersion} — Node.js ${nodeRequired}+ required`,
|
|
30
34
|
});
|
|
31
|
-
// 2. Native bindings —
|
|
32
|
-
//
|
|
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();
|
|
33
39
|
checks.push({
|
|
34
40
|
name: "SQLite native bindings",
|
|
35
|
-
status: "pass",
|
|
36
|
-
detail: "better-sqlite3
|
|
41
|
+
status: bindingProblem ? "fail" : "pass",
|
|
42
|
+
detail: bindingProblem ?? "better-sqlite3 opened an in-memory database",
|
|
37
43
|
});
|
|
38
44
|
// 3. Database discovery — deep integrity_check is appropriate here: --doctor is
|
|
39
45
|
// an explicit, user-invoked diagnostic where a slow full scan is expected. The
|
|
@@ -70,7 +76,21 @@ export function runDoctor() {
|
|
|
70
76
|
detail: envExists ? envDb : `Set to ${envDb} but file does not exist`,
|
|
71
77
|
});
|
|
72
78
|
}
|
|
73
|
-
// 5.
|
|
79
|
+
// 5. Build freshness — repo checkouts only. `.mcp.json` runs dist/, and
|
|
80
|
+
// nothing rebuilds it on a merge; a stale dist served two-day-old behavior
|
|
81
|
+
// on 2026-08-30 with zero symptoms. The published npm package ships no src/
|
|
82
|
+
// (check returns null there) so users never see a false alarm.
|
|
83
|
+
const staleness = checkBuildStaleness();
|
|
84
|
+
if (staleness) {
|
|
85
|
+
checks.push({
|
|
86
|
+
name: "Server build freshness",
|
|
87
|
+
status: staleness.stale ? "fail" : "pass",
|
|
88
|
+
detail: staleness.stale
|
|
89
|
+
? staleness.note ?? "dist/ is older than src/ — rebuild"
|
|
90
|
+
: "dist/ is at least as new as src/",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// 6. Semantic recall provider (optional)
|
|
74
94
|
const embedProvider = process.env.FOURDA_EMBED_PROVIDER;
|
|
75
95
|
const hasOpenAI = !!process.env.OPENAI_API_KEY;
|
|
76
96
|
const hasOllama = process.env.FOURDA_EMBED_PROVIDER === "ollama" ||
|
package/dist/embeddings.js
CHANGED
|
@@ -146,7 +146,13 @@ export async function embedText(text, config, signal) {
|
|
|
146
146
|
const res = await fetch(`${base}/api/embeddings`, {
|
|
147
147
|
method: "POST",
|
|
148
148
|
headers: { "Content-Type": "application/json" },
|
|
149
|
-
|
|
149
|
+
// The embedder runs on the CPU, as the 4DA app's does: on the GPU,
|
|
150
|
+
// Ollama evicts and reloads the local judge model around each call.
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
model: config.model,
|
|
153
|
+
prompt: input,
|
|
154
|
+
options: { num_gpu: 0 },
|
|
155
|
+
}),
|
|
150
156
|
signal,
|
|
151
157
|
});
|
|
152
158
|
if (!res.ok)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker files the Rust engine leaves beside `4da.db` when it cannot record
|
|
3
|
+
* an event in the database itself. `data_freshness` reads them so a
|
|
4
|
+
* DB-backed answer can say what happened to the data it was read from.
|
|
5
|
+
*
|
|
6
|
+
* Read-only by design: the app owns each marker's lifecycle. The engine
|
|
7
|
+
* clears `.engine-blocked` the moment a cycle opens the database again; the
|
|
8
|
+
* desktop app shows `.db-recovered` once and deletes it. Nothing here deletes
|
|
9
|
+
* either one, so every MCP answer keeps saying it until the app has.
|
|
10
|
+
*/
|
|
11
|
+
export interface EngineBlockMarker {
|
|
12
|
+
at: string;
|
|
13
|
+
error: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* `data/.engine-blocked`: written by the Rust engine (`engine_block.rs`) when
|
|
17
|
+
* a scheduled refresh is refused by a newer database schema.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readEngineBlockMarker(dbFile: string): EngineBlockMarker | null;
|
|
20
|
+
/** The kinds the engine writes today. A newer engine's unknown kind is still surfaced. */
|
|
21
|
+
export type DbRecoveryKind = "restored_from_backup" | "quarantined_no_backup" | "recovery_failed";
|
|
22
|
+
export interface DbRecoveredMarker {
|
|
23
|
+
at: string;
|
|
24
|
+
kind: DbRecoveryKind | string;
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `data/.db-recovered`: written by the headless refresh engine when it
|
|
29
|
+
* restored the database from a backup or quarantined it.
|
|
30
|
+
* `{"at": "<ISO UTC>", "kind": "...", "detail": "<path or reason>"}`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readDbRecoveredMarker(dbFile: string): DbRecoveredMarker | null;
|
|
33
|
+
/** The sentence `data_freshness.note` carries for a recovery marker. */
|
|
34
|
+
export declare function dbRecoveryNote(marker: DbRecoveredMarker): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Marker files the Rust engine leaves beside `4da.db` when it cannot record
|
|
4
|
+
* an event in the database itself. `data_freshness` reads them so a
|
|
5
|
+
* DB-backed answer can say what happened to the data it was read from.
|
|
6
|
+
*
|
|
7
|
+
* Read-only by design: the app owns each marker's lifecycle. The engine
|
|
8
|
+
* clears `.engine-blocked` the moment a cycle opens the database again; the
|
|
9
|
+
* desktop app shows `.db-recovered` once and deletes it. Nothing here deletes
|
|
10
|
+
* either one, so every MCP answer keeps saying it until the app has.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
function markerPath(dbFile, name) {
|
|
15
|
+
// An in-memory database has no directory; do not read a marker out of the cwd.
|
|
16
|
+
if (!dbFile || dbFile === ":memory:")
|
|
17
|
+
return null;
|
|
18
|
+
return path.join(path.dirname(dbFile), name);
|
|
19
|
+
}
|
|
20
|
+
function readJsonMarker(dbFile, name) {
|
|
21
|
+
const file = markerPath(dbFile, name);
|
|
22
|
+
if (!file)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
26
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
27
|
+
? parsed
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `data/.engine-blocked`: written by the Rust engine (`engine_block.rs`) when
|
|
36
|
+
* a scheduled refresh is refused by a newer database schema.
|
|
37
|
+
*/
|
|
38
|
+
export function readEngineBlockMarker(dbFile) {
|
|
39
|
+
const v = readJsonMarker(dbFile, ".engine-blocked");
|
|
40
|
+
if (v && typeof v.at === "string" && typeof v.error === "string") {
|
|
41
|
+
return { at: v.at, error: v.error };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `data/.db-recovered`: written by the headless refresh engine when it
|
|
47
|
+
* restored the database from a backup or quarantined it.
|
|
48
|
+
* `{"at": "<ISO UTC>", "kind": "...", "detail": "<path or reason>"}`.
|
|
49
|
+
*/
|
|
50
|
+
export function readDbRecoveredMarker(dbFile) {
|
|
51
|
+
const v = readJsonMarker(dbFile, ".db-recovered");
|
|
52
|
+
if (!v)
|
|
53
|
+
return null;
|
|
54
|
+
const { at, kind, detail } = v;
|
|
55
|
+
if (typeof at !== "string" || typeof kind !== "string" || typeof detail !== "string")
|
|
56
|
+
return null;
|
|
57
|
+
if (!at.trim() || !kind.trim())
|
|
58
|
+
return null;
|
|
59
|
+
return { at, kind, detail };
|
|
60
|
+
}
|
|
61
|
+
/** The sentence `data_freshness.note` carries for a recovery marker. */
|
|
62
|
+
export function dbRecoveryNote(marker) {
|
|
63
|
+
const { at, kind, detail } = marker;
|
|
64
|
+
switch (kind) {
|
|
65
|
+
case "restored_from_backup":
|
|
66
|
+
return `The database was restored from a backup by the background refresh at ${at} — results may be incomplete or empty; the preserved file is ${detail}.`;
|
|
67
|
+
case "quarantined_no_backup":
|
|
68
|
+
return `The database was replaced with a fresh empty database by the background refresh at ${at} — results may be incomplete or empty; the preserved file is ${detail}.`;
|
|
69
|
+
case "recovery_failed":
|
|
70
|
+
return `The background refresh failed to recover the database at ${at} — results may be incomplete or empty; ${detail}.`;
|
|
71
|
+
default:
|
|
72
|
+
return `The background refresh recorded a database recovery (${kind}) at ${at} — results may be incomplete or empty; ${detail}.`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enough of .gitignore to decide whether a directory or lockfile belongs to
|
|
3
|
+
* the project, for the standalone project-tree walk.
|
|
4
|
+
*
|
|
5
|
+
* A directory the repository ignores is not part of the project: scratch
|
|
6
|
+
* clones, generated output, a sibling tool checked out inside the tree.
|
|
7
|
+
* Measured 2026-10-02: walking 4DA's root without this scanned the ignored
|
|
8
|
+
* `victauri-gauntlet/` and `cli/` and reported their advisories (rustls, h2,
|
|
9
|
+
* scc) as the project's, where osv-scanner, which honours .gitignore, did not.
|
|
10
|
+
*
|
|
11
|
+
* Read from the files, never by running git: `git check-ignore` in a cloned
|
|
12
|
+
* repository honours that repository's config, and a config can name programs
|
|
13
|
+
* for git to run (core.fsmonitor). Reading a scanned folder must not execute
|
|
14
|
+
* anything it names.
|
|
15
|
+
*
|
|
16
|
+
* Supported: comments, blank lines, `dir/` (directory only), leading `/` and
|
|
17
|
+
* inner `/` anchoring, `*`, `?`, `**`, nested .gitignore files and
|
|
18
|
+
* .git/info/exclude. A negation (`!pattern`) is treated conservatively: a
|
|
19
|
+
* path that any negation could re-include is never skipped.
|
|
20
|
+
*/
|
|
21
|
+
/** Ignore rules gathered while walking down from a root. */
|
|
22
|
+
export declare class IgnoreRules {
|
|
23
|
+
private readonly root;
|
|
24
|
+
private rules;
|
|
25
|
+
private loaded;
|
|
26
|
+
constructor(root: string);
|
|
27
|
+
/** Read the .gitignore of a directory (relative to the root) once. */
|
|
28
|
+
load(relDir: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Whether an absolute directory below the root lies in, or is, an ignored
|
|
31
|
+
* directory. Every ancestor is checked, loading nested .gitignore files on
|
|
32
|
+
* the way down. A directory outside the root is never "ignored" here.
|
|
33
|
+
*/
|
|
34
|
+
ignoresDirectory(absDir: string): boolean;
|
|
35
|
+
/** Whether a path (relative to the root, `/`-separated) is ignored. */
|
|
36
|
+
ignores(relPath: string, isDir: boolean): boolean;
|
|
37
|
+
}
|