Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ and the lessons learned across every project — automatically.

## Features

- 🧠 **Automatic recall** — relevant memories are injected into every prompt via the
`UserPromptSubmit` hook.
- 🧠 **Automatic recall** — relevant memories are injected for substantive prompts via
the `UserPromptSubmit` hook, with short commands skipped and retrieval capped at 3 seconds.
- 💾 **Automatic capture** — conversations are stored incrementally (every N turns) and
at session end via the `Stop` hook.
- 🏷️ **Shared Agents scoping** — Codex, Claude Code, and OpenCode use one collision-safe
Expand Down Expand Up @@ -119,6 +119,9 @@ Drop this file in to override defaults:
| `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. |
| `recallMode` | `"direct" \| "off" \| "advisory"` | `"direct"` | Directly retrieve relevant memory, disable prompt recall, or inject an advisory directive. |
| `recallDirective` | `string` | (sensible) | Context injected when `recallMode` is `"advisory"`. |
| `autoRecallEveryPrompt` | `boolean` | — | Deprecated compatibility key; `true` maps to direct and `false` maps to off. |
| `autoSaveEveryTurns` | `number` | `3` | Save memories every N turns (incremental capture). |
| `signalExtraction` | `boolean` | `false` | Enable signal-based filtering (only capture turns with keywords like "prefer", "decided"). |
| `signalKeywords` | `string[]` | (defaults) | Keywords that trigger signal extraction. |
Expand Down
2 changes: 2 additions & 0 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const libraryEntries = [
{ in: "src/services/resultMerge.ts", out: "dist/services/resultMerge.js" },
{ in: "src/services/resultText.ts", out: "dist/services/resultText.js" },
{ in: "src/services/factCache.ts", out: "dist/services/factCache.js" },
{ in: "src/services/recallPolicy.ts", out: "dist/services/recallPolicy.js" },
{ in: "src/services/hookRecallClient.ts", out: "dist/services/hookRecallClient.js" },
];

await Promise.all(
Expand Down
6 changes: 3 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
rmSync,
} from "node:fs";
import { loadCredentials } from "./services/auth.js";
import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary, CONFIG } from "./config.js";
import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary } from "./config.js";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -341,8 +341,8 @@ You now have:
• 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"
: "Fresh install: session-start profile + session-end flush only.\nEnable autoRecallEveryPrompt or captureEveryNTurns in ~/.codex/supermemory.json if needed.\n"}
? "Existing recall/capture preferences were preserved in ~/.codex/supermemory.json.\nSet recallMode to direct, off, or advisory to change recall behavior.\n"
: "Fresh install: direct relevant-memory recall plus session-start profile and session-end flush.\nSet recallMode to off or advisory in ~/.codex/supermemory.json if preferred.\n"}

Next steps:
1. Start Codex — on your first prompt, a browser window will open to
Expand Down
44 changes: 33 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export interface CustomContainer {
description: string;
}

export type RecallMode = "direct" | "off" | "advisory";
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.";

interface CodexSupermemoryConfig {
apiKey?: string;
baseUrl?: string;
Expand All @@ -31,6 +35,8 @@ interface CodexSupermemoryConfig {
/** @deprecated Use captureEveryNTurns */
autoSaveEveryTurns?: number;
autoRecallEveryPrompt?: boolean;
recallMode?: RecallMode;
recallDirective?: string;
captureEveryNTurns?: number;
enableCustomContainers?: boolean;
customContainers?: CustomContainer[];
Expand Down Expand Up @@ -61,6 +67,7 @@ const DEFAULTS = {
signalTurnsBefore: 3,
autoSaveEveryTurns: 3,
autoRecallEveryPrompt: false,
recallMode: "direct" as RecallMode,
captureEveryNTurns: 0,
};

Expand Down Expand Up @@ -111,10 +118,14 @@ function resolveCaptureEveryNTurns(config: CodexSupermemoryConfig): number {
return DEFAULTS.captureEveryNTurns;
}

function resolveAutoRecallEveryPrompt(config: CodexSupermemoryConfig): boolean {
if (config.autoRecallEveryPrompt !== undefined) return config.autoRecallEveryPrompt;
if (configExisted) return true;
return DEFAULTS.autoRecallEveryPrompt;
function resolveRecallMode(config: CodexSupermemoryConfig): RecallMode {
if (["direct", "off", "advisory"].includes(config.recallMode ?? "")) {
return config.recallMode as RecallMode;
}
if (config.autoRecallEveryPrompt !== undefined) {
return config.autoRecallEveryPrompt ? "direct" : "off";
}
return DEFAULTS.recallMode;
}

function getApiKey(): string | undefined {
Expand All @@ -129,6 +140,8 @@ export function reloadApiKey(): void {
SUPERMEMORY_API_KEY = getApiKey();
}

const recallMode = resolveRecallMode(fileConfig);

export const CONFIG = {
similarityThreshold: fileConfig.similarityThreshold ?? DEFAULTS.similarityThreshold,
maxMemories: fileConfig.maxMemories ?? DEFAULTS.maxMemories,
Expand All @@ -143,7 +156,13 @@ export const CONFIG = {
signalKeywords: fileConfig.signalKeywords ?? DEFAULTS.signalKeywords,
signalTurnsBefore: fileConfig.signalTurnsBefore ?? DEFAULTS.signalTurnsBefore,
autoSaveEveryTurns: fileConfig.autoSaveEveryTurns ?? DEFAULTS.autoSaveEveryTurns,
autoRecallEveryPrompt: resolveAutoRecallEveryPrompt(fileConfig),
recallMode,
recallDirective:
typeof fileConfig.recallDirective === "string" && fileConfig.recallDirective.trim()
? fileConfig.recallDirective.trim()
: DEFAULT_RECALL_DIRECTIVE,
/** @deprecated Prefer recallMode. */
autoRecallEveryPrompt: recallMode === "direct",
captureEveryNTurns: resolveCaptureEveryNTurns(fileConfig),
enableCustomContainers: fileConfig.enableCustomContainers ?? false,
customContainers: (fileConfig.customContainers ?? []).filter(
Expand Down Expand Up @@ -256,24 +275,27 @@ export function writeInstallDefaults(isExistingInstall: boolean): void {
const current = loadRawConfigForWrite().config;
const next: CodexSupermemoryConfig = { ...current };

if (next.recallMode === undefined) {
next.recallMode = next.autoRecallEveryPrompt === false ? "off" : "direct";
}

if (isExistingInstall) {
if (next.autoRecallEveryPrompt === undefined) {
next.autoRecallEveryPrompt = true;
}
if (next.captureEveryNTurns === undefined) {
next.captureEveryNTurns = next.autoSaveEveryTurns ?? 3;
}
} else {
next.autoRecallEveryPrompt = false;
next.captureEveryNTurns = 0;
}

writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2));
}

export function getRecallModeSummary(): string {
if (CONFIG.autoRecallEveryPrompt) {
return "legacy: recall on every prompt";
if (CONFIG.recallMode === "direct") {
return "direct: relevant recall on substantive prompts";
}
if (CONFIG.recallMode === "advisory") {
return "advisory: prompt the agent to search memory when needed";
}
if (CONFIG.captureEveryNTurns > 0) {
return `unified: session-start profile + capture every ${CONFIG.captureEveryNTurns} turns + session-end flush`;
Expand Down
16 changes: 12 additions & 4 deletions src/hooks/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js";
import { captureEntries, resolveTranscriptPath } from "../services/capture.js";
import { getSeenFacts, addSeenFacts } from "../services/factCache.js";
import { getSessionId } from "../services/session.js";
import { getHookProfileWithSearchMany } from "../services/hookRecallClient.js";
import { prepareRecallQuery, shouldRecallPrompt } from "../services/recallPolicy.js";

const AUTH_ATTEMPTED_FILE = join(homedir(), ".codex", "supermemory", ".auth-attempted");
const LOGGED_OUT_FILE = join(homedir(), ".codex", "supermemory", ".logged-out");
Expand Down Expand Up @@ -105,7 +107,7 @@ async function main() {
query: query.slice(0, 100),
tags,
sessionId,
autoRecallEveryPrompt: CONFIG.autoRecallEveryPrompt,
recallMode: CONFIG.recallMode,
});

const transcriptPath = resolveTranscriptPath(payload.transcript_path, sessionId);
Expand All @@ -118,14 +120,20 @@ async function main() {
});
}

if (!CONFIG.autoRecallEveryPrompt) {
if (CONFIG.recallMode === "off") {
exitWithContext("");
}

if (CONFIG.recallMode === "advisory") {
exitWithContext(CONFIG.recallDirective);
}

if (!shouldRecallPrompt(query)) exitWithContext("");

try {
const profileResult = await client.getProfileWithSearchMany(
const profileResult = await getHookProfileWithSearchMany(
tags.allReads,
query,
prepareRecallQuery(query),
);

const seen = getSeenFacts(sessionId);
Expand Down
113 changes: 113 additions & 0 deletions src/services/hookRecallClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { CONFIG, getApiKeyValue, getBaseUrl } from "../config.js";
import type { ProfileWithSearchResult, SearchResultItem } from "./client.js";
import { mergeProfileResults } from "./resultMerge.js";
import { boundedMemoryText, recallProvenance } from "./resultText.js";

export const HOOK_RECALL_TIMEOUT_MS = 3000;

interface HookRecallOptions {
timeoutMs?: number;
fetchImpl?: typeof fetch;
}

function stringFacts(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter(
(fact): fact is string => typeof fact === "string" && fact.trim().length > 0,
);
}

function normalizeProfileResponse(raw: unknown): ProfileWithSearchResult {
const value = raw && typeof raw === "object"
? raw as Record<string, unknown>
: {};
const profile = value.profile && typeof value.profile === "object"
? value.profile as Record<string, unknown>
: {};
const searchResults = value.searchResults && typeof value.searchResults === "object"
? value.searchResults as Record<string, unknown>
: null;
const rawResults = Array.isArray(searchResults?.results)
? searchResults.results as SearchResultItem[]
: [];
const results = rawResults
.map((result) => {
const provenance = recallProvenance(result);
return {
id: result.id,
memory: boundedMemoryText(result),
similarity: result.similarity,
title: provenance.title,
filepath: provenance.filepath,
updatedAt: result.updatedAt,
};
})
.filter((result) => result.memory.length > 0);

return {
success: true,
profile: {
static: stringFacts(profile.static),
dynamic: stringFacts(profile.dynamic),
},
searchResults: searchResults
? {
results,
total: typeof searchResults.total === "number" ? searchResults.total : results.length,
timing: typeof searchResults.timing === "number" ? searchResults.timing : undefined,
}
: undefined,
};
}

async function fetchProfile(
containerTag: string,
query: string,
options: Required<HookRecallOptions>,
): Promise<ProfileWithSearchResult> {
const apiKey = getApiKeyValue();
if (!apiKey) return { success: false, error: "Missing API key", profile: null };

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const response = await options.fetchImpl(`${getBaseUrl().replace(/\/+$/, "")}/v4/profile`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"x-sm-source": "codex",
},
body: JSON.stringify({ containerTag, q: query }),
signal: controller.signal,
});
if (!response.ok) {
return { success: false, error: `Profile request failed (${response.status})`, profile: null };
}
return normalizeProfileResponse(await response.json());
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
profile: null,
};
} finally {
clearTimeout(timeout);
}
}

export async function getHookProfileWithSearchMany(
containerTags: string[],
query: string,
options: HookRecallOptions = {},
): Promise<ProfileWithSearchResult> {
const resolved = {
timeoutMs: options.timeoutMs ?? HOOK_RECALL_TIMEOUT_MS,
fetchImpl: options.fetchImpl ?? fetch,
};
const uniqueTags = [...new Set(containerTags.filter(Boolean))];
const results = await Promise.all(
uniqueTags.map((containerTag) => fetchProfile(containerTag, query, resolved)),
);
return mergeProfileResults(results, CONFIG.maxMemories);
}
12 changes: 12 additions & 0 deletions src/services/recallPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const MIN_RECALL_QUERY_CHARS = 12;
export const MAX_RECALL_QUERY_CHARS = 500;

export function shouldRecallPrompt(prompt: string): boolean {
const query = prompt.trim();
if (query.length < MIN_RECALL_QUERY_CHARS) return false;
return !["/", "!", "#"].some((prefix) => query.startsWith(prefix));
}

export function prepareRecallQuery(prompt: string): string {
return prompt.trim().slice(0, MAX_RECALL_QUERY_CHARS);
}
3 changes: 2 additions & 1 deletion src/skills/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ function getDevTlsHint(): string | null {
}

function getAutoRecallStatus(): string {
return CONFIG.autoRecallEveryPrompt ? "every prompt" : "off";
if (CONFIG.recallMode === "direct") return "direct (substantive prompts)";
return CONFIG.recallMode;
}

function getAutoCaptureStatus(): string {
Expand Down
Loading