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"; diff --git a/src/index.ts b/src/index.ts index 5b56c708..6afc78ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -769,6 +769,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"; @@ -845,7 +847,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 730d8353..c08827fd 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/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/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(); + }); +}); 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(