Skip to content
Open
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
74 changes: 70 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
// 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<unknown>;
};
}
)?.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;
Expand Down Expand Up @@ -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 () => {
Expand Down
37 changes: 37 additions & 0 deletions src/services/ai/internal-capture-sessions.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
const untrackTimers = new Map<string, ReturnType<typeof setTimeout>>();

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);
}
15 changes: 13 additions & 2 deletions src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set();
Expand Down Expand Up @@ -147,6 +152,8 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
await deleteSession(base, sessionID, directory);
} catch {
// intentionally swallowed
} finally {
untrackInternalCaptureSession(sessionID);
}
}
}
Expand Down Expand Up @@ -186,7 +193,7 @@ async function generateViaSdkClient<T>(
args: SdkStructuredOutputArgs<T>
): Promise<T> {
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");
Expand All @@ -197,6 +204,7 @@ async function generateViaSdkClient<T>(
}

const sessionID = created.id;
trackInternalCaptureSession(sessionID);
try {
const promptResponse = await client.session.prompt({
sessionID,
Expand Down Expand Up @@ -235,6 +243,8 @@ async function generateViaSdkClient<T>(
});
} catch {
// Best-effort cleanup for the transient capture session.
} finally {
untrackInternalCaptureSession(sessionID);
}
}
}
Expand Down Expand Up @@ -287,14 +297,15 @@ async function createSession(base: string, directory?: string): Promise<string>
{
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) {
throw new Error(
"opencode-mem: session.create returned no session id; cannot generate structured output"
);
}
trackInternalCaptureSession(body.id);
return body.id;
}

Expand Down
5 changes: 4 additions & 1 deletion src/services/user-memory-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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,
},
Expand Down
25 changes: 25 additions & 0 deletions tests/internal-capture-sessions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 31 additions & 1 deletion tests/plugin-host-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"module": "ESNext",
"moduleDetection": "force",
"allowJs": true,
"types": ["bun-types"],

"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
Expand All @@ -30,4 +31,3 @@
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}