@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/dist/setup.js CHANGED
@@ -7,160 +7,343 @@
7
7
  * the user can start using 4DA tools immediately after install.
8
8
  *
9
9
  * Usage:
10
- * npx @4da/mcp-server --setup
11
- * 4da-mcp-setup
10
+ * npx @4da/mcp-server --setup [--dry-run]
11
+ * 4da-mcp-setup [--dry-run]
12
12
  * pnpm run setup
13
+ *
14
+ * Audited 2026-10-05: three of the four editors were configured in files the
15
+ * editor never reads (`~/.vscode/mcp.json`, `~/.windsurf/mcp.json`, and
16
+ * `mcpServers` in a project's `.claude/settings.json`), and any config file
17
+ * that failed `JSON.parse` (VS Code's `mcp.json` is JSONC: comments and
18
+ * trailing commas are legal) was replaced by `{}` plus our entry, deleting
19
+ * every other server the user had. Each path below cites the doc it was
20
+ * checked against, and a file that cannot be parsed is never written.
13
21
  */
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
- import { join, dirname } from "node:path";
22
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
23
+ import path from "node:path";
16
24
  import { homedir } from "node:os";
25
+ import jsonc from "jsonc-parser";
26
+ /** The command every editor runs. */
27
+ const SERVER_COMMAND = { command: "npx", args: ["@4da/mcp-server"] };
17
28
  /** MCP config snippet for editors that use the standard mcpServers format */
18
29
  const MCP_CONFIG = {
19
- "4da": {
20
- command: "npx",
21
- args: ["@4da/mcp-server"],
22
- },
30
+ "4da": SERVER_COMMAND,
23
31
  };
32
+ /** The Claude Code command printed instead of editing `~/.claude.json`. */
33
+ export const CLAUDE_CODE_COMMAND = "claude mcp add --scope user 4da -- npx @4da/mcp-server";
34
+ export function currentContext() {
35
+ return { platform: process.platform, env: process.env, home: homedir() };
36
+ }
37
+ function pathFor(ctx) {
38
+ return ctx.platform === "win32" ? path.win32 : path.posix;
39
+ }
40
+ /** `%APPDATA%` on Windows, the Electron-style config base elsewhere. */
41
+ function roamingDir(ctx) {
42
+ const p = pathFor(ctx);
43
+ return ctx.env.APPDATA?.trim() || p.join(ctx.home, "AppData", "Roaming");
44
+ }
45
+ function xdgConfigDir(ctx) {
46
+ return ctx.env.XDG_CONFIG_HOME?.trim() || pathFor(ctx).join(ctx.home, ".config");
47
+ }
24
48
  /**
25
- * Safely read and parse a JSON file, returning empty object on failure.
49
+ * The per-platform user-data dir of a desktop app (Electron `appData`):
50
+ * `%APPDATA%\<name>`, `~/Library/Application Support/<name>`,
51
+ * `$XDG_CONFIG_HOME/<name>` (default `~/.config/<name>`).
26
52
  */
27
- function readJsonSafe(filePath) {
28
- if (!existsSync(filePath))
29
- return {};
30
- try {
31
- return JSON.parse(readFileSync(filePath, "utf-8"));
32
- }
33
- catch {
34
- return {};
35
- }
53
+ function appDataDir(ctx, name) {
54
+ const p = pathFor(ctx);
55
+ if (ctx.platform === "win32")
56
+ return p.join(roamingDir(ctx), name);
57
+ if (ctx.platform === "darwin")
58
+ return p.join(ctx.home, "Library", "Application Support", name);
59
+ return p.join(xdgConfigDir(ctx), name);
36
60
  }
37
61
  /**
38
- * Ensure directory exists and write JSON to file.
62
+ * Every editor this setup can write, with its config path on `ctx.platform`.
63
+ * Pure: nothing is read from disk here.
39
64
  */
40
- function writeJsonFile(filePath, data) {
41
- const dir = dirname(filePath);
42
- if (!existsSync(dir)) {
43
- mkdirSync(dir, { recursive: true });
44
- }
45
- writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
46
- }
47
- const editors = [
48
- {
49
- name: "Claude Code",
50
- detect: () => {
51
- // Project-level config takes priority
52
- const projectClaudeDir = join(process.cwd(), ".claude");
53
- if (existsSync(projectClaudeDir)) {
54
- return join(projectClaudeDir, "settings.json");
55
- }
56
- // Fall back to global config
57
- return join(homedir(), ".claude.json");
58
- },
59
- write: (configPath) => {
60
- const config = readJsonSafe(configPath);
61
- const mcpServers = (config.mcpServers || {});
62
- mcpServers["4da"] = MCP_CONFIG["4da"];
63
- config.mcpServers = mcpServers;
64
- writeJsonFile(configPath, config);
65
- console.log(` Written: ${configPath}`);
66
- },
67
- },
68
- {
69
- name: "Cursor",
70
- detect: () => {
71
- const cursorDir = join(homedir(), ".cursor");
72
- if (existsSync(cursorDir)) {
73
- return join(cursorDir, "mcp.json");
74
- }
75
- return null;
76
- },
77
- write: (configPath) => {
78
- const config = readJsonSafe(configPath);
79
- const mcpServers = (config.mcpServers || {});
80
- mcpServers["4da"] = MCP_CONFIG["4da"];
81
- config.mcpServers = mcpServers;
82
- writeJsonFile(configPath, config);
83
- console.log(` Written: ${configPath}`);
84
- },
85
- },
86
- {
87
- name: "VS Code (Copilot)",
88
- detect: () => {
89
- const vscodePath = join(homedir(), ".vscode");
90
- if (existsSync(vscodePath)) {
91
- return join(vscodePath, "mcp.json");
92
- }
93
- return null;
65
+ export function editorTargets(ctx = currentContext()) {
66
+ const p = pathFor(ctx);
67
+ const vscodeEntry = { type: "stdio", ...SERVER_COMMAND };
68
+ const vscode = (name, product) => {
69
+ const userDir = p.join(appDataDir(ctx, product), "User");
70
+ return {
71
+ name,
72
+ // The default profile's user-level file; it sits beside settings.json.
73
+ // A non-default profile keeps its own under User/profiles/<id>/, which
74
+ // VS Code does not name anywhere setup could find it.
75
+ configPath: p.join(userDir, "mcp.json"),
76
+ detectDir: userDir,
77
+ key: "servers",
78
+ entry: vscodeEntry,
79
+ docs: "https://code.visualstudio.com/docs/copilot/reference/mcp-configuration",
80
+ };
81
+ };
82
+ const devinDir = ctx.platform === "win32" ? p.join(roamingDir(ctx), "devin") : p.join(xdgConfigDir(ctx), "devin");
83
+ const windsurfDir = p.join(ctx.home, ".codeium", "windsurf");
84
+ return [
85
+ {
86
+ name: "Cursor",
87
+ configPath: p.join(ctx.home, ".cursor", "mcp.json"),
88
+ detectDir: p.join(ctx.home, ".cursor"),
89
+ key: "mcpServers",
90
+ entry: SERVER_COMMAND,
91
+ docs: "https://cursor.com/docs/mcp",
94
92
  },
95
- write: (configPath) => {
96
- const config = readJsonSafe(configPath);
97
- // VS Code uses "servers" key instead of "mcpServers"
98
- const servers = (config.servers || {});
99
- servers["4da"] = {
100
- type: "stdio",
101
- command: "npx",
102
- args: ["@4da/mcp-server"],
103
- };
104
- config.servers = servers;
105
- writeJsonFile(configPath, config);
106
- console.log(` Written: ${configPath}`);
93
+ vscode("VS Code (Copilot)", "Code"),
94
+ vscode("VS Code Insiders (Copilot)", "Code - Insiders"),
95
+ {
96
+ name: "Windsurf",
97
+ configPath: p.join(windsurfDir, "mcp_config.json"),
98
+ detectDir: windsurfDir,
99
+ key: "mcpServers",
100
+ entry: SERVER_COMMAND,
101
+ docs: "https://docs.devin.ai/cli/reference/configuration/read-config-from",
107
102
  },
108
- },
109
- {
110
- name: "Windsurf",
111
- detect: () => {
112
- const windsurfDir = join(homedir(), ".windsurf");
113
- if (existsSync(windsurfDir)) {
114
- return join(windsurfDir, "mcp.json");
115
- }
116
- return null;
103
+ {
104
+ // Windsurf's successor. Its Cascade agent and the Devin CLI read the
105
+ // same user-level file.
106
+ name: "Devin Desktop / Devin CLI",
107
+ configPath: p.join(devinDir, "mcp_config.json"),
108
+ detectDir: devinDir,
109
+ key: "mcpServers",
110
+ entry: SERVER_COMMAND,
111
+ docs: "https://docs.devin.ai/desktop/cascade/mcp",
117
112
  },
118
- write: (configPath) => {
119
- const config = readJsonSafe(configPath);
120
- const mcpServers = (config.mcpServers || {});
121
- mcpServers["4da"] = MCP_CONFIG["4da"];
122
- config.mcpServers = mcpServers;
123
- writeJsonFile(configPath, config);
124
- console.log(` Written: ${configPath}`);
113
+ {
114
+ name: "Claude Desktop",
115
+ configPath: p.join(appDataDir(ctx, "Claude"), "claude_desktop_config.json"),
116
+ detectDir: appDataDir(ctx, "Claude"),
117
+ key: "mcpServers",
118
+ entry: SERVER_COMMAND,
119
+ docs: "https://modelcontextprotocol.io/docs/develop/connect-local-servers",
125
120
  },
126
- },
127
- ];
121
+ ];
122
+ }
123
+ /**
124
+ * Claude Code keeps user- and local-scope servers in `~/.claude.json`
125
+ * (`$CLAUDE_CONFIG_DIR/.claude.json` when set), a file it rewrites itself and
126
+ * that also holds the sign-in session and per-project state. Setup never
127
+ * edits it: it prints the `claude mcp add` command, which does.
128
+ * https://code.claude.com/docs/en/mcp
129
+ */
130
+ export function claudeCodeConfigPath(ctx = currentContext()) {
131
+ const p = pathFor(ctx);
132
+ const configDir = ctx.env.CLAUDE_CONFIG_DIR?.trim();
133
+ return configDir ? p.join(configDir, ".claude.json") : p.join(ctx.home, ".claude.json");
134
+ }
135
+ function lineOf(text, offset) {
136
+ return text.slice(0, offset).split("\n").length;
137
+ }
138
+ function isPlainObject(value) {
139
+ return value !== null && typeof value === "object" && !Array.isArray(value);
140
+ }
141
+ function sameJson(a, b) {
142
+ return JSON.stringify(a) === JSON.stringify(b);
143
+ }
144
+ /**
145
+ * Decide what writing `entry` under `<key>["4da"]` does to a config file whose
146
+ * current text is `existing` (null when it does not exist).
147
+ *
148
+ * The file is parsed as JSONC (comments and trailing commas allowed) and
149
+ * edited in place with `jsonc-parser`'s `modify`, so other servers, other keys,
150
+ * comments and formatting all survive. A file that does not parse is refused,
151
+ * never replaced. An existing `4da` entry keeps its other fields (`env`, say);
152
+ * only the fields setup owns are set.
153
+ */
154
+ export function planConfig(existing, key, entry) {
155
+ if (existing === null || existing.trim() === "") {
156
+ return {
157
+ action: existing === null ? "create" : "update",
158
+ content: JSON.stringify({ [key]: { "4da": entry } }, null, 2) + "\n",
159
+ };
160
+ }
161
+ const errors = [];
162
+ const root = jsonc.parse(existing, errors, { allowTrailingComma: true, disallowComments: false });
163
+ if (errors.length > 0) {
164
+ const first = errors[0];
165
+ return {
166
+ action: "refuse",
167
+ reason: `it does not parse (${jsonc.printParseErrorCode(first.error)} at line ${lineOf(existing, first.offset)})`,
168
+ };
169
+ }
170
+ if (!isPlainObject(root)) {
171
+ return { action: "refuse", reason: "its top level is not a JSON object" };
172
+ }
173
+ const servers = root[key];
174
+ if (servers !== undefined && !isPlainObject(servers)) {
175
+ return { action: "refuse", reason: `its "${key}" is not an object` };
176
+ }
177
+ const current = servers?.["4da"];
178
+ if (current !== undefined && !isPlainObject(current)) {
179
+ return { action: "refuse", reason: `its "${key}"."4da" is not an object` };
180
+ }
181
+ if (current && Object.entries(entry).every(([field, value]) => sameJson(current[field], value))) {
182
+ return { action: "unchanged" };
183
+ }
184
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
185
+ const indent = /^([ \t]+)\S/m.exec(existing)?.[1] ?? " ";
186
+ const formattingOptions = {
187
+ insertSpaces: !indent.startsWith("\t"),
188
+ tabSize: indent.startsWith("\t") ? 1 : indent.length,
189
+ eol,
190
+ };
191
+ let content = existing;
192
+ // Field by field when the entry exists, so its other fields and any
193
+ // comments inside it stay; whole when it does not.
194
+ const edits = current
195
+ ? Object.entries(entry).map(([field, value]) => [[key, "4da", field], value])
196
+ : [[[key, "4da"], entry]];
197
+ for (const [jsonPath, value] of edits) {
198
+ content = jsonc.applyEdits(content, jsonc.modify(content, jsonPath, value, { formattingOptions }));
199
+ }
200
+ return { action: "update", content };
201
+ }
202
+ /** Read a config file's text; null when it does not exist. */
203
+ function readConfig(filePath) {
204
+ return existsSync(filePath) ? readFileSync(filePath, "utf-8") : null;
205
+ }
206
+ /** `<file>.bak`, or a timestamped name when that is taken: an earlier backup is never overwritten. */
207
+ function backupPath(filePath) {
208
+ const plain = `${filePath}.bak`;
209
+ if (!existsSync(plain))
210
+ return plain;
211
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..*$/, "");
212
+ for (let n = 0;; n++) {
213
+ const candidate = `${filePath}.${stamp}${n === 0 ? "" : `-${n}`}.bak`;
214
+ if (!existsSync(candidate))
215
+ return candidate;
216
+ }
217
+ }
218
+ /**
219
+ * Write `content` to `filePath`: back up an existing file first, then write a
220
+ * temp file beside it and rename it over the original, so an interrupted run
221
+ * leaves either the old file or the new one, never half of one. A symlinked
222
+ * config (a dotfiles repo) is written through to its target.
223
+ * @returns The backup path, when one was made.
224
+ */
225
+ export function writeConfigAtomic(filePath, content) {
226
+ // Only a symlinked file is resolved: resolving the whole path would turn
227
+ // macOS's /var/... into /private/var/... in every path reported back.
228
+ const target = existsSync(filePath) && lstatSync(filePath).isSymbolicLink() ? realpathSync(filePath) : filePath;
229
+ mkdirSync(path.dirname(target), { recursive: true });
230
+ let backup = null;
231
+ if (existsSync(target)) {
232
+ backup = backupPath(target);
233
+ copyFileSync(target, backup);
234
+ }
235
+ const temp = `${target}.4da-${process.pid}.tmp`;
236
+ try {
237
+ writeFileSync(temp, content);
238
+ renameSync(temp, target);
239
+ }
240
+ catch (error) {
241
+ rmSync(temp, { force: true });
242
+ throw error;
243
+ }
244
+ return backup;
245
+ }
246
+ /** The snippet an editor gets, as printed in a dry run and for manual setup. */
247
+ function snippet(target) {
248
+ return JSON.stringify({ [target.key]: { "4da": target.entry } }, null, 2)
249
+ .split("\n")
250
+ .map((line) => ` ${line}`)
251
+ .join("\n");
252
+ }
253
+ /** Whether `~/.claude.json` already has a user-scope `4da` server. Read-only. */
254
+ function claudeCodeHas4da(configPath) {
255
+ try {
256
+ const root = JSON.parse(readFileSync(configPath, "utf-8"));
257
+ return isPlainObject(root) && isPlainObject(root.mcpServers) && "4da" in root.mcpServers;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ }
128
263
  /**
129
264
  * Run the setup wizard: detect editors and write MCP configurations.
130
265
  * @param dryRun If true, only report what would be written without modifying files.
131
266
  */
132
- export function runSetup(dryRun = false) {
133
- console.log("\n 4DA MCP Server — Setup\n");
267
+ export function runSetup(dryRun = false, ctx = currentContext(), log = console.log) {
268
+ log("\n 4DA MCP Server — Setup\n");
134
269
  if (dryRun)
135
- console.log(" [DRY RUN] No files will be modified.\n");
136
- console.log(" Detecting editors...\n");
137
- let configured = 0;
138
- for (const editor of editors) {
139
- const configPath = editor.detect();
140
- if (configPath) {
141
- console.log(` Found: ${editor.name}`);
142
- if (dryRun) {
143
- console.log(` Would write: ${configPath}`);
270
+ log(" [DRY RUN] No files will be modified.\n");
271
+ log(" Detecting editors...\n");
272
+ let found = 0;
273
+ let refused = 0;
274
+ // Claude Code: print the command, never edit ~/.claude.json.
275
+ const claudeJson = claudeCodeConfigPath(ctx);
276
+ const claudeDir = pathFor(ctx).join(ctx.home, ".claude");
277
+ if (existsSync(claudeJson) || existsSync(claudeDir)) {
278
+ found++;
279
+ log(" Found: Claude Code");
280
+ if (claudeCodeHas4da(claudeJson)) {
281
+ log(` Already configured (user scope, ${claudeJson}).`);
282
+ }
283
+ else {
284
+ log(" Claude Code keeps its MCP servers in ~/.claude.json, which it rewrites");
285
+ log(" itself; setup does not edit it. Add the server with:");
286
+ log(` ${CLAUDE_CODE_COMMAND}`);
287
+ }
288
+ log("");
289
+ }
290
+ for (const target of editorTargets(ctx)) {
291
+ if (!existsSync(target.detectDir))
292
+ continue;
293
+ found++;
294
+ log(` Found: ${target.name}`);
295
+ let plan;
296
+ try {
297
+ plan = planConfig(readConfig(target.configPath), target.key, target.entry);
298
+ }
299
+ catch (error) {
300
+ plan = { action: "refuse", reason: `it could not be read (${error instanceof Error ? error.message : String(error)})` };
301
+ }
302
+ if (plan.action === "unchanged") {
303
+ log(` Already configured: ${target.configPath}`);
304
+ }
305
+ else if (plan.action === "refuse") {
306
+ refused++;
307
+ log(` Not modified: ${target.configPath}`);
308
+ log(` Setup left it untouched because ${plan.reason}. Add this by hand:`);
309
+ log(snippet(target));
310
+ }
311
+ else if (dryRun) {
312
+ log(` Would ${plan.action === "create" ? "create" : "update"}: ${target.configPath}`);
313
+ log(` Adding under "${target.key}":`);
314
+ log(snippet(target));
315
+ }
316
+ else {
317
+ try {
318
+ const backup = writeConfigAtomic(target.configPath, plan.content ?? "");
319
+ log(` Written: ${target.configPath}`);
320
+ if (backup)
321
+ log(` Backup: ${backup}`);
144
322
  }
145
- else {
146
- editor.write(configPath);
323
+ catch (error) {
324
+ refused++;
325
+ log(` Could not write ${target.configPath}: ${error instanceof Error ? error.message : String(error)}`);
326
+ log(" Add this by hand:");
327
+ log(snippet(target));
147
328
  }
148
- configured++;
149
- console.log("");
150
329
  }
330
+ log("");
331
+ }
332
+ if (found === 0) {
333
+ log(" No supported editors detected.\n");
334
+ log(" Manual setup — add to your editor's MCP config:\n");
335
+ log(` ${JSON.stringify({ mcpServers: MCP_CONFIG }, null, 2)}\n`);
336
+ log(` Claude Code: ${CLAUDE_CODE_COMMAND}\n`);
151
337
  }
152
- if (configured === 0) {
153
- console.log(" No supported editors detected.\n");
154
- console.log(" Manual setup — add to your editor's MCP config:\n");
155
- console.log(` ${JSON.stringify(MCP_CONFIG, null, 2)}\n`);
156
- }
157
- console.log(" Done. Restart your editor to activate 4DA.");
158
- console.log("");
159
- console.log(" Note: This server reads data from the 4DA desktop app.");
160
- console.log(" If you haven't installed it yet:");
161
- console.log(" https://github.com/4DA-Systems/4DA/releases/latest");
162
- console.log("");
163
- console.log(" Run: npx @4da/mcp-server --doctor to verify everything works.\n");
338
+ if (refused > 0)
339
+ log(` ${refused} config file${refused === 1 ? " needs" : "s need"} the entry added by hand (above).`);
340
+ log(" Done. Restart your editor to activate 4DA.");
341
+ log("");
342
+ log(" The server works on its own; with the 4DA desktop app installed it");
343
+ log(" also reads the app's scored feed:");
344
+ log(" https://github.com/4DA-Systems/4DA/releases/latest");
345
+ log("");
346
+ log(" Run: npx @4da/mcp-server --doctor to verify everything works.\n");
164
347
  }
165
348
  // Run directly if invoked as a script (bin entry or pnpm run setup)
166
349
  const isDirectRun = process.argv[1]?.endsWith("setup.js") || process.argv[1]?.endsWith("setup.ts");
@@ -20,6 +20,8 @@ export type ToolExecutor = (db: FourDADatabase, params: any) => unknown | Promis
20
20
  * context; the host already owns the process).
21
21
  */
22
22
  export declare function dispatchTool(name: string, db: FourDADatabase, args: Record<string, unknown> | undefined): Promise<CallToolResult>;
23
+ /** What a desktop-only tool says when no desktop app database was found. */
24
+ export declare function desktopAppNote(name: string): string;
23
25
  /** Check if a tool exists in the dispatch map */
24
26
  export declare function hasDispatchTool(name: string): boolean;
25
27
  /** Get count of registered tool executors */
@@ -8,6 +8,7 @@
8
8
  import { assertToolPermission } from "./auth-context.js";
9
9
  import { checkBuildStaleness } from "./build-staleness.js";
10
10
  import { cleanStrings } from "./tool-args.js";
11
+ import { TOOL_REGISTRY } from "./schema-registry.js";
11
12
  import { executeGetRelevantContent, executeGetContext, executeRecordFeedback, executeGetActionableSignals, executeKnowledgeGaps, executeDecisionMemory, executeCheckDecisionAlignment, executeAgentMemory, executeDeveloperDna, executeWhatShouldIKnow, executeVulnerabilityScan, executeEcosystemPulse, executeDependencyHealth, executeUpgradePlanner, executeDependencyCheck, executeUpgradeImpact, } from "./tools/index.js";
12
13
  import { getLiveIntelligence } from "./live-singleton.js";
13
14
  /**
@@ -86,7 +87,11 @@ export async function dispatchTool(name, db, args) {
86
87
  // Feed freshness describes the desktop app's feed; a standalone install has
87
88
  // none, and telling it to "run fourda-engine" is noise.
88
89
  const withFreshness = FRESHNESS_TOOLS.has(name) && !db.isStandalone;
89
- const payload = cleanStrings(withFreshness ? attachFreshness(db, result) : result);
90
+ // A desktop-only tool called without the desktop app's database (tools/list
91
+ // hides these in standalone mode, but a host can call one by name) used to
92
+ // answer `[]`, which reads as "nothing relevant" rather than "no app".
93
+ const desktopOnly = db.isStandalone && TOOL_REGISTRY[name]?.standalone === false;
94
+ const payload = cleanStrings(withFreshness ? attachFreshness(db, result) : desktopOnly ? attachDesktopAppNote(name, result) : result);
90
95
  // An executor that returns `{ error: "<message>" }` failed: say so in the
91
96
  // protocol, not only in the body, so the host and model treat it as an error.
92
97
  const failed = payload !== null && typeof payload === "object" && typeof payload.error === "string";
@@ -140,6 +145,21 @@ function attachFreshness(db, result) {
140
145
  }
141
146
  return { data_freshness, ...extras, result };
142
147
  }
148
+ /** What a desktop-only tool says when no desktop app database was found. */
149
+ export function desktopAppNote(name) {
150
+ return (`${name} reads the 4DA desktop app's database, and none was found (standalone mode), so this result is empty or partial. ` +
151
+ "Install the app (https://github.com/4DA-Systems/4DA/releases/latest) or set FOURDA_DB_PATH to its database. " +
152
+ "The dependency, vulnerability, upgrade and decision tools work without it.");
153
+ }
154
+ /** Same wrapping as attachFreshness: an array becomes { desktop_app_note, item_count, items }. */
155
+ function attachDesktopAppNote(name, result) {
156
+ const desktop_app_note = desktopAppNote(name);
157
+ if (Array.isArray(result))
158
+ return { desktop_app_note, item_count: result.length, items: result };
159
+ if (result && typeof result === "object")
160
+ return { desktop_app_note, ...result };
161
+ return { desktop_app_note, result };
162
+ }
143
163
  /** Check if a tool exists in the dispatch map */
144
164
  export function hasDispatchTool(name) {
145
165
  return name in DISPATCH_MAP;
package/dist/types.d.ts CHANGED
@@ -119,37 +119,6 @@ export interface UserContext {
119
119
  anti_topics: AntiTopic[];
120
120
  };
121
121
  }
122
- /**
123
- * Score breakdown for explain_relevance
124
- */
125
- export interface ScoreBreakdown {
126
- embedding_similarity: number | null;
127
- static_match_score: number;
128
- ace_match_score: number;
129
- learned_affinity_score: number;
130
- anti_penalty: number;
131
- final_score: number;
132
- }
133
- /**
134
- * Matching context elements
135
- */
136
- export interface MatchingContext {
137
- matching_interests: string[];
138
- matching_tech: string[];
139
- matching_topics: string[];
140
- matching_affinities: string[];
141
- }
142
- /**
143
- * Full relevance explanation
144
- */
145
- export interface RelevanceExplanation {
146
- item_id: number;
147
- source_type: string;
148
- title: string;
149
- score_breakdown: ScoreBreakdown;
150
- matching_context: MatchingContext;
151
- explanation: string;
152
- }
153
122
  /**
154
123
  * Valid feedback action types
155
124
  */
@@ -293,107 +262,3 @@ export interface SignalEventRow {
293
262
  data: string;
294
263
  created_at: string;
295
264
  }
296
- /**
297
- * Valid agent feedback outcome types
298
- */
299
- export type AgentFeedbackOutcome = "used" | "rejected" | "partially_used";
300
- /**
301
- * Parameters for recording agent feedback
302
- */
303
- export interface RecordAgentFeedbackParams {
304
- item_ids: string[];
305
- outcome: AgentFeedbackOutcome;
306
- context?: string;
307
- session_task?: string;
308
- }
309
- /**
310
- * Result from recording agent feedback
311
- */
312
- export interface AgentFeedbackResult {
313
- success: boolean;
314
- message: string;
315
- recorded_count: number;
316
- }
317
- /**
318
- * Parameters for getting agent feedback stats
319
- */
320
- export interface GetAgentFeedbackStatsParams {
321
- days?: number;
322
- }
323
- /**
324
- * Outcome breakdown in agent feedback stats
325
- */
326
- export interface AgentFeedbackOutcomeStats {
327
- used: number;
328
- rejected: number;
329
- partially_used: number;
330
- total: number;
331
- }
332
- /**
333
- * Per-source feedback stats
334
- */
335
- export interface AgentFeedbackSourceStats {
336
- source_type: string;
337
- used: number;
338
- rejected: number;
339
- partially_used: number;
340
- total: number;
341
- usefulness_rate: number;
342
- }
343
- /**
344
- * A frequently used item
345
- */
346
- export interface AgentFeedbackTopItem {
347
- item_id: string;
348
- title: string | null;
349
- source_type: string | null;
350
- used_count: number;
351
- rejected_count: number;
352
- }
353
- /**
354
- * A recent feedback entry
355
- */
356
- export interface AgentFeedbackRecentEntry {
357
- item_id: string;
358
- outcome: string;
359
- context: string | null;
360
- session_task: string | null;
361
- recorded_at: string;
362
- }
363
- /**
364
- * Full agent feedback stats response
365
- */
366
- export interface AgentFeedbackStats {
367
- period_days: number;
368
- outcomes: AgentFeedbackOutcomeStats;
369
- by_source: AgentFeedbackSourceStats[];
370
- top_used_items: AgentFeedbackTopItem[];
371
- recent_feedback: AgentFeedbackRecentEntry[];
372
- }
373
- /** Row from agent_feedback table */
374
- export interface AgentFeedbackRow {
375
- item_id: string;
376
- outcome: string;
377
- context: string | null;
378
- session_task: string | null;
379
- recorded_at: string;
380
- }
381
- /** Row from agent_feedback stats query — outcome counts */
382
- export interface AgentFeedbackOutcomeRow {
383
- outcome: string;
384
- cnt: number;
385
- }
386
- /** Row from agent_feedback stats query — source breakdown */
387
- export interface AgentFeedbackSourceRow {
388
- source_type: string;
389
- outcome: string;
390
- cnt: number;
391
- }
392
- /** Row from agent_feedback stats query — top items */
393
- export interface AgentFeedbackTopItemRow {
394
- item_id: string;
395
- title: string | null;
396
- source_type: string | null;
397
- used_count: number;
398
- rejected_count: number;
399
- }