diff --git a/README.md b/README.md index 6ae4b1e..cd8f88d 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,22 @@ and the lessons learned across every project β€” automatically. `UserPromptSubmit` hook. - πŸ’Ύ **Automatic capture** β€” conversations are stored incrementally (every N turns) and at session end via the `Stop` hook. -- 🏷️ **Project + user scoping** β€” memories are tagged per-project and per-user so - context never leaks across repos. +- 🏷️ **Shared Agents scoping** β€” Codex and Claude Code use one collision-safe + repository container. - πŸ“¦ **Custom container tags** β€” define custom memory containers (e.g., `work`, `personal`, `code_style`). The AI automatically picks the right container based on your instructions when saving, searching, or forgetting memories. -- 🏷️ **Project + user scoping** β€” automatic session memories are stored per-user, - while explicit project knowledge is tagged per-repo so context never leaks across repos. -- **Entity-aware extraction** - user and project containers get purpose-specific - extraction context so Supermemory stores durable preferences separately from - project/codebase facts. +- 🏷️ **Personal + project routing** β€” `sm_scope` metadata keeps automatic/personal + memories distinguishable from explicit project knowledge in the shared container. +- **Entity-aware extraction** - the shared container uses one coding-agent context + covering durable preferences and project/codebase facts. - πŸ”’ **Privacy-aware** β€” anything wrapped in `...` is redacted before being sent to Supermemory. - ⚑ **Zero-config install** β€” one command sets up `~/.codex/config.toml` and `~/.codex/hooks.json` for you. - πŸͺΆ **No runtime deps in hooks** β€” the hook scripts are pre-bundled with esbuild for fast cold starts. -- πŸ”§ **Fallback skills** β€” explicit `/supermemory-search`, `/supermemory-save`, +- πŸ”§ **Fallback skills** β€” explicit `/supermemory-search`, `/supermemory-add`, `/supermemory-save`, `/supermemory-forget`, `/supermemory-status`, and `/supermemory-logout` commands available when hooks don't cover your use case. @@ -74,6 +73,24 @@ The installer: The hooks are tolerant: if Supermemory is unreachable, the API key is missing, or anything else fails, they exit cleanly without breaking your Codex session. +### Shared Agents containers + +Codex and Claude Code use one container for a repository: + +- `repo___` stores automatic capture and every explicit save. +- `sm_scope` metadata preserves optional personal/project filtering. + +The hash comes from the normalized Git remote, so clones share memory while +same-named repositories do not collide. Repositories without a remote fall back to +a local path identity. Codex also reads the previous `user_project_*`, +`repo_`, `codex_user_*`, `codex_project_*`, and +`claudecode_project_*` containers, so existing memories remain searchable without +duplicating or migrating them. Set `SUPERMEMORY_ISOLATE_WORKTREES=true` to use +the worktree path instead of the remote identity. + +Explicit `projectContainerTag`/`repoContainerTag` overrides remain the canonical +write destination. Older user/personal overrides remain in the legacy read set. + ## Configuration ### Environment variables @@ -96,9 +113,9 @@ Drop this file in to override defaults: | `maxMemories` | `number` | `5` | Max memories injected per prompt. | | `maxProfileItems` | `number` | `5` | Max profile items considered. | | `injectProfile` | `boolean` | `true` | Whether to fetch and inject the user profile. | -| `containerTagPrefix` | `string` | `"codex"` | Prefix for auto-generated container tags. | -| `userContainerTag` | `string` | auto | Override the user container tag. | -| `projectContainerTag` | `string` | auto (per-repo) | Override the project container tag. | +| `containerTagPrefix` | `string` | `"codex"` | Legacy prefix retained when reading containers created by older versions. | +| `userContainerTag` | `string` | auto | Legacy personal container retained for backward-compatible reads. | +| `projectContainerTag` | `string` | auto (per-repo) | Explicit unified project-container override, also honored by Claude Code. | | `filterPrompt` | `string` | (sensible) | Filter prompt used by Supermemory's stateful filter. | | `debug` | `boolean` | `false` | Enable debug logging. | | `autoSaveEveryTurns` | `number` | `3` | Save memories every N turns (incremental capture). | @@ -109,18 +126,17 @@ Drop this file in to override defaults: | `customContainers` | `array` | `[]` | Custom containers with `tag` and `description` (see below). | | `customContainerInstructions` | `string` | `""` | Free-text instructions for the AI on how to route memories to containers. -User tags are auto-derived from your `git config user.email`. Project tags are -derived from the Git common directory when available, so linked worktrees and -Conductor workspaces for the same repository share one project container by default. -Set `SUPERMEMORY_ISOLATE_WORKTREES=true` to keep each worktree isolated. +Project tags combine the sanitized repository name with a normalized Git-remote +hash. Linked worktrees and clones of the same remote therefore share one container; +same-named repositories with different remotes do not collide. Without a remote, +the Git common directory is used as the fallback identity. ### Entity context -Codex sends an `entityContext` whenever it saves memories. The user container is -guided toward durable user preferences and workflows; the project container is -guided toward repo architecture, conventions, setup, decisions, and implementation -lessons. Supermemory stores this context on the container tag and uses it to guide -memory extraction. +Codex sends one shared coding-agent `entityContext` whenever it saves memories. It +covers durable preferences, workflows, architecture, conventions, setup, decisions, +and implementation lessons without one save type overwriting another container-level +context. ### Signal extraction (optional) @@ -139,11 +155,12 @@ npx codex-supermemory status # show current install status ## Skills (fallback commands) These Codex skills are available as explicit commands when you need more control. -All memory skills support `--container ` to target a specific custom container. +The search, save, and forget skills support `--container ` to target a specific custom container. | Skill | Usage | Description | | ---------------------- | ----------------------------------------------------------- | ---------------------------------------- | | `/supermemory-search` | `/supermemory-search [--container ] ` | Search memories manually. | +| `/supermemory-add` | `/supermemory-add ` | Add a personal memory for this project. | | `/supermemory-save` | `/supermemory-save [--container ] ` | Save a specific memory explicitly. | | `/supermemory-forget` | `/supermemory-forget [--container ] ` | Remove a memory. | | `/supermemory-profile` | `/supermemory-profile` | Show remembered profile facts. | diff --git a/build.mjs b/build.mjs index d59d18d..8f21058 100644 --- a/build.mjs +++ b/build.mjs @@ -16,7 +16,7 @@ const executableEntries = [ in: `src/hooks/${n}.ts`, out: `dist/hooks/${n}.js`, })), - ...["search-memory", "save-memory", "forget-memory", "profile-memory", "status", "login", "logout"].map((n) => ({ + ...["search-memory", "add-memory", "save-memory", "forget-memory", "profile-memory", "status", "login", "logout"].map((n) => ({ in: `src/skills/${n}.ts`, out: `dist/skills/${n}.js`, })), @@ -27,6 +27,7 @@ rmSync("dist", { recursive: true, force: true }); const libraryEntries = [ { in: "src/services/session.ts", out: "dist/services/session.js" }, { in: "src/services/tags.ts", out: "dist/services/tags.js" }, + { in: "src/services/resultMerge.ts", out: "dist/services/resultMerge.js" }, ]; await Promise.all( @@ -50,7 +51,7 @@ await Promise.all( ); // Copy SKILL.md files to dist -for (const skillName of ["supermemory-search", "supermemory-save", "supermemory-forget", "supermemory-profile", "supermemory-status", "supermemory-login", "supermemory-logout"]) { +for (const skillName of ["supermemory-search", "supermemory-add", "supermemory-save", "supermemory-forget", "supermemory-profile", "supermemory-status", "supermemory-login", "supermemory-logout"]) { mkdirSync(`dist/skills/${skillName}`, { recursive: true }); copyFileSync( `src/skills/${skillName}/SKILL.md`, diff --git a/src/cli.ts b/src/cli.ts index bb9b2e0..f61ca7e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,6 +43,7 @@ const SESSION_START_TIMEOUT_SECONDS = 60; // Skill metadata β€” single source of truth for install/uninstall/status. const SKILLS = [ { name: "supermemory-search", script: "search-memory.js" }, + { name: "supermemory-add", script: "add-memory.js" }, { name: "supermemory-save", script: "save-memory.js" }, { name: "supermemory-forget", script: "forget-memory.js" }, { name: "supermemory-status", script: "status.js" }, @@ -309,7 +310,7 @@ Installation complete! You now have: β€’ Session-start profile recall (${getRecallModeSummary()}) - β€’ Explicit memory β€” supermemory-search, supermemory-save, supermemory-forget, supermemory-profile, supermemory-status, supermemory-login, and supermemory-logout skills + β€’ Explicit memory β€” supermemory-search, supermemory-add, supermemory-save, supermemory-forget, supermemory-profile, supermemory-status, supermemory-login, and supermemory-logout skills ${hadExistingConfig ? "Existing install: legacy per-prompt recall/capture preserved in ~/.codex/supermemory.json.\nTo opt into new defaults, set autoRecallEveryPrompt=false and captureEveryNTurns=0.\n" diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index bc87781..72beff2 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -123,17 +123,16 @@ async function main() { } try { - const [profileResult, projectSearchResult] = await Promise.all([ - client.getProfileWithSearch(tags.user, query), - client.searchMemories(query, tags.project), - ]); + const profileResult = await client.getProfileWithSearchMany( + tags.allReads, + query, + ); const seen = getSeenFacts(sessionId); const { text, newFacts } = formatCombinedContext( profileResult, CONFIG.maxMemories, CONFIG.maxProfileItems, - projectSearchResult, seen, ); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 5af3c55..d4c6fda 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -89,7 +89,7 @@ async function main() { log("session-start: begin", { sessionId, tags }); try { - const profileResult = await client.getProfile(tags.user); + const profileResult = await client.getProfileMany(tags.allReads); const seen = getSeenFacts(sessionId); const { text, newFacts } = formatCombinedContext( { @@ -99,7 +99,6 @@ async function main() { }, 0, CONFIG.maxProfileItems, - undefined, seen, ); diff --git a/src/services/capture.ts b/src/services/capture.ts index a97cb9d..4072847 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -17,6 +17,7 @@ import { } from "./transcript.js"; import { getLastCapturedIndex, setLastCapturedIndex } from "./tracker.js"; import { filterBySignals, groupEntriesIntoTurns } from "./signals.js"; +import type { ResolvedTags } from "./tags.js"; export interface CaptureOptions { /** Minimum number of new entries required before capturing. Default: 0 */ @@ -53,7 +54,7 @@ export async function captureEntries( client: SupermemoryClient, sessionId: string, transcriptPath: string | null, - tags: { project: string; user: string }, + tags: Pick, options: CaptureOptions = {}, ): Promise { const { requireMinEntries = 0, requireMinTurns = 0 } = options; @@ -134,17 +135,20 @@ export async function captureEntries( const metadata = { type: "conversation" as const, + project: tags.projectName, + sm_project_id: tags.projectId, sessionId, entryCount: newEntries.length, timestamp: new Date().toISOString(), + sm_scope: "personal", sm_capture_mode: caller === "flush" ? "session_end" : "turn", }; - // Save automatic transcript capture to the user container. Explicit project - // knowledge is still saved via the supermemory-save skill. + // Automatic capture and explicit saves share one project container. Scope + // metadata preserves optional personal/project filtering. // Use customId so all session turns go into the same document. try { - await client.addMemory(content, tags.user, metadata, { + await client.addMemory(content, tags.canonical, metadata, { customId: sessionId, entityContext: USER_ENTITY_CONTEXT, }); diff --git a/src/services/client.ts b/src/services/client.ts index da26e07..4775413 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -2,11 +2,24 @@ import Supermemory from "supermemory"; import { CONFIG, isConfigured, getApiKeyValue, getBaseUrl, PLUGIN_VERSION } from "../config.js"; import { log } from "./logger.js"; import type { MemoryType } from "../types/index.js"; +import { mergeProfileResults, mergeSearchResponses } from "./resultMerge.js"; const TIMEOUT_MS = 30000; const SPACE_NAME_TIMEOUT_MS = 5000; const CODEX_SOURCE = "codex"; +export type MemoryScope = "personal" | "project"; + +function getScopeFilters(scope: MemoryScope) { + return { + AND: [{ key: "sm_scope", value: scope, filterType: "metadata" as const }], + }; +} + +function supportsScopedCanonicalTag(containerTag: string): boolean { + return /^repo_.+__[0-9a-f]{16}$/i.test(containerTag); +} + function withTimeout(promise: Promise, ms: number): Promise { let id: ReturnType; const timeout = new Promise((_, reject) => { @@ -27,6 +40,7 @@ export interface SearchResultItem { title?: string; updatedAt?: string; metadata?: Record | null; + containerTag?: string; } /** Response shape returned by search APIs. */ @@ -58,32 +72,22 @@ export interface ProfileWithSearchResult { error?: string; } -export const USER_ENTITY_CONTEXT = `Developer coding session transcript for a persistent user profile. +export const AGENT_ENTITY_CONTEXT = `Shared coding-agent memory for one software repository. EXTRACT: -- User preferences: preferred languages, frameworks, libraries, editors, workflows, and communication style -- Stable habits: testing style, code review expectations, formatting preferences, privacy preferences -- Repeated personal decisions: tools the user consistently chooses or avoids -- Long-lived learnings: concepts the user learned or wants remembered across projects +- User preferences, accepted decisions, durable workflows, actions, and learnings +- Repository architecture, services, modules, and data flow +- Naming, component, API, testing, and style conventions +- Setup requirements, debugging workflows, and implementation lessons +- Concise outcomes from assistant responses that became useful project knowledge SKIP: -- Project-specific architecture unless it reflects a durable user preference -- One-off assistant suggestions the user did not accept -- Low-level implementation details that only matter inside the current repository`; +- Every granular fact the assistant mentioned +- Generic assistant suggestions the user did not accept +- Transient command output and low-value implementation chatter`; -export const PROJECT_ENTITY_CONTEXT = `Project/codebase knowledge from Codex coding sessions. - -EXTRACT: -- Architecture: repo structure, services, modules, data flow, and integration boundaries -- Conventions: naming, component patterns, API patterns, testing practices, and style rules -- Decisions: chosen approaches, tradeoffs, migrations, and rejected alternatives -- Setup: commands, environment requirements, deployment notes, and debugging workflows -- Implementation lessons: bugs fixed, root causes, and reusable project-specific context - -SKIP: -- Generic user preferences that are not specific to this project -- Verbatim assistant explanations unless they became an accepted project decision -- Transient command output with no lasting project value`; +export const USER_ENTITY_CONTEXT = AGENT_ENTITY_CONTEXT; +export const PROJECT_ENTITY_CONTEXT = AGENT_ENTITY_CONTEXT; export class SupermemoryClient { private client: Supermemory | null = null; @@ -105,17 +109,21 @@ export class SupermemoryClient { } /** - * Get user profile with embedded search results from a single container. - * The recall hook pairs this with a separate `searchMemories()` call to - * the project container so both user and project memories are surfaced. + * Get a profile with embedded search results from one container. A scope is + * optional because default recall searches the unified project container. */ - async getProfileWithSearch(containerTag: string, query?: string): Promise { + async getProfileWithSearch( + containerTag: string, + query?: string, + scope?: MemoryScope, + ): Promise { log("getProfileWithSearch: start", { containerTag, hasQuery: !!query }); try { const result = await withTimeout( this.getClient().profile({ containerTag, q: query, + filters: scope ? getScopeFilters(scope) : undefined, }), TIMEOUT_MS ); @@ -167,9 +175,46 @@ export class SupermemoryClient { } } + async getProfileWithSearchMany( + containerTags: string[], + query?: string, + ): Promise { + const uniqueTags = [...new Set(containerTags.filter(Boolean))]; + const results = await Promise.all( + uniqueTags.map((containerTag) => this.getProfileWithSearch(containerTag, query)), + ); + return mergeProfileResults(results, CONFIG.maxMemories); + } + + async getProfileWithSearchScoped( + canonicalTag: string, + containerTags: string[], + scope: MemoryScope, + query?: string, + ): Promise { + const legacyTags = [...new Set( + containerTags.filter((tag) => tag && tag !== canonicalTag), + )]; + const results = await Promise.all([ + this.getProfileWithSearch( + canonicalTag, + query, + supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + ), + ...legacyTags.map((containerTag) => + this.getProfileWithSearch(containerTag, query), + ), + ]); + return mergeProfileResults(results, CONFIG.maxMemories); + } + // Keep old methods for backward compatibility - async searchMemories(query: string, containerTag: string): Promise { + async searchMemories( + query: string, + containerTag: string, + scope?: MemoryScope, + ): Promise { log("searchMemories: start", { containerTag }); try { const result = await withTimeout( @@ -179,11 +224,16 @@ export class SupermemoryClient { threshold: CONFIG.similarityThreshold, limit: CONFIG.maxMemories, searchMode: "hybrid", + filters: scope ? getScopeFilters(scope) : undefined, }), TIMEOUT_MS ); log("searchMemories: success", { count: result.results?.length || 0 }); - return { success: true, results: result.results as SearchResultItem[], total: result.total, timing: result.timing }; + const results = (result.results as SearchResultItem[]).map((item) => ({ + ...item, + containerTag, + })); + return { success: true, results, total: result.total, timing: result.timing }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log("searchMemories: error", { error: errorMessage }); @@ -191,13 +241,44 @@ export class SupermemoryClient { } } - async getProfile(containerTag: string, query?: string) { + async searchMemoriesMany(query: string, containerTags: string[]): Promise { + const uniqueTags = [...new Set(containerTags.filter(Boolean))]; + const results = await Promise.all( + uniqueTags.map((containerTag) => this.searchMemories(query, containerTag)), + ); + return mergeSearchResponses(results, CONFIG.maxMemories); + } + + async searchMemoriesScoped( + query: string, + canonicalTag: string, + containerTags: string[], + scope: MemoryScope, + ): Promise { + const legacyTags = [...new Set( + containerTags.filter((tag) => tag && tag !== canonicalTag), + )]; + const results = await Promise.all([ + this.searchMemories( + query, + canonicalTag, + supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + ), + ...legacyTags.map((containerTag) => + this.searchMemories(query, containerTag), + ), + ]); + return mergeSearchResponses(results, CONFIG.maxMemories); + } + + async getProfile(containerTag: string, query?: string, scope?: MemoryScope) { log("getProfile: start", { containerTag }); try { const result = await withTimeout( this.getClient().profile({ containerTag, q: query, + filters: scope ? getScopeFilters(scope) : undefined, }), TIMEOUT_MS ); @@ -210,6 +291,24 @@ export class SupermemoryClient { } } + async getProfileMany(containerTags: string[], query?: string): Promise { + return this.getProfileWithSearchMany(containerTags, query); + } + + async getProfileScopedMany( + canonicalTag: string, + containerTags: string[], + scope: MemoryScope, + query?: string, + ): Promise { + return this.getProfileWithSearchScoped( + canonicalTag, + containerTags, + scope, + query, + ); + } + async addMemory( content: string, containerTag: string, @@ -289,7 +388,8 @@ export class SupermemoryClient { if ( currentName && currentName !== `Space ${containerTag}` && - !currentName.startsWith("Codex Β· ") + !currentName.startsWith("Codex Β· ") && + !currentName.startsWith("Agents Β· ") ) { log("updateContainerTagName: kept custom name", { containerTag, currentName }); return { success: true as const }; diff --git a/src/services/context.ts b/src/services/context.ts index 98b304a..5de6e74 100644 --- a/src/services/context.ts +++ b/src/services/context.ts @@ -36,12 +36,7 @@ export interface FormattedContext { } /** - * Format context from combined profile+search result. - * Accepts an optional separate project search result to merge project-scoped - * memories alongside user-scoped ones from the profile API. - * - * Memories from both containers are interleaved by alternating picks so that - * neither source is entirely crowded out when the total exceeds maxMemories. + * Format context from the unified project profile and its embedded search. * * Facts already seen in this session (passed via `seenFacts`) are skipped * to avoid wasting tokens on repeated context. @@ -50,7 +45,6 @@ export function formatCombinedContext( result: ProfileWithSearchResult, maxMemories: number, maxProfileItems: number, - projectSearchResult?: SearchResponse, seenFacts: Set = new Set(), ): FormattedContext { const parts: string[] = []; @@ -64,22 +58,22 @@ export function formatCombinedContext( .slice(0, maxProfileItems); if (items.length > 0) { parts.push( - `[User Profile]\n${items.map((s, i) => `${i + 1}. ${s}`).join("\n")}` + `[Memory Profile]\n${items.map((s, i) => `${i + 1}. ${s}`).join("\n")}` ); newFacts.push(...items); } } - // Collect memories from both user (profile API) and project (search API) - // containers. Deduplicate by id when available, falling back to content. + // Deduplicate embedded search results by id, falling back to content. const seenKeys = new Set(); function dedupKey(id: string | undefined, text: string): string { - if (id) return `id:${id}`; - return `content:${text.toLowerCase().trim()}`; + const normalized = text.toLowerCase().trim(); + if (normalized) return `content:${normalized}`; + return id ? `id:${id}` : ""; } - const userMemories: string[] = []; + const allMemories: string[] = []; if (result.searchResults && result.searchResults.results.length > 0) { for (const r of result.searchResults.results) { const text = r.memory || ""; @@ -87,46 +81,20 @@ export function formatCombinedContext( const key = dedupKey(r.id, text); if (key && !seenKeys.has(key)) { seenKeys.add(key); - userMemories.push(text); + allMemories.push(text); } } } - const projectMemories: string[] = []; - if (projectSearchResult?.success && projectSearchResult.results && projectSearchResult.results.length > 0) { - for (const r of projectSearchResult.results) { - const text = r.memory ?? r.chunk ?? r.content ?? ""; - if (!text || seenFacts.has(normalizeFact(text))) continue; - const key = dedupKey(r.id, text); - if (key && !seenKeys.has(key)) { - seenKeys.add(key); - projectMemories.push(text); - } - } - } - - // Interleave user and project memories so neither source is dropped when - // the total exceeds maxMemories. Alternate picks: user, project, user, … - const allMemories: string[] = []; - let ui = 0; - let pi = 0; - while (allMemories.length < maxMemories && (ui < userMemories.length || pi < projectMemories.length)) { - if (ui < userMemories.length) { - allMemories.push(userMemories[ui++]); - } - if (allMemories.length < maxMemories && pi < projectMemories.length) { - allMemories.push(projectMemories[pi++]); - } - } - if (allMemories.length > 0) { - const memories = allMemories + const limitedMemories = allMemories.slice(0, maxMemories); + const memories = limitedMemories .map((m, i) => `${i + 1}. ${m}`) .filter((m) => m.trim().length > 2) .join("\n"); if (memories) { parts.push(`[Relevant Memories]\n${memories}`); - newFacts.push(...allMemories); + newFacts.push(...limitedMemories); } } @@ -148,7 +116,7 @@ export function formatContextForPrompt( if (profileResult.success) { const profileText = formatProfile(profileResult.profile, maxProfileItems); if (profileText) { - parts.push(`[User Profile]\n${profileText}`); + parts.push(`[Memory Profile]\n${profileText}`); } } diff --git a/src/services/resultMerge.ts b/src/services/resultMerge.ts new file mode 100644 index 0000000..763fce7 --- /dev/null +++ b/src/services/resultMerge.ts @@ -0,0 +1,118 @@ +import type { + ProfileWithSearchResult, + SearchResponse, + SearchResultItem, +} from "./client.js"; + +function normalize(value: unknown): string { + return String(value ?? "").toLowerCase().trim(); +} + +function dedupe(items: T[], getKey: (item: T) => string): T[] { + const seen = new Set(); + return items.filter((item) => { + const key = normalize(getKey(item)); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function memoryText(result: SearchResultItem): string { + return result.memory ?? result.chunk ?? result.content ?? String(result.context ?? ""); +} + +function searchKey(result: SearchResultItem): string { + const content = normalize(memoryText(result)); + if (content) return `content:${content}`; + return result.id ? `id:${result.id}` : ""; +} + +function score(result: SearchResultItem): number { + return result.similarity ?? result.score ?? -1; +} + +export function mergeSearchResponses( + responses: SearchResponse[], + limit: number, +): SearchResponse { + const successful = responses.filter((response) => response.success); + if (successful.length === 0) { + return { + success: false, + error: responses.find((response) => response.error)?.error || "All container searches failed", + results: [], + total: 0, + timing: 0, + }; + } + + const results = successful + .flatMap((response) => response.results ?? []) + .sort((a, b) => { + const scoreDiff = score(b) - score(a); + if (scoreDiff !== 0) return scoreDiff; + const aTime = a.updatedAt ? Date.parse(a.updatedAt) : 0; + const bTime = b.updatedAt ? Date.parse(b.updatedAt) : 0; + return bTime - aTime; + }); + const merged = dedupe(results, searchKey).slice(0, limit); + return { + success: true, + results: merged, + total: merged.length, + timing: Math.max(0, ...successful.map((response) => response.timing ?? 0)), + }; +} + +export function mergeProfileResults( + responses: ProfileWithSearchResult[], + limit: number, +): ProfileWithSearchResult { + const successful = responses.filter((response) => response.success && response.profile); + if (successful.length === 0) { + return { + success: false, + error: responses.find((response) => response.error)?.error || "All profile requests failed", + profile: null, + }; + } + + const staticFacts = dedupe( + successful.flatMap((response) => response.profile?.static ?? []), + (fact) => fact, + ); + const staticKeys = new Set(staticFacts.map(normalize)); + const dynamicFacts = dedupe( + successful.flatMap((response) => response.profile?.dynamic ?? []), + (fact) => fact, + ).filter((fact) => !staticKeys.has(normalize(fact))); + const mergedSearch = mergeSearchResponses( + successful.map((response) => ({ + success: true, + results: response.searchResults?.results ?? [], + total: response.searchResults?.total ?? 0, + timing: response.searchResults?.timing, + })), + limit, + ); + + return { + success: true, + profile: { static: staticFacts, dynamic: dynamicFacts }, + searchResults: + mergedSearch.results && mergedSearch.results.length > 0 + ? { + results: mergedSearch.results.map((result) => ({ + id: result.id, + memory: memoryText(result), + similarity: result.similarity, + title: result.title, + updatedAt: result.updatedAt, + })), + total: mergedSearch.total ?? mergedSearch.results.length, + timing: mergedSearch.timing, + } + : undefined, + }; +} diff --git a/src/services/tags.ts b/src/services/tags.ts index 2d3acdb..7044246 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -1,24 +1,18 @@ import { createHash } from "node:crypto"; import { execSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { hostname } from "node:os"; -import { basename, dirname, resolve, sep } from "node:path"; +import { basename, dirname, join, resolve, sep } from "node:path"; import { CONFIG } from "../config.js"; function sha256(input: string): string { return createHash("sha256").update(input).digest("hex").slice(0, 16); } -function getGitEmail(): string | null { - try { - const email = execSync("git config user.email", { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - return email || null; - } catch { - return null; - } -} +const repoInfoCache = new Map< + string, + { name: string | null; normalizedRemote: string | null } +>(); function getGitRoot(directory: string): string | null { const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; @@ -49,11 +43,7 @@ function getGitRoot(directory: string): string | null { } const resolved = resolve(directory, gitCommonDir); - - if ( - basename(resolved) === ".git" && - !resolved.includes(`${sep}.git${sep}`) - ) { + if (basename(resolved) === ".git" && !resolved.includes(`${sep}.git${sep}`)) { return dirname(resolved); } @@ -68,43 +58,251 @@ function getGitRoot(directory: string): string | null { } } -function getGitRepoName(directory: string): string | null { +function getProjectBasePath(directory: string): string { + return getGitRoot(directory) || resolve(directory); +} + +function getGitEmail(directory: string): string | null { + try { + const email = execSync("git config user.email", { + cwd: getProjectBasePath(directory), + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return email || null; + } catch { + return null; + } +} + +export function normalizeGitRemote(remoteUrl: string): string | null { + const raw = remoteUrl.trim(); + if (!raw) return null; + + let normalized: string; + if (/^[a-z][a-z\d+.-]*:\/\//i.test(raw)) { + try { + const parsed = new URL(raw); + normalized = parsed.protocol === "file:" + ? `file:${decodeURIComponent(parsed.pathname)}` + : `${parsed.hostname.toLowerCase()}${parsed.port ? `:${parsed.port}` : ""}/${parsed.pathname.replace(/^\/+/, "")}`; + } catch { + normalized = raw; + } + } else { + const scpStyle = raw.match(/^(?:[^@/]+@)?([^:]+):(.+)$/); + normalized = scpStyle + ? `${scpStyle[1].toLowerCase()}/${scpStyle[2]}` + : `file:${resolve(raw)}`; + } + + return normalized + .replace(/[?#].*$/, "") + .replace(/\/+$/, "") + .replace(/\.git$/i, "") + .replace(/\/{2,}/g, "/") + .toLowerCase(); +} + +function getGitRepoInfo(directory: string): { + name: string | null; + normalizedRemote: string | null; +} { + const cached = repoInfoCache.get(directory); + if (cached) return cached; + try { const remoteUrl = execSync("git remote get-url origin", { cwd: directory, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); - const match = remoteUrl.match(/[/:]([^/]+?)(?:\.git)?$/); - return match ? match[1] : null; + const normalizedRemote = normalizeGitRemote(remoteUrl); + const displayRemote = remoteUrl.replace(/\/+$/, "").replace(/\.git$/i, ""); + const separator = Math.max( + displayRemote.lastIndexOf("/"), + displayRemote.lastIndexOf(":"), + ); + const result = { + name: displayRemote.slice(separator + 1) || null, + normalizedRemote, + }; + repoInfoCache.set(directory, result); + return result; + } catch { + const result = { name: null, normalizedRemote: null }; + repoInfoCache.set(directory, result); + return result; + } +} + +function getGitRepoName(directory: string): string | null { + return getGitRepoInfo(directory).name; +} + +function loadClaudeProjectConfig(directory: string): { + personalContainerTag?: string; + repoContainerTag?: string; +} | null { + try { + const configPath = join( + getProjectBasePath(directory), + ".claude", + ".supermemory-claude", + "config.json", + ); + if (!existsSync(configPath)) return null; + return JSON.parse(readFileSync(configPath, "utf-8")) as { + personalContainerTag?: string; + repoContainerTag?: string; + }; } catch { return null; } } -export function getUserTag(): string { - if (CONFIG.userContainerTag) return CONFIG.userContainerTag; - const email = getGitEmail(); - if (email) return `${CONFIG.containerTagPrefix}_user_${sha256(email)}`; - const fallback = process.env.USER || process.env.USERNAME || hostname(); - return `${CONFIG.containerTagPrefix}_user_${sha256(fallback)}`; +export function sanitizeRepoName(name: string): string { + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); + return sanitized.slice(0, 95).replace(/_+$/g, "") || "unknown"; +} + +export function getGeneratedPersonalTag(directory: string): string { + return `user_project_${sha256(getProjectBasePath(directory))}`; +} + +export function getPersonalTag(directory: string): string { + return getProjectTag(directory); +} + +/** Backwards-compatible alias retained for callers that still say "user". */ +export function getUserTag(directory = process.cwd()): string { + return getPersonalTag(directory); +} + +export function getGeneratedProjectTag(directory: string): string { + const basePath = getProjectBasePath(directory); + const repoName = getGitRepoName(basePath) || basename(basePath) || "unknown"; + const shortName = sanitizeRepoName(repoName).slice(0, 72).replace(/_+$/g, ""); + return `repo_${shortName || "unknown"}__${getProjectIdentity(directory)}`; +} + +export function getLegacyGeneratedProjectTag(directory: string): string { + const basePath = getProjectBasePath(directory); + const repoName = getGitRepoName(basePath) || basename(basePath) || "unknown"; + return `repo_${sanitizeRepoName(repoName)}`; +} + +export function getProjectIdentity(directory: string): string { + const basePath = getProjectBasePath(directory); + const { normalizedRemote } = getGitRepoInfo(basePath); + const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; + let localIdentity = basePath; + try { + localIdentity = realpathSync.native(basePath); + } catch {} + return sha256( + !isolateWorktrees && normalizedRemote + ? normalizedRemote + : `path:${localIdentity}`, + ); } export function getProjectTag(directory: string): string { - if (CONFIG.projectContainerTag) return CONFIG.projectContainerTag; - const basePath = getGitRoot(directory) || directory; - return `${CONFIG.containerTagPrefix}_project_${sha256(basePath)}`; + return ( + loadClaudeProjectConfig(directory)?.repoContainerTag || + CONFIG.projectContainerTag || + getGeneratedProjectTag(directory) + ); } export function getProjectName(directory: string): string { - const gitRoot = getGitRoot(directory); - const basePath = gitRoot || directory; + const basePath = getProjectBasePath(directory); return getGitRepoName(basePath) || basename(basePath) || "unknown"; } -export function getTags(directory: string): { user: string; project: string } { +function getLegacyCodexUserTags(directory: string): string[] { + const identity = getGitEmail(directory) || process.env.USER || process.env.USERNAME || hostname(); + return [ + CONFIG.userContainerTag, + `${CONFIG.containerTagPrefix}_user_${sha256(identity)}`, + `codex_user_${sha256(identity)}`, + ].filter((tag): tag is string => !!tag); +} + +function getLegacyCodexProjectTags(directory: string): string[] { + const projectHash = sha256(getProjectBasePath(directory)); + return [ + CONFIG.projectContainerTag, + `${CONFIG.containerTagPrefix}_project_${projectHash}`, + `codex_project_${projectHash}`, + ].filter((tag): tag is string => !!tag); +} + +function uniqueTags(tags: Array): string[] { + return [ + ...new Set( + tags.filter( + (tag): tag is string => + typeof tag === "string" && tag.trim().length > 0, + ), + ), + ]; +} + +export function getPersonalReadTags(directory: string): string[] { + const projectHash = sha256(getProjectBasePath(directory)); + const claudeConfig = loadClaudeProjectConfig(directory); + return uniqueTags([ + getPersonalTag(directory), + claudeConfig?.personalContainerTag, + CONFIG.userContainerTag, + getGeneratedPersonalTag(directory), + `claudecode_project_${projectHash}`, + ...getLegacyCodexUserTags(directory), + ]); +} + +export function getProjectReadTags(directory: string): string[] { + return uniqueTags([ + getProjectTag(directory), + getGeneratedProjectTag(directory), + getLegacyGeneratedProjectTag(directory), + ...getLegacyCodexProjectTags(directory), + ]); +} + +export function getAllReadTags(directory: string): string[] { + return uniqueTags([ + ...getPersonalReadTags(directory), + ...getProjectReadTags(directory), + ]); +} + +export interface ResolvedTags { + canonical: string; + user: string; + project: string; + projectId: string; + projectName: string; + personalReads: string[]; + projectReads: string[]; + allReads: string[]; +} + +export function getTags(directory: string): ResolvedTags { + const canonical = getProjectTag(directory); return { - user: getUserTag(), - project: getProjectTag(directory), + canonical, + user: canonical, + project: canonical, + projectId: getProjectIdentity(directory), + projectName: getProjectName(directory), + personalReads: getPersonalReadTags(directory), + projectReads: getProjectReadTags(directory), + allReads: getAllReadTags(directory), }; } diff --git a/src/skills/add-memory.ts b/src/skills/add-memory.ts new file mode 100644 index 0000000..246ebc0 --- /dev/null +++ b/src/skills/add-memory.ts @@ -0,0 +1,52 @@ +import { isConfigured } from "../config.js"; +import { SupermemoryClient, USER_ENTITY_CONTEXT } from "../services/client.js"; +import { + getProjectIdentity, + getProjectName, + getProjectTag, +} from "../services/tags.js"; + +async function main(): Promise { + if (!isConfigured()) { + console.error( + "Supermemory is not authenticated.\n" + + "Run /supermemory-login to connect, or set SUPERMEMORY_CODEX_API_KEY in your shell profile.", + ); + process.exit(1); + } + + const content = process.argv.slice(2).join(" ").trim(); + if (!content) { + console.log('No content provided. Usage: node add-memory.js "content to remember"'); + process.exit(0); + } + + const cwd = process.cwd(); + const containerTag = getProjectTag(cwd); + const projectName = getProjectName(cwd); + const client = new SupermemoryClient(); + const result = await client.addMemory( + content, + containerTag, + { + type: "manual", + project: projectName, + sm_project_id: getProjectIdentity(cwd), + sm_scope: "personal", + sm_capture_mode: "explicit", + timestamp: new Date().toISOString(), + }, + { entityContext: USER_ENTITY_CONTEXT }, + ); + + if (!result.success) { + console.log(`Failed to add personal memory: ${result.error}`); + return; + } + console.log(`Personal memory added for ${projectName} (id: ${result.id})`); +} + +main().catch((error) => { + console.error(`Failed to add personal memory: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/src/skills/forget-memory.ts b/src/skills/forget-memory.ts index 301b8cc..d214674 100644 --- a/src/skills/forget-memory.ts +++ b/src/skills/forget-memory.ts @@ -1,6 +1,6 @@ import { isConfigured, validateContainerTag } from "../config.js"; import { SupermemoryClient } from "../services/client.js"; -import { getProjectTag, getUserTag } from "../services/tags.js"; +import { getTags } from "../services/tags.js"; function parseArgs(args: string[]): { content: string; containerTag?: string } { let containerTag: string | undefined; @@ -54,27 +54,24 @@ async function main(): Promise { console.log(`Failed to forget memory from container '${containerTag}': ${result.error}`); } } else { - const projectTag = getProjectTag(process.cwd()); - const userTag = getUserTag(); - - const [projectResult, userResult] = await Promise.all([ - client.forgetMemory(content, projectTag), - client.forgetMemory(content, userTag), - ]); + const tags = getTags(process.cwd()); + const targetTags = tags.allReads; + const results = await Promise.all( + targetTags.map(async (tag) => ({ + tag, + result: await client.forgetMemory(content, tag), + })), + ); const forgotten: string[] = []; const errors: string[] = []; - if (projectResult.success) { - forgotten.push(projectResult.id ? `project (id: ${projectResult.id})` : "project"); - } else { - errors.push(`project: ${projectResult.error}`); - } - - if (userResult.success) { - forgotten.push(userResult.id ? `user (id: ${userResult.id})` : "user"); - } else { - errors.push(`user: ${userResult.error}`); + for (const { tag, result } of results) { + if (result.success) { + forgotten.push(result.id ? `${tag} (id: ${result.id})` : tag); + } else { + errors.push(`${tag}: ${result.error}`); + } } if (forgotten.length > 0) { diff --git a/src/skills/profile-memory.ts b/src/skills/profile-memory.ts index 5132cd2..459312c 100644 --- a/src/skills/profile-memory.ts +++ b/src/skills/profile-memory.ts @@ -12,7 +12,11 @@ async function main() { const cwd = process.cwd(); const tags = getTags(cwd); const client = new SupermemoryClient(); - const result = await client.getProfile(tags.user); + const result = await client.getProfileScopedMany( + tags.canonical, + tags.personalReads, + "personal", + ); if (!result.success || !result.profile) { console.log("No profile available yet."); diff --git a/src/skills/save-memory.ts b/src/skills/save-memory.ts index 35ca32d..f2a96d8 100644 --- a/src/skills/save-memory.ts +++ b/src/skills/save-memory.ts @@ -1,6 +1,10 @@ import { CONFIG, isConfigured, validateContainerTag } from "../config.js"; import { PROJECT_ENTITY_CONTEXT, SupermemoryClient } from "../services/client.js"; -import { getProjectName, getProjectTag } from "../services/tags.js"; +import { + getProjectIdentity, + getProjectName, + getProjectTag, +} from "../services/tags.js"; function parseArgs(args: string[]): { content: string; containerTag?: string } { let containerTag: string | undefined; @@ -63,6 +67,7 @@ async function main(): Promise { const client = new SupermemoryClient(); const projectTag = getProjectTag(process.cwd()); const projectName = getProjectName(process.cwd()); + const projectId = getProjectIdentity(process.cwd()); const effectiveTag = containerTag || projectTag; try { @@ -70,6 +75,9 @@ async function main(): Promise { type: "project-knowledge" as const, source: "skill", project: projectName, + sm_project_id: projectId, + sm_scope: "project", + sm_capture_mode: "explicit", timestamp: new Date().toISOString(), }; @@ -79,7 +87,7 @@ async function main(): Promise { if (result.success) { if (!containerTag) { - await client.updateContainerTagName(projectTag, `Codex Β· ${projectName}`); + await client.updateContainerTagName(projectTag, `Agents Β· ${projectName}`); } const tagLabel = containerTag ? `container '${containerTag}'` : `project '${effectiveTag}'`; console.log(`Memory saved (id: ${result.id}) to ${tagLabel}`); diff --git a/src/skills/search-memory.ts b/src/skills/search-memory.ts index 51fd30d..101e9cf 100644 --- a/src/skills/search-memory.ts +++ b/src/skills/search-memory.ts @@ -1,7 +1,11 @@ import { CONFIG, isConfigured, validateContainerTag } from "../config.js"; -import { SupermemoryClient, type SearchResponse } from "../services/client.js"; +import { + SupermemoryClient, + type ProfileWithSearchResult, + type SearchResponse, +} from "../services/client.js"; import { formatContextForPrompt } from "../services/context.js"; -import { getProjectTag, getUserTag } from "../services/tags.js"; +import { getTags } from "../services/tags.js"; type Scope = "user" | "project" | "both" | "custom"; @@ -57,8 +61,7 @@ async function main(): Promise { } const client = new SupermemoryClient(); - const userTag = getUserTag(); - const projectTag = getProjectTag(process.cwd()); + const tags = getTags(process.cwd()); if (containerTag) { const validationError = validateContainerTag(containerTag); @@ -79,31 +82,20 @@ async function main(): Promise { return; } } else if (scope === "both") { - const [userResult, projectResult] = await Promise.all([ - client.searchMemories(query, userTag), - client.searchMemories(query, projectTag), - ]); - - // Surface errors when all searches fail - if (!userResult.success && !projectResult.success) { - console.log(`Failed to search memories: ${userResult.error}`); + searchResult = await client.searchMemoriesMany(query, tags.allReads); + if (!searchResult.success) { + console.log(`Failed to search memories: ${searchResult.error}`); return; } - - const combinedResults = [ - ...(userResult.success ? userResult.results ?? [] : []), - ...(projectResult.success ? projectResult.results ?? [] : []), - ]; - - searchResult = { - success: true, - results: combinedResults, - total: combinedResults.length, - timing: 0, - }; } else { - const tag = scope === "user" ? userTag : projectTag; - searchResult = await client.searchMemories(query, tag); + const readTags = scope === "user" ? tags.personalReads : tags.projectReads; + const metadataScope = scope === "user" ? "personal" : "project"; + searchResult = await client.searchMemoriesScoped( + query, + tags.canonical, + readTags, + metadataScope, + ); // Surface error for single-scope search failure if (!searchResult.success) { @@ -112,9 +104,27 @@ async function main(): Promise { } } - const profileResult = includeProfile - ? await client.getProfile(userTag, query) - : { success: false as const, profile: null }; + let profileResult: ProfileWithSearchResult = { + success: false, + profile: null, + }; + if (includeProfile && scope === "both") { + profileResult = await client.getProfileMany(tags.allReads, query); + } else if (includeProfile && scope === "user") { + profileResult = await client.getProfileScopedMany( + tags.canonical, + tags.personalReads, + "personal", + query, + ); + } else if (includeProfile && scope === "project") { + profileResult = await client.getProfileScopedMany( + tags.canonical, + tags.projectReads, + "project", + query, + ); + } const output = formatContextForPrompt( searchResult, diff --git a/src/skills/status.ts b/src/skills/status.ts index 413feb5..adaf353 100644 --- a/src/skills/status.ts +++ b/src/skills/status.ts @@ -102,11 +102,11 @@ async function main(): Promise { lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); lines.push(`API key: ${maskKey(apiKey)} (${getKeySource()})`); lines.push(`API URL: ${API_URL}`); - lines.push(`Memory scope: current project + user profile`); + lines.push(`Memory scope: one project container with metadata scopes`); lines.push(`Recall mode: auto-recall on every prompt`); lines.push(`Capture cadence: ${CONFIG.autoSaveEveryTurns > 0 ? `every ${CONFIG.autoSaveEveryTurns} turn${CONFIG.autoSaveEveryTurns === 1 ? "" : "s"} + session end` : "session end only"}`); - lines.push(`Project tag: ${tags.project}`); - lines.push(`User tag: ${tags.user}`); + lines.push(`Project container: ${tags.canonical}`); + lines.push(`Reads (including legacy): ${tags.allReads.join(", ")}`); if (!isConfigured()) { lines[2] = "Connected: no"; @@ -118,7 +118,7 @@ async function main(): Promise { const client = new SupermemoryClient(); const [profileResult, accountInfo] = await Promise.all([ - client.getProfile(tags.user), + client.getProfileMany(tags.allReads), getAccountInfo(), ]); diff --git a/src/skills/supermemory-add/SKILL.md b/src/skills/supermemory-add/SKILL.md new file mode 100644 index 0000000..3b70812 --- /dev/null +++ b/src/skills/supermemory-add/SKILL.md @@ -0,0 +1,15 @@ +--- +name: supermemory-add +description: Add a personal memory for the current project. Use when the user explicitly asks Codex to remember a preference, intention, learning, or personal context rather than shared project knowledge. +allowed-tools: Bash(node:*) +--- + +# Supermemory Add + +Save a personal memory associated with the current project: + +```bash +node ~/.codex/supermemory/add-memory.js "MEMORY_CONTENT" +``` + +Use this for personal preferences, goals, learnings, and explicit β€œremember this” requests. For architecture, conventions, setup, bug fixes, or decisions that should describe the repository itself, use `/supermemory-save` instead. diff --git a/src/types/index.ts b/src/types/index.ts index b7a740a..9e1b18e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,7 @@ export type MemoryType = | "preference" | "learned-pattern" | "conversation" + | "manual" | "project-knowledge"; export type ConversationRole = "user" | "assistant" | "system" | "tool"; diff --git a/test/unit.mjs b/test/unit.mjs index 2e6f788..4ee26b2 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; -import { basename, dirname, join, resolve, sep } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import * as TOML from "@iarna/toml"; @@ -67,10 +67,10 @@ describe("container tags", () => { return result.stdout.trim(); } - function getProjectTagFor(cwd, home, extraEnv = {}) { + function getPersonalTagFor(cwd, home, extraEnv = {}) { const script = ` - import { getProjectTag } from ${JSON.stringify(tagsModule)}; - console.log(getProjectTag(process.argv.at(-1))); + import { getPersonalTag } from ${JSON.stringify(tagsModule)}; + console.log(getPersonalTag(process.argv.at(-1))); `; const result = spawnSync("node", ["--input-type=module", "-e", script, cwd], { env: { @@ -81,11 +81,11 @@ describe("container tags", () => { }, encoding: "utf-8", }); - assert.equal(result.status, 0, `getProjectTag failed: ${result.stderr}`); + assert.equal(result.status, 0, `getPersonalTag failed: ${result.stderr}`); return result.stdout.trim(); } - test("project tag uses the shared git common directory for worktrees", (t) => { + test("canonical tag uses the shared git common directory for worktrees", (t) => { const tmpDir = makeTmpDir(); t.after(() => rmSync(tmpDir, { recursive: true, force: true })); @@ -102,21 +102,13 @@ describe("container tags", () => { runGit(["add", "README.md"], repoDir); runGit(["commit", "-m", "initial"], repoDir); runGit(["worktree", "add", "--detach", worktreeDir, "HEAD"], repoDir); - const gitCommonDir = runGit(["rev-parse", "--git-common-dir"], worktreeDir); - const resolvedCommonDir = resolve(worktreeDir, gitCommonDir); - const expectedBasePath = - basename(resolvedCommonDir) === ".git" && - !resolvedCommonDir.includes(`${sep}.git${sep}`) - ? dirname(resolvedCommonDir) - : runGit(["rev-parse", "--show-toplevel"], worktreeDir); - assert.equal( - getProjectTagFor(worktreeDir, homeDir), - `codex_project_${hash16(expectedBasePath)}` + getPersonalTagFor(worktreeDir, homeDir), + getPersonalTagFor(repoDir, homeDir), ); }); - test("project tag can still isolate individual worktrees when requested", (t) => { + test("canonical tag can still isolate individual worktrees when requested", (t) => { const tmpDir = makeTmpDir(); t.after(() => rmSync(tmpDir, { recursive: true, force: true })); @@ -133,12 +125,118 @@ describe("container tags", () => { runGit(["add", "README.md"], repoDir); runGit(["commit", "-m", "initial"], repoDir); runGit(["worktree", "add", "--detach", worktreeDir, "HEAD"], repoDir); - const worktreeRoot = runGit(["rev-parse", "--show-toplevel"], worktreeDir); + assert.notEqual( + getPersonalTagFor(worktreeDir, homeDir, { SUPERMEMORY_ISOLATE_WORKTREES: "true" }), + getPersonalTagFor(repoDir, homeDir), + ); + }); - assert.equal( - getProjectTagFor(worktreeDir, homeDir, { SUPERMEMORY_ISOLATE_WORKTREES: "true" }), - `codex_project_${hash16(worktreeRoot)}` + test("uses unified tags and includes legacy Claude and Codex reads", (t) => { + const tmpDir = makeTmpDir(); + t.after(() => rmSync(tmpDir, { recursive: true, force: true })); + const repoDir = join(tmpDir, "Example Project"); + const homeDir = join(tmpDir, "home"); + mkdirSync(repoDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + runGit(["init"], repoDir); + runGit(["config", "user.email", "test@example.com"], repoDir); + runGit(["remote", "add", "origin", "git@github.com:acme/Example.Project.git"], repoDir); + const root = runGit(["rev-parse", "--show-toplevel"], repoDir); + const script = ` + import { getTags } from ${JSON.stringify(tagsModule)}; + console.log(JSON.stringify(getTags(process.argv.at(-1)))); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script, repoDir], { + env: { + ...process.env, + HOME: homeDir, + SUPERMEMORY_CODEX_API_KEY: "sm_test", + }, + encoding: "utf-8", + }); + assert.equal(result.status, 0, result.stderr); + const tags = JSON.parse(result.stdout); + const pathHash = hash16(root); + const projectHash = hash16("github.com/acme/example.project"); + const canonicalTag = `repo_example_project__${projectHash}`; + assert.equal(tags.user, canonicalTag); + assert.equal(tags.project, canonicalTag); + assert.equal(tags.canonical, canonicalTag); + assert.equal(tags.projectId, projectHash); + assert.equal(tags.projectName, "Example.Project"); + assert.deepEqual(tags.personalReads, [ + canonicalTag, + `user_project_${pathHash}`, + `claudecode_project_${pathHash}`, + `codex_user_${hash16("test@example.com")}`, + ]); + assert.deepEqual(tags.projectReads, [ + canonicalTag, + "repo_example_project", + `codex_project_${pathHash}`, + ]); + }); + + test("preserves explicit Codex container overrides for shared writes", (t) => { + const tmpDir = makeTmpDir(); + t.after(() => rmSync(tmpDir, { recursive: true, force: true })); + const repoDir = join(tmpDir, "repo"); + const homeDir = join(tmpDir, "home"); + const codexDir = join(homeDir, ".codex"); + mkdirSync(repoDir, { recursive: true }); + mkdirSync(codexDir, { recursive: true }); + writeFileSync( + join(codexDir, "supermemory.json"), + JSON.stringify({ + userContainerTag: "shared_personal", + projectContainerTag: "shared_project", + }), ); + runGit(["init"], repoDir); + + const script = ` + import { getTags } from ${JSON.stringify(tagsModule)}; + console.log(JSON.stringify(getTags(process.argv.at(-1)))); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script, repoDir], { + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + SUPERMEMORY_CODEX_API_KEY: "sm_test", + }, + encoding: "utf-8", + }); + assert.equal(result.status, 0, result.stderr); + const tags = JSON.parse(result.stdout); + assert.equal(tags.user, "shared_project"); + assert.equal(tags.project, "shared_project"); + assert.equal(tags.personalReads[0], "shared_project"); + assert.ok(tags.personalReads.includes("shared_personal")); + assert.equal(tags.projectReads[0], "shared_project"); + }); +}); + +describe("cross-container result merging", () => { + const mergeModule = new URL("../dist/services/resultMerge.js", import.meta.url).href; + + test("globally ranks and deduplicates legacy results", () => { + const script = ` + import { mergeSearchResponses } from ${JSON.stringify(mergeModule)}; + const merged = mergeSearchResponses([ + { success: true, results: [{ id: "old", memory: "A", similarity: 0.4 }] }, + { success: true, results: [ + { id: "best", memory: "B", similarity: 0.9 }, + { id: "new", memory: "A", similarity: 0.8 } + ] } + ], 10); + console.log(JSON.stringify(merged.results.map((item) => item.id))); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script], { + encoding: "utf-8", + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), ["best", "new"]); }); }); @@ -253,12 +351,22 @@ describe("entity context wiring", () => { test("automatic capture writes user entity context", () => { const content = readFileSync(new URL("../src/services/capture.ts", import.meta.url), "utf-8"); assert.ok(content.includes("entityContext: USER_ENTITY_CONTEXT")); + assert.ok(content.includes('sm_scope: "personal"')); + assert.ok(content.includes("project: tags.projectName")); }); test("manual save writes project entity context", () => { const content = readFileSync(new URL("../src/skills/save-memory.ts", import.meta.url), "utf-8"); assert.ok(content.includes("PROJECT_ENTITY_CONTEXT")); assert.ok(content.includes("entityContext: getEntityContext(containerTag)")); + assert.ok(content.includes('sm_scope: "project"')); + }); + + test("personal add writes the unified personal scope", () => { + const content = readFileSync(new URL("../src/skills/add-memory.ts", import.meta.url), "utf-8"); + assert.ok(content.includes("getProjectTag")); + assert.ok(content.includes('sm_scope: "personal"')); + assert.ok(content.includes("entityContext: USER_ENTITY_CONTEXT")); }); }); @@ -373,7 +481,7 @@ describe("integration: install/uninstall", () => { assert.equal(result.status, 0, `install should exit 0: ${result.stderr}`); const skillsDir = join(codexDir, "skills"); - for (const skillName of ["supermemory-search", "supermemory-save", "supermemory-forget", "supermemory-status", "supermemory-login", "supermemory-logout"]) { + for (const skillName of ["supermemory-search", "supermemory-add", "supermemory-save", "supermemory-forget", "supermemory-status", "supermemory-login", "supermemory-logout"]) { const skillMd = join(skillsDir, skillName, "SKILL.md"); assert.ok(existsSync(skillMd), `${skillName}/SKILL.md should exist`); const content = readFileSync(skillMd, "utf-8"); @@ -393,7 +501,7 @@ describe("integration: install/uninstall", () => { assert.equal(uninstallResult.status, 0, `uninstall should exit 0: ${uninstallResult.stderr}`); const skillsDir = join(codexDir, "skills"); - for (const skillName of ["supermemory-search", "supermemory-save", "supermemory-forget", "supermemory-status", "supermemory-login", "supermemory-logout"]) { + for (const skillName of ["supermemory-search", "supermemory-add", "supermemory-save", "supermemory-forget", "supermemory-status", "supermemory-login", "supermemory-logout"]) { assert.ok( !existsSync(join(skillsDir, skillName)), `${skillName} skill dir should be removed` @@ -594,8 +702,9 @@ describe("flush hook Stop payload", () => { // They reuse SupermemoryClient + tags, so we only smoke-test the CLI shape: // argument parsing, the unconfigured-fallback message, and clean exit codes. -describe("skill scripts: search/save/forget/status/logout", () => { +describe("skill scripts: search/add/save/forget/status/logout", () => { const searchBin = fileURLToPath(new URL("../dist/skills/search-memory.js", import.meta.url)); + const addBin = fileURLToPath(new URL("../dist/skills/add-memory.js", import.meta.url)); const saveBin = fileURLToPath(new URL("../dist/skills/save-memory.js", import.meta.url)); const forgetBin = fileURLToPath(new URL("../dist/skills/forget-memory.js", import.meta.url)); const statusBin = fileURLToPath(new URL("../dist/skills/status.js", import.meta.url)); @@ -638,6 +747,12 @@ describe("skill scripts: search/save/forget/status/logout", () => { assert.match(result.stderr, /Supermemory is not authenticated/); }); + test("add-memory prints not-configured message and exits 1 when no API key", (t) => { + const result = runSkillUnconfigured(t, addBin, ["some content"]); + assert.equal(result.status, 1); + assert.match(result.stderr, /Supermemory is not authenticated/); + }); + test("forget-memory prints not-configured message and exits 1 when no API key", (t) => { const result = runSkillUnconfigured(t, forgetBin, ["some content"]); assert.equal(result.status, 1); @@ -688,6 +803,13 @@ describe("skill scripts: search/save/forget/status/logout", () => { assert.match(result.stdout, /node save-memory\.js/); }); + test("add-memory prints usage and exits 0 when no content is given", (t) => { + const result = runSkillNoArgs(t, addBin); + assert.equal(result.status, 0); + assert.match(result.stdout, /No content provided/); + assert.match(result.stdout, /node add-memory\.js/); + }); + test("forget-memory prints usage and exits 0 when no content is given", (t) => { const result = runSkillNoArgs(t, forgetBin); assert.equal(result.status, 0); @@ -784,12 +906,13 @@ describe("formatCombinedContext interleaving", () => { }); }); -// ─── dedup by id β€” memory deduplication ────────────────────────────────────── +// ─── memory deduplication across canonical and legacy containers ───────────── -describe("memory deduplication by id", () => { +describe("memory deduplication", () => { function dedupKey(id, text) { - if (id) return `id:${id}`; - return `content:${text.toLowerCase().trim()}`; + const normalized = text.toLowerCase().trim(); + if (normalized) return `content:${normalized}`; + return id ? `id:${id}` : ""; } test("deduplicates by id when available", () => { @@ -807,7 +930,7 @@ describe("memory deduplication by id", () => { return true; }); - assert.equal(result.length, 2, "should deduplicate by id"); + assert.equal(result.length, 2, "should deduplicate identical content"); }); test("falls back to content-based dedup when id is missing", () => { @@ -828,7 +951,7 @@ describe("memory deduplication by id", () => { assert.equal(result.length, 2, "should deduplicate by lowercased content"); }); - test("does not over-deduplicate when ids differ but content matches", () => { + test("deduplicates legacy copies when ids differ but content matches", () => { const seen = new Set(); const memories = [ { id: "mem-1", memory: "React components" }, @@ -842,6 +965,6 @@ describe("memory deduplication by id", () => { return true; }); - assert.equal(result.length, 2, "should keep both since ids differ"); + assert.equal(result.length, 1, "should keep one logical memory"); }); });