diff --git a/README.md b/README.md index 20e8f83..7ab7b0d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ 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. +- 📦 **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. - 🔒 **Privacy-aware** — anything wrapped in `...` is redacted @@ -94,6 +99,9 @@ Drop this file in to override defaults: | `signalExtraction` | `boolean` | `false` | Enable signal-based filtering (only capture turns with keywords like "prefer", "decided"). | | `signalKeywords` | `string[]` | (defaults) | Keywords that trigger signal extraction. | | `signalTurnsBefore` | `number` | `3` | Include N turns before a signal for context. | +| `enableCustomContainers` | `boolean` | `false` | Enable AI-driven routing to custom containers. | +| `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 @@ -116,17 +124,63 @@ npx codex-supermemory status # show current install status ## Skills (fallback commands) -These Codex skills are available as explicit commands when you need more control: +These Codex skills are available as explicit commands when you need more control. +All memory skills support `--container ` to target a specific custom container. -| Skill | Usage | Description | -| ---------------------- | ------------------------------------------ | ---------------------------------------- | -| `/supermemory-search` | `/supermemory-search ` | Search memories manually. | -| `/supermemory-save` | `/supermemory-save ` | Save a specific memory explicitly. | -| `/supermemory-forget` | `/supermemory-forget ` | Remove a memory. | -| `/supermemory-login` | `/supermemory-login` | Re-authenticate with Supermemory. | +| Skill | Usage | Description | +| ---------------------- | ----------------------------------------------------------- | ---------------------------------------- | +| `/supermemory-search` | `/supermemory-search [--container ] ` | Search memories manually. | +| `/supermemory-save` | `/supermemory-save [--container ] ` | Save a specific memory explicitly. | +| `/supermemory-forget` | `/supermemory-forget [--container ] ` | Remove a memory. | +| `/supermemory-login` | `/supermemory-login` | Re-authenticate with Supermemory. | Skills are fallback commands — the hooks handle most use cases automatically. +## Custom Container Tags + +Custom container tags let you organize memories into separate buckets (e.g., `work`, +`personal`, `code_style`). The AI reads the container descriptions from your config +and automatically picks the right container when saving memories. + +### Setup + +Add these fields to `~/.codex/supermemory.json`: + +```json +{ + "enableCustomContainers": true, + "customContainers": [ + { "tag": "personal", "description": "Personal life — family, health, hobbies, routines" }, + { "tag": "work", "description": "Work-related — projects, deadlines, meetings, colleagues" }, + { "tag": "code_style", "description": "Coding preferences — languages, tools, patterns, conventions" } + ], + "customContainerInstructions": "Route coding preferences to code_style. Personal topics to personal. Default to project container for ambiguous content." +} +``` + +### How it works + +1. You define containers with a `tag` (identifier) and a `description` (plain English + explaining what belongs there). +2. On every prompt, the container catalog is injected into the AI's context so it knows + what containers are available. +3. When the AI saves a memory (via `/supermemory-save`), it picks the best matching + container based on the descriptions and uses `--container `. +4. When searching or forgetting, the AI can also target specific containers. +5. Automatic capture (background saving) always goes to the default project/user + containers — only explicit saves get routed to custom containers. + +Each container tag automatically becomes a **Space** on the +[Supermemory dashboard](https://app.supermemory.ai), so you can view and manage +memories organized by category. + +### Container config reference + +| Field | Type | Description | +| ------------------ | -------- | -------------------------------------------------- | +| `tag` | `string` | Unique identifier for the container (e.g. `work`). | +| `description` | `string` | Plain English description for AI routing. | + ## Privacy Anything wrapped in `...` is replaced with `[REDACTED]` before diff --git a/src/config.ts b/src/config.ts index f753d61..25be6ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,11 @@ import { loadCredentials } from "./services/auth.js"; const CONFIG_FILE = join(homedir(), ".codex", "supermemory.json"); +export interface CustomContainer { + tag: string; + description: string; +} + interface CodexSupermemoryConfig { apiKey?: string; similarityThreshold?: number; @@ -22,6 +27,10 @@ interface CodexSupermemoryConfig { signalTurnsBefore?: number; // Auto-save interval autoSaveEveryTurns?: number; + // Custom container routing + enableCustomContainers?: boolean; + customContainers?: CustomContainer[]; + customContainerInstructions?: string; } const DEFAULT_SIGNAL_KEYWORDS = [ @@ -130,6 +139,13 @@ export const CONFIG = { signalTurnsBefore: fileConfig.signalTurnsBefore ?? DEFAULTS.signalTurnsBefore, // Auto-save interval autoSaveEveryTurns: fileConfig.autoSaveEveryTurns ?? DEFAULTS.autoSaveEveryTurns, + // Custom container routing + enableCustomContainers: fileConfig.enableCustomContainers ?? false, + customContainers: (fileConfig.customContainers ?? []).filter( + (c): c is CustomContainer => + !!c && typeof c.tag === "string" && typeof c.description === "string", + ), + customContainerInstructions: fileConfig.customContainerInstructions ?? "", }; export function isConfigured(): boolean { @@ -151,3 +167,49 @@ export function getSignalConfig(): { turnsBefore: CONFIG.signalTurnsBefore, }; } + +export function getContainerCatalog(): string | null { + if (!CONFIG.enableCustomContainers || CONFIG.customContainers.length === 0) { + return null; + } + + const lines: string[] = []; + lines.push("Custom memory containers are available for organizing memories:"); + lines.push(""); + for (const c of CONFIG.customContainers) { + lines.push(`- \`${c.tag}\`: ${c.description}`); + } + + if (CONFIG.customContainerInstructions) { + lines.push(""); + lines.push(CONFIG.customContainerInstructions); + } + + lines.push(""); + lines.push( + "When saving memories with /supermemory-save, use --container to route to a specific container.", + ); + lines.push( + "When searching with /supermemory-search, use --container to search a specific container.", + ); + lines.push( + "When forgetting with /supermemory-forget, use --container to target a specific container.", + ); + lines.push("If no container is specified, memories go to the default project/user containers."); + + return lines.join("\n"); +} + +export function validateContainerTag(tag: string): string | null { + if (!CONFIG.enableCustomContainers || CONFIG.customContainers.length === 0) { + return "Custom containers are not enabled. Remove --container or set enableCustomContainers in config."; + } + + const validTags = CONFIG.customContainers.map((c) => c.tag); + if (validTags.includes(tag)) { + return null; + } + + const validList = validTags.map((t) => `'${t}'`).join(", "); + return `Unknown container tag '${tag}'. Valid containers: ${validList}`; +} diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index 5bc6bfa..a248960 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -1,7 +1,7 @@ import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; -import { isConfigured, CONFIG, reloadApiKey } from "../config.js"; +import { isConfigured, CONFIG, reloadApiKey, getContainerCatalog } from "../config.js"; import { SupermemoryClient } from "../services/client.js"; import { getTags } from "../services/tags.js"; import { formatCombinedContext } from "../services/context.js"; @@ -140,9 +140,26 @@ async function main() { seenCount: seen.size, }); + const containerCatalog = getContainerCatalog(); + if (newFacts.length > 0) { addSeenFacts(sessionId, newFacts); - exitWithContext(`[SUPERMEMORY CONTEXT]\n${text}\n[END SUPERMEMORY CONTEXT]`); + let additionalContext = `[SUPERMEMORY CONTEXT]\n${text}\n[END SUPERMEMORY CONTEXT]`; + + if (containerCatalog) { + additionalContext += `\n\n[SUPERMEMORY CONTAINERS]\n${containerCatalog}\n[END SUPERMEMORY CONTAINERS]`; + } + + log("recall: emit context", { + additionalContextLength: additionalContext.length, + }); + exitWithContext(additionalContext); + } else if (containerCatalog) { + const additionalContext = `[SUPERMEMORY CONTAINERS]\n${containerCatalog}\n[END SUPERMEMORY CONTAINERS]`; + log("recall: emit container catalog only", { + additionalContextLength: additionalContext.length, + }); + exitWithContext(additionalContext); } else { exitWithContext(""); } diff --git a/src/services/capture.ts b/src/services/capture.ts index 39b9857..d5fb0c0 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -122,7 +122,12 @@ export async function captureEntries( }); const transcript = formatTranscript(signalEntries); - const content = `[Session ${sessionId}]\n${transcript}`; + const rawContent = `[Session ${sessionId}]\n${transcript}`; + + const content = rawContent + .replace(/\[SUPERMEMORY CONTAINERS\][\s\S]*?\[END SUPERMEMORY CONTAINERS\]\s*/g, "") + .replace(/[\s\S]*?<\/supermemory-containers>\s*/g, "") + .trim(); const metadata = { type: "conversation" as const, diff --git a/src/services/client.ts b/src/services/client.ts index 36aaa71..3acc06c 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -181,7 +181,11 @@ export class SupermemoryClient { metadata?: { type?: MemoryType; tool?: string; [key: string]: unknown }, options?: { customId?: string } ) { - log("addMemory: start", { containerTag, contentLength: content.length, customId: options?.customId }); + log("addMemory: start", { + containerTag, + contentLength: content.length, + customId: options?.customId, + }); try { const payload: { content: string; diff --git a/src/skills/forget-memory.ts b/src/skills/forget-memory.ts index 315561c..301b8cc 100644 --- a/src/skills/forget-memory.ts +++ b/src/skills/forget-memory.ts @@ -1,7 +1,22 @@ -import { isConfigured } from "../config.js"; +import { isConfigured, validateContainerTag } from "../config.js"; import { SupermemoryClient } from "../services/client.js"; import { getProjectTag, getUserTag } from "../services/tags.js"; +function parseArgs(args: string[]): { content: string; containerTag?: string } { + let containerTag: string | undefined; + const contentParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--container" && i + 1 < args.length) { + containerTag = args[++i]; + } else { + contentParts.push(args[i]); + } + } + + return { content: contentParts.join(" "), containerTag }; +} + async function main(): Promise { if (!isConfigured()) { console.error( @@ -11,45 +26,62 @@ async function main(): Promise { process.exit(1); } - const content = process.argv.slice(2).join(" "); + const { content, containerTag } = parseArgs(process.argv.slice(2)); if (!content.trim()) { console.log( - 'No content provided. Usage: node forget-memory.js "content to forget"' + 'No content provided. Usage: node forget-memory.js [--container ] "content to forget"' ); process.exit(0); } const client = new SupermemoryClient(); - const projectTag = getProjectTag(process.cwd()); - const userTag = getUserTag(); + + if (containerTag) { + const validationError = validateContainerTag(containerTag); + if (validationError) { + console.log(validationError); + process.exit(1); + } + } try { - // Forget from both project and user scopes since memories may exist in either. - const [projectResult, userResult] = await Promise.all([ - client.forgetMemory(content, projectTag), - client.forgetMemory(content, userTag), - ]); + if (containerTag) { + const result = await client.forgetMemory(content, containerTag); + if (result.success) { + console.log(`Memory forgotten from container '${containerTag}'${result.id ? ` (id: ${result.id})` : ""}`); + } else { + console.log(`Failed to forget memory from container '${containerTag}': ${result.error}`); + } + } else { + const projectTag = getProjectTag(process.cwd()); + const userTag = getUserTag(); - const forgotten: string[] = []; - const errors: string[] = []; + const [projectResult, userResult] = await Promise.all([ + client.forgetMemory(content, projectTag), + client.forgetMemory(content, userTag), + ]); - if (projectResult.success) { - forgotten.push(projectResult.id ? `project (id: ${projectResult.id})` : "project"); - } else { - errors.push(`project: ${projectResult.error}`); - } + const forgotten: string[] = []; + const errors: string[] = []; - if (userResult.success) { - forgotten.push(userResult.id ? `user (id: ${userResult.id})` : "user"); - } else { - errors.push(`user: ${userResult.error}`); - } + if (projectResult.success) { + forgotten.push(projectResult.id ? `project (id: ${projectResult.id})` : "project"); + } else { + errors.push(`project: ${projectResult.error}`); + } - if (forgotten.length > 0) { - console.log(`Memory forgotten from: ${forgotten.join(", ")}`); - } else { - console.log(`Failed to forget memory: ${errors.join("; ")}`); + if (userResult.success) { + forgotten.push(userResult.id ? `user (id: ${userResult.id})` : "user"); + } else { + errors.push(`user: ${userResult.error}`); + } + + if (forgotten.length > 0) { + console.log(`Memory forgotten from: ${forgotten.join(", ")}`); + } else { + console.log(`Failed to forget memory: ${errors.join("; ")}`); + } } } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/skills/save-memory.ts b/src/skills/save-memory.ts index 37bd2f1..076e272 100644 --- a/src/skills/save-memory.ts +++ b/src/skills/save-memory.ts @@ -1,7 +1,22 @@ -import { isConfigured } from "../config.js"; +import { isConfigured, validateContainerTag } from "../config.js"; import { SupermemoryClient } from "../services/client.js"; import { getProjectTag } from "../services/tags.js"; +function parseArgs(args: string[]): { content: string; containerTag?: string } { + let containerTag: string | undefined; + const contentParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--container" && i + 1 < args.length) { + containerTag = args[++i]; + } else { + contentParts.push(args[i]); + } + } + + return { content: contentParts.join(" "), containerTag }; +} + async function main(): Promise { if (!isConfigured()) { console.error( @@ -11,15 +26,23 @@ async function main(): Promise { process.exit(1); } - const content = process.argv.slice(2).join(" "); + const { content, containerTag } = parseArgs(process.argv.slice(2)); if (!content.trim()) { - console.log('No content provided. Usage: node save-memory.js "content to save"'); + console.log('No content provided. Usage: node save-memory.js [--container ] "content to save"'); process.exit(0); } + if (containerTag) { + const validationError = validateContainerTag(containerTag); + if (validationError) { + console.log(validationError); + process.exit(1); + } + } + const client = new SupermemoryClient(); - const projectTag = getProjectTag(process.cwd()); + const effectiveTag = containerTag || getProjectTag(process.cwd()); try { const metadata = { @@ -28,10 +51,11 @@ async function main(): Promise { timestamp: new Date().toISOString(), }; - const result = await client.addMemory(content, projectTag, metadata); + const result = await client.addMemory(content, effectiveTag, metadata); if (result.success) { - console.log(`Memory saved (id: ${result.id}) to project '${projectTag}'`); + const tagLabel = containerTag ? `container '${containerTag}'` : `project '${effectiveTag}'`; + console.log(`Memory saved (id: ${result.id}) to ${tagLabel}`); } else { console.log(`Failed to save memory: ${result.error}`); } diff --git a/src/skills/search-memory.ts b/src/skills/search-memory.ts index b21253c..51fd30d 100644 --- a/src/skills/search-memory.ts +++ b/src/skills/search-memory.ts @@ -1,36 +1,41 @@ -import { CONFIG, isConfigured } from "../config.js"; +import { CONFIG, isConfigured, validateContainerTag } from "../config.js"; import { SupermemoryClient, type SearchResponse } from "../services/client.js"; import { formatContextForPrompt } from "../services/context.js"; import { getProjectTag, getUserTag } from "../services/tags.js"; -type Scope = "user" | "project" | "both"; +type Scope = "user" | "project" | "both" | "custom"; interface ParsedArgs { scope: Scope; includeProfile: boolean; query: string; + containerTag?: string; } function parseArgs(args: string[]): ParsedArgs { let scope: Scope = "both"; let includeProfile = true; + let containerTag: string | undefined; const queryParts: string[] = []; - for (const arg of args) { - if (arg === "--user") { + for (let i = 0; i < args.length; i++) { + if (args[i] === "--user") { scope = "user"; - } else if (arg === "--project") { + } else if (args[i] === "--project") { scope = "project"; - } else if (arg === "--both") { + } else if (args[i] === "--both") { scope = "both"; - } else if (arg === "--no-profile") { + } else if (args[i] === "--no-profile") { includeProfile = false; + } else if (args[i] === "--container" && i + 1 < args.length) { + containerTag = args[++i]; + scope = "custom"; } else { - queryParts.push(arg); + queryParts.push(args[i]); } } - return { scope, includeProfile, query: queryParts.join(" ") }; + return { scope, includeProfile, query: queryParts.join(" "), containerTag }; } async function main(): Promise { @@ -42,11 +47,11 @@ async function main(): Promise { process.exit(1); } - const { scope, includeProfile, query } = parseArgs(process.argv.slice(2)); + const { scope, includeProfile, query, containerTag } = parseArgs(process.argv.slice(2)); if (!query.trim()) { console.log( - 'No search query provided. Usage: node search-memory.js [--user|--project|--both] "query"' + 'No search query provided. Usage: node search-memory.js [--user|--project|--both|--container ] "query"' ); process.exit(0); } @@ -55,10 +60,25 @@ async function main(): Promise { const userTag = getUserTag(); const projectTag = getProjectTag(process.cwd()); + if (containerTag) { + const validationError = validateContainerTag(containerTag); + if (validationError) { + console.log(validationError); + process.exit(1); + } + } + try { let searchResult: SearchResponse; - if (scope === "both") { + if (scope === "custom" && containerTag) { + searchResult = await client.searchMemories(query, containerTag); + + if (!searchResult.success) { + console.log(`Failed to search container '${containerTag}': ${searchResult.error}`); + return; + } + } else if (scope === "both") { const [userResult, projectResult] = await Promise.all([ client.searchMemories(query, userTag), client.searchMemories(query, projectTag), diff --git a/src/skills/supermemory-forget/SKILL.md b/src/skills/supermemory-forget/SKILL.md index 4c3de07..21efa7a 100644 --- a/src/skills/supermemory-forget/SKILL.md +++ b/src/skills/supermemory-forget/SKILL.md @@ -22,6 +22,12 @@ Describe the content to forget — the system will find and remove matching memo node ~/.codex/supermemory/forget-memory.js "DESCRIPTION_OF_WHAT_TO_FORGET" ``` +To forget from a specific custom container: + +```bash +node ~/.codex/supermemory/forget-memory.js --container "DESCRIPTION_OF_WHAT_TO_FORGET" +``` + ## Examples - User says "I no longer use React, I switched to Vue": diff --git a/src/skills/supermemory-save/SKILL.md b/src/skills/supermemory-save/SKILL.md index de3e09b..057ff0d 100644 --- a/src/skills/supermemory-save/SKILL.md +++ b/src/skills/supermemory-save/SKILL.md @@ -52,3 +52,13 @@ Keep it natural. Capture the conversation flow. ```bash node ~/.codex/supermemory/save-memory.js "FORMATTED_CONTENT" ``` + +### Container Routing + +If custom containers are configured (see `[SUPERMEMORY CONTAINERS]` in your context), you can route the memory to a specific container using `--container`: + +```bash +node ~/.codex/supermemory/save-memory.js --container "FORMATTED_CONTENT" +``` + +Choose the container whose description best matches the content being saved. If unsure, omit `--container` to save to the default project container. diff --git a/src/skills/supermemory-search/SKILL.md b/src/skills/supermemory-search/SKILL.md index 0438f1f..6580b8c 100644 --- a/src/skills/supermemory-search/SKILL.md +++ b/src/skills/supermemory-search/SKILL.md @@ -13,7 +13,7 @@ Search Supermemory for past coding sessions, decisions, and saved information. Run the search script with the user's query and optional scope flag: ```bash -node ~/.codex/supermemory/search-memory.js [--user|--project|--both] "USER_QUERY_HERE" +node ~/.codex/supermemory/search-memory.js [--user|--project|--both|--container ] "USER_QUERY_HERE" ``` ### Scope Flags @@ -21,6 +21,7 @@ node ~/.codex/supermemory/search-memory.js [--user|--project|--both] "USER_QUERY - `--both` (default): Search both personal and project memories in parallel - `--user`: Search personal/user memories across sessions - `--project`: Search project-specific memories +- `--container `: Search a specific custom container (see `[SUPERMEMORY CONTAINERS]` in your context for available containers) ### Options