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 a689563..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 @@ -108,6 +108,13 @@ 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 +# 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. + # --- 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..10d9117 100644 --- a/packages/core/Dockerfile +++ b/packages/core/Dockerfile @@ -52,6 +52,10 @@ 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 \ + && rm -rf /var/lib/apt/lists/* + # Production node_modules + package.json from pnpm deploy. COPY --from=builder /deploy/node_modules ./node_modules COPY --from=builder /deploy/package.json ./package.json @@ -69,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 @@ -79,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 4eb2d4f..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 @@ -297,13 +297,20 @@ 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 account session for personal development | + +For personal local use, `LLM_PROVIDER=claude-code` and `LLM_PROVIDER=codex` +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. + +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/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__/config-env.test.ts b/packages/core/src/__tests__/config-env.test.ts index be97252..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', @@ -18,6 +19,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]]), @@ -89,6 +92,20 @@ 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(DEFAULT_CODEX_LLM_MODEL); + expect(config.codexAuthPath).toContain('.codex/auth.json'); + }); + 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/__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 b9cd399..eb8e144 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -10,6 +10,12 @@ 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'; import { collectFilecoinProviderEnvKeys, @@ -18,7 +24,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'; @@ -123,6 +129,7 @@ export interface RuntimeConfig { llmModel: string; llmApiUrl?: string; llmApiKey?: string; + codexAuthPath: string; groqApiKey?: string; ollamaBaseUrl: string; vectorBackend: VectorBackendName; @@ -687,6 +694,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(', ')}`); @@ -696,7 +704,16 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName): function defaultLlmModel(provider: LLMProviderName): string { if (provider === 'claude-code') return ''; - return 'gpt-4o-mini'; + if (provider === 'codex') return DEFAULT_CODEX_LLM_MODEL; + return DEFAULT_OPENAI_COMPATIBLE_LLM_MODEL; +} + +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'); } @@ -1115,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, @@ -1163,6 +1180,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/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/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__/codex-llm.test.ts b/packages/core/src/services/__tests__/codex-llm.test.ts new file mode 100644 index 0000000..27d964e --- /dev/null +++ b/packages/core/src/services/__tests__/codex-llm.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for the Codex OAuth-backed LLM provider. + */ + +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(), +})); + +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: DEFAULT_CODEX_LLM_MODEL, + 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(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.' }, + ]); + expect(requestBody.store).toBe(false); + expect(requestBody.stream).toBe(true); + 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 () => { + 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-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/__tests__/llm-providers.test.ts b/packages/core/src/services/__tests__/llm-providers.test.ts index e44651d..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,6 +71,13 @@ describe('createLLMProvider', () => { expect(typeof provider.chat).toBe('function'); }); + it('creates Codex OAuth 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/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 { + 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: 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, +): { 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-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'; diff --git a/packages/core/src/services/llm.ts b/packages/core/src/services/llm.ts index 71b3283..a2f117c 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 { CodexLLM } from './codex-llm.js'; import { estimateCostUsd, getCostStage, @@ -30,6 +31,7 @@ export interface LLMConfig extends WriteCostEventConfig { llmModel: string; llmApiUrl?: string; llmApiKey?: string; + codexAuthPath: string; openaiApiKey: string; groqApiKey?: string; anthropicApiKey?: string; @@ -395,6 +397,16 @@ export function createLLMProvider(): LLMProvider { costRunId: config.costRunId, costLogDir: config.costLogDir, }); + case 'codex': + return new CodexLLM({ + llmProvider: config.llmProvider, + llmModel: config.llmModel, + llmApiUrl: config.llmApiUrl, + codexAuthPath: config.codexAuthPath, + costLoggingEnabled: config.costLoggingEnabled, + costRunId: config.costRunId, + costLogDir: config.costLogDir, + }); case 'openai-compatible': return new OpenAICompatibleLLM( config.llmApiKey ?? config.openaiApiKey, diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 27ddd5d..c5d2e84 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -36,8 +36,8 @@ 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_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. | | `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..d6d77af 100644 --- a/packages/mcp-server/src/config.test.ts +++ b/packages/mcp-server/src/config.test.ts @@ -11,7 +11,8 @@ 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' }); }); @@ -40,11 +41,21 @@ 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); }); +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..cb44dfd 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -9,7 +9,8 @@ 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'; 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/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 28e12ae..e22d2fa 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -22,8 +22,8 @@ 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_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 | | `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..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,8 @@ # 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 # so PostCompact (and the rest of the lifecycle hooks) do not exit 1. @@ -63,10 +64,10 @@ 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" -[ -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/__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 cfbac13..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,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:17350" ]; 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 7f3a091..78251df 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:17350` | +| `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,34 @@ 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 + +For local Codex use, AtomicMemory defaults to the logged-in Codex account path: + +```bash +codex login +export LLM_PROVIDER=codex +export EMBEDDING_PROVIDER=transformers +``` + +`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. 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 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'); }