diff --git a/.env.example b/.env.example index 77ca0f3a..5ff84cee 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,14 @@ # OPENAI_TIMEOUT_MS alias for back-compat with v0.9.17 (precedence). # AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s) +# Opt-in Codex/ChatGPT subscription fallback (spawns `codex exec` child +# sessions). Off by default; uses the supported CLI surface and never reads +# private Codex token files. Can consume subscription quota/rate limits. +# AGENTMEMORY_ALLOW_CODEX_SDK=true +# AGENTMEMORY_PREFER_CODEX_SDK=true # Optional; prefer Codex even if API keys exist +# AGENTMEMORY_CODEX_MODEL=gpt-5.4-mini # Optional; defaults to Codex CLI config +# AGENTMEMORY_CODEX_TIMEOUT_MS=60000 # Optional; falls back to AGENTMEMORY_LLM_TIMEOUT_MS +# # Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk # child sessions). Off by default — the agent-sdk fallback can trigger # Stop-hook recursion (#149 follow-up) when invoked from inside Claude Code. diff --git a/README.md b/README.md index 63f78056..b353196b 100644 --- a/README.md +++ b/README.md @@ -1065,7 +1065,7 @@ Full registry: [workers.iii.dev](https://workers.iii.dev). Every worker there co ### LLM Providers -agentmemory auto-detects from your environment. By default, no LLM calls are made unless you configure a provider or explicitly opt in to the Claude subscription fallback. +agentmemory auto-detects from your environment. By default, no LLM calls are made unless you configure a provider or explicitly opt in to a subscription-auth fallback. | Provider | Config | Notes | |----------|--------|-------| @@ -1074,6 +1074,7 @@ agentmemory auto-detects from your environment. By default, no LLM calls are mad | MiniMax | `MINIMAX_API_KEY` | Anthropic-compatible | | Gemini | `GEMINI_API_KEY` | Also enables embeddings | | OpenRouter | `OPENROUTER_API_KEY` | Any model | +| Codex subscription fallback | `AGENTMEMORY_ALLOW_CODEX_SDK=true` | Opt-in only. Spawns `codex exec` and reuses the logged-in Codex/ChatGPT session through the supported CLI surface; never reads private token files. API-key providers still win unless `AGENTMEMORY_PREFER_CODEX_SDK=true` is also set. | | Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Opt-in only. Spawns `@anthropic-ai/claude-agent-sdk` sessions — used to cause unbounded Stop-hook recursion (#149 follow-up) so it is no longer the default. | ### Config File @@ -1089,6 +1090,14 @@ New-Item -ItemType Directory -Force $HOME\.agentmemory notepad $HOME\.agentmemory\.env ``` +To test with a Codex/ChatGPT subscription instead of an API key, opt in explicitly: + +```env +AGENTMEMORY_ALLOW_CODEX_SDK=true +AGENTMEMORY_PREFER_CODEX_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + To test with a Claude Code Pro/Max subscription instead of an API key, opt in explicitly: ```env @@ -1138,6 +1147,13 @@ Create `~/.agentmemory/.env`: # # but no content. # OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection # # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Codex/ChatGPT subscription fallback (spawns `codex exec`); leave OFF +# unless you understand quota/rate-limit impact. Uses the supported CLI surface +# and does not read private Codex token files: +# AGENTMEMORY_ALLOW_CODEX_SDK=true +# AGENTMEMORY_PREFER_CODEX_SDK=true # Optional; prefer Codex even if API keys exist +# AGENTMEMORY_CODEX_MODEL=gpt-5.4-mini # Optional; defaults to Codex CLI config +# AGENTMEMORY_CODEX_TIMEOUT_MS=60000 # Optional; overrides AGENTMEMORY_LLM_TIMEOUT_MS # Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); # leave OFF unless you understand the Stop-hook recursion risk (#149 follow-up): # AGENTMEMORY_ALLOW_AGENT_SDK=true diff --git a/src/config.ts b/src/config.ts index eed5725e..17e7d6a1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,8 +47,36 @@ function hasRealValue(v: string | undefined): v is string { return typeof v === "string" && v.trim().length > 0; } +function buildCodexSdkConfig( + env: Record, + maxTokens: number, + preferred: boolean, +): ProviderConfig { + process.stderr.write( + "[agentmemory] WARNING: Codex CLI subscription-auth fallback enabled via AGENTMEMORY_ALLOW_CODEX_SDK=true. " + + "This shells out to `codex exec` and uses your logged-in Codex/ChatGPT session instead of reading private token files. " + + "It is opt-in because it can consume subscription quota/rate limits; AGENTMEMORY_SDK_CHILD is set on the child process " + + `so agentmemory hooks short-circuit and avoid recursive summarization. ${ + preferred + ? "AGENTMEMORY_PREFER_CODEX_SDK=true is set, so Codex CLI is preferred over API-key providers." + : "Prefer a real API key for production." + }\n`, + ); + return { + provider: "codex-sdk", + model: env["AGENTMEMORY_CODEX_MODEL"] || "codex-default", + maxTokens, + }; +} + function detectProvider(env: Record): ProviderConfig { const maxTokens = parseInt(env["MAX_TOKENS"] || "4096", 10); + const allowCodexSdk = env["AGENTMEMORY_ALLOW_CODEX_SDK"] === "true"; + const preferCodexSdk = env["AGENTMEMORY_PREFER_CODEX_SDK"] === "true"; + + if (allowCodexSdk && preferCodexSdk) { + return buildCodexSdkConfig(env, maxTokens, true); + } // OpenAI-compatible: supports OpenAI, DeepSeek, SiliconFlow, Azure, vLLM, LM Studio if (hasRealValue(env["OPENAI_API_KEY"]) && env["OPENAI_API_KEY_FOR_LLM"] !== "false") { @@ -98,18 +126,20 @@ function detectProvider(env: Record): ProviderConfig { }; } + if (allowCodexSdk) { + return buildCodexSdkConfig(env, maxTokens, false); + } + const allowAgentSdk = env["AGENTMEMORY_ALLOW_AGENT_SDK"] === "true"; if (!allowAgentSdk) { process.stderr.write( "[agentmemory] No LLM provider key found " + "(ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, MINIMAX_API_KEY, OPENAI_API_KEY). " + "LLM-backed compression and summarization are DISABLED — using no-op provider. " + - "This is the safe default: the agent-sdk fallback used to spawn Claude Agent SDK " + - "child sessions which inherit Claude Code's plugin hooks and cause infinite Stop-hook " + - "recursion (#149 follow-up). To opt in to the agent-sdk fallback anyway, set both " + - "AGENTMEMORY_AUTO_COMPRESS=true AND AGENTMEMORY_ALLOW_AGENT_SDK=true — but be aware " + - "it will burn your Claude Pro allocation and may still recurse if you use it from " + - "inside Claude Code itself.\n", + "This is the safe default: subscription-auth fallbacks spawn child agent sessions " + + "and can burn quota or recurse through hooks if enabled carelessly. To opt in, set " + + "AGENTMEMORY_ALLOW_CODEX_SDK=true for the Codex CLI fallback or " + + "AGENTMEMORY_ALLOW_AGENT_SDK=true for the Claude Agent SDK fallback.\n", ); return { provider: "noop", @@ -307,6 +337,7 @@ const VALID_PROVIDERS = new Set([ "gemini", "openrouter", "agent-sdk", + "codex-sdk", "minimax", "openai", ]); diff --git a/src/providers/codex-sdk.ts b/src/providers/codex-sdk.ts new file mode 100644 index 00000000..8215414c --- /dev/null +++ b/src/providers/codex-sdk.ts @@ -0,0 +1,160 @@ +import { spawn } from "node:child_process"; + +import type { MemoryProvider } from "../types.js"; +import { getEnvVar } from "../config.js"; + +const DEFAULT_MODEL = "codex-default"; +const DEFAULT_TIMEOUT_MS = 60_000; +const MAX_ERROR_CHARS = 4_000; + +function parseTimeout(value: string | undefined): number { + if (!value) return DEFAULT_TIMEOUT_MS; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS; +} + +function stripAnsi(value: string): string { + return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function truncate(value: string): string { + const cleaned = stripAnsi(value).trim(); + if (cleaned.length <= MAX_ERROR_CHARS) return cleaned; + return `${cleaned.slice(0, MAX_ERROR_CHARS)}...`; +} + +function buildPrompt(systemPrompt: string, userPrompt: string, maxTokens: number): string { + return [ + "You are agentmemory's Codex CLI compression worker.", + "Do not use tools. Return only the requested memory output.", + `Keep the response within approximately ${maxTokens} tokens.`, + "", + "", + systemPrompt, + "", + "", + "", + userPrompt, + "", + ].join("\n"); +} + +/** + * Opt-in Codex CLI subscription-auth fallback. + * + * This intentionally shells out through the supported `codex exec` CLI surface + * instead of reading private Codex/ChatGPT token files. The child process is + * marked with AGENTMEMORY_SDK_CHILD so agentmemory hooks short-circuit and do + * not recursively capture/summarize the compression session. + */ +export class CodexSDKProvider implements MemoryProvider { + name = "codex-sdk"; + + constructor( + private model = getEnvVar("AGENTMEMORY_CODEX_MODEL") || DEFAULT_MODEL, + private maxTokens = 4_096, + private command = getEnvVar("AGENTMEMORY_CODEX_COMMAND") || "codex", + private timeoutMs = parseTimeout( + getEnvVar("AGENTMEMORY_CODEX_TIMEOUT_MS") || + getEnvVar("AGENTMEMORY_LLM_TIMEOUT_MS"), + ), + ) {} + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.query(systemPrompt, userPrompt); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.query(systemPrompt, userPrompt); + } + + private async query(systemPrompt: string, userPrompt: string): Promise { + if (process.env.AGENTMEMORY_SDK_CHILD === "1") { + return ""; + } + + const args = [ + "exec", + "--ephemeral", + "--ignore-rules", + "--sandbox", + "read-only", + "--skip-git-repo-check", + ]; + if (this.model && this.model !== DEFAULT_MODEL) { + args.push("--model", this.model); + } + args.push("-"); + + const prompt = buildPrompt(systemPrompt, userPrompt, this.maxTokens); + + return await new Promise((resolve, reject) => { + let settled = false; + let stdout = ""; + let stderr = ""; + let timer: NodeJS.Timeout; + + const child = spawn(this.command, args, { + env: { + ...process.env, + AGENTMEMORY_SDK_CHILD: "1", + AGENTMEMORY_CODEX_SDK_CHILD: "1", + NO_COLOR: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const finish = (err: Error | null, value = "") => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) reject(err); + else resolve(value); + }; + + timer = setTimeout(() => { + child.kill("SIGTERM"); + finish( + new Error( + `Codex CLI request timed out after ${this.timeoutMs}ms — set AGENTMEMORY_CODEX_TIMEOUT_MS (or AGENTMEMORY_LLM_TIMEOUT_MS) to raise the bound.`, + ), + ); + }, this.timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.stdin.on("error", () => undefined); + + child.on("error", (err) => { + finish( + new Error( + `Failed to launch Codex CLI (${this.command}): ${err.message}. Install Codex CLI and run codex login, or unset AGENTMEMORY_ALLOW_CODEX_SDK.`, + ), + ); + }); + + child.on("close", (code, signal) => { + if (code === 0) { + finish(null, stripAnsi(stdout).trim()); + return; + } + const detail = truncate(stderr || stdout); + finish( + new Error( + `Codex CLI exited with ${signal ? `signal ${signal}` : `code ${code}`}${ + detail ? `: ${detail}` : "" + }`, + ), + ); + }); + + child.stdin.end(prompt); + }); + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 5de6807c..7fe19939 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,6 +5,7 @@ import type { } from "../types.js"; import { AgentSDKProvider } from "./agent-sdk.js"; import { AnthropicProvider } from "./anthropic.js"; +import { CodexSDKProvider } from "./codex-sdk.js"; import { MinimaxProvider } from "./minimax.js"; import { NoopProvider } from "./noop.js"; import { OpenAIProvider } from "./openai.js"; @@ -111,6 +112,8 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { } case "noop": return new NoopProvider(); + case "codex-sdk": + return new CodexSDKProvider(config.model, config.maxTokens); case "agent-sdk": default: return new AgentSDKProvider(); diff --git a/src/types.ts b/src/types.ts index 72e347b3..947295bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,7 +143,15 @@ export interface ProviderConfig { baseURL?: string; } -export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "openai" | "noop"; +export type ProviderType = + | "agent-sdk" + | "codex-sdk" + | "anthropic" + | "gemini" + | "openrouter" + | "minimax" + | "openai" + | "noop"; export interface MemoryProvider { name: string; diff --git a/test/codex-sdk-provider.test.ts b/test/codex-sdk-provider.test.ts new file mode 100644 index 00000000..bc77ed39 --- /dev/null +++ b/test/codex-sdk-provider.test.ts @@ -0,0 +1,79 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { CodexSDKProvider } from "../src/providers/codex-sdk.js"; + +let tempDirs: string[] = []; + +function makeFakeCodex(body: string): string { + const dir = mkdtempSync(join(tmpdir(), "agentmemory-codex-")); + tempDirs.push(dir); + const file = join(dir, "codex"); + writeFileSync(file, `#!/usr/bin/env node\n${body}\n`); + chmodSync(file, 0o755); + return file; +} + +describe("CodexSDKProvider", () => { + afterEach(() => { + delete process.env.AGENTMEMORY_SDK_CHILD; + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; + }); + + it("invokes codex exec through stdin with the hook recursion guard set", async () => { + const command = makeFakeCodex(` +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { input += chunk; }); +process.stdin.on("end", () => { + const argv = process.argv.slice(2); + if (process.env.AGENTMEMORY_SDK_CHILD !== "1") { + console.error("missing recursion guard"); + process.exit(7); + } + if (!argv.includes("exec") || !argv.includes("--ephemeral") || !argv.includes("--ignore-rules") || !argv.includes("--skip-git-repo-check") || !argv.includes("-")) { + console.error("missing expected codex exec flags: " + argv.join(" ")); + process.exit(8); + } + console.log(input.includes("\\nsystem text\\n") && input.includes("\\nuser text\\n") ? "ok" : "bad prompt"); +}); +`); + + const provider = new CodexSDKProvider("codex-default", 128, command, 2_000); + + await expect(provider.compress("system text", "user text")).resolves.toBe("ok"); + }); + + it("short-circuits when already running inside an agentmemory SDK child", async () => { + process.env.AGENTMEMORY_SDK_CHILD = "1"; + const provider = new CodexSDKProvider( + "codex-default", + 128, + "/definitely/not/codex", + 100, + ); + + await expect(provider.summarize("system", "user")).resolves.toBe(""); + }); + + it("surfaces codex exec failures", async () => { + const command = makeFakeCodex(` +process.stdin.resume(); +process.stdin.on("end", () => { + console.error("boom"); + process.exit(4); +}); +`); + const provider = new CodexSDKProvider("codex-default", 128, command, 2_000); + + await expect(provider.compress("system", "user")).rejects.toThrow( + /Codex CLI exited with code 4: boom/, + ); + }); +}); diff --git a/test/env-loader.test.ts b/test/env-loader.test.ts index 17ff6a8e..e7d6a7b0 100644 --- a/test/env-loader.test.ts +++ b/test/env-loader.test.ts @@ -28,6 +28,15 @@ describe("loadEnvFile", () => { delete process.env["AGENTMEMORY_DROP_STALE_INDEX"]; delete process.env["CONSOLIDATION_ENABLED"]; delete process.env["GRAPH_EXTRACTION_ENABLED"]; + delete process.env["AGENTMEMORY_ALLOW_CODEX_SDK"]; + delete process.env["AGENTMEMORY_PREFER_CODEX_SDK"]; + delete process.env["AGENTMEMORY_CODEX_MODEL"]; + delete process.env["OPENAI_API_KEY"]; + delete process.env["MINIMAX_API_KEY"]; + delete process.env["ANTHROPIC_API_KEY"]; + delete process.env["GEMINI_API_KEY"]; + delete process.env["GOOGLE_API_KEY"]; + delete process.env["OPENROUTER_API_KEY"]; delete process.env["TOKEN"]; delete process.env["HASHVAL"]; }); @@ -89,4 +98,44 @@ describe("loadEnvFile", () => { const cfg = await freshConfig(); expect(cfg.isDropStaleIndexEnabled()).toBe(true); }); + + it("detects the opt-in Codex SDK fallback when no provider key exists", async () => { + writeEnv( + [ + "AGENTMEMORY_ALLOW_CODEX_SDK=true", + "AGENTMEMORY_CODEX_MODEL=gpt-5.4-mini", + ].join("\n"), + ); + const cfg = await freshConfig(); + + expect(cfg.loadConfig().provider).toMatchObject({ + provider: "codex-sdk", + model: "gpt-5.4-mini", + }); + }); + + it("prefers API-key providers over the Codex SDK fallback", async () => { + writeEnv( + [ + "AGENTMEMORY_ALLOW_CODEX_SDK=true", + "OPENAI_API_KEY=sk-test", + ].join("\n"), + ); + const cfg = await freshConfig(); + + expect(cfg.loadConfig().provider.provider).toBe("openai"); + }); + + it("can explicitly prefer the Codex SDK fallback over API-key providers", async () => { + writeEnv( + [ + "AGENTMEMORY_ALLOW_CODEX_SDK=true", + "AGENTMEMORY_PREFER_CODEX_SDK=true", + "OPENAI_API_KEY=sk-test", + ].join("\n"), + ); + const cfg = await freshConfig(); + + expect(cfg.loadConfig().provider.provider).toBe("codex-sdk"); + }); });