diff --git a/README.md b/README.md index 70ca4ca..1e1599f 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,14 @@ All tools that accept a `container` argument support: - any custom string — used as a raw container tag `user` and `project` now write to the same repository container. The -`sm_scope` metadata field keeps personal/session memories separate from +`agent_scope` metadata field keeps personal/session memories separate from explicit project knowledge when an agent requests one scope. +> **Release prerequisite:** Deploy and complete the backend `sm_scope` to +> `agent_scope` metadata and vector backfill before releasing this plugin. +> Canonical scoped reads rely on indexed `agent_scope`; legacy container tags +> intentionally remain unfiltered for compatibility. + ## Configuration ### Environment variables diff --git a/src/client.test.ts b/src/client.test.ts new file mode 100644 index 0000000..967a544 --- /dev/null +++ b/src/client.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from "bun:test"; +import { CursorMemoryClient, scopeFilters } from "./client.ts"; + +function createClientWithRequests() { + const requests: Array<{ method: string; containerTag: string; input: any }> = []; + const client = new CursorMemoryClient("test-api-key"); + + (client as any).raw = (containerTag: string) => ({ + search: { + memories: async (input: any) => { + requests.push({ method: "search", containerTag, input }); + return { results: [] }; + }, + }, + profile: async (input: any) => { + requests.push({ method: "profile", containerTag, input }); + return { profile: { static: [], dynamic: [] }, results: [] }; + }, + documents: { + list: async (input: any) => { + requests.push({ method: "list", containerTag, input }); + return { documents: [] }; + }, + }, + }); + + return { client, requests }; +} + +const canonicalTag = "repo_cursor_supermemory__0123456789abcdef"; +const generatedTag = "repo_cursor_supermemory__fedcba9876543210"; +const legacyTag = "cursor_user_0123456789abcdef"; + +const personalFilters: ReturnType = { + AND: [ + { key: "agent_scope", value: "personal", filterType: "metadata" as const }, + ], +}; + +test("builds scoped filters from indexed agent_scope metadata", () => { + expect(scopeFilters("personal")).toEqual(personalFilters); + expect(JSON.stringify(scopeFilters("project"))).not.toContain("sm_scope"); +}); + +test("scoped searches filter canonical and unified tags but preserve legacy reads", async () => { + const { client, requests } = createClientWithRequests(); + + await client.searchScoped( + "recent decisions", + canonicalTag, + [canonicalTag, generatedTag, legacyTag], + "personal", + 10, + ); + + expect(requests).toEqual([ + expect.objectContaining({ + method: "search", + containerTag: canonicalTag, + input: expect.objectContaining({ filters: personalFilters }), + }), + expect.objectContaining({ + method: "search", + containerTag: generatedTag, + input: expect.objectContaining({ filters: personalFilters }), + }), + expect.objectContaining({ + method: "search", + containerTag: legacyTag, + input: expect.objectContaining({ filters: undefined }), + }), + ]); +}); + +test("scoped profiles use agent_scope filters without filtering legacy containers", async () => { + const { client, requests } = createClientWithRequests(); + + await client.profileScoped( + canonicalTag, + [canonicalTag, legacyTag], + "personal", + undefined, + 10, + ); + + expect(requests).toEqual([ + expect.objectContaining({ + method: "profile", + containerTag: canonicalTag, + input: expect.objectContaining({ filters: personalFilters }), + }), + expect.objectContaining({ + method: "profile", + containerTag: legacyTag, + input: expect.objectContaining({ filters: undefined }), + }), + ]); +}); + +test("scoped lists send agent_scope filters to the server", async () => { + const { client, requests } = createClientWithRequests(); + + await client.listScoped( + canonicalTag, + [canonicalTag, generatedTag, legacyTag], + "personal", + 10, + 2, + ); + + expect(requests).toEqual([ + expect.objectContaining({ + method: "list", + containerTag: canonicalTag, + input: { + containerTags: [canonicalTag], + limit: 10, + page: 2, + filters: personalFilters, + }, + }), + expect.objectContaining({ + method: "list", + containerTag: generatedTag, + input: { + containerTags: [generatedTag], + limit: 10, + page: 2, + filters: personalFilters, + }, + }), + expect.objectContaining({ + method: "list", + containerTag: legacyTag, + input: { + containerTags: [legacyTag], + limit: 10, + page: 2, + filters: undefined, + }, + }), + ]); +}); diff --git a/src/client.ts b/src/client.ts index 7f03476..a5b2546 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,12 +1,13 @@ import { createHash, createHmac } from "node:crypto"; import Supermemory from "supermemory"; +import type { AgentScope } from "./metadata.ts"; const INTEGRITY_VERSION = 1; const SEED = "7f2a9c4b8e1d6f3a5c0b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a"; const CURSOR_SOURCE = "cursor"; -export type MemoryScope = "personal" | "project"; +export type MemoryScope = AgentScope; export const AGENT_ENTITY_CONTEXT = `Shared coding-agent memory for one software repository. @@ -41,9 +42,9 @@ function integrityHeaders(apiKey: string, containerTag: string) { }; } -function scopeFilters(scope: MemoryScope) { +export function scopeFilters(scope: MemoryScope) { return { - AND: [{ key: "sm_scope", value: scope, filterType: "metadata" as const }], + AND: [{ key: "agent_scope", value: scope, filterType: "metadata" as const }], }; } @@ -77,19 +78,6 @@ function resultDate(result: any): number { return Number.isFinite(parsed) ? parsed : 0; } -function resultMetadata(result: any): Record { - if (result?.metadata && typeof result.metadata === "object") { - return result.metadata as Record; - } - if ( - result?.document?.metadata && - typeof result.document.metadata === "object" - ) { - return result.document.metadata as Record; - } - return {}; -} - function listItems(result: any): any[] { if (Array.isArray(result?.memories)) return result.memories; if (Array.isArray(result?.documents)) return result.documents; @@ -262,9 +250,16 @@ export class CursorMemoryClient { query, canonicalTag, limit, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + scope, + ), + ...legacyTags.map((tag) => + this.searchOne( + query, + tag, + limit, + supportsScopedCanonicalTag(tag) ? scope : undefined, + ), ), - ...legacyTags.map((tag) => this.searchOne(query, tag, limit)), ]); return mergeSearchResults( fulfilledOrThrow(settled, "No memory containers could be searched"), @@ -302,9 +297,15 @@ export class CursorMemoryClient { this.profileOne( canonicalTag, query, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + scope, + ), + ...legacyTags.map((tag) => + this.profileOne( + tag, + query, + supportsScopedCanonicalTag(tag) ? scope : undefined, + ), ), - ...legacyTags.map((tag) => this.profileOne(tag, query)), ]); return mergeProfiles( fulfilledOrThrow(settled, "No memory profiles could be loaded"), @@ -322,12 +323,9 @@ export class CursorMemoryClient { containerTags: [containerTag], limit, page, + filters: scope ? scopeFilters(scope) : undefined, }); - if (!scope) return result; - const memories = listItems(result).filter( - (item) => resultMetadata(item).sm_scope === scope, - ); - return { ...result, memories }; + return result; } async listScoped( @@ -347,11 +345,18 @@ export class CursorMemoryClient { const settled = await Promise.allSettled([ this.listOne( canonicalTag, - Math.max(limit * 10, 100), + limit, page, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + scope, + ), + ...legacyTags.map((tag) => + this.listOne( + tag, + limit, + page, + supportsScopedCanonicalTag(tag) ? scope : undefined, + ), ), - ...legacyTags.map((tag) => this.listOne(tag, limit, page)), ]); return mergeLists( fulfilledOrThrow(settled, "No memory containers could be listed"), diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index d79021f..d768384 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -5,6 +5,7 @@ import { AGENT_ENTITY_CONTEXT, CursorMemoryClient, } from "../client.ts"; +import { sessionEndMemoryMetadata } from "../metadata.ts"; interface SessionEndInput { session_id: string; @@ -106,15 +107,12 @@ async function main() { await client.addMemory( content, tags.canonical, - { - type: "conversation", + sessionEndMemoryMetadata({ project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: "personal", - sm_capture_mode: "session_end", + projectId: tags.projectId, sessionId: input.session_id, timestamp: new Date().toISOString(), - }, + }), { customId: captureId(input.session_id), entityContext: AGENT_ENTITY_CONTEXT, diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 3e9efe4..662b772 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -16,6 +16,7 @@ import { CursorMemoryClient, type MemoryScope, } from "./client.ts"; +import { explicitMemoryMetadata } from "./metadata.ts"; function getAuth(cwd = process.cwd()) { const config = loadConfig(cwd); @@ -149,11 +150,11 @@ export function createMcpServer() { writes: { user: { tag: auth.tags.canonical, - sm_scope: "personal", + agent_scope: "personal", }, project: { tag: auth.tags.canonical, - sm_scope: "project", + agent_scope: "project", }, }, reads: { @@ -233,7 +234,7 @@ export function createMcpServer() { { description: "Show the shared repository container and all compatibility read tags. " + - '"user" and "project" write to the same container with different sm_scope metadata.', + '"user" and "project" write to the same container with different agent_scope metadata.', inputSchema: { workspaceRoot: workspaceRootSchema }, }, async ({ workspaceRoot }) => { @@ -252,13 +253,13 @@ export function createMcpServer() { user: { alias: "user", tag: auth.tags.canonical, - sm_scope: "personal", + agent_scope: "personal", reads: auth.tags.personalReads, }, project: { alias: "project", tag: auth.tags.canonical, - sm_scope: "project", + agent_scope: "project", reads: auth.tags.projectReads, }, both: { @@ -333,14 +334,12 @@ export function createMcpServer() { const result = await auth.client.addMemory( content, resolved.tag, - { - type: "memory", + explicitMemoryMetadata({ project: auth.tags.projectName, - sm_project_id: auth.tags.projectId, - ...(resolved.scope ? { sm_scope: resolved.scope } : {}), - sm_capture_mode: "explicit", + projectId: auth.tags.projectId, + scope: resolved.scope, timestamp: new Date().toISOString(), - }, + }), { entityContext: AGENT_ENTITY_CONTEXT }, ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; diff --git a/src/metadata.test.ts b/src/metadata.test.ts new file mode 100644 index 0000000..8256a40 --- /dev/null +++ b/src/metadata.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { + explicitMemoryMetadata, + sessionEndMemoryMetadata, +} from "./metadata.ts"; + +const timestamp = "2026-08-12T00:00:00.000Z"; + +test("MCP explicit personal and project writes use agent_scope only", () => { + const common = { + project: "cursor-supermemory", + projectId: "0123456789abcdef", + timestamp, + }; + + for (const scope of ["personal", "project"] as const) { + const metadata = explicitMemoryMetadata({ ...common, scope }); + + expect(metadata).toMatchObject({ + type: "memory", + sm_project_id: common.projectId, + agent_scope: scope, + sm_capture_mode: "explicit", + }); + expect(metadata).not.toHaveProperty("sm_scope"); + } +}); + +test("session-end writes use personal agent_scope only", () => { + const metadata = sessionEndMemoryMetadata({ + project: "cursor-supermemory", + projectId: "0123456789abcdef", + sessionId: "session-123", + timestamp, + }); + + expect(metadata).toMatchObject({ + type: "conversation", + agent_scope: "personal", + sm_capture_mode: "session_end", + sessionId: "session-123", + }); + expect(metadata).not.toHaveProperty("sm_scope"); +}); diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 0000000..78fb208 --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,34 @@ +export type AgentScope = "personal" | "project"; + +interface MemoryMetadataInput { + project: string; + projectId: string; + timestamp: string; +} + +export function explicitMemoryMetadata( + input: MemoryMetadataInput & { scope: AgentScope | null }, +) { + return { + type: "memory", + project: input.project, + sm_project_id: input.projectId, + ...(input.scope ? { agent_scope: input.scope } : {}), + sm_capture_mode: "explicit", + timestamp: input.timestamp, + }; +} + +export function sessionEndMemoryMetadata( + input: MemoryMetadataInput & { sessionId: string }, +) { + return { + type: "conversation", + project: input.project, + sm_project_id: input.projectId, + agent_scope: "personal" as const, + sm_capture_mode: "session_end", + sessionId: input.sessionId, + timestamp: input.timestamp, + }; +}