diff --git a/README.md b/README.md index 886a9b92..1adae656 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ # OpenAlice -Your one-person Wall Street. Alice is an AI trading agent that covers equities, crypto, commodities, forex, and macro — from research and analysis through position entry, ongoing management, to exit. +Your one-person Wall Street. Alice is an AI trading agent that covers equities, crypto, commodities, forex, and macro, from research and analysis through position entry, ongoing management, and exit. -- **Full-spectrum** — analyze and trade across asset classes. Multiple brokers combine into one unified workspace so you're never stuck with "I can see it but can't trade it." -- **Full-lifecycle** — not just entry signals. Research, position sizing, ongoing monitoring, risk management, and exit decisions — Alice covers the entire trading lifecycle, 24/7. -- **Full-control** — every trade goes through version history and safety checks, and requires your explicit approval before execution. You see every step, you can stop every step. +- **Full-spectrum** - analyze and trade across asset classes. Multiple brokers combine into one unified workspace so you're never stuck with "I can see it but can't trade it." +- **Full-lifecycle** - not just entry signals. Research, position sizing, ongoing monitoring, risk management, and exit decisions. Alice covers the entire trading lifecycle, 24/7. +- **Full-control** - every trade goes through version history and safety checks, and requires your explicit approval before execution. You see every step, and you can stop every step. -Alice runs on your own machine, because trading involves private keys and real money — that trust can't be outsourced. +Alice runs on your own machine, because trading involves private keys and real money. That trust cannot be outsourced.

OpenAlice Preview @@ -27,144 +27,212 @@ Alice runs on your own machine, because trading involves private keys and real m ### Trading -- **Unified Trading Account (UTA)** — multiple brokers (CCXT, Alpaca, Interactive Brokers) combine into unified workspaces. AI interacts with UTAs, never with brokers directly -- **Trading-as-Git** — stage orders, commit with a message, push to execute. Full history reviewable with commit hashes -- **Guard pipeline** — pre-execution safety checks (max position size, cooldown, symbol whitelist) per account -- **Account snapshots** — periodic and event-driven state capture with equity curve visualization +- **Unified Trading Account (UTA)** - multiple brokers (CCXT, Alpaca, Interactive Brokers) combine into unified workspaces. AI interacts with UTAs, never with brokers directly. +- **Trading-as-Git** - stage orders, commit with a message, push to execute. Full history is reviewable with commit hashes. +- **Guard pipeline** - pre-execution safety checks (max position size, cooldown, symbol whitelist) per account. +- **Account snapshots** - periodic and event-driven state capture with equity curve visualization. -### Research & Analysis +### Research and Analysis -- **Market data** — equity, crypto, commodity, currency, and macro data via TypeScript-native OpenBB engine. Unified cross-asset symbol search and technical indicator calculator -- **Fundamental research** — company profiles, financial statements, ratios, analyst estimates, earnings calendar, insider trading, and market movers. Currently deepest for equities, expanding to other asset classes -- **News** — background RSS collection with archive search +- **Market data** - equity, crypto, commodity, currency, and macro data via the TypeScript-native OpenBB engine. Includes unified cross-asset symbol search and a technical indicator calculator. +- **Fundamental research** - company profiles, financial statements, ratios, analyst estimates, earnings calendar, insider trading, and market movers. +- **News** - background RSS collection with archive search. ### Automation -An append-only event log sits at the center of Alice. All system activity — trades, messages, scheduled fires, heartbeat results — flows through as typed events with real-time subscriptions. Automation features are listeners on this bus: +An append-only event log sits at the center of Alice. All system activity, trades, messages, scheduled fires, and heartbeat results, flows through as typed events with real-time subscriptions. -- **Cron scheduling** — cron expressions, intervals, or one-shot timestamps. On fire, emits an event → listener routes through AI → delivers reply to your last-used channel -- **Heartbeat** — a special cron job that periodically reviews market conditions, filters by active hours, and only reaches out when something matters -- **Webhooks** — inbound event triggers from external systems (planned) +- **Cron scheduling** - cron expressions, intervals, or one-shot timestamps. On fire, Alice routes the event through AI and delivers the reply to your last-used channel. +- **Heartbeat** - a special cron job that periodically reviews market conditions, filters by active hours, and only reaches out when something matters. +- **Webhooks** - inbound event triggers from external systems (planned). ### Interface -- **Web UI** — chat with SSE streaming, sub-channels, portfolio dashboard with equity curve, and full config management -- **Telegram** — mobile access with trading panel -- **MCP server** — tool exposure for external agents +- **Web UI** - chat with SSE streaming, sub-channels, portfolio dashboard with equity curve, and full config management. +- **Telegram** - mobile access with a trading panel. +- **MCP server** - tool exposure for external agents. -### And More! - -- **Multi-provider AI** — Claude (Agent SDK with OAuth or API key) or Vercel AI SDK (Anthropic, OpenAI, Google), switchable at runtime -- **Brain** — persistent memory and emotion tracking across conversations -- **Evolution mode** — permission escalation that gives Alice full project access including Bash, enabling self-modification +### And More +- **Multi-provider AI** - Codex CLI, Claude (Agent SDK with OAuth or API key), or Vercel AI SDK (Anthropic, OpenAI, Google), switchable at runtime. +- **Brain** - persistent memory and emotion tracking across conversations. +- **Evolution mode** - permission escalation that gives Alice full project access including Bash, enabling self-modification. ## Architecture Alice has four layers. Each layer only talks to the one directly above or below it. ```mermaid -graph TB - subgraph Interface["Interface — where users interact"] - WEB[Web UI] - TG[Telegram] - MCP[MCP Server] +graph LR + subgraph Providers + CX[Codex CLI] + AS[Claude / Agent SDK] + VS[Vercel AI SDK] end - subgraph Core["Core — orchestration & routing"] - AC[AgentCenter] + subgraph Core PR[ProviderRouter] + AC[AgentCenter] TC[ToolCenter] + S[Session Store] EL[Event Log] CCR[ConnectorCenter] end - subgraph Domain["Domain — business logic"] - subgraph UTA["UTA (Trading)"] - TG2[Trading Git] + subgraph Domain + MD[Market Data] + AN[Analysis] + subgraph UTA[Unified Trading Account] + TR[Trading Git] GD[Guards] BK[Brokers] + SN[Snapshots] end - MD[Market Data] - AN[Analysis] - NC[News] + NC[News Collector] BR[Brain] + BW[Browser] end - subgraph Automation["Automation — scheduled & event-driven"] + subgraph Tasks CRON[Cron Engine] HB[Heartbeat] end - WEB & TG & MCP --> AC - AC --> PR - PR -->|Claude| AS[Agent SDK] - PR -->|Vercel| VS[Vercel AI SDK] - TC --> Domain - EL --> Automation - CCR --> Interface + subgraph Interfaces + WEB[Web UI] + TG[Telegram] + MCP[MCP Server] + end + + CX --> PR + AS --> PR + VS --> PR + PR --> AC + AC --> S + TC -->|Vercel tools| VS + TC -->|in-process MCP| AS + TC -->|MCP tools| MCP + MD --> AN + MD --> NC + AN --> TC + GD --> TR + TR --> BK + UTA --> TC + NC --> TC + BR --> TC + BW --> TC + CRON --> EL + HB --> CRON + EL --> CRON + CCR --> WEB + CCR --> TG + WEB --> AC + TG --> AC + MCP --> AC ``` -**Interface** — external surfaces (Web UI, Telegram, MCP). Users and external agents connect here. ConnectorCenter tracks last-used channel for delivery routing. +**Providers** - interchangeable AI backends. Codex uses the local `codex exec` CLI and mounts Alice's MCP server into the Codex run, so the agent is genuinely Codex-driven. Claude uses `@anthropic-ai/claude-agent-sdk` with tools delivered via in-process MCP. Vercel AI SDK runs a `ToolLoopAgent` in-process with direct API calls. -**Core** — AgentCenter routes all AI calls through ProviderRouter. ToolCenter is a shared registry — domain modules register tools there, and it exports them to whichever AI provider is active. EventLog is the central event bus. +**Core** - AgentCenter routes all AI calls through ProviderRouter. ToolCenter is a shared registry, domain modules register tools there, and it exports them to whichever AI provider is active. EventLog is the central event bus. -**Domain** — business logic. UTA is the trading workspace (see Key Concepts below). Market Data, Analysis, News, and Brain are independent modules, each exposed to AI through tool registrations. +**Domain** - business logic. UTA is the trading workspace. Market Data, Analysis, News, and Brain are independent modules, each exposed to AI through tool registrations. -**Automation** — listeners on the EventLog bus. Cron fires scheduled jobs, Heartbeat is a special cron job for periodic market review. +**Automation** - listeners on the EventLog bus. Cron fires scheduled jobs, and Heartbeat is a special cron job for periodic market review. ## Key Concepts -**UTA (Unified Trading Account)** — The core abstraction. Each UTA wraps a broker connection, operation history, guard pipeline, and snapshot scheduler into a single self-contained workspace. AI and the frontend interact with UTAs exclusively — brokers are internal implementation details. Multiple UTAs work like independent repositories: one for Alpaca US equities, one for Bybit crypto, each with its own history and guards. +**UTA (Unified Trading Account)** - The core abstraction. Each UTA wraps a broker connection, operation history, guard pipeline, and snapshot scheduler into a single self-contained workspace. AI and the frontend interact with UTAs exclusively, brokers are internal implementation details. -**Trading-as-Git** — The workflow inside each UTA. Stage orders, commit with a message, then push to execute. Push runs guards, dispatches to the broker, snapshots account state, and records a commit with an 8-char hash. Full history is reviewable like `git log` / `git show`. +**Trading-as-Git** - The workflow inside each UTA. Stage orders, commit with a message, then push to execute. Push runs guards, dispatches to the broker, snapshots account state, and records a commit with an 8-char hash. -**Guard** — A pre-execution safety check that runs inside a UTA before orders reach the broker. Guards enforce limits (max position size, cooldown between trades, symbol whitelist) and are configured per-account. Think of it as ESLint for trading — automated rules that catch problems before they go live. +**Guard** - A pre-execution safety check that runs inside a UTA before orders reach the broker. Guards enforce limits such as max position size, cooldown between trades, and symbol whitelists. -**Heartbeat** — A periodic check-in where Alice reviews market conditions and decides whether to send you a message. Useful for monitoring positions overnight or tracking macro events — Alice reaches out when something matters, stays quiet when it doesn't. +**Heartbeat** - A periodic check-in where Alice reviews market conditions and decides whether to send you a message. -**Connector** — An external interface through which users interact with Alice. Built-in: Web UI, Telegram, MCP Ask. Delivery always goes to the channel you last spoke through. +**Connector** - An external interface through which users interact with Alice. Built-in connectors include Web UI, Telegram, and MCP Ask. Delivery always goes to the channel you last spoke through. -**AI Provider** — The AI backend that powers Alice. Claude (via Agent SDK, supports OAuth login or API key) or Vercel AI SDK (Anthropic, OpenAI, Google). Switchable at runtime — no restart needed. +**AI Provider** - The AI backend that powers Alice. The default is Codex CLI (`codex exec`) with Alice's MCP tools mounted into the local Codex session. Claude and Vercel AI SDK are also available, and providers can be switched at runtime with no restart needed. ## Quick Start -Prerequisites: Node.js 22+, pnpm 10+, [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated. +Prerequisites: Node.js 22+, pnpm 10+, and [Codex CLI](https://developers.openai.com/codex/cli) installed and authenticated. ```bash git clone https://github.com/TraderAlice/OpenAlice.git cd OpenAlice -pnpm install && pnpm build -pnpm dev +codex login +corepack pnpm install && corepack pnpm build +corepack pnpm dev +``` + +Open [localhost:3002](http://localhost:3002) and start chatting. No API keys or extra provider setup are needed. The default profile uses your local Codex CLI session. + +```bash +corepack pnpm dev # start backend (port 3002) with watch mode +corepack pnpm dev:ui # start frontend dev server (port 5173) with hot reload +corepack pnpm build # production build (backend + UI) +corepack pnpm test # run tests ``` -Open [localhost:3002](http://localhost:3002) and start chatting. No API keys or config needed — the default setup uses your local Claude Code login (Claude Pro/Max subscription). +> **Note:** Port 3002 serves the UI only after `pnpm build`. For frontend development, use `pnpm dev:ui` (port 5173), which proxies to the backend and provides hot reload. + +## Daily Workflow + +For normal daily use, the shortest path is: + +```bash +cd OpenAlice +codex login # only when needed +corepack pnpm dev +``` + +Then open [localhost:3002](http://localhost:3002) and chat with the default profile. That profile routes every turn through local Codex CLI, and Codex reaches Alice's trading, research, browser, and session tools through the built-in MCP server on port `3001`. + +If you want a different backend later, open the AI Provider page in the Web UI and switch profiles there. No restart is required. + +On Windows, you can use the bundled launcher instead: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\start-openalice.ps1 +``` + +Or: + +```bat +.\scripts\start-openalice.cmd +``` + +Or, if you prefer npm scripts: + +```bash +corepack pnpm dev:codex +``` ## Configuration All config lives in `data/config/` as JSON files with Zod validation. Missing files fall back to sensible defaults. You can edit these files directly or use the Web UI. -**AI Provider** — The default provider is Claude (Agent SDK), which uses your local Claude Code login — no API key needed. To use the [Vercel AI SDK](https://sdk.vercel.ai/docs) instead (Anthropic, OpenAI, Google, etc.), switch `ai-provider.json` to `vercel-ai-sdk` and add your API key. Both can be switched at runtime via the Web UI. +**AI Provider** - The default provider is Codex CLI, which uses your local `codex login` session and runs Alice through `codex exec` with Alice's MCP tools attached automatically. To use Claude Agent SDK or the [Vercel AI SDK](https://sdk.vercel.ai/docs) instead, switch `ai-provider-manager.json` or use the Web UI. Providers can be switched at runtime with no restart. -**Trading** — Unified Trading Account (UTA) architecture. Each account in `accounts.json` becomes a UTA with its own broker connection, git history, and guard config. Broker-specific settings live in the `brokerConfig` field — each broker type declares its own schema and validates it internally. +**Trading** - Unified Trading Account (UTA) architecture. Each account in `accounts.json` becomes a UTA with its own broker connection, git history, and guard config. | File | Purpose | |------|---------| | `engine.json` | Trading pairs, tick interval, timeframe | | `agent.json` | Max agent steps, evolution mode toggle, Claude Code tool permissions | -| `ai-provider.json` | Active AI provider (`agent-sdk` or `vercel-ai-sdk`), login method, switchable at runtime | -| `accounts.json` | Trading accounts with `type`, `enabled`, `guards`, and `brokerConfig` (broker-specific settings) | +| `ai-provider-manager.json` | Active AI provider profile (`codex`, `agent-sdk`, or `vercel-ai-sdk`), login method, switchable at runtime | +| `accounts.json` | Trading accounts with `type`, `enabled`, `guards`, and `brokerConfig` | | `connectors.json` | Web/MCP server ports, MCP Ask enable | -| `telegram.json` | Telegram bot credentials + enable | +| `telegram.json` | Telegram bot credentials and enable flag | | `web-subchannels.json` | Web UI sub-channel definitions with per-channel AI provider overrides | | `tools.json` | Tool enable/disable configuration | -| `market-data.json` | Data backend (`typebb-sdk` / `openbb-api`), per-asset-class providers, provider API keys, embedded HTTP server config | +| `market-data.json` | Data backend configuration and provider API keys | | `news.json` | RSS feeds, fetch interval, retention period | | `snapshot.json` | Account snapshot interval and retention | -| `compaction.json` | Context window limits, auto-compaction thresholds | -| `heartbeat.json` | Heartbeat enable/disable, interval, active hours | +| `compaction.json` | Context window limits and auto-compaction thresholds | +| `heartbeat.json` | Heartbeat enable/disable, interval, and active hours | -Persona and heartbeat prompts use a **default + user override** pattern: +Persona and heartbeat prompts use a default plus user override pattern: | Default (git-tracked) | User override (gitignored) | |------------------------|---------------------------| @@ -179,13 +247,13 @@ OpenAlice is a pnpm monorepo with Turborepo build orchestration. See [docs/proje ## Roadmap to v1 -OpenAlice is in pre-release. All planned v1 milestones are now complete — remaining work is testing and stabilization. +OpenAlice is in pre-release. All planned v1 milestones are now complete. Remaining work is testing and stabilization. -- [x] **Tool confirmation** — achieved through Trading-as-Git's push approval mechanism. Order execution requires explicit user approval at the push step, similar to merging a PR -- [x] **Trading-as-Git stable interface** — the core workflow (stage → commit → push → approval) is stable and running in production -- [x] **IBKR broker** — Interactive Brokers integration via TWS/Gateway. `IbkrBroker` bridges the callback-based `@traderalice/ibkr` SDK to the Promise-based `IBroker` interface via `RequestBridge`. Supports all IBroker methods including conId-based contract resolution -- [x] **Account snapshot & analytics** — periodic and event-driven snapshots with equity curve visualization, configurable intervals, and carry-forward for data gaps +- [x] **Tool confirmation** - achieved through Trading-as-Git's push approval mechanism. +- [x] **Trading-as-Git stable interface** - the core workflow is stable and running in production. +- [x] **IBKR broker** - Interactive Brokers integration via TWS/Gateway. +- [x] **Account snapshot and analytics** - periodic and event-driven snapshots with equity curve visualization. ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=TraderAlice/OpenAlice&type=Date)](https://star-history.com/#TraderAlice/OpenAlice&Date) \ No newline at end of file +[![Star History Chart](https://api.star-history.com/svg?repos=TraderAlice/OpenAlice&type=Date)](https://star-history.com/#TraderAlice/OpenAlice&Date) diff --git a/package.json b/package.json index c590852a..a774413f 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,13 @@ "type": "module", "scripts": { "dev": "tsx watch src/main.ts", - "dev:ui": "pnpm --filter open-alice-ui dev", - "predev": "turbo run build --filter=@traderalice/opentypebb --filter=@traderalice/ibkr", - "build": "turbo run build && tsup src/main.ts --format esm --dts", + "dev:ui": "corepack pnpm --filter open-alice-ui dev", + "dev:codex": "powershell -ExecutionPolicy Bypass -File ./scripts/start-openalice.ps1", + "build:deps": "corepack pnpm --filter @traderalice/opentypebb build && corepack pnpm --filter @traderalice/ibkr build", + "build:ui": "corepack pnpm --filter open-alice-ui build", + "build:server": "tsup src/main.ts --format esm --dts", + "predev": "corepack pnpm build:deps", + "build": "corepack pnpm build:deps && corepack pnpm build:ui && corepack pnpm build:server", "start": "node dist/main.js", "test": "vitest run", "test:e2e": "vitest run --config vitest.e2e.config.ts", @@ -36,8 +40,8 @@ "@alpacahq/alpaca-trade-api": "^3.1.3", "@anthropic-ai/claude-agent-sdk": "^0.2.72", "@grammyjs/auto-retry": "^2.0.2", - "@hono/node-server": "^1.19.11", - "@modelcontextprotocol/sdk": "^1.27.1", + "@hono/node-server": "^1.19.14", + "@modelcontextprotocol/sdk": "^1.29.0", "@sinclair/typebox": "0.34.48", "@traderalice/ibkr": "workspace:*", "@traderalice/opentypebb": "workspace:*", @@ -49,7 +53,7 @@ "express": "^5.2.1", "file-type": "^21.3.2", "grammy": "^1.40.0", - "hono": "^4.12.7", + "hono": "^4.12.14", "json5": "^2.2.3", "openai": "^6.33.0", "pino": "^10.3.1", @@ -62,8 +66,12 @@ }, "pnpm": { "overrides": { - "@alpacahq/alpaca-trade-api>axios": "^0.30.3", - "@alpacahq/alpaca-trade-api>eslint": "-" + "@alpacahq/alpaca-trade-api>axios": "^1.15.0", + "@alpacahq/alpaca-trade-api>eslint": "-", + "follow-redirects": "^1.16.0", + "@alpacahq/alpaca-trade-api>lodash": "^4.18.1", + "@modelcontextprotocol/sdk>express-rate-limit": "^8.3.2", + "path-to-regexp": "^8.4.2" } }, "devDependencies": { diff --git a/packages/ibkr/package.json b/packages/ibkr/package.json index 346ba031..3bec4182 100644 --- a/packages/ibkr/package.json +++ b/packages/ibkr/package.json @@ -18,7 +18,7 @@ "directory": "packages/ibkr" }, "scripts": { - "build": "rm -rf dist && tsc", + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc", "test": "vitest run --config vitest.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:all": "vitest run --config vitest.config.ts && vitest run --config vitest.e2e.config.ts", diff --git a/packages/opentypebb/package.json b/packages/opentypebb/package.json index e9fac0da..3dc2840d 100644 --- a/packages/opentypebb/package.json +++ b/packages/opentypebb/package.json @@ -42,8 +42,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@hono/node-server": "^1.13.8", - "hono": "^4.12.7", + "@hono/node-server": "^1.19.14", + "hono": "^4.12.14", "undici": "^7.24.4", "yahoo-finance2": "^3.13.1", "zod": "^3.24.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d061825..37eae5a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,12 @@ settings: excludeLinksFromLockfile: false overrides: - '@alpacahq/alpaca-trade-api>axios': ^0.30.3 + '@alpacahq/alpaca-trade-api>axios': ^1.15.0 '@alpacahq/alpaca-trade-api>eslint': '-' + follow-redirects: ^1.16.0 + '@alpacahq/alpaca-trade-api>lodash': ^4.18.1 + '@modelcontextprotocol/sdk>express-rate-limit': ^8.3.2 + path-to-regexp: ^8.4.2 importers: @@ -31,11 +35,11 @@ importers: specifier: ^2.0.2 version: 2.0.2(grammy@1.40.0) '@hono/node-server': - specifier: ^1.19.11 - version: 1.19.11(hono@4.12.7) + specifier: ^1.19.14 + version: 1.19.14(hono@4.12.14) '@modelcontextprotocol/sdk': - specifier: ^1.27.1 - version: 1.27.1(zod@4.3.6) + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 @@ -70,8 +74,8 @@ importers: specifier: ^1.40.0 version: 1.40.0 hono: - specifier: ^4.12.7 - version: 4.12.7 + specifier: ^4.12.14 + version: 4.12.14 json5: specifier: ^2.2.3 version: 2.2.3 @@ -177,11 +181,11 @@ importers: packages/opentypebb: dependencies: '@hono/node-server': - specifier: ^1.13.8 - version: 1.19.11(hono@4.12.7) + specifier: ^1.19.14 + version: 1.19.14(hono@4.12.14) hono: - specifier: ^4.12.7 - version: 4.12.7 + specifier: ^4.12.14 + version: 4.12.14 undici: specifier: ^7.24.4 version: 7.24.4 @@ -790,8 +794,8 @@ packages: '@grammyjs/types@3.24.0': resolution: {integrity: sha512-qQIEs4lN5WqUdr4aT8MeU6UFpMbGYAvcvYSW1A4OO1PABGJQHz/KLON6qvpf+5RxaNDQBxiY2k2otIhg/AG7RQ==} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -965,8 +969,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1565,8 +1569,8 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - axios@0.30.3: - resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==} + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1900,8 +1904,8 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.3.2: + resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -1954,8 +1958,8 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2029,8 +2033,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.7: - resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -2068,8 +2072,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2237,8 +2241,8 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -2402,8 +2406,8 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2482,8 +2486,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -3227,11 +3232,11 @@ snapshots: '@alpacahq/alpaca-trade-api@3.1.3': dependencies: - axios: 0.30.3 + axios: 1.15.0 dotenv: 6.2.0 events: 3.3.0 just-extend: 4.2.1 - lodash: 4.17.23 + lodash: 4.18.1 minimist: 1.2.8 msgpack5: 5.3.2 nats: 1.4.12 @@ -3601,9 +3606,9 @@ snapshots: '@grammyjs/types@3.24.0': {} - '@hono/node-server@1.19.11(hono@4.12.7)': + '@hono/node-server@1.19.14(hono@4.12.14)': dependencies: - hono: 4.12.7 + hono: 4.12.14 '@img/colour@1.0.0': {} @@ -3720,9 +3725,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.7) + '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -3731,8 +3736,8 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.12.7 + express-rate-limit: 8.3.2(express@5.2.1) + hono: 4.12.14 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4263,11 +4268,11 @@ snapshots: atomic-sleep@1.0.0: {} - axios@0.30.3: + axios@1.15.0: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.16.0 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -4601,10 +4606,10 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.3.2(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.1.0 express@5.2.1: dependencies: @@ -4695,7 +4700,7 @@ snapshots: mlly: 1.8.0 rollup: 4.57.1 - follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} form-data@4.0.5: dependencies: @@ -4766,7 +4771,7 @@ snapshots: highlight.js@11.11.1: {} - hono@4.12.7: {} + hono@4.12.14: {} html-encoding-sniffer@6.0.0: dependencies: @@ -4802,7 +4807,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.0.1: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -4932,7 +4937,7 @@ snapshots: load-tsconfig@0.2.5: {} - lodash@4.17.23: {} + lodash@4.18.1: {} long@5.3.2: {} @@ -5058,7 +5063,7 @@ snapshots: path-key@3.1.1: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -5142,7 +5147,7 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} psl@1.15.0: dependencies: @@ -5284,7 +5289,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color diff --git a/scripts/start-openalice.cmd b/scripts/start-openalice.cmd new file mode 100644 index 00000000..1e80f7a2 --- /dev/null +++ b/scripts/start-openalice.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "%~dp0start-openalice.ps1" %* diff --git a/scripts/start-openalice.ps1 b/scripts/start-openalice.ps1 new file mode 100644 index 00000000..68fd899a --- /dev/null +++ b/scripts/start-openalice.ps1 @@ -0,0 +1,44 @@ +param( + [switch]$NoBrowser +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +function Assert-Command($name) { + if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { + throw "Required command not found: $name" + } +} + +function Assert-PortAvailable($port) { + $existing = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($existing) { + throw "Port $port is already in use by PID $($existing.OwningProcess). Stop the running service first." + } +} + +Assert-Command "codex" +Assert-Command "corepack" + +$loginStatus = & cmd /c "codex login status 2>&1" +if ($LASTEXITCODE -ne 0 -or ($loginStatus -join "`n") -notmatch "Logged in") { + Write-Host "Codex is not logged in yet. Run `codex login` first." -ForegroundColor Yellow + exit 1 +} + +Assert-PortAvailable 3001 +Assert-PortAvailable 3002 +Assert-PortAvailable 6901 + +if (-not $NoBrowser) { + Start-Job -ScriptBlock { + Start-Sleep -Seconds 6 + Start-Process "http://localhost:3002" + } | Out-Null +} + +Write-Host "Starting Open Alice with Codex CLI..." -ForegroundColor Cyan +& corepack pnpm dev diff --git a/src/ai-providers/codex/__test__/codex.e2e.spec.ts b/src/ai-providers/codex/__test__/codex.e2e.spec.ts deleted file mode 100644 index 7caecb64..00000000 --- a/src/ai-providers/codex/__test__/codex.e2e.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Codex provider E2E tests — verifies real API communication. - * - * Requires ~/.codex/auth.json (run `codex login` first). - * Skips gracefully if auth is not configured. - * - * Run: pnpm test:e2e - */ - -import { describe, it, expect, beforeAll, beforeEach } from 'vitest' -import OpenAI from 'openai' -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { homedir } from 'node:os' - -// ==================== Setup ==================== - -const OAUTH_BASE_URL = 'https://chatgpt.com/backend-api/codex' -const MODEL = 'gpt-5.4-mini' // Use mini for faster/cheaper e2e - -let client: OpenAI | null = null - -async function tryLoadToken(): Promise { - const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex') - try { - const raw = JSON.parse(await readFile(join(codexHome, 'auth.json'), 'utf-8')) - return raw?.tokens?.access_token ?? null - } catch { - return null - } -} - -beforeAll(async () => { - const token = await tryLoadToken() - if (!token) { - console.warn('codex e2e: ~/.codex/auth.json not found, skipping tests') - return - } - client = new OpenAI({ apiKey: token, baseURL: OAUTH_BASE_URL }) - console.log('codex e2e: client initialized') -}, 15_000) - -// ==================== Tests ==================== - -describe('Codex API — basic communication', () => { - beforeEach(({ skip }) => { if (!client) skip('no codex auth') }) - - it('receives a text response for a simple prompt', async () => { - const stream = client!.responses.stream({ - model: MODEL, - instructions: 'You are a helpful assistant. Be very brief.', - input: [{ role: 'user', content: 'What is 2+2? Answer with just the number.' }], - store: false, - }) - - let text = '' - for await (const event of stream) { - if (event.type === 'response.output_text.delta') text += event.delta - } - - expect(text).toBeTruthy() - expect(text).toContain('4') - }, 30_000) -}) - -describe('Codex API — tool call round-trip', () => { - beforeEach(({ skip }) => { if (!client) skip('no codex auth') }) - - const tools: OpenAI.Responses.Tool[] = [{ - type: 'function', - name: 'get_price', - description: 'Get the current price of a stock by symbol', - parameters: { - type: 'object', - properties: { symbol: { type: 'string', description: 'Stock ticker symbol' } }, - required: ['symbol'], - }, - strict: null, - }] - - it('receives a function call with call_id, name, and arguments', async () => { - const stream = client!.responses.stream({ - model: MODEL, - instructions: 'You are a stock assistant. Always use the get_price tool when asked about prices.', - input: [{ role: 'user', content: 'What is the price of AAPL?' }], - tools, - store: false, - }) - - let funcCall: { call_id: string; name: string; arguments: string } | null = null - for await (const event of stream) { - if (event.type === 'response.output_item.done') { - const item = (event as any).item - if (item?.type === 'function_call') { - funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments } - } - } - } - - expect(funcCall).not.toBeNull() - expect(funcCall!.call_id).toBeTruthy() - expect(funcCall!.name).toBe('get_price') - const args = JSON.parse(funcCall!.arguments) - expect(args.symbol).toMatch(/AAPL/i) - }, 30_000) - - it('completes a full tool call round-trip', async () => { - // Round 1: get function call - const stream1 = client!.responses.stream({ - model: MODEL, - instructions: 'You are a stock assistant. Always use the get_price tool.', - input: [{ role: 'user', content: 'Price of MSFT?' }], - tools, - store: false, - }) - - let funcCall: { call_id: string; name: string; arguments: string } | null = null - for await (const event of stream1) { - if (event.type === 'response.output_item.done') { - const item = (event as any).item - if (item?.type === 'function_call') { - funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments } - } - } - } - - expect(funcCall).not.toBeNull() - - // Round 2: send tool result back, get final text - const stream2 = client!.responses.stream({ - model: MODEL, - instructions: 'You are a stock assistant.', - input: [ - { role: 'user', content: 'Price of MSFT?' }, - { type: 'function_call', call_id: funcCall!.call_id, name: funcCall!.name, arguments: funcCall!.arguments } as any, - { type: 'function_call_output', call_id: funcCall!.call_id, output: '{"price": 420.50, "currency": "USD"}' } as any, - ], - tools, - store: false, - }) - - let responseText = '' - for await (const event of stream2) { - if (event.type === 'response.output_text.delta') responseText += event.delta - } - - expect(responseText).toBeTruthy() - expect(responseText).toMatch(/420/i) - }, 30_000) -}) - -describe('Codex API — structured multi-turn input', () => { - beforeEach(({ skip }) => { if (!client) skip('no codex auth') }) - - it('references earlier conversation context', async () => { - const stream = client!.responses.stream({ - model: MODEL, - instructions: 'You are a helpful assistant. Be very brief.', - input: [ - { role: 'user', content: 'My name is Alice.' }, - { role: 'assistant', content: 'Nice to meet you, Alice!' }, - { role: 'user', content: 'What is my name?' }, - ], - store: false, - }) - - let text = '' - for await (const event of stream) { - if (event.type === 'response.output_text.delta') text += event.delta - } - - expect(text).toBeTruthy() - expect(text.toLowerCase()).toContain('alice') - }, 30_000) -}) diff --git a/src/ai-providers/codex/auth.spec.ts b/src/ai-providers/codex/auth.spec.ts new file mode 100644 index 00000000..7f7d9011 --- /dev/null +++ b/src/ai-providers/codex/auth.spec.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { getApiKeyFromAuthFile, getAccessToken, clearTokenCache } from './auth.js' + +const ORIGINAL_CODEX_HOME = process.env.CODEX_HOME + +async function withAuthFile(payload: object): Promise { + const dir = await mkdtemp(join(tmpdir(), 'codex-auth-')) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'auth.json'), JSON.stringify(payload, null, 2)) + process.env.CODEX_HOME = dir + clearTokenCache() + return dir +} + +afterEach(async () => { + clearTokenCache() + const dir = process.env.CODEX_HOME + if (dir && dir !== ORIGINAL_CODEX_HOME && dir.includes('codex-auth-')) { + await rm(dir, { recursive: true, force: true }) + } + if (ORIGINAL_CODEX_HOME === undefined) delete process.env.CODEX_HOME + else process.env.CODEX_HOME = ORIGINAL_CODEX_HOME +}) + +describe('codex auth helpers', () => { + it('reads API key mode credentials from auth.json', async () => { + await withAuthFile({ + auth_mode: 'apikey', + OPENAI_API_KEY: 'sk-test-auth-file', + }) + + await expect(getApiKeyFromAuthFile()).resolves.toBe('sk-test-auth-file') + }) + + it('returns API key mode credentials from getAccessToken', async () => { + await withAuthFile({ + auth_mode: 'apikey', + OPENAI_API_KEY: 'sk-test-token', + }) + + await expect(getAccessToken()).resolves.toBe('sk-test-token') + }) +}) diff --git a/src/ai-providers/codex/auth.ts b/src/ai-providers/codex/auth.ts index 975c3a79..9e5594df 100644 --- a/src/ai-providers/codex/auth.ts +++ b/src/ai-providers/codex/auth.ts @@ -1,9 +1,9 @@ /** - * Codex OAuth authentication — reads ~/.codex/auth.json and manages token refresh. + * Codex authentication — reads ~/.codex/auth.json and manages token refresh. * * Users authenticate via `codex login` (OpenAI Codex CLI). This module reads - * the cached OAuth tokens and refreshes them when expired, writing updates back - * to disk so the Codex CLI stays in sync. + * either the cached OAuth tokens or API key mode credentials, writing refreshed + * OAuth tokens back to disk so the Codex CLI stays in sync. */ import { readFile, writeFile, mkdir } from 'node:fs/promises' @@ -34,6 +34,14 @@ export interface CodexAuthFile { last_refresh?: string } +/** Read API key mode credentials from auth.json when Codex CLI is configured with an API key. */ +export async function getApiKeyFromAuthFile(): Promise { + const auth = await readAuthFile() + if (auth.auth_mode !== 'apikey') return null + const apiKey = auth.OPENAI_API_KEY?.trim() + return apiKey ? apiKey : null +} + // ==================== Helpers ==================== /** Resolve the Codex home directory ($CODEX_HOME or ~/.codex). */ @@ -161,6 +169,18 @@ export async function getAccessToken(): Promise { try { const auth = await readAuthFile() + if (auth.auth_mode === 'apikey') { + const apiKey = auth.OPENAI_API_KEY?.trim() + if (!apiKey) { + throw new Error('Codex auth.json is in API key mode but has no OPENAI_API_KEY set.') + } + cachedToken = { + token: apiKey, + expiresAt: Date.now() / 1000 + 3600, + } + return cachedToken.token + } + if (!auth.tokens?.access_token) { throw new Error('Codex auth.json has no tokens. Run `codex login` to authenticate.') } diff --git a/src/ai-providers/codex/codex-provider.spec.ts b/src/ai-providers/codex/codex-provider.spec.ts new file mode 100644 index 00000000..5c060922 --- /dev/null +++ b/src/ai-providers/codex/codex-provider.spec.ts @@ -0,0 +1,103 @@ +import { PassThrough } from 'node:stream' +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})) + +vi.mock('../../core/config.js', async () => { + const actual = await vi.importActual('../../core/config.js') + return { + ...actual, + readConnectorsConfig: vi.fn().mockResolvedValue({ mcp: { port: 4101 } }), + } +}) + +import { spawn } from 'node:child_process' +import { CodexProvider } from './codex-provider.js' + +const spawnMock = vi.mocked(spawn) + +function makeChild(lines: string[], opts?: { exitCode?: number; stderr?: string }) { + const stdout = new PassThrough() + const stderr = new PassThrough() + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough + stderr: PassThrough + } + + child.stdout = stdout + child.stderr = stderr + + queueMicrotask(() => { + for (const line of lines) { + stdout.write(line + '\n') + } + stdout.end() + + if (opts?.stderr) stderr.write(opts.stderr) + stderr.end() + + child.emit('close', opts?.exitCode ?? 0) + }) + + return child as any +} + +describe('CodexProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('asks through codex exec and returns the completed agent message', async () => { + spawnMock.mockImplementation(() => makeChild([ + JSON.stringify({ type: 'thread.started', thread_id: 'thread_1' }), + JSON.stringify({ type: 'turn.started' }), + JSON.stringify({ type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'hello from codex' } }), + JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }), + ])) + + const provider = new CodexProvider(async () => ({}), async () => 'You are Alice.') + const result = await provider.ask('Say hello.', { + backend: 'codex', + model: 'gpt-5.4', + baseUrl: 'https://example.test/v1', + loginMethod: 'api-key', + apiKey: 'sk-test', + }) + + expect(result.text).toBe('hello from codex') + + const [, args, options] = spawnMock.mock.calls[0] + expect(args).toContain('--model') + expect(args).toContain('gpt-5.4') + expect(args).toContain('-c') + expect(args).toContain('mcp_servers.openalice.url="http://127.0.0.1:4101/mcp"') + expect(args).toContain('openai_base_url="https://example.test/v1"') + expect(options?.env?.OPENAI_API_KEY).toBe('sk-test') + expect(options?.env?.OPENAI_BASE_URL).toBeUndefined() + }) + + it('streams delta events and finishes with a done event', async () => { + spawnMock.mockImplementation(() => makeChild([ + JSON.stringify({ type: 'turn.started' }), + JSON.stringify({ type: 'item.agent_message.delta', delta: 'Hel' }), + JSON.stringify({ type: 'item/agentMessage/delta', delta: 'lo' }), + JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }), + ])) + + const provider = new CodexProvider(async () => ({}), async () => 'You are Alice.') + const events = [] + + for await (const event of provider.generate([], 'Say hello.')) { + events.push(event) + } + + expect(events).toEqual([ + { type: 'text', text: 'Hel' }, + { type: 'text', text: 'lo' }, + { type: 'done', result: { text: 'Hello', media: [] } }, + ]) + }) +}) diff --git a/src/ai-providers/codex/codex-provider.ts b/src/ai-providers/codex/codex-provider.ts index 9d876eb9..c1d10e65 100644 --- a/src/ai-providers/codex/codex-provider.ts +++ b/src/ai-providers/codex/codex-provider.ts @@ -1,92 +1,236 @@ /** - * CodexProvider — AIProvider backed by OpenAI Codex models via ChatGPT subscription OAuth. + * CodexProvider — AIProvider backed by the local `codex exec` CLI. * - * Calls the Responses API at chatgpt.com/backend-api/codex/responses using - * the standard OpenAI TypeScript SDK. Auth tokens are read from ~/.codex/auth.json - * (created by `codex login`). - * - * Context is managed by us — each call starts fresh (no previous_response_id). - * Tools are injected via the Responses API `tools` field. + * This provider does not call the OpenAI HTTP APIs directly. Instead, it + * shells out to Codex CLI, points it at Open Alice's MCP server, and streams + * Codex JSONL events back into the engine's ProviderEvent pipeline. */ -import OpenAI from 'openai' +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { Tool } from 'ai' import { pino } from 'pino' import type { ProviderResult, ProviderEvent, AIProvider, GenerateOpts } from '../types.js' import type { SessionEntry } from '../../core/session.js' import type { ResolvedProfile } from '../../core/config.js' -import { toResponsesInput } from '../../core/session.js' -import { readAgentConfig } from '../../core/config.js' -import { getAccessToken, clearTokenCache } from './auth.js' -import { convertTools } from './tool-bridge.js' +import { readConnectorsConfig } from '../../core/config.js' +import { toTextHistory } from '../../core/session.js' +import { createChannel } from '../../core/async-channel.js' +import { buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../utils.js' const logger = pino({ transport: { target: 'pino/file', options: { destination: 'logs/codex.log', mkdir: true } }, }) -const DEFAULT_OAUTH_BASE_URL = 'https://chatgpt.com/backend-api/codex' -const DEFAULT_API_BASE_URL = 'https://api.openai.com/v1' const DEFAULT_MODEL = 'gpt-5.4' +const DEFAULT_SANDBOX = 'read-only' +const DEFAULT_MCP_PORT = 3001 + +interface CodexExecInvocation { + args: string[] + cleanup: () => Promise + env: NodeJS.ProcessEnv + outputFile: string +} -// ==================== Provider ==================== +interface CodexRunResult { + result: ProviderResult + stderr: string +} -export class CodexProvider implements AIProvider { - readonly providerTag = 'codex' as const +interface CodexEventState { + accumulatedText: string +} - constructor( - private getTools: () => Promise>, - private getSystemPrompt: () => Promise, - ) {} +function quoteTomlString(value: string): string { + return JSON.stringify(value) +} + +function normalizeWhitespace(text: string): string { + return text.replace(/\r\n/g, '\n') +} + +function buildSystemPrompt(instructions: string): string { + const trimmed = instructions.trim() + if (!trimmed) return '' + return [ + '', + trimmed, + '', + '', + ].join('\n') +} + +function buildRuntimePrompt(disabledTools?: string[]): string { + const lines = [ + '', + 'You are running inside Open Alice through Codex CLI.', + 'Use the connected `openalice` MCP tools when they help.', + ] + if (disabledTools && disabledTools.length > 0) { + lines.push(`The following tools are disabled for this request: ${disabledTools.join(', ')}.`) + lines.push('Do not call disabled tools.') + } + lines.push('', '') + return lines.join('\n') +} + +export function buildCodexPrompt(opts: { + instructions: string + prompt: string + history: Array<{ role: 'user' | 'assistant'; text: string }> + historyPreamble?: string + disabledTools?: string[] +}): string { + const promptWithHistory = buildChatHistoryPrompt(opts.prompt, opts.history, opts.historyPreamble) + return [ + buildSystemPrompt(opts.instructions), + buildRuntimePrompt(opts.disabledTools), + '', + promptWithHistory, + '', + ].join('\n') +} + +export async function buildCodexExecInvocation( + prompt: string, + profile?: ResolvedProfile, +): Promise { + const connectors = await readConnectorsConfig().catch(() => ({ mcp: { port: DEFAULT_MCP_PORT } })) + const mcpPort = connectors.mcp?.port ?? DEFAULT_MCP_PORT + const mcpUrl = `http://127.0.0.1:${mcpPort}/mcp` + + const tempDir = await mkdtemp(join(tmpdir(), 'openalice-codex-')) + const outputFile = join(tempDir, 'last-message.txt') + + const args = [ + 'exec', + prompt, + '--json', + '--ephemeral', + '--skip-git-repo-check', + '--sandbox', + DEFAULT_SANDBOX, + '--output-last-message', + outputFile, + '-C', + process.cwd(), + '-c', + `mcp_servers.openalice.url=${quoteTomlString(mcpUrl)}`, + ] + + if (profile?.model) { + args.push('--model', profile.model) + } else { + args.push('--model', DEFAULT_MODEL) + } + + if (profile?.baseUrl) { + args.push('-c', `openai_base_url=${quoteTomlString(profile.baseUrl)}`) + } + + const env = { ...process.env } + delete env.OPENAI_BASE_URL + + if (profile?.loginMethod === 'api-key' && profile.apiKey) { + env.OPENAI_API_KEY = profile.apiKey + } - /** - * Create an OpenAI client from a resolved profile. - * - * - loginMethod 'codex-oauth' (default): reads ~/.codex/auth.json, hits - * ChatGPT subscription endpoint. Usage billed to ChatGPT plan. - * - loginMethod 'api-key': uses profile apiKey. - * Standard OpenAI API billing, or compatible third-party endpoint. - */ - private async createClient(profile?: ResolvedProfile): Promise<{ client: OpenAI; model: string }> { - const model = profile?.model ?? DEFAULT_MODEL - const loginMethod = profile?.loginMethod ?? 'codex-oauth' - - if (loginMethod === 'api-key') { - const apiKey = profile?.apiKey - if (!apiKey) throw new Error('Codex api-key mode requires an API key. Configure it in your profile.') - const baseURL = profile?.baseUrl ?? DEFAULT_API_BASE_URL - return { client: new OpenAI({ apiKey, baseURL }), model } + return { + args, + outputFile, + env, + cleanup: async () => { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + }, + } +} + +function maybeEmitCompletedAgentText( + item: Record, + state: CodexEventState, + onEvent?: (event: ProviderEvent) => void, +) { + if (item.type !== 'agent_message' || typeof item.text !== 'string') return + + const fullText = normalizeWhitespace(item.text) + if (fullText.startsWith(state.accumulatedText)) { + const delta = fullText.slice(state.accumulatedText.length) + if (delta) { + state.accumulatedText = fullText + onEvent?.({ type: 'text', text: delta }) } + return + } - // OAuth mode - const token = await getAccessToken() - const baseURL = profile?.baseUrl ?? DEFAULT_OAUTH_BASE_URL - return { client: new OpenAI({ apiKey: token, baseURL }), model } + if (fullText !== state.accumulatedText) { + state.accumulatedText = fullText + onEvent?.({ type: 'text', text: fullText }) } +} - async ask(prompt: string, profile?: ResolvedProfile): Promise { - const { client, model } = await this.createClient(profile) - const instructions = await this.getSystemPrompt() +function handleCodexJsonLine( + line: string, + state: CodexEventState, + onEvent?: (event: ProviderEvent) => void, +) { + if (!line.trim()) return + + let parsed: Record + try { + parsed = JSON.parse(line) as Record + } catch (err) { + logger.warn({ line, err }, 'codex_json_parse_failed') + return + } - try { - // Use streaming — the ChatGPT subscription endpoint may not support non-streaming - const stream = client.responses.stream({ - model, - instructions, - input: [{ role: 'user' as const, content: prompt }], - store: false, - }) + const type = parsed.type + if (typeof type !== 'string') return - let text = '' - for await (const event of stream) { - if (event.type === 'response.output_text.delta') text += event.delta - } + if (type === 'item.agent_message.delta' || type === 'item/agentMessage/delta') { + const delta = parsed.delta + if (typeof delta === 'string' && delta.length > 0) { + state.accumulatedText += normalizeWhitespace(delta) + onEvent?.({ type: 'text', text: delta }) + } + return + } + + if (type === 'item.completed') { + const item = parsed.item + if (!item || typeof item !== 'object') return - return { text: text || '(no output)', media: [] } - } catch (err) { - logger.error({ err }, 'ask_error') - throw err + const completedItem = item as Record + if (completedItem.type === 'error') { + logger.warn({ item: completedItem }, 'codex_item_error') + return } + + maybeEmitCompletedAgentText(completedItem, state, onEvent) + } +} + +export class CodexProvider implements AIProvider { + readonly providerTag = 'codex' as const + + constructor( + private _getTools: () => Promise>, + private getSystemPrompt: () => Promise, + ) {} + + async ask(prompt: string, profile?: ResolvedProfile): Promise { + const instructions = await this.getSystemPrompt() + const fullPrompt = buildCodexPrompt({ + instructions, + prompt, + history: [], + }) + + const { result } = await this.runCodex(fullPrompt, profile) + return result } async *generate( @@ -94,178 +238,124 @@ export class CodexProvider implements AIProvider { prompt: string, opts?: GenerateOpts, ): AsyncGenerator { - const { client, model } = await this.createClient(opts?.profile) + const maxHistory = opts?.maxHistoryEntries ?? DEFAULT_MAX_HISTORY + const history = toTextHistory(entries).slice(-maxHistory) const instructions = opts?.systemPrompt ?? await this.getSystemPrompt() - const agentConfig = await readAgentConfig() - const maxSteps = agentConfig.maxSteps - - // Build tools - const allTools = await this.getTools() - const tools = convertTools(allTools, opts?.disabledTools) - - // Build structured input from session history + current prompt - const history = toResponsesInput(entries) - const input: OpenAI.Responses.ResponseInputItem[] = [ - ...history as OpenAI.Responses.ResponseInputItem[], - { role: 'user', content: prompt }, - ] - - yield* this.toolLoop(client, model, instructions, input, tools, allTools, maxSteps) + const fullPrompt = buildCodexPrompt({ + instructions, + prompt, + history, + historyPreamble: opts?.historyPreamble, + disabledTools: opts?.disabledTools, + }) + + const channel = createChannel() + const resultPromise = this.runCodex(fullPrompt, opts?.profile, (event) => channel.push(event)) + + resultPromise.then(({ result }) => { + channel.push({ type: 'done', result }) + channel.close() + }).catch((err) => { + channel.error(err instanceof Error ? err : new Error(String(err))) + }) + + yield* channel } - /** - * The manual tool loop — sends requests to the Responses API and executes - * function calls until the model responds with text only or we hit maxSteps. - */ - private async *toolLoop( - client: OpenAI, - model: string, - instructions: string, - input: OpenAI.Responses.ResponseInputItem[], - tools: ReturnType, - vercelTools: Record, - maxSteps: number, - ): AsyncGenerator { - let accumulatedText = '' - - for (let step = 0; step < maxSteps; step++) { - const functionCalls: Array<{ - call_id: string - name: string - arguments: string - }> = [] - let stepText = '' - - try { - const stream = client.responses.stream({ - model, - instructions, - input, - tools: tools.length > 0 ? tools : undefined, - store: false, // Required by ChatGPT subscription endpoint - }) - - for await (const event of stream) { - if (event.type === 'response.output_text.delta') { - yield { type: 'text', text: event.delta } - stepText += event.delta - } else if (event.type === 'response.output_item.done') { - // function_call_arguments.done lacks call_id and name; - // output_item.done carries the complete function call object. - const item = (event as any).item - if (item?.type === 'function_call') { - functionCalls.push({ - call_id: item.call_id, - name: item.name, - arguments: item.arguments, - }) - } + private async runCodex( + prompt: string, + profile?: ResolvedProfile, + onEvent?: (event: ProviderEvent) => void, + ): Promise { + const invocation = await buildCodexExecInvocation(prompt, profile) + const child = spawn('codex', invocation.args, { + cwd: process.cwd(), + env: invocation.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + const state: CodexEventState = { accumulatedText: '' } + let stderr = '' + + const stdoutDone = new Promise((resolve, reject) => { + let buffer = '' + let finished = false + + const finish = () => { + if (finished) return + finished = true + if (buffer.trim()) { + try { + handleCodexJsonLine(buffer, state, onEvent) + } catch (err) { + reject(err) + return } } - } catch (err: any) { - // On 401, clear token cache and surface auth error - if (err?.status === 401) { - clearTokenCache() - const errorText = accumulatedText + stepText + - '\n\n[Codex auth expired. Run `codex login` to re-authenticate.]' - yield { type: 'done', result: { text: errorText, media: [] } } - return - } - // Extract all available error detail from OpenAI SDK error - const errDetail: Record = { - message: err?.message, - status: err?.status, - type: err?.type, - code: err?.code, - param: err?.param, - } - // The SDK stores the parsed error body in err.error - if (err?.error) errDetail.errorBody = err.error - // Raw response headers can help debug - if (err?.headers) { - const h: Record = {} - try { for (const [k, v] of Object.entries(err.headers)) h[k] = String(v) } catch {} - if (Object.keys(h).length > 0) errDetail.headers = h - } - errDetail.model = model - errDetail.inputItems = input.length - errDetail.toolCount = tools.length - logger.error(errDetail, 'responses_api_error') - const errorText = accumulatedText + stepText + - `\n\n[Codex API error: ${err?.message ?? 'unknown error'}]` - yield { type: 'done', result: { text: errorText, media: [] } } - return - } - - accumulatedText += stepText - - // No function calls — model is done - if (functionCalls.length === 0) { - yield { type: 'done', result: { text: accumulatedText, media: [] } } - return + resolve() } - // Execute function calls and build follow-up input - const toolResults: Array<{ call_id: string; output: string }> = [] - - for (const fc of functionCalls) { - let parsedInput: unknown - try { - parsedInput = JSON.parse(fc.arguments) - } catch { - parsedInput = {} - } + child.stdout.on('data', (chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8') + buffer += text + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() ?? '' - // Yield tool_use event - yield { type: 'tool_use', id: fc.call_id, name: fc.name, input: parsedInput } - logger.info({ tool: fc.name, call_id: fc.call_id }, 'tool_use') - - // Execute the tool - const tool = vercelTools[fc.name] - let resultContent: string - if (!tool?.execute) { - resultContent = JSON.stringify({ error: `Unknown tool: ${fc.name}` }) - } else { + for (const line of lines) { + if (!line.trim()) continue try { - const result = await tool.execute(parsedInput, { - toolCallId: fc.call_id, - messages: [], - }) - resultContent = typeof result === 'string' ? result : JSON.stringify(result ?? '') + handleCodexJsonLine(line, state, onEvent) } catch (err) { - resultContent = JSON.stringify({ error: `Tool execution failed: ${err}` }) + reject(err) + return } } + }) + child.stdout.once('end', finish) + child.stdout.once('close', finish) + child.stdout.once('error', reject) + }) + + const stderrDone = new Promise((resolve) => { + const finish = () => resolve() + child.stderr.on('data', (chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8') + stderr += text + }) + child.stderr.once('end', finish) + child.stderr.once('close', finish) + child.stderr.once('error', finish) + }) - // Yield tool_result event - yield { type: 'tool_result', tool_use_id: fc.call_id, content: resultContent } - logger.info({ tool: fc.name, call_id: fc.call_id, content: resultContent.slice(0, 300) }, 'tool_result') + const exitCode = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => resolve(code ?? 1)) + }) - toolResults.push({ call_id: fc.call_id, output: resultContent }) - } + await Promise.all([stdoutDone, stderrDone]) - // Append function calls + outputs to input for next round - for (const fc of functionCalls) { - input.push({ - type: 'function_call', - call_id: fc.call_id, - name: fc.name, - arguments: fc.arguments, - } as OpenAI.Responses.ResponseInputItem) - } - for (const tr of toolResults) { - input.push({ - type: 'function_call_output', - call_id: tr.call_id, - output: tr.output, - } as OpenAI.Responses.ResponseInputItem) - } + if (stderr.trim()) { + logger.info({ stderr }, 'codex_cli_stderr') } - // Max steps reached - yield { - type: 'done', - result: { text: accumulatedText + '\n\n[Max tool iterations reached]', media: [] }, + const outputFileText = await readFile(invocation.outputFile, 'utf8').catch(() => '') + const finalText = outputFileText.trim() || state.accumulatedText.trim() || '(no output)' + + try { + if (exitCode !== 0) { + throw new Error( + stderr.trim() + ? `codex exec failed (exit ${exitCode}): ${stderr.trim()}` + : `codex exec failed with exit code ${exitCode}`, + ) + } + + return { + result: { text: finalText, media: [] }, + stderr, + } + } finally { + await invocation.cleanup() } } } diff --git a/src/ai-providers/codex/tool-bridge.ts b/src/ai-providers/codex/tool-bridge.ts deleted file mode 100644 index e1a727da..00000000 --- a/src/ai-providers/codex/tool-bridge.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Tool bridge — converts ToolCenter's Vercel AI SDK tools to OpenAI Responses API format. - * - * Much simpler than the Agent SDK bridge (no MCP server needed) — just JSON Schema objects. - */ - -import { z } from 'zod' -import type { Tool } from 'ai' - -// ==================== Types ==================== - -export interface ResponsesApiTool { - type: 'function' - name: string - description: string - parameters: Record - strict: boolean | null -} - -// ==================== Conversion ==================== - -/** - * Convert Vercel AI SDK tools to OpenAI Responses API tool definitions. - * - * @param tools Record from ToolCenter.getVercelTools() - * @param disabledTools Optional list of tool names to exclude - */ -export function convertTools( - tools: Record, - disabledTools?: string[], -): ResponsesApiTool[] { - const disabledSet = new Set(disabledTools ?? []) - - return Object.entries(tools) - .filter(([name, t]) => t.execute && !disabledSet.has(name)) - .map(([name, t]) => { - let parameters: Record - try { - parameters = z.toJSONSchema(t.inputSchema as z.ZodType) - } catch { - parameters = { type: 'object', properties: {} } - } - return { - type: 'function' as const, - name, - description: t.description ?? name, - parameters, - strict: null, - } - }) -} diff --git a/src/ai-providers/preset-catalog.ts b/src/ai-providers/preset-catalog.ts index d4e6cd7a..9cee527c 100644 --- a/src/ai-providers/preset-catalog.ts +++ b/src/ai-providers/preset-catalog.ts @@ -76,11 +76,11 @@ export const CLAUDE_API: PresetDef = { export const CODEX_OAUTH: PresetDef = { id: 'codex-oauth', - label: 'OpenAI Codex (Subscription)', - description: 'Use your ChatGPT subscription', + label: 'Codex CLI (Subscription)', + description: 'Use your local Codex CLI login and ChatGPT subscription', category: 'official', - defaultName: 'OpenAI Codex (Subscription)', - hint: 'Requires Codex CLI login. Run `codex login` in your terminal first.', + defaultName: 'Codex CLI (Subscription)', + hint: 'Requires Codex CLI login. Run `codex login` in your terminal first. Open Alice will call local `codex exec` and attach Alice tools over MCP.', zodSchema: z.object({ backend: z.literal('codex'), loginMethod: z.literal('codex-oauth'), @@ -94,10 +94,10 @@ export const CODEX_OAUTH: PresetDef = { export const CODEX_API: PresetDef = { id: 'codex-api', - label: 'OpenAI (API Key)', - description: 'Pay per token via OpenAI API', + label: 'Codex CLI (API Key)', + description: 'Use local Codex CLI with an OpenAI API key', category: 'official', - defaultName: 'OpenAI (API Key)', + defaultName: 'Codex CLI (API Key)', zodSchema: z.object({ backend: z.literal('codex'), loginMethod: z.literal('api-key'), diff --git a/src/core/media-store.ts b/src/core/media-store.ts index 4726f079..02e83593 100644 --- a/src/core/media-store.ts +++ b/src/core/media-store.ts @@ -43,6 +43,10 @@ const WORDS = [ const MEDIA_DIR = join(process.cwd(), 'data', 'media') +function toPosixPath(path: string): string { + return path.replace(/\\/g, '/') +} + /** YYYY-MM-DD date folder for today. */ function datePath(): string { const d = new Date() @@ -76,10 +80,10 @@ export async function persistMedia(filePath: string): Promise { await copyFile(filePath, dest) } - return join(dateDir, name) + return toPosixPath(join(dateDir, name)) } /** Resolve a media relative path to its absolute path on disk. */ export function resolveMediaPath(name: string): string { - return join(MEDIA_DIR, name) + return toPosixPath(join(MEDIA_DIR, name)) } diff --git a/src/core/session.ts b/src/core/session.ts index 2b0484c2..b0a94444 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -466,76 +466,6 @@ export function toTextHistory(entries: SessionEntry[]): Array<{ role: 'user' | ' return history } -// ==================== Responses API Input (for Codex provider) ==================== - -/** - * Input item types for OpenAI's Responses API. - * Mirrors the subset of ResponseInputItem that we actually use. - */ -export type ResponsesInputItem = - | { role: 'user' | 'assistant'; content: string } - | { type: 'function_call'; call_id: string; name: string; arguments: string } - | { type: 'function_call_output'; call_id: string; output: string } - -/** - * Convert session entries → OpenAI Responses API input items. - * - * Similar to toModelMessages() but targets the Responses API format. - * Handles orphaned tool calls (compaction truncation) by stripping - * function_call items that have no matching function_call_output. - */ -export function toResponsesInput(entries: SessionEntry[]): ResponsesInputItem[] { - const items: ResponsesInputItem[] = [] - - for (const entry of entries) { - if (entry.type === 'system' && entry.subtype === 'compact_boundary') continue - - const { message } = entry - - if (message.role === 'user') { - if (typeof message.content === 'string') { - items.push({ role: 'user', content: message.content }) - } else { - // tool_result blocks → function_call_output items - for (const block of message.content) { - if (block.type === 'tool_result') { - items.push({ type: 'function_call_output', call_id: block.tool_use_id, output: block.content }) - } else if (block.type === 'text') { - items.push({ role: 'user', content: block.text }) - } - } - } - } else if (message.role === 'assistant') { - if (typeof message.content === 'string') { - items.push({ role: 'assistant', content: message.content }) - } else { - for (const block of message.content) { - if (block.type === 'text') { - items.push({ role: 'assistant', content: block.text }) - } else if (block.type === 'tool_use') { - items.push({ - type: 'function_call', - call_id: block.id, - name: block.name, - arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input), - }) - } - } - } - } - } - - // Sanitize: strip function_call items without a matching function_call_output - const outputIds = new Set() - for (const item of items) { - if ('type' in item && item.type === 'function_call_output') outputIds.add(item.call_id) - } - return items.filter((item) => { - if ('type' in item && item.type === 'function_call') return outputIds.has(item.call_id) - return true - }) -} - // ==================== Chat History (for Web UI) ==================== /** A display-ready chat history item — either plain text or a group of paired tool calls. */ diff --git a/src/tool/session.ts b/src/tool/session.ts index 64d7fca6..ee8ad218 100644 --- a/src/tool/session.ts +++ b/src/tool/session.ts @@ -9,6 +9,10 @@ import { toTextHistory, type SessionEntry } from '@/core/session.js' const DEFAULT_SESSIONS_DIR = join(process.cwd(), 'data', 'sessions') +function toPosixPath(path: string): string { + return path.replace(/\\/g, '/') +} + /** * Create session awareness tools — cross-session visibility for the AI. * @@ -145,7 +149,7 @@ async function findJsonlFiles( results.push(...await findJsonlFiles(fullPath, base)) } else if (entry.name.endsWith('.jsonl')) { const s = await stat(fullPath) - const id = relative(base, fullPath).replace(/\.jsonl$/, '') + const id = toPosixPath(relative(base, fullPath).replace(/\.jsonl$/, '')) results.push({ id, sizeBytes: s.size, lastModified: s.mtime.toISOString() }) } } diff --git a/ui/src/pages/AIProviderPage.tsx b/ui/src/pages/AIProviderPage.tsx index 4baee6f0..cba9aac8 100644 --- a/ui/src/pages/AIProviderPage.tsx +++ b/ui/src/pages/AIProviderPage.tsx @@ -60,11 +60,11 @@ export function AIProviderPage() { setProfiles((p) => p ? { ...p, [slug]: profile } : p) } - if (!profiles) return

+ if (!profiles) return
return (
- +
{Object.entries(profiles).map(([slug, profile]) => { @@ -77,7 +77,7 @@ export function AIProviderPage() { {slug} {isActive && Active}
-

{profile.model || 'Auto (subscription plan)'}

+

{profile.model || 'Auto (CLI session)'}

{!isActive && }