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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----------|--------|-------|
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 37 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,36 @@ function hasRealValue(v: string | undefined): v is string {
return typeof v === "string" && v.trim().length > 0;
}

function buildCodexSdkConfig(
env: Record<string, string>,
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<string, string>): 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") {
Expand Down Expand Up @@ -98,18 +126,20 @@ function detectProvider(env: Record<string, string>): 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",
Expand Down Expand Up @@ -307,6 +337,7 @@ const VALID_PROVIDERS = new Set([
"gemini",
"openrouter",
"agent-sdk",
"codex-sdk",
"minimax",
"openai",
]);
Expand Down
160 changes: 160 additions & 0 deletions src/providers/codex-sdk.ts
Original file line number Diff line number Diff line change
@@ -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.`,
"",
"<system>",
systemPrompt,
"</system>",
"",
"<user>",
userPrompt,
"</user>",
].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<string> {
return this.query(systemPrompt, userPrompt);
}

async summarize(systemPrompt: string, userPrompt: string): Promise<string> {
return this.query(systemPrompt, userPrompt);
}

private async query(systemPrompt: string, userPrompt: string): Promise<string> {
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<string>((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);
});
}
}
3 changes: 3 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading