diff --git a/README.md b/README.md index 3f6dbec4..8ac225f6 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,11 @@ Optionally pin a model reasoning variant with `"opencodeVariant": "xhigh"` (e.g. Slow reasoning models (or very large profile prompts) can exceed the default 90 s structured-output budget. Set `"opencodeTimeoutMs"` (milliseconds) to extend it for internal structured-output calls (auto-capture summaries, profile learning) — e.g. `"opencodeTimeoutMs": 180000` for 3 minutes. Values are clamped to 10000..600000; the default stays 90000. Numeric strings are coerced. Profile cleanup uses a separate, longer timeout. -Supported providers: any provider listed by `opencode providers list` (e.g. `anthropic`, `openai`, `github-copilot`, ...). +Supported providers: any provider listed by `opencode providers list` (e.g. `anthropic`, `openai`, `github-copilot`, `openrouter`, ...). + +**Built-in Free / Zen models** under provider id `opencode` (e.g. `big-pickle`) often cannot run the plugin’s internal structured-output sessions — OpenCode may reject them outside interactive chat. Prefer an authenticated provider from `opencode providers list`, or use `"opencodeModel": "inherit"` so capture follows the model you already use in the session. + +If `autoCaptureEnabled` is `true` (default) but neither `opencodeProvider`/`opencodeModel` nor a complete manual fallback is configured, the plugin logs a warning and shows a startup toast (when `showErrorToasts` is enabled) so missing config is visible without digging through logs. If `opencodeProvider` and `opencodeModel` are set, they take precedence over the manual `memoryProvider` settings below by default (`"autoCaptureProviderOrder": "opencode-first"`). Set `"autoCaptureProviderOrder": "external-first"` to prefer the manual API when both are configured (useful when the host session API is busy/slow); the other channel is used as fallback. Lower `"opencodeTimeoutMs"` to fail over from the opencode channel sooner. diff --git a/src/config.ts b/src/config.ts index 51e90cea..f4df330d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -485,6 +485,12 @@ const CONFIG_TEMPLATE = `{ // Anthropic (OAuth/API key): "opencodeProvider": "anthropic", "opencodeModel": "claude-haiku-4-5-20251001" // OpenAI (API key): "opencodeProvider": "openai", "opencodeModel": "gpt-4o-mini" // GitHub Copilot: "opencodeProvider": "github-copilot", "opencodeModel": "gpt-4o-mini" + // OpenRouter: "opencodeProvider": "openrouter", "opencodeModel": "…" + // + // Avoid pinning the built-in Free provider "opencode" (e.g. big-pickle) for + // auto-capture: those models are often restricted to interactive OpenCode + // chat and fail on the plugin's internal structured-output sessions. Prefer + // an authenticated provider above, or "opencodeModel": "inherit". // // "opencodeProvider": "anthropic", // "opencodeModel": "claude-haiku-4-5-20251001", @@ -502,6 +508,8 @@ const CONFIG_TEMPLATE = `{ // IMPORTANT: Auto-capture only runs after either opencodeProvider/opencodeModel // above is configured, or the manual fallback below is uncommented with real values. + // If autoCaptureEnabled is true but neither path is ready, the plugin shows a + // startup warning toast (when showErrorToasts is true). // It runs in background without blocking your main session // Note: Ollama may not support tool calling. Use OpenAI, Anthropic, or Groq for best results. diff --git a/src/index.ts b/src/index.ts index 0480bfcf..6fef6a2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,8 +35,13 @@ import { isInternalCaptureSessionTitle, isTrackedInternalCaptureSession, } from "./services/ai/internal-capture-sessions.js"; +import { notifyAutoCaptureProviderStatus } from "./services/auto-capture-config-status.js"; export { INTERNAL_CAPTURE_SESSION_TITLE, isInternalCaptureSessionTitle }; +export { + buildAutoCaptureConfigReminderMessage, + notifyAutoCaptureProviderStatus, +} from "./services/auto-capture-config-status.js"; export function isStructuredSummaryPromptMessage(userMessage: string): boolean { // This is the plugin's own structured-summary or profile-analysis request. @@ -351,18 +356,10 @@ export async function configureOpencodeHostTransport(ctx: { } } -function logAutoCaptureProviderStatus(): void { - if (!CONFIG.autoCaptureEnabled || CONFIG.autoCaptureProviderStatus.ready) return; - - log( - `Auto-capture disabled by configuration. Issues: ${CONFIG.autoCaptureProviderStatus.issues.join("; ")}.` - ); -} - export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const { directory } = ctx; initConfig(directory); - logAutoCaptureProviderStatus(); + notifyAutoCaptureProviderStatus(ctx.client); const { startAutoUpdate } = await import("./services/auto-update.js"); startAutoUpdate(ctx, CONFIG.autoUpdate); const tags = getTags(directory); diff --git a/src/services/auto-capture-config-status.ts b/src/services/auto-capture-config-status.ts new file mode 100644 index 00000000..0a86de6d --- /dev/null +++ b/src/services/auto-capture-config-status.ts @@ -0,0 +1,44 @@ +import { CONFIG } from "../config.js"; +import { log } from "./logger.js"; + +/** Toast/log copy when auto-capture is on but no provider is ready (#424). */ +export function buildAutoCaptureConfigReminderMessage(issues: string[]): string { + const issueText = issues.length > 0 ? ` Issues: ${issues.join("; ")}.` : ""; + return ( + "Auto-capture is enabled but no AI provider is configured. " + + "Set opencodeProvider + opencodeModel (or memoryModel/memoryApiUrl/memoryApiKey), " + + `or set autoCaptureEnabled: false.${issueText}` + ); +} + +/** Minimal toast client shape; accepts OpenCode's OpencodeClient.tui. */ +type AutoCaptureStatusToastClient = { + tui?: { + // OpenCode's SDK typing for showToast is version-dependent; keep loose. + showToast?: (input: any) => Promise; + }; +}; + +/** + * Log (and optionally toast) when auto-capture cannot run due to missing + * provider config. Toast is gated by showErrorToasts like other error notices. + */ +export function notifyAutoCaptureProviderStatus(client?: AutoCaptureStatusToastClient): void { + if (!CONFIG.autoCaptureEnabled || CONFIG.autoCaptureProviderStatus.ready) return; + + const message = buildAutoCaptureConfigReminderMessage(CONFIG.autoCaptureProviderStatus.issues); + log(`Auto-capture disabled by configuration. ${message}`); + + if (!CONFIG.showErrorToasts || !client?.tui?.showToast) return; + + client.tui + .showToast({ + body: { + title: "Auto-Capture", + message, + variant: "warning", + duration: 8000, + }, + }) + .catch(() => {}); +} diff --git a/tests/auto-capture-config-toast.test.ts b/tests/auto-capture-config-toast.test.ts new file mode 100644 index 00000000..50f17bac --- /dev/null +++ b/tests/auto-capture-config-toast.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildAutoCaptureConfigReminderMessage } from "../src/services/auto-capture-config-status.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("buildAutoCaptureConfigReminderMessage", () => { + it("mentions config keys and includes issues", () => { + const message = buildAutoCaptureConfigReminderMessage([ + "opencodeProvider is not configured", + "opencodeModel is not configured", + ]); + expect(message).toContain("opencodeProvider + opencodeModel"); + expect(message).toContain("autoCaptureEnabled: false"); + expect(message).toContain("opencodeProvider is not configured"); + expect(message).toContain("opencodeModel is not configured"); + }); +}); + +describe("notifyAutoCaptureProviderStatus", () => { + const statusUrl = new URL("../src/services/auto-capture-config-status.js", import.meta.url).href; + const configUrl = new URL("../src/config.js", import.meta.url).href; + const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; + + function runScenario(opts: { + autoCaptureEnabled: boolean; + ready: boolean; + issues?: string[]; + showErrorToasts: boolean; + withToastClient: boolean; + }) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-capture-toast-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + const script = ` +import { mock } from "bun:test"; + +const logs = []; +const toasts = []; + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { + autoCaptureEnabled: ${JSON.stringify(opts.autoCaptureEnabled)}, + showErrorToasts: ${JSON.stringify(opts.showErrorToasts)}, + autoCaptureProviderStatus: ${JSON.stringify( + opts.ready + ? { ready: true, mode: "opencode", issues: [] } + : { ready: false, issues: opts.issues ?? ["opencodeProvider is not configured"] } + )}, + }, +})); + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ + log: (message) => logs.push(message), +})); + +const { notifyAutoCaptureProviderStatus } = await import(${JSON.stringify(statusUrl)}); + +const client = ${ + opts.withToastClient + ? `{ + tui: { + showToast: async (input) => { + toasts.push(input); + return {}; + }, + }, +}` + : "undefined" + }; + +notifyAutoCaptureProviderStatus(client); +await new Promise((r) => setTimeout(r, 10)); + +console.log(JSON.stringify({ logs, toasts })); +`; + writeFileSync(scriptPath, script); + const proc = Bun.spawnSync(["bun", "run", scriptPath], { + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + if (proc.exitCode !== 0) { + throw new Error(`scenario failed: ${proc.stderr.toString() || proc.stdout.toString()}`); + } + return JSON.parse(proc.stdout.toString()) as { + logs: string[]; + toasts: Array<{ body: { title: string; message: string; variant: string } }>; + }; + } + + it("toasts and logs when auto-capture is on without a provider", () => { + const result = runScenario({ + autoCaptureEnabled: true, + ready: false, + issues: ["opencodeProvider is not configured", "opencodeModel is not configured"], + showErrorToasts: true, + withToastClient: true, + }); + expect(result.logs.some((m) => m.includes("Auto-capture disabled by configuration"))).toBe( + true + ); + expect(result.toasts).toHaveLength(1); + expect(result.toasts[0]?.body.title).toBe("Auto-Capture"); + expect(result.toasts[0]?.body.variant).toBe("warning"); + expect(result.toasts[0]?.body.message).toContain("opencodeProvider + opencodeModel"); + }); + + it("skips toast when showErrorToasts is false but still logs", () => { + const result = runScenario({ + autoCaptureEnabled: true, + ready: false, + showErrorToasts: false, + withToastClient: true, + }); + expect(result.logs.length).toBeGreaterThan(0); + expect(result.toasts).toHaveLength(0); + }); + + it("does nothing when provider status is ready", () => { + const result = runScenario({ + autoCaptureEnabled: true, + ready: true, + showErrorToasts: true, + withToastClient: true, + }); + expect(result.logs).toHaveLength(0); + expect(result.toasts).toHaveLength(0); + }); + + it("does nothing when auto-capture is disabled", () => { + const result = runScenario({ + autoCaptureEnabled: false, + ready: false, + showErrorToasts: true, + withToastClient: true, + }); + expect(result.logs).toHaveLength(0); + expect(result.toasts).toHaveLength(0); + }); +});