diff --git a/src/index.ts b/src/index.ts index 4023f567..8fdd737e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -806,6 +806,8 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const { userProfileManager } = await import("./services/user-profile/user-profile-manager.js"); + const { toPublicProfileData } = + await import("./services/user-profile/profile-utils.js"); const userId = tags.user.userEmail || "unknown"; @@ -882,7 +884,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 = toPublicProfileData(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 9f4677ae..1bbb9b41 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 { toPublicProfileData } from "./user-profile/profile-utils.js"; import type { ShardInfo } from "./turso/types.js"; async function getAllMemoryShards(): Promise { @@ -932,7 +933,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. Mutates in place and returns the same object. + */ +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; +}; + +/** + * Clone profile data and strip embeddings for public responses. + * Use this when the original object must keep embeddings (e.g. pending cleanups). + */ +export const toPublicProfileData = (data: T): T => { + return stripProfileEmbeddings(structuredClone(data)); +}; + export const safeObject = (obj: any, fallback: T): T => { if (!obj) return fallback; let result = obj; diff --git a/tests/api-handlers-profile-strip.test.ts b/tests/api-handlers-profile-strip.test.ts new file mode 100644 index 00000000..cc3c3746 --- /dev/null +++ b/tests/api-handlers-profile-strip.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const tempDirs: string[] = []; +const apiHandlersUrl = new URL("../src/services/api-handlers.js", import.meta.url).href; +const userProfileManagerUrl = new URL( + "../src/services/user-profile/user-profile-manager.js", + import.meta.url +).href; +const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; +const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; +const userPromptManagerUrl = new URL( + "../src/services/user-prompt/user-prompt-manager.js", + import.meta.url +).href; + +const profileWithEmbeddings = { + preferences: [ + { + id: "pref-1", + description: "prefer TypeScript", + confidence: 0.9, + centroid: [0.1, 0.2, 0.3], + anchor: [0.1, 0.2, 0.3], + }, + ], + patterns: [ + { + id: "pat-1", + description: "asks for diffs", + frequency: 4, + centroid: [0.4, 0.5], + anchor: [0.4, 0.5], + }, + ], + workflows: [ + { + id: "wf-1", + description: "review then commit", + frequency: 2, + steps: ["review", "commit"], + centroid: [0.6], + anchor: [0.7], + }, + ], +}; + +function runScenario(scriptBody: string) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-profile-strip-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + const script = ` +import { mock } from "bun:test"; + +const profileWithEmbeddings = ${JSON.stringify(profileWithEmbeddings)}; + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ + log: () => {}, +})); + +mock.module(${JSON.stringify(userPromptManagerUrl)}, () => ({ + userPromptManager: {}, +})); + +mock.module(${JSON.stringify(tagsUrl)}, () => ({ + getTags: () => ({ + user: { userEmail: "user@example.com" }, + }), +})); + +mock.module(${JSON.stringify(userProfileManagerUrl)}, () => ({ + userProfileManager: { + getActiveProfile: async () => ({ + id: "profile_1", + userId: "user@example.com", + displayName: "User", + userName: "user", + userEmail: "user@example.com", + version: 3, + createdAt: Date.now(), + lastAnalyzedAt: Date.now(), + totalPromptsAnalyzed: 12, + profileData: JSON.stringify(profileWithEmbeddings), + }), + getChangelogById: async () => ({ + id: "cl_1", + version: 2, + createdAt: Date.now(), + profileDataSnapshot: JSON.stringify(profileWithEmbeddings), + }), + }, +})); + +const { + handleGetUserProfile, + handleGetProfileSnapshot, +} = await import(${JSON.stringify(apiHandlersUrl)}); + +function assertNoEmbeddings(profileData) { + for (const key of ["preferences", "patterns", "workflows"]) { + for (const item of profileData[key] ?? []) { + if (item.centroid !== undefined || item.anchor !== undefined) { + throw new Error(\`embedding leaked in \${key}\`); + } + } + } + const json = JSON.stringify(profileData); + if (json.includes('"centroid"') || json.includes('"anchor"')) { + throw new Error("embedding keys present in serialized profileData"); + } +} + +${scriptBody} +`; + writeFileSync(scriptPath, script, "utf-8"); + const result = Bun.spawnSync({ + cmd: [process.execPath, scriptPath], + stdout: "pipe", + stderr: "pipe", + }); + const stdout = Buffer.from(result.stdout).toString("utf8").trim(); + const stderr = Buffer.from(result.stderr).toString("utf8").trim(); + const jsonLine = stdout + .split("\n") + .reverse() + .find((line) => line.trim().startsWith("{")); + + return { + exitCode: result.exitCode, + stdout, + stderr, + parsed: jsonLine ? JSON.parse(jsonLine) : null, + }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("API profile responses strip embeddings", () => { + it("handleGetUserProfile omits centroid/anchor", () => { + const result = runScenario(` +const response = await handleGetUserProfile("user@example.com"); +assertNoEmbeddings(response.data.profileData); +console.log(JSON.stringify({ + success: response.success, + description: response.data.profileData.preferences[0].description, +})); +`); + expect(result.exitCode).toBe(0); + expect(result.parsed?.success).toBe(true); + expect(result.parsed?.description).toBe("prefer TypeScript"); + }); + + it("handleGetProfileSnapshot omits centroid/anchor", () => { + const result = runScenario(` +const response = await handleGetProfileSnapshot("cl_1"); +assertNoEmbeddings(response.data.profileData); +console.log(JSON.stringify({ + success: response.success, + version: response.data.version, +})); +`); + expect(result.exitCode).toBe(0); + expect(result.parsed?.success).toBe(true); + expect(result.parsed?.version).toBe(2); + }); +}); diff --git a/tests/profile-utils.test.ts b/tests/profile-utils.test.ts new file mode 100644 index 00000000..1ee60da5 --- /dev/null +++ b/tests/profile-utils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import { + stripProfileEmbeddings, + toPublicProfileData, +} from "../src/services/user-profile/profile-utils.js"; + +function sampleProfile() { + return { + 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"] }], + }; +} + +describe("stripProfileEmbeddings", () => { + it("removes centroid and anchor from every item type", () => { + const data = sampleProfile(); + + 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(); + }); +}); + +describe("toPublicProfileData", () => { + it("clones before stripping so the original keeps embeddings", () => { + const data = sampleProfile(); + + const result = toPublicProfileData(data); + + expect(result).not.toBe(data); + expect(result.preferences[0]).not.toBe(data.preferences[0]); + expect(data.preferences[0].centroid).toEqual([1, 2]); + expect(data.preferences[0].anchor).toEqual([3, 4]); + expect(result.preferences[0].centroid).toBeUndefined(); + expect(result.preferences[0].anchor).toBeUndefined(); + expect(result.patterns[0].centroid).toBeUndefined(); + expect(result.workflows[0].anchor).toBeUndefined(); + expect(result.preferences[0].confidence).toBe(0.5); + }); + + it("serializes without centroid or anchor keys", () => { + const json = JSON.stringify(toPublicProfileData(sampleProfile())); + expect(json).not.toContain('"centroid"'); + expect(json).not.toContain('"anchor"'); + expect(json).toContain('"description":"a"'); + }); +});