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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.

Expand Down
15 changes: 6 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions src/services/auto-capture-config-status.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
};
};

/**
* 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(() => {});
}
149 changes: 149 additions & 0 deletions tests/auto-capture-config-toast.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading