mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
feat(coding-agent): ResourceLoader, package management, and /reload command (#645)
- Add ResourceLoader interface and DefaultResourceLoader implementation - Add PackageManager for npm/git extension sources with install/remove/update - Add session.reload() and session.bindExtensions() APIs - Add /reload command in interactive mode - Add CLI flags: --skill, --theme, --prompt-template, --no-themes, --no-prompt-templates - Add pi install/remove/update commands for extension management - Refactor settings.json to use arrays for skills, prompts, themes - Remove legacy SkillsSettings source flags and filters - Update SDK examples and documentation for ResourceLoader pattern - Add theme registration and loadThemeFromPath for dynamic themes - Add getShellEnv to include bin dir in PATH for bash commands
This commit is contained in:
parent
866d21c252
commit
b846a4bfcf
51 changed files with 2724 additions and 1852 deletions
|
|
@ -1,64 +0,0 @@
|
||||||
---
|
|
||||||
name: pi-tmux-test
|
|
||||||
description: Test pi's interactive mode via tmux. Use when you need to test TUI behavior, extensions, or interactive features programmatically.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Testing Pi Interactively via tmux
|
|
||||||
|
|
||||||
Use tmux to test pi's interactive mode. This allows sending input and capturing output programmatically.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Kill any existing test session and create a new one
|
|
||||||
tmux kill-session -t pi-test 2>/dev/null
|
|
||||||
tmux new-session -d -s pi-test -c /Users/badlogic/workspaces/pi-mono -x 100 -y 30
|
|
||||||
|
|
||||||
# Start pi using the test script (runs via tsx, picks up source changes)
|
|
||||||
# Always use --no-session to avoid creating session files during testing
|
|
||||||
tmux send-keys -t pi-test "./pi-test.sh --no-session" Enter
|
|
||||||
|
|
||||||
# Wait for startup
|
|
||||||
sleep 4
|
|
||||||
tmux capture-pane -t pi-test -p
|
|
||||||
```
|
|
||||||
|
|
||||||
## Interaction
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Send input
|
|
||||||
tmux send-keys -t pi-test "your message here" Enter
|
|
||||||
|
|
||||||
# Wait and capture output
|
|
||||||
sleep 5
|
|
||||||
tmux capture-pane -t pi-test -p
|
|
||||||
|
|
||||||
# Send special keys
|
|
||||||
tmux send-keys -t pi-test Escape
|
|
||||||
tmux send-keys -t pi-test C-c # Ctrl+C
|
|
||||||
tmux send-keys -t pi-test C-d # Ctrl+D
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux kill-session -t pi-test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Extensions
|
|
||||||
|
|
||||||
Write extensions to /tmp and load with `-e`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat > /tmp/test-extension.ts << 'EOF'
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// extension code
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Run pi with the extension
|
|
||||||
tmux send-keys -t pi-test "./pi-test.sh --no-session -e /tmp/test-extension.ts" Enter
|
|
||||||
```
|
|
||||||
|
|
||||||
Clean up after testing: `rm /tmp/test-extension.ts`
|
|
||||||
|
|
@ -2,6 +2,22 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Resource loading now uses `ResourceLoader` only and settings.json uses arrays for extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
|
||||||
|
- Removed `discoverAuthStorage` and `discoverModels` from the SDK. `AuthStorage` and `ModelRegistry` now default to `~/.pi/agent` paths unless you pass an `agentDir` ([#645](https://github.com/badlogic/pi-mono/issues/645))
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `markdown.codeBlockIndent` setting to customize code block indentation in rendered output
|
||||||
|
- Extension package management with `pi install`, `pi remove`, and `pi update` commands ([#645](https://github.com/badlogic/pi-mono/issues/645))
|
||||||
|
- `/reload` command to reload extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
|
||||||
|
- CLI flags for `--skill`, `--prompt-template`, `--theme`, `--no-prompt-templates`, and `--no-themes` ([#645](https://github.com/badlogic/pi-mono/issues/645))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Skill, prompt template, and theme discovery now use settings and CLI path arrays instead of legacy filters ([#645](https://github.com/badlogic/pi-mono/issues/645))
|
||||||
|
|
||||||
## [0.49.3] - 2026-01-22
|
## [0.49.3] - 2026-01-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
|
||||||
| `/new` | Start a new session |
|
| `/new` | Start a new session |
|
||||||
| `/copy` | Copy last agent message to clipboard |
|
| `/copy` | Copy last agent message to clipboard |
|
||||||
| `/compact [instructions]` | Manually compact conversation context |
|
| `/compact [instructions]` | Manually compact conversation context |
|
||||||
|
| `/reload` | Reload extensions, skills, prompts, and themes |
|
||||||
|
|
||||||
### Editor Features
|
### Editor Features
|
||||||
|
|
||||||
|
|
@ -791,9 +792,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||||
"reserveTokens": 16384,
|
"reserveTokens": 16384,
|
||||||
"keepRecentTokens": 20000
|
"keepRecentTokens": 20000
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": ["/path/to/skills"],
|
||||||
"enabled": true
|
"prompts": ["/path/to/prompts"],
|
||||||
},
|
"themes": ["/path/to/themes"],
|
||||||
|
"enableSkillCommands": true,
|
||||||
"retry": {
|
"retry": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"maxRetries": 3,
|
"maxRetries": 3,
|
||||||
|
|
@ -827,7 +829,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||||
| `compaction.enabled` | Enable auto-compaction | `true` |
|
| `compaction.enabled` | Enable auto-compaction | `true` |
|
||||||
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
|
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
|
||||||
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
|
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
|
||||||
| `skills.enabled` | Enable skills discovery | `true` |
|
| `skills` | Additional skill file or directory paths | `[]` |
|
||||||
|
| `prompts` | Additional prompt template paths | `[]` |
|
||||||
|
| `themes` | Additional theme file or directory paths | `[]` |
|
||||||
|
| `enableSkillCommands` | Register skills as `/skill:name` commands | `true` |
|
||||||
| `retry.enabled` | Auto-retry on transient errors | `true` |
|
| `retry.enabled` | Auto-retry on transient errors | `true` |
|
||||||
| `retry.maxRetries` | Maximum retry attempts | `3` |
|
| `retry.maxRetries` | Maximum retry attempts | `3` |
|
||||||
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
|
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
|
||||||
|
|
@ -835,10 +840,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||||
| `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` |
|
| `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` |
|
||||||
| `images.blockImages` | Prevent images from being sent to LLM providers | `false` |
|
| `images.blockImages` | Prevent images from being sent to LLM providers | `false` |
|
||||||
| `showHardwareCursor` | Show terminal cursor while still positioning it for IME support | `false` |
|
| `showHardwareCursor` | Show terminal cursor while still positioning it for IME support | `false` |
|
||||||
| `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `branch` | `tree` |
|
| `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `fork` | `tree` |
|
||||||
| `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` |
|
| `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` |
|
||||||
| `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` |
|
| `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` |
|
||||||
| `extensions` | Additional extension file paths | `[]` |
|
| `extensions` | Extension sources or file paths (npm:, git:, local) | `[]` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -852,6 +857,8 @@ Select theme via `/settings` or set in `~/.pi/agent/settings.json`.
|
||||||
|
|
||||||
**Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload.
|
**Custom themes:** Create `~/.pi/agent/themes/*.json`. Custom themes support live reload.
|
||||||
|
|
||||||
|
Add additional theme paths via `settings.json` `themes` array or `--theme <path>`. Disable automatic theme discovery with `--no-themes`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.pi/agent/themes
|
mkdir -p ~/.pi/agent/themes
|
||||||
cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json
|
cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json
|
||||||
|
|
@ -870,6 +877,8 @@ Define reusable prompts as Markdown files:
|
||||||
**Locations:**
|
**Locations:**
|
||||||
- Global: `~/.pi/agent/prompts/*.md`
|
- Global: `~/.pi/agent/prompts/*.md`
|
||||||
- Project: `.pi/prompts/*.md`
|
- Project: `.pi/prompts/*.md`
|
||||||
|
- Additional paths from settings.json `prompts` array
|
||||||
|
- CLI `--prompt-template` paths
|
||||||
|
|
||||||
**Format:**
|
**Format:**
|
||||||
|
|
||||||
|
|
@ -900,6 +909,8 @@ Usage: `/component Button "onClick handler" "disabled support"`
|
||||||
- `${@:N}` = arguments from the Nth position onwards (1-indexed)
|
- `${@:N}` = arguments from the Nth position onwards (1-indexed)
|
||||||
- `${@:N:L}` = `L` arguments starting from the Nth position
|
- `${@:N:L}` = `L` arguments starting from the Nth position
|
||||||
|
|
||||||
|
Disable prompt template discovery with `--no-prompt-templates`.
|
||||||
|
|
||||||
**Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md` → `/component (project:frontend)`
|
**Namespacing:** Subdirectories create prefixes. `.pi/prompts/frontend/component.md` → `/component (project:frontend)`
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -918,10 +929,12 @@ A skill provides specialized workflows, setup instructions, helper scripts, and
|
||||||
- YouTube transcript extraction
|
- YouTube transcript extraction
|
||||||
|
|
||||||
**Skill locations:**
|
**Skill locations:**
|
||||||
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive)
|
- Global: `~/.pi/agent/skills/`
|
||||||
- Pi project: `.pi/skills/**/SKILL.md` (recursive)
|
- Project: `.pi/skills/`
|
||||||
- Claude Code: `~/.claude/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`
|
- Additional paths from settings.json `skills` array
|
||||||
- Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive)
|
- CLI `--skill` paths (additive even with `--no-skills`)
|
||||||
|
|
||||||
|
Use `enableSkillCommands` in settings to toggle `/skill:name` commands.
|
||||||
|
|
||||||
**Format:**
|
**Format:**
|
||||||
|
|
||||||
|
|
@ -948,7 +961,7 @@ cd /path/to/brave-search && npm install
|
||||||
- `name`: Required. Must match parent directory name. Lowercase, hyphens, max 64 chars.
|
- `name`: Required. Must match parent directory name. Lowercase, hyphens, max 64 chars.
|
||||||
- `description`: Required. Max 1024 chars. Determines when the skill is loaded.
|
- `description`: Required. Max 1024 chars. Determines when the skill is loaded.
|
||||||
|
|
||||||
**Disable skills:** `pi --no-skills` or set `skills.enabled: false` in settings.
|
**Disable skills:** `pi --no-skills` (automatic discovery off, explicit `--skill` paths still load).
|
||||||
|
|
||||||
> See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories. pi can help you create new skills.
|
> See [docs/skills.md](docs/skills.md) for details, examples, and links to skill repositories. pi can help you create new skills.
|
||||||
|
|
||||||
|
|
@ -967,7 +980,18 @@ Extensions are TypeScript modules that extend pi's behavior.
|
||||||
**Locations:**
|
**Locations:**
|
||||||
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts`
|
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/extensions/*/index.ts`
|
||||||
- Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts`
|
- Project: `.pi/extensions/*.ts` or `.pi/extensions/*/index.ts`
|
||||||
- CLI: `--extension <path>` or `-e <path>`
|
- Settings: `extensions` array supports file paths and `npm:` or `git:` sources
|
||||||
|
- CLI: `--extension <path>` or `-e <path>` (temporary for this run)
|
||||||
|
|
||||||
|
Install and remove extension sources with the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:@foo/bar@1.0.0
|
||||||
|
pi install git:github.com/user/repo@v1
|
||||||
|
pi remove npm:@foo/bar
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `-l` to install into project settings (`.pi/settings.json`).
|
||||||
|
|
||||||
**Dependencies:** Extensions can have their own dependencies. Place a `package.json` next to the extension (or in a parent directory), run `npm install`, and imports are resolved via [jiti](https://github.com/unjs/jiti). See [examples/extensions/with-deps/](examples/extensions/with-deps/).
|
**Dependencies:** Extensions can have their own dependencies. Place a `package.json` next to the extension (or in a parent directory), run `npm install`, and imports are resolved via [jiti](https://github.com/unjs/jiti). See [examples/extensions/with-deps/](examples/extensions/with-deps/).
|
||||||
|
|
||||||
|
|
@ -1214,6 +1238,14 @@ await ctx.ui.custom((tui, theme, done) => ({
|
||||||
pi [options] [@files...] [messages...]
|
pi [options] [@files...] [messages...]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `install <source> [-l]` | Install extension source and add to settings (`-l` for project) |
|
||||||
|
| `remove <source> [-l]` | Remove extension source from settings |
|
||||||
|
| `update [source]` | Update installed extensions (skips pinned sources) |
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|
|
@ -1236,8 +1268,12 @@ pi [options] [@files...] [messages...]
|
||||||
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
|
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
|
||||||
| `--extension <path>`, `-e` | Load an extension file (can be used multiple times) |
|
| `--extension <path>`, `-e` | Load an extension file (can be used multiple times) |
|
||||||
| `--no-extensions` | Disable extension discovery (explicit `-e` paths still work) |
|
| `--no-extensions` | Disable extension discovery (explicit `-e` paths still work) |
|
||||||
|
| `--skill <path>` | Load a skill file or directory (can be used multiple times) |
|
||||||
|
| `--prompt-template <path>` | Load a prompt template file or directory (can be used multiple times) |
|
||||||
|
| `--theme <path>` | Load a theme file or directory (can be used multiple times) |
|
||||||
| `--no-skills` | Disable skills discovery and loading |
|
| `--no-skills` | Disable skills discovery and loading |
|
||||||
| `--skills <patterns>` | Comma-separated glob patterns to filter skills (e.g., `git-*,docker`) |
|
| `--no-prompt-templates` | Disable prompt template discovery and loading |
|
||||||
|
| `--no-themes` | Disable theme discovery and loading |
|
||||||
| `--export <file> [output]` | Export session to HTML |
|
| `--export <file> [output]` | Export session to HTML |
|
||||||
| `--help`, `-h` | Show help |
|
| `--help`, `-h` | Show help |
|
||||||
| `--version`, `-v` | Show version |
|
| `--version`, `-v` | Show version |
|
||||||
|
|
@ -1339,10 +1375,10 @@ For adding new tools, see [Extensions](#extensions) in the Customization section
|
||||||
For embedding pi in Node.js/TypeScript applications, use the SDK:
|
For embedding pi in Node.js/TypeScript applications, use the SDK:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
|
|
@ -1361,15 +1397,13 @@ await session.prompt("What files are in the current directory?");
|
||||||
|
|
||||||
The SDK provides full control over:
|
The SDK provides full control over:
|
||||||
- Model selection and thinking level
|
- Model selection and thinking level
|
||||||
- System prompt (replace or modify)
|
|
||||||
- Tools (built-in subsets, custom tools)
|
- Tools (built-in subsets, custom tools)
|
||||||
- Extensions (discovered or via paths)
|
- Resources via `ResourceLoader` (extensions, skills, prompts, themes, context files, system prompt)
|
||||||
- Skills, context files, prompt templates
|
|
||||||
- Session persistence (`SessionManager`)
|
- Session persistence (`SessionManager`)
|
||||||
- Settings (`SettingsManager`)
|
- Settings (`SettingsManager`)
|
||||||
- API key resolution and OAuth
|
- API key resolution and OAuth
|
||||||
|
|
||||||
**Philosophy:** "Omit to discover, provide to override." Omit an option and pi discovers from standard locations. Provide an option and your value is used.
|
**Philosophy:** "Omit to discover, provide to override." Resource discovery is handled by `ResourceLoader`, while model and tool options still follow the omit or override pattern.
|
||||||
|
|
||||||
> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control.
|
> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,22 @@ Additional paths via `settings.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"extensions": ["/path/to/extension.ts", "/path/to/extension/dir"]
|
"extensions": [
|
||||||
|
"npm:@foo/bar@1.0.0",
|
||||||
|
"git:github.com/user/repo@v1",
|
||||||
|
"/path/to/extension.ts",
|
||||||
|
"/path/to/extension/dir"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `pi install` and `pi remove` to manage extension sources in settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:@foo/bar@1.0.0
|
||||||
|
pi remove npm:@foo/bar
|
||||||
|
```
|
||||||
|
|
||||||
**Discovery rules:**
|
**Discovery rules:**
|
||||||
|
|
||||||
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
|
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
|
||||||
|
|
@ -146,13 +158,17 @@ Additional paths via `settings.json`:
|
||||||
"zod": "^3.0.0"
|
"zod": "^3.0.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"]
|
"extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"],
|
||||||
|
"skills": ["./skills/"],
|
||||||
|
"prompts": ["./prompts/"],
|
||||||
|
"themes": ["./themes/dark.json"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `package.json` approach enables:
|
The `package.json` approach enables:
|
||||||
- Multiple extensions from one package
|
- Multiple extensions from one package
|
||||||
|
- Skills, prompts, and themes declared alongside extensions
|
||||||
- Third-party npm dependencies (resolved via jiti)
|
- Third-party npm dependencies (resolved via jiti)
|
||||||
- Nested source structure (no depth limit within the package)
|
- Nested source structure (no depth limit within the package)
|
||||||
- Deployment to and installation from npm
|
- Deployment to and installation from npm
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Set up credential storage and model registry
|
// Set up credential storage and model registry
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
|
|
@ -51,20 +51,17 @@ The SDK is included in the main package. No separate installation needed.
|
||||||
|
|
||||||
The main factory function. Creates an `AgentSession` with configurable options.
|
The main factory function. Creates an `AgentSession` with configurable options.
|
||||||
|
|
||||||
**Philosophy:** "Omit to discover, provide to override."
|
`createAgentSession()` uses a `ResourceLoader` to supply extensions, skills, prompt templates, themes, and context files. If you do not provide one, it uses `DefaultResourceLoader` with standard discovery.
|
||||||
- Omit an option → pi discovers/loads from standard locations
|
|
||||||
- Provide an option → your value is used, discovery skipped for that option
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Minimal: all defaults (discovers everything from cwd and ~/.pi/agent)
|
// Minimal: defaults with DefaultResourceLoader
|
||||||
const { session } = await createAgentSession();
|
const { session } = await createAgentSession();
|
||||||
|
|
||||||
// Custom: override specific options
|
// Custom: override specific options
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
model: myModel,
|
model: myModel,
|
||||||
systemPrompt: "You are helpful.",
|
|
||||||
tools: [readTool, bashTool],
|
tools: [readTool, bashTool],
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
@ -251,7 +248,7 @@ session.subscribe((event) => {
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
// Working directory for project-local discovery
|
// Working directory for DefaultResourceLoader discovery
|
||||||
cwd: process.cwd(), // default
|
cwd: process.cwd(), // default
|
||||||
|
|
||||||
// Global config directory
|
// Global config directory
|
||||||
|
|
@ -259,14 +256,14 @@ const { session } = await createAgentSession({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
`cwd` is used for:
|
`cwd` is used by `DefaultResourceLoader` for:
|
||||||
- Project extensions (`.pi/extensions/`)
|
- Project extensions (`.pi/extensions/`)
|
||||||
- Project skills (`.pi/skills/`)
|
- Project skills (`.pi/skills/`)
|
||||||
- Project prompts (`.pi/prompts/`)
|
- Project prompts (`.pi/prompts/`)
|
||||||
- Context files (`AGENTS.md` walking up from cwd)
|
- Context files (`AGENTS.md` walking up from cwd)
|
||||||
- Session directory naming
|
- Session directory naming
|
||||||
|
|
||||||
`agentDir` is used for:
|
`agentDir` is used by `DefaultResourceLoader` for:
|
||||||
- Global extensions (`extensions/`)
|
- Global extensions (`extensions/`)
|
||||||
- Global skills (`skills/`)
|
- Global skills (`skills/`)
|
||||||
- Global prompts (`prompts/`)
|
- Global prompts (`prompts/`)
|
||||||
|
|
@ -276,14 +273,16 @@ const { session } = await createAgentSession({
|
||||||
- Credentials (`auth.json`)
|
- Credentials (`auth.json`)
|
||||||
- Sessions (`sessions/`)
|
- Sessions (`sessions/`)
|
||||||
|
|
||||||
|
When you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control resource discovery. They still influence session naming and tool path resolution.
|
||||||
|
|
||||||
### Model
|
### Model
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
// Find specific built-in model (doesn't check if API key exists)
|
// Find specific built-in model (doesn't check if API key exists)
|
||||||
const opus = getModel("anthropic", "claude-opus-4-5");
|
const opus = getModel("anthropic", "claude-opus-4-5");
|
||||||
|
|
@ -327,11 +326,11 @@ API key resolution priority (handled by AuthStorage):
|
||||||
4. Fallback resolver (for custom provider keys from `models.json`)
|
4. Fallback resolver (for custom provider keys from `models.json`)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { AuthStorage, ModelRegistry, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
|
// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
|
|
@ -360,16 +359,17 @@ const simpleRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
### System Prompt
|
### System Prompt
|
||||||
|
|
||||||
|
Use a `ResourceLoader` to override the system prompt:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const { session } = await createAgentSession({
|
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||||
// Replace entirely
|
|
||||||
systemPrompt: "You are a helpful assistant.",
|
const loader = new DefaultResourceLoader({
|
||||||
|
systemPromptOverride: () => "You are a helpful assistant.",
|
||||||
// Or modify default (receives default, returns modified)
|
|
||||||
systemPrompt: (defaultPrompt) => {
|
|
||||||
return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)
|
> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)
|
||||||
|
|
@ -462,61 +462,45 @@ const { session } = await createAgentSession({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions discovered from `~/.pi/agent/extensions/` and `.pi/extensions/` can also register tools via `pi.registerTool()`.
|
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.
|
||||||
|
|
||||||
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
|
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
|
||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
By default, extensions are discovered from multiple locations:
|
Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources.
|
||||||
- `~/.pi/agent/extensions/` (global)
|
|
||||||
- `.pi/extensions/` (project-local)
|
|
||||||
- Paths listed in `settings.json` `"extensions"` array
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession, type ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Inline extension factory
|
const loader = new DefaultResourceLoader({
|
||||||
const myExtension: ExtensionFactory = (pi) => {
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
|
||||||
console.log(`Tool: ${event.toolName}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("hello", {
|
|
||||||
description: "Say hello",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
ctx.ui.notify("Hello!", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pass inline extensions (skips file discovery)
|
|
||||||
const { session } = await createAgentSession({
|
|
||||||
extensions: [myExtension],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add paths to load (merged with discovery)
|
|
||||||
const { session } = await createAgentSession({
|
|
||||||
additionalExtensionPaths: ["/path/to/my-extension.ts"],
|
additionalExtensionPaths: ["/path/to/my-extension.ts"],
|
||||||
|
extensionFactories: [
|
||||||
|
(pi) => {
|
||||||
|
pi.on("agent_start", () => {
|
||||||
|
console.log("[Inline Extension] Agent starting");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
// Disable extension discovery entirely
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
const { session } = await createAgentSession({
|
|
||||||
extensions: [],
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Extensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API.
|
Extensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API.
|
||||||
|
|
||||||
**Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `createAgentSession()` if you need to emit/listen from outside:
|
**Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `DefaultResourceLoader` if you need to emit or listen from outside:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession, createEventBus } from "@mariozechner/pi-coding-agent";
|
import { createEventBus, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
const eventBus = createEventBus();
|
const eventBus = createEventBus();
|
||||||
const { session } = await createAgentSession({ eventBus });
|
const loader = new DefaultResourceLoader({
|
||||||
|
eventBus,
|
||||||
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
// Listen for events from extensions
|
|
||||||
eventBus.on("my-extension:status", (data) => console.log(data));
|
eventBus.on("my-extension:status", (data) => console.log(data));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -525,14 +509,13 @@ eventBus.on("my-extension:status", (data) => console.log(data));
|
||||||
### Skills
|
### Skills
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession, discoverSkills, type Skill } from "@mariozechner/pi-coding-agent";
|
import {
|
||||||
|
createAgentSession,
|
||||||
|
DefaultResourceLoader,
|
||||||
|
type Skill,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Discover and filter
|
const customSkill: Skill = {
|
||||||
const { skills: allSkills, warnings } = discoverSkills();
|
|
||||||
const filtered = allSkills.filter(s => s.name.includes("search"));
|
|
||||||
|
|
||||||
// Custom skill
|
|
||||||
const mySkill: Skill = {
|
|
||||||
name: "my-skill",
|
name: "my-skill",
|
||||||
description: "Custom instructions",
|
description: "Custom instructions",
|
||||||
filePath: "/path/to/SKILL.md",
|
filePath: "/path/to/SKILL.md",
|
||||||
|
|
@ -540,20 +523,15 @@ const mySkill: Skill = {
|
||||||
source: "custom",
|
source: "custom",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const loader = new DefaultResourceLoader({
|
||||||
skills: [...filtered, mySkill],
|
skillsOverride: (current) => ({
|
||||||
|
skills: [...current.skills, customSkill],
|
||||||
|
diagnostics: current.diagnostics,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
// Disable skills
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
const { session } = await createAgentSession({
|
|
||||||
skills: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Discovery with settings filter
|
|
||||||
const { skills } = discoverSkills(process.cwd(), undefined, {
|
|
||||||
ignoredSkills: ["browser-*"], // glob patterns to exclude
|
|
||||||
includeSkills: ["search-*"], // glob patterns to include (empty = all)
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
|
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
|
||||||
|
|
@ -561,26 +539,19 @@ const { skills } = discoverSkills(process.cwd(), undefined, {
|
||||||
### Context Files
|
### Context Files
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession, discoverContextFiles } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Discover AGENTS.md files
|
const loader = new DefaultResourceLoader({
|
||||||
const discovered = discoverContextFiles();
|
agentsFilesOverride: (current) => ({
|
||||||
|
agentsFiles: [
|
||||||
// Add custom context
|
...current.agentsFiles,
|
||||||
const { session } = await createAgentSession({
|
{ path: "/virtual/AGENTS.md", content: "# Guidelines\n\n- Be concise" },
|
||||||
contextFiles: [
|
],
|
||||||
...discovered,
|
}),
|
||||||
{
|
|
||||||
path: "/virtual/AGENTS.md",
|
|
||||||
content: "# Guidelines\n\n- Be concise\n- Use TypeScript",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
// Disable context files
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
const { session } = await createAgentSession({
|
|
||||||
contextFiles: [],
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
|
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
|
||||||
|
|
@ -588,9 +559,11 @@ const { session } = await createAgentSession({
|
||||||
### Slash Commands
|
### Slash Commands
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgentSession, discoverPromptTemplates, type PromptTemplate } from "@mariozechner/pi-coding-agent";
|
import {
|
||||||
|
createAgentSession,
|
||||||
const discovered = discoverPromptTemplates();
|
DefaultResourceLoader,
|
||||||
|
type PromptTemplate,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
const customCommand: PromptTemplate = {
|
const customCommand: PromptTemplate = {
|
||||||
name: "deploy",
|
name: "deploy",
|
||||||
|
|
@ -599,9 +572,15 @@ const customCommand: PromptTemplate = {
|
||||||
content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
|
content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const loader = new DefaultResourceLoader({
|
||||||
promptTemplates: [...discovered, customCommand],
|
promptsOverride: (current) => ({
|
||||||
|
prompts: [...current.prompts, customCommand],
|
||||||
|
diagnostics: current.diagnostics,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)
|
> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)
|
||||||
|
|
@ -719,62 +698,31 @@ Settings load from two locations and merge:
|
||||||
1. Global: `~/.pi/agent/settings.json`
|
1. Global: `~/.pi/agent/settings.json`
|
||||||
2. Project: `<cwd>/.pi/settings.json`
|
2. Project: `<cwd>/.pi/settings.json`
|
||||||
|
|
||||||
Project overrides global. Nested objects merge keys. Setters only modify global (project is read-only for version control).
|
Project overrides global. Nested objects merge keys. Setters modify global settings by default.
|
||||||
|
|
||||||
> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)
|
> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)
|
||||||
|
|
||||||
## Discovery Functions
|
## ResourceLoader
|
||||||
|
|
||||||
All discovery functions accept optional `cwd` and `agentDir` parameters.
|
Use `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and context files.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
DefaultResourceLoader,
|
||||||
ModelRegistry,
|
getAgentDir,
|
||||||
discoverAuthStorage,
|
|
||||||
discoverModels,
|
|
||||||
discoverSkills,
|
|
||||||
discoverExtensions,
|
|
||||||
discoverContextFiles,
|
|
||||||
discoverPromptTemplates,
|
|
||||||
loadSettings,
|
|
||||||
buildSystemPrompt,
|
|
||||||
createEventBus,
|
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Auth and Models
|
const loader = new DefaultResourceLoader({
|
||||||
const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
|
|
||||||
const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
|
|
||||||
const allModels = modelRegistry.getAll(); // All models (built-in + custom)
|
|
||||||
const available = await modelRegistry.getAvailable(); // Only models with API keys
|
|
||||||
const model = modelRegistry.find("provider", "id"); // Find specific model
|
|
||||||
const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
|
|
||||||
|
|
||||||
// Skills
|
|
||||||
const { skills, warnings } = discoverSkills(cwd, agentDir, skillsSettings);
|
|
||||||
|
|
||||||
// Extensions (async - loads TypeScript)
|
|
||||||
// Pass eventBus to share pi.events across extensions
|
|
||||||
const eventBus = createEventBus();
|
|
||||||
const { extensions, errors } = await discoverExtensions(eventBus, cwd, agentDir);
|
|
||||||
|
|
||||||
// Context files
|
|
||||||
const contextFiles = discoverContextFiles(cwd, agentDir);
|
|
||||||
|
|
||||||
// Prompt templates
|
|
||||||
const templates = discoverPromptTemplates(cwd, agentDir);
|
|
||||||
|
|
||||||
// Settings (global + project merged)
|
|
||||||
const settings = loadSettings(cwd, agentDir);
|
|
||||||
|
|
||||||
// Build system prompt manually
|
|
||||||
const prompt = buildSystemPrompt({
|
|
||||||
skills,
|
|
||||||
contextFiles,
|
|
||||||
appendPrompt: "Additional instructions",
|
|
||||||
cwd,
|
cwd,
|
||||||
|
agentDir: getAgentDir(),
|
||||||
});
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
const extensions = loader.getExtensions();
|
||||||
|
const skills = loader.getSkills();
|
||||||
|
const prompts = loader.getPrompts();
|
||||||
|
const themes = loader.getThemes();
|
||||||
|
const contextFiles = loader.getAgentsFiles().agentsFiles;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Return Value
|
## Return Value
|
||||||
|
|
@ -808,12 +756,12 @@ import { Type } from "@sinclair/typebox";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
|
DefaultResourceLoader,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
readTool,
|
readTool,
|
||||||
bashTool,
|
bashTool,
|
||||||
type ExtensionFactory,
|
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
|
@ -828,14 +776,6 @@ 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 extension
|
|
||||||
const auditExtension: ExtensionFactory = (pi) => {
|
|
||||||
pi.on("tool_call", async (event) => {
|
|
||||||
console.log(`[Audit] ${event.toolName}`);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inline tool
|
// Inline tool
|
||||||
const statusTool: ToolDefinition = {
|
const statusTool: ToolDefinition = {
|
||||||
name: "status",
|
name: "status",
|
||||||
|
|
@ -857,24 +797,27 @@ const settingsManager = SettingsManager.inMemory({
|
||||||
retry: { enabled: true, maxRetries: 2 },
|
retry: { enabled: true, maxRetries: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loader = new DefaultResourceLoader({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
agentDir: "/custom/agent",
|
||||||
|
settingsManager,
|
||||||
|
systemPromptOverride: () => "You are a minimal assistant. Be concise.",
|
||||||
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
agentDir: "/custom/agent",
|
agentDir: "/custom/agent",
|
||||||
|
|
||||||
model,
|
model,
|
||||||
thinkingLevel: "off",
|
thinkingLevel: "off",
|
||||||
authStorage,
|
authStorage,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
|
||||||
systemPrompt: "You are a minimal assistant. Be concise.",
|
|
||||||
|
|
||||||
tools: [readTool, bashTool],
|
tools: [readTool, bashTool],
|
||||||
customTools: [statusTool],
|
customTools: [statusTool],
|
||||||
extensions: [auditExtension],
|
resourceLoader: loader,
|
||||||
skills: [],
|
|
||||||
contextFiles: [],
|
|
||||||
promptTemplates: [],
|
|
||||||
|
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
settingsManager,
|
settingsManager,
|
||||||
});
|
});
|
||||||
|
|
@ -976,21 +919,13 @@ createAgentSession
|
||||||
// Auth and Models
|
// Auth and Models
|
||||||
AuthStorage
|
AuthStorage
|
||||||
ModelRegistry
|
ModelRegistry
|
||||||
discoverAuthStorage
|
|
||||||
discoverModels
|
|
||||||
|
|
||||||
// Discovery
|
// Resource loading
|
||||||
discoverSkills
|
DefaultResourceLoader
|
||||||
discoverExtensions
|
type ResourceLoader
|
||||||
discoverContextFiles
|
|
||||||
discoverPromptTemplates
|
|
||||||
|
|
||||||
// Event Bus (for shared extension communication)
|
|
||||||
createEventBus
|
createEventBus
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
loadSettings
|
|
||||||
buildSystemPrompt
|
|
||||||
|
|
||||||
// Session management
|
// Session management
|
||||||
SessionManager
|
SessionManager
|
||||||
|
|
@ -1016,8 +951,6 @@ type ExtensionAPI
|
||||||
type ToolDefinition
|
type ToolDefinition
|
||||||
type Skill
|
type Skill
|
||||||
type PromptTemplate
|
type PromptTemplate
|
||||||
type Settings
|
|
||||||
type SkillsSettings
|
|
||||||
type Tool
|
type Tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,64 +141,43 @@ Run the extraction script:
|
||||||
|
|
||||||
Skills are discovered from these locations (later wins on name collision):
|
Skills are discovered from these locations (later wins on name collision):
|
||||||
|
|
||||||
1. `~/.codex/skills/**/SKILL.md` (Codex CLI, recursive)
|
1. `~/.pi/agent/skills/` (global)
|
||||||
2. `~/.claude/skills/*/SKILL.md` (Claude Code user, one level)
|
2. `<cwd>/.pi/skills/` (project)
|
||||||
3. `<cwd>/.claude/skills/*/SKILL.md` (Claude Code project, one level)
|
3. Paths listed in `settings.json` under `skills`
|
||||||
4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive)
|
4. CLI `--skill` paths (additive even with `--no-skills`)
|
||||||
5. `<cwd>/.pi/skills/**/SKILL.md` (Pi project, recursive)
|
|
||||||
|
Discovery rules for each directory:
|
||||||
|
- Direct `.md` files in the root
|
||||||
|
- Recursive `SKILL.md` files under subdirectories
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configure skill loading in `~/.pi/agent/settings.json`:
|
Configure skill paths in `~/.pi/agent/settings.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"skills": {
|
"skills": ["~/my-skills-repo", "/path/to/skill/SKILL.md"],
|
||||||
"enabled": true,
|
"enableSkillCommands": true
|
||||||
"enableCodexUser": true,
|
|
||||||
"enableClaudeUser": true,
|
|
||||||
"enableClaudeProject": true,
|
|
||||||
"enablePiUser": true,
|
|
||||||
"enablePiProject": true,
|
|
||||||
"enableSkillCommands": true,
|
|
||||||
"customDirectories": ["~/my-skills-repo"],
|
|
||||||
"ignoredSkills": ["deprecated-skill"],
|
|
||||||
"includeSkills": ["git-*", "docker"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use project-local settings in `<cwd>/.pi/settings.json` to scope skills to a single project.
|
||||||
|
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `enabled` | `true` | Master toggle for all skills |
|
| `skills` | `[]` | Additional skill file or directory paths |
|
||||||
| `enableCodexUser` | `true` | Load from `~/.codex/skills/` |
|
|
||||||
| `enableClaudeUser` | `true` | Load from `~/.claude/skills/` |
|
|
||||||
| `enableClaudeProject` | `true` | Load from `<cwd>/.claude/skills/` |
|
|
||||||
| `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` |
|
|
||||||
| `enablePiProject` | `true` | Load from `<cwd>/.pi/skills/` |
|
|
||||||
| `enableSkillCommands` | `true` | Register skills as `/skill:name` commands |
|
| `enableSkillCommands` | `true` | Register skills as `/skill:name` commands |
|
||||||
| `customDirectories` | `[]` | Additional directories to scan (supports `~` expansion) |
|
|
||||||
| `ignoredSkills` | `[]` | Glob patterns to exclude (e.g., `["deprecated-*", "test-skill"]`) |
|
|
||||||
| `includeSkills` | `[]` | Glob patterns to include (empty = all; e.g., `["git-*", "docker"]`) |
|
|
||||||
|
|
||||||
**Note:** `ignoredSkills` takes precedence over both `includeSkills` in settings and the `--skills` CLI flag. A skill matching any ignore pattern will be excluded regardless of include patterns.
|
### CLI Additions
|
||||||
|
|
||||||
### CLI Filtering
|
Use `--skill` to add skills for a specific invocation:
|
||||||
|
|
||||||
Use `--skills` to filter skills for a specific invocation:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Only load specific skills
|
pi --skill ~/my-skills/terraform
|
||||||
pi --skills git,docker
|
pi --skill ./skills/custom-skill/SKILL.md
|
||||||
|
|
||||||
# Glob patterns
|
|
||||||
pi --skills "git-*,docker-*"
|
|
||||||
|
|
||||||
# All skills matching a prefix
|
|
||||||
pi --skills "aws-*"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This overrides the `includeSkills` setting for the current session.
|
Use `--no-skills` to disable automatic discovery (CLI `--skill` paths still load).
|
||||||
|
|
||||||
## How Skills Work
|
## How Skills Work
|
||||||
|
|
||||||
|
|
@ -224,9 +203,7 @@ Toggle skill commands via `/settings` or in `settings.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"skills": {
|
"enableSkillCommands": true
|
||||||
"enableSkillCommands": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -285,12 +262,6 @@ cd /path/to/brave-search && npm install
|
||||||
\`\`\`
|
\`\`\`
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
**Claude Code**: Pi reads skills from `~/.claude/skills/*/SKILL.md`. The `allowed-tools` and `model` frontmatter fields are ignored.
|
|
||||||
|
|
||||||
**Codex CLI**: Pi reads skills from `~/.codex/skills/` recursively. Hidden files/directories and symlinks are skipped.
|
|
||||||
|
|
||||||
## Skill Repositories
|
## Skill Repositories
|
||||||
|
|
||||||
For inspiration and ready-to-use skills:
|
For inspiration and ready-to-use skills:
|
||||||
|
|
@ -305,13 +276,4 @@ CLI:
|
||||||
pi --no-skills
|
pi --no-skills
|
||||||
```
|
```
|
||||||
|
|
||||||
Settings (`~/.pi/agent/settings.json`):
|
Use `--no-skills` to disable automatic discovery while keeping explicit `--skill` paths.
|
||||||
```json
|
|
||||||
{
|
|
||||||
"skills": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the granular `enable*` flags to disable individual sources (e.g., `enableClaudeUser: false` to skip `~/.claude/skills`).
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, createAgentSession, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Set up auth storage and model registry
|
// Set up auth storage and model registry
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
// Option 1: Find a specific built-in model by provider/id
|
// Option 1: Find a specific built-in model by provider/id
|
||||||
const opus = getModel("anthropic", "claude-opus-4-5");
|
const opus = getModel("anthropic", "claude-opus-4-5");
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,18 @@
|
||||||
* Shows how to replace or modify the default system prompt.
|
* Shows how to replace or modify the default system prompt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Option 1: Replace prompt entirely
|
// Option 1: Replace prompt entirely
|
||||||
const { session: session1 } = await createAgentSession({
|
const loader1 = new DefaultResourceLoader({
|
||||||
systemPrompt: `You are a helpful assistant that speaks like a pirate.
|
systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate.
|
||||||
Always end responses with "Arrr!"`,
|
Always end responses with "Arrr!"`,
|
||||||
|
appendSystemPromptOverride: () => [],
|
||||||
|
});
|
||||||
|
await loader1.reload();
|
||||||
|
|
||||||
|
const { session: session1 } = await createAgentSession({
|
||||||
|
resourceLoader: loader1,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -23,13 +29,17 @@ console.log("=== Replace prompt ===");
|
||||||
await session1.prompt("What is 2 + 2?");
|
await session1.prompt("What is 2 + 2?");
|
||||||
console.log("\n");
|
console.log("\n");
|
||||||
|
|
||||||
// Option 2: Modify default prompt (receives default, returns modified)
|
// Option 2: Append instructions to the default prompt
|
||||||
const { session: session2 } = await createAgentSession({
|
const loader2 = new DefaultResourceLoader({
|
||||||
systemPrompt: (defaultPrompt) => `${defaultPrompt}
|
appendSystemPromptOverride: (base) => [
|
||||||
|
...base,
|
||||||
|
"## Additional Instructions\n- Always be concise\n- Use bullet points when listing things",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await loader2.reload();
|
||||||
|
|
||||||
## Additional Instructions
|
const { session: session2 } = await createAgentSession({
|
||||||
- Always be concise
|
resourceLoader: loader2,
|
||||||
- Use bullet points when listing things`,
|
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,7 @@
|
||||||
* Discover, filter, merge, or replace them.
|
* Discover, filter, merge, or replace them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, DefaultResourceLoader, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
|
|
||||||
const { skills: allSkills, warnings } = discoverSkills();
|
|
||||||
console.log(
|
|
||||||
"Discovered skills:",
|
|
||||||
allSkills.map((s) => s.name),
|
|
||||||
);
|
|
||||||
if (warnings.length > 0) {
|
|
||||||
console.log("Warnings:", warnings);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to specific skills
|
|
||||||
const filteredSkills = allSkills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
|
|
||||||
|
|
||||||
// Or define custom skills inline
|
// Or define custom skills inline
|
||||||
const customSkill: Skill = {
|
const customSkill: Skill = {
|
||||||
|
|
@ -29,19 +16,30 @@ const customSkill: Skill = {
|
||||||
source: "custom",
|
source: "custom",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use filtered + custom skills
|
const loader = new DefaultResourceLoader({
|
||||||
|
skillsOverride: (current) => {
|
||||||
|
const filteredSkills = current.skills.filter((s) => s.name.includes("browser") || s.name.includes("search"));
|
||||||
|
return {
|
||||||
|
skills: [...filteredSkills, customSkill],
|
||||||
|
diagnostics: current.diagnostics,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
|
||||||
|
const discovered = loader.getSkills();
|
||||||
|
console.log(
|
||||||
|
"Discovered skills:",
|
||||||
|
discovered.skills.map((s) => s.name),
|
||||||
|
);
|
||||||
|
if (discovered.diagnostics.length > 0) {
|
||||||
|
console.log("Warnings:", discovered.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
await createAgentSession({
|
await createAgentSession({
|
||||||
skills: [...filteredSkills, customSkill],
|
resourceLoader: loader,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Session created with ${filteredSkills.length + 1} skills`);
|
console.log("Session created with filtered skills");
|
||||||
|
|
||||||
// To disable all skills:
|
|
||||||
// skills: []
|
|
||||||
|
|
||||||
// To use discovery with filtering via settings:
|
|
||||||
// discoverSkills(process.cwd(), undefined, {
|
|
||||||
// ignoredSkills: ["browser-tools"], // glob patterns to exclude
|
|
||||||
// includeSkills: ["brave-*"], // glob patterns to include (empty = all)
|
|
||||||
// })
|
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,25 @@
|
||||||
* export default function (pi: ExtensionAPI) { ... }
|
* export default function (pi: ExtensionAPI) { ... }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Extensions are discovered automatically from standard locations.
|
// Extensions are discovered automatically from standard locations.
|
||||||
// You can also add paths via:
|
// You can also add paths via settings.json or DefaultResourceLoader options.
|
||||||
// 1. settings.json: { "extensions": ["./my-extension.ts"] }
|
|
||||||
// 2. additionalExtensionPaths option (see below)
|
|
||||||
|
|
||||||
// To add additional extension paths beyond discovery:
|
const resourceLoader = new DefaultResourceLoader({
|
||||||
const { session } = await createAgentSession({
|
|
||||||
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
|
additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"],
|
||||||
|
extensionFactories: [
|
||||||
|
(pi) => {
|
||||||
|
pi.on("agent_start", () => {
|
||||||
|
console.log("[Inline Extension] Agent starting");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await resourceLoader.reload();
|
||||||
|
|
||||||
|
const { session } = await createAgentSession({
|
||||||
|
resourceLoader,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,36 @@
|
||||||
* Context files provide project-specific instructions loaded into the system prompt.
|
* Context files provide project-specific instructions loaded into the system prompt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Discover AGENTS.md files walking up from cwd
|
const loader = new DefaultResourceLoader({
|
||||||
const discovered = discoverContextFiles();
|
agentsFilesOverride: (current) => ({
|
||||||
console.log("Discovered context files:");
|
agentsFiles: [
|
||||||
for (const file of discovered) {
|
...current.agentsFiles,
|
||||||
console.log(` - ${file.path} (${file.content.length} chars)`);
|
{
|
||||||
}
|
path: "/virtual/AGENTS.md",
|
||||||
|
content: `# Project Guidelines
|
||||||
// Use custom context files
|
|
||||||
await createAgentSession({
|
|
||||||
contextFiles: [
|
|
||||||
...discovered,
|
|
||||||
{
|
|
||||||
path: "/virtual/AGENTS.md",
|
|
||||||
content: `# Project Guidelines
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
- Use TypeScript strict mode
|
- Use TypeScript strict mode
|
||||||
- No any types
|
- No any types
|
||||||
- Prefer const over let`,
|
- Prefer const over let`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
// Discover AGENTS.md files walking up from cwd
|
||||||
|
const discovered = loader.getAgentsFiles().agentsFiles;
|
||||||
|
console.log("Discovered context files:");
|
||||||
|
for (const file of discovered) {
|
||||||
|
console.log(` - ${file.path} (${file.content.length} chars)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAgentSession({
|
||||||
|
resourceLoader: loader,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Session created with ${discovered.length + 1} context files`);
|
console.log(`Session created with ${discovered.length + 1} context files`);
|
||||||
|
|
||||||
// Disable context files:
|
|
||||||
// contextFiles: []
|
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,11 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
discoverPromptTemplates,
|
DefaultResourceLoader,
|
||||||
type PromptTemplate,
|
type PromptTemplate,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
|
|
||||||
const discovered = discoverPromptTemplates();
|
|
||||||
console.log("Discovered prompt templates:");
|
|
||||||
for (const template of discovered) {
|
|
||||||
console.log(` /${template.name}: ${template.description}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define custom templates
|
// Define custom templates
|
||||||
const deployTemplate: PromptTemplate = {
|
const deployTemplate: PromptTemplate = {
|
||||||
name: "deploy",
|
name: "deploy",
|
||||||
|
|
@ -30,13 +23,24 @@ const deployTemplate: PromptTemplate = {
|
||||||
3. Deploy: npm run deploy`,
|
3. Deploy: npm run deploy`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use discovered + custom templates
|
const loader = new DefaultResourceLoader({
|
||||||
|
promptsOverride: (current) => ({
|
||||||
|
prompts: [...current.prompts, deployTemplate],
|
||||||
|
diagnostics: current.diagnostics,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await loader.reload();
|
||||||
|
|
||||||
|
// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/
|
||||||
|
const discovered = loader.getPrompts().prompts;
|
||||||
|
console.log("Discovered prompt templates:");
|
||||||
|
for (const template of discovered) {
|
||||||
|
console.log(` /${template.name}: ${template.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
await createAgentSession({
|
await createAgentSession({
|
||||||
promptTemplates: [...discovered, deployTemplate],
|
resourceLoader: loader,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Session created with ${discovered.length + 1} prompt templates`);
|
console.log(`Session created with ${discovered.length + 1} prompt templates`);
|
||||||
|
|
||||||
// Disable prompt templates:
|
|
||||||
// promptTemplates: []
|
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,12 @@
|
||||||
* Configure API key resolution via AuthStorage and ModelRegistry.
|
* Configure API key resolution via AuthStorage and ModelRegistry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
AuthStorage,
|
|
||||||
createAgentSession,
|
|
||||||
discoverAuthStorage,
|
|
||||||
discoverModels,
|
|
||||||
ModelRegistry,
|
|
||||||
SessionManager,
|
|
||||||
} from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
|
// Default: AuthStorage uses ~/.pi/agent/auth.json
|
||||||
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
|
// ModelRegistry loads built-in + custom models from ~/.pi/agent/models.json
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
await createAgentSession({
|
await createAgentSession({
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
* Override settings using SettingsManager.
|
* Override settings using SettingsManager.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Load current settings (merged global + project)
|
// Load current settings (merged global + project)
|
||||||
const settings = loadSettings();
|
const settingsManagerFromDisk = SettingsManager.create();
|
||||||
console.log("Current settings:", JSON.stringify(settings, null, 2));
|
console.log("Current settings:", JSON.stringify(settingsManagerFromDisk.getGlobalSettings(), null, 2));
|
||||||
|
|
||||||
// Override specific settings
|
// Override specific settings
|
||||||
const settingsManager = SettingsManager.create();
|
const settingsManager = SettingsManager.create();
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
createBashTool,
|
createBashTool,
|
||||||
|
createExtensionRuntime,
|
||||||
createReadTool,
|
createReadTool,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
|
type ResourceLoader,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
@ -42,6 +44,18 @@ const settingsManager = SettingsManager.inMemory({
|
||||||
// When using a custom cwd with explicit tools, use the factory functions
|
// When using a custom cwd with explicit tools, use the factory functions
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
const resourceLoader: ResourceLoader = {
|
||||||
|
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||||
|
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||||
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||||
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||||
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||||
|
getSystemPrompt: () => `You are a minimal assistant.
|
||||||
|
Available: read, bash. Be concise.`,
|
||||||
|
getAppendSystemPrompt: () => [],
|
||||||
|
reload: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
cwd,
|
cwd,
|
||||||
agentDir: "/tmp/my-agent",
|
agentDir: "/tmp/my-agent",
|
||||||
|
|
@ -49,15 +63,9 @@ const { session } = await createAgentSession({
|
||||||
thinkingLevel: "off",
|
thinkingLevel: "off",
|
||||||
authStorage,
|
authStorage,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
systemPrompt: `You are a minimal assistant.
|
resourceLoader,
|
||||||
Available: read, bash. Be concise.`,
|
|
||||||
// Use factory functions with the same cwd to ensure path resolution works correctly
|
// Use factory functions with the same cwd to ensure path resolution works correctly
|
||||||
tools: [createReadTool(cwd), createBashTool(cwd)],
|
tools: [createReadTool(cwd), createBashTool(cwd)],
|
||||||
// Pass empty array to disable extension discovery, or provide inline factories
|
|
||||||
extensions: [],
|
|
||||||
skills: [],
|
|
||||||
contextFiles: [],
|
|
||||||
promptTemplates: [],
|
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
settingsManager,
|
settingsManager,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,24 +33,18 @@ import { getModel } from "@mariozechner/pi-ai";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
discoverAuthStorage,
|
DefaultResourceLoader,
|
||||||
discoverModels,
|
|
||||||
discoverSkills,
|
|
||||||
discoverExtensions,
|
|
||||||
discoverContextFiles,
|
|
||||||
discoverPromptTemplates,
|
|
||||||
loadSettings,
|
|
||||||
buildSystemPrompt,
|
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
|
SettingsManager,
|
||||||
codingTools,
|
codingTools,
|
||||||
readOnlyTools,
|
readOnlyTools,
|
||||||
readTool, bashTool, editTool, writeTool,
|
readTool, bashTool, editTool, writeTool,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
// Auth and models setup
|
// Auth and models setup
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
// Minimal
|
// Minimal
|
||||||
const { session } = await createAgentSession({ authStorage, modelRegistry });
|
const { session } = await createAgentSession({ authStorage, modelRegistry });
|
||||||
|
|
@ -60,11 +54,11 @@ const model = getModel("anthropic", "claude-opus-4-5");
|
||||||
const { session } = await createAgentSession({ model, thinkingLevel: "high", authStorage, modelRegistry });
|
const { session } = await createAgentSession({ model, thinkingLevel: "high", authStorage, modelRegistry });
|
||||||
|
|
||||||
// Modify prompt
|
// Modify prompt
|
||||||
const { session } = await createAgentSession({
|
const loader = new DefaultResourceLoader({
|
||||||
systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.",
|
systemPromptOverride: (base) => `${base}\n\nBe concise.`,
|
||||||
authStorage,
|
|
||||||
modelRegistry,
|
|
||||||
});
|
});
|
||||||
|
await loader.reload();
|
||||||
|
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry });
|
||||||
|
|
||||||
// Read-only
|
// Read-only
|
||||||
const { session } = await createAgentSession({ tools: readOnlyTools, authStorage, modelRegistry });
|
const { session } = await createAgentSession({ tools: readOnlyTools, authStorage, modelRegistry });
|
||||||
|
|
@ -81,18 +75,24 @@ const customAuth = new AuthStorage("/my/app/auth.json");
|
||||||
customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
|
customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
|
||||||
const customRegistry = new ModelRegistry(customAuth);
|
const customRegistry = new ModelRegistry(customAuth);
|
||||||
|
|
||||||
|
const resourceLoader = new DefaultResourceLoader({
|
||||||
|
systemPromptOverride: () => "You are helpful.",
|
||||||
|
extensionFactories: [myExtension],
|
||||||
|
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
||||||
|
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
||||||
|
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
|
||||||
|
});
|
||||||
|
await resourceLoader.reload();
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
model,
|
model,
|
||||||
authStorage: customAuth,
|
authStorage: customAuth,
|
||||||
modelRegistry: customRegistry,
|
modelRegistry: customRegistry,
|
||||||
systemPrompt: "You are helpful.",
|
resourceLoader,
|
||||||
tools: [readTool, bashTool],
|
tools: [readTool, bashTool],
|
||||||
customTools: [{ tool: myTool }],
|
customTools: [{ tool: myTool }],
|
||||||
extensions: [{ factory: myExtension }],
|
|
||||||
skills: [],
|
|
||||||
contextFiles: [],
|
|
||||||
promptTemplates: [],
|
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
|
settingsManager: SettingsManager.inMemory(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run prompts
|
// Run prompts
|
||||||
|
|
@ -108,23 +108,17 @@ await session.prompt("Hello");
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `authStorage` | `discoverAuthStorage()` | Credential storage |
|
| `authStorage` | `new AuthStorage()` | Credential storage |
|
||||||
| `modelRegistry` | `discoverModels(authStorage)` | Model registry |
|
| `modelRegistry` | `new ModelRegistry(authStorage)` | Model registry |
|
||||||
| `cwd` | `process.cwd()` | Working directory |
|
| `cwd` | `process.cwd()` | Working directory |
|
||||||
| `agentDir` | `~/.pi/agent` | Config directory |
|
| `agentDir` | `~/.pi/agent` | Config directory |
|
||||||
| `model` | From settings/first available | Model to use |
|
| `model` | From settings/first available | Model to use |
|
||||||
| `thinkingLevel` | From settings/"off" | off, low, medium, high |
|
| `thinkingLevel` | From settings/"off" | off, low, medium, high |
|
||||||
| `systemPrompt` | Discovered | String or `(default) => modified` |
|
|
||||||
| `tools` | `codingTools` | Built-in tools |
|
| `tools` | `codingTools` | Built-in tools |
|
||||||
| `customTools` | Discovered | Replaces discovery |
|
| `customTools` | `[]` | Additional tool definitions |
|
||||||
| `additionalCustomToolPaths` | `[]` | Merge with discovery |
|
| `resourceLoader` | DefaultResourceLoader | Resource loader for extensions, skills, prompts, themes |
|
||||||
| `extensions` | Discovered | Replaces discovery |
|
|
||||||
| `additionalExtensionPaths` | `[]` | Merge with discovery |
|
|
||||||
| `skills` | Discovered | Skills for prompt |
|
|
||||||
| `contextFiles` | Discovered | AGENTS.md files |
|
|
||||||
| `promptTemplates` | Discovered | Prompt templates (slash commands) |
|
|
||||||
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
|
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
|
||||||
| `settingsManager` | From agentDir | Settings overrides |
|
| `settingsManager` | `SettingsManager.create(cwd, agentDir)` | Settings overrides |
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ export interface Args {
|
||||||
export?: string;
|
export?: string;
|
||||||
noSkills?: boolean;
|
noSkills?: boolean;
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
promptTemplates?: string[];
|
||||||
|
noPromptTemplates?: boolean;
|
||||||
|
themes?: string[];
|
||||||
|
noThemes?: boolean;
|
||||||
listModels?: string | true;
|
listModels?: string | true;
|
||||||
messages: string[];
|
messages: string[];
|
||||||
fileArgs: string[];
|
fileArgs: string[];
|
||||||
|
|
@ -122,11 +126,21 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
||||||
result.extensions.push(args[++i]);
|
result.extensions.push(args[++i]);
|
||||||
} else if (arg === "--no-extensions") {
|
} else if (arg === "--no-extensions") {
|
||||||
result.noExtensions = true;
|
result.noExtensions = true;
|
||||||
|
} else if (arg === "--skill" && i + 1 < args.length) {
|
||||||
|
result.skills = result.skills ?? [];
|
||||||
|
result.skills.push(args[++i]);
|
||||||
|
} else if (arg === "--prompt-template" && i + 1 < args.length) {
|
||||||
|
result.promptTemplates = result.promptTemplates ?? [];
|
||||||
|
result.promptTemplates.push(args[++i]);
|
||||||
|
} else if (arg === "--theme" && i + 1 < args.length) {
|
||||||
|
result.themes = result.themes ?? [];
|
||||||
|
result.themes.push(args[++i]);
|
||||||
} else if (arg === "--no-skills") {
|
} else if (arg === "--no-skills") {
|
||||||
result.noSkills = true;
|
result.noSkills = true;
|
||||||
} else if (arg === "--skills" && i + 1 < args.length) {
|
} else if (arg === "--no-prompt-templates") {
|
||||||
// Comma-separated glob patterns for skill filtering
|
result.noPromptTemplates = true;
|
||||||
result.skills = args[++i].split(",").map((s) => s.trim());
|
} else if (arg === "--no-themes") {
|
||||||
|
result.noThemes = true;
|
||||||
} else if (arg === "--list-models") {
|
} else if (arg === "--list-models") {
|
||||||
// Check if next arg is a search pattern (not a flag or file arg)
|
// Check if next arg is a search pattern (not a flag or file arg)
|
||||||
if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) {
|
if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) {
|
||||||
|
|
@ -162,6 +176,11 @@ export function printHelp(): void {
|
||||||
${chalk.bold("Usage:")}
|
${chalk.bold("Usage:")}
|
||||||
${APP_NAME} [options] [@files...] [messages...]
|
${APP_NAME} [options] [@files...] [messages...]
|
||||||
|
|
||||||
|
${chalk.bold("Commands:")}
|
||||||
|
${APP_NAME} install <source> [-l] Install extension source and add to settings
|
||||||
|
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
||||||
|
${APP_NAME} update [source] Update installed extensions (skips pinned sources)
|
||||||
|
|
||||||
${chalk.bold("Options:")}
|
${chalk.bold("Options:")}
|
||||||
--provider <name> Provider name (default: google)
|
--provider <name> Provider name (default: google)
|
||||||
--model <id> Model ID (default: gemini-2.5-flash)
|
--model <id> Model ID (default: gemini-2.5-flash)
|
||||||
|
|
@ -183,8 +202,12 @@ ${chalk.bold("Options:")}
|
||||||
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
||||||
--extension, -e <path> Load an extension file (can be used multiple times)
|
--extension, -e <path> Load an extension file (can be used multiple times)
|
||||||
--no-extensions Disable extension discovery (explicit -e paths still work)
|
--no-extensions Disable extension discovery (explicit -e paths still work)
|
||||||
|
--skill <path> Load a skill file or directory (can be used multiple times)
|
||||||
--no-skills Disable skills discovery and loading
|
--no-skills Disable skills discovery and loading
|
||||||
--skills <patterns> Comma-separated glob patterns to filter skills (e.g., git-*,docker)
|
--prompt-template <path> Load a prompt template file or directory (can be used multiple times)
|
||||||
|
--no-prompt-templates Disable prompt template discovery and loading
|
||||||
|
--theme <path> Load a theme file or directory (can be used multiple times)
|
||||||
|
--no-themes Disable theme discovery and loading
|
||||||
--export <file> Export session file to HTML and exit
|
--export <file> Export session file to HTML and exit
|
||||||
--list-models [search] List available models (with optional fuzzy search)
|
--list-models [search] List available models (with optional fuzzy search)
|
||||||
--help, -h Show this help
|
--help, -h Show this help
|
||||||
|
|
|
||||||
|
|
@ -40,25 +40,35 @@ import {
|
||||||
} from "./compaction/index.js";
|
} from "./compaction/index.js";
|
||||||
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
|
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
|
||||||
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
|
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
|
||||||
import type {
|
import {
|
||||||
ContextUsage,
|
type ContextUsage,
|
||||||
|
type ExtensionCommandContextActions,
|
||||||
|
type ExtensionErrorListener,
|
||||||
ExtensionRunner,
|
ExtensionRunner,
|
||||||
InputSource,
|
type ExtensionUIContext,
|
||||||
SessionBeforeCompactResult,
|
type InputSource,
|
||||||
SessionBeforeForkResult,
|
type SessionBeforeCompactResult,
|
||||||
SessionBeforeSwitchResult,
|
type SessionBeforeForkResult,
|
||||||
SessionBeforeTreeResult,
|
type SessionBeforeSwitchResult,
|
||||||
TreePreparation,
|
type SessionBeforeTreeResult,
|
||||||
TurnEndEvent,
|
type ShutdownHandler,
|
||||||
TurnStartEvent,
|
type ToolDefinition,
|
||||||
|
type TreePreparation,
|
||||||
|
type TurnEndEvent,
|
||||||
|
type TurnStartEvent,
|
||||||
|
wrapRegisteredTools,
|
||||||
|
wrapToolsWithExtensions,
|
||||||
} from "./extensions/index.js";
|
} from "./extensions/index.js";
|
||||||
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
|
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
|
||||||
import type { ModelRegistry } from "./model-registry.js";
|
import type { ModelRegistry } from "./model-registry.js";
|
||||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
|
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
|
||||||
|
import type { ResourceLoader } from "./resource-loader.js";
|
||||||
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
|
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
|
||||||
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
|
import type { SettingsManager } from "./settings-manager.js";
|
||||||
import type { Skill, SkillWarning } from "./skills.js";
|
import type { Skill, SkillWarning } from "./skills.js";
|
||||||
|
import { buildSystemPrompt } from "./system-prompt.js";
|
||||||
import type { BashOperations } from "./tools/bash.js";
|
import type { BashOperations } from "./tools/bash.js";
|
||||||
|
import { createAllTools } from "./tools/index.js";
|
||||||
|
|
||||||
/** Session-specific events that extend the core AgentEvent */
|
/** Session-specific events that extend the core AgentEvent */
|
||||||
export type AgentSessionEvent =
|
export type AgentSessionEvent =
|
||||||
|
|
@ -85,23 +95,28 @@ export interface AgentSessionConfig {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
settingsManager: SettingsManager;
|
settingsManager: SettingsManager;
|
||||||
|
cwd: string;
|
||||||
/** Models to cycle through with Ctrl+P (from --models flag) */
|
/** Models to cycle through with Ctrl+P (from --models flag) */
|
||||||
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||||
/** File-based prompt templates for expansion */
|
/** Resource loader for skills, prompts, themes, context files, system prompt */
|
||||||
promptTemplates?: PromptTemplate[];
|
resourceLoader: ResourceLoader;
|
||||||
/** Extension runner (created in sdk.ts with wrapped tools) */
|
/** SDK custom tools registered outside extensions */
|
||||||
extensionRunner?: ExtensionRunner;
|
customTools?: ToolDefinition[];
|
||||||
/** Loaded skills (already discovered by SDK) */
|
|
||||||
skills?: Skill[];
|
|
||||||
/** Skill loading warnings (already captured by SDK) */
|
|
||||||
skillWarnings?: SkillWarning[];
|
|
||||||
skillsSettings?: Required<SkillsSettings>;
|
|
||||||
/** Model registry for API key resolution and model discovery */
|
/** Model registry for API key resolution and model discovery */
|
||||||
modelRegistry: ModelRegistry;
|
modelRegistry: ModelRegistry;
|
||||||
/** Tool registry for extension getTools/setTools - maps name to tool */
|
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
|
||||||
toolRegistry?: Map<string, AgentTool>;
|
initialActiveToolNames?: string[];
|
||||||
/** Function to rebuild system prompt when tools change */
|
/** Override base tools (useful for custom runtimes). */
|
||||||
rebuildSystemPrompt?: (toolNames: string[]) => string;
|
baseToolsOverride?: Record<string, AgentTool>;
|
||||||
|
/** Mutable ref used by Agent to access the current ExtensionRunner */
|
||||||
|
extensionRunnerRef?: { current?: ExtensionRunner };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionBindings {
|
||||||
|
uiContext?: ExtensionUIContext;
|
||||||
|
commandContextActions?: ExtensionCommandContextActions;
|
||||||
|
shutdownHandler?: ShutdownHandler;
|
||||||
|
onError?: ExtensionErrorListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for AgentSession.prompt() */
|
/** Options for AgentSession.prompt() */
|
||||||
|
|
@ -163,7 +178,6 @@ export class AgentSession {
|
||||||
readonly settingsManager: SettingsManager;
|
readonly settingsManager: SettingsManager;
|
||||||
|
|
||||||
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||||
private _promptTemplates: PromptTemplate[];
|
|
||||||
|
|
||||||
// Event subscription state
|
// Event subscription state
|
||||||
private _unsubscribeAgent?: () => void;
|
private _unsubscribeAgent?: () => void;
|
||||||
|
|
@ -197,40 +211,49 @@ export class AgentSession {
|
||||||
private _extensionRunner: ExtensionRunner | undefined = undefined;
|
private _extensionRunner: ExtensionRunner | undefined = undefined;
|
||||||
private _turnIndex = 0;
|
private _turnIndex = 0;
|
||||||
|
|
||||||
private _skills: Skill[];
|
private _resourceLoader: ResourceLoader;
|
||||||
private _skillWarnings: SkillWarning[];
|
private _customTools: ToolDefinition[];
|
||||||
private _skillsSettings: Required<SkillsSettings> | undefined;
|
private _baseToolRegistry: Map<string, AgentTool> = new Map();
|
||||||
|
private _cwd: string;
|
||||||
|
private _extensionRunnerRef?: { current?: ExtensionRunner };
|
||||||
|
private _initialActiveToolNames?: string[];
|
||||||
|
private _baseToolsOverride?: Record<string, AgentTool>;
|
||||||
|
private _extensionUIContext?: ExtensionUIContext;
|
||||||
|
private _extensionCommandContextActions?: ExtensionCommandContextActions;
|
||||||
|
private _extensionShutdownHandler?: ShutdownHandler;
|
||||||
|
private _extensionErrorListeners = new Set<ExtensionErrorListener>();
|
||||||
|
private _extensionErrorUnsubscribers: Array<() => void> = [];
|
||||||
|
|
||||||
// Model registry for API key resolution
|
// Model registry for API key resolution
|
||||||
private _modelRegistry: ModelRegistry;
|
private _modelRegistry: ModelRegistry;
|
||||||
|
|
||||||
// Tool registry for extension getTools/setTools
|
// Tool registry for extension getTools/setTools
|
||||||
private _toolRegistry: Map<string, AgentTool>;
|
private _toolRegistry: Map<string, AgentTool> = new Map();
|
||||||
|
|
||||||
// Function to rebuild system prompt when tools change
|
|
||||||
private _rebuildSystemPrompt?: (toolNames: string[]) => string;
|
|
||||||
|
|
||||||
// Base system prompt (without extension appends) - used to apply fresh appends each turn
|
// Base system prompt (without extension appends) - used to apply fresh appends each turn
|
||||||
private _baseSystemPrompt: string;
|
private _baseSystemPrompt = "";
|
||||||
|
|
||||||
constructor(config: AgentSessionConfig) {
|
constructor(config: AgentSessionConfig) {
|
||||||
this.agent = config.agent;
|
this.agent = config.agent;
|
||||||
this.sessionManager = config.sessionManager;
|
this.sessionManager = config.sessionManager;
|
||||||
this.settingsManager = config.settingsManager;
|
this.settingsManager = config.settingsManager;
|
||||||
this._scopedModels = config.scopedModels ?? [];
|
this._scopedModels = config.scopedModels ?? [];
|
||||||
this._promptTemplates = config.promptTemplates ?? [];
|
this._resourceLoader = config.resourceLoader;
|
||||||
this._extensionRunner = config.extensionRunner;
|
this._customTools = config.customTools ?? [];
|
||||||
this._skills = config.skills ?? [];
|
this._cwd = config.cwd;
|
||||||
this._skillWarnings = config.skillWarnings ?? [];
|
|
||||||
this._skillsSettings = config.skillsSettings;
|
|
||||||
this._modelRegistry = config.modelRegistry;
|
this._modelRegistry = config.modelRegistry;
|
||||||
this._toolRegistry = config.toolRegistry ?? new Map();
|
this._extensionRunnerRef = config.extensionRunnerRef;
|
||||||
this._rebuildSystemPrompt = config.rebuildSystemPrompt;
|
this._initialActiveToolNames = config.initialActiveToolNames;
|
||||||
this._baseSystemPrompt = config.agent.state.systemPrompt;
|
this._baseToolsOverride = config.baseToolsOverride;
|
||||||
|
|
||||||
// Always subscribe to agent events for internal handling
|
// Always subscribe to agent events for internal handling
|
||||||
// (session persistence, extensions, auto-compaction, retry logic)
|
// (session persistence, extensions, auto-compaction, retry logic)
|
||||||
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
||||||
|
|
||||||
|
this._buildRuntime({
|
||||||
|
activeToolNames: this._initialActiveToolNames,
|
||||||
|
includeAllExtensionTools: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Model registry for API key resolution and model discovery */
|
/** Model registry for API key resolution and model discovery */
|
||||||
|
|
@ -502,10 +525,8 @@ export class AgentSession {
|
||||||
this.agent.setTools(tools);
|
this.agent.setTools(tools);
|
||||||
|
|
||||||
// Rebuild base system prompt with new tool set
|
// Rebuild base system prompt with new tool set
|
||||||
if (this._rebuildSystemPrompt) {
|
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
|
||||||
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
|
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
||||||
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether auto-compaction is currently running */
|
/** Whether auto-compaction is currently running */
|
||||||
|
|
@ -550,7 +571,26 @@ export class AgentSession {
|
||||||
|
|
||||||
/** File-based prompt templates */
|
/** File-based prompt templates */
|
||||||
get promptTemplates(): ReadonlyArray<PromptTemplate> {
|
get promptTemplates(): ReadonlyArray<PromptTemplate> {
|
||||||
return this._promptTemplates;
|
return this._resourceLoader.getPrompts().prompts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rebuildSystemPrompt(toolNames: string[]): string {
|
||||||
|
const validToolNames = toolNames.filter((name) => this._baseToolRegistry.has(name));
|
||||||
|
const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();
|
||||||
|
const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();
|
||||||
|
const appendSystemPrompt =
|
||||||
|
loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
|
||||||
|
const loadedSkills = this._resourceLoader.getSkills().skills;
|
||||||
|
const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
|
||||||
|
|
||||||
|
return buildSystemPrompt({
|
||||||
|
cwd: this._cwd,
|
||||||
|
skills: loadedSkills,
|
||||||
|
contextFiles: loadedContextFiles,
|
||||||
|
customPrompt: loaderSystemPrompt,
|
||||||
|
appendSystemPrompt,
|
||||||
|
selectedTools: validToolNames,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -601,7 +641,7 @@ export class AgentSession {
|
||||||
let expandedText = currentText;
|
let expandedText = currentText;
|
||||||
if (expandPromptTemplates) {
|
if (expandPromptTemplates) {
|
||||||
expandedText = this._expandSkillCommand(expandedText);
|
expandedText = this._expandSkillCommand(expandedText);
|
||||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If streaming, queue via steer() or followUp() based on option
|
// If streaming, queue via steer() or followUp() based on option
|
||||||
|
|
@ -750,7 +790,7 @@ export class AgentSession {
|
||||||
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
||||||
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
|
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
|
||||||
|
|
||||||
const skill = this._skills.find((s) => s.name === skillName);
|
const skill = this.skills.find((s) => s.name === skillName);
|
||||||
if (!skill) return text; // Unknown skill, pass through
|
if (!skill) return text; // Unknown skill, pass through
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -784,7 +824,7 @@ export class AgentSession {
|
||||||
|
|
||||||
// Expand skill commands and prompt templates
|
// Expand skill commands and prompt templates
|
||||||
let expandedText = this._expandSkillCommand(text);
|
let expandedText = this._expandSkillCommand(text);
|
||||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||||
|
|
||||||
await this._queueSteer(expandedText);
|
await this._queueSteer(expandedText);
|
||||||
}
|
}
|
||||||
|
|
@ -803,7 +843,7 @@ export class AgentSession {
|
||||||
|
|
||||||
// Expand skill commands and prompt templates
|
// Expand skill commands and prompt templates
|
||||||
let expandedText = this._expandSkillCommand(text);
|
let expandedText = this._expandSkillCommand(text);
|
||||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||||
|
|
||||||
await this._queueFollowUp(expandedText);
|
await this._queueFollowUp(expandedText);
|
||||||
}
|
}
|
||||||
|
|
@ -891,6 +931,8 @@ export class AgentSession {
|
||||||
message.display,
|
message.display,
|
||||||
message.details,
|
message.details,
|
||||||
);
|
);
|
||||||
|
this._emit({ type: "message_start", message: appMessage });
|
||||||
|
this._emit({ type: "message_end", message: appMessage });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -963,18 +1005,21 @@ export class AgentSession {
|
||||||
return this._followUpMessages;
|
return this._followUpMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
get skillsSettings(): Required<SkillsSettings> | undefined {
|
/** Skills loaded by resource loader */
|
||||||
return this._skillsSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
|
|
||||||
get skills(): readonly Skill[] {
|
get skills(): readonly Skill[] {
|
||||||
return this._skills;
|
return this._resourceLoader.getSkills().skills;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Skill loading warnings captured by SDK */
|
/** Skill loading warnings captured by resource loader */
|
||||||
get skillWarnings(): readonly SkillWarning[] {
|
get skillWarnings(): readonly SkillWarning[] {
|
||||||
return this._skillWarnings;
|
return this._resourceLoader.getSkills().diagnostics.map((diagnostic) => ({
|
||||||
|
skillPath: diagnostic.path ?? "<unknown>",
|
||||||
|
message: diagnostic.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get resourceLoader(): ResourceLoader {
|
||||||
|
return this._resourceLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1588,6 +1633,219 @@ export class AgentSession {
|
||||||
return this.settingsManager.getCompactionEnabled();
|
return this.settingsManager.getCompactionEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bindExtensions(bindings: ExtensionBindings): Promise<void> {
|
||||||
|
if (bindings.uiContext !== undefined) {
|
||||||
|
this._extensionUIContext = bindings.uiContext;
|
||||||
|
}
|
||||||
|
if (bindings.commandContextActions !== undefined) {
|
||||||
|
this._extensionCommandContextActions = bindings.commandContextActions;
|
||||||
|
}
|
||||||
|
if (bindings.shutdownHandler !== undefined) {
|
||||||
|
this._extensionShutdownHandler = bindings.shutdownHandler;
|
||||||
|
}
|
||||||
|
if (bindings.onError) {
|
||||||
|
this._extensionErrorListeners.add(bindings.onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._extensionRunner) {
|
||||||
|
this._applyExtensionBindings(this._extensionRunner);
|
||||||
|
await this._extensionRunner.emit({ type: "session_start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _applyExtensionBindings(runner: ExtensionRunner): void {
|
||||||
|
runner.setUIContext(this._extensionUIContext);
|
||||||
|
runner.bindCommandContext(this._extensionCommandContextActions);
|
||||||
|
|
||||||
|
for (const unsubscribe of this._extensionErrorUnsubscribers) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
this._extensionErrorUnsubscribers = [];
|
||||||
|
for (const listener of this._extensionErrorListeners) {
|
||||||
|
this._extensionErrorUnsubscribers.push(runner.onError(listener));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bindExtensionCore(runner: ExtensionRunner): void {
|
||||||
|
runner.bindCore(
|
||||||
|
{
|
||||||
|
sendMessage: (message, options) => {
|
||||||
|
this.sendCustomMessage(message, options).catch((err) => {
|
||||||
|
runner.emitError({
|
||||||
|
extensionPath: "<runtime>",
|
||||||
|
event: "send_message",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
sendUserMessage: (content, options) => {
|
||||||
|
this.sendUserMessage(content, options).catch((err) => {
|
||||||
|
runner.emitError({
|
||||||
|
extensionPath: "<runtime>",
|
||||||
|
event: "send_user_message",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
appendEntry: (customType, data) => {
|
||||||
|
this.sessionManager.appendCustomEntry(customType, data);
|
||||||
|
},
|
||||||
|
setSessionName: (name) => {
|
||||||
|
this.sessionManager.appendSessionInfo(name);
|
||||||
|
},
|
||||||
|
getSessionName: () => {
|
||||||
|
return this.sessionManager.getSessionName();
|
||||||
|
},
|
||||||
|
setLabel: (entryId, label) => {
|
||||||
|
this.sessionManager.appendLabelChange(entryId, label);
|
||||||
|
},
|
||||||
|
getActiveTools: () => this.getActiveToolNames(),
|
||||||
|
getAllTools: () => this.getAllTools(),
|
||||||
|
setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
|
||||||
|
setModel: async (model) => {
|
||||||
|
const key = await this.modelRegistry.getApiKey(model);
|
||||||
|
if (!key) return false;
|
||||||
|
await this.setModel(model);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getThinkingLevel: () => this.thinkingLevel,
|
||||||
|
setThinkingLevel: (level) => this.setThinkingLevel(level),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getModel: () => this.model,
|
||||||
|
isIdle: () => !this.isStreaming,
|
||||||
|
abort: () => this.abort(),
|
||||||
|
hasPendingMessages: () => this.pendingMessageCount > 0,
|
||||||
|
shutdown: () => {
|
||||||
|
this._extensionShutdownHandler?.();
|
||||||
|
},
|
||||||
|
getContextUsage: () => this.getContextUsage(),
|
||||||
|
compact: (options) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await this.compact(options?.customInstructions);
|
||||||
|
options?.onComplete?.(result);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
options?.onError?.(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildRuntime(options: {
|
||||||
|
activeToolNames?: string[];
|
||||||
|
flagValues?: Map<string, boolean | string>;
|
||||||
|
includeAllExtensionTools?: boolean;
|
||||||
|
}): void {
|
||||||
|
const autoResizeImages = this.settingsManager.getImageAutoResize();
|
||||||
|
const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
|
||||||
|
const baseTools = this._baseToolsOverride
|
||||||
|
? this._baseToolsOverride
|
||||||
|
: createAllTools(this._cwd, {
|
||||||
|
read: { autoResizeImages },
|
||||||
|
bash: { commandPrefix: shellCommandPrefix },
|
||||||
|
});
|
||||||
|
|
||||||
|
this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool]));
|
||||||
|
|
||||||
|
const extensionsResult = this._resourceLoader.getExtensions();
|
||||||
|
if (options.flagValues) {
|
||||||
|
for (const [name, value] of options.flagValues) {
|
||||||
|
extensionsResult.runtime.flagValues.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExtensions = extensionsResult.extensions.length > 0;
|
||||||
|
const hasCustomTools = this._customTools.length > 0;
|
||||||
|
this._extensionRunner =
|
||||||
|
hasExtensions || hasCustomTools
|
||||||
|
? new ExtensionRunner(
|
||||||
|
extensionsResult.extensions,
|
||||||
|
extensionsResult.runtime,
|
||||||
|
this._cwd,
|
||||||
|
this.sessionManager,
|
||||||
|
this._modelRegistry,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (this._extensionRunnerRef) {
|
||||||
|
this._extensionRunnerRef.current = this._extensionRunner;
|
||||||
|
}
|
||||||
|
if (this._extensionRunner) {
|
||||||
|
this._bindExtensionCore(this._extensionRunner);
|
||||||
|
this._applyExtensionBindings(this._extensionRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registeredTools = this._extensionRunner?.getAllRegisteredTools() ?? [];
|
||||||
|
const allCustomTools = [
|
||||||
|
...registeredTools,
|
||||||
|
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })),
|
||||||
|
];
|
||||||
|
const wrappedExtensionTools = this._extensionRunner
|
||||||
|
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const toolRegistry = new Map(this._baseToolRegistry);
|
||||||
|
for (const tool of wrappedExtensionTools as AgentTool[]) {
|
||||||
|
toolRegistry.set(tool.name, tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultActiveToolNames = this._baseToolsOverride
|
||||||
|
? Object.keys(this._baseToolsOverride)
|
||||||
|
: ["read", "bash", "edit", "write"];
|
||||||
|
const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
|
||||||
|
const activeToolNameSet = new Set<string>(baseActiveToolNames);
|
||||||
|
if (options.includeAllExtensionTools) {
|
||||||
|
for (const tool of wrappedExtensionTools as AgentTool[]) {
|
||||||
|
activeToolNameSet.add(tool.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionToolNames = new Set(wrappedExtensionTools.map((tool) => tool.name));
|
||||||
|
const activeBaseTools = Array.from(activeToolNameSet)
|
||||||
|
.filter((name) => this._baseToolRegistry.has(name) && !extensionToolNames.has(name))
|
||||||
|
.map((name) => this._baseToolRegistry.get(name) as AgentTool);
|
||||||
|
const activeExtensionTools = wrappedExtensionTools.filter((tool) => activeToolNameSet.has(tool.name));
|
||||||
|
const activeToolsArray: AgentTool[] = [...activeBaseTools, ...activeExtensionTools];
|
||||||
|
|
||||||
|
if (this._extensionRunner) {
|
||||||
|
const wrappedActiveTools = wrapToolsWithExtensions(activeToolsArray, this._extensionRunner);
|
||||||
|
this.agent.setTools(wrappedActiveTools as AgentTool[]);
|
||||||
|
|
||||||
|
const wrappedAllTools = wrapToolsWithExtensions(Array.from(toolRegistry.values()), this._extensionRunner);
|
||||||
|
this._toolRegistry = new Map(wrappedAllTools.map((tool) => [tool.name, tool]));
|
||||||
|
} else {
|
||||||
|
this.agent.setTools(activeToolsArray);
|
||||||
|
this._toolRegistry = toolRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPromptToolNames = Array.from(activeToolNameSet).filter((name) => this._baseToolRegistry.has(name));
|
||||||
|
this._baseSystemPrompt = this._rebuildSystemPrompt(systemPromptToolNames);
|
||||||
|
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
const previousFlagValues = this._extensionRunner?.getFlagValues();
|
||||||
|
await this._extensionRunner?.emit({ type: "session_shutdown" });
|
||||||
|
await this._resourceLoader.reload();
|
||||||
|
this._buildRuntime({
|
||||||
|
activeToolNames: this.getActiveToolNames(),
|
||||||
|
flagValues: previousFlagValues,
|
||||||
|
includeAllExtensionTools: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasBindings =
|
||||||
|
this._extensionUIContext ||
|
||||||
|
this._extensionCommandContextActions ||
|
||||||
|
this._extensionShutdownHandler ||
|
||||||
|
this._extensionErrorListeners.size > 0;
|
||||||
|
if (this._extensionRunner && hasBindings) {
|
||||||
|
await this._extensionRunner.emit({ type: "session_start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Auto-Retry
|
// Auto-Retry
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ import {
|
||||||
type OAuthProvider,
|
type OAuthProvider,
|
||||||
} from "@mariozechner/pi-ai";
|
} from "@mariozechner/pi-ai";
|
||||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||||
import { dirname } from "path";
|
import { dirname, join } from "path";
|
||||||
import lockfile from "proper-lockfile";
|
import lockfile from "proper-lockfile";
|
||||||
|
import { getAgentDir } from "../config.js";
|
||||||
|
|
||||||
export type ApiKeyCredential = {
|
export type ApiKeyCredential = {
|
||||||
type: "api_key";
|
type: "api_key";
|
||||||
|
|
@ -42,7 +43,7 @@ export class AuthStorage {
|
||||||
private runtimeOverrides: Map<string, string> = new Map();
|
private runtimeOverrides: Map<string, string> = new Map();
|
||||||
private fallbackResolver?: (provider: string) => string | undefined;
|
private fallbackResolver?: (provider: string) => string | undefined;
|
||||||
|
|
||||||
constructor(private authPath: string) {
|
constructor(private authPath: string = join(getAgentDir(), "auth.json")) {
|
||||||
this.reload();
|
this.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { type ChildProcess, spawn } from "child_process";
|
import { type ChildProcess, spawn } from "child_process";
|
||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
|
import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
|
||||||
import type { BashOperations } from "./tools/bash.js";
|
import type { BashOperations } from "./tools/bash.js";
|
||||||
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
||||||
|
|
||||||
|
|
@ -63,6 +63,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
|
||||||
const { shell, args } = getShellConfig();
|
const { shell, args } = getShellConfig();
|
||||||
const child: ChildProcess = spawn(shell, [...args, command], {
|
const child: ChildProcess = spawn(shell, [...args, command], {
|
||||||
detached: true,
|
detached: true,
|
||||||
|
env: getShellEnv(),
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a runtime with throwing stubs for action methods.
|
* Create a runtime with throwing stubs for action methods.
|
||||||
* Runner.initialize() replaces these with real implementations.
|
* Runner.bindCore() replaces these with real implementations.
|
||||||
*/
|
*/
|
||||||
export function createExtensionRuntime(): ExtensionRuntime {
|
export function createExtensionRuntime(): ExtensionRuntime {
|
||||||
const notInitialized = () => {
|
const notInitialized = () => {
|
||||||
|
|
@ -246,6 +246,7 @@ function createExtensionAPI(
|
||||||
|
|
||||||
async function loadExtensionModule(extensionPath: string) {
|
async function loadExtensionModule(extensionPath: string) {
|
||||||
const jiti = createJiti(import.meta.url, {
|
const jiti = createJiti(import.meta.url, {
|
||||||
|
moduleCache: false,
|
||||||
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
|
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
|
||||||
// Also disable tryNative so jiti handles ALL imports (not just the entry point)
|
// 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
|
// In Node.js/dev: use aliases to resolve to node_modules paths
|
||||||
|
|
@ -347,6 +348,7 @@ interface PiManifest {
|
||||||
extensions?: string[];
|
extensions?: string[];
|
||||||
themes?: string[];
|
themes?: string[];
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
prompts?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPiManifest(packageJsonPath: string): PiManifest | null {
|
function readPiManifest(packageJsonPath: string): PiManifest | null {
|
||||||
|
|
|
||||||
|
|
@ -178,12 +178,7 @@ export class ExtensionRunner {
|
||||||
this.modelRegistry = modelRegistry;
|
this.modelRegistry = modelRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(
|
bindCore(actions: ExtensionActions, contextActions: ExtensionContextActions): void {
|
||||||
actions: ExtensionActions,
|
|
||||||
contextActions: ExtensionContextActions,
|
|
||||||
commandContextActions?: ExtensionCommandContextActions,
|
|
||||||
uiContext?: ExtensionUIContext,
|
|
||||||
): void {
|
|
||||||
// Copy actions into the shared runtime (all extension APIs reference this)
|
// Copy actions into the shared runtime (all extension APIs reference this)
|
||||||
this.runtime.sendMessage = actions.sendMessage;
|
this.runtime.sendMessage = actions.sendMessage;
|
||||||
this.runtime.sendUserMessage = actions.sendUserMessage;
|
this.runtime.sendUserMessage = actions.sendUserMessage;
|
||||||
|
|
@ -206,14 +201,24 @@ export class ExtensionRunner {
|
||||||
this.shutdownHandler = contextActions.shutdown;
|
this.shutdownHandler = contextActions.shutdown;
|
||||||
this.getContextUsageFn = contextActions.getContextUsage;
|
this.getContextUsageFn = contextActions.getContextUsage;
|
||||||
this.compactFn = contextActions.compact;
|
this.compactFn = contextActions.compact;
|
||||||
|
}
|
||||||
|
|
||||||
// Command context actions (optional, only for interactive mode)
|
bindCommandContext(actions?: ExtensionCommandContextActions): void {
|
||||||
if (commandContextActions) {
|
if (actions) {
|
||||||
this.waitForIdleFn = commandContextActions.waitForIdle;
|
this.waitForIdleFn = actions.waitForIdle;
|
||||||
this.newSessionHandler = commandContextActions.newSession;
|
this.newSessionHandler = actions.newSession;
|
||||||
this.forkHandler = commandContextActions.fork;
|
this.forkHandler = actions.fork;
|
||||||
this.navigateTreeHandler = commandContextActions.navigateTree;
|
this.navigateTreeHandler = actions.navigateTree;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.waitForIdleFn = async () => {};
|
||||||
|
this.newSessionHandler = async () => ({ cancelled: false });
|
||||||
|
this.forkHandler = async () => ({ cancelled: false });
|
||||||
|
this.navigateTreeHandler = async () => ({ cancelled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
setUIContext(uiContext?: ExtensionUIContext): void {
|
||||||
this.uiContext = uiContext ?? noOpUIContext;
|
this.uiContext = uiContext ?? noOpUIContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,6 +270,10 @@ export class ExtensionRunner {
|
||||||
this.runtime.flagValues.set(name, value);
|
this.runtime.flagValues.set(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFlagValues(): Map<string, boolean | string> {
|
||||||
|
return new Map(this.runtime.flagValues);
|
||||||
|
}
|
||||||
|
|
||||||
getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> {
|
getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> {
|
||||||
const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings);
|
const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings);
|
||||||
const extensionShortcuts = new Map<KeyId, ExtensionShortcut>();
|
const extensionShortcuts = new Map<KeyId, ExtensionShortcut>();
|
||||||
|
|
@ -351,7 +360,7 @@ export class ExtensionRunner {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request a graceful shutdown. Called by extension tools and event handlers.
|
* Request a graceful shutdown. Called by extension tools and event handlers.
|
||||||
* The actual shutdown behavior is provided by the mode via initialize().
|
* The actual shutdown behavior is provided by the mode via bindExtensions().
|
||||||
*/
|
*/
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
this.shutdownHandler();
|
this.shutdownHandler();
|
||||||
|
|
@ -359,7 +368,7 @@ export class ExtensionRunner {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an ExtensionContext for use in event handlers and tool execution.
|
* Create an ExtensionContext for use in event handlers and tool execution.
|
||||||
* Context values are resolved at call time, so changes via initialize() are reflected.
|
* Context values are resolved at call time, so changes via bindCore/bindUI are reflected.
|
||||||
*/
|
*/
|
||||||
createContext(): ExtensionContext {
|
createContext(): ExtensionContext {
|
||||||
const getModel = this.getModel;
|
const getModel = this.getModel;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,11 @@ export class FooterDataProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal: clear extension statuses */
|
||||||
|
clearExtensionStatuses(): void {
|
||||||
|
this.extensionStatuses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/** Internal: cleanup */
|
/** Internal: cleanup */
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.gitWatcher) {
|
if (this.gitWatcher) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { type Static, Type } from "@sinclair/typebox";
|
||||||
import AjvModule from "ajv";
|
import AjvModule from "ajv";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { getAgentDir } from "../config.js";
|
||||||
import type { AuthStorage } from "./auth-storage.js";
|
import type { AuthStorage } from "./auth-storage.js";
|
||||||
|
|
||||||
const Ajv = (AjvModule as any).default || AjvModule;
|
const Ajv = (AjvModule as any).default || AjvModule;
|
||||||
|
|
@ -159,7 +161,7 @@ export class ModelRegistry {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly authStorage: AuthStorage,
|
readonly authStorage: AuthStorage,
|
||||||
private modelsJsonPath: string | undefined = undefined,
|
private modelsJsonPath: string | undefined = join(getAgentDir(), "models.json"),
|
||||||
) {
|
) {
|
||||||
// Set up fallback resolver for custom provider API keys
|
// Set up fallback resolver for custom provider API keys
|
||||||
this.authStorage.setFallbackResolver((provider) => {
|
this.authStorage.setFallbackResolver((provider) => {
|
||||||
|
|
|
||||||
536
packages/coding-agent/src/core/package-manager.ts
Normal file
536
packages/coding-agent/src/core/package-manager.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir, tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { CONFIG_DIR_NAME } from "../config.js";
|
||||||
|
import type { SettingsManager } from "./settings-manager.js";
|
||||||
|
|
||||||
|
export interface ResolvedPaths {
|
||||||
|
extensions: string[];
|
||||||
|
skills: string[];
|
||||||
|
prompts: string[];
|
||||||
|
themes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MissingSourceAction = "install" | "skip" | "error";
|
||||||
|
|
||||||
|
export interface PackageManager {
|
||||||
|
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
|
||||||
|
install(source: string, options?: { local?: boolean }): Promise<void>;
|
||||||
|
remove(source: string, options?: { local?: boolean }): Promise<void>;
|
||||||
|
update(source?: string): Promise<void>;
|
||||||
|
resolveExtensionSources(
|
||||||
|
sources: string[],
|
||||||
|
options?: { local?: boolean; temporary?: boolean },
|
||||||
|
): Promise<ResolvedPaths>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageManagerOptions {
|
||||||
|
cwd: string;
|
||||||
|
agentDir: string;
|
||||||
|
settingsManager: SettingsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceScope = "global" | "project" | "temporary";
|
||||||
|
|
||||||
|
type NpmSource = {
|
||||||
|
type: "npm";
|
||||||
|
spec: string;
|
||||||
|
name: string;
|
||||||
|
pinned: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitSource = {
|
||||||
|
type: "git";
|
||||||
|
repo: string;
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
ref?: string;
|
||||||
|
pinned: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocalSource = {
|
||||||
|
type: "local";
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedSource = NpmSource | GitSource | LocalSource;
|
||||||
|
|
||||||
|
interface PiManifest {
|
||||||
|
extensions?: string[];
|
||||||
|
skills?: string[];
|
||||||
|
prompts?: string[];
|
||||||
|
themes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceAccumulator {
|
||||||
|
extensions: Set<string>;
|
||||||
|
skills: Set<string>;
|
||||||
|
prompts: Set<string>;
|
||||||
|
themes: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DefaultPackageManager implements PackageManager {
|
||||||
|
private cwd: string;
|
||||||
|
private agentDir: string;
|
||||||
|
private settingsManager: SettingsManager;
|
||||||
|
private globalNpmRoot: string | undefined;
|
||||||
|
|
||||||
|
constructor(options: PackageManagerOptions) {
|
||||||
|
this.cwd = options.cwd;
|
||||||
|
this.agentDir = options.agentDir;
|
||||||
|
this.settingsManager = options.settingsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
|
||||||
|
const accumulator = this.createAccumulator();
|
||||||
|
const globalSettings = this.settingsManager.getGlobalSettings();
|
||||||
|
const projectSettings = this.settingsManager.getProjectSettings();
|
||||||
|
|
||||||
|
const extensionSources: Array<{ source: string; scope: SourceScope }> = [];
|
||||||
|
for (const source of globalSettings.extensions ?? []) {
|
||||||
|
extensionSources.push({ source, scope: "global" });
|
||||||
|
}
|
||||||
|
for (const source of projectSettings.extensions ?? []) {
|
||||||
|
extensionSources.push({ source, scope: "project" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.resolveExtensionSourcesInternal(extensionSources, accumulator, onMissing);
|
||||||
|
|
||||||
|
for (const skill of projectSettings.skills ?? []) {
|
||||||
|
this.addPath(accumulator.skills, this.resolvePath(skill));
|
||||||
|
}
|
||||||
|
for (const skill of globalSettings.skills ?? []) {
|
||||||
|
this.addPath(accumulator.skills, this.resolvePath(skill));
|
||||||
|
}
|
||||||
|
for (const prompt of projectSettings.prompts ?? []) {
|
||||||
|
this.addPath(accumulator.prompts, this.resolvePath(prompt));
|
||||||
|
}
|
||||||
|
for (const prompt of globalSettings.prompts ?? []) {
|
||||||
|
this.addPath(accumulator.prompts, this.resolvePath(prompt));
|
||||||
|
}
|
||||||
|
for (const theme of projectSettings.themes ?? []) {
|
||||||
|
this.addPath(accumulator.themes, this.resolvePath(theme));
|
||||||
|
}
|
||||||
|
for (const theme of globalSettings.themes ?? []) {
|
||||||
|
this.addPath(accumulator.themes, this.resolvePath(theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toResolvedPaths(accumulator);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveExtensionSources(
|
||||||
|
sources: string[],
|
||||||
|
options?: { local?: boolean; temporary?: boolean },
|
||||||
|
): Promise<ResolvedPaths> {
|
||||||
|
const accumulator = this.createAccumulator();
|
||||||
|
const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "global";
|
||||||
|
const extensionSources = sources.map((source) => ({ source, scope }));
|
||||||
|
await this.resolveExtensionSourcesInternal(extensionSources, accumulator);
|
||||||
|
return this.toResolvedPaths(accumulator);
|
||||||
|
}
|
||||||
|
|
||||||
|
async install(source: string, options?: { local?: boolean }): Promise<void> {
|
||||||
|
const parsed = this.parseSource(source);
|
||||||
|
const scope: SourceScope = options?.local ? "project" : "global";
|
||||||
|
if (parsed.type === "npm") {
|
||||||
|
await this.installNpm(parsed, scope, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === "git") {
|
||||||
|
await this.installGit(parsed, scope, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported install source: ${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(source: string, options?: { local?: boolean }): Promise<void> {
|
||||||
|
const parsed = this.parseSource(source);
|
||||||
|
const scope: SourceScope = options?.local ? "project" : "global";
|
||||||
|
if (parsed.type === "npm") {
|
||||||
|
await this.uninstallNpm(parsed, scope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === "git") {
|
||||||
|
await this.removeGit(parsed, scope, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported remove source: ${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(source?: string): Promise<void> {
|
||||||
|
if (source) {
|
||||||
|
await this.updateSourceForScope(source, "global");
|
||||||
|
await this.updateSourceForScope(source, "project");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalSettings = this.settingsManager.getGlobalSettings();
|
||||||
|
const projectSettings = this.settingsManager.getProjectSettings();
|
||||||
|
for (const extension of globalSettings.extensions ?? []) {
|
||||||
|
await this.updateSourceForScope(extension, "global");
|
||||||
|
}
|
||||||
|
for (const extension of projectSettings.extensions ?? []) {
|
||||||
|
await this.updateSourceForScope(extension, "project");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateSourceForScope(source: string, scope: SourceScope): Promise<void> {
|
||||||
|
const parsed = this.parseSource(source);
|
||||||
|
if (parsed.type === "npm") {
|
||||||
|
if (parsed.pinned) return;
|
||||||
|
await this.installNpm(parsed, scope, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === "git") {
|
||||||
|
if (parsed.pinned) return;
|
||||||
|
await this.updateGit(parsed, scope, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveExtensionSourcesInternal(
|
||||||
|
sources: Array<{ source: string; scope: SourceScope }>,
|
||||||
|
accumulator: ResourceAccumulator,
|
||||||
|
onMissing?: (source: string) => Promise<MissingSourceAction>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const { source, scope } of sources) {
|
||||||
|
const parsed = this.parseSource(source);
|
||||||
|
if (parsed.type === "local") {
|
||||||
|
this.resolveLocalExtensionSource(parsed, accumulator);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installMissing = async (): Promise<boolean> => {
|
||||||
|
if (!onMissing) {
|
||||||
|
await this.installParsedSource(parsed, scope);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const action = await onMissing(source);
|
||||||
|
if (action === "skip") return false;
|
||||||
|
if (action === "error") throw new Error(`Missing source: ${source}`);
|
||||||
|
await this.installParsedSource(parsed, scope);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsed.type === "npm") {
|
||||||
|
const installedPath = this.getNpmInstallPath(parsed, scope);
|
||||||
|
if (!existsSync(installedPath)) {
|
||||||
|
const installed = await installMissing();
|
||||||
|
if (!installed) continue;
|
||||||
|
}
|
||||||
|
this.collectPackageResources(installedPath, accumulator);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "git") {
|
||||||
|
const installedPath = this.getGitInstallPath(parsed, scope);
|
||||||
|
if (!existsSync(installedPath)) {
|
||||||
|
const installed = await installMissing();
|
||||||
|
if (!installed) continue;
|
||||||
|
}
|
||||||
|
this.collectPackageResources(installedPath, accumulator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLocalExtensionSource(source: LocalSource, accumulator: ResourceAccumulator): void {
|
||||||
|
const resolved = this.resolvePath(source.path);
|
||||||
|
if (!existsSync(resolved)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(resolved);
|
||||||
|
if (stats.isFile()) {
|
||||||
|
this.addPath(accumulator.extensions, resolved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const resources = this.collectPackageResources(resolved, accumulator);
|
||||||
|
if (!resources) {
|
||||||
|
this.addPath(accumulator.extensions, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {
|
||||||
|
if (parsed.type === "npm") {
|
||||||
|
await this.installNpm(parsed, scope, scope === "temporary");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === "git") {
|
||||||
|
await this.installGit(parsed, scope, scope === "temporary");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSource(source: string): ParsedSource {
|
||||||
|
if (source.startsWith("npm:")) {
|
||||||
|
const spec = source.slice("npm:".length).trim();
|
||||||
|
const { name, version } = this.parseNpmSpec(spec);
|
||||||
|
return {
|
||||||
|
type: "npm",
|
||||||
|
spec,
|
||||||
|
name,
|
||||||
|
pinned: Boolean(version),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.startsWith("git:")) {
|
||||||
|
const repoSpec = source.slice("git:".length).trim();
|
||||||
|
const [repo, ref] = repoSpec.split("@");
|
||||||
|
const normalized = repo.replace(/^https?:\/\//, "");
|
||||||
|
const parts = normalized.split("/");
|
||||||
|
const host = parts.shift() ?? "";
|
||||||
|
const repoPath = parts.join("/");
|
||||||
|
return {
|
||||||
|
type: "git",
|
||||||
|
repo: normalized,
|
||||||
|
host,
|
||||||
|
path: repoPath,
|
||||||
|
ref,
|
||||||
|
pinned: Boolean(ref),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "local", path: source };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseNpmSpec(spec: string): { name: string; version?: string } {
|
||||||
|
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
|
||||||
|
if (!match) {
|
||||||
|
return { name: spec };
|
||||||
|
}
|
||||||
|
const name = match[1] ?? spec;
|
||||||
|
const version = match[2];
|
||||||
|
return { name, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
|
||||||
|
if (scope === "global" && !temporary) {
|
||||||
|
await this.runCommand("npm", ["install", "-g", source.spec]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const installRoot = this.getNpmInstallRoot(scope, temporary);
|
||||||
|
this.ensureNpmProject(installRoot);
|
||||||
|
await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
|
||||||
|
if (scope === "global") {
|
||||||
|
await this.runCommand("npm", ["uninstall", "-g", source.name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const installRoot = this.getNpmInstallRoot(scope, false);
|
||||||
|
if (!existsSync(installRoot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise<void> {
|
||||||
|
const targetDir = this.getGitInstallPath(source, scope, temporary);
|
||||||
|
if (existsSync(targetDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(targetDir), { recursive: true });
|
||||||
|
const cloneUrl = source.repo.startsWith("http") ? source.repo : `https://${source.repo}`;
|
||||||
|
await this.runCommand("git", ["clone", cloneUrl, targetDir]);
|
||||||
|
if (source.ref) {
|
||||||
|
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise<void> {
|
||||||
|
const targetDir = this.getGitInstallPath(source, scope, temporary);
|
||||||
|
if (!existsSync(targetDir)) {
|
||||||
|
await this.installGit(source, scope, temporary);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.runCommand("git", ["pull"], { cwd: targetDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeGit(source: GitSource, scope: SourceScope, temporary: boolean): Promise<void> {
|
||||||
|
const targetDir = this.getGitInstallPath(source, scope, temporary);
|
||||||
|
if (!existsSync(targetDir)) return;
|
||||||
|
rmSync(targetDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureNpmProject(installRoot: string): void {
|
||||||
|
if (!existsSync(installRoot)) {
|
||||||
|
mkdirSync(installRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
const packageJsonPath = join(installRoot, "package.json");
|
||||||
|
if (!existsSync(packageJsonPath)) {
|
||||||
|
const pkgJson = { name: "pi-extensions", private: true };
|
||||||
|
writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string {
|
||||||
|
if (temporary) {
|
||||||
|
return this.getTemporaryDir("npm");
|
||||||
|
}
|
||||||
|
if (scope === "project") {
|
||||||
|
return join(this.cwd, CONFIG_DIR_NAME, "npm");
|
||||||
|
}
|
||||||
|
return join(this.getGlobalNpmRoot(), "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGlobalNpmRoot(): string {
|
||||||
|
if (this.globalNpmRoot) {
|
||||||
|
return this.globalNpmRoot;
|
||||||
|
}
|
||||||
|
const result = this.runCommandSync("npm", ["root", "-g"]);
|
||||||
|
this.globalNpmRoot = result.trim();
|
||||||
|
return this.globalNpmRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
|
||||||
|
if (scope === "temporary") {
|
||||||
|
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
|
||||||
|
}
|
||||||
|
if (scope === "project") {
|
||||||
|
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
|
||||||
|
}
|
||||||
|
return join(this.getGlobalNpmRoot(), source.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGitInstallPath(source: GitSource, scope: SourceScope, temporary?: boolean): string {
|
||||||
|
if (temporary) {
|
||||||
|
return this.getTemporaryDir(`git-${source.host}`, source.path);
|
||||||
|
}
|
||||||
|
if (scope === "project") {
|
||||||
|
return join(this.cwd, CONFIG_DIR_NAME, "git", source.host, source.path);
|
||||||
|
}
|
||||||
|
return join(this.agentDir, "git", source.host, source.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTemporaryDir(prefix: string, suffix?: string): string {
|
||||||
|
const hash = createHash("sha256")
|
||||||
|
.update(`${prefix}-${suffix ?? ""}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 8);
|
||||||
|
return join(tmpdir(), "pi-extensions", prefix, hash, suffix ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePath(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed === "~") return homedir();
|
||||||
|
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||||
|
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
|
||||||
|
return resolve(this.cwd, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectPackageResources(packageRoot: string, accumulator: ResourceAccumulator): boolean {
|
||||||
|
const manifest = this.readPiManifest(packageRoot);
|
||||||
|
if (manifest) {
|
||||||
|
this.addManifestEntries(manifest.extensions, packageRoot, accumulator.extensions);
|
||||||
|
this.addManifestEntries(manifest.skills, packageRoot, accumulator.skills);
|
||||||
|
this.addManifestEntries(manifest.prompts, packageRoot, accumulator.prompts);
|
||||||
|
this.addManifestEntries(manifest.themes, packageRoot, accumulator.themes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionsDir = join(packageRoot, "extensions");
|
||||||
|
const skillsDir = join(packageRoot, "skills");
|
||||||
|
const promptsDir = join(packageRoot, "prompts");
|
||||||
|
const themesDir = join(packageRoot, "themes");
|
||||||
|
|
||||||
|
const hasAnyDir =
|
||||||
|
existsSync(extensionsDir) || existsSync(skillsDir) || existsSync(promptsDir) || existsSync(themesDir);
|
||||||
|
if (!hasAnyDir) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(extensionsDir)) {
|
||||||
|
this.addPath(accumulator.extensions, extensionsDir);
|
||||||
|
}
|
||||||
|
if (existsSync(skillsDir)) {
|
||||||
|
this.addPath(accumulator.skills, skillsDir);
|
||||||
|
}
|
||||||
|
if (existsSync(promptsDir)) {
|
||||||
|
this.addPath(accumulator.prompts, promptsDir);
|
||||||
|
}
|
||||||
|
if (existsSync(themesDir)) {
|
||||||
|
this.addPath(accumulator.themes, themesDir);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readPiManifest(packageRoot: string): PiManifest | null {
|
||||||
|
const packageJsonPath = join(packageRoot, "package.json");
|
||||||
|
if (!existsSync(packageJsonPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(packageJsonPath, "utf-8");
|
||||||
|
const pkg = JSON.parse(content) as { pi?: PiManifest };
|
||||||
|
return pkg.pi ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addManifestEntries(entries: string[] | undefined, root: string, target: Set<string>): void {
|
||||||
|
if (!entries) return;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const resolved = resolve(root, entry);
|
||||||
|
this.addPath(target, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addPath(set: Set<string>, value: string): void {
|
||||||
|
if (!value) return;
|
||||||
|
set.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAccumulator(): ResourceAccumulator {
|
||||||
|
return {
|
||||||
|
extensions: new Set<string>(),
|
||||||
|
skills: new Set<string>(),
|
||||||
|
prompts: new Set<string>(),
|
||||||
|
themes: new Set<string>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths {
|
||||||
|
return {
|
||||||
|
extensions: Array.from(accumulator.extensions),
|
||||||
|
skills: Array.from(accumulator.skills),
|
||||||
|
prompts: Array.from(accumulator.prompts),
|
||||||
|
themes: Array.from(accumulator.themes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: options?.cwd,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolvePromise();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private runCommandSync(command: string, args: string[]): string {
|
||||||
|
const result = spawnSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
return (result.stdout || result.stderr || "").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
||||||
import { join, resolve } from "path";
|
import { homedir } from "os";
|
||||||
|
import { basename, isAbsolute, join, resolve } from "path";
|
||||||
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
|
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
|
||||||
import { parseFrontmatter } from "../utils/frontmatter.js";
|
import { parseFrontmatter } from "../utils/frontmatter.js";
|
||||||
|
|
||||||
|
|
@ -10,7 +11,7 @@ export interface PromptTemplate {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
content: string;
|
content: string;
|
||||||
source: string; // e.g., "(user)", "(project)", "(project:frontend)"
|
source: string; // e.g., "(user)", "(project)", "(custom:my-dir)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,10 +98,42 @@ export function substituteArgs(content: string, args: string[]): string {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadTemplateFromFile(filePath: string, sourceLabel: string): PromptTemplate | null {
|
||||||
|
try {
|
||||||
|
const rawContent = readFileSync(filePath, "utf-8");
|
||||||
|
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
|
||||||
|
|
||||||
|
const name = basename(filePath).replace(/\.md$/, "");
|
||||||
|
|
||||||
|
// Get description from frontmatter or first non-empty line
|
||||||
|
let description = frontmatter.description || "";
|
||||||
|
if (!description) {
|
||||||
|
const firstLine = body.split("\n").find((line) => line.trim());
|
||||||
|
if (firstLine) {
|
||||||
|
// Truncate if too long
|
||||||
|
description = firstLine.slice(0, 60);
|
||||||
|
if (firstLine.length > 60) description += "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append source to description
|
||||||
|
description = description ? `${description} ${sourceLabel}` : sourceLabel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
content: body,
|
||||||
|
source: sourceLabel,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates
|
* Scan a directory for .md files (non-recursive) and load them as prompt templates.
|
||||||
*/
|
*/
|
||||||
function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: string = ""): PromptTemplate[] {
|
function loadTemplatesFromDir(dir: string, sourceLabel: string): PromptTemplate[] {
|
||||||
const templates: PromptTemplate[] = [];
|
const templates: PromptTemplate[] = [];
|
||||||
|
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
|
|
@ -113,13 +146,11 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry.name);
|
const fullPath = join(dir, entry.name);
|
||||||
|
|
||||||
// For symlinks, check if they point to a directory and follow them
|
// For symlinks, check if they point to a file
|
||||||
let isDirectory = entry.isDirectory();
|
|
||||||
let isFile = entry.isFile();
|
let isFile = entry.isFile();
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
try {
|
try {
|
||||||
const stats = statSync(fullPath);
|
const stats = statSync(fullPath);
|
||||||
isDirectory = stats.isDirectory();
|
|
||||||
isFile = stats.isFile();
|
isFile = stats.isFile();
|
||||||
} catch {
|
} catch {
|
||||||
// Broken symlink, skip it
|
// Broken symlink, skip it
|
||||||
|
|
@ -127,52 +158,15 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDirectory) {
|
if (isFile && entry.name.endsWith(".md")) {
|
||||||
// Recurse into subdirectory
|
const template = loadTemplateFromFile(fullPath, sourceLabel);
|
||||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
if (template) {
|
||||||
templates.push(...loadTemplatesFromDir(fullPath, source, newSubdir));
|
templates.push(template);
|
||||||
} else if (isFile && entry.name.endsWith(".md")) {
|
|
||||||
try {
|
|
||||||
const rawContent = readFileSync(fullPath, "utf-8");
|
|
||||||
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
|
|
||||||
|
|
||||||
const name = entry.name.slice(0, -3); // Remove .md extension
|
|
||||||
|
|
||||||
// Build source string
|
|
||||||
let sourceStr: string;
|
|
||||||
if (source === "user") {
|
|
||||||
sourceStr = subdir ? `(user:${subdir})` : "(user)";
|
|
||||||
} else {
|
|
||||||
sourceStr = subdir ? `(project:${subdir})` : "(project)";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get description from frontmatter or first non-empty line
|
|
||||||
let description = frontmatter.description || "";
|
|
||||||
if (!description) {
|
|
||||||
const firstLine = body.split("\n").find((line) => line.trim());
|
|
||||||
if (firstLine) {
|
|
||||||
// Truncate if too long
|
|
||||||
description = firstLine.slice(0, 60);
|
|
||||||
if (firstLine.length > 60) description += "...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append source to description
|
|
||||||
description = description ? `${description} ${sourceStr}` : sourceStr;
|
|
||||||
|
|
||||||
templates.push({
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
content: body,
|
|
||||||
source: sourceStr,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
// Silently skip files that can't be read
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch {
|
||||||
// Silently skip directories that can't be read
|
return templates;
|
||||||
}
|
}
|
||||||
|
|
||||||
return templates;
|
return templates;
|
||||||
|
|
@ -183,27 +177,71 @@ export interface LoadPromptTemplatesOptions {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Agent config directory for global templates. Default: from getPromptsDir() */
|
/** Agent config directory for global templates. Default: from getPromptsDir() */
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
|
/** Explicit prompt template paths (files or directories) */
|
||||||
|
promptPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed === "~") return homedir();
|
||||||
|
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||||
|
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePromptPath(p: string, cwd: string): string {
|
||||||
|
const normalized = normalizePath(p);
|
||||||
|
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCustomSourceLabel(p: string): string {
|
||||||
|
const base = basename(p).replace(/\.md$/, "") || "custom";
|
||||||
|
return `(custom:${base})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all prompt templates from:
|
* Load all prompt templates from:
|
||||||
* 1. Global: agentDir/prompts/
|
* 1. Global: agentDir/prompts/
|
||||||
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
|
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
|
||||||
|
* 3. Explicit prompt paths
|
||||||
*/
|
*/
|
||||||
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
|
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
|
||||||
const resolvedCwd = options.cwd ?? process.cwd();
|
const resolvedCwd = options.cwd ?? process.cwd();
|
||||||
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
|
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
|
||||||
|
const promptPaths = options.promptPaths ?? [];
|
||||||
|
|
||||||
const templates: PromptTemplate[] = [];
|
const templates: PromptTemplate[] = [];
|
||||||
|
|
||||||
// 1. Load global templates from agentDir/prompts/
|
// 1. Load global templates from agentDir/prompts/
|
||||||
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
|
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
|
||||||
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
|
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
|
||||||
templates.push(...loadTemplatesFromDir(globalPromptsDir, "user"));
|
templates.push(...loadTemplatesFromDir(globalPromptsDir, "(user)"));
|
||||||
|
|
||||||
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
|
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
|
||||||
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
|
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
|
||||||
templates.push(...loadTemplatesFromDir(projectPromptsDir, "project"));
|
templates.push(...loadTemplatesFromDir(projectPromptsDir, "(project)"));
|
||||||
|
|
||||||
|
// 3. Load explicit prompt paths
|
||||||
|
for (const rawPath of promptPaths) {
|
||||||
|
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(resolvedPath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
templates.push(...loadTemplatesFromDir(resolvedPath, buildCustomSourceLabel(resolvedPath)));
|
||||||
|
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
|
||||||
|
const template = loadTemplateFromFile(resolvedPath, buildCustomSourceLabel(resolvedPath));
|
||||||
|
if (template) {
|
||||||
|
templates.push(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore read failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return templates;
|
return templates;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
516
packages/coding-agent/src/core/resource-loader.ts
Normal file
516
packages/coding-agent/src/core/resource-loader.ts
Normal file
|
|
@ -0,0 +1,516 @@
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
|
||||||
|
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
|
||||||
|
import { createEventBus, type EventBus } from "./event-bus.js";
|
||||||
|
import {
|
||||||
|
createExtensionRuntime,
|
||||||
|
discoverAndLoadExtensions,
|
||||||
|
loadExtensionFromFactory,
|
||||||
|
loadExtensions,
|
||||||
|
} from "./extensions/loader.js";
|
||||||
|
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js";
|
||||||
|
import { DefaultPackageManager } from "./package-manager.js";
|
||||||
|
import type { PromptTemplate } from "./prompt-templates.js";
|
||||||
|
import { loadPromptTemplates } from "./prompt-templates.js";
|
||||||
|
import { SettingsManager } from "./settings-manager.js";
|
||||||
|
import type { Skill, SkillWarning } from "./skills.js";
|
||||||
|
import { loadSkills } from "./skills.js";
|
||||||
|
|
||||||
|
export interface ResourceDiagnostic {
|
||||||
|
type: "warning" | "error";
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceLoader {
|
||||||
|
getExtensions(): LoadExtensionsResult;
|
||||||
|
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||||
|
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
|
||||||
|
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
|
||||||
|
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> };
|
||||||
|
getSystemPrompt(): string | undefined;
|
||||||
|
getAppendSystemPrompt(): string[];
|
||||||
|
reload(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
|
||||||
|
if (!input) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(input)) {
|
||||||
|
try {
|
||||||
|
return readFileSync(input, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
|
||||||
|
const candidates = ["AGENTS.md", "CLAUDE.md"];
|
||||||
|
for (const filename of candidates) {
|
||||||
|
const filePath = join(dir, filename);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
content: readFileSync(filePath, "utf-8"),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProjectContextFiles(
|
||||||
|
options: { cwd?: string; agentDir?: string } = {},
|
||||||
|
): Array<{ path: string; content: string }> {
|
||||||
|
const resolvedCwd = options.cwd ?? process.cwd();
|
||||||
|
const resolvedAgentDir = options.agentDir ?? getAgentDir();
|
||||||
|
|
||||||
|
const contextFiles: Array<{ path: string; content: string }> = [];
|
||||||
|
const seenPaths = new Set<string>();
|
||||||
|
|
||||||
|
const globalContext = loadContextFileFromDir(resolvedAgentDir);
|
||||||
|
if (globalContext) {
|
||||||
|
contextFiles.push(globalContext);
|
||||||
|
seenPaths.add(globalContext.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
||||||
|
|
||||||
|
let currentDir = resolvedCwd;
|
||||||
|
const root = resolve("/");
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const contextFile = loadContextFileFromDir(currentDir);
|
||||||
|
if (contextFile && !seenPaths.has(contextFile.path)) {
|
||||||
|
ancestorContextFiles.unshift(contextFile);
|
||||||
|
seenPaths.add(contextFile.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDir === root) break;
|
||||||
|
|
||||||
|
const parentDir = resolve(currentDir, "..");
|
||||||
|
if (parentDir === currentDir) break;
|
||||||
|
currentDir = parentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
contextFiles.push(...ancestorContextFiles);
|
||||||
|
|
||||||
|
return contextFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DefaultResourceLoaderOptions {
|
||||||
|
cwd?: string;
|
||||||
|
agentDir?: string;
|
||||||
|
settingsManager?: SettingsManager;
|
||||||
|
eventBus?: EventBus;
|
||||||
|
additionalExtensionPaths?: string[];
|
||||||
|
additionalSkillPaths?: string[];
|
||||||
|
additionalPromptTemplatePaths?: string[];
|
||||||
|
additionalThemePaths?: string[];
|
||||||
|
extensionFactories?: ExtensionFactory[];
|
||||||
|
noExtensions?: boolean;
|
||||||
|
noSkills?: boolean;
|
||||||
|
noPromptTemplates?: boolean;
|
||||||
|
noThemes?: boolean;
|
||||||
|
systemPrompt?: string;
|
||||||
|
appendSystemPrompt?: string;
|
||||||
|
extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
|
||||||
|
skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||||
|
skills: Skill[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
};
|
||||||
|
promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||||
|
prompts: PromptTemplate[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
};
|
||||||
|
themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||||
|
themes: Theme[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
};
|
||||||
|
agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
|
||||||
|
agentsFiles: Array<{ path: string; content: string }>;
|
||||||
|
};
|
||||||
|
systemPromptOverride?: (base: string | undefined) => string | undefined;
|
||||||
|
appendSystemPromptOverride?: (base: string[]) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DefaultResourceLoader implements ResourceLoader {
|
||||||
|
private cwd: string;
|
||||||
|
private agentDir: string;
|
||||||
|
private settingsManager: SettingsManager;
|
||||||
|
private eventBus: EventBus;
|
||||||
|
private packageManager: DefaultPackageManager;
|
||||||
|
private additionalExtensionPaths: string[];
|
||||||
|
private additionalSkillPaths: string[];
|
||||||
|
private additionalPromptTemplatePaths: string[];
|
||||||
|
private additionalThemePaths: string[];
|
||||||
|
private extensionFactories: ExtensionFactory[];
|
||||||
|
private noExtensions: boolean;
|
||||||
|
private noSkills: boolean;
|
||||||
|
private noPromptTemplates: boolean;
|
||||||
|
private noThemes: boolean;
|
||||||
|
private systemPromptSource?: string;
|
||||||
|
private appendSystemPromptSource?: string;
|
||||||
|
private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;
|
||||||
|
private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||||
|
skills: Skill[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
};
|
||||||
|
private promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||||
|
prompts: PromptTemplate[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
};
|
||||||
|
private themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||||
|
themes: Theme[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
};
|
||||||
|
private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {
|
||||||
|
agentsFiles: Array<{ path: string; content: string }>;
|
||||||
|
};
|
||||||
|
private systemPromptOverride?: (base: string | undefined) => string | undefined;
|
||||||
|
private appendSystemPromptOverride?: (base: string[]) => string[];
|
||||||
|
|
||||||
|
private extensionsResult: LoadExtensionsResult;
|
||||||
|
private skills: Skill[];
|
||||||
|
private skillDiagnostics: ResourceDiagnostic[];
|
||||||
|
private prompts: PromptTemplate[];
|
||||||
|
private promptDiagnostics: ResourceDiagnostic[];
|
||||||
|
private themes: Theme[];
|
||||||
|
private themeDiagnostics: ResourceDiagnostic[];
|
||||||
|
private agentsFiles: Array<{ path: string; content: string }>;
|
||||||
|
private systemPrompt?: string;
|
||||||
|
private appendSystemPrompt: string[];
|
||||||
|
|
||||||
|
constructor(options: DefaultResourceLoaderOptions) {
|
||||||
|
this.cwd = options.cwd ?? process.cwd();
|
||||||
|
this.agentDir = options.agentDir ?? getAgentDir();
|
||||||
|
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
|
||||||
|
this.eventBus = options.eventBus ?? createEventBus();
|
||||||
|
this.packageManager = new DefaultPackageManager({
|
||||||
|
cwd: this.cwd,
|
||||||
|
agentDir: this.agentDir,
|
||||||
|
settingsManager: this.settingsManager,
|
||||||
|
});
|
||||||
|
this.additionalExtensionPaths = options.additionalExtensionPaths ?? [];
|
||||||
|
this.additionalSkillPaths = options.additionalSkillPaths ?? [];
|
||||||
|
this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? [];
|
||||||
|
this.additionalThemePaths = options.additionalThemePaths ?? [];
|
||||||
|
this.extensionFactories = options.extensionFactories ?? [];
|
||||||
|
this.noExtensions = options.noExtensions ?? false;
|
||||||
|
this.noSkills = options.noSkills ?? false;
|
||||||
|
this.noPromptTemplates = options.noPromptTemplates ?? false;
|
||||||
|
this.noThemes = options.noThemes ?? false;
|
||||||
|
this.systemPromptSource = options.systemPrompt;
|
||||||
|
this.appendSystemPromptSource = options.appendSystemPrompt;
|
||||||
|
this.extensionsOverride = options.extensionsOverride;
|
||||||
|
this.skillsOverride = options.skillsOverride;
|
||||||
|
this.promptsOverride = options.promptsOverride;
|
||||||
|
this.themesOverride = options.themesOverride;
|
||||||
|
this.agentsFilesOverride = options.agentsFilesOverride;
|
||||||
|
this.systemPromptOverride = options.systemPromptOverride;
|
||||||
|
this.appendSystemPromptOverride = options.appendSystemPromptOverride;
|
||||||
|
|
||||||
|
this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
||||||
|
this.skills = [];
|
||||||
|
this.skillDiagnostics = [];
|
||||||
|
this.prompts = [];
|
||||||
|
this.promptDiagnostics = [];
|
||||||
|
this.themes = [];
|
||||||
|
this.themeDiagnostics = [];
|
||||||
|
this.agentsFiles = [];
|
||||||
|
this.appendSystemPrompt = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getExtensions(): LoadExtensionsResult {
|
||||||
|
return this.extensionsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
|
||||||
|
return { skills: this.skills, diagnostics: this.skillDiagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {
|
||||||
|
return { prompts: this.prompts, diagnostics: this.promptDiagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
|
||||||
|
return { themes: this.themes, diagnostics: this.themeDiagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } {
|
||||||
|
return { agentsFiles: this.agentsFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
getSystemPrompt(): string | undefined {
|
||||||
|
return this.systemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppendSystemPrompt(): string[] {
|
||||||
|
return this.appendSystemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
const resolvedPaths = await this.packageManager.resolve();
|
||||||
|
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||||
|
temporary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const extensionPaths = this.noExtensions
|
||||||
|
? cliExtensionPaths.extensions
|
||||||
|
: this.mergePaths(resolvedPaths.extensions, cliExtensionPaths.extensions);
|
||||||
|
|
||||||
|
let extensionsResult: LoadExtensionsResult;
|
||||||
|
if (this.noExtensions) {
|
||||||
|
extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||||
|
} else {
|
||||||
|
extensionsResult = await discoverAndLoadExtensions(extensionPaths, this.cwd, this.agentDir, this.eventBus);
|
||||||
|
}
|
||||||
|
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||||
|
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||||
|
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||||
|
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
|
||||||
|
|
||||||
|
const skillPaths = this.noSkills
|
||||||
|
? this.mergePaths(cliExtensionPaths.skills, this.additionalSkillPaths)
|
||||||
|
: this.mergePaths([...resolvedPaths.skills, ...cliExtensionPaths.skills], this.additionalSkillPaths);
|
||||||
|
|
||||||
|
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||||
|
if (this.noSkills && skillPaths.length === 0) {
|
||||||
|
skillsResult = { skills: [], diagnostics: [] };
|
||||||
|
} else {
|
||||||
|
const result = loadSkills({
|
||||||
|
cwd: this.cwd,
|
||||||
|
agentDir: this.agentDir,
|
||||||
|
skillPaths,
|
||||||
|
});
|
||||||
|
skillsResult = { skills: result.skills, diagnostics: this.toDiagnostics(result.warnings) };
|
||||||
|
}
|
||||||
|
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
|
||||||
|
this.skills = resolvedSkills.skills;
|
||||||
|
this.skillDiagnostics = resolvedSkills.diagnostics;
|
||||||
|
|
||||||
|
const promptPaths = this.noPromptTemplates
|
||||||
|
? this.mergePaths(cliExtensionPaths.prompts, this.additionalPromptTemplatePaths)
|
||||||
|
: this.mergePaths(
|
||||||
|
[...resolvedPaths.prompts, ...cliExtensionPaths.prompts],
|
||||||
|
this.additionalPromptTemplatePaths,
|
||||||
|
);
|
||||||
|
|
||||||
|
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
|
||||||
|
if (this.noPromptTemplates && promptPaths.length === 0) {
|
||||||
|
promptsResult = { prompts: [], diagnostics: [] };
|
||||||
|
} else {
|
||||||
|
promptsResult = {
|
||||||
|
prompts: loadPromptTemplates({
|
||||||
|
cwd: this.cwd,
|
||||||
|
agentDir: this.agentDir,
|
||||||
|
promptPaths,
|
||||||
|
}),
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;
|
||||||
|
this.prompts = resolvedPrompts.prompts;
|
||||||
|
this.promptDiagnostics = resolvedPrompts.diagnostics;
|
||||||
|
|
||||||
|
const themePaths = this.noThemes
|
||||||
|
? this.mergePaths(cliExtensionPaths.themes, this.additionalThemePaths)
|
||||||
|
: this.mergePaths([...resolvedPaths.themes, ...cliExtensionPaths.themes], this.additionalThemePaths);
|
||||||
|
|
||||||
|
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
|
||||||
|
if (this.noThemes && themePaths.length === 0) {
|
||||||
|
themesResult = { themes: [], diagnostics: [] };
|
||||||
|
} else {
|
||||||
|
themesResult = this.loadThemes(themePaths);
|
||||||
|
}
|
||||||
|
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;
|
||||||
|
this.themes = resolvedThemes.themes;
|
||||||
|
this.themeDiagnostics = resolvedThemes.diagnostics;
|
||||||
|
|
||||||
|
const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) };
|
||||||
|
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
|
||||||
|
this.agentsFiles = resolvedAgentsFiles.agentsFiles;
|
||||||
|
|
||||||
|
const baseSystemPrompt = resolvePromptInput(
|
||||||
|
this.systemPromptSource ?? this.discoverSystemPromptFile(),
|
||||||
|
"system prompt",
|
||||||
|
);
|
||||||
|
this.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt;
|
||||||
|
|
||||||
|
const appendSource = this.appendSystemPromptSource ?? this.discoverAppendSystemPromptFile();
|
||||||
|
const resolvedAppend = resolvePromptInput(appendSource, "append system prompt");
|
||||||
|
const baseAppend = resolvedAppend ? [resolvedAppend] : [];
|
||||||
|
this.appendSystemPrompt = this.appendSystemPromptOverride
|
||||||
|
? this.appendSystemPromptOverride(baseAppend)
|
||||||
|
: baseAppend;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergePaths(primary: string[], additional: string[]): string[] {
|
||||||
|
const merged: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const p of [...primary, ...additional]) {
|
||||||
|
const resolved = this.resolveResourcePath(p);
|
||||||
|
if (seen.has(resolved)) continue;
|
||||||
|
seen.add(resolved);
|
||||||
|
merged.push(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveResourcePath(p: string): string {
|
||||||
|
const trimmed = p.trim();
|
||||||
|
let expanded = trimmed;
|
||||||
|
if (trimmed === "~") {
|
||||||
|
expanded = homedir();
|
||||||
|
} else if (trimmed.startsWith("~/")) {
|
||||||
|
expanded = join(homedir(), trimmed.slice(2));
|
||||||
|
} else if (trimmed.startsWith("~")) {
|
||||||
|
expanded = join(homedir(), trimmed.slice(1));
|
||||||
|
}
|
||||||
|
return resolve(this.cwd, expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadThemes(paths: string[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
|
||||||
|
const themes: Theme[] = [];
|
||||||
|
const diagnostics: ResourceDiagnostic[] = [];
|
||||||
|
const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")];
|
||||||
|
|
||||||
|
for (const dir of defaultDirs) {
|
||||||
|
this.loadThemesFromDir(dir, themes, diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of paths) {
|
||||||
|
const resolved = resolve(this.cwd, p);
|
||||||
|
if (!existsSync(resolved)) {
|
||||||
|
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(resolved);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
this.loadThemesFromDir(resolved, themes, diagnostics);
|
||||||
|
} else if (stats.isFile() && resolved.endsWith(".json")) {
|
||||||
|
this.loadThemeFromFile(resolved, themes, diagnostics);
|
||||||
|
} else {
|
||||||
|
diagnostics.push({ type: "warning", message: "theme path is not a json file", path: resolved });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "failed to read theme path";
|
||||||
|
diagnostics.push({ type: "warning", message, path: resolved });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { themes, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadThemesFromDir(dir: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
let isFile = entry.isFile();
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
try {
|
||||||
|
isFile = statSync(join(dir, entry.name)).isFile();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isFile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.name.endsWith(".json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "failed to read theme directory";
|
||||||
|
diagnostics.push({ type: "warning", message, path: dir });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadThemeFromFile(filePath: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {
|
||||||
|
try {
|
||||||
|
themes.push(loadThemeFromPath(filePath));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "failed to load theme";
|
||||||
|
diagnostics.push({ type: "warning", message, path: filePath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{
|
||||||
|
extensions: Extension[];
|
||||||
|
errors: Array<{ path: string; error: string }>;
|
||||||
|
}> {
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
const errors: Array<{ path: string; error: string }> = [];
|
||||||
|
|
||||||
|
for (const [index, factory] of this.extensionFactories.entries()) {
|
||||||
|
const extensionPath = `<inline:${index + 1}>`;
|
||||||
|
try {
|
||||||
|
const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);
|
||||||
|
extensions.push(extension);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "failed to load extension";
|
||||||
|
errors.push({ path: extensionPath, error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { extensions, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDiagnostics(warnings: SkillWarning[]): ResourceDiagnostic[] {
|
||||||
|
return warnings.map((warning) => ({
|
||||||
|
type: "warning",
|
||||||
|
message: warning.message,
|
||||||
|
path: warning.skillPath,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private discoverSystemPromptFile(): string | undefined {
|
||||||
|
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
|
||||||
|
if (existsSync(projectPath)) {
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalPath = join(this.agentDir, "SYSTEM.md");
|
||||||
|
if (existsSync(globalPath)) {
|
||||||
|
return globalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private discoverAppendSystemPromptFile(): string | undefined {
|
||||||
|
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
|
||||||
|
if (existsSync(projectPath)) {
|
||||||
|
return projectPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
|
||||||
|
if (existsSync(globalPath)) {
|
||||||
|
return globalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,59 +1,21 @@
|
||||||
/**
|
import { join } from "node:path";
|
||||||
* SDK for programmatic usage of AgentSession.
|
import { Agent, type AgentMessage, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||||
*
|
|
||||||
* Provides a factory function and discovery helpers that allow full control
|
|
||||||
* over agent configuration, or sensible defaults that match CLI behavior.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Minimal - everything auto-discovered
|
|
||||||
* const session = await createAgentSession();
|
|
||||||
*
|
|
||||||
* // Full control
|
|
||||||
* const session = await createAgentSession({
|
|
||||||
* model: myModel,
|
|
||||||
* getApiKey: async () => process.env.MY_KEY,
|
|
||||||
* tools: [readTool, bashTool],
|
|
||||||
* skills: [],
|
|
||||||
* sessionFile: false,
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
||||||
import type { Message, Model } from "@mariozechner/pi-ai";
|
import type { Message, Model } from "@mariozechner/pi-ai";
|
||||||
import { join } from "path";
|
|
||||||
import { getAgentDir, getAuthPath } from "../config.js";
|
import { getAgentDir, getAuthPath } from "../config.js";
|
||||||
import { AgentSession } from "./agent-session.js";
|
import { AgentSession } from "./agent-session.js";
|
||||||
import { AuthStorage } from "./auth-storage.js";
|
import { AuthStorage } from "./auth-storage.js";
|
||||||
import { createEventBus, type EventBus } from "./event-bus.js";
|
import type { ExtensionRunner, LoadExtensionsResult, ToolDefinition } from "./extensions/index.js";
|
||||||
import {
|
|
||||||
createExtensionRuntime,
|
|
||||||
discoverAndLoadExtensions,
|
|
||||||
type ExtensionFactory,
|
|
||||||
ExtensionRunner,
|
|
||||||
type LoadExtensionsResult,
|
|
||||||
loadExtensionFromFactory,
|
|
||||||
type ToolDefinition,
|
|
||||||
wrapRegisteredTools,
|
|
||||||
wrapToolsWithExtensions,
|
|
||||||
} from "./extensions/index.js";
|
|
||||||
import { convertToLlm } from "./messages.js";
|
import { convertToLlm } from "./messages.js";
|
||||||
import { ModelRegistry } from "./model-registry.js";
|
import { ModelRegistry } from "./model-registry.js";
|
||||||
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates.js";
|
import type { ResourceLoader } from "./resource-loader.js";
|
||||||
|
import { DefaultResourceLoader } from "./resource-loader.js";
|
||||||
import { SessionManager } from "./session-manager.js";
|
import { SessionManager } from "./session-manager.js";
|
||||||
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
|
import { SettingsManager } from "./settings-manager.js";
|
||||||
import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills.js";
|
|
||||||
import {
|
|
||||||
buildSystemPrompt as buildSystemPromptInternal,
|
|
||||||
loadProjectContextFiles as loadContextFilesInternal,
|
|
||||||
} from "./system-prompt.js";
|
|
||||||
import { time } from "./timings.js";
|
import { time } from "./timings.js";
|
||||||
import {
|
import {
|
||||||
allTools,
|
allTools,
|
||||||
bashTool,
|
bashTool,
|
||||||
codingTools,
|
codingTools,
|
||||||
createAllTools,
|
|
||||||
createBashTool,
|
createBashTool,
|
||||||
createCodingTools,
|
createCodingTools,
|
||||||
createEditTool,
|
createEditTool,
|
||||||
|
|
@ -74,17 +36,15 @@ import {
|
||||||
writeTool,
|
writeTool,
|
||||||
} from "./tools/index.js";
|
} from "./tools/index.js";
|
||||||
|
|
||||||
// Types
|
|
||||||
|
|
||||||
export interface CreateAgentSessionOptions {
|
export interface CreateAgentSessionOptions {
|
||||||
/** Working directory for project-local discovery. Default: process.cwd() */
|
/** Working directory for project-local discovery. Default: process.cwd() */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Global config directory. Default: ~/.pi/agent */
|
/** Global config directory. Default: ~/.pi/agent */
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
|
|
||||||
/** Auth storage for credentials. Default: discoverAuthStorage(agentDir) */
|
/** Auth storage for credentials. Default: new AuthStorage(agentDir/auth.json) */
|
||||||
authStorage?: AuthStorage;
|
authStorage?: AuthStorage;
|
||||||
/** Model registry. Default: discoverModels(authStorage, agentDir) */
|
/** Model registry. Default: new ModelRegistry(authStorage, agentDir/models.json) */
|
||||||
modelRegistry?: ModelRegistry;
|
modelRegistry?: ModelRegistry;
|
||||||
|
|
||||||
/** Model to use. Default: from settings, else first available */
|
/** Model to use. Default: from settings, else first available */
|
||||||
|
|
@ -94,32 +54,13 @@ export interface CreateAgentSessionOptions {
|
||||||
/** Models available for cycling (Ctrl+P in interactive mode) */
|
/** Models available for cycling (Ctrl+P in interactive mode) */
|
||||||
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||||
|
|
||||||
/** System prompt. String replaces default, function receives default and returns final. */
|
|
||||||
systemPrompt?: string | ((defaultPrompt: string) => string);
|
|
||||||
|
|
||||||
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
|
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
|
||||||
tools?: Tool[];
|
tools?: Tool[];
|
||||||
/** Custom tools to register (in addition to built-in tools). */
|
/** Custom tools to register (in addition to built-in tools). */
|
||||||
customTools?: ToolDefinition[];
|
customTools?: ToolDefinition[];
|
||||||
/** Inline extensions. When provided (even if empty), skips file discovery. */
|
|
||||||
extensions?: ExtensionFactory[];
|
|
||||||
/** Additional extension paths to load (merged with discovery). */
|
|
||||||
additionalExtensionPaths?: string[];
|
|
||||||
/**
|
|
||||||
* Pre-loaded extensions result (skips file discovery).
|
|
||||||
* @internal Used by CLI when extensions are loaded early to parse custom flags.
|
|
||||||
*/
|
|
||||||
preloadedExtensions?: LoadExtensionsResult;
|
|
||||||
|
|
||||||
/** Shared event bus for tool/extension communication. Default: creates new bus. */
|
/** Resource loader. When omitted, DefaultResourceLoader is used. */
|
||||||
eventBus?: EventBus;
|
resourceLoader?: ResourceLoader;
|
||||||
|
|
||||||
/** Skills. Default: discovered from multiple locations */
|
|
||||||
skills?: Skill[];
|
|
||||||
/** Context files (AGENTS.md content). Default: discovered walking up from cwd */
|
|
||||||
contextFiles?: Array<{ path: string; content: string }>;
|
|
||||||
/** Prompt templates. Default: discovered from cwd/.pi/prompts/ + agentDir/prompts/ */
|
|
||||||
promptTemplates?: PromptTemplate[];
|
|
||||||
|
|
||||||
/** Session manager. Default: SessionManager.create(cwd) */
|
/** Session manager. Default: SessionManager.create(cwd) */
|
||||||
sessionManager?: SessionManager;
|
sessionManager?: SessionManager;
|
||||||
|
|
@ -148,7 +89,6 @@ export type {
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
} from "./extensions/index.js";
|
} from "./extensions/index.js";
|
||||||
export type { PromptTemplate } from "./prompt-templates.js";
|
export type { PromptTemplate } from "./prompt-templates.js";
|
||||||
export type { Settings, SkillsSettings } from "./settings-manager.js";
|
|
||||||
export type { Skill } from "./skills.js";
|
export type { Skill } from "./skills.js";
|
||||||
export type { Tool } from "./tools/index.js";
|
export type { Tool } from "./tools/index.js";
|
||||||
|
|
||||||
|
|
@ -182,133 +122,6 @@ function getDefaultAgentDir(): string {
|
||||||
return getAgentDir();
|
return getAgentDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discovery Functions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an AuthStorage instance for the given agent directory.
|
|
||||||
*/
|
|
||||||
export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): AuthStorage {
|
|
||||||
return new AuthStorage(join(agentDir, "auth.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a ModelRegistry for the given agent directory.
|
|
||||||
*/
|
|
||||||
export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry {
|
|
||||||
return new ModelRegistry(authStorage, join(agentDir, "models.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover extensions from cwd and agentDir.
|
|
||||||
* @param eventBus - Shared event bus for extension communication. Pass to createAgentSession too.
|
|
||||||
* @param cwd - Current working directory
|
|
||||||
* @param agentDir - Agent configuration directory
|
|
||||||
*/
|
|
||||||
export async function discoverExtensions(
|
|
||||||
eventBus: EventBus,
|
|
||||||
cwd?: string,
|
|
||||||
agentDir?: string,
|
|
||||||
): Promise<LoadExtensionsResult> {
|
|
||||||
const resolvedCwd = cwd ?? process.cwd();
|
|
||||||
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
|
||||||
|
|
||||||
const result = await discoverAndLoadExtensions([], resolvedCwd, resolvedAgentDir, eventBus);
|
|
||||||
|
|
||||||
// Log errors but don't fail
|
|
||||||
for (const { path, error } of result.errors) {
|
|
||||||
console.error(`Failed to load extension "${path}": ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover skills from cwd and agentDir.
|
|
||||||
*/
|
|
||||||
export function discoverSkills(
|
|
||||||
cwd?: string,
|
|
||||||
agentDir?: string,
|
|
||||||
settings?: SkillsSettings,
|
|
||||||
): { skills: Skill[]; warnings: SkillWarning[] } {
|
|
||||||
return loadSkillsInternal({
|
|
||||||
...settings,
|
|
||||||
cwd: cwd ?? process.cwd(),
|
|
||||||
agentDir: agentDir ?? getDefaultAgentDir(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover context files (AGENTS.md) walking up from cwd.
|
|
||||||
*/
|
|
||||||
export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ path: string; content: string }> {
|
|
||||||
return loadContextFilesInternal({
|
|
||||||
cwd: cwd ?? process.cwd(),
|
|
||||||
agentDir: agentDir ?? getDefaultAgentDir(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover prompt templates from cwd and agentDir.
|
|
||||||
*/
|
|
||||||
export function discoverPromptTemplates(cwd?: string, agentDir?: string): PromptTemplate[] {
|
|
||||||
return loadPromptTemplatesInternal({
|
|
||||||
cwd: cwd ?? process.cwd(),
|
|
||||||
agentDir: agentDir ?? getDefaultAgentDir(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Key Helpers
|
|
||||||
|
|
||||||
// System Prompt
|
|
||||||
|
|
||||||
export interface BuildSystemPromptOptions {
|
|
||||||
tools?: Tool[];
|
|
||||||
skills?: Skill[];
|
|
||||||
contextFiles?: Array<{ path: string; content: string }>;
|
|
||||||
cwd?: string;
|
|
||||||
appendPrompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the default system prompt.
|
|
||||||
*/
|
|
||||||
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string {
|
|
||||||
return buildSystemPromptInternal({
|
|
||||||
cwd: options.cwd,
|
|
||||||
skills: options.skills,
|
|
||||||
contextFiles: options.contextFiles,
|
|
||||||
appendSystemPrompt: options.appendPrompt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load settings from agentDir/settings.json merged with cwd/.pi/settings.json.
|
|
||||||
*/
|
|
||||||
export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
|
||||||
const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
|
|
||||||
return {
|
|
||||||
defaultProvider: manager.getDefaultProvider(),
|
|
||||||
defaultModel: manager.getDefaultModel(),
|
|
||||||
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
|
|
||||||
steeringMode: manager.getSteeringMode(),
|
|
||||||
followUpMode: manager.getFollowUpMode(),
|
|
||||||
theme: manager.getTheme(),
|
|
||||||
compaction: manager.getCompactionSettings(),
|
|
||||||
retry: manager.getRetrySettings(),
|
|
||||||
hideThinkingBlock: manager.getHideThinkingBlock(),
|
|
||||||
shellPath: manager.getShellPath(),
|
|
||||||
collapseChangelog: manager.getCollapseChangelog(),
|
|
||||||
extensions: manager.getExtensionPaths(),
|
|
||||||
skills: manager.getSkillsSettings(),
|
|
||||||
terminal: { showImages: manager.getShowImages() },
|
|
||||||
images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an AgentSession with the specified options.
|
* Create an AgentSession with the specified options.
|
||||||
*
|
*
|
||||||
|
|
@ -330,12 +143,16 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* // Full control
|
* // Full control
|
||||||
|
* const loader = new DefaultResourceLoader({
|
||||||
|
* cwd: process.cwd(),
|
||||||
|
* agentDir: getAgentDir(),
|
||||||
|
* settingsManager: SettingsManager.create(),
|
||||||
|
* });
|
||||||
|
* await loader.reload();
|
||||||
* const { session } = await createAgentSession({
|
* const { session } = await createAgentSession({
|
||||||
* model: myModel,
|
* model: myModel,
|
||||||
* getApiKey: async () => process.env.MY_KEY,
|
|
||||||
* systemPrompt: 'You are helpful.',
|
|
||||||
* tools: [readTool, bashTool],
|
* tools: [readTool, bashTool],
|
||||||
* skills: [],
|
* resourceLoader: loader,
|
||||||
* sessionManager: SessionManager.inMemory(),
|
* sessionManager: SessionManager.inMemory(),
|
||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
|
|
@ -343,21 +160,25 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
||||||
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
|
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
|
||||||
const cwd = options.cwd ?? process.cwd();
|
const cwd = options.cwd ?? process.cwd();
|
||||||
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
||||||
const eventBus = options.eventBus ?? createEventBus();
|
let resourceLoader = options.resourceLoader;
|
||||||
|
|
||||||
// Use provided or create AuthStorage and ModelRegistry
|
// Use provided or create AuthStorage and ModelRegistry
|
||||||
const authStorage = options.authStorage ?? discoverAuthStorage(agentDir);
|
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
|
||||||
const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir);
|
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
|
||||||
time("discoverModels");
|
const authStorage = options.authStorage ?? new AuthStorage(authPath);
|
||||||
|
const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage, modelsPath);
|
||||||
|
|
||||||
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
|
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
|
||||||
time("settingsManager");
|
|
||||||
const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
|
const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
|
||||||
time("sessionManager");
|
|
||||||
|
if (!resourceLoader) {
|
||||||
|
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
|
||||||
|
await resourceLoader.reload();
|
||||||
|
time("resourceLoader.reload");
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session has existing data to restore
|
// Check if session has existing data to restore
|
||||||
const existingSession = sessionManager.buildSessionContext();
|
const existingSession = sessionManager.buildSessionContext();
|
||||||
time("loadSession");
|
|
||||||
const hasExistingSession = existingSession.messages.length > 0;
|
const hasExistingSession = existingSession.messages.length > 0;
|
||||||
|
|
||||||
let model = options.model;
|
let model = options.model;
|
||||||
|
|
@ -394,7 +215,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time("findAvailableModel");
|
|
||||||
if (model) {
|
if (model) {
|
||||||
if (modelFallbackMessage) {
|
if (modelFallbackMessage) {
|
||||||
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
|
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
|
||||||
|
|
@ -422,161 +242,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
thinkingLevel = "off";
|
thinkingLevel = "off";
|
||||||
}
|
}
|
||||||
|
|
||||||
let skills: Skill[];
|
|
||||||
let skillWarnings: SkillWarning[];
|
|
||||||
if (options.skills !== undefined) {
|
|
||||||
skills = options.skills;
|
|
||||||
skillWarnings = [];
|
|
||||||
} else {
|
|
||||||
const discovered = discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
|
|
||||||
skills = discovered.skills;
|
|
||||||
skillWarnings = discovered.warnings;
|
|
||||||
}
|
|
||||||
time("discoverSkills");
|
|
||||||
|
|
||||||
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
|
|
||||||
time("discoverContextFiles");
|
|
||||||
|
|
||||||
const autoResizeImages = settingsManager.getImageAutoResize();
|
|
||||||
const shellCommandPrefix = settingsManager.getShellCommandPrefix();
|
|
||||||
// Create ALL built-in tools for the registry (extensions can enable any of them)
|
|
||||||
const allBuiltInToolsMap = createAllTools(cwd, {
|
|
||||||
read: { autoResizeImages },
|
|
||||||
bash: { commandPrefix: shellCommandPrefix },
|
|
||||||
});
|
|
||||||
// Determine initially active built-in tools (default: read, bash, edit, write)
|
|
||||||
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
|
const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"];
|
||||||
const initialActiveToolNames: ToolName[] = options.tools
|
const initialActiveToolNames: ToolName[] = options.tools
|
||||||
? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap)
|
? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allTools)
|
||||||
: defaultActiveToolNames;
|
: defaultActiveToolNames;
|
||||||
const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]);
|
|
||||||
time("createAllTools");
|
|
||||||
|
|
||||||
// Load extensions (discovers from standard locations + configured paths)
|
|
||||||
let extensionsResult: LoadExtensionsResult;
|
|
||||||
if (options.preloadedExtensions !== undefined) {
|
|
||||||
// Use pre-loaded extensions (from early CLI flag discovery)
|
|
||||||
extensionsResult = options.preloadedExtensions;
|
|
||||||
} else if (options.extensions !== undefined) {
|
|
||||||
// User explicitly provided extensions array (even if empty) - skip discovery
|
|
||||||
// Create runtime for inline extensions
|
|
||||||
const runtime = createExtensionRuntime();
|
|
||||||
extensionsResult = {
|
|
||||||
extensions: [],
|
|
||||||
errors: [],
|
|
||||||
runtime,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Discover extensions, merging with additional paths
|
|
||||||
const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])];
|
|
||||||
extensionsResult = await discoverAndLoadExtensions(configuredPaths, cwd, agentDir, eventBus);
|
|
||||||
time("discoverAndLoadExtensions");
|
|
||||||
for (const { path, error } of extensionsResult.errors) {
|
|
||||||
console.error(`Failed to load extension "${path}": ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load inline extensions from factories
|
|
||||||
if (options.extensions && options.extensions.length > 0) {
|
|
||||||
for (let i = 0; i < options.extensions.length; i++) {
|
|
||||||
const factory = options.extensions[i];
|
|
||||||
const loaded = await loadExtensionFromFactory(
|
|
||||||
factory,
|
|
||||||
cwd,
|
|
||||||
eventBus,
|
|
||||||
extensionsResult.runtime,
|
|
||||||
`<inline-${i}>`,
|
|
||||||
);
|
|
||||||
extensionsResult.extensions.push(loaded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create extension runner if we have extensions or SDK custom tools
|
|
||||||
// The runner provides consistent context for tool execution (shutdown, abort, etc.)
|
|
||||||
let extensionRunner: ExtensionRunner | undefined;
|
|
||||||
const hasExtensions = extensionsResult.extensions.length > 0;
|
|
||||||
const hasCustomTools = options.customTools && options.customTools.length > 0;
|
|
||||||
if (hasExtensions || hasCustomTools) {
|
|
||||||
extensionRunner = new ExtensionRunner(
|
|
||||||
extensionsResult.extensions,
|
|
||||||
extensionsResult.runtime,
|
|
||||||
cwd,
|
|
||||||
sessionManager,
|
|
||||||
modelRegistry,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap extension-registered tools and SDK-provided custom tools
|
|
||||||
// Tools use runner.createContext() for consistent context with event handlers
|
|
||||||
let agent: Agent;
|
let agent: Agent;
|
||||||
const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
|
|
||||||
// Combine extension-registered tools with SDK-provided custom tools
|
|
||||||
const allCustomTools = [
|
|
||||||
...registeredTools,
|
|
||||||
...(options.customTools?.map((def) => ({ definition: def, extensionPath: "<sdk>" })) ?? []),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Wrap tools using runner's context (ensures shutdown, abort, etc. work correctly)
|
|
||||||
const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : [];
|
|
||||||
|
|
||||||
// Create tool registry mapping name -> tool (for extension getTools/setTools)
|
|
||||||
// Registry contains ALL built-in tools so extensions can enable any of them
|
|
||||||
const toolRegistry = new Map<string, AgentTool>();
|
|
||||||
for (const [name, tool] of Object.entries(allBuiltInToolsMap)) {
|
|
||||||
toolRegistry.set(name, tool as AgentTool);
|
|
||||||
}
|
|
||||||
for (const tool of wrappedExtensionTools as AgentTool[]) {
|
|
||||||
toolRegistry.set(tool.name, tool);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initially active tools = active built-in + extension tools
|
|
||||||
// Extension tools can override built-in tools with the same name
|
|
||||||
const extensionToolNames = new Set(wrappedExtensionTools.map((t) => t.name));
|
|
||||||
const nonOverriddenBuiltInTools = initialActiveBuiltInTools.filter((t) => !extensionToolNames.has(t.name));
|
|
||||||
let activeToolsArray: Tool[] = [...nonOverriddenBuiltInTools, ...wrappedExtensionTools];
|
|
||||||
time("combineTools");
|
|
||||||
|
|
||||||
// Wrap tools with extensions if available
|
|
||||||
let wrappedToolRegistry: Map<string, AgentTool> | undefined;
|
|
||||||
if (extensionRunner) {
|
|
||||||
activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner);
|
|
||||||
// Wrap ALL registry tools (not just active) so extensions can enable any
|
|
||||||
const allRegistryTools = Array.from(toolRegistry.values());
|
|
||||||
const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner);
|
|
||||||
wrappedToolRegistry = new Map<string, AgentTool>();
|
|
||||||
for (const tool of wrappedAllTools) {
|
|
||||||
wrappedToolRegistry.set(tool.name, tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to rebuild system prompt when tools change
|
|
||||||
// Captures static options (cwd, agentDir, skills, contextFiles, customPrompt)
|
|
||||||
const rebuildSystemPrompt = (toolNames: string[]): string => {
|
|
||||||
// Filter to valid tool names
|
|
||||||
const validToolNames = toolNames.filter((n): n is ToolName => n in allBuiltInToolsMap);
|
|
||||||
const defaultPrompt = buildSystemPromptInternal({
|
|
||||||
cwd,
|
|
||||||
agentDir,
|
|
||||||
skills,
|
|
||||||
contextFiles,
|
|
||||||
selectedTools: validToolNames,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.systemPrompt === undefined) {
|
|
||||||
return defaultPrompt;
|
|
||||||
} else if (typeof options.systemPrompt === "string") {
|
|
||||||
// String is a full replacement - use as-is without appending context/skills
|
|
||||||
return options.systemPrompt;
|
|
||||||
} else {
|
|
||||||
return options.systemPrompt(defaultPrompt);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const systemPrompt = rebuildSystemPrompt(initialActiveToolNames);
|
|
||||||
time("buildSystemPrompt");
|
|
||||||
|
|
||||||
const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir);
|
|
||||||
time("discoverPromptTemplates");
|
|
||||||
|
|
||||||
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
|
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
|
||||||
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
|
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
|
||||||
|
|
@ -615,20 +286,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extensionRunnerRef: { current?: ExtensionRunner } = {};
|
||||||
|
|
||||||
agent = new Agent({
|
agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt,
|
systemPrompt: "",
|
||||||
model,
|
model,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
tools: activeToolsArray,
|
tools: [],
|
||||||
},
|
},
|
||||||
convertToLlm: convertToLlmWithBlockImages,
|
convertToLlm: convertToLlmWithBlockImages,
|
||||||
sessionId: sessionManager.getSessionId(),
|
sessionId: sessionManager.getSessionId(),
|
||||||
transformContext: extensionRunner
|
transformContext: async (messages) => {
|
||||||
? async (messages) => {
|
const runner = extensionRunnerRef.current;
|
||||||
return extensionRunner.emitContext(messages);
|
if (!runner) return messages;
|
||||||
}
|
return runner.emitContext(messages);
|
||||||
: undefined,
|
},
|
||||||
steeringMode: settingsManager.getSteeringMode(),
|
steeringMode: settingsManager.getSteeringMode(),
|
||||||
followUpMode: settingsManager.getFollowUpMode(),
|
followUpMode: settingsManager.getFollowUpMode(),
|
||||||
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
||||||
|
|
@ -658,7 +331,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
time("createAgent");
|
|
||||||
|
|
||||||
// Restore messages if session has existing data
|
// Restore messages if session has existing data
|
||||||
if (hasExistingSession) {
|
if (hasExistingSession) {
|
||||||
|
|
@ -675,17 +347,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd,
|
||||||
scopedModels: options.scopedModels,
|
scopedModels: options.scopedModels,
|
||||||
promptTemplates: promptTemplates,
|
resourceLoader,
|
||||||
extensionRunner,
|
customTools: options.customTools,
|
||||||
skills,
|
|
||||||
skillWarnings,
|
|
||||||
skillsSettings: settingsManager.getSkillsSettings(),
|
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
toolRegistry: wrappedToolRegistry ?? toolRegistry,
|
initialActiveToolNames,
|
||||||
rebuildSystemPrompt,
|
extensionRunnerRef,
|
||||||
});
|
});
|
||||||
time("createAgentSession");
|
const extensionsResult = resourceLoader.getExtensions();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,6 @@ export interface RetrySettings {
|
||||||
baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
|
baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsSettings {
|
|
||||||
enabled?: boolean; // default: true
|
|
||||||
enableCodexUser?: boolean; // default: true
|
|
||||||
enableClaudeUser?: boolean; // default: true
|
|
||||||
enableClaudeProject?: boolean; // default: true
|
|
||||||
enablePiUser?: boolean; // default: true
|
|
||||||
enablePiProject?: boolean; // default: true
|
|
||||||
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
|
|
||||||
customDirectories?: string[]; // default: []
|
|
||||||
ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills)
|
|
||||||
includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalSettings {
|
export interface TerminalSettings {
|
||||||
showImages?: boolean; // default: true (only relevant if terminal supports images)
|
showImages?: boolean; // default: true (only relevant if terminal supports images)
|
||||||
}
|
}
|
||||||
|
|
@ -67,8 +54,11 @@ export interface Settings {
|
||||||
quietStartup?: boolean;
|
quietStartup?: boolean;
|
||||||
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
||||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||||
extensions?: string[]; // Array of extension file paths
|
extensions?: string[]; // Array of extension file paths or directories
|
||||||
skills?: SkillsSettings;
|
skills?: string[]; // Array of skill file paths or directories
|
||||||
|
prompts?: string[]; // Array of prompt template paths or directories
|
||||||
|
themes?: string[]; // Array of theme file paths or directories
|
||||||
|
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
|
||||||
terminal?: TerminalSettings;
|
terminal?: TerminalSettings;
|
||||||
images?: ImageSettings;
|
images?: ImageSettings;
|
||||||
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
|
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
|
||||||
|
|
@ -165,6 +155,27 @@ export class SettingsManager {
|
||||||
settings.steeringMode = settings.queueMode;
|
settings.steeringMode = settings.queueMode;
|
||||||
delete settings.queueMode;
|
delete settings.queueMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
"skills" in settings &&
|
||||||
|
typeof settings.skills === "object" &&
|
||||||
|
settings.skills !== null &&
|
||||||
|
!Array.isArray(settings.skills)
|
||||||
|
) {
|
||||||
|
const skillsSettings = settings.skills as {
|
||||||
|
enableSkillCommands?: boolean;
|
||||||
|
customDirectories?: unknown;
|
||||||
|
};
|
||||||
|
if (skillsSettings.enableSkillCommands !== undefined && settings.enableSkillCommands === undefined) {
|
||||||
|
settings.enableSkillCommands = skillsSettings.enableSkillCommands;
|
||||||
|
}
|
||||||
|
if (Array.isArray(skillsSettings.customDirectories) && skillsSettings.customDirectories.length > 0) {
|
||||||
|
settings.skills = skillsSettings.customDirectories;
|
||||||
|
} else {
|
||||||
|
delete settings.skills;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return settings as Settings;
|
return settings as Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +194,14 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getGlobalSettings(): Settings {
|
||||||
|
return structuredClone(this.globalSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectSettings(): Settings {
|
||||||
|
return this.loadProjectSettings();
|
||||||
|
}
|
||||||
|
|
||||||
/** Apply additional overrides on top of current settings */
|
/** Apply additional overrides on top of current settings */
|
||||||
applyOverrides(overrides: Partial<Settings>): void {
|
applyOverrides(overrides: Partial<Settings>): void {
|
||||||
this.settings = deepMergeSettings(this.settings, overrides);
|
this.settings = deepMergeSettings(this.settings, overrides);
|
||||||
|
|
@ -214,6 +233,21 @@ export class SettingsManager {
|
||||||
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
|
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private saveProjectSettings(settings: Settings): void {
|
||||||
|
if (!this.persist || !this.projectSettingsPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dir = dirname(this.projectSettingsPath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
writeFileSync(this.projectSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Warning: Could not save project settings file: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getLastChangelogVersion(): string | undefined {
|
getLastChangelogVersion(): string | undefined {
|
||||||
return this.settings.lastChangelogVersion;
|
return this.settings.lastChangelogVersion;
|
||||||
}
|
}
|
||||||
|
|
@ -391,42 +425,46 @@ export class SettingsManager {
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSkillsEnabled(): boolean {
|
setProjectExtensionPaths(paths: string[]): void {
|
||||||
return this.settings.skills?.enabled ?? true;
|
const projectSettings = this.loadProjectSettings();
|
||||||
|
projectSettings.extensions = paths;
|
||||||
|
this.saveProjectSettings(projectSettings);
|
||||||
|
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSkillsEnabled(enabled: boolean): void {
|
getSkillPaths(): string[] {
|
||||||
if (!this.globalSettings.skills) {
|
return [...(this.settings.skills ?? [])];
|
||||||
this.globalSettings.skills = {};
|
}
|
||||||
}
|
|
||||||
this.globalSettings.skills.enabled = enabled;
|
setSkillPaths(paths: string[]): void {
|
||||||
|
this.globalSettings.skills = paths;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSkillsSettings(): Required<SkillsSettings> {
|
getPromptTemplatePaths(): string[] {
|
||||||
return {
|
return [...(this.settings.prompts ?? [])];
|
||||||
enabled: this.settings.skills?.enabled ?? true,
|
}
|
||||||
enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
|
|
||||||
enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true,
|
setPromptTemplatePaths(paths: string[]): void {
|
||||||
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
|
this.globalSettings.prompts = paths;
|
||||||
enablePiUser: this.settings.skills?.enablePiUser ?? true,
|
this.save();
|
||||||
enablePiProject: this.settings.skills?.enablePiProject ?? true,
|
}
|
||||||
enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
|
|
||||||
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
|
getThemePaths(): string[] {
|
||||||
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
|
return [...(this.settings.themes ?? [])];
|
||||||
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
|
}
|
||||||
};
|
|
||||||
|
setThemePaths(paths: string[]): void {
|
||||||
|
this.globalSettings.themes = paths;
|
||||||
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
getEnableSkillCommands(): boolean {
|
getEnableSkillCommands(): boolean {
|
||||||
return this.settings.skills?.enableSkillCommands ?? true;
|
return this.settings.enableSkillCommands ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnableSkillCommands(enabled: boolean): void {
|
setEnableSkillCommands(enabled: boolean): void {
|
||||||
if (!this.globalSettings.skills) {
|
this.globalSettings.enableSkillCommands = enabled;
|
||||||
this.globalSettings.skills = {};
|
|
||||||
}
|
|
||||||
this.globalSettings.skills.enableSkillCommands = enabled;
|
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
|
||||||
import { minimatch } from "minimatch";
|
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { basename, dirname, join, resolve } from "path";
|
import { basename, dirname, isAbsolute, join, resolve } from "path";
|
||||||
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
|
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
|
||||||
import { parseFrontmatter } from "../utils/frontmatter.js";
|
import { parseFrontmatter } from "../utils/frontmatter.js";
|
||||||
import type { SkillsSettings } from "./settings-manager.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard frontmatter fields per Agent Skills spec.
|
* Standard frontmatter fields per Agent Skills spec.
|
||||||
|
|
@ -49,8 +47,6 @@ export interface LoadSkillsResult {
|
||||||
warnings: SkillWarning[];
|
warnings: SkillWarning[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkillFormat = "recursive" | "claude";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate skill name per Agent Skills spec.
|
* Validate skill name per Agent Skills spec.
|
||||||
* Returns array of validation error messages (empty if valid).
|
* Returns array of validation error messages (empty if valid).
|
||||||
|
|
@ -88,7 +84,7 @@ function validateDescription(description: string | undefined): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!description || description.trim() === "") {
|
if (!description || description.trim() === "") {
|
||||||
errors.push(`description is required`);
|
errors.push("description is required");
|
||||||
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
|
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||||
errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
|
errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
|
||||||
}
|
}
|
||||||
|
|
@ -117,15 +113,18 @@ export interface LoadSkillsFromDirOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load skills from a directory recursively.
|
* Load skills from a directory.
|
||||||
* Skills are directories containing a SKILL.md file with frontmatter including a description.
|
*
|
||||||
|
* Discovery rules:
|
||||||
|
* - direct .md children in the root
|
||||||
|
* - recursive SKILL.md under subdirectories
|
||||||
*/
|
*/
|
||||||
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
|
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
|
||||||
const { dir, source } = options;
|
const { dir, source } = options;
|
||||||
return loadSkillsFromDirInternal(dir, source, "recursive");
|
return loadSkillsFromDirInternal(dir, source, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFormat): LoadSkillsResult {
|
function loadSkillsFromDirInternal(dir: string, source: string, includeRootFiles: boolean): LoadSkillsResult {
|
||||||
const skills: Skill[] = [];
|
const skills: Skill[] = [];
|
||||||
const warnings: SkillWarning[] = [];
|
const warnings: SkillWarning[] = [];
|
||||||
|
|
||||||
|
|
@ -162,36 +161,28 @@ function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "recursive") {
|
if (isDirectory) {
|
||||||
// Recursive format: scan directories, look for SKILL.md files
|
const subResult = loadSkillsFromDirInternal(fullPath, source, false);
|
||||||
if (isDirectory) {
|
skills.push(...subResult.skills);
|
||||||
const subResult = loadSkillsFromDirInternal(fullPath, source, format);
|
warnings.push(...subResult.warnings);
|
||||||
skills.push(...subResult.skills);
|
continue;
|
||||||
warnings.push(...subResult.warnings);
|
|
||||||
} else if (isFile && entry.name === "SKILL.md") {
|
|
||||||
const result = loadSkillFromFile(fullPath, source);
|
|
||||||
if (result.skill) {
|
|
||||||
skills.push(result.skill);
|
|
||||||
}
|
|
||||||
warnings.push(...result.warnings);
|
|
||||||
}
|
|
||||||
} else if (format === "claude") {
|
|
||||||
// Claude format: only one level deep, each directory must contain SKILL.md
|
|
||||||
if (!isDirectory) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillFile = join(fullPath, "SKILL.md");
|
|
||||||
if (!existsSync(skillFile)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = loadSkillFromFile(skillFile, source);
|
|
||||||
if (result.skill) {
|
|
||||||
skills.push(result.skill);
|
|
||||||
}
|
|
||||||
warnings.push(...result.warnings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isFile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRootMd = includeRootFiles && entry.name.endsWith(".md");
|
||||||
|
const isSkillMd = !includeRootFiles && entry.name === "SKILL.md";
|
||||||
|
if (!isRootMd && !isSkillMd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = loadSkillFromFile(fullPath, source);
|
||||||
|
if (result.skill) {
|
||||||
|
skills.push(result.skill);
|
||||||
|
}
|
||||||
|
warnings.push(...result.warnings);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
|
@ -290,11 +281,26 @@ function escapeXml(str: string): string {
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadSkillsOptions extends SkillsSettings {
|
export interface LoadSkillsOptions {
|
||||||
/** Working directory for project-local skills. Default: process.cwd() */
|
/** Working directory for project-local skills. Default: process.cwd() */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Agent config directory for global skills. Default: ~/.pi/agent */
|
/** Agent config directory for global skills. Default: ~/.pi/agent */
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
|
/** Explicit skill paths (files or directories) */
|
||||||
|
skillPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed === "~") return homedir();
|
||||||
|
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||||
|
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSkillPath(p: string, cwd: string): string {
|
||||||
|
const normalized = normalizePath(p);
|
||||||
|
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -302,18 +308,7 @@ export interface LoadSkillsOptions extends SkillsSettings {
|
||||||
* Returns skills and any validation warnings.
|
* Returns skills and any validation warnings.
|
||||||
*/
|
*/
|
||||||
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
||||||
const {
|
const { cwd = process.cwd(), agentDir, skillPaths = [] } = options;
|
||||||
cwd = process.cwd(),
|
|
||||||
agentDir,
|
|
||||||
enableCodexUser = true,
|
|
||||||
enableClaudeUser = true,
|
|
||||||
enableClaudeProject = true,
|
|
||||||
enablePiUser = true,
|
|
||||||
enablePiProject = true,
|
|
||||||
customDirectories = [],
|
|
||||||
ignoredSkills = [],
|
|
||||||
includeSkills = [],
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Resolve agentDir - if not provided, use default from config
|
// Resolve agentDir - if not provided, use default from config
|
||||||
const resolvedAgentDir = agentDir ?? getAgentDir();
|
const resolvedAgentDir = agentDir ?? getAgentDir();
|
||||||
|
|
@ -323,30 +318,9 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
||||||
const allWarnings: SkillWarning[] = [];
|
const allWarnings: SkillWarning[] = [];
|
||||||
const collisionWarnings: SkillWarning[] = [];
|
const collisionWarnings: SkillWarning[] = [];
|
||||||
|
|
||||||
// Check if skill name matches any of the include patterns
|
|
||||||
function matchesIncludePatterns(name: string): boolean {
|
|
||||||
if (includeSkills.length === 0) return true; // No filter = include all
|
|
||||||
return includeSkills.some((pattern) => minimatch(name, pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if skill name matches any of the ignore patterns
|
|
||||||
function matchesIgnorePatterns(name: string): boolean {
|
|
||||||
if (ignoredSkills.length === 0) return false;
|
|
||||||
return ignoredSkills.some((pattern) => minimatch(name, pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSkills(result: LoadSkillsResult) {
|
function addSkills(result: LoadSkillsResult) {
|
||||||
allWarnings.push(...result.warnings);
|
allWarnings.push(...result.warnings);
|
||||||
for (const skill of result.skills) {
|
for (const skill of result.skills) {
|
||||||
// Apply ignore filter (glob patterns) - takes precedence over include
|
|
||||||
if (matchesIgnorePatterns(skill.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Apply include filter (glob patterns)
|
|
||||||
if (!matchesIncludePatterns(skill.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve symlinks to detect duplicate files
|
// Resolve symlinks to detect duplicate files
|
||||||
let realPath: string;
|
let realPath: string;
|
||||||
try {
|
try {
|
||||||
|
|
@ -373,23 +347,34 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableCodexUser) {
|
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
|
||||||
addSkills(loadSkillsFromDirInternal(join(homedir(), ".codex", "skills"), "codex-user", "recursive"));
|
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
|
||||||
}
|
|
||||||
if (enableClaudeUser) {
|
for (const rawPath of skillPaths) {
|
||||||
addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
|
const resolvedPath = resolveSkillPath(rawPath, cwd);
|
||||||
}
|
if (!existsSync(resolvedPath)) {
|
||||||
if (enableClaudeProject) {
|
allWarnings.push({ skillPath: resolvedPath, message: "skill path does not exist" });
|
||||||
addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude"));
|
continue;
|
||||||
}
|
}
|
||||||
if (enablePiUser) {
|
|
||||||
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive"));
|
try {
|
||||||
}
|
const stats = statSync(resolvedPath);
|
||||||
if (enablePiProject) {
|
if (stats.isDirectory()) {
|
||||||
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive"));
|
addSkills(loadSkillsFromDirInternal(resolvedPath, "custom", true));
|
||||||
}
|
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
|
||||||
for (const customDir of customDirectories) {
|
const result = loadSkillFromFile(resolvedPath, "custom");
|
||||||
addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
|
if (result.skill) {
|
||||||
|
addSkills({ skills: [result.skill], warnings: result.warnings });
|
||||||
|
} else {
|
||||||
|
allWarnings.push(...result.warnings);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allWarnings.push({ skillPath: resolvedPath, message: "skill path is not a markdown file" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "failed to read skill path";
|
||||||
|
allWarnings.push({ skillPath: resolvedPath, message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,11 @@
|
||||||
* System prompt construction and project context loading
|
* System prompt construction and project context loading
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import chalk from "chalk";
|
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { formatSkillsForPrompt, type Skill } from "./skills.js";
|
||||||
import { join, resolve } from "path";
|
|
||||||
import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
|
|
||||||
import type { SkillsSettings } from "./settings-manager.js";
|
|
||||||
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
|
|
||||||
import type { ToolName } from "./tools/index.js";
|
|
||||||
|
|
||||||
/** Tool descriptions for system prompt */
|
/** Tool descriptions for system prompt */
|
||||||
const toolDescriptions: Record<ToolName, string> = {
|
const toolDescriptions: Record<string, string> = {
|
||||||
read: "Read file contents",
|
read: "Read file contents",
|
||||||
bash: "Execute bash commands (ls, grep, find, etc.)",
|
bash: "Execute bash commands (ls, grep, find, etc.)",
|
||||||
edit: "Make surgical edits to files (find exact text and replace)",
|
edit: "Make surgical edits to files (find exact text and replace)",
|
||||||
|
|
@ -21,117 +16,18 @@ const toolDescriptions: Record<ToolName, string> = {
|
||||||
ls: "List directory contents",
|
ls: "List directory contents",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Resolve input as file path or literal string */
|
|
||||||
export function resolvePromptInput(input: string | undefined, description: string): string | undefined {
|
|
||||||
if (!input) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(input)) {
|
|
||||||
try {
|
|
||||||
return readFileSync(input, "utf-8");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md) */
|
|
||||||
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
|
|
||||||
const candidates = ["AGENTS.md", "CLAUDE.md"];
|
|
||||||
for (const filename of candidates) {
|
|
||||||
const filePath = join(dir, filename);
|
|
||||||
if (existsSync(filePath)) {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
path: filePath,
|
|
||||||
content: readFileSync(filePath, "utf-8"),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoadContextFilesOptions {
|
|
||||||
/** Working directory to start walking up from. Default: process.cwd() */
|
|
||||||
cwd?: string;
|
|
||||||
/** Agent config directory for global context. Default: from getAgentDir() */
|
|
||||||
agentDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all project context files in order:
|
|
||||||
* 1. Global: agentDir/AGENTS.md or CLAUDE.md
|
|
||||||
* 2. Parent directories (top-most first) down to cwd
|
|
||||||
* Each returns {path, content} for separate messages
|
|
||||||
*/
|
|
||||||
export function loadProjectContextFiles(
|
|
||||||
options: LoadContextFilesOptions = {},
|
|
||||||
): Array<{ path: string; content: string }> {
|
|
||||||
const resolvedCwd = options.cwd ?? process.cwd();
|
|
||||||
const resolvedAgentDir = options.agentDir ?? getAgentDir();
|
|
||||||
|
|
||||||
const contextFiles: Array<{ path: string; content: string }> = [];
|
|
||||||
const seenPaths = new Set<string>();
|
|
||||||
|
|
||||||
// 1. Load global context from agentDir
|
|
||||||
const globalContext = loadContextFileFromDir(resolvedAgentDir);
|
|
||||||
if (globalContext) {
|
|
||||||
contextFiles.push(globalContext);
|
|
||||||
seenPaths.add(globalContext.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Walk up from cwd to root, collecting all context files
|
|
||||||
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
|
||||||
|
|
||||||
let currentDir = resolvedCwd;
|
|
||||||
const root = resolve("/");
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const contextFile = loadContextFileFromDir(currentDir);
|
|
||||||
if (contextFile && !seenPaths.has(contextFile.path)) {
|
|
||||||
// Add to beginning so we get top-most parent first
|
|
||||||
ancestorContextFiles.unshift(contextFile);
|
|
||||||
seenPaths.add(contextFile.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop if we've reached root
|
|
||||||
if (currentDir === root) break;
|
|
||||||
|
|
||||||
// Move up one directory
|
|
||||||
const parentDir = resolve(currentDir, "..");
|
|
||||||
if (parentDir === currentDir) break; // Safety check
|
|
||||||
currentDir = parentDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ancestor files in order (top-most → cwd)
|
|
||||||
contextFiles.push(...ancestorContextFiles);
|
|
||||||
|
|
||||||
return contextFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BuildSystemPromptOptions {
|
export interface BuildSystemPromptOptions {
|
||||||
/** Custom system prompt (replaces default). */
|
/** Custom system prompt (replaces default). */
|
||||||
customPrompt?: string;
|
customPrompt?: string;
|
||||||
/** Tools to include in prompt. Default: [read, bash, edit, write] */
|
/** Tools to include in prompt. Default: [read, bash, edit, write] */
|
||||||
selectedTools?: ToolName[];
|
selectedTools?: string[];
|
||||||
/** Text to append to system prompt. */
|
/** Text to append to system prompt. */
|
||||||
appendSystemPrompt?: string;
|
appendSystemPrompt?: string;
|
||||||
/** Skills settings for discovery. */
|
|
||||||
skillsSettings?: SkillsSettings;
|
|
||||||
/** Working directory. Default: process.cwd() */
|
/** Working directory. Default: process.cwd() */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Agent config directory. Default: from getAgentDir() */
|
/** Pre-loaded context files. */
|
||||||
agentDir?: string;
|
|
||||||
/** Pre-loaded context files (skips discovery if provided). */
|
|
||||||
contextFiles?: Array<{ path: string; content: string }>;
|
contextFiles?: Array<{ path: string; content: string }>;
|
||||||
/** Pre-loaded skills (skips discovery if provided). */
|
/** Pre-loaded skills. */
|
||||||
skills?: Skill[];
|
skills?: Skill[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,15 +37,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||||
customPrompt,
|
customPrompt,
|
||||||
selectedTools,
|
selectedTools,
|
||||||
appendSystemPrompt,
|
appendSystemPrompt,
|
||||||
skillsSettings,
|
|
||||||
cwd,
|
cwd,
|
||||||
agentDir,
|
|
||||||
contextFiles: providedContextFiles,
|
contextFiles: providedContextFiles,
|
||||||
skills: providedSkills,
|
skills: providedSkills,
|
||||||
} = options;
|
} = options;
|
||||||
const resolvedCwd = cwd ?? process.cwd();
|
const resolvedCwd = cwd ?? process.cwd();
|
||||||
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
|
|
||||||
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateTime = now.toLocaleString("en-US", {
|
const dateTime = now.toLocaleString("en-US", {
|
||||||
|
|
@ -163,18 +55,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||||
timeZoneName: "short",
|
timeZoneName: "short",
|
||||||
});
|
});
|
||||||
|
|
||||||
const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : "";
|
const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
|
||||||
|
|
||||||
// Resolve context files: use provided or discover
|
const contextFiles = providedContextFiles ?? [];
|
||||||
const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd, agentDir });
|
const skills = providedSkills ?? [];
|
||||||
|
|
||||||
// Resolve skills: use provided or discover
|
if (customPrompt) {
|
||||||
const skills =
|
let prompt = customPrompt;
|
||||||
providedSkills ??
|
|
||||||
(skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd, agentDir }).skills : []);
|
|
||||||
|
|
||||||
if (resolvedCustomPrompt) {
|
|
||||||
let prompt = resolvedCustomPrompt;
|
|
||||||
|
|
||||||
if (appendSection) {
|
if (appendSection) {
|
||||||
prompt += appendSection;
|
prompt += appendSection;
|
||||||
|
|
@ -208,8 +95,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||||
const examplesPath = getExamplesPath();
|
const examplesPath = getExamplesPath();
|
||||||
|
|
||||||
// Build tools list based on selected tools
|
// Build tools list based on selected tools
|
||||||
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
|
const tools = selectedTools || ["read", "bash", "edit", "write"];
|
||||||
const toolsList = tools.length > 0 ? tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n") : "(none)";
|
const toolsList =
|
||||||
|
tools.length > 0 ? tools.map((t) => `- ${t}: ${toolDescriptions[t] ?? "Custom tool"}`).join("\n") : "(none)";
|
||||||
|
|
||||||
// Build guidelines based on which tools are actually available
|
// Build guidelines based on which tools are actually available
|
||||||
const guidelinesList: string[] = [];
|
const guidelinesList: string[] = [];
|
||||||
|
|
@ -222,16 +110,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||||
const hasLs = tools.includes("ls");
|
const hasLs = tools.includes("ls");
|
||||||
const hasRead = tools.includes("read");
|
const hasRead = tools.includes("read");
|
||||||
|
|
||||||
// Bash without edit/write = read-only bash mode
|
|
||||||
if (hasBash && !hasEdit && !hasWrite) {
|
|
||||||
guidelinesList.push(
|
|
||||||
"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// File exploration guidelines
|
// File exploration guidelines
|
||||||
if (hasBash && !hasGrep && !hasFind && !hasLs) {
|
if (hasBash && !hasGrep && !hasFind && !hasLs) {
|
||||||
guidelinesList.push("Use bash for file operations like ls, grep, find");
|
guidelinesList.push("Use bash for file operations like ls, rg, find");
|
||||||
} else if (hasBash && (hasGrep || hasFind || hasLs)) {
|
} else if (hasBash && (hasGrep || hasFind || hasLs)) {
|
||||||
guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
|
guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +132,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
||||||
guidelinesList.push("Use write only for new files or complete rewrites");
|
guidelinesList.push("Use write only for new files or complete rewrites");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output guideline (only when actually writing/executing)
|
// Output guideline (only when actually writing or executing)
|
||||||
if (hasEdit || hasWrite) {
|
if (hasEdit || hasWrite) {
|
||||||
guidelinesList.push(
|
guidelinesList.push(
|
||||||
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
|
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
|
||||||
|
|
@ -274,7 +155,7 @@ In addition to the tools above, you may have access to other custom tools depend
|
||||||
Guidelines:
|
Guidelines:
|
||||||
${guidelines}
|
${guidelines}
|
||||||
|
|
||||||
Pi documentation (only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
|
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
|
||||||
- Main documentation: ${readmePath}
|
- Main documentation: ${readmePath}
|
||||||
- Additional docs: ${docsPath}
|
- Additional docs: ${docsPath}
|
||||||
- Examples: ${examplesPath} (extensions, custom tools, SDK)
|
- Examples: ${examplesPath} (extensions, custom tools, SDK)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { getShellConfig, killProcessTree } from "../../utils/shell.js";
|
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
|
||||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -65,6 +65,7 @@ const defaultBashOperations: BashOperations = {
|
||||||
const child = spawn(shell, [...args, command], {
|
const child = spawn(shell, [...args, command], {
|
||||||
cwd,
|
cwd,
|
||||||
detached: true,
|
detached: true,
|
||||||
|
env: getShellEnv(),
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ export type {
|
||||||
WidgetPlacement,
|
WidgetPlacement,
|
||||||
} from "./core/extensions/index.js";
|
} from "./core/extensions/index.js";
|
||||||
export {
|
export {
|
||||||
|
createExtensionRuntime,
|
||||||
ExtensionRunner,
|
ExtensionRunner,
|
||||||
isBashToolResult,
|
isBashToolResult,
|
||||||
isEditToolResult,
|
isEditToolResult,
|
||||||
|
|
@ -115,10 +116,10 @@ export {
|
||||||
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
|
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
|
||||||
export { convertToLlm } from "./core/messages.js";
|
export { convertToLlm } from "./core/messages.js";
|
||||||
export { ModelRegistry } from "./core/model-registry.js";
|
export { ModelRegistry } from "./core/model-registry.js";
|
||||||
|
export type { ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
|
||||||
|
export { DefaultResourceLoader } from "./core/resource-loader.js";
|
||||||
// SDK for programmatic usage
|
// SDK for programmatic usage
|
||||||
export {
|
export {
|
||||||
type BuildSystemPromptOptions,
|
|
||||||
buildSystemPrompt,
|
|
||||||
type CreateAgentSessionOptions,
|
type CreateAgentSessionOptions,
|
||||||
type CreateAgentSessionResult,
|
type CreateAgentSessionResult,
|
||||||
// Factory
|
// Factory
|
||||||
|
|
@ -133,14 +134,6 @@ export {
|
||||||
createReadOnlyTools,
|
createReadOnlyTools,
|
||||||
createReadTool,
|
createReadTool,
|
||||||
createWriteTool,
|
createWriteTool,
|
||||||
// Discovery
|
|
||||||
discoverAuthStorage,
|
|
||||||
discoverContextFiles,
|
|
||||||
discoverExtensions,
|
|
||||||
discoverModels,
|
|
||||||
discoverPromptTemplates,
|
|
||||||
discoverSkills,
|
|
||||||
loadSettings,
|
|
||||||
type PromptTemplate,
|
type PromptTemplate,
|
||||||
// Pre-built tools (use process.cwd())
|
// Pre-built tools (use process.cwd())
|
||||||
readOnlyTools,
|
readOnlyTools,
|
||||||
|
|
@ -172,9 +165,7 @@ export {
|
||||||
type CompactionSettings,
|
type CompactionSettings,
|
||||||
type ImageSettings,
|
type ImageSettings,
|
||||||
type RetrySettings,
|
type RetrySettings,
|
||||||
type Settings,
|
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
type SkillsSettings,
|
|
||||||
} from "./core/settings-manager.js";
|
} from "./core/settings-manager.js";
|
||||||
// Skills
|
// Skills
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,23 @@
|
||||||
|
|
||||||
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { existsSync } from "fs";
|
|
||||||
import { join } from "path";
|
|
||||||
import { createInterface } from "readline";
|
import { createInterface } from "readline";
|
||||||
import { type Args, parseArgs, printHelp } from "./cli/args.js";
|
import { type Args, parseArgs, printHelp } from "./cli/args.js";
|
||||||
import { processFileArguments } from "./cli/file-processor.js";
|
import { processFileArguments } from "./cli/file-processor.js";
|
||||||
import { listModels } from "./cli/list-models.js";
|
import { listModels } from "./cli/list-models.js";
|
||||||
import { selectSession } from "./cli/session-picker.js";
|
import { selectSession } from "./cli/session-picker.js";
|
||||||
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
|
import { getAgentDir, getModelsPath, VERSION } from "./config.js";
|
||||||
import { createEventBus } from "./core/event-bus.js";
|
import { AuthStorage } from "./core/auth-storage.js";
|
||||||
import { exportFromFile } from "./core/export-html/index.js";
|
import { exportFromFile } from "./core/export-html/index.js";
|
||||||
import { discoverAndLoadExtensions, type LoadExtensionsResult, loadExtensions } from "./core/extensions/index.js";
|
import type { LoadExtensionsResult } from "./core/extensions/index.js";
|
||||||
import { KeybindingsManager } from "./core/keybindings.js";
|
import { KeybindingsManager } from "./core/keybindings.js";
|
||||||
import type { ModelRegistry } from "./core/model-registry.js";
|
import { ModelRegistry } from "./core/model-registry.js";
|
||||||
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
||||||
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk.js";
|
import { DefaultPackageManager } from "./core/package-manager.js";
|
||||||
|
import { DefaultResourceLoader } from "./core/resource-loader.js";
|
||||||
|
import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js";
|
||||||
import { SessionManager } from "./core/session-manager.js";
|
import { SessionManager } from "./core/session-manager.js";
|
||||||
import { SettingsManager } from "./core/settings-manager.js";
|
import { SettingsManager } from "./core/settings-manager.js";
|
||||||
import { resolvePromptInput } from "./core/system-prompt.js";
|
|
||||||
import { printTimings, time } from "./core/timings.js";
|
import { printTimings, time } from "./core/timings.js";
|
||||||
import { allTools } from "./core/tools/index.js";
|
import { allTools } from "./core/tools/index.js";
|
||||||
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
|
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
|
||||||
|
|
@ -54,6 +53,118 @@ async function readPipedStdin(): Promise<string | undefined> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PackageCommand = "install" | "remove" | "update";
|
||||||
|
|
||||||
|
interface PackageCommandOptions {
|
||||||
|
command: PackageCommand;
|
||||||
|
source?: string;
|
||||||
|
local: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePackageCommand(args: string[]): PackageCommandOptions | undefined {
|
||||||
|
const [command, ...rest] = args;
|
||||||
|
if (command !== "install" && command !== "remove" && command !== "update") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local = false;
|
||||||
|
const sources: string[] = [];
|
||||||
|
for (const arg of rest) {
|
||||||
|
if (arg === "-l" || arg === "--local") {
|
||||||
|
local = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sources.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command, source: sources[0], local };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtensionSource(source: string): { type: "npm" | "git" | "local"; key: string } {
|
||||||
|
if (source.startsWith("npm:")) {
|
||||||
|
const spec = source.slice("npm:".length).trim();
|
||||||
|
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/);
|
||||||
|
return { type: "npm", key: match?.[1] ?? spec };
|
||||||
|
}
|
||||||
|
if (source.startsWith("git:")) {
|
||||||
|
const repo = source.slice("git:".length).trim().split("@")[0] ?? "";
|
||||||
|
return { type: "git", key: repo.replace(/^https?:\/\//, "") };
|
||||||
|
}
|
||||||
|
return { type: "local", key: source };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourcesMatch(a: string, b: string): boolean {
|
||||||
|
const left = normalizeExtensionSource(a);
|
||||||
|
const right = normalizeExtensionSource(b);
|
||||||
|
return left.type === right.type && left.key === right.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExtensionSources(
|
||||||
|
settingsManager: SettingsManager,
|
||||||
|
source: string,
|
||||||
|
local: boolean,
|
||||||
|
action: "add" | "remove",
|
||||||
|
): void {
|
||||||
|
const currentSettings = local ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
|
||||||
|
const currentSources = currentSettings.extensions ?? [];
|
||||||
|
|
||||||
|
let nextSources: string[];
|
||||||
|
if (action === "add") {
|
||||||
|
const exists = currentSources.some((existing) => sourcesMatch(existing, source));
|
||||||
|
nextSources = exists ? currentSources : [...currentSources, source];
|
||||||
|
} else {
|
||||||
|
nextSources = currentSources.filter((existing) => !sourcesMatch(existing, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local) {
|
||||||
|
settingsManager.setProjectExtensionPaths(nextSources);
|
||||||
|
} else {
|
||||||
|
settingsManager.setExtensionPaths(nextSources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePackageCommand(args: string[]): Promise<boolean> {
|
||||||
|
const options = parsePackageCommand(args);
|
||||||
|
if (!options) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const agentDir = getAgentDir();
|
||||||
|
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||||
|
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||||
|
|
||||||
|
if (options.command === "install") {
|
||||||
|
if (!options.source) {
|
||||||
|
console.error(chalk.red("Missing install source."));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await packageManager.install(options.source, { local: options.local });
|
||||||
|
updateExtensionSources(settingsManager, options.source, options.local, "add");
|
||||||
|
console.log(`Installed ${options.source}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.command === "remove") {
|
||||||
|
if (!options.source) {
|
||||||
|
console.error(chalk.red("Missing remove source."));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await packageManager.remove(options.source, { local: options.local });
|
||||||
|
updateExtensionSources(settingsManager, options.source, options.local, "remove");
|
||||||
|
console.log(`Removed ${options.source}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await packageManager.update(options.source);
|
||||||
|
if (options.source) {
|
||||||
|
console.log(`Updated ${options.source}`);
|
||||||
|
} else {
|
||||||
|
console.log("Updated extensions");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareInitialMessage(
|
async function prepareInitialMessage(
|
||||||
parsed: Args,
|
parsed: Args,
|
||||||
autoResizeImages: boolean,
|
autoResizeImages: boolean,
|
||||||
|
|
@ -173,58 +284,15 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discover SYSTEM.md file if no CLI system prompt was provided */
|
|
||||||
function discoverSystemPromptFile(): string | undefined {
|
|
||||||
// Check project-local first: .pi/SYSTEM.md
|
|
||||||
const projectPath = join(process.cwd(), CONFIG_DIR_NAME, "SYSTEM.md");
|
|
||||||
if (existsSync(projectPath)) {
|
|
||||||
return projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to global: ~/.pi/agent/SYSTEM.md
|
|
||||||
const globalPath = join(getAgentDir(), "SYSTEM.md");
|
|
||||||
if (existsSync(globalPath)) {
|
|
||||||
return globalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discover APPEND_SYSTEM.md file if no CLI append system prompt was provided */
|
|
||||||
function discoverAppendSystemPromptFile(): string | undefined {
|
|
||||||
// Check project-local first: .pi/APPEND_SYSTEM.md
|
|
||||||
const projectPath = join(process.cwd(), CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
|
|
||||||
if (existsSync(projectPath)) {
|
|
||||||
return projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to global: ~/.pi/agent/APPEND_SYSTEM.md
|
|
||||||
const globalPath = join(getAgentDir(), "APPEND_SYSTEM.md");
|
|
||||||
if (existsSync(globalPath)) {
|
|
||||||
return globalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessionOptions(
|
function buildSessionOptions(
|
||||||
parsed: Args,
|
parsed: Args,
|
||||||
scopedModels: ScopedModel[],
|
scopedModels: ScopedModel[],
|
||||||
sessionManager: SessionManager | undefined,
|
sessionManager: SessionManager | undefined,
|
||||||
modelRegistry: ModelRegistry,
|
modelRegistry: ModelRegistry,
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
extensionsResult?: LoadExtensionsResult,
|
|
||||||
): CreateAgentSessionOptions {
|
): CreateAgentSessionOptions {
|
||||||
const options: CreateAgentSessionOptions = {};
|
const options: CreateAgentSessionOptions = {};
|
||||||
|
|
||||||
// Auto-discover SYSTEM.md if no CLI system prompt provided
|
|
||||||
const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
|
|
||||||
// Auto-discover APPEND_SYSTEM.md if no CLI append system prompt provided
|
|
||||||
const appendSystemPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
|
|
||||||
|
|
||||||
const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt");
|
|
||||||
const resolvedAppendPrompt = resolvePromptInput(appendSystemPromptSource, "append system prompt");
|
|
||||||
|
|
||||||
if (sessionManager) {
|
if (sessionManager) {
|
||||||
options.sessionManager = sessionManager;
|
options.sessionManager = sessionManager;
|
||||||
}
|
}
|
||||||
|
|
@ -276,15 +344,6 @@ function buildSessionOptions(
|
||||||
// API key from CLI - set in authStorage
|
// API key from CLI - set in authStorage
|
||||||
// (handled by caller before createAgentSession)
|
// (handled by caller before createAgentSession)
|
||||||
|
|
||||||
// System prompt
|
|
||||||
if (resolvedSystemPrompt && resolvedAppendPrompt) {
|
|
||||||
options.systemPrompt = `${resolvedSystemPrompt}\n\n${resolvedAppendPrompt}`;
|
|
||||||
} else if (resolvedSystemPrompt) {
|
|
||||||
options.systemPrompt = resolvedSystemPrompt;
|
|
||||||
} else if (resolvedAppendPrompt) {
|
|
||||||
options.systemPrompt = (defaultPrompt) => `${defaultPrompt}\n\n${resolvedAppendPrompt}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tools
|
// Tools
|
||||||
if (parsed.noTools) {
|
if (parsed.noTools) {
|
||||||
// --no-tools: start with no built-in tools
|
// --no-tools: start with no built-in tools
|
||||||
|
|
@ -298,60 +357,52 @@ function buildSessionOptions(
|
||||||
options.tools = parsed.tools.map((name) => allTools[name]);
|
options.tools = parsed.tools.map((name) => allTools[name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills
|
|
||||||
if (parsed.noSkills) {
|
|
||||||
options.skills = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-loaded extensions (from early CLI flag discovery)
|
|
||||||
if (extensionsResult) {
|
|
||||||
options.preloadedExtensions = extensionsResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function main(args: string[]) {
|
export async function main(args: string[]) {
|
||||||
time("start");
|
if (await handlePackageCommand(args)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Run migrations (pass cwd for project-local migrations)
|
// Run migrations (pass cwd for project-local migrations)
|
||||||
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
|
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
|
||||||
|
|
||||||
// Create AuthStorage and ModelRegistry upfront
|
// Create AuthStorage and ModelRegistry upfront
|
||||||
const authStorage = discoverAuthStorage();
|
const authStorage = new AuthStorage();
|
||||||
const modelRegistry = discoverModels(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
time("discoverModels");
|
|
||||||
|
|
||||||
// First pass: parse args to get --extension paths
|
// First pass: parse args to get --extension paths
|
||||||
const firstPass = parseArgs(args);
|
const firstPass = parseArgs(args);
|
||||||
time("parseArgs-firstPass");
|
|
||||||
|
|
||||||
// Early load extensions to discover their CLI flags (unless --no-extensions)
|
// Early load extensions to discover their CLI flags
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const agentDir = getAgentDir();
|
const agentDir = getAgentDir();
|
||||||
const eventBus = createEventBus();
|
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||||
const settingsManager = SettingsManager.create(cwd);
|
|
||||||
time("SettingsManager.create");
|
|
||||||
|
|
||||||
let extensionsResult: LoadExtensionsResult;
|
const resourceLoader = new DefaultResourceLoader({
|
||||||
if (firstPass.noExtensions) {
|
cwd,
|
||||||
// --no-extensions disables discovery, but explicit -e flags still work
|
agentDir,
|
||||||
const explicitPaths = firstPass.extensions ?? [];
|
settingsManager,
|
||||||
extensionsResult = await loadExtensions(explicitPaths, cwd, eventBus);
|
additionalExtensionPaths: firstPass.extensions,
|
||||||
time("loadExtensions");
|
additionalSkillPaths: firstPass.skills,
|
||||||
} else {
|
additionalPromptTemplatePaths: firstPass.promptTemplates,
|
||||||
// Merge CLI --extension args with settings.json extensions
|
additionalThemePaths: firstPass.themes,
|
||||||
const extensionPaths = [...settingsManager.getExtensionPaths(), ...(firstPass.extensions ?? [])];
|
noExtensions: firstPass.noExtensions,
|
||||||
extensionsResult = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus);
|
noSkills: firstPass.noSkills,
|
||||||
time("discoverExtensionFlags");
|
noPromptTemplates: firstPass.noPromptTemplates,
|
||||||
}
|
noThemes: firstPass.noThemes,
|
||||||
|
systemPrompt: firstPass.systemPrompt,
|
||||||
|
appendSystemPrompt: firstPass.appendSystemPrompt,
|
||||||
|
});
|
||||||
|
await resourceLoader.reload();
|
||||||
|
time("resourceLoader.reload");
|
||||||
|
|
||||||
// Log extension loading errors
|
const extensionsResult: LoadExtensionsResult = resourceLoader.getExtensions();
|
||||||
for (const { path, error } of extensionsResult.errors) {
|
for (const { path, error } of extensionsResult.errors) {
|
||||||
console.error(chalk.red(`Failed to load extension "${path}": ${error}`));
|
console.error(chalk.red(`Failed to load extension "${path}": ${error}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all extension flags
|
|
||||||
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
|
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
|
||||||
for (const ext of extensionsResult.extensions) {
|
for (const ext of extensionsResult.extensions) {
|
||||||
for (const [name, flag] of ext.flags) {
|
for (const [name, flag] of ext.flags) {
|
||||||
|
|
@ -361,7 +412,6 @@ export async function main(args: string[]) {
|
||||||
|
|
||||||
// Second pass: parse args with extension flags
|
// Second pass: parse args with extension flags
|
||||||
const parsed = parseArgs(args, extensionFlags);
|
const parsed = parseArgs(args, extensionFlags);
|
||||||
time("parseArgs");
|
|
||||||
|
|
||||||
// Pass flag values to extensions via runtime
|
// Pass flag values to extensions via runtime
|
||||||
for (const [name, value] of parsed.unknownFlags) {
|
for (const [name, value] of parsed.unknownFlags) {
|
||||||
|
|
@ -393,7 +443,6 @@ export async function main(args: string[]) {
|
||||||
// Prepend stdin content to messages
|
// Prepend stdin content to messages
|
||||||
parsed.messages.unshift(stdinContent);
|
parsed.messages.unshift(stdinContent);
|
||||||
}
|
}
|
||||||
time("readPipedStdin");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.export) {
|
if (parsed.export) {
|
||||||
|
|
@ -415,11 +464,9 @@ export async function main(args: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
|
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
|
||||||
time("prepareInitialMessage");
|
|
||||||
const isInteractive = !parsed.print && parsed.mode === undefined;
|
const isInteractive = !parsed.print && parsed.mode === undefined;
|
||||||
const mode = parsed.mode || "text";
|
const mode = parsed.mode || "text";
|
||||||
initTheme(settingsManager.getTheme(), isInteractive);
|
initTheme(settingsManager.getTheme(), isInteractive);
|
||||||
time("initTheme");
|
|
||||||
|
|
||||||
// Show deprecation warnings in interactive mode
|
// Show deprecation warnings in interactive mode
|
||||||
if (isInteractive && deprecationWarnings.length > 0) {
|
if (isInteractive && deprecationWarnings.length > 0) {
|
||||||
|
|
@ -430,12 +477,10 @@ export async function main(args: string[]) {
|
||||||
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
|
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
|
||||||
if (modelPatterns && modelPatterns.length > 0) {
|
if (modelPatterns && modelPatterns.length > 0) {
|
||||||
scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
|
scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
|
||||||
time("resolveModelScope");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session manager based on CLI flags
|
// Create session manager based on CLI flags
|
||||||
let sessionManager = await createSessionManager(parsed, cwd);
|
let sessionManager = await createSessionManager(parsed, cwd);
|
||||||
time("createSessionManager");
|
|
||||||
|
|
||||||
// Handle --resume: show session picker
|
// Handle --resume: show session picker
|
||||||
if (parsed.resume) {
|
if (parsed.resume) {
|
||||||
|
|
@ -446,7 +491,6 @@ export async function main(args: string[]) {
|
||||||
(onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress),
|
(onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress),
|
||||||
SessionManager.listAll,
|
SessionManager.listAll,
|
||||||
);
|
);
|
||||||
time("selectSession");
|
|
||||||
if (!selectedPath) {
|
if (!selectedPath) {
|
||||||
console.log(chalk.dim("No session selected"));
|
console.log(chalk.dim("No session selected"));
|
||||||
stopThemeWatcher();
|
stopThemeWatcher();
|
||||||
|
|
@ -455,17 +499,10 @@ export async function main(args: string[]) {
|
||||||
sessionManager = SessionManager.open(selectedPath);
|
sessionManager = SessionManager.open(selectedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionOptions = buildSessionOptions(
|
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, settingsManager);
|
||||||
parsed,
|
|
||||||
scopedModels,
|
|
||||||
sessionManager,
|
|
||||||
modelRegistry,
|
|
||||||
settingsManager,
|
|
||||||
extensionsResult,
|
|
||||||
);
|
|
||||||
sessionOptions.authStorage = authStorage;
|
sessionOptions.authStorage = authStorage;
|
||||||
sessionOptions.modelRegistry = modelRegistry;
|
sessionOptions.modelRegistry = modelRegistry;
|
||||||
sessionOptions.eventBus = eventBus;
|
sessionOptions.resourceLoader = resourceLoader;
|
||||||
|
|
||||||
// Handle CLI --api-key as runtime override (not persisted)
|
// Handle CLI --api-key as runtime override (not persisted)
|
||||||
if (parsed.apiKey) {
|
if (parsed.apiKey) {
|
||||||
|
|
@ -476,9 +513,7 @@ export async function main(args: string[]) {
|
||||||
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
|
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
time("buildSessionOptions");
|
|
||||||
const { session, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
const { session, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||||
time("createAgentSession");
|
|
||||||
|
|
||||||
if (!isInteractive && !session.model) {
|
if (!isInteractive && !session.model) {
|
||||||
console.error(chalk.red("No models available."));
|
console.error(chalk.red("No models available."));
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,66 @@
|
||||||
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||||
import type { Theme } from "../theme/theme.js";
|
import type { Theme } from "../theme/theme.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
import { keyHint } from "./keybinding-hints.js";
|
import { keyHint } from "./keybinding-hints.js";
|
||||||
|
|
||||||
/** Loader wrapped with borders for extension UI */
|
/** Loader wrapped with borders for extension UI */
|
||||||
export class BorderedLoader extends Container {
|
export class BorderedLoader extends Container {
|
||||||
private loader: CancellableLoader;
|
private loader: CancellableLoader | Loader;
|
||||||
|
private cancellable: boolean;
|
||||||
|
private signalController?: AbortController;
|
||||||
|
|
||||||
constructor(tui: TUI, theme: Theme, message: string) {
|
constructor(tui: TUI, theme: Theme, message: string, options?: { cancellable?: boolean }) {
|
||||||
super();
|
super();
|
||||||
|
this.cancellable = options?.cancellable ?? true;
|
||||||
const borderColor = (s: string) => theme.fg("border", s);
|
const borderColor = (s: string) => theme.fg("border", s);
|
||||||
this.addChild(new DynamicBorder(borderColor));
|
this.addChild(new DynamicBorder(borderColor));
|
||||||
this.loader = new CancellableLoader(
|
if (this.cancellable) {
|
||||||
tui,
|
this.loader = new CancellableLoader(
|
||||||
(s) => theme.fg("accent", s),
|
tui,
|
||||||
(s) => theme.fg("muted", s),
|
(s) => theme.fg("accent", s),
|
||||||
message,
|
(s) => theme.fg("muted", s),
|
||||||
);
|
message,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.signalController = new AbortController();
|
||||||
|
this.loader = new Loader(
|
||||||
|
tui,
|
||||||
|
(s) => theme.fg("accent", s),
|
||||||
|
(s) => theme.fg("muted", s),
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
this.addChild(this.loader);
|
this.addChild(this.loader);
|
||||||
this.addChild(new Spacer(1));
|
if (this.cancellable) {
|
||||||
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
|
||||||
|
}
|
||||||
this.addChild(new Spacer(1));
|
this.addChild(new Spacer(1));
|
||||||
this.addChild(new DynamicBorder(borderColor));
|
this.addChild(new DynamicBorder(borderColor));
|
||||||
}
|
}
|
||||||
|
|
||||||
get signal(): AbortSignal {
|
get signal(): AbortSignal {
|
||||||
return this.loader.signal;
|
if (this.cancellable) {
|
||||||
|
return (this.loader as CancellableLoader).signal;
|
||||||
|
}
|
||||||
|
return this.signalController?.signal ?? new AbortController().signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
set onAbort(fn: (() => void) | undefined) {
|
set onAbort(fn: (() => void) | undefined) {
|
||||||
this.loader.onAbort = fn;
|
if (this.cancellable) {
|
||||||
|
(this.loader as CancellableLoader).onAbort = fn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(data: string): void {
|
handleInput(data: string): void {
|
||||||
this.loader.handleInput(data);
|
if (this.cancellable) {
|
||||||
|
(this.loader as CancellableLoader).handleInput(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.loader.dispose();
|
if ("dispose" in this.loader && typeof this.loader.dispose === "function") {
|
||||||
|
this.loader.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
|
||||||
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
||||||
import { resolveModelScope } from "../../core/model-resolver.js";
|
import { resolveModelScope } from "../../core/model-resolver.js";
|
||||||
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
||||||
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
|
||||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
|
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
|
||||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||||
|
|
@ -156,7 +155,6 @@ export class InteractiveMode {
|
||||||
private keybindings: KeybindingsManager;
|
private keybindings: KeybindingsManager;
|
||||||
private version: string;
|
private version: string;
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
private hasRenderedInitialMessages = false;
|
|
||||||
private onInputCallback?: (text: string) => void;
|
private onInputCallback?: (text: string) => void;
|
||||||
private loadingAnimation: Loader | undefined = undefined;
|
private loadingAnimation: Loader | undefined = undefined;
|
||||||
private readonly defaultWorkingMessage = "Working...";
|
private readonly defaultWorkingMessage = "Working...";
|
||||||
|
|
@ -321,6 +319,7 @@ export class InteractiveMode {
|
||||||
{ name: "new", description: "Start a new session" },
|
{ name: "new", description: "Start a new session" },
|
||||||
{ name: "compact", description: "Manually compact the session context" },
|
{ name: "compact", description: "Manually compact the session context" },
|
||||||
{ name: "resume", description: "Resume a different session" },
|
{ name: "resume", description: "Resume a different session" },
|
||||||
|
{ name: "reload", description: "Reload extensions, skills, prompts, and themes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Convert prompt templates to SlashCommand format for autocomplete
|
// Convert prompt templates to SlashCommand format for autocomplete
|
||||||
|
|
@ -617,133 +616,63 @@ export class InteractiveMode {
|
||||||
// Extension System
|
// Extension System
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
private showLoadedResources(options?: { extensionPaths?: string[]; force?: boolean }): void {
|
||||||
|
const shouldShow = options?.force || !this.settingsManager.getQuietStartup();
|
||||||
|
if (!shouldShow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
|
||||||
|
if (contextFiles.length > 0) {
|
||||||
|
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
|
||||||
|
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const skills = this.session.skills;
|
||||||
|
if (skills.length > 0) {
|
||||||
|
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
||||||
|
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillWarnings = this.session.skillWarnings;
|
||||||
|
if (skillWarnings.length > 0) {
|
||||||
|
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
|
||||||
|
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = this.session.promptTemplates;
|
||||||
|
if (templates.length > 0) {
|
||||||
|
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
|
||||||
|
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionPaths = options?.extensionPaths ?? [];
|
||||||
|
if (extensionPaths.length > 0) {
|
||||||
|
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||||
|
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the extension system with TUI-based UI context.
|
* Initialize the extension system with TUI-based UI context.
|
||||||
*/
|
*/
|
||||||
private async initExtensions(): Promise<void> {
|
private async initExtensions(): Promise<void> {
|
||||||
// Show discovery info unless silenced
|
|
||||||
if (!this.settingsManager.getQuietStartup()) {
|
|
||||||
// Show loaded project context files
|
|
||||||
const contextFiles = loadProjectContextFiles();
|
|
||||||
if (contextFiles.length > 0) {
|
|
||||||
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
|
|
||||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loaded skills (already discovered by SDK)
|
|
||||||
const skills = this.session.skills;
|
|
||||||
if (skills.length > 0) {
|
|
||||||
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
|
||||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show skill warnings if any
|
|
||||||
const skillWarnings = this.session.skillWarnings;
|
|
||||||
if (skillWarnings.length > 0) {
|
|
||||||
const warningList = skillWarnings
|
|
||||||
.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
|
|
||||||
.join("\n");
|
|
||||||
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loaded prompt templates
|
|
||||||
const templates = this.session.promptTemplates;
|
|
||||||
if (templates.length > 0) {
|
|
||||||
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
|
|
||||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionRunner = this.session.extensionRunner;
|
const extensionRunner = this.session.extensionRunner;
|
||||||
if (!extensionRunner) {
|
if (!extensionRunner) {
|
||||||
return; // No extensions loaded
|
this.showLoadedResources({ extensionPaths: [], force: false });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension UI context
|
// Create extension UI context
|
||||||
const uiContext = this.createExtensionUIContext();
|
const uiContext = this.createExtensionUIContext();
|
||||||
|
await this.session.bindExtensions({
|
||||||
extensionRunner.initialize(
|
uiContext,
|
||||||
// ExtensionActions - for pi.* API
|
commandContextActions: {
|
||||||
{
|
|
||||||
sendMessage: (message, options) => {
|
|
||||||
const wasStreaming = this.session.isStreaming;
|
|
||||||
this.session
|
|
||||||
.sendCustomMessage(message, options)
|
|
||||||
.then(() => {
|
|
||||||
// Don't rebuild if initial render hasn't happened yet
|
|
||||||
// (renderInitialMessages will handle it)
|
|
||||||
if (!wasStreaming && message.display && this.hasRenderedInitialMessages) {
|
|
||||||
this.rebuildChatFromMessages();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.showError(
|
|
||||||
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sendUserMessage: (content, options) => {
|
|
||||||
this.session.sendUserMessage(content, options).catch((err) => {
|
|
||||||
this.showError(
|
|
||||||
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
appendEntry: (customType, data) => {
|
|
||||||
this.sessionManager.appendCustomEntry(customType, data);
|
|
||||||
},
|
|
||||||
setSessionName: (name) => {
|
|
||||||
this.sessionManager.appendSessionInfo(name);
|
|
||||||
this.updateTerminalTitle();
|
|
||||||
},
|
|
||||||
getSessionName: () => {
|
|
||||||
return this.sessionManager.getSessionName();
|
|
||||||
},
|
|
||||||
setLabel: (entryId, label) => {
|
|
||||||
this.sessionManager.appendLabelChange(entryId, label);
|
|
||||||
},
|
|
||||||
getActiveTools: () => this.session.getActiveToolNames(),
|
|
||||||
getAllTools: () => this.session.getAllTools(),
|
|
||||||
setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
|
|
||||||
setModel: async (model) => {
|
|
||||||
const key = await this.session.modelRegistry.getApiKey(model);
|
|
||||||
if (!key) return false;
|
|
||||||
await this.session.setModel(model);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
getThinkingLevel: () => this.session.thinkingLevel,
|
|
||||||
setThinkingLevel: (level) => this.session.setThinkingLevel(level),
|
|
||||||
},
|
|
||||||
// ExtensionContextActions - for ctx.* in event handlers
|
|
||||||
{
|
|
||||||
getModel: () => this.session.model,
|
|
||||||
isIdle: () => !this.session.isStreaming,
|
|
||||||
abort: () => this.session.abort(),
|
|
||||||
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
|
||||||
shutdown: () => {
|
|
||||||
this.shutdownRequested = true;
|
|
||||||
},
|
|
||||||
getContextUsage: () => this.session.getContextUsage(),
|
|
||||||
compact: (options) => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await this.executeCompaction(options?.customInstructions, false);
|
|
||||||
if (result) {
|
|
||||||
options?.onComplete?.(result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
options?.onError?.(err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// ExtensionCommandContextActions - for ctx.* in command handlers
|
|
||||||
{
|
|
||||||
waitForIdle: () => this.session.agent.waitForIdle(),
|
waitForIdle: () => this.session.agent.waitForIdle(),
|
||||||
newSession: async (options) => {
|
newSession: async (options) => {
|
||||||
if (this.loadingAnimation) {
|
if (this.loadingAnimation) {
|
||||||
|
|
@ -808,31 +737,16 @@ export class InteractiveMode {
|
||||||
return { cancelled: false };
|
return { cancelled: false };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
uiContext,
|
shutdownHandler: () => {
|
||||||
);
|
this.shutdownRequested = true;
|
||||||
|
},
|
||||||
// Subscribe to extension errors
|
onError: (error) => {
|
||||||
extensionRunner.onError((error) => {
|
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up extension-registered shortcuts
|
|
||||||
this.setupExtensionShortcuts(extensionRunner);
|
this.setupExtensionShortcuts(extensionRunner);
|
||||||
|
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
|
||||||
// Show loaded extensions (unless silenced)
|
|
||||||
if (!this.settingsManager.getQuietStartup()) {
|
|
||||||
const extensionPaths = extensionRunner.getExtensionPaths();
|
|
||||||
if (extensionPaths.length > 0) {
|
|
||||||
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
|
||||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit session_start event
|
|
||||||
await extensionRunner.emit({
|
|
||||||
type: "session_start",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -950,6 +864,38 @@ export class InteractiveMode {
|
||||||
this.renderWidgets();
|
this.renderWidgets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearExtensionWidgets(): void {
|
||||||
|
for (const widget of this.extensionWidgetsAbove.values()) {
|
||||||
|
widget.dispose?.();
|
||||||
|
}
|
||||||
|
for (const widget of this.extensionWidgetsBelow.values()) {
|
||||||
|
widget.dispose?.();
|
||||||
|
}
|
||||||
|
this.extensionWidgetsAbove.clear();
|
||||||
|
this.extensionWidgetsBelow.clear();
|
||||||
|
this.renderWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetExtensionUI(): void {
|
||||||
|
if (this.extensionSelector) {
|
||||||
|
this.hideExtensionSelector();
|
||||||
|
}
|
||||||
|
if (this.extensionInput) {
|
||||||
|
this.hideExtensionInput();
|
||||||
|
}
|
||||||
|
if (this.extensionEditor) {
|
||||||
|
this.hideExtensionEditor();
|
||||||
|
}
|
||||||
|
this.ui.hideOverlay();
|
||||||
|
this.setExtensionFooter(undefined);
|
||||||
|
this.setExtensionHeader(undefined);
|
||||||
|
this.clearExtensionWidgets();
|
||||||
|
this.footerDataProvider.clearExtensionStatuses();
|
||||||
|
this.footer.invalidate();
|
||||||
|
this.setCustomEditorComponent(undefined);
|
||||||
|
this.defaultEditor.onExtensionShortcut = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Maximum total widget lines to prevent viewport overflow
|
// Maximum total widget lines to prevent viewport overflow
|
||||||
private static readonly MAX_WIDGET_LINES = 10;
|
private static readonly MAX_WIDGET_LINES = 10;
|
||||||
|
|
||||||
|
|
@ -1608,6 +1554,11 @@ export class InteractiveMode {
|
||||||
await this.handleCompactCommand(customInstructions);
|
await this.handleCompactCommand(customInstructions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (text === "/reload") {
|
||||||
|
this.editor.setText("");
|
||||||
|
await this.handleReloadCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (text === "/debug") {
|
if (text === "/debug") {
|
||||||
this.handleDebugCommand();
|
this.handleDebugCommand();
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
|
|
@ -2143,7 +2094,6 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderInitialMessages(): void {
|
renderInitialMessages(): void {
|
||||||
this.hasRenderedInitialMessages = true;
|
|
||||||
// Get aligned messages and entries from session context
|
// Get aligned messages and entries from session context
|
||||||
const context = this.sessionManager.buildSessionContext();
|
const context = this.sessionManager.buildSessionContext();
|
||||||
this.renderSessionContext(context, {
|
this.renderSessionContext(context, {
|
||||||
|
|
@ -3276,6 +3226,53 @@ export class InteractiveMode {
|
||||||
// Command handlers
|
// Command handlers
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
private async handleReloadCommand(): Promise<void> {
|
||||||
|
if (this.session.isStreaming) {
|
||||||
|
this.showWarning("Wait for the current response to finish before reloading.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.session.isCompacting) {
|
||||||
|
this.showWarning("Wait for compaction to finish before reloading.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetExtensionUI();
|
||||||
|
|
||||||
|
const loader = new BorderedLoader(this.ui, theme, "Reloading resources...", { cancellable: false });
|
||||||
|
const previousEditor = this.editor;
|
||||||
|
this.editorContainer.clear();
|
||||||
|
this.editorContainer.addChild(loader);
|
||||||
|
this.ui.setFocus(loader);
|
||||||
|
this.ui.requestRender();
|
||||||
|
|
||||||
|
const restoreEditor = () => {
|
||||||
|
loader.dispose();
|
||||||
|
this.editorContainer.clear();
|
||||||
|
this.editorContainer.addChild(previousEditor);
|
||||||
|
this.ui.setFocus(previousEditor as Component);
|
||||||
|
this.ui.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.session.reload();
|
||||||
|
this.rebuildAutocomplete();
|
||||||
|
const runner = this.session.extensionRunner;
|
||||||
|
if (runner) {
|
||||||
|
this.setupExtensionShortcuts(runner);
|
||||||
|
}
|
||||||
|
restoreEditor();
|
||||||
|
this.showLoadedResources({ extensionPaths: runner?.getExtensionPaths() ?? [], force: true });
|
||||||
|
const modelsJsonError = this.session.modelRegistry.getError();
|
||||||
|
if (modelsJsonError) {
|
||||||
|
this.showError(`models.json error: ${modelsJsonError}`);
|
||||||
|
}
|
||||||
|
this.showStatus("Reloaded resources");
|
||||||
|
} catch (error) {
|
||||||
|
restoreEditor();
|
||||||
|
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleExportCommand(text: string): Promise<void> {
|
private async handleExportCommand(text: string): Promise<void> {
|
||||||
const parts = text.split(/\s+/);
|
const parts = text.split(/\s+/);
|
||||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||||
|
|
@ -3632,12 +3629,13 @@ export class InteractiveMode {
|
||||||
|
|
||||||
private handleDebugCommand(): void {
|
private handleDebugCommand(): void {
|
||||||
const width = this.ui.terminal.columns;
|
const width = this.ui.terminal.columns;
|
||||||
|
const height = this.ui.terminal.rows;
|
||||||
const allLines = this.ui.render(width);
|
const allLines = this.ui.render(width);
|
||||||
|
|
||||||
const debugLogPath = getDebugLogPath();
|
const debugLogPath = getDebugLogPath();
|
||||||
const debugData = [
|
const debugData = [
|
||||||
`Debug output at ${new Date().toISOString()}`,
|
`Debug output at ${new Date().toISOString()}`,
|
||||||
`Terminal width: ${width}`,
|
`Terminal: ${width}x${height}`,
|
||||||
`Total lines: ${allLines.length}`,
|
`Total lines: ${allLines.length}`,
|
||||||
"",
|
"",
|
||||||
"=== All rendered lines with visible widths ===",
|
"=== All rendered lines with visible widths ===",
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,8 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export class Theme {
|
export class Theme {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly sourcePath?: string;
|
||||||
private fgColors: Map<ThemeColor, string>;
|
private fgColors: Map<ThemeColor, string>;
|
||||||
private bgColors: Map<ThemeBg, string>;
|
private bgColors: Map<ThemeBg, string>;
|
||||||
private mode: ColorMode;
|
private mode: ColorMode;
|
||||||
|
|
@ -342,7 +344,10 @@ export class Theme {
|
||||||
fgColors: Record<ThemeColor, string | number>,
|
fgColors: Record<ThemeColor, string | number>,
|
||||||
bgColors: Record<ThemeBg, string | number>,
|
bgColors: Record<ThemeBg, string | number>,
|
||||||
mode: ColorMode,
|
mode: ColorMode,
|
||||||
|
options: { name?: string; sourcePath?: string } = {},
|
||||||
) {
|
) {
|
||||||
|
this.name = options.name;
|
||||||
|
this.sourcePath = options.sourcePath;
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
this.fgColors = new Map();
|
this.fgColors = new Map();
|
||||||
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
|
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
|
||||||
|
|
@ -457,6 +462,9 @@ export function getAvailableThemes(): string[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const name of registeredThemes.keys()) {
|
||||||
|
themes.add(name);
|
||||||
|
}
|
||||||
return Array.from(themes).sort();
|
return Array.from(themes).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -487,26 +495,16 @@ export function getAvailableThemesWithPaths(): ThemeInfo[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [name, theme] of registeredThemes.entries()) {
|
||||||
|
if (!result.some((t) => t.name === name)) {
|
||||||
|
result.push({ name, path: theme.sourcePath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadThemeJson(name: string): ThemeJson {
|
function parseThemeJson(label: string, json: unknown): ThemeJson {
|
||||||
const builtinThemes = getBuiltinThemes();
|
|
||||||
if (name in builtinThemes) {
|
|
||||||
return builtinThemes[name];
|
|
||||||
}
|
|
||||||
const customThemesDir = getCustomThemesDir();
|
|
||||||
const themePath = path.join(customThemesDir, `${name}.json`);
|
|
||||||
if (!fs.existsSync(themePath)) {
|
|
||||||
throw new Error(`Theme not found: ${name}`);
|
|
||||||
}
|
|
||||||
const content = fs.readFileSync(themePath, "utf-8");
|
|
||||||
let json: unknown;
|
|
||||||
try {
|
|
||||||
json = JSON.parse(content);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to parse theme ${name}: ${error}`);
|
|
||||||
}
|
|
||||||
if (!validateThemeJson.Check(json)) {
|
if (!validateThemeJson.Check(json)) {
|
||||||
const errors = Array.from(validateThemeJson.Errors(json));
|
const errors = Array.from(validateThemeJson.Errors(json));
|
||||||
const missingColors: string[] = [];
|
const missingColors: string[] = [];
|
||||||
|
|
@ -522,12 +520,12 @@ function loadThemeJson(name: string): ThemeJson {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let errorMessage = `Invalid theme "${name}":\n`;
|
let errorMessage = `Invalid theme "${label}":\n`;
|
||||||
if (missingColors.length > 0) {
|
if (missingColors.length > 0) {
|
||||||
errorMessage += `\nMissing required color tokens:\n`;
|
errorMessage += "\nMissing required color tokens:\n";
|
||||||
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
|
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
|
||||||
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
|
errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
|
||||||
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
|
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
|
||||||
}
|
}
|
||||||
if (otherErrors.length > 0) {
|
if (otherErrors.length > 0) {
|
||||||
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
|
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
|
||||||
|
|
@ -535,10 +533,35 @@ function loadThemeJson(name: string): ThemeJson {
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json as ThemeJson;
|
return json as ThemeJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
function parseThemeJsonContent(label: string, content: string): ThemeJson {
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse theme ${label}: ${error}`);
|
||||||
|
}
|
||||||
|
return parseThemeJson(label, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThemeJson(name: string): ThemeJson {
|
||||||
|
const builtinThemes = getBuiltinThemes();
|
||||||
|
if (name in builtinThemes) {
|
||||||
|
return builtinThemes[name];
|
||||||
|
}
|
||||||
|
const customThemesDir = getCustomThemesDir();
|
||||||
|
const themePath = path.join(customThemesDir, `${name}.json`);
|
||||||
|
if (!fs.existsSync(themePath)) {
|
||||||
|
throw new Error(`Theme not found: ${name}`);
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(themePath, "utf-8");
|
||||||
|
return parseThemeJsonContent(name, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {
|
||||||
const colorMode = mode ?? detectColorMode();
|
const colorMode = mode ?? detectColorMode();
|
||||||
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||||
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
||||||
|
|
@ -558,10 +581,23 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
||||||
fgColors[key as ThemeColor] = value;
|
fgColors[key as ThemeColor] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new Theme(fgColors, bgColors, colorMode);
|
return new Theme(fgColors, bgColors, colorMode, {
|
||||||
|
name: themeJson.name,
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme {
|
||||||
|
const content = fs.readFileSync(themePath, "utf-8");
|
||||||
|
const themeJson = parseThemeJsonContent(themePath, content);
|
||||||
|
return createTheme(themeJson, mode, themePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTheme(name: string, mode?: ColorMode): Theme {
|
function loadTheme(name: string, mode?: ColorMode): Theme {
|
||||||
|
const registeredTheme = registeredThemes.get(name);
|
||||||
|
if (registeredTheme) {
|
||||||
|
return registeredTheme;
|
||||||
|
}
|
||||||
const themeJson = loadThemeJson(name);
|
const themeJson = loadThemeJson(name);
|
||||||
return createTheme(themeJson, mode);
|
return createTheme(themeJson, mode);
|
||||||
}
|
}
|
||||||
|
|
@ -617,6 +653,16 @@ function setGlobalTheme(t: Theme): void {
|
||||||
let currentThemeName: string | undefined;
|
let currentThemeName: string | undefined;
|
||||||
let themeWatcher: fs.FSWatcher | undefined;
|
let themeWatcher: fs.FSWatcher | undefined;
|
||||||
let onThemeChangeCallback: (() => void) | undefined;
|
let onThemeChangeCallback: (() => void) | undefined;
|
||||||
|
const registeredThemes = new Map<string, Theme>();
|
||||||
|
|
||||||
|
export function setRegisteredThemes(themes: Theme[]): void {
|
||||||
|
registeredThemes.clear();
|
||||||
|
for (const theme of themes) {
|
||||||
|
if (theme.name) {
|
||||||
|
registeredThemes.set(theme.name, theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initTheme(themeName?: string, enableWatcher: boolean = false): void {
|
export function initTheme(themeName?: string, enableWatcher: boolean = false): void {
|
||||||
const name = themeName ?? getDefaultTheme();
|
const name = themeName ?? getDefaultTheme();
|
||||||
|
|
|
||||||
|
|
@ -35,68 +35,11 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
||||||
console.log(JSON.stringify(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)
|
||||||
const extensionRunner = session.extensionRunner;
|
const extensionRunner = session.extensionRunner;
|
||||||
if (extensionRunner) {
|
if (extensionRunner) {
|
||||||
extensionRunner.initialize(
|
await session.bindExtensions({
|
||||||
// ExtensionActions
|
commandContextActions: {
|
||||||
{
|
|
||||||
sendMessage: (message, options) => {
|
|
||||||
session.sendCustomMessage(message, options).catch((e) => {
|
|
||||||
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sendUserMessage: (content, options) => {
|
|
||||||
session.sendUserMessage(content, options).catch((e) => {
|
|
||||||
console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
appendEntry: (customType, data) => {
|
|
||||||
session.sessionManager.appendCustomEntry(customType, data);
|
|
||||||
},
|
|
||||||
setSessionName: (name) => {
|
|
||||||
session.sessionManager.appendSessionInfo(name);
|
|
||||||
},
|
|
||||||
getSessionName: () => {
|
|
||||||
return session.sessionManager.getSessionName();
|
|
||||||
},
|
|
||||||
setLabel: (entryId, label) => {
|
|
||||||
session.sessionManager.appendLabelChange(entryId, label);
|
|
||||||
},
|
|
||||||
getActiveTools: () => session.getActiveToolNames(),
|
|
||||||
getAllTools: () => session.getAllTools(),
|
|
||||||
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
|
||||||
setModel: async (model) => {
|
|
||||||
const key = await session.modelRegistry.getApiKey(model);
|
|
||||||
if (!key) return false;
|
|
||||||
await session.setModel(model);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
getThinkingLevel: () => session.thinkingLevel,
|
|
||||||
setThinkingLevel: (level) => session.setThinkingLevel(level),
|
|
||||||
},
|
|
||||||
// ExtensionContextActions
|
|
||||||
{
|
|
||||||
getModel: () => session.model,
|
|
||||||
isIdle: () => !session.isStreaming,
|
|
||||||
abort: () => session.abort(),
|
|
||||||
hasPendingMessages: () => session.pendingMessageCount > 0,
|
|
||||||
shutdown: () => {},
|
|
||||||
getContextUsage: () => session.getContextUsage(),
|
|
||||||
compact: (options) => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await session.compact(options?.customInstructions);
|
|
||||||
options?.onComplete?.(result);
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
options?.onError?.(err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// ExtensionCommandContextActions - commands invokable via prompt("/command")
|
|
||||||
{
|
|
||||||
waitForIdle: () => session.agent.waitForIdle(),
|
waitForIdle: () => session.agent.waitForIdle(),
|
||||||
newSession: async (options) => {
|
newSession: async (options) => {
|
||||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
const success = await session.newSession({ parentSession: options?.parentSession });
|
||||||
|
|
@ -119,14 +62,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
||||||
return { cancelled: result.cancelled };
|
return { cancelled: result.cancelled };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// No UI context - hasUI will be false
|
onError: (err) => {
|
||||||
);
|
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
||||||
extensionRunner.onError((err) => {
|
},
|
||||||
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
|
||||||
});
|
|
||||||
// Emit session_start event
|
|
||||||
await extensionRunner.emit({
|
|
||||||
type: "session_start",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -256,67 +256,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
// Set up extensions with RPC-based UI context
|
// Set up extensions with RPC-based UI context
|
||||||
const extensionRunner = session.extensionRunner;
|
const extensionRunner = session.extensionRunner;
|
||||||
if (extensionRunner) {
|
if (extensionRunner) {
|
||||||
extensionRunner.initialize(
|
await session.bindExtensions({
|
||||||
// ExtensionActions
|
uiContext: createExtensionUIContext(),
|
||||||
{
|
commandContextActions: {
|
||||||
sendMessage: (message, options) => {
|
|
||||||
session.sendCustomMessage(message, options).catch((e) => {
|
|
||||||
output(error(undefined, "extension_send", e.message));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sendUserMessage: (content, options) => {
|
|
||||||
session.sendUserMessage(content, options).catch((e) => {
|
|
||||||
output(error(undefined, "extension_send_user", e.message));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
appendEntry: (customType, data) => {
|
|
||||||
session.sessionManager.appendCustomEntry(customType, data);
|
|
||||||
},
|
|
||||||
setSessionName: (name) => {
|
|
||||||
session.sessionManager.appendSessionInfo(name);
|
|
||||||
},
|
|
||||||
getSessionName: () => {
|
|
||||||
return session.sessionManager.getSessionName();
|
|
||||||
},
|
|
||||||
setLabel: (entryId, label) => {
|
|
||||||
session.sessionManager.appendLabelChange(entryId, label);
|
|
||||||
},
|
|
||||||
getActiveTools: () => session.getActiveToolNames(),
|
|
||||||
getAllTools: () => session.getAllTools(),
|
|
||||||
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
|
|
||||||
setModel: async (model) => {
|
|
||||||
const key = await session.modelRegistry.getApiKey(model);
|
|
||||||
if (!key) return false;
|
|
||||||
await session.setModel(model);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
getThinkingLevel: () => session.thinkingLevel,
|
|
||||||
setThinkingLevel: (level) => session.setThinkingLevel(level),
|
|
||||||
},
|
|
||||||
// ExtensionContextActions
|
|
||||||
{
|
|
||||||
getModel: () => session.agent.state.model,
|
|
||||||
isIdle: () => !session.isStreaming,
|
|
||||||
abort: () => session.abort(),
|
|
||||||
hasPendingMessages: () => session.pendingMessageCount > 0,
|
|
||||||
shutdown: () => {
|
|
||||||
shutdownRequested = true;
|
|
||||||
},
|
|
||||||
getContextUsage: () => session.getContextUsage(),
|
|
||||||
compact: (options) => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await session.compact(options?.customInstructions);
|
|
||||||
options?.onComplete?.(result);
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
options?.onError?.(err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// ExtensionCommandContextActions - commands invokable via prompt("/command")
|
|
||||||
{
|
|
||||||
waitForIdle: () => session.agent.waitForIdle(),
|
waitForIdle: () => session.agent.waitForIdle(),
|
||||||
newSession: async (options) => {
|
newSession: async (options) => {
|
||||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
const success = await session.newSession({ parentSession: options?.parentSession });
|
||||||
|
|
@ -340,14 +282,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
return { cancelled: result.cancelled };
|
return { cancelled: result.cancelled };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createExtensionUIContext(),
|
shutdownHandler: () => {
|
||||||
);
|
shutdownRequested = true;
|
||||||
extensionRunner.onError((err) => {
|
},
|
||||||
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
onError: (err) => {
|
||||||
});
|
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
||||||
// Emit session_start event
|
},
|
||||||
await extensionRunner.emit({
|
|
||||||
type: "session_start",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -577,8 +517,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
async function checkShutdownRequested(): Promise<void> {
|
async function checkShutdownRequested(): Promise<void> {
|
||||||
if (!shutdownRequested) return;
|
if (!shutdownRequested) return;
|
||||||
|
|
||||||
if (extensionRunner?.hasHandlers("session_shutdown")) {
|
const currentRunner = session.extensionRunner;
|
||||||
await extensionRunner.emit({ type: "session_shutdown" });
|
if (currentRunner?.hasHandlers("session_shutdown")) {
|
||||||
|
await currentRunner.emit({ type: "session_shutdown" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close readline interface to stop waiting for input
|
// Close readline interface to stop waiting for input
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import { delimiter } from "node:path";
|
||||||
import { spawn, spawnSync } from "child_process";
|
import { spawn, spawnSync } from "child_process";
|
||||||
import { getSettingsPath } from "../config.js";
|
import { getSettingsPath } from "../config.js";
|
||||||
|
import { getBinDir } from "../config.js";
|
||||||
import { SettingsManager } from "../core/settings-manager.js";
|
import { SettingsManager } from "../core/settings-manager.js";
|
||||||
|
|
||||||
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
||||||
|
|
@ -95,6 +97,20 @@ export function getShellConfig(): { shell: string; args: string[] } {
|
||||||
return cachedShellConfig;
|
return cachedShellConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShellEnv(): NodeJS.ProcessEnv {
|
||||||
|
const binDir = getBinDir();
|
||||||
|
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||||
|
const currentPath = process.env[pathKey] ?? "";
|
||||||
|
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
||||||
|
const hasBinDir = pathEntries.includes(binDir);
|
||||||
|
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
[pathKey]: updatedPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize binary output for display/storage.
|
* Sanitize binary output for display/storage.
|
||||||
* Removes characters that crash string-width or cause display issues:
|
* Removes characters that crash string-width or cause display issues:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
import { codingTools } from "../src/core/tools/index.js";
|
import { codingTools } from "../src/core/tools/index.js";
|
||||||
import { API_KEY } from "./utilities.js";
|
import { API_KEY, createTestResourceLoader } from "./utilities.js";
|
||||||
|
|
||||||
describe.skipIf(!API_KEY)("AgentSession forking", () => {
|
describe.skipIf(!API_KEY)("AgentSession forking", () => {
|
||||||
let session: AgentSession;
|
let session: AgentSession;
|
||||||
|
|
@ -61,7 +61,9 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Must subscribe to enable session persistence
|
// Must subscribe to enable session persistence
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
import { codingTools } from "../src/core/tools/index.js";
|
import { codingTools } from "../src/core/tools/index.js";
|
||||||
import { API_KEY } from "./utilities.js";
|
import { API_KEY, createTestResourceLoader } from "./utilities.js";
|
||||||
|
|
||||||
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||||
let session: AgentSession;
|
let session: AgentSession;
|
||||||
|
|
@ -67,7 +67,9 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to track events
|
// Subscribe to track events
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { AuthStorage } from "../src/core/auth-storage.js";
|
||||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
|
import { createTestResourceLoader } from "./utilities.js";
|
||||||
|
|
||||||
// Mock stream that mimics AssistantMessageEventStream
|
// Mock stream that mimics AssistantMessageEventStream
|
||||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||||
|
|
@ -107,7 +108,9 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
|
|
@ -197,7 +200,9 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// First prompt completes
|
// First prompt completes
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,42 @@ describe("parseArgs", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("--skill flag", () => {
|
||||||
|
test("parses single --skill", () => {
|
||||||
|
const result = parseArgs(["--skill", "./skill-dir"]);
|
||||||
|
expect(result.skills).toEqual(["./skill-dir"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple --skill flags", () => {
|
||||||
|
const result = parseArgs(["--skill", "./skill-a", "--skill", "./skill-b"]);
|
||||||
|
expect(result.skills).toEqual(["./skill-a", "./skill-b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("--prompt-template flag", () => {
|
||||||
|
test("parses single --prompt-template", () => {
|
||||||
|
const result = parseArgs(["--prompt-template", "./prompts"]);
|
||||||
|
expect(result.promptTemplates).toEqual(["./prompts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple --prompt-template flags", () => {
|
||||||
|
const result = parseArgs(["--prompt-template", "./one", "--prompt-template", "./two"]);
|
||||||
|
expect(result.promptTemplates).toEqual(["./one", "./two"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("--theme flag", () => {
|
||||||
|
test("parses single --theme", () => {
|
||||||
|
const result = parseArgs(["--theme", "./theme.json"]);
|
||||||
|
expect(result.themes).toEqual(["./theme.json"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple --theme flags", () => {
|
||||||
|
const result = parseArgs(["--theme", "./dark.json", "--theme", "./light.json"]);
|
||||||
|
expect(result.themes).toEqual(["./dark.json", "./light.json"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("--no-skills flag", () => {
|
describe("--no-skills flag", () => {
|
||||||
test("parses --no-skills flag", () => {
|
test("parses --no-skills flag", () => {
|
||||||
const result = parseArgs(["--no-skills"]);
|
const result = parseArgs(["--no-skills"]);
|
||||||
|
|
@ -170,6 +206,20 @@ describe("parseArgs", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("--no-prompt-templates flag", () => {
|
||||||
|
test("parses --no-prompt-templates flag", () => {
|
||||||
|
const result = parseArgs(["--no-prompt-templates"]);
|
||||||
|
expect(result.noPromptTemplates).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("--no-themes flag", () => {
|
||||||
|
test("parses --no-themes flag", () => {
|
||||||
|
const result = parseArgs(["--no-themes"]);
|
||||||
|
expect(result.noThemes).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("--no-tools flag", () => {
|
describe("--no-tools flag", () => {
|
||||||
test("parses --no-tools flag", () => {
|
test("parses --no-tools flag", () => {
|
||||||
const result = parseArgs(["--no-tools"]);
|
const result = parseArgs(["--no-tools"]);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { AuthStorage } from "../src/core/auth-storage.js";
|
||||||
import {
|
import {
|
||||||
createExtensionRuntime,
|
createExtensionRuntime,
|
||||||
type Extension,
|
type Extension,
|
||||||
ExtensionRunner,
|
|
||||||
type SessionBeforeCompactEvent,
|
type SessionBeforeCompactEvent,
|
||||||
type SessionCompactEvent,
|
type SessionCompactEvent,
|
||||||
type SessionEvent,
|
type SessionEvent,
|
||||||
|
|
@ -22,13 +21,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
import { codingTools } from "../src/core/tools/index.js";
|
import { codingTools } from "../src/core/tools/index.js";
|
||||||
|
import { createTestResourceLoader } from "./utilities.js";
|
||||||
|
|
||||||
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
||||||
let session: AgentSession;
|
let session: AgentSession;
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let extensionRunner: ExtensionRunner;
|
|
||||||
let capturedEvents: SessionEvent[];
|
let capturedEvents: SessionEvent[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -101,51 +100,18 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
||||||
const modelRegistry = new ModelRegistry(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
const runtime = createExtensionRuntime();
|
const runtime = createExtensionRuntime();
|
||||||
extensionRunner = new ExtensionRunner(extensions, runtime, tempDir, sessionManager, modelRegistry);
|
const resourceLoader = {
|
||||||
extensionRunner.initialize(
|
...createTestResourceLoader(),
|
||||||
// ExtensionActions
|
getExtensions: () => ({ extensions, errors: [], runtime }),
|
||||||
{
|
};
|
||||||
sendMessage: async () => {},
|
|
||||||
sendUserMessage: async () => {},
|
|
||||||
appendEntry: async () => {},
|
|
||||||
setSessionName: () => {},
|
|
||||||
getSessionName: () => undefined,
|
|
||||||
setLabel: () => {},
|
|
||||||
getActiveTools: () => [],
|
|
||||||
getAllTools: () => [],
|
|
||||||
setActiveTools: () => {},
|
|
||||||
setModel: async () => false,
|
|
||||||
getThinkingLevel: () => "off",
|
|
||||||
setThinkingLevel: () => {},
|
|
||||||
},
|
|
||||||
// ExtensionContextActions
|
|
||||||
{
|
|
||||||
getModel: () => session.model,
|
|
||||||
isIdle: () => !session.isStreaming,
|
|
||||||
abort: () => session.abort(),
|
|
||||||
hasPendingMessages: () => session.pendingMessageCount > 0,
|
|
||||||
shutdown: () => {},
|
|
||||||
getContextUsage: () => session.getContextUsage(),
|
|
||||||
compact: (options) => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await session.compact(options?.customInstructions);
|
|
||||||
options?.onComplete?.(result);
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
options?.onError?.(err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
session = new AgentSession({
|
session = new AgentSession({
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
extensionRunner,
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
import { codingTools } from "../src/core/tools/index.js";
|
import { codingTools } from "../src/core/tools/index.js";
|
||||||
import { API_KEY, getRealAuthStorage, hasAuthForProvider, resolveApiKey } from "./utilities.js";
|
import {
|
||||||
|
API_KEY,
|
||||||
|
createTestResourceLoader,
|
||||||
|
getRealAuthStorage,
|
||||||
|
hasAuthForProvider,
|
||||||
|
resolveApiKey,
|
||||||
|
} from "./utilities.js";
|
||||||
|
|
||||||
// Check for auth
|
// Check for auth
|
||||||
const HAS_ANTIGRAVITY_AUTH = hasAuthForProvider("google-antigravity");
|
const HAS_ANTIGRAVITY_AUTH = hasAuthForProvider("google-antigravity");
|
||||||
|
|
@ -81,7 +87,9 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
});
|
});
|
||||||
|
|
||||||
session.subscribe(() => {});
|
session.subscribe(() => {});
|
||||||
|
|
@ -175,7 +183,9 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
});
|
});
|
||||||
|
|
||||||
session.subscribe(() => {});
|
session.subscribe(() => {});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
|
||||||
|
import type { ResourceLoader } from "../src/core/resource-loader.js";
|
||||||
import { createAgentSession } from "../src/core/sdk.js";
|
import { createAgentSession } from "../src/core/sdk.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
|
|
||||||
|
|
@ -47,21 +49,30 @@ This is a test skill.
|
||||||
expect(session.skills.some((s) => s.name === "test-skill")).toBe(true);
|
expect(session.skills.some((s) => s.name === "test-skill")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have empty skills when options.skills is empty array (--no-skills)", async () => {
|
it("should have empty skills when resource loader returns none (--no-skills)", async () => {
|
||||||
|
const resourceLoader: ResourceLoader = {
|
||||||
|
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||||
|
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||||
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||||
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||||
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||||
|
getSystemPrompt: () => undefined,
|
||||||
|
getAppendSystemPrompt: () => [],
|
||||||
|
reload: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
cwd: tempDir,
|
cwd: tempDir,
|
||||||
agentDir: tempDir,
|
agentDir: tempDir,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
skills: [], // Explicitly empty - like --no-skills
|
resourceLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
// session.skills should be empty
|
|
||||||
expect(session.skills).toEqual([]);
|
expect(session.skills).toEqual([]);
|
||||||
// No warnings since we didn't discover
|
|
||||||
expect(session.skillWarnings).toEqual([]);
|
expect(session.skillWarnings).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use provided skills when options.skills is explicitly set", async () => {
|
it("should use provided skills when resource loader supplies them", async () => {
|
||||||
const customSkill = {
|
const customSkill = {
|
||||||
name: "custom-skill",
|
name: "custom-skill",
|
||||||
description: "A custom skill",
|
description: "A custom skill",
|
||||||
|
|
@ -70,16 +81,25 @@ This is a test skill.
|
||||||
source: "custom" as const,
|
source: "custom" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resourceLoader: ResourceLoader = {
|
||||||
|
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||||
|
getSkills: () => ({ skills: [customSkill], diagnostics: [] }),
|
||||||
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||||
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||||
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||||
|
getSystemPrompt: () => undefined,
|
||||||
|
getAppendSystemPrompt: () => [],
|
||||||
|
reload: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
cwd: tempDir,
|
cwd: tempDir,
|
||||||
agentDir: tempDir,
|
agentDir: tempDir,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
skills: [customSkill],
|
resourceLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
// session.skills should contain only the provided skill
|
|
||||||
expect(session.skills).toEqual([customSkill]);
|
expect(session.skills).toEqual([customSkill]);
|
||||||
// No warnings since we didn't discover
|
|
||||||
expect(session.skillWarnings).toEqual([]);
|
expect(session.skillWarnings).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -254,152 +254,44 @@ describe("skills", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadSkills with options", () => {
|
describe("loadSkills with options", () => {
|
||||||
it("should load from customDirectories only when built-ins disabled", () => {
|
const emptyAgentDir = resolve(__dirname, "fixtures/empty-agent");
|
||||||
const { skills } = loadSkills({
|
const emptyCwd = resolve(__dirname, "fixtures/empty-cwd");
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
it("should load from explicit skillPaths", () => {
|
||||||
enableClaudeProject: false,
|
const { skills, warnings } = loadSkills({
|
||||||
enablePiUser: false,
|
agentDir: emptyAgentDir,
|
||||||
enablePiProject: false,
|
cwd: emptyCwd,
|
||||||
customDirectories: [fixturesDir],
|
skillPaths: [join(fixturesDir, "valid-skill")],
|
||||||
});
|
});
|
||||||
expect(skills.length).toBeGreaterThan(0);
|
expect(skills).toHaveLength(1);
|
||||||
expect(skills.every((s) => s.source === "custom")).toBe(true);
|
expect(skills[0].source).toBe("custom");
|
||||||
|
expect(warnings).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter out ignoredSkills", () => {
|
it("should warn when skill path does not exist", () => {
|
||||||
const { skills } = loadSkills({
|
const { skills, warnings } = loadSkills({
|
||||||
enableCodexUser: false,
|
agentDir: emptyAgentDir,
|
||||||
enableClaudeUser: false,
|
cwd: emptyCwd,
|
||||||
enableClaudeProject: false,
|
skillPaths: ["/non/existent/path"],
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [join(fixturesDir, "valid-skill")],
|
|
||||||
ignoredSkills: ["valid-skill"],
|
|
||||||
});
|
});
|
||||||
expect(skills).toHaveLength(0);
|
expect(skills).toHaveLength(0);
|
||||||
|
expect(warnings.some((w) => w.message.includes("does not exist"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should support glob patterns in ignoredSkills", () => {
|
it("should expand ~ in skillPaths", () => {
|
||||||
const { skills } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [fixturesDir],
|
|
||||||
ignoredSkills: ["valid-*"],
|
|
||||||
});
|
|
||||||
expect(skills.every((s) => !s.name.startsWith("valid-"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have ignoredSkills take precedence over includeSkills", () => {
|
|
||||||
const { skills } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [fixturesDir],
|
|
||||||
includeSkills: ["valid-*"],
|
|
||||||
ignoredSkills: ["valid-skill"],
|
|
||||||
});
|
|
||||||
// valid-skill should be excluded even though it matches includeSkills
|
|
||||||
expect(skills.every((s) => s.name !== "valid-skill")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should expand ~ in customDirectories", () => {
|
|
||||||
const homeSkillsDir = join(homedir(), ".pi/agent/skills");
|
const homeSkillsDir = join(homedir(), ".pi/agent/skills");
|
||||||
const { skills: withTilde } = loadSkills({
|
const { skills: withTilde } = loadSkills({
|
||||||
enableCodexUser: false,
|
agentDir: emptyAgentDir,
|
||||||
enableClaudeUser: false,
|
cwd: emptyCwd,
|
||||||
enableClaudeProject: false,
|
skillPaths: ["~/.pi/agent/skills"],
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: ["~/.pi/agent/skills"],
|
|
||||||
});
|
});
|
||||||
const { skills: withoutTilde } = loadSkills({
|
const { skills: withoutTilde } = loadSkills({
|
||||||
enableCodexUser: false,
|
agentDir: emptyAgentDir,
|
||||||
enableClaudeUser: false,
|
cwd: emptyCwd,
|
||||||
enableClaudeProject: false,
|
skillPaths: [homeSkillsDir],
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [homeSkillsDir],
|
|
||||||
});
|
});
|
||||||
expect(withTilde.length).toBe(withoutTilde.length);
|
expect(withTilde.length).toBe(withoutTilde.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty when all sources disabled and no custom dirs", () => {
|
|
||||||
const { skills } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
});
|
|
||||||
expect(skills).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter skills with includeSkills glob patterns", () => {
|
|
||||||
// Load all skills from fixtures
|
|
||||||
const { skills: allSkills } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [fixturesDir],
|
|
||||||
});
|
|
||||||
expect(allSkills.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Filter to only include "valid-skill"
|
|
||||||
const { skills: filtered } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [fixturesDir],
|
|
||||||
includeSkills: ["valid-skill"],
|
|
||||||
});
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(filtered[0].name).toBe("valid-skill");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support glob patterns in includeSkills", () => {
|
|
||||||
const { skills } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [fixturesDir],
|
|
||||||
includeSkills: ["valid-*"],
|
|
||||||
});
|
|
||||||
expect(skills.length).toBeGreaterThan(0);
|
|
||||||
expect(skills.every((s) => s.name.startsWith("valid-"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return all skills when includeSkills is empty", () => {
|
|
||||||
const { skills: withEmpty } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [fixturesDir],
|
|
||||||
includeSkills: [],
|
|
||||||
});
|
|
||||||
const { skills: withoutOption } = loadSkills({
|
|
||||||
enableCodexUser: false,
|
|
||||||
enableClaudeUser: false,
|
|
||||||
enableClaudeProject: false,
|
|
||||||
enablePiUser: false,
|
|
||||||
enablePiProject: false,
|
|
||||||
customDirectories: [fixturesDir],
|
|
||||||
});
|
|
||||||
expect(withEmpty.length).toBe(withoutOption.length);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("collision handling", () => {
|
describe("collision handling", () => {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import { Agent } from "@mariozechner/pi-agent-core";
|
||||||
import { getModel, getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
|
import { getModel, getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
|
||||||
import { AgentSession } from "../src/core/agent-session.js";
|
import { AgentSession } from "../src/core/agent-session.js";
|
||||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||||
|
import { createExtensionRuntime } from "../src/core/extensions/loader.js";
|
||||||
import { ModelRegistry } from "../src/core/model-registry.js";
|
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
|
import type { ResourceLoader } from "../src/core/resource-loader.js";
|
||||||
import { SessionManager } from "../src/core/session-manager.js";
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
import { codingTools } from "../src/core/tools/index.js";
|
import { codingTools } from "../src/core/tools/index.js";
|
||||||
|
|
@ -172,6 +174,19 @@ export interface TestSessionContext {
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createTestResourceLoader(): ResourceLoader {
|
||||||
|
return {
|
||||||
|
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||||
|
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||||
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||||
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||||
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||||
|
getSystemPrompt: () => undefined,
|
||||||
|
getAppendSystemPrompt: () => [],
|
||||||
|
reload: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an AgentSession for testing with proper setup and cleanup.
|
* Create an AgentSession for testing with proper setup and cleanup.
|
||||||
* Use this for e2e tests that need real LLM calls.
|
* Use this for e2e tests that need real LLM calls.
|
||||||
|
|
@ -204,7 +219,9 @@ export function createTestSession(options: TestSessionOptions = {}): TestSession
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Must subscribe to enable session persistence
|
// Must subscribe to enable session persistence
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import {
|
||||||
AgentSession,
|
AgentSession,
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
convertToLlm,
|
convertToLlm,
|
||||||
|
createExtensionRuntime,
|
||||||
formatSkillsForPrompt,
|
formatSkillsForPrompt,
|
||||||
loadSkillsFromDir,
|
loadSkillsFromDir,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
|
type ResourceLoader,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type Skill,
|
type Skill,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
@ -448,12 +450,28 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
|
||||||
log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
|
log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resourceLoader: ResourceLoader = {
|
||||||
|
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
||||||
|
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||||
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||||
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||||
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||||
|
getSystemPrompt: () => systemPrompt,
|
||||||
|
getAppendSystemPrompt: () => [],
|
||||||
|
reload: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
|
||||||
|
|
||||||
// Create AgentSession wrapper
|
// Create AgentSession wrapper
|
||||||
const session = new AgentSession({
|
const session = new AgentSession({
|
||||||
agent,
|
agent,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager: settingsManager as any,
|
settingsManager: settingsManager as any,
|
||||||
|
cwd: process.cwd(),
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
|
resourceLoader,
|
||||||
|
baseToolsOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable per-run state - event handler references this
|
// Mutable per-run state - event handler references this
|
||||||
|
|
|
||||||
19
test.sh
19
test.sh
|
|
@ -33,9 +33,28 @@ unset XAI_API_KEY
|
||||||
unset OPENROUTER_API_KEY
|
unset OPENROUTER_API_KEY
|
||||||
unset ZAI_API_KEY
|
unset ZAI_API_KEY
|
||||||
unset MISTRAL_API_KEY
|
unset MISTRAL_API_KEY
|
||||||
|
unset MINIMAX_API_KEY
|
||||||
|
unset MINIMAX_CN_API_KEY
|
||||||
|
unset AI_GATEWAY_API_KEY
|
||||||
|
unset OPENCODE_API_KEY
|
||||||
unset COPILOT_GITHUB_TOKEN
|
unset COPILOT_GITHUB_TOKEN
|
||||||
unset GH_TOKEN
|
unset GH_TOKEN
|
||||||
unset GITHUB_TOKEN
|
unset GITHUB_TOKEN
|
||||||
|
unset GOOGLE_APPLICATION_CREDENTIALS
|
||||||
|
unset GOOGLE_CLOUD_PROJECT
|
||||||
|
unset GCLOUD_PROJECT
|
||||||
|
unset GOOGLE_CLOUD_LOCATION
|
||||||
|
unset AWS_PROFILE
|
||||||
|
unset AWS_ACCESS_KEY_ID
|
||||||
|
unset AWS_SECRET_ACCESS_KEY
|
||||||
|
unset AWS_SESSION_TOKEN
|
||||||
|
unset AWS_REGION
|
||||||
|
unset AWS_DEFAULT_REGION
|
||||||
|
unset AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
unset AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
|
||||||
|
unset AWS_CONTAINER_CREDENTIALS_FULL_URI
|
||||||
|
unset AWS_WEB_IDENTITY_TOKEN_FILE
|
||||||
|
unset BEDROCK_EXTENSIVE_MODEL_TEST
|
||||||
|
|
||||||
echo "Running tests without API keys..."
|
echo "Running tests without API keys..."
|
||||||
npm test
|
npm test
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue