diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 85ae894..ec98595 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Startup no longer blocks on embedding warmup or score recalculation** — the plugin loads immediately and warms up in the background. + +### Fixed + +- **Auto-capture no longer strands prompts in captured=2 state when capture is skipped after claiming** — early returns now release the claim for the next idle cycle. +- **Plugin now implements the opencode `dispose` hook** — all timers, jobs, the web server, and sqlite connections are cleaned up when the host disposes or reloads the plugin. +- **Warmup timeout race no longer triggers an unhandled promise rejection.** +- **Auto-capture and profile learning now wait for opencode provider state instead of racing it at startup.** +- **The forget tool now reports actual deletion failures instead of always claiming success.** + ## [2.23.1] - 2026-09-07 ### Fixed diff --git a/src/index.ts b/src/index.ts index 55a61cf..e34ecaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import type { UserProfileData } from "./services/user-profile/types.js"; import type { SearchResult } from "./services/sqlite/types.js"; import { getLanguageName } from "./services/language-detector.js"; import type { MemoryScope } from "./services/client.js"; +import { setProviderStateInit } from "./services/ai/opencode-provider.js"; async function showToast( ctx: PluginInput, @@ -128,25 +129,35 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem0.plugin.warmedup"); if (!(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) { - try { - const timeoutMs = CONFIG.warmupTimeoutMs ?? 30000; - await Promise.race([ - memoryClient.warmup(), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Warmup timed out after ${timeoutMs}ms`)), timeoutMs) - ), - ]); - (globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true; - } catch (error) { - log("Plugin warmup failed", { error: String(error) }); - if (error instanceof Error && error.message.includes("timed out")) { - embeddingService.embeddingAvailable = false; - embeddingService.isWarmedUp = true; - log( - "Embedding model warmup timed out — marking embeddings unavailable. Searches will use text-only fallback." - ); + void (async () => { + try { + const timeoutMs = CONFIG.warmupTimeoutMs ?? 30000; + let timeoutId: ReturnType | undefined; + try { + await Promise.race([ + memoryClient.warmup(), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Warmup timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }), + ]); + } finally { + clearTimeout(timeoutId); + } + (globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true; + } catch (error) { + log("Plugin warmup failed", { error: String(error) }); + if (error instanceof Error && error.message.includes("timed out")) { + embeddingService.embeddingAvailable = false; + embeddingService.isWarmedUp = true; + log( + "Embedding model warmup timed out — marking embeddings unavailable. Searches will use text-only fallback." + ); + } } - } + })(); } // Notify when a newer release exists (OpenCode pins plugin versions in its @@ -178,9 +189,9 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { } } - // Wire opencode state path and provider list — fire-and-forget to avoid blocking init - // These calls can hang if opencode isn't fully bootstrapped yet - (async () => { + // Wire opencode state path and provider list — fire-and-forget to avoid blocking init. + // Callers await ensureProviderState() before getStatePath(). + const providerStateReady = (async () => { try { const { setStatePath, setConnectedProviders } = await import("./services/ai/opencode-provider.js"); @@ -196,6 +207,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { log("Failed to initialize opencode provider state", { error: String(error) }); } })(); + setProviderStateInit(providerStateReady); if (isConfigured() && CONFIG.webServerEnabled) { startWebServer({ @@ -247,12 +259,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { // Start background memory scoring recalculation if (isConfigured() && CONFIG.memoryScoring.enabled) { startScoringRecalculation(); - // Run one-time recalculation on startup to ensure existing memories are scored - try { - recalculateAllScores(true); - } catch (error) { - log("Initial scoring recalculation failed", { error: String(error) }); - } + void Promise.resolve().then(() => { + try { + recalculateAllScores(true); + } catch (error) { + log("Initial scoring recalculation failed", { error: String(error) }); + } + }); } // Start memory lifecycle job (STM/LTM decay, promotion, archiving) @@ -271,6 +284,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const shutdownHandler = async () => { delete (globalThis as any)[Symbol.for("opencode-mem0.shutdown")]; try { + for (const timer of sessionIdleTimers.values()) { + clearTimeout(timer); + } + sessionIdleTimers.clear(); stopScoringRecalculation(); stopLifecycleJob(); clearInterval(sessionCleanupTimer); @@ -596,7 +613,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { if (!args.memoryId) return JSON.stringify({ success: false, error: "memoryId required" }); const delRes = await memoryClient.deleteMemory(args.memoryId); - return JSON.stringify({ success: delRes.success, message: "Memory removed" }); + return JSON.stringify({ + success: delRes.success, + message: delRes.success ? "Memory removed" : delRes.error || "Memory removal failed", + }); } try { @@ -634,6 +654,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { await handleSessionCompacted(event, ctx, directory); } }, + dispose: shutdownHandler, }; }; diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 6fe9b69..35238fc 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -15,6 +15,16 @@ type FetchInput = string | Request | URL; let _statePath: string | null = null; let _connectedProviders: string[] = []; +let providerStateInit: Promise = Promise.resolve(); + +export function setProviderStateInit(promise: Promise): void { + providerStateInit = promise; +} + +export async function ensureProviderState(): Promise { + await providerStateInit; +} + export function setStatePath(path: string): void { _statePath = path; } diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index 656c80a..72dbc4e 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -131,9 +131,12 @@ export async function performAutoCapture( isCapturing = true; let claimedPromptId: string | null = null; try { + const { ensureProviderState } = await import("./ai/opencode-provider.js"); + await ensureProviderState(); const prompt = userPromptManager.getLastUncapturedPrompt(sessionID); if (!prompt) return; if (!userPromptManager.claimPrompt(prompt.id)) return; + claimedPromptId = prompt.id; const maxRetries = CONFIG.autoCaptureMaxRetries ?? 3; const existingAttempts = userPromptManager.getCaptureAttempts(prompt.id); diff --git a/src/services/memory-conflicts.ts b/src/services/memory-conflicts.ts index 3fe673b..199bcfb 100644 --- a/src/services/memory-conflicts.ts +++ b/src/services/memory-conflicts.ts @@ -41,10 +41,11 @@ const verdictViaOpencode = async ( ): Promise<"yes" | "no" | null> => { if (!CONFIG.opencodeProvider || !CONFIG.opencodeModel) return null; - const { isProviderConnected, getStatePath, generateStructuredOutput } = + const { isProviderConnected, getStatePath, generateStructuredOutput, ensureProviderState } = await import("./ai/opencode-provider.js"); if (!isProviderConnected(CONFIG.opencodeProvider)) return null; + await ensureProviderState(); const schema = z.object({ contradicts: z.enum(["YES", "NO"]), diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index f011fc4..cb81dce 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -101,6 +101,8 @@ export async function performUserProfileLearning( isLearningRunning = true; try { + const { ensureProviderState } = await import("./ai/opencode-provider.js"); + await ensureProviderState(); const threshold = CONFIG.userProfileAnalysisInterval; const maxBatches = CONFIG.userProfileMaxBatchesPerIdle; diff --git a/tests/auto-capture.test.ts b/tests/auto-capture.test.ts index 3941153..20e7445 100644 --- a/tests/auto-capture.test.ts +++ b/tests/auto-capture.test.ts @@ -34,6 +34,7 @@ const mockGetLanguageName = vi.fn().mockReturnValue("English"); const mockIsProviderConnected = vi.fn().mockReturnValue(true); const mockGetStatePath = vi.fn().mockReturnValue("/some/path"); const mockGenerateStructuredOutput = vi.fn(); +const mockEnsureProviderState = vi.fn().mockResolvedValue(undefined); vi.mock("../src/services/tags.js", () => ({ getTags: (...args: any[]) => mockGetTags(...args), @@ -65,6 +66,7 @@ vi.mock("../src/services/ai/opencode-provider.js", () => ({ isProviderConnected: (...args: unknown[]) => mockIsProviderConnected(...args), getStatePath: (...args: unknown[]) => mockGetStatePath(...args), generateStructuredOutput: (...args: unknown[]) => mockGenerateStructuredOutput(...args), + ensureProviderState: (...args: unknown[]) => mockEnsureProviderState(...args), })); vi.mock("../src/services/language-detector.js", () => ({ @@ -132,6 +134,7 @@ describe("auto-capture helpers", () => { mockIsProviderConnected.mockReset().mockReturnValue(true); mockGetStatePath.mockReset().mockReturnValue("/some/path"); mockGenerateStructuredOutput.mockReset(); + mockEnsureProviderState.mockReset().mockResolvedValue(undefined); }); it("acquires mutex and prevents concurrent capture calls", async () => { @@ -329,6 +332,59 @@ describe("auto-capture helpers", () => { expect(mockMemoryClient.addMemory).not.toHaveBeenCalled(); }); + it("releases claim on early return after claimPrompt", async () => { + const prompt = { id: "p1", messageId: "m1", content: "test" }; + let capturedState = 0; + mockUserPromptManager.getLastUncapturedPrompt.mockImplementation(() => + capturedState === 0 ? prompt : null + ); + mockUserPromptManager.claimPrompt.mockImplementation(() => { + if (capturedState !== 0) return false; + capturedState = 2; + return true; + }); + mockUserPromptManager.resetPromptClaim.mockImplementation(() => { + if (capturedState === 2) capturedState = 0; + }); + const ctx = { + client: { + session: { messages: () => ({ data: undefined }) }, + }, + } as any; + + await performAutoCapture(ctx, "sess-1", "/test"); + expect(mockUserPromptManager.resetPromptClaim).toHaveBeenCalledWith("p1"); + + await performAutoCapture(ctx, "sess-1", "/test"); + expect(mockUserPromptManager.getLastUncapturedPrompt).toHaveBeenCalledTimes(2); + expect(mockUserPromptManager.claimPrompt).toHaveBeenCalledTimes(2); + expect(mockUserPromptManager.getLastUncapturedPrompt).toHaveNthReturnedWith(2, prompt); + }); + + it("waits for provider state before capturing", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + mockEnsureProviderState.mockReturnValue(gate); + mockUserPromptManager.getLastUncapturedPrompt.mockReturnValue({ + id: "p1", + messageId: "m1", + content: "test", + }); + mockUserPromptManager.claimPrompt.mockReturnValue(true); + const messages = vi.fn().mockResolvedValue({ data: undefined }); + const ctx = { client: { session: { messages } } } as any; + + const pending = performAutoCapture(ctx, "sess-1", "/test"); + await Promise.resolve(); + expect(messages).not.toHaveBeenCalled(); + + release(); + await expect(pending).resolves.toBeUndefined(); + expect(messages).toHaveBeenCalled(); + }); + it("returns early when AI response has only tool calls with no text", async () => { mockUserPromptManager.getLastUncapturedPrompt.mockReturnValue({ id: "p1", diff --git a/tests/chat-message-mode.test.ts b/tests/chat-message-mode.test.ts index f33201f..3c0e28c 100644 --- a/tests/chat-message-mode.test.ts +++ b/tests/chat-message-mode.test.ts @@ -102,6 +102,8 @@ vi.mock("../src/services/logger.js", () => ({ vi.mock("../src/services/ai/opencode-provider.js", () => ({ setStatePath: vi.fn(), setConnectedProviders: vi.fn(), + setProviderStateInit: vi.fn(), + ensureProviderState: () => Promise.resolve(), })); vi.mock("../src/services/language-detector.js", () => ({ diff --git a/tests/memory-conflicts.test.ts b/tests/memory-conflicts.test.ts index b8db30c..c9a601b 100644 --- a/tests/memory-conflicts.test.ts +++ b/tests/memory-conflicts.test.ts @@ -57,6 +57,7 @@ vi.mock("../src/services/ai/opencode-provider.js", () => ({ isProviderConnected: vi.fn().mockReturnValue(false), getStatePath: vi.fn().mockReturnValue("/tmp/state.json"), generateStructuredOutput: vi.fn(), + ensureProviderState: () => Promise.resolve(), })); vi.mock("../src/services/ai/ai-provider-factory.js", () => ({ diff --git a/tests/plugin-error-handling.test.ts b/tests/plugin-error-handling.test.ts index 689514b..cf9c05f 100644 --- a/tests/plugin-error-handling.test.ts +++ b/tests/plugin-error-handling.test.ts @@ -38,6 +38,8 @@ vi.mock("../src/services/web-server.js", () => ({ vi.mock("../src/services/ai/opencode-provider.js", () => ({ setStatePath: vi.fn(), setConnectedProviders: vi.fn(), + setProviderStateInit: vi.fn(), + ensureProviderState: () => Promise.resolve(), })); vi.mock("../src/services/auto-capture.js", () => ({ @@ -199,4 +201,84 @@ describe("OpenCodeMemPlugin error handling", () => { expect(toastErrors.length).toBeGreaterThanOrEqual(1); expect(toastErrors[0].data?.error).toContain("Takeover toast failed"); }); + + it("warmup timeout race no longer triggers an unhandled promise rejection", async () => { + vi.useFakeTimers(); + const rejections: unknown[] = []; + const onUnhandled = (reason: unknown) => { + rejections.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + const warmupKey = Symbol.for("opencode-mem0.plugin.warmedup"); + delete (globalThis as Record)[warmupKey]; + const timeoutMs = 50; + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + try { + const { memoryClient } = await import("../src/services/client.js"); + const { CONFIG } = await import("../src/config.js"); + (CONFIG as { warmupTimeoutMs: number }).warmupTimeoutMs = timeoutMs; + (memoryClient.warmup as ReturnType).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(), 1)) + ); + + const mockCtx = { + directory: "/test", + client: { + session: { prompt: vi.fn().mockResolvedValue({ success: true }) }, + tui: { showToast: vi.fn().mockResolvedValue(undefined) }, + path: { get: vi.fn().mockResolvedValue({ data: { state: "/test/.opencode" } }) }, + provider: { list: vi.fn().mockResolvedValue({ data: { connected: [] } }) }, + }, + }; + + const pluginPromise = OpenCodeMemPlugin(mockCtx as never); + await Promise.resolve(); + const timeoutCallIndex = setTimeoutSpy.mock.calls.findIndex((call) => call[1] === timeoutMs); + expect(timeoutCallIndex).toBeGreaterThanOrEqual(0); + const timeoutId = setTimeoutSpy.mock.results[timeoutCallIndex]?.value; + + await vi.advanceTimersByTimeAsync(1); + await pluginPromise; + await vi.advanceTimersByTimeAsync(timeoutMs + 50); + await Promise.resolve(); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId); + expect(rejections).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + (globalThis as Record)[warmupKey] = true; + } + }); + + it("factory returns without awaiting warmup", async () => { + const warmupKey = Symbol.for("opencode-mem0.plugin.warmedup"); + delete (globalThis as Record)[warmupKey]; + const { memoryClient } = await import("../src/services/client.js"); + const { CONFIG } = await import("../src/config.js"); + (CONFIG as { warmupTimeoutMs: number }).warmupTimeoutMs = 30000; + (memoryClient.warmup as ReturnType).mockReturnValue(new Promise(() => {})); + + const mockCtx = { + directory: "/test", + client: { + session: { prompt: vi.fn().mockResolvedValue({ success: true }) }, + tui: { showToast: vi.fn().mockResolvedValue(undefined) }, + path: { get: vi.fn().mockResolvedValue({ data: { state: "/test/.opencode" } }) }, + provider: { list: vi.fn().mockResolvedValue({ data: { connected: [] } }) }, + }, + }; + + const plugin = await Promise.race([ + OpenCodeMemPlugin(mockCtx as never), + new Promise((_, reject) => + setTimeout(() => reject(new Error("factory blocked on warmup")), 200) + ), + ]); + expect(typeof plugin.event).toBe("function"); + (globalThis as Record)[warmupKey] = true; + }); }); diff --git a/tests/tool-scope.test.ts b/tests/tool-scope.test.ts index 9827727..5047375 100644 --- a/tests/tool-scope.test.ts +++ b/tests/tool-scope.test.ts @@ -165,4 +165,19 @@ describe("tool memory scope", () => { await memoryTool.execute({ mode: "list" }, { sessionID: "s1" }); expect(lastListScope).toBe("project"); }); + + it("reports actual deletion failures from forget", async () => { + mockClient.deleteMemory = () => ({ success: false, error: "Memory not found" }); + const plugin = await createPlugin(); + const memoryTool = plugin.tool?.memory; + if (!memoryTool) throw new Error("memory tool not available"); + + const result = JSON.parse( + await memoryTool.execute({ mode: "forget", memoryId: "missing" }, { sessionID: "s1" }) + ); + expect(result.success).toBe(false); + expect(result.message).toBe("Memory not found"); + + mockClient.deleteMemory = () => ({ success: true }); + }); }); diff --git a/tests/transcript-idle-wiring.test.ts b/tests/transcript-idle-wiring.test.ts index c204312..bb7f93b 100644 --- a/tests/transcript-idle-wiring.test.ts +++ b/tests/transcript-idle-wiring.test.ts @@ -31,6 +31,8 @@ vi.mock("../src/services/web-server.js", () => ({ vi.mock("../src/services/ai/opencode-provider.js", () => ({ setStatePath: vi.fn(), setConnectedProviders: vi.fn(), + setProviderStateInit: vi.fn(), + ensureProviderState: () => Promise.resolve(), })); const mocks = vi.hoisted(() => ({ @@ -257,4 +259,32 @@ describe("session.idle transcript capture wiring", () => { config.promptRetentionDays = 30; mocks.isServerOwner.mockReturnValue(false); }); + + it("dispose clears idle timers", async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + const plugin = await OpenCodeMemPlugin(makeCtx() as never); + if (!plugin.event) throw new Error("event hook missing"); + const dispose = (plugin as { dispose?: () => Promise }).dispose; + expect(dispose).toEqual(expect.any(Function)); + + await plugin.event({ + event: { type: "session.idle", properties: { sessionID: "sess-dispose" } }, + }); + await dispose!(); + await vi.advanceTimersByTimeAsync(20000); + + expect(mocks.performAutoCapture).not.toHaveBeenCalled(); + expect(mocks.performTranscriptCapture).not.toHaveBeenCalled(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(clearIntervalSpy).toHaveBeenCalled(); + + const { stopScoringRecalculation } = + await import("../src/services/memory-scoring-service.js"); + const { stopLifecycleJob } = await import("../src/services/memory-lifecycle.js"); + const { memoryClient } = await import("../src/services/client.js"); + expect(stopScoringRecalculation).toHaveBeenCalled(); + expect(stopLifecycleJob).toHaveBeenCalled(); + expect(memoryClient.close).toHaveBeenCalled(); + }); }); diff --git a/tests/user-memory-learning.test.ts b/tests/user-memory-learning.test.ts index f10c703..0c9f16f 100644 --- a/tests/user-memory-learning.test.ts +++ b/tests/user-memory-learning.test.ts @@ -62,6 +62,7 @@ vi.mock("../src/services/ai/opencode-provider.js", () => ({ isProviderConnected: () => true, getStatePath: () => "/tmp/test-state", generateStructuredOutput: (...args: unknown[]) => mockGenerateStructuredOutput(...args), + ensureProviderState: () => Promise.resolve(), })); // The REAL user-profile-manager module (no mock): the shared resolver and the