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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ and the lessons learned across every project — automatically.
`~/.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`, and
`/supermemory-forget` commands available when hooks don't cover your use case.
- 🔧 **Fallback skills** — explicit `/supermemory-search`, `/supermemory-save`,
`/supermemory-forget`, `/supermemory-status`, and `/supermemory-logout` commands available when hooks
don't cover your use case.

## Quick start

Expand Down Expand Up @@ -132,7 +133,10 @@ All memory skills support `--container <tag>` to target a specific custom contai
| `/supermemory-search` | `/supermemory-search [--container <tag>] <query>` | Search memories manually. |
| `/supermemory-save` | `/supermemory-save [--container <tag>] <content>` | Save a specific memory explicitly. |
| `/supermemory-forget` | `/supermemory-forget [--container <tag>] <content>` | Remove a memory. |
| `/supermemory-profile` | `/supermemory-profile` | Show remembered profile facts. |
| `/supermemory-status` | `/supermemory-status` | Show connection and account status. |
| `/supermemory-login` | `/supermemory-login` | Re-authenticate with Supermemory. |
| `/supermemory-logout` | `/supermemory-logout` | Remove saved local credentials. |

Skills are fallback commands — the hooks handle most use cases automatically.

Expand Down
8 changes: 5 additions & 3 deletions build.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as esbuild from "esbuild";
import { mkdirSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
import { mkdirSync, writeFileSync, chmodSync, copyFileSync, rmSync } from "node:fs";

const sharedConfig = {
bundle: true,
Expand All @@ -16,12 +16,14 @@ const executableEntries = [
in: `src/hooks/${n}.ts`,
out: `dist/hooks/${n}.js`,
})),
...["search-memory", "save-memory", "forget-memory", "profile-memory", "login"].map((n) => ({
...["search-memory", "save-memory", "forget-memory", "profile-memory", "status", "login", "logout"].map((n) => ({
in: `src/skills/${n}.ts`,
out: `dist/skills/${n}.js`,
})),
];

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" },
Expand All @@ -48,7 +50,7 @@ await Promise.all(
);

// Copy SKILL.md files to dist
for (const skillName of ["supermemory-search", "supermemory-save", "supermemory-forget", "supermemory-profile", "supermemory-login"]) {
for (const skillName of ["supermemory-search", "supermemory-save", "supermemory-forget", "supermemory-profile", "supermemory-status", "supermemory-login", "supermemory-logout"]) {
mkdirSync(`dist/skills/${skillName}`, { recursive: true });
copyFileSync(
`src/skills/${skillName}/SKILL.md`,
Expand Down
19 changes: 13 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,15 @@ const SKILLS = [
{ name: "supermemory-search", script: "search-memory.js" },
{ name: "supermemory-save", script: "save-memory.js" },
{ name: "supermemory-forget", script: "forget-memory.js" },
{ name: "supermemory-status", script: "status.js" },
{ name: "supermemory-profile", script: "profile-memory.js" },
{ name: "supermemory-login", script: "login.js" },
{ name: "supermemory-logout", script: "logout.js" },
] as const;

const LEGACY_SUPERMEMORY_SCRIPTS = [
"capture.js",
"tags.js",
] as const;

const SCRIPT_DIR = getScriptDir();
Expand Down Expand Up @@ -267,10 +274,10 @@ function install() {
copyFileSync(flushSrc, FLUSH_SCRIPT);
copyFileSync(sessionStartSrc, SESSION_START_SCRIPT);

// Remove old capture.js if it exists
const oldCapture = join(SUPERMEMORY_HOOKS_DIR, "capture.js");
if (existsSync(oldCapture)) {
rmSync(oldCapture);
// Remove script names left by older package layouts.
for (const script of LEGACY_SUPERMEMORY_SCRIPTS) {
const oldScript = join(SUPERMEMORY_HOOKS_DIR, script);
if (existsSync(oldScript)) rmSync(oldScript);
}

// Copy skill scripts and SKILL.md files
Expand Down Expand Up @@ -302,7 +309,7 @@ Installation complete!

You now have:
• Session-start profile recall (${getRecallModeSummary()})
• Explicit memory — supermemory-search, supermemory-save, supermemory-forget, supermemory-profile, supermemory-login
• Explicit memory — supermemory-search, 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"
Expand All @@ -316,7 +323,7 @@ Next steps:
/supermemory-login (inside Codex)
export SUPERMEMORY_CODEX_API_KEY="sm_..." (in your shell profile)

2. Get an API key at: https://console.supermemory.ai/keys (if needed)
2. Get an API key at: https://app.supermemory.ai/?view=integrations (if needed)

Optional: Enable debug logging:
export SUPERMEMORY_DEBUG=true
Expand Down
11 changes: 10 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { loadCredentials } from "./services/auth.js";
import { loadCredentialData, loadCredentials } from "./services/auth.js";

export const CONFIG_FILE = join(homedir(), ".codex", "supermemory.json");
export const PLUGIN_VERSION = "1.0.7";
Expand Down Expand Up @@ -132,6 +132,15 @@ export function getApiKeyValue(): string | undefined {
return SUPERMEMORY_API_KEY;
}

export function getApiBaseUrl(): string {
return (
process.env.SUPERMEMORY_API_URL ||
process.env.SUPERMEMORY_BASE_URL ||
loadCredentialData()?.apiBaseUrl ||
"https://api.supermemory.ai"
);
}

export function getSignalConfig(): {
enabled: boolean;
keywords: string[];
Expand Down
6 changes: 6 additions & 0 deletions src/hooks/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getSeenFacts, addSeenFacts } from "../services/factCache.js";
import { getSessionId } from "../services/session.js";

const AUTH_ATTEMPTED_FILE = join(homedir(), ".codex", "supermemory", ".auth-attempted");
const LOGGED_OUT_FILE = join(homedir(), ".codex", "supermemory", ".logged-out");

interface CodexHookPayload {
session_id?: string;
Expand Down Expand Up @@ -45,6 +46,11 @@ async function main() {
}

if (!isConfigured()) {
if (existsSync(LOGGED_OUT_FILE)) {
log("recall: logged out marker present, skipping browser auth");
exitWithContext("");
}

const alreadyAttempted = existsSync(AUTH_ATTEMPTED_FILE);

if (!alreadyAttempted) {
Expand Down
70 changes: 46 additions & 24 deletions src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir, hostname, platform, arch } from "node:os";
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import type { AddressInfo } from "node:net";
import { openUrl } from "./openUrl.js";

const SUPERMEMORY_DIR = join(homedir(), ".codex", "supermemory");
const CREDENTIALS_FILE = join(SUPERMEMORY_DIR, "credentials.json");
export interface Credentials {
apiKey?: string;
apiBaseUrl?: string;
savedAt?: string;
}

const AUTH_BASE_URL =
process.env.SUPERMEMORY_AUTH_URL || "https://console.supermemory.ai/auth/agent-connect";
const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 60_000;
process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/agent-connect";
const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 5 * 60_000;

const AUTH_SUCCESS_HTML = `<!DOCTYPE html>
<html><head><title>Connected - Supermemory</title><style>
Expand Down Expand Up @@ -39,38 +44,47 @@ p{color:#666;font-size:16px}
<p>Invalid API key received. Please try again.</p>
</body></html>`;

export function loadCredentials(): string | undefined {
export function loadCredentialData(): Credentials | null {
try {
if (existsSync(CREDENTIALS_FILE)) {
const data = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")) as {
apiKey?: string;
};
if (data.apiKey) return data.apiKey;
return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")) as Credentials;
}
} catch {}
return null;
}

export function loadCredentials(): string | undefined {
const data = loadCredentialData();
if (data?.apiKey) return data.apiKey;
return undefined;
}

function saveCredentials(apiKey: string): void {
function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined {
if (!apiBaseUrl) return undefined;
try {
const url = new URL(apiBaseUrl);
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
url.pathname = url.pathname.replace(/\/+$/, "");
url.search = "";
url.hash = "";
return url.toString().replace(/\/$/, "");
} catch {
return undefined;
}
}

function saveCredentials(apiKey: string, apiBaseUrl?: string): void {
mkdirSync(SUPERMEMORY_DIR, { recursive: true, mode: 0o700 });
const credentials: Credentials = { apiKey, savedAt: new Date().toISOString() };
const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl);
if (normalizedApiBaseUrl) credentials.apiBaseUrl = normalizedApiBaseUrl;
writeFileSync(
CREDENTIALS_FILE,
JSON.stringify({ apiKey, savedAt: new Date().toISOString() }, null, 2),
JSON.stringify(credentials, null, 2),
{ mode: 0o600 }
);
}

function openBrowser(url: string): void {
const onError = () => {};
if (process.platform === "win32") {
execFile("explorer.exe", [url], onError);
} else if (process.platform === "darwin") {
execFile("open", [url], onError);
} else {
execFile("xdg-open", [url], onError);
}
}

export function startAuthFlow(): Promise<string> {
return new Promise((resolve, reject) => {
let resolved = false;
Expand All @@ -89,9 +103,11 @@ export function startAuthFlow(): Promise<string> {

const apiKey =
url.searchParams.get("apikey") || url.searchParams.get("api_key");
const apiBaseUrl =
url.searchParams.get("api_url") || url.searchParams.get("api_base_url");

if (apiKey?.startsWith("sm_")) {
saveCredentials(apiKey);
saveCredentials(apiKey, apiBaseUrl ?? undefined);
res.writeHead(200, { "Content-Type": "text/html" });
res.end(AUTH_SUCCESS_HTML);
resolved = true;
Expand All @@ -112,7 +128,7 @@ export function startAuthFlow(): Promise<string> {
// the callback URL so the console redirects it back through the redirect.
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as AddressInfo;
const callbackUrl = `http://localhost:${port}/callback?state=${stateToken}`;
const callbackUrl = `http://127.0.0.1:${port}/callback?state=${stateToken}`;
const params = new URLSearchParams({
callback: callbackUrl,
client: "codex",
Expand All @@ -122,7 +138,13 @@ export function startAuthFlow(): Promise<string> {
cli_version: "1.0.0",
});
const authUrl = `${AUTH_BASE_URL}?${params.toString()}`;
openBrowser(authUrl);
openUrl(authUrl).catch((error) => {
if (!resolved) {
clearTimeout(timer);
server.close();
reject(new Error(`Failed to open browser: ${error.message}`));
}
});
});

server.on("error", (err) => {
Expand Down
3 changes: 2 additions & 1 deletion src/services/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Supermemory from "supermemory";
import { CONFIG, isConfigured, getApiKeyValue, PLUGIN_VERSION } from "../config.js";
import { CONFIG, isConfigured, getApiBaseUrl, getApiKeyValue, PLUGIN_VERSION } from "../config.js";
Comment thread
ishaanxgupta marked this conversation as resolved.
import { log } from "./logger.js";
import type { MemoryType } from "../types/index.js";

Expand Down Expand Up @@ -74,6 +74,7 @@ export class SupermemoryClient {
// writes to the Codex plugin in PostHog / `document.source`.
this.client = new Supermemory({
apiKey: getApiKeyValue(),
baseURL: getApiBaseUrl(),
defaultHeaders: { "x-sm-source": CODEX_SOURCE },
});
}
Expand Down
34 changes: 34 additions & 0 deletions src/services/openUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { execFile } from "node:child_process";

function run(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
execFile(command, args, { windowsHide: true }, (error) => {
if (error) reject(error);
else resolve();
});
});
}

export async function openUrl(url: string | URL): Promise<void> {
const href = url.toString();
if (!/^https?:\/\//i.test(href)) {
throw new Error("Refusing to open non-http URL");
}

if (process.platform === "win32") {
try {
await run("rundll32.exe", ["url.dll,FileProtocolHandler", href]);
return;
} catch {}

await run("cmd.exe", ["/c", "start", '""', href]);
return;
}

if (process.platform === "darwin") {
await run("open", [href]);
return;
}

await run("xdg-open", [href]);
}
14 changes: 10 additions & 4 deletions src/skills/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@ import { isConfigured } from "../config.js";
import { startAuthFlow, AUTH_BASE_URL, CREDENTIALS_FILE } from "../services/auth.js";

const AUTH_ATTEMPTED_FILE = join(homedir(), ".codex", "supermemory", ".auth-attempted");
const LOGGED_OUT_FILE = join(homedir(), ".codex", "supermemory", ".logged-out");

async function main(): Promise<void> {
// Clear the auth-attempted marker so the recall hook will try browser auth again
try {
if (existsSync(AUTH_ATTEMPTED_FILE)) unlinkSync(AUTH_ATTEMPTED_FILE);
if (existsSync(LOGGED_OUT_FILE)) unlinkSync(LOGGED_OUT_FILE);
} catch {}

if (isConfigured()) {
console.log("Already authenticated with Supermemory. Memory is active.");
console.log(`To re-authenticate, remove ${CREDENTIALS_FILE} and run this again.`);
return;
process.exit(0);
}

// Clear the auth-attempted marker so the recall hook will try browser auth again.
try {
if (existsSync(AUTH_ATTEMPTED_FILE)) unlinkSync(AUTH_ATTEMPTED_FILE);
} catch {}

console.log("Opening browser to authenticate with Supermemory...");
console.log(`If the browser does not open, visit: ${AUTH_BASE_URL}`);

Expand All @@ -27,6 +32,7 @@ async function main(): Promise<void> {
if (existsSync(AUTH_ATTEMPTED_FILE)) unlinkSync(AUTH_ATTEMPTED_FILE);
} catch {}
console.log("\nAuthenticated successfully! Supermemory is now active.");
process.exit(0);
} catch (err) {
const isTimeout = err instanceof Error && err.message === "AUTH_TIMEOUT";
if (isTimeout) {
Expand All @@ -36,7 +42,7 @@ async function main(): Promise<void> {
}
console.error(`\nAlternatively, set the API key manually:`);
console.error(` export SUPERMEMORY_CODEX_API_KEY="sm_..."`);
console.error(` Get your key at: https://console.supermemory.ai/keys`);
console.error(` Get your key at: https://app.supermemory.ai/?view=integrations`);
process.exit(1);
}
}
Expand Down
Loading