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) => {