Skip to content
Open
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
48 changes: 30 additions & 18 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
copyFileSync,
rmSync,
} from "node:fs";
import { loadCredentials } from "./services/auth.js";
import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary } from "./config.js";
import { loadCredentialData, startAuthFlow, clearCredentials } from "./services/auth.js";
import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary, getApiKeyValue, isConfigured } from "./config.js";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -578,14 +578,9 @@ function uninstall() {
}

function status() {
const envApiKey = process.env.SUPERMEMORY_CODEX_API_KEY;
const credentialsApiKey = !envApiKey ? loadCredentials() : undefined;
const apiKey = envApiKey || credentialsApiKey;
const apiKeySource = envApiKey
? "SUPERMEMORY_CODEX_API_KEY env var"
: credentialsApiKey
? "credentials file (~/.codex/supermemory/credentials.json)"
: null;
const apiKey = getApiKeyValue();
const credentials = loadCredentialData();
const configured = isConfigured();

const hooksInstalled =
existsSync(RECALL_SCRIPT) &&
Expand Down Expand Up @@ -646,7 +641,11 @@ function status() {
}

console.log("codex-supermemory status:\n");
console.log(` API key: ${apiKey ? `✓ set (${apiKeySource})` : "✗ not set"}`);
console.log(` Authentication: ${apiKey ? "API key" : credentials?.type === "oauth" ? "OAuth" : "not configured"}`);
if (!apiKey && credentials?.type === "oauth") {
console.log(` OAuth client: ${credentials.clientId}`);
console.log(` Login needed: ${configured ? "no" : "yes"}`);
}
console.log(` Recall mode: ${getRecallModeSummary()}`);
console.log(` Hook scripts: ${hooksInstalled ? `✓ installed at ${SUPERMEMORY_HOOKS_DIR}` : "✗ not installed"}`);
console.log(` hooks.json: ${hooksEnabled ? "✓ registered (implicit memory)" : "✗ not registered"}`);
Expand All @@ -655,15 +654,16 @@ function status() {
console.log(` Persistent mark: ${persistentIndicatorEnabled ? "✓ enabled" : ownsSupermemoryPet() ? "○ installed, another pet selection is active" : "✗ not installed"}`);
console.log(` config.toml: ${configTomlExists ? "✓ exists" : "✗ not found"}`);

if (!apiKey || !hooksInstalled || !hooksEnabled || !mcpInstalled || !statusSkillInstalled) {
if (!configured || !hooksInstalled || !hooksEnabled || !mcpInstalled || !statusSkillInstalled) {
console.log("\nRun `npx codex-supermemory install` to set up.");
if (!configured) console.log("Run `npx codex-supermemory login` to authenticate.");
} else {
console.log("\nAll good! Memory is active.");
}
}

const command = process.argv[2];
try {
async function main(): Promise<void> {
const command = process.argv[2];
switch (command) {
case "install":
install();
Expand All @@ -674,11 +674,23 @@ try {
case "status":
status();
break;
case "login":
await startAuthFlow(undefined, { printUrl: process.argv.includes("--print-url") });
console.log("Supermemory connected through OAuth.");
if (getApiKeyValue()) console.log("An environment/config API key still takes precedence. Remove it to use OAuth.");
break;
case "logout":
await clearCredentials();
console.log("Supermemory saved credentials cleared; automatic login is paused until you run login.");
if (getApiKeyValue()) console.log("An environment/config API key is still configured and has not been removed.");
break;
default:
console.log("Usage: codex-supermemory <install|uninstall|status>");
console.log("Usage: codex-supermemory <install|uninstall|status|login|logout>");
process.exit(1);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
53 changes: 50 additions & 3 deletions 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 { loadCredentialData, loadCredentials } from "./services/auth.js";
import { loadCredentialData, loadCredentials, getAccessToken, getOAuthConfig, hasOAuthCredentials, authRequired } from "./services/auth.js";

export { PLUGIN_VERSION } from "./version.js";

Expand Down Expand Up @@ -160,13 +160,43 @@ export const CONFIG = {
};

export function isConfigured(): boolean {
return !!SUPERMEMORY_API_KEY;
if (getApiKey()) return true;
try {
return hasOAuthCredentials(getOAuthConfig(getBaseUrl(), getMcpUrl()));
} catch {
return false;
}
}

export function getApiKeyValue(): string | undefined {
return SUPERMEMORY_API_KEY;
return getApiKey();
}

export async function getAuthToken(timeoutMs?: number, apiBaseUrl?: string): Promise<string> {
const apiKey = getApiKey();
if (apiKey) return apiKey;
if (loadCredentialData()?.type !== "oauth") throw authRequired();
return getAccessToken(getOAuthConfig(apiBaseUrl ?? getBaseUrl(), getMcpUrl()), timeoutMs);
}

// The legacy SDK only accepts a static API key. Resolve the bearer at send time
// so cached SDK clients and retries observe rotated tokens and logout.
export const authenticatedFetch: typeof fetch = async (input, init) => {
const target = new URL(input instanceof Request ? input.url : input.toString());
const base = new URL(getBaseUrl());
const basePath = base.pathname.replace(/\/+$/, "");
if (target.origin !== base.origin || !target.pathname.startsWith(`${basePath}/`)) {
throw authRequired();
}
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
signal?.throwIfAborted();
const token = await getAuthToken(undefined, base.toString());
signal?.throwIfAborted();
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
headers.set("Authorization", `Bearer ${token}`);
return fetch(input, { ...init, headers });
};

function normalizeBaseUrl(baseUrl: unknown): string | null {
if (typeof baseUrl !== "string" || !baseUrl.trim()) return null;

Expand Down Expand Up @@ -194,6 +224,23 @@ export function getBaseUrl(): string {
return normalized;
}

export function getMcpUrl(): string {
if (process.env.SUPERMEMORY_MCP_URL) {
const configured = normalizeBaseUrl(process.env.SUPERMEMORY_MCP_URL);
if (!configured) throw new Error("Invalid SUPERMEMORY_MCP_URL");
return configured;
}
const api = new URL(getBaseUrl());
if (api.hostname.startsWith("api.")) {
api.hostname = api.hostname.replace(/^api\./, "mcp.");
api.pathname = "/mcp";
api.search = "";
api.hash = "";
return api.toString();
}
throw new Error("Set SUPERMEMORY_MCP_URL for OAuth or MCP on a custom Supermemory backend.");
}

export function getApiVersion(): "v5" | "legacy" {
const configured = process.env.SUPERMEMORY_API_VERSION ?? fileConfig.apiVersion;
if (configured !== undefined) {
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/flush.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { settleAuth } from "../services/auth.js";
import { readFileSync } from "node:fs";
import { isConfigured } from "../config.js";
import { SupermemoryClient } from "../services/client.js";
Expand Down Expand Up @@ -51,6 +52,7 @@ async function main() {
}
}

// Let a refresh that is still rotating tokens save them before exiting.
main()
.catch(() => {})
.finally(() => process.exit(0));
.finally(() => settleAuth().finally(() => process.exit(0)));
28 changes: 12 additions & 16 deletions src/hooks/mcp-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { createInterface } from "node:readline";
import { getApiKeyValue } from "../config.js";

const MCP_URL =
process.env.SUPERMEMORY_MCP_URL || "https://mcp.supermemory.ai/mcp";
import { getAuthToken, getMcpUrl } from "../config.js";
import { isAuthRequired , settleAuth } from "../services/auth.js";
const REQUEST_TIMEOUT_MS = 30_000;

let sessionId: string | null = null;
Expand Down Expand Up @@ -43,7 +41,7 @@ async function forward(message: JsonRpcMessage, apiKey: string): Promise<void> {
};
if (sessionId) headers["Mcp-Session-Id"] = sessionId;

const response = await fetch(MCP_URL, {
const response = await fetch(getMcpUrl(), {
method: "POST",
headers,
body: JSON.stringify(message),
Expand Down Expand Up @@ -73,7 +71,6 @@ async function forward(message: JsonRpcMessage, apiKey: string): Promise<void> {
}

function main(): void {
const apiKey = getApiKeyValue();
let queue = Promise.resolve();
const lines = createInterface({ input: process.stdin });

Expand All @@ -88,26 +85,25 @@ function main(): void {
}

queue = queue.then(async () => {
if (!apiKey) {
sendError(
message.id,
-32001,
"Supermemory is not authenticated. Start a new Codex task to log in, or set SUPERMEMORY_CODEX_API_KEY.",
);
return;
}

try {
// SessionStart may authenticate after the proxy has already started.
const apiKey = await getAuthToken();
await forward(message, apiKey);
} catch (error) {
if (isAuthRequired(error)) {
sessionId = null;
sendError(message.id, -32001, "Supermemory is not authenticated. Start a new Codex task to log in, run `npx codex-supermemory login`, or set SUPERMEMORY_CODEX_API_KEY.");
return;
}
const detail = error instanceof Error ? error.message : String(error);
sendError(message.id, -32000, `Supermemory MCP proxy error: ${detail}`);
}
});
});

lines.on("close", () => {
queue.finally(() => process.exit(0));
// Let a refresh that is still rotating tokens save them before exiting.
queue.finally(() => settleAuth().finally(() => process.exit(0)));
});
}

Expand Down
19 changes: 12 additions & 7 deletions src/hooks/recall.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { exitHook, isHookExit } from "../services/auth.js";
import { readFileSync } from "node:fs";
import { isConfigured, CONFIG } from "../config.js";
import { getTags } from "../services/tags.js";
Expand Down Expand Up @@ -34,7 +35,7 @@ function exitWithContext(additionalContext: string, systemMessage?: string): nev
})
);
}
process.exit(0);
exitHook(0);
}

interface RecallItem {
Expand Down Expand Up @@ -65,15 +66,15 @@ async function main() {
try {
rawInput = readFileSync(0, "utf-8");
} catch {
process.exit(0);
exitHook(0);
}

if (!isConfigured()) {
// UserPromptSubmit has a 5s backstop and must never launch browser auth.
// SessionStart owns authentication.
exitWithContext(
"[SUPERMEMORY] Memory is installed but NOT active — missing API key.\n" +
"Start a new Codex task to authenticate, or set SUPERMEMORY_CODEX_API_KEY in your shell profile."
"[SUPERMEMORY] Memory is installed but NOT active — authentication required.\n" +
"Run `npx codex-supermemory login`, start a new Codex task to authenticate, or set SUPERMEMORY_CODEX_API_KEY in your shell profile."
);
}

Expand Down Expand Up @@ -148,11 +149,15 @@ async function main() {

exitWithContext("");
} catch (error) {
if (isHookExit(error)) throw error;
log("recall: error", { error: String(error) });
exitWithContext("", "◪ supermemory · recall unavailable; continuing without recalled context");
}
}

main().catch(() => {
exitWithContext("");
});
main()
.catch((error) => {
if (isHookExit(error)) return;
exitWithContext("");
})
.catch(() => {});
45 changes: 29 additions & 16 deletions src/hooks/session-start.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { isConfigured, CONFIG, PLUGIN_VERSION, reloadApiKey } from "../config.js";
import { getAuthToken, CONFIG, PLUGIN_VERSION } 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 { log } from "../services/logger.js";
import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js";
import { startAuthFlow, isAuthRequired, isLoggedOut, exitHook, isHookExit, AuthError } from "../services/auth.js";
import { getSeenFacts, addSeenFacts } from "../services/factCache.js";
import { checkNpmUpdate, formatUpdateNotice } from "../services/version-check.js";

Expand Down Expand Up @@ -46,7 +46,7 @@ function exitWithContext(additionalContext: string, systemMessage?: string): nev
})
);
}
process.exit(0);
exitHook(0);
}

function combineContextParts(parts: Array<string | null | undefined>): string {
Expand All @@ -69,18 +69,27 @@ async function main() {
try {
rawInput = readFileSync(0, "utf-8");
} catch {
process.exit(0);
exitHook(0);
}

if (!isConfigured()) {
try {
await getAuthToken(1000);
Comment thread
ishaanxgupta marked this conversation as resolved.
} catch (error) {
try {
// Transient refresh failures must not launch another browser login.
if (!isAuthRequired(error) || isLoggedOut()) throw error;
await startAuthFlow(getSessionStartAuthTimeoutMs());
reloadApiKey();
} catch {
} catch (authError) {
// The refresh still completes in the background for the next hook.
if (authError instanceof AuthError && authError.code === "AUTH_PENDING") {
exitWithContext(
"[SUPERMEMORY] Memory is installed; the Supermemory sign-in is still refreshing. " +
"Memories will load on the next prompt.",
);
}
exitWithContext(
"[SUPERMEMORY] Memory is installed but NOT active — missing API key.\n" +
`Visit: ${AUTH_BASE_URL}\n` +
"A new Codex task will try browser authentication again, or set SUPERMEMORY_CODEX_API_KEY."
"[SUPERMEMORY] Memory is installed but NOT active — authentication unavailable.\n" +
"Run `npx codex-supermemory login` to connect, or set SUPERMEMORY_CODEX_API_KEY."
);
}
}
Expand Down Expand Up @@ -157,6 +166,7 @@ ${text}
markTip(),
]));
} catch (error) {
if (isHookExit(error)) throw error;
log("session-start: error", { error: String(error) });
exitWithContext(
"",
Expand All @@ -168,9 +178,12 @@ ${text}
}
}

main().catch(() => {
exitWithContext(
"",
"◪ supermemory · profile unavailable; continuing without recalled context",
);
});
main()
.catch((error) => {
if (isHookExit(error)) return;
exitWithContext(
"",
"◪ supermemory · profile unavailable; continuing without recalled context",
);
})
.catch(() => {});
Loading