From b88e8e93c1c8ae71a422b6f6d80a037179effca8 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 19 May 2026 19:15:45 -0700 Subject: [PATCH 1/8] Add Codex CLI LLM provider --- packages/core/.env.example | 5 + packages/core/Dockerfile | 11 ++ packages/core/README.md | 15 +- .../core/src/__tests__/config-env.test.ts | 13 ++ packages/core/src/config.ts | 5 +- .../services/__tests__/codex-cli-llm.test.ts | 138 ++++++++++++++ .../services/__tests__/llm-providers.test.ts | 7 + packages/core/src/services/codex-cli-llm.ts | 180 ++++++++++++++++++ packages/core/src/services/llm.ts | 9 + plugins/codex/README.md | 18 ++ 10 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/services/__tests__/codex-cli-llm.test.ts create mode 100644 packages/core/src/services/codex-cli-llm.ts diff --git a/packages/core/.env.example b/packages/core/.env.example index a689563..4a369ad 100644 --- a/packages/core/.env.example +++ b/packages/core/.env.example @@ -108,6 +108,11 @@ EMBEDDING_DIMENSIONS=1536 # For fully local/no-provider-key development, pair this with a non-OpenAI # embedding provider such as EMBEDDING_PROVIDER=transformers. +# Personal local Codex extraction, no separate OpenAI API key: +# LLM_PROVIDER=codex +# For fully local/no-provider-key development, pair this with a non-OpenAI +# embedding provider such as EMBEDDING_PROVIDER=transformers. + # --- Runtime config mutation (dev/test only) --- # Opt-in gate for PUT /memories/config. Leave unset in production — the # route returns 410 Gone unless this is true. diff --git a/packages/core/Dockerfile b/packages/core/Dockerfile index 6ea5bf2..a95aebc 100644 --- a/packages/core/Dockerfile +++ b/packages/core/Dockerfile @@ -45,6 +45,8 @@ RUN pnpm deploy --filter @atomicmemory/core --prod /deploy # --------------------------------------------------------------------------- FROM pgvector/pgvector:pg17 +ARG CODEX_CLI_VERSION=0.131.0 + WORKDIR /app COPY --from=node-base /usr/local/bin/node /usr/local/bin/node @@ -52,6 +54,15 @@ COPY --from=node-base /usr/local/lib/node_modules /usr/local/lib/node_modules RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bubblewrap \ + && rm -rf /var/lib/apt/lists/* + +# Optional local-account LLM provider support. `LLM_PROVIDER=codex` delegates +# extraction turns to this CLI when a Codex auth home is mounted at runtime. +RUN npm install -g --no-audit --no-fund "@openai/codex@${CODEX_CLI_VERSION}" \ + && codex --version | grep -q "${CODEX_CLI_VERSION}" + # Production node_modules + package.json from pnpm deploy. COPY --from=builder /deploy/node_modules ./node_modules COPY --from=builder /deploy/package.json ./package.json diff --git a/packages/core/README.md b/packages/core/README.md index 4eb2d4f..37d8890 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -297,13 +297,14 @@ Set `LLM_PROVIDER` to choose the extraction backend: | `anthropic` | Anthropic Messages API | | `google-genai` | Google Gemini OpenAI-compatible endpoint | | `claude-code` | Local Claude Code Agent SDK session for personal development | - -For personal local use, `LLM_PROVIDER=claude-code` uses the logged-in -`claude` CLI session instead of requiring `ANTHROPIC_API_KEY`. It still consumes -the user's Claude Code / Claude subscription limits and is not intended for -hosted or team deployments. Pair it with a non-OpenAI embedding provider, such -as `EMBEDDING_PROVIDER=transformers`, if you want to run without an OpenAI API -key as well. +| `codex` | Local Codex CLI account session for personal development | + +For personal local use, `LLM_PROVIDER=claude-code` and `LLM_PROVIDER=codex` +use the logged-in `claude` or `codex` CLI session instead of requiring a +separate LLM API key. They still consume the user's account limits and are not +intended for hosted or team deployments. Pair either one with a non-OpenAI +embedding provider, such as `EMBEDDING_PROVIDER=transformers`, if you want to +run without an OpenAI API key as well. In-process benchmark harnesses can avoid editing env files by passing a composition-time config to the runtime: diff --git a/packages/core/src/__tests__/config-env.test.ts b/packages/core/src/__tests__/config-env.test.ts index be97252..4528048 100644 --- a/packages/core/src/__tests__/config-env.test.ts +++ b/packages/core/src/__tests__/config-env.test.ts @@ -89,6 +89,19 @@ describe('config env loading', () => { expect(config.llmModel).toBe('sonnet'); }); + it('accepts codex LLM provider without an OpenAI API key', async () => { + process.env.LLM_PROVIDER = 'codex'; + process.env.EMBEDDING_PROVIDER = 'transformers'; + delete process.env.LLM_MODEL; + delete process.env.OPENAI_API_KEY; + vi.resetModules(); + + const { config } = await import('../config.js'); + + expect(config.llmProvider).toBe('codex'); + expect(config.llmModel).toBe(''); + }); + it('loads optional admin cleanup endpoint config', async () => { process.env.CORE_ADMIN_API_KEY = 'test-admin-key'; process.env.CORE_TEST_SCOPE_ALLOW_PATTERN = '^(smoke-|docker-).+'; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b9cd399..44430e6 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,7 +18,7 @@ import { } from './storage/providers/filecoin/config.js'; export type EmbeddingProviderName = 'openai' | 'ollama' | 'openai-compatible' | 'transformers' | 'voyage'; -export type LLMProviderName = EmbeddingProviderName | 'groq' | 'anthropic' | 'google-genai' | 'claude-code'; +export type LLMProviderName = EmbeddingProviderName | 'groq' | 'anthropic' | 'google-genai' | 'claude-code' | 'codex'; export type VectorBackendName = 'pgvector' | 'ruvector-mock' | 'zvec-mock'; export type CrossEncoderDtype = 'auto' | 'fp32' | 'fp16' | 'q8' | 'int8' | 'uint8' | 'q4' | 'bnb4' | 'q4f16'; @@ -687,6 +687,7 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName): 'anthropic', 'google-genai', 'claude-code', + 'codex', ]; if (!valid.includes(value as LLMProviderName)) { throw new Error(`Invalid provider "${value}". Must be one of: ${valid.join(', ')}`); @@ -695,7 +696,7 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName): } function defaultLlmModel(provider: LLMProviderName): string { - if (provider === 'claude-code') return ''; + if (provider === 'claude-code' || provider === 'codex') return ''; return 'gpt-4o-mini'; } diff --git a/packages/core/src/services/__tests__/codex-cli-llm.test.ts b/packages/core/src/services/__tests__/codex-cli-llm.test.ts new file mode 100644 index 0000000..d54f9fd --- /dev/null +++ b/packages/core/src/services/__tests__/codex-cli-llm.test.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for the Codex CLI-backed LLM provider. + */ + +import { execFile } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})); + +const { CodexCliLLM } = await import('../codex-cli-llm.js'); +const execFileMock = vi.mocked(execFile); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function provider(): InstanceType { + return new CodexCliLLM({ + llmProvider: 'codex', + llmModel: 'gpt-5.5', + costLoggingEnabled: false, + costRunId: 'test', + costLogDir: '/tmp/test-cost', + }); +} + +function defaultModelProvider(): InstanceType { + return new CodexCliLLM({ + llmProvider: 'codex', + llmModel: '', + costLoggingEnabled: false, + costRunId: 'test', + costLogDir: '/tmp/test-cost', + }); +} + +function mockLoggedIn(): void { + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; + callback(null, 'Logged in using ChatGPT', ''); + return null as never; + }); +} + +function mockCodexExec(output: string): void { + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; + const outputPath = outputPathFromArgs(args[1] as string[]); + writeFileSync(outputPath, output, 'utf8'); + callback(null, '', ''); + return null as never; + }); +} + +function mockCodexExecFailure(error: Error): void { + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; + callback(error, '', ''); + return null as never; + }); +} + +function outputPathFromArgs(args: string[]): string { + const flagIndex = args.indexOf('--output-last-message'); + if (flagIndex < 0) throw new Error('missing --output-last-message'); + return args[flagIndex + 1] ?? ''; +} + +describe('CodexCliLLM', () => { + it('runs an isolated Codex exec turn through account auth', async () => { + mockLoggedIn(); + mockCodexExec('{"memories": []}'); + + const text = await provider().chat([ + { role: 'system', content: 'Extract memory JSON.' }, + { role: 'user', content: 'User prefers concise answers.' }, + ], { jsonMode: true }); + + const execCall = execFileMock.mock.calls[1]; + expect(text).toBe('{"memories": []}'); + expect(execCall?.[0]).toBe('codex'); + expect(execCall?.[1]).toEqual(expect.arrayContaining([ + 'exec', + '--ephemeral', + '--ignore-rules', + '--ignore-user-config', + '--skip-git-repo-check', + '--sandbox', + 'read-only', + '--model', + 'gpt-5.5', + ])); + expect((execCall?.[1] as string[]).at(-1)).toContain('Return only valid JSON'); + expect(execCall?.[2]).toHaveProperty('env'); + expect((execCall?.[2] as { env: NodeJS.ProcessEnv }).env.OPENAI_API_KEY).toBeUndefined(); + expect((execCall?.[2] as { env: NodeJS.ProcessEnv }).env.ANTHROPIC_API_KEY).toBeUndefined(); + }); + + it('omits model when core is configured to use Codex default', async () => { + mockLoggedIn(); + mockCodexExec('ok'); + + await defaultModelProvider().chat([{ role: 'user', content: 'hello' }]); + + const execArgs = execFileMock.mock.calls[1]?.[1] as string[]; + expect(execArgs).not.toContain('--model'); + }); + + it('surfaces missing Codex auth with setup guidance', async () => { + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; + callback(new Error('not logged in'), '', ''); + return null as never; + }); + + await expect(provider().chat([{ role: 'user', content: 'hello' }])) + .rejects.toThrow('Confirm `codex` is installed and authenticated'); + }); + + it('rejects empty final output', async () => { + mockLoggedIn(); + mockCodexExec(' '); + + await expect(provider().chat([{ role: 'user', content: 'hello' }])) + .rejects.toThrow('empty final response'); + }); + + it('surfaces Codex exec failures with setup guidance', async () => { + mockLoggedIn(); + mockCodexExecFailure(new Error('codex crashed')); + + await expect(provider().chat([{ role: 'user', content: 'hello' }])) + .rejects.toThrow('Codex CLI LLM failed to run'); + }); +}); diff --git a/packages/core/src/services/__tests__/llm-providers.test.ts b/packages/core/src/services/__tests__/llm-providers.test.ts index e44651d..d67b7df 100644 --- a/packages/core/src/services/__tests__/llm-providers.test.ts +++ b/packages/core/src/services/__tests__/llm-providers.test.ts @@ -70,6 +70,13 @@ describe('createLLMProvider', () => { expect(typeof provider.chat).toBe('function'); }); + it('creates Codex CLI provider', () => { + initLlm({ ...baseConfig, llmProvider: 'codex', llmModel: '' }); + const provider = createLLMProvider(); + expect(provider).toBeDefined(); + expect(typeof provider.chat).toBe('function'); + }); + it('throws for unknown provider', () => { initLlm({ ...baseConfig, llmProvider: 'unknown-provider' as never }); expect(() => createLLMProvider()).toThrow('Unknown LLM provider'); diff --git a/packages/core/src/services/codex-cli-llm.ts b/packages/core/src/services/codex-cli-llm.ts new file mode 100644 index 0000000..48bb120 --- /dev/null +++ b/packages/core/src/services/codex-cli-llm.ts @@ -0,0 +1,180 @@ +/** + * Codex CLI-backed LLM provider for local personal development. + * + * This provider delegates extraction turns to a locally authenticated Codex CLI + * account session. It is intentionally isolated from project rules, MCP, and + * writable workspace state so core extraction behaves like a one-turn chat + * completion rather than a coding-agent task. + */ + +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ChatMessage, ChatOptions, LLMProvider } from './llm.js'; +import { + estimateCostUsd, + getCostStage, + writeCostEvent, + type WriteCostEventConfig, +} from './cost-telemetry.js'; + +const CODEX_EXEC_TIMEOUT_MS = 300_000; +const CODEX_STATUS_TIMEOUT_MS = 10_000; +const CODEX_MAX_BUFFER_BYTES = 64 * 1024; +const CODEX_CHILD_ENV_KEYS = [ + 'PATH', + 'HOME', + 'CODEX_HOME', + 'XDG_CONFIG_HOME', + 'XDG_CACHE_HOME', + 'XDG_DATA_HOME', + 'TMPDIR', + 'LANG', + 'LC_ALL', +] as const; + +interface ExecResult { + stdout: string; + stderr: string; +} + +export interface CodexCliLLMConfig extends WriteCostEventConfig { + llmProvider: 'codex'; + llmModel: string; +} + +export class CodexCliLLM implements LLMProvider { + constructor(private readonly config: CodexCliLLMConfig) {} + + async chat(messages: ChatMessage[], options: ChatOptions = {}): Promise { + const started = performance.now(); + await assertCodexLoggedIn(); + const result = await runCodexExec(buildPrompt(messages, options), this.config.llmModel); + recordCodexCost(this.config, started); + return result; + } +} + +function buildPrompt(messages: ChatMessage[], options: ChatOptions): string { + const body = messages.map(formatMessage).join('\n\n').trim(); + if (!options.jsonMode) return body; + return [ + 'Return only valid JSON. Do not include markdown fences or commentary.', + body, + ].join('\n\n'); +} + +function formatMessage(message: ChatMessage): string { + return `${message.role.toUpperCase()}:\n${message.content}`; +} + +async function assertCodexLoggedIn(): Promise { + try { + const { stdout, stderr } = await runCommand('codex', ['login', 'status'], { + timeout: CODEX_STATUS_TIMEOUT_MS, + maxBuffer: CODEX_MAX_BUFFER_BYTES, + env: codexChildEnv(), + }); + const statusText = `${stdout}\n${stderr}`.trim(); + if (!/\blogged in\b/i.test(statusText) || /\bnot logged in\b/i.test(statusText)) { + throw new Error(statusText || 'Codex login status did not report logged in'); + } + } catch (error) { + throw new Error( + 'Codex CLI LLM failed before execution. Confirm `codex` is installed and authenticated: ' + + errorMessage(error), + ); + } +} + +async function runCodexExec(prompt: string, model: string): Promise { + const workDir = await mkdtemp(join(tmpdir(), 'atomicmemory-codex-')); + const outputPath = join(workDir, 'last-message.txt'); + try { + await runCommand('codex', codexExecArgs(outputPath, model, prompt), { + cwd: workDir, + timeout: CODEX_EXEC_TIMEOUT_MS, + maxBuffer: CODEX_MAX_BUFFER_BYTES, + env: codexChildEnv(), + }); + return await readCodexOutput(outputPath); + } catch (error) { + throw new Error( + 'Codex CLI LLM failed to run. Confirm `codex` is installed and authenticated: ' + + errorMessage(error), + ); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} + +function runCommand( + file: string, + args: string[], + options: { cwd?: string; timeout: number; maxBuffer: number; env: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolve, reject) => { + const child = execFile(file, args, options, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + (child as { stdin?: { end?: () => void } } | null)?.stdin?.end?.(); + }); +} + +function codexExecArgs(outputPath: string, model: string, prompt: string): string[] { + return [ + 'exec', + '--ephemeral', + '--ignore-rules', + '--ignore-user-config', + '--skip-git-repo-check', + '--sandbox', + 'read-only', + '--color', + 'never', + '--output-last-message', + outputPath, + ...(model ? ['--model', model] : []), + prompt, + ]; +} + +function codexChildEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of CODEX_CHILD_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + return env; +} + +async function readCodexOutput(outputPath: string): Promise { + const output = (await readFile(outputPath, 'utf8')).trim(); + if (!output) throw new Error('Codex CLI wrote an empty final response'); + return output; +} + +function recordCodexCost(config: CodexCliLLMConfig, started: number): void { + const model = config.llmModel || 'codex-default'; + writeCostEvent({ + stage: getCostStage(), + provider: config.llmProvider, + model, + requestKind: 'chat', + durationMs: performance.now() - started, + cacheHit: false, + inputTokens: null, + outputTokens: null, + totalTokens: null, + estimatedCostUsd: estimateCostUsd(config.llmProvider, model), + }, config); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/services/llm.ts b/packages/core/src/services/llm.ts index 71b3283..86d8cd3 100644 --- a/packages/core/src/services/llm.ts +++ b/packages/core/src/services/llm.ts @@ -8,6 +8,7 @@ import Anthropic from '@anthropic-ai/sdk'; import OpenAI from 'openai'; import { Agent as UndiciAgent } from 'undici'; import { retryOnRateLimit } from './api-retry.js'; +import { CodexCliLLM } from './codex-cli-llm.js'; import { estimateCostUsd, getCostStage, @@ -395,6 +396,14 @@ export function createLLMProvider(): LLMProvider { costRunId: config.costRunId, costLogDir: config.costLogDir, }); + case 'codex': + return new CodexCliLLM({ + llmProvider: config.llmProvider, + llmModel: config.llmModel, + costLoggingEnabled: config.costLoggingEnabled, + costRunId: config.costRunId, + costLogDir: config.costLogDir, + }); case 'openai-compatible': return new OpenAICompatibleLLM( config.llmApiKey ?? config.openaiApiKey, diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 7f3a091..eeaf981 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -71,6 +71,24 @@ export ATOMICMEMORY_SCOPE_USER="pip" At least one `ATOMICMEMORY_SCOPE_*` must be set — the server rejects scopeless requests. The MCP server itself is fetched from npm on first use via `npx -y --package=@atomicmemory/mcp-server@^0.1.2 atomicmemory-mcp`, so no local clone or build is required. +### Optional local core extraction through Codex + +If you run AtomicMemory core on the same machine and want extraction to use the +same logged-in Codex account instead of a separate OpenAI API key, configure the +core process with: + +```bash +codex login +export LLM_PROVIDER=codex +export EMBEDDING_PROVIDER=transformers +``` + +This configures AtomicMemory core, not the MCP plugin. The plugin still uses +`ATOMICMEMORY_API_URL`, `ATOMICMEMORY_API_KEY`, and scope variables to connect +to core. `LLM_PROVIDER=codex` is intended for personal local development; it +consumes the logged-in Codex account's limits and is not recommended for hosted +or team deployments. + ## Memory behavior By default, capture is tool-driven by the installed skill: From 2113a94b83f93bc85eefbd5edb2c29648d877a57 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 20 May 2026 11:41:42 -0700 Subject: [PATCH 2/8] Use direct Codex OAuth provider --- packages/core/.env.example | 2 + packages/core/Dockerfile | 9 +- packages/core/README.md | 8 +- .../core/src/__tests__/config-env.test.ts | 5 +- packages/core/src/config.ts | 15 +- .../services/__tests__/codex-cli-llm.test.ts | 138 ----------- .../src/services/__tests__/codex-llm.test.ts | 111 +++++++++ .../services/__tests__/llm-providers.test.ts | 3 +- packages/core/src/services/codex-cli-llm.ts | 180 --------------- packages/core/src/services/codex-llm.ts | 218 ++++++++++++++++++ packages/core/src/services/llm.ts | 7 +- plugins/codex/README.md | 8 +- 12 files changed, 367 insertions(+), 337 deletions(-) delete mode 100644 packages/core/src/services/__tests__/codex-cli-llm.test.ts create mode 100644 packages/core/src/services/__tests__/codex-llm.test.ts delete mode 100644 packages/core/src/services/codex-cli-llm.ts create mode 100644 packages/core/src/services/codex-llm.ts diff --git a/packages/core/.env.example b/packages/core/.env.example index 4a369ad..cb7efc7 100644 --- a/packages/core/.env.example +++ b/packages/core/.env.example @@ -110,6 +110,8 @@ EMBEDDING_DIMENSIONS=1536 # Personal local Codex extraction, no separate OpenAI API key: # LLM_PROVIDER=codex +# Optional: defaults to CODEX_HOME/auth.json or ~/.codex/auth.json. +# CODEX_AUTH_PATH=/path/to/codex/auth.json # For fully local/no-provider-key development, pair this with a non-OpenAI # embedding provider such as EMBEDDING_PROVIDER=transformers. diff --git a/packages/core/Dockerfile b/packages/core/Dockerfile index a95aebc..46ac3b1 100644 --- a/packages/core/Dockerfile +++ b/packages/core/Dockerfile @@ -45,8 +45,6 @@ RUN pnpm deploy --filter @atomicmemory/core --prod /deploy # --------------------------------------------------------------------------- FROM pgvector/pgvector:pg17 -ARG CODEX_CLI_VERSION=0.131.0 - WORKDIR /app COPY --from=node-base /usr/local/bin/node /usr/local/bin/node @@ -55,14 +53,9 @@ RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bubblewrap \ + ca-certificates \ && rm -rf /var/lib/apt/lists/* -# Optional local-account LLM provider support. `LLM_PROVIDER=codex` delegates -# extraction turns to this CLI when a Codex auth home is mounted at runtime. -RUN npm install -g --no-audit --no-fund "@openai/codex@${CODEX_CLI_VERSION}" \ - && codex --version | grep -q "${CODEX_CLI_VERSION}" - # Production node_modules + package.json from pnpm deploy. COPY --from=builder /deploy/node_modules ./node_modules COPY --from=builder /deploy/package.json ./package.json diff --git a/packages/core/README.md b/packages/core/README.md index 37d8890..23845ae 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -297,11 +297,13 @@ Set `LLM_PROVIDER` to choose the extraction backend: | `anthropic` | Anthropic Messages API | | `google-genai` | Google Gemini OpenAI-compatible endpoint | | `claude-code` | Local Claude Code Agent SDK session for personal development | -| `codex` | Local Codex CLI account session for personal development | +| `codex` | Local Codex account session for personal development | For personal local use, `LLM_PROVIDER=claude-code` and `LLM_PROVIDER=codex` -use the logged-in `claude` or `codex` CLI session instead of requiring a -separate LLM API key. They still consume the user's account limits and are not +use the logged-in `claude` or `codex` account session instead of requiring a +separate LLM API key. `claude-code` routes through the Claude Agent SDK; +`codex` reads the auth file produced by `codex login` and calls the Codex +backend directly. They still consume the user's account limits and are not intended for hosted or team deployments. Pair either one with a non-OpenAI embedding provider, such as `EMBEDDING_PROVIDER=transformers`, if you want to run without an OpenAI API key as well. diff --git a/packages/core/src/__tests__/config-env.test.ts b/packages/core/src/__tests__/config-env.test.ts index 4528048..6844a7b 100644 --- a/packages/core/src/__tests__/config-env.test.ts +++ b/packages/core/src/__tests__/config-env.test.ts @@ -18,6 +18,8 @@ const trackedEnvNames = [ 'RAW_STORAGE_DEPLOYMENT_ENV', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', + 'CODEX_AUTH_PATH', + 'CODEX_HOME', ] as const; const originalEnv = Object.fromEntries( trackedEnvNames.map((name) => [name, process.env[name]]), @@ -99,7 +101,8 @@ describe('config env loading', () => { const { config } = await import('../config.js'); expect(config.llmProvider).toBe('codex'); - expect(config.llmModel).toBe(''); + expect(config.llmModel).toBe('gpt-5.4-mini'); + expect(config.codexAuthPath).toContain('.codex/auth.json'); }); it('loads optional admin cleanup endpoint config', async () => { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 44430e6..740c540 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -10,6 +10,8 @@ import { type RetrievalProfile, type RetrievalProfileName, } from './services/retrieval-profiles.js'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { parsePointerUriSchemes } from './storage/pointer-uri-allowlist.js'; import { collectFilecoinProviderEnvKeys, @@ -123,6 +125,7 @@ export interface RuntimeConfig { llmModel: string; llmApiUrl?: string; llmApiKey?: string; + codexAuthPath: string; groqApiKey?: string; ollamaBaseUrl: string; vectorBackend: VectorBackendName; @@ -696,10 +699,19 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName): } function defaultLlmModel(provider: LLMProviderName): string { - if (provider === 'claude-code' || provider === 'codex') return ''; + if (provider === 'claude-code') return ''; + if (provider === 'codex') return 'gpt-5.4-mini'; return 'gpt-4o-mini'; } +function defaultCodexAuthPath(): string { + const explicitPath = optionalEnv('CODEX_AUTH_PATH'); + if (explicitPath) return explicitPath; + const codexHome = optionalEnv('CODEX_HOME'); + if (codexHome) return join(codexHome, 'auth.json'); + return join(homedir(), '.codex', 'auth.json'); +} + function requireFiniteNumber(value: number, field: string): number { if (!Number.isFinite(value)) { @@ -1164,6 +1176,7 @@ export const config: RuntimeConfig = { llmModel: optionalEnv('LLM_MODEL') ?? defaultLlmModel(llmProvider), llmApiUrl: optionalEnv('LLM_API_URL'), llmApiKey: optionalEnv('LLM_API_KEY'), + codexAuthPath: defaultCodexAuthPath(), // Groq groqApiKey: groqApiKey ?? undefined, diff --git a/packages/core/src/services/__tests__/codex-cli-llm.test.ts b/packages/core/src/services/__tests__/codex-cli-llm.test.ts deleted file mode 100644 index d54f9fd..0000000 --- a/packages/core/src/services/__tests__/codex-cli-llm.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Unit tests for the Codex CLI-backed LLM provider. - */ - -import { execFile } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('node:child_process', () => ({ - execFile: vi.fn(), -})); - -const { CodexCliLLM } = await import('../codex-cli-llm.js'); -const execFileMock = vi.mocked(execFile); - -afterEach(() => { - vi.clearAllMocks(); -}); - -function provider(): InstanceType { - return new CodexCliLLM({ - llmProvider: 'codex', - llmModel: 'gpt-5.5', - costLoggingEnabled: false, - costRunId: 'test', - costLogDir: '/tmp/test-cost', - }); -} - -function defaultModelProvider(): InstanceType { - return new CodexCliLLM({ - llmProvider: 'codex', - llmModel: '', - costLoggingEnabled: false, - costRunId: 'test', - costLogDir: '/tmp/test-cost', - }); -} - -function mockLoggedIn(): void { - execFileMock.mockImplementationOnce((...args: unknown[]) => { - const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; - callback(null, 'Logged in using ChatGPT', ''); - return null as never; - }); -} - -function mockCodexExec(output: string): void { - execFileMock.mockImplementationOnce((...args: unknown[]) => { - const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; - const outputPath = outputPathFromArgs(args[1] as string[]); - writeFileSync(outputPath, output, 'utf8'); - callback(null, '', ''); - return null as never; - }); -} - -function mockCodexExecFailure(error: Error): void { - execFileMock.mockImplementationOnce((...args: unknown[]) => { - const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; - callback(error, '', ''); - return null as never; - }); -} - -function outputPathFromArgs(args: string[]): string { - const flagIndex = args.indexOf('--output-last-message'); - if (flagIndex < 0) throw new Error('missing --output-last-message'); - return args[flagIndex + 1] ?? ''; -} - -describe('CodexCliLLM', () => { - it('runs an isolated Codex exec turn through account auth', async () => { - mockLoggedIn(); - mockCodexExec('{"memories": []}'); - - const text = await provider().chat([ - { role: 'system', content: 'Extract memory JSON.' }, - { role: 'user', content: 'User prefers concise answers.' }, - ], { jsonMode: true }); - - const execCall = execFileMock.mock.calls[1]; - expect(text).toBe('{"memories": []}'); - expect(execCall?.[0]).toBe('codex'); - expect(execCall?.[1]).toEqual(expect.arrayContaining([ - 'exec', - '--ephemeral', - '--ignore-rules', - '--ignore-user-config', - '--skip-git-repo-check', - '--sandbox', - 'read-only', - '--model', - 'gpt-5.5', - ])); - expect((execCall?.[1] as string[]).at(-1)).toContain('Return only valid JSON'); - expect(execCall?.[2]).toHaveProperty('env'); - expect((execCall?.[2] as { env: NodeJS.ProcessEnv }).env.OPENAI_API_KEY).toBeUndefined(); - expect((execCall?.[2] as { env: NodeJS.ProcessEnv }).env.ANTHROPIC_API_KEY).toBeUndefined(); - }); - - it('omits model when core is configured to use Codex default', async () => { - mockLoggedIn(); - mockCodexExec('ok'); - - await defaultModelProvider().chat([{ role: 'user', content: 'hello' }]); - - const execArgs = execFileMock.mock.calls[1]?.[1] as string[]; - expect(execArgs).not.toContain('--model'); - }); - - it('surfaces missing Codex auth with setup guidance', async () => { - execFileMock.mockImplementationOnce((...args: unknown[]) => { - const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void; - callback(new Error('not logged in'), '', ''); - return null as never; - }); - - await expect(provider().chat([{ role: 'user', content: 'hello' }])) - .rejects.toThrow('Confirm `codex` is installed and authenticated'); - }); - - it('rejects empty final output', async () => { - mockLoggedIn(); - mockCodexExec(' '); - - await expect(provider().chat([{ role: 'user', content: 'hello' }])) - .rejects.toThrow('empty final response'); - }); - - it('surfaces Codex exec failures with setup guidance', async () => { - mockLoggedIn(); - mockCodexExecFailure(new Error('codex crashed')); - - await expect(provider().chat([{ role: 'user', content: 'hello' }])) - .rejects.toThrow('Codex CLI LLM failed to run'); - }); -}); diff --git a/packages/core/src/services/__tests__/codex-llm.test.ts b/packages/core/src/services/__tests__/codex-llm.test.ts new file mode 100644 index 0000000..8b72d33 --- /dev/null +++ b/packages/core/src/services/__tests__/codex-llm.test.ts @@ -0,0 +1,111 @@ +/** + * Unit tests for the Codex OAuth-backed LLM provider. + */ + +import { readFile } from 'node:fs/promises'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +const { CodexLLM } = await import('../codex-llm.js'); +const readFileMock = vi.mocked(readFile); +const fetchMock = vi.fn(); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +function provider(): InstanceType { + return new CodexLLM({ + llmProvider: 'codex', + llmModel: 'gpt-5.4-mini', + llmApiUrl: undefined, + codexAuthPath: '/tmp/codex-auth.json', + costLoggingEnabled: false, + costRunId: 'test', + costLogDir: '/tmp/test-cost', + }); +} + +function mockAuth(): void { + readFileMock.mockResolvedValue(JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { + access_token: 'codex-token', + account_id: 'acct-123', + }, + })); +} + +function mockSseResponse(body: string): void { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => body, + } as Response); +} + +function sse(...parts: string[]): string { + return parts.map((part) => `event: response.text.delta\ndata: ${JSON.stringify({ delta: part })}\n`).join(''); +} + +describe('CodexLLM', () => { + it('calls the Codex backend directly with OAuth credentials from the Codex auth file', async () => { + mockAuth(); + mockSseResponse(sse('{', '"memories": []', '}')); + + const text = await provider().chat([ + { role: 'system', content: 'Extract memory JSON.' }, + { role: 'user', content: 'User prefers concise answers.' }, + ], { jsonMode: true, maxTokens: 256 }); + + const fetchCall = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(String(fetchCall?.[1]?.body)) as Record; + expect(text).toBe('{"memories": []}'); + expect(fetchCall?.[0]).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(fetchCall?.[1]?.headers).toMatchObject({ + Authorization: 'Bearer codex-token', + 'OpenAI-Account-ID': 'acct-123', + Origin: 'https://chatgpt.com', + }); + expect(requestBody.model).toBe('gpt-5.4-mini'); + expect(requestBody.instructions).toContain('Return only valid JSON'); + expect(requestBody.input).toEqual([ + { type: 'message', role: 'user', content: 'User prefers concise answers.' }, + ]); + expect(requestBody.store).toBe(false); + expect(requestBody.stream).toBe(true); + expect(requestBody.max_output_tokens).toBeUndefined(); + }); + + it('rejects missing Codex auth with setup guidance', async () => { + readFileMock.mockRejectedValue(new Error('ENOENT')); + + await expect(provider().chat([{ role: 'user', content: 'hello' }])) + .rejects.toThrow('Run `codex login`'); + }); + + it('rejects non-ChatGPT Codex auth files', async () => { + readFileMock.mockResolvedValue(JSON.stringify({ auth_mode: 'apikey' })); + + await expect(provider().chat([{ role: 'user', content: 'hello' }])) + .rejects.toThrow('not a ChatGPT login'); + }); + + it('surfaces Codex HTTP auth failures with re-login guidance', async () => { + mockAuth(); + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'unauthorized', + } as Response); + + await expect(provider().chat([{ role: 'user', content: 'hello' }])) + .rejects.toThrow('Run `codex login` again'); + }); +}); diff --git a/packages/core/src/services/__tests__/llm-providers.test.ts b/packages/core/src/services/__tests__/llm-providers.test.ts index d67b7df..2542752 100644 --- a/packages/core/src/services/__tests__/llm-providers.test.ts +++ b/packages/core/src/services/__tests__/llm-providers.test.ts @@ -17,6 +17,7 @@ const baseConfig: LLMConfig = { groqApiKey: 'test-groq-key', llmApiUrl: undefined, llmApiKey: undefined, + codexAuthPath: '/tmp/codex-auth.json', ollamaBaseUrl: 'http://localhost:11434', llmSeed: undefined, costLoggingEnabled: false, @@ -70,7 +71,7 @@ describe('createLLMProvider', () => { expect(typeof provider.chat).toBe('function'); }); - it('creates Codex CLI provider', () => { + it('creates Codex OAuth provider', () => { initLlm({ ...baseConfig, llmProvider: 'codex', llmModel: '' }); const provider = createLLMProvider(); expect(provider).toBeDefined(); diff --git a/packages/core/src/services/codex-cli-llm.ts b/packages/core/src/services/codex-cli-llm.ts deleted file mode 100644 index 48bb120..0000000 --- a/packages/core/src/services/codex-cli-llm.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Codex CLI-backed LLM provider for local personal development. - * - * This provider delegates extraction turns to a locally authenticated Codex CLI - * account session. It is intentionally isolated from project rules, MCP, and - * writable workspace state so core extraction behaves like a one-turn chat - * completion rather than a coding-agent task. - */ - -import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { ChatMessage, ChatOptions, LLMProvider } from './llm.js'; -import { - estimateCostUsd, - getCostStage, - writeCostEvent, - type WriteCostEventConfig, -} from './cost-telemetry.js'; - -const CODEX_EXEC_TIMEOUT_MS = 300_000; -const CODEX_STATUS_TIMEOUT_MS = 10_000; -const CODEX_MAX_BUFFER_BYTES = 64 * 1024; -const CODEX_CHILD_ENV_KEYS = [ - 'PATH', - 'HOME', - 'CODEX_HOME', - 'XDG_CONFIG_HOME', - 'XDG_CACHE_HOME', - 'XDG_DATA_HOME', - 'TMPDIR', - 'LANG', - 'LC_ALL', -] as const; - -interface ExecResult { - stdout: string; - stderr: string; -} - -export interface CodexCliLLMConfig extends WriteCostEventConfig { - llmProvider: 'codex'; - llmModel: string; -} - -export class CodexCliLLM implements LLMProvider { - constructor(private readonly config: CodexCliLLMConfig) {} - - async chat(messages: ChatMessage[], options: ChatOptions = {}): Promise { - const started = performance.now(); - await assertCodexLoggedIn(); - const result = await runCodexExec(buildPrompt(messages, options), this.config.llmModel); - recordCodexCost(this.config, started); - return result; - } -} - -function buildPrompt(messages: ChatMessage[], options: ChatOptions): string { - const body = messages.map(formatMessage).join('\n\n').trim(); - if (!options.jsonMode) return body; - return [ - 'Return only valid JSON. Do not include markdown fences or commentary.', - body, - ].join('\n\n'); -} - -function formatMessage(message: ChatMessage): string { - return `${message.role.toUpperCase()}:\n${message.content}`; -} - -async function assertCodexLoggedIn(): Promise { - try { - const { stdout, stderr } = await runCommand('codex', ['login', 'status'], { - timeout: CODEX_STATUS_TIMEOUT_MS, - maxBuffer: CODEX_MAX_BUFFER_BYTES, - env: codexChildEnv(), - }); - const statusText = `${stdout}\n${stderr}`.trim(); - if (!/\blogged in\b/i.test(statusText) || /\bnot logged in\b/i.test(statusText)) { - throw new Error(statusText || 'Codex login status did not report logged in'); - } - } catch (error) { - throw new Error( - 'Codex CLI LLM failed before execution. Confirm `codex` is installed and authenticated: ' + - errorMessage(error), - ); - } -} - -async function runCodexExec(prompt: string, model: string): Promise { - const workDir = await mkdtemp(join(tmpdir(), 'atomicmemory-codex-')); - const outputPath = join(workDir, 'last-message.txt'); - try { - await runCommand('codex', codexExecArgs(outputPath, model, prompt), { - cwd: workDir, - timeout: CODEX_EXEC_TIMEOUT_MS, - maxBuffer: CODEX_MAX_BUFFER_BYTES, - env: codexChildEnv(), - }); - return await readCodexOutput(outputPath); - } catch (error) { - throw new Error( - 'Codex CLI LLM failed to run. Confirm `codex` is installed and authenticated: ' + - errorMessage(error), - ); - } finally { - await rm(workDir, { recursive: true, force: true }); - } -} - -function runCommand( - file: string, - args: string[], - options: { cwd?: string; timeout: number; maxBuffer: number; env: NodeJS.ProcessEnv }, -): Promise { - return new Promise((resolve, reject) => { - const child = execFile(file, args, options, (error, stdout, stderr) => { - if (error) { - reject(error); - return; - } - resolve({ stdout, stderr }); - }); - (child as { stdin?: { end?: () => void } } | null)?.stdin?.end?.(); - }); -} - -function codexExecArgs(outputPath: string, model: string, prompt: string): string[] { - return [ - 'exec', - '--ephemeral', - '--ignore-rules', - '--ignore-user-config', - '--skip-git-repo-check', - '--sandbox', - 'read-only', - '--color', - 'never', - '--output-last-message', - outputPath, - ...(model ? ['--model', model] : []), - prompt, - ]; -} - -function codexChildEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {}; - for (const key of CODEX_CHILD_ENV_KEYS) { - const value = process.env[key]; - if (value !== undefined) env[key] = value; - } - return env; -} - -async function readCodexOutput(outputPath: string): Promise { - const output = (await readFile(outputPath, 'utf8')).trim(); - if (!output) throw new Error('Codex CLI wrote an empty final response'); - return output; -} - -function recordCodexCost(config: CodexCliLLMConfig, started: number): void { - const model = config.llmModel || 'codex-default'; - writeCostEvent({ - stage: getCostStage(), - provider: config.llmProvider, - model, - requestKind: 'chat', - durationMs: performance.now() - started, - cacheHit: false, - inputTokens: null, - outputTokens: null, - totalTokens: null, - estimatedCostUsd: estimateCostUsd(config.llmProvider, model), - }, config); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/core/src/services/codex-llm.ts b/packages/core/src/services/codex-llm.ts new file mode 100644 index 0000000..fac91a1 --- /dev/null +++ b/packages/core/src/services/codex-llm.ts @@ -0,0 +1,218 @@ +/** + * Codex OAuth-backed LLM provider for local personal development. + * + * This provider reads the authentication file created by `codex login` and + * calls the Codex backend directly. That keeps the local account-auth + * quickstart while avoiding a per-call shell-out to the Codex agent CLI. + */ + +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import type { ChatMessage, ChatOptions, LLMProvider } from './llm.js'; +import { + estimateCostUsd, + getCostStage, + summarizeUsage, + writeCostEvent, + type WriteCostEventConfig, +} from './cost-telemetry.js'; + +const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api'; +const CODEX_REQUEST_TIMEOUT_MS = 120_000; +const CODEX_USER_AGENT = 'AtomicMemory Core Codex Provider'; + +export interface CodexLLMConfig extends WriteCostEventConfig { + llmProvider: 'codex'; + llmModel: string; + llmApiUrl?: string; + codexAuthPath: string; +} + +interface CodexAuthFile { + auth_mode?: string; + tokens?: { + access_token?: string; + account_id?: string; + }; +} + +interface CodexPayloadMessage { + type: 'message'; + role: ChatMessage['role']; + content: string; +} + +export class CodexLLM implements LLMProvider { + private readonly model: string; + private readonly baseUrl: string; + + constructor(private readonly config: CodexLLMConfig) { + this.model = config.llmModel || 'gpt-5.4-mini'; + this.baseUrl = config.llmApiUrl || CODEX_DEFAULT_BASE_URL; + } + + async chat(messages: ChatMessage[], options: ChatOptions = {}): Promise { + const started = performance.now(); + const auth = await loadCodexAuth(this.config.codexAuthPath); + const response = await postCodexResponse(this.baseUrl, buildCodexPayload(this.model, messages, options), auth); + recordCodexCost(this.config, this.model, messages, response, started); + return response; + } +} + +async function loadCodexAuth(authPath: string): Promise<{ accessToken: string; accountId?: string }> { + let parsed: CodexAuthFile; + try { + parsed = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile; + } catch (error) { + throw new Error(`Codex auth file could not be read at ${authPath}. Run \`codex login\`: ${errorMessage(error)}`); + } + if (parsed.auth_mode !== 'chatgpt') { + throw new Error(`Codex auth file at ${authPath} is not a ChatGPT login. Run \`codex login\`.`); + } + const accessToken = parsed.tokens?.access_token; + if (!accessToken) { + throw new Error(`Codex auth file at ${authPath} has no access token. Run \`codex login\` again.`); + } + return { accessToken, accountId: parsed.tokens?.account_id }; +} + +function buildCodexPayload(model: string, messages: ChatMessage[], options: ChatOptions): Record { + const { instructions, input } = splitCodexMessages(messages, options.jsonMode === true); + return { + model, + instructions, + input, + tools: [], + tool_choice: 'auto', + parallel_tool_calls: true, + reasoning: { summary: 'concise' }, + store: false, + stream: true, + include: ['reasoning.encrypted_content'], + prompt_cache_key: randomUUID(), + }; +} + +function splitCodexMessages( + messages: ChatMessage[], + jsonMode: boolean, +): { instructions: string; input: CodexPayloadMessage[] } { + const systemParts = jsonMode ? ['Return only valid JSON. Do not include markdown fences or commentary.'] : []; + const input: CodexPayloadMessage[] = []; + for (const message of messages) { + if (message.role === 'system') { + systemParts.push(message.content); + continue; + } + input.push({ type: 'message', role: message.role, content: message.content }); + } + return { instructions: systemParts.join('\n\n'), input }; +} + +async function postCodexResponse( + baseUrl: string, + payload: Record, + auth: { accessToken: string; accountId?: string }, +): Promise { + const response = await fetch(`${baseUrl}/codex/responses`, { + method: 'POST', + headers: codexHeaders(auth), + body: JSON.stringify(payload), + signal: AbortSignal.timeout(CODEX_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) throw await codexHttpError(response); + const content = parseCodexSse(await response.text()); + if (!content.trim()) throw new Error('Codex returned an empty response'); + return content; +} + +function codexHeaders(auth: { accessToken: string; accountId?: string }): Record { + return { + Authorization: `Bearer ${auth.accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': CODEX_USER_AGENT, + Origin: 'https://chatgpt.com', + ...(auth.accountId ? { 'OpenAI-Account-ID': auth.accountId } : {}), + }; +} + +async function codexHttpError(response: Response): Promise { + const body = (await response.text()).slice(0, 300); + if (response.status === 401 || response.status === 403) { + return new Error(`Codex authentication failed with HTTP ${response.status}. Run \`codex login\` again.`); + } + return new Error(`Codex backend failed with HTTP ${response.status}: ${body}`); +} + +function parseCodexSse(body: string): string { + let eventType = ''; + let fullText = ''; + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + if (line.startsWith('event: ')) eventType = line.slice('event: '.length); + if (line.startsWith('data: ')) fullText += parseCodexDataLine(eventType, line.slice('data: '.length)); + } + return fullText; +} + +function parseCodexDataLine(eventType: string, dataText: string): string { + if (dataText === '[DONE]') return ''; + try { + const data = JSON.parse(dataText) as Record; + if (eventType === 'response.text.delta' || eventType === 'response.content_part.delta') { + return typeof data.delta === 'string' ? data.delta : ''; + } + return textFromCodexItem(data.item); + } catch { + return ''; + } +} + +function textFromCodexItem(item: unknown): string { + if (!isRecord(item)) return ''; + const content = item.content; + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content.map(textFromContentPart).join(''); +} + +function textFromContentPart(part: unknown): string { + return isRecord(part) && typeof part.text === 'string' ? part.text : ''; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function recordCodexCost( + config: CodexLLMConfig, + model: string, + messages: ChatMessage[], + response: string, + started: number, +): void { + const inputTokens = estimateTokens(messages.map((m) => m.content).join('\n')); + const outputTokens = estimateTokens(response); + const usage = summarizeUsage(inputTokens, outputTokens); + writeCostEvent({ + stage: getCostStage(), + provider: config.llmProvider, + model, + requestKind: 'chat', + durationMs: performance.now() - started, + cacheHit: false, + inputTokens: usage.inputTokens ?? null, + outputTokens: usage.outputTokens ?? null, + totalTokens: usage.totalTokens ?? null, + estimatedCostUsd: estimateCostUsd(config.llmProvider, model, usage), + }, config); +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/services/llm.ts b/packages/core/src/services/llm.ts index 86d8cd3..a2f117c 100644 --- a/packages/core/src/services/llm.ts +++ b/packages/core/src/services/llm.ts @@ -8,7 +8,7 @@ import Anthropic from '@anthropic-ai/sdk'; import OpenAI from 'openai'; import { Agent as UndiciAgent } from 'undici'; import { retryOnRateLimit } from './api-retry.js'; -import { CodexCliLLM } from './codex-cli-llm.js'; +import { CodexLLM } from './codex-llm.js'; import { estimateCostUsd, getCostStage, @@ -31,6 +31,7 @@ export interface LLMConfig extends WriteCostEventConfig { llmModel: string; llmApiUrl?: string; llmApiKey?: string; + codexAuthPath: string; openaiApiKey: string; groqApiKey?: string; anthropicApiKey?: string; @@ -397,9 +398,11 @@ export function createLLMProvider(): LLMProvider { costLogDir: config.costLogDir, }); case 'codex': - return new CodexCliLLM({ + return new CodexLLM({ llmProvider: config.llmProvider, llmModel: config.llmModel, + llmApiUrl: config.llmApiUrl, + codexAuthPath: config.codexAuthPath, costLoggingEnabled: config.costLoggingEnabled, costRunId: config.costRunId, costLogDir: config.costLogDir, diff --git a/plugins/codex/README.md b/plugins/codex/README.md index eeaf981..98b57c2 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -85,9 +85,11 @@ export EMBEDDING_PROVIDER=transformers This configures AtomicMemory core, not the MCP plugin. The plugin still uses `ATOMICMEMORY_API_URL`, `ATOMICMEMORY_API_KEY`, and scope variables to connect -to core. `LLM_PROVIDER=codex` is intended for personal local development; it -consumes the logged-in Codex account's limits and is not recommended for hosted -or team deployments. +to core. `LLM_PROVIDER=codex` reads the auth file created by `codex login` +(`CODEX_AUTH_PATH`, `CODEX_HOME/auth.json`, or `~/.codex/auth.json`) and calls +the Codex backend directly. It is intended for personal local development, +consumes the logged-in Codex account's limits, and is not recommended for +hosted or team deployments. ## Memory behavior From 1c74ade08d79e39b53a7490f444301275ce7ba87 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 20 May 2026 11:46:25 -0700 Subject: [PATCH 3/8] Centralize Codex default model --- packages/core/src/__tests__/config-env.test.ts | 3 ++- packages/core/src/config.ts | 8 ++++++-- packages/core/src/services/__tests__/codex-llm.test.ts | 5 +++-- packages/core/src/services/codex-llm.ts | 3 ++- packages/core/src/services/llm-defaults.ts | 10 ++++++++++ 5 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/services/llm-defaults.ts diff --git a/packages/core/src/__tests__/config-env.test.ts b/packages/core/src/__tests__/config-env.test.ts index 6844a7b..a7bcc2d 100644 --- a/packages/core/src/__tests__/config-env.test.ts +++ b/packages/core/src/__tests__/config-env.test.ts @@ -3,6 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CODEX_LLM_MODEL } from '../services/llm-defaults.js'; const trackedEnvNames = [ 'SIMILARITY_THRESHOLD', @@ -101,7 +102,7 @@ describe('config env loading', () => { const { config } = await import('../config.js'); expect(config.llmProvider).toBe('codex'); - expect(config.llmModel).toBe('gpt-5.4-mini'); + expect(config.llmModel).toBe(DEFAULT_CODEX_LLM_MODEL); expect(config.codexAuthPath).toContain('.codex/auth.json'); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 740c540..062b48c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -10,6 +10,10 @@ import { type RetrievalProfile, type RetrievalProfileName, } from './services/retrieval-profiles.js'; +import { + DEFAULT_CODEX_LLM_MODEL, + DEFAULT_OPENAI_COMPATIBLE_LLM_MODEL, +} from './services/llm-defaults.js'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { parsePointerUriSchemes } from './storage/pointer-uri-allowlist.js'; @@ -700,8 +704,8 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName): function defaultLlmModel(provider: LLMProviderName): string { if (provider === 'claude-code') return ''; - if (provider === 'codex') return 'gpt-5.4-mini'; - return 'gpt-4o-mini'; + if (provider === 'codex') return DEFAULT_CODEX_LLM_MODEL; + return DEFAULT_OPENAI_COMPATIBLE_LLM_MODEL; } function defaultCodexAuthPath(): string { diff --git a/packages/core/src/services/__tests__/codex-llm.test.ts b/packages/core/src/services/__tests__/codex-llm.test.ts index 8b72d33..aedade2 100644 --- a/packages/core/src/services/__tests__/codex-llm.test.ts +++ b/packages/core/src/services/__tests__/codex-llm.test.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CODEX_LLM_MODEL } from '../llm-defaults.js'; vi.mock('node:fs/promises', () => ({ readFile: vi.fn(), @@ -21,7 +22,7 @@ afterEach(() => { function provider(): InstanceType { return new CodexLLM({ llmProvider: 'codex', - llmModel: 'gpt-5.4-mini', + llmModel: DEFAULT_CODEX_LLM_MODEL, llmApiUrl: undefined, codexAuthPath: '/tmp/codex-auth.json', costLoggingEnabled: false, @@ -72,7 +73,7 @@ describe('CodexLLM', () => { 'OpenAI-Account-ID': 'acct-123', Origin: 'https://chatgpt.com', }); - expect(requestBody.model).toBe('gpt-5.4-mini'); + expect(requestBody.model).toBe(DEFAULT_CODEX_LLM_MODEL); expect(requestBody.instructions).toContain('Return only valid JSON'); expect(requestBody.input).toEqual([ { type: 'message', role: 'user', content: 'User prefers concise answers.' }, diff --git a/packages/core/src/services/codex-llm.ts b/packages/core/src/services/codex-llm.ts index fac91a1..2cf50a8 100644 --- a/packages/core/src/services/codex-llm.ts +++ b/packages/core/src/services/codex-llm.ts @@ -16,6 +16,7 @@ import { writeCostEvent, type WriteCostEventConfig, } from './cost-telemetry.js'; +import { DEFAULT_CODEX_LLM_MODEL } from './llm-defaults.js'; const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api'; const CODEX_REQUEST_TIMEOUT_MS = 120_000; @@ -47,7 +48,7 @@ export class CodexLLM implements LLMProvider { private readonly baseUrl: string; constructor(private readonly config: CodexLLMConfig) { - this.model = config.llmModel || 'gpt-5.4-mini'; + this.model = config.llmModel || DEFAULT_CODEX_LLM_MODEL; this.baseUrl = config.llmApiUrl || CODEX_DEFAULT_BASE_URL; } diff --git a/packages/core/src/services/llm-defaults.ts b/packages/core/src/services/llm-defaults.ts new file mode 100644 index 0000000..cb65f10 --- /dev/null +++ b/packages/core/src/services/llm-defaults.ts @@ -0,0 +1,10 @@ +/** + * Shared LLM provider defaults. + * + * Runtime config and provider implementations both need these values. Keeping + * them here avoids hidden drift between config defaults, provider fallbacks, + * tests, and documentation snippets. + */ + +export const DEFAULT_OPENAI_COMPATIBLE_LLM_MODEL = 'gpt-4o-mini'; +export const DEFAULT_CODEX_LLM_MODEL = 'gpt-5.4-mini'; From c5f3f76eb7d385b7c92bc21095fca2c01eaa04b2 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 20 May 2026 11:57:08 -0700 Subject: [PATCH 4/8] Make Codex login the documented local default --- packages/core/README.md | 4 ++++ plugins/codex/README.md | 23 +++++++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 23845ae..943e4ac 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -308,6 +308,10 @@ intended for hosted or team deployments. Pair either one with a non-OpenAI embedding provider, such as `EMBEDDING_PROVIDER=transformers`, if you want to run without an OpenAI API key as well. +For AtomicMemory for Codex local setup, prefer `codex login` with +`LLM_PROVIDER=codex`. Use `LLM_PROVIDER=openai` plus `OPENAI_API_KEY` for +hosted or team deployments. + In-process benchmark harnesses can avoid editing env files by passing a composition-time config to the runtime: diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 98b57c2..117bdce 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -71,11 +71,9 @@ export ATOMICMEMORY_SCOPE_USER="pip" At least one `ATOMICMEMORY_SCOPE_*` must be set — the server rejects scopeless requests. The MCP server itself is fetched from npm on first use via `npx -y --package=@atomicmemory/mcp-server@^0.1.2 atomicmemory-mcp`, so no local clone or build is required. -### Optional local core extraction through Codex +### Default extraction mode: Codex login -If you run AtomicMemory core on the same machine and want extraction to use the -same logged-in Codex account instead of a separate OpenAI API key, configure the -core process with: +For local Codex use, AtomicMemory defaults to the logged-in Codex account path: ```bash codex login @@ -83,13 +81,18 @@ export LLM_PROVIDER=codex export EMBEDDING_PROVIDER=transformers ``` -This configures AtomicMemory core, not the MCP plugin. The plugin still uses -`ATOMICMEMORY_API_URL`, `ATOMICMEMORY_API_KEY`, and scope variables to connect -to core. `LLM_PROVIDER=codex` reads the auth file created by `codex login` +`LLM_PROVIDER=codex` reads the auth file created by `codex login` (`CODEX_AUTH_PATH`, `CODEX_HOME/auth.json`, or `~/.codex/auth.json`) and calls -the Codex backend directly. It is intended for personal local development, -consumes the logged-in Codex account's limits, and is not recommended for -hosted or team deployments. +the Codex backend directly. This is the recommended local setup because it does +not require an OpenAI API key. It consumes the logged-in Codex account's limits +and is not recommended for hosted or team deployments. + +For hosted or team deployments, use an API-key provider instead: + +```bash +export LLM_PROVIDER=openai +export OPENAI_API_KEY="sk-..." +``` ## Memory behavior From 7b2560fcff2ba9267367af34202fa7053e58cad5 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 20 May 2026 12:29:33 -0700 Subject: [PATCH 5/8] Add live LLM auth smoke coverage --- .../__tests__/claude-code-llm.test.ts | 13 ++- .../__tests__/codex-llm.integration.test.ts | 91 +++++++++++++++++++ .../__tests__/llm-api-key.integration.test.ts | 78 ++++++++++++++++ packages/core/src/services/claude-code-llm.ts | 15 ++- 4 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/services/__tests__/codex-llm.integration.test.ts create mode 100644 packages/core/src/services/__tests__/llm-api-key.integration.test.ts diff --git a/packages/core/src/services/__tests__/claude-code-llm.test.ts b/packages/core/src/services/__tests__/claude-code-llm.test.ts index cb423f4..90e6a7b 100644 --- a/packages/core/src/services/__tests__/claude-code-llm.test.ts +++ b/packages/core/src/services/__tests__/claude-code-llm.test.ts @@ -90,6 +90,17 @@ describe('ClaudeCodeLLM', () => { }); await expect(provider().chat([{ role: 'user', content: 'hello' }])) - .rejects.toThrow('Confirm `claude` is installed and authenticated'); + .rejects.toThrow('Run `claude auth login`'); + }); + + it('surfaces non-success result messages with setup guidance', async () => { + mocks.query.mockReturnValueOnce(messages([{ + type: 'result', + subtype: 'error', + errors: ['auth required'], + }])); + + await expect(provider().chat([{ role: 'user', content: 'hello' }])) + .rejects.toThrow('LLM_PROVIDER=anthropic'); }); }); diff --git a/packages/core/src/services/__tests__/codex-llm.integration.test.ts b/packages/core/src/services/__tests__/codex-llm.integration.test.ts new file mode 100644 index 0000000..cdd85e5 --- /dev/null +++ b/packages/core/src/services/__tests__/codex-llm.integration.test.ts @@ -0,0 +1,91 @@ +/** + * Live integration smoke for the Codex account-auth LLM provider. + * + * This test is default-on for developer machines where `codex login` has + * created a ChatGPT auth file. Set ATOMICMEMORY_SKIP_CODEX_TEST=1 to opt out + * when avoiding local account usage. + */ + +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { CodexLLM } from '../codex-llm.js'; +import { DEFAULT_CODEX_LLM_MODEL } from '../llm-defaults.js'; + +const SKIP_ENV = 'ATOMICMEMORY_SKIP_CODEX_TEST'; + +interface CodexReadiness { + runnable: boolean; + authPath: string; + reason: string; +} + +interface CodexAuthFile { + auth_mode?: string; + tokens?: { + access_token?: string; + }; +} + +const readiness = await resolveCodexReadiness(); + +describe.skipIf(!readiness.runnable)(`CodexLLM live integration (${readiness.reason})`, () => { + it('runs through Codex local auth without OPENAI_API_KEY', async () => { + const previousOpenAiApiKey = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + + try { + const provider = new CodexLLM({ + llmProvider: 'codex', + llmModel: DEFAULT_CODEX_LLM_MODEL, + llmApiUrl: undefined, + codexAuthPath: readiness.authPath, + costLoggingEnabled: false, + costRunId: 'codex-live-test', + costLogDir: '/tmp/atomicmemory-codex-live-test', + }); + + const output = await provider.chat([ + { role: 'system', content: 'Return exactly: atomicmemory-codex-ok' }, + { role: 'user', content: 'Run the AtomicMemory Codex live smoke test.' }, + ]); + + expect(output).toContain('atomicmemory-codex-ok'); + } finally { + restoreEnv('OPENAI_API_KEY', previousOpenAiApiKey); + } + }, 120_000); +}); + +async function resolveCodexReadiness(): Promise { + const authPath = process.env.CODEX_AUTH_PATH ?? join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'auth.json'); + if (process.env[SKIP_ENV] === '1') { + return { runnable: false, authPath, reason: `${SKIP_ENV}=1` }; + } + + try { + const parsed = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile; + if (parsed.auth_mode !== 'chatgpt') { + return { runnable: false, authPath, reason: `codex auth at ${authPath} is not ChatGPT login auth` }; + } + if (!parsed.tokens?.access_token) { + return { runnable: false, authPath, reason: `codex auth at ${authPath} has no access token` }; + } + return { runnable: true, authPath, reason: `codex auth file is present at ${authPath}` }; + } catch (error) { + return { runnable: false, authPath, reason: `codex auth unavailable: ${errorMessage(error)}` }; + } +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/services/__tests__/llm-api-key.integration.test.ts b/packages/core/src/services/__tests__/llm-api-key.integration.test.ts new file mode 100644 index 0000000..d6e94c0 --- /dev/null +++ b/packages/core/src/services/__tests__/llm-api-key.integration.test.ts @@ -0,0 +1,78 @@ +/** + * Live integration smokes for API-key-backed LLM providers. + * + * These tests run only when real provider keys are present in the test + * environment. Placeholder keys from .env.test are treated as unavailable. + */ + +import { describe, expect, it } from 'vitest'; +import { createLLMProvider, initLlm, type LLMConfig, type LLMProvider } from '../llm.js'; + +interface ApiKeyReadiness { + runnable: boolean; + reason: string; +} + +const openaiReadiness = resolveApiKeyReadiness('OPENAI_API_KEY'); +const anthropicReadiness = resolveApiKeyReadiness('ANTHROPIC_API_KEY'); + +describe.skipIf(!openaiReadiness.runnable)(`OpenAI LLM live integration (${openaiReadiness.reason})`, () => { + it('runs through OpenAI API-key auth', async () => { + const provider = providerFor({ + llmProvider: 'openai', + llmModel: 'gpt-4o-mini', + openaiApiKey: process.env.OPENAI_API_KEY ?? '', + }); + + const output = await provider.chat([ + { role: 'system', content: 'Return exactly: atomicmemory-openai-ok' }, + { role: 'user', content: 'Run the AtomicMemory OpenAI live smoke test.' }, + ], { maxTokens: 16 }); + + expect(output).toContain('atomicmemory-openai-ok'); + }, 60_000); +}); + +describe.skipIf(!anthropicReadiness.runnable)(`Anthropic LLM live integration (${anthropicReadiness.reason})`, () => { + it('runs through Anthropic API-key auth', async () => { + const provider = providerFor({ + llmProvider: 'anthropic', + llmModel: 'claude-sonnet-4-20250514', + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + }); + + const output = await provider.chat([ + { role: 'system', content: 'Return exactly: atomicmemory-anthropic-ok' }, + { role: 'user', content: 'Run the AtomicMemory Anthropic live smoke test.' }, + ], { maxTokens: 16 }); + + expect(output).toContain('atomicmemory-anthropic-ok'); + }, 60_000); +}); + +function providerFor(overrides: Partial): LLMProvider { + initLlm({ + llmProvider: 'openai', + llmModel: 'gpt-4o-mini', + codexAuthPath: '/tmp/unused-codex-auth.json', + openaiApiKey: '', + ollamaBaseUrl: 'http://127.0.0.1:11434', + costLoggingEnabled: false, + costRunId: 'llm-api-key-live-test', + costLogDir: '/tmp/atomicmemory-llm-api-key-live-test', + ...overrides, + }); + return createLLMProvider(); +} + +function resolveApiKeyReadiness(name: string): ApiKeyReadiness { + const value = process.env[name]; + if (!value) return { runnable: false, reason: `${name} is not set` }; + if (isPlaceholderSecret(value)) return { runnable: false, reason: `${name} is a placeholder` }; + return { runnable: true, reason: `${name} is set` }; +} + +function isPlaceholderSecret(value: string): boolean { + const lowered = value.toLowerCase(); + return lowered.includes('test') || lowered.includes('dummy') || value.includes('...') || value.includes('<'); +} diff --git a/packages/core/src/services/claude-code-llm.ts b/packages/core/src/services/claude-code-llm.ts index 9c2e6de..30d2934 100644 --- a/packages/core/src/services/claude-code-llm.ts +++ b/packages/core/src/services/claude-code-llm.ts @@ -17,6 +17,14 @@ import { type WriteCostEventConfig, } from './cost-telemetry.js'; +const CLAUDE_CODE_SETUP_GUIDANCE = [ + 'Claude Code LLM failed to run.', + 'Install Claude Code, then authenticate with your Claude account.', + 'Run `claude auth login` or start `claude` and complete the login flow.', + 'Verify the login with `claude auth status --json`, `claude doctor`, or `claude --version`.', + 'For hosted or team deployments, use `LLM_PROVIDER=anthropic` with `ANTHROPIC_API_KEY` instead.', +].join(' '); + export interface ClaudeCodeLLMConfig extends WriteCostEventConfig { llmProvider: 'claude-code'; llmModel: string; @@ -33,7 +41,7 @@ export class ClaudeCodeLLM implements LLMProvider { ); if (result.subtype !== 'success') { - throw new Error(`Claude Code LLM failed: ${result.errors.join('; ')}`); + throw new Error(`${CLAUDE_CODE_SETUP_GUIDANCE} SDK errors: ${result.errors.join('; ')}`); } recordClaudeCodeCost(this.config, result, started); @@ -86,10 +94,7 @@ async function runClaudeCodeQuery(prompt: string, options: Options): Promise Date: Wed, 20 May 2026 12:49:06 -0700 Subject: [PATCH 6/8] Default local integrations to quickstart auth --- packages/mcp-server/README.md | 2 +- packages/mcp-server/src/config.test.ts | 11 +++++++++ packages/mcp-server/src/config.ts | 20 +++++++++++++++- plugins/claude-code/README.md | 2 +- .../scripts/__tests__/load-env-defaults.sh | 17 ++++++++++++-- .../claude-code/scripts/lib/atomicmemory.sh | 4 ++++ plugins/codex/README.md | 23 ++++++++++++++++--- 7 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 27ddd5d..88aff16 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -37,7 +37,7 @@ The binary loads config from environment variables: | Variable | Required | Purpose | |---|---|---| | `ATOMICMEMORY_API_URL` | no** | Provider base URL. Defaults to the local AtomicMemory core (`http://127.0.0.1:3050`) when `ATOMICMEMORY_PROVIDER=atomicmemory`; required for `mem0`. | -| `ATOMICMEMORY_API_KEY` | no | Optional bearer credential forwarded to providers that require HTTP authorization. | +| `ATOMICMEMORY_API_KEY` | no | Bearer credential forwarded to providers that require HTTP authorization. Defaults to `local-dev-key` only for the local AtomicMemory core URL. | | `ATOMICMEMORY_PROVIDER` | no | Provider name — one of `atomicmemory` or `mem0`. Defaults to `atomicmemory`. | | `ATOMICMEMORY_SCOPE_USER` | no | Default `user` scope. Defaults to the local machine user when omitted. | | `ATOMICMEMORY_SCOPE_AGENT` | no* | Default `agent` scope | diff --git a/packages/mcp-server/src/config.test.ts b/packages/mcp-server/src/config.test.ts index 94c7778..4707a61 100644 --- a/packages/mcp-server/src/config.test.ts +++ b/packages/mcp-server/src/config.test.ts @@ -12,6 +12,7 @@ test('loadConfigFromEnv defaults URL, provider, and user scope', () => { } as NodeJS.ProcessEnv); assert.equal(config.apiUrl, 'http://127.0.0.1:3050'); + assert.equal(config.apiKey, 'local-dev-key'); assert.equal(config.provider, 'atomicmemory'); assert.deepEqual(config.scope, { user: 'machine-user' }); }); @@ -41,10 +42,20 @@ test('validateConfig accepts plugin config without URL, key, or scope', () => { const config = validateConfig({}); assert.equal(config.apiUrl, 'http://127.0.0.1:3050'); + assert.equal(config.apiKey, 'local-dev-key'); assert.equal(config.provider, 'atomicmemory'); assert.ok(config.scope?.user); }); +test('validateConfig does not default API key for remote AtomicMemory URL', () => { + const config = validateConfig({ + apiUrl: 'https://memory.example.com', + }); + + assert.equal(config.apiUrl, 'https://memory.example.com'); + assert.equal(config.apiKey, undefined); +}); + test('validateConfig accepts explicit plugin api key', () => { const config = validateConfig({ apiUrl: 'https://memory.example.com', diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index dcaa325..f1cf9b7 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -10,6 +10,7 @@ import { hostname, userInfo } from 'node:os'; import { z } from 'zod'; const DEFAULT_API_URL = 'http://127.0.0.1:3050'; +const DEFAULT_LOCAL_API_KEY = 'local-dev-key'; const DEFAULT_PROVIDER = 'atomicmemory'; const ScopeSchema = z @@ -67,9 +68,11 @@ function parseScope(env: NodeJS.ProcessEnv): Scope | undefined { function normalizeConfig(input: unknown, env: NodeJS.ProcessEnv): ServerConfig { const parsed = ConfigSchema.parse(input); + const apiUrl = resolveApiUrl(parsed.apiUrl, parsed.provider); return { ...parsed, - apiUrl: resolveApiUrl(parsed.apiUrl, parsed.provider), + apiUrl, + apiKey: resolveApiKey(parsed.apiKey, apiUrl, parsed.provider), scope: normalizeScope(parsed.scope, env), }; } @@ -109,6 +112,21 @@ function resolveApiUrl( throw new Error('provider=mem0 requires an explicit apiUrl'); } +function resolveApiKey( + apiKey: string | undefined, + apiUrl: string, + provider: ServerConfig['provider'], +): string | undefined { + const normalized = cleanOptional(apiKey); + if (normalized) return normalized; + if (provider === 'atomicmemory' && isDefaultLocalUrl(apiUrl)) return DEFAULT_LOCAL_API_KEY; + return undefined; +} + +function isDefaultLocalUrl(apiUrl: string): boolean { + return apiUrl === DEFAULT_API_URL; +} + function readOsUsername(): string | undefined { try { return cleanOptional(userInfo().username); diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index 28e12ae..c1b6255 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -23,7 +23,7 @@ The MCP server and the lifecycle hook scripts read their config from the shell e | Var | Local-mode default | |---|---| | `ATOMICMEMORY_API_URL` | `http://127.0.0.1:3050` | -| `ATOMICMEMORY_API_KEY` | not required | +| `ATOMICMEMORY_API_KEY` | `local-dev-key` for the local URL | | `ATOMICMEMORY_PROVIDER` | `atomicmemory` | | `ATOMICMEMORY_SCOPE_USER` | derived from the host OS user | | `ATOMICMEMORY_CAPTURE_LEVEL` | `balanced` | diff --git a/plugins/claude-code/scripts/__tests__/load-env-defaults.sh b/plugins/claude-code/scripts/__tests__/load-env-defaults.sh index cfe7c43..0236bdb 100755 --- a/plugins/claude-code/scripts/__tests__/load-env-defaults.sh +++ b/plugins/claude-code/scripts/__tests__/load-env-defaults.sh @@ -8,6 +8,7 @@ # - ATOMICMEMORY_CAPTURE_LEVEL defaults to "balanced" # - ATOMICMEMORY_PROVIDER defaults to "atomicmemory" # - ATOMICMEMORY_API_URL defaults to "http://127.0.0.1:3050" +# - ATOMICMEMORY_API_KEY defaults to "local-dev-key" only for the local URL # - ATOMICMEMORY_SCOPE_USER is auto-derived from the OS user # A fresh install with no ATOMICMEMORY_* host env vars set MUST succeed # so PostCompact (and the rest of the lifecycle hooks) do not exit 1. @@ -65,8 +66,8 @@ assert "AM_PROVIDER defaults to atomicmemory" "$cond" assert "AM_CAPTURE_LEVEL defaults to balanced (matches docs)" "$cond" [ "$AM_API_URL" = "http://127.0.0.1:3050" ] && cond=true || cond=false assert "AM_API_URL defaults to local core URL" "$cond" -[ -z "$AM_API_KEY" ] && cond=true || cond=false -assert "AM_API_KEY empty when unset (local mode)" "$cond" +[ "$AM_API_KEY" = "local-dev-key" ] && cond=true || cond=false +assert "AM_API_KEY defaults to local quickstart key" "$cond" [ -n "$AM_SCOPE_USER" ] && cond=true || cond=false assert "AM_SCOPE_USER auto-derives a non-empty value" "$cond" @@ -86,6 +87,18 @@ assert "AM_API_KEY exposed from ATOMICMEMORY_API_KEY" "$cond" unset ATOMICMEMORY_API_URL unset ATOMICMEMORY_API_KEY +printf '\nCase: remote URL does not receive local default API key\n' +export ATOMICMEMORY_API_URL="https://memory.example.com" +set +e +am_load_env +exit_code=$? +set -e +[ "$exit_code" -eq 0 ] && cond=true || cond=false +assert "exit 0 with remote URL and no API key" "$cond" +[ -z "$AM_API_KEY" ] && cond=true || cond=false +assert "AM_API_KEY remains empty for remote URL" "$cond" +unset ATOMICMEMORY_API_URL + printf '\nCase: ATOMICMEMORY_API_URL trailing slash is stripped\n' export ATOMICMEMORY_API_URL="https://memory.example.com/" set +e diff --git a/plugins/claude-code/scripts/lib/atomicmemory.sh b/plugins/claude-code/scripts/lib/atomicmemory.sh index cfbac13..ece1226 100755 --- a/plugins/claude-code/scripts/lib/atomicmemory.sh +++ b/plugins/claude-code/scripts/lib/atomicmemory.sh @@ -51,6 +51,10 @@ am_load_env() { AM_API_URL="${AM_API_URL%/}" + if [ -z "$AM_API_KEY" ] && [ "$AM_PROVIDER" = "atomicmemory" ] && [ "$AM_API_URL" = "http://127.0.0.1:3050" ]; then + AM_API_KEY="local-dev-key" + fi + if [ -z "$AM_API_URL" ] || [ -z "$AM_SCOPE_USER" ] || [ -z "$AM_PROVIDER" ] || [ -z "$AM_CAPTURE_LEVEL" ]; then am_debug "missing local scope user, ATOMICMEMORY_PROVIDER, or ATOMICMEMORY_CAPTURE_LEVEL" return 1 diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 117bdce..121b04a 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -52,11 +52,24 @@ Same JSON but at `~/.agents/plugins/marketplace.json`, with `source.path` pointi If you don't want plugin-system installation, register the MCP server directly in your Codex MCP config using the contents of [`.codex-mcp.json`](./.codex-mcp.json). Skips the skill; you'll need to decide when to call memory tools yourself. -The MCP config forwards the required environment variables with `env_vars`, so values come from the shell or Codex environment rather than being copied into the plugin file. +The MCP config forwards optional environment variables with `env_vars`, so +hosted overrides can come from the shell or Codex environment rather than being +copied into the plugin file. With no overrides, the MCP server uses the local +AtomicMemory core URL and local quickstart key. ## Configure -Export scope and credentials in your shell: +For local core, no provider connection variables are required. The MCP server +defaults to: + +| Var | Local-mode default | +|---|---| +| `ATOMICMEMORY_API_URL` | `http://127.0.0.1:3050` | +| `ATOMICMEMORY_API_KEY` | `local-dev-key` | +| `ATOMICMEMORY_PROVIDER` | `atomicmemory` | +| `ATOMICMEMORY_SCOPE_USER` | derived from the host OS user | + +For hosted or team services, export scope and credentials in your shell: ```bash export ATOMICMEMORY_API_URL="https://memory.yourco.com" @@ -69,7 +82,11 @@ export ATOMICMEMORY_SCOPE_USER="pip" # export ATOMICMEMORY_SCOPE_THREAD="" ``` -At least one `ATOMICMEMORY_SCOPE_*` must be set — the server rejects scopeless requests. The MCP server itself is fetched from npm on first use via `npx -y --package=@atomicmemory/mcp-server@^0.1.2 atomicmemory-mcp`, so no local clone or build is required. +Set `ATOMICMEMORY_SCOPE_USER` explicitly when multiple operators share a machine +or when you need a stable cross-machine identity. The MCP server itself is +fetched from npm on first use via +`npx -y --package=@atomicmemory/mcp-server@^0.1.2 atomicmemory-mcp`, so no local +clone or build is required. ### Default extraction mode: Codex login From f59fefb13cfe2669c1051204fe7e270e1607e47c Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 20 May 2026 13:15:14 -0700 Subject: [PATCH 7/8] Move local core default port to 17350 --- README.md | 2 +- adapters/openai-agents/README.md | 2 +- adapters/openai-agents/scripts/smoke-backend.mjs | 2 +- packages/cli/README.md | 4 ++-- packages/cli/cli-spec.json | 2 +- packages/cli/scripts/test-backend-docker.mjs | 2 +- .../cli/src/__tests__/adapter-atomicmemory.test.ts | 2 +- packages/cli/src/__tests__/adapter-registry.test.ts | 2 +- .../cli/src/__tests__/backend-gated-smoke.test.ts | 4 ++-- .../cli/src/__tests__/interactive-dashboard.test.ts | 12 ++++++------ .../cli/src/__tests__/profile-resolution.test.ts | 2 +- packages/core/.env.example | 6 +++--- packages/core/Dockerfile | 4 ++-- packages/core/README.md | 10 +++++----- packages/core/docker-compose.image.yml | 6 +++--- packages/core/docker-compose.smoke-isolated.yml | 6 +++--- packages/core/docker-compose.smoke.yml | 2 +- packages/core/docker-compose.yml | 6 +++--- packages/core/openapi.json | 2 +- packages/core/openapi.yaml | 2 +- packages/core/scripts/generate-openapi.ts | 2 +- .../core/src/__tests__/deployment-config.test.ts | 6 +++--- packages/core/src/bin.ts | 2 +- packages/core/src/config.ts | 2 +- packages/core/src/routes/memories.ts | 2 +- packages/mcp-server/README.md | 2 +- packages/mcp-server/src/config.test.ts | 4 ++-- packages/mcp-server/src/config.ts | 2 +- packages/sdk/README.md | 6 +++--- packages/sdk/scripts/capture-fixtures.ts | 2 +- .../sdk/src/client/__tests__/memory-client.test.ts | 10 +++++----- packages/sdk/src/client/atomic-memory-client.ts | 2 +- packages/sdk/src/client/memory-client.ts | 2 +- packages/sdk/src/index.ts | 2 +- .../__tests__/fixtures/README.md | 2 +- .../sdk/src/memory/atomicmemory-provider/types.ts | 2 +- .../storage/__tests__/client-network-errors.test.ts | 2 +- plugins/claude-code/README.md | 2 +- .../scripts/__tests__/load-env-defaults.sh | 4 ++-- .../scripts/__tests__/metadata-roundtrip.sh | 2 +- plugins/claude-code/scripts/lib/atomicmemory.sh | 4 ++-- plugins/codex/README.md | 2 +- plugins/hermes/README.md | 2 +- plugins/hermes/install.mjs | 2 +- plugins/openclaw/README.md | 4 ++-- plugins/openclaw/openclaw.plugin.json | 2 +- plugins/openclaw/skills/atomicmemory/skill.yaml | 4 ++-- plugins/openclaw/src/index.test.ts | 4 ++-- plugins/openclaw/src/index.ts | 2 +- 49 files changed, 83 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index a8aff0d..bc77474 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ import { MemoryClient } from '@atomicmemory/sdk'; const memory = new MemoryClient({ providers: { - atomicmemory: { apiUrl: 'http://localhost:3050' }, + atomicmemory: { apiUrl: 'http://localhost:17350' }, }, }); diff --git a/adapters/openai-agents/README.md b/adapters/openai-agents/README.md index cbbd09e..7f782d7 100644 --- a/adapters/openai-agents/README.md +++ b/adapters/openai-agents/README.md @@ -117,7 +117,7 @@ pnpm --filter @atomicmemory/openai-agents build Run the backend smoke test without making an OpenAI API call: ```bash -export ATOMICMEMORY_API_URL="http://localhost:3050" +export ATOMICMEMORY_API_URL="http://localhost:17350" export ATOMICMEMORY_API_KEY="..." export ATOMICMEMORY_PROVIDER="atomicmemory" export ATOMICMEMORY_SCOPE_USER="$USER" diff --git a/adapters/openai-agents/scripts/smoke-backend.mjs b/adapters/openai-agents/scripts/smoke-backend.mjs index a9b23df..5098ffb 100644 --- a/adapters/openai-agents/scripts/smoke-backend.mjs +++ b/adapters/openai-agents/scripts/smoke-backend.mjs @@ -2,7 +2,7 @@ import { MemoryClient } from '@atomicmemory/sdk'; import { augmentInputWithMemory, runWithMemory } from '../dist/index.js'; import { userInfo } from 'node:os'; -const apiUrl = 'http://127.0.0.1:3050'; +const apiUrl = 'http://127.0.0.1:17350'; const provider = process.env.ATOMICMEMORY_PROVIDER || 'atomicmemory'; if (provider !== 'atomicmemory' && provider !== 'mem0') { diff --git a/packages/cli/README.md b/packages/cli/README.md index 4907dbc..b2fb0be 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -59,7 +59,7 @@ the rest of the integrations repo. ```bash atomicmemory -atomicmemory init --api-url http://127.0.0.1:3050 --user "$USER" +atomicmemory init --api-url http://127.0.0.1:17350 --user "$USER" atomicmemory doctor atomicmemory status atomicmemory add "The project uses pnpm workspaces." @@ -78,7 +78,7 @@ Every command accepts the same provider and scope overrides: ```bash atomicmemory search "release policy" \ --provider atomicmemory \ - --api-url http://127.0.0.1:3050 \ + --api-url http://127.0.0.1:17350 \ --user "$USER" \ --namespace atomicmemory ``` diff --git a/packages/cli/cli-spec.json b/packages/cli/cli-spec.json index 59a3725..8a0a627 100644 --- a/packages/cli/cli-spec.json +++ b/packages/cli/cli-spec.json @@ -71,7 +71,7 @@ "--trust-surface local|self-hosted|authenticated-wrapper" ], "examples": [ - "atomicmemory init --profile local --api-url http://127.0.0.1:3050 --user $USER", + "atomicmemory init --profile local --api-url http://127.0.0.1:17350 --user $USER", "echo \"$ATOMICMEMORY_API_KEY\" | atomicmemory init --api-key-stdin --save-api-key --profile cloud" ] }, diff --git a/packages/cli/scripts/test-backend-docker.mjs b/packages/cli/scripts/test-backend-docker.mjs index 5846a81..f5b12fd 100644 --- a/packages/cli/scripts/test-backend-docker.mjs +++ b/packages/cli/scripts/test-backend-docker.mjs @@ -173,7 +173,7 @@ function listDockerPublishedPorts() { function bringUp(appPort, pgPort) { log(`docker compose up${SKIP_BUILD ? '' : ' --build'} (this may take a few minutes on first run)…`); // Layer order matters: core base → core smoke (transformers - // embedding, dummy OpenAI key, port 3050) → CLI overlay (routes + // embedding, dummy OpenAI key, port 17350) → CLI overlay (routes // LLM at the mock-openai-extraction service so /v1/memories/ingest // actually returns 200 instead of 401). const args = [ diff --git a/packages/cli/src/__tests__/adapter-atomicmemory.test.ts b/packages/cli/src/__tests__/adapter-atomicmemory.test.ts index 7672ff9..4d3da3e 100644 --- a/packages/cli/src/__tests__/adapter-atomicmemory.test.ts +++ b/packages/cli/src/__tests__/adapter-atomicmemory.test.ts @@ -35,7 +35,7 @@ import { CliError } from '../types.js'; const profile: CliProfileShape = { provider: 'atomicmemory', - apiUrl: 'http://localhost:3050', + apiUrl: 'http://localhost:17350', trustSurface: 'local', apiKey: 'sk-test', }; diff --git a/packages/cli/src/__tests__/adapter-registry.test.ts b/packages/cli/src/__tests__/adapter-registry.test.ts index c96bee7..6765e8f 100644 --- a/packages/cli/src/__tests__/adapter-registry.test.ts +++ b/packages/cli/src/__tests__/adapter-registry.test.ts @@ -19,7 +19,7 @@ import type { MemoryClient } from '@atomicmemory/sdk'; const baseProfile: CliProfileShape = { provider: 'atomicmemory', - apiUrl: 'http://localhost:3050', + apiUrl: 'http://localhost:17350', trustSurface: 'local', }; diff --git a/packages/cli/src/__tests__/backend-gated-smoke.test.ts b/packages/cli/src/__tests__/backend-gated-smoke.test.ts index bca8ee4..5cffd0d 100644 --- a/packages/cli/src/__tests__/backend-gated-smoke.test.ts +++ b/packages/cli/src/__tests__/backend-gated-smoke.test.ts @@ -5,7 +5,7 @@ * network access; opt in with: * * ATOMICMEMORY_TEST_BACKEND=1 \ - * ATOMICMEMORY_TEST_API_URL=http://127.0.0.1:3050 \ + * ATOMICMEMORY_TEST_API_URL=http://127.0.0.1:17350 \ * pnpm test:backend * * These tests verify semantic outcomes, not just exit codes: persisted @@ -27,7 +27,7 @@ const binPath = resolve(cliRoot, 'dist', 'bin.js'); const skipIfDisabled = process.env.ATOMICMEMORY_TEST_BACKEND !== '1' || !existsSync(binPath); -const apiUrl = process.env.ATOMICMEMORY_TEST_API_URL ?? 'http://127.0.0.1:3050'; +const apiUrl = process.env.ATOMICMEMORY_TEST_API_URL ?? 'http://127.0.0.1:17350'; interface RunResult { stdout: string; diff --git a/packages/cli/src/__tests__/interactive-dashboard.test.ts b/packages/cli/src/__tests__/interactive-dashboard.test.ts index ad6698f..943444f 100644 --- a/packages/cli/src/__tests__/interactive-dashboard.test.ts +++ b/packages/cli/src/__tests__/interactive-dashboard.test.ts @@ -98,7 +98,7 @@ test('bare dashboard hydrates saved profile and scope for cached commands', () = const scope = resolveRuntimeScope(invocation, profile, {}); assert.equal(profile?.provider, 'atomicmemory'); - assert.equal(profile?.apiUrl, 'http://localhost:3050'); + assert.equal(profile?.apiUrl, 'http://localhost:17350'); assert.deepEqual(scope, { user: 'user-from-profile' }); }); @@ -116,7 +116,7 @@ test('mergeInteractiveFlags inherits only profile, provider, scope, and config f const merged = mergeInteractiveFlags( { agent: true, - 'api-url': 'http://localhost:3050', + 'api-url': 'http://localhost:17350', json: true, namespace: 'testing', output: 'quiet', @@ -128,7 +128,7 @@ test('mergeInteractiveFlags inherits only profile, provider, scope, and config f ); assert.deepEqual(merged, { - 'api-url': 'http://localhost:3050', + 'api-url': 'http://localhost:17350', interactive: false, limit: 5, namespace: 'testing', @@ -320,7 +320,7 @@ test('formatDashboardCommandResult styles config profiles without leaking api ke }, local: { provider: 'atomicmemory', - apiUrl: 'http://localhost:3050', + apiUrl: 'http://localhost:17350', trustSurface: 'local', scope: { user: 'user-1', @@ -335,7 +335,7 @@ test('formatDashboardCommandResult styles config profiles without leaking api ke assert.match(rendered, /profile local \(active\)/); assert.match(rendered, /provider\s+atomicmemory/); assert.match(rendered, /trust surface\s+local/); - assert.match(rendered, /api url\s+http:\/\/localhost:3050/); + assert.match(rendered, /api url\s+http:\/\/localhost:17350/); assert.match(rendered, /scope\s+user=user-1 namespace=testing/); assert.match(rendered, /api key\s+configured \(redacted\)/); assert.match(rendered, /profile cloud/); @@ -418,7 +418,7 @@ function sessionConfig(user: string): RuntimeState['config'] { profiles: { default: { provider: 'atomicmemory', - apiUrl: 'http://localhost:3050', + apiUrl: 'http://localhost:17350', trustSurface: 'local', scope: { user }, }, diff --git a/packages/cli/src/__tests__/profile-resolution.test.ts b/packages/cli/src/__tests__/profile-resolution.test.ts index a576905..ac7721a 100644 --- a/packages/cli/src/__tests__/profile-resolution.test.ts +++ b/packages/cli/src/__tests__/profile-resolution.test.ts @@ -47,7 +47,7 @@ test('persisted profile keeps its saved trust surface', () => { profiles: { default: { provider: 'atomicmemory', - apiUrl: 'http://127.0.0.1:3050', + apiUrl: 'http://127.0.0.1:17350', trustSurface: 'local', }, }, diff --git a/packages/core/.env.example b/packages/core/.env.example index cb7efc7..22d78fe 100644 --- a/packages/core/.env.example +++ b/packages/core/.env.example @@ -41,14 +41,14 @@ CORE_API_KEY=replace-with-a-strong-random-secret STORAGE_KEY_HMAC_SECRET=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f # --- Server --- -PORT=3050 +PORT=17350 # Required deployment posture for storage-policy gates. Use `local` for # laptop/docker-compose development, `staging` for pre-prod, and `production` # for hardened hosted deployments. # Docker image local mode defaults this to `local` when omitted. RAW_STORAGE_DEPLOYMENT_ENV=local -# Comma-separated list of allowed CORS origins (default: localhost:3050,3081) -# ALLOWED_ORIGINS=http://localhost:3050,http://localhost:3081 +# Comma-separated list of allowed CORS origins (default: localhost:17350,3081) +# ALLOWED_ORIGINS=http://localhost:17350,http://localhost:3081 # --- Embedding --- # EMBEDDING_PROVIDER=openai diff --git a/packages/core/Dockerfile b/packages/core/Dockerfile index 46ac3b1..10d9117 100644 --- a/packages/core/Dockerfile +++ b/packages/core/Dockerfile @@ -73,7 +73,7 @@ RUN useradd --create-home appuser \ ENV PATH="/usr/lib/postgresql/17/bin:${PATH}" ENV NODE_ENV=production -ENV PORT=3050 +ENV PORT=17350 ENV DATABASE_URL=embedded ENV EMBEDDING_DIMENSIONS=1536 ENV RAW_STORAGE_DEPLOYMENT_ENV=local @@ -83,7 +83,7 @@ ENV EMBEDDED_POSTGRES_PORT=5432 ENV EMBEDDED_POSTGRES_USER=atomicmemory ENV EMBEDDED_POSTGRES_DB=atomicmemory -EXPOSE 3050 +EXPOSE 17350 ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] CMD ["./node_modules/.bin/tsx", "src/server.ts"] diff --git a/packages/core/README.md b/packages/core/README.md index 943e4ac..2f5520f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -56,7 +56,7 @@ Postgres/pgvector database and persists it to the mounted host directory: export OPENAI_API_KEY=sk-... docker run --rm -it --pull always \ - -p 127.0.0.1:3050:3050 \ + -p 127.0.0.1:17350:17350 \ -e OPENAI_API_KEY=$OPENAI_API_KEY \ -v $HOME/.atomicstrata/atomicmemory-docker:/var/lib/atomicmemory/postgres \ ghcr.io/atomicstrata/atomicmemory-core:latest @@ -78,7 +78,7 @@ export CORE_API_KEY=$(openssl rand -hex 32) export STORAGE_KEY_HMAC_SECRET=$(openssl rand -hex 32) docker run --rm -it --pull always \ - -p 3050:3050 \ + -p 17350:17350 \ -e DATABASE_URL=postgresql://user:pass@postgres.example.com:5432/atomicmemory \ -e OPENAI_API_KEY=$OPENAI_API_KEY \ -e CORE_API_KEY=$CORE_API_KEY \ @@ -123,7 +123,7 @@ npm run migrate npm run dev ``` -Health check: `curl http://localhost:3050/v1/memories/health` +Health check: `curl http://localhost:17350/v1/memories/health` ### Migrations @@ -237,7 +237,7 @@ overlays the startup `RuntimeConfig` for that single request. Useful for A/B tests, experiments, or dial-turning without restarting the server. ```bash -curl -X POST http://localhost:3050/v1/memories/search \ +curl -X POST http://localhost:17350/v1/memories/search \ -H 'Content-Type: application/json' \ -d '{ "user_id": "alice", @@ -268,7 +268,7 @@ release. |----------|-------------| | `DATABASE_URL` | Postgres connection string (must have pgvector extension) | | `OPENAI_API_KEY` | OpenAI API key (when using `openai` embedding/LLM provider) | -| `PORT` | Server port (default: 3050) | +| `PORT` | Server port (default: 17350) | ### Embedding Provider diff --git a/packages/core/docker-compose.image.yml b/packages/core/docker-compose.image.yml index 55f1492..d076a1a 100644 --- a/packages/core/docker-compose.image.yml +++ b/packages/core/docker-compose.image.yml @@ -20,10 +20,10 @@ services: image: ghcr.io/atomicstrata/atomicmemory-core:latest restart: unless-stopped ports: - - "${APP_PORT:-3050}:3050" + - "${APP_PORT:-17350}:17350" environment: DATABASE_URL: postgresql://atomicmemory:atomicmemory@postgres:5432/atomicmemory - PORT: "3050" + PORT: "17350" OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} env_file: - ${ENV_FILE:-.env} @@ -33,7 +33,7 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3050/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://localhost:17350/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 diff --git a/packages/core/docker-compose.smoke-isolated.yml b/packages/core/docker-compose.smoke-isolated.yml index bdad57b..16bbb85 100644 --- a/packages/core/docker-compose.smoke-isolated.yml +++ b/packages/core/docker-compose.smoke-isolated.yml @@ -20,10 +20,10 @@ services: build: . restart: unless-stopped ports: - - "${APP_PORT:-3061}:3050" + - "${APP_PORT:-3061}:17350" environment: DATABASE_URL: postgresql://atomicmemory:atomicmemory@postgres:5432/atomicmemory - PORT: "3050" + PORT: "17350" OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} EMBEDDING_PROVIDER: transformers EMBEDDING_MODEL: Xenova/all-MiniLM-L6-v2 @@ -55,7 +55,7 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3050/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://localhost:17350/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 diff --git a/packages/core/docker-compose.smoke.yml b/packages/core/docker-compose.smoke.yml index 4709a3e..a6ba308 100644 --- a/packages/core/docker-compose.smoke.yml +++ b/packages/core/docker-compose.smoke.yml @@ -10,4 +10,4 @@ services: - EMBEDDING_DIMENSIONS=384 - LLM_PROVIDER=openai - OPENAI_API_KEY=sk-smoke-test-dummy - - PORT=3050 + - PORT=17350 diff --git a/packages/core/docker-compose.yml b/packages/core/docker-compose.yml index 8ae826b..ec50207 100644 --- a/packages/core/docker-compose.yml +++ b/packages/core/docker-compose.yml @@ -24,10 +24,10 @@ services: dockerfile: packages/core/Dockerfile restart: unless-stopped ports: - - "${APP_PORT:-3050}:3050" + - "${APP_PORT:-17350}:17350" environment: DATABASE_URL: postgresql://atomicmemory:atomicmemory@postgres:5432/atomicmemory - PORT: "3050" + PORT: "17350" OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} env_file: - ${ENV_FILE:-.env} @@ -37,7 +37,7 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3050/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://localhost:17350/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 diff --git a/packages/core/openapi.json b/packages/core/openapi.json index 23e8dde..d9926db 100644 --- a/packages/core/openapi.json +++ b/packages/core/openapi.json @@ -15591,7 +15591,7 @@ "servers": [ { "description": "Local development server", - "url": "http://localhost:3050" + "url": "http://localhost:17350" } ], "webhooks": {} diff --git a/packages/core/openapi.yaml b/packages/core/openapi.yaml index 6f6c553..845d361 100644 --- a/packages/core/openapi.yaml +++ b/packages/core/openapi.yaml @@ -10924,5 +10924,5 @@ security: - bearerAuth: [] servers: - description: Local development server - url: http://localhost:3050 + url: http://localhost:17350 webhooks: {} diff --git a/packages/core/scripts/generate-openapi.ts b/packages/core/scripts/generate-openapi.ts index 4f4e84e..2f08115 100644 --- a/packages/core/scripts/generate-openapi.ts +++ b/packages/core/scripts/generate-openapi.ts @@ -50,7 +50,7 @@ function generate(): void { license: { name: 'Apache-2.0', url: 'https://www.apache.org/licenses/LICENSE-2.0' }, }, servers: [ - { url: 'http://localhost:3050', description: 'Local development server' }, + { url: 'http://localhost:17350', description: 'Local development server' }, ], // Document-level default: every `/v1/*` route requires the // `bearerAuth` scheme registered in `openapi.ts`. Individual diff --git a/packages/core/src/__tests__/deployment-config.test.ts b/packages/core/src/__tests__/deployment-config.test.ts index 25af0e2..a767a83 100644 --- a/packages/core/src/__tests__/deployment-config.test.ts +++ b/packages/core/src/__tests__/deployment-config.test.ts @@ -45,8 +45,8 @@ function extractComposeEnvVar(composeContent: string, varName: string): string | /** * Build a regex that matches a docker-compose `ports:` list entry binding * an external host port to the given internal container port. Accepts both - * a literal external port (`"3050:3050"`) and a shell-variable substitution - * (`"${APP_PORT:-3050}:3050"`). The substitution form is the side-by-side-CI + * a literal external port (`"17350:17350"`) and a shell-variable substitution + * (`"${APP_PORT:-17350}:17350"`). The substitution form is the side-by-side-CI * shape introduced in PR #6. */ function composePortBindingRegex(internalPort: number): RegExp { @@ -115,7 +115,7 @@ describe('deployment configuration', () => { it('app port is exposed', () => { const compose = readComposeRaw(); - expect(compose).toMatch(composePortBindingRegex(3050)); + expect(compose).toMatch(composePortBindingRegex(17350)); }); }); diff --git a/packages/core/src/bin.ts b/packages/core/src/bin.ts index db17c97..4573ccf 100644 --- a/packages/core/src/bin.ts +++ b/packages/core/src/bin.ts @@ -15,7 +15,7 @@ const LOCAL_PROFILE_DEFAULTS = { EMBEDDING_MODEL: 'Xenova/all-MiniLM-L6-v2', EMBEDDING_PROVIDER: 'transformers', LLM_PROVIDER: 'claude-code', - PORT: '3050', + PORT: '17350', RAW_STORAGE_DEPLOYMENT_ENV: 'local', RAW_STORAGE_MODE: 'pointer_only', STORAGE_KEY_HMAC_SECRET: diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 062b48c..eb8e144 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1132,7 +1132,7 @@ export const config: RuntimeConfig = { coreAdminApiKey: optionalEnv('CORE_ADMIN_API_KEY'), coreTestScopeAllowPattern: parseRegexEnv('CORE_TEST_SCOPE_ALLOW_PATTERN'), storageKeyHmacSecret: parseStorageKeyHmacSecret(requireEnv('STORAGE_KEY_HMAC_SECRET')), - port: parseInt(process.env.PORT ?? '3050', 10), + port: parseInt(process.env.PORT ?? '17350', 10), retrievalProfile, retrievalProfileSettings, maxSearchResults: retrievalProfileSettings.maxSearchResults, diff --git a/packages/core/src/routes/memories.ts b/packages/core/src/routes/memories.ts index 68e1c9e..133b897 100644 --- a/packages/core/src/routes/memories.ts +++ b/packages/core/src/routes/memories.ts @@ -70,7 +70,7 @@ import { import { verifyAnswer } from '../services/answer-verifier.js'; const ALLOWED_ORIGINS = new Set( - (process.env.ALLOWED_ORIGINS ?? 'http://localhost:3050,http://localhost:3081') + (process.env.ALLOWED_ORIGINS ?? 'http://localhost:17350,http://localhost:3081') .split(',') .map((o) => o.trim()) .filter(Boolean), diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 88aff16..c5d2e84 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -36,7 +36,7 @@ The binary loads config from environment variables: | Variable | Required | Purpose | |---|---|---| -| `ATOMICMEMORY_API_URL` | no** | Provider base URL. Defaults to the local AtomicMemory core (`http://127.0.0.1:3050`) when `ATOMICMEMORY_PROVIDER=atomicmemory`; required for `mem0`. | +| `ATOMICMEMORY_API_URL` | no** | Provider base URL. Defaults to the local AtomicMemory core (`http://127.0.0.1:17350`) when `ATOMICMEMORY_PROVIDER=atomicmemory`; required for `mem0`. | | `ATOMICMEMORY_API_KEY` | no | Bearer credential forwarded to providers that require HTTP authorization. Defaults to `local-dev-key` only for the local AtomicMemory core URL. | | `ATOMICMEMORY_PROVIDER` | no | Provider name — one of `atomicmemory` or `mem0`. Defaults to `atomicmemory`. | | `ATOMICMEMORY_SCOPE_USER` | no | Default `user` scope. Defaults to the local machine user when omitted. | diff --git a/packages/mcp-server/src/config.test.ts b/packages/mcp-server/src/config.test.ts index 4707a61..d6d77af 100644 --- a/packages/mcp-server/src/config.test.ts +++ b/packages/mcp-server/src/config.test.ts @@ -11,7 +11,7 @@ test('loadConfigFromEnv defaults URL, provider, and user scope', () => { USER: 'machine-user', } as NodeJS.ProcessEnv); - assert.equal(config.apiUrl, 'http://127.0.0.1:3050'); + assert.equal(config.apiUrl, 'http://127.0.0.1:17350'); assert.equal(config.apiKey, 'local-dev-key'); assert.equal(config.provider, 'atomicmemory'); assert.deepEqual(config.scope, { user: 'machine-user' }); @@ -41,7 +41,7 @@ test('loadConfigFromEnv keeps explicit scope overrides', () => { test('validateConfig accepts plugin config without URL, key, or scope', () => { const config = validateConfig({}); - assert.equal(config.apiUrl, 'http://127.0.0.1:3050'); + assert.equal(config.apiUrl, 'http://127.0.0.1:17350'); assert.equal(config.apiKey, 'local-dev-key'); assert.equal(config.provider, 'atomicmemory'); assert.ok(config.scope?.user); diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index f1cf9b7..cb44dfd 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -9,7 +9,7 @@ import { hostname, userInfo } from 'node:os'; import { z } from 'zod'; -const DEFAULT_API_URL = 'http://127.0.0.1:3050'; +const DEFAULT_API_URL = 'http://127.0.0.1:17350'; const DEFAULT_LOCAL_API_KEY = 'local-dev-key'; const DEFAULT_PROVIDER = 'atomicmemory'; diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 02b81a5..ec670ad 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -47,12 +47,12 @@ Prerequisite: start `atomicmemory-core` first. The full SDK walkthrough is in th import { AtomicMemoryClient } from '@atomicmemory/sdk'; const client = new AtomicMemoryClient({ - apiUrl: 'http://localhost:3050', + apiUrl: 'http://localhost:17350', apiKey: process.env.ATOMICMEMORY_API_KEY!, userId: 'demo-user', memory: { providers: { - atomicmemory: { apiUrl: 'http://localhost:3050' }, + atomicmemory: { apiUrl: 'http://localhost:17350' }, }, }, }); @@ -90,7 +90,7 @@ namespaced `AtomicMemoryClient.memory` surface. const memory = new MemoryClient({ providers: { atomicmemory: { - apiUrl: 'http://localhost:3050', + apiUrl: 'http://localhost:17350', apiKey: process.env.ATOMICMEMORY_API_KEY, timeout: 30_000, }, diff --git a/packages/sdk/scripts/capture-fixtures.ts b/packages/sdk/scripts/capture-fixtures.ts index 777578d..d48d1bc 100644 --- a/packages/sdk/scripts/capture-fixtures.ts +++ b/packages/sdk/scripts/capture-fixtures.ts @@ -38,7 +38,7 @@ const FIXTURES_DIR = resolve( 'src/memory/atomicmemory-provider/__tests__/fixtures', ); -const DEFAULT_CORE_URL = 'http://localhost:3050'; +const DEFAULT_CORE_URL = 'http://localhost:17350'; const HEALTH_TIMEOUT_MS = 30_000; const HEALTH_POLL_INTERVAL_MS = 500; diff --git a/packages/sdk/src/client/__tests__/memory-client.test.ts b/packages/sdk/src/client/__tests__/memory-client.test.ts index 1395e09..4372b11 100644 --- a/packages/sdk/src/client/__tests__/memory-client.test.ts +++ b/packages/sdk/src/client/__tests__/memory-client.test.ts @@ -10,7 +10,7 @@ describe('MemoryClient', () => { it('rejects operations before initialize()', async () => { const client = new MemoryClient({ - providers: { atomicmemory: { apiUrl: 'http://localhost:3050' } }, + providers: { atomicmemory: { apiUrl: 'http://localhost:17350' } }, }); await expect( client.ingest({ mode: 'text', content: 'x', scope: { user: 'u' } }) @@ -19,14 +19,14 @@ describe('MemoryClient', () => { it('capabilities() throws before initialize()', () => { const client = new MemoryClient({ - providers: { atomicmemory: { apiUrl: 'http://localhost:3050' } }, + providers: { atomicmemory: { apiUrl: 'http://localhost:17350' } }, }); expect(() => client.capabilities()).toThrow(/not initialized/i); }); it('getExtension() throws before initialize()', () => { const client = new MemoryClient({ - providers: { atomicmemory: { apiUrl: 'http://localhost:3050' } }, + providers: { atomicmemory: { apiUrl: 'http://localhost:17350' } }, }); expect(() => client.getExtension('any.extension')).toThrow( /not initialized/i @@ -36,7 +36,7 @@ describe('MemoryClient', () => { it('getProviderStatus reports configured but uninitialized providers', () => { const client = new MemoryClient({ providers: { - atomicmemory: { apiUrl: 'http://localhost:3050' }, + atomicmemory: { apiUrl: 'http://localhost:17350' }, mem0: { apiUrl: 'http://localhost:8888' }, }, }); @@ -49,7 +49,7 @@ describe('MemoryClient', () => { it('atomicmemory getter returns undefined before initialize()', () => { const client = new MemoryClient({ - providers: { atomicmemory: { apiUrl: 'http://localhost:3050' } }, + providers: { atomicmemory: { apiUrl: 'http://localhost:17350' } }, }); expect(client.atomicmemory).toBeUndefined(); }); diff --git a/packages/sdk/src/client/atomic-memory-client.ts b/packages/sdk/src/client/atomic-memory-client.ts index b11eb03..57dc8e7 100644 --- a/packages/sdk/src/client/atomic-memory-client.ts +++ b/packages/sdk/src/client/atomic-memory-client.ts @@ -7,7 +7,7 @@ * * import { AtomicMemoryClient } from '@atomicmemory/sdk'; * const client = new AtomicMemoryClient({ - * apiUrl: 'http://localhost:3050', + * apiUrl: 'http://localhost:17350', * apiKey: process.env.ATOMICMEMORY_API_KEY!, * userId: 'u1', * }); diff --git a/packages/sdk/src/client/memory-client.ts b/packages/sdk/src/client/memory-client.ts index c6a9b31..b923faf 100644 --- a/packages/sdk/src/client/memory-client.ts +++ b/packages/sdk/src/client/memory-client.ts @@ -67,7 +67,7 @@ export interface ProviderStatus { * @example * ```ts * const memory = new MemoryClient({ - * providers: { atomicmemory: { apiUrl: 'http://localhost:3050' } }, + * providers: { atomicmemory: { apiUrl: 'http://localhost:17350' } }, * }); * await memory.initialize(); * await memory.ingest({ mode: 'text', content: 'hi', scope: { user: 'u1' } }); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 61b91f7..4de417b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -9,7 +9,7 @@ * import { MemoryClient } from '@atomicmemory/sdk'; * * const memory = new MemoryClient({ - * providers: { atomicmemory: { apiUrl: 'http://localhost:3050' } }, + * providers: { atomicmemory: { apiUrl: 'http://localhost:17350' } }, * }); * await memory.initialize(); * await memory.ingest({ mode: 'text', content: 'hello', scope: { user: 'u1' } }); diff --git a/packages/sdk/src/memory/atomicmemory-provider/__tests__/fixtures/README.md b/packages/sdk/src/memory/atomicmemory-provider/__tests__/fixtures/README.md index 044734d..22f1f57 100644 --- a/packages/sdk/src/memory/atomicmemory-provider/__tests__/fixtures/README.md +++ b/packages/sdk/src/memory/atomicmemory-provider/__tests__/fixtures/README.md @@ -26,7 +26,7 @@ provider config at capture time. Not asserted by tests. 1. In `packages/core`, ensure `.env` has a real `OPENAI_API_KEY` (or `LLM_PROVIDER=ollama` with a reachable - ollama server). Bring core up on the standard port 3050: + ollama server). Bring core up on the standard port 17350: docker compose up -d --build diff --git a/packages/sdk/src/memory/atomicmemory-provider/types.ts b/packages/sdk/src/memory/atomicmemory-provider/types.ts index 2e66254..5b2d9ee 100644 --- a/packages/sdk/src/memory/atomicmemory-provider/types.ts +++ b/packages/sdk/src/memory/atomicmemory-provider/types.ts @@ -5,7 +5,7 @@ import type { MetaFactFilterConfig } from '../meta-fact-filter'; export interface AtomicMemoryProviderConfig { - /** Base URL of the atomicmemory-core instance, e.g. `http://localhost:3050`. */ + /** Base URL of the atomicmemory-core instance, e.g. `http://localhost:17350`. */ apiUrl: string; /** Optional bearer token forwarded as `Authorization: Bearer `. */ apiKey?: string; diff --git a/packages/sdk/src/storage/__tests__/client-network-errors.test.ts b/packages/sdk/src/storage/__tests__/client-network-errors.test.ts index 3cc4b7a..8d216e7 100644 --- a/packages/sdk/src/storage/__tests__/client-network-errors.test.ts +++ b/packages/sdk/src/storage/__tests__/client-network-errors.test.ts @@ -45,7 +45,7 @@ describe('StorageClient — transport-level fetch rejections wrap to network_err }); it('preserves the typed error class so callers can branch on `StorageClientError`', async () => { - const client = makeRejectingClient(new Error('ECONNREFUSED 127.0.0.1:3050')); + const client = makeRejectingClient(new Error('ECONNREFUSED 127.0.0.1:17350')); try { await client.head({ artifactId: 'a-1' }); throw new Error('expected throw'); diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index c1b6255..e22d2fa 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -22,7 +22,7 @@ The MCP server and the lifecycle hook scripts read their config from the shell e | Var | Local-mode default | |---|---| -| `ATOMICMEMORY_API_URL` | `http://127.0.0.1:3050` | +| `ATOMICMEMORY_API_URL` | `http://127.0.0.1:17350` | | `ATOMICMEMORY_API_KEY` | `local-dev-key` for the local URL | | `ATOMICMEMORY_PROVIDER` | `atomicmemory` | | `ATOMICMEMORY_SCOPE_USER` | derived from the host OS user | diff --git a/plugins/claude-code/scripts/__tests__/load-env-defaults.sh b/plugins/claude-code/scripts/__tests__/load-env-defaults.sh index 0236bdb..e9eb2cb 100755 --- a/plugins/claude-code/scripts/__tests__/load-env-defaults.sh +++ b/plugins/claude-code/scripts/__tests__/load-env-defaults.sh @@ -7,7 +7,7 @@ # https://docs.atomicstrata.ai/integrations/coding-agents/claude-code/local: # - ATOMICMEMORY_CAPTURE_LEVEL defaults to "balanced" # - ATOMICMEMORY_PROVIDER defaults to "atomicmemory" -# - ATOMICMEMORY_API_URL defaults to "http://127.0.0.1:3050" +# - ATOMICMEMORY_API_URL defaults to "http://127.0.0.1:17350" # - ATOMICMEMORY_API_KEY defaults to "local-dev-key" only for the local URL # - ATOMICMEMORY_SCOPE_USER is auto-derived from the OS user # A fresh install with no ATOMICMEMORY_* host env vars set MUST succeed @@ -64,7 +64,7 @@ assert "exit 0 on fresh-install env" "$cond" assert "AM_PROVIDER defaults to atomicmemory" "$cond" [ "$AM_CAPTURE_LEVEL" = "balanced" ] && cond=true || cond=false assert "AM_CAPTURE_LEVEL defaults to balanced (matches docs)" "$cond" -[ "$AM_API_URL" = "http://127.0.0.1:3050" ] && cond=true || cond=false +[ "$AM_API_URL" = "http://127.0.0.1:17350" ] && cond=true || cond=false assert "AM_API_URL defaults to local core URL" "$cond" [ "$AM_API_KEY" = "local-dev-key" ] && cond=true || cond=false assert "AM_API_KEY defaults to local quickstart key" "$cond" diff --git a/plugins/claude-code/scripts/__tests__/metadata-roundtrip.sh b/plugins/claude-code/scripts/__tests__/metadata-roundtrip.sh index eb49dee..2e8d969 100755 --- a/plugins/claude-code/scripts/__tests__/metadata-roundtrip.sh +++ b/plugins/claude-code/scripts/__tests__/metadata-roundtrip.sh @@ -19,7 +19,7 @@ # # Required env (script exits cleanly if not set): # ATOMICMEMORY_CORE_URL — base URL of a running core, e.g. -# http://localhost:3050 +# http://localhost:17350 set -euo pipefail diff --git a/plugins/claude-code/scripts/lib/atomicmemory.sh b/plugins/claude-code/scripts/lib/atomicmemory.sh index ece1226..1962ead 100755 --- a/plugins/claude-code/scripts/lib/atomicmemory.sh +++ b/plugins/claude-code/scripts/lib/atomicmemory.sh @@ -41,7 +41,7 @@ am_load_env() { } AM_PROVIDER="${ATOMICMEMORY_PROVIDER:-atomicmemory}" - AM_API_URL="${ATOMICMEMORY_API_URL:-http://127.0.0.1:3050}" + AM_API_URL="${ATOMICMEMORY_API_URL:-http://127.0.0.1:17350}" AM_API_KEY="${ATOMICMEMORY_API_KEY:-}" AM_SCOPE_USER="${ATOMICMEMORY_SCOPE_USER:-$(am_default_scope_user)}" AM_SCOPE_AGENT="${ATOMICMEMORY_SCOPE_AGENT:-}" @@ -51,7 +51,7 @@ am_load_env() { AM_API_URL="${AM_API_URL%/}" - if [ -z "$AM_API_KEY" ] && [ "$AM_PROVIDER" = "atomicmemory" ] && [ "$AM_API_URL" = "http://127.0.0.1:3050" ]; then + if [ -z "$AM_API_KEY" ] && [ "$AM_PROVIDER" = "atomicmemory" ] && [ "$AM_API_URL" = "http://127.0.0.1:17350" ]; then AM_API_KEY="local-dev-key" fi diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 121b04a..78251df 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -64,7 +64,7 @@ defaults to: | Var | Local-mode default | |---|---| -| `ATOMICMEMORY_API_URL` | `http://127.0.0.1:3050` | +| `ATOMICMEMORY_API_URL` | `http://127.0.0.1:17350` | | `ATOMICMEMORY_API_KEY` | `local-dev-key` | | `ATOMICMEMORY_PROVIDER` | `atomicmemory` | | `ATOMICMEMORY_SCOPE_USER` | derived from the host OS user | diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md index 9aeb6db..5d5fb17 100644 --- a/plugins/hermes/README.md +++ b/plugins/hermes/README.md @@ -36,7 +36,7 @@ clone is required. ```bash npx -y @atomicmemory/hermes-plugin install -export ATOMICMEMORY_API_URL="http://127.0.0.1:3050" +export ATOMICMEMORY_API_URL="http://127.0.0.1:17350" export ATOMICMEMORY_API_KEY="local-dev-key" ``` diff --git a/plugins/hermes/install.mjs b/plugins/hermes/install.mjs index 1e9d802..8df134f 100755 --- a/plugins/hermes/install.mjs +++ b/plugins/hermes/install.mjs @@ -82,7 +82,7 @@ function printNextSteps(target) { console.log(`Installed AtomicMemory Hermes provider to ${target}`); console.log(''); console.log('Next:'); - console.log(' export ATOMICMEMORY_API_URL="http://127.0.0.1:3050"'); + console.log(' export ATOMICMEMORY_API_URL="http://127.0.0.1:17350"'); console.log(' export ATOMICMEMORY_API_KEY="local-dev-key"'); console.log(' hermes memory setup'); console.log(' hermes memory status'); diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 84b22e6..45a97d3 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -21,7 +21,7 @@ OpenClaw passes config from `openclaw.plugin.json` into the plugin entrypoint: ```json { - "apiUrl": "http://127.0.0.1:3050", + "apiUrl": "http://127.0.0.1:17350", "apiKey": "local-dev-key", "provider": "atomicmemory", "scope": { @@ -33,7 +33,7 @@ OpenClaw passes config from `openclaw.plugin.json` into the plugin entrypoint: ``` The shipped skill permissions allow only local AtomicMemory core origins: -`http://127.0.0.1:3050` and `http://localhost:3050`. Remote providers require +`http://127.0.0.1:17350` and `http://localhost:17350`. Remote providers require a separately permissioned plugin manifest and host validation. `scope.user` is required and should be the stable channel-agnostic user identity. Optional `agent`, `namespace`, and `thread` narrow memory when needed. The plugin normalizes the API URL, strips whitespace from the API key, and drops empty optional scope fields before spawning the MCP server. diff --git a/plugins/openclaw/openclaw.plugin.json b/plugins/openclaw/openclaw.plugin.json index b2a0f34..8a91ab7 100644 --- a/plugins/openclaw/openclaw.plugin.json +++ b/plugins/openclaw/openclaw.plugin.json @@ -14,7 +14,7 @@ "properties": { "apiUrl": { "type": "string", - "description": "Optional provider base URL. Defaults to the local AtomicMemory core. The shipped skill permissions allow http://127.0.0.1:3050 and http://localhost:3050." + "description": "Optional provider base URL. Defaults to the local AtomicMemory core. The shipped skill permissions allow http://127.0.0.1:17350 and http://localhost:17350." }, "apiKey": { "type": "string", diff --git a/plugins/openclaw/skills/atomicmemory/skill.yaml b/plugins/openclaw/skills/atomicmemory/skill.yaml index 0cde666..e997ad3 100644 --- a/plugins/openclaw/skills/atomicmemory/skill.yaml +++ b/plugins/openclaw/skills/atomicmemory/skill.yaml @@ -12,8 +12,8 @@ description: | permissions: network: - - http://127.0.0.1:3050 - - http://localhost:3050 + - http://127.0.0.1:17350 + - http://localhost:17350 credentials: [] filesystem: [] shell: [] diff --git a/plugins/openclaw/src/index.test.ts b/plugins/openclaw/src/index.test.ts index a31e190..0dd7bc7 100644 --- a/plugins/openclaw/src/index.test.ts +++ b/plugins/openclaw/src/index.test.ts @@ -78,7 +78,7 @@ function registerWithConfig(testPlugin: typeof plugin) { const tools: Array[0]['registerTool']>[0]> = []; testPlugin.register({ pluginConfig: { - apiUrl: 'http://127.0.0.1:3050///', + apiUrl: 'http://127.0.0.1:17350///', apiKey: ' local-dev-key ', provider: 'atomicmemory', scope: { user: 'pip', namespace: 'repo' }, @@ -92,7 +92,7 @@ function registerWithConfig(testPlugin: typeof plugin) { function normalizedConfig() { return { - apiUrl: 'http://127.0.0.1:3050', + apiUrl: 'http://127.0.0.1:17350', apiKey: 'local-dev-key', provider: 'atomicmemory', scope: { user: 'pip', namespace: 'repo' }, diff --git a/plugins/openclaw/src/index.ts b/plugins/openclaw/src/index.ts index deb3b93..130693f 100644 --- a/plugins/openclaw/src/index.ts +++ b/plugins/openclaw/src/index.ts @@ -250,7 +250,7 @@ function defaultScopeUser(): string { function resolveApiUrl(apiUrl: string | undefined, provider: 'atomicmemory' | 'mem0'): string { const normalized = cleanOptional(apiUrl); if (normalized) return normalized.replace(/\/+$/, ''); - if (provider === 'atomicmemory') return 'http://127.0.0.1:3050'; + if (provider === 'atomicmemory') return 'http://127.0.0.1:17350'; throw new Error('AtomicMemory OpenClaw plugin requires config.apiUrl when provider=mem0'); } From d4d963684fec4d18daff3ddf0911c3c813d2f26a Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 20 May 2026 16:25:52 -0700 Subject: [PATCH 8/8] Fix Codex provider token caps and cache key --- .../src/services/__tests__/codex-llm.test.ts | 25 ++++++++++++++++++- packages/core/src/services/codex-llm.ts | 10 ++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/__tests__/codex-llm.test.ts b/packages/core/src/services/__tests__/codex-llm.test.ts index aedade2..27d964e 100644 --- a/packages/core/src/services/__tests__/codex-llm.test.ts +++ b/packages/core/src/services/__tests__/codex-llm.test.ts @@ -80,7 +80,30 @@ describe('CodexLLM', () => { ]); expect(requestBody.store).toBe(false); expect(requestBody.stream).toBe(true); - expect(requestBody.max_output_tokens).toBeUndefined(); + expect(requestBody.max_output_tokens).toBe(256); + expect(requestBody.prompt_cache_key).toMatch(/^atomicmemory:[a-f0-9]{32}$/); + }); + + it('uses a stable cache key for repeated extraction prompts', async () => { + mockAuth(); + mockSseResponse(sse('ok')); + + const messages = [ + { role: 'system' as const, content: 'Extract memory JSON.' }, + { role: 'user' as const, content: 'First user content.' }, + ]; + + await provider().chat(messages, { jsonMode: true }); + await provider().chat([ + messages[0], + { role: 'user', content: 'Different user content.' }, + ], { jsonMode: true }); + + const cacheKeys = fetchMock.mock.calls.map((call) => { + const body = JSON.parse(String(call[1]?.body)) as Record; + return body.prompt_cache_key; + }); + expect(cacheKeys[0]).toBe(cacheKeys[1]); }); it('rejects missing Codex auth with setup guidance', async () => { diff --git a/packages/core/src/services/codex-llm.ts b/packages/core/src/services/codex-llm.ts index 2cf50a8..e4c950f 100644 --- a/packages/core/src/services/codex-llm.ts +++ b/packages/core/src/services/codex-llm.ts @@ -6,7 +6,7 @@ * quickstart while avoiding a per-call shell-out to the Codex agent CLI. */ -import { randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import type { ChatMessage, ChatOptions, LLMProvider } from './llm.js'; import { @@ -91,10 +91,16 @@ function buildCodexPayload(model: string, messages: ChatMessage[], options: Chat store: false, stream: true, include: ['reasoning.encrypted_content'], - prompt_cache_key: randomUUID(), + prompt_cache_key: codexPromptCacheKey(model, instructions), + ...(options.maxTokens ? { max_output_tokens: options.maxTokens } : {}), }; } +function codexPromptCacheKey(model: string, instructions: string): string { + const hash = createHash('sha256').update(model).update('\0').update(instructions).digest('hex').slice(0, 32); + return `atomicmemory:${hash}`; +} + function splitCodexMessages( messages: ChatMessage[], jsonMode: boolean,