From 08fbfaf3be56385cbc379afba5a10fa94270900b Mon Sep 17 00:00:00 2001 From: share121 Date: Sun, 13 Sep 2026 08:30:14 +0800 Subject: [PATCH 1/3] fix: remove embedding vectors from profile data Signed-off-by: share121 --- src/index.ts | 4 ++- src/services/api-handlers.ts | 3 +- src/services/user-profile/profile-utils.ts | 25 ++++++++++++++++ tests/profile-utils.test.ts | 35 ++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tests/profile-utils.test.ts diff --git a/src/index.ts b/src/index.ts index 326a69d3..bccbf0cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -757,6 +757,8 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const { userProfileManager } = await import("./services/user-profile/user-profile-manager.js"); + const { stripProfileEmbeddings } = + await import("./services/user-profile/profile-utils.js"); const userId = tags.user.userEmail || "unknown"; @@ -833,7 +835,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { // --- READ: no content provided --- const profile = await userProfileManager.getActiveProfile(userId); if (!profile) return JSON.stringify({ success: true, profile: null }); - const pData = JSON.parse(profile.profileData); + const pData = stripProfileEmbeddings(JSON.parse(profile.profileData)); return JSON.stringify({ success: true, profile: { diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index 478e00e4..ca4b3375 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -11,6 +11,7 @@ import type { MemoryType } from "../types/index.js"; import { userPromptManager } from "./user-prompt/user-prompt-manager.js"; import type { UserProfileData } from "./user-profile/types.js"; import { sortProfileItems } from "../utils/profile.js"; +import { stripProfileEmbeddings } from "./user-profile/profile-utils.js"; import type { ShardInfo } from "./turso/types.js"; async function getAllMemoryShards(): Promise { @@ -915,7 +916,7 @@ export async function handleGetUserProfile(userId?: string): Promise(arr: any): T[] => { return flattened; }; +/** + * Remove per-item embedding vectors (`centroid`/`anchor`) from profile data. + * + * Those 768-dim vectors are only used internally for similarity, dedup and + * drift detection. Returning them to the model or the read-only API inflates + * the payload by hundreds of KB, so callers that serialize profile data for + * display must strip them first. Callers pass freshly parsed profile JSON, so + * the fields are deleted in place and the same object is returned. + */ +export const stripProfileEmbeddings = (data: T): T => { + if (!data || typeof data !== "object") return data; + const container = data as Record; + for (const key of ["preferences", "patterns", "workflows"]) { + const items = container[key]; + if (!Array.isArray(items)) continue; + for (const item of items) { + if (item && typeof item === "object") { + delete (item as Record).centroid; + delete (item as Record).anchor; + } + } + } + return data; +}; + export const safeObject = (obj: any, fallback: T): T => { if (!obj) return fallback; let result = obj; diff --git a/tests/profile-utils.test.ts b/tests/profile-utils.test.ts new file mode 100644 index 00000000..a8630d44 --- /dev/null +++ b/tests/profile-utils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { stripProfileEmbeddings } from "../src/services/user-profile/profile-utils.js"; + +describe("stripProfileEmbeddings", () => { + it("removes centroid and anchor from every item type", () => { + const data = { + preferences: [{ description: "a", centroid: [1, 2], anchor: [3, 4], confidence: 0.5 }], + patterns: [{ description: "b", centroid: [1], anchor: [2], frequency: 3 }], + workflows: [{ description: "c", centroid: [1], anchor: [2], steps: ["x"] }], + }; + + const result = stripProfileEmbeddings(data); + + expect(result).toBe(data); + for (const key of ["preferences", "patterns", "workflows"] as const) { + for (const item of result[key]) { + expect(item.centroid).toBeUndefined(); + expect(item.anchor).toBeUndefined(); + } + } + expect(result.preferences[0].confidence).toBe(0.5); + expect(result.patterns[0].frequency).toBe(3); + expect(result.workflows[0].steps).toEqual(["x"]); + }); + + it("tolerates missing, malformed or non-object sections", () => { + expect(stripProfileEmbeddings(undefined as any)).toBeUndefined(); + expect(stripProfileEmbeddings(null as any)).toBeNull(); + expect(stripProfileEmbeddings({} as any)).toEqual({}); + expect(stripProfileEmbeddings({ preferences: "not-an-array" } as any)).toEqual({ + preferences: "not-an-array", + }); + expect(() => stripProfileEmbeddings({ patterns: [null, 1, "x"] } as any)).not.toThrow(); + }); +}); From fb6ea451c39b3408323eca4ad0cf6ac9840ea74b Mon Sep 17 00:00:00 2001 From: share121 Date: Thu, 24 Sep 2026 21:17:26 +0800 Subject: [PATCH 2/3] fix(plugin): add v2 root entrypoint OpenCode v2 resolves plugin-directory entrypoints from the root index.ts/js/mjs file and does not read package.json exports, so a local checkout configured via plugins was silently skipped. Re-export the built entrypoint from the repository root so the directory loads. --- index.js | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 index.js diff --git a/index.js b/index.js new file mode 100644 index 00000000..ba4e4ecb --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +export * from "./dist/plugin.js"; +export { default } from "./dist/plugin.js"; From 998e63f23888fa30b6b7a4b9b0c3264f398dac0a Mon Sep 17 00:00:00 2001 From: share121 Date: Thu, 24 Sep 2026 22:10:01 +0800 Subject: [PATCH 3/3] fix(v2): restore auto-capture by mapping execution end to session.idle OpenCode v2 no longer emits the deprecated session.idle event, nor an idle session.status, so the V1 idle handler that drives auto-capture and user-profile learning never fired. Map a finished session execution (succeeded/failed/interrupted) back to session.idle so the existing idle pipeline runs again. --- src/v2/legacy-client.ts | 16 +++++++++++++- tests/v2-legacy-client.test.ts | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/v2/legacy-client.ts b/src/v2/legacy-client.ts index 2f7a11bc..d9d36adb 100644 --- a/src/v2/legacy-client.ts +++ b/src/v2/legacy-client.ts @@ -239,8 +239,22 @@ export function toLegacyEvent(raw: any): { type: string; properties: any } { const envelope = raw?.payload ?? raw; const source = envelope?.type === "sync" && envelope.syncEvent ? envelope.syncEvent : envelope; const rawType = typeof source?.type === "string" ? source.type.replace(/\.1$/, "") : source?.type; - const type = rawType === "session.compaction.ended" ? "session.compacted" : rawType; const data = source?.data ?? {}; + // OpenCode v2 no longer emits the deprecated `session.idle` (nor an idle + // `session.status`). A finished session execution is the equivalent signal, + // so translate it back for the V1 idle handler (auto-capture and + // user-profile learning). + const isIdleStatus = rawType === "session.status" && data?.status?.type === "idle"; + const isExecutionEnd = + rawType === "session.execution.succeeded" || + rawType === "session.execution.failed" || + rawType === "session.execution.interrupted"; + const type = + rawType === "session.compaction.ended" + ? "session.compacted" + : isIdleStatus || isExecutionEnd + ? "session.idle" + : rawType; if (source && typeof source === "object" && "properties" in source) { return { type, properties: source.properties }; } diff --git a/tests/v2-legacy-client.test.ts b/tests/v2-legacy-client.test.ts index d2870cff..de3d0f49 100644 --- a/tests/v2-legacy-client.test.ts +++ b/tests/v2-legacy-client.test.ts @@ -121,6 +121,45 @@ describe("OpenCode v2 legacy client bridge", () => { }); }); + it("maps V2 idle session.status events back to session.idle", () => { + expect( + toLegacyEvent({ + type: "session.status", + data: { sessionID: "ses-1", status: { type: "idle" } }, + }) + ).toEqual({ + type: "session.idle", + properties: { sessionID: "ses-1", status: { type: "idle" } }, + }); + // Non-idle statuses and unrelated event types pass through untouched. + expect( + toLegacyEvent({ + type: "session.status", + data: { sessionID: "ses-1", status: { type: "busy" } }, + }).type + ).toBe("session.status"); + expect(toLegacyEvent({ type: "message.updated", data: { sessionID: "ses-1" } }).type).toBe( + "message.updated" + ); + }); + + it("maps finished V2 session executions to session.idle", () => { + for (const type of [ + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + ]) { + expect(toLegacyEvent({ type, data: { sessionID: "ses-1" } })).toEqual({ + type: "session.idle", + properties: { sessionID: "ses-1" }, + }); + } + // Execution lifecycle start is not a turn end and must not trigger capture. + expect( + toLegacyEvent({ type: "session.execution.started", data: { sessionID: "ses-1" } }).type + ).toBe("session.execution.started"); + }); + it("filters the global event stream by direct or session location", async () => { const ctx = createContext(); expect(