@4da/mcp-server 5.0.4 → 6.0.1
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 +698 -0
- package/README.md +70 -39
- package/dist/build-staleness.d.ts +35 -0
- package/dist/build-staleness.js +95 -0
- package/dist/db.d.ts +53 -58
- package/dist/db.js +226 -299
- 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 +12 -1
- package/dist/live/osv-scanner.js +50 -15
- 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 -136
- package/package.json +35 -35
- 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"));
|
|
65
89
|
}
|
|
66
|
-
|
|
67
|
-
|
|
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;
|
|
127
|
+
}
|
|
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,
|
|
@@ -587,17 +668,6 @@ export class FourDADatabase {
|
|
|
587
668
|
);
|
|
588
669
|
CREATE INDEX IF NOT EXISTS idx_advantage_period ON advantage_score(period, computed_at);
|
|
589
670
|
|
|
590
|
-
-- Agent feedback — queried by record_agent_feedback, get_agent_feedback_stats
|
|
591
|
-
CREATE TABLE IF NOT EXISTS agent_feedback (
|
|
592
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
593
|
-
item_id TEXT NOT NULL,
|
|
594
|
-
outcome TEXT NOT NULL,
|
|
595
|
-
context TEXT,
|
|
596
|
-
session_task TEXT,
|
|
597
|
-
recorded_at TEXT DEFAULT (datetime('now'))
|
|
598
|
-
);
|
|
599
|
-
CREATE INDEX IF NOT EXISTS idx_agent_feedback_item ON agent_feedback(item_id);
|
|
600
|
-
CREATE INDEX IF NOT EXISTS idx_agent_feedback_outcome ON agent_feedback(outcome);
|
|
601
671
|
`);
|
|
602
672
|
}
|
|
603
673
|
/**
|
|
@@ -610,8 +680,11 @@ export class FourDADatabase {
|
|
|
610
680
|
const insertDep = this.db.prepare("INSERT OR IGNORE INTO project_dependencies (project_path, manifest_type, package_name, version, is_dev, language) VALUES (?, ?, ?, ?, ?, ?)");
|
|
611
681
|
const insertInterest = this.db.prepare("INSERT OR IGNORE INTO explicit_interests (topic, weight, source) VALUES (?, ?, ?)");
|
|
612
682
|
const insertTechStack = this.db.prepare("INSERT OR IGNORE INTO tech_stack (technology) VALUES (?)");
|
|
613
|
-
// Run all inserts in a single transaction for speed
|
|
683
|
+
// Run all inserts in a single transaction for speed. A rescan replaces
|
|
684
|
+
// this project's dependency rows rather than accumulating removed ones.
|
|
685
|
+
const clearDeps = this.db.prepare("DELETE FROM project_dependencies WHERE project_path = ?");
|
|
614
686
|
const populate = this.db.transaction(() => {
|
|
687
|
+
clearDeps.run(scan.projectPath);
|
|
615
688
|
// Languages -> detected_tech + tech_stack + active_topics
|
|
616
689
|
for (const lang of scan.languages) {
|
|
617
690
|
insertTech.run(lang, "language", 0.95, "project_scan");
|
|
@@ -683,15 +756,25 @@ export class FourDADatabase {
|
|
|
683
756
|
* Get relevant content items scored by the Rust pipeline (preferred) or
|
|
684
757
|
* TypeScript fallback scoring when Rust scores are unavailable.
|
|
685
758
|
*/
|
|
686
|
-
getRelevantContent(minScore = 0.35, sourceType, limit = 20, sinceHours = 24, requireCurrentVersion = false
|
|
759
|
+
getRelevantContent(minScore = 0.35, sourceType, limit = 20, sinceHours = 24, requireCurrentVersion = false,
|
|
760
|
+
/**
|
|
761
|
+
* Restrict to items the scoring pipeline stamped with this signal_type.
|
|
762
|
+
* Lets a type-specific read (a security-only pass) fill its limit with
|
|
763
|
+
* matches instead of competing for the top-N slots across every type.
|
|
764
|
+
* Only meaningful over pipeline-stamped rows: without Rust scores there
|
|
765
|
+
* are no stored types, so the read returns nothing rather than pretend.
|
|
766
|
+
*/
|
|
767
|
+
signalType) {
|
|
687
768
|
const sinceDate = new Date(Date.now() - sinceHours * 60 * 60 * 1000)
|
|
688
769
|
.toISOString()
|
|
689
770
|
.replace("T", " ")
|
|
690
771
|
.slice(0, 19);
|
|
691
772
|
const hasRustScores = this.hasColumn("source_items", "relevance_score");
|
|
692
773
|
if (hasRustScores) {
|
|
693
|
-
return this.getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion);
|
|
774
|
+
return this.getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion, signalType);
|
|
694
775
|
}
|
|
776
|
+
if (signalType)
|
|
777
|
+
return [];
|
|
695
778
|
// TypeScript fallback computes scores fresh per call — current by construction.
|
|
696
779
|
return this.getRelevantContentFallback(minScore, sourceType, limit, sinceDate);
|
|
697
780
|
}
|
|
@@ -722,10 +805,30 @@ export class FourDADatabase {
|
|
|
722
805
|
}
|
|
723
806
|
const ageMinutes = minutesSince(lastFetchAt);
|
|
724
807
|
const isStale = ageMinutes === null || ageMinutes > STALE_AFTER_MINUTES;
|
|
725
|
-
|
|
808
|
+
let note = isStale
|
|
726
809
|
? `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
810
|
"`fourda-engine --once` (or open the 4DA app) to refresh."
|
|
728
811
|
: `Feed data is ~${ageMinutes} min old (fresh).`;
|
|
812
|
+
// Engine-block marker: a scheduled refresh that can only REFUSE (binary
|
|
813
|
+
// older than the DB schema) cannot record its failure in the database, so
|
|
814
|
+
// the Rust engine leaves `data/.engine-blocked` beside it. That exact
|
|
815
|
+
// failure froze the feed for two days (2026-08-28→30) with ERROR-log-only
|
|
816
|
+
// symptoms; this reader is the one that caught it, so it now names the
|
|
817
|
+
// cause instead of just the staleness.
|
|
818
|
+
const engineBlock = readEngineBlockMarker(this.db.name);
|
|
819
|
+
if (engineBlock) {
|
|
820
|
+
note += ` ENGINE BLOCKED since ${engineBlock.at}: ${engineBlock.error} — a rebuilt/updated 4DA binary is required; a plain refresh will keep refusing.`;
|
|
821
|
+
}
|
|
822
|
+
// Database-recovery marker: when the headless refresh engine restores the
|
|
823
|
+
// database from a backup, or quarantines it and starts a fresh empty one,
|
|
824
|
+
// every answer read from it may be incomplete or empty, and a fresh
|
|
825
|
+
// database looks "fresh" by every other field here. The engine leaves
|
|
826
|
+
// `data/.db-recovered` beside the DB; it is surfaced on every DB-backed
|
|
827
|
+
// answer until the desktop app has shown it and deleted it. Read-only.
|
|
828
|
+
const recovered = readDbRecoveredMarker(this.db.name);
|
|
829
|
+
if (recovered) {
|
|
830
|
+
note += ` ${dbRecoveryNote(recovered)}`;
|
|
831
|
+
}
|
|
729
832
|
return {
|
|
730
833
|
source_items_total: total,
|
|
731
834
|
newest_item_at: newestItemAt,
|
|
@@ -736,6 +839,16 @@ export class FourDADatabase {
|
|
|
736
839
|
age_minutes: ageMinutes,
|
|
737
840
|
is_stale: isStale,
|
|
738
841
|
note,
|
|
842
|
+
...(engineBlock
|
|
843
|
+
? { engine_blocked_at: engineBlock.at, engine_blocked_error: engineBlock.error }
|
|
844
|
+
: {}),
|
|
845
|
+
...(recovered
|
|
846
|
+
? {
|
|
847
|
+
db_recovered_at: recovered.at,
|
|
848
|
+
db_recovery_kind: recovered.kind,
|
|
849
|
+
db_recovery_detail: recovered.detail,
|
|
850
|
+
}
|
|
851
|
+
: {}),
|
|
739
852
|
};
|
|
740
853
|
}
|
|
741
854
|
/** `MAX(sources.last_fetch)`, tolerant of a missing `sources` table (returns null). */
|
|
@@ -771,7 +884,7 @@ export class FourDADatabase {
|
|
|
771
884
|
* Use Rust-computed relevance_score from the desktop app's scoring pipeline.
|
|
772
885
|
* These scores are from the full 5-axis PASIFA engine — much more accurate.
|
|
773
886
|
*/
|
|
774
|
-
getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion = false) {
|
|
887
|
+
getRelevantContentFromRust(minScore, sourceType, limit, sinceDate, requireCurrentVersion = false, signalType) {
|
|
775
888
|
// Grounding subquery — guarded: older/partial 4DA DBs may have relevance_score
|
|
776
889
|
// (so the Rust path runs) but lack source_item_dependencies. Fall back to 0
|
|
777
890
|
// (→ evidence_class "semantic_only") rather than throwing.
|
|
@@ -822,6 +935,17 @@ export class FourDADatabase {
|
|
|
822
935
|
const curationGuard = this.hasColumn("source_items", "feed_relevant")
|
|
823
936
|
? ` AND (feed_relevant IS NULL OR feed_relevant = 1)`
|
|
824
937
|
: ``;
|
|
938
|
+
// Historical-advisory guard: OSV/CVE backfills ingest decades-old advisories
|
|
939
|
+
// whose created_at (discovery time) is days old but whose published_at is
|
|
940
|
+
// ancient — and the scoring brain rates them 0.9 because they name the
|
|
941
|
+
// user's deps. Live 2026-08-30: half the top-20 was React XSS fixed in 0.14
|
|
942
|
+
// (2015) and tokio races fixed in 2021, on a react 19 / tokio 1.52 stack.
|
|
943
|
+
// A reading feed is about what is CURRENT; version-aware exposure checking
|
|
944
|
+
// is vulnerability_scan / knowledge_gaps territory. NULL published_at is
|
|
945
|
+
// kept — absence of a date is not evidence of age.
|
|
946
|
+
const advisoryFreshnessGuard = this.hasColumn("source_items", "published_at")
|
|
947
|
+
? ` AND NOT (source_type IN ('osv', 'cve') AND published_at IS NOT NULL AND datetime(published_at) < datetime('now', '-90 days'))`
|
|
948
|
+
: ``;
|
|
825
949
|
let query = `
|
|
826
950
|
SELECT id, source_type, source_id, url, title, content, content_hash,
|
|
827
951
|
created_at, last_seen, relevance_score, content_type,
|
|
@@ -829,13 +953,17 @@ export class FourDADatabase {
|
|
|
829
953
|
${depCount} AS dep_match_count
|
|
830
954
|
FROM source_items
|
|
831
955
|
WHERE relevance_score >= ?
|
|
832
|
-
AND datetime(created_at) >= datetime(?)${versionGuard}${curationGuard}
|
|
956
|
+
AND datetime(created_at) >= datetime(?)${versionGuard}${curationGuard}${advisoryFreshnessGuard}
|
|
833
957
|
`;
|
|
834
958
|
const params = [minScore, sinceDate];
|
|
835
959
|
if (sourceType) {
|
|
836
960
|
query += ` AND source_type = ?`;
|
|
837
961
|
params.push(sourceType);
|
|
838
962
|
}
|
|
963
|
+
if (signalType && this.hasColumn("source_items", "signal_type")) {
|
|
964
|
+
query += ` AND signal_type = ?`;
|
|
965
|
+
params.push(signalType);
|
|
966
|
+
}
|
|
839
967
|
// Ranked read (desktop audit items 12+26, schema 110): order by the
|
|
840
968
|
// batch-relative rank when the analysis cycle has ranked the item, falling
|
|
841
969
|
// back to the evidence score. This mirrors the Rust source of truth —
|
|
@@ -843,9 +971,13 @@ export class FourDADatabase {
|
|
|
843
971
|
// stay textually in sync with it. The `relevance_score >= ?` membership
|
|
844
972
|
// filter above deliberately stays on evidence: evidence decides membership,
|
|
845
973
|
// rank decides order. Guarded on column existence for pre-110 DBs.
|
|
974
|
+
// `id DESC` breaks ties deterministically: a hard score cap parks many
|
|
975
|
+
// items at exactly the same value (100 of 651 surfaced items sat at
|
|
976
|
+
// 0.5000), and without a tie-break their order was whatever SQLite
|
|
977
|
+
// emitted. The pre-110 fallback gets the same tie-break.
|
|
846
978
|
const rankedOrder = this.hasColumn("source_items", "rank_score")
|
|
847
|
-
? `COALESCE(rank_score, relevance_score) DESC`
|
|
848
|
-
: `relevance_score DESC`;
|
|
979
|
+
? `COALESCE(rank_score, relevance_score) DESC, id DESC`
|
|
980
|
+
: `relevance_score DESC, id DESC`;
|
|
849
981
|
query += ` ORDER BY ${rankedOrder} LIMIT ?`;
|
|
850
982
|
// Over-fetch so near-duplicate collapsing below can still fill `limit`
|
|
851
983
|
// (the same story arrives via reddit + rss + mastodon: live feeds showed
|
|
@@ -897,10 +1029,15 @@ export class FourDADatabase {
|
|
|
897
1029
|
*/
|
|
898
1030
|
getRelevantContentFallback(minScore, sourceType, limit, sinceDate) {
|
|
899
1031
|
const context = this.getUserContext(true, true);
|
|
1032
|
+
// Same historical-advisory guard as the Rust-score path: a backfilled
|
|
1033
|
+
// 2015 advisory is not current reading, whatever its keyword score.
|
|
1034
|
+
const advisoryFreshnessGuard = this.hasColumn("source_items", "published_at")
|
|
1035
|
+
? ` AND NOT (source_type IN ('osv', 'cve') AND published_at IS NOT NULL AND datetime(published_at) < datetime('now', '-90 days'))`
|
|
1036
|
+
: ``;
|
|
900
1037
|
let query = `
|
|
901
1038
|
SELECT id, source_type, source_id, url, title, content, content_hash, created_at, last_seen
|
|
902
1039
|
FROM source_items
|
|
903
|
-
WHERE datetime(created_at) >= datetime(?)
|
|
1040
|
+
WHERE datetime(created_at) >= datetime(?)${advisoryFreshnessGuard}
|
|
904
1041
|
`;
|
|
905
1042
|
const params = [sinceDate];
|
|
906
1043
|
if (sourceType) {
|
|
@@ -949,17 +1086,6 @@ export class FourDADatabase {
|
|
|
949
1086
|
.sort((a, b) => b.relevance_score - a.relevance_score)
|
|
950
1087
|
.slice(0, limit);
|
|
951
1088
|
}
|
|
952
|
-
/**
|
|
953
|
-
* Get a single source item by ID and type
|
|
954
|
-
*/
|
|
955
|
-
getSourceItem(itemId, sourceType) {
|
|
956
|
-
const stmt = this.db.prepare(`
|
|
957
|
-
SELECT id, source_type, source_id, url, title, content, content_hash, created_at, last_seen
|
|
958
|
-
FROM source_items
|
|
959
|
-
WHERE id = ? AND source_type = ?
|
|
960
|
-
`);
|
|
961
|
-
return stmt.get(itemId, sourceType) || null;
|
|
962
|
-
}
|
|
963
1089
|
/**
|
|
964
1090
|
* Look up persisted necessity scores for an item (written by Rust analysis pipeline).
|
|
965
1091
|
* Returns defaults (0.0/null) if no necessity data exists yet.
|
|
@@ -1057,111 +1183,25 @@ export class FourDADatabase {
|
|
|
1057
1183
|
return context;
|
|
1058
1184
|
}
|
|
1059
1185
|
// ===========================================================================
|
|
1060
|
-
// Relevance Explanation
|
|
1061
|
-
// ===========================================================================
|
|
1062
|
-
/**
|
|
1063
|
-
* Explain why an item was considered relevant
|
|
1064
|
-
*/
|
|
1065
|
-
explainRelevance(itemId, sourceType) {
|
|
1066
|
-
const item = this.getSourceItem(itemId, sourceType);
|
|
1067
|
-
if (!item) {
|
|
1068
|
-
return null;
|
|
1069
|
-
}
|
|
1070
|
-
const context = this.getUserContext(true, true);
|
|
1071
|
-
const itemTopics = this.extractTopics(item.title + " " + item.content);
|
|
1072
|
-
// Compute matching elements
|
|
1073
|
-
const matchingInterests = context.interests
|
|
1074
|
-
.filter((i) => itemTopics.some((t) => this.topicMatches(t, i.topic)))
|
|
1075
|
-
.map((i) => i.topic);
|
|
1076
|
-
const matchingTech = context.tech_stack.filter((tech) => itemTopics.some((t) => this.topicMatches(t, tech)));
|
|
1077
|
-
const matchingTopics = context.ace?.active_topics
|
|
1078
|
-
.filter((at) => itemTopics.some((t) => this.topicMatches(t, at.topic)))
|
|
1079
|
-
.map((at) => at.topic) || [];
|
|
1080
|
-
const matchingAffinities = context.learned?.topic_affinities
|
|
1081
|
-
.filter((ta) => ta.affinity_score > 0 && itemTopics.some((t) => this.topicMatches(t, ta.topic)))
|
|
1082
|
-
.map((ta) => ta.topic) || [];
|
|
1083
|
-
// Compute score breakdown
|
|
1084
|
-
const staticScore = matchingInterests.length * 0.3 +
|
|
1085
|
-
matchingTech.length * 0.2 +
|
|
1086
|
-
(context.domains.some((d) => itemTopics.some((t) => this.topicMatches(t, d)))
|
|
1087
|
-
? 0.15
|
|
1088
|
-
: 0);
|
|
1089
|
-
const aceScore = context.ace
|
|
1090
|
-
? matchingTopics.length * 0.1 +
|
|
1091
|
-
context.ace.detected_tech.filter((dt) => itemTopics.some((t) => this.topicMatches(t, dt.name))).length *
|
|
1092
|
-
0.05
|
|
1093
|
-
: 0;
|
|
1094
|
-
// Learned affinity score DEMOTED in v19 (AD-029) — must mirror
|
|
1095
|
-
// computeRelevanceScore so the explanation matches what actually
|
|
1096
|
-
// scored (the two previously diverged on the anti-penalty weight too:
|
|
1097
|
-
// scorer used *0.3, this explanation used *0.5).
|
|
1098
|
-
const learnedScore = 0;
|
|
1099
|
-
// Anti-penalty from user-authored exclusions only (learned anti-topics
|
|
1100
|
-
// demoted with the rest of the behavioral stack, AD-029).
|
|
1101
|
-
let antiPenalty = 0;
|
|
1102
|
-
for (const exclusion of context.exclusions) {
|
|
1103
|
-
if (itemTopics.some((t) => this.topicMatches(t, exclusion))) {
|
|
1104
|
-
antiPenalty = 1.0; // Hard exclusion
|
|
1105
|
-
break;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
const finalScore = Math.max(0, Math.min(1, staticScore + aceScore + learnedScore - antiPenalty));
|
|
1109
|
-
const scoreBreakdown = {
|
|
1110
|
-
embedding_similarity: null, // Not computing embedding similarity in MCP server
|
|
1111
|
-
static_match_score: Math.round(staticScore * 100) / 100,
|
|
1112
|
-
ace_match_score: Math.round(aceScore * 100) / 100,
|
|
1113
|
-
learned_affinity_score: Math.round(learnedScore * 100) / 100,
|
|
1114
|
-
anti_penalty: Math.round(antiPenalty * 100) / 100,
|
|
1115
|
-
final_score: Math.round(finalScore * 100) / 100,
|
|
1116
|
-
};
|
|
1117
|
-
const matchingContext = {
|
|
1118
|
-
matching_interests: matchingInterests,
|
|
1119
|
-
matching_tech: matchingTech,
|
|
1120
|
-
matching_topics: matchingTopics,
|
|
1121
|
-
matching_affinities: matchingAffinities,
|
|
1122
|
-
};
|
|
1123
|
-
// Generate human-readable explanation
|
|
1124
|
-
const explanationParts = [];
|
|
1125
|
-
if (matchingInterests.length > 0) {
|
|
1126
|
-
explanationParts.push(`Matches your interests: ${matchingInterests.slice(0, 3).join(", ")}`);
|
|
1127
|
-
}
|
|
1128
|
-
if (matchingTech.length > 0) {
|
|
1129
|
-
explanationParts.push(`Related to your tech stack: ${matchingTech.slice(0, 3).join(", ")}`);
|
|
1130
|
-
}
|
|
1131
|
-
if (matchingTopics.length > 0) {
|
|
1132
|
-
explanationParts.push(`Relevant to recent work: ${matchingTopics.slice(0, 3).join(", ")}`);
|
|
1133
|
-
}
|
|
1134
|
-
if (matchingAffinities.length > 0) {
|
|
1135
|
-
explanationParts.push(`You've shown interest in: ${matchingAffinities.slice(0, 3).join(", ")}`);
|
|
1136
|
-
}
|
|
1137
|
-
if (antiPenalty > 0) {
|
|
1138
|
-
explanationParts.push(`Score reduced due to excluded topics`);
|
|
1139
|
-
}
|
|
1140
|
-
const explanation = explanationParts.length > 0
|
|
1141
|
-
? explanationParts.join(". ") + "."
|
|
1142
|
-
: "No specific matches found - may be based on general relevance.";
|
|
1143
|
-
return {
|
|
1144
|
-
item_id: itemId,
|
|
1145
|
-
source_type: sourceType,
|
|
1146
|
-
title: item.title,
|
|
1147
|
-
score_breakdown: scoreBreakdown,
|
|
1148
|
-
matching_context: matchingContext,
|
|
1149
|
-
explanation,
|
|
1150
|
-
};
|
|
1151
|
-
}
|
|
1152
|
-
// ===========================================================================
|
|
1153
1186
|
// Feedback
|
|
1154
1187
|
// ===========================================================================
|
|
1155
1188
|
/**
|
|
1156
1189
|
* Record user feedback on an item
|
|
1157
1190
|
*/
|
|
1158
1191
|
recordFeedback(itemId, sourceType, action) {
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1161
|
-
|
|
1192
|
+
// Look the item up by id. source_type is optional and, when given, must
|
|
1193
|
+
// match. It used to be a required filter restricted to three sources
|
|
1194
|
+
// (hackernews / arxiv / reddit), so an agent could not rate an item from
|
|
1195
|
+
// any of the other sources — about 75% of the corpus (measured 2026-09-24).
|
|
1196
|
+
const row = this.db
|
|
1197
|
+
.prepare(`SELECT id, source_type FROM source_items WHERE id = ?`)
|
|
1198
|
+
.get(itemId);
|
|
1199
|
+
if (!row || (sourceType && row.source_type !== sourceType)) {
|
|
1162
1200
|
return {
|
|
1163
1201
|
success: false,
|
|
1164
|
-
message:
|
|
1202
|
+
message: sourceType
|
|
1203
|
+
? `Item ${itemId} of type ${sourceType} not found`
|
|
1204
|
+
: `Item ${itemId} not found`,
|
|
1165
1205
|
};
|
|
1166
1206
|
}
|
|
1167
1207
|
// Map action to signal strength — v19: unified onto the canonical ACE
|
|
@@ -1176,16 +1216,39 @@ export class FourDADatabase {
|
|
|
1176
1216
|
dismiss: -0.8,
|
|
1177
1217
|
mark_irrelevant: -1.0,
|
|
1178
1218
|
};
|
|
1219
|
+
// Only an explicit relevance statement becomes a label in `feedback` (the
|
|
1220
|
+
// table the desktop calibration fitter treats as ground truth). A click or
|
|
1221
|
+
// a dismiss is interaction history, not a relevance judgment. Mirrors the
|
|
1222
|
+
// desktop rule in src/store/feedback-slice.ts.
|
|
1223
|
+
const relevanceLabel = {
|
|
1224
|
+
save: 1,
|
|
1225
|
+
mark_irrelevant: 0,
|
|
1226
|
+
};
|
|
1227
|
+
const label = relevanceLabel[action];
|
|
1179
1228
|
try {
|
|
1180
|
-
|
|
1229
|
+
if (label !== undefined)
|
|
1230
|
+
this.ensureFeedbackTable();
|
|
1231
|
+
const insertInteraction = this.db.prepare(`
|
|
1181
1232
|
INSERT INTO interactions (item_id, action_type, item_source, signal_strength, timestamp)
|
|
1182
1233
|
VALUES (?, ?, ?, ?, datetime('now'))
|
|
1183
1234
|
`);
|
|
1184
|
-
const
|
|
1235
|
+
const write = this.db.transaction(() => {
|
|
1236
|
+
const r = insertInteraction.run(itemId, action, row.source_type, signalStrength[action]);
|
|
1237
|
+
if (label !== undefined) {
|
|
1238
|
+
this.db
|
|
1239
|
+
.prepare(`INSERT INTO feedback (source_item_id, relevant) VALUES (?, ?)`)
|
|
1240
|
+
.run(itemId, label);
|
|
1241
|
+
}
|
|
1242
|
+
return r;
|
|
1243
|
+
});
|
|
1244
|
+
const result = write();
|
|
1185
1245
|
return {
|
|
1186
1246
|
success: true,
|
|
1187
|
-
message:
|
|
1247
|
+
message: label === undefined
|
|
1248
|
+
? `Recorded ${action} feedback for item ${itemId}`
|
|
1249
|
+
: `Recorded ${action} feedback for item ${itemId} (relevance label: ${label === 1 ? "relevant" : "not relevant"})`,
|
|
1188
1250
|
interaction_id: result.lastInsertRowid,
|
|
1251
|
+
relevance_label: label === undefined ? null : label === 1,
|
|
1189
1252
|
};
|
|
1190
1253
|
}
|
|
1191
1254
|
catch (error) {
|
|
@@ -1195,160 +1258,24 @@ export class FourDADatabase {
|
|
|
1195
1258
|
};
|
|
1196
1259
|
}
|
|
1197
1260
|
}
|
|
1198
|
-
// ===========================================================================
|
|
1199
|
-
// Agent Feedback
|
|
1200
|
-
// ===========================================================================
|
|
1201
1261
|
/**
|
|
1202
|
-
* Ensure the
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1262
|
+
* Ensure the `feedback` table exists. The desktop app always has it; a
|
|
1263
|
+
* standalone database created before record_feedback wrote labels does not
|
|
1264
|
+
* (createMinimalSchema runs only for brand-new files). Same DDL as desktop,
|
|
1265
|
+
* so against the desktop database this is a no-op.
|
|
1205
1266
|
*/
|
|
1206
|
-
|
|
1267
|
+
ensureFeedbackTable() {
|
|
1207
1268
|
this.db.exec(`
|
|
1208
|
-
CREATE TABLE IF NOT EXISTS
|
|
1269
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
1209
1270
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
recorded_at TEXT DEFAULT (datetime('now'))
|
|
1271
|
+
source_item_id INTEGER NOT NULL,
|
|
1272
|
+
relevant INTEGER NOT NULL,
|
|
1273
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1274
|
+
FOREIGN KEY (source_item_id) REFERENCES source_items(id)
|
|
1215
1275
|
);
|
|
1216
|
-
CREATE INDEX IF NOT EXISTS
|
|
1217
|
-
CREATE INDEX IF NOT EXISTS idx_agent_feedback_outcome ON agent_feedback(outcome);
|
|
1276
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_item ON feedback(source_item_id);
|
|
1218
1277
|
`);
|
|
1219
1278
|
}
|
|
1220
|
-
/**
|
|
1221
|
-
* Record agent feedback on recommended content items
|
|
1222
|
-
*/
|
|
1223
|
-
recordAgentFeedback(itemIds, outcome, context, sessionTask) {
|
|
1224
|
-
this.ensureAgentFeedbackTable();
|
|
1225
|
-
const validOutcomes = ["used", "rejected", "partially_used"];
|
|
1226
|
-
if (!validOutcomes.includes(outcome)) {
|
|
1227
|
-
return {
|
|
1228
|
-
success: false,
|
|
1229
|
-
message: `Invalid outcome: ${outcome}. Valid outcomes: ${validOutcomes.join(", ")}`,
|
|
1230
|
-
recorded_count: 0,
|
|
1231
|
-
};
|
|
1232
|
-
}
|
|
1233
|
-
if (!itemIds || itemIds.length === 0) {
|
|
1234
|
-
return {
|
|
1235
|
-
success: false,
|
|
1236
|
-
message: "item_ids must contain at least one item ID",
|
|
1237
|
-
recorded_count: 0,
|
|
1238
|
-
};
|
|
1239
|
-
}
|
|
1240
|
-
try {
|
|
1241
|
-
const stmt = this.db.prepare(`
|
|
1242
|
-
INSERT INTO agent_feedback (item_id, outcome, context, session_task)
|
|
1243
|
-
VALUES (?, ?, ?, ?)
|
|
1244
|
-
`);
|
|
1245
|
-
const insertMany = this.db.transaction((ids) => {
|
|
1246
|
-
let count = 0;
|
|
1247
|
-
for (const id of ids) {
|
|
1248
|
-
stmt.run(id, outcome, context || null, sessionTask || null);
|
|
1249
|
-
count++;
|
|
1250
|
-
}
|
|
1251
|
-
return count;
|
|
1252
|
-
});
|
|
1253
|
-
const count = insertMany(itemIds);
|
|
1254
|
-
return {
|
|
1255
|
-
success: true,
|
|
1256
|
-
message: `Recorded ${outcome} feedback for ${count} item(s)`,
|
|
1257
|
-
recorded_count: count,
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
catch (error) {
|
|
1261
|
-
return {
|
|
1262
|
-
success: false,
|
|
1263
|
-
message: `Failed to record agent feedback: ${error instanceof Error ? error.message : String(error)}`,
|
|
1264
|
-
recorded_count: 0,
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
/**
|
|
1269
|
-
* Get agent feedback statistics for the given period
|
|
1270
|
-
*/
|
|
1271
|
-
getAgentFeedbackStats(days = 30) {
|
|
1272
|
-
this.ensureAgentFeedbackTable();
|
|
1273
|
-
const cutoff = `datetime('now', '-${Math.max(1, Math.min(365, days))} days')`;
|
|
1274
|
-
// Outcome totals
|
|
1275
|
-
const outcomeRows = this.db
|
|
1276
|
-
.prepare(`SELECT outcome, COUNT(*) as cnt
|
|
1277
|
-
FROM agent_feedback
|
|
1278
|
-
WHERE recorded_at >= ${cutoff}
|
|
1279
|
-
GROUP BY outcome`)
|
|
1280
|
-
.all();
|
|
1281
|
-
const outcomes = { used: 0, rejected: 0, partially_used: 0, total: 0 };
|
|
1282
|
-
for (const row of outcomeRows) {
|
|
1283
|
-
if (row.outcome === "used")
|
|
1284
|
-
outcomes.used = row.cnt;
|
|
1285
|
-
else if (row.outcome === "rejected")
|
|
1286
|
-
outcomes.rejected = row.cnt;
|
|
1287
|
-
else if (row.outcome === "partially_used")
|
|
1288
|
-
outcomes.partially_used = row.cnt;
|
|
1289
|
-
outcomes.total += row.cnt;
|
|
1290
|
-
}
|
|
1291
|
-
// By source type — join with source_items to get source_type
|
|
1292
|
-
const sourceRows = this.db
|
|
1293
|
-
.prepare(`SELECT COALESCE(si.source_type, 'unknown') as source_type, af.outcome, COUNT(*) as cnt
|
|
1294
|
-
FROM agent_feedback af
|
|
1295
|
-
LEFT JOIN source_items si ON CAST(af.item_id AS INTEGER) = si.id
|
|
1296
|
-
WHERE af.recorded_at >= ${cutoff}
|
|
1297
|
-
GROUP BY source_type, af.outcome
|
|
1298
|
-
ORDER BY cnt DESC`)
|
|
1299
|
-
.all();
|
|
1300
|
-
// Aggregate source rows into per-source stats
|
|
1301
|
-
const sourceMap = new Map();
|
|
1302
|
-
for (const row of sourceRows) {
|
|
1303
|
-
if (!sourceMap.has(row.source_type)) {
|
|
1304
|
-
sourceMap.set(row.source_type, { used: 0, rejected: 0, partially_used: 0, total: 0 });
|
|
1305
|
-
}
|
|
1306
|
-
const entry = sourceMap.get(row.source_type);
|
|
1307
|
-
if (row.outcome === "used")
|
|
1308
|
-
entry.used += row.cnt;
|
|
1309
|
-
else if (row.outcome === "rejected")
|
|
1310
|
-
entry.rejected += row.cnt;
|
|
1311
|
-
else if (row.outcome === "partially_used")
|
|
1312
|
-
entry.partially_used += row.cnt;
|
|
1313
|
-
entry.total += row.cnt;
|
|
1314
|
-
}
|
|
1315
|
-
const bySource = Array.from(sourceMap.entries()).map(([source_type, stats]) => ({
|
|
1316
|
-
source_type,
|
|
1317
|
-
...stats,
|
|
1318
|
-
usefulness_rate: stats.total > 0 ? (stats.used + stats.partially_used * 0.5) / stats.total : 0,
|
|
1319
|
-
}));
|
|
1320
|
-
// Top used items
|
|
1321
|
-
const topItems = this.db
|
|
1322
|
-
.prepare(`SELECT
|
|
1323
|
-
af.item_id,
|
|
1324
|
-
si.title,
|
|
1325
|
-
si.source_type,
|
|
1326
|
-
SUM(CASE WHEN af.outcome = 'used' THEN 1 ELSE 0 END) as used_count,
|
|
1327
|
-
SUM(CASE WHEN af.outcome = 'rejected' THEN 1 ELSE 0 END) as rejected_count
|
|
1328
|
-
FROM agent_feedback af
|
|
1329
|
-
LEFT JOIN source_items si ON CAST(af.item_id AS INTEGER) = si.id
|
|
1330
|
-
WHERE af.recorded_at >= ${cutoff}
|
|
1331
|
-
GROUP BY af.item_id
|
|
1332
|
-
HAVING used_count > 0
|
|
1333
|
-
ORDER BY used_count DESC
|
|
1334
|
-
LIMIT 10`)
|
|
1335
|
-
.all();
|
|
1336
|
-
// Recent feedback entries
|
|
1337
|
-
const recentFeedback = this.db
|
|
1338
|
-
.prepare(`SELECT item_id, outcome, context, session_task, recorded_at
|
|
1339
|
-
FROM agent_feedback
|
|
1340
|
-
WHERE recorded_at >= ${cutoff}
|
|
1341
|
-
ORDER BY recorded_at DESC
|
|
1342
|
-
LIMIT 20`)
|
|
1343
|
-
.all();
|
|
1344
|
-
return {
|
|
1345
|
-
period_days: days,
|
|
1346
|
-
outcomes,
|
|
1347
|
-
by_source: bySource,
|
|
1348
|
-
top_used_items: topItems,
|
|
1349
|
-
recent_feedback: recentFeedback,
|
|
1350
|
-
};
|
|
1351
|
-
}
|
|
1352
1279
|
// ===========================================================================
|
|
1353
1280
|
// Helper Methods
|
|
1354
1281
|
// ===========================================================================
|