From b83a1e6d895ec93d9a0f1950e7c9d4c3850c79a5 Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:09:07 +0000 Subject: [PATCH 1/6] fix(auto-capture): release prompt claim on early return --- docs/CHANGELOG.md | 4 ++++ src/services/auto-capture.ts | 1 + tests/auto-capture.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 85ae894..4de92e4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [2.23.1] - 2026-09-07 ### Fixed diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index 656c80a..82b85c4 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -134,6 +134,7 @@ export async function performAutoCapture( 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/tests/auto-capture.test.ts b/tests/auto-capture.test.ts index 3941153..8c585dd 100644 --- a/tests/auto-capture.test.ts +++ b/tests/auto-capture.test.ts @@ -329,6 +329,35 @@ 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("returns early when AI response has only tool calls with no text", async () => { mockUserPromptManager.getLastUncapturedPrompt.mockReturnValue({ id: "p1", From 8c515486249cdfae4c908d24a1ab5f583127b5ae Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:10:25 +0000 Subject: [PATCH 2/6] fix(plugin): implement dispose hook and clear all timers --- docs/CHANGELOG.md | 1 + src/index.ts | 5 +++++ tests/transcript-idle-wiring.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4de92e4..ffe2db8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. ## [2.23.1] - 2026-09-07 diff --git a/src/index.ts b/src/index.ts index 55a61cf..dcd81a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,6 +271,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); @@ -634,6 +638,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { await handleSessionCompacted(event, ctx, directory); } }, + dispose: shutdownHandler, }; }; diff --git a/tests/transcript-idle-wiring.test.ts b/tests/transcript-idle-wiring.test.ts index c204312..15fd48b 100644 --- a/tests/transcript-idle-wiring.test.ts +++ b/tests/transcript-idle-wiring.test.ts @@ -257,4 +257,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(); + }); }); From e1b22d8557bd332ac3f54357a154c0585163c13f Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:13:29 +0000 Subject: [PATCH 3/6] fix(plugin): warmup race timer --- docs/CHANGELOG.md | 1 + src/index.ts | 20 +++++++---- tests/plugin-error-handling.test.ts | 52 +++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffe2db8..81f2b58 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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.** ## [2.23.1] - 2026-09-07 diff --git a/src/index.ts b/src/index.ts index dcd81a9..255431f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -130,12 +130,20 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { 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) - ), - ]); + 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) }); diff --git a/tests/plugin-error-handling.test.ts b/tests/plugin-error-handling.test.ts index 689514b..fa8e976 100644 --- a/tests/plugin-error-handling.test.ts +++ b/tests/plugin-error-handling.test.ts @@ -199,4 +199,56 @@ 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; + } + }); }); From 582aa83ca868705f0f2394dae855de59a6640863 Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:15:46 +0000 Subject: [PATCH 4/6] fix(plugin): provider state race --- docs/CHANGELOG.md | 1 + src/index.ts | 8 +++++--- src/services/ai/opencode-provider.ts | 10 ++++++++++ src/services/auto-capture.ts | 2 ++ src/services/memory-conflicts.ts | 3 ++- src/services/user-memory-learning.ts | 2 ++ tests/auto-capture.test.ts | 27 +++++++++++++++++++++++++++ tests/chat-message-mode.test.ts | 2 ++ tests/memory-conflicts.test.ts | 1 + tests/plugin-error-handling.test.ts | 2 ++ tests/transcript-idle-wiring.test.ts | 2 ++ tests/user-memory-learning.test.ts | 1 + 12 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 81f2b58..75ce575 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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.** ## [2.23.1] - 2026-09-07 diff --git a/src/index.ts b/src/index.ts index 255431f..f67869c 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, @@ -186,9 +187,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"); @@ -204,6 +205,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({ 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 82b85c4..72dbc4e 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -131,6 +131,8 @@ 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; 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 8c585dd..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 () => { @@ -358,6 +361,30 @@ describe("auto-capture helpers", () => { 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 fa8e976..69c84d5 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", () => ({ diff --git a/tests/transcript-idle-wiring.test.ts b/tests/transcript-idle-wiring.test.ts index 15fd48b..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(() => ({ 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 From d42275e2f1c414eab85ebc9ea1eb426269763f8a Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:16:41 +0000 Subject: [PATCH 5/6] fix(tools): honest forget failure message --- docs/CHANGELOG.md | 1 + src/index.ts | 5 ++++- tests/tool-scope.test.ts | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 75ce575..1175083 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 diff --git a/src/index.ts b/src/index.ts index f67869c..3f2e6e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -610,7 +610,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 { 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 }); + }); }); From dcfe834b542b583418230586680bcd6c49bafd7a Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:18:04 +0000 Subject: [PATCH 6/6] perf(plugin): non-blocking startup --- docs/CHANGELOG.md | 4 ++ src/index.ts | 67 +++++++++++++++-------------- tests/plugin-error-handling.test.ts | 28 ++++++++++++ 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1175083..ec98595 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,10 @@ 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. diff --git a/src/index.ts b/src/index.ts index 3f2e6e9..e34ecaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,33 +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; - let timeoutId: ReturnType | undefined; + void (async () => { 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." - ); + 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 @@ -257,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) @@ -612,7 +615,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const delRes = await memoryClient.deleteMemory(args.memoryId); return JSON.stringify({ success: delRes.success, - message: delRes.success ? "Memory removed" : (delRes.error || "Memory removal failed"), + message: delRes.success ? "Memory removed" : delRes.error || "Memory removal failed", }); } diff --git a/tests/plugin-error-handling.test.ts b/tests/plugin-error-handling.test.ts index 69c84d5..cf9c05f 100644 --- a/tests/plugin-error-handling.test.ts +++ b/tests/plugin-error-handling.test.ts @@ -253,4 +253,32 @@ describe("OpenCodeMemPlugin error handling", () => { (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; + }); });