mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +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]
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
|
|||
| `/new` | Start a new session |
|
||||
| `/copy` | Copy last agent message to clipboard |
|
||||
| `/compact [instructions]` | Manually compact conversation context |
|
||||
| `/reload` | Reload extensions, skills, prompts, and themes |
|
||||
|
||||
### Editor Features
|
||||
|
||||
|
|
@ -791,9 +792,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
|
|||
"reserveTokens": 16384,
|
||||
"keepRecentTokens": 20000
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true
|
||||
},
|
||||
"skills": ["/path/to/skills"],
|
||||
"prompts": ["/path/to/prompts"],
|
||||
"themes": ["/path/to/themes"],
|
||||
"enableSkillCommands": true,
|
||||
"retry": {
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
|
|
@ -827,7 +829,10 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
|
|||
| `compaction.enabled` | Enable auto-compaction | `true` |
|
||||
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
|
||||
| `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.maxRetries` | Maximum retry attempts | `3` |
|
||||
| `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.blockImages` | Prevent images from being sent to LLM providers | `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` |
|
||||
| `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.
|
||||
|
||||
Add additional theme paths via `settings.json` `themes` array or `--theme <path>`. Disable automatic theme discovery with `--no-themes`.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.pi/agent/themes
|
||||
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:**
|
||||
- Global: `~/.pi/agent/prompts/*.md`
|
||||
- Project: `.pi/prompts/*.md`
|
||||
- Additional paths from settings.json `prompts` array
|
||||
- CLI `--prompt-template` paths
|
||||
|
||||
**Format:**
|
||||
|
||||
|
|
@ -900,6 +909,8 @@ Usage: `/component Button "onClick handler" "disabled support"`
|
|||
- `${@:N}` = arguments from the Nth position onwards (1-indexed)
|
||||
- `${@: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)`
|
||||
|
||||
|
||||
|
|
@ -918,10 +929,12 @@ A skill provides specialized workflows, setup instructions, helper scripts, and
|
|||
- YouTube transcript extraction
|
||||
|
||||
**Skill locations:**
|
||||
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive)
|
||||
- Pi project: `.pi/skills/**/SKILL.md` (recursive)
|
||||
- Claude Code: `~/.claude/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`
|
||||
- Codex CLI: `~/.codex/skills/**/SKILL.md` (recursive)
|
||||
- Global: `~/.pi/agent/skills/`
|
||||
- Project: `.pi/skills/`
|
||||
- Additional paths from settings.json `skills` array
|
||||
- CLI `--skill` paths (additive even with `--no-skills`)
|
||||
|
||||
Use `enableSkillCommands` in settings to toggle `/skill:name` commands.
|
||||
|
||||
**Format:**
|
||||
|
||||
|
|
@ -948,7 +961,7 @@ cd /path/to/brave-search && npm install
|
|||
- `name`: Required. Must match parent directory name. Lowercase, hyphens, max 64 chars.
|
||||
- `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.
|
||||
|
||||
|
|
@ -967,7 +980,18 @@ Extensions are TypeScript modules that extend pi's behavior.
|
|||
**Locations:**
|
||||
- Global: `~/.pi/agent/extensions/*.ts` or `~/.pi/agent/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/).
|
||||
|
||||
|
|
@ -1214,6 +1238,14 @@ await ctx.ui.custom((tui, theme, done) => ({
|
|||
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
|
||||
|
||||
| Option | Description |
|
||||
|
|
@ -1236,8 +1268,12 @@ pi [options] [@files...] [messages...]
|
|||
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
|
||||
| `--extension <path>`, `-e` | Load an extension file (can be used multiple times) |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| `--help`, `-h` | Show help |
|
||||
| `--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:
|
||||
|
||||
```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 modelRegistry = discoverModels(authStorage);
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
|
|
@ -1361,15 +1397,13 @@ await session.prompt("What files are in the current directory?");
|
|||
|
||||
The SDK provides full control over:
|
||||
- Model selection and thinking level
|
||||
- System prompt (replace or modify)
|
||||
- Tools (built-in subsets, custom tools)
|
||||
- Extensions (discovered or via paths)
|
||||
- Skills, context files, prompt templates
|
||||
- Resources via `ResourceLoader` (extensions, skills, prompts, themes, context files, system prompt)
|
||||
- Session persistence (`SessionManager`)
|
||||
- Settings (`SettingsManager`)
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -115,10 +115,22 @@ Additional paths via `settings.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:**
|
||||
|
||||
1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly
|
||||
|
|
@ -146,13 +158,17 @@ Additional paths via `settings.json`:
|
|||
"zod": "^3.0.0"
|
||||
},
|
||||
"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:
|
||||
- Multiple extensions from one package
|
||||
- Skills, prompts, and themes declared alongside extensions
|
||||
- Third-party npm dependencies (resolved via jiti)
|
||||
- Nested source structure (no depth limit within the package)
|
||||
- Deployment to and installation from npm
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full
|
|||
## Quick Start
|
||||
|
||||
```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
|
||||
const authStorage = discoverAuthStorage();
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
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.
|
||||
|
||||
**Philosophy:** "Omit to discover, provide to override."
|
||||
- Omit an option → pi discovers/loads from standard locations
|
||||
- Provide an option → your value is used, discovery skipped for that option
|
||||
`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.
|
||||
|
||||
```typescript
|
||||
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();
|
||||
|
||||
// Custom: override specific options
|
||||
const { session } = await createAgentSession({
|
||||
model: myModel,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [readTool, bashTool],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
|
@ -251,7 +248,7 @@ session.subscribe((event) => {
|
|||
|
||||
```typescript
|
||||
const { session } = await createAgentSession({
|
||||
// Working directory for project-local discovery
|
||||
// Working directory for DefaultResourceLoader discovery
|
||||
cwd: process.cwd(), // default
|
||||
|
||||
// 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 skills (`.pi/skills/`)
|
||||
- Project prompts (`.pi/prompts/`)
|
||||
- Context files (`AGENTS.md` walking up from cwd)
|
||||
- Session directory naming
|
||||
|
||||
`agentDir` is used for:
|
||||
`agentDir` is used by `DefaultResourceLoader` for:
|
||||
- Global extensions (`extensions/`)
|
||||
- Global skills (`skills/`)
|
||||
- Global prompts (`prompts/`)
|
||||
|
|
@ -276,14 +273,16 @@ const { session } = await createAgentSession({
|
|||
- Credentials (`auth.json`)
|
||||
- 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
|
||||
|
||||
```typescript
|
||||
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 modelRegistry = discoverModels(authStorage);
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
// Find specific built-in model (doesn't check if API key exists)
|
||||
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`)
|
||||
|
||||
```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
|
||||
const authStorage = discoverAuthStorage();
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
|
|
@ -360,16 +359,17 @@ const simpleRegistry = new ModelRegistry(authStorage);
|
|||
|
||||
### System Prompt
|
||||
|
||||
```typescript
|
||||
const { session } = await createAgentSession({
|
||||
// Replace entirely
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
Use a `ResourceLoader` to override the system prompt:
|
||||
|
||||
// Or modify default (receives default, returns modified)
|
||||
systemPrompt: (defaultPrompt) => {
|
||||
return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
|
||||
},
|
||||
```typescript
|
||||
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const loader = new DefaultResourceLoader({
|
||||
systemPromptOverride: () => "You are a helpful assistant.",
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||
```
|
||||
|
||||
> 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)
|
||||
|
||||
### Extensions
|
||||
|
||||
By default, extensions are discovered from multiple locations:
|
||||
- `~/.pi/agent/extensions/` (global)
|
||||
- `.pi/extensions/` (project-local)
|
||||
- Paths listed in `settings.json` `"extensions"` array
|
||||
Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources.
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, type ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Inline extension factory
|
||||
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({
|
||||
const loader = new DefaultResourceLoader({
|
||||
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({
|
||||
extensions: [],
|
||||
});
|
||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||
```
|
||||
|
||||
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
|
||||
import { createAgentSession, createEventBus } from "@mariozechner/pi-coding-agent";
|
||||
import { createEventBus, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
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));
|
||||
```
|
||||
|
||||
|
|
@ -525,14 +509,13 @@ eventBus.on("my-extension:status", (data) => console.log(data));
|
|||
### Skills
|
||||
|
||||
```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 { skills: allSkills, warnings } = discoverSkills();
|
||||
const filtered = allSkills.filter(s => s.name.includes("search"));
|
||||
|
||||
// Custom skill
|
||||
const mySkill: Skill = {
|
||||
const customSkill: Skill = {
|
||||
name: "my-skill",
|
||||
description: "Custom instructions",
|
||||
filePath: "/path/to/SKILL.md",
|
||||
|
|
@ -540,20 +523,15 @@ const mySkill: Skill = {
|
|||
source: "custom",
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
skills: [...filtered, mySkill],
|
||||
const loader = new DefaultResourceLoader({
|
||||
skillsOverride: (current) => ({
|
||||
skills: [...current.skills, customSkill],
|
||||
diagnostics: current.diagnostics,
|
||||
}),
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
// Disable skills
|
||||
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)
|
||||
});
|
||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||
```
|
||||
|
||||
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
|
||||
|
|
@ -561,26 +539,19 @@ const { skills } = discoverSkills(process.cwd(), undefined, {
|
|||
### Context Files
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverContextFiles } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, DefaultResourceLoader } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover AGENTS.md files
|
||||
const discovered = discoverContextFiles();
|
||||
|
||||
// Add custom context
|
||||
const { session } = await createAgentSession({
|
||||
contextFiles: [
|
||||
...discovered,
|
||||
{
|
||||
path: "/virtual/AGENTS.md",
|
||||
content: "# Guidelines\n\n- Be concise\n- Use TypeScript",
|
||||
},
|
||||
],
|
||||
const loader = new DefaultResourceLoader({
|
||||
agentsFilesOverride: (current) => ({
|
||||
agentsFiles: [
|
||||
...current.agentsFiles,
|
||||
{ path: "/virtual/AGENTS.md", content: "# Guidelines\n\n- Be concise" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
// Disable context files
|
||||
const { session } = await createAgentSession({
|
||||
contextFiles: [],
|
||||
});
|
||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||
```
|
||||
|
||||
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
|
||||
|
|
@ -588,9 +559,11 @@ const { session } = await createAgentSession({
|
|||
### Slash Commands
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, discoverPromptTemplates, type PromptTemplate } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const discovered = discoverPromptTemplates();
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
type PromptTemplate,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const customCommand: PromptTemplate = {
|
||||
name: "deploy",
|
||||
|
|
@ -599,9 +572,15 @@ const customCommand: PromptTemplate = {
|
|||
content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
|
||||
};
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
promptTemplates: [...discovered, customCommand],
|
||||
const loader = new DefaultResourceLoader({
|
||||
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)
|
||||
|
|
@ -719,62 +698,31 @@ Settings load from two locations and merge:
|
|||
1. Global: `~/.pi/agent/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)
|
||||
|
||||
## Discovery Functions
|
||||
## ResourceLoader
|
||||
|
||||
All discovery functions accept optional `cwd` and `agentDir` parameters.
|
||||
Use `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and context files.
|
||||
|
||||
```typescript
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
AuthStorage,
|
||||
ModelRegistry,
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
discoverSkills,
|
||||
discoverExtensions,
|
||||
discoverContextFiles,
|
||||
discoverPromptTemplates,
|
||||
loadSettings,
|
||||
buildSystemPrompt,
|
||||
createEventBus,
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Auth and Models
|
||||
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",
|
||||
const loader = new DefaultResourceLoader({
|
||||
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
|
||||
|
|
@ -808,12 +756,12 @@ import { Type } from "@sinclair/typebox";
|
|||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
readTool,
|
||||
bashTool,
|
||||
type ExtensionFactory,
|
||||
type ToolDefinition,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
|
|
@ -828,14 +776,6 @@ if (process.env.MY_KEY) {
|
|||
// Model registry (no custom models.json)
|
||||
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
|
||||
const statusTool: ToolDefinition = {
|
||||
name: "status",
|
||||
|
|
@ -857,6 +797,14 @@ const settingsManager = SettingsManager.inMemory({
|
|||
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({
|
||||
cwd: process.cwd(),
|
||||
agentDir: "/custom/agent",
|
||||
|
|
@ -866,14 +814,9 @@ const { session } = await createAgentSession({
|
|||
authStorage,
|
||||
modelRegistry,
|
||||
|
||||
systemPrompt: "You are a minimal assistant. Be concise.",
|
||||
|
||||
tools: [readTool, bashTool],
|
||||
customTools: [statusTool],
|
||||
extensions: [auditExtension],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
resourceLoader: loader,
|
||||
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
|
|
@ -976,21 +919,13 @@ createAgentSession
|
|||
// Auth and Models
|
||||
AuthStorage
|
||||
ModelRegistry
|
||||
discoverAuthStorage
|
||||
discoverModels
|
||||
|
||||
// Discovery
|
||||
discoverSkills
|
||||
discoverExtensions
|
||||
discoverContextFiles
|
||||
discoverPromptTemplates
|
||||
|
||||
// Event Bus (for shared extension communication)
|
||||
// Resource loading
|
||||
DefaultResourceLoader
|
||||
type ResourceLoader
|
||||
createEventBus
|
||||
|
||||
// Helpers
|
||||
loadSettings
|
||||
buildSystemPrompt
|
||||
|
||||
// Session management
|
||||
SessionManager
|
||||
|
|
@ -1016,8 +951,6 @@ type ExtensionAPI
|
|||
type ToolDefinition
|
||||
type Skill
|
||||
type PromptTemplate
|
||||
type Settings
|
||||
type SkillsSettings
|
||||
type Tool
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -141,64 +141,43 @@ Run the extraction script:
|
|||
|
||||
Skills are discovered from these locations (later wins on name collision):
|
||||
|
||||
1. `~/.codex/skills/**/SKILL.md` (Codex CLI, recursive)
|
||||
2. `~/.claude/skills/*/SKILL.md` (Claude Code user, one level)
|
||||
3. `<cwd>/.claude/skills/*/SKILL.md` (Claude Code project, one level)
|
||||
4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive)
|
||||
5. `<cwd>/.pi/skills/**/SKILL.md` (Pi project, recursive)
|
||||
1. `~/.pi/agent/skills/` (global)
|
||||
2. `<cwd>/.pi/skills/` (project)
|
||||
3. Paths listed in `settings.json` under `skills`
|
||||
4. CLI `--skill` paths (additive even with `--no-skills`)
|
||||
|
||||
Discovery rules for each directory:
|
||||
- Direct `.md` files in the root
|
||||
- Recursive `SKILL.md` files under subdirectories
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure skill loading in `~/.pi/agent/settings.json`:
|
||||
Configure skill paths in `~/.pi/agent/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"enabled": true,
|
||||
"enableCodexUser": true,
|
||||
"enableClaudeUser": true,
|
||||
"enableClaudeProject": true,
|
||||
"enablePiUser": true,
|
||||
"enablePiProject": true,
|
||||
"enableSkillCommands": true,
|
||||
"customDirectories": ["~/my-skills-repo"],
|
||||
"ignoredSkills": ["deprecated-skill"],
|
||||
"includeSkills": ["git-*", "docker"]
|
||||
}
|
||||
"skills": ["~/my-skills-repo", "/path/to/skill/SKILL.md"],
|
||||
"enableSkillCommands": true
|
||||
}
|
||||
```
|
||||
|
||||
Use project-local settings in `<cwd>/.pi/settings.json` to scope skills to a single project.
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `enabled` | `true` | Master toggle for all skills |
|
||||
| `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/` |
|
||||
| `skills` | `[]` | Additional skill file or directory paths |
|
||||
| `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 `--skills` to filter skills for a specific invocation:
|
||||
Use `--skill` to add skills for a specific invocation:
|
||||
|
||||
```bash
|
||||
# Only load specific skills
|
||||
pi --skills git,docker
|
||||
|
||||
# Glob patterns
|
||||
pi --skills "git-*,docker-*"
|
||||
|
||||
# All skills matching a prefix
|
||||
pi --skills "aws-*"
|
||||
pi --skill ~/my-skills/terraform
|
||||
pi --skill ./skills/custom-skill/SKILL.md
|
||||
```
|
||||
|
||||
This overrides the `includeSkills` setting for the current session.
|
||||
Use `--no-skills` to disable automatic discovery (CLI `--skill` paths still load).
|
||||
|
||||
## How Skills Work
|
||||
|
||||
|
|
@ -224,9 +203,7 @@ Toggle skill commands via `/settings` or in `settings.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
|
||||
|
||||
For inspiration and ready-to-use skills:
|
||||
|
|
@ -305,13 +276,4 @@ CLI:
|
|||
pi --no-skills
|
||||
```
|
||||
|
||||
Settings (`~/.pi/agent/settings.json`):
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the granular `enable*` flags to disable individual sources (e.g., `enableClaudeUser: false` to skip `~/.claude/skills`).
|
||||
Use `--no-skills` to disable automatic discovery while keeping explicit `--skill` paths.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
*/
|
||||
|
||||
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
|
||||
const authStorage = discoverAuthStorage();
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
// Option 1: Find a specific built-in model by provider/id
|
||||
const opus = getModel("anthropic", "claude-opus-4-5");
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@
|
|||
* 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
|
||||
const { session: session1 } = await createAgentSession({
|
||||
systemPrompt: `You are a helpful assistant that speaks like a pirate.
|
||||
const loader1 = new DefaultResourceLoader({
|
||||
systemPromptOverride: () => `You are a helpful assistant that speaks like a pirate.
|
||||
Always end responses with "Arrr!"`,
|
||||
appendSystemPromptOverride: () => [],
|
||||
});
|
||||
await loader1.reload();
|
||||
|
||||
const { session: session1 } = await createAgentSession({
|
||||
resourceLoader: loader1,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
|
|
@ -23,13 +29,17 @@ console.log("=== Replace prompt ===");
|
|||
await session1.prompt("What is 2 + 2?");
|
||||
console.log("\n");
|
||||
|
||||
// Option 2: Modify default prompt (receives default, returns modified)
|
||||
const { session: session2 } = await createAgentSession({
|
||||
systemPrompt: (defaultPrompt) => `${defaultPrompt}
|
||||
// Option 2: Append instructions to the default prompt
|
||||
const loader2 = new DefaultResourceLoader({
|
||||
appendSystemPromptOverride: (base) => [
|
||||
...base,
|
||||
"## Additional Instructions\n- Always be concise\n- Use bullet points when listing things",
|
||||
],
|
||||
});
|
||||
await loader2.reload();
|
||||
|
||||
## Additional Instructions
|
||||
- Always be concise
|
||||
- Use bullet points when listing things`,
|
||||
const { session: session2 } = await createAgentSession({
|
||||
resourceLoader: loader2,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,20 +5,7 @@
|
|||
* Discover, filter, merge, or replace them.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverSkills, 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"));
|
||||
import { createAgentSession, DefaultResourceLoader, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Or define custom skills inline
|
||||
const customSkill: Skill = {
|
||||
|
|
@ -29,19 +16,30 @@ const customSkill: Skill = {
|
|||
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({
|
||||
skills: [...filteredSkills, customSkill],
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
console.log(`Session created with ${filteredSkills.length + 1} 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)
|
||||
// })
|
||||
console.log("Session created with filtered skills");
|
||||
|
|
|
|||
|
|
@ -13,16 +13,25 @@
|
|||
* 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.
|
||||
// You can also add paths via:
|
||||
// 1. settings.json: { "extensions": ["./my-extension.ts"] }
|
||||
// 2. additionalExtensionPaths option (see below)
|
||||
// You can also add paths via settings.json or DefaultResourceLoader options.
|
||||
|
||||
// To add additional extension paths beyond discovery:
|
||||
const { session } = await createAgentSession({
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,33 +4,36 @@
|
|||
* 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 discovered = discoverContextFiles();
|
||||
console.log("Discovered context files:");
|
||||
for (const file of discovered) {
|
||||
console.log(` - ${file.path} (${file.content.length} chars)`);
|
||||
}
|
||||
|
||||
// Use custom context files
|
||||
await createAgentSession({
|
||||
contextFiles: [
|
||||
...discovered,
|
||||
{
|
||||
path: "/virtual/AGENTS.md",
|
||||
content: `# Project Guidelines
|
||||
const loader = new DefaultResourceLoader({
|
||||
agentsFilesOverride: (current) => ({
|
||||
agentsFiles: [
|
||||
...current.agentsFiles,
|
||||
{
|
||||
path: "/virtual/AGENTS.md",
|
||||
content: `# Project Guidelines
|
||||
|
||||
## Code Style
|
||||
- Use TypeScript strict mode
|
||||
- No any types
|
||||
- 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(),
|
||||
});
|
||||
|
||||
console.log(`Session created with ${discovered.length + 1} context files`);
|
||||
|
||||
// Disable context files:
|
||||
// contextFiles: []
|
||||
|
|
|
|||
|
|
@ -6,18 +6,11 @@
|
|||
|
||||
import {
|
||||
createAgentSession,
|
||||
discoverPromptTemplates,
|
||||
DefaultResourceLoader,
|
||||
type PromptTemplate,
|
||||
SessionManager,
|
||||
} 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
|
||||
const deployTemplate: PromptTemplate = {
|
||||
name: "deploy",
|
||||
|
|
@ -30,13 +23,24 @@ const deployTemplate: PromptTemplate = {
|
|||
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({
|
||||
promptTemplates: [...discovered, deployTemplate],
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
});
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
|
||||
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
|
||||
const authStorage = discoverAuthStorage();
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
// Default: AuthStorage uses ~/.pi/agent/auth.json
|
||||
// ModelRegistry loads built-in + custom models from ~/.pi/agent/models.json
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
* 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)
|
||||
const settings = loadSettings();
|
||||
console.log("Current settings:", JSON.stringify(settings, null, 2));
|
||||
const settingsManagerFromDisk = SettingsManager.create();
|
||||
console.log("Current settings:", JSON.stringify(settingsManagerFromDisk.getGlobalSettings(), null, 2));
|
||||
|
||||
// Override specific settings
|
||||
const settingsManager = SettingsManager.create();
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import {
|
|||
AuthStorage,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createExtensionRuntime,
|
||||
createReadTool,
|
||||
ModelRegistry,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} 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
|
||||
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({
|
||||
cwd,
|
||||
agentDir: "/tmp/my-agent",
|
||||
|
|
@ -49,15 +63,9 @@ const { session } = await createAgentSession({
|
|||
thinkingLevel: "off",
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
systemPrompt: `You are a minimal assistant.
|
||||
Available: read, bash. Be concise.`,
|
||||
resourceLoader,
|
||||
// Use factory functions with the same cwd to ensure path resolution works correctly
|
||||
tools: [createReadTool(cwd), createBashTool(cwd)],
|
||||
// Pass empty array to disable extension discovery, or provide inline factories
|
||||
extensions: [],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,24 +33,18 @@ import { getModel } from "@mariozechner/pi-ai";
|
|||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
discoverSkills,
|
||||
discoverExtensions,
|
||||
discoverContextFiles,
|
||||
discoverPromptTemplates,
|
||||
loadSettings,
|
||||
buildSystemPrompt,
|
||||
DefaultResourceLoader,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
codingTools,
|
||||
readOnlyTools,
|
||||
readTool, bashTool, editTool, writeTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Auth and models setup
|
||||
const authStorage = discoverAuthStorage();
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
// Minimal
|
||||
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 });
|
||||
|
||||
// Modify prompt
|
||||
const { session } = await createAgentSession({
|
||||
systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.",
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
const loader = new DefaultResourceLoader({
|
||||
systemPromptOverride: (base) => `${base}\n\nBe concise.`,
|
||||
});
|
||||
await loader.reload();
|
||||
const { session } = await createAgentSession({ resourceLoader: loader, authStorage, modelRegistry });
|
||||
|
||||
// Read-only
|
||||
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!);
|
||||
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({
|
||||
model,
|
||||
authStorage: customAuth,
|
||||
modelRegistry: customRegistry,
|
||||
systemPrompt: "You are helpful.",
|
||||
resourceLoader,
|
||||
tools: [readTool, bashTool],
|
||||
customTools: [{ tool: myTool }],
|
||||
extensions: [{ factory: myExtension }],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
});
|
||||
|
||||
// Run prompts
|
||||
|
|
@ -108,23 +108,17 @@ await session.prompt("Hello");
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `authStorage` | `discoverAuthStorage()` | Credential storage |
|
||||
| `modelRegistry` | `discoverModels(authStorage)` | Model registry |
|
||||
| `authStorage` | `new AuthStorage()` | Credential storage |
|
||||
| `modelRegistry` | `new ModelRegistry(authStorage)` | Model registry |
|
||||
| `cwd` | `process.cwd()` | Working directory |
|
||||
| `agentDir` | `~/.pi/agent` | Config directory |
|
||||
| `model` | From settings/first available | Model to use |
|
||||
| `thinkingLevel` | From settings/"off" | off, low, medium, high |
|
||||
| `systemPrompt` | Discovered | String or `(default) => modified` |
|
||||
| `tools` | `codingTools` | Built-in tools |
|
||||
| `customTools` | Discovered | Replaces discovery |
|
||||
| `additionalCustomToolPaths` | `[]` | Merge with discovery |
|
||||
| `extensions` | Discovered | Replaces discovery |
|
||||
| `additionalExtensionPaths` | `[]` | Merge with discovery |
|
||||
| `skills` | Discovered | Skills for prompt |
|
||||
| `contextFiles` | Discovered | AGENTS.md files |
|
||||
| `promptTemplates` | Discovered | Prompt templates (slash commands) |
|
||||
| `customTools` | `[]` | Additional tool definitions |
|
||||
| `resourceLoader` | DefaultResourceLoader | Resource loader for extensions, skills, prompts, themes |
|
||||
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
|
||||
| `settingsManager` | From agentDir | Settings overrides |
|
||||
| `settingsManager` | `SettingsManager.create(cwd, agentDir)` | Settings overrides |
|
||||
|
||||
## Events
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ export interface Args {
|
|||
export?: string;
|
||||
noSkills?: boolean;
|
||||
skills?: string[];
|
||||
promptTemplates?: string[];
|
||||
noPromptTemplates?: boolean;
|
||||
themes?: string[];
|
||||
noThemes?: boolean;
|
||||
listModels?: string | true;
|
||||
messages: string[];
|
||||
fileArgs: string[];
|
||||
|
|
@ -122,11 +126,21 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
|||
result.extensions.push(args[++i]);
|
||||
} else if (arg === "--no-extensions") {
|
||||
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") {
|
||||
result.noSkills = true;
|
||||
} else if (arg === "--skills" && i + 1 < args.length) {
|
||||
// Comma-separated glob patterns for skill filtering
|
||||
result.skills = args[++i].split(",").map((s) => s.trim());
|
||||
} else if (arg === "--no-prompt-templates") {
|
||||
result.noPromptTemplates = true;
|
||||
} else if (arg === "--no-themes") {
|
||||
result.noThemes = true;
|
||||
} else if (arg === "--list-models") {
|
||||
// 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("@")) {
|
||||
|
|
@ -162,6 +176,11 @@ export function printHelp(): void {
|
|||
${chalk.bold("Usage:")}
|
||||
${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:")}
|
||||
--provider <name> Provider name (default: google)
|
||||
--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
|
||||
--extension, -e <path> Load an extension file (can be used multiple times)
|
||||
--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
|
||||
--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
|
||||
--list-models [search] List available models (with optional fuzzy search)
|
||||
--help, -h Show this help
|
||||
|
|
|
|||
|
|
@ -40,25 +40,35 @@ import {
|
|||
} from "./compaction/index.js";
|
||||
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
|
||||
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
|
||||
import type {
|
||||
ContextUsage,
|
||||
import {
|
||||
type ContextUsage,
|
||||
type ExtensionCommandContextActions,
|
||||
type ExtensionErrorListener,
|
||||
ExtensionRunner,
|
||||
InputSource,
|
||||
SessionBeforeCompactResult,
|
||||
SessionBeforeForkResult,
|
||||
SessionBeforeSwitchResult,
|
||||
SessionBeforeTreeResult,
|
||||
TreePreparation,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
type ExtensionUIContext,
|
||||
type InputSource,
|
||||
type SessionBeforeCompactResult,
|
||||
type SessionBeforeForkResult,
|
||||
type SessionBeforeSwitchResult,
|
||||
type SessionBeforeTreeResult,
|
||||
type ShutdownHandler,
|
||||
type ToolDefinition,
|
||||
type TreePreparation,
|
||||
type TurnEndEvent,
|
||||
type TurnStartEvent,
|
||||
wrapRegisteredTools,
|
||||
wrapToolsWithExtensions,
|
||||
} from "./extensions/index.js";
|
||||
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
|
||||
import type { ModelRegistry } from "./model-registry.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 { SettingsManager, SkillsSettings } from "./settings-manager.js";
|
||||
import type { SettingsManager } from "./settings-manager.js";
|
||||
import type { Skill, SkillWarning } from "./skills.js";
|
||||
import { buildSystemPrompt } from "./system-prompt.js";
|
||||
import type { BashOperations } from "./tools/bash.js";
|
||||
import { createAllTools } from "./tools/index.js";
|
||||
|
||||
/** Session-specific events that extend the core AgentEvent */
|
||||
export type AgentSessionEvent =
|
||||
|
|
@ -85,23 +95,28 @@ export interface AgentSessionConfig {
|
|||
agent: Agent;
|
||||
sessionManager: SessionManager;
|
||||
settingsManager: SettingsManager;
|
||||
cwd: string;
|
||||
/** Models to cycle through with Ctrl+P (from --models flag) */
|
||||
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||
/** File-based prompt templates for expansion */
|
||||
promptTemplates?: PromptTemplate[];
|
||||
/** Extension runner (created in sdk.ts with wrapped tools) */
|
||||
extensionRunner?: ExtensionRunner;
|
||||
/** Loaded skills (already discovered by SDK) */
|
||||
skills?: Skill[];
|
||||
/** Skill loading warnings (already captured by SDK) */
|
||||
skillWarnings?: SkillWarning[];
|
||||
skillsSettings?: Required<SkillsSettings>;
|
||||
/** Resource loader for skills, prompts, themes, context files, system prompt */
|
||||
resourceLoader: ResourceLoader;
|
||||
/** SDK custom tools registered outside extensions */
|
||||
customTools?: ToolDefinition[];
|
||||
/** Model registry for API key resolution and model discovery */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Tool registry for extension getTools/setTools - maps name to tool */
|
||||
toolRegistry?: Map<string, AgentTool>;
|
||||
/** Function to rebuild system prompt when tools change */
|
||||
rebuildSystemPrompt?: (toolNames: string[]) => string;
|
||||
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
|
||||
initialActiveToolNames?: string[];
|
||||
/** Override base tools (useful for custom runtimes). */
|
||||
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() */
|
||||
|
|
@ -163,7 +178,6 @@ export class AgentSession {
|
|||
readonly settingsManager: SettingsManager;
|
||||
|
||||
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
||||
private _promptTemplates: PromptTemplate[];
|
||||
|
||||
// Event subscription state
|
||||
private _unsubscribeAgent?: () => void;
|
||||
|
|
@ -197,40 +211,49 @@ export class AgentSession {
|
|||
private _extensionRunner: ExtensionRunner | undefined = undefined;
|
||||
private _turnIndex = 0;
|
||||
|
||||
private _skills: Skill[];
|
||||
private _skillWarnings: SkillWarning[];
|
||||
private _skillsSettings: Required<SkillsSettings> | undefined;
|
||||
private _resourceLoader: ResourceLoader;
|
||||
private _customTools: ToolDefinition[];
|
||||
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
|
||||
private _modelRegistry: ModelRegistry;
|
||||
|
||||
// Tool registry for extension getTools/setTools
|
||||
private _toolRegistry: Map<string, AgentTool>;
|
||||
|
||||
// Function to rebuild system prompt when tools change
|
||||
private _rebuildSystemPrompt?: (toolNames: string[]) => string;
|
||||
private _toolRegistry: Map<string, AgentTool> = new Map();
|
||||
|
||||
// Base system prompt (without extension appends) - used to apply fresh appends each turn
|
||||
private _baseSystemPrompt: string;
|
||||
private _baseSystemPrompt = "";
|
||||
|
||||
constructor(config: AgentSessionConfig) {
|
||||
this.agent = config.agent;
|
||||
this.sessionManager = config.sessionManager;
|
||||
this.settingsManager = config.settingsManager;
|
||||
this._scopedModels = config.scopedModels ?? [];
|
||||
this._promptTemplates = config.promptTemplates ?? [];
|
||||
this._extensionRunner = config.extensionRunner;
|
||||
this._skills = config.skills ?? [];
|
||||
this._skillWarnings = config.skillWarnings ?? [];
|
||||
this._skillsSettings = config.skillsSettings;
|
||||
this._resourceLoader = config.resourceLoader;
|
||||
this._customTools = config.customTools ?? [];
|
||||
this._cwd = config.cwd;
|
||||
this._modelRegistry = config.modelRegistry;
|
||||
this._toolRegistry = config.toolRegistry ?? new Map();
|
||||
this._rebuildSystemPrompt = config.rebuildSystemPrompt;
|
||||
this._baseSystemPrompt = config.agent.state.systemPrompt;
|
||||
this._extensionRunnerRef = config.extensionRunnerRef;
|
||||
this._initialActiveToolNames = config.initialActiveToolNames;
|
||||
this._baseToolsOverride = config.baseToolsOverride;
|
||||
|
||||
// Always subscribe to agent events for internal handling
|
||||
// (session persistence, extensions, auto-compaction, retry logic)
|
||||
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
||||
|
||||
this._buildRuntime({
|
||||
activeToolNames: this._initialActiveToolNames,
|
||||
includeAllExtensionTools: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Model registry for API key resolution and model discovery */
|
||||
|
|
@ -502,10 +525,8 @@ export class AgentSession {
|
|||
this.agent.setTools(tools);
|
||||
|
||||
// Rebuild base system prompt with new tool set
|
||||
if (this._rebuildSystemPrompt) {
|
||||
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
|
||||
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
||||
}
|
||||
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
|
||||
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
||||
}
|
||||
|
||||
/** Whether auto-compaction is currently running */
|
||||
|
|
@ -550,7 +571,26 @@ export class AgentSession {
|
|||
|
||||
/** File-based prompt templates */
|
||||
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;
|
||||
if (expandPromptTemplates) {
|
||||
expandedText = this._expandSkillCommand(expandedText);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||
}
|
||||
|
||||
// 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 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
|
||||
|
||||
try {
|
||||
|
|
@ -784,7 +824,7 @@ export class AgentSession {
|
|||
|
||||
// Expand skill commands and prompt templates
|
||||
let expandedText = this._expandSkillCommand(text);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||
|
||||
await this._queueSteer(expandedText);
|
||||
}
|
||||
|
|
@ -803,7 +843,7 @@ export class AgentSession {
|
|||
|
||||
// Expand skill commands and prompt templates
|
||||
let expandedText = this._expandSkillCommand(text);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||
|
||||
await this._queueFollowUp(expandedText);
|
||||
}
|
||||
|
|
@ -891,6 +931,8 @@ export class AgentSession {
|
|||
message.display,
|
||||
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;
|
||||
}
|
||||
|
||||
get skillsSettings(): Required<SkillsSettings> | undefined {
|
||||
return this._skillsSettings;
|
||||
}
|
||||
|
||||
/** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
|
||||
/** Skills loaded by resource loader */
|
||||
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[] {
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ import {
|
|||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import { dirname, join } from "path";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { getAgentDir } from "../config.js";
|
||||
|
||||
export type ApiKeyCredential = {
|
||||
type: "api_key";
|
||||
|
|
@ -42,7 +43,7 @@ export class AuthStorage {
|
|||
private runtimeOverrides: Map<string, string> = new Map();
|
||||
private fallbackResolver?: (provider: string) => string | undefined;
|
||||
|
||||
constructor(private authPath: string) {
|
||||
constructor(private authPath: string = join(getAgentDir(), "auth.json")) {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { type ChildProcess, spawn } from "child_process";
|
||||
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 { 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 child: ChildProcess = spawn(shell, [...args, command], {
|
||||
detached: true,
|
||||
env: getShellEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
|||
|
||||
/**
|
||||
* 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 {
|
||||
const notInitialized = () => {
|
||||
|
|
@ -246,6 +246,7 @@ function createExtensionAPI(
|
|||
|
||||
async function loadExtensionModule(extensionPath: string) {
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
moduleCache: false,
|
||||
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
|
||||
// Also disable tryNative so jiti handles ALL imports (not just the entry point)
|
||||
// In Node.js/dev: use aliases to resolve to node_modules paths
|
||||
|
|
@ -347,6 +348,7 @@ interface PiManifest {
|
|||
extensions?: string[];
|
||||
themes?: string[];
|
||||
skills?: string[];
|
||||
prompts?: string[];
|
||||
}
|
||||
|
||||
function readPiManifest(packageJsonPath: string): PiManifest | null {
|
||||
|
|
|
|||
|
|
@ -178,12 +178,7 @@ export class ExtensionRunner {
|
|||
this.modelRegistry = modelRegistry;
|
||||
}
|
||||
|
||||
initialize(
|
||||
actions: ExtensionActions,
|
||||
contextActions: ExtensionContextActions,
|
||||
commandContextActions?: ExtensionCommandContextActions,
|
||||
uiContext?: ExtensionUIContext,
|
||||
): void {
|
||||
bindCore(actions: ExtensionActions, contextActions: ExtensionContextActions): void {
|
||||
// Copy actions into the shared runtime (all extension APIs reference this)
|
||||
this.runtime.sendMessage = actions.sendMessage;
|
||||
this.runtime.sendUserMessage = actions.sendUserMessage;
|
||||
|
|
@ -206,14 +201,24 @@ export class ExtensionRunner {
|
|||
this.shutdownHandler = contextActions.shutdown;
|
||||
this.getContextUsageFn = contextActions.getContextUsage;
|
||||
this.compactFn = contextActions.compact;
|
||||
}
|
||||
|
||||
// Command context actions (optional, only for interactive mode)
|
||||
if (commandContextActions) {
|
||||
this.waitForIdleFn = commandContextActions.waitForIdle;
|
||||
this.newSessionHandler = commandContextActions.newSession;
|
||||
this.forkHandler = commandContextActions.fork;
|
||||
this.navigateTreeHandler = commandContextActions.navigateTree;
|
||||
bindCommandContext(actions?: ExtensionCommandContextActions): void {
|
||||
if (actions) {
|
||||
this.waitForIdleFn = actions.waitForIdle;
|
||||
this.newSessionHandler = actions.newSession;
|
||||
this.forkHandler = actions.fork;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +270,10 @@ export class ExtensionRunner {
|
|||
this.runtime.flagValues.set(name, value);
|
||||
}
|
||||
|
||||
getFlagValues(): Map<string, boolean | string> {
|
||||
return new Map(this.runtime.flagValues);
|
||||
}
|
||||
|
||||
getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> {
|
||||
const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings);
|
||||
const extensionShortcuts = new Map<KeyId, ExtensionShortcut>();
|
||||
|
|
@ -351,7 +360,7 @@ export class ExtensionRunner {
|
|||
|
||||
/**
|
||||
* 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 {
|
||||
this.shutdownHandler();
|
||||
|
|
@ -359,7 +368,7 @@ export class ExtensionRunner {
|
|||
|
||||
/**
|
||||
* 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 {
|
||||
const getModel = this.getModel;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ export class FooterDataProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/** Internal: clear extension statuses */
|
||||
clearExtensionStatuses(): void {
|
||||
this.extensionStatuses.clear();
|
||||
}
|
||||
|
||||
/** Internal: cleanup */
|
||||
dispose(): void {
|
||||
if (this.gitWatcher) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { type Static, Type } from "@sinclair/typebox";
|
|||
import AjvModule from "ajv";
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import type { AuthStorage } from "./auth-storage.js";
|
||||
|
||||
const Ajv = (AjvModule as any).default || AjvModule;
|
||||
|
|
@ -159,7 +161,7 @@ export class ModelRegistry {
|
|||
|
||||
constructor(
|
||||
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
|
||||
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 { join, resolve } from "path";
|
||||
import { homedir } from "os";
|
||||
import { basename, isAbsolute, join, resolve } from "path";
|
||||
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
|
||||
import { parseFrontmatter } from "../utils/frontmatter.js";
|
||||
|
||||
|
|
@ -10,7 +11,7 @@ export interface PromptTemplate {
|
|||
name: string;
|
||||
description: 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;
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
|
|
@ -113,13 +146,11 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
|
|||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
// For symlinks, check if they point to a directory and follow them
|
||||
let isDirectory = entry.isDirectory();
|
||||
// For symlinks, check if they point to a file
|
||||
let isFile = entry.isFile();
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const stats = statSync(fullPath);
|
||||
isDirectory = stats.isDirectory();
|
||||
isFile = stats.isFile();
|
||||
} catch {
|
||||
// Broken symlink, skip it
|
||||
|
|
@ -127,52 +158,15 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
|
|||
}
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
// Recurse into subdirectory
|
||||
const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name;
|
||||
templates.push(...loadTemplatesFromDir(fullPath, source, newSubdir));
|
||||
} 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
|
||||
if (isFile && entry.name.endsWith(".md")) {
|
||||
const template = loadTemplateFromFile(fullPath, sourceLabel);
|
||||
if (template) {
|
||||
templates.push(template);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Silently skip directories that can't be read
|
||||
} catch {
|
||||
return templates;
|
||||
}
|
||||
|
||||
return templates;
|
||||
|
|
@ -183,27 +177,71 @@ export interface LoadPromptTemplatesOptions {
|
|||
cwd?: string;
|
||||
/** Agent config directory for global templates. Default: from getPromptsDir() */
|
||||
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:
|
||||
* 1. Global: agentDir/prompts/
|
||||
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
|
||||
* 3. Explicit prompt paths
|
||||
*/
|
||||
export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): PromptTemplate[] {
|
||||
const resolvedCwd = options.cwd ?? process.cwd();
|
||||
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
|
||||
const promptPaths = options.promptPaths ?? [];
|
||||
|
||||
const templates: PromptTemplate[] = [];
|
||||
|
||||
// 1. Load global templates from agentDir/prompts/
|
||||
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
|
||||
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/
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
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 @@
|
|||
/**
|
||||
* SDK for programmatic usage of AgentSession.
|
||||
*
|
||||
* 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 { join } from "node:path";
|
||||
import { Agent, type AgentMessage, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Message, Model } from "@mariozechner/pi-ai";
|
||||
import { join } from "path";
|
||||
import { getAgentDir, getAuthPath } from "../config.js";
|
||||
import { AgentSession } from "./agent-session.js";
|
||||
import { AuthStorage } from "./auth-storage.js";
|
||||
import { createEventBus, type EventBus } from "./event-bus.js";
|
||||
import {
|
||||
createExtensionRuntime,
|
||||
discoverAndLoadExtensions,
|
||||
type ExtensionFactory,
|
||||
ExtensionRunner,
|
||||
type LoadExtensionsResult,
|
||||
loadExtensionFromFactory,
|
||||
type ToolDefinition,
|
||||
wrapRegisteredTools,
|
||||
wrapToolsWithExtensions,
|
||||
} from "./extensions/index.js";
|
||||
import type { ExtensionRunner, LoadExtensionsResult, ToolDefinition } from "./extensions/index.js";
|
||||
import { convertToLlm } from "./messages.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 { type Settings, SettingsManager, type SkillsSettings } 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 { SettingsManager } from "./settings-manager.js";
|
||||
import { time } from "./timings.js";
|
||||
import {
|
||||
allTools,
|
||||
bashTool,
|
||||
codingTools,
|
||||
createAllTools,
|
||||
createBashTool,
|
||||
createCodingTools,
|
||||
createEditTool,
|
||||
|
|
@ -74,17 +36,15 @@ import {
|
|||
writeTool,
|
||||
} from "./tools/index.js";
|
||||
|
||||
// Types
|
||||
|
||||
export interface CreateAgentSessionOptions {
|
||||
/** Working directory for project-local discovery. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Global config directory. Default: ~/.pi/agent */
|
||||
agentDir?: string;
|
||||
|
||||
/** Auth storage for credentials. Default: discoverAuthStorage(agentDir) */
|
||||
/** Auth storage for credentials. Default: new AuthStorage(agentDir/auth.json) */
|
||||
authStorage?: AuthStorage;
|
||||
/** Model registry. Default: discoverModels(authStorage, agentDir) */
|
||||
/** Model registry. Default: new ModelRegistry(authStorage, agentDir/models.json) */
|
||||
modelRegistry?: ModelRegistry;
|
||||
|
||||
/** 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) */
|
||||
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] */
|
||||
tools?: Tool[];
|
||||
/** Custom tools to register (in addition to built-in tools). */
|
||||
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. */
|
||||
eventBus?: EventBus;
|
||||
|
||||
/** 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[];
|
||||
/** Resource loader. When omitted, DefaultResourceLoader is used. */
|
||||
resourceLoader?: ResourceLoader;
|
||||
|
||||
/** Session manager. Default: SessionManager.create(cwd) */
|
||||
sessionManager?: SessionManager;
|
||||
|
|
@ -148,7 +89,6 @@ export type {
|
|||
ToolDefinition,
|
||||
} from "./extensions/index.js";
|
||||
export type { PromptTemplate } from "./prompt-templates.js";
|
||||
export type { Settings, SkillsSettings } from "./settings-manager.js";
|
||||
export type { Skill } from "./skills.js";
|
||||
export type { Tool } from "./tools/index.js";
|
||||
|
||||
|
|
@ -182,133 +122,6 @@ function getDefaultAgentDir(): string {
|
|||
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.
|
||||
*
|
||||
|
|
@ -330,12 +143,16 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
|||
* });
|
||||
*
|
||||
* // Full control
|
||||
* const loader = new DefaultResourceLoader({
|
||||
* cwd: process.cwd(),
|
||||
* agentDir: getAgentDir(),
|
||||
* settingsManager: SettingsManager.create(),
|
||||
* });
|
||||
* await loader.reload();
|
||||
* const { session } = await createAgentSession({
|
||||
* model: myModel,
|
||||
* getApiKey: async () => process.env.MY_KEY,
|
||||
* systemPrompt: 'You are helpful.',
|
||||
* tools: [readTool, bashTool],
|
||||
* skills: [],
|
||||
* resourceLoader: loader,
|
||||
* sessionManager: SessionManager.inMemory(),
|
||||
* });
|
||||
* ```
|
||||
|
|
@ -343,21 +160,25 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
|||
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
||||
const eventBus = options.eventBus ?? createEventBus();
|
||||
let resourceLoader = options.resourceLoader;
|
||||
|
||||
// Use provided or create AuthStorage and ModelRegistry
|
||||
const authStorage = options.authStorage ?? discoverAuthStorage(agentDir);
|
||||
const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir);
|
||||
time("discoverModels");
|
||||
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
|
||||
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
|
||||
const authStorage = options.authStorage ?? new AuthStorage(authPath);
|
||||
const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage, modelsPath);
|
||||
|
||||
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
|
||||
time("settingsManager");
|
||||
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
|
||||
const existingSession = sessionManager.buildSessionContext();
|
||||
time("loadSession");
|
||||
const hasExistingSession = existingSession.messages.length > 0;
|
||||
|
||||
let model = options.model;
|
||||
|
|
@ -394,7 +215,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
break;
|
||||
}
|
||||
}
|
||||
time("findAvailableModel");
|
||||
if (model) {
|
||||
if (modelFallbackMessage) {
|
||||
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
|
||||
|
|
@ -422,161 +242,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
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 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;
|
||||
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;
|
||||
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)
|
||||
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
|
||||
|
|
@ -615,20 +286,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
});
|
||||
};
|
||||
|
||||
const extensionRunnerRef: { current?: ExtensionRunner } = {};
|
||||
|
||||
agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
systemPrompt: "",
|
||||
model,
|
||||
thinkingLevel,
|
||||
tools: activeToolsArray,
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm: convertToLlmWithBlockImages,
|
||||
sessionId: sessionManager.getSessionId(),
|
||||
transformContext: extensionRunner
|
||||
? async (messages) => {
|
||||
return extensionRunner.emitContext(messages);
|
||||
}
|
||||
: undefined,
|
||||
transformContext: async (messages) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner) return messages;
|
||||
return runner.emitContext(messages);
|
||||
},
|
||||
steeringMode: settingsManager.getSteeringMode(),
|
||||
followUpMode: settingsManager.getFollowUpMode(),
|
||||
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
||||
|
|
@ -658,7 +331,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
return key;
|
||||
},
|
||||
});
|
||||
time("createAgent");
|
||||
|
||||
// Restore messages if session has existing data
|
||||
if (hasExistingSession) {
|
||||
|
|
@ -675,17 +347,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd,
|
||||
scopedModels: options.scopedModels,
|
||||
promptTemplates: promptTemplates,
|
||||
extensionRunner,
|
||||
skills,
|
||||
skillWarnings,
|
||||
skillsSettings: settingsManager.getSkillsSettings(),
|
||||
resourceLoader,
|
||||
customTools: options.customTools,
|
||||
modelRegistry,
|
||||
toolRegistry: wrappedToolRegistry ?? toolRegistry,
|
||||
rebuildSystemPrompt,
|
||||
initialActiveToolNames,
|
||||
extensionRunnerRef,
|
||||
});
|
||||
time("createAgentSession");
|
||||
const extensionsResult = resourceLoader.getExtensions();
|
||||
|
||||
return {
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -18,19 +18,6 @@ export interface RetrySettings {
|
|||
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 {
|
||||
showImages?: boolean; // default: true (only relevant if terminal supports images)
|
||||
}
|
||||
|
|
@ -67,8 +54,11 @@ export interface Settings {
|
|||
quietStartup?: boolean;
|
||||
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)
|
||||
extensions?: string[]; // Array of extension file paths
|
||||
skills?: SkillsSettings;
|
||||
extensions?: string[]; // Array of extension file paths or directories
|
||||
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;
|
||||
images?: ImageSettings;
|
||||
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
|
||||
|
|
@ -165,6 +155,27 @@ export class SettingsManager {
|
|||
settings.steeringMode = 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;
|
||||
}
|
||||
|
||||
|
|
@ -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 */
|
||||
applyOverrides(overrides: Partial<Settings>): void {
|
||||
this.settings = deepMergeSettings(this.settings, overrides);
|
||||
|
|
@ -214,6 +233,21 @@ export class SettingsManager {
|
|||
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 {
|
||||
return this.settings.lastChangelogVersion;
|
||||
}
|
||||
|
|
@ -391,42 +425,46 @@ export class SettingsManager {
|
|||
this.save();
|
||||
}
|
||||
|
||||
getSkillsEnabled(): boolean {
|
||||
return this.settings.skills?.enabled ?? true;
|
||||
setProjectExtensionPaths(paths: string[]): void {
|
||||
const projectSettings = this.loadProjectSettings();
|
||||
projectSettings.extensions = paths;
|
||||
this.saveProjectSettings(projectSettings);
|
||||
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
|
||||
}
|
||||
|
||||
setSkillsEnabled(enabled: boolean): void {
|
||||
if (!this.globalSettings.skills) {
|
||||
this.globalSettings.skills = {};
|
||||
}
|
||||
this.globalSettings.skills.enabled = enabled;
|
||||
getSkillPaths(): string[] {
|
||||
return [...(this.settings.skills ?? [])];
|
||||
}
|
||||
|
||||
setSkillPaths(paths: string[]): void {
|
||||
this.globalSettings.skills = paths;
|
||||
this.save();
|
||||
}
|
||||
|
||||
getSkillsSettings(): Required<SkillsSettings> {
|
||||
return {
|
||||
enabled: this.settings.skills?.enabled ?? true,
|
||||
enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
|
||||
enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true,
|
||||
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
|
||||
enablePiUser: this.settings.skills?.enablePiUser ?? true,
|
||||
enablePiProject: this.settings.skills?.enablePiProject ?? true,
|
||||
enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
|
||||
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
|
||||
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
|
||||
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
|
||||
};
|
||||
getPromptTemplatePaths(): string[] {
|
||||
return [...(this.settings.prompts ?? [])];
|
||||
}
|
||||
|
||||
setPromptTemplatePaths(paths: string[]): void {
|
||||
this.globalSettings.prompts = paths;
|
||||
this.save();
|
||||
}
|
||||
|
||||
getThemePaths(): string[] {
|
||||
return [...(this.settings.themes ?? [])];
|
||||
}
|
||||
|
||||
setThemePaths(paths: string[]): void {
|
||||
this.globalSettings.themes = paths;
|
||||
this.save();
|
||||
}
|
||||
|
||||
getEnableSkillCommands(): boolean {
|
||||
return this.settings.skills?.enableSkillCommands ?? true;
|
||||
return this.settings.enableSkillCommands ?? true;
|
||||
}
|
||||
|
||||
setEnableSkillCommands(enabled: boolean): void {
|
||||
if (!this.globalSettings.skills) {
|
||||
this.globalSettings.skills = {};
|
||||
}
|
||||
this.globalSettings.skills.enableSkillCommands = enabled;
|
||||
this.globalSettings.enableSkillCommands = enabled;
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
|
||||
import { minimatch } from "minimatch";
|
||||
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 { parseFrontmatter } from "../utils/frontmatter.js";
|
||||
import type { SkillsSettings } from "./settings-manager.js";
|
||||
|
||||
/**
|
||||
* Standard frontmatter fields per Agent Skills spec.
|
||||
|
|
@ -49,8 +47,6 @@ export interface LoadSkillsResult {
|
|||
warnings: SkillWarning[];
|
||||
}
|
||||
|
||||
type SkillFormat = "recursive" | "claude";
|
||||
|
||||
/**
|
||||
* Validate skill name per Agent Skills spec.
|
||||
* Returns array of validation error messages (empty if valid).
|
||||
|
|
@ -88,7 +84,7 @@ function validateDescription(description: string | undefined): string[] {
|
|||
const errors: string[] = [];
|
||||
|
||||
if (!description || description.trim() === "") {
|
||||
errors.push(`description is required`);
|
||||
errors.push("description is required");
|
||||
} else if (description.length > MAX_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.
|
||||
* Skills are directories containing a SKILL.md file with frontmatter including a description.
|
||||
* Load skills from a directory.
|
||||
*
|
||||
* Discovery rules:
|
||||
* - direct .md children in the root
|
||||
* - recursive SKILL.md under subdirectories
|
||||
*/
|
||||
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
|
||||
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 warnings: SkillWarning[] = [];
|
||||
|
||||
|
|
@ -162,36 +161,28 @@ function loadSkillsFromDirInternal(dir: string, source: string, format: SkillFor
|
|||
}
|
||||
}
|
||||
|
||||
if (format === "recursive") {
|
||||
// Recursive format: scan directories, look for SKILL.md files
|
||||
if (isDirectory) {
|
||||
const subResult = loadSkillsFromDirInternal(fullPath, source, format);
|
||||
skills.push(...subResult.skills);
|
||||
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 (isDirectory) {
|
||||
const subResult = loadSkillsFromDirInternal(fullPath, source, false);
|
||||
skills.push(...subResult.skills);
|
||||
warnings.push(...subResult.warnings);
|
||||
continue;
|
||||
}
|
||||
|
||||
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 {}
|
||||
|
||||
|
|
@ -290,11 +281,26 @@ function escapeXml(str: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export interface LoadSkillsOptions extends SkillsSettings {
|
||||
export interface LoadSkillsOptions {
|
||||
/** Working directory for project-local skills. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Agent config directory for global skills. Default: ~/.pi/agent */
|
||||
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.
|
||||
*/
|
||||
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
agentDir,
|
||||
enableCodexUser = true,
|
||||
enableClaudeUser = true,
|
||||
enableClaudeProject = true,
|
||||
enablePiUser = true,
|
||||
enablePiProject = true,
|
||||
customDirectories = [],
|
||||
ignoredSkills = [],
|
||||
includeSkills = [],
|
||||
} = options;
|
||||
const { cwd = process.cwd(), agentDir, skillPaths = [] } = options;
|
||||
|
||||
// Resolve agentDir - if not provided, use default from config
|
||||
const resolvedAgentDir = agentDir ?? getAgentDir();
|
||||
|
|
@ -323,30 +318,9 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
|||
const allWarnings: 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) {
|
||||
allWarnings.push(...result.warnings);
|
||||
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
|
||||
let realPath: string;
|
||||
try {
|
||||
|
|
@ -373,23 +347,34 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
|
|||
}
|
||||
}
|
||||
|
||||
if (enableCodexUser) {
|
||||
addSkills(loadSkillsFromDirInternal(join(homedir(), ".codex", "skills"), "codex-user", "recursive"));
|
||||
}
|
||||
if (enableClaudeUser) {
|
||||
addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
|
||||
}
|
||||
if (enableClaudeProject) {
|
||||
addSkills(loadSkillsFromDirInternal(resolve(cwd, ".claude", "skills"), "claude-project", "claude"));
|
||||
}
|
||||
if (enablePiUser) {
|
||||
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", "recursive"));
|
||||
}
|
||||
if (enablePiProject) {
|
||||
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", "recursive"));
|
||||
}
|
||||
for (const customDir of customDirectories) {
|
||||
addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
|
||||
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
|
||||
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
|
||||
|
||||
for (const rawPath of skillPaths) {
|
||||
const resolvedPath = resolveSkillPath(rawPath, cwd);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
allWarnings.push({ skillPath: resolvedPath, message: "skill path does not exist" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = statSync(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
addSkills(loadSkillsFromDirInternal(resolvedPath, "custom", true));
|
||||
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
|
||||
const result = loadSkillFromFile(resolvedPath, "custom");
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,11 @@
|
|||
* System prompt construction and project context loading
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
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";
|
||||
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
|
||||
import { formatSkillsForPrompt, type Skill } from "./skills.js";
|
||||
|
||||
/** Tool descriptions for system prompt */
|
||||
const toolDescriptions: Record<ToolName, string> = {
|
||||
const toolDescriptions: Record<string, string> = {
|
||||
read: "Read file contents",
|
||||
bash: "Execute bash commands (ls, grep, find, etc.)",
|
||||
edit: "Make surgical edits to files (find exact text and replace)",
|
||||
|
|
@ -21,117 +16,18 @@ const toolDescriptions: Record<ToolName, string> = {
|
|||
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 {
|
||||
/** Custom system prompt (replaces default). */
|
||||
customPrompt?: string;
|
||||
/** Tools to include in prompt. Default: [read, bash, edit, write] */
|
||||
selectedTools?: ToolName[];
|
||||
selectedTools?: string[];
|
||||
/** Text to append to system prompt. */
|
||||
appendSystemPrompt?: string;
|
||||
/** Skills settings for discovery. */
|
||||
skillsSettings?: SkillsSettings;
|
||||
/** Working directory. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
/** Agent config directory. Default: from getAgentDir() */
|
||||
agentDir?: string;
|
||||
/** Pre-loaded context files (skips discovery if provided). */
|
||||
/** Pre-loaded context files. */
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
/** Pre-loaded skills (skips discovery if provided). */
|
||||
/** Pre-loaded skills. */
|
||||
skills?: Skill[];
|
||||
}
|
||||
|
||||
|
|
@ -141,15 +37,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
customPrompt,
|
||||
selectedTools,
|
||||
appendSystemPrompt,
|
||||
skillsSettings,
|
||||
cwd,
|
||||
agentDir,
|
||||
contextFiles: providedContextFiles,
|
||||
skills: providedSkills,
|
||||
} = options;
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
|
||||
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
|
||||
|
||||
const now = new Date();
|
||||
const dateTime = now.toLocaleString("en-US", {
|
||||
|
|
@ -163,18 +55,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : "";
|
||||
const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
|
||||
|
||||
// Resolve context files: use provided or discover
|
||||
const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd, agentDir });
|
||||
const contextFiles = providedContextFiles ?? [];
|
||||
const skills = providedSkills ?? [];
|
||||
|
||||
// Resolve skills: use provided or discover
|
||||
const skills =
|
||||
providedSkills ??
|
||||
(skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd, agentDir }).skills : []);
|
||||
|
||||
if (resolvedCustomPrompt) {
|
||||
let prompt = resolvedCustomPrompt;
|
||||
if (customPrompt) {
|
||||
let prompt = customPrompt;
|
||||
|
||||
if (appendSection) {
|
||||
prompt += appendSection;
|
||||
|
|
@ -208,8 +95,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
const examplesPath = getExamplesPath();
|
||||
|
||||
// Build tools list based on selected tools
|
||||
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
|
||||
const toolsList = tools.length > 0 ? tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n") : "(none)";
|
||||
const tools = selectedTools || ["read", "bash", "edit", "write"];
|
||||
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
|
||||
const guidelinesList: string[] = [];
|
||||
|
|
@ -222,16 +110,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
const hasLs = tools.includes("ls");
|
||||
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
|
||||
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)) {
|
||||
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");
|
||||
}
|
||||
|
||||
// Output guideline (only when actually writing/executing)
|
||||
// Output guideline (only when actually writing or executing)
|
||||
if (hasEdit || hasWrite) {
|
||||
guidelinesList.push(
|
||||
"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}
|
||||
|
||||
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}
|
||||
- Additional docs: ${docsPath}
|
||||
- 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 } from "@sinclair/typebox";
|
||||
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";
|
||||
|
||||
/**
|
||||
|
|
@ -65,6 +65,7 @@ const defaultBashOperations: BashOperations = {
|
|||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: true,
|
||||
env: getShellEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export type {
|
|||
WidgetPlacement,
|
||||
} from "./core/extensions/index.js";
|
||||
export {
|
||||
createExtensionRuntime,
|
||||
ExtensionRunner,
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
|
|
@ -115,10 +116,10 @@ export {
|
|||
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
|
||||
export { convertToLlm } from "./core/messages.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
|
||||
export {
|
||||
type BuildSystemPromptOptions,
|
||||
buildSystemPrompt,
|
||||
type CreateAgentSessionOptions,
|
||||
type CreateAgentSessionResult,
|
||||
// Factory
|
||||
|
|
@ -133,14 +134,6 @@ export {
|
|||
createReadOnlyTools,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
// Discovery
|
||||
discoverAuthStorage,
|
||||
discoverContextFiles,
|
||||
discoverExtensions,
|
||||
discoverModels,
|
||||
discoverPromptTemplates,
|
||||
discoverSkills,
|
||||
loadSettings,
|
||||
type PromptTemplate,
|
||||
// Pre-built tools (use process.cwd())
|
||||
readOnlyTools,
|
||||
|
|
@ -172,9 +165,7 @@ export {
|
|||
type CompactionSettings,
|
||||
type ImageSettings,
|
||||
type RetrySettings,
|
||||
type Settings,
|
||||
SettingsManager,
|
||||
type SkillsSettings,
|
||||
} from "./core/settings-manager.js";
|
||||
// Skills
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -7,24 +7,23 @@
|
|||
|
||||
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { createInterface } from "readline";
|
||||
import { type Args, parseArgs, printHelp } from "./cli/args.js";
|
||||
import { processFileArguments } from "./cli/file-processor.js";
|
||||
import { listModels } from "./cli/list-models.js";
|
||||
import { selectSession } from "./cli/session-picker.js";
|
||||
import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
|
||||
import { createEventBus } from "./core/event-bus.js";
|
||||
import { getAgentDir, getModelsPath, VERSION } from "./config.js";
|
||||
import { AuthStorage } from "./core/auth-storage.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 type { ModelRegistry } from "./core/model-registry.js";
|
||||
import { ModelRegistry } from "./core/model-registry.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 { SettingsManager } from "./core/settings-manager.js";
|
||||
import { resolvePromptInput } from "./core/system-prompt.js";
|
||||
import { printTimings, time } from "./core/timings.js";
|
||||
import { allTools } from "./core/tools/index.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(
|
||||
parsed: Args,
|
||||
autoResizeImages: boolean,
|
||||
|
|
@ -173,58 +284,15 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
|
|||
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(
|
||||
parsed: Args,
|
||||
scopedModels: ScopedModel[],
|
||||
sessionManager: SessionManager | undefined,
|
||||
modelRegistry: ModelRegistry,
|
||||
settingsManager: SettingsManager,
|
||||
extensionsResult?: LoadExtensionsResult,
|
||||
): 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) {
|
||||
options.sessionManager = sessionManager;
|
||||
}
|
||||
|
|
@ -276,15 +344,6 @@ function buildSessionOptions(
|
|||
// API key from CLI - set in authStorage
|
||||
// (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
|
||||
if (parsed.noTools) {
|
||||
// --no-tools: start with no built-in tools
|
||||
|
|
@ -298,60 +357,52 @@ function buildSessionOptions(
|
|||
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;
|
||||
}
|
||||
|
||||
export async function main(args: string[]) {
|
||||
time("start");
|
||||
if (await handlePackageCommand(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run migrations (pass cwd for project-local migrations)
|
||||
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
|
||||
|
||||
// Create AuthStorage and ModelRegistry upfront
|
||||
const authStorage = discoverAuthStorage();
|
||||
const modelRegistry = discoverModels(authStorage);
|
||||
time("discoverModels");
|
||||
const authStorage = new AuthStorage();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
// First pass: parse args to get --extension paths
|
||||
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 agentDir = getAgentDir();
|
||||
const eventBus = createEventBus();
|
||||
const settingsManager = SettingsManager.create(cwd);
|
||||
time("SettingsManager.create");
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
|
||||
let extensionsResult: LoadExtensionsResult;
|
||||
if (firstPass.noExtensions) {
|
||||
// --no-extensions disables discovery, but explicit -e flags still work
|
||||
const explicitPaths = firstPass.extensions ?? [];
|
||||
extensionsResult = await loadExtensions(explicitPaths, cwd, eventBus);
|
||||
time("loadExtensions");
|
||||
} else {
|
||||
// Merge CLI --extension args with settings.json extensions
|
||||
const extensionPaths = [...settingsManager.getExtensionPaths(), ...(firstPass.extensions ?? [])];
|
||||
extensionsResult = await discoverAndLoadExtensions(extensionPaths, cwd, agentDir, eventBus);
|
||||
time("discoverExtensionFlags");
|
||||
}
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
additionalExtensionPaths: firstPass.extensions,
|
||||
additionalSkillPaths: firstPass.skills,
|
||||
additionalPromptTemplatePaths: firstPass.promptTemplates,
|
||||
additionalThemePaths: firstPass.themes,
|
||||
noExtensions: firstPass.noExtensions,
|
||||
noSkills: firstPass.noSkills,
|
||||
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) {
|
||||
console.error(chalk.red(`Failed to load extension "${path}": ${error}`));
|
||||
}
|
||||
|
||||
// Collect all extension flags
|
||||
const extensionFlags = new Map<string, { type: "boolean" | "string" }>();
|
||||
for (const ext of extensionsResult.extensions) {
|
||||
for (const [name, flag] of ext.flags) {
|
||||
|
|
@ -361,7 +412,6 @@ export async function main(args: string[]) {
|
|||
|
||||
// Second pass: parse args with extension flags
|
||||
const parsed = parseArgs(args, extensionFlags);
|
||||
time("parseArgs");
|
||||
|
||||
// Pass flag values to extensions via runtime
|
||||
for (const [name, value] of parsed.unknownFlags) {
|
||||
|
|
@ -393,7 +443,6 @@ export async function main(args: string[]) {
|
|||
// Prepend stdin content to messages
|
||||
parsed.messages.unshift(stdinContent);
|
||||
}
|
||||
time("readPipedStdin");
|
||||
}
|
||||
|
||||
if (parsed.export) {
|
||||
|
|
@ -415,11 +464,9 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
|
||||
time("prepareInitialMessage");
|
||||
const isInteractive = !parsed.print && parsed.mode === undefined;
|
||||
const mode = parsed.mode || "text";
|
||||
initTheme(settingsManager.getTheme(), isInteractive);
|
||||
time("initTheme");
|
||||
|
||||
// Show deprecation warnings in interactive mode
|
||||
if (isInteractive && deprecationWarnings.length > 0) {
|
||||
|
|
@ -430,12 +477,10 @@ export async function main(args: string[]) {
|
|||
const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
|
||||
if (modelPatterns && modelPatterns.length > 0) {
|
||||
scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
|
||||
time("resolveModelScope");
|
||||
}
|
||||
|
||||
// Create session manager based on CLI flags
|
||||
let sessionManager = await createSessionManager(parsed, cwd);
|
||||
time("createSessionManager");
|
||||
|
||||
// Handle --resume: show session picker
|
||||
if (parsed.resume) {
|
||||
|
|
@ -446,7 +491,6 @@ export async function main(args: string[]) {
|
|||
(onProgress) => SessionManager.list(cwd, parsed.sessionDir, onProgress),
|
||||
SessionManager.listAll,
|
||||
);
|
||||
time("selectSession");
|
||||
if (!selectedPath) {
|
||||
console.log(chalk.dim("No session selected"));
|
||||
stopThemeWatcher();
|
||||
|
|
@ -455,17 +499,10 @@ export async function main(args: string[]) {
|
|||
sessionManager = SessionManager.open(selectedPath);
|
||||
}
|
||||
|
||||
const sessionOptions = buildSessionOptions(
|
||||
parsed,
|
||||
scopedModels,
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
settingsManager,
|
||||
extensionsResult,
|
||||
);
|
||||
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry, settingsManager);
|
||||
sessionOptions.authStorage = authStorage;
|
||||
sessionOptions.modelRegistry = modelRegistry;
|
||||
sessionOptions.eventBus = eventBus;
|
||||
sessionOptions.resourceLoader = resourceLoader;
|
||||
|
||||
// Handle CLI --api-key as runtime override (not persisted)
|
||||
if (parsed.apiKey) {
|
||||
|
|
@ -476,9 +513,7 @@ export async function main(args: string[]) {
|
|||
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
|
||||
}
|
||||
|
||||
time("buildSessionOptions");
|
||||
const { session, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||
time("createAgentSession");
|
||||
|
||||
if (!isInteractive && !session.model) {
|
||||
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 { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint } from "./keybinding-hints.js";
|
||||
|
||||
/** Loader wrapped with borders for extension UI */
|
||||
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();
|
||||
this.cancellable = options?.cancellable ?? true;
|
||||
const borderColor = (s: string) => theme.fg("border", s);
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
this.loader = new CancellableLoader(
|
||||
tui,
|
||||
(s) => theme.fg("accent", s),
|
||||
(s) => theme.fg("muted", s),
|
||||
message,
|
||||
);
|
||||
if (this.cancellable) {
|
||||
this.loader = new CancellableLoader(
|
||||
tui,
|
||||
(s) => theme.fg("accent", s),
|
||||
(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(new Spacer(1));
|
||||
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
|
||||
if (this.cancellable) {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
|
||||
}
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
}
|
||||
|
||||
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) {
|
||||
this.loader.onAbort = fn;
|
||||
if (this.cancellable) {
|
||||
(this.loader as CancellableLoader).onAbort = fn;
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.loader.handleInput(data);
|
||||
if (this.cancellable) {
|
||||
(this.loader as CancellableLoader).handleInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
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 { resolveModelScope } from "../../core/model-resolver.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 { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
|
||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
|
|
@ -156,7 +155,6 @@ export class InteractiveMode {
|
|||
private keybindings: KeybindingsManager;
|
||||
private version: string;
|
||||
private isInitialized = false;
|
||||
private hasRenderedInitialMessages = false;
|
||||
private onInputCallback?: (text: string) => void;
|
||||
private loadingAnimation: Loader | undefined = undefined;
|
||||
private readonly defaultWorkingMessage = "Working...";
|
||||
|
|
@ -321,6 +319,7 @@ export class InteractiveMode {
|
|||
{ name: "new", description: "Start a new session" },
|
||||
{ name: "compact", description: "Manually compact the session context" },
|
||||
{ name: "resume", description: "Resume a different session" },
|
||||
{ name: "reload", description: "Reload extensions, skills, prompts, and themes" },
|
||||
];
|
||||
|
||||
// Convert prompt templates to SlashCommand format for autocomplete
|
||||
|
|
@ -617,133 +616,63 @@ export class InteractiveMode {
|
|||
// 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.
|
||||
*/
|
||||
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;
|
||||
if (!extensionRunner) {
|
||||
return; // No extensions loaded
|
||||
this.showLoadedResources({ extensionPaths: [], force: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create extension UI context
|
||||
const uiContext = this.createExtensionUIContext();
|
||||
|
||||
extensionRunner.initialize(
|
||||
// ExtensionActions - for pi.* API
|
||||
{
|
||||
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
|
||||
{
|
||||
await this.session.bindExtensions({
|
||||
uiContext,
|
||||
commandContextActions: {
|
||||
waitForIdle: () => this.session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
if (this.loadingAnimation) {
|
||||
|
|
@ -808,31 +737,16 @@ export class InteractiveMode {
|
|||
return { cancelled: false };
|
||||
},
|
||||
},
|
||||
uiContext,
|
||||
);
|
||||
|
||||
// Subscribe to extension errors
|
||||
extensionRunner.onError((error) => {
|
||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||
shutdownHandler: () => {
|
||||
this.shutdownRequested = true;
|
||||
},
|
||||
onError: (error) => {
|
||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||
},
|
||||
});
|
||||
|
||||
// Set up extension-registered shortcuts
|
||||
this.setupExtensionShortcuts(extensionRunner);
|
||||
|
||||
// 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",
|
||||
});
|
||||
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -950,6 +864,38 @@ export class InteractiveMode {
|
|||
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
|
||||
private static readonly MAX_WIDGET_LINES = 10;
|
||||
|
||||
|
|
@ -1608,6 +1554,11 @@ export class InteractiveMode {
|
|||
await this.handleCompactCommand(customInstructions);
|
||||
return;
|
||||
}
|
||||
if (text === "/reload") {
|
||||
this.editor.setText("");
|
||||
await this.handleReloadCommand();
|
||||
return;
|
||||
}
|
||||
if (text === "/debug") {
|
||||
this.handleDebugCommand();
|
||||
this.editor.setText("");
|
||||
|
|
@ -2143,7 +2094,6 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
renderInitialMessages(): void {
|
||||
this.hasRenderedInitialMessages = true;
|
||||
// Get aligned messages and entries from session context
|
||||
const context = this.sessionManager.buildSessionContext();
|
||||
this.renderSessionContext(context, {
|
||||
|
|
@ -3276,6 +3226,53 @@ export class InteractiveMode {
|
|||
// 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> {
|
||||
const parts = text.split(/\s+/);
|
||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||
|
|
@ -3632,12 +3629,13 @@ export class InteractiveMode {
|
|||
|
||||
private handleDebugCommand(): void {
|
||||
const width = this.ui.terminal.columns;
|
||||
const height = this.ui.terminal.rows;
|
||||
const allLines = this.ui.render(width);
|
||||
|
||||
const debugLogPath = getDebugLogPath();
|
||||
const debugData = [
|
||||
`Debug output at ${new Date().toISOString()}`,
|
||||
`Terminal width: ${width}`,
|
||||
`Terminal: ${width}x${height}`,
|
||||
`Total lines: ${allLines.length}`,
|
||||
"",
|
||||
"=== All rendered lines with visible widths ===",
|
||||
|
|
|
|||
|
|
@ -334,6 +334,8 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
|
|||
// ============================================================================
|
||||
|
||||
export class Theme {
|
||||
readonly name?: string;
|
||||
readonly sourcePath?: string;
|
||||
private fgColors: Map<ThemeColor, string>;
|
||||
private bgColors: Map<ThemeBg, string>;
|
||||
private mode: ColorMode;
|
||||
|
|
@ -342,7 +344,10 @@ export class Theme {
|
|||
fgColors: Record<ThemeColor, string | number>,
|
||||
bgColors: Record<ThemeBg, string | number>,
|
||||
mode: ColorMode,
|
||||
options: { name?: string; sourcePath?: string } = {},
|
||||
) {
|
||||
this.name = options.name;
|
||||
this.sourcePath = options.sourcePath;
|
||||
this.mode = mode;
|
||||
this.fgColors = new Map();
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
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");
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse theme ${name}: ${error}`);
|
||||
}
|
||||
function parseThemeJson(label: string, json: unknown): ThemeJson {
|
||||
if (!validateThemeJson.Check(json)) {
|
||||
const errors = Array.from(validateThemeJson.Errors(json));
|
||||
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) {
|
||||
errorMessage += `\nMissing required color tokens:\n`;
|
||||
errorMessage += "\nMissing required color tokens:\n";
|
||||
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
|
||||
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 += '\n\nPlease add these colors to your theme\'s "colors" object.';
|
||||
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
|
||||
}
|
||||
if (otherErrors.length > 0) {
|
||||
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
|
||||
|
|
@ -535,10 +533,35 @@ function loadThemeJson(name: string): ThemeJson {
|
|||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
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 resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
const registeredTheme = registeredThemes.get(name);
|
||||
if (registeredTheme) {
|
||||
return registeredTheme;
|
||||
}
|
||||
const themeJson = loadThemeJson(name);
|
||||
return createTheme(themeJson, mode);
|
||||
}
|
||||
|
|
@ -617,6 +653,16 @@ function setGlobalTheme(t: Theme): void {
|
|||
let currentThemeName: string | undefined;
|
||||
let themeWatcher: fs.FSWatcher | 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 {
|
||||
const name = themeName ?? getDefaultTheme();
|
||||
|
|
|
|||
|
|
@ -35,68 +35,11 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|||
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;
|
||||
if (extensionRunner) {
|
||||
extensionRunner.initialize(
|
||||
// ExtensionActions
|
||||
{
|
||||
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")
|
||||
{
|
||||
await session.bindExtensions({
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
||||
|
|
@ -119,14 +62,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|||
return { cancelled: result.cancelled };
|
||||
},
|
||||
},
|
||||
// No UI context - hasUI will be false
|
||||
);
|
||||
extensionRunner.onError((err) => {
|
||||
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
||||
});
|
||||
// Emit session_start event
|
||||
await extensionRunner.emit({
|
||||
type: "session_start",
|
||||
onError: (err) => {
|
||||
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -256,67 +256,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
// Set up extensions with RPC-based UI context
|
||||
const extensionRunner = session.extensionRunner;
|
||||
if (extensionRunner) {
|
||||
extensionRunner.initialize(
|
||||
// ExtensionActions
|
||||
{
|
||||
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")
|
||||
{
|
||||
await session.bindExtensions({
|
||||
uiContext: createExtensionUIContext(),
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
||||
|
|
@ -340,14 +282,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
return { cancelled: result.cancelled };
|
||||
},
|
||||
},
|
||||
createExtensionUIContext(),
|
||||
);
|
||||
extensionRunner.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",
|
||||
shutdownHandler: () => {
|
||||
shutdownRequested = true;
|
||||
},
|
||||
onError: (err) => {
|
||||
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -577,8 +517,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
async function checkShutdownRequested(): Promise<void> {
|
||||
if (!shutdownRequested) return;
|
||||
|
||||
if (extensionRunner?.hasHandlers("session_shutdown")) {
|
||||
await extensionRunner.emit({ type: "session_shutdown" });
|
||||
const currentRunner = session.extensionRunner;
|
||||
if (currentRunner?.hasHandlers("session_shutdown")) {
|
||||
await currentRunner.emit({ type: "session_shutdown" });
|
||||
}
|
||||
|
||||
// Close readline interface to stop waiting for input
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { delimiter } from "node:path";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { getSettingsPath } from "../config.js";
|
||||
import { getBinDir } from "../config.js";
|
||||
import { SettingsManager } from "../core/settings-manager.js";
|
||||
|
||||
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
||||
|
|
@ -95,6 +97,20 @@ export function getShellConfig(): { shell: string; args: string[] } {
|
|||
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.
|
||||
* 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 { SettingsManager } from "../src/core/settings-manager.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", () => {
|
||||
let session: AgentSession;
|
||||
|
|
@ -61,7 +61,9 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// 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 { SettingsManager } from "../src/core/settings-manager.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", () => {
|
||||
let session: AgentSession;
|
||||
|
|
@ -67,7 +67,9 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// 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 { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { createTestResourceLoader } from "./utilities.js";
|
||||
|
||||
// Mock stream that mimics AssistantMessageEventStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
|
|
@ -107,7 +108,9 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
return session;
|
||||
|
|
@ -197,7 +200,9 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
test("parses --no-skills flag", () => {
|
||||
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", () => {
|
||||
test("parses --no-tools flag", () => {
|
||||
const result = parseArgs(["--no-tools"]);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { AuthStorage } from "../src/core/auth-storage.js";
|
|||
import {
|
||||
createExtensionRuntime,
|
||||
type Extension,
|
||||
ExtensionRunner,
|
||||
type SessionBeforeCompactEvent,
|
||||
type SessionCompactEvent,
|
||||
type SessionEvent,
|
||||
|
|
@ -22,13 +21,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
|
|||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.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;
|
||||
|
||||
describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
||||
let session: AgentSession;
|
||||
let tempDir: string;
|
||||
let extensionRunner: ExtensionRunner;
|
||||
let capturedEvents: SessionEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -101,51 +100,18 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
|||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const runtime = createExtensionRuntime();
|
||||
extensionRunner = new ExtensionRunner(extensions, runtime, tempDir, sessionManager, modelRegistry);
|
||||
extensionRunner.initialize(
|
||||
// ExtensionActions
|
||||
{
|
||||
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);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
);
|
||||
const resourceLoader = {
|
||||
...createTestResourceLoader(),
|
||||
getExtensions: () => ({ extensions, errors: [], runtime }),
|
||||
};
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
extensionRunner,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
return session;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@ import { ModelRegistry } from "../src/core/model-registry.js";
|
|||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.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
|
||||
const HAS_ANTIGRAVITY_AUTH = hasAuthForProvider("google-antigravity");
|
||||
|
|
@ -81,7 +87,9 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
session.subscribe(() => {});
|
||||
|
|
@ -175,7 +183,9 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
session.subscribe(() => {});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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 { 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);
|
||||
});
|
||||
|
||||
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({
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
skills: [], // Explicitly empty - like --no-skills
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
// session.skills should be empty
|
||||
expect(session.skills).toEqual([]);
|
||||
// No warnings since we didn't discover
|
||||
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 = {
|
||||
name: "custom-skill",
|
||||
description: "A custom skill",
|
||||
|
|
@ -70,16 +81,25 @@ This is a test skill.
|
|||
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({
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
skills: [customSkill],
|
||||
resourceLoader,
|
||||
});
|
||||
|
||||
// session.skills should contain only the provided skill
|
||||
expect(session.skills).toEqual([customSkill]);
|
||||
// No warnings since we didn't discover
|
||||
expect(session.skillWarnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -254,152 +254,44 @@ describe("skills", () => {
|
|||
});
|
||||
|
||||
describe("loadSkills with options", () => {
|
||||
it("should load from customDirectories only when built-ins disabled", () => {
|
||||
const { skills } = loadSkills({
|
||||
enableCodexUser: false,
|
||||
enableClaudeUser: false,
|
||||
enableClaudeProject: false,
|
||||
enablePiUser: false,
|
||||
enablePiProject: false,
|
||||
customDirectories: [fixturesDir],
|
||||
const emptyAgentDir = resolve(__dirname, "fixtures/empty-agent");
|
||||
const emptyCwd = resolve(__dirname, "fixtures/empty-cwd");
|
||||
|
||||
it("should load from explicit skillPaths", () => {
|
||||
const { skills, warnings } = loadSkills({
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: [join(fixturesDir, "valid-skill")],
|
||||
});
|
||||
expect(skills.length).toBeGreaterThan(0);
|
||||
expect(skills.every((s) => s.source === "custom")).toBe(true);
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].source).toBe("custom");
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should filter out ignoredSkills", () => {
|
||||
const { skills } = loadSkills({
|
||||
enableCodexUser: false,
|
||||
enableClaudeUser: false,
|
||||
enableClaudeProject: false,
|
||||
enablePiUser: false,
|
||||
enablePiProject: false,
|
||||
customDirectories: [join(fixturesDir, "valid-skill")],
|
||||
ignoredSkills: ["valid-skill"],
|
||||
it("should warn when skill path does not exist", () => {
|
||||
const { skills, warnings } = loadSkills({
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: ["/non/existent/path"],
|
||||
});
|
||||
expect(skills).toHaveLength(0);
|
||||
expect(warnings.some((w) => w.message.includes("does not exist"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should support glob patterns in ignoredSkills", () => {
|
||||
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", () => {
|
||||
it("should expand ~ in skillPaths", () => {
|
||||
const homeSkillsDir = join(homedir(), ".pi/agent/skills");
|
||||
const { skills: withTilde } = loadSkills({
|
||||
enableCodexUser: false,
|
||||
enableClaudeUser: false,
|
||||
enableClaudeProject: false,
|
||||
enablePiUser: false,
|
||||
enablePiProject: false,
|
||||
customDirectories: ["~/.pi/agent/skills"],
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: ["~/.pi/agent/skills"],
|
||||
});
|
||||
const { skills: withoutTilde } = loadSkills({
|
||||
enableCodexUser: false,
|
||||
enableClaudeUser: false,
|
||||
enableClaudeProject: false,
|
||||
enablePiUser: false,
|
||||
enablePiProject: false,
|
||||
customDirectories: [homeSkillsDir],
|
||||
agentDir: emptyAgentDir,
|
||||
cwd: emptyCwd,
|
||||
skillPaths: [homeSkillsDir],
|
||||
});
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { Agent } from "@mariozechner/pi-agent-core";
|
|||
import { getModel, getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import { AgentSession } from "../src/core/agent-session.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 type { ResourceLoader } from "../src/core/resource-loader.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||
import { codingTools } from "../src/core/tools/index.js";
|
||||
|
|
@ -172,6 +174,19 @@ export interface TestSessionContext {
|
|||
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.
|
||||
* Use this for e2e tests that need real LLM calls.
|
||||
|
|
@ -204,7 +219,9 @@ export function createTestSession(options: TestSessionOptions = {}): TestSession
|
|||
agent,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
cwd: tempDir,
|
||||
modelRegistry,
|
||||
resourceLoader: createTestResourceLoader(),
|
||||
});
|
||||
|
||||
// Must subscribe to enable session persistence
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import {
|
|||
AgentSession,
|
||||
AuthStorage,
|
||||
convertToLlm,
|
||||
createExtensionRuntime,
|
||||
formatSkillsForPrompt,
|
||||
loadSkillsFromDir,
|
||||
ModelRegistry,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
type Skill,
|
||||
} 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`);
|
||||
}
|
||||
|
||||
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
|
||||
const session = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settingsManager: settingsManager as any,
|
||||
cwd: process.cwd(),
|
||||
modelRegistry,
|
||||
resourceLoader,
|
||||
baseToolsOverride,
|
||||
});
|
||||
|
||||
// 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 ZAI_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 GH_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..."
|
||||
npm test
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue