Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: {
Expand Down
11 changes: 7 additions & 4 deletions src/services/api-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShardInfo[]> {
Expand Down Expand Up @@ -932,7 +933,7 @@ export async function handleGetUserProfile(userId?: string): Promise<ApiResponse
createdAt: safeToISOString(profile.createdAt),
lastAnalyzedAt: safeToISOString(profile.lastAnalyzedAt),
totalPromptsAnalyzed: profile.totalPromptsAnalyzed,
profileData,
profileData: toPublicProfileData(profileData),
},
};
} catch (error) {
Expand Down Expand Up @@ -970,7 +971,7 @@ export async function handleGetProfileSnapshot(changelogId: string): Promise<Api
const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
const changelog = await userProfileManager.getChangelogById(changelogId);
if (!changelog) return { success: false, error: "Changelog not found" };
const profileData = JSON.parse(changelog.profileDataSnapshot);
const profileData = toPublicProfileData(JSON.parse(changelog.profileDataSnapshot));
return {
success: true,
data: {
Expand Down Expand Up @@ -1091,8 +1092,10 @@ export async function handleAICleanup(
return {
success: true,
data: {
old: profileData,
new: result.cleaned,
// Strip embeddings only on the HTTP response; pendingCleanups keeps
// full vectors for apply/merge.
old: toPublicProfileData(profileData),
new: toPublicProfileData(result.cleaned),
changes: result.diff,
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/services/user-profile/profile-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,38 @@ export const safeArray = <T>(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 = <T>(data: T): T => {
if (!data || typeof data !== "object") return data;
const container = data as Record<string, unknown>;
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<string, unknown>).centroid;
delete (item as Record<string, unknown>).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 = <T>(data: T): T => {
return stripProfileEmbeddings(structuredClone(data));
};

export const safeObject = <T extends object>(obj: any, fallback: T): T => {
if (!obj) return fallback;
let result = obj;
Expand Down
173 changes: 173 additions & 0 deletions tests/api-handlers-profile-strip.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
67 changes: 67 additions & 0 deletions tests/profile-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
Loading