umbrella-context 0.1.39 → 0.1.41

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.
@@ -1,4 +1,5 @@
1
- import { promises as fs } from "fs";
1
+ import { existsSync, promises as fs } from "fs";
2
+ import os from "os";
2
3
  import path from "path";
3
4
  import { randomUUID } from "crypto";
4
5
  import chalk from "chalk";
@@ -11,6 +12,12 @@ const CONNECTOR_TEMPLATES = [
11
12
  type: "mcp",
12
13
  description: "Connect IDE agents to Umbrella Context through the local MCP server.",
13
14
  },
15
+ {
16
+ key: "claude-desktop-mcp",
17
+ name: "Claude Desktop Connector",
18
+ type: "mcp",
19
+ description: "Connect Claude Desktop to Umbrella Context through its MCP config on macOS or Windows.",
20
+ },
14
21
  {
15
22
  key: "github-review-rule",
16
23
  name: "GitHub Review Rule",
@@ -59,6 +66,20 @@ function buildConnectorAsset(entry) {
59
66
  ],
60
67
  };
61
68
  }
69
+ function getClaudeDesktopConfigPath() {
70
+ if (process.platform === "win32") {
71
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
72
+ const msixDir = path.join(localAppData, "Packages", "Claude_pzs8sxrjxfjjc", "LocalCache", "Roaming", "Claude");
73
+ const baseDir = existsSync(msixDir)
74
+ ? msixDir
75
+ : path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "Claude");
76
+ return path.join(baseDir, "claude_desktop_config.json");
77
+ }
78
+ if (process.platform === "darwin") {
79
+ return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
80
+ }
81
+ return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "Claude", "claude_desktop_config.json");
82
+ }
62
83
  async function runConnectorCheck(entry, cwd = process.cwd()) {
63
84
  const { repoRoot } = await getRepoContext(cwd);
64
85
  if (entry.source === "codex-mcp") {
@@ -80,6 +101,25 @@ async function runConnectorCheck(entry, cwd = process.cwd()) {
80
101
  };
81
102
  }
82
103
  }
104
+ if (entry.source === "claude-desktop-mcp") {
105
+ const filePath = getClaudeDesktopConfigPath();
106
+ try {
107
+ const parsed = JSON.parse(await fs.readFile(filePath, "utf8"));
108
+ const configured = Boolean(parsed?.mcpServers?.["umbrella-context"]);
109
+ return {
110
+ status: configured ? "success" : "failure",
111
+ summary: configured
112
+ ? `${entry.name} verified that Claude Desktop is pointing at umbrella-context mcp.`
113
+ : `${entry.name} could not find an umbrella-context MCP entry in Claude Desktop's config.`,
114
+ };
115
+ }
116
+ catch {
117
+ return {
118
+ status: "failure",
119
+ summary: `${entry.name} could not read Claude Desktop's config file yet.`,
120
+ };
121
+ }
122
+ }
83
123
  if (entry.source === "github-review-rule") {
84
124
  return {
85
125
  status: "success",
@@ -214,6 +254,29 @@ function buildConnectorFiles(entry) {
214
254
  },
215
255
  ];
216
256
  }
257
+ if (entry.key === "claude-desktop-mcp") {
258
+ return [
259
+ {
260
+ name: "claude-desktop-connector.md",
261
+ content: [
262
+ "# Claude Desktop Connector",
263
+ "",
264
+ "This connector adds `umbrella-context mcp` to Claude Desktop's MCP config.",
265
+ "",
266
+ "## What it should change",
267
+ "",
268
+ "- Write or update Claude Desktop's `claude_desktop_config.json` file",
269
+ "- Add an `umbrella-context` MCP server entry",
270
+ "",
271
+ "## Important restart step",
272
+ "",
273
+ "Fully quit Claude Desktop from the tray or menu bar, then reopen it.",
274
+ "A normal window close is not enough for MCP changes to reload.",
275
+ "",
276
+ ].join("\n"),
277
+ },
278
+ ];
279
+ }
217
280
  if (entry.key === "github-review-rule") {
218
281
  return [
219
282
  {
@@ -294,6 +357,29 @@ async function installUmbrellaMcpIntoRepo(cwd = process.cwd()) {
294
357
  await fs.writeFile(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
295
358
  return filePath;
296
359
  }
360
+ async function installUmbrellaMcpIntoClaudeDesktop() {
361
+ const filePath = getClaudeDesktopConfigPath();
362
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
363
+ let current = { mcpServers: {} };
364
+ try {
365
+ current = JSON.parse(await fs.readFile(filePath, "utf8"));
366
+ }
367
+ catch {
368
+ current = { mcpServers: {} };
369
+ }
370
+ const next = {
371
+ ...current,
372
+ mcpServers: {
373
+ ...(current.mcpServers ?? {}),
374
+ "umbrella-context": {
375
+ command: "umbrella-context",
376
+ args: ["mcp"],
377
+ },
378
+ },
379
+ };
380
+ await fs.writeFile(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
381
+ return filePath;
382
+ }
297
383
  async function getConnectorOutputSummary(source, cwd = process.cwd()) {
298
384
  const { repoRoot } = await getRepoContext(cwd);
299
385
  const connectorDir = path.join(repoRoot, ".um", "connectors", source);
@@ -466,6 +552,9 @@ export async function connectorsCommandAction(action, target) {
466
552
  if (selected.key === "codex-mcp") {
467
553
  repoConfigPath = await installUmbrellaMcpIntoRepo();
468
554
  }
555
+ else if (selected.key === "claude-desktop-mcp") {
556
+ repoConfigPath = await installUmbrellaMcpIntoClaudeDesktop();
557
+ }
469
558
  await recordSessionEvent({
470
559
  kind: "connector",
471
560
  title: `${selected.name} was already installed`,
@@ -510,6 +599,9 @@ export async function connectorsCommandAction(action, target) {
510
599
  if (selected.key === "codex-mcp") {
511
600
  repoConfigPath = await installUmbrellaMcpIntoRepo();
512
601
  }
602
+ else if (selected.key === "claude-desktop-mcp") {
603
+ repoConfigPath = await installUmbrellaMcpIntoClaudeDesktop();
604
+ }
513
605
  await recordSessionEvent({
514
606
  kind: "connector",
515
607
  title: `Installed connector ${selected.name}`,
@@ -526,6 +618,9 @@ export async function connectorsCommandAction(action, target) {
526
618
  if (repoConfigPath) {
527
619
  console.log(chalk.gray(` Updated repo MCP config at ${repoConfigPath}`));
528
620
  }
621
+ if (selected.key === "claude-desktop-mcp") {
622
+ console.log(chalk.yellow(" Fully quit Claude Desktop from the tray or menu bar, then reopen it so the new MCP config is picked up."));
623
+ }
529
624
  return {
530
625
  action: "install",
531
626
  changed: true,
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { configManager } from "../config.js";
3
+ import { refreshAdaptiveKnowledge } from "../adaptive/runtime.js";
3
4
  import { addPendingMemory, completeTask, createTask, ensureRepoContext, getPendingMemories, getRepoContext, recordSessionCuration, recordSessionEvent, setSessionPanel, } from "../repo-state.js";
4
5
  const RELATIVE_TIME_PATTERN = /^(\d+)(m|h|d|w)$/;
5
6
  function printCurateResult(format, payload) {
@@ -105,6 +106,7 @@ export async function curateCommandAction(content, opts = {}) {
105
106
  focus: entry.id,
106
107
  status: "success",
107
108
  });
109
+ await refreshAdaptiveKnowledge();
108
110
  await completeTask(task.id, "completed", `Saved local draft ${entry.id} in .um.`);
109
111
  printCurateResult(format, {
110
112
  ok: true,
@@ -20,6 +20,7 @@ export async function logoutCommandAction(force = false) {
20
20
  }
21
21
  }
22
22
  configManager.clearUmbrellaSession();
23
+ configManager.clearAuthSnapshot();
23
24
  console.log(chalk.green("\n This device is now signed out of Umbrella Context."));
24
25
  console.log(chalk.gray(' Your local providers and hub registries were kept on this device.'));
25
26
  console.log(chalk.gray(' Run "umbrella-context setup" to connect again.'));
@@ -1,12 +1,18 @@
1
1
  import chalk from "chalk";
2
2
  import prompts from "prompts";
3
3
  import { configManager } from "../config.js";
4
+ import { getProviderChoice } from "../adapters/umbrella-provider-runtime.js";
4
5
  import { getSessionState, recordSessionEvent, recordSessionModel, setSessionPanel } from "../repo-state.js";
5
6
  const MODELS_BY_PROVIDER = {
6
- anthropic: ["claude-3-7-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku"],
7
- openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-4.1"],
7
+ anthropic: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"],
8
+ openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5", "gpt-4.1", "o4-mini"],
8
9
  google: ["gemini-2.5-pro", "gemini-2.5-flash"],
9
- openrouter: ["openai/gpt-4.1", "anthropic/claude-3.7-sonnet", "google/gemini-2.5-pro"],
10
+ openrouter: ["openai/gpt-5", "anthropic/claude-sonnet-4-6", "google/gemini-2.5-pro", "custom-model"],
11
+ minimax: ["MiniMax-M2.7", "MiniMax-M2.5", "custom-model"],
12
+ groq: ["custom-model"],
13
+ together: ["custom-model"],
14
+ deepseek: ["deepseek-chat", "deepseek-reasoner", "custom-model"],
15
+ fireworks: ["custom-model"],
10
16
  "openai-compatible": ["custom-model"],
11
17
  };
12
18
  function getActiveProvider() {
@@ -18,7 +24,11 @@ export function getModelsForActiveProvider() {
18
24
  const provider = getActiveProvider();
19
25
  if (!provider)
20
26
  return [];
21
- return MODELS_BY_PROVIDER[provider.kind] ?? ["custom-model"];
27
+ const builtInModels = MODELS_BY_PROVIDER[provider.kind] ?? getProviderChoice(provider.kind)?.exampleModels ?? ["custom-model"];
28
+ if (provider.name && configManager.config?.activeModel && !builtInModels.includes(configManager.config.activeModel)) {
29
+ return [...builtInModels, configManager.config.activeModel];
30
+ }
31
+ return builtInModels;
22
32
  }
23
33
  function resolveModel(models, target) {
24
34
  if (!target)
@@ -184,7 +194,10 @@ export async function modelCommandAction(action, target) {
184
194
  type: "select",
185
195
  name: "value",
186
196
  message: `Choose the active model for ${provider.name}`,
187
- choices: models.map((model) => ({ title: model, value: model })),
197
+ choices: [
198
+ ...models.map((model) => ({ title: model, value: model })),
199
+ { title: "Custom model ID…", value: "__custom__" },
200
+ ],
188
201
  });
189
202
  if (!answer.value) {
190
203
  console.log(chalk.yellow("\n No model selected."));
@@ -192,6 +205,18 @@ export async function modelCommandAction(action, target) {
192
205
  }
193
206
  selectedModel = answer.value;
194
207
  }
208
+ if (selectedModel === "__custom__") {
209
+ const customAnswer = await prompts({
210
+ type: "text",
211
+ name: "value",
212
+ message: `Type the exact model ID for ${provider.name}`,
213
+ });
214
+ if (!customAnswer.value?.trim()) {
215
+ console.log(chalk.yellow("\n No model selected."));
216
+ return;
217
+ }
218
+ selectedModel = customAnswer.value.trim();
219
+ }
195
220
  if (!selectedModel) {
196
221
  console.log(chalk.yellow("\n No model selected."));
197
222
  return;
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import prompts from "prompts";
3
3
  import { configManager } from "../config.js";
4
- import { connectSavedProviderFromDraft, createEmptyProviderConnectDraft, getActiveSavedProvider, getProviderRuntimeReadiness, inspectRecentSavedProvider, inspectSavedProvider, resolveSavedProvider, switchActiveSavedProvider, testActiveSavedProviderRuntime, disconnectSavedProvider, UMBRELLA_PROVIDER_CHOICES, } from "../adapters/umbrella-provider-runtime.js";
4
+ import { connectSavedProviderFromDraft, createEmptyProviderConnectDraft, getActiveSavedProvider, getProviderChoice, getProviderRuntimeReadiness, inspectRecentSavedProvider, inspectSavedProvider, resolveSavedProvider, switchActiveSavedProvider, testActiveSavedProviderRuntime, disconnectSavedProvider, UMBRELLA_PROVIDER_CHOICES, } from "../adapters/umbrella-provider-runtime.js";
5
5
  import { recordSessionEvent, setSessionPanel } from "../repo-state.js";
6
6
  import { modelCommandAction } from "./model.js";
7
7
  function printProviders(providers, activeProvider) {
@@ -264,12 +264,17 @@ export async function providersCommandAction(action, target) {
264
264
  type: "select",
265
265
  name: "kind",
266
266
  message: "Choose a provider",
267
- choices: UMBRELLA_PROVIDER_CHOICES.map((entry) => ({ title: entry.name, value: entry.kind })),
267
+ choices: UMBRELLA_PROVIDER_CHOICES.map((entry) => ({
268
+ title: entry.name,
269
+ description: entry.description,
270
+ value: entry.kind,
271
+ })),
268
272
  },
269
273
  {
270
274
  type: "text",
271
275
  name: "label",
272
276
  message: "What label should this provider use on this device?",
277
+ initial: (prev) => getProviderChoice(prev)?.suggestedLabel ?? "",
273
278
  },
274
279
  {
275
280
  type: "password",
@@ -277,9 +282,10 @@ export async function providersCommandAction(action, target) {
277
282
  message: "Paste the provider API key (stored only on this device)",
278
283
  },
279
284
  {
280
- type: (prev) => (prev === "openai-compatible" ? "text" : null),
285
+ type: (_prev, values) => getProviderChoice(values.kind)?.requiresBaseUrlInput ? "text" : null,
281
286
  name: "baseUrl",
282
287
  message: "Base URL for the compatible API",
288
+ initial: (_prev, values) => getProviderChoice(values.kind)?.baseUrl ?? "",
283
289
  },
284
290
  ]);
285
291
  if (!answer.kind || !answer.label || !answer.apiKey) {
@@ -1,6 +1,7 @@
1
1
  type OutputFormat = "text" | "json";
2
2
  export declare function searchCommandAction(query: string, opts?: {
3
3
  format?: OutputFormat;
4
+ timeout?: number;
4
5
  }): Promise<void>;
5
6
  export declare function searchCommand(cli: any): void;
6
7
  export {};
@@ -1,11 +1,15 @@
1
1
  import chalk from "chalk";
2
2
  import { configManager } from "../config.js";
3
3
  import { getProviderReadinessSummary } from "./providers.js";
4
+ import { getAdaptiveKnowledgeBoost, recordAdaptiveConsultations, refreshAdaptiveKnowledge, } from "../adaptive/runtime.js";
4
5
  import { addTaskReasoningContent, addTaskToolCall, appendTaskStreamingContent, buildLocalQueryFingerprint, completeTask, createTask, getQueryCacheEntry, getPendingMemories, getPulledMemories, markQueryCacheHit, patchTaskLifecycle, removeTask, recordSessionEvent, recordSessionQuery, setSessionPanel, storeQueryCacheEntry, summarizeLocalMemoryMatches, } from "../repo-state.js";
5
6
  const DIRECT_RESPONSE_SCORE_THRESHOLD = 0.85;
6
7
  const DIRECT_RESPONSE_HIGH_CONFIDENCE_THRESHOLD = 0.93;
7
8
  const DIRECT_RESPONSE_MIN_GAP = 0.08;
8
9
  const SYNTHESIS_CONTEXT_LIMIT = 6;
10
+ const DEFAULT_TIMEOUT_SECONDS = 300;
11
+ const MIN_TIMEOUT_SECONDS = 1;
12
+ const MAX_TIMEOUT_SECONDS = 3600;
9
13
  function printJson(payload) {
10
14
  console.log(JSON.stringify(payload, null, 2));
11
15
  }
@@ -130,9 +134,12 @@ function scoreLocalMatch(entry, query) {
130
134
  }
131
135
  return score;
132
136
  }
133
- function rankLocalMatches(entries, query) {
134
- return [...entries]
135
- .map((entry) => ({ entry, score: scoreLocalMatch(entry, query) }))
137
+ async function rankLocalMatches(entries, query) {
138
+ const scoredEntries = await Promise.all([...entries].map(async (entry) => ({
139
+ entry,
140
+ score: scoreLocalMatch(entry, query) + (await getAdaptiveKnowledgeBoost(entry.id)),
141
+ })));
142
+ return scoredEntries
136
143
  .sort((a, b) => b.score - a.score || b.entry.createdAt.localeCompare(a.entry.createdAt))
137
144
  .map(({ entry, score }) => ({ entry, score }));
138
145
  }
@@ -356,6 +363,16 @@ function extractGoogleText(data) {
356
363
  .join("\n")
357
364
  .trim();
358
365
  }
366
+ function buildTimeoutController(timeoutMs) {
367
+ const controller = new AbortController();
368
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
369
+ return {
370
+ controller,
371
+ dispose() {
372
+ clearTimeout(timeout);
373
+ },
374
+ };
375
+ }
359
376
  function inferAnswerGaps(details, query, fallback) {
360
377
  const normalized = details.toLowerCase();
361
378
  if (normalized.includes("not enough") ||
@@ -396,7 +413,7 @@ function parseStructuredProviderAnswer(rawText) {
396
413
  return null;
397
414
  }
398
415
  }
399
- async function synthesizeAnswerFromProvider(query, localMatches, serverMatches) {
416
+ async function synthesizeAnswerFromProvider(query, localMatches, serverMatches, timeoutMs) {
400
417
  const config = configManager.config;
401
418
  if (!config?.activeProvider || !config.activeModel)
402
419
  return null;
@@ -420,6 +437,7 @@ async function synthesizeAnswerFromProvider(query, localMatches, serverMatches)
420
437
  ].join("\n");
421
438
  let responseText = null;
422
439
  if (provider.kind === "anthropic") {
440
+ const timeout = buildTimeoutController(timeoutMs);
423
441
  const res = await fetch(provider.baseUrl?.replace(/\/+$/, "") || "https://api.anthropic.com/v1/messages", {
424
442
  method: "POST",
425
443
  headers: {
@@ -433,13 +451,16 @@ async function synthesizeAnswerFromProvider(query, localMatches, serverMatches)
433
451
  system: "You answer engineering questions using only the provided saved context. Be concise and do not invent facts.",
434
452
  messages: [{ role: "user", content: userPrompt }],
435
453
  }),
454
+ signal: timeout.controller.signal,
436
455
  });
456
+ timeout.dispose();
437
457
  if (!res.ok)
438
458
  return null;
439
459
  responseText = extractAnthropicText(await res.json());
440
460
  }
441
461
  else if (provider.kind === "google") {
442
462
  const model = encodeURIComponent(config.activeModel);
463
+ const timeout = buildTimeoutController(timeoutMs);
443
464
  const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(provider.apiKey)}`, {
444
465
  method: "POST",
445
466
  headers: { "content-type": "application/json" },
@@ -457,7 +478,9 @@ async function synthesizeAnswerFromProvider(query, localMatches, serverMatches)
457
478
  maxOutputTokens: 220,
458
479
  },
459
480
  }),
481
+ signal: timeout.controller.signal,
460
482
  });
483
+ timeout.dispose();
461
484
  if (!res.ok)
462
485
  return null;
463
486
  responseText = extractGoogleText(await res.json());
@@ -472,6 +495,7 @@ async function synthesizeAnswerFromProvider(query, localMatches, serverMatches)
472
495
  headers["HTTP-Referer"] = "https://github.com/buildingwithai/Umbrella";
473
496
  headers["X-Title"] = "Umbrella Context CLI";
474
497
  }
498
+ const timeout = buildTimeoutController(timeoutMs);
475
499
  const res = await fetch(endpoint, {
476
500
  method: "POST",
477
501
  headers,
@@ -490,7 +514,9 @@ async function synthesizeAnswerFromProvider(query, localMatches, serverMatches)
490
514
  },
491
515
  ],
492
516
  }),
517
+ signal: timeout.controller.signal,
493
518
  });
519
+ timeout.dispose();
494
520
  if (!res.ok)
495
521
  return null;
496
522
  responseText = extractOpenAiStyleText(await res.json());
@@ -687,11 +713,11 @@ async function enrichCachedPayload(query, payload, providerReady) {
687
713
  if (payload.total === 0) {
688
714
  return payload;
689
715
  }
690
- const rankedLocalMatches = rankLocalMatches(payload.localMatches, query);
716
+ const rankedLocalMatches = await rankLocalMatches(payload.localMatches, query);
691
717
  const directAnswer = buildDirectAnswer(query, rankedLocalMatches, payload.serverMatches);
692
718
  const synthesizedAnswer = !directAnswer && providerReady
693
719
  ? (payload.synthesizedAnswer
694
- ?? await synthesizeAnswerFromProvider(query, payload.localMatches, payload.serverMatches))
720
+ ?? await synthesizeAnswerFromProvider(query, payload.localMatches, payload.serverMatches, DEFAULT_TIMEOUT_SECONDS * 1000))
695
721
  : null;
696
722
  const nextProviderRequiredForSynthesis = !directAnswer && payload.total > 0;
697
723
  const nextSuggestions = !directAnswer && nextProviderRequiredForSynthesis && !providerReady
@@ -723,6 +749,8 @@ export async function searchCommandAction(query, opts = {}) {
723
749
  const format = opts.format === "json" ? "json" : "text";
724
750
  const providerReadiness = getProviderReadinessSummary();
725
751
  const providerReady = providerReadiness.providerReady && providerReadiness.modelReady;
752
+ const timeoutSeconds = Math.max(MIN_TIMEOUT_SECONDS, Math.min(MAX_TIMEOUT_SECONDS, Number(opts.timeout ?? DEFAULT_TIMEOUT_SECONDS)));
753
+ const timeoutMs = timeoutSeconds * 1000;
726
754
  if (!config) {
727
755
  if (format === "json") {
728
756
  printJson({ ok: false, action: "query", error: "Not configured. Run: umbrella-context setup" });
@@ -765,6 +793,7 @@ export async function searchCommandAction(query, opts = {}) {
765
793
  try {
766
794
  await setSessionPanel("query", query);
767
795
  await recordSessionQuery(query);
796
+ await refreshAdaptiveKnowledge();
768
797
  const fingerprint = await buildLocalQueryFingerprint();
769
798
  const cached = await getQueryCacheEntry(query, fingerprint);
770
799
  if (cached) {
@@ -786,6 +815,7 @@ export async function searchCommandAction(query, opts = {}) {
786
815
  focus: query,
787
816
  status: "success",
788
817
  });
818
+ await recordAdaptiveConsultations(enrichedPayload.localMatches.slice(0, 3).map((entry) => entry.id));
789
819
  printSearchPayload(format, enrichedPayload, true);
790
820
  return;
791
821
  }
@@ -797,7 +827,7 @@ export async function searchCommandAction(query, opts = {}) {
797
827
  panel: "query",
798
828
  focus: query,
799
829
  });
800
- const rankedLocalMatches = rankLocalMatches(summarizeLocalMemoryMatches([...(await getPendingMemories()), ...(await getPulledMemories())], query), query);
830
+ const rankedLocalMatches = await rankLocalMatches(summarizeLocalMemoryMatches([...(await getPendingMemories()), ...(await getPulledMemories())], query), query);
801
831
  const localMatches = rankedLocalMatches.map(({ entry }) => entry);
802
832
  await addTaskReasoningContent(task.id, `Checked local repo context first and found ${localMatches.length} candidate match${localMatches.length === 1 ? "" : "es"}.`);
803
833
  await addTaskToolCall(task.id, {
@@ -811,9 +841,12 @@ export async function searchCommandAction(query, opts = {}) {
811
841
  reason: "Moved from local scan to shared search.",
812
842
  });
813
843
  await appendTaskStreamingContent(task.id, `local=${localMatches.length};`);
844
+ const timeout = buildTimeoutController(timeoutMs);
814
845
  const res = await fetch(`${config.serverUrl}/api/memories/search?query=${encodeURIComponent(query)}`, {
815
846
  headers: { Authorization: `Bearer ${config.apiKey}` },
847
+ signal: timeout.controller.signal,
816
848
  });
849
+ timeout.dispose();
817
850
  if (!res.ok) {
818
851
  const err = await res.json();
819
852
  await addTaskToolCall(task.id, {
@@ -879,7 +912,7 @@ export async function searchCommandAction(query, opts = {}) {
879
912
  await addTaskReasoningContent(task.id, "Partial context exists, but there is no provider/model ready for a richer synthesized answer yet.");
880
913
  }
881
914
  else {
882
- payload.synthesizedAnswer = await synthesizeAnswerFromProvider(query, localMatches, data.results);
915
+ payload.synthesizedAnswer = await synthesizeAnswerFromProvider(query, localMatches, data.results, timeoutMs);
883
916
  await addTaskReasoningContent(task.id, payload.synthesizedAnswer
884
917
  ? "Partial context existed, and the connected provider synthesized one cleaner answer from the saved matches."
885
918
  : "Partial context exists and a provider is ready, so this query can support richer synthesis in a later pass.");
@@ -903,23 +936,27 @@ export async function searchCommandAction(query, opts = {}) {
903
936
  fingerprint,
904
937
  payload,
905
938
  });
939
+ await recordAdaptiveConsultations(localMatches.slice(0, 3).map((entry) => entry.id));
906
940
  printSearchPayload(format, payload);
907
941
  }
908
942
  catch (err) {
943
+ const message = err?.name === "AbortError"
944
+ ? `Query timed out after ${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}.`
945
+ : err.message;
909
946
  const failedTask = await createTask({
910
947
  kind: "query",
911
948
  title: `Query failed: ${query}`,
912
- detail: err.message,
949
+ detail: message,
913
950
  status: "failed",
914
951
  panel: "query",
915
952
  focus: query,
916
953
  });
917
- await completeTask(failedTask.id, "failed", err.message);
954
+ await completeTask(failedTask.id, "failed", message);
918
955
  if (format === "json") {
919
- printJson({ ok: false, action: "query", error: err.message });
956
+ printJson({ ok: false, action: "query", error: message });
920
957
  return;
921
958
  }
922
- console.log(chalk.red(`\n Error: ${err.message}`));
959
+ console.log(chalk.red(`\n Error: ${message}`));
923
960
  }
924
961
  }
925
962
  export function searchCommand(cli) {
@@ -934,8 +971,10 @@ Bad:
934
971
  - "authentication"
935
972
  - "show me code"`)
936
973
  .option("--format <format>", "Output format (text or json)")
974
+ .option("--timeout <seconds>", "Maximum seconds to wait before query work times out")
937
975
  .example('search "How is authentication implemented?"')
938
976
  .example('search "How does auth work?" --format json')
977
+ .example('search "How does auth work?" --timeout 900')
939
978
  .action(async (query, opts) => {
940
979
  await searchCommandAction(query, opts);
941
980
  });
@@ -25,7 +25,7 @@ function detectMixedSavedConnection(umbrellaUrl, serverUrl) {
25
25
  const backend = new URL(serverUrl);
26
26
  const umbrellaIsLoopback = isLocalOnlyServerUrl(umbrellaUrl);
27
27
  const backendIsLoopback = isLocalOnlyServerUrl(serverUrl);
28
- const differentHosts = umbrella.hostname !== backend.hostname || umbrella.port !== backend.port;
28
+ const differentHosts = umbrella.hostname !== backend.hostname;
29
29
  if (!differentHosts && umbrellaIsLoopback === backendIsLoopback) {
30
30
  return null;
31
31
  }
@@ -0,0 +1,11 @@
1
+ export declare function sourceAddCommandAction(targetPath: string, opts?: {
2
+ alias?: string;
3
+ format?: string;
4
+ }): Promise<void>;
5
+ export declare function sourceListCommandAction(opts?: {
6
+ format?: string;
7
+ }): Promise<void>;
8
+ export declare function sourceRemoveCommandAction(aliasOrPath: string, opts?: {
9
+ format?: string;
10
+ }): Promise<void>;
11
+ export declare function sourceCommand(cli: any): void;