mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +00:00
Merge branch 'main' into feat/tui-overlay-options
This commit is contained in:
commit
7d45e434de
90 changed files with 10277 additions and 1700 deletions
7
.github/workflows/build-binaries.yml
vendored
7
.github/workflows/build-binaries.yml
vendored
|
|
@ -28,7 +28,7 @@ jobs:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
|
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
|
||||||
with:
|
with:
|
||||||
bun-version: 1.3.4
|
bun-version: 1.2.20
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||||
|
|
@ -108,9 +108,10 @@ jobs:
|
||||||
# Create archives
|
# Create archives
|
||||||
cd binaries
|
cd binaries
|
||||||
|
|
||||||
# Unix platforms (tar.gz)
|
# Unix platforms (tar.gz) - use wrapper directory for mise compatibility
|
||||||
|
# mise auto-detects single-directory archives and strips one component
|
||||||
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do
|
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do
|
||||||
tar -czf pi-$platform.tar.gz -C $platform .
|
mv $platform pi && tar -czf pi-$platform.tar.gz pi && mv pi $platform
|
||||||
done
|
done
|
||||||
|
|
||||||
# Windows (zip)
|
# Windows (zip)
|
||||||
|
|
|
||||||
40
AGENTS.md
40
AGENTS.md
|
|
@ -75,6 +75,46 @@ Use these sections under `## [Unreleased]`:
|
||||||
- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))`
|
- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))`
|
||||||
- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))`
|
- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))`
|
||||||
|
|
||||||
|
## Adding a New LLM Provider (packages/ai)
|
||||||
|
|
||||||
|
Adding a new provider requires changes across multiple files:
|
||||||
|
|
||||||
|
### 1. Core Types (`packages/ai/src/types.ts`)
|
||||||
|
- Add API identifier to `Api` type union (e.g., `"bedrock-converse-stream"`)
|
||||||
|
- Create options interface extending `StreamOptions`
|
||||||
|
- Add mapping to `ApiOptionsMap`
|
||||||
|
- Add provider name to `KnownProvider` type union
|
||||||
|
|
||||||
|
### 2. Provider Implementation (`packages/ai/src/providers/`)
|
||||||
|
Create provider file exporting:
|
||||||
|
- `stream<Provider>()` function returning `AssistantMessageEventStream`
|
||||||
|
- Message/tool conversion functions
|
||||||
|
- Response parsing emitting standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`)
|
||||||
|
|
||||||
|
### 3. Stream Integration (`packages/ai/src/stream.ts`)
|
||||||
|
- Import provider's stream function and options type
|
||||||
|
- Add credential detection in `getEnvApiKey()`
|
||||||
|
- Add case in `mapOptionsForApi()` for `SimpleStreamOptions` mapping
|
||||||
|
- Add provider to `streamFunctions` map
|
||||||
|
|
||||||
|
### 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
|
||||||
|
- Add logic to fetch/parse models from provider source
|
||||||
|
- Map to standardized `Model` interface
|
||||||
|
|
||||||
|
### 5. Tests (`packages/ai/test/`)
|
||||||
|
Add provider to: `stream.test.ts`, `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `image-limits.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`
|
||||||
|
|
||||||
|
For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection.
|
||||||
|
|
||||||
|
### 6. Coding Agent (`packages/coding-agent/`)
|
||||||
|
- `src/core/model-resolver.ts`: Add default model ID to `DEFAULT_MODELS`
|
||||||
|
- `src/cli/args.ts`: Add env var documentation
|
||||||
|
- `README.md`: Add provider setup instructions
|
||||||
|
|
||||||
|
### 7. Documentation
|
||||||
|
- `packages/ai/README.md`: Add to providers table, document options/auth, add env vars
|
||||||
|
- `packages/ai/CHANGELOG.md`: Add entry under `## [Unreleased]`
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together.
|
**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together.
|
||||||
|
|
|
||||||
2070
package-lock.json
generated
2070
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,7 @@
|
||||||
},
|
},
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mariozechner/jiti": "^2.6.5",
|
||||||
"@mariozechner/pi-coding-agent": "^0.30.2",
|
"@mariozechner/pi-coding-agent": "^0.30.2",
|
||||||
"get-east-asian-width": "^1.4.0"
|
"get-east-asian-width": "^1.4.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.5] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.4] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.3] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.2] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.1] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-01-12
|
||||||
|
|
||||||
## [0.43.0] - 2026-01-11
|
## [0.43.0] - 2026-01-11
|
||||||
|
|
||||||
## [0.42.5] - 2026-01-11
|
## [0.42.5] - 2026-01-11
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-agent-core",
|
"name": "@mariozechner/pi-agent-core",
|
||||||
"version": "0.43.0",
|
"version": "0.45.5",
|
||||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
"prepublishOnly": "npm run clean && npm run build"
|
"prepublishOnly": "npm run clean && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.43.0",
|
"@mariozechner/pi-ai": "^0.45.5",
|
||||||
"@mariozechner/pi-tui": "^0.43.0"
|
"@mariozechner/pi-tui": "^0.45.5"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
|
|
|
||||||
18
packages/agent/test/bedrock-utils.ts
Normal file
18
packages/agent/test/bedrock-utils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Utility functions for Amazon Bedrock tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any valid AWS credentials are configured for Bedrock.
|
||||||
|
* Returns true if any of the following are set:
|
||||||
|
* - AWS_PROFILE (named profile from ~/.aws/credentials)
|
||||||
|
* - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (IAM keys)
|
||||||
|
* - AWS_BEARER_TOKEN_BEDROCK (Bedrock API key)
|
||||||
|
*/
|
||||||
|
export function hasBedrockCredentials(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.AWS_PROFILE ||
|
||||||
|
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
|
||||||
|
process.env.AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@m
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { Agent } from "../src/index.js";
|
import { Agent } from "../src/index.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { calculateTool } from "./utils/calculate.js";
|
import { calculateTool } from "./utils/calculate.js";
|
||||||
|
|
||||||
async function basicPrompt(model: Model<any>) {
|
async function basicPrompt(model: Model<any>) {
|
||||||
|
|
@ -324,6 +325,30 @@ describe("Agent E2E Tests", () => {
|
||||||
await multiTurnConversation(model);
|
await multiTurnConversation(model);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-sonnet-4-5)", () => {
|
||||||
|
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should handle basic text prompt", async () => {
|
||||||
|
await basicPrompt(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should execute tools correctly", async () => {
|
||||||
|
await toolExecution(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle abort during execution", async () => {
|
||||||
|
await abortExecution(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit state updates during streaming", async () => {
|
||||||
|
await stateUpdates(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain context across multiple turns", async () => {
|
||||||
|
await multiTurnConversation(model);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Agent.continue()", () => {
|
describe("Agent.continue()", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,36 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.5] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.4] - 2026-01-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added Vercel AI Gateway provider with model discovery and `AI_GATEWAY_API_KEY` env support ([#689](https://github.com/badlogic/pi-mono/pull/689) by [@timolins](https://github.com/timolins))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed z.ai thinking/reasoning: z.ai uses `thinking: { type: "enabled" }` instead of OpenAI's `reasoning_effort`. Added `thinkingFormat` compat flag to handle this. ([#688](https://github.com/badlogic/pi-mono/issues/688))
|
||||||
|
|
||||||
|
## [0.45.3] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.2] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.1] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-01-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- MiniMax provider support with M2 and M2.1 models via Anthropic-compatible API ([#656](https://github.com/badlogic/pi-mono/pull/656) by [@dannote](https://github.com/dannote))
|
||||||
|
- Add Amazon Bedrock provider with prompt caching for Claude models (experimental, tested with Anthropic Claude models only) ([#494](https://github.com/badlogic/pi-mono/pull/494) by [@unexge](https://github.com/unexge))
|
||||||
|
- Added `serviceTier` option for OpenAI Responses requests ([#672](https://github.com/badlogic/pi-mono/pull/672) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||||
|
- **Anthropic caching on OpenRouter**: Interactions with Anthropic models via OpenRouter now set a 5-minute cache point using Anthropic-style `cache_control` breakpoints on the last assistant or user message. ([#584](https://github.com/badlogic/pi-mono/pull/584) by [@nathyong](https://github.com/nathyong))
|
||||||
|
- **Google Gemini CLI provider improvements**: Added Antigravity endpoint fallback (tries daily sandbox then prod when `baseUrl` is unset), header-based retry delay parsing (`Retry-After`, `x-ratelimit-reset`, `x-ratelimit-reset-after`), stable `sessionId` derivation from first user message for cache affinity, empty SSE stream retry with backoff, and `anthropic-beta` header for Claude thinking models ([#670](https://github.com/badlogic/pi-mono/pull/670) by [@kim0](https://github.com/kim0))
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-01-12
|
||||||
|
|
||||||
## [0.43.0] - 2026-01-11
|
## [0.43.0] - 2026-01-11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,12 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||||
- **Cerebras**
|
- **Cerebras**
|
||||||
- **xAI**
|
- **xAI**
|
||||||
- **OpenRouter**
|
- **OpenRouter**
|
||||||
|
- **Vercel AI Gateway**
|
||||||
|
- **MiniMax**
|
||||||
- **GitHub Copilot** (requires OAuth, see below)
|
- **GitHub Copilot** (requires OAuth, see below)
|
||||||
- **Google Gemini CLI** (requires OAuth, see below)
|
- **Google Gemini CLI** (requires OAuth, see below)
|
||||||
- **Antigravity** (requires OAuth, see below)
|
- **Antigravity** (requires OAuth, see below)
|
||||||
|
- **Amazon Bedrock**
|
||||||
- **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc.
|
- **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -708,6 +711,7 @@ interface OpenAICompat {
|
||||||
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
|
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
|
||||||
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
|
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
|
||||||
maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
|
maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
|
||||||
|
thinkingFormat?: 'openai' | 'zai'; // Format for reasoning param: 'openai' uses reasoning_effort, 'zai' uses thinking: { type: "enabled" } (default: openai)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -860,7 +864,9 @@ In Node.js environments, you can set environment variables to avoid passing API
|
||||||
| Cerebras | `CEREBRAS_API_KEY` |
|
| Cerebras | `CEREBRAS_API_KEY` |
|
||||||
| xAI | `XAI_API_KEY` |
|
| xAI | `XAI_API_KEY` |
|
||||||
| OpenRouter | `OPENROUTER_API_KEY` |
|
| OpenRouter | `OPENROUTER_API_KEY` |
|
||||||
|
| Vercel AI Gateway | `AI_GATEWAY_API_KEY` |
|
||||||
| zAI | `ZAI_API_KEY` |
|
| zAI | `ZAI_API_KEY` |
|
||||||
|
| MiniMax | `MINIMAX_API_KEY` |
|
||||||
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
|
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
|
||||||
|
|
||||||
When set, the library automatically uses these keys:
|
When set, the library automatically uses these keys:
|
||||||
|
|
@ -1026,6 +1032,90 @@ const response = await complete(model, {
|
||||||
|
|
||||||
**Google Gemini CLI / Antigravity**: These use Google Cloud OAuth. The `apiKey` returned by `getOAuthApiKey()` is a JSON string containing both the token and project ID, which the library handles automatically.
|
**Google Gemini CLI / Antigravity**: These use Google Cloud OAuth. The `apiKey` returned by `getOAuthApiKey()` is a JSON string containing both the token and project ID, which the library handles automatically.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding a New Provider
|
||||||
|
|
||||||
|
Adding a new LLM provider requires changes across multiple files. This checklist covers all necessary steps:
|
||||||
|
|
||||||
|
#### 1. Core Types (`src/types.ts`)
|
||||||
|
|
||||||
|
- Add the API identifier to the `Api` type union (e.g., `"bedrock-converse-stream"`)
|
||||||
|
- Create an options interface extending `StreamOptions` (e.g., `BedrockOptions`)
|
||||||
|
- Add the mapping to `ApiOptionsMap`
|
||||||
|
- Add the provider name to `KnownProvider` type union (e.g., `"amazon-bedrock"`)
|
||||||
|
|
||||||
|
#### 2. Provider Implementation (`src/providers/`)
|
||||||
|
|
||||||
|
Create a new provider file (e.g., `amazon-bedrock.ts`) that exports:
|
||||||
|
|
||||||
|
- `stream<Provider>()` function returning `AssistantMessageEventStream`
|
||||||
|
- Provider-specific options interface
|
||||||
|
- Message conversion functions to transform `Context` to provider format
|
||||||
|
- Tool conversion if the provider supports tools
|
||||||
|
- Response parsing to emit standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`)
|
||||||
|
|
||||||
|
#### 3. Stream Integration (`src/stream.ts`)
|
||||||
|
|
||||||
|
- Import the provider's stream function and options type
|
||||||
|
- Add credential detection in `getEnvApiKey()` for the new provider
|
||||||
|
- Add a case in `mapOptionsForApi()` to map `SimpleStreamOptions` to provider options
|
||||||
|
- Add the provider's stream function to the `streamFunctions` map
|
||||||
|
|
||||||
|
#### 4. Model Generation (`scripts/generate-models.ts`)
|
||||||
|
|
||||||
|
- Add logic to fetch and parse models from the provider's source (e.g., models.dev API)
|
||||||
|
- Map provider model data to the standardized `Model` interface
|
||||||
|
- Handle provider-specific quirks (pricing format, capability flags, model ID transformations)
|
||||||
|
|
||||||
|
#### 5. Tests (`test/`)
|
||||||
|
|
||||||
|
Create or update test files to cover the new provider:
|
||||||
|
|
||||||
|
- `stream.test.ts` - Basic streaming and tool use
|
||||||
|
- `tokens.test.ts` - Token usage reporting
|
||||||
|
- `abort.test.ts` - Request cancellation
|
||||||
|
- `empty.test.ts` - Empty message handling
|
||||||
|
- `context-overflow.test.ts` - Context limit errors
|
||||||
|
- `image-limits.test.ts` - Image support (if applicable)
|
||||||
|
- `unicode-surrogate.test.ts` - Unicode handling
|
||||||
|
- `tool-call-without-result.test.ts` - Orphaned tool calls
|
||||||
|
- `image-tool-result.test.ts` - Images in tool results
|
||||||
|
- `total-tokens.test.ts` - Token counting accuracy
|
||||||
|
|
||||||
|
For providers with non-standard auth (AWS, Google Vertex), create a utility like `bedrock-utils.ts` with credential detection helpers.
|
||||||
|
|
||||||
|
#### 6. Coding Agent Integration (`../coding-agent/`)
|
||||||
|
|
||||||
|
Update `src/core/model-resolver.ts`:
|
||||||
|
|
||||||
|
- Add a default model ID for the provider in `DEFAULT_MODELS`
|
||||||
|
|
||||||
|
Update `src/cli/args.ts`:
|
||||||
|
|
||||||
|
- Add environment variable documentation in the help text
|
||||||
|
|
||||||
|
Update `README.md`:
|
||||||
|
|
||||||
|
- Add the provider to the providers section with setup instructions
|
||||||
|
|
||||||
|
#### 7. Documentation
|
||||||
|
|
||||||
|
Update `packages/ai/README.md`:
|
||||||
|
|
||||||
|
- Add to the Supported Providers table
|
||||||
|
- Document any provider-specific options or authentication requirements
|
||||||
|
- Add environment variable to the Environment Variables section
|
||||||
|
|
||||||
|
#### 8. Changelog
|
||||||
|
|
||||||
|
Add an entry to `packages/ai/CHANGELOG.md` under `## [Unreleased]`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Added
|
||||||
|
- Added support for [Provider Name] provider ([#PR](link) by [@author](link))
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-ai",
|
"name": "@mariozechner/pi-ai",
|
||||||
"version": "0.43.0",
|
"version": "0.45.5",
|
||||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.71.2",
|
"@anthropic-ai/sdk": "0.71.2",
|
||||||
|
"@aws-sdk/client-bedrock-runtime": "^3.966.0",
|
||||||
"@google/genai": "1.34.0",
|
"@google/genai": "1.34.0",
|
||||||
"@mistralai/mistralai": "1.10.0",
|
"@mistralai/mistralai": "1.10.0",
|
||||||
"@sinclair/typebox": "^0.34.41",
|
"@sinclair/typebox": "^0.34.41",
|
||||||
|
|
@ -39,6 +40,7 @@
|
||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"gemini",
|
"gemini",
|
||||||
|
"bedrock",
|
||||||
"unified",
|
"unified",
|
||||||
"api"
|
"api"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,20 @@ interface ModelsDevModel {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AiGatewayModel {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
context_window?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
tags?: string[];
|
||||||
|
pricing?: {
|
||||||
|
input?: string | number;
|
||||||
|
output?: string | number;
|
||||||
|
input_cache_read?: string | number;
|
||||||
|
input_cache_write?: string | number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const COPILOT_STATIC_HEADERS = {
|
const COPILOT_STATIC_HEADERS = {
|
||||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||||
"Editor-Version": "vscode/1.107.0",
|
"Editor-Version": "vscode/1.107.0",
|
||||||
|
|
@ -39,6 +53,9 @@ const COPILOT_STATIC_HEADERS = {
|
||||||
"Copilot-Integration-Id": "vscode-chat",
|
"Copilot-Integration-Id": "vscode-chat",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1";
|
||||||
|
const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
|
||||||
|
|
||||||
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
||||||
try {
|
try {
|
||||||
console.log("Fetching models from OpenRouter API...");
|
console.log("Fetching models from OpenRouter API...");
|
||||||
|
|
@ -97,6 +114,64 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAiGatewayModels(): Promise<Model<any>[]> {
|
||||||
|
try {
|
||||||
|
console.log("Fetching models from Vercel AI Gateway API...");
|
||||||
|
const response = await fetch(`${AI_GATEWAY_MODELS_URL}/models`);
|
||||||
|
const data = await response.json();
|
||||||
|
const models: Model<any>[] = [];
|
||||||
|
|
||||||
|
const toNumber = (value: string | number | undefined): number => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
const parsed = parseFloat(value ?? "0");
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = Array.isArray(data.data) ? (data.data as AiGatewayModel[]) : [];
|
||||||
|
for (const model of items) {
|
||||||
|
const tags = Array.isArray(model.tags) ? model.tags : [];
|
||||||
|
// Only include models that support tools
|
||||||
|
if (!tags.includes("tool-use")) continue;
|
||||||
|
|
||||||
|
const input: ("text" | "image")[] = ["text"];
|
||||||
|
if (tags.includes("vision")) {
|
||||||
|
input.push("image");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCost = toNumber(model.pricing?.input) * 1_000_000;
|
||||||
|
const outputCost = toNumber(model.pricing?.output) * 1_000_000;
|
||||||
|
const cacheReadCost = toNumber(model.pricing?.input_cache_read) * 1_000_000;
|
||||||
|
const cacheWriteCost = toNumber(model.pricing?.input_cache_write) * 1_000_000;
|
||||||
|
|
||||||
|
models.push({
|
||||||
|
id: model.id,
|
||||||
|
name: model.name || model.id,
|
||||||
|
api: "anthropic-messages",
|
||||||
|
baseUrl: AI_GATEWAY_BASE_URL,
|
||||||
|
provider: "vercel-ai-gateway",
|
||||||
|
reasoning: tags.includes("reasoning"),
|
||||||
|
input,
|
||||||
|
cost: {
|
||||||
|
input: inputCost,
|
||||||
|
output: outputCost,
|
||||||
|
cacheRead: cacheReadCost,
|
||||||
|
cacheWrite: cacheWriteCost,
|
||||||
|
},
|
||||||
|
contextWindow: model.context_window || 4096,
|
||||||
|
maxTokens: model.max_tokens || 4096,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetched ${models.length} tool-capable models from Vercel AI Gateway`);
|
||||||
|
return models;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch Vercel AI Gateway models:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadModelsDevData(): Promise<Model<any>[]> {
|
async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||||
try {
|
try {
|
||||||
console.log("Fetching models from models.dev API...");
|
console.log("Fetching models from models.dev API...");
|
||||||
|
|
@ -105,6 +180,87 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||||
|
|
||||||
const models: Model<any>[] = [];
|
const models: Model<any>[] = [];
|
||||||
|
|
||||||
|
// Process Amazon Bedrock models
|
||||||
|
if (data["amazon-bedrock"]?.models) {
|
||||||
|
for (const [modelId, model] of Object.entries(data["amazon-bedrock"].models)) {
|
||||||
|
const m = model as ModelsDevModel;
|
||||||
|
if (m.tool_call !== true) continue;
|
||||||
|
|
||||||
|
let id = modelId;
|
||||||
|
|
||||||
|
if (id.startsWith("ai21.jamba")) {
|
||||||
|
// These models doesn't support tool use in streaming mode
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.startsWith("amazon.titan-text-express") ||
|
||||||
|
id.startsWith("mistral.mistral-7b-instruct-v0")) {
|
||||||
|
// These models doesn't support system messages
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some Amazon Bedrock models require cross-region inference profiles to work.
|
||||||
|
// To use cross-region inference, we need to add a region prefix to the models.
|
||||||
|
// See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system
|
||||||
|
// TODO: Remove Claude models once https://github.com/anomalyco/models.dev/pull/607 is merged, and follow-up with other models.
|
||||||
|
|
||||||
|
// Models with global cross-region inference profiles
|
||||||
|
if (id.startsWith("anthropic.claude-haiku-4-5") ||
|
||||||
|
id.startsWith("anthropic.claude-sonnet-4") ||
|
||||||
|
id.startsWith("anthropic.claude-opus-4-5") ||
|
||||||
|
id.startsWith("amazon.nova-2-lite") ||
|
||||||
|
id.startsWith("cohere.embed-v4") ||
|
||||||
|
id.startsWith("twelvelabs.pegasus-1-2")) {
|
||||||
|
id = "global." + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models with US cross-region inference profiles
|
||||||
|
if (id.startsWith("amazon.nova-lite") ||
|
||||||
|
id.startsWith("amazon.nova-micro") ||
|
||||||
|
id.startsWith("amazon.nova-premier") ||
|
||||||
|
id.startsWith("amazon.nova-pro") ||
|
||||||
|
id.startsWith("anthropic.claude-3-7-sonnet") ||
|
||||||
|
id.startsWith("anthropic.claude-opus-4-1") ||
|
||||||
|
id.startsWith("anthropic.claude-opus-4-20250514") ||
|
||||||
|
id.startsWith("deepseek.r1") ||
|
||||||
|
id.startsWith("meta.llama3-2") ||
|
||||||
|
id.startsWith("meta.llama3-3") ||
|
||||||
|
id.startsWith("meta.llama4")) {
|
||||||
|
id = "us." + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bedrockModel = {
|
||||||
|
id,
|
||||||
|
name: m.name || id,
|
||||||
|
api: "bedrock-converse-stream" as const,
|
||||||
|
provider: "amazon-bedrock" as const,
|
||||||
|
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||||
|
reasoning: m.reasoning === true,
|
||||||
|
input: (m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
|
||||||
|
cost: {
|
||||||
|
input: m.cost?.input || 0,
|
||||||
|
output: m.cost?.output || 0,
|
||||||
|
cacheRead: m.cost?.cache_read || 0,
|
||||||
|
cacheWrite: m.cost?.cache_write || 0,
|
||||||
|
},
|
||||||
|
contextWindow: m.limit?.context || 4096,
|
||||||
|
maxTokens: m.limit?.output || 4096,
|
||||||
|
};
|
||||||
|
models.push(bedrockModel);
|
||||||
|
|
||||||
|
// Add EU cross-region inference variants for Claude models
|
||||||
|
if (modelId.startsWith("anthropic.claude-haiku-4-5") ||
|
||||||
|
modelId.startsWith("anthropic.claude-sonnet-4-5") ||
|
||||||
|
modelId.startsWith("anthropic.claude-opus-4-5")) {
|
||||||
|
models.push({
|
||||||
|
...bedrockModel,
|
||||||
|
id: "eu." + modelId,
|
||||||
|
name: (m.name || modelId) + " (EU)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process Anthropic models
|
// Process Anthropic models
|
||||||
if (data.anthropic?.models) {
|
if (data.anthropic?.models) {
|
||||||
for (const [modelId, model] of Object.entries(data.anthropic.models)) {
|
for (const [modelId, model] of Object.entries(data.anthropic.models)) {
|
||||||
|
|
@ -284,6 +440,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||||
},
|
},
|
||||||
compat: {
|
compat: {
|
||||||
supportsDeveloperRole: false,
|
supportsDeveloperRole: false,
|
||||||
|
thinkingFormat: "zai",
|
||||||
},
|
},
|
||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
|
|
@ -409,6 +566,33 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process MiniMax models
|
||||||
|
if (data.minimax?.models) {
|
||||||
|
for (const [modelId, model] of Object.entries(data.minimax.models)) {
|
||||||
|
const m = model as ModelsDevModel;
|
||||||
|
if (m.tool_call !== true) continue;
|
||||||
|
|
||||||
|
models.push({
|
||||||
|
id: modelId,
|
||||||
|
name: m.name || modelId,
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "minimax",
|
||||||
|
// MiniMax's Anthropic-compatible API - SDK appends /v1/messages
|
||||||
|
baseUrl: "https://api.minimax.io/anthropic",
|
||||||
|
reasoning: m.reasoning === true,
|
||||||
|
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||||
|
cost: {
|
||||||
|
input: m.cost?.input || 0,
|
||||||
|
output: m.cost?.output || 0,
|
||||||
|
cacheRead: m.cost?.cache_read || 0,
|
||||||
|
cacheWrite: m.cost?.cache_write || 0,
|
||||||
|
},
|
||||||
|
contextWindow: m.limit?.context || 4096,
|
||||||
|
maxTokens: m.limit?.output || 4096,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Loaded ${models.length} tool-capable models from models.dev`);
|
console.log(`Loaded ${models.length} tool-capable models from models.dev`);
|
||||||
return models;
|
return models;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -421,11 +605,13 @@ async function generateModels() {
|
||||||
// Fetch models from both sources
|
// Fetch models from both sources
|
||||||
// models.dev: Anthropic, Google, OpenAI, Groq, Cerebras
|
// models.dev: Anthropic, Google, OpenAI, Groq, Cerebras
|
||||||
// OpenRouter: xAI and other providers (excluding Anthropic, Google, OpenAI)
|
// OpenRouter: xAI and other providers (excluding Anthropic, Google, OpenAI)
|
||||||
|
// AI Gateway: OpenAI-compatible catalog with tool-capable models
|
||||||
const modelsDevModels = await loadModelsDevData();
|
const modelsDevModels = await loadModelsDevData();
|
||||||
const openRouterModels = await fetchOpenRouterModels();
|
const openRouterModels = await fetchOpenRouterModels();
|
||||||
|
const aiGatewayModels = await fetchAiGatewayModels();
|
||||||
|
|
||||||
// Combine models (models.dev has priority)
|
// Combine models (models.dev has priority)
|
||||||
const allModels = [...modelsDevModels, ...openRouterModels];
|
const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels];
|
||||||
|
|
||||||
// Fix incorrect cache pricing for Claude Opus 4.5 from models.dev
|
// Fix incorrect cache pricing for Claude Opus 4.5 from models.dev
|
||||||
// models.dev has 3x the correct pricing (1.5/18.75 instead of 0.5/6.25)
|
// models.dev has 3x the correct pricing (1.5/18.75 instead of 0.5/6.25)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
548
packages/ai/src/providers/amazon-bedrock.ts
Normal file
548
packages/ai/src/providers/amazon-bedrock.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
import {
|
||||||
|
BedrockRuntimeClient,
|
||||||
|
StopReason as BedrockStopReason,
|
||||||
|
type Tool as BedrockTool,
|
||||||
|
CachePointType,
|
||||||
|
type ContentBlock,
|
||||||
|
type ContentBlockDeltaEvent,
|
||||||
|
type ContentBlockStartEvent,
|
||||||
|
type ContentBlockStopEvent,
|
||||||
|
ConversationRole,
|
||||||
|
ConverseStreamCommand,
|
||||||
|
type ConverseStreamMetadataEvent,
|
||||||
|
ImageFormat,
|
||||||
|
type Message,
|
||||||
|
type SystemContentBlock,
|
||||||
|
type ToolChoice,
|
||||||
|
type ToolConfiguration,
|
||||||
|
ToolResultStatus,
|
||||||
|
} from "@aws-sdk/client-bedrock-runtime";
|
||||||
|
|
||||||
|
import { calculateCost } from "../models.js";
|
||||||
|
import type {
|
||||||
|
Api,
|
||||||
|
AssistantMessage,
|
||||||
|
Context,
|
||||||
|
Model,
|
||||||
|
StopReason,
|
||||||
|
StreamFunction,
|
||||||
|
StreamOptions,
|
||||||
|
TextContent,
|
||||||
|
ThinkingBudgets,
|
||||||
|
ThinkingContent,
|
||||||
|
ThinkingLevel,
|
||||||
|
Tool,
|
||||||
|
ToolCall,
|
||||||
|
ToolResultMessage,
|
||||||
|
} from "../types.js";
|
||||||
|
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||||
|
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||||
|
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||||
|
|
||||||
|
export interface BedrockOptions extends StreamOptions {
|
||||||
|
region?: string;
|
||||||
|
profile?: string;
|
||||||
|
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
|
||||||
|
/* See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-reasoning.html for supported models. */
|
||||||
|
reasoning?: ThinkingLevel;
|
||||||
|
/* Custom token budgets per thinking level. Overrides default budgets. */
|
||||||
|
thinkingBudgets?: ThinkingBudgets;
|
||||||
|
/* Only supported by Claude 4.x models, see https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-extended-thinking.html#claude-messages-extended-thinking-tool-use-interleaved */
|
||||||
|
interleavedThinking?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
|
||||||
|
|
||||||
|
export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||||
|
model: Model<"bedrock-converse-stream">,
|
||||||
|
context: Context,
|
||||||
|
options: BedrockOptions,
|
||||||
|
): AssistantMessageEventStream => {
|
||||||
|
const stream = new AssistantMessageEventStream();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const output: AssistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [],
|
||||||
|
api: "bedrock-converse-stream" as Api,
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.id,
|
||||||
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
},
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocks = output.content as Block[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new BedrockRuntimeClient({
|
||||||
|
region: options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1",
|
||||||
|
profile: options.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = new ConverseStreamCommand({
|
||||||
|
modelId: model.id,
|
||||||
|
messages: convertMessages(context, model),
|
||||||
|
system: buildSystemPrompt(context.systemPrompt, model),
|
||||||
|
inferenceConfig: { maxTokens: options.maxTokens, temperature: options.temperature },
|
||||||
|
toolConfig: convertToolConfig(context.tools, options.toolChoice),
|
||||||
|
additionalModelRequestFields: buildAdditionalModelRequestFields(model, options),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.send(command, { abortSignal: options.signal });
|
||||||
|
|
||||||
|
for await (const item of response.stream!) {
|
||||||
|
if (item.messageStart) {
|
||||||
|
if (item.messageStart.role !== ConversationRole.ASSISTANT) {
|
||||||
|
throw new Error("Unexpected assistant message start but got user message start instead");
|
||||||
|
}
|
||||||
|
stream.push({ type: "start", partial: output });
|
||||||
|
} else if (item.contentBlockStart) {
|
||||||
|
handleContentBlockStart(item.contentBlockStart, blocks, output, stream);
|
||||||
|
} else if (item.contentBlockDelta) {
|
||||||
|
handleContentBlockDelta(item.contentBlockDelta, blocks, output, stream);
|
||||||
|
} else if (item.contentBlockStop) {
|
||||||
|
handleContentBlockStop(item.contentBlockStop, blocks, output, stream);
|
||||||
|
} else if (item.messageStop) {
|
||||||
|
output.stopReason = mapStopReason(item.messageStop.stopReason);
|
||||||
|
} else if (item.metadata) {
|
||||||
|
handleMetadata(item.metadata, model, output);
|
||||||
|
} else if (item.internalServerException) {
|
||||||
|
throw new Error(`Internal server error: ${item.internalServerException.message}`);
|
||||||
|
} else if (item.modelStreamErrorException) {
|
||||||
|
throw new Error(`Model stream error: ${item.modelStreamErrorException.message}`);
|
||||||
|
} else if (item.validationException) {
|
||||||
|
throw new Error(`Validation error: ${item.validationException.message}`);
|
||||||
|
} else if (item.throttlingException) {
|
||||||
|
throw new Error(`Throttling error: ${item.throttlingException.message}`);
|
||||||
|
} else if (item.serviceUnavailableException) {
|
||||||
|
throw new Error(`Service unavailable: ${item.serviceUnavailableException.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new Error("Request was aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.stopReason === "error" || output.stopReason === "aborted") {
|
||||||
|
throw new Error("An unknown error occurred");
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
|
stream.end();
|
||||||
|
} catch (error) {
|
||||||
|
for (const block of output.content) {
|
||||||
|
delete (block as Block).index;
|
||||||
|
delete (block as Block).partialJson;
|
||||||
|
}
|
||||||
|
output.stopReason = options.signal?.aborted ? "aborted" : "error";
|
||||||
|
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||||
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
|
stream.end();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleContentBlockStart(
|
||||||
|
event: ContentBlockStartEvent,
|
||||||
|
blocks: Block[],
|
||||||
|
output: AssistantMessage,
|
||||||
|
stream: AssistantMessageEventStream,
|
||||||
|
): void {
|
||||||
|
const index = event.contentBlockIndex!;
|
||||||
|
const start = event.start;
|
||||||
|
|
||||||
|
if (start?.toolUse) {
|
||||||
|
const block: Block = {
|
||||||
|
type: "toolCall",
|
||||||
|
id: start.toolUse.toolUseId || "",
|
||||||
|
name: start.toolUse.name || "",
|
||||||
|
arguments: {},
|
||||||
|
partialJson: "",
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
output.content.push(block);
|
||||||
|
stream.push({ type: "toolcall_start", contentIndex: blocks.length - 1, partial: output });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentBlockDelta(
|
||||||
|
event: ContentBlockDeltaEvent,
|
||||||
|
blocks: Block[],
|
||||||
|
output: AssistantMessage,
|
||||||
|
stream: AssistantMessageEventStream,
|
||||||
|
): void {
|
||||||
|
const contentBlockIndex = event.contentBlockIndex!;
|
||||||
|
const delta = event.delta;
|
||||||
|
let index = blocks.findIndex((b) => b.index === contentBlockIndex);
|
||||||
|
let block = blocks[index];
|
||||||
|
|
||||||
|
if (delta?.text !== undefined) {
|
||||||
|
// If no text block exists yet, create one, as `handleContentBlockStart` is not sent for text blocks
|
||||||
|
if (!block) {
|
||||||
|
const newBlock: Block = { type: "text", text: "", index: contentBlockIndex };
|
||||||
|
output.content.push(newBlock);
|
||||||
|
index = blocks.length - 1;
|
||||||
|
block = blocks[index];
|
||||||
|
stream.push({ type: "text_start", contentIndex: index, partial: output });
|
||||||
|
}
|
||||||
|
if (block.type === "text") {
|
||||||
|
block.text += delta.text;
|
||||||
|
stream.push({ type: "text_delta", contentIndex: index, delta: delta.text, partial: output });
|
||||||
|
}
|
||||||
|
} else if (delta?.toolUse && block?.type === "toolCall") {
|
||||||
|
block.partialJson = (block.partialJson || "") + (delta.toolUse.input || "");
|
||||||
|
block.arguments = parseStreamingJson(block.partialJson);
|
||||||
|
stream.push({ type: "toolcall_delta", contentIndex: index, delta: delta.toolUse.input || "", partial: output });
|
||||||
|
} else if (delta?.reasoningContent) {
|
||||||
|
let thinkingBlock = block;
|
||||||
|
let thinkingIndex = index;
|
||||||
|
|
||||||
|
if (!thinkingBlock) {
|
||||||
|
const newBlock: Block = { type: "thinking", thinking: "", thinkingSignature: "", index: contentBlockIndex };
|
||||||
|
output.content.push(newBlock);
|
||||||
|
thinkingIndex = blocks.length - 1;
|
||||||
|
thinkingBlock = blocks[thinkingIndex];
|
||||||
|
stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thinkingBlock?.type === "thinking") {
|
||||||
|
if (delta.reasoningContent.text) {
|
||||||
|
thinkingBlock.thinking += delta.reasoningContent.text;
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_delta",
|
||||||
|
contentIndex: thinkingIndex,
|
||||||
|
delta: delta.reasoningContent.text,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (delta.reasoningContent.signature) {
|
||||||
|
thinkingBlock.thinkingSignature =
|
||||||
|
(thinkingBlock.thinkingSignature || "") + delta.reasoningContent.signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMetadata(
|
||||||
|
event: ConverseStreamMetadataEvent,
|
||||||
|
model: Model<"bedrock-converse-stream">,
|
||||||
|
output: AssistantMessage,
|
||||||
|
): void {
|
||||||
|
if (event.usage) {
|
||||||
|
output.usage.input = event.usage.inputTokens || 0;
|
||||||
|
output.usage.output = event.usage.outputTokens || 0;
|
||||||
|
output.usage.cacheRead = event.usage.cacheReadInputTokens || 0;
|
||||||
|
output.usage.cacheWrite = event.usage.cacheWriteInputTokens || 0;
|
||||||
|
output.usage.totalTokens = event.usage.totalTokens || output.usage.input + output.usage.output;
|
||||||
|
calculateCost(model, output.usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentBlockStop(
|
||||||
|
event: ContentBlockStopEvent,
|
||||||
|
blocks: Block[],
|
||||||
|
output: AssistantMessage,
|
||||||
|
stream: AssistantMessageEventStream,
|
||||||
|
): void {
|
||||||
|
const index = blocks.findIndex((b) => b.index === event.contentBlockIndex);
|
||||||
|
const block = blocks[index];
|
||||||
|
if (!block) return;
|
||||||
|
delete (block as Block).index;
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case "text":
|
||||||
|
stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
|
||||||
|
break;
|
||||||
|
case "thinking":
|
||||||
|
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
|
||||||
|
break;
|
||||||
|
case "toolCall":
|
||||||
|
block.arguments = parseStreamingJson(block.partialJson);
|
||||||
|
delete (block as Block).partialJson;
|
||||||
|
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the model supports prompt caching.
|
||||||
|
* Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x models
|
||||||
|
*/
|
||||||
|
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
|
||||||
|
const id = model.id.toLowerCase();
|
||||||
|
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
|
||||||
|
if (id.includes("claude") && (id.includes("-4-") || id.includes("-4."))) return true;
|
||||||
|
// Claude 3.7 Sonnet
|
||||||
|
if (id.includes("claude-3-7-sonnet")) return true;
|
||||||
|
// Claude 3.5 Haiku
|
||||||
|
if (id.includes("claude-3-5-haiku")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemPrompt(
|
||||||
|
systemPrompt: string | undefined,
|
||||||
|
model: Model<"bedrock-converse-stream">,
|
||||||
|
): SystemContentBlock[] | undefined {
|
||||||
|
if (!systemPrompt) return undefined;
|
||||||
|
|
||||||
|
const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }];
|
||||||
|
|
||||||
|
// Add cache point for supported Claude models
|
||||||
|
if (supportsPromptCaching(model)) {
|
||||||
|
blocks.push({ cachePoint: { type: CachePointType.DEFAULT } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMessages(context: Context, model: Model<"bedrock-converse-stream">): Message[] {
|
||||||
|
const result: Message[] = [];
|
||||||
|
const messages = context.messages;
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const m = messages[i];
|
||||||
|
|
||||||
|
switch (m.role) {
|
||||||
|
case "user":
|
||||||
|
result.push({
|
||||||
|
role: ConversationRole.USER,
|
||||||
|
content:
|
||||||
|
typeof m.content === "string"
|
||||||
|
? [{ text: sanitizeSurrogates(m.content) }]
|
||||||
|
: m.content.map((c) => {
|
||||||
|
switch (c.type) {
|
||||||
|
case "text":
|
||||||
|
return { text: sanitizeSurrogates(c.text) };
|
||||||
|
case "image":
|
||||||
|
return { image: createImageBlock(c.mimeType, c.data) };
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown user content type");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "assistant": {
|
||||||
|
// Skip assistant messages with empty content (e.g., from aborted requests)
|
||||||
|
// Bedrock rejects messages with empty content arrays
|
||||||
|
if (m.content.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const contentBlocks: ContentBlock[] = [];
|
||||||
|
for (const c of m.content) {
|
||||||
|
switch (c.type) {
|
||||||
|
case "text":
|
||||||
|
// Skip empty text blocks
|
||||||
|
if (c.text.trim().length === 0) continue;
|
||||||
|
contentBlocks.push({ text: sanitizeSurrogates(c.text) });
|
||||||
|
break;
|
||||||
|
case "toolCall":
|
||||||
|
contentBlocks.push({
|
||||||
|
toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "thinking":
|
||||||
|
// Skip empty thinking blocks
|
||||||
|
if (c.thinking.trim().length === 0) continue;
|
||||||
|
contentBlocks.push({
|
||||||
|
reasoningContent: {
|
||||||
|
reasoningText: { text: sanitizeSurrogates(c.thinking), signature: c.thinkingSignature },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown assistant content type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip if all content blocks were filtered out
|
||||||
|
if (contentBlocks.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
role: ConversationRole.ASSISTANT,
|
||||||
|
content: contentBlocks,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toolResult": {
|
||||||
|
// Collect all consecutive toolResult messages into a single user message
|
||||||
|
// Bedrock requires all tool results to be in one message
|
||||||
|
const toolResults: ContentBlock.ToolResultMember[] = [];
|
||||||
|
|
||||||
|
// Add current tool result with all content blocks combined
|
||||||
|
toolResults.push({
|
||||||
|
toolResult: {
|
||||||
|
toolUseId: m.toolCallId,
|
||||||
|
content: m.content.map((c) =>
|
||||||
|
c.type === "image"
|
||||||
|
? { image: createImageBlock(c.mimeType, c.data) }
|
||||||
|
: { text: sanitizeSurrogates(c.text) },
|
||||||
|
),
|
||||||
|
status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Look ahead for consecutive toolResult messages
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < messages.length && messages[j].role === "toolResult") {
|
||||||
|
const nextMsg = messages[j] as ToolResultMessage;
|
||||||
|
toolResults.push({
|
||||||
|
toolResult: {
|
||||||
|
toolUseId: nextMsg.toolCallId,
|
||||||
|
content: nextMsg.content.map((c) =>
|
||||||
|
c.type === "image"
|
||||||
|
? { image: createImageBlock(c.mimeType, c.data) }
|
||||||
|
: { text: sanitizeSurrogates(c.text) },
|
||||||
|
),
|
||||||
|
status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the messages we've already processed
|
||||||
|
i = j - 1;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
role: ConversationRole.USER,
|
||||||
|
content: toolResults,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown message role");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cache point to the last user message for supported Claude models
|
||||||
|
if (supportsPromptCaching(model) && result.length > 0) {
|
||||||
|
const lastMessage = result[result.length - 1];
|
||||||
|
if (lastMessage.role === ConversationRole.USER && lastMessage.content) {
|
||||||
|
(lastMessage.content as ContentBlock[]).push({ cachePoint: { type: CachePointType.DEFAULT } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToolConfig(
|
||||||
|
tools: Tool[] | undefined,
|
||||||
|
toolChoice: BedrockOptions["toolChoice"],
|
||||||
|
): ToolConfiguration | undefined {
|
||||||
|
if (!tools?.length || toolChoice === "none") return undefined;
|
||||||
|
|
||||||
|
const bedrockTools: BedrockTool[] = tools.map((tool) => ({
|
||||||
|
toolSpec: {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: { json: tool.parameters },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let bedrockToolChoice: ToolChoice | undefined;
|
||||||
|
switch (toolChoice) {
|
||||||
|
case "auto":
|
||||||
|
bedrockToolChoice = { auto: {} };
|
||||||
|
break;
|
||||||
|
case "any":
|
||||||
|
bedrockToolChoice = { any: {} };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (toolChoice?.type === "tool") {
|
||||||
|
bedrockToolChoice = { tool: { name: toolChoice.name } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tools: bedrockTools, toolChoice: bedrockToolChoice };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStopReason(reason: string | undefined): StopReason {
|
||||||
|
switch (reason) {
|
||||||
|
case BedrockStopReason.END_TURN:
|
||||||
|
case BedrockStopReason.STOP_SEQUENCE:
|
||||||
|
return "stop";
|
||||||
|
case BedrockStopReason.MAX_TOKENS:
|
||||||
|
case BedrockStopReason.MODEL_CONTEXT_WINDOW_EXCEEDED:
|
||||||
|
return "length";
|
||||||
|
case BedrockStopReason.TOOL_USE:
|
||||||
|
return "toolUse";
|
||||||
|
default:
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdditionalModelRequestFields(
|
||||||
|
model: Model<"bedrock-converse-stream">,
|
||||||
|
options: BedrockOptions,
|
||||||
|
): Record<string, any> | undefined {
|
||||||
|
if (!options.reasoning || !model.reasoning) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.id.includes("anthropic.claude")) {
|
||||||
|
const defaultBudgets: Record<ThinkingLevel, number> = {
|
||||||
|
minimal: 1024,
|
||||||
|
low: 2048,
|
||||||
|
medium: 8192,
|
||||||
|
high: 16384,
|
||||||
|
xhigh: 16384, // Claude doesn't support xhigh, clamp to high
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom budgets override defaults (xhigh not in ThinkingBudgets, use high)
|
||||||
|
const level = options.reasoning === "xhigh" ? "high" : options.reasoning;
|
||||||
|
const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[options.reasoning];
|
||||||
|
|
||||||
|
const result: Record<string, any> = {
|
||||||
|
thinking: {
|
||||||
|
type: "enabled",
|
||||||
|
budget_tokens: budget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.interleavedThinking) {
|
||||||
|
result.anthropic_beta = ["interleaved-thinking-2025-05-14"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImageBlock(mimeType: string, data: string) {
|
||||||
|
let format: ImageFormat;
|
||||||
|
switch (mimeType) {
|
||||||
|
case "image/jpeg":
|
||||||
|
case "image/jpg":
|
||||||
|
format = ImageFormat.JPEG;
|
||||||
|
break;
|
||||||
|
case "image/png":
|
||||||
|
format = ImageFormat.PNG;
|
||||||
|
break;
|
||||||
|
case "image/gif":
|
||||||
|
format = ImageFormat.GIF;
|
||||||
|
break;
|
||||||
|
case "image/webp":
|
||||||
|
format = ImageFormat.WEBP;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown image type: ${mimeType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryString = atob(data);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: { bytes }, format };
|
||||||
|
}
|
||||||
|
|
@ -287,7 +287,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||||
throw new Error("An unkown error ocurred");
|
throw new Error("An unknown error occurred");
|
||||||
}
|
}
|
||||||
|
|
||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
* Uses the Cloud Code Assist API endpoint to access Gemini and Claude models.
|
* Uses the Cloud Code Assist API endpoint to access Gemini and Claude models.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { Content, ThinkingConfig } from "@google/genai";
|
import type { Content, ThinkingConfig } from "@google/genai";
|
||||||
import { calculateCost } from "../models.js";
|
import { calculateCost } from "../models.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -54,6 +55,8 @@ export interface GoogleGeminiCliOptions extends StreamOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||||
|
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||||
|
const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, DEFAULT_ENDPOINT] as const;
|
||||||
// Headers for Gemini CLI (prod endpoint)
|
// Headers for Gemini CLI (prod endpoint)
|
||||||
const GEMINI_CLI_HEADERS = {
|
const GEMINI_CLI_HEADERS = {
|
||||||
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||||
|
|
@ -163,16 +166,66 @@ let toolCallCounter = 0;
|
||||||
// Retry configuration
|
// Retry configuration
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const BASE_DELAY_MS = 1000;
|
const BASE_DELAY_MS = 1000;
|
||||||
|
const MAX_EMPTY_STREAM_RETRIES = 2;
|
||||||
|
const EMPTY_STREAM_BASE_DELAY_MS = 500;
|
||||||
|
const CLAUDE_THINKING_BETA_HEADER = "interleaved-thinking-2025-05-14";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract retry delay from Gemini error response (in milliseconds).
|
* Extract retry delay from Gemini error response (in milliseconds).
|
||||||
* Parses patterns like:
|
* Checks headers first (Retry-After, x-ratelimit-reset, x-ratelimit-reset-after),
|
||||||
|
* then parses body patterns like:
|
||||||
* - "Your quota will reset after 39s"
|
* - "Your quota will reset after 39s"
|
||||||
* - "Your quota will reset after 18h31m10s"
|
* - "Your quota will reset after 18h31m10s"
|
||||||
* - "Please retry in Xs" or "Please retry in Xms"
|
* - "Please retry in Xs" or "Please retry in Xms"
|
||||||
* - "retryDelay": "34.074824224s" (JSON field)
|
* - "retryDelay": "34.074824224s" (JSON field)
|
||||||
*/
|
*/
|
||||||
function extractRetryDelay(errorText: string): number | undefined {
|
export function extractRetryDelay(errorText: string, response?: Response | Headers): number | undefined {
|
||||||
|
const normalizeDelay = (ms: number): number | undefined => (ms > 0 ? Math.ceil(ms + 1000) : undefined);
|
||||||
|
|
||||||
|
const headers = response instanceof Headers ? response : response?.headers;
|
||||||
|
if (headers) {
|
||||||
|
const retryAfter = headers.get("retry-after");
|
||||||
|
if (retryAfter) {
|
||||||
|
const retryAfterSeconds = Number(retryAfter);
|
||||||
|
if (Number.isFinite(retryAfterSeconds)) {
|
||||||
|
const delay = normalizeDelay(retryAfterSeconds * 1000);
|
||||||
|
if (delay !== undefined) {
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const retryAfterDate = new Date(retryAfter);
|
||||||
|
const retryAfterMs = retryAfterDate.getTime();
|
||||||
|
if (!Number.isNaN(retryAfterMs)) {
|
||||||
|
const delay = normalizeDelay(retryAfterMs - Date.now());
|
||||||
|
if (delay !== undefined) {
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitReset = headers.get("x-ratelimit-reset");
|
||||||
|
if (rateLimitReset) {
|
||||||
|
const resetSeconds = Number.parseInt(rateLimitReset, 10);
|
||||||
|
if (!Number.isNaN(resetSeconds)) {
|
||||||
|
const delay = normalizeDelay(resetSeconds * 1000 - Date.now());
|
||||||
|
if (delay !== undefined) {
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitResetAfter = headers.get("x-ratelimit-reset-after");
|
||||||
|
if (rateLimitResetAfter) {
|
||||||
|
const resetAfterSeconds = Number(rateLimitResetAfter);
|
||||||
|
if (Number.isFinite(resetAfterSeconds)) {
|
||||||
|
const delay = normalizeDelay(resetAfterSeconds * 1000);
|
||||||
|
if (delay !== undefined) {
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pattern 1: "Your quota will reset after ..." (formats: "18h31m10s", "10m15s", "6s", "39s")
|
// Pattern 1: "Your quota will reset after ..." (formats: "18h31m10s", "10m15s", "6s", "39s")
|
||||||
const durationMatch = errorText.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i);
|
const durationMatch = errorText.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i);
|
||||||
if (durationMatch) {
|
if (durationMatch) {
|
||||||
|
|
@ -181,8 +234,9 @@ function extractRetryDelay(errorText: string): number | undefined {
|
||||||
const seconds = parseFloat(durationMatch[3]);
|
const seconds = parseFloat(durationMatch[3]);
|
||||||
if (!Number.isNaN(seconds)) {
|
if (!Number.isNaN(seconds)) {
|
||||||
const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000;
|
const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000;
|
||||||
if (totalMs > 0) {
|
const delay = normalizeDelay(totalMs);
|
||||||
return Math.ceil(totalMs + 1000); // Add 1s buffer
|
if (delay !== undefined) {
|
||||||
|
return delay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +247,10 @@ function extractRetryDelay(errorText: string): number | undefined {
|
||||||
const value = parseFloat(retryInMatch[1]);
|
const value = parseFloat(retryInMatch[1]);
|
||||||
if (!Number.isNaN(value) && value > 0) {
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
const ms = retryInMatch[2].toLowerCase() === "ms" ? value : value * 1000;
|
const ms = retryInMatch[2].toLowerCase() === "ms" ? value : value * 1000;
|
||||||
return Math.ceil(ms + 1000);
|
const delay = normalizeDelay(ms);
|
||||||
|
if (delay !== undefined) {
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,21 +260,45 @@ function extractRetryDelay(errorText: string): number | undefined {
|
||||||
const value = parseFloat(retryDelayMatch[1]);
|
const value = parseFloat(retryDelayMatch[1]);
|
||||||
if (!Number.isNaN(value) && value > 0) {
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
const ms = retryDelayMatch[2].toLowerCase() === "ms" ? value : value * 1000;
|
const ms = retryDelayMatch[2].toLowerCase() === "ms" ? value : value * 1000;
|
||||||
return Math.ceil(ms + 1000);
|
const delay = normalizeDelay(ms);
|
||||||
|
if (delay !== undefined) {
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isClaudeThinkingModel(modelId: string): boolean {
|
||||||
|
const normalized = modelId.toLowerCase();
|
||||||
|
return normalized.includes("claude") && normalized.includes("thinking");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is retryable (rate limit, server error, etc.)
|
* Check if an error is retryable (rate limit, server error, network error, etc.)
|
||||||
*/
|
*/
|
||||||
function isRetryableError(status: number, errorText: string): boolean {
|
function isRetryableError(status: number, errorText: string): boolean {
|
||||||
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
|
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return /resource.?exhausted|rate.?limit|overloaded|service.?unavailable/i.test(errorText);
|
return /resource.?exhausted|rate.?limit|overloaded|service.?unavailable|other.?side.?closed/i.test(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a clean, user-friendly error message from Google API error response.
|
||||||
|
* Parses JSON error responses and returns just the message field.
|
||||||
|
*/
|
||||||
|
function extractErrorMessage(errorText: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(errorText) as { error?: { message?: string } };
|
||||||
|
if (parsed.error?.message) {
|
||||||
|
return parsed.error.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, return as-is
|
||||||
|
}
|
||||||
|
return errorText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -242,6 +323,7 @@ interface CloudCodeAssistRequest {
|
||||||
model: string;
|
model: string;
|
||||||
request: {
|
request: {
|
||||||
contents: Content[];
|
contents: Content[];
|
||||||
|
sessionId?: string;
|
||||||
systemInstruction?: { role?: string; parts: { text: string }[] };
|
systemInstruction?: { role?: string; parts: { text: string }[] };
|
||||||
generationConfig?: {
|
generationConfig?: {
|
||||||
maxOutputTokens?: number;
|
maxOutputTokens?: number;
|
||||||
|
|
@ -339,17 +421,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||||
throw new Error("Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate.");
|
throw new Error("Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = model.baseUrl || DEFAULT_ENDPOINT;
|
const isAntigravity = model.provider === "google-antigravity";
|
||||||
const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
|
const baseUrl = model.baseUrl?.trim();
|
||||||
|
const endpoints = baseUrl ? [baseUrl] : isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
|
||||||
|
|
||||||
// Use Antigravity headers for sandbox endpoint, otherwise Gemini CLI headers
|
|
||||||
const isAntigravity = endpoint.includes("sandbox.googleapis.com");
|
|
||||||
const requestBody = buildRequest(model, context, projectId, options, isAntigravity);
|
const requestBody = buildRequest(model, context, projectId, options, isAntigravity);
|
||||||
const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
|
const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
|
||||||
|
|
||||||
|
const requestHeaders = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
...headers,
|
||||||
|
...(isClaudeThinkingModel(model.id) ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}),
|
||||||
|
};
|
||||||
|
const requestBodyJson = JSON.stringify(requestBody);
|
||||||
|
|
||||||
// Fetch with retry logic for rate limits and transient errors
|
// Fetch with retry logic for rate limits and transient errors
|
||||||
let response: Response | undefined;
|
let response: Response | undefined;
|
||||||
let lastError: Error | undefined;
|
let lastError: Error | undefined;
|
||||||
|
let requestUrl: string | undefined;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
if (options?.signal?.aborted) {
|
if (options?.signal?.aborted) {
|
||||||
|
|
@ -357,15 +448,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
const endpoint = endpoints[Math.min(attempt, endpoints.length - 1)];
|
||||||
|
requestUrl = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
|
||||||
|
response = await fetch(requestUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
Authorization: `Bearer ${accessToken}`,
|
body: requestBodyJson,
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -378,14 +466,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||||
// Check if retryable
|
// Check if retryable
|
||||||
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||||
// Use server-provided delay or exponential backoff
|
// Use server-provided delay or exponential backoff
|
||||||
const serverDelay = extractRetryDelay(errorText);
|
const serverDelay = extractRetryDelay(errorText, response);
|
||||||
const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt;
|
const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt;
|
||||||
await sleep(delayMs, options?.signal);
|
await sleep(delayMs, options?.signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not retryable or max retries exceeded
|
// Not retryable or max retries exceeded
|
||||||
throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
|
throw new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check for abort - fetch throws AbortError, our code throws "Request was aborted"
|
// Check for abort - fetch throws AbortError, our code throws "Request was aborted"
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|
@ -393,7 +481,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||||
throw new Error("Request was aborted");
|
throw new Error("Request was aborted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Extract detailed error message from fetch errors (Node includes cause)
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
if (lastError.message === "fetch failed" && lastError.cause instanceof Error) {
|
||||||
|
lastError = new Error(`Network error: ${lastError.cause.message}`);
|
||||||
|
}
|
||||||
// Network errors are retryable
|
// Network errors are retryable
|
||||||
if (attempt < MAX_RETRIES) {
|
if (attempt < MAX_RETRIES) {
|
||||||
const delayMs = BASE_DELAY_MS * 2 ** attempt;
|
const delayMs = BASE_DELAY_MS * 2 ** attempt;
|
||||||
|
|
@ -408,73 +500,160 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||||
throw lastError ?? new Error("Failed to get response after retries");
|
throw lastError ?? new Error("Failed to get response after retries");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
let started = false;
|
||||||
throw new Error("No response body");
|
const ensureStarted = () => {
|
||||||
}
|
if (!started) {
|
||||||
|
stream.push({ type: "start", partial: output });
|
||||||
stream.push({ type: "start", partial: output });
|
started = true;
|
||||||
|
}
|
||||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
|
||||||
const blocks = output.content;
|
|
||||||
const blockIndex = () => blocks.length - 1;
|
|
||||||
|
|
||||||
// Read SSE stream
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
// Set up abort handler to cancel reader when signal fires
|
|
||||||
const abortHandler = () => {
|
|
||||||
void reader.cancel().catch(() => {});
|
|
||||||
};
|
};
|
||||||
options?.signal?.addEventListener("abort", abortHandler);
|
|
||||||
|
|
||||||
try {
|
const resetOutput = () => {
|
||||||
while (true) {
|
output.content = [];
|
||||||
// Check abort signal before each read
|
output.usage = {
|
||||||
if (options?.signal?.aborted) {
|
input: 0,
|
||||||
throw new Error("Request was aborted");
|
output: 0,
|
||||||
}
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
};
|
||||||
|
output.stopReason = "stop";
|
||||||
|
output.errorMessage = undefined;
|
||||||
|
output.timestamp = Date.now();
|
||||||
|
started = false;
|
||||||
|
};
|
||||||
|
|
||||||
const { done, value } = await reader.read();
|
const streamResponse = async (activeResponse: Response): Promise<boolean> => {
|
||||||
if (done) break;
|
if (!activeResponse.body) {
|
||||||
|
throw new Error("No response body");
|
||||||
|
}
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
let hasContent = false;
|
||||||
const lines = buffer.split("\n");
|
let currentBlock: TextContent | ThinkingContent | null = null;
|
||||||
buffer = lines.pop() || "";
|
const blocks = output.content;
|
||||||
|
const blockIndex = () => blocks.length - 1;
|
||||||
|
|
||||||
for (const line of lines) {
|
// Read SSE stream
|
||||||
if (!line.startsWith("data:")) continue;
|
const reader = activeResponse.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
const jsonStr = line.slice(5).trim();
|
// Set up abort handler to cancel reader when signal fires
|
||||||
if (!jsonStr) continue;
|
const abortHandler = () => {
|
||||||
|
void reader.cancel().catch(() => {});
|
||||||
|
};
|
||||||
|
options?.signal?.addEventListener("abort", abortHandler);
|
||||||
|
|
||||||
let chunk: CloudCodeAssistResponseChunk;
|
try {
|
||||||
try {
|
while (true) {
|
||||||
chunk = JSON.parse(jsonStr);
|
// Check abort signal before each read
|
||||||
} catch {
|
if (options?.signal?.aborted) {
|
||||||
continue;
|
throw new Error("Request was aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap the response
|
const { done, value } = await reader.read();
|
||||||
const responseData = chunk.response;
|
if (done) break;
|
||||||
if (!responseData) continue;
|
|
||||||
|
|
||||||
const candidate = responseData.candidates?.[0];
|
buffer += decoder.decode(value, { stream: true });
|
||||||
if (candidate?.content?.parts) {
|
const lines = buffer.split("\n");
|
||||||
for (const part of candidate.content.parts) {
|
buffer = lines.pop() || "";
|
||||||
if (part.text !== undefined) {
|
|
||||||
const isThinking = isThinkingPart(part);
|
for (const line of lines) {
|
||||||
if (
|
if (!line.startsWith("data:")) continue;
|
||||||
!currentBlock ||
|
|
||||||
(isThinking && currentBlock.type !== "thinking") ||
|
const jsonStr = line.slice(5).trim();
|
||||||
(!isThinking && currentBlock.type !== "text")
|
if (!jsonStr) continue;
|
||||||
) {
|
|
||||||
|
let chunk: CloudCodeAssistResponseChunk;
|
||||||
|
try {
|
||||||
|
chunk = JSON.parse(jsonStr);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap the response
|
||||||
|
const responseData = chunk.response;
|
||||||
|
if (!responseData) continue;
|
||||||
|
|
||||||
|
const candidate = responseData.candidates?.[0];
|
||||||
|
if (candidate?.content?.parts) {
|
||||||
|
for (const part of candidate.content.parts) {
|
||||||
|
if (part.text !== undefined) {
|
||||||
|
hasContent = true;
|
||||||
|
const isThinking = isThinkingPart(part);
|
||||||
|
if (
|
||||||
|
!currentBlock ||
|
||||||
|
(isThinking && currentBlock.type !== "thinking") ||
|
||||||
|
(!isThinking && currentBlock.type !== "text")
|
||||||
|
) {
|
||||||
|
if (currentBlock) {
|
||||||
|
if (currentBlock.type === "text") {
|
||||||
|
stream.push({
|
||||||
|
type: "text_end",
|
||||||
|
contentIndex: blocks.length - 1,
|
||||||
|
content: currentBlock.text,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_end",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
content: currentBlock.thinking,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isThinking) {
|
||||||
|
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
|
||||||
|
output.content.push(currentBlock);
|
||||||
|
ensureStarted();
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_start",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentBlock = { type: "text", text: "" };
|
||||||
|
output.content.push(currentBlock);
|
||||||
|
ensureStarted();
|
||||||
|
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentBlock.type === "thinking") {
|
||||||
|
currentBlock.thinking += part.text;
|
||||||
|
currentBlock.thinkingSignature = retainThoughtSignature(
|
||||||
|
currentBlock.thinkingSignature,
|
||||||
|
part.thoughtSignature,
|
||||||
|
);
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_delta",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
delta: part.text,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentBlock.text += part.text;
|
||||||
|
currentBlock.textSignature = retainThoughtSignature(
|
||||||
|
currentBlock.textSignature,
|
||||||
|
part.thoughtSignature,
|
||||||
|
);
|
||||||
|
stream.push({
|
||||||
|
type: "text_delta",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
delta: part.text,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.functionCall) {
|
||||||
|
hasContent = true;
|
||||||
if (currentBlock) {
|
if (currentBlock) {
|
||||||
if (currentBlock.type === "text") {
|
if (currentBlock.type === "text") {
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_end",
|
type: "text_end",
|
||||||
contentIndex: blocks.length - 1,
|
contentIndex: blockIndex(),
|
||||||
content: currentBlock.text,
|
content: currentBlock.text,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
|
|
@ -486,143 +665,142 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
currentBlock = null;
|
||||||
}
|
}
|
||||||
if (isThinking) {
|
|
||||||
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
|
const providedId = part.functionCall.id;
|
||||||
output.content.push(currentBlock);
|
const needsNewId =
|
||||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
!providedId ||
|
||||||
} else {
|
output.content.some((b) => b.type === "toolCall" && b.id === providedId);
|
||||||
currentBlock = { type: "text", text: "" };
|
const toolCallId = needsNewId
|
||||||
output.content.push(currentBlock);
|
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
|
||||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
: providedId;
|
||||||
}
|
|
||||||
}
|
const toolCall: ToolCall = {
|
||||||
if (currentBlock.type === "thinking") {
|
type: "toolCall",
|
||||||
currentBlock.thinking += part.text;
|
id: toolCallId,
|
||||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
name: part.functionCall.name || "",
|
||||||
currentBlock.thinkingSignature,
|
arguments: part.functionCall.args as Record<string, unknown>,
|
||||||
part.thoughtSignature,
|
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
||||||
);
|
};
|
||||||
|
|
||||||
|
output.content.push(toolCall);
|
||||||
|
ensureStarted();
|
||||||
|
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "thinking_delta",
|
type: "toolcall_delta",
|
||||||
contentIndex: blockIndex(),
|
contentIndex: blockIndex(),
|
||||||
delta: part.text,
|
delta: JSON.stringify(toolCall.arguments),
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
currentBlock.text += part.text;
|
|
||||||
currentBlock.textSignature = retainThoughtSignature(
|
|
||||||
currentBlock.textSignature,
|
|
||||||
part.thoughtSignature,
|
|
||||||
);
|
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_delta",
|
type: "toolcall_end",
|
||||||
contentIndex: blockIndex(),
|
contentIndex: blockIndex(),
|
||||||
delta: part.text,
|
toolCall,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (part.functionCall) {
|
if (candidate?.finishReason) {
|
||||||
if (currentBlock) {
|
output.stopReason = mapStopReasonString(candidate.finishReason);
|
||||||
if (currentBlock.type === "text") {
|
if (output.content.some((b) => b.type === "toolCall")) {
|
||||||
stream.push({
|
output.stopReason = "toolUse";
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
currentBlock = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providedId = part.functionCall.id;
|
|
||||||
const needsNewId =
|
|
||||||
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
|
|
||||||
const toolCallId = needsNewId
|
|
||||||
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
|
|
||||||
: providedId;
|
|
||||||
|
|
||||||
const toolCall: ToolCall = {
|
|
||||||
type: "toolCall",
|
|
||||||
id: toolCallId,
|
|
||||||
name: part.functionCall.name || "",
|
|
||||||
arguments: part.functionCall.args as Record<string, unknown>,
|
|
||||||
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
|
||||||
};
|
|
||||||
|
|
||||||
output.content.push(toolCall);
|
|
||||||
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
delta: JSON.stringify(toolCall.arguments),
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate?.finishReason) {
|
if (responseData.usageMetadata) {
|
||||||
output.stopReason = mapStopReasonString(candidate.finishReason);
|
// promptTokenCount includes cachedContentTokenCount, so subtract to get fresh input
|
||||||
if (output.content.some((b) => b.type === "toolCall")) {
|
const promptTokens = responseData.usageMetadata.promptTokenCount || 0;
|
||||||
output.stopReason = "toolUse";
|
const cacheReadTokens = responseData.usageMetadata.cachedContentTokenCount || 0;
|
||||||
}
|
output.usage = {
|
||||||
}
|
input: promptTokens - cacheReadTokens,
|
||||||
|
output:
|
||||||
if (responseData.usageMetadata) {
|
(responseData.usageMetadata.candidatesTokenCount || 0) +
|
||||||
// promptTokenCount includes cachedContentTokenCount, so subtract to get fresh input
|
(responseData.usageMetadata.thoughtsTokenCount || 0),
|
||||||
const promptTokens = responseData.usageMetadata.promptTokenCount || 0;
|
cacheRead: cacheReadTokens,
|
||||||
const cacheReadTokens = responseData.usageMetadata.cachedContentTokenCount || 0;
|
|
||||||
output.usage = {
|
|
||||||
input: promptTokens - cacheReadTokens,
|
|
||||||
output:
|
|
||||||
(responseData.usageMetadata.candidatesTokenCount || 0) +
|
|
||||||
(responseData.usageMetadata.thoughtsTokenCount || 0),
|
|
||||||
cacheRead: cacheReadTokens,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: responseData.usageMetadata.totalTokenCount || 0,
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
total: 0,
|
totalTokens: responseData.usageMetadata.totalTokenCount || 0,
|
||||||
},
|
cost: {
|
||||||
};
|
input: 0,
|
||||||
calculateCost(model, output.usage);
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
calculateCost(model, output.usage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
options?.signal?.removeEventListener("abort", abortHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBlock) {
|
||||||
|
if (currentBlock.type === "text") {
|
||||||
|
stream.push({
|
||||||
|
type: "text_end",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
content: currentBlock.text,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_end",
|
||||||
|
contentIndex: blockIndex(),
|
||||||
|
content: currentBlock.thinking,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
let receivedContent = false;
|
||||||
|
let currentResponse = response;
|
||||||
|
|
||||||
|
for (let emptyAttempt = 0; emptyAttempt <= MAX_EMPTY_STREAM_RETRIES; emptyAttempt++) {
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw new Error("Request was aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyAttempt > 0) {
|
||||||
|
const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1);
|
||||||
|
await sleep(backoffMs, options?.signal);
|
||||||
|
|
||||||
|
if (!requestUrl) {
|
||||||
|
throw new Error("Missing request URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
currentResponse = await fetch(requestUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: requestBodyJson,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentResponse.ok) {
|
||||||
|
const retryErrorText = await currentResponse.text();
|
||||||
|
throw new Error(`Cloud Code Assist API error (${currentResponse.status}): ${retryErrorText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamed = await streamResponse(currentResponse);
|
||||||
|
if (streamed) {
|
||||||
|
receivedContent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) {
|
||||||
|
resetOutput();
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
options?.signal?.removeEventListener("abort", abortHandler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentBlock) {
|
if (!receivedContent) {
|
||||||
if (currentBlock.type === "text") {
|
throw new Error("Cloud Code Assist API returned an empty response");
|
||||||
stream.push({
|
|
||||||
type: "text_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.text,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_end",
|
|
||||||
contentIndex: blockIndex(),
|
|
||||||
content: currentBlock.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.signal?.aborted) {
|
if (options?.signal?.aborted) {
|
||||||
|
|
@ -651,7 +829,34 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||||
return stream;
|
return stream;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildRequest(
|
function deriveSessionId(context: Context): string | undefined {
|
||||||
|
for (const message of context.messages) {
|
||||||
|
if (message.role !== "user") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
text = message.content;
|
||||||
|
} else if (Array.isArray(message.content)) {
|
||||||
|
text = message.content
|
||||||
|
.filter((item): item is TextContent => item.type === "text")
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = createHash("sha256").update(text).digest("hex");
|
||||||
|
return hash.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequest(
|
||||||
model: Model<"google-gemini-cli">,
|
model: Model<"google-gemini-cli">,
|
||||||
context: Context,
|
context: Context,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -686,6 +891,11 @@ function buildRequest(
|
||||||
contents,
|
contents,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sessionId = deriveSessionId(context);
|
||||||
|
if (sessionId) {
|
||||||
|
request.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
// System instruction must be object with parts, not plain string
|
// System instruction must be object with parts, not plain string
|
||||||
if (context.systemPrompt) {
|
if (context.systemPrompt) {
|
||||||
request.systemInstruction = {
|
request.systemInstruction = {
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,7 @@ function createClient(model: Model<"openai-completions">, context: Context, apiK
|
||||||
function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) {
|
function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) {
|
||||||
const compat = getCompat(model);
|
const compat = getCompat(model);
|
||||||
const messages = convertMessages(model, context, compat);
|
const messages = convertMessages(model, context, compat);
|
||||||
|
maybeAddOpenRouterAnthropicCacheControl(model, messages);
|
||||||
|
|
||||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
|
|
@ -403,13 +404,51 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
|
||||||
params.tool_choice = options.toolChoice;
|
params.tool_choice = options.toolChoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
|
if (compat.thinkingFormat === "zai" && model.reasoning) {
|
||||||
|
// Z.ai uses binary thinking: { type: "enabled" | "disabled" }
|
||||||
|
// Must explicitly disable since z.ai defaults to thinking enabled
|
||||||
|
(params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
|
||||||
|
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
|
||||||
|
// OpenAI-style reasoning_effort
|
||||||
params.reasoning_effort = options.reasoningEffort;
|
params.reasoning_effort = options.reasoningEffort;
|
||||||
}
|
}
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeAddOpenRouterAnthropicCacheControl(
|
||||||
|
model: Model<"openai-completions">,
|
||||||
|
messages: ChatCompletionMessageParam[],
|
||||||
|
): void {
|
||||||
|
if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/")) return;
|
||||||
|
|
||||||
|
// Anthropic-style caching requires cache_control on a text part. Add a breakpoint
|
||||||
|
// on the last user/assistant message (walking backwards until we find text content).
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
||||||
|
|
||||||
|
const content = msg.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
msg.content = [
|
||||||
|
Object.assign({ type: "text" as const, text: content }, { cache_control: { type: "ephemeral" } }),
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
|
||||||
|
// Find last text part and add cache_control
|
||||||
|
for (let j = content.length - 1; j >= 0; j--) {
|
||||||
|
const part = content[j];
|
||||||
|
if (part?.type === "text") {
|
||||||
|
Object.assign(part, { cache_control: { type: "ephemeral" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function convertMessages(
|
function convertMessages(
|
||||||
model: Model<"openai-completions">,
|
model: Model<"openai-completions">,
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -644,11 +683,14 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto
|
||||||
* Returns a fully resolved OpenAICompat object with all fields set.
|
* Returns a fully resolved OpenAICompat object with all fields set.
|
||||||
*/
|
*/
|
||||||
function detectCompatFromUrl(baseUrl: string): Required<OpenAICompat> {
|
function detectCompatFromUrl(baseUrl: string): Required<OpenAICompat> {
|
||||||
|
const isZai = baseUrl.includes("api.z.ai");
|
||||||
|
|
||||||
const isNonStandard =
|
const isNonStandard =
|
||||||
baseUrl.includes("cerebras.ai") ||
|
baseUrl.includes("cerebras.ai") ||
|
||||||
baseUrl.includes("api.x.ai") ||
|
baseUrl.includes("api.x.ai") ||
|
||||||
baseUrl.includes("mistral.ai") ||
|
baseUrl.includes("mistral.ai") ||
|
||||||
baseUrl.includes("chutes.ai");
|
baseUrl.includes("chutes.ai") ||
|
||||||
|
isZai;
|
||||||
|
|
||||||
const useMaxTokens = baseUrl.includes("mistral.ai") || baseUrl.includes("chutes.ai");
|
const useMaxTokens = baseUrl.includes("mistral.ai") || baseUrl.includes("chutes.ai");
|
||||||
|
|
||||||
|
|
@ -659,13 +701,14 @@ function detectCompatFromUrl(baseUrl: string): Required<OpenAICompat> {
|
||||||
return {
|
return {
|
||||||
supportsStore: !isNonStandard,
|
supportsStore: !isNonStandard,
|
||||||
supportsDeveloperRole: !isNonStandard,
|
supportsDeveloperRole: !isNonStandard,
|
||||||
supportsReasoningEffort: !isGrok,
|
supportsReasoningEffort: !isGrok && !isZai,
|
||||||
supportsUsageInStreaming: true,
|
supportsUsageInStreaming: true,
|
||||||
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
|
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
|
||||||
requiresToolResultName: isMistral,
|
requiresToolResultName: isMistral,
|
||||||
requiresAssistantAfterToolResult: false, // Mistral no longer requires this as of Dec 2024
|
requiresAssistantAfterToolResult: false, // Mistral no longer requires this as of Dec 2024
|
||||||
requiresThinkingAsText: isMistral,
|
requiresThinkingAsText: isMistral,
|
||||||
requiresMistralToolIds: isMistral,
|
requiresMistralToolIds: isMistral,
|
||||||
|
thinkingFormat: isZai ? "zai" : "openai",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,5 +731,6 @@ function getCompat(model: Model<"openai-completions">): Required<OpenAICompat> {
|
||||||
model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult,
|
model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult,
|
||||||
requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
|
requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
|
||||||
requiresMistralToolIds: model.compat.requiresMistralToolIds ?? detected.requiresMistralToolIds,
|
requiresMistralToolIds: model.compat.requiresMistralToolIds ?? detected.requiresMistralToolIds,
|
||||||
|
thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import type {
|
||||||
ThinkingContent,
|
ThinkingContent,
|
||||||
Tool,
|
Tool,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
|
Usage,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||||
|
|
@ -48,6 +49,7 @@ function shortHash(str: string): string {
|
||||||
export interface OpenAIResponsesOptions extends StreamOptions {
|
export interface OpenAIResponsesOptions extends StreamOptions {
|
||||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||||
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
||||||
|
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -85,7 +87,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
||||||
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
||||||
const client = createClient(model, context, apiKey);
|
const client = createClient(model, context, apiKey);
|
||||||
const params = buildParams(model, context, options);
|
const params = buildParams(model, context, options);
|
||||||
const openaiStream = await client.responses.create(params, { signal: options?.signal });
|
const openaiStream = await client.responses.create(params, { signal: options?.signal, timeout: undefined });
|
||||||
stream.push({ type: "start", partial: output });
|
stream.push({ type: "start", partial: output });
|
||||||
|
|
||||||
let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null;
|
let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null;
|
||||||
|
|
@ -276,6 +278,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
calculateCost(model, output.usage);
|
calculateCost(model, output.usage);
|
||||||
|
applyServiceTierPricing(output.usage, response?.service_tier ?? options?.serviceTier);
|
||||||
// Map status to stop reason
|
// Map status to stop reason
|
||||||
output.stopReason = mapStopReason(response?.status);
|
output.stopReason = mapStopReason(response?.status);
|
||||||
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
|
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
|
||||||
|
|
@ -363,6 +366,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
|
||||||
model: model.id,
|
model: model.id,
|
||||||
input: messages,
|
input: messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
prompt_cache_key: options?.sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.maxTokens) {
|
if (options?.maxTokens) {
|
||||||
|
|
@ -373,6 +377,10 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
|
||||||
params.temperature = options?.temperature;
|
params.temperature = options?.temperature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.serviceTier !== undefined) {
|
||||||
|
params.service_tier = options.serviceTier;
|
||||||
|
}
|
||||||
|
|
||||||
if (context.tools) {
|
if (context.tools) {
|
||||||
params.tools = convertTools(context.tools);
|
params.tools = convertTools(context.tools);
|
||||||
}
|
}
|
||||||
|
|
@ -547,6 +555,28 @@ function convertTools(tools: Tool[]): OpenAITool[] {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getServiceTierCostMultiplier(serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined): number {
|
||||||
|
switch (serviceTier) {
|
||||||
|
case "flex":
|
||||||
|
return 0.5;
|
||||||
|
case "priority":
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyServiceTierPricing(usage: Usage, serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined) {
|
||||||
|
const multiplier = getServiceTierCostMultiplier(serviceTier);
|
||||||
|
if (multiplier === 1) return;
|
||||||
|
|
||||||
|
usage.cost.input *= multiplier;
|
||||||
|
usage.cost.output *= multiplier;
|
||||||
|
usage.cost.cacheRead *= multiplier;
|
||||||
|
usage.cost.cacheWrite *= multiplier;
|
||||||
|
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||||
|
}
|
||||||
|
|
||||||
function mapStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason {
|
function mapStopReason(status: OpenAI.Responses.ResponseStatus | undefined): StopReason {
|
||||||
if (!status) return "stop";
|
if (!status) return "stop";
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../types.js";
|
import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize tool call ID for GitHub Copilot cross-API compatibility.
|
* Normalize tool call ID for cross-provider compatibility.
|
||||||
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
||||||
* Other APIs (Claude, etc.) require max 40 chars and only alphanumeric + underscore + hyphen.
|
* Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
|
||||||
*/
|
*/
|
||||||
function normalizeCopilotToolCallId(id: string): string {
|
function normalizeToolCallId(id: string): string {
|
||||||
return id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
|
return id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,11 +38,17 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to normalize tool call IDs (github-copilot cross-API)
|
// Check if we need to normalize tool call IDs
|
||||||
const needsToolCallIdNormalization =
|
// Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars)
|
||||||
|
// OpenAI Responses API generates IDs with `|` and 450+ chars
|
||||||
|
// GitHub Copilot routes to Anthropic for Claude models
|
||||||
|
const targetRequiresStrictIds = model.api === "anthropic-messages" || model.provider === "github-copilot";
|
||||||
|
const crossProviderSwitch = assistantMsg.provider !== model.provider;
|
||||||
|
const copilotCrossApiSwitch =
|
||||||
assistantMsg.provider === "github-copilot" &&
|
assistantMsg.provider === "github-copilot" &&
|
||||||
model.provider === "github-copilot" &&
|
model.provider === "github-copilot" &&
|
||||||
assistantMsg.api !== model.api;
|
assistantMsg.api !== model.api;
|
||||||
|
const needsToolCallIdNormalization = targetRequiresStrictIds && (crossProviderSwitch || copilotCrossApiSwitch);
|
||||||
|
|
||||||
// Transform message from different provider/model
|
// Transform message from different provider/model
|
||||||
const transformedContent = assistantMsg.content.flatMap((block) => {
|
const transformedContent = assistantMsg.content.flatMap((block) => {
|
||||||
|
|
@ -54,10 +60,10 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
|
||||||
text: block.thinking,
|
text: block.thinking,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Normalize tool call IDs for github-copilot cross-API switches
|
// Normalize tool call IDs when target API requires strict format
|
||||||
if (block.type === "toolCall" && needsToolCallIdNormalization) {
|
if (block.type === "toolCall" && needsToolCallIdNormalization) {
|
||||||
const toolCall = block as ToolCall;
|
const toolCall = block as ToolCall;
|
||||||
const normalizedId = normalizeCopilotToolCallId(toolCall.id);
|
const normalizedId = normalizeToolCallId(toolCall.id);
|
||||||
if (normalizedId !== toolCall.id) {
|
if (normalizedId !== toolCall.id) {
|
||||||
toolCallIdMap.set(toolCall.id, normalizedId);
|
toolCallIdMap.set(toolCall.id, normalizedId);
|
||||||
return { ...toolCall, id: normalizedId };
|
return { ...toolCall, id: normalizedId };
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { supportsXhigh } from "./models.js";
|
import { supportsXhigh } from "./models.js";
|
||||||
|
import { type BedrockOptions, streamBedrock } from "./providers/amazon-bedrock.js";
|
||||||
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js";
|
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js";
|
||||||
import { type GoogleOptions, streamGoogle } from "./providers/google.js";
|
import { type GoogleOptions, streamGoogle } from "./providers/google.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -74,6 +75,20 @@ export function getEnvApiKey(provider: any): string | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "amazon-bedrock") {
|
||||||
|
// Amazon Bedrock supports multiple credential sources:
|
||||||
|
// 1. AWS_PROFILE - named profile from ~/.aws/credentials
|
||||||
|
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
|
||||||
|
// 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token)
|
||||||
|
if (
|
||||||
|
process.env.AWS_PROFILE ||
|
||||||
|
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
|
||||||
|
process.env.AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
) {
|
||||||
|
return "<authenticated>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const envMap: Record<string, string> = {
|
const envMap: Record<string, string> = {
|
||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
google: "GEMINI_API_KEY",
|
google: "GEMINI_API_KEY",
|
||||||
|
|
@ -81,8 +96,10 @@ export function getEnvApiKey(provider: any): string | undefined {
|
||||||
cerebras: "CEREBRAS_API_KEY",
|
cerebras: "CEREBRAS_API_KEY",
|
||||||
xai: "XAI_API_KEY",
|
xai: "XAI_API_KEY",
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
|
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
||||||
zai: "ZAI_API_KEY",
|
zai: "ZAI_API_KEY",
|
||||||
mistral: "MISTRAL_API_KEY",
|
mistral: "MISTRAL_API_KEY",
|
||||||
|
minimax: "MINIMAX_API_KEY",
|
||||||
opencode: "OPENCODE_API_KEY",
|
opencode: "OPENCODE_API_KEY",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -98,6 +115,9 @@ export function stream<TApi extends Api>(
|
||||||
// Vertex AI uses Application Default Credentials, not API keys
|
// Vertex AI uses Application Default Credentials, not API keys
|
||||||
if (model.api === "google-vertex") {
|
if (model.api === "google-vertex") {
|
||||||
return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions);
|
return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions);
|
||||||
|
} else if (model.api === "bedrock-converse-stream") {
|
||||||
|
// Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
|
||||||
|
return streamBedrock(model as Model<"bedrock-converse-stream">, context, (options || {}) as BedrockOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
||||||
|
|
@ -156,6 +176,10 @@ export function streamSimple<TApi extends Api>(
|
||||||
if (model.api === "google-vertex") {
|
if (model.api === "google-vertex") {
|
||||||
const providerOptions = mapOptionsForApi(model, options, undefined);
|
const providerOptions = mapOptionsForApi(model, options, undefined);
|
||||||
return stream(model, context, providerOptions);
|
return stream(model, context, providerOptions);
|
||||||
|
} else if (model.api === "bedrock-converse-stream") {
|
||||||
|
// Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
|
||||||
|
const providerOptions = mapOptionsForApi(model, options, undefined);
|
||||||
|
return stream(model, context, providerOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
|
||||||
|
|
@ -228,6 +252,13 @@ function mapOptionsForApi<TApi extends Api>(
|
||||||
} satisfies AnthropicOptions;
|
} satisfies AnthropicOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "bedrock-converse-stream":
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
reasoning: options?.reasoning,
|
||||||
|
thinkingBudgets: options?.thinkingBudgets,
|
||||||
|
} satisfies BedrockOptions;
|
||||||
|
|
||||||
case "openai-completions":
|
case "openai-completions":
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { BedrockOptions } from "./providers/amazon-bedrock.js";
|
||||||
import type { AnthropicOptions } from "./providers/anthropic.js";
|
import type { AnthropicOptions } from "./providers/anthropic.js";
|
||||||
import type { GoogleOptions } from "./providers/google.js";
|
import type { GoogleOptions } from "./providers/google.js";
|
||||||
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli.js";
|
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli.js";
|
||||||
|
|
@ -14,12 +15,14 @@ export type Api =
|
||||||
| "openai-responses"
|
| "openai-responses"
|
||||||
| "openai-codex-responses"
|
| "openai-codex-responses"
|
||||||
| "anthropic-messages"
|
| "anthropic-messages"
|
||||||
|
| "bedrock-converse-stream"
|
||||||
| "google-generative-ai"
|
| "google-generative-ai"
|
||||||
| "google-gemini-cli"
|
| "google-gemini-cli"
|
||||||
| "google-vertex";
|
| "google-vertex";
|
||||||
|
|
||||||
export interface ApiOptionsMap {
|
export interface ApiOptionsMap {
|
||||||
"anthropic-messages": AnthropicOptions;
|
"anthropic-messages": AnthropicOptions;
|
||||||
|
"bedrock-converse-stream": BedrockOptions;
|
||||||
"openai-completions": OpenAICompletionsOptions;
|
"openai-completions": OpenAICompletionsOptions;
|
||||||
"openai-responses": OpenAIResponsesOptions;
|
"openai-responses": OpenAIResponsesOptions;
|
||||||
"openai-codex-responses": OpenAICodexResponsesOptions;
|
"openai-codex-responses": OpenAICodexResponsesOptions;
|
||||||
|
|
@ -40,6 +43,7 @@ const _exhaustive: _CheckExhaustive = true;
|
||||||
export type OptionsForApi<TApi extends Api> = ApiOptionsMap[TApi];
|
export type OptionsForApi<TApi extends Api> = ApiOptionsMap[TApi];
|
||||||
|
|
||||||
export type KnownProvider =
|
export type KnownProvider =
|
||||||
|
| "amazon-bedrock"
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "google"
|
| "google"
|
||||||
| "google-gemini-cli"
|
| "google-gemini-cli"
|
||||||
|
|
@ -52,8 +56,10 @@ export type KnownProvider =
|
||||||
| "groq"
|
| "groq"
|
||||||
| "cerebras"
|
| "cerebras"
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
|
| "vercel-ai-gateway"
|
||||||
| "zai"
|
| "zai"
|
||||||
| "mistral"
|
| "mistral"
|
||||||
|
| "minimax"
|
||||||
| "opencode";
|
| "opencode";
|
||||||
export type Provider = KnownProvider | string;
|
export type Provider = KnownProvider | string;
|
||||||
|
|
||||||
|
|
@ -219,6 +225,8 @@ export interface OpenAICompat {
|
||||||
requiresThinkingAsText?: boolean;
|
requiresThinkingAsText?: boolean;
|
||||||
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
|
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
|
||||||
requiresMistralToolIds?: boolean;
|
requiresMistralToolIds?: boolean;
|
||||||
|
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }. Default: "openai". */
|
||||||
|
thinkingFormat?: "openai" | "zai";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model interface for the unified model system
|
// Model interface for the unified model system
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type { AssistantMessage } from "../types.js";
|
||||||
* - llama.cpp: "the request exceeds the available context size, try increasing it"
|
* - llama.cpp: "the request exceeds the available context size, try increasing it"
|
||||||
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
|
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
|
||||||
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
|
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
|
||||||
|
* - MiniMax: "invalid params, context window exceeds limit"
|
||||||
* - Cerebras: Returns "400 status code (no body)" - handled separately below
|
* - Cerebras: Returns "400 status code (no body)" - handled separately below
|
||||||
* - Mistral: Returns "400 status code (no body)" - handled separately below
|
* - Mistral: Returns "400 status code (no body)" - handled separately below
|
||||||
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
|
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
|
||||||
|
|
@ -24,6 +25,7 @@ import type { AssistantMessage } from "../types.js";
|
||||||
*/
|
*/
|
||||||
const OVERFLOW_PATTERNS = [
|
const OVERFLOW_PATTERNS = [
|
||||||
/prompt is too long/i, // Anthropic
|
/prompt is too long/i, // Anthropic
|
||||||
|
/input is too long for requested model/i, // Amazon Bedrock
|
||||||
/exceeds the context window/i, // OpenAI (Completions & Responses API)
|
/exceeds the context window/i, // OpenAI (Completions & Responses API)
|
||||||
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
||||||
/maximum prompt length is \d+/i, // xAI (Grok)
|
/maximum prompt length is \d+/i, // xAI (Grok)
|
||||||
|
|
@ -32,6 +34,7 @@ const OVERFLOW_PATTERNS = [
|
||||||
/exceeds the limit of \d+/i, // GitHub Copilot
|
/exceeds the limit of \d+/i, // GitHub Copilot
|
||||||
/exceeds the available context size/i, // llama.cpp server
|
/exceeds the available context size/i, // llama.cpp server
|
||||||
/greater than the context length/i, // LM Studio
|
/greater than the context length/i, // LM Studio
|
||||||
|
/context window exceeds limit/i, // MiniMax
|
||||||
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
||||||
/too many tokens/i, // Generic fallback
|
/too many tokens/i, // Generic fallback
|
||||||
/token limit exceeded/i, // Generic fallback
|
/token limit exceeded/i, // Generic fallback
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { complete, stream } from "../src/stream.js";
|
import { complete, stream } from "../src/stream.js";
|
||||||
import type { Api, Context, Model, OptionsForApi } from "../src/types.js";
|
import type { Api, Context, Model, OptionsForApi } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||||
|
|
@ -66,6 +67,35 @@ async function testImmediateAbort<TApi extends Api>(llm: Model<TApi>, options: O
|
||||||
expect(response.stopReason).toBe("aborted");
|
expect(response.stopReason).toBe("aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testAbortThenNewMessage<TApi extends Api>(llm: Model<TApi>, options: OptionsForApi<TApi> = {}) {
|
||||||
|
// First request: abort immediately before any response content arrives
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const context: Context = {
|
||||||
|
messages: [{ role: "user", content: "Hello, how are you?", timestamp: Date.now() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortedResponse = await complete(llm, context, { ...options, signal: controller.signal });
|
||||||
|
expect(abortedResponse.stopReason).toBe("aborted");
|
||||||
|
// The aborted message has empty content since we aborted before anything arrived
|
||||||
|
expect(abortedResponse.content.length).toBe(0);
|
||||||
|
|
||||||
|
// Add the aborted assistant message to context (this is what happens in the real coding agent)
|
||||||
|
context.messages.push(abortedResponse);
|
||||||
|
|
||||||
|
// Second request: send a new message - this should work even with the aborted message in context
|
||||||
|
context.messages.push({
|
||||||
|
role: "user",
|
||||||
|
content: "What is 2 + 2?",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const followUp = await complete(llm, context, options);
|
||||||
|
expect(followUp.stopReason).toBe("stop");
|
||||||
|
expect(followUp.content.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
describe("AI Providers Abort Tests", () => {
|
describe("AI Providers Abort Tests", () => {
|
||||||
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Abort", () => {
|
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Abort", () => {
|
||||||
const llm = getModel("google", "gemini-2.5-flash");
|
const llm = getModel("google", "gemini-2.5-flash");
|
||||||
|
|
@ -130,6 +160,30 @@ describe("AI Providers Abort Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Abort", () => {
|
||||||
|
const llm = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
|
||||||
|
it("should abort mid-stream", { retry: 3 }, async () => {
|
||||||
|
await testAbortSignal(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle immediate abort", { retry: 3 }, async () => {
|
||||||
|
await testImmediateAbort(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider Abort", () => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should abort mid-stream", { retry: 3 }, async () => {
|
||||||
|
await testAbortSignal(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle immediate abort", { retry: 3 }, async () => {
|
||||||
|
await testImmediateAbort(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Google Gemini CLI / Antigravity share the same provider, so one test covers both
|
// Google Gemini CLI / Antigravity share the same provider, so one test covers both
|
||||||
describe("Google Gemini CLI Provider Abort", () => {
|
describe("Google Gemini CLI Provider Abort", () => {
|
||||||
it.skipIf(!geminiCliToken)("should abort mid-stream", { retry: 3 }, async () => {
|
it.skipIf(!geminiCliToken)("should abort mid-stream", { retry: 3 }, async () => {
|
||||||
|
|
@ -154,4 +208,20 @@ describe("AI Providers Abort Tests", () => {
|
||||||
await testImmediateAbort(llm, { apiKey: openaiCodexToken });
|
await testImmediateAbort(llm, { apiKey: openaiCodexToken });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Abort", () => {
|
||||||
|
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should abort mid-stream", { retry: 3 }, async () => {
|
||||||
|
await testAbortSignal(llm, { reasoning: "medium" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle immediate abort", { retry: 3 }, async () => {
|
||||||
|
await testImmediateAbort(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle abort then new message", { retry: 3 }, async () => {
|
||||||
|
await testAbortThenNewMessage(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
66
packages/ai/test/bedrock-models.test.ts
Normal file
66
packages/ai/test/bedrock-models.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* A test suite to ensure all configured Amazon Bedrock models are usable.
|
||||||
|
*
|
||||||
|
* This is here to make sure we got correct model identifiers from models.dev and other sources.
|
||||||
|
* Because Amazon Bedrock requires cross-region inference in some models,
|
||||||
|
* plain model identifiers are not always usable and it requires tweaking of model identifiers to use cross-region inference.
|
||||||
|
* See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system for more details.
|
||||||
|
*
|
||||||
|
* This test suite is not enabled by default unless AWS credentials and `BEDROCK_EXTENSIVE_MODEL_TEST` environment variables are set.
|
||||||
|
* This test suite takes ~2 minutes to run. Because not all models are available in all regions,
|
||||||
|
* it's recommended to use `us-west-2` region for best coverage for running this test suite.
|
||||||
|
*
|
||||||
|
* You can run this test suite with:
|
||||||
|
* ```bash
|
||||||
|
* $ AWS_REGION=us-west-2 BEDROCK_EXTENSIVE_MODEL_TEST=1 AWS_PROFILE=... npm test -- ./test/bedrock-models.test.ts
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getModels } from "../src/models.js";
|
||||||
|
import { complete } from "../src/stream.js";
|
||||||
|
import type { Context } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
|
|
||||||
|
describe("Amazon Bedrock Models", () => {
|
||||||
|
const models = getModels("amazon-bedrock");
|
||||||
|
|
||||||
|
it("should get all available Bedrock models", () => {
|
||||||
|
expect(models.length).toBeGreaterThan(0);
|
||||||
|
console.log(`Found ${models.length} Bedrock models`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasBedrockCredentials() && process.env.BEDROCK_EXTENSIVE_MODEL_TEST) {
|
||||||
|
for (const model of models) {
|
||||||
|
it(`should make a simple request with ${model.id}`, { timeout: 10_000 }, async () => {
|
||||||
|
const context: Context = {
|
||||||
|
systemPrompt: "You are a helpful assistant. Be extremely concise.",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Reply with exactly: 'OK'",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await complete(model, context);
|
||||||
|
|
||||||
|
expect(response.role).toBe("assistant");
|
||||||
|
expect(response.content).toBeTruthy();
|
||||||
|
expect(response.content.length).toBeGreaterThan(0);
|
||||||
|
expect(response.usage.input + response.usage.cacheRead).toBeGreaterThan(0);
|
||||||
|
expect(response.usage.output).toBeGreaterThan(0);
|
||||||
|
expect(response.errorMessage).toBeFalsy();
|
||||||
|
|
||||||
|
const textContent = response.content
|
||||||
|
.filter((b) => b.type === "text")
|
||||||
|
.map((b) => (b.type === "text" ? b.text : ""))
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
expect(textContent).toBeTruthy();
|
||||||
|
console.log(`${model.id}: ${textContent.substring(0, 100)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
18
packages/ai/test/bedrock-utils.ts
Normal file
18
packages/ai/test/bedrock-utils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Utility functions for Amazon Bedrock tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any valid AWS credentials are configured for Bedrock.
|
||||||
|
* Returns true if any of the following are set:
|
||||||
|
* - AWS_PROFILE (named profile from ~/.aws/credentials)
|
||||||
|
* - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (IAM keys)
|
||||||
|
* - AWS_BEARER_TOKEN_BEDROCK (Bedrock API key)
|
||||||
|
*/
|
||||||
|
export function hasBedrockCredentials(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.AWS_PROFILE ||
|
||||||
|
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
|
||||||
|
process.env.AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import { getModel } from "../src/models.js";
|
||||||
import { complete } from "../src/stream.js";
|
import { complete } from "../src/stream.js";
|
||||||
import type { AssistantMessage, Context, Model, Usage } from "../src/types.js";
|
import type { AssistantMessage, Context, Model, Usage } from "../src/types.js";
|
||||||
import { isContextOverflow } from "../src/utils/overflow.js";
|
import { isContextOverflow } from "../src/utils/overflow.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||||
|
|
@ -284,6 +285,22 @@ describe("Context overflow error handling", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Amazon Bedrock
|
||||||
|
// Expected pattern: "Input is too long for requested model"
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock", () => {
|
||||||
|
it("claude-sonnet-4-5 - should detect overflow via isContextOverflow", async () => {
|
||||||
|
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
const result = await testContextOverflow(model, "");
|
||||||
|
logResult(result);
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
|
||||||
|
}, 120000);
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// xAI
|
// xAI
|
||||||
// Expected pattern: "maximum prompt length is X but the request contains Y"
|
// Expected pattern: "maximum prompt length is X but the request contains Y"
|
||||||
|
|
@ -379,6 +396,37 @@ describe("Context overflow error handling", () => {
|
||||||
}, 120000);
|
}, 120000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MiniMax
|
||||||
|
// Expected pattern: TBD - need to test actual error message
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax", () => {
|
||||||
|
it("MiniMax-M2.1 - should detect overflow via isContextOverflow", async () => {
|
||||||
|
const model = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
const result = await testContextOverflow(model, process.env.MINIMAX_API_KEY!);
|
||||||
|
logResult(result);
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
|
||||||
|
}, 120000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Vercel AI Gateway - Unified API for multiple providers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway", () => {
|
||||||
|
it("google/gemini-2.5-flash via AI Gateway - should detect overflow via isContextOverflow", async () => {
|
||||||
|
const model = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
const result = await testContextOverflow(model, process.env.AI_GATEWAY_API_KEY!);
|
||||||
|
logResult(result);
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
|
||||||
|
}, 120000);
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// OpenRouter - Multiple backend providers
|
// OpenRouter - Multiple backend providers
|
||||||
// Expected pattern: "maximum context length is X tokens"
|
// Expected pattern: "maximum context length is X tokens"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { complete } from "../src/stream.js";
|
import { complete } from "../src/stream.js";
|
||||||
import type { Api, AssistantMessage, Context, Model, OptionsForApi, UserMessage } from "../src/types.js";
|
import type { Api, AssistantMessage, Context, Model, OptionsForApi, UserMessage } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||||
|
|
@ -321,6 +322,66 @@ describe("AI Providers Empty Message Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Empty Messages", () => {
|
||||||
|
const llm = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
|
||||||
|
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyStringMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testWhitespaceOnlyMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyAssistantMessage(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider Empty Messages", () => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyStringMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testWhitespaceOnlyMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyAssistantMessage(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Empty Messages", () => {
|
||||||
|
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyStringMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testWhitespaceOnlyMessage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmptyAssistantMessage(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { streamGoogleGeminiCli } from "../src/providers/google-gemini-cli.js";
|
||||||
|
import type { Context, Model } from "../src/types.js";
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const apiKey = JSON.stringify({ token: "token", projectId: "project" });
|
||||||
|
|
||||||
|
const createSseResponse = () => {
|
||||||
|
const sse = `${[
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
response: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { role: "model", parts: [{ text: "Hello" }] },
|
||||||
|
finishReason: "STOP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
].join("\n\n")}\n\n`;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(sse));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("google-gemini-cli Claude thinking header", () => {
|
||||||
|
const context: Context = {
|
||||||
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("adds anthropic-beta for Claude thinking models", async () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14");
|
||||||
|
return createSseResponse();
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const model: Model<"google-gemini-cli"> = {
|
||||||
|
id: "claude-opus-4-5-thinking",
|
||||||
|
name: "Claude Opus 4.5 Thinking",
|
||||||
|
api: "google-gemini-cli",
|
||||||
|
provider: "google-antigravity",
|
||||||
|
baseUrl: "https://cloudcode-pa.googleapis.com",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = streamGoogleGeminiCli(model, context, { apiKey });
|
||||||
|
for await (const _event of stream) {
|
||||||
|
// exhaust stream
|
||||||
|
}
|
||||||
|
await stream.result();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add anthropic-beta for Gemini models", async () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.has("anthropic-beta")).toBe(false);
|
||||||
|
return createSseResponse();
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const model: Model<"google-gemini-cli"> = {
|
||||||
|
id: "gemini-2.5-flash",
|
||||||
|
name: "Gemini 2.5 Flash",
|
||||||
|
api: "google-gemini-cli",
|
||||||
|
provider: "google-gemini-cli",
|
||||||
|
baseUrl: "https://cloudcode-pa.googleapis.com",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = streamGoogleGeminiCli(model, context, { apiKey });
|
||||||
|
for await (const _event of stream) {
|
||||||
|
// exhaust stream
|
||||||
|
}
|
||||||
|
await stream.result();
|
||||||
|
});
|
||||||
|
});
|
||||||
108
packages/ai/test/google-gemini-cli-empty-stream.test.ts
Normal file
108
packages/ai/test/google-gemini-cli-empty-stream.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { streamGoogleGeminiCli } from "../src/providers/google-gemini-cli.js";
|
||||||
|
import type { Context, Model } from "../src/types.js";
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("google-gemini-cli empty stream retry", () => {
|
||||||
|
it("retries empty SSE responses without duplicate start", async () => {
|
||||||
|
const emptyStream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sse = `${[
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
response: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { role: "model", parts: [{ text: "Hello" }] },
|
||||||
|
finishReason: "STOP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 1,
|
||||||
|
candidatesTokenCount: 1,
|
||||||
|
totalTokenCount: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
].join("\n\n")}\n\n`;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const dataStream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(sse));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
callCount += 1;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return new Response(emptyStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(dataStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const model: Model<"google-gemini-cli"> = {
|
||||||
|
id: "gemini-2.5-flash",
|
||||||
|
name: "Gemini 2.5 Flash",
|
||||||
|
api: "google-gemini-cli",
|
||||||
|
provider: "google-gemini-cli",
|
||||||
|
baseUrl: "https://cloudcode-pa.googleapis.com",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
};
|
||||||
|
|
||||||
|
const context: Context = {
|
||||||
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = streamGoogleGeminiCli(model, context, {
|
||||||
|
apiKey: JSON.stringify({ token: "token", projectId: "project" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
let startCount = 0;
|
||||||
|
let doneCount = 0;
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (event.type === "start") {
|
||||||
|
startCount += 1;
|
||||||
|
}
|
||||||
|
if (event.type === "done") {
|
||||||
|
doneCount += 1;
|
||||||
|
}
|
||||||
|
if (event.type === "text_delta") {
|
||||||
|
text += event.delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await stream.result();
|
||||||
|
|
||||||
|
expect(text).toBe("Hello");
|
||||||
|
expect(result.stopReason).toBe("stop");
|
||||||
|
expect(startCount).toBe(1);
|
||||||
|
expect(doneCount).toBe(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
packages/ai/test/google-gemini-cli-retry-delay.test.ts
Normal file
53
packages/ai/test/google-gemini-cli-retry-delay.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { extractRetryDelay } from "../src/providers/google-gemini-cli.js";
|
||||||
|
|
||||||
|
describe("extractRetryDelay header parsing", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers Retry-After seconds header", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
const response = new Response("", { headers: { "Retry-After": "5" } });
|
||||||
|
const delay = extractRetryDelay("Please retry in 1s", response);
|
||||||
|
|
||||||
|
expect(delay).toBe(6000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses Retry-After HTTP date header", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const now = new Date("2025-01-01T00:00:00Z");
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
|
||||||
|
const retryAt = new Date(now.getTime() + 12000).toUTCString();
|
||||||
|
const response = new Response("", { headers: { "Retry-After": retryAt } });
|
||||||
|
const delay = extractRetryDelay("", response);
|
||||||
|
|
||||||
|
expect(delay).toBe(13000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses x-ratelimit-reset header", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const now = new Date("2025-01-01T00:00:00Z");
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
|
||||||
|
const resetAtMs = now.getTime() + 20000;
|
||||||
|
const resetSeconds = Math.floor(resetAtMs / 1000).toString();
|
||||||
|
const response = new Response("", { headers: { "x-ratelimit-reset": resetSeconds } });
|
||||||
|
const delay = extractRetryDelay("", response);
|
||||||
|
|
||||||
|
expect(delay).toBe(21000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses x-ratelimit-reset-after header", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
const response = new Response("", { headers: { "x-ratelimit-reset-after": "30" } });
|
||||||
|
const delay = extractRetryDelay("", response);
|
||||||
|
|
||||||
|
expect(delay).toBe(31000);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
packages/ai/test/google-gemini-cli-session-id.test.ts
Normal file
50
packages/ai/test/google-gemini-cli-session-id.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildRequest } from "../src/providers/google-gemini-cli.js";
|
||||||
|
import type { Context, Model } from "../src/types.js";
|
||||||
|
|
||||||
|
const model: Model<"google-gemini-cli"> = {
|
||||||
|
id: "gemini-2.5-flash",
|
||||||
|
name: "Gemini 2.5 Flash",
|
||||||
|
api: "google-gemini-cli",
|
||||||
|
provider: "google-gemini-cli",
|
||||||
|
baseUrl: "https://cloudcode-pa.googleapis.com",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildRequest sessionId", () => {
|
||||||
|
it("derives sessionId from the first user message", () => {
|
||||||
|
const context: Context = {
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "First message", timestamp: Date.now() },
|
||||||
|
{ role: "user", content: "Second message", timestamp: Date.now() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildRequest(model, context, "project-id");
|
||||||
|
const expected = createHash("sha256").update("First message").digest("hex").slice(0, 32);
|
||||||
|
|
||||||
|
expect(result.request.sessionId).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits sessionId when the first user message has no text", () => {
|
||||||
|
const context: Context = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "image", data: "Zm9v", mimeType: "image/png" }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{ role: "user", content: "Later text", timestamp: Date.now() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildRequest(model, context, "project-id");
|
||||||
|
|
||||||
|
expect(result.request.sessionId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -75,6 +75,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { complete } from "../src/stream.js";
|
import { complete } from "../src/stream.js";
|
||||||
import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "../src/types.js";
|
import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
@ -840,6 +841,122 @@ describe("Image Limits E2E Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Vercel AI Gateway (google/gemini-2.5-flash)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway (google/gemini-2.5-flash)", () => {
|
||||||
|
const model = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should accept a small number of images (5)", async () => {
|
||||||
|
const result = await testImageCount(model, 5, smallImage);
|
||||||
|
expect(result.success, result.error).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find maximum image count limit", { timeout: 600000 }, async () => {
|
||||||
|
const { limit, lastError } = await findLimit((count) => testImageCount(model, count, smallImage), 10, 100, 10);
|
||||||
|
console.log(`\n Vercel AI Gateway max images: ~${limit} (last error: ${lastError})`);
|
||||||
|
expect(limit).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find maximum image size limit", { timeout: 600000 }, async () => {
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
const sizes = [5, 10, 15, 20];
|
||||||
|
|
||||||
|
let lastSuccess = 0;
|
||||||
|
let lastError: string | undefined;
|
||||||
|
|
||||||
|
for (const sizeMB of sizes) {
|
||||||
|
console.log(` Testing size: ${sizeMB}MB...`);
|
||||||
|
const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`);
|
||||||
|
const result = await testImageSize(model, imageBase64);
|
||||||
|
if (result.success) {
|
||||||
|
lastSuccess = sizeMB;
|
||||||
|
console.log(` SUCCESS`);
|
||||||
|
} else {
|
||||||
|
lastError = result.error;
|
||||||
|
console.log(` FAILED: ${result.error?.substring(0, 100)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n Vercel AI Gateway max image size: ~${lastSuccess}MB (last error: ${lastError})`);
|
||||||
|
expect(lastSuccess).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Amazon Bedrock (claude-sonnet-4-5)
|
||||||
|
// Limits: 100 images (Anthropic), 5MB per image, 8000px max dimension
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock (claude-sonnet-4-5)", () => {
|
||||||
|
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should accept a small number of images (5)", async () => {
|
||||||
|
const result = await testImageCount(model, 5, smallImage);
|
||||||
|
expect(result.success, result.error).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find maximum image count limit", { timeout: 600000 }, async () => {
|
||||||
|
// Anthropic limit: 100 images
|
||||||
|
const { limit, lastError } = await findLimit((count) => testImageCount(model, count, smallImage), 20, 120, 20);
|
||||||
|
console.log(`\n Bedrock max images: ~${limit} (last error: ${lastError})`);
|
||||||
|
expect(limit).toBeGreaterThanOrEqual(80);
|
||||||
|
expect(limit).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find maximum image size limit", { timeout: 600000 }, async () => {
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
// Anthropic limit: 5MB per image
|
||||||
|
const sizes = [1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
|
let lastSuccess = 0;
|
||||||
|
let lastError: string | undefined;
|
||||||
|
|
||||||
|
for (const sizeMB of sizes) {
|
||||||
|
console.log(` Testing size: ${sizeMB}MB...`);
|
||||||
|
const imageBase64 = generateImageWithSize(sizeMB * MB, `size-${sizeMB}mb.png`);
|
||||||
|
const result = await testImageSize(model, imageBase64);
|
||||||
|
if (result.success) {
|
||||||
|
lastSuccess = sizeMB;
|
||||||
|
console.log(` SUCCESS`);
|
||||||
|
} else {
|
||||||
|
lastError = result.error;
|
||||||
|
console.log(` FAILED: ${result.error?.substring(0, 100)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n Bedrock max image size: ~${lastSuccess}MB (last error: ${lastError})`);
|
||||||
|
expect(lastSuccess).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find maximum image dimension limit", { timeout: 600000 }, async () => {
|
||||||
|
// Anthropic limit: 8000px
|
||||||
|
const dimensions = [1000, 2000, 4000, 6000, 8000, 10000];
|
||||||
|
|
||||||
|
let lastSuccess = 0;
|
||||||
|
let lastError: string | undefined;
|
||||||
|
|
||||||
|
for (const dim of dimensions) {
|
||||||
|
console.log(` Testing dimension: ${dim}x${dim}...`);
|
||||||
|
const imageBase64 = generateImage(dim, dim, `dim-${dim}.png`);
|
||||||
|
const result = await testImageDimensions(model, imageBase64);
|
||||||
|
if (result.success) {
|
||||||
|
lastSuccess = dim;
|
||||||
|
console.log(` SUCCESS`);
|
||||||
|
} else {
|
||||||
|
lastError = result.error;
|
||||||
|
console.log(` FAILED: ${result.error?.substring(0, 100)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n Bedrock max dimension: ~${lastSuccess}px (last error: ${lastError})`);
|
||||||
|
expect(lastSuccess).toBeGreaterThanOrEqual(6000);
|
||||||
|
expect(lastSuccess).toBeLessThanOrEqual(8000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// MAX SIZE IMAGES TEST
|
// MAX SIZE IMAGES TEST
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -898,6 +1015,38 @@ describe("Image Limits E2E Tests", () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Amazon Bedrock (Claude) - 5MB per image limit, same as Anthropic direct
|
||||||
|
// Using 3MB to stay under 5MB limit
|
||||||
|
it.skipIf(!hasBedrockCredentials())(
|
||||||
|
"Bedrock: max ~3MB images before rejection",
|
||||||
|
{ timeout: 900000 },
|
||||||
|
async () => {
|
||||||
|
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
const image3mb = getImageAtSize(3);
|
||||||
|
// Similar to Anthropic, test progressively
|
||||||
|
const counts = [1, 2, 4, 6, 8, 10, 12];
|
||||||
|
|
||||||
|
let lastSuccess = 0;
|
||||||
|
let lastError: string | undefined;
|
||||||
|
|
||||||
|
for (const count of counts) {
|
||||||
|
console.log(` Testing ${count} x ~3MB images...`);
|
||||||
|
const result = await testImageCount(model, count, image3mb);
|
||||||
|
if (result.success) {
|
||||||
|
lastSuccess = count;
|
||||||
|
console.log(` SUCCESS`);
|
||||||
|
} else {
|
||||||
|
lastError = result.error;
|
||||||
|
console.log(` FAILED: ${result.error?.substring(0, 150)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n Bedrock max ~3MB images: ${lastSuccess} (last error: ${lastError})`);
|
||||||
|
expect(lastSuccess).toBeGreaterThanOrEqual(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// OpenAI - 20MB per image documented, we found ≥25MB works
|
// OpenAI - 20MB per image documented, we found ≥25MB works
|
||||||
// Test with 15MB images to stay safely under limit
|
// Test with 15MB images to stay safely under limit
|
||||||
it.skipIf(!process.env.OPENAI_API_KEY)(
|
it.skipIf(!process.env.OPENAI_API_KEY)(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/index.js";
|
import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/index.js";
|
||||||
import { complete, getModel } from "../src/index.js";
|
import { complete, getModel } from "../src/index.js";
|
||||||
import type { OptionsForApi } from "../src/types.js";
|
import type { OptionsForApi } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||||
|
|
@ -273,6 +274,30 @@ describe("Tool Results with Images", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider (google/gemini-2.5-flash)", () => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await handleToolWithImageResult(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await handleToolWithTextAndImageResult(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-sonnet-4-5)", () => {
|
||||||
|
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await handleToolWithImageResult(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await handleToolWithTextAndImageResult(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { getModel } from "../src/models.js";
|
||||||
import { complete, stream } from "../src/stream.js";
|
import { complete, stream } from "../src/stream.js";
|
||||||
import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "../src/types.js";
|
import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "../src/types.js";
|
||||||
import { StringEnum } from "../src/utils/typebox-helpers.js";
|
import { StringEnum } from "../src/utils/typebox-helpers.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
@ -356,7 +357,7 @@ describe("Generate E2E Tests", () => {
|
||||||
await handleStreaming(llm);
|
await handleStreaming(llm);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle ", { retry: 3 }, async () => {
|
it("should handle thinking", { retry: 3 }, async () => {
|
||||||
await handleThinking(llm, { thinking: { enabled: true, budgetTokens: 1024 } });
|
await handleThinking(llm, { thinking: { enabled: true, budgetTokens: 1024 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -597,6 +598,87 @@ describe("Generate E2E Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)(
|
||||||
|
"Vercel AI Gateway Provider (google/gemini-2.5-flash via Anthropic Messages)",
|
||||||
|
() => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should complete basic text generation", { retry: 3 }, async () => {
|
||||||
|
await basicTextGeneration(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tool calling", { retry: 3 }, async () => {
|
||||||
|
await handleToolCall(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle streaming", { retry: 3 }, async () => {
|
||||||
|
await handleStreaming(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image input", { retry: 3 }, async () => {
|
||||||
|
await handleImage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multi-turn with tools", { retry: 3 }, async () => {
|
||||||
|
await multiTurn(llm);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)(
|
||||||
|
"Vercel AI Gateway Provider (anthropic/claude-opus-4.5 via Anthropic Messages)",
|
||||||
|
() => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "anthropic/claude-opus-4.5");
|
||||||
|
|
||||||
|
it("should complete basic text generation", { retry: 3 }, async () => {
|
||||||
|
await basicTextGeneration(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tool calling", { retry: 3 }, async () => {
|
||||||
|
await handleToolCall(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle streaming", { retry: 3 }, async () => {
|
||||||
|
await handleStreaming(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image input", { retry: 3 }, async () => {
|
||||||
|
await handleImage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multi-turn with tools", { retry: 3 }, async () => {
|
||||||
|
await multiTurn(llm);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)(
|
||||||
|
"Vercel AI Gateway Provider (openai/gpt-5.1-codex-max via Anthropic Messages)",
|
||||||
|
() => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "openai/gpt-5.1-codex-max");
|
||||||
|
|
||||||
|
it("should complete basic text generation", { retry: 3 }, async () => {
|
||||||
|
await basicTextGeneration(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tool calling", { retry: 3 }, async () => {
|
||||||
|
await handleToolCall(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle streaming", { retry: 3 }, async () => {
|
||||||
|
await handleStreaming(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image input", { retry: 3 }, async () => {
|
||||||
|
await handleImage(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multi-turn with tools", { retry: 3 }, async () => {
|
||||||
|
await multiTurn(llm);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air via OpenAI Completions)", () => {
|
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air via OpenAI Completions)", () => {
|
||||||
const llm = getModel("zai", "glm-4.5-air");
|
const llm = getModel("zai", "glm-4.5-air");
|
||||||
|
|
||||||
|
|
@ -698,6 +780,30 @@ describe("Generate E2E Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider (MiniMax-M2.1 via Anthropic Messages)", () => {
|
||||||
|
const llm = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
|
||||||
|
it("should complete basic text generation", { retry: 3 }, async () => {
|
||||||
|
await basicTextGeneration(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tool calling", { retry: 3 }, async () => {
|
||||||
|
await handleToolCall(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle streaming", { retry: 3 }, async () => {
|
||||||
|
await handleStreaming(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle thinking mode", { retry: 3 }, async () => {
|
||||||
|
await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
|
||||||
|
await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
||||||
// Tokens are resolved at module level (see oauthTokens above)
|
// Tokens are resolved at module level (see oauthTokens above)
|
||||||
|
|
@ -907,6 +1013,34 @@ describe("Generate E2E Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-sonnet-4-5)", () => {
|
||||||
|
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should complete basic text generation", { retry: 3 }, async () => {
|
||||||
|
await basicTextGeneration(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tool calling", { retry: 3 }, async () => {
|
||||||
|
await handleToolCall(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle streaming", { retry: 3 }, async () => {
|
||||||
|
await handleStreaming(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle thinking", { retry: 3 }, async () => {
|
||||||
|
await handleThinking(llm, { reasoning: "medium" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
|
||||||
|
await multiTurn(llm, { reasoning: "high" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image input", { retry: 3 }, async () => {
|
||||||
|
await handleImage(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Check if ollama is installed and local LLM tests are enabled
|
// Check if ollama is installed and local LLM tests are enabled
|
||||||
let ollamaInstalled = false;
|
let ollamaInstalled = false;
|
||||||
if (!process.env.PI_NO_LOCAL_LLM) {
|
if (!process.env.PI_NO_LOCAL_LLM) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { stream } from "../src/stream.js";
|
import { stream } from "../src/stream.js";
|
||||||
import type { Api, Context, Model, OptionsForApi } from "../src/types.js";
|
import type { Api, Context, Model, OptionsForApi } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||||
|
|
@ -44,18 +45,25 @@ async function testTokensOnAbort<TApi extends Api>(llm: Model<TApi>, options: Op
|
||||||
|
|
||||||
expect(msg.stopReason).toBe("aborted");
|
expect(msg.stopReason).toBe("aborted");
|
||||||
|
|
||||||
// OpenAI providers, OpenAI Codex, Gemini CLI, zai, and the GPT-OSS model on Antigravity only send usage in the final chunk,
|
// OpenAI providers, OpenAI Codex, Gemini CLI, zai, Amazon Bedrock, and the GPT-OSS model on Antigravity only send usage in the final chunk,
|
||||||
// so when aborted they have no token stats Anthropic and Google send usage information early in the stream
|
// so when aborted they have no token stats. Anthropic and Google send usage information early in the stream.
|
||||||
|
// MiniMax reports input tokens but not output tokens when aborted.
|
||||||
if (
|
if (
|
||||||
llm.api === "openai-completions" ||
|
llm.api === "openai-completions" ||
|
||||||
llm.api === "openai-responses" ||
|
llm.api === "openai-responses" ||
|
||||||
llm.api === "openai-codex-responses" ||
|
llm.api === "openai-codex-responses" ||
|
||||||
llm.provider === "google-gemini-cli" ||
|
llm.provider === "google-gemini-cli" ||
|
||||||
llm.provider === "zai" ||
|
llm.provider === "zai" ||
|
||||||
|
llm.provider === "amazon-bedrock" ||
|
||||||
|
llm.provider === "vercel-ai-gateway" ||
|
||||||
(llm.provider === "google-antigravity" && llm.id.includes("gpt-oss"))
|
(llm.provider === "google-antigravity" && llm.id.includes("gpt-oss"))
|
||||||
) {
|
) {
|
||||||
expect(msg.usage.input).toBe(0);
|
expect(msg.usage.input).toBe(0);
|
||||||
expect(msg.usage.output).toBe(0);
|
expect(msg.usage.output).toBe(0);
|
||||||
|
} else if (llm.provider === "minimax") {
|
||||||
|
// MiniMax reports input tokens early but output tokens only in final chunk
|
||||||
|
expect(msg.usage.input).toBeGreaterThan(0);
|
||||||
|
expect(msg.usage.output).toBe(0);
|
||||||
} else {
|
} else {
|
||||||
expect(msg.usage.input).toBeGreaterThan(0);
|
expect(msg.usage.input).toBeGreaterThan(0);
|
||||||
expect(msg.usage.output).toBeGreaterThan(0);
|
expect(msg.usage.output).toBeGreaterThan(0);
|
||||||
|
|
@ -144,6 +152,22 @@ describe("Token Statistics on Abort", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider", () => {
|
||||||
|
const llm = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
|
||||||
|
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testTokensOnAbort(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider", () => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testTokensOnAbort(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -230,4 +254,12 @@ describe("Token Statistics on Abort", () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider", () => {
|
||||||
|
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testTokensOnAbort(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { complete } from "../src/stream.js";
|
import { complete } from "../src/stream.js";
|
||||||
import type { Api, Context, Model, OptionsForApi, Tool } from "../src/types.js";
|
import type { Api, Context, Model, OptionsForApi, Tool } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||||
|
|
@ -170,6 +171,30 @@ describe("Tool Call Without Result Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider", () => {
|
||||||
|
const model = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
|
||||||
|
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testToolCallWithoutResult(model);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider", () => {
|
||||||
|
const model = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testToolCallWithoutResult(model);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider", () => {
|
||||||
|
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testToolCallWithoutResult(model);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { complete } from "../src/stream.js";
|
import { complete } from "../src/stream.js";
|
||||||
import type { Api, Context, Model, OptionsForApi, Usage } from "../src/types.js";
|
import type { Api, Context, Model, OptionsForApi, Usage } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||||
|
|
@ -324,6 +325,52 @@ describe("totalTokens field", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MiniMax
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax", () => {
|
||||||
|
it(
|
||||||
|
"MiniMax-M2.1 - should return totalTokens equal to sum of components",
|
||||||
|
{ retry: 3, timeout: 60000 },
|
||||||
|
async () => {
|
||||||
|
const llm = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
|
||||||
|
console.log(`\nMiniMax / ${llm.id}:`);
|
||||||
|
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.MINIMAX_API_KEY });
|
||||||
|
|
||||||
|
logUsage("First request", first);
|
||||||
|
logUsage("Second request", second);
|
||||||
|
|
||||||
|
assertTotalTokensEqualsComponents(first);
|
||||||
|
assertTotalTokensEqualsComponents(second);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Vercel AI Gateway
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway", () => {
|
||||||
|
it(
|
||||||
|
"google/gemini-2.5-flash - should return totalTokens equal to sum of components",
|
||||||
|
{ retry: 3, timeout: 60000 },
|
||||||
|
async () => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
console.log(`\nVercel AI Gateway / ${llm.id}:`);
|
||||||
|
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.AI_GATEWAY_API_KEY });
|
||||||
|
|
||||||
|
logUsage("First request", first);
|
||||||
|
logUsage("Second request", second);
|
||||||
|
|
||||||
|
assertTotalTokensEqualsComponents(first);
|
||||||
|
assertTotalTokensEqualsComponents(second);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// OpenRouter - Multiple backend providers
|
// OpenRouter - Multiple backend providers
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -535,6 +582,25 @@ describe("totalTokens field", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock", () => {
|
||||||
|
it(
|
||||||
|
"claude-sonnet-4-5 - should return totalTokens equal to sum of components",
|
||||||
|
{ retry: 3, timeout: 60000 },
|
||||||
|
async () => {
|
||||||
|
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
console.log(`\nAmazon Bedrock / ${llm.id}:`);
|
||||||
|
const { first, second } = await testTotalTokensWithCache(llm);
|
||||||
|
|
||||||
|
logUsage("First request", first);
|
||||||
|
logUsage("Second request", second);
|
||||||
|
|
||||||
|
assertTotalTokensEqualsComponents(first);
|
||||||
|
assertTotalTokensEqualsComponents(second);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// OpenAI Codex (OAuth)
|
// OpenAI Codex (OAuth)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import { getModel } from "../src/models.js";
|
import { getModel } from "../src/models.js";
|
||||||
import { complete } from "../src/stream.js";
|
import { complete } from "../src/stream.js";
|
||||||
import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "../src/types.js";
|
import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "../src/types.js";
|
||||||
|
import { hasBedrockCredentials } from "./bedrock-utils.js";
|
||||||
import { resolveApiKey } from "./oauth.js";
|
import { resolveApiKey } from "./oauth.js";
|
||||||
|
|
||||||
// Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist
|
// Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist
|
||||||
|
|
@ -617,6 +618,54 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Unicode Handling", () => {
|
||||||
|
const llm = getModel("minimax", "MiniMax-M2.1");
|
||||||
|
|
||||||
|
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmojiInToolResults(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testRealWorldLinkedInData(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testUnpairedHighSurrogate(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider Unicode Handling", () => {
|
||||||
|
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
||||||
|
|
||||||
|
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmojiInToolResults(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testRealWorldLinkedInData(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testUnpairedHighSurrogate(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Unicode Handling", () => {
|
||||||
|
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
||||||
|
|
||||||
|
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testEmojiInToolResults(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testRealWorldLinkedInData(llm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
|
||||||
|
await testUnpairedHighSurrogate(llm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("OpenAI Codex Provider Unicode Handling", () => {
|
describe("OpenAI Codex Provider Unicode Handling", () => {
|
||||||
it.skipIf(!openaiCodexToken)(
|
it.skipIf(!openaiCodexToken)(
|
||||||
"gpt-5.2-codex - should handle emoji in tool results",
|
"gpt-5.2-codex - should handle emoji in tool results",
|
||||||
|
|
|
||||||
1
packages/coding-agent/.gitignore
vendored
Normal file
1
packages/coding-agent/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.bun-build
|
||||||
|
|
@ -2,6 +2,60 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.5] - 2026-01-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Skip changelog display on fresh install (only show on upgrades)
|
||||||
|
|
||||||
|
## [0.45.4] - 2026-01-13
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Light theme colors adjusted for WCAG AA compliance (4.5:1 contrast ratio against white backgrounds)
|
||||||
|
- Replaced `sharp` with `wasm-vips` for image processing (resize, PNG conversion). Eliminates native build requirements that caused installation failures on some systems. ([#696](https://github.com/badlogic/pi-mono/issues/696))
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extension example: `summarize.ts` for summarizing conversations using custom UI and an external model ([#684](https://github.com/badlogic/pi-mono/pull/684) by [@scutifer](https://github.com/scutifer))
|
||||||
|
- Extension example: `question.ts` enhanced with custom UI for asking user questions ([#693](https://github.com/badlogic/pi-mono/pull/693) by [@ferologics](https://github.com/ferologics))
|
||||||
|
- Extension example: `plan-mode/` enhanced with explicit step tracking and progress widget ([#694](https://github.com/badlogic/pi-mono/pull/694) by [@ferologics](https://github.com/ferologics))
|
||||||
|
- Extension example: `questionnaire.ts` for multi-question input with tab bar navigation ([#695](https://github.com/badlogic/pi-mono/pull/695) by [@ferologics](https://github.com/ferologics))
|
||||||
|
- Experimental Vercel AI Gateway provider support: set `AI_GATEWAY_API_KEY` and use `--provider vercel-ai-gateway`. Token usage is currently reported incorrectly by Anthropic Messages compatible endpoint. ([#689](https://github.com/badlogic/pi-mono/pull/689) by [@timolins](https://github.com/timolins))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix API key resolution after model switches by using provider argument ([#691](https://github.com/badlogic/pi-mono/pull/691) by [@joshp123](https://github.com/joshp123))
|
||||||
|
- Fixed z.ai thinking/reasoning: thinking toggle now correctly enables/disables thinking for z.ai models ([#688](https://github.com/badlogic/pi-mono/issues/688))
|
||||||
|
- Fixed extension loading in compiled Bun binary: extensions with local file imports now work correctly. Updated `@mariozechner/jiti` to v2.6.5 which bundles babel for Bun binary compatibility. ([#681](https://github.com/badlogic/pi-mono/issues/681))
|
||||||
|
- Fixed theme loading when installed via mise: use wrapper directory in release tarballs for compatibility with mise's `strip_components=1` extraction. ([#681](https://github.com/badlogic/pi-mono/issues/681))
|
||||||
|
|
||||||
|
## [0.45.3] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.2] - 2026-01-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Extensions now load correctly in compiled Bun binary using `@mariozechner/jiti` fork with `virtualModules` support. Bundled packages (`@sinclair/typebox`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`) are accessible to extensions without filesystem node_modules.
|
||||||
|
|
||||||
|
## [0.45.1] - 2026-01-13
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `/share` now outputs `buildwithpi.ai` session preview URLs instead of `shittycodingagent.ai`
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-01-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- MiniMax provider support: set `MINIMAX_API_KEY` and use `minimax/MiniMax-M2.1` ([#656](https://github.com/badlogic/pi-mono/pull/656) by [@dannote](https://github.com/dannote))
|
||||||
|
- `/scoped-models`: Alt+Up/Down to reorder enabled models. Order is preserved when saving with Ctrl+S and determines Ctrl+P cycling order. ([#676](https://github.com/badlogic/pi-mono/pull/676) by [@thomasmhr](https://github.com/thomasmhr))
|
||||||
|
- Amazon Bedrock provider support (experimental, tested with Anthropic Claude models only) ([#494](https://github.com/badlogic/pi-mono/pull/494) by [@unexge](https://github.com/unexge))
|
||||||
|
- Extension example: `sandbox/` for OS-level bash sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config ([#673](https://github.com/badlogic/pi-mono/pull/673) by [@dannote](https://github.com/dannote))
|
||||||
|
- Print mode JSON output now emits the session header as the first line.
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-01-12
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- `pi.getAllTools()` now returns `ToolInfo[]` (with `name` and `description`) instead of `string[]`. Extensions that only need names can use `.map(t => t.name)`. ([#648](https://github.com/badlogic/pi-mono/pull/648) by [@carsonfarmer](https://github.com/carsonfarmer))
|
- `pi.getAllTools()` now returns `ToolInfo[]` (with `name` and `description`) instead of `string[]`. Extensions that only need names can use `.map(t => t.name)`. ([#648](https://github.com/badlogic/pi-mono/pull/648) by [@carsonfarmer](https://github.com/carsonfarmer))
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,9 @@ Add API keys to `~/.pi/agent/auth.json`:
|
||||||
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
|
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
|
||||||
| xAI | `xai` | `XAI_API_KEY` |
|
| xAI | `xai` | `XAI_API_KEY` |
|
||||||
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
|
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
|
||||||
|
| Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` |
|
||||||
| ZAI | `zai` | `ZAI_API_KEY` |
|
| ZAI | `zai` | `ZAI_API_KEY` |
|
||||||
|
| MiniMax | `minimax` | `MINIMAX_API_KEY` |
|
||||||
|
|
||||||
Auth file keys take priority over environment variables.
|
Auth file keys take priority over environment variables.
|
||||||
|
|
||||||
|
|
@ -211,6 +213,29 @@ Credentials stored in `~/.pi/agent/auth.json`. Use `/logout` to clear.
|
||||||
- **Token expired / refresh failed:** Run `/login` again for the provider to refresh credentials.
|
- **Token expired / refresh failed:** Run `/login` again for the provider to refresh credentials.
|
||||||
- **Usage limits (429):** Wait for the reset window; pi will surface a friendly message with the approximate retry time.
|
- **Usage limits (429):** Wait for the reset window; pi will surface a friendly message with the approximate retry time.
|
||||||
|
|
||||||
|
**Amazon Bedrock:**
|
||||||
|
|
||||||
|
Amazon Bedrock supports multiple authentication methods:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: AWS Profile (from ~/.aws/credentials)
|
||||||
|
export AWS_PROFILE=your-profile-name
|
||||||
|
|
||||||
|
# Option 2: IAM Access Keys
|
||||||
|
export AWS_ACCESS_KEY_ID=AKIA...
|
||||||
|
export AWS_SECRET_ACCESS_KEY=...
|
||||||
|
|
||||||
|
# Option 3: Bedrock API Key (bearer token)
|
||||||
|
export AWS_BEARER_TOKEN_BEDROCK=...
|
||||||
|
|
||||||
|
# Optional: Set region (defaults to us-east-1)
|
||||||
|
export AWS_REGION=us-east-1
|
||||||
|
|
||||||
|
pi --provider amazon-bedrock --model global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Supported foundation models in Amazon Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html).
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -1119,7 +1144,7 @@ pi [options] [@files...] [messages...]
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
|
| `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`, `amazon-bedrock`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `vercel-ai-gateway`, `zai`, `minimax`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
|
||||||
| `--model <id>` | Model ID |
|
| `--model <id>` | Model ID |
|
||||||
| `--api-key <key>` | API key (overrides environment) |
|
| `--api-key <key>` | API key (overrides environment) |
|
||||||
| `--system-prompt <text\|file>` | Custom system prompt (text or file path) |
|
| `--system-prompt <text\|file>` | Custom system prompt (text or file path) |
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ Extensions are TypeScript modules that extend pi's behavior. They can subscribe
|
||||||
- Git checkpointing (stash at each turn, restore on branch)
|
- Git checkpointing (stash at each turn, restore on branch)
|
||||||
- Path protection (block writes to `.env`, `node_modules/`)
|
- Path protection (block writes to `.env`, `node_modules/`)
|
||||||
- Custom compaction (summarize conversation your way)
|
- Custom compaction (summarize conversation your way)
|
||||||
|
- Conversation summaries (see `summarize.ts` example)
|
||||||
- Interactive tools (questions, wizards, custom dialogs)
|
- Interactive tools (questions, wizards, custom dialogs)
|
||||||
- Stateful tools (todo lists, connection pools)
|
- Stateful tools (todo lists, connection pools)
|
||||||
- External integrations (file watchers, webhooks, CI triggers)
|
- External integrations (file watchers, webhooks, CI triggers)
|
||||||
|
|
@ -438,7 +439,7 @@ pi.on("before_agent_start", async (event, ctx) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [ssh.ts](../examples/extensions/ssh.ts)
|
**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [ssh.ts](../examples/extensions/ssh.ts)
|
||||||
|
|
||||||
#### agent_start / agent_end
|
#### agent_start / agent_end
|
||||||
|
|
||||||
|
|
@ -452,7 +453,7 @@ pi.on("agent_end", async (event, ctx) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
|
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
|
||||||
|
|
||||||
#### turn_start / turn_end
|
#### turn_start / turn_end
|
||||||
|
|
||||||
|
|
@ -468,7 +469,7 @@ pi.on("turn_end", async (event, ctx) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [status-line.ts](../examples/extensions/status-line.ts)
|
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [status-line.ts](../examples/extensions/status-line.ts)
|
||||||
|
|
||||||
#### context
|
#### context
|
||||||
|
|
||||||
|
|
@ -482,7 +483,7 @@ pi.on("context", async (event, ctx) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
|
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
|
||||||
|
|
||||||
### Model Events
|
### Model Events
|
||||||
|
|
||||||
|
|
@ -527,7 +528,7 @@ pi.on("tool_call", async (event, ctx) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)
|
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)
|
||||||
|
|
||||||
#### tool_result
|
#### tool_result
|
||||||
|
|
||||||
|
|
@ -549,7 +550,7 @@ pi.on("tool_result", async (event, ctx) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
|
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
|
||||||
|
|
||||||
### User Bash Events
|
### User Bash Events
|
||||||
|
|
||||||
|
|
@ -723,7 +724,7 @@ pi.registerTool({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [hello.ts](../examples/extensions/hello.ts), [question.ts](../examples/extensions/question.ts), [todo.ts](../examples/extensions/todo.ts), [truncated-tool.ts](../examples/extensions/truncated-tool.ts)
|
**Examples:** [hello.ts](../examples/extensions/hello.ts), [question.ts](../examples/extensions/question.ts), [questionnaire.ts](../examples/extensions/questionnaire.ts), [todo.ts](../examples/extensions/todo.ts), [truncated-tool.ts](../examples/extensions/truncated-tool.ts)
|
||||||
|
|
||||||
### pi.sendMessage(message, options?)
|
### pi.sendMessage(message, options?)
|
||||||
|
|
||||||
|
|
@ -748,7 +749,7 @@ pi.sendMessage({
|
||||||
- `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
|
- `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
|
||||||
- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
|
- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
|
||||||
|
|
||||||
**Examples:** [file-trigger.ts](../examples/extensions/file-trigger.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
|
**Examples:** [file-trigger.ts](../examples/extensions/file-trigger.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
|
||||||
|
|
||||||
### pi.sendUserMessage(content, options?)
|
### pi.sendUserMessage(content, options?)
|
||||||
|
|
||||||
|
|
@ -795,7 +796,7 @@ pi.on("session_start", async (_event, ctx) => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [snake.ts](../examples/extensions/snake.ts), [tools.ts](../examples/extensions/tools.ts)
|
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [snake.ts](../examples/extensions/snake.ts), [tools.ts](../examples/extensions/tools.ts)
|
||||||
|
|
||||||
### pi.setSessionName(name)
|
### pi.setSessionName(name)
|
||||||
|
|
||||||
|
|
@ -830,7 +831,7 @@ pi.registerCommand("stats", {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
|
**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
|
||||||
|
|
||||||
### pi.registerMessageRenderer(customType, renderer)
|
### pi.registerMessageRenderer(customType, renderer)
|
||||||
|
|
||||||
|
|
@ -849,7 +850,7 @@ pi.registerShortcut("ctrl+shift+p", {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
|
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts)
|
||||||
|
|
||||||
### pi.registerFlag(name, options)
|
### pi.registerFlag(name, options)
|
||||||
|
|
||||||
|
|
@ -868,7 +869,7 @@ if (pi.getFlag("--plan")) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
|
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts)
|
||||||
|
|
||||||
### pi.exec(command, args, options?)
|
### pi.exec(command, args, options?)
|
||||||
|
|
||||||
|
|
@ -892,7 +893,7 @@ const names = all.map(t => t.name); // Just names if needed
|
||||||
pi.setActiveTools(["read", "bash"]); // Switch to read-only
|
pi.setActiveTools(["read", "bash"]); // Switch to read-only
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)
|
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)
|
||||||
|
|
||||||
### pi.setModel(model)
|
### pi.setModel(model)
|
||||||
|
|
||||||
|
|
@ -1243,7 +1244,7 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
- `ctx.ui.select()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [question.ts](../examples/extensions/question.ts)
|
- `ctx.ui.select()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [question.ts](../examples/extensions/question.ts), [questionnaire.ts](../examples/extensions/questionnaire.ts)
|
||||||
- `ctx.ui.confirm()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts)
|
- `ctx.ui.confirm()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts)
|
||||||
- `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts)
|
- `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts)
|
||||||
- `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.ts)
|
- `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.ts)
|
||||||
|
|
@ -1345,8 +1346,8 @@ ctx.ui.theme.fg("accent", "styled text"); // Access current theme
|
||||||
```
|
```
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
- `ctx.ui.setStatus()`: [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [status-line.ts](../examples/extensions/status-line.ts)
|
- `ctx.ui.setStatus()`: [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [status-line.ts](../examples/extensions/status-line.ts)
|
||||||
- `ctx.ui.setWidget()`: [plan-mode.ts](../examples/extensions/plan-mode.ts)
|
- `ctx.ui.setWidget()`: [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
|
||||||
- `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
- `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
||||||
- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts)
|
- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts)
|
||||||
- `ctx.ui.setEditorComponent()`: [modal-editor.ts](../examples/extensions/modal-editor.ts)
|
- `ctx.ui.setEditorComponent()`: [modal-editor.ts](../examples/extensions/modal-editor.ts)
|
||||||
|
|
@ -1395,9 +1396,22 @@ const result = await ctx.ui.custom<string | null>(
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Overlay components should define a `width` property to control their size. The overlay is centered by default. See [overlay-test.ts](../examples/extensions/overlay-test.ts) for a complete example.
|
For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically:
|
||||||
|
|
||||||
**Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts), [overlay-test.ts](../examples/extensions/overlay-test.ts)
|
```typescript
|
||||||
|
const result = await ctx.ui.custom<string | null>(
|
||||||
|
(tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),
|
||||||
|
{
|
||||||
|
overlay: true,
|
||||||
|
overlayOptions: { anchor: "top-right", width: "50%", margin: 2 },
|
||||||
|
onHandle: (handle) => { /* handle.setHidden(true/false) */ }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples.
|
||||||
|
|
||||||
|
**Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts), [overlay-test.ts](../examples/extensions/overlay-test.ts)
|
||||||
|
|
||||||
### Custom Editor
|
### Custom Editor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -735,12 +735,12 @@ import {
|
||||||
discoverAuthStorage,
|
discoverAuthStorage,
|
||||||
discoverModels,
|
discoverModels,
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
discoverHooks,
|
discoverExtensions,
|
||||||
discoverCustomTools,
|
|
||||||
discoverContextFiles,
|
discoverContextFiles,
|
||||||
discoverPromptTemplates,
|
discoverPromptTemplates,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
|
createEventBus,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Auth and Models
|
// Auth and Models
|
||||||
|
|
@ -754,19 +754,16 @@ const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
|
||||||
// Skills
|
// Skills
|
||||||
const { skills, warnings } = discoverSkills(cwd, agentDir, skillsSettings);
|
const { skills, warnings } = discoverSkills(cwd, agentDir, skillsSettings);
|
||||||
|
|
||||||
// Hooks (async - loads TypeScript)
|
// Extensions (async - loads TypeScript)
|
||||||
// Pass eventBus to share pi.events across hooks/tools
|
// Pass eventBus to share pi.events across extensions
|
||||||
const eventBus = createEventBus();
|
const eventBus = createEventBus();
|
||||||
const hooks = await discoverHooks(eventBus, cwd, agentDir);
|
const { extensions, errors } = await discoverExtensions(eventBus, cwd, agentDir);
|
||||||
|
|
||||||
// Custom tools (async - loads TypeScript)
|
|
||||||
const tools = await discoverCustomTools(eventBus, cwd, agentDir);
|
|
||||||
|
|
||||||
// Context files
|
// Context files
|
||||||
const contextFiles = discoverContextFiles(cwd, agentDir);
|
const contextFiles = discoverContextFiles(cwd, agentDir);
|
||||||
|
|
||||||
// Prompt templates
|
// Prompt templates
|
||||||
const commands = discoverPromptTemplates(cwd, agentDir);
|
const templates = discoverPromptTemplates(cwd, agentDir);
|
||||||
|
|
||||||
// Settings (global + project merged)
|
// Settings (global + project merged)
|
||||||
const settings = loadSettings(cwd, agentDir);
|
const settings = loadSettings(cwd, agentDir);
|
||||||
|
|
@ -816,8 +813,8 @@ import {
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
readTool,
|
readTool,
|
||||||
bashTool,
|
bashTool,
|
||||||
type HookFactory,
|
type ExtensionFactory,
|
||||||
type CustomTool,
|
type ToolDefinition,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Set up auth storage (custom location)
|
// Set up auth storage (custom location)
|
||||||
|
|
@ -831,16 +828,16 @@ if (process.env.MY_KEY) {
|
||||||
// Model registry (no custom models.json)
|
// Model registry (no custom models.json)
|
||||||
const modelRegistry = new ModelRegistry(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
// Inline hook
|
// Inline extension
|
||||||
const auditHook: HookFactory = (api) => {
|
const auditExtension: ExtensionFactory = (pi) => {
|
||||||
api.on("tool_call", async (event) => {
|
pi.on("tool_call", async (event) => {
|
||||||
console.log(`[Audit] ${event.toolName}`);
|
console.log(`[Audit] ${event.toolName}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inline tool
|
// Inline tool
|
||||||
const statusTool: CustomTool = {
|
const statusTool: ToolDefinition = {
|
||||||
name: "status",
|
name: "status",
|
||||||
label: "Status",
|
label: "Status",
|
||||||
description: "Get system status",
|
description: "Get system status",
|
||||||
|
|
@ -872,8 +869,8 @@ const { session } = await createAgentSession({
|
||||||
systemPrompt: "You are a minimal assistant. Be concise.",
|
systemPrompt: "You are a minimal assistant. Be concise.",
|
||||||
|
|
||||||
tools: [readTool, bashTool],
|
tools: [readTool, bashTool],
|
||||||
customTools: [{ tool: statusTool }],
|
customTools: [statusTool],
|
||||||
hooks: [{ factory: auditHook }],
|
extensions: [auditExtension],
|
||||||
skills: [],
|
skills: [],
|
||||||
contextFiles: [],
|
contextFiles: [],
|
||||||
promptTemplates: [],
|
promptTemplates: [],
|
||||||
|
|
@ -961,7 +958,7 @@ The SDK is preferred when:
|
||||||
- You want type safety
|
- You want type safety
|
||||||
- You're in the same Node.js process
|
- You're in the same Node.js process
|
||||||
- You need direct access to agent state
|
- You need direct access to agent state
|
||||||
- You want to customize tools/hooks programmatically
|
- You want to customize tools/extensions programmatically
|
||||||
|
|
||||||
RPC mode is preferred when:
|
RPC mode is preferred when:
|
||||||
- You're integrating from another language
|
- You're integrating from another language
|
||||||
|
|
@ -984,12 +981,11 @@ discoverModels
|
||||||
|
|
||||||
// Discovery
|
// Discovery
|
||||||
discoverSkills
|
discoverSkills
|
||||||
discoverHooks
|
discoverExtensions
|
||||||
discoverCustomTools
|
|
||||||
discoverContextFiles
|
discoverContextFiles
|
||||||
discoverPromptTemplates
|
discoverPromptTemplates
|
||||||
|
|
||||||
// Event Bus (for shared hook/tool communication)
|
// Event Bus (for shared extension communication)
|
||||||
createEventBus
|
createEventBus
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -1015,8 +1011,9 @@ createGrepTool, createFindTool, createLsTool
|
||||||
// Types
|
// Types
|
||||||
type CreateAgentSessionOptions
|
type CreateAgentSessionOptions
|
||||||
type CreateAgentSessionResult
|
type CreateAgentSessionResult
|
||||||
type CustomTool
|
type ExtensionFactory
|
||||||
type HookFactory
|
type ExtensionAPI
|
||||||
|
type ToolDefinition
|
||||||
type Skill
|
type Skill
|
||||||
type PromptTemplate
|
type PromptTemplate
|
||||||
type Settings
|
type Settings
|
||||||
|
|
@ -1024,28 +1021,4 @@ type SkillsSettings
|
||||||
type Tool
|
type Tool
|
||||||
```
|
```
|
||||||
|
|
||||||
For hook types, import from the hooks subpath:
|
For extension types, see [extensions.md](extensions.md) for the full API.
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type {
|
|
||||||
HookAPI,
|
|
||||||
HookMessage,
|
|
||||||
HookFactory,
|
|
||||||
HookEventContext,
|
|
||||||
HookCommandContext,
|
|
||||||
ToolCallEvent,
|
|
||||||
ToolResultEvent,
|
|
||||||
} from "@mariozechner/pi-coding-agent/hooks";
|
|
||||||
```
|
|
||||||
|
|
||||||
For message utilities:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { isHookMessage, createHookMessage } from "@mariozechner/pi-coding-agent";
|
|
||||||
```
|
|
||||||
|
|
||||||
For config utilities:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getAgentDir } from "@mariozechner/pi-coding-agent/config";
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,56 @@ async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Overlays
|
||||||
|
|
||||||
|
Overlays render components on top of existing content without clearing the screen. Pass `{ overlay: true }` to `ctx.ui.custom()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await ctx.ui.custom<string | null>(
|
||||||
|
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
|
||||||
|
{ overlay: true }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
For positioning and sizing, use `overlayOptions`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await ctx.ui.custom<string | null>(
|
||||||
|
(tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
|
||||||
|
{
|
||||||
|
overlay: true,
|
||||||
|
overlayOptions: {
|
||||||
|
// Size: number or percentage string
|
||||||
|
width: "50%", // 50% of terminal width
|
||||||
|
minWidth: 40, // minimum 40 columns
|
||||||
|
maxHeight: "80%", // max 80% of terminal height
|
||||||
|
|
||||||
|
// Position: anchor-based (default: "center")
|
||||||
|
anchor: "right-center", // 9 positions: center, top-left, top-center, etc.
|
||||||
|
offsetX: -2, // offset from anchor
|
||||||
|
offsetY: 0,
|
||||||
|
|
||||||
|
// Or percentage/absolute positioning
|
||||||
|
row: "25%", // 25% from top
|
||||||
|
col: 10, // column 10
|
||||||
|
|
||||||
|
// Margins
|
||||||
|
margin: 2, // all sides, or { top, right, bottom, left }
|
||||||
|
|
||||||
|
// Responsive: hide on narrow terminals
|
||||||
|
visible: (termWidth, termHeight) => termWidth >= 80,
|
||||||
|
},
|
||||||
|
// Get handle for programmatic visibility control
|
||||||
|
onHandle: (handle) => {
|
||||||
|
// handle.setHidden(true/false) - toggle visibility
|
||||||
|
// handle.hide() - permanently remove
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
See [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation.
|
||||||
|
|
||||||
## Built-in Components
|
## Built-in Components
|
||||||
|
|
||||||
Import from `@mariozechner/pi-tui`:
|
Import from `@mariozechner/pi-tui`:
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ Programmatic usage via `createAgentSession()`. Shows how to customize models, pr
|
||||||
### [extensions/](extensions/)
|
### [extensions/](extensions/)
|
||||||
Example extensions demonstrating:
|
Example extensions demonstrating:
|
||||||
- Lifecycle event handlers (tool interception, safety gates, context modifications)
|
- Lifecycle event handlers (tool interception, safety gates, context modifications)
|
||||||
- Custom tools (todo lists, subagents)
|
- Custom tools (todo lists, questions, subagents, output truncation)
|
||||||
- Commands and keyboard shortcuts
|
- Commands and keyboard shortcuts
|
||||||
- External integrations (git, file watchers)
|
- Custom UI (footers, headers, editors, overlays)
|
||||||
|
- Git integration (checkpoints, auto-commit)
|
||||||
|
- System prompt modifications and custom compaction
|
||||||
|
- External integrations (SSH, file watchers, system theme sync)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
||||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
|
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
|
||||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
||||||
|
| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config |
|
||||||
|
|
||||||
### Custom Tools
|
### Custom Tools
|
||||||
|
|
||||||
|
|
@ -29,8 +30,10 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence |
|
| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence |
|
||||||
| `hello.ts` | Minimal custom tool example |
|
| `hello.ts` | Minimal custom tool example |
|
||||||
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions |
|
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions with custom UI |
|
||||||
|
| `questionnaire.ts` | Multi-question input with tab bar navigation between questions |
|
||||||
| `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
|
| `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
|
||||||
|
| `truncated-tool.ts` | Wraps ripgrep with proper output truncation (50KB/2000 lines) |
|
||||||
| `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations |
|
| `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations |
|
||||||
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
|
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
|
||||||
|
|
||||||
|
|
@ -39,19 +42,26 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||||
| Extension | Description |
|
| Extension | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `preset.ts` | Named presets for model, thinking level, tools, and instructions via `--preset` flag and `/preset` command |
|
| `preset.ts` | Named presets for model, thinking level, tools, and instructions via `--preset` flag and `/preset` command |
|
||||||
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
|
| `plan-mode/` | Claude Code-style plan mode for read-only exploration with `/plan` command and step tracking |
|
||||||
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
|
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
|
||||||
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
|
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
|
||||||
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
|
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
|
||||||
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
|
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
|
||||||
|
| `model-status.ts` | Shows model changes in status bar via `model_select` hook |
|
||||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
||||||
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
|
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
|
||||||
| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs |
|
| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs |
|
||||||
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
|
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
|
||||||
|
| `rainbow-editor.ts` | Animated rainbow text effect via custom editor |
|
||||||
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
|
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
|
||||||
|
| `summarize.ts` | Summarize conversation with GPT-5.2 and show in transient UI |
|
||||||
|
| `custom-footer.ts` | Custom footer with git branch and token stats via `ctx.ui.setFooter()` |
|
||||||
|
| `custom-header.ts` | Custom header via `ctx.ui.setHeader()` |
|
||||||
| `overlay-test.ts` | Test overlay compositing with inline text inputs and edge cases |
|
| `overlay-test.ts` | Test overlay compositing with inline text inputs and edge cases |
|
||||||
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
|
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
|
||||||
| `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
|
| `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
|
||||||
|
| `shutdown-command.ts` | Adds `/quit` command demonstrating `ctx.shutdown()` |
|
||||||
|
| `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook |
|
||||||
|
|
||||||
### Git Integration
|
### Git Integration
|
||||||
|
|
||||||
|
|
@ -65,8 +75,15 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||||
| Extension | Description |
|
| Extension | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
|
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
|
||||||
|
| `claude-rules.ts` | Scans `.claude/rules/` folder and lists rules in system prompt |
|
||||||
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
|
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
|
||||||
|
|
||||||
|
### System Integration
|
||||||
|
|
||||||
|
| Extension | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `mac-system-theme.ts` | Syncs pi theme with macOS dark/light mode |
|
||||||
|
|
||||||
### External Dependencies
|
### External Dependencies
|
||||||
|
|
||||||
| Extension | Description |
|
| Extension | Description |
|
||||||
|
|
|
||||||
|
|
@ -1,548 +0,0 @@
|
||||||
/**
|
|
||||||
* Plan Mode Extension
|
|
||||||
*
|
|
||||||
* Provides a Claude Code-style "plan mode" for safe code exploration.
|
|
||||||
* When enabled, the agent can only use read-only tools and cannot modify files.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - /plan command to toggle plan mode
|
|
||||||
* - In plan mode: only read, bash (read-only), grep, find, ls are available
|
|
||||||
* - Injects system context telling the agent about the restrictions
|
|
||||||
* - After each agent response, prompts to execute the plan or continue planning
|
|
||||||
* - Shows "plan" indicator in footer when active
|
|
||||||
* - Extracts todo list from plan and tracks progress during execution
|
|
||||||
* - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
|
||||||
* 2. Use /plan to toggle plan mode on/off
|
|
||||||
* 3. Or start in plan mode with --plan flag
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Key } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
// Read-only tools for plan mode
|
|
||||||
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
|
|
||||||
|
|
||||||
// Full set of tools for normal mode
|
|
||||||
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
|
|
||||||
|
|
||||||
// Patterns for destructive bash commands that should be blocked in plan mode
|
|
||||||
const DESTRUCTIVE_PATTERNS = [
|
|
||||||
/\brm\b/i,
|
|
||||||
/\brmdir\b/i,
|
|
||||||
/\bmv\b/i,
|
|
||||||
/\bcp\b/i,
|
|
||||||
/\bmkdir\b/i,
|
|
||||||
/\btouch\b/i,
|
|
||||||
/\bchmod\b/i,
|
|
||||||
/\bchown\b/i,
|
|
||||||
/\bchgrp\b/i,
|
|
||||||
/\bln\b/i,
|
|
||||||
/\btee\b/i,
|
|
||||||
/\btruncate\b/i,
|
|
||||||
/\bdd\b/i,
|
|
||||||
/\bshred\b/i,
|
|
||||||
/[^<]>(?!>)/,
|
|
||||||
/>>/,
|
|
||||||
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
||||||
/\byarn\s+(add|remove|install|publish)/i,
|
|
||||||
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
||||||
/\bpip\s+(install|uninstall)/i,
|
|
||||||
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
||||||
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
||||||
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout\s+-b|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
||||||
/\bsudo\b/i,
|
|
||||||
/\bsu\b/i,
|
|
||||||
/\bkill\b/i,
|
|
||||||
/\bpkill\b/i,
|
|
||||||
/\bkillall\b/i,
|
|
||||||
/\breboot\b/i,
|
|
||||||
/\bshutdown\b/i,
|
|
||||||
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
||||||
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
||||||
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Read-only commands that are always safe
|
|
||||||
const SAFE_COMMANDS = [
|
|
||||||
/^\s*cat\b/,
|
|
||||||
/^\s*head\b/,
|
|
||||||
/^\s*tail\b/,
|
|
||||||
/^\s*less\b/,
|
|
||||||
/^\s*more\b/,
|
|
||||||
/^\s*grep\b/,
|
|
||||||
/^\s*find\b/,
|
|
||||||
/^\s*ls\b/,
|
|
||||||
/^\s*pwd\b/,
|
|
||||||
/^\s*echo\b/,
|
|
||||||
/^\s*printf\b/,
|
|
||||||
/^\s*wc\b/,
|
|
||||||
/^\s*sort\b/,
|
|
||||||
/^\s*uniq\b/,
|
|
||||||
/^\s*diff\b/,
|
|
||||||
/^\s*file\b/,
|
|
||||||
/^\s*stat\b/,
|
|
||||||
/^\s*du\b/,
|
|
||||||
/^\s*df\b/,
|
|
||||||
/^\s*tree\b/,
|
|
||||||
/^\s*which\b/,
|
|
||||||
/^\s*whereis\b/,
|
|
||||||
/^\s*type\b/,
|
|
||||||
/^\s*env\b/,
|
|
||||||
/^\s*printenv\b/,
|
|
||||||
/^\s*uname\b/,
|
|
||||||
/^\s*whoami\b/,
|
|
||||||
/^\s*id\b/,
|
|
||||||
/^\s*date\b/,
|
|
||||||
/^\s*cal\b/,
|
|
||||||
/^\s*uptime\b/,
|
|
||||||
/^\s*ps\b/,
|
|
||||||
/^\s*top\b/,
|
|
||||||
/^\s*htop\b/,
|
|
||||||
/^\s*free\b/,
|
|
||||||
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
||||||
/^\s*git\s+ls-/i,
|
|
||||||
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
||||||
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
||||||
/^\s*node\s+--version/i,
|
|
||||||
/^\s*python\s+--version/i,
|
|
||||||
/^\s*curl\s/i,
|
|
||||||
/^\s*wget\s+-O\s*-/i,
|
|
||||||
/^\s*jq\b/,
|
|
||||||
/^\s*sed\s+-n/i,
|
|
||||||
/^\s*awk\b/,
|
|
||||||
/^\s*rg\b/,
|
|
||||||
/^\s*fd\b/,
|
|
||||||
/^\s*bat\b/,
|
|
||||||
/^\s*exa\b/,
|
|
||||||
];
|
|
||||||
|
|
||||||
function isSafeCommand(command: string): boolean {
|
|
||||||
if (SAFE_COMMANDS.some((pattern) => pattern.test(command))) {
|
|
||||||
if (!DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Todo item with step number
|
|
||||||
interface TodoItem {
|
|
||||||
step: number;
|
|
||||||
text: string;
|
|
||||||
completed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up extracted step text for display.
|
|
||||||
*/
|
|
||||||
function cleanStepText(text: string): string {
|
|
||||||
let cleaned = text
|
|
||||||
// Remove markdown bold/italic
|
|
||||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
|
|
||||||
// Remove markdown code
|
|
||||||
.replace(/`([^`]+)`/g, "$1")
|
|
||||||
// Remove leading action words that are redundant
|
|
||||||
.replace(
|
|
||||||
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
// Clean up extra whitespace
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Capitalize first letter
|
|
||||||
if (cleaned.length > 0) {
|
|
||||||
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate if too long
|
|
||||||
if (cleaned.length > 50) {
|
|
||||||
cleaned = `${cleaned.slice(0, 47)}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract todo items from assistant message.
|
|
||||||
*/
|
|
||||||
function extractTodoItems(message: string): TodoItem[] {
|
|
||||||
const items: TodoItem[] = [];
|
|
||||||
|
|
||||||
// Match numbered lists: "1. Task" or "1) Task" - also handle **bold** prefixes
|
|
||||||
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
|
||||||
for (const match of message.matchAll(numberedPattern)) {
|
|
||||||
let text = match[2].trim();
|
|
||||||
text = text.replace(/\*{1,2}$/, "").trim();
|
|
||||||
// Skip if too short or looks like code/command
|
|
||||||
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
|
||||||
const cleaned = cleanStepText(text);
|
|
||||||
if (cleaned.length > 3) {
|
|
||||||
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no numbered items, try bullet points
|
|
||||||
if (items.length === 0) {
|
|
||||||
const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim;
|
|
||||||
for (const match of message.matchAll(stepPattern)) {
|
|
||||||
let text = match[1].trim();
|
|
||||||
text = text.replace(/\*{1,2}$/, "").trim();
|
|
||||||
if (text.length > 10 && !text.startsWith("`")) {
|
|
||||||
const cleaned = cleanStepText(text);
|
|
||||||
if (cleaned.length > 3) {
|
|
||||||
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function planModeExtension(pi: ExtensionAPI) {
|
|
||||||
let planModeEnabled = false;
|
|
||||||
let toolsCalledThisTurn = false;
|
|
||||||
let executionMode = false;
|
|
||||||
let todoItems: TodoItem[] = [];
|
|
||||||
|
|
||||||
// Register --plan CLI flag
|
|
||||||
pi.registerFlag("plan", {
|
|
||||||
description: "Start in plan mode (read-only exploration)",
|
|
||||||
type: "boolean",
|
|
||||||
default: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to update status displays
|
|
||||||
function updateStatus(ctx: ExtensionContext) {
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
const completed = todoItems.filter((t) => t.completed).length;
|
|
||||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
|
|
||||||
} else if (planModeEnabled) {
|
|
||||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
|
||||||
} else {
|
|
||||||
ctx.ui.setStatus("plan-mode", undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show widget during execution (no IDs shown to user)
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const item of todoItems) {
|
|
||||||
if (item.completed) {
|
|
||||||
lines.push(
|
|
||||||
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.ui.setWidget("plan-todos", lines);
|
|
||||||
} else {
|
|
||||||
ctx.ui.setWidget("plan-todos", undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePlanMode(ctx: ExtensionContext) {
|
|
||||||
planModeEnabled = !planModeEnabled;
|
|
||||||
executionMode = false;
|
|
||||||
todoItems = [];
|
|
||||||
|
|
||||||
if (planModeEnabled) {
|
|
||||||
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
||||||
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
|
|
||||||
} else {
|
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
||||||
ctx.ui.notify("Plan mode disabled. Full access restored.");
|
|
||||||
}
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register /plan command
|
|
||||||
pi.registerCommand("plan", {
|
|
||||||
description: "Toggle plan mode (read-only exploration)",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
togglePlanMode(ctx);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register /todos command
|
|
||||||
pi.registerCommand("todos", {
|
|
||||||
description: "Show current plan todo list",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
if (todoItems.length === 0) {
|
|
||||||
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const todoList = todoItems
|
|
||||||
.map((item, i) => {
|
|
||||||
const checkbox = item.completed ? "✓" : "○";
|
|
||||||
return `${i + 1}. ${checkbox} ${item.text}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
ctx.ui.notify(`Plan Progress:\n${todoList}`, "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register Shift+P shortcut
|
|
||||||
pi.registerShortcut(Key.shift("p"), {
|
|
||||||
description: "Toggle plan mode",
|
|
||||||
handler: async (ctx) => {
|
|
||||||
togglePlanMode(ctx);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Block destructive bash in plan mode
|
|
||||||
pi.on("tool_call", async (event) => {
|
|
||||||
if (!planModeEnabled) return;
|
|
||||||
if (event.toolName !== "bash") return;
|
|
||||||
|
|
||||||
const command = event.input.command as string;
|
|
||||||
if (!isSafeCommand(command)) {
|
|
||||||
return {
|
|
||||||
block: true,
|
|
||||||
reason: `Plan mode: destructive command blocked. Use /plan to disable plan mode first.\nCommand: ${command}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track step completion based on tool results
|
|
||||||
pi.on("tool_result", async (_event, ctx) => {
|
|
||||||
toolsCalledThisTurn = true;
|
|
||||||
|
|
||||||
if (!executionMode || todoItems.length === 0) return;
|
|
||||||
|
|
||||||
// Mark the first uncompleted step as done when any tool succeeds
|
|
||||||
const nextStep = todoItems.find((t) => !t.completed);
|
|
||||||
if (nextStep) {
|
|
||||||
nextStep.completed = true;
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter out stale plan mode context messages from LLM context
|
|
||||||
// This ensures the agent only sees the CURRENT state (plan mode on/off)
|
|
||||||
pi.on("context", async (event) => {
|
|
||||||
// Only filter when NOT in plan mode (i.e., when executing)
|
|
||||||
if (planModeEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any previous plan-mode-context messages
|
|
||||||
const _beforeCount = event.messages.length;
|
|
||||||
const filtered = event.messages.filter((m) => {
|
|
||||||
if (m.role === "user" && Array.isArray(m.content)) {
|
|
||||||
const hasOldContext = m.content.some((c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"));
|
|
||||||
if (hasOldContext) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return { messages: filtered };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inject plan mode context
|
|
||||||
pi.on("before_agent_start", async () => {
|
|
||||||
if (!planModeEnabled && !executionMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planModeEnabled) {
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
customType: "plan-mode-context",
|
|
||||||
content: `[PLAN MODE ACTIVE]
|
|
||||||
You are in plan mode - a read-only exploration mode for safe code analysis.
|
|
||||||
|
|
||||||
Restrictions:
|
|
||||||
- You can only use: read, bash, grep, find, ls
|
|
||||||
- You CANNOT use: edit, write (file modifications are disabled)
|
|
||||||
- Bash is restricted to READ-ONLY commands
|
|
||||||
- Focus on analysis, planning, and understanding the codebase
|
|
||||||
|
|
||||||
Create a detailed numbered plan:
|
|
||||||
1. First step description
|
|
||||||
2. Second step description
|
|
||||||
...
|
|
||||||
|
|
||||||
Do NOT attempt to make changes - just describe what you would do.`,
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
const remaining = todoItems.filter((t) => !t.completed);
|
|
||||||
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
customType: "plan-execution-context",
|
|
||||||
content: `[EXECUTING PLAN - Full tool access enabled]
|
|
||||||
|
|
||||||
Remaining steps:
|
|
||||||
${todoList}
|
|
||||||
|
|
||||||
Execute each step in order.`,
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// After agent finishes
|
|
||||||
pi.on("agent_end", async (event, ctx) => {
|
|
||||||
// In execution mode, check if all steps complete
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
const allComplete = todoItems.every((t) => t.completed);
|
|
||||||
if (allComplete) {
|
|
||||||
// Show final completed list in chat
|
|
||||||
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
|
|
||||||
pi.sendMessage(
|
|
||||||
{
|
|
||||||
customType: "plan-complete",
|
|
||||||
content: `**Plan Complete!** ✓\n\n${completedList}`,
|
|
||||||
display: true,
|
|
||||||
},
|
|
||||||
{ triggerTurn: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
executionMode = false;
|
|
||||||
todoItems = [];
|
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!planModeEnabled) return;
|
|
||||||
if (!ctx.hasUI) return;
|
|
||||||
|
|
||||||
// Extract todos from last message
|
|
||||||
const messages = event.messages;
|
|
||||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
|
||||||
if (lastAssistant && Array.isArray(lastAssistant.content)) {
|
|
||||||
const textContent = lastAssistant.content
|
|
||||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
||||||
.map((block) => block.text)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
if (textContent) {
|
|
||||||
const extracted = extractTodoItems(textContent);
|
|
||||||
if (extracted.length > 0) {
|
|
||||||
todoItems = extracted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasTodos = todoItems.length > 0;
|
|
||||||
|
|
||||||
// Show todo list in chat (no IDs shown to user, just numbered)
|
|
||||||
if (hasTodos) {
|
|
||||||
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
|
||||||
pi.sendMessage(
|
|
||||||
{
|
|
||||||
customType: "plan-todo-list",
|
|
||||||
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
|
|
||||||
display: true,
|
|
||||||
},
|
|
||||||
{ triggerTurn: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const choice = await ctx.ui.select("Plan mode - what next?", [
|
|
||||||
hasTodos ? "Execute the plan (track progress)" : "Execute the plan",
|
|
||||||
"Stay in plan mode",
|
|
||||||
"Refine the plan",
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (choice?.startsWith("Execute")) {
|
|
||||||
planModeEnabled = false;
|
|
||||||
executionMode = hasTodos;
|
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
||||||
updateStatus(ctx);
|
|
||||||
|
|
||||||
// Simple execution message - context event filters old plan mode messages
|
|
||||||
// and before_agent_start injects fresh execution context with IDs
|
|
||||||
const execMessage = hasTodos
|
|
||||||
? `Execute the plan. Start with: ${todoItems[0].text}`
|
|
||||||
: "Execute the plan you just created.";
|
|
||||||
|
|
||||||
pi.sendMessage(
|
|
||||||
{
|
|
||||||
customType: "plan-mode-execute",
|
|
||||||
content: execMessage,
|
|
||||||
display: true,
|
|
||||||
},
|
|
||||||
{ triggerTurn: true },
|
|
||||||
);
|
|
||||||
} else if (choice === "Refine the plan") {
|
|
||||||
const refinement = await ctx.ui.input("What should be refined?");
|
|
||||||
if (refinement) {
|
|
||||||
ctx.ui.setEditorText(refinement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize state on session start
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
if (pi.getFlag("plan") === true) {
|
|
||||||
planModeEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
const planModeEntry = entries
|
|
||||||
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
|
|
||||||
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
|
|
||||||
|
|
||||||
if (planModeEntry?.data) {
|
|
||||||
if (planModeEntry.data.enabled !== undefined) {
|
|
||||||
planModeEnabled = planModeEntry.data.enabled;
|
|
||||||
}
|
|
||||||
if (planModeEntry.data.todos) {
|
|
||||||
todoItems = planModeEntry.data.todos;
|
|
||||||
}
|
|
||||||
if (planModeEntry.data.executing) {
|
|
||||||
executionMode = planModeEntry.data.executing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planModeEnabled) {
|
|
||||||
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
||||||
}
|
|
||||||
updateStatus(ctx);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset tool tracking at start of each turn and persist state
|
|
||||||
pi.on("turn_start", async () => {
|
|
||||||
toolsCalledThisTurn = false;
|
|
||||||
pi.appendEntry("plan-mode", {
|
|
||||||
enabled: planModeEnabled,
|
|
||||||
todos: todoItems,
|
|
||||||
executing: executionMode,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle non-tool turns (e.g., analysis, explanation steps)
|
|
||||||
pi.on("turn_end", async (_event, ctx) => {
|
|
||||||
if (!executionMode || todoItems.length === 0) return;
|
|
||||||
|
|
||||||
// If no tools were called this turn, the agent was doing analysis/explanation
|
|
||||||
// Mark the next uncompleted step as done
|
|
||||||
if (!toolsCalledThisTurn) {
|
|
||||||
const nextStep = todoItems.find((t) => !t.completed);
|
|
||||||
if (nextStep) {
|
|
||||||
nextStep.completed = true;
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Plan Mode Extension
|
||||||
|
|
||||||
|
Read-only exploration mode for safe code analysis.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
|
||||||
|
- **Bash allowlist**: Only read-only bash commands are allowed
|
||||||
|
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
|
||||||
|
- **Progress tracking**: Widget shows completion status during execution
|
||||||
|
- **[DONE:n] markers**: Explicit step completion tracking
|
||||||
|
- **Session persistence**: State survives session resume
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `/plan` - Toggle plan mode
|
||||||
|
- `/todos` - Show current plan progress
|
||||||
|
- `Shift+P` - Toggle plan mode (shortcut)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Enable plan mode with `/plan` or `--plan` flag
|
||||||
|
2. Ask the agent to analyze code and create a plan
|
||||||
|
3. The agent should output a numbered plan under a `Plan:` header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Plan:
|
||||||
|
1. First step description
|
||||||
|
2. Second step description
|
||||||
|
3. Third step description
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Choose "Execute the plan" when prompted
|
||||||
|
5. During execution, the agent marks steps complete with `[DONE:n]` tags
|
||||||
|
6. Progress widget shows completion status
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Plan Mode (Read-Only)
|
||||||
|
- Only read-only tools available
|
||||||
|
- Bash commands filtered through allowlist
|
||||||
|
- Agent creates a plan without making changes
|
||||||
|
|
||||||
|
### Execution Mode
|
||||||
|
- Full tool access restored
|
||||||
|
- Agent executes steps in order
|
||||||
|
- `[DONE:n]` markers track completion
|
||||||
|
- Widget shows progress
|
||||||
|
|
||||||
|
### Command Allowlist
|
||||||
|
|
||||||
|
Safe commands (allowed):
|
||||||
|
- File inspection: `cat`, `head`, `tail`, `less`, `more`
|
||||||
|
- Search: `grep`, `find`, `rg`, `fd`
|
||||||
|
- Directory: `ls`, `pwd`, `tree`
|
||||||
|
- Git read: `git status`, `git log`, `git diff`, `git branch`
|
||||||
|
- Package info: `npm list`, `npm outdated`, `yarn info`
|
||||||
|
- System info: `uname`, `whoami`, `date`, `uptime`
|
||||||
|
|
||||||
|
Blocked commands:
|
||||||
|
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
|
||||||
|
- Git write: `git add`, `git commit`, `git push`
|
||||||
|
- Package install: `npm install`, `yarn add`, `pip install`
|
||||||
|
- System: `sudo`, `kill`, `reboot`
|
||||||
|
- Editors: `vim`, `nano`, `code`
|
||||||
340
packages/coding-agent/examples/extensions/plan-mode/index.ts
Normal file
340
packages/coding-agent/examples/extensions/plan-mode/index.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
/**
|
||||||
|
* Plan Mode Extension
|
||||||
|
*
|
||||||
|
* Read-only exploration mode for safe code analysis.
|
||||||
|
* When enabled, only read-only tools are available.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - /plan command or Shift+P to toggle
|
||||||
|
* - Bash restricted to allowlisted read-only commands
|
||||||
|
* - Extracts numbered plan steps from "Plan:" sections
|
||||||
|
* - [DONE:n] markers to complete steps during execution
|
||||||
|
* - Progress tracking widget during execution
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Key } from "@mariozechner/pi-tui";
|
||||||
|
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
|
||||||
|
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
|
||||||
|
|
||||||
|
// Type guard for assistant messages
|
||||||
|
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
||||||
|
return m.role === "assistant" && Array.isArray(m.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text content from an assistant message
|
||||||
|
function getTextContent(message: AssistantMessage): string {
|
||||||
|
return message.content
|
||||||
|
.filter((block): block is TextContent => block.type === "text")
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function planModeExtension(pi: ExtensionAPI): void {
|
||||||
|
let planModeEnabled = false;
|
||||||
|
let executionMode = false;
|
||||||
|
let todoItems: TodoItem[] = [];
|
||||||
|
|
||||||
|
pi.registerFlag("plan", {
|
||||||
|
description: "Start in plan mode (read-only exploration)",
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateStatus(ctx: ExtensionContext): void {
|
||||||
|
// Footer status
|
||||||
|
if (executionMode && todoItems.length > 0) {
|
||||||
|
const completed = todoItems.filter((t) => t.completed).length;
|
||||||
|
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
|
||||||
|
} else if (planModeEnabled) {
|
||||||
|
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
||||||
|
} else {
|
||||||
|
ctx.ui.setStatus("plan-mode", undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget showing todo list
|
||||||
|
if (executionMode && todoItems.length > 0) {
|
||||||
|
const lines = todoItems.map((item) => {
|
||||||
|
if (item.completed) {
|
||||||
|
return (
|
||||||
|
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
|
||||||
|
});
|
||||||
|
ctx.ui.setWidget("plan-todos", lines);
|
||||||
|
} else {
|
||||||
|
ctx.ui.setWidget("plan-todos", undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlanMode(ctx: ExtensionContext): void {
|
||||||
|
planModeEnabled = !planModeEnabled;
|
||||||
|
executionMode = false;
|
||||||
|
todoItems = [];
|
||||||
|
|
||||||
|
if (planModeEnabled) {
|
||||||
|
pi.setActiveTools(PLAN_MODE_TOOLS);
|
||||||
|
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
|
||||||
|
} else {
|
||||||
|
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||||
|
ctx.ui.notify("Plan mode disabled. Full access restored.");
|
||||||
|
}
|
||||||
|
updateStatus(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistState(): void {
|
||||||
|
pi.appendEntry("plan-mode", {
|
||||||
|
enabled: planModeEnabled,
|
||||||
|
todos: todoItems,
|
||||||
|
executing: executionMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerCommand("plan", {
|
||||||
|
description: "Toggle plan mode (read-only exploration)",
|
||||||
|
handler: async (_args, ctx) => togglePlanMode(ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("todos", {
|
||||||
|
description: "Show current plan todo list",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (todoItems.length === 0) {
|
||||||
|
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
|
||||||
|
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerShortcut(Key.shift("p"), {
|
||||||
|
description: "Toggle plan mode",
|
||||||
|
handler: async (ctx) => togglePlanMode(ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block destructive bash commands in plan mode
|
||||||
|
pi.on("tool_call", async (event) => {
|
||||||
|
if (!planModeEnabled || event.toolName !== "bash") return;
|
||||||
|
|
||||||
|
const command = event.input.command as string;
|
||||||
|
if (!isSafeCommand(command)) {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out stale plan mode context when not in plan mode
|
||||||
|
pi.on("context", async (event) => {
|
||||||
|
if (planModeEnabled) return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: event.messages.filter((m) => {
|
||||||
|
const msg = m as AgentMessage & { customType?: string };
|
||||||
|
if (msg.customType === "plan-mode-context") return false;
|
||||||
|
if (msg.role !== "user") return true;
|
||||||
|
|
||||||
|
const content = msg.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return !content.includes("[PLAN MODE ACTIVE]");
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return !content.some(
|
||||||
|
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject plan/execution context before agent starts
|
||||||
|
pi.on("before_agent_start", async () => {
|
||||||
|
if (planModeEnabled) {
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
customType: "plan-mode-context",
|
||||||
|
content: `[PLAN MODE ACTIVE]
|
||||||
|
You are in plan mode - a read-only exploration mode for safe code analysis.
|
||||||
|
|
||||||
|
Restrictions:
|
||||||
|
- You can only use: read, bash, grep, find, ls, questionnaire
|
||||||
|
- You CANNOT use: edit, write (file modifications are disabled)
|
||||||
|
- Bash is restricted to an allowlist of read-only commands
|
||||||
|
|
||||||
|
Ask clarifying questions using the questionnaire tool.
|
||||||
|
Use brave-search skill via bash for web research.
|
||||||
|
|
||||||
|
Create a detailed numbered plan under a "Plan:" header:
|
||||||
|
|
||||||
|
Plan:
|
||||||
|
1. First step description
|
||||||
|
2. Second step description
|
||||||
|
...
|
||||||
|
|
||||||
|
Do NOT attempt to make changes - just describe what you would do.`,
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executionMode && todoItems.length > 0) {
|
||||||
|
const remaining = todoItems.filter((t) => !t.completed);
|
||||||
|
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
customType: "plan-execution-context",
|
||||||
|
content: `[EXECUTING PLAN - Full tool access enabled]
|
||||||
|
|
||||||
|
Remaining steps:
|
||||||
|
${todoList}
|
||||||
|
|
||||||
|
Execute each step in order.
|
||||||
|
After completing a step, include a [DONE:n] tag in your response.`,
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track progress after each turn
|
||||||
|
pi.on("turn_end", async (event, ctx) => {
|
||||||
|
if (!executionMode || todoItems.length === 0) return;
|
||||||
|
if (!isAssistantMessage(event.message)) return;
|
||||||
|
|
||||||
|
const text = getTextContent(event.message);
|
||||||
|
if (markCompletedSteps(text, todoItems) > 0) {
|
||||||
|
updateStatus(ctx);
|
||||||
|
}
|
||||||
|
persistState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle plan completion and plan mode UI
|
||||||
|
pi.on("agent_end", async (event, ctx) => {
|
||||||
|
// Check if execution is complete
|
||||||
|
if (executionMode && todoItems.length > 0) {
|
||||||
|
if (todoItems.every((t) => t.completed)) {
|
||||||
|
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
|
||||||
|
pi.sendMessage(
|
||||||
|
{ customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
|
||||||
|
{ triggerTurn: false },
|
||||||
|
);
|
||||||
|
executionMode = false;
|
||||||
|
todoItems = [];
|
||||||
|
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||||
|
updateStatus(ctx);
|
||||||
|
persistState(); // Save cleared state so resume doesn't restore old execution mode
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!planModeEnabled || !ctx.hasUI) return;
|
||||||
|
|
||||||
|
// Extract todos from last assistant message
|
||||||
|
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
||||||
|
if (lastAssistant) {
|
||||||
|
const extracted = extractTodoItems(getTextContent(lastAssistant));
|
||||||
|
if (extracted.length > 0) {
|
||||||
|
todoItems = extracted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show plan steps and prompt for next action
|
||||||
|
if (todoItems.length > 0) {
|
||||||
|
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
||||||
|
pi.sendMessage(
|
||||||
|
{
|
||||||
|
customType: "plan-todo-list",
|
||||||
|
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
|
||||||
|
display: true,
|
||||||
|
},
|
||||||
|
{ triggerTurn: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select("Plan mode - what next?", [
|
||||||
|
todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
|
||||||
|
"Stay in plan mode",
|
||||||
|
"Refine the plan",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (choice?.startsWith("Execute")) {
|
||||||
|
planModeEnabled = false;
|
||||||
|
executionMode = todoItems.length > 0;
|
||||||
|
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||||
|
updateStatus(ctx);
|
||||||
|
|
||||||
|
const execMessage =
|
||||||
|
todoItems.length > 0
|
||||||
|
? `Execute the plan. Start with: ${todoItems[0].text}`
|
||||||
|
: "Execute the plan you just created.";
|
||||||
|
pi.sendMessage(
|
||||||
|
{ customType: "plan-mode-execute", content: execMessage, display: true },
|
||||||
|
{ triggerTurn: true },
|
||||||
|
);
|
||||||
|
} else if (choice === "Refine the plan") {
|
||||||
|
const refinement = await ctx.ui.editor("Refine the plan:", "");
|
||||||
|
if (refinement?.trim()) {
|
||||||
|
pi.sendUserMessage(refinement.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore state on session start/resume
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
if (pi.getFlag("plan") === true) {
|
||||||
|
planModeEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = ctx.sessionManager.getEntries();
|
||||||
|
|
||||||
|
// Restore persisted state
|
||||||
|
const planModeEntry = entries
|
||||||
|
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
|
||||||
|
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
|
||||||
|
|
||||||
|
if (planModeEntry?.data) {
|
||||||
|
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
|
||||||
|
todoItems = planModeEntry.data.todos ?? todoItems;
|
||||||
|
executionMode = planModeEntry.data.executing ?? executionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On resume: re-scan messages to rebuild completion state
|
||||||
|
// Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
|
||||||
|
const isResume = planModeEntry !== undefined;
|
||||||
|
if (isResume && executionMode && todoItems.length > 0) {
|
||||||
|
// Find the index of the last plan-mode-execute entry (marks when current execution started)
|
||||||
|
let executeIndex = -1;
|
||||||
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
|
const entry = entries[i] as { type: string; customType?: string };
|
||||||
|
if (entry.customType === "plan-mode-execute") {
|
||||||
|
executeIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only scan messages after the execute marker
|
||||||
|
const messages: AssistantMessage[] = [];
|
||||||
|
for (let i = executeIndex + 1; i < entries.length; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
||||||
|
messages.push(entry.message as AssistantMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allText = messages.map(getTextContent).join("\n");
|
||||||
|
markCompletedSteps(allText, todoItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planModeEnabled) {
|
||||||
|
pi.setActiveTools(PLAN_MODE_TOOLS);
|
||||||
|
}
|
||||||
|
updateStatus(ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
168
packages/coding-agent/examples/extensions/plan-mode/utils.ts
Normal file
168
packages/coding-agent/examples/extensions/plan-mode/utils.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* Pure utility functions for plan mode.
|
||||||
|
* Extracted for testability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Destructive commands blocked in plan mode
|
||||||
|
const DESTRUCTIVE_PATTERNS = [
|
||||||
|
/\brm\b/i,
|
||||||
|
/\brmdir\b/i,
|
||||||
|
/\bmv\b/i,
|
||||||
|
/\bcp\b/i,
|
||||||
|
/\bmkdir\b/i,
|
||||||
|
/\btouch\b/i,
|
||||||
|
/\bchmod\b/i,
|
||||||
|
/\bchown\b/i,
|
||||||
|
/\bchgrp\b/i,
|
||||||
|
/\bln\b/i,
|
||||||
|
/\btee\b/i,
|
||||||
|
/\btruncate\b/i,
|
||||||
|
/\bdd\b/i,
|
||||||
|
/\bshred\b/i,
|
||||||
|
/(^|[^<])>(?!>)/,
|
||||||
|
/>>/,
|
||||||
|
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
||||||
|
/\byarn\s+(add|remove|install|publish)/i,
|
||||||
|
/\bpnpm\s+(add|remove|install|publish)/i,
|
||||||
|
/\bpip\s+(install|uninstall)/i,
|
||||||
|
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
||||||
|
/\bbrew\s+(install|uninstall|upgrade)/i,
|
||||||
|
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
||||||
|
/\bsudo\b/i,
|
||||||
|
/\bsu\b/i,
|
||||||
|
/\bkill\b/i,
|
||||||
|
/\bpkill\b/i,
|
||||||
|
/\bkillall\b/i,
|
||||||
|
/\breboot\b/i,
|
||||||
|
/\bshutdown\b/i,
|
||||||
|
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
||||||
|
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
||||||
|
/\b(vim?|nano|emacs|code|subl)\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Safe read-only commands allowed in plan mode
|
||||||
|
const SAFE_PATTERNS = [
|
||||||
|
/^\s*cat\b/,
|
||||||
|
/^\s*head\b/,
|
||||||
|
/^\s*tail\b/,
|
||||||
|
/^\s*less\b/,
|
||||||
|
/^\s*more\b/,
|
||||||
|
/^\s*grep\b/,
|
||||||
|
/^\s*find\b/,
|
||||||
|
/^\s*ls\b/,
|
||||||
|
/^\s*pwd\b/,
|
||||||
|
/^\s*echo\b/,
|
||||||
|
/^\s*printf\b/,
|
||||||
|
/^\s*wc\b/,
|
||||||
|
/^\s*sort\b/,
|
||||||
|
/^\s*uniq\b/,
|
||||||
|
/^\s*diff\b/,
|
||||||
|
/^\s*file\b/,
|
||||||
|
/^\s*stat\b/,
|
||||||
|
/^\s*du\b/,
|
||||||
|
/^\s*df\b/,
|
||||||
|
/^\s*tree\b/,
|
||||||
|
/^\s*which\b/,
|
||||||
|
/^\s*whereis\b/,
|
||||||
|
/^\s*type\b/,
|
||||||
|
/^\s*env\b/,
|
||||||
|
/^\s*printenv\b/,
|
||||||
|
/^\s*uname\b/,
|
||||||
|
/^\s*whoami\b/,
|
||||||
|
/^\s*id\b/,
|
||||||
|
/^\s*date\b/,
|
||||||
|
/^\s*cal\b/,
|
||||||
|
/^\s*uptime\b/,
|
||||||
|
/^\s*ps\b/,
|
||||||
|
/^\s*top\b/,
|
||||||
|
/^\s*htop\b/,
|
||||||
|
/^\s*free\b/,
|
||||||
|
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
||||||
|
/^\s*git\s+ls-/i,
|
||||||
|
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
||||||
|
/^\s*yarn\s+(list|info|why|audit)/i,
|
||||||
|
/^\s*node\s+--version/i,
|
||||||
|
/^\s*python\s+--version/i,
|
||||||
|
/^\s*curl\s/i,
|
||||||
|
/^\s*wget\s+-O\s*-/i,
|
||||||
|
/^\s*jq\b/,
|
||||||
|
/^\s*sed\s+-n/i,
|
||||||
|
/^\s*awk\b/,
|
||||||
|
/^\s*rg\b/,
|
||||||
|
/^\s*fd\b/,
|
||||||
|
/^\s*bat\b/,
|
||||||
|
/^\s*exa\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSafeCommand(command: string): boolean {
|
||||||
|
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
||||||
|
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
|
||||||
|
return !isDestructive && isSafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoItem {
|
||||||
|
step: number;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanStepText(text: string): string {
|
||||||
|
let cleaned = text
|
||||||
|
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
|
||||||
|
.replace(/`([^`]+)`/g, "$1") // Remove code
|
||||||
|
.replace(
|
||||||
|
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (cleaned.length > 0) {
|
||||||
|
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||||
|
}
|
||||||
|
if (cleaned.length > 50) {
|
||||||
|
cleaned = `${cleaned.slice(0, 47)}...`;
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTodoItems(message: string): TodoItem[] {
|
||||||
|
const items: TodoItem[] = [];
|
||||||
|
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
||||||
|
if (!headerMatch) return items;
|
||||||
|
|
||||||
|
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
||||||
|
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
||||||
|
|
||||||
|
for (const match of planSection.matchAll(numberedPattern)) {
|
||||||
|
const text = match[2]
|
||||||
|
.trim()
|
||||||
|
.replace(/\*{1,2}$/, "")
|
||||||
|
.trim();
|
||||||
|
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
||||||
|
const cleaned = cleanStepText(text);
|
||||||
|
if (cleaned.length > 3) {
|
||||||
|
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDoneSteps(message: string): number[] {
|
||||||
|
const steps: number[] = [];
|
||||||
|
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
||||||
|
const step = Number(match[1]);
|
||||||
|
if (Number.isFinite(step)) steps.push(step);
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
||||||
|
const doneSteps = extractDoneSteps(text);
|
||||||
|
for (const step of doneSteps) {
|
||||||
|
const item = items.find((t) => t.step === step);
|
||||||
|
if (item) item.completed = true;
|
||||||
|
}
|
||||||
|
return doneSteps.length;
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,50 @@
|
||||||
/**
|
/**
|
||||||
* Question Tool - Let the LLM ask the user a question with options
|
* Question Tool - Single question with options
|
||||||
|
* Full custom UI: options list + inline editor for "Type something..."
|
||||||
|
* Escape in editor returns to options, Escape in options cancels
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
interface OptionWithDesc {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayOption = OptionWithDesc & { isOther?: boolean };
|
||||||
|
|
||||||
interface QuestionDetails {
|
interface QuestionDetails {
|
||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
answer: string | null;
|
answer: string | null;
|
||||||
|
wasCustom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Support both simple strings and objects with descriptions
|
||||||
|
const OptionSchema = Type.Union([
|
||||||
|
Type.String(),
|
||||||
|
Type.Object({
|
||||||
|
label: Type.String({ description: "Display label for the option" }),
|
||||||
|
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const QuestionParams = Type.Object({
|
const QuestionParams = Type.Object({
|
||||||
question: Type.String({ description: "The question to ask the user" }),
|
question: Type.String({ description: "The question to ask the user" }),
|
||||||
options: Type.Array(Type.String(), { description: "Options for the user to choose from" }),
|
options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
// Normalize option to { label, description? }
|
||||||
|
function normalizeOption(opt: string | { label: string; description?: string }): OptionWithDesc {
|
||||||
|
if (typeof opt === "string") {
|
||||||
|
return { label: opt };
|
||||||
|
}
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function question(pi: ExtensionAPI) {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "question",
|
name: "question",
|
||||||
label: "Question",
|
label: "Question",
|
||||||
|
|
@ -28,7 +55,11 @@ export default function (pi: ExtensionAPI) {
|
||||||
if (!ctx.hasUI) {
|
if (!ctx.hasUI) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
||||||
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
|
details: {
|
||||||
|
question: params.question,
|
||||||
|
options: params.options.map((o) => (typeof o === "string" ? o : o.label)),
|
||||||
|
answer: null,
|
||||||
|
} as QuestionDetails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,25 +70,183 @@ export default function (pi: ExtensionAPI) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const answer = await ctx.ui.select(params.question, params.options);
|
// Normalize options
|
||||||
|
const normalizedOptions = params.options.map(normalizeOption);
|
||||||
|
const allOptions: DisplayOption[] = [...normalizedOptions, { label: "Type something.", isOther: true }];
|
||||||
|
|
||||||
if (answer === undefined) {
|
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
|
||||||
|
(tui, theme, _kb, done) => {
|
||||||
|
let optionIndex = 0;
|
||||||
|
let editMode = false;
|
||||||
|
let cachedLines: string[] | undefined;
|
||||||
|
|
||||||
|
const editorTheme: EditorTheme = {
|
||||||
|
borderColor: (s) => theme.fg("accent", s),
|
||||||
|
selectList: {
|
||||||
|
selectedPrefix: (t) => theme.fg("accent", t),
|
||||||
|
selectedText: (t) => theme.fg("accent", t),
|
||||||
|
description: (t) => theme.fg("muted", t),
|
||||||
|
scrollInfo: (t) => theme.fg("dim", t),
|
||||||
|
noMatch: (t) => theme.fg("warning", t),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const editor = new Editor(editorTheme);
|
||||||
|
|
||||||
|
editor.onSubmit = (value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
done({ answer: trimmed, wasCustom: true });
|
||||||
|
} else {
|
||||||
|
editMode = false;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
cachedLines = undefined;
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(data: string) {
|
||||||
|
if (editMode) {
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
editMode = false;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.handleInput(data);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
optionIndex = Math.max(0, optionIndex - 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
const selected = allOptions[optionIndex];
|
||||||
|
if (selected.isOther) {
|
||||||
|
editMode = true;
|
||||||
|
refresh();
|
||||||
|
} else {
|
||||||
|
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
done(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(width: number): string[] {
|
||||||
|
if (cachedLines) return cachedLines;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||||
|
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
add(theme.fg("text", ` ${params.question}`));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
for (let i = 0; i < allOptions.length; i++) {
|
||||||
|
const opt = allOptions[i];
|
||||||
|
const selected = i === optionIndex;
|
||||||
|
const isOther = opt.isOther === true;
|
||||||
|
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||||
|
|
||||||
|
if (isOther && editMode) {
|
||||||
|
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
||||||
|
} else if (selected) {
|
||||||
|
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
|
||||||
|
} else {
|
||||||
|
add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show description if present
|
||||||
|
if (opt.description) {
|
||||||
|
add(` ${theme.fg("muted", opt.description)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
lines.push("");
|
||||||
|
add(theme.fg("muted", " Your answer:"));
|
||||||
|
for (const line of editor.render(width - 2)) {
|
||||||
|
add(` ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
if (editMode) {
|
||||||
|
add(theme.fg("dim", " Enter to submit • Esc to go back"));
|
||||||
|
} else {
|
||||||
|
add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"));
|
||||||
|
}
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render,
|
||||||
|
invalidate: () => {
|
||||||
|
cachedLines = undefined;
|
||||||
|
},
|
||||||
|
handleInput,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build simple options list for details
|
||||||
|
const simpleOptions = normalizedOptions.map((o) => o.label);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "User cancelled the selection" }],
|
content: [{ type: "text", text: "User cancelled the selection" }],
|
||||||
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
|
details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.wasCustom) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `User wrote: ${result.answer}` }],
|
||||||
|
details: {
|
||||||
|
question: params.question,
|
||||||
|
options: simpleOptions,
|
||||||
|
answer: result.answer,
|
||||||
|
wasCustom: true,
|
||||||
|
} as QuestionDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `User selected: ${answer}` }],
|
content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }],
|
||||||
details: { question: params.question, options: params.options, answer } as QuestionDetails,
|
details: {
|
||||||
|
question: params.question,
|
||||||
|
options: simpleOptions,
|
||||||
|
answer: result.answer,
|
||||||
|
wasCustom: false,
|
||||||
|
} as QuestionDetails,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
renderCall(args, theme) {
|
renderCall(args, theme) {
|
||||||
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
|
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
|
||||||
if (args.options?.length) {
|
const opts = Array.isArray(args.options) ? args.options : [];
|
||||||
text += `\n${theme.fg("dim", ` Options: ${args.options.join(", ")}`)}`;
|
if (opts.length) {
|
||||||
|
const labels = opts.map((o: string | { label: string }) => (typeof o === "string" ? o : o.label));
|
||||||
|
const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`);
|
||||||
|
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
|
||||||
}
|
}
|
||||||
return new Text(text, 0, 0);
|
return new Text(text, 0, 0);
|
||||||
},
|
},
|
||||||
|
|
@ -73,7 +262,16 @@ export default function (pi: ExtensionAPI) {
|
||||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0);
|
if (details.wasCustom) {
|
||||||
|
return new Text(
|
||||||
|
theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idx = details.options.indexOf(details.answer) + 1;
|
||||||
|
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer;
|
||||||
|
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
427
packages/coding-agent/examples/extensions/questionnaire.ts
Normal file
427
packages/coding-agent/examples/extensions/questionnaire.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
/**
|
||||||
|
* Questionnaire Tool - Unified tool for asking single or multiple questions
|
||||||
|
*
|
||||||
|
* Single question: simple options list
|
||||||
|
* Multiple questions: tab bar navigation between questions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface QuestionOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderOption = QuestionOption & { isOther?: boolean };
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
allowOther: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Answer {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
wasCustom: boolean;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestionnaireResult {
|
||||||
|
questions: Question[];
|
||||||
|
answers: Answer[];
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema
|
||||||
|
const QuestionOptionSchema = Type.Object({
|
||||||
|
value: Type.String({ description: "The value returned when selected" }),
|
||||||
|
label: Type.String({ description: "Display label for the option" }),
|
||||||
|
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionSchema = Type.Object({
|
||||||
|
id: Type.String({ description: "Unique identifier for this question" }),
|
||||||
|
label: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
prompt: Type.String({ description: "The full question text to display" }),
|
||||||
|
options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
|
||||||
|
allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionnaireParams = Type.Object({
|
||||||
|
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorResult(
|
||||||
|
message: string,
|
||||||
|
questions: Question[] = [],
|
||||||
|
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: message }],
|
||||||
|
details: { questions, answers: [], cancelled: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function questionnaire(pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "questionnaire",
|
||||||
|
label: "Questionnaire",
|
||||||
|
description:
|
||||||
|
"Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
|
||||||
|
parameters: QuestionnaireParams,
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, _onUpdate, ctx, _signal) {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
return errorResult("Error: UI not available (running in non-interactive mode)");
|
||||||
|
}
|
||||||
|
if (params.questions.length === 0) {
|
||||||
|
return errorResult("Error: No questions provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize questions with defaults
|
||||||
|
const questions: Question[] = params.questions.map((q, i) => ({
|
||||||
|
...q,
|
||||||
|
label: q.label || `Q${i + 1}`,
|
||||||
|
allowOther: q.allowOther !== false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isMulti = questions.length > 1;
|
||||||
|
const totalTabs = questions.length + 1; // questions + Submit
|
||||||
|
|
||||||
|
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
|
||||||
|
// State
|
||||||
|
let currentTab = 0;
|
||||||
|
let optionIndex = 0;
|
||||||
|
let inputMode = false;
|
||||||
|
let inputQuestionId: string | null = null;
|
||||||
|
let cachedLines: string[] | undefined;
|
||||||
|
const answers = new Map<string, Answer>();
|
||||||
|
|
||||||
|
// Editor for "Type something" option
|
||||||
|
const editorTheme: EditorTheme = {
|
||||||
|
borderColor: (s) => theme.fg("accent", s),
|
||||||
|
selectList: {
|
||||||
|
selectedPrefix: (t) => theme.fg("accent", t),
|
||||||
|
selectedText: (t) => theme.fg("accent", t),
|
||||||
|
description: (t) => theme.fg("muted", t),
|
||||||
|
scrollInfo: (t) => theme.fg("dim", t),
|
||||||
|
noMatch: (t) => theme.fg("warning", t),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const editor = new Editor(editorTheme);
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function refresh() {
|
||||||
|
cachedLines = undefined;
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(cancelled: boolean) {
|
||||||
|
done({ questions, answers: Array.from(answers.values()), cancelled });
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentQuestion(): Question | undefined {
|
||||||
|
return questions[currentTab];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentOptions(): RenderOption[] {
|
||||||
|
const q = currentQuestion();
|
||||||
|
if (!q) return [];
|
||||||
|
const opts: RenderOption[] = [...q.options];
|
||||||
|
if (q.allowOther) {
|
||||||
|
opts.push({ value: "__other__", label: "Type something.", isOther: true });
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allAnswered(): boolean {
|
||||||
|
return questions.every((q) => answers.has(q.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceAfterAnswer() {
|
||||||
|
if (!isMulti) {
|
||||||
|
submit(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTab < questions.length - 1) {
|
||||||
|
currentTab++;
|
||||||
|
} else {
|
||||||
|
currentTab = questions.length; // Submit tab
|
||||||
|
}
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
|
||||||
|
answers.set(questionId, { id: questionId, value, label, wasCustom, index });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor submit callback
|
||||||
|
editor.onSubmit = (value) => {
|
||||||
|
if (!inputQuestionId) return;
|
||||||
|
const trimmed = value.trim() || "(no response)";
|
||||||
|
saveAnswer(inputQuestionId, trimmed, trimmed, true);
|
||||||
|
inputMode = false;
|
||||||
|
inputQuestionId = null;
|
||||||
|
editor.setText("");
|
||||||
|
advanceAfterAnswer();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInput(data: string) {
|
||||||
|
// Input mode: route to editor
|
||||||
|
if (inputMode) {
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
inputMode = false;
|
||||||
|
inputQuestionId = null;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.handleInput(data);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = currentQuestion();
|
||||||
|
const opts = currentOptions();
|
||||||
|
|
||||||
|
// Tab navigation (multi-question only)
|
||||||
|
if (isMulti) {
|
||||||
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
|
currentTab = (currentTab + 1) % totalTabs;
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
|
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit tab
|
||||||
|
if (currentTab === questions.length) {
|
||||||
|
if (matchesKey(data, Key.enter) && allAnswered()) {
|
||||||
|
submit(false);
|
||||||
|
} else if (matchesKey(data, Key.escape)) {
|
||||||
|
submit(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option navigation
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
optionIndex = Math.max(0, optionIndex - 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select option
|
||||||
|
if (matchesKey(data, Key.enter) && q) {
|
||||||
|
const opt = opts[optionIndex];
|
||||||
|
if (opt.isOther) {
|
||||||
|
inputMode = true;
|
||||||
|
inputQuestionId = q.id;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
|
||||||
|
advanceAfterAnswer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
submit(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(width: number): string[] {
|
||||||
|
if (cachedLines) return cachedLines;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const q = currentQuestion();
|
||||||
|
const opts = currentOptions();
|
||||||
|
|
||||||
|
// Helper to add truncated line
|
||||||
|
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||||
|
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
// Tab bar (multi-question only)
|
||||||
|
if (isMulti) {
|
||||||
|
const tabs: string[] = ["← "];
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const isActive = i === currentTab;
|
||||||
|
const isAnswered = answers.has(questions[i].id);
|
||||||
|
const lbl = questions[i].label;
|
||||||
|
const box = isAnswered ? "■" : "□";
|
||||||
|
const color = isAnswered ? "success" : "muted";
|
||||||
|
const text = ` ${box} ${lbl} `;
|
||||||
|
const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
|
||||||
|
tabs.push(`${styled} `);
|
||||||
|
}
|
||||||
|
const canSubmit = allAnswered();
|
||||||
|
const isSubmitTab = currentTab === questions.length;
|
||||||
|
const submitText = " ✓ Submit ";
|
||||||
|
const submitStyled = isSubmitTab
|
||||||
|
? theme.bg("selectedBg", theme.fg("text", submitText))
|
||||||
|
: theme.fg(canSubmit ? "success" : "dim", submitText);
|
||||||
|
tabs.push(`${submitStyled} →`);
|
||||||
|
add(` ${tabs.join("")}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to render options list
|
||||||
|
function renderOptions() {
|
||||||
|
for (let i = 0; i < opts.length; i++) {
|
||||||
|
const opt = opts[i];
|
||||||
|
const selected = i === optionIndex;
|
||||||
|
const isOther = opt.isOther === true;
|
||||||
|
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||||
|
const color = selected ? "accent" : "text";
|
||||||
|
// Mark "Type something" differently when in input mode
|
||||||
|
if (isOther && inputMode) {
|
||||||
|
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
||||||
|
} else {
|
||||||
|
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
|
||||||
|
}
|
||||||
|
if (opt.description) {
|
||||||
|
add(` ${theme.fg("muted", opt.description)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (inputMode && q) {
|
||||||
|
add(theme.fg("text", ` ${q.prompt}`));
|
||||||
|
lines.push("");
|
||||||
|
// Show options for reference
|
||||||
|
renderOptions();
|
||||||
|
lines.push("");
|
||||||
|
add(theme.fg("muted", " Your answer:"));
|
||||||
|
for (const line of editor.render(width - 2)) {
|
||||||
|
add(` ${line}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
|
||||||
|
} else if (currentTab === questions.length) {
|
||||||
|
add(theme.fg("accent", theme.bold(" Ready to submit")));
|
||||||
|
lines.push("");
|
||||||
|
for (const question of questions) {
|
||||||
|
const answer = answers.get(question.id);
|
||||||
|
if (answer) {
|
||||||
|
const prefix = answer.wasCustom ? "(wrote) " : "";
|
||||||
|
add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
if (allAnswered()) {
|
||||||
|
add(theme.fg("success", " Press Enter to submit"));
|
||||||
|
} else {
|
||||||
|
const missing = questions
|
||||||
|
.filter((q) => !answers.has(q.id))
|
||||||
|
.map((q) => q.label)
|
||||||
|
.join(", ");
|
||||||
|
add(theme.fg("warning", ` Unanswered: ${missing}`));
|
||||||
|
}
|
||||||
|
} else if (q) {
|
||||||
|
add(theme.fg("text", ` ${q.prompt}`));
|
||||||
|
lines.push("");
|
||||||
|
renderOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
if (!inputMode) {
|
||||||
|
const help = isMulti
|
||||||
|
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
|
||||||
|
: " ↑↓ navigate • Enter select • Esc cancel";
|
||||||
|
add(theme.fg("dim", help));
|
||||||
|
}
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render,
|
||||||
|
invalidate: () => {
|
||||||
|
cachedLines = undefined;
|
||||||
|
},
|
||||||
|
handleInput,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerLines = result.answers.map((a) => {
|
||||||
|
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
|
||||||
|
if (a.wasCustom) {
|
||||||
|
return `${qLabel}: user wrote: ${a.label}`;
|
||||||
|
}
|
||||||
|
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: answerLines.join("\n") }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args, theme) {
|
||||||
|
const qs = (args.questions as Question[]) || [];
|
||||||
|
const count = qs.length;
|
||||||
|
const labels = qs.map((q) => q.label || q.id).join(", ");
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
|
||||||
|
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
|
||||||
|
if (labels) {
|
||||||
|
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
|
||||||
|
}
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResult(result, _options, theme) {
|
||||||
|
const details = result.details as QuestionnaireResult | undefined;
|
||||||
|
if (!details) {
|
||||||
|
const text = result.content[0];
|
||||||
|
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||||
|
}
|
||||||
|
if (details.cancelled) {
|
||||||
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||||
|
}
|
||||||
|
const lines = details.answers.map((a) => {
|
||||||
|
if (a.wasCustom) {
|
||||||
|
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
|
||||||
|
}
|
||||||
|
const display = a.index ? `${a.index}. ${a.label}` : a.label;
|
||||||
|
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
|
||||||
|
});
|
||||||
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
node_modules
|
||||||
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal file
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
/**
|
||||||
|
* Sandbox Extension - OS-level sandboxing for bash commands
|
||||||
|
*
|
||||||
|
* Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
|
||||||
|
* restrictions on bash commands at the OS level (sandbox-exec on macOS,
|
||||||
|
* bubblewrap on Linux).
|
||||||
|
*
|
||||||
|
* Config files (merged, project takes precedence):
|
||||||
|
* - ~/.pi/agent/sandbox.json (global)
|
||||||
|
* - <cwd>/.pi/sandbox.json (project-local)
|
||||||
|
*
|
||||||
|
* Example .pi/sandbox.json:
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "enabled": true,
|
||||||
|
* "network": {
|
||||||
|
* "allowedDomains": ["github.com", "*.github.com"],
|
||||||
|
* "deniedDomains": []
|
||||||
|
* },
|
||||||
|
* "filesystem": {
|
||||||
|
* "denyRead": ["~/.ssh", "~/.aws"],
|
||||||
|
* "allowWrite": [".", "/tmp"],
|
||||||
|
* "denyWrite": [".env"]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - `pi -e ./sandbox` - sandbox enabled with default/config settings
|
||||||
|
* - `pi -e ./sandbox --no-sandbox` - disable sandboxing
|
||||||
|
* - `/sandbox` - show current sandbox configuration
|
||||||
|
*
|
||||||
|
* Setup:
|
||||||
|
* 1. Copy sandbox/ directory to ~/.pi/agent/extensions/
|
||||||
|
* 2. Run `npm install` in ~/.pi/agent/extensions/sandbox/
|
||||||
|
*
|
||||||
|
* Linux also requires: bubblewrap, socat, ripgrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { type BashOperations, createBashTool } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
interface SandboxConfig extends SandboxRuntimeConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SandboxConfig = {
|
||||||
|
enabled: true,
|
||||||
|
network: {
|
||||||
|
allowedDomains: [
|
||||||
|
"npmjs.org",
|
||||||
|
"*.npmjs.org",
|
||||||
|
"registry.npmjs.org",
|
||||||
|
"registry.yarnpkg.com",
|
||||||
|
"pypi.org",
|
||||||
|
"*.pypi.org",
|
||||||
|
"github.com",
|
||||||
|
"*.github.com",
|
||||||
|
"api.github.com",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
],
|
||||||
|
deniedDomains: [],
|
||||||
|
},
|
||||||
|
filesystem: {
|
||||||
|
denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
|
||||||
|
allowWrite: [".", "/tmp"],
|
||||||
|
denyWrite: [".env", ".env.*", "*.pem", "*.key"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadConfig(cwd: string): SandboxConfig {
|
||||||
|
const projectConfigPath = join(cwd, ".pi", "sandbox.json");
|
||||||
|
const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
|
||||||
|
|
||||||
|
let globalConfig: Partial<SandboxConfig> = {};
|
||||||
|
let projectConfig: Partial<SandboxConfig> = {};
|
||||||
|
|
||||||
|
if (existsSync(globalConfigPath)) {
|
||||||
|
try {
|
||||||
|
globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(projectConfigPath)) {
|
||||||
|
try {
|
||||||
|
projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
|
||||||
|
const result: SandboxConfig = { ...base };
|
||||||
|
|
||||||
|
if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
|
||||||
|
if (overrides.network) {
|
||||||
|
result.network = { ...base.network, ...overrides.network };
|
||||||
|
}
|
||||||
|
if (overrides.filesystem) {
|
||||||
|
result.filesystem = { ...base.filesystem, ...overrides.filesystem };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extOverrides = overrides as {
|
||||||
|
ignoreViolations?: Record<string, string[]>;
|
||||||
|
enableWeakerNestedSandbox?: boolean;
|
||||||
|
};
|
||||||
|
const extResult = result as { ignoreViolations?: Record<string, string[]>; enableWeakerNestedSandbox?: boolean };
|
||||||
|
|
||||||
|
if (extOverrides.ignoreViolations) {
|
||||||
|
extResult.ignoreViolations = extOverrides.ignoreViolations;
|
||||||
|
}
|
||||||
|
if (extOverrides.enableWeakerNestedSandbox !== undefined) {
|
||||||
|
extResult.enableWeakerNestedSandbox = extOverrides.enableWeakerNestedSandbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSandboxedBashOps(): BashOperations {
|
||||||
|
return {
|
||||||
|
async exec(command, cwd, { onData, signal, timeout }) {
|
||||||
|
if (!existsSync(cwd)) {
|
||||||
|
throw new Error(`Working directory does not exist: ${cwd}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn("bash", ["-c", wrappedCommand], {
|
||||||
|
cwd,
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let timedOut = false;
|
||||||
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
if (timeout !== undefined && timeout > 0) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
if (child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, timeout * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout?.on("data", onData);
|
||||||
|
child.stderr?.on("data", onData);
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
if (child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
} else if (timedOut) {
|
||||||
|
reject(new Error(`timeout:${timeout}`));
|
||||||
|
} else {
|
||||||
|
resolve({ exitCode: code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerFlag("no-sandbox", {
|
||||||
|
description: "Disable OS-level sandboxing for bash commands",
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const localCwd = process.cwd();
|
||||||
|
const localBash = createBashTool(localCwd);
|
||||||
|
|
||||||
|
let sandboxEnabled = false;
|
||||||
|
let sandboxInitialized = false;
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localBash,
|
||||||
|
label: "bash (sandboxed)",
|
||||||
|
async execute(id, params, onUpdate, _ctx, signal) {
|
||||||
|
if (!sandboxEnabled || !sandboxInitialized) {
|
||||||
|
return localBash.execute(id, params, signal, onUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandboxedBash = createBashTool(localCwd, {
|
||||||
|
operations: createSandboxedBashOps(),
|
||||||
|
});
|
||||||
|
return sandboxedBash.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("user_bash", () => {
|
||||||
|
if (!sandboxEnabled || !sandboxInitialized) return;
|
||||||
|
return { operations: createSandboxedBashOps() };
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const noSandbox = pi.getFlag("no-sandbox") as boolean;
|
||||||
|
|
||||||
|
if (noSandbox) {
|
||||||
|
sandboxEnabled = false;
|
||||||
|
ctx.ui.notify("Sandbox disabled via --no-sandbox", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(ctx.cwd);
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
sandboxEnabled = false;
|
||||||
|
ctx.ui.notify("Sandbox disabled via config", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = process.platform;
|
||||||
|
if (platform !== "darwin" && platform !== "linux") {
|
||||||
|
sandboxEnabled = false;
|
||||||
|
ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configExt = config as unknown as {
|
||||||
|
ignoreViolations?: Record<string, string[]>;
|
||||||
|
enableWeakerNestedSandbox?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
await SandboxManager.initialize({
|
||||||
|
network: config.network,
|
||||||
|
filesystem: config.filesystem,
|
||||||
|
ignoreViolations: configExt.ignoreViolations,
|
||||||
|
enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox,
|
||||||
|
});
|
||||||
|
|
||||||
|
sandboxEnabled = true;
|
||||||
|
sandboxInitialized = true;
|
||||||
|
|
||||||
|
const networkCount = config.network?.allowedDomains?.length ?? 0;
|
||||||
|
const writeCount = config.filesystem?.allowWrite?.length ?? 0;
|
||||||
|
ctx.ui.setStatus(
|
||||||
|
"sandbox",
|
||||||
|
ctx.ui.theme.fg("accent", `🔒 Sandbox: ${networkCount} domains, ${writeCount} write paths`),
|
||||||
|
);
|
||||||
|
ctx.ui.notify("Sandbox initialized", "info");
|
||||||
|
} catch (err) {
|
||||||
|
sandboxEnabled = false;
|
||||||
|
ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
if (sandboxInitialized) {
|
||||||
|
try {
|
||||||
|
await SandboxManager.reset();
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("sandbox", {
|
||||||
|
description: "Show sandbox configuration",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (!sandboxEnabled) {
|
||||||
|
ctx.ui.notify("Sandbox is disabled", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(ctx.cwd);
|
||||||
|
const lines = [
|
||||||
|
"Sandbox Configuration:",
|
||||||
|
"",
|
||||||
|
"Network:",
|
||||||
|
` Allowed: ${config.network?.allowedDomains?.join(", ") || "(none)"}`,
|
||||||
|
` Denied: ${config.network?.deniedDomains?.join(", ") || "(none)"}`,
|
||||||
|
"",
|
||||||
|
"Filesystem:",
|
||||||
|
` Deny Read: ${config.filesystem?.denyRead?.join(", ") || "(none)"}`,
|
||||||
|
` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
|
||||||
|
` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
|
||||||
|
];
|
||||||
|
ctx.ui.notify(lines.join("\n"), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal file
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
{
|
||||||
|
"name": "pi-extension-sandbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "pi-extension-sandbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/sandbox-runtime": {
|
||||||
|
"version": "0.0.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz",
|
||||||
|
"integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@pondwader/socks5-server": "^1.0.10",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"commander": "^12.1.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"shell-quote": "^1.8.3",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"srt": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pondwader/socks5-server": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/lodash-es": {
|
||||||
|
"version": "4.17.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/lodash": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "12.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
|
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.17.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
||||||
|
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "pi-extension-sandbox",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "echo 'nothing to clean'",
|
||||||
|
"build": "echo 'nothing to build'",
|
||||||
|
"check": "echo 'nothing to check'"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||||
|
}
|
||||||
|
}
|
||||||
195
packages/coding-agent/examples/extensions/summarize.ts
Normal file
195
packages/coding-agent/examples/extensions/summarize.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||||
|
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { DynamicBorder, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Container, Markdown, matchesKey, Text } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
type ContentBlock = {
|
||||||
|
type?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
arguments?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionEntry = {
|
||||||
|
type: string;
|
||||||
|
message?: {
|
||||||
|
role?: string;
|
||||||
|
content?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractTextParts = (content: unknown): string[] => {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return [content];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const textParts: string[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (!part || typeof part !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = part as ContentBlock;
|
||||||
|
if (block.type === "text" && typeof block.text === "string") {
|
||||||
|
textParts.push(block.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return textParts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractToolCallLines = (content: unknown): string[] => {
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls: string[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (!part || typeof part !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = part as ContentBlock;
|
||||||
|
if (block.type !== "toolCall" || typeof block.name !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = block.arguments ?? {};
|
||||||
|
toolCalls.push(`Tool ${block.name} was called with args ${JSON.stringify(args)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildConversationText = (entries: SessionEntry[]): string => {
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.type !== "message" || !entry.message?.role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = entry.message.role;
|
||||||
|
const isUser = role === "user";
|
||||||
|
const isAssistant = role === "assistant";
|
||||||
|
|
||||||
|
if (!isUser && !isAssistant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryLines: string[] = [];
|
||||||
|
const textParts = extractTextParts(entry.message.content);
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
const roleLabel = isUser ? "User" : "Assistant";
|
||||||
|
const messageText = textParts.join("\n").trim();
|
||||||
|
if (messageText.length > 0) {
|
||||||
|
entryLines.push(`${roleLabel}: ${messageText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAssistant) {
|
||||||
|
entryLines.push(...extractToolCallLines(entry.message.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryLines.length > 0) {
|
||||||
|
sections.push(entryLines.join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.join("\n\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSummaryPrompt = (conversationText: string): string =>
|
||||||
|
[
|
||||||
|
"Summarize this conversation so I can resume it later.",
|
||||||
|
"Include goals, key decisions, progress, open questions, and next steps.",
|
||||||
|
"Keep it concise and structured with headings.",
|
||||||
|
"",
|
||||||
|
"<conversation>",
|
||||||
|
conversationText,
|
||||||
|
"</conversation>",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
||||||
|
const container = new Container();
|
||||||
|
const border = new DynamicBorder((s: string) => theme.fg("accent", s));
|
||||||
|
const mdTheme = getMarkdownTheme();
|
||||||
|
|
||||||
|
container.addChild(border);
|
||||||
|
container.addChild(new Text(theme.fg("accent", theme.bold("Conversation Summary")), 1, 0));
|
||||||
|
container.addChild(new Markdown(summary, 1, 1, mdTheme));
|
||||||
|
container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
|
||||||
|
container.addChild(border);
|
||||||
|
|
||||||
|
return {
|
||||||
|
render: (width: number) => container.render(width),
|
||||||
|
invalidate: () => container.invalidate(),
|
||||||
|
handleInput: (data: string) => {
|
||||||
|
if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
|
||||||
|
done(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerCommand("summarize", {
|
||||||
|
description: "Summarize the current conversation in a custom UI",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const branch = ctx.sessionManager.getBranch();
|
||||||
|
const conversationText = buildConversationText(branch);
|
||||||
|
|
||||||
|
if (!conversationText.trim()) {
|
||||||
|
if (ctx.hasUI) {
|
||||||
|
ctx.ui.notify("No conversation text found", "warning");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.hasUI) {
|
||||||
|
ctx.ui.notify("Preparing summary...", "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = getModel("openai", "gpt-5.2");
|
||||||
|
if (!model && ctx.hasUI) {
|
||||||
|
ctx.ui.notify("Model openai/gpt-5.2 not found", "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = model ? await ctx.modelRegistry.getApiKey(model) : undefined;
|
||||||
|
if (!apiKey && ctx.hasUI) {
|
||||||
|
ctx.ui.notify("No API key for openai/gpt-5.2", "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model || !apiKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryMessages = [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: [{ type: "text" as const, text: buildSummaryPrompt(conversationText) }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await complete(model, { messages: summaryMessages }, { apiKey, reasoningEffort: "high" });
|
||||||
|
|
||||||
|
const summary = response.content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
await showSummaryUi(summary, ctx);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "1.7.0",
|
"version": "1.9.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "1.7.0",
|
"version": "1.9.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.0",
|
"version": "1.9.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,8 @@ import {
|
||||||
discoverModels,
|
discoverModels,
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
discoverExtensions,
|
discoverExtensions,
|
||||||
discoverCustomTools,
|
|
||||||
discoverContextFiles,
|
discoverContextFiles,
|
||||||
discoverSlashCommands,
|
discoverPromptTemplates,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
|
|
@ -92,7 +91,7 @@ const { session } = await createAgentSession({
|
||||||
extensions: [{ factory: myExtension }],
|
extensions: [{ factory: myExtension }],
|
||||||
skills: [],
|
skills: [],
|
||||||
contextFiles: [],
|
contextFiles: [],
|
||||||
slashCommands: [],
|
promptTemplates: [],
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -123,7 +122,7 @@ await session.prompt("Hello");
|
||||||
| `additionalExtensionPaths` | `[]` | Merge with discovery |
|
| `additionalExtensionPaths` | `[]` | Merge with discovery |
|
||||||
| `skills` | Discovered | Skills for prompt |
|
| `skills` | Discovered | Skills for prompt |
|
||||||
| `contextFiles` | Discovered | AGENTS.md files |
|
| `contextFiles` | Discovered | AGENTS.md files |
|
||||||
| `slashCommands` | Discovered | File commands |
|
| `promptTemplates` | Discovered | Prompt templates (slash commands) |
|
||||||
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
|
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
|
||||||
| `settingsManager` | From agentDir | Settings overrides |
|
| `settingsManager` | From agentDir | Settings overrides |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-coding-agent",
|
"name": "@mariozechner/pi-coding-agent",
|
||||||
"version": "0.43.0",
|
"version": "0.45.5",
|
||||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"piConfig": {
|
"piConfig": {
|
||||||
|
|
@ -39,19 +39,19 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/clipboard": "^0.3.0",
|
"@mariozechner/clipboard": "^0.3.0",
|
||||||
"@mariozechner/pi-agent-core": "^0.43.0",
|
"@mariozechner/jiti": "^2.6.2",
|
||||||
"@mariozechner/pi-ai": "^0.43.0",
|
"@mariozechner/pi-agent-core": "^0.45.5",
|
||||||
"@mariozechner/pi-tui": "^0.43.0",
|
"@mariozechner/pi-ai": "^0.45.5",
|
||||||
|
"@mariozechner/pi-tui": "^0.45.5",
|
||||||
"chalk": "^5.5.0",
|
"chalk": "^5.5.0",
|
||||||
"cli-highlight": "^2.1.11",
|
"cli-highlight": "^2.1.11",
|
||||||
"diff": "^8.0.2",
|
"diff": "^8.0.2",
|
||||||
"file-type": "^21.1.1",
|
"file-type": "^21.1.1",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"jiti": "^2.6.1",
|
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.1.1",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"sharp": "^0.34.2"
|
"wasm-vips": "^0.0.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/diff": "^7.0.2",
|
"@types/diff": "^7.0.2",
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,15 @@ ${chalk.bold("Environment Variables:")}
|
||||||
CEREBRAS_API_KEY - Cerebras API key
|
CEREBRAS_API_KEY - Cerebras API key
|
||||||
XAI_API_KEY - xAI Grok API key
|
XAI_API_KEY - xAI Grok API key
|
||||||
OPENROUTER_API_KEY - OpenRouter API key
|
OPENROUTER_API_KEY - OpenRouter API key
|
||||||
|
AI_GATEWAY_API_KEY - Vercel AI Gateway API key
|
||||||
ZAI_API_KEY - ZAI API key
|
ZAI_API_KEY - ZAI API key
|
||||||
|
MISTRAL_API_KEY - Mistral API key
|
||||||
|
MINIMAX_API_KEY - MiniMax API key
|
||||||
|
AWS_PROFILE - AWS profile for Amazon Bedrock
|
||||||
|
AWS_ACCESS_KEY_ID - AWS access key for Amazon Bedrock
|
||||||
|
AWS_SECRET_ACCESS_KEY - AWS secret key for Amazon Bedrock
|
||||||
|
AWS_BEARER_TOKEN_BEDROCK - Bedrock API key (bearer token)
|
||||||
|
AWS_REGION - AWS region for Amazon Bedrock (e.g., us-east-1)
|
||||||
${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
|
${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
|
||||||
|
|
||||||
${chalk.bold("Available Tools (default: read, bash, edit, write):")}
|
${chalk.bold("Available Tools (default: read, bash, edit, write):")}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ function formatTokenCount(count: number): string {
|
||||||
* List available models, optionally filtered by search pattern
|
* List available models, optionally filtered by search pattern
|
||||||
*/
|
*/
|
||||||
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> {
|
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> {
|
||||||
const models = await modelRegistry.getAvailable();
|
const models = modelRegistry.getAvailable();
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
console.log("No models available. Set API keys in environment variables.");
|
console.log("No models available. Set API keys in environment variables.");
|
||||||
|
|
|
||||||
|
|
@ -1520,8 +1520,8 @@ export class AgentSession {
|
||||||
if (isContextOverflow(message, contextWindow)) return false;
|
if (isContextOverflow(message, contextWindow)) return false;
|
||||||
|
|
||||||
const err = message.errorMessage;
|
const err = message.errorMessage;
|
||||||
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error
|
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error, other side closed
|
||||||
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
|
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|other side closed/i.test(
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Extension loader - loads TypeScript extension modules using jiti.
|
* Extension loader - loads TypeScript extension modules using jiti.
|
||||||
|
*
|
||||||
|
* Uses @mariozechner/jiti fork with virtualModules support for compiled Bun binaries.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
|
|
@ -7,9 +9,19 @@ import { createRequire } from "node:module";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createJiti } from "@mariozechner/jiti";
|
||||||
|
import * as _bundledPiAgentCore from "@mariozechner/pi-agent-core";
|
||||||
|
import * as _bundledPiAi from "@mariozechner/pi-ai";
|
||||||
import type { KeyId } from "@mariozechner/pi-tui";
|
import type { KeyId } from "@mariozechner/pi-tui";
|
||||||
import { createJiti } from "jiti";
|
import * as _bundledPiTui from "@mariozechner/pi-tui";
|
||||||
|
// Static imports of packages that extensions may use.
|
||||||
|
// These MUST be static so Bun bundles them into the compiled binary.
|
||||||
|
// The virtualModules option then makes them available to extensions.
|
||||||
|
import * as _bundledTypebox from "@sinclair/typebox";
|
||||||
import { getAgentDir, isBunBinary } from "../../config.js";
|
import { getAgentDir, isBunBinary } from "../../config.js";
|
||||||
|
// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
|
||||||
|
// avoiding a circular dependency. Extensions can import from @mariozechner/pi-coding-agent.
|
||||||
|
import * as _bundledPiCodingAgent from "../../index.js";
|
||||||
import { createEventBus, type EventBus } from "../event-bus.js";
|
import { createEventBus, type EventBus } from "../event-bus.js";
|
||||||
import type { ExecOptions } from "../exec.js";
|
import type { ExecOptions } from "../exec.js";
|
||||||
import { execCommand } from "../exec.js";
|
import { execCommand } from "../exec.js";
|
||||||
|
|
@ -24,8 +36,21 @@ import type {
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
/** Modules available to extensions via virtualModules (for compiled Bun binary) */
|
||||||
|
const VIRTUAL_MODULES: Record<string, unknown> = {
|
||||||
|
"@sinclair/typebox": _bundledTypebox,
|
||||||
|
"@mariozechner/pi-agent-core": _bundledPiAgentCore,
|
||||||
|
"@mariozechner/pi-tui": _bundledPiTui,
|
||||||
|
"@mariozechner/pi-ai": _bundledPiAi,
|
||||||
|
"@mariozechner/pi-coding-agent": _bundledPiCodingAgent,
|
||||||
|
};
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aliases for jiti (used in Node.js/development mode).
|
||||||
|
* In Bun binary mode, virtualModules is used instead.
|
||||||
|
*/
|
||||||
let _aliases: Record<string, string> | null = null;
|
let _aliases: Record<string, string> | null = null;
|
||||||
function getAliases(): Record<string, string> {
|
function getAliases(): Record<string, string> {
|
||||||
if (_aliases) return _aliases;
|
if (_aliases) return _aliases;
|
||||||
|
|
@ -38,11 +63,12 @@ function getAliases(): Record<string, string> {
|
||||||
|
|
||||||
_aliases = {
|
_aliases = {
|
||||||
"@mariozechner/pi-coding-agent": packageIndex,
|
"@mariozechner/pi-coding-agent": packageIndex,
|
||||||
"@mariozechner/pi-coding-agent/extensions": path.resolve(__dirname, "index.js"),
|
"@mariozechner/pi-agent-core": require.resolve("@mariozechner/pi-agent-core"),
|
||||||
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
|
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
|
||||||
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
|
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
|
||||||
"@sinclair/typebox": typeboxRoot,
|
"@sinclair/typebox": typeboxRoot,
|
||||||
};
|
};
|
||||||
|
|
||||||
return _aliases;
|
return _aliases;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,18 +239,15 @@ function createExtensionAPI(
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBun(path: string) {
|
async function loadExtensionModule(extensionPath: string) {
|
||||||
const module = await import(path);
|
|
||||||
const factory = (module.default ?? module) as ExtensionFactory;
|
|
||||||
return typeof factory !== "function" ? undefined : factory;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadJiti(path: string) {
|
|
||||||
const jiti = createJiti(import.meta.url, {
|
const jiti = createJiti(import.meta.url, {
|
||||||
alias: getAliases(),
|
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
|
||||||
|
// Also disable tryNative so jiti handles ALL imports (not just the entry point)
|
||||||
|
// In Node.js/dev: use aliases to resolve to node_modules paths
|
||||||
|
...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const module = await jiti.import(path, { default: true });
|
const module = await jiti.import(extensionPath, { default: true });
|
||||||
const factory = module as ExtensionFactory;
|
const factory = module as ExtensionFactory;
|
||||||
return typeof factory !== "function" ? undefined : factory;
|
return typeof factory !== "function" ? undefined : factory;
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +277,7 @@ async function loadExtension(
|
||||||
const resolvedPath = resolvePath(extensionPath, cwd);
|
const resolvedPath = resolvePath(extensionPath, cwd);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const factory = isBunBinary ? await loadBun(resolvedPath) : await loadJiti(resolvedPath);
|
const factory = await loadExtensionModule(resolvedPath);
|
||||||
if (!factory) {
|
if (!factory) {
|
||||||
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
|
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ const ModelDefinitionSchema = Type.Object({
|
||||||
Type.Literal("openai-codex-responses"),
|
Type.Literal("openai-codex-responses"),
|
||||||
Type.Literal("anthropic-messages"),
|
Type.Literal("anthropic-messages"),
|
||||||
Type.Literal("google-generative-ai"),
|
Type.Literal("google-generative-ai"),
|
||||||
|
Type.Literal("bedrock-converse-stream"),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
reasoning: Type.Boolean(),
|
reasoning: Type.Boolean(),
|
||||||
|
|
@ -63,6 +64,7 @@ const ProviderConfigSchema = Type.Object({
|
||||||
Type.Literal("openai-codex-responses"),
|
Type.Literal("openai-codex-responses"),
|
||||||
Type.Literal("anthropic-messages"),
|
Type.Literal("anthropic-messages"),
|
||||||
Type.Literal("google-generative-ai"),
|
Type.Literal("google-generative-ai"),
|
||||||
|
Type.Literal("bedrock-converse-stream"),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||||
|
|
@ -373,6 +375,13 @@ export class ModelRegistry {
|
||||||
return this.authStorage.getApiKey(model.provider);
|
return this.authStorage.getApiKey(model.provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API key for a provider.
|
||||||
|
*/
|
||||||
|
async getApiKeyForProvider(provider: string): Promise<string | undefined> {
|
||||||
|
return this.authStorage.getApiKey(provider);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a model is using OAuth credentials (subscription).
|
* Check if a model is using OAuth credentials (subscription).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { ModelRegistry } from "./model-registry.js";
|
||||||
|
|
||||||
/** Default model IDs for each known provider */
|
/** Default model IDs for each known provider */
|
||||||
export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||||
|
"amazon-bedrock": "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||||
anthropic: "claude-sonnet-4-5",
|
anthropic: "claude-sonnet-4-5",
|
||||||
openai: "gpt-5.1-codex",
|
openai: "gpt-5.1-codex",
|
||||||
"openai-codex": "gpt-5.2-codex",
|
"openai-codex": "gpt-5.2-codex",
|
||||||
|
|
@ -20,11 +21,13 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||||
"google-vertex": "gemini-3-pro-preview",
|
"google-vertex": "gemini-3-pro-preview",
|
||||||
"github-copilot": "gpt-4o",
|
"github-copilot": "gpt-4o",
|
||||||
openrouter: "openai/gpt-5.1-codex",
|
openrouter: "openai/gpt-5.1-codex",
|
||||||
|
"vercel-ai-gateway": "anthropic/claude-opus-4.5",
|
||||||
xai: "grok-4-fast-non-reasoning",
|
xai: "grok-4-fast-non-reasoning",
|
||||||
groq: "openai/gpt-oss-120b",
|
groq: "openai/gpt-oss-120b",
|
||||||
cerebras: "zai-glm-4.6",
|
cerebras: "zai-glm-4.6",
|
||||||
zai: "glm-4.6",
|
zai: "glm-4.6",
|
||||||
mistral: "devstral-medium-latest",
|
mistral: "devstral-medium-latest",
|
||||||
|
minimax: "MiniMax-M2.1",
|
||||||
opencode: "claude-opus-4-5",
|
opencode: "claude-opus-4-5",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -628,14 +628,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
steeringMode: settingsManager.getSteeringMode(),
|
steeringMode: settingsManager.getSteeringMode(),
|
||||||
followUpMode: settingsManager.getFollowUpMode(),
|
followUpMode: settingsManager.getFollowUpMode(),
|
||||||
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
||||||
getApiKey: async () => {
|
getApiKey: async (provider) => {
|
||||||
const currentModel = agent.state.model;
|
// Use the provider argument from the in-flight request;
|
||||||
if (!currentModel) {
|
// agent.state.model may already be switched mid-turn.
|
||||||
|
const resolvedProvider = provider || agent.state.model?.provider;
|
||||||
|
if (!resolvedProvider) {
|
||||||
throw new Error("No model selected");
|
throw new Error("No model selected");
|
||||||
}
|
}
|
||||||
const key = await modelRegistry.getApiKey(currentModel);
|
const key = await modelRegistry.getApiKeyForProvider(resolvedProvider);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
throw new Error(`No API key found for provider "${currentModel.provider}"`);
|
throw new Error(`No API key found for provider "${resolvedProvider}"`);
|
||||||
}
|
}
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,6 @@ export type {
|
||||||
UserBashEventResult,
|
UserBashEventResult,
|
||||||
} from "./core/extensions/index.js";
|
} from "./core/extensions/index.js";
|
||||||
export {
|
export {
|
||||||
createExtensionRuntime,
|
|
||||||
discoverAndLoadExtensions,
|
|
||||||
ExtensionRunner,
|
ExtensionRunner,
|
||||||
isBashToolResult,
|
isBashToolResult,
|
||||||
isEditToolResult,
|
isEditToolResult,
|
||||||
|
|
@ -98,7 +96,6 @@ export {
|
||||||
isLsToolResult,
|
isLsToolResult,
|
||||||
isReadToolResult,
|
isReadToolResult,
|
||||||
isWriteToolResult,
|
isWriteToolResult,
|
||||||
loadExtensions,
|
|
||||||
wrapRegisteredTool,
|
wrapRegisteredTool,
|
||||||
wrapRegisteredTools,
|
wrapRegisteredTools,
|
||||||
wrapToolsWithExtensions,
|
wrapToolsWithExtensions,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,55 @@ import {
|
||||||
import { theme } from "../theme/theme.js";
|
import { theme } from "../theme/theme.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
|
||||||
|
// EnabledIds: null = all enabled (no filter), string[] = explicit ordered list
|
||||||
|
type EnabledIds = string[] | null;
|
||||||
|
|
||||||
|
function isEnabled(enabledIds: EnabledIds, id: string): boolean {
|
||||||
|
return enabledIds === null || enabledIds.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(enabledIds: EnabledIds, id: string): EnabledIds {
|
||||||
|
if (enabledIds === null) return [id]; // First toggle: start with only this one
|
||||||
|
const index = enabledIds.indexOf(id);
|
||||||
|
if (index >= 0) return [...enabledIds.slice(0, index), ...enabledIds.slice(index + 1)];
|
||||||
|
return [...enabledIds, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
|
||||||
|
if (enabledIds === null) return null; // Already all enabled
|
||||||
|
const targets = targetIds ?? allIds;
|
||||||
|
const result = [...enabledIds];
|
||||||
|
for (const id of targets) {
|
||||||
|
if (!result.includes(id)) result.push(id);
|
||||||
|
}
|
||||||
|
return result.length === allIds.length ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
|
||||||
|
if (enabledIds === null) {
|
||||||
|
return targetIds ? allIds.filter((id) => !targetIds.includes(id)) : [];
|
||||||
|
}
|
||||||
|
const targets = new Set(targetIds ?? enabledIds);
|
||||||
|
return enabledIds.filter((id) => !targets.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(enabledIds: EnabledIds, allIds: string[], id: string, delta: number): EnabledIds {
|
||||||
|
const list = enabledIds ?? [...allIds];
|
||||||
|
const index = list.indexOf(id);
|
||||||
|
if (index < 0) return list;
|
||||||
|
const newIndex = index + delta;
|
||||||
|
if (newIndex < 0 || newIndex >= list.length) return list;
|
||||||
|
const result = [...list];
|
||||||
|
[result[index], result[newIndex]] = [result[newIndex], result[index]];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] {
|
||||||
|
if (enabledIds === null) return allIds;
|
||||||
|
const enabledSet = new Set(enabledIds);
|
||||||
|
return [...enabledIds, ...allIds.filter((id) => !enabledSet.has(id))];
|
||||||
|
}
|
||||||
|
|
||||||
interface ModelItem {
|
interface ModelItem {
|
||||||
fullId: string;
|
fullId: string;
|
||||||
model: Model<any>;
|
model: Model<any>;
|
||||||
|
|
@ -44,7 +93,9 @@ export interface ModelsCallbacks {
|
||||||
* Changes are session-only until explicitly persisted with Ctrl+S.
|
* Changes are session-only until explicitly persisted with Ctrl+S.
|
||||||
*/
|
*/
|
||||||
export class ScopedModelsSelectorComponent extends Container {
|
export class ScopedModelsSelectorComponent extends Container {
|
||||||
private items: ModelItem[] = [];
|
private modelsById: Map<string, Model<any>> = new Map();
|
||||||
|
private allIds: string[] = [];
|
||||||
|
private enabledIds: EnabledIds = null;
|
||||||
private filteredItems: ModelItem[] = [];
|
private filteredItems: ModelItem[] = [];
|
||||||
private selectedIndex = 0;
|
private selectedIndex = 0;
|
||||||
private searchInput: Input;
|
private searchInput: Input;
|
||||||
|
|
@ -58,28 +109,14 @@ export class ScopedModelsSelectorComponent extends Container {
|
||||||
super();
|
super();
|
||||||
this.callbacks = callbacks;
|
this.callbacks = callbacks;
|
||||||
|
|
||||||
// Group models by provider for organized display
|
|
||||||
const modelsByProvider = new Map<string, Model<any>[]>();
|
|
||||||
for (const model of config.allModels) {
|
for (const model of config.allModels) {
|
||||||
const list = modelsByProvider.get(model.provider) ?? [];
|
const fullId = `${model.provider}/${model.id}`;
|
||||||
list.push(model);
|
this.modelsById.set(fullId, model);
|
||||||
modelsByProvider.set(model.provider, list);
|
this.allIds.push(fullId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build items - group by provider
|
this.enabledIds = config.hasEnabledModelsFilter ? [...config.enabledModelIds] : null;
|
||||||
for (const [provider, models] of modelsByProvider) {
|
this.filteredItems = this.buildItems();
|
||||||
for (const model of models) {
|
|
||||||
const fullId = `${provider}/${model.id}`;
|
|
||||||
// If no filter defined, all models are enabled by default
|
|
||||||
const isEnabled = !config.hasEnabledModelsFilter || config.enabledModelIds.has(fullId);
|
|
||||||
this.items.push({
|
|
||||||
fullId,
|
|
||||||
model,
|
|
||||||
enabled: isEnabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.filteredItems = this.getSortedItems();
|
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
this.addChild(new DynamicBorder());
|
this.addChild(new DynamicBorder());
|
||||||
|
|
@ -103,41 +140,34 @@ export class ScopedModelsSelectorComponent extends Container {
|
||||||
this.addChild(this.footerText);
|
this.addChild(this.footerText);
|
||||||
|
|
||||||
this.addChild(new DynamicBorder());
|
this.addChild(new DynamicBorder());
|
||||||
|
|
||||||
this.updateList();
|
this.updateList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get items sorted with enabled items first */
|
private buildItems(): ModelItem[] {
|
||||||
private getSortedItems(): ModelItem[] {
|
return getSortedIds(this.enabledIds, this.allIds).map((id) => ({
|
||||||
const enabled = this.items.filter((i) => i.enabled);
|
fullId: id,
|
||||||
const disabled = this.items.filter((i) => !i.enabled);
|
model: this.modelsById.get(id)!,
|
||||||
return [...enabled, ...disabled];
|
enabled: isEnabled(this.enabledIds, id),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFooterText(): string {
|
private getFooterText(): string {
|
||||||
const enabledCount = this.items.filter((i) => i.enabled).length;
|
const enabledCount = this.enabledIds?.length ?? this.allIds.length;
|
||||||
const allEnabled = enabledCount === this.items.length;
|
const allEnabled = this.enabledIds === null;
|
||||||
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.items.length} enabled`;
|
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`;
|
||||||
const parts = ["Enter toggle", "^A all", "^X clear", "^P provider", "^S save", countText];
|
const parts = ["Enter toggle", "^A all", "^X clear", "^P provider", "Alt+↑↓ reorder", "^S save", countText];
|
||||||
if (this.isDirty) {
|
return this.isDirty
|
||||||
return theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)");
|
? theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)")
|
||||||
}
|
: theme.fg("dim", ` ${parts.join(" · ")}`);
|
||||||
return theme.fg("dim", ` ${parts.join(" · ")}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateFooter(): void {
|
private refresh(): void {
|
||||||
this.footerText.setText(this.getFooterText());
|
const query = this.searchInput.getValue();
|
||||||
}
|
const items = this.buildItems();
|
||||||
|
this.filteredItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items;
|
||||||
private filterItems(query: string): void {
|
|
||||||
const sorted = this.getSortedItems();
|
|
||||||
if (!query) {
|
|
||||||
this.filteredItems = sorted;
|
|
||||||
} else {
|
|
||||||
this.filteredItems = fuzzyFilter(sorted, query, (item) => `${item.model.id} ${item.model.provider}`);
|
|
||||||
}
|
|
||||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||||
this.updateList();
|
this.updateList();
|
||||||
|
this.footerText.setText(this.getFooterText());
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateList(): void {
|
private updateList(): void {
|
||||||
|
|
@ -153,53 +183,26 @@ export class ScopedModelsSelectorComponent extends Container {
|
||||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
||||||
);
|
);
|
||||||
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
||||||
|
const allEnabled = this.enabledIds === null;
|
||||||
// Only show status if there's a filter (not all models enabled)
|
|
||||||
const allEnabled = this.items.every((i) => i.enabled);
|
|
||||||
|
|
||||||
for (let i = startIndex; i < endIndex; i++) {
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
const item = this.filteredItems[i];
|
const item = this.filteredItems[i]!;
|
||||||
if (!item) continue;
|
|
||||||
|
|
||||||
const isSelected = i === this.selectedIndex;
|
const isSelected = i === this.selectedIndex;
|
||||||
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
||||||
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
|
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
|
||||||
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
|
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
|
||||||
// Only show checkmarks when there's actually a filter
|
|
||||||
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
|
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
|
||||||
|
|
||||||
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
|
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add scroll indicator if needed
|
// Add scroll indicator if needed
|
||||||
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
||||||
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`);
|
this.listContainer.addChild(
|
||||||
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
|
new Text(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`), 0, 0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleItem(item: ModelItem): void {
|
|
||||||
// If all models are currently enabled (no scope yet), first toggle starts fresh:
|
|
||||||
// clear all and enable only the selected model
|
|
||||||
const allEnabled = this.items.every((i) => i.enabled);
|
|
||||||
if (allEnabled) {
|
|
||||||
for (const i of this.items) {
|
|
||||||
i.enabled = false;
|
|
||||||
}
|
|
||||||
item.enabled = true;
|
|
||||||
this.isDirty = true;
|
|
||||||
this.callbacks.onClearAll();
|
|
||||||
this.callbacks.onModelToggle(item.fullId, true);
|
|
||||||
} else {
|
|
||||||
item.enabled = !item.enabled;
|
|
||||||
this.isDirty = true;
|
|
||||||
this.callbacks.onModelToggle(item.fullId, item.enabled);
|
|
||||||
}
|
|
||||||
// Re-sort and re-filter to move item to correct section
|
|
||||||
this.filterItems(this.searchInput.getValue());
|
|
||||||
this.updateFooter();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
handleInput(data: string): void {
|
||||||
const kb = getEditorKeybindings();
|
const kb = getEditorKeybindings();
|
||||||
|
|
||||||
|
|
@ -217,70 +220,81 @@ export class ScopedModelsSelectorComponent extends Container {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alt+Up/Down - Reorder enabled models
|
||||||
|
if (matchesKey(data, Key.alt("up")) || matchesKey(data, Key.alt("down"))) {
|
||||||
|
const item = this.filteredItems[this.selectedIndex];
|
||||||
|
if (item && isEnabled(this.enabledIds, item.fullId)) {
|
||||||
|
const delta = matchesKey(data, Key.alt("up")) ? -1 : 1;
|
||||||
|
const enabledList = this.enabledIds ?? this.allIds;
|
||||||
|
const currentIndex = enabledList.indexOf(item.fullId);
|
||||||
|
const newIndex = currentIndex + delta;
|
||||||
|
// Only move if within bounds
|
||||||
|
if (newIndex >= 0 && newIndex < enabledList.length) {
|
||||||
|
this.enabledIds = move(this.enabledIds, this.allIds, item.fullId, delta);
|
||||||
|
this.isDirty = true;
|
||||||
|
this.selectedIndex += delta;
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle on Enter
|
// Toggle on Enter
|
||||||
if (matchesKey(data, Key.enter)) {
|
if (matchesKey(data, Key.enter)) {
|
||||||
const item = this.filteredItems[this.selectedIndex];
|
const item = this.filteredItems[this.selectedIndex];
|
||||||
if (item) {
|
if (item) {
|
||||||
this.toggleItem(item);
|
const wasAllEnabled = this.enabledIds === null;
|
||||||
|
this.enabledIds = toggle(this.enabledIds, item.fullId);
|
||||||
|
this.isDirty = true;
|
||||||
|
if (wasAllEnabled) this.callbacks.onClearAll();
|
||||||
|
this.callbacks.onModelToggle(item.fullId, isEnabled(this.enabledIds, item.fullId));
|
||||||
|
this.refresh();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+A - Enable all (filtered if search active, otherwise all)
|
// Ctrl+A - Enable all (filtered if search active, otherwise all)
|
||||||
if (matchesKey(data, Key.ctrl("a"))) {
|
if (matchesKey(data, Key.ctrl("a"))) {
|
||||||
const targets = this.searchInput.getValue() ? this.filteredItems : this.items;
|
const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined;
|
||||||
for (const item of targets) {
|
this.enabledIds = enableAll(this.enabledIds, this.allIds, targetIds);
|
||||||
item.enabled = true;
|
|
||||||
}
|
|
||||||
this.isDirty = true;
|
this.isDirty = true;
|
||||||
this.callbacks.onEnableAll(targets.map((i) => i.fullId));
|
this.callbacks.onEnableAll(targetIds ?? this.allIds);
|
||||||
this.filterItems(this.searchInput.getValue());
|
this.refresh();
|
||||||
this.updateFooter();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+X - Clear all (filtered if search active, otherwise all)
|
// Ctrl+X - Clear all (filtered if search active, otherwise all)
|
||||||
if (matchesKey(data, Key.ctrl("x"))) {
|
if (matchesKey(data, Key.ctrl("x"))) {
|
||||||
const targets = this.searchInput.getValue() ? this.filteredItems : this.items;
|
const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined;
|
||||||
for (const item of targets) {
|
this.enabledIds = clearAll(this.enabledIds, this.allIds, targetIds);
|
||||||
item.enabled = false;
|
|
||||||
}
|
|
||||||
this.isDirty = true;
|
this.isDirty = true;
|
||||||
this.callbacks.onClearAll();
|
this.callbacks.onClearAll();
|
||||||
this.filterItems(this.searchInput.getValue());
|
this.refresh();
|
||||||
this.updateFooter();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+P - Toggle provider of current item
|
// Ctrl+P - Toggle provider of current item
|
||||||
if (matchesKey(data, Key.ctrl("p"))) {
|
if (matchesKey(data, Key.ctrl("p"))) {
|
||||||
const currentItem = this.filteredItems[this.selectedIndex];
|
const item = this.filteredItems[this.selectedIndex];
|
||||||
if (currentItem) {
|
if (item) {
|
||||||
const provider = currentItem.model.provider;
|
const provider = item.model.provider;
|
||||||
const providerItems = this.items.filter((i) => i.model.provider === provider);
|
const providerIds = this.allIds.filter((id) => this.modelsById.get(id)!.provider === provider);
|
||||||
const allEnabled = providerItems.every((i) => i.enabled);
|
const allEnabled = providerIds.every((id) => isEnabled(this.enabledIds, id));
|
||||||
const newState = !allEnabled;
|
this.enabledIds = allEnabled
|
||||||
for (const item of providerItems) {
|
? clearAll(this.enabledIds, this.allIds, providerIds)
|
||||||
item.enabled = newState;
|
: enableAll(this.enabledIds, this.allIds, providerIds);
|
||||||
}
|
|
||||||
this.isDirty = true;
|
this.isDirty = true;
|
||||||
this.callbacks.onToggleProvider(
|
this.callbacks.onToggleProvider(provider, providerIds, !allEnabled);
|
||||||
provider,
|
this.refresh();
|
||||||
providerItems.map((i) => i.fullId),
|
|
||||||
newState,
|
|
||||||
);
|
|
||||||
this.filterItems(this.searchInput.getValue());
|
|
||||||
this.updateFooter();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+S - Save/persist to settings
|
// Ctrl+S - Save/persist to settings
|
||||||
if (matchesKey(data, Key.ctrl("s"))) {
|
if (matchesKey(data, Key.ctrl("s"))) {
|
||||||
const enabledIds = this.items.filter((i) => i.enabled).map((i) => i.fullId);
|
this.callbacks.onPersist(this.enabledIds ?? [...this.allIds]);
|
||||||
this.callbacks.onPersist(enabledIds);
|
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
this.updateFooter();
|
this.footerText.setText(this.getFooterText());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,7 +302,7 @@ export class ScopedModelsSelectorComponent extends Container {
|
||||||
if (matchesKey(data, Key.ctrl("c"))) {
|
if (matchesKey(data, Key.ctrl("c"))) {
|
||||||
if (this.searchInput.getValue()) {
|
if (this.searchInput.getValue()) {
|
||||||
this.searchInput.setValue("");
|
this.searchInput.setValue("");
|
||||||
this.filterItems("");
|
this.refresh();
|
||||||
} else {
|
} else {
|
||||||
this.callbacks.onCancel();
|
this.callbacks.onCancel();
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +317,7 @@ export class ScopedModelsSelectorComponent extends Container {
|
||||||
|
|
||||||
// Pass everything else to search input
|
// Pass everything else to search input
|
||||||
this.searchInput.handleInput(data);
|
this.searchInput.handleInput(data);
|
||||||
this.filterItems(this.searchInput.getValue());
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSearchInput(): Input {
|
getSearchInput(): Input {
|
||||||
|
|
|
||||||
|
|
@ -603,10 +603,9 @@ export class InteractiveMode {
|
||||||
const entries = parseChangelog(changelogPath);
|
const entries = parseChangelog(changelogPath);
|
||||||
|
|
||||||
if (!lastVersion) {
|
if (!lastVersion) {
|
||||||
if (entries.length > 0) {
|
// Fresh install - just record the version, don't show changelog
|
||||||
this.settingsManager.setLastChangelogVersion(VERSION);
|
this.settingsManager.setLastChangelogVersion(VERSION);
|
||||||
return entries.map((e) => e.content).join("\n\n");
|
return undefined;
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const newEntries = getNewEntries(entries, lastVersion);
|
const newEntries = getNewEntries(entries, lastVersion);
|
||||||
if (newEntries.length > 0) {
|
if (newEntries.length > 0) {
|
||||||
|
|
@ -3271,7 +3270,7 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the preview URL
|
// Create the preview URL
|
||||||
const previewUrl = `https://shittycodingagent.ai/session?${gistId}`;
|
const previewUrl = `https://buildwithpi.ai/session?${gistId}`;
|
||||||
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
|
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!loader.signal.aborted) {
|
if (!loader.signal.aborted) {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||||
"name": "light",
|
"name": "light",
|
||||||
"vars": {
|
"vars": {
|
||||||
"teal": "#5f8787",
|
"teal": "#5a8080",
|
||||||
"blue": "#5f87af",
|
"blue": "#547da7",
|
||||||
"green": "#87af87",
|
"green": "#588458",
|
||||||
"red": "#af5f5f",
|
"red": "#aa5555",
|
||||||
"yellow": "#d7af5f",
|
"yellow": "#9a7326",
|
||||||
"mediumGray": "#6c6c6c",
|
"mediumGray": "#6c6c6c",
|
||||||
"dimGray": "#8a8a8a",
|
"dimGray": "#767676",
|
||||||
"lightGray": "#b0b0b0",
|
"lightGray": "#b0b0b0",
|
||||||
"selectedBg": "#d0d0e0",
|
"selectedBg": "#d0d0e0",
|
||||||
"userMsgBg": "#e8e8e8",
|
"userMsgBg": "#e8e8e8",
|
||||||
|
|
@ -68,9 +68,9 @@
|
||||||
"syntaxPunctuation": "#000000",
|
"syntaxPunctuation": "#000000",
|
||||||
|
|
||||||
"thinkingOff": "lightGray",
|
"thinkingOff": "lightGray",
|
||||||
"thinkingMinimal": "#9e9e9e",
|
"thinkingMinimal": "#767676",
|
||||||
"thinkingLow": "#5f87af",
|
"thinkingLow": "blue",
|
||||||
"thinkingMedium": "#5f8787",
|
"thinkingMedium": "teal",
|
||||||
"thinkingHigh": "#875f87",
|
"thinkingHigh": "#875f87",
|
||||||
"thinkingXhigh": "#8b008b",
|
"thinkingXhigh": "#8b008b",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ export interface PrintModeOptions {
|
||||||
*/
|
*/
|
||||||
export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<void> {
|
export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<void> {
|
||||||
const { mode, messages = [], initialMessage, initialImages } = options;
|
const { mode, messages = [], initialMessage, initialImages } = options;
|
||||||
|
if (mode === "json") {
|
||||||
|
const header = session.sessionManager.getHeader();
|
||||||
|
if (header) {
|
||||||
|
console.log(JSON.stringify(header));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Set up extensions for print mode (no UI, no command context)
|
// Set up extensions for print mode (no UI, no command context)
|
||||||
const extensionRunner = session.extensionRunner;
|
const extensionRunner = session.extensionRunner;
|
||||||
if (extensionRunner) {
|
if (extensionRunner) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { getVips } from "./vips.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert image to PNG format for terminal display.
|
* Convert image to PNG format for terminal display.
|
||||||
* Kitty graphics protocol requires PNG format (f=100).
|
* Kitty graphics protocol requires PNG format (f=100).
|
||||||
|
|
@ -11,16 +13,23 @@ export async function convertToPng(
|
||||||
return { data: base64Data, mimeType };
|
return { data: base64Data, mimeType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vips = await getVips();
|
||||||
|
if (!vips) {
|
||||||
|
// wasm-vips not available
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sharp = (await import("sharp")).default;
|
|
||||||
const buffer = Buffer.from(base64Data, "base64");
|
const buffer = Buffer.from(base64Data, "base64");
|
||||||
const pngBuffer = await sharp(buffer).png().toBuffer();
|
const img = vips.Image.newFromBuffer(buffer);
|
||||||
|
const pngBuffer = img.writeToBuffer(".png");
|
||||||
|
img.delete();
|
||||||
return {
|
return {
|
||||||
data: pngBuffer.toString("base64"),
|
data: Buffer.from(pngBuffer).toString("base64"),
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Sharp not available or conversion failed
|
// Conversion failed
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
|
import { getVips } from "./vips.js";
|
||||||
|
|
||||||
export interface ImageResizeOptions {
|
export interface ImageResizeOptions {
|
||||||
maxWidth?: number; // Default: 2000
|
maxWidth?: number; // Default: 2000
|
||||||
|
|
@ -29,9 +30,9 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
||||||
|
|
||||||
/** Helper to pick the smaller of two buffers */
|
/** Helper to pick the smaller of two buffers */
|
||||||
function pickSmaller(
|
function pickSmaller(
|
||||||
a: { buffer: Buffer; mimeType: string },
|
a: { buffer: Uint8Array; mimeType: string },
|
||||||
b: { buffer: Buffer; mimeType: string },
|
b: { buffer: Uint8Array; mimeType: string },
|
||||||
): { buffer: Buffer; mimeType: string } {
|
): { buffer: Uint8Array; mimeType: string } {
|
||||||
return a.buffer.length <= b.buffer.length ? a : b;
|
return a.buffer.length <= b.buffer.length ? a : b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +40,7 @@ function pickSmaller(
|
||||||
* Resize an image to fit within the specified max dimensions and file size.
|
* Resize an image to fit within the specified max dimensions and file size.
|
||||||
* Returns the original image if it already fits within the limits.
|
* Returns the original image if it already fits within the limits.
|
||||||
*
|
*
|
||||||
* Uses sharp for image processing. If sharp is not available (e.g., in some
|
* Uses wasm-vips for image processing. If wasm-vips is not available (e.g., in some
|
||||||
* environments), returns the original image unchanged.
|
* environments), returns the original image unchanged.
|
||||||
*
|
*
|
||||||
* Strategy for staying under maxBytes:
|
* Strategy for staying under maxBytes:
|
||||||
|
|
@ -52,12 +53,29 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
const buffer = Buffer.from(img.data, "base64");
|
const buffer = Buffer.from(img.data, "base64");
|
||||||
|
|
||||||
let sharp: typeof import("sharp") | undefined;
|
const vipsOrNull = await getVips();
|
||||||
|
if (!vipsOrNull) {
|
||||||
|
// wasm-vips not available - return original image
|
||||||
|
// We can't get dimensions without vips, so return 0s
|
||||||
|
return {
|
||||||
|
data: img.data,
|
||||||
|
mimeType: img.mimeType,
|
||||||
|
originalWidth: 0,
|
||||||
|
originalHeight: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
wasResized: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Capture non-null reference for use in nested functions
|
||||||
|
const vips = vipsOrNull;
|
||||||
|
|
||||||
|
// Load image to get metadata
|
||||||
|
let sourceImg: InstanceType<typeof vips.Image>;
|
||||||
try {
|
try {
|
||||||
sharp = (await import("sharp")).default;
|
sourceImg = vips.Image.newFromBuffer(buffer);
|
||||||
} catch {
|
} catch {
|
||||||
// Sharp not available - return original image
|
// Failed to load image
|
||||||
// We can't get dimensions without sharp, so return 0s
|
|
||||||
return {
|
return {
|
||||||
data: img.data,
|
data: img.data,
|
||||||
mimeType: img.mimeType,
|
mimeType: img.mimeType,
|
||||||
|
|
@ -69,16 +87,14 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharpImg = sharp(buffer);
|
const originalWidth = sourceImg.width;
|
||||||
const metadata = await sharpImg.metadata();
|
const originalHeight = sourceImg.height;
|
||||||
|
|
||||||
const originalWidth = metadata.width ?? 0;
|
|
||||||
const originalHeight = metadata.height ?? 0;
|
|
||||||
const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png";
|
|
||||||
|
|
||||||
// Check if already within all limits (dimensions AND size)
|
// Check if already within all limits (dimensions AND size)
|
||||||
const originalSize = buffer.length;
|
const originalSize = buffer.length;
|
||||||
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
|
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
|
||||||
|
sourceImg.delete();
|
||||||
|
const format = img.mimeType?.split("/")[1] ?? "png";
|
||||||
return {
|
return {
|
||||||
data: img.data,
|
data: img.data,
|
||||||
mimeType: img.mimeType ?? `image/${format}`,
|
mimeType: img.mimeType ?? `image/${format}`,
|
||||||
|
|
@ -104,37 +120,45 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to resize and encode in both formats, returning the smaller one
|
// Helper to resize and encode in both formats, returning the smaller one
|
||||||
async function tryBothFormats(
|
function tryBothFormats(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
jpegQuality: number,
|
jpegQuality: number,
|
||||||
): Promise<{ buffer: Buffer; mimeType: string }> {
|
): { buffer: Uint8Array; mimeType: string } {
|
||||||
const resized = await sharp!(buffer)
|
// Load image fresh and resize using scale factor
|
||||||
.resize(width, height, { fit: "inside", withoutEnlargement: true })
|
// (Using newFromBuffer + resize instead of thumbnailBuffer to avoid lazy re-read issues)
|
||||||
.toBuffer();
|
const img = vips.Image.newFromBuffer(buffer);
|
||||||
|
const scale = Math.min(width / img.width, height / img.height);
|
||||||
|
const resized = scale < 1 ? img.resize(scale) : img;
|
||||||
|
|
||||||
const [pngBuffer, jpegBuffer] = await Promise.all([
|
const pngBuffer = resized.writeToBuffer(".png");
|
||||||
sharp!(resized).png({ compressionLevel: 9 }).toBuffer(),
|
const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
|
||||||
sharp!(resized).jpeg({ quality: jpegQuality }).toBuffer(),
|
|
||||||
]);
|
if (resized !== img) {
|
||||||
|
resized.delete();
|
||||||
|
}
|
||||||
|
img.delete();
|
||||||
|
|
||||||
return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
|
return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up the source image
|
||||||
|
sourceImg.delete();
|
||||||
|
|
||||||
// Try to produce an image under maxBytes
|
// Try to produce an image under maxBytes
|
||||||
const qualitySteps = [85, 70, 55, 40];
|
const qualitySteps = [85, 70, 55, 40];
|
||||||
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
|
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
|
||||||
|
|
||||||
let best: { buffer: Buffer; mimeType: string };
|
let best: { buffer: Uint8Array; mimeType: string };
|
||||||
let finalWidth = targetWidth;
|
let finalWidth = targetWidth;
|
||||||
let finalHeight = targetHeight;
|
let finalHeight = targetHeight;
|
||||||
|
|
||||||
// First attempt: resize to target dimensions, try both formats
|
// First attempt: resize to target dimensions, try both formats
|
||||||
best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
|
best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
|
||||||
|
|
||||||
if (best.buffer.length <= opts.maxBytes) {
|
if (best.buffer.length <= opts.maxBytes) {
|
||||||
return {
|
return {
|
||||||
data: best.buffer.toString("base64"),
|
data: Buffer.from(best.buffer).toString("base64"),
|
||||||
mimeType: best.mimeType,
|
mimeType: best.mimeType,
|
||||||
originalWidth,
|
originalWidth,
|
||||||
originalHeight,
|
originalHeight,
|
||||||
|
|
@ -146,11 +170,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||||
|
|
||||||
// Still too large - try JPEG with decreasing quality (and compare to PNG each time)
|
// Still too large - try JPEG with decreasing quality (and compare to PNG each time)
|
||||||
for (const quality of qualitySteps) {
|
for (const quality of qualitySteps) {
|
||||||
best = await tryBothFormats(targetWidth, targetHeight, quality);
|
best = tryBothFormats(targetWidth, targetHeight, quality);
|
||||||
|
|
||||||
if (best.buffer.length <= opts.maxBytes) {
|
if (best.buffer.length <= opts.maxBytes) {
|
||||||
return {
|
return {
|
||||||
data: best.buffer.toString("base64"),
|
data: Buffer.from(best.buffer).toString("base64"),
|
||||||
mimeType: best.mimeType,
|
mimeType: best.mimeType,
|
||||||
originalWidth,
|
originalWidth,
|
||||||
originalHeight,
|
originalHeight,
|
||||||
|
|
@ -172,11 +196,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const quality of qualitySteps) {
|
for (const quality of qualitySteps) {
|
||||||
best = await tryBothFormats(finalWidth, finalHeight, quality);
|
best = tryBothFormats(finalWidth, finalHeight, quality);
|
||||||
|
|
||||||
if (best.buffer.length <= opts.maxBytes) {
|
if (best.buffer.length <= opts.maxBytes) {
|
||||||
return {
|
return {
|
||||||
data: best.buffer.toString("base64"),
|
data: Buffer.from(best.buffer).toString("base64"),
|
||||||
mimeType: best.mimeType,
|
mimeType: best.mimeType,
|
||||||
originalWidth,
|
originalWidth,
|
||||||
originalHeight,
|
originalHeight,
|
||||||
|
|
@ -191,7 +215,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||||
// Last resort: return smallest version we produced even if over limit
|
// Last resort: return smallest version we produced even if over limit
|
||||||
// (the API will reject it, but at least we tried everything)
|
// (the API will reject it, but at least we tried everything)
|
||||||
return {
|
return {
|
||||||
data: best.buffer.toString("base64"),
|
data: Buffer.from(best.buffer).toString("base64"),
|
||||||
mimeType: best.mimeType,
|
mimeType: best.mimeType,
|
||||||
originalWidth,
|
originalWidth,
|
||||||
originalHeight,
|
originalHeight,
|
||||||
|
|
|
||||||
40
packages/coding-agent/src/utils/vips.ts
Normal file
40
packages/coding-agent/src/utils/vips.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Singleton wrapper for wasm-vips initialization.
|
||||||
|
* wasm-vips requires async initialization, so we cache the instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Vips from "wasm-vips";
|
||||||
|
|
||||||
|
let vipsInstance: Awaited<ReturnType<typeof Vips>> | null = null;
|
||||||
|
let vipsInitPromise: Promise<Awaited<ReturnType<typeof Vips>> | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the initialized wasm-vips instance.
|
||||||
|
* Returns null if wasm-vips is not available or fails to initialize.
|
||||||
|
*/
|
||||||
|
export async function getVips(): Promise<Awaited<ReturnType<typeof Vips>> | null> {
|
||||||
|
if (vipsInstance) {
|
||||||
|
return vipsInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vipsInitPromise) {
|
||||||
|
return vipsInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
vipsInitPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const VipsInit = (await import("wasm-vips")).default;
|
||||||
|
vipsInstance = await VipsInit();
|
||||||
|
return vipsInstance;
|
||||||
|
} catch {
|
||||||
|
// wasm-vips not available
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = await vipsInitPromise;
|
||||||
|
if (!result) {
|
||||||
|
vipsInitPromise = null; // Allow retry on failure
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
144
packages/coding-agent/test/image-processing.test.ts
Normal file
144
packages/coding-agent/test/image-processing.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
/**
|
||||||
|
* Tests for image processing utilities using wasm-vips.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { convertToPng } from "../src/utils/image-convert.js";
|
||||||
|
import { formatDimensionNote, resizeImage } from "../src/utils/image-resize.js";
|
||||||
|
import { getVips } from "../src/utils/vips.js";
|
||||||
|
|
||||||
|
// Small 2x2 red PNG image (base64)
|
||||||
|
const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAADklEQVQI12P4z8DAwMAAAA0BA/m5sb9AAAAAAElFTkSuQmCC";
|
||||||
|
|
||||||
|
// Small 2x2 blue JPEG image (base64)
|
||||||
|
const TINY_JPEG =
|
||||||
|
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAB//2Q==";
|
||||||
|
|
||||||
|
// 100x100 gray PNG (generated with wasm-vips)
|
||||||
|
const MEDIUM_PNG_100x100 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAtGVYSWZJSSoACAAAAAYAEgEDAAEAAAABAAAAGgEFAAEAAABWAAAAGwEFAAEAAABeAAAAKAEDAAEAAAACAAAAEwIDAAEAAAABAAAAaYcEAAEAAABmAAAAAAAAADhjAADoAwAAOGMAAOgDAAAGAACQBwAEAAAAMDIxMAGRBwAEAAAAAQIDAACgBwAEAAAAMDEwMAGgAwABAAAA//8AAAKgBAABAAAAZAAAAAOgBAABAAAAZAAAAAAAAAC1xMTxAAAA4klEQVR4nO3QoQEAAAiAME/3dF+QvmUSs7zNP8WswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKz9zzpHfptnWvrkoQAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
// 200x200 colored PNG (generated with wasm-vips)
|
||||||
|
const LARGE_PNG_200x200 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAtGVYSWZJSSoACAAAAAYAEgEDAAEAAAABAAAAGgEFAAEAAABWAAAAGwEFAAEAAABeAAAAKAEDAAEAAAACAAAAEwIDAAEAAAABAAAAaYcEAAEAAABmAAAAAAAAADhjAADoAwAAOGMAAOgDAAAGAACQBwAEAAAAMDIxMAGRBwAEAAAAAQIDAACgBwAEAAAAMDEwMAGgAwABAAAA//8AAAKgBAABAAAAyAAAAAOgBAABAAAAyAAAAAAAAADqHRv+AAAD8UlEQVR4nO2UAQnAQACEFtZMy/SxVmJDdggmOOUu7hMtwNsZXG3aAnxwLoVVWKewiuD85V97LN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN/BJIXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5Y5AHNA7iPx5BmcQAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
describe("wasm-vips initialization", () => {
|
||||||
|
it("should initialize wasm-vips successfully", async () => {
|
||||||
|
const vips = await getVips();
|
||||||
|
expect(vips).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return cached instance on subsequent calls", async () => {
|
||||||
|
const vips1 = await getVips();
|
||||||
|
const vips2 = await getVips();
|
||||||
|
expect(vips1).toBe(vips2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("convertToPng", () => {
|
||||||
|
it("should return original data for PNG input", async () => {
|
||||||
|
const result = await convertToPng(TINY_PNG, "image/png");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.data).toBe(TINY_PNG);
|
||||||
|
expect(result!.mimeType).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert JPEG to PNG", async () => {
|
||||||
|
const result = await convertToPng(TINY_JPEG, "image/jpeg");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.mimeType).toBe("image/png");
|
||||||
|
// Result should be valid base64
|
||||||
|
expect(() => Buffer.from(result!.data, "base64")).not.toThrow();
|
||||||
|
// PNG magic bytes
|
||||||
|
const buffer = Buffer.from(result!.data, "base64");
|
||||||
|
expect(buffer[0]).toBe(0x89);
|
||||||
|
expect(buffer[1]).toBe(0x50); // 'P'
|
||||||
|
expect(buffer[2]).toBe(0x4e); // 'N'
|
||||||
|
expect(buffer[3]).toBe(0x47); // 'G'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resizeImage", () => {
|
||||||
|
it("should return original image if within limits", async () => {
|
||||||
|
const result = await resizeImage(
|
||||||
|
{ type: "image", data: TINY_PNG, mimeType: "image/png" },
|
||||||
|
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.wasResized).toBe(false);
|
||||||
|
expect(result.data).toBe(TINY_PNG);
|
||||||
|
expect(result.originalWidth).toBe(2);
|
||||||
|
expect(result.originalHeight).toBe(2);
|
||||||
|
expect(result.width).toBe(2);
|
||||||
|
expect(result.height).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resize image exceeding dimension limits", async () => {
|
||||||
|
const result = await resizeImage(
|
||||||
|
{ type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" },
|
||||||
|
{ maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.wasResized).toBe(true);
|
||||||
|
expect(result.originalWidth).toBe(100);
|
||||||
|
expect(result.originalHeight).toBe(100);
|
||||||
|
expect(result.width).toBeLessThanOrEqual(50);
|
||||||
|
expect(result.height).toBeLessThanOrEqual(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resize image exceeding byte limit", async () => {
|
||||||
|
const originalBuffer = Buffer.from(LARGE_PNG_200x200, "base64");
|
||||||
|
const originalSize = originalBuffer.length;
|
||||||
|
|
||||||
|
// Set maxBytes to less than the original image size
|
||||||
|
const result = await resizeImage(
|
||||||
|
{ type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
|
||||||
|
{ maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(originalSize / 2) },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have tried to reduce size
|
||||||
|
const resultBuffer = Buffer.from(result.data, "base64");
|
||||||
|
expect(resultBuffer.length).toBeLessThan(originalSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle JPEG input", async () => {
|
||||||
|
const result = await resizeImage(
|
||||||
|
{ type: "image", data: TINY_JPEG, mimeType: "image/jpeg" },
|
||||||
|
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.wasResized).toBe(false);
|
||||||
|
expect(result.originalWidth).toBe(2);
|
||||||
|
expect(result.originalHeight).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDimensionNote", () => {
|
||||||
|
it("should return undefined for non-resized images", () => {
|
||||||
|
const note = formatDimensionNote({
|
||||||
|
data: "",
|
||||||
|
mimeType: "image/png",
|
||||||
|
originalWidth: 100,
|
||||||
|
originalHeight: 100,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
wasResized: false,
|
||||||
|
});
|
||||||
|
expect(note).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return formatted note for resized images", () => {
|
||||||
|
const note = formatDimensionNote({
|
||||||
|
data: "",
|
||||||
|
mimeType: "image/png",
|
||||||
|
originalWidth: 2000,
|
||||||
|
originalHeight: 1000,
|
||||||
|
width: 1000,
|
||||||
|
height: 500,
|
||||||
|
wasResized: true,
|
||||||
|
});
|
||||||
|
expect(note).toContain("original 2000x1000");
|
||||||
|
expect(note).toContain("displayed at 1000x500");
|
||||||
|
expect(note).toContain("2.00"); // scale factor
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Model } from "@mariozechner/pi-ai";
|
import type { Model } from "@mariozechner/pi-ai";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { parseModelPattern } from "../src/core/model-resolver.js";
|
import { defaultModelPerProvider, findInitialModel, parseModelPattern } from "../src/core/model-resolver.js";
|
||||||
|
|
||||||
// Mock models for testing
|
// Mock models for testing
|
||||||
const mockModels: Model<"anthropic-messages">[] = [
|
const mockModels: Model<"anthropic-messages">[] = [
|
||||||
|
|
@ -200,3 +200,37 @@ describe("parseModelPattern", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("default model selection", () => {
|
||||||
|
test("ai-gateway default is opus 4.5", () => {
|
||||||
|
expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("anthropic/claude-opus-4.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findInitialModel selects ai-gateway default when available", async () => {
|
||||||
|
const aiGatewayModel: Model<"anthropic-messages"> = {
|
||||||
|
id: "anthropic/claude-opus-4.5",
|
||||||
|
name: "Claude Opus 4.5",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "vercel-ai-gateway",
|
||||||
|
baseUrl: "https://ai-gateway.vercel.sh",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = {
|
||||||
|
getAvailable: async () => [aiGatewayModel],
|
||||||
|
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
|
||||||
|
|
||||||
|
const result = await findInitialModel({
|
||||||
|
scopedModels: [],
|
||||||
|
isContinuing: false,
|
||||||
|
modelRegistry: registry,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.model?.provider).toBe("vercel-ai-gateway");
|
||||||
|
expect(result.model?.id).toBe("anthropic/claude-opus-4.5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
261
packages/coding-agent/test/plan-mode-utils.test.ts
Normal file
261
packages/coding-agent/test/plan-mode-utils.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
cleanStepText,
|
||||||
|
extractDoneSteps,
|
||||||
|
extractTodoItems,
|
||||||
|
isSafeCommand,
|
||||||
|
markCompletedSteps,
|
||||||
|
type TodoItem,
|
||||||
|
} from "../examples/extensions/plan-mode/utils.js";
|
||||||
|
|
||||||
|
describe("isSafeCommand", () => {
|
||||||
|
describe("safe commands", () => {
|
||||||
|
it("allows basic read commands", () => {
|
||||||
|
expect(isSafeCommand("ls -la")).toBe(true);
|
||||||
|
expect(isSafeCommand("cat file.txt")).toBe(true);
|
||||||
|
expect(isSafeCommand("head -n 10 file.txt")).toBe(true);
|
||||||
|
expect(isSafeCommand("tail -f log.txt")).toBe(true);
|
||||||
|
expect(isSafeCommand("grep pattern file")).toBe(true);
|
||||||
|
expect(isSafeCommand("find . -name '*.ts'")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows git read commands", () => {
|
||||||
|
expect(isSafeCommand("git status")).toBe(true);
|
||||||
|
expect(isSafeCommand("git log --oneline")).toBe(true);
|
||||||
|
expect(isSafeCommand("git diff")).toBe(true);
|
||||||
|
expect(isSafeCommand("git branch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows npm/yarn read commands", () => {
|
||||||
|
expect(isSafeCommand("npm list")).toBe(true);
|
||||||
|
expect(isSafeCommand("npm outdated")).toBe(true);
|
||||||
|
expect(isSafeCommand("yarn info react")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows other safe commands", () => {
|
||||||
|
expect(isSafeCommand("pwd")).toBe(true);
|
||||||
|
expect(isSafeCommand("echo hello")).toBe(true);
|
||||||
|
expect(isSafeCommand("wc -l file.txt")).toBe(true);
|
||||||
|
expect(isSafeCommand("du -sh .")).toBe(true);
|
||||||
|
expect(isSafeCommand("df -h")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("destructive commands", () => {
|
||||||
|
it("blocks file modification commands", () => {
|
||||||
|
expect(isSafeCommand("rm file.txt")).toBe(false);
|
||||||
|
expect(isSafeCommand("rm -rf dir")).toBe(false);
|
||||||
|
expect(isSafeCommand("mv old new")).toBe(false);
|
||||||
|
expect(isSafeCommand("cp src dst")).toBe(false);
|
||||||
|
expect(isSafeCommand("mkdir newdir")).toBe(false);
|
||||||
|
expect(isSafeCommand("touch newfile")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks git write commands", () => {
|
||||||
|
expect(isSafeCommand("git add .")).toBe(false);
|
||||||
|
expect(isSafeCommand("git commit -m 'msg'")).toBe(false);
|
||||||
|
expect(isSafeCommand("git push")).toBe(false);
|
||||||
|
expect(isSafeCommand("git checkout main")).toBe(false);
|
||||||
|
expect(isSafeCommand("git reset --hard")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks package manager installs", () => {
|
||||||
|
expect(isSafeCommand("npm install lodash")).toBe(false);
|
||||||
|
expect(isSafeCommand("yarn add react")).toBe(false);
|
||||||
|
expect(isSafeCommand("pip install requests")).toBe(false);
|
||||||
|
expect(isSafeCommand("brew install node")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks redirects", () => {
|
||||||
|
expect(isSafeCommand("echo hello > file.txt")).toBe(false);
|
||||||
|
expect(isSafeCommand("cat foo >> bar")).toBe(false);
|
||||||
|
expect(isSafeCommand(">file.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks dangerous commands", () => {
|
||||||
|
expect(isSafeCommand("sudo rm -rf /")).toBe(false);
|
||||||
|
expect(isSafeCommand("kill -9 1234")).toBe(false);
|
||||||
|
expect(isSafeCommand("reboot")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks editors", () => {
|
||||||
|
expect(isSafeCommand("vim file.txt")).toBe(false);
|
||||||
|
expect(isSafeCommand("nano file.txt")).toBe(false);
|
||||||
|
expect(isSafeCommand("code .")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("requires command to be in safe list (not just non-destructive)", () => {
|
||||||
|
expect(isSafeCommand("unknown-command")).toBe(false);
|
||||||
|
expect(isSafeCommand("my-script.sh")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles commands with leading whitespace", () => {
|
||||||
|
expect(isSafeCommand(" ls -la")).toBe(true);
|
||||||
|
expect(isSafeCommand(" rm file")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanStepText", () => {
|
||||||
|
it("removes markdown bold/italic", () => {
|
||||||
|
expect(cleanStepText("**bold text**")).toBe("Bold text");
|
||||||
|
expect(cleanStepText("*italic text*")).toBe("Italic text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes markdown code", () => {
|
||||||
|
expect(cleanStepText("run `npm install`")).toBe("Npm install"); // "run" is stripped as action word
|
||||||
|
expect(cleanStepText("check the `config.json` file")).toBe("Config.json file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes leading action words", () => {
|
||||||
|
expect(cleanStepText("Create the new file")).toBe("New file");
|
||||||
|
expect(cleanStepText("Run the tests")).toBe("Tests");
|
||||||
|
expect(cleanStepText("Check the status")).toBe("Status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("capitalizes first letter", () => {
|
||||||
|
expect(cleanStepText("update config")).toBe("Config");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long text", () => {
|
||||||
|
const longText = "This is a very long step description that exceeds the maximum allowed length for display";
|
||||||
|
const result = cleanStepText(longText);
|
||||||
|
expect(result.length).toBe(50);
|
||||||
|
expect(result.endsWith("...")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes whitespace", () => {
|
||||||
|
expect(cleanStepText("multiple spaces here")).toBe("Multiple spaces here");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractTodoItems", () => {
|
||||||
|
it("extracts numbered items after Plan: header", () => {
|
||||||
|
const message = `Here's what we'll do:
|
||||||
|
|
||||||
|
Plan:
|
||||||
|
1. First step here
|
||||||
|
2. Second step here
|
||||||
|
3. Third step here`;
|
||||||
|
|
||||||
|
const items = extractTodoItems(message);
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
expect(items[0].step).toBe(1);
|
||||||
|
expect(items[0].text).toBe("First step here");
|
||||||
|
expect(items[0].completed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles bold Plan header", () => {
|
||||||
|
const message = `**Plan:**
|
||||||
|
1. Do something`;
|
||||||
|
|
||||||
|
const items = extractTodoItems(message);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles parenthesis-style numbering", () => {
|
||||||
|
const message = `Plan:
|
||||||
|
1) First item
|
||||||
|
2) Second item`;
|
||||||
|
|
||||||
|
const items = extractTodoItems(message);
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array without Plan header", () => {
|
||||||
|
const message = `Here are some steps:
|
||||||
|
1. First step
|
||||||
|
2. Second step`;
|
||||||
|
|
||||||
|
const items = extractTodoItems(message);
|
||||||
|
expect(items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out short items", () => {
|
||||||
|
const message = `Plan:
|
||||||
|
1. OK
|
||||||
|
2. This is a proper step`;
|
||||||
|
|
||||||
|
const items = extractTodoItems(message);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].text).toContain("proper");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out code-like items", () => {
|
||||||
|
const message = `Plan:
|
||||||
|
1. \`npm install\`
|
||||||
|
2. Run the build process`;
|
||||||
|
|
||||||
|
const items = extractTodoItems(message);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractDoneSteps", () => {
|
||||||
|
it("extracts single DONE marker", () => {
|
||||||
|
const message = "I've completed the first step [DONE:1]";
|
||||||
|
expect(extractDoneSteps(message)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts multiple DONE markers", () => {
|
||||||
|
const message = "Did steps [DONE:1] and [DONE:2] and [DONE:3]";
|
||||||
|
expect(extractDoneSteps(message)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles case insensitivity", () => {
|
||||||
|
const message = "[done:1] [DONE:2] [Done:3]";
|
||||||
|
expect(extractDoneSteps(message)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array with no markers", () => {
|
||||||
|
const message = "No markers here";
|
||||||
|
expect(extractDoneSteps(message)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed markers", () => {
|
||||||
|
const message = "[DONE:abc] [DONE:] [DONE:1]";
|
||||||
|
expect(extractDoneSteps(message)).toEqual([1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markCompletedSteps", () => {
|
||||||
|
it("marks matching items as completed", () => {
|
||||||
|
const items: TodoItem[] = [
|
||||||
|
{ step: 1, text: "First", completed: false },
|
||||||
|
{ step: 2, text: "Second", completed: false },
|
||||||
|
{ step: 3, text: "Third", completed: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const count = markCompletedSteps("[DONE:1] [DONE:3]", items);
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(items[0].completed).toBe(true);
|
||||||
|
expect(items[1].completed).toBe(false);
|
||||||
|
expect(items[2].completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns count of completed items", () => {
|
||||||
|
const items: TodoItem[] = [{ step: 1, text: "First", completed: false }];
|
||||||
|
|
||||||
|
expect(markCompletedSteps("[DONE:1]", items)).toBe(1);
|
||||||
|
expect(markCompletedSteps("no markers", items)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores markers for non-existent steps", () => {
|
||||||
|
const items: TodoItem[] = [{ step: 1, text: "First", completed: false }];
|
||||||
|
|
||||||
|
const count = markCompletedSteps("[DONE:99]", items);
|
||||||
|
|
||||||
|
expect(count).toBe(1); // Still counts the marker found
|
||||||
|
expect(items[0].completed).toBe(false); // But doesn't mark anything
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't double-complete already completed items", () => {
|
||||||
|
const items: TodoItem[] = [{ step: 1, text: "First", completed: true }];
|
||||||
|
|
||||||
|
markCompletedSteps("[DONE:1]", items);
|
||||||
|
expect(items[0].completed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,75 +1,246 @@
|
||||||
|
import fs from "fs";
|
||||||
import { initTheme, theme } from "../src/modes/interactive/theme/theme.js";
|
import { initTheme, theme } from "../src/modes/interactive/theme/theme.js";
|
||||||
|
|
||||||
// Initialize with dark theme explicitly
|
// --- Color utilities ---
|
||||||
process.env.COLORTERM = "truecolor";
|
|
||||||
initTheme("dark");
|
|
||||||
|
|
||||||
console.log("\n=== Foreground Colors ===\n");
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : [0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
// Core UI colors
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
console.log("accent:", theme.fg("accent", "Sample text"));
|
return (
|
||||||
console.log("border:", theme.fg("border", "Sample text"));
|
"#" +
|
||||||
console.log("borderAccent:", theme.fg("borderAccent", "Sample text"));
|
[r, g, b]
|
||||||
console.log("borderMuted:", theme.fg("borderMuted", "Sample text"));
|
.map((x) =>
|
||||||
console.log("success:", theme.fg("success", "Sample text"));
|
Math.round(Math.max(0, Math.min(255, x)))
|
||||||
console.log("error:", theme.fg("error", "Sample text"));
|
.toString(16)
|
||||||
console.log("warning:", theme.fg("warning", "Sample text"));
|
.padStart(2, "0"),
|
||||||
console.log("muted:", theme.fg("muted", "Sample text"));
|
)
|
||||||
console.log("dim:", theme.fg("dim", "Sample text"));
|
.join("")
|
||||||
console.log("text:", theme.fg("text", "Sample text"));
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("\n=== Message Text Colors ===\n");
|
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||||
console.log("userMessageText:", theme.fg("userMessageText", "Sample text"));
|
r /= 255;
|
||||||
console.log("toolTitle:", theme.fg("toolTitle", "Sample text"));
|
g /= 255;
|
||||||
console.log("toolOutput:", theme.fg("toolOutput", "Sample text"));
|
b /= 255;
|
||||||
|
const max = Math.max(r, g, b),
|
||||||
|
min = Math.min(r, g, b);
|
||||||
|
let h = 0,
|
||||||
|
s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = ((b - r) / d + 2) / 6;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = ((r - g) / d + 4) / 6;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [h, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
console.log("\n=== Markdown Colors ===\n");
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
console.log("mdHeading:", theme.fg("mdHeading", "Sample text"));
|
let r: number, g: number, b: number;
|
||||||
console.log("mdLink:", theme.fg("mdLink", "Sample text"));
|
if (s === 0) {
|
||||||
console.log("mdCode:", theme.fg("mdCode", "Sample text"));
|
r = g = b = l;
|
||||||
console.log("mdCodeBlock:", theme.fg("mdCodeBlock", "Sample text"));
|
} else {
|
||||||
console.log("mdCodeBlockBorder:", theme.fg("mdCodeBlockBorder", "Sample text"));
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
console.log("mdQuote:", theme.fg("mdQuote", "Sample text"));
|
if (t < 0) t += 1;
|
||||||
console.log("mdQuoteBorder:", theme.fg("mdQuoteBorder", "Sample text"));
|
if (t > 1) t -= 1;
|
||||||
console.log("mdHr:", theme.fg("mdHr", "Sample text"));
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
console.log("mdListBullet:", theme.fg("mdListBullet", "Sample text"));
|
if (t < 1 / 2) return q;
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
r = hue2rgb(p, q, h + 1 / 3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1 / 3);
|
||||||
|
}
|
||||||
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||||
|
}
|
||||||
|
|
||||||
console.log("\n=== Tool Diff Colors ===\n");
|
function getLuminance(r: number, g: number, b: number): number {
|
||||||
console.log("toolDiffAdded:", theme.fg("toolDiffAdded", "Sample text"));
|
const lin = (c: number) => {
|
||||||
console.log("toolDiffRemoved:", theme.fg("toolDiffRemoved", "Sample text"));
|
c = c / 255;
|
||||||
console.log("toolDiffContext:", theme.fg("toolDiffContext", "Sample text"));
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("\n=== Thinking Border Colors ===\n");
|
function getContrast(rgb: [number, number, number], bgLum: number): number {
|
||||||
console.log("thinkingOff:", theme.fg("thinkingOff", "Sample text"));
|
const fgLum = getLuminance(...rgb);
|
||||||
console.log("thinkingMinimal:", theme.fg("thinkingMinimal", "Sample text"));
|
const lighter = Math.max(fgLum, bgLum);
|
||||||
console.log("thinkingLow:", theme.fg("thinkingLow", "Sample text"));
|
const darker = Math.min(fgLum, bgLum);
|
||||||
console.log("thinkingMedium:", theme.fg("thinkingMedium", "Sample text"));
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
console.log("thinkingHigh:", theme.fg("thinkingHigh", "Sample text"));
|
}
|
||||||
|
|
||||||
console.log("\n=== Background Colors ===\n");
|
function adjustColorToContrast(hex: string, targetContrast: number, againstWhite: boolean): string {
|
||||||
console.log("userMessageBg:", theme.bg("userMessageBg", " Sample background text "));
|
const rgb = hexToRgb(hex);
|
||||||
console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample background text "));
|
const [h, s] = rgbToHsl(...rgb);
|
||||||
console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample background text "));
|
const bgLum = againstWhite ? 1.0 : 0.0;
|
||||||
console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample background text "));
|
|
||||||
|
|
||||||
console.log("\n=== Raw ANSI Codes ===\n");
|
let lo = againstWhite ? 0 : 0.5;
|
||||||
console.log("thinkingMedium ANSI:", JSON.stringify(theme.getFgAnsi("thinkingMedium")));
|
let hi = againstWhite ? 0.5 : 1.0;
|
||||||
console.log("accent ANSI:", JSON.stringify(theme.getFgAnsi("accent")));
|
|
||||||
console.log("muted ANSI:", JSON.stringify(theme.getFgAnsi("muted")));
|
|
||||||
console.log("dim ANSI:", JSON.stringify(theme.getFgAnsi("dim")));
|
|
||||||
|
|
||||||
console.log("\n=== Direct RGB Test ===\n");
|
for (let i = 0; i < 50; i++) {
|
||||||
console.log("Gray #6c6c6c: \x1b[38;2;108;108;108mSample text\x1b[0m");
|
const mid = (lo + hi) / 2;
|
||||||
console.log("Gray #444444: \x1b[38;2;68;68;68mSample text\x1b[0m");
|
const testRgb = hslToRgb(h, s, mid);
|
||||||
console.log("Gray #303030: \x1b[38;2;48;48;48mSample text\x1b[0m");
|
const contrast = getContrast(testRgb, bgLum);
|
||||||
|
|
||||||
console.log("\n=== Hex Color Test ===\n");
|
if (againstWhite) {
|
||||||
console.log("Direct #00d7ff test: \x1b[38;2;0;215;255mBRIGHT CYAN\x1b[0m");
|
if (contrast < targetContrast) hi = mid;
|
||||||
console.log("Theme cyan (should match above):", theme.fg("accent", "BRIGHT CYAN"));
|
else lo = mid;
|
||||||
|
} else {
|
||||||
|
if (contrast < targetContrast) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("\n=== Environment ===\n");
|
const finalL = againstWhite ? lo : hi;
|
||||||
console.log("TERM:", process.env.TERM);
|
return rgbToHex(...hslToRgb(h, s, finalL));
|
||||||
console.log("COLORTERM:", process.env.COLORTERM);
|
}
|
||||||
console.log("Color mode:", theme.getColorMode());
|
|
||||||
|
|
||||||
console.log("\n");
|
function fgAnsi(hex: string): string {
|
||||||
|
const rgb = hexToRgb(hex);
|
||||||
|
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = "\x1b[0m";
|
||||||
|
|
||||||
|
// --- Commands ---
|
||||||
|
|
||||||
|
function cmdContrast(targetContrast: number): void {
|
||||||
|
const baseColors = {
|
||||||
|
teal: "#5f8787",
|
||||||
|
blue: "#5f87af",
|
||||||
|
green: "#87af87",
|
||||||
|
yellow: "#d7af5f",
|
||||||
|
red: "#af5f5f",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n=== Colors adjusted to ${targetContrast}:1 contrast ===\n`);
|
||||||
|
|
||||||
|
console.log("For LIGHT theme (vs white):");
|
||||||
|
for (const [name, hex] of Object.entries(baseColors)) {
|
||||||
|
const adjusted = adjustColorToContrast(hex, targetContrast, true);
|
||||||
|
const rgb = hexToRgb(adjusted);
|
||||||
|
const contrast = getContrast(rgb, 1.0);
|
||||||
|
console.log(` ${name.padEnd(8)} ${fgAnsi(adjusted)}Sample${reset} ${adjusted} (${contrast.toFixed(2)}:1)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nFor DARK theme (vs black):");
|
||||||
|
for (const [name, hex] of Object.entries(baseColors)) {
|
||||||
|
const adjusted = adjustColorToContrast(hex, targetContrast, false);
|
||||||
|
const rgb = hexToRgb(adjusted);
|
||||||
|
const contrast = getContrast(rgb, 0.0);
|
||||||
|
console.log(` ${name.padEnd(8)} ${fgAnsi(adjusted)}Sample${reset} ${adjusted} (${contrast.toFixed(2)}:1)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdTest(filePath: string): void {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(`File not found: ${filePath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
const vars = data.vars || data;
|
||||||
|
|
||||||
|
console.log(`\n=== Testing ${filePath} ===\n`);
|
||||||
|
|
||||||
|
for (const [name, hex] of Object.entries(vars as Record<string, string>)) {
|
||||||
|
if (!hex.startsWith("#")) continue;
|
||||||
|
const rgb = hexToRgb(hex);
|
||||||
|
const vsWhite = getContrast(rgb, 1.0);
|
||||||
|
const vsBlack = getContrast(rgb, 0.0);
|
||||||
|
const passW = vsWhite >= 4.5 ? "AA" : vsWhite >= 3.0 ? "AA-lg" : "FAIL";
|
||||||
|
const passB = vsBlack >= 4.5 ? "AA" : vsBlack >= 3.0 ? "AA-lg" : "FAIL";
|
||||||
|
console.log(
|
||||||
|
`${name.padEnd(14)} ${fgAnsi(hex)}Sample text${reset} ${hex} white: ${vsWhite.toFixed(2)}:1 ${passW.padEnd(5)} black: ${vsBlack.toFixed(2)}:1 ${passB}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdTheme(themeName: string): void {
|
||||||
|
process.env.COLORTERM = "truecolor";
|
||||||
|
initTheme(themeName);
|
||||||
|
|
||||||
|
const parseAnsiRgb = (ansi: string): [number, number, number] | null => {
|
||||||
|
const match = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
|
||||||
|
return match ? [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContrastVsWhite = (colorName: string): string => {
|
||||||
|
const ansi = theme.getFgAnsi(colorName as Parameters<typeof theme.getFgAnsi>[0]);
|
||||||
|
const rgb = parseAnsiRgb(ansi);
|
||||||
|
if (!rgb) return "(default)";
|
||||||
|
const ratio = getContrast(rgb, 1.0);
|
||||||
|
const pass = ratio >= 4.5 ? "AA" : ratio >= 3.0 ? "AA-lg" : "FAIL";
|
||||||
|
return `${ratio.toFixed(2)}:1 ${pass}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContrastVsBlack = (colorName: string): string => {
|
||||||
|
const ansi = theme.getFgAnsi(colorName as Parameters<typeof theme.getFgAnsi>[0]);
|
||||||
|
const rgb = parseAnsiRgb(ansi);
|
||||||
|
if (!rgb) return "(default)";
|
||||||
|
const ratio = getContrast(rgb, 0.0);
|
||||||
|
const pass = ratio >= 4.5 ? "AA" : ratio >= 3.0 ? "AA-lg" : "FAIL";
|
||||||
|
return `${ratio.toFixed(2)}:1 ${pass}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logColor = (name: string): void => {
|
||||||
|
const sample = theme.fg(name as Parameters<typeof theme.fg>[0], "Sample text");
|
||||||
|
const cw = getContrastVsWhite(name);
|
||||||
|
const cb = getContrastVsBlack(name);
|
||||||
|
console.log(`${name.padEnd(20)} ${sample} white: ${cw.padEnd(12)} black: ${cb}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n=== ${themeName} theme (WCAG AA = 4.5:1) ===`);
|
||||||
|
|
||||||
|
console.log("\n--- Core UI ---");
|
||||||
|
["accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim"].forEach(logColor);
|
||||||
|
|
||||||
|
console.log("\n--- Markdown ---");
|
||||||
|
["mdHeading", "mdLink", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdListBullet"].forEach(logColor);
|
||||||
|
|
||||||
|
console.log("\n--- Diff ---");
|
||||||
|
["toolDiffAdded", "toolDiffRemoved", "toolDiffContext"].forEach(logColor);
|
||||||
|
|
||||||
|
console.log("\n--- Thinking ---");
|
||||||
|
["thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh"].forEach(logColor);
|
||||||
|
|
||||||
|
console.log("\n--- Backgrounds ---");
|
||||||
|
console.log("userMessageBg:", theme.bg("userMessageBg", " Sample "));
|
||||||
|
console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample "));
|
||||||
|
console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample "));
|
||||||
|
console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample "));
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
|
||||||
|
const [cmd, arg] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (cmd === "contrast") {
|
||||||
|
cmdContrast(parseFloat(arg) || 4.5);
|
||||||
|
} else if (cmd === "test") {
|
||||||
|
cmdTest(arg);
|
||||||
|
} else if (cmd === "light" || cmd === "dark") {
|
||||||
|
cmdTheme(cmd);
|
||||||
|
} else {
|
||||||
|
console.log("Usage:");
|
||||||
|
console.log(" npx tsx test-theme-colors.ts light|dark Test built-in theme");
|
||||||
|
console.log(" npx tsx test-theme-colors.ts contrast 4.5 Compute colors at ratio");
|
||||||
|
console.log(" npx tsx test-theme-colors.ts test file.json Test any JSON file");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.5] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.4] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.3] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.2] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.1] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-01-12
|
||||||
|
|
||||||
## [0.43.0] - 2026-01-11
|
## [0.43.0] - 2026-01-11
|
||||||
|
|
||||||
## [0.42.5] - 2026-01-11
|
## [0.42.5] - 2026-01-11
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-mom",
|
"name": "@mariozechner/pi-mom",
|
||||||
"version": "0.43.0",
|
"version": "0.45.5",
|
||||||
"description": "Slack bot that delegates messages to the pi coding agent",
|
"description": "Slack bot that delegates messages to the pi coding agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -20,9 +20,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.16",
|
"@anthropic-ai/sandbox-runtime": "^0.0.16",
|
||||||
"@mariozechner/pi-agent-core": "^0.43.0",
|
"@mariozechner/pi-agent-core": "^0.45.5",
|
||||||
"@mariozechner/pi-ai": "^0.43.0",
|
"@mariozechner/pi-ai": "^0.45.5",
|
||||||
"@mariozechner/pi-coding-agent": "^0.43.0",
|
"@mariozechner/pi-coding-agent": "^0.45.5",
|
||||||
"@sinclair/typebox": "^0.34.0",
|
"@sinclair/typebox": "^0.34.0",
|
||||||
"@slack/socket-mode": "^2.0.0",
|
"@slack/socket-mode": "^2.0.0",
|
||||||
"@slack/web-api": "^7.0.0",
|
"@slack/web-api": "^7.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi",
|
"name": "@mariozechner/pi",
|
||||||
"version": "0.43.0",
|
"version": "0.45.5",
|
||||||
"description": "CLI tool for managing vLLM deployments on GPU pods",
|
"description": "CLI tool for managing vLLM deployments on GPU pods",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-agent-core": "^0.43.0",
|
"@mariozechner/pi-agent-core": "^0.45.5",
|
||||||
"chalk": "^5.5.0"
|
"chalk": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.5] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.4] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.3] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.2] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.1] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-01-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `SettingsListOptions` with `enableSearch` for fuzzy filtering in `SettingsList` ([#643](https://github.com/badlogic/pi-mono/pull/643) by [@ninlds](https://github.com/ninlds))
|
- `SettingsListOptions` with `enableSearch` for fuzzy filtering in `SettingsList` ([#643](https://github.com/badlogic/pi-mono/pull/643) by [@ninlds](https://github.com/ninlds))
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,17 @@ Overlays render components on top of existing content without replacing it. Usef
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Show overlay with default options (centered, max 80 cols)
|
// Show overlay with default options (centered, max 80 cols)
|
||||||
tui.showOverlay(component);
|
const handle = tui.showOverlay(component);
|
||||||
|
|
||||||
// Show overlay with custom positioning and sizing
|
// Show overlay with custom positioning and sizing
|
||||||
tui.showOverlay(component, {
|
// Values can be numbers (absolute) or percentage strings (e.g., "50%")
|
||||||
|
const handle = tui.showOverlay(component, {
|
||||||
// Sizing
|
// Sizing
|
||||||
width: 60, // Fixed width in columns
|
width: 60, // Fixed width in columns
|
||||||
widthPercent: 80, // Width as percentage of terminal (0-100)
|
width: "80%", // Width as percentage of terminal
|
||||||
minWidth: 40, // Minimum width floor
|
minWidth: 40, // Minimum width floor
|
||||||
maxHeight: 20, // Maximum height in rows
|
maxHeight: 20, // Maximum height in rows
|
||||||
maxHeightPercent: 50, // Maximum height as percentage of terminal
|
maxHeight: "50%", // Maximum height as percentage of terminal
|
||||||
|
|
||||||
// Anchor-based positioning (default: 'center')
|
// Anchor-based positioning (default: 'center')
|
||||||
anchor: 'bottom-right', // Position relative to anchor point
|
anchor: 'bottom-right', // Position relative to anchor point
|
||||||
|
|
@ -79,8 +80,8 @@ tui.showOverlay(component, {
|
||||||
offsetY: -1, // Vertical offset from anchor
|
offsetY: -1, // Vertical offset from anchor
|
||||||
|
|
||||||
// Percentage-based positioning (alternative to anchor)
|
// Percentage-based positioning (alternative to anchor)
|
||||||
rowPercent: 25, // Vertical position (0=top, 100=bottom)
|
row: "25%", // Vertical position (0%=top, 100%=bottom)
|
||||||
colPercent: 50, // Horizontal position (0=left, 100=right)
|
col: "50%", // Horizontal position (0%=left, 100%=right)
|
||||||
|
|
||||||
// Absolute positioning (overrides anchor/percent)
|
// Absolute positioning (overrides anchor/percent)
|
||||||
row: 5, // Exact row position
|
row: 5, // Exact row position
|
||||||
|
|
@ -88,23 +89,32 @@ tui.showOverlay(component, {
|
||||||
|
|
||||||
// Margin from terminal edges
|
// Margin from terminal edges
|
||||||
margin: 2, // All sides
|
margin: 2, // All sides
|
||||||
margin: { top: 1, right: 2, bottom: 1, left: 2 }
|
margin: { top: 1, right: 2, bottom: 1, left: 2 },
|
||||||
|
|
||||||
|
// Responsive visibility
|
||||||
|
visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OverlayHandle methods
|
||||||
|
handle.hide(); // Permanently remove the overlay
|
||||||
|
handle.setHidden(true); // Temporarily hide (can show again)
|
||||||
|
handle.setHidden(false); // Show again after hiding
|
||||||
|
handle.isHidden(); // Check if temporarily hidden
|
||||||
|
|
||||||
// Hide topmost overlay
|
// Hide topmost overlay
|
||||||
tui.hideOverlay();
|
tui.hideOverlay();
|
||||||
|
|
||||||
// Check if any overlay is active
|
// Check if any visible overlay is active
|
||||||
tui.hasOverlay();
|
tui.hasOverlay();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Anchor values**: `'center'`, `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'`, `'top-center'`, `'bottom-center'`, `'left-center'`, `'right-center'`
|
**Anchor values**: `'center'`, `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'`, `'top-center'`, `'bottom-center'`, `'left-center'`, `'right-center'`
|
||||||
|
|
||||||
**Resolution order**:
|
**Resolution order**:
|
||||||
1. `width` takes precedence over `widthPercent`
|
1. `minWidth` is applied as a floor after width calculation
|
||||||
2. `minWidth` is applied as a floor after width calculation
|
2. For position: absolute `row`/`col` > percentage `row`/`col` > `anchor`
|
||||||
3. For position: `row`/`col` > `rowPercent`/`colPercent` > `anchor`
|
3. `margin` clamps final position to stay within terminal bounds
|
||||||
4. `margin` clamps final position to stay within terminal bounds
|
4. `visible` callback controls whether overlay renders (called each frame)
|
||||||
|
|
||||||
### Component Interface
|
### Component Interface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-tui",
|
"name": "@mariozechner/pi-tui",
|
||||||
"version": "0.43.0",
|
"version": "0.45.5",
|
||||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.5] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.4] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.3] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.2] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.1] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-01-13
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-01-12
|
||||||
|
|
||||||
## [0.43.0] - 2026-01-11
|
## [0.43.0] - 2026-01-11
|
||||||
|
|
||||||
## [0.42.5] - 2026-01-11
|
## [0.42.5] - 2026-01-11
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pi-web-ui-example",
|
"name": "pi-web-ui-example",
|
||||||
"version": "1.31.0",
|
"version": "1.33.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-web-ui",
|
"name": "@mariozechner/pi-web-ui",
|
||||||
"version": "0.43.0",
|
"version": "0.45.5",
|
||||||
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
|
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lmstudio/sdk": "^1.5.0",
|
"@lmstudio/sdk": "^1.5.0",
|
||||||
"@mariozechner/pi-ai": "^0.43.0",
|
"@mariozechner/pi-ai": "^0.45.5",
|
||||||
"@mariozechner/pi-tui": "^0.43.0",
|
"@mariozechner/pi-tui": "^0.45.5",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide": "^0.544.0",
|
"lucide": "^0.544.0",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const TEST_MODELS: Record<string, string> = {
|
||||||
google: "gemini-2.5-flash",
|
google: "gemini-2.5-flash",
|
||||||
groq: "openai/gpt-oss-20b",
|
groq: "openai/gpt-oss-20b",
|
||||||
openrouter: "z-ai/glm-4.6",
|
openrouter: "z-ai/glm-4.6",
|
||||||
|
"vercel-ai-gateway": "anthropic/claude-opus-4.5",
|
||||||
cerebras: "gpt-oss-120b",
|
cerebras: "gpt-oss-120b",
|
||||||
xai: "grok-4-fast-non-reasoning",
|
xai: "grok-4-fast-non-reasoning",
|
||||||
zai: "glm-4.5-air",
|
zai: "glm-4.5-air",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue