diff --git a/README.md b/README.md
index f0cb49b..ed641f9 100644
--- a/README.md
+++ b/README.md
@@ -112,8 +112,12 @@ Drop this file in to override defaults:
| `apiKey` | `string` | — | API key (env var takes precedence, browser auth is preferred). |
| `baseUrl` | `string` | `https://api.supermemory.ai` | Supermemory API base URL (`SUPERMEMORY_API_URL`/`SUPERMEMORY_BASE_URL` env vars take precedence). |
| `similarityThreshold` | `number` | `0.6` | Minimum similarity score for retrieved memories. |
-| `maxMemories` | `number` | `5` | Max memories injected per prompt. |
-| `maxProfileItems` | `number` | `5` | Max profile items considered from each persistent/recent section. |
+| `maxMemories` | `number` | `5` | Global max memories injected per prompt across all searched containers. |
+| `maxProfileItems` | `number` | `5` | Max profile items from each persistent and recent section. |
+| `maxRecallTokens` | `number` | `2500` | Approximate whole-context token budget for session-start profile recall. |
+| `maxPromptRecallTokens` | `number` | `maxRecallTokens` | Approximate whole-context token budget for prompt recall. |
+| `autoRecallContainers` | `boolean` | `false` | Search every configured custom container on each substantive prompt. |
+| `customContainers` | `{ tag: string, description: string }[]` | `[]` | Custom containers available for automatic recall. |
| `injectProfile` | `boolean` | `true` | Whether to fetch and inject the user profile. |
| `containerTagPrefix` | `string` | `"codex"` | Legacy prefix retained when reading containers created by older versions. |
| `userContainerTag` | `string` | auto | Legacy personal container retained for backward-compatible reads. |
diff --git a/src/cli.ts b/src/cli.ts
index 3cf131e..fcdee2e 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -225,6 +225,7 @@ interface HookEntry {
command: string;
timeout?: number;
statusMessage?: string;
+ additionalContextLimit?: number;
async?: boolean;
}
@@ -279,6 +280,7 @@ function ensureHookRegistered(
statusMessage: string,
background = false,
matcher?: string,
+ additionalContextLimit?: number,
): void {
const exists = groups.some((g) => g.hooks.some((h) => h.command === command));
if (exists) {
@@ -287,6 +289,9 @@ function ensureHookRegistered(
if (hook.command === command) {
hook.timeout = timeout;
hook.statusMessage = statusMessage;
+ if (additionalContextLimit !== undefined) {
+ hook.additionalContextLimit = additionalContextLimit;
+ }
if (background) hook.async = true;
else delete hook.async;
}
@@ -301,6 +306,7 @@ function ensureHookRegistered(
command,
timeout,
statusMessage,
+ ...(additionalContextLimit !== undefined ? { additionalContextLimit } : {}),
...(background ? { async: true } : {}),
};
if (matchingGroup) {
@@ -346,11 +352,22 @@ function mergeHooksJson(add: boolean) {
sessionStartCmd,
SESSION_START_TIMEOUT_SECONDS,
"Loading memory profile...",
+ false,
+ undefined,
+ 0,
);
// Recall must stay synchronous because its output is injected.
if (!hooks.UserPromptSubmit) hooks.UserPromptSubmit = [];
- ensureHookRegistered(hooks.UserPromptSubmit, recallCmd, RECALL_TIMEOUT_SECONDS, "Searching memories...");
+ ensureHookRegistered(
+ hooks.UserPromptSubmit,
+ recallCmd,
+ RECALL_TIMEOUT_SECONDS,
+ "Searching memories...",
+ false,
+ undefined,
+ 0,
+ );
// Remove the old per-prompt capture hook. Stop now owns automatic capture.
hooks.UserPromptSubmit = removeHookCommands(
diff --git a/src/config.ts b/src/config.ts
index 7c93dd3..72cf44e 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -9,6 +9,10 @@ export const CONFIG_FILE = join(homedir(), ".codex", "supermemory.json");
export const DEFAULT_BASE_URL = "https://api.supermemory.ai";
export type RecallMode = "direct" | "off" | "advisory";
+export interface CustomContainer {
+ tag: string;
+ description: string;
+}
export const DEFAULT_RECALL_DIRECTIVE =
"Relevant prior context may exist in Supermemory. Search memory before answering when the request depends on previous decisions, preferences, or project history.";
@@ -18,6 +22,10 @@ interface CodexSupermemoryConfig {
similarityThreshold?: number;
maxMemories?: number;
maxProfileItems?: number;
+ maxRecallTokens?: number;
+ maxPromptRecallTokens?: number;
+ autoRecallContainers?: boolean;
+ customContainers?: CustomContainer[];
injectProfile?: boolean;
containerTagPrefix?: string;
userContainerTag?: string;
@@ -49,6 +57,8 @@ const DEFAULTS = {
similarityThreshold: 0.6,
maxMemories: 5,
maxProfileItems: 5,
+ maxRecallTokens: 2500,
+ autoRecallContainers: false,
injectProfile: true,
containerTagPrefix: "codex",
filterPrompt:
@@ -133,11 +143,30 @@ export function reloadApiKey(): void {
}
const recallMode = resolveRecallMode(fileConfig);
+const maxRecallTokens = fileConfig.maxRecallTokens ?? DEFAULTS.maxRecallTokens;
+const customContainers = Array.isArray(fileConfig.customContainers)
+ ? fileConfig.customContainers
+ .filter(
+ (container): container is CustomContainer =>
+ !!container &&
+ typeof container.tag === "string" &&
+ container.tag.trim().length > 0 &&
+ typeof container.description === "string",
+ )
+ .map((container) => ({
+ tag: container.tag.trim(),
+ description: container.description.trim(),
+ }))
+ : [];
export const CONFIG = {
similarityThreshold: fileConfig.similarityThreshold ?? DEFAULTS.similarityThreshold,
maxMemories: fileConfig.maxMemories ?? DEFAULTS.maxMemories,
maxProfileItems: fileConfig.maxProfileItems ?? DEFAULTS.maxProfileItems,
+ maxRecallTokens,
+ maxPromptRecallTokens: fileConfig.maxPromptRecallTokens ?? maxRecallTokens,
+ autoRecallContainers: fileConfig.autoRecallContainers ?? DEFAULTS.autoRecallContainers,
+ customContainers,
injectProfile: fileConfig.injectProfile ?? DEFAULTS.injectProfile,
containerTagPrefix: fileConfig.containerTagPrefix ?? DEFAULTS.containerTagPrefix,
userContainerTag: fileConfig.userContainerTag,
diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts
index b53e1f6..c8fa037 100644
--- a/src/hooks/recall.ts
+++ b/src/hooks/recall.ts
@@ -6,8 +6,7 @@ import { getSeenFacts, addSeenFacts, factKey } from "../services/factCache.js";
import { getSessionId } from "../services/session.js";
import { getHookProfileWithSearchMany } from "../services/hookRecallClient.js";
import { prepareRecallQuery, shouldRecallPrompt } from "../services/recallPolicy.js";
-
-const MAX_RESULT_CHARS = 300;
+import { formatRecallContext } from "../services/context.js";
interface CodexHookPayload {
session_id?: string;
@@ -37,29 +36,6 @@ function exitWithContext(additionalContext: string, systemMessage?: string): nev
process.exit(0);
}
-interface RecallItem {
- memory: string;
- title?: string;
- filepath?: string;
-}
-
-function formatRecall(items: RecallItem[], containerTag: string): string {
- const lines = items.map((item) => {
- const text = item.memory.replace(/\s+/g, " ").slice(0, MAX_RESULT_CHARS);
- const title = item.title?.trim();
- const prefix = title && !text.startsWith(title) ? `${title} — ` : "";
- const filepath = item.filepath ? ` (${item.filepath})` : "";
- return `- ◪ ${prefix}${text}${filepath}`;
- });
-
- return `
-◪ Recalled from supermemory for this prompt (relevance-ranked):
-${lines.join("\n")}
-
-When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool (containerTag: "${containerTag}").
-`;
-}
-
async function main() {
let rawInput = "";
try {
@@ -122,24 +98,27 @@ async function main() {
const seen = getSeenFacts(sessionId);
const matches = profileResult.searchResults?.results ?? [];
- const fresh = matches
- .filter((item) => !seen.has(factKey(item.memory)))
- .slice(0, Math.min(CONFIG.maxMemories, 5));
- const repeats = matches.length - fresh.length;
+ const repeats = matches.filter((item) => seen.has(factKey(item.memory))).length;
+ const { text: additionalContext, newFacts } = formatRecallContext(matches, {
+ containerTag: tags.canonical,
+ maxMemories: CONFIG.maxMemories,
+ maxTokens: CONFIG.maxPromptRecallTokens,
+ seenFacts: seen,
+ customContainers: CONFIG.autoRecallContainers ? CONFIG.customContainers : [],
+ });
log("recall: done", {
matchCount: matches.length,
- freshCount: fresh.length,
+ freshCount: newFacts.length,
seenCount: seen.size,
});
- if (fresh.length > 0) {
- addSeenFacts(sessionId, fresh.map((item) => item.memory));
- const additionalContext = formatRecall(fresh, tags.canonical);
+ if (newFacts.length > 0) {
+ addSeenFacts(sessionId, newFacts);
const tokens = Math.round(additionalContext.length / 4);
const label = repeats > 0
- ? `recalled ${fresh.length} new (${tokens} tok) · ${repeats} already in context`
- : `recalled ${fresh.length} ${fresh.length === 1 ? "memory" : "memories"} (${tokens} tok)`;
+ ? `recalled ${newFacts.length} new (${tokens} tok) · ${repeats} already in context`
+ : `recalled ${newFacts.length} ${newFacts.length === 1 ? "memory" : "memories"} (${tokens} tok)`;
log("recall: emit context", {
additionalContextLength: additionalContext.length,
});
@@ -149,7 +128,10 @@ async function main() {
exitWithContext("");
} catch (error) {
log("recall: error", { error: String(error) });
- exitWithContext("", "◪ supermemory · recall unavailable; continuing without recalled context");
+ const message = error instanceof RangeError
+ ? `◪ supermemory · invalid recall configuration: ${error.message}`
+ : "◪ supermemory · recall unavailable; continuing without recalled context";
+ exitWithContext("", message);
}
}
diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts
index 68a35c6..ab783cb 100644
--- a/src/hooks/session-start.ts
+++ b/src/hooks/session-start.ts
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
import { isConfigured, CONFIG, PLUGIN_VERSION, reloadApiKey } from "../config.js";
import { HOOK_API_TIMEOUT_MS, SupermemoryClient } from "../services/client.js";
import { getTags } from "../services/tags.js";
-import { formatCombinedContext } from "../services/context.js";
+import { formatSessionContext } from "../services/context.js";
import { log } from "../services/logger.js";
import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js";
import { getSeenFacts, addSeenFacts } from "../services/factCache.js";
@@ -108,15 +108,19 @@ async function main() {
{ timeoutMs: HOOK_API_TIMEOUT_MS },
);
const seen = getSeenFacts(sessionId);
- const { text, newFacts } = formatCombinedContext(
+ const { text, newFacts } = formatSessionContext(
{
success: profileResult.success,
profile: profileResult.profile,
searchResults: undefined,
},
- 0,
- CONFIG.maxProfileItems,
- seen,
+ {
+ maxProfileItems: CONFIG.maxProfileItems,
+ maxTokens: CONFIG.maxRecallTokens,
+ seenFacts: seen,
+ projectName: tags.projectName,
+ containerTag: tags.canonical,
+ },
);
if (!profileResult.success) {
@@ -133,13 +137,7 @@ async function main() {
if (newFacts.length > 0) {
addSeenFacts(sessionId, newFacts);
const updateNotice = await updateCheck;
- const context = `
-Recalled memory for this project (${tags.projectName}). Every line marked ◪ comes from supermemory — when citing one, keep the mark and phrase it naturally. If you name the source, say "from supermemory" — never "from memory".
-This project's memory container: ${tags.canonical}
-
-${text}
-`;
- exitWithContext(context, combineContextParts([
+ exitWithContext(text, combineContextParts([
updateNotice,
`◪ supermemory · active · ${newFacts.length} ${newFacts.length === 1 ? "memory" : "memories"} loaded for ${tags.projectName}`,
markTip(),
@@ -158,11 +156,14 @@ ${text}
]));
} catch (error) {
log("session-start: error", { error: String(error) });
+ const message = error instanceof RangeError
+ ? `◪ supermemory · invalid recall configuration: ${error.message}`
+ : "◪ supermemory · profile unavailable; continuing without recalled context";
exitWithContext(
"",
combineContextParts([
await updateCheck,
- "◪ supermemory · profile unavailable; continuing without recalled context",
+ message,
]),
);
}
diff --git a/src/services/context.ts b/src/services/context.ts
index 9e2d2dc..3260d2f 100644
--- a/src/services/context.ts
+++ b/src/services/context.ts
@@ -1,6 +1,7 @@
import type { ProfileWithSearchResult, SearchResponse } from "./client.js";
+import type { CustomContainer } from "../config.js";
import { factKey } from "./factCache.js";
-import { boundedMemoryText, memoryText } from "./resultText.js";
+import { memoryText } from "./resultText.js";
const ATTRIBUTION_GUIDANCE =
"Items marked ◪ were recalled from supermemory. Use them when relevant; if you mention the source, say \"from supermemory\".";
@@ -39,6 +40,162 @@ export interface FormattedContext {
newFacts: string[];
}
+interface BoundedItem {
+ before: string;
+ prefix: string;
+ text: string;
+ suffix: string;
+}
+
+const CHARS_PER_TOKEN = 4;
+
+function formatBoundedItems(
+ items: BoundedItem[],
+ maxTokens: number,
+ limitName: string,
+ render: (body: string) => string,
+): FormattedContext {
+ if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
+ throw new RangeError(`${limitName} must be a positive number`);
+ }
+
+ const maxChars = Math.floor(maxTokens * CHARS_PER_TOKEN);
+ if (render("").length > maxChars) {
+ throw new RangeError(`${limitName} is too small for fixed recall context`);
+ }
+
+ let body = "";
+ const newFacts: string[] = [];
+ for (const item of items) {
+ const fullBody = `${body}${item.before}${item.prefix}${item.text}${item.suffix}`;
+ if (render(fullBody).length <= maxChars) {
+ body = fullBody;
+ newFacts.push(item.text);
+ continue;
+ }
+
+ const fixedBody = `${body}${item.before}${item.prefix}${item.suffix}`;
+ const available = maxChars - render(fixedBody).length;
+ if (available > 1) {
+ const emitted = `${item.text.slice(0, available - 1)}…`;
+ body = `${body}${item.before}${item.prefix}${emitted}${item.suffix}`;
+ newFacts.push(item.text);
+ }
+ break;
+ }
+
+ return { text: newFacts.length > 0 ? render(body) : "", newFacts };
+}
+
+function singleLine(value: string): string {
+ return value.replace(/\s+/g, " ").trim();
+}
+
+interface RecallItem {
+ memory: string;
+ title?: string;
+ filepath?: string;
+}
+
+interface RecallContextOptions {
+ containerTag: string;
+ maxMemories: number;
+ maxTokens: number;
+ seenFacts?: Set;
+ customContainers?: CustomContainer[];
+}
+
+export function formatRecallContext(
+ matches: RecallItem[],
+ options: RecallContextOptions,
+): FormattedContext {
+ const seen = new Set(options.seenFacts ?? []);
+ const fresh: RecallItem[] = [];
+ if (options.maxMemories > 0) {
+ for (const match of matches) {
+ const text = singleLine(match.memory);
+ const key = text ? factKey(text) : "";
+ if (!text || seen.has(key)) continue;
+ seen.add(key);
+ fresh.push({ ...match, memory: text });
+ if (fresh.length >= options.maxMemories) break;
+ }
+ }
+
+ const catalog = options.customContainers?.length
+ ? `\n\nConfigured automatic recall containers:\n${options.customContainers
+ .map((container) => `- ${singleLine(container.tag)}: ${singleLine(container.description)}`)
+ .join("\n")}`
+ : "";
+ const render = (body: string): string => `
+◪ Recalled from supermemory for this prompt (relevance-ranked):
+${body}${catalog}
+
+When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool (containerTag: ${JSON.stringify(options.containerTag)}).
+`;
+ const items = fresh.map((item, index) => {
+ const title = singleLine(item.title ?? "");
+ const prefix = title && !item.memory.startsWith(title) ? `${title} — ` : "";
+ const filepath = singleLine(item.filepath ?? "");
+ return {
+ before: index === 0 ? "" : "\n",
+ prefix: `- ◪ ${prefix}`,
+ text: item.memory,
+ suffix: filepath ? ` (${filepath})` : "",
+ };
+ });
+ return formatBoundedItems(items, options.maxTokens, "maxPromptRecallTokens", render);
+}
+
+interface SessionContextOptions {
+ maxProfileItems: number;
+ maxTokens: number;
+ seenFacts?: Set;
+ projectName: string;
+ containerTag: string;
+}
+
+export function formatSessionContext(
+ result: ProfileWithSearchResult,
+ options: SessionContextOptions,
+): FormattedContext {
+ const seen = new Set(options.seenFacts ?? []);
+ const takeFresh = (facts: string[]): string[] => {
+ if (options.maxProfileItems <= 0) return [];
+ const fresh: string[] = [];
+ for (const fact of facts) {
+ const text = fact.trim();
+ const key = text ? factKey(text) : "";
+ if (!text || seen.has(key)) continue;
+ seen.add(key);
+ fresh.push(text);
+ if (fresh.length >= options.maxProfileItems) break;
+ }
+ return fresh;
+ };
+ const facts = result.success && result.profile
+ ? [
+ ...takeFresh(result.profile.static ?? []),
+ ...takeFresh(result.profile.dynamic ?? []),
+ ]
+ : [];
+ const render = (body: string): string => `
+Recalled memory for this project (${options.projectName}). Every line marked ◪ comes from supermemory — when citing one, keep the mark and phrase it naturally. If you name the source, say "from supermemory" — never "from memory".
+This project's memory container: ${options.containerTag}
+
+${ATTRIBUTION_GUIDANCE}
+
+${body}
+`;
+ const items = facts.map((fact, index) => ({
+ before: index === 0 ? "[Memory Profile]\n" : "\n",
+ prefix: `${index + 1}. ◪ `,
+ text: fact,
+ suffix: "",
+ }));
+ return formatBoundedItems(items, options.maxTokens, "maxRecallTokens", render);
+}
+
/**
* Format context from the unified project profile and its embedded search.
*
@@ -109,8 +266,8 @@ export function formatCombinedContext(
allMemories.push({
text,
display: labels.length > 0
- ? `◪ [${labels.join(" — ")}] ${boundedMemoryText(r)}`
- : `◪ ${boundedMemoryText(r)}`,
+ ? `◪ [${labels.join(" — ")}] ${singleLine(text)}`
+ : `◪ ${singleLine(text)}`,
});
}
}
@@ -156,7 +313,7 @@ export function formatContextForPrompt(
if (searchResult.success && searchResult.results && searchResult.results.length > 0) {
const memories = searchResult.results
.slice(0, maxMemories)
- .map((r, i) => `${i + 1}. ${boundedMemoryText(r)}`)
+ .map((r, i) => `${i + 1}. ${singleLine(memoryText(r))}`)
.filter((m) => m.trim().length > 2)
.join("\n");
if (memories) {
diff --git a/src/services/resultMerge.ts b/src/services/resultMerge.ts
index c06a9fe..0566055 100644
--- a/src/services/resultMerge.ts
+++ b/src/services/resultMerge.ts
@@ -6,7 +6,6 @@ import type {
import {
memoryText,
recallProvenance,
- RECALL_MAX_RESULTS,
RECALL_MIN_SIMILARITY,
} from "./resultText.js";
@@ -110,7 +109,7 @@ export function mergeProfileResults(
total: response.searchResults?.total ?? 0,
timing: response.searchResults?.timing,
})),
- Math.min(limit, RECALL_MAX_RESULTS),
+ limit,
);
return {
diff --git a/src/services/resultText.ts b/src/services/resultText.ts
index 9ebc3ea..9e9dabf 100644
--- a/src/services/resultText.ts
+++ b/src/services/resultText.ts
@@ -12,8 +12,6 @@ export interface MemoryTextShape {
}
export const RECALL_MIN_SIMILARITY = 0.55;
-export const RECALL_MAX_RESULTS = 5;
-export const RECALL_MAX_RESULT_CHARS = 300;
/** Return only a real string field; never stringify objects as `[object Object]`. */
export function memoryText(result: MemoryTextShape): string {
@@ -29,13 +27,6 @@ export function memoryText(result: MemoryTextShape): string {
return "";
}
-export function boundedMemoryText(
- result: MemoryTextShape,
- maxChars = RECALL_MAX_RESULT_CHARS,
-): string {
- return memoryText(result).replace(/\s+/g, " ").slice(0, maxChars).trim();
-}
-
function stringValue(...values: unknown[]): string | undefined {
const value = values.find(
(candidate) => typeof candidate === "string" && candidate.trim().length > 0,
diff --git a/src/services/tags.ts b/src/services/tags.ts
index 439c23a..dbb4be0 100644
--- a/src/services/tags.ts
+++ b/src/services/tags.ts
@@ -460,10 +460,9 @@ export function getLegacyCursorProjectTags(directory: string): string[] {
function uniqueTags(tags: Array): string[] {
return [
...new Set(
- tags.filter(
- (tag): tag is string =>
- typeof tag === "string" && tag.trim().length > 0,
- ),
+ tags
+ .map((tag) => typeof tag === "string" ? tag.trim() : "")
+ .filter((tag) => tag.length > 0),
),
];
}
@@ -499,6 +498,9 @@ export function getAllReadTags(directory: string): string[] {
return uniqueTags([
...getPersonalReadTags(directory),
...getProjectReadTags(directory),
+ ...(CONFIG.autoRecallContainers
+ ? CONFIG.customContainers.map((container) => container.tag)
+ : []),
]);
}
diff --git a/test/unit.mjs b/test/unit.mjs
index b53cd47..13f67f1 100644
--- a/test/unit.mjs
+++ b/test/unit.mjs
@@ -254,6 +254,43 @@ describe("container tags", () => {
assert.ok(tags.personalReads.includes("shared_personal"));
assert.equal(tags.projectReads[0], "shared_project");
});
+
+ test("adds configured recall containers only when automatic recall is enabled", (t) => {
+ const tmpDir = makeTmpDir();
+ t.after(() => rmSync(tmpDir, { recursive: true, force: true }));
+ const repoDir = join(tmpDir, "repo");
+ mkdirSync(repoDir, { recursive: true });
+ runGit(["init"], repoDir);
+
+ const readTags = (name, config) => {
+ const homeDir = join(tmpDir, name);
+ mkdirSync(join(homeDir, ".codex"), { recursive: true });
+ writeFileSync(join(homeDir, ".codex", "supermemory.json"), JSON.stringify(config));
+ const script = `
+ import { getAllReadTags } from ${JSON.stringify(tagsModule)};
+ console.log(JSON.stringify(getAllReadTags(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);
+ return JSON.parse(result.stdout);
+ };
+
+ const customContainers = [
+ { tag: " coding_personal ", description: "Personal coding context" },
+ { tag: "coding_personal", description: "Duplicate" },
+ { tag: "copla_company", description: "Company context" },
+ ];
+ const baseline = readTags("baseline", {});
+ const disabled = readTags("disabled", { autoRecallContainers: false, customContainers });
+ const enabled = readTags("enabled", { autoRecallContainers: true, customContainers });
+
+ assert.deepEqual(disabled, baseline);
+ assert.equal(enabled.filter((tag) => tag === "coding_personal").length, 1);
+ assert.equal(enabled.filter((tag) => tag === "copla_company").length, 1);
+ });
});
describe("cross-container result merging", () => {
@@ -303,10 +340,11 @@ describe("cross-container result merging", () => {
});
assert.equal(result.status, 0, result.stderr);
const results = JSON.parse(result.stdout);
- assert.equal(results.length, 5);
- assert.deepEqual(results.map((item) => item.memory), [
+ assert.equal(results.length, 6);
+ assert.deepEqual(results.slice(0, 5).map((item) => item.memory), [
"memory value", "chunk value", "content value", "text value", "context value",
]);
+ assert.equal(results[5].memory.length, 360);
assert.equal(results[0].title, "Decision");
assert.equal(results[0].filepath, "src/a.ts");
});
@@ -347,6 +385,43 @@ describe("cross-container result merging", () => {
assert.notEqual(results[1].memory, results[2].memory);
assert.ok(!results.some((item) => item.memory === "weak score only"));
});
+
+ test("keeps the default five-result limit when configured with five", () => {
+ const script = `
+ import { mergeProfileResults } from ${JSON.stringify(mergeModule)};
+ const results = Array.from({ length: 10 }, (_, index) => ({
+ id: String(index), memory: \`memory \${index}\`, similarity: 1 - index / 100,
+ }));
+ const merged = mergeProfileResults([{
+ success: true,
+ profile: { static: [], dynamic: [] },
+ searchResults: { results, total: results.length },
+ }], 5);
+ console.log(merged.searchResults.results.length);
+ `;
+ const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf-8" });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(Number(result.stdout), 5);
+ });
+
+ test("globally ranks, deduplicates, and returns fifteen results when configured", () => {
+ const script = `
+ import { mergeProfileResults } from ${JSON.stringify(mergeModule)};
+ const results = Array.from({ length: 20 }, (_, index) => ({
+ id: String(index), memory: \`memory \${index}\`, similarity: 1 - index / 100,
+ }));
+ const merged = mergeProfileResults([
+ { success: true, profile: { static: [], dynamic: [] }, searchResults: { results: results.slice(0, 10), total: 10 } },
+ { success: true, profile: { static: [], dynamic: [] }, searchResults: { results: [{ ...results[0], id: "duplicate" }, ...results.slice(10)], total: 11 } },
+ ], 15);
+ console.log(JSON.stringify(merged.searchResults.results));
+ `;
+ const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf-8" });
+ assert.equal(result.status, 0, result.stderr);
+ const results = JSON.parse(result.stdout);
+ assert.equal(results.length, 15);
+ assert.deepEqual(results.map((item) => item.memory), Array.from({ length: 15 }, (_, i) => `memory ${i}`));
+ });
});
describe("capture tracker", () => {
@@ -497,7 +572,7 @@ describe("hook SDK request bounds", () => {
describe("combined recall formatting", () => {
const contextModule = new URL("../dist/services/context.js", import.meta.url).href;
- test("budgets both profile sections and truncates only display text", () => {
+ test("keeps full memory text while budgeting profile sections independently", () => {
const script = `
import { formatCombinedContext } from ${JSON.stringify(contextModule)};
const long = "z".repeat(320) + " durable ending";
@@ -515,10 +590,133 @@ describe("combined recall formatting", () => {
const output = JSON.parse(result.stdout);
assert.match(output.text, /1\. ◪ s1/);
assert.match(output.text, /3\. ◪ d1/);
- assert.doesNotMatch(output.text, /s3|d3|durable ending/);
+ assert.doesNotMatch(output.text, /s3|d3/);
+ assert.match(output.text, /durable ending/);
assert.match(output.text, /from supermemory/);
assert.equal(output.newFacts.at(-1).endsWith("durable ending"), true);
});
+
+ test("bounds complete prompt context and returns only emitted memories", () => {
+ const script = `
+ import { formatRecallContext } from ${JSON.stringify(contextModule)};
+ import { factKey } from ${JSON.stringify(new URL("../dist/services/factCache.js", import.meta.url).href)};
+ const matches = Array.from({ length: 20 }, (_, index) => ({
+ memory: \`memory-\${index}-\${"x".repeat(1000)}\`,
+ similarity: 1 - index / 100,
+ }));
+ const options = {
+ containerTag: "repo_test",
+ maxMemories: 15,
+ maxTokens: 2000,
+ customContainers: [
+ { tag: "coding_personal", description: "Personal coding context" },
+ { tag: "copla_company", description: "Company context" },
+ ],
+ };
+ const result = formatRecallContext(matches, options);
+ const repeated = formatRecallContext([matches[0]], {
+ ...options,
+ seenFacts: new Set(result.newFacts.map(factKey)),
+ });
+ console.log(JSON.stringify({ result, repeated, excluded: matches.at(-1).memory }));
+ `;
+ const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf-8" });
+ assert.equal(result.status, 0, result.stderr);
+ const output = JSON.parse(result.stdout);
+ assert.ok(output.result.text.length <= 8_000);
+ assert.match(output.result.text, /^/);
+ assert.match(output.result.text, /<\/supermemory-recall>$/);
+ assert.match(output.result.text, /coding_personal/);
+ assert.ok(output.result.newFacts.length <= 15);
+ assert.ok(!output.result.newFacts.includes(output.excluded));
+ assert.deepEqual(output.repeated, { text: "", newFacts: [] });
+ });
+
+ test("returns up to fifteen static and fifteen dynamic facts within session budget", () => {
+ const script = `
+ import { formatSessionContext } from ${JSON.stringify(contextModule)};
+ const result = formatSessionContext({
+ success: true,
+ profile: {
+ static: Array.from({ length: 20 }, (_, index) => \`static \${index}\`),
+ dynamic: Array.from({ length: 20 }, (_, index) => \`dynamic \${index}\`),
+ },
+ }, {
+ maxProfileItems: 15,
+ maxTokens: 5000,
+ projectName: "project",
+ containerTag: "repo_project",
+ });
+ console.log(JSON.stringify(result));
+ `;
+ const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf-8" });
+ assert.equal(result.status, 0, result.stderr);
+ const output = JSON.parse(result.stdout);
+ assert.equal(output.newFacts.length, 30);
+ assert.ok(output.text.length <= 20_000);
+ assert.match(output.text, /^/);
+ assert.match(output.text, /<\/supermemory-context>$/);
+ assert.ok(!output.newFacts.includes("static 15"));
+ assert.ok(!output.newFacts.includes("dynamic 15"));
+ });
+
+ test("truncates only the final session item and keeps closing markup", () => {
+ const script = `
+ import { formatSessionContext } from ${JSON.stringify(contextModule)};
+ import { factKey } from ${JSON.stringify(new URL("../dist/services/factCache.js", import.meta.url).href)};
+ const longFact = "x".repeat(30_000);
+ const profile = {
+ success: true,
+ profile: {
+ static: [longFact],
+ dynamic: [],
+ },
+ };
+ const options = {
+ maxProfileItems: 15,
+ maxTokens: 5000,
+ projectName: "project",
+ containerTag: "repo_project",
+ };
+ const result = formatSessionContext(profile, options);
+ const repeated = formatSessionContext(profile, {
+ ...options,
+ seenFacts: new Set(result.newFacts.map(factKey)),
+ });
+ console.log(JSON.stringify({ result, repeated, longFact }));
+ `;
+ const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf-8" });
+ assert.equal(result.status, 0, result.stderr);
+ const output = JSON.parse(result.stdout);
+ assert.ok(output.result.text.length <= 20_000);
+ assert.match(output.result.text, /…\n<\/supermemory-context>$/);
+ assert.deepEqual(output.result.newFacts, [output.longFact]);
+ assert.deepEqual(output.repeated, { text: "", newFacts: [] });
+ });
+
+ test("rejects invalid token limits with the configured field name", () => {
+ const script = `
+ import { formatRecallContext, formatSessionContext } from ${JSON.stringify(contextModule)};
+ const messages = [];
+ try {
+ formatRecallContext([{ memory: "memory" }], {
+ containerTag: "repo", maxMemories: 1, maxTokens: 0,
+ });
+ } catch (error) { messages.push(error.message); }
+ try {
+ formatSessionContext({ success: true, profile: { static: ["fact"], dynamic: [] } }, {
+ maxProfileItems: 1, maxTokens: Number.NaN, projectName: "project", containerTag: "repo",
+ });
+ } catch (error) { messages.push(error.message); }
+ console.log(JSON.stringify(messages));
+ `;
+ 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), [
+ "maxPromptRecallTokens must be a positive number",
+ "maxRecallTokens must be a positive number",
+ ]);
+ });
});
describe("session recall deduplication", () => {
@@ -688,7 +886,7 @@ describe("browser auth opener", () => {
test("SessionStart keeps update notices out of model context", () => {
const sessionStartSource = readFileSync(new URL("../src/hooks/session-start.ts", import.meta.url), "utf-8");
const versionCheckSource = readFileSync(new URL("../src/services/version-check.ts", import.meta.url), "utf-8");
- assert.ok(sessionStartSource.includes("exitWithContext(context, combineContextParts(["));
+ assert.ok(sessionStartSource.includes("exitWithContext(text, combineContextParts(["));
assert.ok(!sessionStartSource.includes("context,\n updateNotice,"));
assert.ok(!sessionStartSource.includes('exitWithContext(await updateCheck ?? ""'));
assert.ok(!versionCheckSource.includes("[SUPERMEMORY UPDATE]"));
@@ -904,12 +1102,16 @@ describe("integration: install/uninstall", () => {
);
assert.equal(recall.async, undefined);
assert.equal(recall.timeout, 5);
+ assert.equal(recall.additionalContextLimit, 0);
assert.equal(recallApprove.async, undefined);
assert.equal(recallApprove.timeout, 5);
+ assert.equal(recallApprove.additionalContextLimit, undefined);
assert.equal(recallApproveGroup.matcher, "^mcp__supermemory__");
assert.ok(!existsSync(join(codexDir, "supermemory", "capture-turn.js")));
assert.equal(sessionStart.async, undefined);
assert.equal(sessionStart.timeout, 30);
+ assert.equal(sessionStart.additionalContextLimit, 0);
+ assert.equal(stop.additionalContextLimit, undefined);
});
test("uninstall removes skill directories", (t) => {