@4da/mcp-server 6.0.0 → 6.0.2
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 +82 -0
- package/README.md +12 -9
- package/dist/db.d.ts +17 -57
- package/dist/db.js +71 -292
- package/dist/index.js +11 -3
- package/dist/setup.d.ts +76 -3
- package/dist/setup.js +313 -130
- package/dist/tool-dispatch.d.ts +2 -0
- package/dist/tool-dispatch.js +21 -1
- package/dist/types.d.ts +0 -135
- package/package.json +15 -11
package/dist/db.js
CHANGED
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
import path from "path";
|
|
9
9
|
import * as fs from "fs";
|
|
10
10
|
import * as os from "os";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { dirname } from "node:path";
|
|
13
11
|
import { checkNativeBindings, isNativeBindingError, nativeBindingMessage } from "./native-bindings.js";
|
|
14
12
|
let Database;
|
|
15
13
|
try {
|
|
@@ -23,38 +21,37 @@ catch (err) {
|
|
|
23
21
|
export function nativeBindingProblem() {
|
|
24
22
|
return checkNativeBindings(Database);
|
|
25
23
|
}
|
|
26
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
27
|
-
const __dirname = dirname(__filename);
|
|
28
24
|
import { dbRecoveryNote, readDbRecoveredMarker, readEngineBlockMarker } from "./freshness-markers.js";
|
|
29
25
|
/**
|
|
30
26
|
* Resolve the database path by checking multiple locations in priority order:
|
|
31
27
|
* 1. FOURDA_DB_PATH env var
|
|
32
|
-
* 2. data/4da.db relative to cwd
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
28
|
+
* 2. data/4da.db relative to cwd: the desktop app's development database,
|
|
29
|
+
* when the server is started in a checkout of the app's repository
|
|
30
|
+
* 3. The deployed desktop app's database (desktopAppDbPaths)
|
|
31
|
+
* 4. Otherwise the standalone database (standaloneDbPath), created on first use
|
|
32
|
+
*
|
|
33
|
+
* Until 6.0.2 a step between 2 and 3 looked for data/4da.db two directories
|
|
34
|
+
* above this file, which found the app's database while this server was the
|
|
35
|
+
* app repository's mcp-4da-server/ folder. Since the server moved to its own
|
|
36
|
+
* repository (6.0.1) that resolves to node_modules/@4da/ or to the folder
|
|
37
|
+
* holding a clone, never to an app database, so it was removed.
|
|
36
38
|
*/
|
|
37
39
|
function getDefaultDbPath() {
|
|
38
40
|
// 1. Environment variable (highest priority)
|
|
39
41
|
if (process.env.FOURDA_DB_PATH) {
|
|
40
42
|
return process.env.FOURDA_DB_PATH;
|
|
41
43
|
}
|
|
42
|
-
// 2. Relative to cwd (development)
|
|
44
|
+
// 2. Relative to cwd (the app repository's development database)
|
|
43
45
|
const cwdPath = path.resolve(process.cwd(), "data", "4da.db");
|
|
44
46
|
if (fs.existsSync(cwdPath)) {
|
|
45
47
|
return cwdPath;
|
|
46
48
|
}
|
|
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.
|
|
49
|
+
// 3. The desktop app's own database, where the deployed app keeps it.
|
|
53
50
|
for (const appDataPath of desktopAppDbPaths()) {
|
|
54
51
|
if (fs.existsSync(appDataPath))
|
|
55
52
|
return appDataPath;
|
|
56
53
|
}
|
|
57
|
-
//
|
|
54
|
+
// 4. No desktop database: the standalone database, in the user's data dir.
|
|
58
55
|
return standaloneDbPath();
|
|
59
56
|
}
|
|
60
57
|
/**
|
|
@@ -104,6 +101,38 @@ export function standaloneDbPath() {
|
|
|
104
101
|
: process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
105
102
|
return path.join(base, "4da-mcp", "standalone.db");
|
|
106
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether an error says the database file itself is unreadable: not SQLite at
|
|
106
|
+
* all, or damaged. better-sqlite3 reports these as SQLITE_NOTADB ("file is not
|
|
107
|
+
* a database") and SQLITE_CORRUPT ("database disk image is malformed").
|
|
108
|
+
*/
|
|
109
|
+
export function isUnreadableDbError(error) {
|
|
110
|
+
const code = error?.code;
|
|
111
|
+
if (code === "SQLITE_NOTADB" || code === "SQLITE_CORRUPT")
|
|
112
|
+
return true;
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
return /file is not a database|database disk image is malformed/i.test(message);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What to tell the user about an unreadable database. Every tool call used to
|
|
118
|
+
* answer with the bare SQLite text ("file is not a database"), with no way
|
|
119
|
+
* out. Nothing is deleted or moved automatically: the desktop app's database
|
|
120
|
+
* can be the user's whole corpus, and the standalone one holds their recorded
|
|
121
|
+
* decisions and agent memory.
|
|
122
|
+
*/
|
|
123
|
+
export function unreadableDbMessage(absolutePath, error) {
|
|
124
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
125
|
+
const isStandaloneFile = path.resolve(absolutePath) === path.resolve(standaloneDbPath());
|
|
126
|
+
const remedy = isStandaloneFile
|
|
127
|
+
? "It is this server's standalone database: move it aside (keep it if it holds decisions or agent " +
|
|
128
|
+
"memory you want) and the next call creates a new one."
|
|
129
|
+
: process.env.FOURDA_DB_PATH
|
|
130
|
+
? "FOURDA_DB_PATH points at it: point it at a valid 4DA database, or unset it to use the default."
|
|
131
|
+
: "If it is the 4DA desktop app's database, start the app, which checks its database on startup. " +
|
|
132
|
+
"Do not delete it: it may be your only copy of the app's data.";
|
|
133
|
+
return (`The 4DA database at ${absolutePath} cannot be read (${detail}): the file is damaged or is not ` +
|
|
134
|
+
`a SQLite database. ${remedy} Run \`npx @4da/mcp-server --doctor\` for details.`);
|
|
135
|
+
}
|
|
107
136
|
/** Marker table written into every standalone database. */
|
|
108
137
|
const STANDALONE_MARKER = "mcp_standalone";
|
|
109
138
|
/**
|
|
@@ -200,6 +229,8 @@ export const DEPENDENCY_GROUP_QUERY = "SELECT DISTINCT package_name, language, p
|
|
|
200
229
|
export class FourDADatabase {
|
|
201
230
|
db;
|
|
202
231
|
_isStandalone = false;
|
|
232
|
+
/** Absolute path of the open database file. */
|
|
233
|
+
dbPath;
|
|
203
234
|
constructor(dbPath) {
|
|
204
235
|
const resolvedPath = dbPath || getDefaultDbPath();
|
|
205
236
|
// Resolve path - if relative, resolve from cwd
|
|
@@ -214,15 +245,29 @@ export class FourDADatabase {
|
|
|
214
245
|
fs.mkdirSync(dir, { recursive: true });
|
|
215
246
|
}
|
|
216
247
|
}
|
|
248
|
+
let opened = null;
|
|
217
249
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
250
|
+
opened = new Database(absolutePath, { readonly: false }); // Need write for feedback
|
|
251
|
+
opened.pragma("journal_mode = WAL");
|
|
220
252
|
}
|
|
221
253
|
catch (error) {
|
|
254
|
+
// Close the handle a failed pragma leaves open: every tool call retries
|
|
255
|
+
// the open, and on Windows each leaked handle also keeps the file locked
|
|
256
|
+
// against the move-aside the message below suggests.
|
|
257
|
+
try {
|
|
258
|
+
opened?.close();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Nothing to close when the open itself failed.
|
|
262
|
+
}
|
|
222
263
|
throw new Error(isNativeBindingError(error)
|
|
223
264
|
? nativeBindingMessage(error)
|
|
224
|
-
:
|
|
265
|
+
: isUnreadableDbError(error)
|
|
266
|
+
? unreadableDbMessage(absolutePath, error)
|
|
267
|
+
: `Failed to open 4DA database at ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
268
|
}
|
|
269
|
+
this.db = opened;
|
|
270
|
+
this.dbPath = absolutePath;
|
|
226
271
|
// Standalone mode: create schema for a brand-new database, and recognise
|
|
227
272
|
// an existing standalone database as one on every later session.
|
|
228
273
|
if (isNew) {
|
|
@@ -302,8 +347,10 @@ export class FourDADatabase {
|
|
|
302
347
|
if (integrityStatus !== "ok") {
|
|
303
348
|
return {
|
|
304
349
|
valid: false,
|
|
305
|
-
|
|
306
|
-
|
|
350
|
+
// It used to say "Try deleting data/4da.db": a path that is not
|
|
351
|
+
// where the app keeps its database, and advice that destroys the
|
|
352
|
+
// user's only copy of it.
|
|
353
|
+
error: unreadableDbMessage(absolutePath, new Error(`integrity check failed: ${integrityStatus}`)),
|
|
307
354
|
};
|
|
308
355
|
}
|
|
309
356
|
}
|
|
@@ -322,8 +369,9 @@ export class FourDADatabase {
|
|
|
322
369
|
catch (error) {
|
|
323
370
|
return {
|
|
324
371
|
valid: false,
|
|
325
|
-
error:
|
|
326
|
-
|
|
372
|
+
error: isUnreadableDbError(error)
|
|
373
|
+
? unreadableDbMessage(absolutePath, error)
|
|
374
|
+
: `Failed to open database at ${absolutePath}: ` + (error instanceof Error ? error.message : String(error)),
|
|
327
375
|
};
|
|
328
376
|
}
|
|
329
377
|
finally {
|
|
@@ -668,17 +716,6 @@ export class FourDADatabase {
|
|
|
668
716
|
);
|
|
669
717
|
CREATE INDEX IF NOT EXISTS idx_advantage_period ON advantage_score(period, computed_at);
|
|
670
718
|
|
|
671
|
-
-- Agent feedback — queried by record_agent_feedback, get_agent_feedback_stats
|
|
672
|
-
CREATE TABLE IF NOT EXISTS agent_feedback (
|
|
673
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
674
|
-
item_id TEXT NOT NULL,
|
|
675
|
-
outcome TEXT NOT NULL,
|
|
676
|
-
context TEXT,
|
|
677
|
-
session_task TEXT,
|
|
678
|
-
recorded_at TEXT DEFAULT (datetime('now'))
|
|
679
|
-
);
|
|
680
|
-
CREATE INDEX IF NOT EXISTS idx_agent_feedback_item ON agent_feedback(item_id);
|
|
681
|
-
CREATE INDEX IF NOT EXISTS idx_agent_feedback_outcome ON agent_feedback(outcome);
|
|
682
719
|
`);
|
|
683
720
|
}
|
|
684
721
|
/**
|
|
@@ -1097,17 +1134,6 @@ export class FourDADatabase {
|
|
|
1097
1134
|
.sort((a, b) => b.relevance_score - a.relevance_score)
|
|
1098
1135
|
.slice(0, limit);
|
|
1099
1136
|
}
|
|
1100
|
-
/**
|
|
1101
|
-
* Get a single source item by ID and type
|
|
1102
|
-
*/
|
|
1103
|
-
getSourceItem(itemId, sourceType) {
|
|
1104
|
-
const stmt = this.db.prepare(`
|
|
1105
|
-
SELECT id, source_type, source_id, url, title, content, content_hash, created_at, last_seen
|
|
1106
|
-
FROM source_items
|
|
1107
|
-
WHERE id = ? AND source_type = ?
|
|
1108
|
-
`);
|
|
1109
|
-
return stmt.get(itemId, sourceType) || null;
|
|
1110
|
-
}
|
|
1111
1137
|
/**
|
|
1112
1138
|
* Look up persisted necessity scores for an item (written by Rust analysis pipeline).
|
|
1113
1139
|
* Returns defaults (0.0/null) if no necessity data exists yet.
|
|
@@ -1205,99 +1231,6 @@ export class FourDADatabase {
|
|
|
1205
1231
|
return context;
|
|
1206
1232
|
}
|
|
1207
1233
|
// ===========================================================================
|
|
1208
|
-
// Relevance Explanation
|
|
1209
|
-
// ===========================================================================
|
|
1210
|
-
/**
|
|
1211
|
-
* Explain why an item was considered relevant
|
|
1212
|
-
*/
|
|
1213
|
-
explainRelevance(itemId, sourceType) {
|
|
1214
|
-
const item = this.getSourceItem(itemId, sourceType);
|
|
1215
|
-
if (!item) {
|
|
1216
|
-
return null;
|
|
1217
|
-
}
|
|
1218
|
-
const context = this.getUserContext(true, true);
|
|
1219
|
-
const itemTopics = this.extractTopics(item.title + " " + item.content);
|
|
1220
|
-
// Compute matching elements
|
|
1221
|
-
const matchingInterests = context.interests
|
|
1222
|
-
.filter((i) => itemTopics.some((t) => this.topicMatches(t, i.topic)))
|
|
1223
|
-
.map((i) => i.topic);
|
|
1224
|
-
const matchingTech = context.tech_stack.filter((tech) => itemTopics.some((t) => this.topicMatches(t, tech)));
|
|
1225
|
-
const matchingTopics = context.ace?.active_topics
|
|
1226
|
-
.filter((at) => itemTopics.some((t) => this.topicMatches(t, at.topic)))
|
|
1227
|
-
.map((at) => at.topic) || [];
|
|
1228
|
-
const matchingAffinities = context.learned?.topic_affinities
|
|
1229
|
-
.filter((ta) => ta.affinity_score > 0 && itemTopics.some((t) => this.topicMatches(t, ta.topic)))
|
|
1230
|
-
.map((ta) => ta.topic) || [];
|
|
1231
|
-
// Compute score breakdown
|
|
1232
|
-
const staticScore = matchingInterests.length * 0.3 +
|
|
1233
|
-
matchingTech.length * 0.2 +
|
|
1234
|
-
(context.domains.some((d) => itemTopics.some((t) => this.topicMatches(t, d)))
|
|
1235
|
-
? 0.15
|
|
1236
|
-
: 0);
|
|
1237
|
-
const aceScore = context.ace
|
|
1238
|
-
? matchingTopics.length * 0.1 +
|
|
1239
|
-
context.ace.detected_tech.filter((dt) => itemTopics.some((t) => this.topicMatches(t, dt.name))).length *
|
|
1240
|
-
0.05
|
|
1241
|
-
: 0;
|
|
1242
|
-
// Learned affinity score DEMOTED in v19 (AD-029) — must mirror
|
|
1243
|
-
// computeRelevanceScore so the explanation matches what actually
|
|
1244
|
-
// scored (the two previously diverged on the anti-penalty weight too:
|
|
1245
|
-
// scorer used *0.3, this explanation used *0.5).
|
|
1246
|
-
const learnedScore = 0;
|
|
1247
|
-
// Anti-penalty from user-authored exclusions only (learned anti-topics
|
|
1248
|
-
// demoted with the rest of the behavioral stack, AD-029).
|
|
1249
|
-
let antiPenalty = 0;
|
|
1250
|
-
for (const exclusion of context.exclusions) {
|
|
1251
|
-
if (itemTopics.some((t) => this.topicMatches(t, exclusion))) {
|
|
1252
|
-
antiPenalty = 1.0; // Hard exclusion
|
|
1253
|
-
break;
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
const finalScore = Math.max(0, Math.min(1, staticScore + aceScore + learnedScore - antiPenalty));
|
|
1257
|
-
const scoreBreakdown = {
|
|
1258
|
-
embedding_similarity: null, // Not computing embedding similarity in MCP server
|
|
1259
|
-
static_match_score: Math.round(staticScore * 100) / 100,
|
|
1260
|
-
ace_match_score: Math.round(aceScore * 100) / 100,
|
|
1261
|
-
learned_affinity_score: Math.round(learnedScore * 100) / 100,
|
|
1262
|
-
anti_penalty: Math.round(antiPenalty * 100) / 100,
|
|
1263
|
-
final_score: Math.round(finalScore * 100) / 100,
|
|
1264
|
-
};
|
|
1265
|
-
const matchingContext = {
|
|
1266
|
-
matching_interests: matchingInterests,
|
|
1267
|
-
matching_tech: matchingTech,
|
|
1268
|
-
matching_topics: matchingTopics,
|
|
1269
|
-
matching_affinities: matchingAffinities,
|
|
1270
|
-
};
|
|
1271
|
-
// Generate human-readable explanation
|
|
1272
|
-
const explanationParts = [];
|
|
1273
|
-
if (matchingInterests.length > 0) {
|
|
1274
|
-
explanationParts.push(`Matches your interests: ${matchingInterests.slice(0, 3).join(", ")}`);
|
|
1275
|
-
}
|
|
1276
|
-
if (matchingTech.length > 0) {
|
|
1277
|
-
explanationParts.push(`Related to your tech stack: ${matchingTech.slice(0, 3).join(", ")}`);
|
|
1278
|
-
}
|
|
1279
|
-
if (matchingTopics.length > 0) {
|
|
1280
|
-
explanationParts.push(`Relevant to recent work: ${matchingTopics.slice(0, 3).join(", ")}`);
|
|
1281
|
-
}
|
|
1282
|
-
if (matchingAffinities.length > 0) {
|
|
1283
|
-
explanationParts.push(`You've shown interest in: ${matchingAffinities.slice(0, 3).join(", ")}`);
|
|
1284
|
-
}
|
|
1285
|
-
if (antiPenalty > 0) {
|
|
1286
|
-
explanationParts.push(`Score reduced due to excluded topics`);
|
|
1287
|
-
}
|
|
1288
|
-
const explanation = explanationParts.length > 0
|
|
1289
|
-
? explanationParts.join(". ") + "."
|
|
1290
|
-
: "No specific matches found - may be based on general relevance.";
|
|
1291
|
-
return {
|
|
1292
|
-
item_id: itemId,
|
|
1293
|
-
source_type: sourceType,
|
|
1294
|
-
title: item.title,
|
|
1295
|
-
score_breakdown: scoreBreakdown,
|
|
1296
|
-
matching_context: matchingContext,
|
|
1297
|
-
explanation,
|
|
1298
|
-
};
|
|
1299
|
-
}
|
|
1300
|
-
// ===========================================================================
|
|
1301
1234
|
// Feedback
|
|
1302
1235
|
// ===========================================================================
|
|
1303
1236
|
/**
|
|
@@ -1392,160 +1325,6 @@ export class FourDADatabase {
|
|
|
1392
1325
|
`);
|
|
1393
1326
|
}
|
|
1394
1327
|
// ===========================================================================
|
|
1395
|
-
// Agent Feedback
|
|
1396
|
-
// ===========================================================================
|
|
1397
|
-
/**
|
|
1398
|
-
* Ensure the agent_feedback table exists.
|
|
1399
|
-
* Called on first use so existing databases (created before this feature)
|
|
1400
|
-
* get the table added without requiring a migration.
|
|
1401
|
-
*/
|
|
1402
|
-
ensureAgentFeedbackTable() {
|
|
1403
|
-
this.db.exec(`
|
|
1404
|
-
CREATE TABLE IF NOT EXISTS agent_feedback (
|
|
1405
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1406
|
-
item_id TEXT NOT NULL,
|
|
1407
|
-
outcome TEXT NOT NULL,
|
|
1408
|
-
context TEXT,
|
|
1409
|
-
session_task TEXT,
|
|
1410
|
-
recorded_at TEXT DEFAULT (datetime('now'))
|
|
1411
|
-
);
|
|
1412
|
-
CREATE INDEX IF NOT EXISTS idx_agent_feedback_item ON agent_feedback(item_id);
|
|
1413
|
-
CREATE INDEX IF NOT EXISTS idx_agent_feedback_outcome ON agent_feedback(outcome);
|
|
1414
|
-
`);
|
|
1415
|
-
}
|
|
1416
|
-
/**
|
|
1417
|
-
* Record agent feedback on recommended content items
|
|
1418
|
-
*/
|
|
1419
|
-
recordAgentFeedback(itemIds, outcome, context, sessionTask) {
|
|
1420
|
-
this.ensureAgentFeedbackTable();
|
|
1421
|
-
const validOutcomes = ["used", "rejected", "partially_used"];
|
|
1422
|
-
if (!validOutcomes.includes(outcome)) {
|
|
1423
|
-
return {
|
|
1424
|
-
success: false,
|
|
1425
|
-
message: `Invalid outcome: ${outcome}. Valid outcomes: ${validOutcomes.join(", ")}`,
|
|
1426
|
-
recorded_count: 0,
|
|
1427
|
-
};
|
|
1428
|
-
}
|
|
1429
|
-
if (!itemIds || itemIds.length === 0) {
|
|
1430
|
-
return {
|
|
1431
|
-
success: false,
|
|
1432
|
-
message: "item_ids must contain at least one item ID",
|
|
1433
|
-
recorded_count: 0,
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
|
-
try {
|
|
1437
|
-
const stmt = this.db.prepare(`
|
|
1438
|
-
INSERT INTO agent_feedback (item_id, outcome, context, session_task)
|
|
1439
|
-
VALUES (?, ?, ?, ?)
|
|
1440
|
-
`);
|
|
1441
|
-
const insertMany = this.db.transaction((ids) => {
|
|
1442
|
-
let count = 0;
|
|
1443
|
-
for (const id of ids) {
|
|
1444
|
-
stmt.run(id, outcome, context || null, sessionTask || null);
|
|
1445
|
-
count++;
|
|
1446
|
-
}
|
|
1447
|
-
return count;
|
|
1448
|
-
});
|
|
1449
|
-
const count = insertMany(itemIds);
|
|
1450
|
-
return {
|
|
1451
|
-
success: true,
|
|
1452
|
-
message: `Recorded ${outcome} feedback for ${count} item(s)`,
|
|
1453
|
-
recorded_count: count,
|
|
1454
|
-
};
|
|
1455
|
-
}
|
|
1456
|
-
catch (error) {
|
|
1457
|
-
return {
|
|
1458
|
-
success: false,
|
|
1459
|
-
message: `Failed to record agent feedback: ${error instanceof Error ? error.message : String(error)}`,
|
|
1460
|
-
recorded_count: 0,
|
|
1461
|
-
};
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
/**
|
|
1465
|
-
* Get agent feedback statistics for the given period
|
|
1466
|
-
*/
|
|
1467
|
-
getAgentFeedbackStats(days = 30) {
|
|
1468
|
-
this.ensureAgentFeedbackTable();
|
|
1469
|
-
const cutoff = `datetime('now', '-${Math.max(1, Math.min(365, days))} days')`;
|
|
1470
|
-
// Outcome totals
|
|
1471
|
-
const outcomeRows = this.db
|
|
1472
|
-
.prepare(`SELECT outcome, COUNT(*) as cnt
|
|
1473
|
-
FROM agent_feedback
|
|
1474
|
-
WHERE recorded_at >= ${cutoff}
|
|
1475
|
-
GROUP BY outcome`)
|
|
1476
|
-
.all();
|
|
1477
|
-
const outcomes = { used: 0, rejected: 0, partially_used: 0, total: 0 };
|
|
1478
|
-
for (const row of outcomeRows) {
|
|
1479
|
-
if (row.outcome === "used")
|
|
1480
|
-
outcomes.used = row.cnt;
|
|
1481
|
-
else if (row.outcome === "rejected")
|
|
1482
|
-
outcomes.rejected = row.cnt;
|
|
1483
|
-
else if (row.outcome === "partially_used")
|
|
1484
|
-
outcomes.partially_used = row.cnt;
|
|
1485
|
-
outcomes.total += row.cnt;
|
|
1486
|
-
}
|
|
1487
|
-
// By source type — join with source_items to get source_type
|
|
1488
|
-
const sourceRows = this.db
|
|
1489
|
-
.prepare(`SELECT COALESCE(si.source_type, 'unknown') as source_type, af.outcome, COUNT(*) as cnt
|
|
1490
|
-
FROM agent_feedback af
|
|
1491
|
-
LEFT JOIN source_items si ON CAST(af.item_id AS INTEGER) = si.id
|
|
1492
|
-
WHERE af.recorded_at >= ${cutoff}
|
|
1493
|
-
GROUP BY source_type, af.outcome
|
|
1494
|
-
ORDER BY cnt DESC`)
|
|
1495
|
-
.all();
|
|
1496
|
-
// Aggregate source rows into per-source stats
|
|
1497
|
-
const sourceMap = new Map();
|
|
1498
|
-
for (const row of sourceRows) {
|
|
1499
|
-
if (!sourceMap.has(row.source_type)) {
|
|
1500
|
-
sourceMap.set(row.source_type, { used: 0, rejected: 0, partially_used: 0, total: 0 });
|
|
1501
|
-
}
|
|
1502
|
-
const entry = sourceMap.get(row.source_type);
|
|
1503
|
-
if (row.outcome === "used")
|
|
1504
|
-
entry.used += row.cnt;
|
|
1505
|
-
else if (row.outcome === "rejected")
|
|
1506
|
-
entry.rejected += row.cnt;
|
|
1507
|
-
else if (row.outcome === "partially_used")
|
|
1508
|
-
entry.partially_used += row.cnt;
|
|
1509
|
-
entry.total += row.cnt;
|
|
1510
|
-
}
|
|
1511
|
-
const bySource = Array.from(sourceMap.entries()).map(([source_type, stats]) => ({
|
|
1512
|
-
source_type,
|
|
1513
|
-
...stats,
|
|
1514
|
-
usefulness_rate: stats.total > 0 ? (stats.used + stats.partially_used * 0.5) / stats.total : 0,
|
|
1515
|
-
}));
|
|
1516
|
-
// Top used items
|
|
1517
|
-
const topItems = this.db
|
|
1518
|
-
.prepare(`SELECT
|
|
1519
|
-
af.item_id,
|
|
1520
|
-
si.title,
|
|
1521
|
-
si.source_type,
|
|
1522
|
-
SUM(CASE WHEN af.outcome = 'used' THEN 1 ELSE 0 END) as used_count,
|
|
1523
|
-
SUM(CASE WHEN af.outcome = 'rejected' THEN 1 ELSE 0 END) as rejected_count
|
|
1524
|
-
FROM agent_feedback af
|
|
1525
|
-
LEFT JOIN source_items si ON CAST(af.item_id AS INTEGER) = si.id
|
|
1526
|
-
WHERE af.recorded_at >= ${cutoff}
|
|
1527
|
-
GROUP BY af.item_id
|
|
1528
|
-
HAVING used_count > 0
|
|
1529
|
-
ORDER BY used_count DESC
|
|
1530
|
-
LIMIT 10`)
|
|
1531
|
-
.all();
|
|
1532
|
-
// Recent feedback entries
|
|
1533
|
-
const recentFeedback = this.db
|
|
1534
|
-
.prepare(`SELECT item_id, outcome, context, session_task, recorded_at
|
|
1535
|
-
FROM agent_feedback
|
|
1536
|
-
WHERE recorded_at >= ${cutoff}
|
|
1537
|
-
ORDER BY recorded_at DESC
|
|
1538
|
-
LIMIT 20`)
|
|
1539
|
-
.all();
|
|
1540
|
-
return {
|
|
1541
|
-
period_days: days,
|
|
1542
|
-
outcomes,
|
|
1543
|
-
by_source: bySource,
|
|
1544
|
-
top_used_items: topItems,
|
|
1545
|
-
recent_feedback: recentFeedback,
|
|
1546
|
-
};
|
|
1547
|
-
}
|
|
1548
|
-
// ===========================================================================
|
|
1549
1328
|
// Helper Methods
|
|
1550
1329
|
// ===========================================================================
|
|
1551
1330
|
/**
|
package/dist/index.js
CHANGED
|
@@ -68,7 +68,7 @@ const SERVER_VERSION = (() => {
|
|
|
68
68
|
return "0.0.0";
|
|
69
69
|
}
|
|
70
70
|
})();
|
|
71
|
-
import { createDatabase, DEPENDENCY_GROUP_QUERY, FourDADatabase } from "./db.js";
|
|
71
|
+
import { createDatabase, DEPENDENCY_GROUP_QUERY, FourDADatabase, isUnreadableDbError, unreadableDbMessage, } from "./db.js";
|
|
72
72
|
// =============================================================================
|
|
73
73
|
// Server Setup
|
|
74
74
|
// =============================================================================
|
|
@@ -376,7 +376,14 @@ export function buildServer() {
|
|
|
376
376
|
return await dispatchTool(name, database, args);
|
|
377
377
|
}
|
|
378
378
|
catch (error) {
|
|
379
|
-
|
|
379
|
+
// A database that turns out damaged mid-session (SQLITE_CORRUPT on a
|
|
380
|
+
// page the open did not touch) gets the same guidance as one that
|
|
381
|
+
// fails to open, instead of the bare SQLite text.
|
|
382
|
+
const errorMessage = db && isUnreadableDbError(error)
|
|
383
|
+
? unreadableDbMessage(db.dbPath, error)
|
|
384
|
+
: error instanceof Error
|
|
385
|
+
? error.message
|
|
386
|
+
: String(error);
|
|
380
387
|
return {
|
|
381
388
|
content: [
|
|
382
389
|
{
|
|
@@ -441,6 +448,7 @@ async function main() {
|
|
|
441
448
|
refused unless MCP_AUTH_SECRET is set; authentication
|
|
442
449
|
is then mandatory for every request.
|
|
443
450
|
--setup Detect editors and write MCP config
|
|
451
|
+
--dry-run With --setup: print each file and entry, write nothing
|
|
444
452
|
--doctor Validate database, bindings, and LLM providers
|
|
445
453
|
|
|
446
454
|
Environment:
|
|
@@ -463,7 +471,7 @@ async function main() {
|
|
|
463
471
|
}
|
|
464
472
|
// Setup command: configure editors
|
|
465
473
|
if (args.includes("--setup") || args.includes("setup")) {
|
|
466
|
-
runSetup();
|
|
474
|
+
runSetup(args.includes("--dry-run"));
|
|
467
475
|
return;
|
|
468
476
|
}
|
|
469
477
|
// Doctor command: validate installation health
|
package/dist/setup.d.ts
CHANGED
|
@@ -6,12 +6,85 @@
|
|
|
6
6
|
* the user can start using 4DA tools immediately after install.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* npx @4da/mcp-server --setup
|
|
10
|
-
* 4da-mcp-setup
|
|
9
|
+
* npx @4da/mcp-server --setup [--dry-run]
|
|
10
|
+
* 4da-mcp-setup [--dry-run]
|
|
11
11
|
* pnpm run setup
|
|
12
|
+
*
|
|
13
|
+
* Audited 2026-10-05: three of the four editors were configured in files the
|
|
14
|
+
* editor never reads (`~/.vscode/mcp.json`, `~/.windsurf/mcp.json`, and
|
|
15
|
+
* `mcpServers` in a project's `.claude/settings.json`), and any config file
|
|
16
|
+
* that failed `JSON.parse` (VS Code's `mcp.json` is JSONC: comments and
|
|
17
|
+
* trailing commas are legal) was replaced by `{}` plus our entry, deleting
|
|
18
|
+
* every other server the user had. Each path below cites the doc it was
|
|
19
|
+
* checked against, and a file that cannot be parsed is never written.
|
|
20
|
+
*/
|
|
21
|
+
/** The Claude Code command printed instead of editing `~/.claude.json`. */
|
|
22
|
+
export declare const CLAUDE_CODE_COMMAND = "claude mcp add --scope user 4da -- npx @4da/mcp-server";
|
|
23
|
+
/** Where the paths are resolved: injected so tests can stand in any platform. */
|
|
24
|
+
export interface SetupContext {
|
|
25
|
+
platform: NodeJS.Platform;
|
|
26
|
+
env: NodeJS.ProcessEnv;
|
|
27
|
+
home: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function currentContext(): SetupContext;
|
|
30
|
+
/** An editor whose MCP config file this setup writes. */
|
|
31
|
+
export interface EditorTarget {
|
|
32
|
+
name: string;
|
|
33
|
+
/** Config file the server entry goes into. */
|
|
34
|
+
configPath: string;
|
|
35
|
+
/** Directory whose existence means the editor is installed. */
|
|
36
|
+
detectDir: string;
|
|
37
|
+
/** Top-level key holding the servers: VS Code uses `servers`, the rest `mcpServers`. */
|
|
38
|
+
key: "mcpServers" | "servers";
|
|
39
|
+
/** The value written under `<key>["4da"]`. */
|
|
40
|
+
entry: Record<string, unknown>;
|
|
41
|
+
/** Where the path and key were verified. */
|
|
42
|
+
docs: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every editor this setup can write, with its config path on `ctx.platform`.
|
|
46
|
+
* Pure: nothing is read from disk here.
|
|
47
|
+
*/
|
|
48
|
+
export declare function editorTargets(ctx?: SetupContext): EditorTarget[];
|
|
49
|
+
/**
|
|
50
|
+
* Claude Code keeps user- and local-scope servers in `~/.claude.json`
|
|
51
|
+
* (`$CLAUDE_CONFIG_DIR/.claude.json` when set), a file it rewrites itself and
|
|
52
|
+
* that also holds the sign-in session and per-project state. Setup never
|
|
53
|
+
* edits it: it prints the `claude mcp add` command, which does.
|
|
54
|
+
* https://code.claude.com/docs/en/mcp
|
|
55
|
+
*/
|
|
56
|
+
export declare function claudeCodeConfigPath(ctx?: SetupContext): string;
|
|
57
|
+
export type PlanAction = "create" | "update" | "unchanged" | "refuse";
|
|
58
|
+
export interface ConfigPlan {
|
|
59
|
+
action: PlanAction;
|
|
60
|
+
/** The full new file content for create/update. */
|
|
61
|
+
content?: string;
|
|
62
|
+
/** Why a file is refused, for the user. */
|
|
63
|
+
reason?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decide what writing `entry` under `<key>["4da"]` does to a config file whose
|
|
67
|
+
* current text is `existing` (null when it does not exist).
|
|
68
|
+
*
|
|
69
|
+
* The file is parsed as JSONC (comments and trailing commas allowed) and
|
|
70
|
+
* edited in place with `jsonc-parser`'s `modify`, so other servers, other keys,
|
|
71
|
+
* comments and formatting all survive. A file that does not parse is refused,
|
|
72
|
+
* never replaced. An existing `4da` entry keeps its other fields (`env`, say);
|
|
73
|
+
* only the fields setup owns are set.
|
|
74
|
+
*/
|
|
75
|
+
export declare function planConfig(existing: string | null, key: string, entry: Record<string, unknown>): ConfigPlan;
|
|
76
|
+
/**
|
|
77
|
+
* Write `content` to `filePath`: back up an existing file first, then write a
|
|
78
|
+
* temp file beside it and rename it over the original, so an interrupted run
|
|
79
|
+
* leaves either the old file or the new one, never half of one. A symlinked
|
|
80
|
+
* config (a dotfiles repo) is written through to its target.
|
|
81
|
+
* @returns The backup path, when one was made.
|
|
12
82
|
*/
|
|
83
|
+
export declare function writeConfigAtomic(filePath: string, content: string): string | null;
|
|
84
|
+
type Log = (line: string) => void;
|
|
13
85
|
/**
|
|
14
86
|
* Run the setup wizard: detect editors and write MCP configurations.
|
|
15
87
|
* @param dryRun If true, only report what would be written without modifying files.
|
|
16
88
|
*/
|
|
17
|
-
export declare function runSetup(dryRun?: boolean): void;
|
|
89
|
+
export declare function runSetup(dryRun?: boolean, ctx?: SetupContext, log?: Log): void;
|
|
90
|
+
export {};
|