@meshxdata/fops 0.0.1 → 0.0.3

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/src/agent/llm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { execa, execaSync } from "execa";
3
3
  import { resolveAnthropicApiKey, resolveOpenAiApiKey, readClaudeCodeKeychain } from "../auth/index.js";
4
- import { renderSpinner } from "../ui/index.js";
4
+ import { renderThinking } from "../ui/index.js";
5
5
 
6
6
  // Check if Claude Code CLI is available
7
7
  export function hasClaudeCode() {
@@ -86,22 +86,61 @@ export async function streamAssistantReply(root, messages, systemContent, opts)
86
86
  const model = opts.model || (anthropicKey ? "claude-sonnet-4-20250514" : "gpt-4o-mini");
87
87
 
88
88
  let fullText = "";
89
- const spinner = renderSpinner();
89
+ const display = renderThinking();
90
90
 
91
91
  try {
92
92
  if (useClaudeCode) {
93
- // Build full conversation prompt so context is preserved between turns
94
93
  const prompt = buildConversationPrompt(messages);
95
- fullText = await streamViaClaudeCode(prompt, systemContent);
94
+ fullText = await streamViaClaudeCode(prompt, systemContent, (chunk) => {
95
+ display.setStatus("Responding");
96
+ display.appendContent(chunk);
97
+ });
96
98
  } else if (anthropicKey) {
97
99
  const { default: Anthropic } = await import("@anthropic-ai/sdk");
98
100
  const client = new Anthropic({ apiKey: anthropicKey });
99
- const stream = await client.messages.create({
100
- model, max_tokens: 2048, system: systemContent, messages, stream: true,
101
- });
101
+ const isClaudeModel = model.includes("claude");
102
+
103
+ const createParams = {
104
+ model, max_tokens: isClaudeModel ? 16000 : 2048,
105
+ system: systemContent, messages, stream: true,
106
+ };
107
+
108
+ // Enable extended thinking for Claude models
109
+ if (isClaudeModel) {
110
+ createParams.thinking = { type: "enabled", budget_tokens: 10000 };
111
+ }
112
+
113
+ let stream;
114
+ try {
115
+ stream = await client.messages.create(createParams);
116
+ } catch (err) {
117
+ // Fall back to regular streaming if extended thinking is unsupported
118
+ if (isClaudeModel && (err?.status === 400 || err?.status === 422)) {
119
+ delete createParams.thinking;
120
+ createParams.max_tokens = 2048;
121
+ stream = await client.messages.create(createParams);
122
+ } else {
123
+ throw err;
124
+ }
125
+ }
126
+
102
127
  for await (const event of stream) {
103
- if (event.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) {
104
- fullText += event.delta.text;
128
+ if (event.type === "content_block_start") {
129
+ if (event.content_block?.type === "thinking") {
130
+ display.setStatus("Reasoning");
131
+ } else if (event.content_block?.type === "text") {
132
+ display.setThinking(""); // clear thinking preview
133
+ display.setStatus("Responding");
134
+ }
135
+ }
136
+ if (event.type === "content_block_delta") {
137
+ if (event.delta?.type === "thinking_delta" && event.delta.thinking) {
138
+ display.appendThinking(event.delta.thinking);
139
+ }
140
+ if (event.delta?.type === "text_delta" && event.delta.text) {
141
+ fullText += event.delta.text;
142
+ display.setContent(fullText);
143
+ }
105
144
  }
106
145
  }
107
146
  } else if (openaiKey) {
@@ -114,13 +153,17 @@ export async function streamAssistantReply(root, messages, systemContent, opts)
114
153
  });
115
154
  for await (const chunk of stream) {
116
155
  const delta = chunk.choices?.[0]?.delta?.content;
117
- if (delta) fullText += delta;
156
+ if (delta) {
157
+ fullText += delta;
158
+ display.setStatus("Responding");
159
+ display.setContent(fullText);
160
+ }
118
161
  }
119
162
  } else {
120
163
  throw new Error("No API key (use ANTHROPIC_API_KEY, OPENAI_API_KEY, or ~/.claude auth)");
121
164
  }
122
165
  } finally {
123
- spinner.stop();
166
+ display.stop();
124
167
  }
125
168
 
126
169
  return fullText;
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import chalk from "chalk";
5
+ import inquirer from "inquirer";
6
+ import { openBrowser } from "./login.js";
7
+
8
+ const CODA_ACCOUNT_URL = "https://coda.io/account";
9
+ const FOPS_CONFIG_PATH = path.join(os.homedir(), ".fops.json");
10
+
11
+ function readFopsConfig() {
12
+ try {
13
+ if (fs.existsSync(FOPS_CONFIG_PATH)) {
14
+ return JSON.parse(fs.readFileSync(FOPS_CONFIG_PATH, "utf8"));
15
+ }
16
+ } catch {}
17
+ return {};
18
+ }
19
+
20
+ function saveFopsConfig(config) {
21
+ fs.writeFileSync(FOPS_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
22
+ }
23
+
24
+ export function resolveCodaApiToken() {
25
+ const envToken = process.env.CODA_API_TOKEN?.trim();
26
+ if (envToken) return envToken;
27
+ try {
28
+ const config = readFopsConfig();
29
+ const token = config.coda?.apiToken?.trim();
30
+ if (token) return token;
31
+ } catch {}
32
+ return null;
33
+ }
34
+
35
+ function saveCodaToken(token) {
36
+ const config = readFopsConfig();
37
+ config.coda = { ...config.coda, apiToken: token.trim() };
38
+ saveFopsConfig(config);
39
+ }
40
+
41
+ async function validateCodaToken(token) {
42
+ try {
43
+ const res = await fetch("https://coda.io/apis/v1/whoami", {
44
+ headers: { Authorization: `Bearer ${token}` },
45
+ });
46
+ if (res.ok) {
47
+ const data = await res.json();
48
+ return { valid: true, name: data.name, loginId: data.loginId };
49
+ }
50
+ if (res.status === 401 || res.status === 403) {
51
+ return { valid: false, error: "Invalid or expired API token." };
52
+ }
53
+ return { valid: false, error: `Coda API returned ${res.status}.` };
54
+ } catch (err) {
55
+ return { valid: false, error: `Could not reach Coda API: ${err.message}` };
56
+ }
57
+ }
58
+
59
+ export async function runCodaLogin() {
60
+ const existing = resolveCodaApiToken();
61
+ if (existing) {
62
+ const masked = existing.slice(0, 8) + "..." + existing.slice(-4);
63
+ const { overwrite } = await inquirer.prompt([{
64
+ type: "confirm",
65
+ name: "overwrite",
66
+ message: `Already have a Coda API token (${masked}). Replace it?`,
67
+ default: false,
68
+ }]);
69
+ if (!overwrite) {
70
+ console.log(chalk.gray("Keeping existing Coda token."));
71
+ return true;
72
+ }
73
+ }
74
+
75
+ console.log("");
76
+ console.log(chalk.bold.cyan(" Coda API Token Setup"));
77
+ console.log("");
78
+ console.log(chalk.white(" To connect Foundation to Coda, you need an API token."));
79
+ console.log(chalk.white(" Here's how to get one:\n"));
80
+ console.log(chalk.gray(" 1. Go to ") + chalk.cyan(CODA_ACCOUNT_URL));
81
+ console.log(chalk.gray(" 2. Scroll to ") + chalk.white("\"API Settings\""));
82
+ console.log(chalk.gray(" 3. Click ") + chalk.white("\"Generate API token\""));
83
+ console.log(chalk.gray(" 4. Give it a name (e.g. \"Foundation CLI\")"));
84
+ console.log(chalk.gray(" 5. Copy the token and paste it below"));
85
+ console.log("");
86
+
87
+ const { openIt } = await inquirer.prompt([{
88
+ type: "confirm",
89
+ name: "openIt",
90
+ message: "Open Coda account page in your browser?",
91
+ default: true,
92
+ }]);
93
+
94
+ if (openIt) {
95
+ const opened = openBrowser(CODA_ACCOUNT_URL);
96
+ if (!opened) {
97
+ console.log(chalk.yellow("\n Could not open browser. Visit: " + CODA_ACCOUNT_URL + "\n"));
98
+ } else {
99
+ console.log("");
100
+ }
101
+ }
102
+
103
+ const { token } = await inquirer.prompt([{
104
+ type: "password",
105
+ name: "token",
106
+ message: "Paste your Coda API token:",
107
+ mask: "*",
108
+ validate: (v) => {
109
+ if (!v?.trim()) return "API token is required.";
110
+ return true;
111
+ },
112
+ }]);
113
+
114
+ console.log(chalk.gray("\n Validating token..."));
115
+ const result = await validateCodaToken(token.trim());
116
+
117
+ if (!result.valid) {
118
+ console.log(chalk.red(`\n ${result.error}`));
119
+ console.log(chalk.gray(" Check the token and try again with: fops login coda\n"));
120
+ return false;
121
+ }
122
+
123
+ saveCodaToken(token);
124
+ console.log(chalk.green(`\n Coda login successful!`));
125
+ console.log(chalk.gray(` Logged in as: ${result.name || result.loginId}`));
126
+ console.log(chalk.gray(" Token saved to ~/.fops.json\n"));
127
+ return true;
128
+ }
package/src/auth/index.js CHANGED
@@ -2,3 +2,4 @@ export { resolveAnthropicApiKey, resolveOpenAiApiKey, readJsonKey } from "./reso
2
2
  export { readClaudeCodeKeychain } from "./keychain.js";
3
3
  export { authHelp, offerClaudeLogin, runLogin } from "./login.js";
4
4
  export { runOAuthLogin } from "./oauth.js";
5
+ export { runCodaLogin, resolveCodaApiToken } from "./coda.js";
@@ -10,7 +10,7 @@ import { runSetup, runInitWizard } from "../setup/index.js";
10
10
  import { ensureEcrAuth } from "../setup/aws.js";
11
11
  import { runAgentSingleTurn, runAgentInteractive } from "../agent/index.js";
12
12
  import { runDoctor } from "../doctor.js";
13
- import { runLogin } from "../auth/index.js";
13
+ import { runLogin, runCodaLogin } from "../auth/index.js";
14
14
  import { runHook, loadSkills } from "../plugins/index.js";
15
15
 
16
16
  export function registerCommands(program, registry) {
@@ -18,10 +18,19 @@ export function registerCommands(program, registry) {
18
18
 
19
19
  program
20
20
  .command("login")
21
- .description("Authenticate with Claude (OAuth login via browser)")
21
+ .description("Authenticate with services (Claude, Coda)")
22
+ .argument("[service]", "Service to login to: claude (default) or coda")
22
23
  .option("--no-browser", "Paste API key in terminal instead of OAuth")
23
- .action(async (opts) => {
24
- await runLogin({ browser: opts.browser });
24
+ .action(async (service, opts) => {
25
+ const target = (service || "claude").toLowerCase();
26
+ if (target === "coda") {
27
+ await runCodaLogin();
28
+ } else if (target === "claude") {
29
+ await runLogin({ browser: opts.browser });
30
+ } else {
31
+ console.error(chalk.red(`Unknown service "${target}". Use: claude, coda`));
32
+ process.exit(1);
33
+ }
25
34
  });
26
35
 
27
36
  program
@@ -82,9 +91,9 @@ export function registerCommands(program, registry) {
82
91
  .action(async (opts) => {
83
92
  const root = requireRoot(program);
84
93
  if (opts.message) {
85
- await runAgentSingleTurn(root, opts.message, { runSuggestions: opts.run !== false, model: opts.model });
94
+ await runAgentSingleTurn(root, opts.message, { runSuggestions: opts.run !== false, model: opts.model, registry });
86
95
  } else {
87
- await runAgentInteractive(root);
96
+ await runAgentInteractive(root, { registry });
88
97
  }
89
98
  });
90
99
 
@@ -97,9 +106,14 @@ export function registerCommands(program, registry) {
97
106
  const root = requireRoot(program);
98
107
  await ensureEcrAuth(root);
99
108
  await runHook(registry, "before:up", { root });
100
- await make(root, "start");
109
+ const result = await make(root, "start");
101
110
  await runHook(registry, "after:up", { root });
102
- if (opts.chat !== false) await runAgentInteractive(root);
111
+ if (result.exitCode !== 0) {
112
+ console.error(chalk.red(`\n Some services failed to start (exit code ${result.exitCode}).`));
113
+ console.error(chalk.gray(" Run `fops doctor` to diagnose or `fops logs` to inspect.\n"));
114
+ process.exit(result.exitCode);
115
+ }
116
+ if (opts.chat !== false) await runAgentInteractive(root, { registry });
103
117
  });
104
118
 
105
119
  program
@@ -107,7 +121,7 @@ export function registerCommands(program, registry) {
107
121
  .description("Interactive AI assistant (same as foundation agent with no -m)")
108
122
  .action(async () => {
109
123
  const root = requireRoot(program);
110
- await runAgentInteractive(root);
124
+ await runAgentInteractive(root, { registry });
111
125
  });
112
126
 
113
127
  program
package/src/doctor.js CHANGED
@@ -116,6 +116,29 @@ export async function runDoctor(opts = {}, registry = null) {
116
116
  // ── Prerequisites ──────────────────────────────────
117
117
  header("Prerequisites");
118
118
 
119
+ // winget (Windows only — needed to install other tools)
120
+ let hasWinget = false;
121
+ if (process.platform === "win32") {
122
+ const wingetVer = await cmdVersion("winget");
123
+ if (wingetVer) {
124
+ ok("winget available", wingetVer);
125
+ hasWinget = true;
126
+ } else {
127
+ fail("winget not found", "needed to install Docker", async () => {
128
+ console.log(chalk.cyan(" ▶ Installing winget via Add-AppxPackage…"));
129
+ await execa("powershell", ["-Command", [
130
+ "$url = (Invoke-RestMethod 'https://api.github.com/repos/microsoft/winget-cli/releases/latest').assets",
131
+ "| Where-Object { $_.name -match '.msixbundle$' } | Select-Object -First 1 -ExpandProperty browser_download_url;",
132
+ "$tmp = Join-Path $env:TEMP 'winget.msixbundle';",
133
+ "Invoke-WebRequest -Uri $url -OutFile $tmp;",
134
+ "Add-AppxPackage -Path $tmp;",
135
+ "Remove-Item $tmp",
136
+ ].join(" ")], { stdio: "inherit", timeout: 120_000 });
137
+ hasWinget = true;
138
+ });
139
+ }
140
+ }
141
+
119
142
  // Docker
120
143
  const dockerVer = await cmdVersion("docker");
121
144
  if (dockerVer) {
@@ -124,12 +147,91 @@ export async function runDoctor(opts = {}, registry = null) {
124
147
  await execa("docker", ["info"], { timeout: 5000 });
125
148
  ok("Docker running", dockerVer);
126
149
  } catch {
127
- fail("Docker daemon not running", "start Docker Desktop or dockerd");
150
+ fail("Docker daemon not running", "start Docker Desktop or dockerd", async () => {
151
+ if (process.platform === "darwin") {
152
+ console.log(chalk.cyan(" ▶ open -a Docker"));
153
+ await execa("open", ["-a", "Docker"], { timeout: 10000 });
154
+ } else if (process.platform === "win32") {
155
+ console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
156
+ await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
157
+ } else {
158
+ console.log(chalk.cyan(" ▶ sudo systemctl start docker"));
159
+ await execa("sudo", ["systemctl", "start", "docker"], { stdio: "inherit", timeout: 30000 });
160
+ return;
161
+ }
162
+ // macOS / Windows: wait for daemon to become ready
163
+ console.log(chalk.gray(" Waiting for Docker daemon to start…"));
164
+ for (let i = 0; i < 30; i++) {
165
+ await new Promise((r) => setTimeout(r, 2000));
166
+ try {
167
+ await execa("docker", ["info"], { timeout: 5000 });
168
+ return;
169
+ } catch {}
170
+ }
171
+ throw new Error("Docker daemon did not start within 60 s");
172
+ });
128
173
  }
129
174
  } else {
130
- fail("Docker not found", "install from docker.com");
175
+ fail("Docker not found", "install from docker.com", async () => {
176
+ if (process.platform === "darwin") {
177
+ let hasBrew = false;
178
+ try { await execa("brew", ["--version"]); hasBrew = true; } catch {}
179
+ if (!hasBrew) {
180
+ console.log(chalk.cyan(" ▶ Installing Homebrew…"));
181
+ await execa("bash", ["-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], {
182
+ stdio: "inherit", timeout: 300_000,
183
+ });
184
+ // Add brew to PATH for Apple Silicon
185
+ const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
186
+ for (const bp of brewPaths) {
187
+ if (fs.existsSync(bp)) {
188
+ const { stdout } = await execa(bp, ["shellenv"], { timeout: 5000 });
189
+ for (const line of stdout.split("\n")) {
190
+ const m = line.match(/export\s+PATH="([^"]+)"/);
191
+ if (m) process.env.PATH = m[1] + ":" + process.env.PATH;
192
+ }
193
+ break;
194
+ }
195
+ }
196
+ hasBrew = true;
197
+ }
198
+ console.log(chalk.cyan(" ▶ brew install --cask docker"));
199
+ await execa("brew", ["install", "--cask", "docker"], { stdio: "inherit", timeout: 300_000 });
200
+ console.log(chalk.cyan(" ▶ open -a Docker"));
201
+ await execa("open", ["-a", "Docker"], { timeout: 10000 });
202
+ } else if (process.platform === "win32") {
203
+ if (!hasWinget) throw new Error("winget is required to install Docker — fix winget first");
204
+ console.log(chalk.cyan(" ▶ winget install Docker.DockerDesktop"));
205
+ await execa("winget", ["install", "Docker.DockerDesktop", "--accept-source-agreements", "--accept-package-agreements"], {
206
+ stdio: "inherit", timeout: 300_000,
207
+ });
208
+ console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
209
+ await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
210
+ } else {
211
+ console.log(chalk.cyan(" ▶ curl -fsSL https://get.docker.com | sudo sh"));
212
+ await execa("sh", ["-c", "curl -fsSL https://get.docker.com | sudo sh"], {
213
+ stdio: "inherit", timeout: 300_000,
214
+ });
215
+ return;
216
+ }
217
+ // macOS / Windows: wait for daemon after install
218
+ console.log(chalk.gray(" Waiting for Docker daemon to start…"));
219
+ for (let i = 0; i < 30; i++) {
220
+ await new Promise((r) => setTimeout(r, 2000));
221
+ try {
222
+ await execa("docker", ["info"], { timeout: 5000 });
223
+ return;
224
+ } catch {}
225
+ }
226
+ throw new Error("Docker daemon did not start within 60 s");
227
+ });
131
228
  }
132
229
 
230
+ // Docker Compose (bundled with Docker Desktop, but verify)
231
+ const composeVer = await cmdVersion("docker", ["compose", "version"]);
232
+ if (composeVer) ok("Docker Compose", composeVer);
233
+ else fail("Docker Compose not found", "included with Docker Desktop — restart Docker or install the compose plugin");
234
+
133
235
  // Git
134
236
  const gitVer = await cmdVersion("git");
135
237
  if (gitVer) ok("Git available", gitVer);
@@ -141,6 +243,11 @@ export async function runDoctor(opts = {}, registry = null) {
141
243
  if (nodeMajor >= 18) ok(`Node.js v${nodeVer}`, ">=18 required");
142
244
  else fail(`Node.js v${nodeVer}`, "upgrade to >=18");
143
245
 
246
+ // Claude CLI (bundled as a dependency)
247
+ const claudeVer = await cmdVersion("claude");
248
+ if (claudeVer) ok("Claude CLI", claudeVer);
249
+ else fail("Claude CLI not found", "run: npm install (included as a dependency)");
250
+
144
251
  // AWS CLI (optional)
145
252
  const awsVer = await cmdVersion("aws");
146
253
  if (awsVer) ok("AWS CLI", awsVer);
@@ -316,21 +423,42 @@ export async function runDoctor(opts = {}, registry = null) {
316
423
 
317
424
  // Host disk available
318
425
  try {
319
- const { stdout: dfHost } = await execa("df", ["-h", "/"], {
320
- timeout: 5000, reject: false,
321
- });
322
- if (dfHost) {
323
- const lines = dfHost.trim().split("\n");
324
- if (lines.length >= 2) {
325
- const cols = lines[lines.length - 1].split(/\s+/);
326
- const size = cols[1];
327
- const avail = cols[3];
328
- const pct = parseInt(cols[4], 10);
329
- if (pct >= 90) fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room");
330
- else if (pct >= 80) warn(`Host disk: ${avail} free of ${size}`, "getting low");
331
- else ok(`Host disk: ${avail} free of ${size}`);
426
+ let size, avail, pct;
427
+ if (process.platform === "win32") {
428
+ const { stdout: wmicOut } = await execa("wmic", ["logicaldisk", "where", "DeviceID='C:'", "get", "Size,FreeSpace", "/format:csv"], {
429
+ timeout: 5000, reject: false,
430
+ });
431
+ if (wmicOut) {
432
+ const parts = wmicOut.trim().split("\n").pop()?.split(",");
433
+ if (parts?.length >= 3) {
434
+ const free = parseInt(parts[1], 10);
435
+ const total = parseInt(parts[2], 10);
436
+ if (total > 0) {
437
+ size = `${(total / (1024 ** 3)).toFixed(0)} GB`;
438
+ avail = `${(free / (1024 ** 3)).toFixed(0)} GB`;
439
+ pct = Math.round(((total - free) / total) * 100);
440
+ }
441
+ }
442
+ }
443
+ } else {
444
+ const { stdout: dfHost } = await execa("df", ["-h", "/"], {
445
+ timeout: 5000, reject: false,
446
+ });
447
+ if (dfHost) {
448
+ const lines = dfHost.trim().split("\n");
449
+ if (lines.length >= 2) {
450
+ const cols = lines[lines.length - 1].split(/\s+/);
451
+ size = cols[1];
452
+ avail = cols[3];
453
+ pct = parseInt(cols[4], 10);
454
+ }
332
455
  }
333
456
  }
457
+ if (size && avail && pct != null) {
458
+ if (pct >= 90) fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room");
459
+ else if (pct >= 80) warn(`Host disk: ${avail} free of ${size}`, "getting low");
460
+ else ok(`Host disk: ${avail} free of ${size}`);
461
+ }
334
462
  } catch {}
335
463
 
336
464
  // Port conflicts
@@ -33,5 +33,14 @@ export function createPluginApi(pluginId, registry) {
33
33
  registerHook(event, handler, priority = 0) {
34
34
  registry.hooks.push({ pluginId, event, handler, priority });
35
35
  },
36
+
37
+ registerKnowledgeSource(source) {
38
+ registry.knowledgeSources.push({
39
+ pluginId,
40
+ name: source.name,
41
+ description: source.description || "",
42
+ search: source.search,
43
+ });
44
+ },
36
45
  };
37
46
  }
@@ -1,3 +1,4 @@
1
1
  export { loadPlugins } from "./loader.js";
2
2
  export { runHook } from "./hooks.js";
3
3
  export { loadSkills } from "./skills.js";
4
+ export { searchKnowledge } from "./knowledge.js";
@@ -0,0 +1,124 @@
1
+ /**
2
+ * RAG knowledge orchestrator.
3
+ * Fans out search queries to all registered knowledge sources,
4
+ * merges results, enforces token budgets, and caches.
5
+ */
6
+
7
+ const MAX_TOTAL_BYTES = 16_384; // ~4000 tokens
8
+ const MAX_PER_SOURCE_BYTES = 8192;
9
+ const MAX_PER_RESULT_BYTES = 4096;
10
+ const SOURCE_TIMEOUT_MS = 5000;
11
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
12
+ const CACHE_MAX_ENTRIES = 50;
13
+
14
+ /** Simple LRU cache keyed by query string. */
15
+ const cache = new Map();
16
+
17
+ function cacheGet(key) {
18
+ const entry = cache.get(key);
19
+ if (!entry) return null;
20
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
21
+ cache.delete(key);
22
+ return null;
23
+ }
24
+ // Move to end (most-recently used)
25
+ cache.delete(key);
26
+ cache.set(key, entry);
27
+ return entry.value;
28
+ }
29
+
30
+ function cacheSet(key, value) {
31
+ // Evict oldest if over capacity
32
+ if (cache.size >= CACHE_MAX_ENTRIES) {
33
+ const oldest = cache.keys().next().value;
34
+ cache.delete(oldest);
35
+ }
36
+ cache.set(key, { value, ts: Date.now() });
37
+ }
38
+
39
+ function truncate(str, maxBytes) {
40
+ if (str.length <= maxBytes) return str;
41
+ return str.slice(0, maxBytes - 3) + "...";
42
+ }
43
+
44
+ /**
45
+ * Search all registered knowledge sources and return formatted context.
46
+ * @param {object} registry — plugin registry
47
+ * @param {string} query — user's message / search query
48
+ * @returns {string|null} formatted knowledge block or null if no results
49
+ */
50
+ export async function searchKnowledge(registry, query) {
51
+ const sources = registry?.knowledgeSources;
52
+ if (!sources?.length || !query?.trim()) return null;
53
+
54
+ const normalizedQuery = query.trim().toLowerCase();
55
+ const cached = cacheGet(normalizedQuery);
56
+ if (cached !== null) return cached;
57
+
58
+ // Fan out to all sources in parallel with individual timeouts
59
+ const sourceResults = await Promise.all(
60
+ sources.map(async (source) => {
61
+ try {
62
+ const results = await Promise.race([
63
+ source.search(query),
64
+ new Promise((_, reject) =>
65
+ setTimeout(() => reject(new Error("timeout")), SOURCE_TIMEOUT_MS),
66
+ ),
67
+ ]);
68
+ if (!Array.isArray(results)) return [];
69
+ return results.map((r) => ({ ...r, _source: source.name }));
70
+ } catch {
71
+ // Skip failing sources silently
72
+ return [];
73
+ }
74
+ }),
75
+ );
76
+
77
+ // Flatten and sort by score (descending), then by source order
78
+ const allResults = sourceResults.flat();
79
+ if (!allResults.length) {
80
+ cacheSet(normalizedQuery, null);
81
+ return null;
82
+ }
83
+
84
+ allResults.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
85
+
86
+ // Enforce per-source budget
87
+ const sourceBytes = {};
88
+ const budgeted = allResults.filter((r) => {
89
+ const src = r._source;
90
+ const used = sourceBytes[src] || 0;
91
+ const content = truncate(r.content || "", MAX_PER_RESULT_BYTES);
92
+ const size = content.length;
93
+ if (used + size > MAX_PER_SOURCE_BYTES) return false;
94
+ sourceBytes[src] = used + size;
95
+ r._truncatedContent = content;
96
+ return true;
97
+ });
98
+
99
+ // Enforce total budget and format
100
+ let totalBytes = 0;
101
+ const sections = [];
102
+
103
+ for (const r of budgeted) {
104
+ const title = r.title || "Untitled";
105
+ const source = r._source || "unknown";
106
+ const url = r.url ? ` (${r.url})` : "";
107
+ const header = `### [${source}] ${title}${url}`;
108
+ const content = r._truncatedContent || "";
109
+ const section = `${header}\n${content}`;
110
+
111
+ if (totalBytes + section.length > MAX_TOTAL_BYTES) break;
112
+ totalBytes += section.length;
113
+ sections.push(section);
114
+ }
115
+
116
+ if (!sections.length) {
117
+ cacheSet(normalizedQuery, null);
118
+ return null;
119
+ }
120
+
121
+ const result = "## Knowledge Base\n\n" + sections.join("\n\n");
122
+ cacheSet(normalizedQuery, result);
123
+ return result;
124
+ }
@@ -10,5 +10,6 @@ export function createRegistry() {
10
10
  doctorChecks: [], // { pluginId, name, fn }
11
11
  hooks: [], // { pluginId, event, handler, priority }
12
12
  skills: [], // { pluginId, name, description, content }
13
+ knowledgeSources: [], // { pluginId, name, description, search }
13
14
  };
14
15
  }