Skip to content
Open
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof scopeFilters> = {
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,
},
}),
]);
});
61 changes: 33 additions & 28 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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 }],
};
}

Expand Down Expand Up @@ -77,19 +78,6 @@ function resultDate(result: any): number {
return Number.isFinite(parsed) ? parsed : 0;
}

function resultMetadata(result: any): Record<string, unknown> {
if (result?.metadata && typeof result.metadata === "object") {
return result.metadata as Record<string, unknown>;
}
if (
result?.document?.metadata &&
typeof result.document.metadata === "object"
) {
return result.document.metadata as Record<string, unknown>;
}
return {};
}

function listItems(result: any): any[] {
if (Array.isArray(result?.memories)) return result.memories;
if (Array.isArray(result?.documents)) return result.documents;
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand All @@ -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(
Expand All @@ -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"),
Expand Down
10 changes: 4 additions & 6 deletions src/hooks/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AGENT_ENTITY_CONTEXT,
CursorMemoryClient,
} from "../client.ts";
import { sessionEndMemoryMetadata } from "../metadata.ts";

interface SessionEndInput {
session_id: string;
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 10 additions & 11 deletions src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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: {
Expand Down Expand Up @@ -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) }] };
Expand Down
Loading