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");
});
});