Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/cli/dashboard-observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export function computeHealth(input: {
botRunning: boolean;
modelSet: boolean;
lastRunStatus?: string;
/** Content-grade web-search keys (Firecrawl/Tavily/Brave): how many are set, and the first
* missing one to suggest. Omitted → no badge (old callers unaffected). */
webKeys?: { set: number; total: number; suggest?: { service: string; env: string; url: string } };
}): HealthItem[] {
const cloud = input.providers.filter(p => p.keyEnv);
const ready = cloud.filter(p => p.keySet).length;
Expand All @@ -28,6 +31,16 @@ export function computeHealth(input: {
detail: cloud.length === 0 ? 'all local' : `${ready}/${cloud.length} cloud keys set`,
});
items.push({ label: 'Default model', ok: input.modelSet, detail: input.modelSet ? 'configured' : 'unset — run `qodex setup`' });
if (input.webKeys) {
const w = input.webKeys;
items.push({
label: 'Web search',
ok: w.set > 0,
detail: w.set > 0
? `${w.set}/${w.total} content-grade backend(s) keyed`
: `keyless fallback only — ${w.suggest ? `get a free ${w.suggest.service} key at ${w.suggest.url} (${w.suggest.env})` : 'set a search API key'}`,
});
}
items.push({ label: 'Scheduler', ok: true, detail: `${input.schedulesEnabled} task(s) enabled` });
items.push({ label: 'Bot', ok: true, detail: input.botRunning ? 'running' : 'stopped' });
if (input.lastRunStatus) {
Expand Down
10 changes: 9 additions & 1 deletion src/cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,17 @@ export async function gatherDashboardData(cwd: string): Promise<DashboardData> {
catch { return { running: false }; }
})();
const { computeHealth, tailLines } = await import('./dashboard-observability.js');
const webKeys = await (async () => {
try {
const { webKeyStatus, WEB_SERVICE_KEYS } = await import('../setup/key-guidance.js');
const s = webKeyStatus(process.env as Record<string, string | undefined>);
const first = s.missing[0];
return { set: s.set.length, total: WEB_SERVICE_KEYS.length, suggest: first ? { service: first.service, env: first.env, url: first.url } : undefined };
} catch { return undefined; }
})();
const health = computeHealth({
providers, schedulesEnabled: schedules.filter(s => s.enabled).length, botRunning: bot.running,
modelSet: !!defModel && defModel !== '(unset)', lastRunStatus: runs[0]?.status,
modelSet: !!defModel && defModel !== '(unset)', lastRunStatus: runs[0]?.status, webKeys,
});
const logs = await (async () => {
try { const { QODEX_LOG_FILE } = await import('../config/defaults.js'); return tailLines(await fs.readFile(QODEX_LOG_FILE, 'utf-8'), 40); }
Expand Down
70 changes: 70 additions & 0 deletions src/setup/key-guidance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* API-key guidance — turn "it silently can't search" into an actionable next step.
*
* The failure mode this removes: a user sets up their models, asks for a task that needs web
* search / page content, and the keyed backends (Firecrawl / Tavily / Brave) are skipped because
* no API key is set — the task limps along on the keyless fallback or fails, and the user never
* learns WHY or what to do. Instead, every key-related failure now tells them exactly:
* 1. where to get the key (with the free-tier note),
* 2. how to set it themselves (~/.qodex/.env or a shell export), and
* 3. that they can simply PASTE it in chat — the agent saves it with the `save_api_key` tool
* and continues the task in the same session.
*
* PURE (env passed in) — unit-tested. The registry is the single source of truth reused by the
* backend error messages, the web_search failure summary, and the dashboard health badge.
*/

export interface ServiceKey {
env: string;
service: string;
url: string; // where to get the key
unlocks: string; // what capability it enables, in user terms
freeTier: boolean;
}

/** Keys that unlock web capabilities. Order = recommendation order. */
export const WEB_SERVICE_KEYS: readonly ServiceKey[] = [
{ env: 'FIRECRAWL_API_KEY', service: 'Firecrawl', url: 'https://www.firecrawl.dev', unlocks: 'search with full-page content + the best page extraction', freeTier: true },
{ env: 'TAVILY_API_KEY', service: 'Tavily', url: 'https://app.tavily.com', unlocks: 'fast LLM-grade web search', freeTier: true },
{ env: 'BRAVE_SEARCH_API_KEY', service: 'Brave Search', url: 'https://api-dashboard.search.brave.com', unlocks: 'independent-index web search', freeTier: true },
] as const;

export function findServiceKey(env: string): ServiceKey | undefined {
return WEB_SERVICE_KEYS.find(k => k.env === env);
}

/** One-key guidance block: where to get it + the three ways to set it. PURE. */
export function keyGuidance(env: string): string {
const k = findServiceKey(env);
const from = k ? `Get a key at ${k.url}${k.freeTier ? ' (free tier available)' : ''} — it unlocks ${k.unlocks}.` : `This needs the ${env} environment variable.`;
return [
from,
'Then any of:',
` • paste the key here in chat — I'll store it safely (~/.qodex/.env, chmod 600) and continue your task`,
` • add \`${env}=<key>\` to ~/.qodex/.env yourself`,
` • or \`export ${env}=<key>\` in your shell`,
].join('\n');
}

/** Which web keys are set / missing, given an env-like map. PURE. */
export function webKeyStatus(env: Record<string, string | undefined>): { set: ServiceKey[]; missing: ServiceKey[] } {
const set: ServiceKey[] = [], missing: ServiceKey[] = [];
for (const k of WEB_SERVICE_KEYS) (env[k.env] ? set : missing).push(k);
return { set, missing };
}

/**
* The "your search is running degraded" message: shown when a web task failed (or fell back to
* the keyless engine) while content-grade backends sit unused for lack of a key. PURE.
*/
export function missingWebKeysGuidance(env: Record<string, string | undefined>): string | null {
const { set, missing } = webKeyStatus(env);
if (set.length > 0 || missing.length === 0) return null; // at least one content-grade backend works
const lines = [
'Web search/extract is limited right now: no search API key is set, so only the keyless fallback is available.',
'To unlock a content-grade backend (pick ONE — all have free tiers):',
...missing.map(k => ` • ${k.service}: ${k.url} → ${k.env} (${k.unlocks})`),
'You can paste a key here in chat and I will store it safely (~/.qodex/.env) and retry — or add it to ~/.qodex/.env yourself.',
];
return lines.join('\n');
}
49 changes: 49 additions & 0 deletions src/tools/builtin/save-api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* `save_api_key` — when the user pastes an API key in chat ("here, set it for me"), store it in
* ~/.qodex/.env (chmod 600, atomic, the same place `qodex provider add` uses) AND load it into the
* running process, so the interrupted task can resume in the SAME session without a restart.
*
* Security posture: the value is never echoed back, never logged, and never written to config.yaml
* (secrets live in env, not config). Only the env-var NAME appears in the confirmation.
*/
import { z } from 'zod';
import { Tool, ToolContext, ToolResult } from '../base.js';

const Args = z.object({
env_var: z.string().describe('The environment variable name, e.g. FIRECRAWL_API_KEY or TAVILY_API_KEY.'),
value: z.string().describe('The key value the user provided. Stored in ~/.qodex/.env (0600); never echoed back.'),
});

export class SaveApiKeyTool extends Tool<z.infer<typeof Args>> {
name = 'save_api_key';
description = 'Store an API key the USER just provided in chat (e.g. FIRECRAWL_API_KEY / TAVILY_API_KEY / BRAVE_SEARCH_API_KEY) into ~/.qodex/.env (chmod 600) and load it into this session, so a blocked task (web search, page extraction, a cloud provider) can continue immediately. Use ONLY with a key the user explicitly pasted — never invent or reuse values. The value is never echoed back or logged.';
isReadOnly = false;
isDestructive = false;
argsSchema = Args;

async execute(args: z.infer<typeof Args>, _ctx: ToolContext): Promise<ToolResult> {
const envVar = args.env_var.trim().toUpperCase();
const value = args.value.trim();
if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(envVar)) {
return { content: `[SAVE_KEY_ERROR] "${envVar}" is not a valid environment variable name (A-Z, 0-9, _).`, isError: true };
}
if (!value || value.length < 8) {
return { content: '[SAVE_KEY_ERROR] That does not look like a key (too short). Ask the user to re-paste it.', isError: true };
}
if (/\s/.test(value)) {
return { content: '[SAVE_KEY_ERROR] The value contains whitespace — probably a paste accident. Ask the user to re-paste just the key.', isError: true };
}

const { setEnvKey } = await import('../../setup/env-writer.js');
const file = await setEnvKey(envVar, value);
process.env[envVar] = value; // live for THIS session — the blocked task can retry right away

const { findServiceKey } = await import('../../setup/key-guidance.js');
const svc = findServiceKey(envVar);
const unlocked = svc ? ` ${svc.service} is now available — ${svc.unlocks}.` : '';
return {
content: `✓ ${envVar} saved to ${file} (chmod 600) and loaded into this session.${unlocked} Retry the blocked step now.`,
metadata: { envVar, service: svc?.service },
};
}
}
2 changes: 2 additions & 0 deletions src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import { DiagnosticsTool } from './diagnostics/diagnostics-tool.js';
import { RememberTool, RecallTool, ForgetTool } from './builtin/memory.js';
import { RecallApproachTool } from './builtin/recall-approach.js';
import { SuggestSkillTool } from './builtin/suggest-skill.js';
import { SaveApiKeyTool } from './builtin/save-api-key.js';
import { AddProviderTool } from './builtin/add-provider.js';
// v1.40 — infrastructure tool groups
import { NetworkOptimizeTool } from './network/network-optimize.js';
Expand Down Expand Up @@ -289,6 +290,7 @@ export class ToolRegistry {
new RecallTool(),
new RecallApproachTool(),
new SuggestSkillTool(),
new SaveApiKeyTool(),
new ForgetTool(),
new AddProviderTool(),
new ProjectLogTool(),
Expand Down
3 changes: 2 additions & 1 deletion src/tools/web/brave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export class BraveBackend implements WebSearchBackend {
if (!apiKey) {
throw new WebSearchError(
'Brave backend selected but BRAVE_SEARCH_API_KEY is not set. ' +
'Get a key at https://api.search.brave.com (free tier 2000 q/month).',
'Get a free key at https://api-dashboard.search.brave.com — then paste it in chat (the agent saves it via save_api_key and retries), ' +
'or add BRAVE_SEARCH_API_KEY=... to ~/.qodex/.env.',
this.name,
);
}
Expand Down
5 changes: 3 additions & 2 deletions src/tools/web/firecrawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ export class FirecrawlBackend implements WebSearchBackend {
const apiKey = process.env.FIRECRAWL_API_KEY;
if (!apiKey) {
throw new WebSearchError(
'Firecrawl backend selected but FIRECRAWL_API_KEY is not set in the environment. ' +
'Export FIRECRAWL_API_KEY=fc-... or switch to another backend in your config.',
'Firecrawl backend selected but FIRECRAWL_API_KEY is not set. ' +
'Get a free key at https://www.firecrawl.dev — then paste it in chat (the agent saves it via save_api_key and retries), ' +
'or add FIRECRAWL_API_KEY=fc-... to ~/.qodex/.env.',
this.name,
);
}
Expand Down
5 changes: 3 additions & 2 deletions src/tools/web/tavily.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ export class TavilyBackend implements WebSearchBackend {
const apiKey = process.env.TAVILY_API_KEY;
if (!apiKey) {
throw new WebSearchError(
'Tavily backend selected but TAVILY_API_KEY is not set in the environment. ' +
'Either export TAVILY_API_KEY=... or switch to the duckduckgo backend in your config.',
'Tavily backend selected but TAVILY_API_KEY is not set. ' +
'Get a free key at https://app.tavily.com — then paste it in chat (the agent saves it via save_api_key and retries), ' +
'or add TAVILY_API_KEY=... to ~/.qodex/.env.',
this.name,
);
}
Expand Down
12 changes: 8 additions & 4 deletions src/tools/web/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,23 +149,27 @@ export class WebSearchTool extends Tool<z.infer<typeof WebSearchArgs>> {
}
}

// When failing, tell the user EXACTLY how to unlock a content-grade backend — including that
// they can paste a key in chat and the agent saves it (save_api_key) and retries.
const { missingWebKeysGuidance } = await import('../../setup/key-guidance.js');
const guidance = missingWebKeysGuidance(process.env as Record<string, string | undefined>);
const unlockHint = guidance ? `\n\n${guidance}` : '\n\nRun /network to check connectivity.';

// Every backend errored at the transport level (and none cleanly returned an
// empty result set) → this is a real failure, not just "nothing found".
if (anyTransportError && !anyEmpty) {
return {
isError: true,
content: `[WEB_SEARCH_ERROR] All ${chain.length} backend(s) errored for "${args.query}":\n` +
failures.map(f => ` - ${f}`).join('\n') +
`\n\nRun /network to check connectivity, or set TAVILY_API_KEY / BRAVE_SEARCH_API_KEY for additional backends.`,
failures.map(f => ` - ${f}`).join('\n') + unlockHint,
};
}

// At least one backend reached the service but nothing matched → no results.
return {
content: `[NO_RESULTS] All ${chain.length} backend(s) failed for "${args.query}":\n` +
failures.map(f => ` - ${f}`).join('\n') +
`\n\nTry a different/more specific query, run /network to check connectivity, ` +
`or set TAVILY_API_KEY / BRAVE_SEARCH_API_KEY for additional backends.`,
`\n\nTry a different/more specific query.` + unlockHint,
};
}
}
66 changes: 66 additions & 0 deletions test/key-guidance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { keyGuidance, webKeyStatus, missingWebKeysGuidance, findServiceKey, WEB_SERVICE_KEYS } from '../src/setup/key-guidance.ts';
import { computeHealth } from '../src/cli/dashboard-observability.ts';
import { SaveApiKeyTool } from '../src/tools/builtin/save-api-key.ts';

describe('key guidance — actionable, never a dead end', () => {
it('per-key guidance names the signup URL, the env var, and the paste-in-chat option', () => {
const g = keyGuidance('FIRECRAWL_API_KEY');
expect(g).toContain('firecrawl.dev');
expect(g).toContain('free tier');
expect(g).toContain('FIRECRAWL_API_KEY=');
expect(g).toMatch(/paste the key here in chat/);
expect(g).toContain('~/.qodex/.env');
});

it('webKeyStatus splits set vs missing', () => {
const s = webKeyStatus({ TAVILY_API_KEY: 'tvly-x' });
expect(s.set.map(k => k.env)).toEqual(['TAVILY_API_KEY']);
expect(s.missing).toHaveLength(WEB_SERVICE_KEYS.length - 1);
});

it('missingWebKeysGuidance fires ONLY when no content-grade key is set', () => {
const none = missingWebKeysGuidance({});
expect(none).toMatch(/no search API key is set/);
expect(none).toContain('https://www.firecrawl.dev');
expect(none).toContain('https://app.tavily.com');
expect(none).toMatch(/paste a key here in chat/);
expect(missingWebKeysGuidance({ FIRECRAWL_API_KEY: 'fc-x' })).toBeNull(); // one key → no nag
});

it('findServiceKey resolves known env names', () => {
expect(findServiceKey('TAVILY_API_KEY')!.service).toBe('Tavily');
expect(findServiceKey('NOT_A_KEY')).toBeUndefined();
});
});

describe('health badge — web search readiness', () => {
const base = { providers: [], schedulesEnabled: 0, botRunning: false, modelSet: true };
it('warns with a concrete suggestion when zero keys are set', () => {
const items = computeHealth({ ...base, webKeys: { set: 0, total: 3, suggest: { service: 'Firecrawl', env: 'FIRECRAWL_API_KEY', url: 'https://www.firecrawl.dev' } } });
const w = items.find(i => i.label === 'Web search')!;
expect(w.ok).toBe(false);
expect(w.detail).toContain('keyless fallback only');
expect(w.detail).toContain('firecrawl.dev');
});
it('is green once any key is set; absent input → no badge (back-compat)', () => {
const w = computeHealth({ ...base, webKeys: { set: 1, total: 3 } }).find(i => i.label === 'Web search')!;
expect(w.ok).toBe(true);
expect(computeHealth(base).find(i => i.label === 'Web search')).toBeUndefined();
});
});

describe('save_api_key — input validation (no I/O paths)', () => {
const tool = new SaveApiKeyTool();
it('rejects a bad env-var name, a too-short value, and whitespace pastes', async () => {
expect((await tool.execute({ env_var: 'bad name!', value: 'x'.repeat(20) }, {} as any)).isError).toBe(true);
expect((await tool.execute({ env_var: 'TAVILY_API_KEY', value: 'short' }, {} as any)).isError).toBe(true);
const ws = await tool.execute({ env_var: 'TAVILY_API_KEY', value: 'abc def ghij klmno' }, {} as any);
expect(ws.isError).toBe(true);
expect(ws.content).toMatch(/whitespace/);
});
it('never echoes the value back in any error message', async () => {
const r = await tool.execute({ env_var: 'bad name!', value: 'SUPER-SECRET-VALUE-123' }, {} as any);
expect(r.content).not.toContain('SUPER-SECRET-VALUE-123');
});
});