From a34bf4f07fa231312c8c2de0cd0890e95d330d7c Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Sat, 25 Jul 2026 21:18:09 +0200 Subject: [PATCH 1/3] fix(profile-learning): stop infinite User Profile Updated loop (#170) Do not persist echoed profile-analysis prompts, ignore idle events from internal capture sessions, and mark batches after update conflicts so the same prompts are not retried forever. Co-authored-by: Cursor --- src/index.ts | 42 +++++++++++++++++++++++++--- src/services/user-memory-learning.ts | 5 +++- tests/plugin-host-config.test.ts | 32 ++++++++++++++++++++- tsconfig.json | 2 +- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index d8df868..e5dc4f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,14 +20,41 @@ import type { MemoryScope } from "./services/client.js"; import { getHostClientConfig } from "./services/ai/opencode-host-config.js"; import { loadOpencodeProvider } from "./services/ai/opencode-provider-loader.js"; +/** Title used for transient structured-output sessions (capture / profile learning). */ +export const INTERNAL_CAPTURE_SESSION_TITLE = "opencode-mem capture"; + export function isStructuredSummaryPromptMessage(userMessage: string): boolean { - // This is the plugin's own structured-summary request. OpenCode echoes it - // through chat.message like a normal user message, but capturing it would - // create self-referential memories about the memory prompt instead of the - // user's conversation. + // This is the plugin's own structured-summary or profile-analysis request. + // OpenCode echoes it through chat.message like a normal user message, but + // capturing it would create self-referential memories / an infinite learning loop. + if (userMessage.includes("# User Profile Analysis")) { + return true; + } return userMessage.includes("Analyze this conversation.") && userMessage.includes('type="skip"'); } +export function isInternalCaptureSessionTitle(title: string | undefined | null): boolean { + return title === INTERNAL_CAPTURE_SESSION_TITLE; +} + +async function isInternalCaptureSession(client: unknown, sessionID: string): Promise { + const sessionGet = (client as { session?: { get?: (args: unknown) => Promise } }) + ?.session?.get; + if (typeof sessionGet !== "function") { + return false; + } + try { + const response = (await sessionGet({ + sessionID, + path: { id: sessionID }, + })) as { data?: { title?: string }; title?: string } | undefined; + const title = response?.data?.title ?? response?.title; + return isInternalCaptureSessionTitle(title); + } catch { + return false; + } +} + export async function configureOpencodeHostTransport(ctx: { readonly client: unknown; readonly serverUrl?: string | URL; @@ -573,6 +600,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const sessionID = event.properties?.sessionID; if (!sessionID) return; + // Transient structured-output sessions must not re-trigger capture/learning + // (that self-schedules an unbounded idle → LLM → idle loop). + if (await isInternalCaptureSession(ctx.client, sessionID)) { + log("Skipping idle processing for internal capture session", { sessionID }); + return; + } + if (idleTimeout) clearTimeout(idleTimeout); idleTimeout = setTimeout(async () => { diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index ebf7244..44ed47c 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -196,10 +196,13 @@ Rules: } if (!success) { + // Mark the batch anyway so the same prompts are not retried forever + // (token burn + repeated toasts on every subsequent idle). log("User profile update conflict: exhausted retries", { profileId: existingProfile?.id, userId, }); + userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id)); return; } @@ -221,7 +224,7 @@ Rules: .showToast({ body: { title: "User Profile Updated", - message: `Analyzed ${prompts.length} prompts and updated your profile`, + message: `Analyzed ${prompts.length} of ${count} pending prompts and updated your profile`, variant: "success", duration: 3000, }, diff --git a/tests/plugin-host-config.test.ts b/tests/plugin-host-config.test.ts index c821e87..672d5dd 100644 --- a/tests/plugin-host-config.test.ts +++ b/tests/plugin-host-config.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "bun:test"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { configureOpencodeHostTransport, isStructuredSummaryPromptMessage } from "../src/index.js"; +import { + configureOpencodeHostTransport, + INTERNAL_CAPTURE_SESSION_TITLE, + isInternalCaptureSessionTitle, + isStructuredSummaryPromptMessage, +} from "../src/index.js"; import { getHostClientConfig } from "../src/services/ai/opencode-host-config.js"; import { createV2Client, @@ -130,9 +135,34 @@ describe("structured summary prompt filter", () => { ).toBe(true); }); + it("identifies the plugin's own user-profile analysis prompt echo", () => { + expect( + isStructuredSummaryPromptMessage( + "# User Profile Analysis\n\nAnalyze 10 user prompts to update the user profile." + ) + ).toBe(true); + }); + it("does not filter ordinary user messages", () => { expect(isStructuredSummaryPromptMessage("Analyze this conversation in the bug report.")).toBe( false ); + expect(isStructuredSummaryPromptMessage("Please update my user profile preferences.")).toBe( + false + ); + }); +}); + +describe("internal capture session title", () => { + it("matches the transient structured-output session title", () => { + expect(isInternalCaptureSessionTitle(INTERNAL_CAPTURE_SESSION_TITLE)).toBe(true); + expect(isInternalCaptureSessionTitle("opencode-mem capture")).toBe(true); + }); + + it("does not match ordinary session titles", () => { + expect(isInternalCaptureSessionTitle("My coding session")).toBe(false); + expect(isInternalCaptureSessionTitle("")).toBe(false); + expect(isInternalCaptureSessionTitle(undefined)).toBe(false); + expect(isInternalCaptureSessionTitle(null)).toBe(false); }); }); diff --git a/tsconfig.json b/tsconfig.json index f835efe..888d707 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "module": "ESNext", "moduleDetection": "force", "allowJs": true, + "types": ["bun-types"], "moduleResolution": "bundler", "allowImportingTsExtensions": false, @@ -30,4 +31,3 @@ "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } - From 29cc6264d82ba66c87a634a33ea198004772f52c Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Sat, 25 Jul 2026 21:21:37 +0200 Subject: [PATCH 2/3] fix(profile-learning): make idle skip work against OpenCode host client Live testing showed session.get must use path-based args; also track internal capture session IDs so post-delete idle events still match. Co-authored-by: Cursor --- src/index.ts | 66 +++++++++++++++----- src/services/ai/internal-capture-sessions.ts | 37 +++++++++++ src/services/ai/opencode-provider.ts | 18 ++++-- tests/internal-capture-sessions.test.ts | 25 ++++++++ 4 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 src/services/ai/internal-capture-sessions.ts create mode 100644 tests/internal-capture-sessions.test.ts diff --git a/src/index.ts b/src/index.ts index e5dc4f9..e75e37d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,13 @@ import type { MemoryScope } from "./services/client.js"; import { getHostClientConfig } from "./services/ai/opencode-host-config.js"; import { loadOpencodeProvider } from "./services/ai/opencode-provider-loader.js"; -/** Title used for transient structured-output sessions (capture / profile learning). */ -export const INTERNAL_CAPTURE_SESSION_TITLE = "opencode-mem capture"; +import { + INTERNAL_CAPTURE_SESSION_TITLE, + isInternalCaptureSessionTitle, + isTrackedInternalCaptureSession, +} from "./services/ai/internal-capture-sessions.js"; + +export { INTERNAL_CAPTURE_SESSION_TITLE, isInternalCaptureSessionTitle }; export function isStructuredSummaryPromptMessage(userMessage: string): boolean { // This is the plugin's own structured-summary or profile-analysis request. @@ -33,26 +38,53 @@ export function isStructuredSummaryPromptMessage(userMessage: string): boolean { return userMessage.includes("Analyze this conversation.") && userMessage.includes('type="skip"'); } -export function isInternalCaptureSessionTitle(title: string | undefined | null): boolean { - return title === INTERNAL_CAPTURE_SESSION_TITLE; +function extractSessionTitle(response: unknown): string | undefined { + if (!response || typeof response !== "object") return undefined; + const obj = response as { + data?: { title?: string }; + title?: string; + }; + return obj.data?.title ?? obj.title; } async function isInternalCaptureSession(client: unknown, sessionID: string): Promise { - const sessionGet = (client as { session?: { get?: (args: unknown) => Promise } }) - ?.session?.get; - if (typeof sessionGet !== "function") { - return false; + // Fast path: sessions we created ourselves (survives brief post-delete window). + if (isTrackedInternalCaptureSession(sessionID)) { + return true; } - try { - const response = (await sessionGet({ - sessionID, - path: { id: sessionID }, - })) as { data?: { title?: string }; title?: string } | undefined; - const title = response?.data?.title ?? response?.title; - return isInternalCaptureSessionTitle(title); - } catch { - return false; + + const sessionClient = ( + client as { + session?: { + get?: (args: unknown) => Promise; + }; + } + )?.session; + + // Plugin host client uses path-based args (same as session.messages). + if (typeof sessionClient?.get === "function") { + try { + const response = await sessionClient.get({ path: { id: sessionID } }); + const title = extractSessionTitle(response); + if (isInternalCaptureSessionTitle(title)) { + return true; + } + log("internal capture session check via session.get", { + sessionID, + title: title ?? null, + matched: false, + }); + } catch (error) { + log("internal capture session check via session.get failed", { + sessionID, + error: String(error), + }); + } + } else { + log("internal capture session check: session.get unavailable", { sessionID }); } + + return false; } export async function configureOpencodeHostTransport(ctx: { diff --git a/src/services/ai/internal-capture-sessions.ts b/src/services/ai/internal-capture-sessions.ts new file mode 100644 index 0000000..df73510 --- /dev/null +++ b/src/services/ai/internal-capture-sessions.ts @@ -0,0 +1,37 @@ +/** Title used for transient structured-output sessions (capture / profile learning). */ +export const INTERNAL_CAPTURE_SESSION_TITLE = "opencode-mem capture"; + +/** Grace period so session.idle can still match after best-effort delete. */ +const UNTRACK_GRACE_MS = 60_000; + +const trackedSessionIDs = new Set(); +const untrackTimers = new Map>(); + +export function isInternalCaptureSessionTitle(title: string | undefined | null): boolean { + return title === INTERNAL_CAPTURE_SESSION_TITLE; +} + +export function trackInternalCaptureSession(sessionID: string): void { + const pending = untrackTimers.get(sessionID); + if (pending) { + clearTimeout(pending); + untrackTimers.delete(sessionID); + } + trackedSessionIDs.add(sessionID); +} + +export function untrackInternalCaptureSession(sessionID: string): void { + const pending = untrackTimers.get(sessionID); + if (pending) { + clearTimeout(pending); + } + const timer = setTimeout(() => { + trackedSessionIDs.delete(sessionID); + untrackTimers.delete(sessionID); + }, UNTRACK_GRACE_MS); + untrackTimers.set(sessionID, timer); +} + +export function isTrackedInternalCaptureSession(sessionID: string): boolean { + return trackedSessionIDs.has(sessionID); +} diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index b6efd2b..1c2a6bc 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -24,6 +24,11 @@ import { responseStatus, type FetchEndpoint, } from "./opencode-diagnostics.js"; +import { + INTERNAL_CAPTURE_SESSION_TITLE, + trackInternalCaptureSession, + untrackInternalCaptureSession, +} from "./internal-capture-sessions.js"; import { createLazyV2Client, type HostTransport } from "./opencode-sdk-client.js"; let _connectedProviders: Set = new Set(); @@ -147,6 +152,8 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< await deleteSession(base, sessionID, directory); } catch { // intentionally swallowed + } finally { + untrackInternalCaptureSession(sessionID); } } } @@ -186,7 +193,7 @@ async function generateViaSdkClient( args: SdkStructuredOutputArgs ): Promise { const createdResponse = await client.session.create({ - title: "opencode-mem capture", + title: INTERNAL_CAPTURE_SESSION_TITLE, ...(args.directory ? { directory: args.directory } : {}), }); const created = readSdkData<{ id?: string }>(createdResponse, "POST /session"); @@ -197,6 +204,7 @@ async function generateViaSdkClient( } const sessionID = created.id; + trackInternalCaptureSession(sessionID); try { const promptResponse = await client.session.prompt({ sessionID, @@ -235,14 +243,15 @@ async function generateViaSdkClient( }); } catch { // Best-effort cleanup for the transient capture session. + } finally { + untrackInternalCaptureSession(sessionID); } } } function readSdkData(response: unknown, label: string): T { const result = response as - | { data?: T; error?: unknown; request?: Request; response?: Response } - | undefined; + { data?: T; error?: unknown; request?: Request; response?: Response } | undefined; if (result?.error !== undefined) { const status = result.response ? ` (${responseStatus(result.response)})` : ""; const responseUrl = result.response?.url || result.request?.url; @@ -287,7 +296,7 @@ async function createSession(base: string, directory?: string): Promise { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "opencode-mem capture" }), + body: JSON.stringify({ title: INTERNAL_CAPTURE_SESSION_TITLE }), } ); if (!body.id) { @@ -295,6 +304,7 @@ async function createSession(base: string, directory?: string): Promise "opencode-mem: session.create returned no session id; cannot generate structured output" ); } + trackInternalCaptureSession(body.id); return body.id; } diff --git a/tests/internal-capture-sessions.test.ts b/tests/internal-capture-sessions.test.ts new file mode 100644 index 0000000..b0e1aa6 --- /dev/null +++ b/tests/internal-capture-sessions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { + INTERNAL_CAPTURE_SESSION_TITLE, + isInternalCaptureSessionTitle, + isTrackedInternalCaptureSession, + trackInternalCaptureSession, + untrackInternalCaptureSession, +} from "../src/services/ai/internal-capture-sessions.js"; + +describe("internal capture session tracking", () => { + it("tracks and untracks session ids with grace", async () => { + const id = "ses_live170_test"; + expect(isTrackedInternalCaptureSession(id)).toBe(false); + trackInternalCaptureSession(id); + expect(isTrackedInternalCaptureSession(id)).toBe(true); + untrackInternalCaptureSession(id); + // Still tracked during grace window + expect(isTrackedInternalCaptureSession(id)).toBe(true); + }); + + it("matches the shared capture title constant", () => { + expect(isInternalCaptureSessionTitle(INTERNAL_CAPTURE_SESSION_TITLE)).toBe(true); + expect(isInternalCaptureSessionTitle("other")).toBe(false); + }); +}); From f3332cf7bf97622bdd7a2ca7bafa6c1cd3f8bb52 Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Sat, 25 Jul 2026 22:13:07 +0200 Subject: [PATCH 3/3] style: format opencode-provider for prettier check Co-authored-by: Cursor --- src/services/ai/opencode-provider.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 1c2a6bc..6ffef88 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -251,7 +251,8 @@ async function generateViaSdkClient( function readSdkData(response: unknown, label: string): T { const result = response as - { data?: T; error?: unknown; request?: Request; response?: Response } | undefined; + | { data?: T; error?: unknown; request?: Request; response?: Response } + | undefined; if (result?.error !== undefined) { const status = result.response ? ` (${responseStatus(result.response)})` : ""; const responseUrl = result.response?.url || result.request?.url;