diff --git a/src/index.ts b/src/index.ts index d8df868..e75e37d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,14 +20,73 @@ 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"; +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 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"'); } +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 { + // Fast path: sessions we created ourselves (survives brief post-delete window). + if (isTrackedInternalCaptureSession(sessionID)) { + return true; + } + + 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: { readonly client: unknown; readonly serverUrl?: string | URL; @@ -573,6 +632,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/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..6ffef88 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,6 +243,8 @@ async function generateViaSdkClient( }); } catch { // Best-effort cleanup for the transient capture session. + } finally { + untrackInternalCaptureSession(sessionID); } } } @@ -287,7 +297,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 +305,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/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/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); + }); +}); 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"] } -