mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 20:03:05 +00:00
Merge branch 'main' into pb/tui-status-coalesce
This commit is contained in:
commit
ac6f5006a9
216 changed files with 14479 additions and 8725 deletions
|
|
@ -2,13 +2,213 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
This release introduces session trees for in-place branching, major API changes to hooks and custom tools, and structured compaction with file tracking.
|
||||
|
||||
### Session Tree
|
||||
|
||||
Sessions now use a tree structure with `id`/`parentId` fields. This enables in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
|
||||
|
||||
**Existing sessions are automatically migrated** (v1 → v2) on first load. No manual action required.
|
||||
|
||||
New entry types: `BranchSummaryEntry` (context from abandoned branches), `CustomEntry` (hook state), `CustomMessageEntry` (hook-injected messages), `LabelEntry` (bookmarks).
|
||||
|
||||
See [docs/session.md](docs/session.md) for the file format and `SessionManager` API.
|
||||
|
||||
### Hooks Migration
|
||||
|
||||
The hooks API has been restructured with more granular events and better session access.
|
||||
|
||||
**Type renames:**
|
||||
- `HookEventContext` → `HookContext`
|
||||
- `HookCommandContext` removed (use `HookContext` for command handlers)
|
||||
|
||||
**Event changes:**
|
||||
- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_new`, `session_new`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown`
|
||||
- New `session_before_tree` and `session_tree` events for `/tree` navigation (hook can provide custom branch summary)
|
||||
- New `before_agent_start` event: inject messages before the agent loop starts
|
||||
- New `context` event: modify messages non-destructively before each LLM call
|
||||
- Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead
|
||||
|
||||
**API changes:**
|
||||
- `pi.send(text, attachments?)` → `pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`)
|
||||
- New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context)
|
||||
- New `pi.registerCommand(name, options)` for custom slash commands
|
||||
- New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering
|
||||
- New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus
|
||||
- `ctx.exec()` moved to `pi.exec()`
|
||||
- `ctx.sessionFile` → `ctx.sessionManager.getSessionFile()`
|
||||
- New `ctx.modelRegistry` and `ctx.model` for API key resolution
|
||||
|
||||
**Removed:**
|
||||
- `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort)
|
||||
- `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`)
|
||||
|
||||
See [docs/hooks.md](docs/hooks.md) and [examples/hooks/](examples/hooks/) for the current API.
|
||||
|
||||
### Custom Tools Migration
|
||||
|
||||
The custom tools API has been restructured to mirror the hooks pattern with a context object.
|
||||
|
||||
**Type renames:**
|
||||
- `CustomAgentTool` → `CustomTool`
|
||||
- `ToolAPI` → `CustomToolAPI`
|
||||
- `ToolContext` → `CustomToolContext`
|
||||
- `ToolSessionEvent` → `CustomToolSessionEvent`
|
||||
|
||||
**Execute signature changed:**
|
||||
```typescript
|
||||
// Before (v0.30.2)
|
||||
execute(toolCallId, params, signal, onUpdate)
|
||||
|
||||
// After
|
||||
execute(toolCallId, params, onUpdate, ctx, signal?)
|
||||
```
|
||||
|
||||
The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, and `model`.
|
||||
|
||||
**Session event changes:**
|
||||
- `CustomToolSessionEvent` now only has `reason` and `previousSessionFile`
|
||||
- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` or `ctx.sessionManager.getEntries()` to reconstruct state
|
||||
- New reasons: `"tree"` (for `/tree` navigation) and `"shutdown"` (for cleanup on exit)
|
||||
- `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup
|
||||
|
||||
See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/) for the current API.
|
||||
|
||||
### SDK Migration
|
||||
|
||||
**Type changes:**
|
||||
- `CustomAgentTool` → `CustomTool`
|
||||
- `AppMessage` → `AgentMessage`
|
||||
- `sessionFile` returns `string | undefined` (was `string | null`)
|
||||
- `model` returns `Model | undefined` (was `Model | null`)
|
||||
- `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays.
|
||||
|
||||
**AgentSession branching API:**
|
||||
- `branch(entryIndex: number)` → `branch(entryId: string)`
|
||||
- `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }`
|
||||
- `reset()` and `switchSession()` now return `Promise<boolean>` (false if cancelled by hook)
|
||||
- New `navigateTree(targetId, options?)` for in-place tree navigation
|
||||
|
||||
**Hook integration:**
|
||||
- New `sendHookMessage(message, triggerTurn?)` for hook message injection
|
||||
|
||||
**SessionManager API:**
|
||||
- Method renames: `saveXXX()` → `appendXXX()` (e.g., `appendMessage`, `appendCompaction`)
|
||||
- `branchInPlace()` → `branch()`
|
||||
- `reset()` → `newSession()`
|
||||
- `createBranchedSessionFromEntries(entries, index)` → `createBranchedSession(leafId)`
|
||||
- `saveCompaction(entry)` → `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?)`
|
||||
- `getEntries()` now excludes the session header (use `getHeader()` separately)
|
||||
- `getSessionFile()` returns `string | undefined` (undefined for in-memory sessions)
|
||||
- New tree methods: `getTree()`, `getBranch()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()`
|
||||
- New append methods: `appendCustomEntry()`, `appendCustomMessageEntry()`, `appendLabelChange()`
|
||||
- New branch methods: `branch(entryId)`, `branchWithSummary()`
|
||||
|
||||
**ModelRegistry (new):**
|
||||
|
||||
`ModelRegistry` is a new class that manages model discovery and API key resolution. It combines built-in models with custom models from `models.json` and resolves API keys via `AuthStorage`.
|
||||
|
||||
```typescript
|
||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
|
||||
const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
|
||||
|
||||
// Get all models (built-in + custom)
|
||||
const allModels = modelRegistry.getAll();
|
||||
|
||||
// Get only models with valid API keys
|
||||
const available = await modelRegistry.getAvailable();
|
||||
|
||||
// Find specific model
|
||||
const model = modelRegistry.find("anthropic", "claude-sonnet-4-20250514");
|
||||
|
||||
// Get API key for a model
|
||||
const apiKey = await modelRegistry.getApiKey(model);
|
||||
```
|
||||
|
||||
This replaces the old `resolveApiKey` callback pattern. Hooks and custom tools access it via `ctx.modelRegistry`.
|
||||
|
||||
**Renamed exports:**
|
||||
- `messageTransformer` → `convertToLlm`
|
||||
- `SessionContext` alias `LoadedSession` removed
|
||||
|
||||
See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the current API.
|
||||
|
||||
### RPC Migration
|
||||
|
||||
**Branching commands:**
|
||||
- `branch` command: `entryIndex` → `entryId`
|
||||
- `get_branch_messages` response: `entryIndex` → `entryId`
|
||||
|
||||
**Type changes:**
|
||||
- Messages are now `AgentMessage` (was `AppMessage`)
|
||||
- `prompt` command: `attachments` field replaced with `images` field using `ImageContent` format
|
||||
|
||||
**Compaction events:**
|
||||
- `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`)
|
||||
- `auto_compaction_end` now includes `willRetry` field
|
||||
- `compact` response includes full `CompactionResult` (`summary`, `firstKeptEntryId`, `tokensBefore`, `details`)
|
||||
|
||||
See [docs/rpc.md](docs/rpc.md) for the current protocol.
|
||||
|
||||
### Structured Compaction
|
||||
|
||||
Compaction and branch summarization now use a structured output format:
|
||||
- Clear sections: Goal, Progress, Key Information, File Operations
|
||||
- File tracking: `readFiles` and `modifiedFiles` arrays in `details`, accumulated across compactions
|
||||
- Conversations are serialized to text before summarization to prevent the model from "continuing" them
|
||||
|
||||
The `before_compact` and `before_tree` hook events allow custom compaction implementations. See [docs/compaction.md](docs/compaction.md).
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
**`/tree` command:**
|
||||
- Navigate the full session tree in-place
|
||||
- Search by typing, page with ←/→
|
||||
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
|
||||
- Press `l` to label entries as bookmarks
|
||||
- Selecting a branch switches context and optionally injects a summary of the abandoned branch
|
||||
|
||||
**Entry labels:**
|
||||
- Bookmark any entry via `/tree` → select → `l`
|
||||
- Labels appear in tree view and persist as `LabelEntry`
|
||||
|
||||
**Theme changes (breaking for custom themes):**
|
||||
|
||||
Custom themes must add these new color tokens or they will fail to load:
|
||||
- `selectedBg`: background for selected/highlighted items in tree selector and other components
|
||||
- `customMessageBg`: background for hook-injected messages (`CustomMessageEntry`)
|
||||
- `customMessageText`: text color for hook messages
|
||||
- `customMessageLabel`: label color for hook messages (the `[customType]` prefix)
|
||||
|
||||
Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) for the full color list and copy values from the built-in dark/light themes.
|
||||
|
||||
**Settings:**
|
||||
- `enabledModels`: allowlist models in `settings.json` (same format as `--models` CLI)
|
||||
|
||||
### Added
|
||||
|
||||
- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting.
|
||||
- **Snake game example hook**: Demonstrates `ui.custom()`, `registerCommand()`, and session persistence. See [examples/hooks/snake.ts](examples/hooks/snake.ts).
|
||||
- **`thinkingText` theme token**: Configurable color for thinking block text. ([#366](https://github.com/badlogic/pi-mono/pull/366) by [@paulbettner](https://github.com/paulbettner))
|
||||
|
||||
### Changed
|
||||
|
||||
- **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs
|
||||
- **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355))
|
||||
- **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed.
|
||||
- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou))
|
||||
- **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon))
|
||||
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey))
|
||||
- **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri))
|
||||
- **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez))
|
||||
- **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus))
|
||||
- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded
|
||||
- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings
|
||||
- **Compaction with branched sessions**: Fixed compaction incorrectly including entries from abandoned branches, causing token overflow errors. Compaction now uses `sessionManager.getPath()` to work only on the current branch path, eliminating 80+ lines of duplicate entry collection logic between `prepareCompaction()` and `compact()`
|
||||
|
||||
## [0.30.2] - 2025-12-26
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
|
|||
- [Project Context Files](#project-context-files)
|
||||
- [Custom System Prompt](#custom-system-prompt)
|
||||
- [Custom Models and Providers](#custom-models-and-providers)
|
||||
- [Settings File](#settings-file)
|
||||
- [Extensions](#extensions)
|
||||
- [Themes](#themes)
|
||||
- [Custom Slash Commands](#custom-slash-commands)
|
||||
- [Skills](#skills)
|
||||
- [Hooks](#hooks)
|
||||
- [Custom Tools](#custom-tools)
|
||||
- [Settings File](#settings-file)
|
||||
- [CLI Reference](#cli-reference)
|
||||
- [Tools](#tools)
|
||||
- [Programmatic Usage](#programmatic-usage)
|
||||
|
|
@ -193,6 +194,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
|
|||
| `/session` | Show session info: path, message counts, token usage, cost |
|
||||
| `/hotkeys` | Show all keyboard shortcuts |
|
||||
| `/changelog` | Display full version history |
|
||||
| `/tree` | Navigate session tree in-place (search, filter, label entries) |
|
||||
| `/branch` | Create new conversation branch from a previous message |
|
||||
| `/resume` | Switch to a different session (interactive selector) |
|
||||
| `/login` | OAuth login for subscription-based models |
|
||||
|
|
@ -291,6 +293,10 @@ Toggle inline images via `/settings` or set `terminal.showImages: false` in sett
|
|||
|
||||
## Sessions
|
||||
|
||||
Sessions are stored as JSONL files with a **tree structure**. Each entry has an `id` and `parentId`, enabling in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
|
||||
|
||||
See [docs/session.md](docs/session.md) for the file format and programmatic API.
|
||||
|
||||
### Session Management
|
||||
|
||||
Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory.
|
||||
|
|
@ -319,14 +325,6 @@ Long sessions can exhaust context windows. Compaction summarizes older messages
|
|||
|
||||
When disabled, neither case triggers automatic compaction (use `/compact` manually if needed).
|
||||
|
||||
**How it works:**
|
||||
1. Cut point calculated to keep ~20k tokens of recent messages
|
||||
2. Messages before cut point are summarized
|
||||
3. Summary replaces old messages as "context handoff"
|
||||
4. Previous compaction summaries chain into new ones
|
||||
|
||||
Compaction does not create a new session, but continues the existing one, with a marker in the `.jsonl` file that encodes the compaction point.
|
||||
|
||||
**Configuration** (`~/.pi/agent/settings.json`):
|
||||
|
||||
```json
|
||||
|
|
@ -339,11 +337,20 @@ Compaction does not create a new session, but continues the existing one, with a
|
|||
}
|
||||
```
|
||||
|
||||
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/branch` to revisit any previous point.
|
||||
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point.
|
||||
|
||||
See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks.
|
||||
|
||||
### Branching
|
||||
|
||||
Use `/branch` to explore alternative conversation paths:
|
||||
**In-place navigation (`/tree`):** Navigate the session tree without creating new files. Select any previous point, continue from there, and switch between branches while preserving all history.
|
||||
|
||||
- Search by typing, page with ←/→
|
||||
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
|
||||
- Press `l` to label entries as bookmarks
|
||||
- When switching branches, you're prompted whether to generate a summary of the abandoned branch (messages up to the common ancestor)
|
||||
|
||||
**Create new session (`/branch`):** Branch to a new session file:
|
||||
|
||||
1. Opens selector showing all your user messages
|
||||
2. Select a message to branch from
|
||||
|
|
@ -473,6 +480,75 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`:
|
|||
|
||||
> pi can help you create custom provider and model configurations.
|
||||
|
||||
### Settings File
|
||||
|
||||
Settings are loaded from two locations and merged:
|
||||
|
||||
1. **Global:** `~/.pi/agent/settings.json` - user preferences
|
||||
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
|
||||
|
||||
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
|
||||
|
||||
Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "dark",
|
||||
"defaultProvider": "anthropic",
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"],
|
||||
"queueMode": "one-at-a-time",
|
||||
"shellPath": "C:\\path\\to\\bash.exe",
|
||||
"hideThinkingBlock": false,
|
||||
"collapseChangelog": false,
|
||||
"compaction": {
|
||||
"enabled": true,
|
||||
"reserveTokens": 16384,
|
||||
"keepRecentTokens": 20000
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true
|
||||
},
|
||||
"retry": {
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
"baseDelayMs": 2000
|
||||
},
|
||||
"terminal": {
|
||||
"showImages": true
|
||||
},
|
||||
"hooks": ["/path/to/hook.ts"],
|
||||
"customTools": ["/path/to/tool.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `theme` | Color theme name | auto-detected |
|
||||
| `defaultProvider` | Default model provider | - |
|
||||
| `defaultModel` | Default model ID | - |
|
||||
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
|
||||
| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - |
|
||||
| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
|
||||
| `shellPath` | Custom bash path (Windows) | auto-detected |
|
||||
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
|
||||
| `collapseChangelog` | Show condensed changelog after update | `false` |
|
||||
| `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` |
|
||||
| `retry.enabled` | Auto-retry on transient errors | `true` |
|
||||
| `retry.maxRetries` | Maximum retry attempts | `3` |
|
||||
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
|
||||
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
|
||||
| `hooks` | Additional hook file paths | `[]` |
|
||||
| `customTools` | Additional custom tool file paths | `[]` |
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
### Themes
|
||||
|
||||
Built-in themes: `dark` (default), `light`. Auto-detected on first run.
|
||||
|
|
@ -612,18 +688,23 @@ export default function (pi: HookAPI) {
|
|||
|
||||
**Sending messages from hooks:**
|
||||
|
||||
Use `pi.send(text, attachments?)` to inject messages into the session. If the agent is streaming, the message is queued; otherwise a new agent loop starts immediately.
|
||||
Use `pi.sendMessage(message, triggerTurn?)` to inject messages into the session. Messages are persisted as `CustomMessageEntry` and sent to the LLM. If the agent is streaming, the message is queued; otherwise a new agent loop starts if `triggerTurn` is true.
|
||||
|
||||
```typescript
|
||||
import * as fs from "node:fs";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event) => {
|
||||
if (event.reason !== "start") return;
|
||||
pi.on("session_start", async () => {
|
||||
fs.watch("/tmp/trigger.txt", () => {
|
||||
const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
|
||||
if (content) pi.send(content);
|
||||
if (content) {
|
||||
pi.sendMessage({
|
||||
customType: "file-trigger",
|
||||
content,
|
||||
display: true,
|
||||
}, true); // triggerTurn: start agent loop
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -659,10 +740,11 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: { greeted: params.name },
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -682,73 +764,6 @@ export default factory;
|
|||
|
||||
> See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction.
|
||||
|
||||
### Settings File
|
||||
|
||||
Settings are loaded from two locations and merged:
|
||||
|
||||
1. **Global:** `~/.pi/agent/settings.json` - user preferences
|
||||
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
|
||||
|
||||
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
|
||||
|
||||
Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "dark",
|
||||
"defaultProvider": "anthropic",
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"],
|
||||
"queueMode": "one-at-a-time",
|
||||
"shellPath": "C:\\path\\to\\bash.exe",
|
||||
"hideThinkingBlock": false,
|
||||
"collapseChangelog": false,
|
||||
"compaction": {
|
||||
"enabled": true,
|
||||
"reserveTokens": 16384,
|
||||
"keepRecentTokens": 20000
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true
|
||||
},
|
||||
"retry": {
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
"baseDelayMs": 2000
|
||||
},
|
||||
"terminal": {
|
||||
"showImages": true
|
||||
},
|
||||
"hooks": ["/path/to/hook.ts"],
|
||||
"hookTimeout": 30000,
|
||||
"customTools": ["/path/to/tool.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `theme` | Color theme name | auto-detected |
|
||||
| `defaultProvider` | Default model provider | - |
|
||||
| `defaultModel` | Default model ID | - |
|
||||
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
|
||||
| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - |
|
||||
| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
|
||||
| `shellPath` | Custom bash path (Windows) | auto-detected |
|
||||
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
|
||||
| `collapseChangelog` | Show condensed changelog after update | `false` |
|
||||
| `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` |
|
||||
| `retry.enabled` | Auto-retry on transient errors | `true` |
|
||||
| `retry.maxRetries` | Maximum retry attempts | `3` |
|
||||
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
|
||||
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
|
||||
| `hooks` | Additional hook file paths | `[]` |
|
||||
| `hookTimeout` | Timeout for hook operations (ms) | `30000` |
|
||||
| `customTools` | Additional custom tool file paths | `[]` |
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
|
|
|||
388
packages/coding-agent/docs/compaction.md
Normal file
388
packages/coding-agent/docs/compaction.md
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# Compaction & Branch Summarization
|
||||
|
||||
LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.
|
||||
|
||||
**Source files:**
|
||||
- [`src/core/compaction/compaction.ts`](../src/core/compaction/compaction.ts) - Auto-compaction logic
|
||||
- [`src/core/compaction/branch-summarization.ts`](../src/core/compaction/branch-summarization.ts) - Branch summarization
|
||||
- [`src/core/compaction/utils.ts`](../src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)
|
||||
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)
|
||||
- [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) - Hook event types
|
||||
|
||||
## Overview
|
||||
|
||||
Pi has two summarization mechanisms:
|
||||
|
||||
| Mechanism | Trigger | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
|
||||
| Branch summarization | `/tree` navigation | Preserve context when switching branches |
|
||||
|
||||
Both use the same structured summary format and track file operations cumulatively.
|
||||
|
||||
## Compaction
|
||||
|
||||
### When It Triggers
|
||||
|
||||
Auto-compaction triggers when:
|
||||
|
||||
```
|
||||
contextTokens > contextWindow - reserveTokens
|
||||
```
|
||||
|
||||
By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`). This leaves room for the LLM's response.
|
||||
|
||||
You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`) is reached
|
||||
2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point
|
||||
3. **Generate summary**: Call LLM to summarize with structured format
|
||||
4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`
|
||||
5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards
|
||||
|
||||
```
|
||||
Before compaction:
|
||||
|
||||
entry: 0 1 2 3 4 5 6 7 8 9
|
||||
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
|
||||
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
|
||||
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
|
||||
└────────┬───────┘ └──────────────┬──────────────┘
|
||||
messagesToSummarize kept messages
|
||||
↑
|
||||
firstKeptEntryId (entry 4)
|
||||
|
||||
After compaction (new entry appended):
|
||||
|
||||
entry: 0 1 2 3 4 5 6 7 8 9 10
|
||||
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
|
||||
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
|
||||
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
|
||||
└──────────┬──────┘ └──────────────────────┬───────────────────┘
|
||||
not sent to LLM sent to LLM
|
||||
↑
|
||||
starts from firstKeptEntryId
|
||||
|
||||
What the LLM sees:
|
||||
|
||||
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
|
||||
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
|
||||
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
|
||||
↑ ↑ └─────────────────┬────────────────┘
|
||||
prompt from cmp messages from firstKeptEntryId
|
||||
```
|
||||
|
||||
### Split Turns
|
||||
|
||||
A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.
|
||||
|
||||
When a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a "split turn":
|
||||
|
||||
```
|
||||
Split turn (one huge turn exceeds budget):
|
||||
|
||||
entry: 0 1 2 3 4 5 6 7 8
|
||||
┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
|
||||
│ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
|
||||
└─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
|
||||
↑ ↑
|
||||
turnStartIndex = 1 firstKeptEntryId = 7
|
||||
│ │
|
||||
└──── turnPrefixMessages (1-6) ───────┘
|
||||
└── kept (7-8)
|
||||
|
||||
isSplitTurn = true
|
||||
messagesToSummarize = [] (no complete turns before)
|
||||
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
|
||||
```
|
||||
|
||||
For split turns, pi generates two summaries and merges them:
|
||||
1. **History summary**: Previous context (if any)
|
||||
2. **Turn prefix summary**: The early part of the split turn
|
||||
|
||||
### Cut Point Rules
|
||||
|
||||
Valid cut points are:
|
||||
- User messages
|
||||
- Assistant messages
|
||||
- BashExecution messages
|
||||
- Hook messages (custom_message, branch_summary)
|
||||
|
||||
Never cut at tool results (they must stay with their tool call).
|
||||
|
||||
### CompactionEntry Structure
|
||||
|
||||
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
|
||||
|
||||
```typescript
|
||||
interface CompactionEntry<T = unknown> {
|
||||
type: "compaction";
|
||||
id: string;
|
||||
parentId: string;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
fromHook?: boolean; // true if hook provided the compaction
|
||||
details?: T; // hook-specific data
|
||||
}
|
||||
|
||||
// Default compaction uses this for details (from compaction.ts):
|
||||
interface CompactionDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure.
|
||||
|
||||
See [`prepareCompaction()`](../src/core/compaction/compaction.ts) and [`compact()`](../src/core/compaction/compaction.ts) for the implementation.
|
||||
|
||||
## Branch Summarization
|
||||
|
||||
### When It Triggers
|
||||
|
||||
When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Find common ancestor**: Deepest node shared by old and new positions
|
||||
2. **Collect entries**: Walk from old leaf back to common ancestor
|
||||
3. **Prepare with budget**: Include messages up to token budget (newest first)
|
||||
4. **Generate summary**: Call LLM with structured format
|
||||
5. **Append entry**: Save `BranchSummaryEntry` at navigation point
|
||||
|
||||
```
|
||||
Tree before navigation:
|
||||
|
||||
┌─ B ─ C ─ D (old leaf, being abandoned)
|
||||
A ───┤
|
||||
└─ E ─ F (target)
|
||||
|
||||
Common ancestor: A
|
||||
Entries to summarize: B, C, D
|
||||
|
||||
After navigation with summary:
|
||||
|
||||
┌─ B ─ C ─ D ─ [summary of B,C,D]
|
||||
A ───┤
|
||||
└─ E ─ F (new leaf)
|
||||
```
|
||||
|
||||
### Cumulative File Tracking
|
||||
|
||||
Both compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from:
|
||||
- Tool calls in the messages being summarized
|
||||
- Previous compaction or branch summary `details` (if any)
|
||||
|
||||
This means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.
|
||||
|
||||
### BranchSummaryEntry Structure
|
||||
|
||||
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
|
||||
|
||||
```typescript
|
||||
interface BranchSummaryEntry<T = unknown> {
|
||||
type: "branch_summary";
|
||||
id: string;
|
||||
parentId: string;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fromId: string; // Entry we navigated from
|
||||
fromHook?: boolean; // true if hook provided the summary
|
||||
details?: T; // hook-specific data
|
||||
}
|
||||
|
||||
// Default branch summarization uses this for details (from branch-summarization.ts):
|
||||
interface BranchSummaryDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Same as compaction, hooks can store custom data in `details`.
|
||||
|
||||
See [`collectEntriesForBranchSummary()`](../src/core/compaction/branch-summarization.ts), [`prepareBranchEntries()`](../src/core/compaction/branch-summarization.ts), and [`generateBranchSummary()`](../src/core/compaction/branch-summarization.ts) for the implementation.
|
||||
|
||||
## Summary Format
|
||||
|
||||
Both compaction and branch summarization use the same structured format:
|
||||
|
||||
```markdown
|
||||
## Goal
|
||||
[What the user is trying to accomplish]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Requirements mentioned by user]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [What should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Data needed to continue]
|
||||
|
||||
<read-files>
|
||||
path/to/file1.ts
|
||||
path/to/file2.ts
|
||||
</read-files>
|
||||
|
||||
<modified-files>
|
||||
path/to/changed.ts
|
||||
</modified-files>
|
||||
```
|
||||
|
||||
### Message Serialization
|
||||
|
||||
Before summarization, messages are serialized to text via [`serializeConversation()`](../src/core/compaction/utils.ts):
|
||||
|
||||
```
|
||||
[User]: What they said
|
||||
[Assistant thinking]: Internal reasoning
|
||||
[Assistant]: Response text
|
||||
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
|
||||
[Tool result]: Output from tool
|
||||
```
|
||||
|
||||
This prevents the model from treating it as a conversation to continue.
|
||||
|
||||
## Custom Summarization via Hooks
|
||||
|
||||
Hooks can intercept and customize both compaction and branch summarization. See [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) for event type definitions.
|
||||
|
||||
### session_before_compact
|
||||
|
||||
Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. See `SessionBeforeCompactEvent` and `CompactionPreparation` in the types file.
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, branchEntries, customInstructions, signal } = event;
|
||||
|
||||
// preparation.messagesToSummarize - messages to summarize
|
||||
// preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
|
||||
// preparation.previousSummary - previous compaction summary
|
||||
// preparation.fileOps - extracted file operations
|
||||
// preparation.tokensBefore - context tokens before compaction
|
||||
// preparation.firstKeptEntryId - where kept messages start
|
||||
// preparation.settings - compaction settings
|
||||
|
||||
// branchEntries - all entries on current branch (for custom state)
|
||||
// signal - AbortSignal (pass to LLM calls)
|
||||
|
||||
// Cancel:
|
||||
return { cancel: true };
|
||||
|
||||
// Custom summary:
|
||||
return {
|
||||
compaction: {
|
||||
summary: "Your summary...",
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { /* custom data */ },
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
#### Converting Messages to Text
|
||||
|
||||
To generate a summary with your own model, convert messages to text using `serializeConversation`:
|
||||
|
||||
```typescript
|
||||
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation } = event;
|
||||
|
||||
// Convert AgentMessage[] to Message[], then serialize to text
|
||||
const conversationText = serializeConversation(
|
||||
convertToLlm(preparation.messagesToSummarize)
|
||||
);
|
||||
// Returns:
|
||||
// [User]: message text
|
||||
// [Assistant thinking]: thinking content
|
||||
// [Assistant]: response text
|
||||
// [Assistant tool calls]: read(path="..."); bash(command="...")
|
||||
// [Tool result]: output text
|
||||
|
||||
// Now send to your model for summarization
|
||||
const summary = await myModel.summarize(conversationText);
|
||||
|
||||
return {
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model.
|
||||
|
||||
### session_before_tree
|
||||
|
||||
Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_tree", async (event, ctx) => {
|
||||
const { preparation, signal } = event;
|
||||
|
||||
// preparation.targetId - where we're navigating to
|
||||
// preparation.oldLeafId - current position (being abandoned)
|
||||
// preparation.commonAncestorId - shared ancestor
|
||||
// preparation.entriesToSummarize - entries that would be summarized
|
||||
// preparation.userWantsSummary - whether user chose to summarize
|
||||
|
||||
// Cancel navigation entirely:
|
||||
return { cancel: true };
|
||||
|
||||
// Provide custom summary (only used if userWantsSummary is true):
|
||||
if (preparation.userWantsSummary) {
|
||||
return {
|
||||
summary: {
|
||||
summary: "Your summary...",
|
||||
details: { /* custom data */ },
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
See `SessionBeforeTreeEvent` and `TreePreparation` in the types file.
|
||||
|
||||
## Settings
|
||||
|
||||
Configure compaction in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compaction": {
|
||||
"enabled": true,
|
||||
"reserveTokens": 16384,
|
||||
"keepRecentTokens": 20000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `enabled` | `true` | Enable auto-compaction |
|
||||
| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
|
||||
| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
|
||||
|
||||
Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
> pi can create custom tools. Ask it to build one for your use case.
|
||||
|
||||
# Custom Tools
|
||||
|
||||
Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.
|
||||
|
||||
**Key capabilities:**
|
||||
- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs)
|
||||
- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult`
|
||||
- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md))
|
||||
- **State management** - Persist state in tool result `details` for proper branching support
|
||||
- **Streaming results** - Send partial updates via `onUpdate` callback
|
||||
|
||||
**Example use cases:**
|
||||
- Ask the user questions with selectable options
|
||||
- Maintain state across calls (todo lists, connection pools)
|
||||
- Custom TUI rendering (progress indicators, structured output)
|
||||
- Integrate external services with proper error handling
|
||||
- Tools that need user confirmation before proceeding
|
||||
- Interactive dialogs (questions with selectable options)
|
||||
- Stateful tools (todo lists, connection pools)
|
||||
- Rich output rendering (progress indicators, structured views)
|
||||
- External service integrations with confirmation flows
|
||||
|
||||
**When to use custom tools vs. alternatives:**
|
||||
|
||||
|
|
@ -36,10 +44,11 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: { greeted: params.name },
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -82,7 +91,7 @@ Custom tools can import from these packages (automatically resolved by pi):
|
|||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
|
||||
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent`, etc.) |
|
||||
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) |
|
||||
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
|
||||
| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |
|
||||
|
||||
|
|
@ -94,7 +103,12 @@ Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
|
||||
import type {
|
||||
CustomTool,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "my_tool",
|
||||
|
|
@ -106,9 +120,10 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
text: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, signal, onUpdate) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// signal - AbortSignal for cancellation
|
||||
// onUpdate - Callback for streaming partial results
|
||||
// ctx - CustomToolContext with sessionManager, modelRegistry, model
|
||||
return {
|
||||
content: [{ type: "text", text: "Result for LLM" }],
|
||||
details: { /* structured data for rendering */ },
|
||||
|
|
@ -116,14 +131,17 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
},
|
||||
|
||||
// Optional: Session lifecycle callback
|
||||
onSession(event) { /* reconstruct state from entries */ },
|
||||
onSession(event, ctx) {
|
||||
if (event.reason === "shutdown") {
|
||||
// Cleanup resources (close connections, save state, etc.)
|
||||
return;
|
||||
}
|
||||
// Reconstruct state from ctx.sessionManager.getBranch()
|
||||
},
|
||||
|
||||
// Optional: Custom rendering
|
||||
renderCall(args, theme) { /* return Component */ },
|
||||
renderResult(result, options, theme) { /* return Component */ },
|
||||
|
||||
// Optional: Cleanup on session end
|
||||
dispose() { /* save state, close connections */ },
|
||||
});
|
||||
|
||||
export default factory;
|
||||
|
|
@ -131,23 +149,26 @@ export default factory;
|
|||
|
||||
**Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.
|
||||
|
||||
## ToolAPI Object
|
||||
## CustomToolAPI Object
|
||||
|
||||
The factory receives a `ToolAPI` object (named `pi` by convention):
|
||||
The factory receives a `CustomToolAPI` object (named `pi` by convention):
|
||||
|
||||
```typescript
|
||||
interface ToolAPI {
|
||||
interface CustomToolAPI {
|
||||
cwd: string; // Current working directory
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
ui: {
|
||||
select(title: string, options: string[]): Promise<string | null>;
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
input(title: string, placeholder?: string): Promise<string | null>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
};
|
||||
ui: ToolUIContext;
|
||||
hasUI: boolean; // false in --print or --mode rpc
|
||||
}
|
||||
|
||||
interface ToolUIContext {
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
|
||||
}
|
||||
|
||||
interface ExecOptions {
|
||||
signal?: AbortSignal; // Cancel the process
|
||||
timeout?: number; // Timeout in milliseconds
|
||||
|
|
@ -168,7 +189,7 @@ Always check `pi.hasUI` before using UI methods.
|
|||
Pass the `signal` from `execute` to `pi.exec` to support cancellation:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, signal) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const result = await pi.exec("long-running-command", ["arg"], { signal });
|
||||
if (result.killed) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
|
|
@ -177,16 +198,51 @@ async execute(toolCallId, params, signal) {
|
|||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Throw an error** when the tool fails. Do not return an error message as content.
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { path } = params as { path: string };
|
||||
|
||||
// Throw on error - pi will catch it and report to the LLM
|
||||
if (!fs.existsSync(path)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
// Return content only on success
|
||||
return { content: [{ type: "text", text: "Success" }] };
|
||||
}
|
||||
```
|
||||
|
||||
Thrown errors are:
|
||||
- Reported to the LLM as tool errors (with `isError: true`)
|
||||
- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`)
|
||||
- Displayed in the TUI with error styling
|
||||
|
||||
## CustomToolContext
|
||||
|
||||
The `execute` and `onSession` callbacks receive a `CustomToolContext`:
|
||||
|
||||
```typescript
|
||||
interface CustomToolContext {
|
||||
sessionManager: ReadonlySessionManager; // Read-only access to session
|
||||
modelRegistry: ModelRegistry; // For API key resolution
|
||||
model: Model | undefined; // Current model (may be undefined)
|
||||
}
|
||||
```
|
||||
|
||||
Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction.
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
Tools can implement `onSession` to react to session changes:
|
||||
|
||||
```typescript
|
||||
interface ToolSessionEvent {
|
||||
entries: SessionEntry[]; // All session entries
|
||||
sessionFile: string | null; // Current session file
|
||||
previousSessionFile: string | null; // Previous session file
|
||||
reason: "start" | "switch" | "branch" | "new";
|
||||
interface CustomToolSessionEvent {
|
||||
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -195,6 +251,8 @@ interface ToolSessionEvent {
|
|||
- `switch`: User switched to a different session (`/resume`)
|
||||
- `branch`: User branched from a previous message (`/branch`)
|
||||
- `new`: User started a new session (`/new`)
|
||||
- `tree`: User navigated to a different point in the session tree (`/tree`)
|
||||
- `shutdown`: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
|
|
@ -210,9 +268,11 @@ const factory: CustomToolFactory = (pi) => {
|
|||
let items: string[] = [];
|
||||
|
||||
// Reconstruct state from session entries
|
||||
const reconstructState = (event: ToolSessionEvent) => {
|
||||
const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") return;
|
||||
|
||||
items = [];
|
||||
for (const entry of event.entries) {
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (msg.role !== "toolResult") continue;
|
||||
|
|
@ -233,7 +293,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
|
||||
onSession: reconstructState,
|
||||
|
||||
async execute(toolCallId, params) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// Modify items...
|
||||
items.push("new item");
|
||||
|
||||
|
|
@ -254,7 +314,7 @@ This pattern ensures:
|
|||
|
||||
## Custom Rendering
|
||||
|
||||
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional.
|
||||
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API.
|
||||
|
||||
### How It Works
|
||||
|
||||
|
|
@ -355,7 +415,7 @@ If `renderCall` or `renderResult` is not defined or throws an error:
|
|||
## Execute Function
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, args, signal, onUpdate) {
|
||||
async execute(toolCallId, args, onUpdate, ctx, signal) {
|
||||
// Type assertion for params (TypeBox schema doesn't flow through)
|
||||
const params = args as { action: "list" | "add"; text?: string };
|
||||
|
||||
|
|
@ -387,13 +447,16 @@ const factory: CustomToolFactory = (pi) => {
|
|||
// Shared state
|
||||
let connection = null;
|
||||
|
||||
const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") {
|
||||
connection?.close();
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: "db_connect", ... },
|
||||
{ name: "db_query", ... },
|
||||
{
|
||||
name: "db_close",
|
||||
dispose() { connection?.close(); }
|
||||
},
|
||||
{ name: "db_connect", onSession: handleSession, ... },
|
||||
{ name: "db_query", onSession: handleSession, ... },
|
||||
{ name: "db_close", onSession: handleSession, ... },
|
||||
];
|
||||
};
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,385 +0,0 @@
|
|||
# Hooks v2: Context Control + Commands
|
||||
|
||||
Issue: #289
|
||||
|
||||
## Motivation
|
||||
|
||||
Enable features like session stacking (`/pop`) as hooks, not core code. Core provides primitives, hooks implement features.
|
||||
|
||||
## Primitives
|
||||
|
||||
| Primitive | Purpose |
|
||||
|-----------|---------|
|
||||
| `ctx.saveEntry({type, ...})` | Persist custom entry to session |
|
||||
| `pi.on("context", handler)` | Transform messages before LLM |
|
||||
| `ctx.rebuildContext()` | Trigger context rebuild |
|
||||
| `pi.command(name, opts)` | Register slash command |
|
||||
|
||||
## Extended HookEventContext
|
||||
|
||||
```typescript
|
||||
interface HookEventContext {
|
||||
// Existing
|
||||
exec, ui, hasUI, cwd, sessionFile
|
||||
|
||||
// State (read-only)
|
||||
model: Model<any> | null;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
entries: readonly SessionEntry[];
|
||||
|
||||
// Utilities
|
||||
findModel(provider: string, id: string): Model<any> | null;
|
||||
availableModels(): Promise<Model<any>[]>;
|
||||
resolveApiKey(model: Model<any>): Promise<string | undefined>;
|
||||
|
||||
// Mutation
|
||||
saveEntry(entry: { type: string; [k: string]: unknown }): Promise<void>;
|
||||
rebuildContext(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ContextMessage {
|
||||
message: AppMessage;
|
||||
entryIndex: number | null; // null = synthetic
|
||||
}
|
||||
|
||||
interface ContextEvent {
|
||||
type: "context";
|
||||
entries: readonly SessionEntry[];
|
||||
messages: ContextMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
Commands also get: `args`, `argsRaw`, `signal`, `setModel()`, `setThinkingLevel()`.
|
||||
|
||||
## Stacking: Design
|
||||
|
||||
### Entry Format
|
||||
|
||||
```typescript
|
||||
interface StackPopEntry {
|
||||
type: "stack_pop";
|
||||
backToIndex: number;
|
||||
summary: string;
|
||||
prePopSummary?: string; // when crossing compaction
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Crossing Compaction
|
||||
|
||||
Entries are never deleted. Raw data always available.
|
||||
|
||||
When `backToIndex < compaction.firstKeptEntryIndex`:
|
||||
1. Read raw entries `[0, backToIndex)` → summarize → `prePopSummary`
|
||||
2. Read raw entries `[backToIndex, now)` → summarize → `summary`
|
||||
|
||||
### Context Algorithm: Later Wins
|
||||
|
||||
Assign sequential IDs to ranges. On overlap, highest ID wins.
|
||||
|
||||
```
|
||||
Compaction at 40: range [0, 30) id=0
|
||||
StackPop at 50, backTo=20, prePopSummary: ranges [0, 20) id=1, [20, 50) id=2
|
||||
|
||||
Index 0-19: id=0 and id=1 cover → id=1 wins (prePopSummary)
|
||||
Index 20-29: id=0 and id=2 cover → id=2 wins (popSummary)
|
||||
Index 30-49: id=2 covers → id=2 (already emitted at 20)
|
||||
Index 50+: no coverage → include as messages
|
||||
```
|
||||
|
||||
## Complex Scenario Trace
|
||||
|
||||
```
|
||||
Initial: [msg1, msg2, msg3, msg4, msg5]
|
||||
idx: 1, 2, 3, 4, 5
|
||||
|
||||
Compaction triggers:
|
||||
[msg1-5, compaction{firstKept:4, summary:C1}]
|
||||
idx: 1-5, 6
|
||||
Context: [C1, msg4, msg5]
|
||||
|
||||
User continues:
|
||||
[..., compaction, msg4, msg5, msg6, msg7]
|
||||
idx: 6, 4*, 5*, 7, 8 (* kept from before)
|
||||
|
||||
User does /pop to msg2 (index 2):
|
||||
- backTo=2 < firstKept=4 → crossing!
|
||||
- prePopSummary: summarize raw [0,2) → P1
|
||||
- summary: summarize raw [2,8) → S1
|
||||
- save: stack_pop{backTo:2, summary:S1, prePopSummary:P1} at index 9
|
||||
|
||||
Ranges:
|
||||
compaction [0,4) id=0
|
||||
prePopSummary [0,2) id=1
|
||||
popSummary [2,9) id=2
|
||||
|
||||
Context build:
|
||||
idx 0: covered by id=0,1 → id=1 wins, emit P1
|
||||
idx 1: covered by id=0,1 → id=1 (already emitted)
|
||||
idx 2: covered by id=0,2 → id=2 wins, emit S1
|
||||
idx 3-8: covered by id=0 or id=2 → id=2 (already emitted)
|
||||
idx 9: stack_pop entry, skip
|
||||
idx 10+: not covered, include as messages
|
||||
|
||||
Result: [P1, S1, msg10+]
|
||||
|
||||
User continues, another compaction:
|
||||
[..., stack_pop, msg10, msg11, msg12, compaction{firstKept:11, summary:C2}]
|
||||
idx: 9, 10, 11, 12, 13
|
||||
|
||||
Ranges:
|
||||
compaction@6 [0,4) id=0
|
||||
prePopSummary [0,2) id=1
|
||||
popSummary [2,9) id=2
|
||||
compaction@13 [0,11) id=3 ← this now covers previous ranges!
|
||||
|
||||
Context build:
|
||||
idx 0-10: covered by multiple, id=3 wins → emit C2 at idx 0
|
||||
idx 11+: include as messages
|
||||
|
||||
Result: [C2, msg11, msg12]
|
||||
|
||||
C2's summary text includes info from P1 and S1 (they were in context when C2 was generated).
|
||||
```
|
||||
|
||||
The "later wins" rule naturally handles all cases.
|
||||
|
||||
## Core Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `session-manager.ts` | `saveEntry()`, `buildSessionContext()` returns `ContextMessage[]` |
|
||||
| `hooks/types.ts` | `ContextEvent`, `ContextMessage`, extended context, command types |
|
||||
| `hooks/loader.ts` | Track commands |
|
||||
| `hooks/runner.ts` | `setStateCallbacks()`, `emitContext()`, command methods |
|
||||
| `agent-session.ts` | `saveEntry()`, `rebuildContext()`, state callbacks |
|
||||
| `interactive-mode.ts` | Command handling, autocomplete |
|
||||
|
||||
## Stacking Hook: Complete Implementation
|
||||
|
||||
```typescript
|
||||
import { complete } from "@mariozechner/pi-ai";
|
||||
import type { HookAPI, AppMessage, SessionEntry, ContextMessage } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function(pi: HookAPI) {
|
||||
pi.command("pop", {
|
||||
description: "Pop to previous turn, summarizing work",
|
||||
handler: async (ctx) => {
|
||||
const entries = ctx.entries as SessionEntry[];
|
||||
|
||||
// Get user turns
|
||||
const turns = entries
|
||||
.map((e, i) => ({ e, i }))
|
||||
.filter(({ e }) => e.type === "message" && (e as any).message.role === "user")
|
||||
.map(({ e, i }) => ({ idx: i, text: preview((e as any).message) }));
|
||||
|
||||
if (turns.length < 2) return { status: "Need at least 2 turns" };
|
||||
|
||||
// Select target (skip last turn - that's current)
|
||||
const options = turns.slice(0, -1).map(t => `[${t.idx}] ${t.text}`);
|
||||
const selected = ctx.args[0]
|
||||
? options.find(o => o.startsWith(`[${ctx.args[0]}]`))
|
||||
: await ctx.ui.select("Pop to:", options);
|
||||
|
||||
if (!selected) return;
|
||||
const backTo = parseInt(selected.match(/\[(\d+)\]/)![1]);
|
||||
|
||||
// Check compaction crossing
|
||||
const compactions = entries.filter(e => e.type === "compaction") as any[];
|
||||
const latestCompaction = compactions[compactions.length - 1];
|
||||
const crossing = latestCompaction && backTo < latestCompaction.firstKeptEntryIndex;
|
||||
|
||||
// Generate summaries
|
||||
let prePopSummary: string | undefined;
|
||||
if (crossing) {
|
||||
ctx.ui.notify("Crossing compaction, generating pre-pop summary...", "info");
|
||||
const preMsgs = getMessages(entries.slice(0, backTo));
|
||||
prePopSummary = await summarize(preMsgs, ctx, "context before this work");
|
||||
}
|
||||
|
||||
const popMsgs = getMessages(entries.slice(backTo));
|
||||
const summary = await summarize(popMsgs, ctx, "completed work");
|
||||
|
||||
// Save and rebuild
|
||||
await ctx.saveEntry({
|
||||
type: "stack_pop",
|
||||
backToIndex: backTo,
|
||||
summary,
|
||||
prePopSummary,
|
||||
});
|
||||
|
||||
await ctx.rebuildContext();
|
||||
return { status: `Popped to turn ${backTo}` };
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("context", (event, ctx) => {
|
||||
const hasPops = event.entries.some(e => e.type === "stack_pop");
|
||||
if (!hasPops) return;
|
||||
|
||||
// Collect ranges with IDs
|
||||
let rangeId = 0;
|
||||
const ranges: Array<{from: number; to: number; summary: string; id: number}> = [];
|
||||
|
||||
for (let i = 0; i < event.entries.length; i++) {
|
||||
const e = event.entries[i] as any;
|
||||
if (e.type === "compaction") {
|
||||
ranges.push({ from: 0, to: e.firstKeptEntryIndex, summary: e.summary, id: rangeId++ });
|
||||
}
|
||||
if (e.type === "stack_pop") {
|
||||
if (e.prePopSummary) {
|
||||
ranges.push({ from: 0, to: e.backToIndex, summary: e.prePopSummary, id: rangeId++ });
|
||||
}
|
||||
ranges.push({ from: e.backToIndex, to: i, summary: e.summary, id: rangeId++ });
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages
|
||||
const messages: ContextMessage[] = [];
|
||||
const emitted = new Set<number>();
|
||||
|
||||
for (let i = 0; i < event.entries.length; i++) {
|
||||
const covering = ranges.filter(r => r.from <= i && i < r.to);
|
||||
|
||||
if (covering.length) {
|
||||
const winner = covering.reduce((a, b) => a.id > b.id ? a : b);
|
||||
if (i === winner.from && !emitted.has(winner.id)) {
|
||||
messages.push({
|
||||
message: { role: "user", content: `[Summary]\n\n${winner.summary}`, timestamp: Date.now() } as AppMessage,
|
||||
entryIndex: null
|
||||
});
|
||||
emitted.add(winner.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const e = event.entries[i];
|
||||
if (e.type === "message") {
|
||||
messages.push({ message: (e as any).message, entryIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
return { messages };
|
||||
});
|
||||
}
|
||||
|
||||
function getMessages(entries: SessionEntry[]): AppMessage[] {
|
||||
return entries.filter(e => e.type === "message").map(e => (e as any).message);
|
||||
}
|
||||
|
||||
function preview(msg: AppMessage): string {
|
||||
const text = typeof msg.content === "string" ? msg.content
|
||||
: (msg.content as any[]).filter(c => c.type === "text").map(c => c.text).join(" ");
|
||||
return text.slice(0, 40) + (text.length > 40 ? "..." : "");
|
||||
}
|
||||
|
||||
async function summarize(msgs: AppMessage[], ctx: any, purpose: string): Promise<string> {
|
||||
const apiKey = await ctx.resolveApiKey(ctx.model);
|
||||
const resp = await complete(ctx.model, {
|
||||
messages: [...msgs, { role: "user", content: `Summarize as "${purpose}". Be concise.`, timestamp: Date.now() }]
|
||||
}, { apiKey, maxTokens: 2000, signal: ctx.signal });
|
||||
return resp.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n");
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Session Resumed Without Hook
|
||||
|
||||
User has stacking hook, does `/pop`, saves `stack_pop` entry. Later removes hook and resumes session.
|
||||
|
||||
**What happens:**
|
||||
1. Core loads all entries (including `stack_pop`)
|
||||
2. Core's `buildSessionContext()` ignores unknown types, returns compaction + message entries
|
||||
3. `context` event fires, but no handler processes `stack_pop`
|
||||
4. Core's messages pass through unchanged
|
||||
|
||||
**Result:** Messages that were "popped" return to context. The pop is effectively undone.
|
||||
|
||||
**Why this is OK:**
|
||||
- Session file is intact, no data lost
|
||||
- If compaction happened after pop, the compaction summary captured the popped state
|
||||
- User removed the hook, so hook's behavior (hiding messages) is gone
|
||||
- User can re-add hook to restore stacking behavior
|
||||
|
||||
**Mitigation:** Could warn on session load if unknown entry types found:
|
||||
```typescript
|
||||
// In session load
|
||||
const unknownTypes = entries
|
||||
.map(e => e.type)
|
||||
.filter(t => !knownTypes.has(t));
|
||||
if (unknownTypes.length) {
|
||||
console.warn(`Session has entries of unknown types: ${unknownTypes.join(", ")}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Added to Existing Session
|
||||
|
||||
User has old session without stacking. Adds stacking hook, does `/pop`.
|
||||
|
||||
**What happens:**
|
||||
1. Hook saves `stack_pop` entry
|
||||
2. `context` event fires, hook processes it
|
||||
3. Works normally
|
||||
|
||||
No issue. Hook processes entries it recognizes, ignores others.
|
||||
|
||||
### Multiple Hooks with Different Entry Types
|
||||
|
||||
Hook A handles `type_a` entries, Hook B handles `type_b` entries.
|
||||
|
||||
**What happens:**
|
||||
1. `context` event chains through both hooks
|
||||
2. Each hook checks for its entry types, passes through if none found
|
||||
3. Each hook's transforms are applied in order
|
||||
|
||||
**Best practice:** Hooks should:
|
||||
- Only process their own entry types
|
||||
- Return `undefined` (pass through) if no relevant entries
|
||||
- Use prefixed type names: `myhook_pop`, `myhook_prune`
|
||||
|
||||
### Conflicting Hooks
|
||||
|
||||
Two hooks both try to handle the same entry type (e.g., both handle `compaction`).
|
||||
|
||||
**What happens:**
|
||||
- Later hook (project > global) wins in the chain
|
||||
- Earlier hook's transform is overwritten
|
||||
|
||||
**Mitigation:**
|
||||
- Core entry types (`compaction`, `message`, etc.) should not be overridden by hooks
|
||||
- Hooks should use unique prefixed type names
|
||||
- Document which types are "reserved"
|
||||
|
||||
### Session with Future Entry Types
|
||||
|
||||
User downgrades pi version, session has entry types from newer version.
|
||||
|
||||
**What happens:**
|
||||
- Same as "hook removed" - unknown types ignored
|
||||
- Core handles what it knows, hooks handle what they know
|
||||
|
||||
**Session file is forward-compatible:** Unknown entries are preserved in file, just not processed.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
| Phase | Scope | LOC |
|
||||
|-------|-------|-----|
|
||||
| v2.0 | `saveEntry`, `context` event, `rebuildContext`, extended context | ~150 |
|
||||
| v2.1 | `pi.command()`, TUI integration, autocomplete | ~200 |
|
||||
| v2.2 | Example hooks, documentation | ~300 |
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `ContextMessage` type, update `buildSessionContext()` return type
|
||||
2. `saveEntry()` in session-manager
|
||||
3. `context` event in runner with chaining
|
||||
4. State callbacks interface and wiring
|
||||
5. `rebuildContext()` in agent-session
|
||||
6. Manual test with simple hook
|
||||
7. Command registration in loader
|
||||
8. Command invocation in runner
|
||||
9. TUI command handling + autocomplete
|
||||
10. Stacking example hook
|
||||
11. Pruning example hook
|
||||
12. Update hooks.md
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -36,9 +36,9 @@ Send a user prompt to the agent. Returns immediately; events stream asynchronous
|
|||
{"id": "req-1", "type": "prompt", "message": "Hello, world!"}
|
||||
```
|
||||
|
||||
With attachments:
|
||||
With images:
|
||||
```json
|
||||
{"type": "prompt", "message": "What's in this image?", "attachments": [...]}
|
||||
{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "source": {"type": "base64", "mediaType": "image/png", "data": "..."}}]}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
|
@ -46,7 +46,7 @@ Response:
|
|||
{"id": "req-1", "type": "response", "command": "prompt", "success": true}
|
||||
```
|
||||
|
||||
The `attachments` field is optional. See [Attachments](#attachments) for the schema.
|
||||
The `images` field is optional. Each image uses `ImageContent` format with base64 or URL source.
|
||||
|
||||
#### queue_message
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ Response:
|
|||
}
|
||||
```
|
||||
|
||||
Messages are `AppMessage` objects (see [Message Types](#message-types)).
|
||||
Messages are `AgentMessage` objects (see [Message Types](#message-types)).
|
||||
|
||||
### Model
|
||||
|
||||
|
|
@ -289,8 +289,10 @@ Response:
|
|||
"command": "compact",
|
||||
"success": true,
|
||||
"data": {
|
||||
"summary": "Summary of conversation...",
|
||||
"firstKeptEntryId": "abc123",
|
||||
"tokensBefore": 150000,
|
||||
"summary": "Summary of conversation..."
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -491,7 +493,7 @@ If a hook cancelled the switch:
|
|||
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from.
|
||||
|
||||
```json
|
||||
{"type": "branch", "entryIndex": 2}
|
||||
{"type": "branch", "entryId": "abc123"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
|
@ -530,8 +532,8 @@ Response:
|
|||
"success": true,
|
||||
"data": {
|
||||
"messages": [
|
||||
{"entryIndex": 0, "text": "First prompt..."},
|
||||
{"entryIndex": 2, "text": "Second prompt..."}
|
||||
{"entryId": "abc123", "text": "First prompt..."},
|
||||
{"entryId": "def456", "text": "Second prompt..."}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -618,7 +620,7 @@ A turn consists of one assistant response plus any resulting tool calls and resu
|
|||
|
||||
### message_start / message_end
|
||||
|
||||
Emitted when a message begins and completes. The `message` field contains an `AppMessage`.
|
||||
Emitted when a message begins and completes. The `message` field contains an `AgentMessage`.
|
||||
|
||||
```json
|
||||
{"type": "message_start", "message": {...}}
|
||||
|
|
@ -717,20 +719,27 @@ Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_upd
|
|||
Emitted when automatic compaction runs (when context is nearly full).
|
||||
|
||||
```json
|
||||
{"type": "auto_compaction_start"}
|
||||
{"type": "auto_compaction_start", "reason": "threshold"}
|
||||
```
|
||||
|
||||
The `reason` field is `"threshold"` (context getting large) or `"overflow"` (context exceeded limit).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auto_compaction_end",
|
||||
"result": {
|
||||
"summary": "Summary of conversation...",
|
||||
"firstKeptEntryId": "abc123",
|
||||
"tokensBefore": 150000,
|
||||
"summary": "Summary of conversation..."
|
||||
"details": {}
|
||||
},
|
||||
"aborted": false
|
||||
"aborted": false,
|
||||
"willRetry": false
|
||||
}
|
||||
```
|
||||
|
||||
If `reason` was `"overflow"` and compaction succeeds, `willRetry` is `true` and the agent will automatically retry the prompt.
|
||||
|
||||
If compaction was aborted, `result` is `null` and `aborted` is `true`.
|
||||
|
||||
### auto_retry_start / auto_retry_end
|
||||
|
|
@ -806,7 +815,7 @@ Parse errors:
|
|||
|
||||
Source files:
|
||||
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `AgentEvent`
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
|
||||
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
|
||||
- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> pi can help you use the SDK. Ask it to build an integration for your use case.
|
||||
|
||||
# SDK
|
||||
|
||||
The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.
|
||||
|
|
@ -81,26 +83,32 @@ interface AgentSession {
|
|||
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
||||
|
||||
// Session info
|
||||
sessionFile: string | null;
|
||||
sessionFile: string | undefined; // undefined for in-memory
|
||||
sessionId: string;
|
||||
|
||||
// Model control
|
||||
setModel(model: Model): Promise<void>;
|
||||
setThinkingLevel(level: ThinkingLevel): void;
|
||||
cycleModel(): Promise<ModelCycleResult | null>;
|
||||
cycleThinkingLevel(): ThinkingLevel | null;
|
||||
cycleModel(): Promise<ModelCycleResult | undefined>;
|
||||
cycleThinkingLevel(): ThinkingLevel | undefined;
|
||||
|
||||
// State access
|
||||
agent: Agent;
|
||||
model: Model | null;
|
||||
model: Model | undefined;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
messages: AppMessage[];
|
||||
messages: AgentMessage[];
|
||||
isStreaming: boolean;
|
||||
|
||||
// Session management
|
||||
reset(): Promise<void>;
|
||||
branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }>;
|
||||
switchSession(sessionPath: string): Promise<void>;
|
||||
reset(): Promise<boolean>; // Returns false if cancelled by hook
|
||||
switchSession(sessionPath: string): Promise<boolean>;
|
||||
|
||||
// Branching
|
||||
branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
|
||||
|
||||
// Hook message injection
|
||||
sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise<void>;
|
||||
|
||||
// Compaction
|
||||
compact(customInstructions?: string): Promise<CompactionResult>;
|
||||
|
|
@ -122,7 +130,7 @@ The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM inte
|
|||
// Access current state
|
||||
const state = session.agent.state;
|
||||
|
||||
// state.messages: AppMessage[] - conversation history
|
||||
// state.messages: AgentMessage[] - conversation history
|
||||
// state.model: Model - current model
|
||||
// state.thinkingLevel: ThinkingLevel - current thinking level
|
||||
// state.systemPrompt: string - system prompt
|
||||
|
|
@ -394,10 +402,10 @@ const { session } = await createAgentSession({
|
|||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createAgentSession, discoverCustomTools, type CustomAgentTool } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, discoverCustomTools, type CustomTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Inline custom tool
|
||||
const myTool: CustomAgentTool = {
|
||||
const myTool: CustomTool = {
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "Does something useful",
|
||||
|
|
@ -436,18 +444,38 @@ import { createAgentSession, discoverHooks, type HookFactory } from "@mariozechn
|
|||
|
||||
// Inline hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
// Log tool calls
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`Tool: ${event.toolName}`);
|
||||
return undefined; // Don't block
|
||||
});
|
||||
|
||||
// Block dangerous commands
|
||||
api.on("tool_call", async (event) => {
|
||||
// Block dangerous commands
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
return { block: true, reason: "Dangerous command" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Register custom slash command
|
||||
api.registerCommand("stats", {
|
||||
description: "Show session stats",
|
||||
handler: async (ctx) => {
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
ctx.ui.notify(`${entries.length} entries`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
// Inject messages
|
||||
api.sendMessage({
|
||||
customType: "my-hook",
|
||||
content: "Hook initialized",
|
||||
display: false, // Hidden from TUI
|
||||
}, false); // Don't trigger agent turn
|
||||
|
||||
// Persist hook state
|
||||
api.appendEntry("my-hook", { initialized: true });
|
||||
};
|
||||
|
||||
// Replace discovery
|
||||
|
|
@ -472,7 +500,15 @@ const { session } = await createAgentSession({
|
|||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts)
|
||||
Hook API methods:
|
||||
- `api.on(event, handler)` - Subscribe to events
|
||||
- `api.sendMessage(message, triggerTurn?)` - Inject message (creates `CustomMessageEntry`)
|
||||
- `api.appendEntry(customType, data?)` - Persist hook state (not in LLM context)
|
||||
- `api.registerCommand(name, options)` - Register custom slash command
|
||||
- `api.registerMessageRenderer(customType, renderer)` - Custom TUI rendering
|
||||
- `api.exec(command, args, options?)` - Execute shell commands
|
||||
|
||||
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts) and [docs/hooks.md](hooks.md)
|
||||
|
||||
### Skills
|
||||
|
||||
|
|
@ -560,6 +596,8 @@ const { session } = await createAgentSession({
|
|||
|
||||
### Session Management
|
||||
|
||||
Sessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
|
|
@ -597,12 +635,32 @@ const customDir = "/path/to/my-sessions";
|
|||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.create(process.cwd(), customDir),
|
||||
});
|
||||
// Also works with list and continueRecent:
|
||||
// SessionManager.list(process.cwd(), customDir);
|
||||
// SessionManager.continueRecent(process.cwd(), customDir);
|
||||
```
|
||||
|
||||
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts)
|
||||
**SessionManager tree API:**
|
||||
|
||||
```typescript
|
||||
const sm = SessionManager.open("/path/to/session.jsonl");
|
||||
|
||||
// Tree traversal
|
||||
const entries = sm.getEntries(); // All entries (excludes header)
|
||||
const tree = sm.getTree(); // Full tree structure
|
||||
const path = sm.getPath(); // Path from root to current leaf
|
||||
const leaf = sm.getLeafEntry(); // Current leaf entry
|
||||
const entry = sm.getEntry(id); // Get entry by ID
|
||||
const children = sm.getChildren(id); // Direct children of entry
|
||||
|
||||
// Labels
|
||||
const label = sm.getLabel(id); // Get label for entry
|
||||
sm.appendLabelChange(id, "checkpoint"); // Set label
|
||||
|
||||
// Branching
|
||||
sm.branch(entryId); // Move leaf to earlier entry
|
||||
sm.branchWithSummary(id, "Summary..."); // Branch with context summary
|
||||
sm.createBranchedSession(leafId); // Extract path to new file
|
||||
```
|
||||
|
||||
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts) and [docs/session.md](session.md)
|
||||
|
||||
### Settings Management
|
||||
|
||||
|
|
@ -737,7 +795,7 @@ import {
|
|||
readTool,
|
||||
bashTool,
|
||||
type HookFactory,
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Set up auth storage (custom location)
|
||||
|
|
@ -760,7 +818,7 @@ const auditHook: HookFactory = (api) => {
|
|||
};
|
||||
|
||||
// Inline tool
|
||||
const statusTool: CustomAgentTool = {
|
||||
const statusTool: CustomTool = {
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: "Get system status",
|
||||
|
|
@ -876,7 +934,7 @@ createGrepTool, createFindTool, createLsTool
|
|||
// Types
|
||||
type CreateAgentSessionOptions
|
||||
type CreateAgentSessionResult
|
||||
type CustomAgentTool
|
||||
type CustomTool
|
||||
type HookFactory
|
||||
type Skill
|
||||
type FileSlashCommand
|
||||
|
|
@ -888,7 +946,21 @@ type Tool
|
|||
For hook types, import from the hooks subpath:
|
||||
|
||||
```typescript
|
||||
import type { HookAPI, HookEvent, ToolCallEvent } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type {
|
||||
HookAPI,
|
||||
HookMessage,
|
||||
HookFactory,
|
||||
HookEventContext,
|
||||
HookCommandContext,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
} from "@mariozechner/pi-coding-agent/hooks";
|
||||
```
|
||||
|
||||
For message utilities:
|
||||
|
||||
```typescript
|
||||
import { isHookMessage, createHookMessage } from "@mariozechner/pi-coding-agent";
|
||||
```
|
||||
|
||||
For config utilities:
|
||||
|
|
|
|||
441
packages/coding-agent/docs/session-tree-plan.md
Normal file
441
packages/coding-agent/docs/session-tree-plan.md
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
# Session Tree Implementation Plan
|
||||
|
||||
Reference: [session-tree.md](./session-tree.md)
|
||||
|
||||
## Phase 1: SessionManager Core ✅
|
||||
|
||||
- [x] Update entry types with `id`, `parentId` fields (using SessionEntryBase)
|
||||
- [x] Add `version` field to `SessionHeader`
|
||||
- [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId`
|
||||
- [x] Add `BranchSummaryEntry` type
|
||||
- [x] Add `CustomEntry` type for hooks
|
||||
- [x] Add `byId: Map<string, SessionEntry>` index
|
||||
- [x] Add `leafId: string` tracking
|
||||
- [x] Implement `getPath(fromId?)` tree traversal
|
||||
- [x] Implement `getTree()` returning `SessionTreeNode[]`
|
||||
- [x] Implement `getEntry(id)` lookup
|
||||
- [x] Implement `getLeafUuid()` and `getLeafEntry()` helpers
|
||||
- [x] Update `_buildIndex()` to populate `byId` map
|
||||
- [x] Rename `saveXXX()` to `appendXXX()` (returns id, advances leaf)
|
||||
- [x] Add `appendCustomEntry(customType, data)` for hooks
|
||||
- [x] Update `buildSessionContext()` to use `getPath()` traversal
|
||||
|
||||
## Phase 2: Migration ✅
|
||||
|
||||
- [x] Add `CURRENT_SESSION_VERSION = 2` constant
|
||||
- [x] Implement `migrateV1ToV2()` with extensible migration chain
|
||||
- [x] Update `setSessionFile()` to detect version and migrate
|
||||
- [x] Implement `_rewriteFile()` for post-migration persistence
|
||||
- [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration
|
||||
|
||||
## Phase 3: Branching ✅
|
||||
|
||||
- [x] Implement `branch(id)` - switch leaf pointer
|
||||
- [x] Implement `branchWithSummary(id, summary)` - create summary entry
|
||||
- [x] Implement `createBranchedSession(leafId)` - extract path to new file
|
||||
- [x] Update `AgentSession.branch()` to use new API
|
||||
|
||||
## Phase 4: Compaction Integration ✅
|
||||
|
||||
- [x] Update `compaction.ts` to work with IDs
|
||||
- [x] Update `prepareCompaction()` to return `firstKeptEntryId`
|
||||
- [x] Update `compact()` to return `CompactionResult` with `firstKeptEntryId`
|
||||
- [x] Update `AgentSession` compaction methods
|
||||
- [x] Add `firstKeptEntryId` to `before_compact` hook event
|
||||
|
||||
## Phase 5: Testing ✅
|
||||
|
||||
- [x] `migration.test.ts` - v1 to v2 migration, idempotency
|
||||
- [x] `build-context.test.ts` - context building with tree structure, compaction, branches
|
||||
- [x] `tree-traversal.test.ts` - append operations, getPath, getTree, branching
|
||||
- [x] `file-operations.test.ts` - loadEntriesFromFile, findMostRecentSession
|
||||
- [x] `save-entry.test.ts` - custom entry integration
|
||||
- [x] Update existing compaction tests for new types
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Compaction Refactor
|
||||
|
||||
- [x] Use `CompactionResult` type for hook return value
|
||||
- [x] Make `CompactionEntry<T>` generic with optional `details?: T` field for hook-specific data
|
||||
- [x] Make `CompactionResult<T>` generic to match
|
||||
- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields
|
||||
- [x] Update `before_compact` event:
|
||||
- Pass `preparation: CompactionPreparation` instead of individual fields
|
||||
- Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string`
|
||||
- Keep: `customInstructions`, `model`, `signal`
|
||||
- Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries`
|
||||
- [x] Update hook example `custom-compaction.ts` to use new API
|
||||
- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions
|
||||
- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile`
|
||||
|
||||
Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers.
|
||||
|
||||
### Branch Summary Design ✅
|
||||
|
||||
Current type:
|
||||
```typescript
|
||||
export interface BranchSummaryEntry extends SessionEntryBase {
|
||||
type: "branch_summary";
|
||||
summary: string;
|
||||
fromId: string; // References the abandoned leaf
|
||||
fromHook?: boolean; // Whether summary was generated by a hook
|
||||
details?: unknown; // File tracking: { readFiles, modifiedFiles }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] `fromId` field references the abandoned leaf
|
||||
- [x] `fromHook` field distinguishes pi-generated vs hook-generated summaries
|
||||
- [x] `details` field for file tracking
|
||||
- [x] Branch summarizer implemented with structured output format
|
||||
- [x] Uses serialization approach (same as compaction) to prevent model confusion
|
||||
- [x] Tests for `branchWithSummary()` flow
|
||||
|
||||
### Entry Labels ✅
|
||||
|
||||
- [x] Add `LabelEntry` type with `targetId` and `label` fields
|
||||
- [x] Add `labelsById: Map<string, string>` private field
|
||||
- [x] Build labels map in `_buildIndex()` via linear scan
|
||||
- [x] Add `getLabel(id)` method
|
||||
- [x] Add `appendLabelChange(targetId, label)` method (undefined clears)
|
||||
- [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map
|
||||
- [x] `buildSessionContext()` already ignores LabelEntry (only handles message types)
|
||||
- [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()`
|
||||
- [x] Display labels in UI (tree-selector shows labels)
|
||||
- [x] `/label` command (implemented in tree-selector)
|
||||
|
||||
### CustomMessageEntry<T>
|
||||
|
||||
Hook-injected messages that participate in LLM context. Unlike `CustomEntry<T>` (for hook state only), these are sent to the model.
|
||||
|
||||
```typescript
|
||||
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
||||
type: "custom_message";
|
||||
customType: string; // Hook identifier
|
||||
content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage)
|
||||
details?: T; // Hook-specific data for state reconstruction on reload
|
||||
display: boolean; // Whether to display in TUI
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- [x] Type definition matching plan
|
||||
- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager
|
||||
- [x] `buildSessionContext()` includes custom_message entries as user messages
|
||||
- [x] Exported from main index
|
||||
- [x] TUI rendering:
|
||||
- `display: false` - hidden entirely
|
||||
- `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
|
||||
- [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers
|
||||
- [x] Renderer returns inner Component, TUI wraps in styled Box
|
||||
|
||||
### Hook API Changes ✅
|
||||
|
||||
**Renamed:**
|
||||
- `renderCustomMessage()` → `registerCustomMessageRenderer()`
|
||||
|
||||
**New: `sendMessage()` ✅**
|
||||
|
||||
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
|
||||
|
||||
```typescript
|
||||
type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'content' | 'display' | 'details'>;
|
||||
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Uses agent's queue mechanism with `_hookData` marker on AppMessage
|
||||
- `message_end` handler routes based on marker presence
|
||||
- `AgentSession.sendHookMessage()` handles three cases:
|
||||
- Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end`
|
||||
- Not streaming + triggerTurn: direct append + `agent.continue()`
|
||||
- Not streaming + no trigger: direct append only
|
||||
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
|
||||
|
||||
**New: `appendEntry()` ✅**
|
||||
|
||||
For hook state persistence (NOT in LLM context):
|
||||
|
||||
```typescript
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
```
|
||||
|
||||
Calls `sessionManager.appendCustomEntry()` directly.
|
||||
|
||||
**New: `registerCommand()` (types ✅, wiring TODO)**
|
||||
|
||||
```typescript
|
||||
// HookAPI (the `pi` object) - utilities available to all hooks:
|
||||
interface HookAPI {
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
registerCommand(name: string, options: RegisteredCommand): void;
|
||||
registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void;
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
}
|
||||
|
||||
// HookEventContext - passed to event handlers, has stable context:
|
||||
interface HookEventContext {
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec moved to HookAPI, sessionManager/modelRegistry moved from SessionEventBase
|
||||
|
||||
// HookCommandContext - passed to command handlers:
|
||||
interface HookCommandContext {
|
||||
args: string; // Everything after /commandname
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec and sendMessage accessed via `pi` closure
|
||||
|
||||
registerCommand(name: string, options: {
|
||||
description?: string;
|
||||
handler: (ctx: HookCommandContext) => Promise<void>;
|
||||
}): void;
|
||||
```
|
||||
|
||||
Handler return:
|
||||
- `void` - command completed (use `sendMessage()` with `triggerTurn: true` to prompt LLM)
|
||||
|
||||
Wiring (all in AgentSession.prompt()):
|
||||
- [x] Add hook commands to autocomplete in interactive-mode
|
||||
- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution
|
||||
- [x] Build HookCommandContext with ui (from hookRunner), exec, sessionManager, etc.
|
||||
- [x] If handler returns string, use as prompt text
|
||||
- [x] If handler returns undefined, return early (no LLM call)
|
||||
- [x] Works for all modes (interactive, RPC, print) via shared AgentSession
|
||||
|
||||
**New: `ui.custom()` ✅**
|
||||
|
||||
For arbitrary hook UI with keyboard focus:
|
||||
|
||||
```typescript
|
||||
interface HookUIContext {
|
||||
// ... existing: select, confirm, input, notify
|
||||
|
||||
/** Show custom component with keyboard focus. Call done() when finished. */
|
||||
custom(component: Component, done: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.
|
||||
|
||||
**New: `context` event ✅**
|
||||
|
||||
Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively.
|
||||
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
type: "context";
|
||||
/** Messages that will be sent to the LLM */
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface ContextEventResult {
|
||||
/** Modified messages to send instead */
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
// In HookAPI:
|
||||
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult | void>): void;
|
||||
```
|
||||
|
||||
Example use case: **Dynamic Context Pruning** ([discussion #330](https://github.com/badlogic/pi-mono/discussions/330))
|
||||
|
||||
Non-destructive pruning of tool results to reduce context size:
|
||||
|
||||
```typescript
|
||||
export default function(pi: HookAPI) {
|
||||
// Register /prune command
|
||||
pi.registerCommand("prune", {
|
||||
description: "Mark tool results for pruning",
|
||||
handler: async (ctx) => {
|
||||
// Show UI to select which tool results to prune
|
||||
// Append custom entry recording pruning decisions:
|
||||
// { toolResultId, strategy: "summary" | "truncate" | "remove" }
|
||||
pi.appendEntry("tool-result-pruning", { ... });
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept context before LLM call
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// Find all pruning entries in session
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const pruningRules = entries
|
||||
.filter(e => e.type === "custom" && e.customType === "tool-result-pruning")
|
||||
.map(e => e.data);
|
||||
|
||||
// Apply pruning rules to messages
|
||||
const prunedMessages = applyPruning(event.messages, pruningRules);
|
||||
return { messages: prunedMessages };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Original tool results stay intact in session
|
||||
- Pruning is stored as custom entries, survives session reload
|
||||
- Works with branching (pruning entries are part of the tree)
|
||||
- Trade-off: cache busting on first submission after pruning
|
||||
|
||||
### Investigate: `context` event vs `before_agent_start` ✅
|
||||
|
||||
References:
|
||||
- [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal
|
||||
- [#330](https://github.com/badlogic/pi-mono/discussions/330) - Dynamic Context Pruning (why `context` was added)
|
||||
|
||||
**Current `context` event:**
|
||||
- Fires before each LLM call within the agent loop
|
||||
- Receives `AgentMessage[]` (deep copy, safe to modify)
|
||||
- Returns `Message[]` (inconsistent with input type)
|
||||
- Modifications are transient (not persisted to session)
|
||||
- No TUI visibility of what was changed
|
||||
- Use case: non-destructive pruning, dynamic context manipulation
|
||||
|
||||
**Type inconsistency:** Event receives `AgentMessage[]` but result returns `Message[]`:
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
messages: AgentMessage[]; // Input
|
||||
}
|
||||
interface ContextEventResult {
|
||||
messages?: Message[]; // Output - different type!
|
||||
}
|
||||
```
|
||||
|
||||
Questions:
|
||||
- [ ] Should input/output both be `Message[]` (LLM format)?
|
||||
- [ ] Or both be `AgentMessage[]` with conversion happening after?
|
||||
- [ ] Where does `AgentMessage[]` → `Message[]` conversion currently happen?
|
||||
|
||||
**Proposed `before_agent_start` event:**
|
||||
- Fires once when user submits a prompt, before `agent_start`
|
||||
- Allows hooks to inject additional content that gets **persisted** to session
|
||||
- Injected content is visible in TUI (observability)
|
||||
- Does not bust prompt cache (appended after user message, not modifying system prompt)
|
||||
|
||||
**Key difference:**
|
||||
| Aspect | `context` | `before_agent_start` |
|
||||
|--------|-----------|---------------------|
|
||||
| When | Before each LLM call | Once per user prompt |
|
||||
| Persisted | No | Yes (as SystemMessage) |
|
||||
| TUI visible | No | Yes (collapsible) |
|
||||
| Cache impact | Can bust cache | Append-only, cache-safe |
|
||||
| Use case | Transient manipulation | Persistent context injection |
|
||||
|
||||
**Implementation (completed):**
|
||||
- Reuses `HookMessage` type (no new message type needed)
|
||||
- Handler returns `{ message: Pick<HookMessage, "customType" | "content" | "display" | "details"> }`
|
||||
- Message is appended to agent state AND persisted to session before `agent.prompt()` is called
|
||||
- Renders using existing `HookMessageComponent` (or custom renderer if registered)
|
||||
- [ ] How does it interact with compaction? (treated like user messages?)
|
||||
- [ ] Can hook return multiple messages or just one?
|
||||
|
||||
**Implementation sketch:**
|
||||
```typescript
|
||||
interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
userMessage: UserMessage; // The prompt user just submitted
|
||||
}
|
||||
|
||||
interface BeforeAgentStartResult {
|
||||
/** Additional context to inject (persisted as SystemMessage) */
|
||||
inject?: {
|
||||
label: string; // Shown in collapsed TUI state
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Export
|
||||
|
||||
- [ ] Add collapsible sidebar showing full tree structure
|
||||
- [ ] Allow selecting any node in tree to view that path
|
||||
- [ ] Add "reset to session leaf" button
|
||||
- [ ] Render full path (no compaction resolution needed)
|
||||
- [ ] Responsive: collapse sidebar on mobile
|
||||
|
||||
### UI Commands ✅
|
||||
|
||||
- [x] `/branch` - Creates new session file from current path (uses `createBranchedSession()`)
|
||||
- [x] `/tree` - In-session tree navigation via tree-selector component
|
||||
- Shows full tree structure with labels
|
||||
- Navigate between branches (moves leaf pointer)
|
||||
- Shows current position
|
||||
- Generates branch summaries when switching branches
|
||||
|
||||
### Tree Selector Improvements ✅
|
||||
|
||||
- [x] Active line highlight using `selectedBg` theme color
|
||||
- [x] Filter modes via `^O` (forward) / `Shift+^O` (backward):
|
||||
- `default`: hides label/custom entries
|
||||
- `no-tools`: default minus tool results
|
||||
- `user-only`: just user messages
|
||||
- `labeled-only`: just labeled entries
|
||||
- `all`: everything
|
||||
|
||||
### Documentation
|
||||
|
||||
Review and update all docs:
|
||||
|
||||
- [ ] `docs/hooks.md` - Major update for hook API:
|
||||
- `pi.send()` → `pi.sendMessage()` with new signature
|
||||
- New `pi.appendEntry()` for state persistence
|
||||
- New `pi.registerCommand()` for custom slash commands
|
||||
- New `pi.registerCustomMessageRenderer()` for custom TUI rendering
|
||||
- `HookCommandContext` interface and handler patterns
|
||||
- `HookMessage<T>` type
|
||||
- Updated event signatures (`SessionEventBase`, `before_compact`, etc.)
|
||||
- [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete
|
||||
- [ ] `docs/sdk.md` - Update for:
|
||||
- `HookMessage` and `isHookMessage()`
|
||||
- `Agent.prompt(AppMessage)` overload
|
||||
- Session v2 tree structure
|
||||
- SessionManager API changes
|
||||
- [ ] `docs/session.md` - Update for v2 tree structure, new entry types
|
||||
- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools
|
||||
- [ ] `docs/rpc.md` - Check if hook commands work in RPC mode
|
||||
- [ ] `docs/skills.md` - Review for any hook-related updates
|
||||
- [ ] `docs/extension-loading.md` - Review
|
||||
- [x] `docs/theme.md` - Added selectedBg, customMessageBg/Text/Label color tokens (50 total)
|
||||
- [ ] `README.md` - Update hook examples if any
|
||||
|
||||
### Examples
|
||||
|
||||
Review and update examples:
|
||||
|
||||
- [ ] `examples/hooks/` - Update existing, add new examples:
|
||||
- [ ] Review `custom-compaction.ts` for new API
|
||||
- [ ] Add `registerCommand()` example
|
||||
- [ ] Add `sendMessage()` example
|
||||
- [ ] Add `registerCustomMessageRenderer()` example
|
||||
- [ ] `examples/sdk/` - Update for new session/hook APIs
|
||||
- [ ] `examples/custom-tools/` - Review for compatibility
|
||||
|
||||
---
|
||||
|
||||
## Before Release
|
||||
|
||||
- [ ] Run full automated test suite: `npm test`
|
||||
- [ ] Manual testing of tree navigation and branch summarization
|
||||
- [ ] Verify compaction with file tracking works correctly
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All append methods return the new entry's ID
|
||||
- Migration rewrites file on first load if version < CURRENT_VERSION
|
||||
- Existing sessions become linear chains after migration (parentId = previous entry)
|
||||
- Tree features available immediately after migration
|
||||
- SessionHeader does NOT have id/parentId (it's metadata, not part of tree)
|
||||
- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
# Session Tree Format
|
||||
|
||||
Analysis of switching from linear JSONL to tree-based session storage.
|
||||
|
||||
## Current Format (Linear)
|
||||
|
||||
```jsonl
|
||||
{"type":"session","id":"...","timestamp":"...","cwd":"..."}
|
||||
{"type":"message","timestamp":"...","message":{"role":"user",...}}
|
||||
{"type":"message","timestamp":"...","message":{"role":"assistant",...}}
|
||||
{"type":"compaction","timestamp":"...","summary":"...","firstKeptEntryIndex":2,"tokensBefore":50000}
|
||||
{"type":"message","timestamp":"...","message":{"role":"user",...}}
|
||||
```
|
||||
|
||||
Context is built by scanning linearly, applying compaction ranges.
|
||||
|
||||
## Proposed Format (Tree)
|
||||
|
||||
Each entry has a `uuid` and `parentUuid` field (null for root). Session header includes `version` for future migrations:
|
||||
|
||||
```jsonl
|
||||
{"type":"session","version":2,"uuid":"a1b2c3","parentUuid":null,"id":"...","cwd":"..."}
|
||||
{"type":"message","uuid":"d4e5f6","parentUuid":"a1b2c3","message":{"role":"user",...}}
|
||||
{"type":"message","uuid":"g7h8i9","parentUuid":"d4e5f6","message":{"role":"assistant",...}}
|
||||
{"type":"message","uuid":"j0k1l2","parentUuid":"g7h8i9","message":{"role":"user",...}}
|
||||
{"type":"message","uuid":"m3n4o5","parentUuid":"j0k1l2","message":{"role":"assistant",...}}
|
||||
```
|
||||
|
||||
Version history:
|
||||
- **v1** (implicit): Linear format, no uuid/parentUuid
|
||||
- **v2**: Tree format with uuid/parentUuid
|
||||
|
||||
The **last entry** is always the current leaf. Context = walk from leaf to root via `parentUuid`.
|
||||
|
||||
Using UUIDs (like Claude Code does) instead of indices because:
|
||||
- No remapping needed when branching to new file
|
||||
- Robust to entry deletion/reordering
|
||||
- Orphan references are detectable
|
||||
- ~30 extra bytes per entry is negligible for text-heavy sessions
|
||||
|
||||
### Branching
|
||||
|
||||
Branch from entry `g7h8i9` (after first assistant response):
|
||||
|
||||
```jsonl
|
||||
... entries unchanged ...
|
||||
{"type":"message","uuid":"p6q7r8","parentUuid":"g7h8i9","message":{"role":"user",...}}
|
||||
{"type":"message","uuid":"s9t0u1","parentUuid":"p6q7r8","message":{"role":"assistant",...}}
|
||||
```
|
||||
|
||||
Walking s9t0u1→p6q7r8→g7h8i9→d4e5f6→a1b2c3 gives the branched context.
|
||||
|
||||
The old path (j0k1l2, m3n4o5) remains in the file but is not in the current context.
|
||||
|
||||
### Visual
|
||||
|
||||
```
|
||||
[a1b2:session]
|
||||
│
|
||||
[d4e5:user "hello"]
|
||||
│
|
||||
[g7h8:assistant "hi"]
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
[j0k1:user A] [p6q7:user B] ← branch point
|
||||
│ │
|
||||
[m3n4:asst A] [s9t0:asst B] ← current leaf
|
||||
│
|
||||
(old path)
|
||||
```
|
||||
|
||||
## Context Building
|
||||
|
||||
```typescript
|
||||
function buildContext(entries: SessionEntry[]): AppMessage[] {
|
||||
// Build UUID -> entry map
|
||||
const byUuid = new Map(entries.map(e => [e.uuid, e]));
|
||||
|
||||
// Start from last entry (current leaf)
|
||||
let current: SessionEntry | undefined = entries[entries.length - 1];
|
||||
|
||||
// Walk to root, collecting messages
|
||||
const path: SessionEntry[] = [];
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentUuid ? byUuid.get(current.parentUuid) : undefined;
|
||||
}
|
||||
|
||||
// Extract messages, apply compaction summaries
|
||||
return pathToMessages(path);
|
||||
}
|
||||
```
|
||||
|
||||
Complexity: O(n) to build map, O(depth) to walk. Total O(n), but walk is fast.
|
||||
|
||||
## Consequences for Stacking
|
||||
|
||||
### Current Approach (hooks-v2.md)
|
||||
|
||||
Stacking uses `stack_pop` entries with complex range overlap rules:
|
||||
|
||||
```typescript
|
||||
interface StackPopEntry {
|
||||
type: "stack_pop";
|
||||
backToIndex: number;
|
||||
summary: string;
|
||||
prePopSummary?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Context building requires tracking ranges, IDs, "later wins" logic.
|
||||
|
||||
### Tree Approach
|
||||
|
||||
Stacking becomes trivial branching:
|
||||
|
||||
```jsonl
|
||||
... conversation entries ...
|
||||
{"type":"stack_summary","uuid":"x1y2z3","parentUuid":"g7h8i9","summary":"Work done after this point"}
|
||||
```
|
||||
|
||||
To "pop" to entry `g7h8i9`:
|
||||
1. Generate summary of entries after `g7h8i9`
|
||||
2. Append summary entry with `parentUuid: "g7h8i9"`
|
||||
|
||||
Context walk follows parentUuid chain. Abandoned entries are not traversed.
|
||||
|
||||
**No range tracking. No overlap rules. No "later wins" logic.**
|
||||
|
||||
### Multiple Pops
|
||||
|
||||
```
|
||||
[a]─[b]─[c]─[d]─[e]─[f]─[g]─[h]
|
||||
│
|
||||
└─[i:summary]─[j]─[k]─[l]
|
||||
│
|
||||
└─[m:summary]─[n:current]
|
||||
```
|
||||
|
||||
Each pop just creates a new branch. Context: n→m→k→j→i→c→b→a.
|
||||
|
||||
## Consequences for Compaction
|
||||
|
||||
### Current Approach
|
||||
|
||||
Compaction stores `firstKeptEntryIndex` (an index) and requires careful handling when stacking crosses compaction boundaries.
|
||||
|
||||
### Tree Approach
|
||||
|
||||
Compaction is just another entry in the linear chain, not a branch. Only change: `firstKeptEntryIndex` → `firstKeptEntryUuid`.
|
||||
|
||||
```
|
||||
root → m1 → m2 → m3 → m4 → m5 → m6 → m7 → m8 → m9 → m10 → compaction
|
||||
```
|
||||
|
||||
```jsonl
|
||||
{"type":"compaction","uuid":"c1","parentUuid":"m10","summary":"...","firstKeptEntryUuid":"m6","tokensBefore":50000}
|
||||
```
|
||||
|
||||
Context building:
|
||||
1. Walk from leaf (compaction) to root
|
||||
2. See compaction entry → note `firstKeptEntryUuid: "m6"`
|
||||
3. Continue walking: m10, m9, m8, m7, m6 ← stop here
|
||||
4. Everything before m6 is replaced by summary
|
||||
5. Result: `[summary, m6, m7, m8, m9, m10]`
|
||||
|
||||
**Tree is for branching (stacking, alternative paths). Compaction is just a marker in the linear chain.**
|
||||
|
||||
### Compaction + Stacking
|
||||
|
||||
Stacking creates a branch, compaction is inline on each branch:
|
||||
|
||||
```
|
||||
[root]─[m1]─[m2]─[m3]─[m4]─[m5]─[compaction1]─[m6]─[m7]─[m8]
|
||||
│
|
||||
└─[stack_summary]─[m9]─[m10]─[compaction2]─[m11:current]
|
||||
```
|
||||
|
||||
Each branch has its own compaction history. Context walks the current branch only.
|
||||
|
||||
## Consequences for API
|
||||
|
||||
### SessionManager Changes
|
||||
|
||||
```typescript
|
||||
interface SessionEntry {
|
||||
type: string;
|
||||
uuid: string; // NEW: unique identifier
|
||||
parentUuid: string | null; // NEW: null for root
|
||||
timestamp?: string;
|
||||
// ... type-specific fields
|
||||
}
|
||||
|
||||
class SessionManager {
|
||||
// NEW: Get current leaf entry
|
||||
getCurrentLeaf(): SessionEntry;
|
||||
|
||||
// NEW: Walk from entry to root
|
||||
getPath(fromUuid?: string): SessionEntry[];
|
||||
|
||||
// NEW: Get entry by UUID
|
||||
getEntry(uuid: string): SessionEntry | undefined;
|
||||
|
||||
// CHANGED: Uses tree walk instead of linear scan
|
||||
buildSessionContext(): SessionContext;
|
||||
|
||||
// NEW: Create branch point
|
||||
branch(parentUuid: string): string; // returns new entry's uuid
|
||||
|
||||
// NEW: Create branch with summary of abandoned subtree
|
||||
branchWithSummary(parentUuid: string, summary: string): string;
|
||||
|
||||
// CHANGED: Simpler, just creates summary node
|
||||
saveCompaction(entry: CompactionEntry): void;
|
||||
|
||||
// CHANGED: Now requires parentUuid (uses current leaf if omitted)
|
||||
saveMessage(message: AppMessage, parentUuid?: string): void;
|
||||
saveEntry(entry: SessionEntry): void;
|
||||
}
|
||||
```
|
||||
|
||||
### AgentSession Changes
|
||||
|
||||
```typescript
|
||||
class AgentSession {
|
||||
// CHANGED: Uses tree-based branching
|
||||
async branch(entryUuid: string): Promise<BranchResult>;
|
||||
|
||||
// NEW: Branch in current session (no new file)
|
||||
async branchInPlace(entryUuid: string, options?: {
|
||||
summarize?: boolean; // Generate summary of abandoned subtree
|
||||
}): Promise<void>;
|
||||
|
||||
// NEW: Get tree structure for visualization
|
||||
getSessionTree(): SessionTree;
|
||||
|
||||
// CHANGED: Simpler implementation
|
||||
async compact(): Promise<CompactionResult>;
|
||||
}
|
||||
|
||||
interface BranchResult {
|
||||
selectedText: string;
|
||||
cancelled: boolean;
|
||||
newSessionFile?: string; // If branching to new file
|
||||
inPlace: boolean; // If branched in current file
|
||||
}
|
||||
```
|
||||
|
||||
### Hook API Changes
|
||||
|
||||
```typescript
|
||||
interface HookEventContext {
|
||||
// NEW: Tree-aware entry access
|
||||
entries: readonly SessionEntry[];
|
||||
currentPath: readonly SessionEntry[]; // Entries from root to current leaf
|
||||
|
||||
// NEW: Branch without creating new file
|
||||
branchInPlace(parentUuid: string, summary?: string): Promise<void>;
|
||||
|
||||
// Existing
|
||||
saveEntry(entry: SessionEntry): Promise<void>;
|
||||
rebuildContext(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## New Features Enabled
|
||||
|
||||
### 1. In-Place Branching
|
||||
|
||||
Currently, `/branch` always creates a new session file. With tree format:
|
||||
|
||||
```
|
||||
/branch → Create new session file (current behavior)
|
||||
/branch-here → Branch in current file, optionally with summary
|
||||
```
|
||||
|
||||
Use case: Quick "let me try something else" without file proliferation.
|
||||
|
||||
### 2. Branch History Navigation
|
||||
|
||||
```
|
||||
/branches → List all branches in current session
|
||||
/switch <uuid> → Switch to branch at entry
|
||||
```
|
||||
|
||||
The session file contains full history. UI can visualize the tree.
|
||||
|
||||
### 3. Simpler Stacking
|
||||
|
||||
No hooks needed for basic stacking:
|
||||
|
||||
```
|
||||
/pop → Branch to previous user message with auto-summary
|
||||
/pop <uuid> → Branch to specific entry with auto-summary
|
||||
```
|
||||
|
||||
Core functionality, not hook-dependent.
|
||||
|
||||
### 4. Subtree Export
|
||||
|
||||
```
|
||||
/export-branch <uuid> → Export just the subtree from entry
|
||||
```
|
||||
|
||||
Useful for sharing specific conversation paths. No index remapping needed since UUIDs are stable.
|
||||
|
||||
### 5. Merge/Cherry-pick (Future)
|
||||
|
||||
With tree structure, could support:
|
||||
|
||||
```
|
||||
/cherry-pick <uuid> → Copy entry's message to current branch
|
||||
/merge <uuid> → Merge branch into current
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
### Strategy: Migrate on Load + Rewrite
|
||||
|
||||
When loading a session, check if migration is needed. If so, migrate in memory and rewrite the file. This is transparent to users and only happens once per session file.
|
||||
|
||||
```typescript
|
||||
const CURRENT_VERSION = 2;
|
||||
|
||||
function loadSession(path: string): SessionEntry[] {
|
||||
const content = readFileSync(path, 'utf8');
|
||||
const entries = parseEntries(content);
|
||||
|
||||
const header = entries.find(e => e.type === 'session');
|
||||
const version = header?.version ?? 1;
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
migrateEntries(entries, version);
|
||||
writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function migrateEntries(entries: SessionEntry[], fromVersion: number): void {
|
||||
if (fromVersion < 2) {
|
||||
// v1 → v2: Add uuid/parentUuid, convert firstKeptEntryIndex
|
||||
const uuids: string[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const uuid = generateUuid();
|
||||
uuids.push(uuid);
|
||||
|
||||
entry.uuid = uuid;
|
||||
entry.parentUuid = i === 0 ? null : uuids[i - 1];
|
||||
|
||||
// Update session header version
|
||||
if (entry.type === 'session') {
|
||||
entry.version = CURRENT_VERSION;
|
||||
}
|
||||
|
||||
// Convert compaction index to UUID
|
||||
if (entry.type === 'compaction' && 'firstKeptEntryIndex' in entry) {
|
||||
entry.firstKeptEntryUuid = uuids[entry.firstKeptEntryIndex];
|
||||
delete entry.firstKeptEntryIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Future migrations: if (fromVersion < 3) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### What Gets Migrated
|
||||
|
||||
| v1 Field | v2 Field |
|
||||
|----------|----------|
|
||||
| (none) | `uuid` (generated) |
|
||||
| (none) | `parentUuid` (previous entry's uuid, null for root) |
|
||||
| (none on session) | `version: 2` |
|
||||
| `firstKeptEntryIndex` | `firstKeptEntryUuid` |
|
||||
|
||||
Migrated sessions work exactly as before (linear path). Tree features become available.
|
||||
|
||||
### API Compatibility
|
||||
|
||||
- `buildSessionContext()` returns same structure
|
||||
- `branch()` still works, just uses UUIDs
|
||||
- Existing hooks continue to work
|
||||
- Old sessions auto-migrate on first load
|
||||
|
||||
## Complexity Analysis
|
||||
|
||||
| Operation | Linear | Tree |
|
||||
|-----------|--------|------|
|
||||
| Append message | O(1) | O(1) |
|
||||
| Build context | O(n) | O(n) map + O(depth) walk |
|
||||
| Branch to new file | O(n) copy | O(path) copy, no remapping |
|
||||
| Find entry by UUID | O(n) | O(1) with map |
|
||||
| Compaction | O(n) | O(depth) |
|
||||
|
||||
Tree with UUIDs is comparable or better. The UUID map can be cached.
|
||||
|
||||
## File Size
|
||||
|
||||
Tree format adds ~50 bytes per entry (`"uuid":"...","parentUuid":"..."`, 36 chars each). For 1000-entry session: ~50KB overhead. Negligible for text-heavy sessions.
|
||||
|
||||
Abandoned branches remain in file but don't affect context building performance.
|
||||
|
||||
## Example: Full Session with Branching
|
||||
|
||||
```jsonl
|
||||
{"type":"session","version":2,"uuid":"ses1","parentUuid":null,"id":"abc","cwd":"/project"}
|
||||
{"type":"message","uuid":"m1","parentUuid":"ses1","message":{"role":"user","content":"Build a CLI"}}
|
||||
{"type":"message","uuid":"m2","parentUuid":"m1","message":{"role":"assistant","content":"I'll create..."}}
|
||||
{"type":"message","uuid":"m3","parentUuid":"m2","message":{"role":"user","content":"Add --verbose flag"}}
|
||||
{"type":"message","uuid":"m4","parentUuid":"m3","message":{"role":"assistant","content":"Here's the flag..."}}
|
||||
{"type":"message","uuid":"m5","parentUuid":"m4","message":{"role":"user","content":"Actually use Python"}}
|
||||
{"type":"message","uuid":"m6","parentUuid":"m5","message":{"role":"assistant","content":"Converting to Python..."}}
|
||||
{"type":"branch_summary","uuid":"bs1","parentUuid":"m2","summary":"Attempted Node.js CLI with --verbose flag"}
|
||||
{"type":"message","uuid":"m7","parentUuid":"bs1","message":{"role":"user","content":"Use Rust instead"}}
|
||||
{"type":"message","uuid":"m8","parentUuid":"m7","message":{"role":"assistant","content":"Creating Rust CLI..."}}
|
||||
```
|
||||
|
||||
Context path: m8→m7→bs1→m2→m1→ses1
|
||||
|
||||
Result:
|
||||
1. User: "Build a CLI"
|
||||
2. Assistant: "I'll create..."
|
||||
3. Summary: "Attempted Node.js CLI with --verbose flag"
|
||||
4. User: "Use Rust instead"
|
||||
5. Assistant: "Creating Rust CLI..."
|
||||
|
||||
Entries m3-m6 (the Node.js/Python path) are preserved but not in context.
|
||||
|
||||
## Prior Art
|
||||
|
||||
Claude Code uses the same approach:
|
||||
- `uuid` field on each entry
|
||||
- `parentUuid` links to parent (null for root)
|
||||
- `leafUuid` in summary entries to track conversation endpoints
|
||||
- Separate files for sidechains (`isSidechain: true`)
|
||||
|
||||
## Recommendation
|
||||
|
||||
The tree format with UUIDs:
|
||||
- Simplifies stacking (no range overlap logic)
|
||||
- Simplifies compaction (no boundary crossing)
|
||||
- Enables in-place branching
|
||||
- Enables branch visualization/navigation
|
||||
- No index remapping on branch-to-file
|
||||
- Maintains backward compatibility
|
||||
- Validated by Claude Code's implementation
|
||||
|
||||
**Recommend implementing for v2 of hooks/session system.**
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Session File Format
|
||||
|
||||
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field.
|
||||
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files.
|
||||
|
||||
## File Location
|
||||
|
||||
|
|
@ -10,47 +10,66 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with
|
|||
|
||||
Where `<path>` is the working directory with `/` replaced by `-`.
|
||||
|
||||
## Session Version
|
||||
|
||||
Sessions have a version field in the header:
|
||||
|
||||
- **Version 1**: Linear entry sequence (legacy, auto-migrated on load)
|
||||
- **Version 2**: Tree structure with `id`/`parentId` linking
|
||||
|
||||
Existing v1 sessions are automatically migrated to v2 when loaded.
|
||||
|
||||
## Type Definitions
|
||||
|
||||
- [`src/session-manager.ts`](../src/session-manager.ts) - Session entry types (`SessionHeader`, `SessionMessageEntry`, etc.)
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `ThinkingLevel`
|
||||
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Session entry types
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `Attachment`, `ThinkingLevel`
|
||||
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`
|
||||
|
||||
## Entry Base
|
||||
|
||||
All entries (except `SessionHeader`) extend `SessionEntryBase`:
|
||||
|
||||
```typescript
|
||||
interface SessionEntryBase {
|
||||
type: string;
|
||||
id: string; // 8-char hex ID
|
||||
parentId: string | null; // Parent entry ID (null for first entry)
|
||||
timestamp: string; // ISO timestamp
|
||||
}
|
||||
```
|
||||
|
||||
## Entry Types
|
||||
|
||||
### SessionHeader
|
||||
|
||||
First line of the file. Defines session metadata.
|
||||
First line of the file. Metadata only, not part of the tree (no `id`/`parentId`).
|
||||
|
||||
```json
|
||||
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off"}
|
||||
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
|
||||
```
|
||||
|
||||
For branched sessions, includes the source session path:
|
||||
For branched sessions (created via `/branch` command):
|
||||
|
||||
```json
|
||||
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off","branchedFrom":"/path/to/original/session.jsonl"}
|
||||
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","branchedFrom":"/path/to/original/session.jsonl"}
|
||||
```
|
||||
|
||||
### SessionMessageEntry
|
||||
|
||||
A message in the conversation. The `message` field contains an `AppMessage` (see [rpc.md](./rpc.md#message-types)).
|
||||
A message in the conversation. The `message` field contains an `AgentMessage`.
|
||||
|
||||
```json
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234567890}}
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234567891}}
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234567900}}
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:04.000Z","message":{"role":"bashExecution","command":"ls -la","output":"total 48\n...","exitCode":0,"cancelled":false,"truncated":false,"timestamp":1733234567950}}
|
||||
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello"}}
|
||||
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
|
||||
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false}}
|
||||
```
|
||||
|
||||
The `bashExecution` role is a custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command). See [rpc.md](./rpc.md#bashexecutionmessage) for the full schema.
|
||||
|
||||
### ModelChangeEntry
|
||||
|
||||
Emitted when the user switches models mid-session.
|
||||
|
||||
```json
|
||||
{"type":"model_change","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
|
||||
{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
|
||||
```
|
||||
|
||||
### ThinkingLevelChangeEntry
|
||||
|
|
@ -58,9 +77,92 @@ Emitted when the user switches models mid-session.
|
|||
Emitted when the user changes the thinking/reasoning level.
|
||||
|
||||
```json
|
||||
{"type":"thinking_level_change","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
|
||||
{"type":"thinking_level_change","id":"e5f6g7h8","parentId":"d4e5f6g7","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
|
||||
```
|
||||
|
||||
### CompactionEntry
|
||||
|
||||
Created when context is compacted. Stores a summary of earlier messages.
|
||||
|
||||
```json
|
||||
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
|
||||
```
|
||||
|
||||
Optional fields:
|
||||
- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for custom hook implementations)
|
||||
- `fromHook`: `true` if generated by a hook, `false`/`undefined` if pi-generated
|
||||
|
||||
### BranchSummaryEntry
|
||||
|
||||
Created when switching branches via `/tree` with an LLM generated summary of the left branch up to the common ancestor. Captures context from the abandoned path.
|
||||
|
||||
```json
|
||||
{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:15:00.000Z","fromId":"f6g7h8i9","summary":"Branch explored approach A..."}
|
||||
```
|
||||
|
||||
Optional fields:
|
||||
- `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default implementation, arbitrary for custom implementation
|
||||
- `fromHook`: `true` if generated by a hook
|
||||
|
||||
### CustomEntry
|
||||
|
||||
Hook state persistence. Does NOT participate in LLM context.
|
||||
|
||||
```json
|
||||
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-hook","data":{"count":42}}
|
||||
```
|
||||
|
||||
Use `customType` to identify your hook's entries on reload.
|
||||
|
||||
### CustomMessageEntry
|
||||
|
||||
Hook-injected messages that DO participate in LLM context.
|
||||
|
||||
```json
|
||||
{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-hook","content":"Injected context...","display":true}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage)
|
||||
- `display`: `true` = show in TUI with purple styling, `false` = hidden
|
||||
- `details`: Optional hook-specific metadata (not sent to LLM)
|
||||
|
||||
### LabelEntry
|
||||
|
||||
User-defined bookmark/marker on an entry.
|
||||
|
||||
```json
|
||||
{"type":"label","id":"j0k1l2m3","parentId":"i9j0k1l2","timestamp":"2024-12-03T14:30:00.000Z","targetId":"a1b2c3d4","label":"checkpoint-1"}
|
||||
```
|
||||
|
||||
Set `label` to `undefined` to clear a label.
|
||||
|
||||
## Tree Structure
|
||||
|
||||
Entries form a tree:
|
||||
- First entry has `parentId: null`
|
||||
- Each subsequent entry points to its parent via `parentId`
|
||||
- Branching creates new children from an earlier entry
|
||||
- The "leaf" is the current position in the tree
|
||||
|
||||
```
|
||||
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
|
||||
│
|
||||
└─ [branch_summary] ─── [user msg] ← alternate branch
|
||||
```
|
||||
|
||||
## Context Building
|
||||
|
||||
`buildSessionContext()` walks from the current leaf to the root, producing the message list for the LLM:
|
||||
|
||||
1. Collects all entries on the path
|
||||
2. Extracts current model and thinking level settings
|
||||
3. If a `CompactionEntry` is on the path:
|
||||
- Emits the summary first
|
||||
- Then messages from `firstKeptEntryId` to compaction
|
||||
- Then messages after compaction
|
||||
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
|
||||
|
||||
## Parsing Example
|
||||
|
||||
```typescript
|
||||
|
|
@ -70,20 +172,69 @@ const lines = readFileSync("session.jsonl", "utf8").trim().split("\n");
|
|||
|
||||
for (const line of lines) {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
|
||||
switch (entry.type) {
|
||||
case "session":
|
||||
console.log(`Session: ${entry.id}, Model: ${entry.provider}/${entry.modelId}`);
|
||||
console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
|
||||
break;
|
||||
case "message":
|
||||
console.log(`${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
|
||||
console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
|
||||
break;
|
||||
case "compaction":
|
||||
console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);
|
||||
break;
|
||||
case "branch_summary":
|
||||
console.log(`[${entry.id}] Branch from ${entry.fromId}`);
|
||||
break;
|
||||
case "custom":
|
||||
console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
|
||||
break;
|
||||
case "custom_message":
|
||||
console.log(`[${entry.id}] Hook message (${entry.customType}): ${entry.content}`);
|
||||
break;
|
||||
case "label":
|
||||
console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
|
||||
break;
|
||||
case "model_change":
|
||||
console.log(`Switched to: ${entry.provider}/${entry.modelId}`);
|
||||
console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
console.log(`Thinking: ${entry.thinkingLevel}`);
|
||||
console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SessionManager API
|
||||
|
||||
Key methods for working with sessions programmatically:
|
||||
|
||||
### Creation
|
||||
- `SessionManager.create(cwd, sessionDir?)` - New session
|
||||
- `SessionManager.open(path, sessionDir?)` - Open existing
|
||||
- `SessionManager.continueRecent(cwd, sessionDir?)` - Continue most recent or create new
|
||||
- `SessionManager.inMemory(cwd?)` - No file persistence
|
||||
|
||||
### Appending (all return entry ID)
|
||||
- `appendMessage(message)` - Add message
|
||||
- `appendThinkingLevelChange(level)` - Record thinking change
|
||||
- `appendModelChange(provider, modelId)` - Record model change
|
||||
- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction
|
||||
- `appendCustomEntry(customType, data?)` - Hook state (not in context)
|
||||
- `appendCustomMessageEntry(customType, content, display, details?)` - Hook message (in context)
|
||||
- `appendLabelChange(targetId, label)` - Set/clear label
|
||||
|
||||
### Tree Navigation
|
||||
- `getLeafId()` - Current position
|
||||
- `getEntry(id)` - Get entry by ID
|
||||
- `getPath(fromId?)` - Walk from entry to root
|
||||
- `getTree()` - Get full tree structure
|
||||
- `getChildren(parentId)` - Get direct children
|
||||
- `getLabel(id)` - Get label for entry
|
||||
- `branch(entryId)` - Move leaf to earlier entry
|
||||
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
|
||||
|
||||
### Context
|
||||
- `buildSessionContext()` - Get messages for LLM
|
||||
- `getEntries()` - All entries (excluding header)
|
||||
- `getHeader()` - Session metadata
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> pi can create skills. Ask it to build one for your use case.
|
||||
|
||||
# Skills
|
||||
|
||||
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> pi can create themes. Ask it to build one for your use case.
|
||||
|
||||
# Pi Coding Agent Themes
|
||||
|
||||
Themes allow you to customize the colors used throughout the coding agent TUI.
|
||||
|
|
@ -20,13 +22,18 @@ Every theme must define all color tokens. There are no optional colors.
|
|||
| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
|
||||
| `dim` | Very dimmed text | Less important info, placeholders |
|
||||
| `text` | Default text color | Main content (usually `""`) |
|
||||
| `thinkingText` | Thinking block text | Assistant reasoning traces |
|
||||
|
||||
### Backgrounds & Content Text (7 colors)
|
||||
### Backgrounds & Content Text (11 colors)
|
||||
|
||||
| Token | Purpose |
|
||||
|-------|---------|
|
||||
| `selectedBg` | Selected/active line background (e.g., tree selector) |
|
||||
| `userMessageBg` | User message background |
|
||||
| `userMessageText` | User message text color |
|
||||
| `customMessageBg` | Hook custom message background |
|
||||
| `customMessageText` | Hook custom message text color |
|
||||
| `customMessageLabel` | Hook custom message label/type text |
|
||||
| `toolPendingBg` | Tool execution box (pending state) |
|
||||
| `toolSuccessBg` | Tool execution box (success state) |
|
||||
| `toolErrorBg` | Tool execution box (error state) |
|
||||
|
|
@ -95,7 +102,7 @@ These create a visual hierarchy: off → minimal → low → medium → high →
|
|||
|-------|---------|
|
||||
| `bashMode` | Editor border color when in bash mode (! prefix) |
|
||||
|
||||
**Total: 46 color tokens** (all required)
|
||||
**Total: 50 color tokens** (all required)
|
||||
|
||||
## Theme Format
|
||||
|
||||
|
|
@ -113,6 +120,7 @@ Themes are defined in JSON files with the following structure:
|
|||
"colors": {
|
||||
"accent": "blue",
|
||||
"muted": "gray",
|
||||
"thinkingText": "gray",
|
||||
"text": "",
|
||||
...
|
||||
}
|
||||
|
|
|
|||
197
packages/coding-agent/docs/tree.md
Normal file
197
packages/coding-agent/docs/tree.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Session Tree Navigation
|
||||
|
||||
The `/tree` command provides tree-based navigation of the session history.
|
||||
|
||||
## Overview
|
||||
|
||||
Sessions are stored as trees where each entry has an `id` and `parentId`. The "leaf" pointer tracks the current position. `/tree` lets you navigate to any point and optionally summarize the branch you're leaving.
|
||||
|
||||
### Comparison with `/branch`
|
||||
|
||||
| Feature | `/branch` | `/tree` |
|
||||
|---------|-----------|---------|
|
||||
| View | Flat list of user messages | Full tree structure |
|
||||
| Action | Extracts path to **new session file** | Changes leaf in **same session** |
|
||||
| Summary | Never | Optional (user prompted) |
|
||||
| Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` |
|
||||
|
||||
## Tree UI
|
||||
|
||||
```
|
||||
├─ user: "Hello, can you help..."
|
||||
│ └─ assistant: "Of course! I can..."
|
||||
│ ├─ user: "Let's try approach A..."
|
||||
│ │ └─ assistant: "For approach A..."
|
||||
│ │ └─ [compaction: 12k tokens]
|
||||
│ │ └─ user: "That worked..." ← active
|
||||
│ └─ user: "Actually, approach B..."
|
||||
│ └─ assistant: "For approach B..."
|
||||
```
|
||||
|
||||
### Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| ↑/↓ | Navigate (depth-first order) |
|
||||
| Enter | Select node |
|
||||
| Escape/Ctrl+C | Cancel |
|
||||
| Ctrl+U | Toggle: user messages only |
|
||||
| Ctrl+O | Toggle: show all (including custom/label entries) |
|
||||
|
||||
### Display
|
||||
|
||||
- Height: half terminal height
|
||||
- Current leaf marked with `← active`
|
||||
- Labels shown inline: `[label-name]`
|
||||
- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode)
|
||||
- Children sorted by timestamp (oldest first)
|
||||
|
||||
## Selection Behavior
|
||||
|
||||
### User Message or Custom Message
|
||||
1. Leaf set to **parent** of selected node (or `null` if root)
|
||||
2. Message text placed in **editor** for re-submission
|
||||
3. User edits and submits, creating a new branch
|
||||
|
||||
### Non-User Message (assistant, compaction, etc.)
|
||||
1. Leaf set to **selected node**
|
||||
2. Editor stays empty
|
||||
3. User continues from that point
|
||||
|
||||
### Selecting Root User Message
|
||||
If user selects the very first message (has no parent):
|
||||
1. Leaf reset to `null` (empty conversation)
|
||||
2. Message text placed in editor
|
||||
3. User effectively restarts from scratch
|
||||
|
||||
## Branch Summarization
|
||||
|
||||
When switching, user is prompted: "Summarize the branch you're leaving?"
|
||||
|
||||
### What Gets Summarized
|
||||
|
||||
Path from old leaf back to common ancestor with target:
|
||||
|
||||
```
|
||||
A → B → C → D → E → F ← old leaf
|
||||
↘ G → H ← target
|
||||
```
|
||||
|
||||
Abandoned path: D → E → F (summarized)
|
||||
|
||||
Summarization stops at:
|
||||
1. Common ancestor (always)
|
||||
2. Compaction node (if encountered first)
|
||||
|
||||
### Summary Storage
|
||||
|
||||
Stored as `BranchSummaryEntry`:
|
||||
|
||||
```typescript
|
||||
interface BranchSummaryEntry {
|
||||
type: "branch_summary";
|
||||
id: string;
|
||||
parentId: string; // New leaf position
|
||||
timestamp: string;
|
||||
fromId: string; // Old leaf we abandoned
|
||||
summary: string; // LLM-generated summary
|
||||
details?: unknown; // Optional hook data
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### AgentSession.navigateTree()
|
||||
|
||||
```typescript
|
||||
async navigateTree(
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean; customInstructions?: string }
|
||||
): Promise<{ editorText?: string; cancelled: boolean }>
|
||||
```
|
||||
|
||||
Flow:
|
||||
1. Validate target, check no-op (target === current leaf)
|
||||
2. Find common ancestor between old leaf and target
|
||||
3. Collect entries to summarize (if requested)
|
||||
4. Fire `session_before_tree` event (hook can cancel or provide summary)
|
||||
5. Run default summarizer if needed
|
||||
6. Switch leaf via `branch()` or `branchWithSummary()`
|
||||
7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)`
|
||||
8. Fire `session_tree` event
|
||||
9. Notify custom tools via session event
|
||||
10. Return result with `editorText` if user message was selected
|
||||
|
||||
### SessionManager
|
||||
|
||||
- `getLeafUuid(): string | null` - Current leaf (null if empty)
|
||||
- `resetLeaf(): void` - Set leaf to null (for root user message navigation)
|
||||
- `getTree(): SessionTreeNode[]` - Full tree with children sorted by timestamp
|
||||
- `branch(id)` - Change leaf pointer
|
||||
- `branchWithSummary(id, summary)` - Change leaf and create summary entry
|
||||
|
||||
### InteractiveMode
|
||||
|
||||
`/tree` command shows `TreeSelectorComponent`, then:
|
||||
1. Prompt for summarization
|
||||
2. Call `session.navigateTree()`
|
||||
3. Clear and re-render chat
|
||||
4. Set editor text if applicable
|
||||
|
||||
## Hook Events
|
||||
|
||||
### `session_before_tree`
|
||||
|
||||
```typescript
|
||||
interface TreePreparation {
|
||||
targetId: string;
|
||||
oldLeafId: string | null;
|
||||
commonAncestorId: string | null;
|
||||
entriesToSummarize: SessionEntry[];
|
||||
userWantsSummary: boolean;
|
||||
}
|
||||
|
||||
interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
preparation: TreePreparation;
|
||||
model: Model;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
interface SessionBeforeTreeResult {
|
||||
cancel?: boolean;
|
||||
summary?: { summary: string; details?: unknown };
|
||||
}
|
||||
```
|
||||
|
||||
### `session_tree`
|
||||
|
||||
```typescript
|
||||
interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
newLeafId: string | null;
|
||||
oldLeafId: string | null;
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Custom Summarizer
|
||||
|
||||
```typescript
|
||||
export default function(pi: HookAPI) {
|
||||
pi.on("session_before_tree", async (event, ctx) => {
|
||||
if (!event.preparation.userWantsSummary) return;
|
||||
if (event.preparation.entriesToSummarize.length === 0) return;
|
||||
|
||||
const summary = await myCustomSummarizer(event.preparation.entriesToSummarize);
|
||||
return { summary: { summary, details: { custom: true } } };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Summarization failure: cancels navigation, shows error
|
||||
- User abort (Escape): cancels navigation
|
||||
- Hook returns `cancel: true`: cancels navigation silently
|
||||
343
packages/coding-agent/docs/tui.md
Normal file
343
packages/coding-agent/docs/tui.md
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
> pi can create TUI components. Ask it to build one for your use case.
|
||||
|
||||
# TUI Components
|
||||
|
||||
Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
|
||||
|
||||
**Source:** [`@mariozechner/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
|
||||
|
||||
## Component Interface
|
||||
|
||||
All components implement:
|
||||
|
||||
```typescript
|
||||
interface Component {
|
||||
render(width: number): string[];
|
||||
handleInput?(data: string): void;
|
||||
invalidate?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
|
||||
| `handleInput?(data)` | Receive keyboard input when component has focus. |
|
||||
| `invalidate?()` | Clear cached render state. |
|
||||
|
||||
## Using Components
|
||||
|
||||
**In hooks** via `ctx.ui.custom()`:
|
||||
|
||||
```typescript
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const handle = ctx.ui.custom(myComponent);
|
||||
// handle.requestRender() - trigger re-render
|
||||
// handle.close() - restore normal UI
|
||||
});
|
||||
```
|
||||
|
||||
**In custom tools** via `pi.ui.custom()`:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const handle = pi.ui.custom(myComponent);
|
||||
// ...
|
||||
handle.close();
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Components
|
||||
|
||||
Import from `@mariozechner/pi-tui`:
|
||||
|
||||
```typescript
|
||||
import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";
|
||||
```
|
||||
|
||||
### Text
|
||||
|
||||
Multi-line text with word wrapping.
|
||||
|
||||
```typescript
|
||||
const text = new Text(
|
||||
"Hello World", // content
|
||||
1, // paddingX (default: 1)
|
||||
1, // paddingY (default: 1)
|
||||
(s) => bgGray(s) // optional background function
|
||||
);
|
||||
text.setText("Updated");
|
||||
```
|
||||
|
||||
### Box
|
||||
|
||||
Container with padding and background color.
|
||||
|
||||
```typescript
|
||||
const box = new Box(
|
||||
1, // paddingX
|
||||
1, // paddingY
|
||||
(s) => bgGray(s) // background function
|
||||
);
|
||||
box.addChild(new Text("Content", 0, 0));
|
||||
box.setBgFn((s) => bgBlue(s));
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
Groups child components vertically.
|
||||
|
||||
```typescript
|
||||
const container = new Container();
|
||||
container.addChild(component1);
|
||||
container.addChild(component2);
|
||||
container.removeChild(component1);
|
||||
```
|
||||
|
||||
### Spacer
|
||||
|
||||
Empty vertical space.
|
||||
|
||||
```typescript
|
||||
const spacer = new Spacer(2); // 2 empty lines
|
||||
```
|
||||
|
||||
### Markdown
|
||||
|
||||
Renders markdown with syntax highlighting.
|
||||
|
||||
```typescript
|
||||
const md = new Markdown(
|
||||
"# Title\n\nSome **bold** text",
|
||||
1, // paddingX
|
||||
1, // paddingY
|
||||
theme // MarkdownTheme (see below)
|
||||
);
|
||||
md.setText("Updated markdown");
|
||||
```
|
||||
|
||||
### Image
|
||||
|
||||
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
|
||||
|
||||
```typescript
|
||||
const image = new Image(
|
||||
base64Data, // base64-encoded image
|
||||
"image/png", // MIME type
|
||||
theme, // ImageTheme
|
||||
{ maxWidthCells: 80, maxHeightCells: 24 }
|
||||
);
|
||||
```
|
||||
|
||||
## Keyboard Input
|
||||
|
||||
Use key detection helpers:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isEnter, isEscape, isTab,
|
||||
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
|
||||
isCtrlC, isCtrlO, isBackspace, isDelete,
|
||||
// ... and more
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
handleInput(data: string) {
|
||||
if (isArrowUp(data)) {
|
||||
this.selectedIndex--;
|
||||
} else if (isEnter(data)) {
|
||||
this.onSelect?.(this.selectedIndex);
|
||||
} else if (isEscape(data)) {
|
||||
this.onCancel?.();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Line Width
|
||||
|
||||
**Critical:** Each line from `render()` must not exceed the `width` parameter.
|
||||
|
||||
```typescript
|
||||
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
render(width: number): string[] {
|
||||
// Truncate long lines
|
||||
return [truncateToWidth(this.text, width)];
|
||||
}
|
||||
```
|
||||
|
||||
Utilities:
|
||||
- `visibleWidth(str)` - Get display width (ignores ANSI codes)
|
||||
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
|
||||
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
|
||||
|
||||
## Creating Custom Components
|
||||
|
||||
Example: Interactive selector
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isEnter, isEscape, isArrowUp, isArrowDown,
|
||||
truncateToWidth, visibleWidth
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
class MySelector {
|
||||
private items: string[];
|
||||
private selected = 0;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
public onSelect?: (item: string) => void;
|
||||
public onCancel?: () => void;
|
||||
|
||||
constructor(items: string[]) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isArrowUp(data) && this.selected > 0) {
|
||||
this.selected--;
|
||||
this.invalidate();
|
||||
} else if (isArrowDown(data) && this.selected < this.items.length - 1) {
|
||||
this.selected++;
|
||||
this.invalidate();
|
||||
} else if (isEnter(data)) {
|
||||
this.onSelect?.(this.items[this.selected]);
|
||||
} else if (isEscape(data)) {
|
||||
this.onCancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
this.cachedLines = this.items.map((item, i) => {
|
||||
const prefix = i === this.selected ? "> " : " ";
|
||||
return truncateToWidth(prefix + item, width);
|
||||
});
|
||||
this.cachedWidth = width;
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in a hook:
|
||||
|
||||
```typescript
|
||||
pi.registerCommand("pick", {
|
||||
description: "Pick an item",
|
||||
handler: async (args, ctx) => {
|
||||
const items = ["Option A", "Option B", "Option C"];
|
||||
const selector = new MySelector(items);
|
||||
|
||||
let handle: { close: () => void; requestRender: () => void };
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
selector.onSelect = (item) => {
|
||||
ctx.ui.notify(`Selected: ${item}`, "info");
|
||||
handle.close();
|
||||
resolve();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
handle.close();
|
||||
resolve();
|
||||
};
|
||||
handle = ctx.ui.custom(selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
Components accept theme objects for styling.
|
||||
|
||||
**In `renderCall`/`renderResult`**, use the `theme` parameter:
|
||||
|
||||
```typescript
|
||||
renderResult(result, options, theme) {
|
||||
// Use theme.fg() for foreground colors
|
||||
return new Text(theme.fg("success", "Done!"), 0, 0);
|
||||
|
||||
// Use theme.bg() for background colors
|
||||
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
|
||||
}
|
||||
```
|
||||
|
||||
**Foreground colors** (`theme.fg(color, text)`):
|
||||
|
||||
| Category | Colors |
|
||||
|----------|--------|
|
||||
| General | `text`, `accent`, `muted`, `dim` |
|
||||
| Status | `success`, `error`, `warning` |
|
||||
| Borders | `border`, `borderAccent`, `borderMuted` |
|
||||
| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
|
||||
| Tools | `toolTitle`, `toolOutput` |
|
||||
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
|
||||
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
|
||||
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
|
||||
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
|
||||
| Modes | `bashMode` |
|
||||
|
||||
**Background colors** (`theme.bg(color, text)`):
|
||||
|
||||
`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
|
||||
|
||||
**For Markdown**, use `getMarkdownTheme()`:
|
||||
|
||||
```typescript
|
||||
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { Markdown } from "@mariozechner/pi-tui";
|
||||
|
||||
renderResult(result, options, theme) {
|
||||
const mdTheme = getMarkdownTheme();
|
||||
return new Markdown(result.details.markdown, 0, 0, mdTheme);
|
||||
}
|
||||
```
|
||||
|
||||
**For custom components**, define your own theme interface:
|
||||
|
||||
```typescript
|
||||
interface MyTheme {
|
||||
selected: (s: string) => string;
|
||||
normal: (s: string) => string;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Cache rendered output when possible:
|
||||
|
||||
```typescript
|
||||
class CachedComponent {
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
// ... compute lines ...
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render.
|
||||
|
||||
## Examples
|
||||
|
||||
- **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence
|
||||
- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult`
|
||||
|
|
@ -9,10 +9,11 @@ const factory: CustomToolFactory = (_pi) => ({
|
|||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: { greeted: params.name },
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Question Tool - Let the LLM ask the user a question with options
|
||||
*/
|
||||
|
||||
import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
|
|
@ -18,13 +18,13 @@ const QuestionParams = Type.Object({
|
|||
});
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
const tool: CustomAgentTool<typeof QuestionParams, QuestionDetails> = {
|
||||
const tool: CustomTool<typeof QuestionParams, QuestionDetails> = {
|
||||
name: "question",
|
||||
label: "Question",
|
||||
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
|
||||
parameters: QuestionParams,
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
if (!pi.hasUI) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
||||
|
|
@ -41,7 +41,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
|
||||
const answer = await pi.ui.select(params.question, params.options);
|
||||
|
||||
if (answer === null) {
|
||||
if (answer === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled the selection" }],
|
||||
details: { question: params.question, options: params.options, answer: null },
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@ import { spawn } from "node:child_process";
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentToolResult, Message } from "@mariozechner/pi-ai";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
type CustomToolAPI,
|
||||
type CustomToolFactory,
|
||||
getMarkdownTheme,
|
||||
type ToolAPI,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
|
@ -223,7 +224,7 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string
|
|||
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
||||
|
||||
async function runSingleAgent(
|
||||
pi: ToolAPI,
|
||||
pi: CustomToolAPI,
|
||||
agents: AgentConfig[],
|
||||
agentName: string,
|
||||
task: string,
|
||||
|
|
@ -410,7 +411,7 @@ const SubagentParams = Type.Object({
|
|||
});
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
const tool: CustomAgentTool<typeof SubagentParams, SubagentDetails> = {
|
||||
const tool: CustomTool<typeof SubagentParams, SubagentDetails> = {
|
||||
name: "subagent",
|
||||
label: "Subagent",
|
||||
get description() {
|
||||
|
|
@ -432,7 +433,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
},
|
||||
parameters: SubagentParams,
|
||||
|
||||
async execute(_toolCallId, params, signal, onUpdate) {
|
||||
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
|
||||
const agentScope: AgentScope = params.agentScope ?? "user";
|
||||
const discovery = discoverAgents(pi.cwd, agentScope);
|
||||
const agents = discovery.agents;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@
|
|||
*/
|
||||
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
|
||||
import type {
|
||||
CustomTool,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
|
|
@ -43,11 +48,12 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
* Reconstruct state from session entries.
|
||||
* Scans tool results for this tool and applies them in order.
|
||||
*/
|
||||
const reconstructState = (event: ToolSessionEvent) => {
|
||||
const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
todos = [];
|
||||
nextId = 1;
|
||||
|
||||
for (const entry of event.entries) {
|
||||
// Use getBranch() to get entries on the current branch
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
|
||||
|
|
@ -63,7 +69,7 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
}
|
||||
};
|
||||
|
||||
const tool: CustomAgentTool<typeof TodoParams, TodoDetails> = {
|
||||
const tool: CustomTool<typeof TodoParams, TodoDetails> = {
|
||||
name: "todo",
|
||||
label: "Todo",
|
||||
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
|
||||
|
|
@ -72,7 +78,7 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
// Called on session start/switch/branch/clear
|
||||
onSession: reconstructState,
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,97 +2,53 @@
|
|||
|
||||
Example hooks for pi-coding-agent.
|
||||
|
||||
## Examples
|
||||
|
||||
### permission-gate.ts
|
||||
Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.).
|
||||
|
||||
### git-checkpoint.ts
|
||||
Creates git stash checkpoints at each turn, allowing code restoration when branching.
|
||||
|
||||
### protected-paths.ts
|
||||
Blocks writes to protected paths (.env, .git/, node_modules/).
|
||||
|
||||
### file-trigger.ts
|
||||
Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent.
|
||||
|
||||
### confirm-destructive.ts
|
||||
Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events.
|
||||
|
||||
### dirty-repo-guard.ts
|
||||
Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit.
|
||||
|
||||
### auto-commit-on-exit.ts
|
||||
Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message.
|
||||
|
||||
### custom-compaction.ts
|
||||
Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Test directly
|
||||
# Load a hook with --hook flag
|
||||
pi --hook examples/hooks/permission-gate.ts
|
||||
|
||||
# Or copy to hooks directory for persistent use
|
||||
# Or copy to hooks directory for auto-discovery
|
||||
cp permission-gate.ts ~/.pi/agent/hooks/
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
|
||||
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
|
||||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
||||
| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
|
||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
|
||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
||||
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
|
||||
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
|
||||
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
|
||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
||||
|
||||
## Writing Hooks
|
||||
|
||||
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
|
||||
|
||||
### Key Points
|
||||
|
||||
**Hook structure:**
|
||||
```typescript
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" |
|
||||
// "before_branch" | "branch" | "shutdown"
|
||||
// event.targetTurnIndex: number (only for before_branch/branch)
|
||||
// ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI
|
||||
|
||||
// Cancel before_* actions:
|
||||
if (event.reason === "before_clear") {
|
||||
return { cancel: true };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Subscribe to events
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
// Can block tool execution
|
||||
if (dangerous) {
|
||||
return { block: true, reason: "Blocked" };
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
||||
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
// Can modify result
|
||||
return { result: "modified result" };
|
||||
// Register custom commands
|
||||
pi.registerCommand("hello", {
|
||||
description: "Say hello",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify("Hello!", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Available events:**
|
||||
- `session` - lifecycle events with before/after variants (can cancel before_* actions)
|
||||
- `agent_start` / `agent_end` - per user prompt
|
||||
- `turn_start` / `turn_end` - per LLM turn
|
||||
- `tool_call` - before tool execution (can block)
|
||||
- `tool_result` - after tool execution (can modify)
|
||||
|
||||
**UI methods:**
|
||||
```typescript
|
||||
const choice = await ctx.ui.select("Title", ["Option A", "Option B"]);
|
||||
const confirmed = await ctx.ui.confirm("Title", "Are you sure?");
|
||||
const input = await ctx.ui.input("Title", "placeholder");
|
||||
ctx.ui.notify("Message", "info"); // or "warning", "error"
|
||||
```
|
||||
|
||||
**Sending messages:**
|
||||
```typescript
|
||||
pi.send("Message to inject into conversation");
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,14 +5,12 @@
|
|||
* Uses the last assistant message to generate a commit message.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
if (event.reason !== "shutdown") return;
|
||||
|
||||
pi.on("session_shutdown", async (_event, ctx) => {
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status, code } = await ctx.exec("git", ["status", "--porcelain"]);
|
||||
const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]);
|
||||
|
||||
if (code !== 0 || status.trim().length === 0) {
|
||||
// Not a git repo or no changes
|
||||
|
|
@ -20,9 +18,10 @@ export default function (pi: HookAPI) {
|
|||
}
|
||||
|
||||
// Find the last assistant message for commit context
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
let lastAssistantText = "";
|
||||
for (let i = event.entries.length - 1; i >= 0; i--) {
|
||||
const entry = event.entries[i];
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const content = entry.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
|
|
@ -40,8 +39,8 @@ export default function (pi: HookAPI) {
|
|||
const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`;
|
||||
|
||||
// Stage and commit
|
||||
await ctx.exec("git", ["add", "-A"]);
|
||||
const { code: commitCode } = await ctx.exec("git", ["commit", "-m", commitMessage]);
|
||||
await pi.exec("git", ["add", "-A"]);
|
||||
const { code: commitCode } = await pi.exec("git", ["commit", "-m", commitMessage]);
|
||||
|
||||
if (commitCode === 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info");
|
||||
|
|
|
|||
|
|
@ -2,59 +2,56 @@
|
|||
* Confirm Destructive Actions Hook
|
||||
*
|
||||
* Prompts for confirmation before destructive session actions (clear, switch, branch).
|
||||
* Demonstrates how to cancel session events using the before_* variants.
|
||||
* Demonstrates how to cancel session events using the before_* events.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only handle before_* events (the ones that can be cancelled)
|
||||
if (event.reason === "before_new") {
|
||||
if (!ctx.hasUI) return;
|
||||
pi.on("session_before_new", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const confirmed = await ctx.ui.confirm("Clear session?", "This will delete all messages in the current session.");
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Clear cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_before_switch", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// Check if there are unsaved changes (messages since last assistant response)
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const hasUnsavedWork = entries.some(
|
||||
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
|
||||
);
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Clear session?",
|
||||
"This will delete all messages in the current session.",
|
||||
"Switch session?",
|
||||
"You have messages in the current session. Switch anyway?",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Clear cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (event.reason === "before_switch") {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// Check if there are unsaved changes (messages since last assistant response)
|
||||
const hasUnsavedWork = event.entries.some((e) => e.type === "message" && e.message.role === "user");
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Switch session?",
|
||||
"You have messages in the current session. Switch anyway?",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Switch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.reason === "before_branch") {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const choice = await ctx.ui.select(`Branch from turn ${event.targetTurnIndex}?`, [
|
||||
"Yes, create branch",
|
||||
"No, stay in current session",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, create branch") {
|
||||
ctx.ui.notify("Branch cancelled", "info");
|
||||
ctx.ui.notify("Switch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_before_branch", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const choice = await ctx.ui.select(`Branch from entry ${event.entryId.slice(0, 8)}?`, [
|
||||
"Yes, create branch",
|
||||
"No, stay in current session",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, create branch") {
|
||||
ctx.ui.notify("Branch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Replaces the default compaction behavior with a full summary of the entire context.
|
||||
* Instead of keeping the last 20k tokens of conversation turns, this hook:
|
||||
* 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and previousSummary)
|
||||
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
|
||||
* 2. Discards all old turns completely, keeping only the summary
|
||||
*
|
||||
* This example also demonstrates using a different model (Gemini Flash) for summarization,
|
||||
|
|
@ -14,17 +14,15 @@
|
|||
*/
|
||||
|
||||
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||
import { messageTransformer } from "@mariozechner/pi-coding-agent";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
if (event.reason !== "before_compact") return;
|
||||
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
ctx.ui.notify("Custom compaction hook triggered", "info");
|
||||
|
||||
const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, resolveApiKey, entries, signal } =
|
||||
event;
|
||||
const { preparation, branchEntries: _, signal } = event;
|
||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
|
||||
|
||||
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
|
|
@ -34,35 +32,34 @@ export default function (pi: HookAPI) {
|
|||
}
|
||||
|
||||
// Resolve API key for the summarization model
|
||||
const apiKey = await resolveApiKey(model);
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all messages for full summary
|
||||
const allMessages = [...messagesToSummarize, ...messagesToKeep];
|
||||
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
|
||||
|
||||
ctx.ui.notify(
|
||||
`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Transform app messages to pi-ai package format
|
||||
const transformedMessages = messageTransformer(allMessages);
|
||||
// Convert messages to readable text format
|
||||
const conversationText = serializeConversation(convertToLlm(allMessages));
|
||||
|
||||
// Include previous summary context if available
|
||||
const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
|
||||
|
||||
// Build messages that ask for a comprehensive summary
|
||||
const summaryMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}
|
||||
text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext}
|
||||
|
||||
1. The main goals and objectives discussed
|
||||
2. Key decisions made and their rationale
|
||||
|
|
@ -73,7 +70,11 @@ export default function (pi: HookAPI) {
|
|||
|
||||
Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.
|
||||
|
||||
Format the summary as structured markdown with clear sections.`,
|
||||
Format the summary as structured markdown with clear sections.
|
||||
|
||||
<conversation>
|
||||
${conversationText}
|
||||
</conversation>`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
|
|
@ -94,14 +95,12 @@ Format the summary as structured markdown with clear sections.`,
|
|||
return;
|
||||
}
|
||||
|
||||
// Return a compaction entry that discards ALL messages
|
||||
// firstKeptEntryIndex points past all current entries
|
||||
// Return compaction content - SessionManager adds id/parentId
|
||||
// Use firstKeptEntryId from preparation to keep recent messages
|
||||
return {
|
||||
compactionEntry: {
|
||||
type: "compaction" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryIndex: entries.length,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,47 +5,51 @@
|
|||
* Useful to ensure work is committed before switching context.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
|
||||
// Check for uncommitted changes
|
||||
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
||||
|
||||
if (code !== 0) {
|
||||
// Not a git repo, allow the action
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanges = stdout.trim().length > 0;
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
// Count changed files
|
||||
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
||||
"Yes, proceed anyway",
|
||||
"No, let me commit first",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, proceed anyway") {
|
||||
ctx.ui.notify("Commit your changes first", "warning");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only guard destructive actions
|
||||
if (event.reason !== "before_new" && event.reason !== "before_switch" && event.reason !== "before_branch") {
|
||||
return;
|
||||
}
|
||||
pi.on("session_before_new", async (_event, ctx) => {
|
||||
return checkDirtyRepo(pi, ctx, "new session");
|
||||
});
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout, code } = await ctx.exec("git", ["status", "--porcelain"]);
|
||||
pi.on("session_before_switch", async (_event, ctx) => {
|
||||
return checkDirtyRepo(pi, ctx, "switch session");
|
||||
});
|
||||
|
||||
if (code !== 0) {
|
||||
// Not a git repo, allow the action
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanges = stdout.trim().length > 0;
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
// Count changed files
|
||||
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const action =
|
||||
event.reason === "before_new" ? "new session" : event.reason === "before_switch" ? "switch session" : "branch";
|
||||
|
||||
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
||||
"Yes, proceed anyway",
|
||||
"No, let me commit first",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, proceed anyway") {
|
||||
ctx.ui.notify("Commit your changes first", "warning");
|
||||
return { cancel: true };
|
||||
}
|
||||
pi.on("session_before_branch", async (_event, ctx) => {
|
||||
return checkDirtyRepo(pi, ctx, "branch");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,24 @@
|
|||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
if (event.reason !== "start") return;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const triggerFile = "/tmp/agent-trigger.txt";
|
||||
|
||||
fs.watch(triggerFile, () => {
|
||||
try {
|
||||
const content = fs.readFileSync(triggerFile, "utf-8").trim();
|
||||
if (content) {
|
||||
pi.send(`External trigger: ${content}`);
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "file-trigger",
|
||||
content: `External trigger: ${content}`,
|
||||
display: true,
|
||||
},
|
||||
true, // triggerTurn - get LLM to respond
|
||||
);
|
||||
fs.writeFileSync(triggerFile, ""); // Clear after reading
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -5,25 +5,29 @@
|
|||
* When branching, offers to restore code to that point in history.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
const checkpoints = new Map<number, string>();
|
||||
const checkpoints = new Map<string, string>();
|
||||
let currentEntryId: string | undefined;
|
||||
|
||||
pi.on("turn_start", async (event, ctx) => {
|
||||
// Track the current entry ID when user messages are saved
|
||||
pi.on("tool_result", async (_event, ctx) => {
|
||||
const leaf = ctx.sessionManager.getLeafEntry();
|
||||
if (leaf) currentEntryId = leaf.id;
|
||||
});
|
||||
|
||||
pi.on("turn_start", async () => {
|
||||
// Create a git stash entry before LLM makes changes
|
||||
const { stdout } = await ctx.exec("git", ["stash", "create"]);
|
||||
const { stdout } = await pi.exec("git", ["stash", "create"]);
|
||||
const ref = stdout.trim();
|
||||
if (ref) {
|
||||
checkpoints.set(event.turnIndex, ref);
|
||||
if (ref && currentEntryId) {
|
||||
checkpoints.set(currentEntryId, ref);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only handle before_branch events
|
||||
if (event.reason !== "before_branch") return;
|
||||
|
||||
const ref = checkpoints.get(event.targetTurnIndex);
|
||||
pi.on("session_before_branch", async (event, ctx) => {
|
||||
const ref = checkpoints.get(event.entryId);
|
||||
if (!ref) return;
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
|
|
@ -37,7 +41,7 @@ export default function (pi: HookAPI) {
|
|||
]);
|
||||
|
||||
if (choice?.startsWith("Yes")) {
|
||||
await ctx.exec("git", ["stash", "apply", ref]);
|
||||
await pi.exec("git", ["stash", "apply", ref]);
|
||||
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Useful for preventing accidental modifications to sensitive files.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||
|
|
|
|||
119
packages/coding-agent/examples/hooks/qna.ts
Normal file
119
packages/coding-agent/examples/hooks/qna.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Q&A extraction hook - extracts questions from assistant responses
|
||||
*
|
||||
* Demonstrates the "prompt generator" pattern:
|
||||
* 1. /qna command gets the last assistant message
|
||||
* 2. Shows a spinner while extracting (hides editor)
|
||||
* 3. Loads the result into the editor for user to fill in answers
|
||||
*/
|
||||
|
||||
import { complete, type UserMessage } from "@mariozechner/pi-ai";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
|
||||
|
||||
Output format:
|
||||
- List each question on its own line, prefixed with "Q: "
|
||||
- After each question, add a blank line for the answer prefixed with "A: "
|
||||
- If no questions are found, output "No questions found in the last message."
|
||||
|
||||
Example output:
|
||||
Q: What is your preferred database?
|
||||
A:
|
||||
|
||||
Q: Should we use TypeScript or JavaScript?
|
||||
A:
|
||||
|
||||
Keep questions in the order they appeared. Be concise.`;
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.registerCommand("qna", {
|
||||
description: "Extract questions from last assistant message into editor",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("qna requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.model) {
|
||||
ctx.ui.notify("No model selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last assistant message on the current branch
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
let lastAssistantText: string | undefined;
|
||||
|
||||
for (let i = branch.length - 1; i >= 0; i--) {
|
||||
const entry = branch[i];
|
||||
if (entry.type === "message") {
|
||||
const msg = entry.message;
|
||||
if ("role" in msg && msg.role === "assistant") {
|
||||
if (msg.stopReason !== "stop") {
|
||||
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
|
||||
return;
|
||||
}
|
||||
const textParts = msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text);
|
||||
if (textParts.length > 0) {
|
||||
lastAssistantText = textParts.join("\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistantText) {
|
||||
ctx.ui.notify("No assistant messages found", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run extraction with loader UI
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
|
||||
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
// Do the work
|
||||
const doExtract = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: lastAssistantText! }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await complete(
|
||||
ctx.model!,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
doExtract()
|
||||
.then(done)
|
||||
.catch(() => done(null));
|
||||
|
||||
return loader;
|
||||
});
|
||||
|
||||
if (result === null) {
|
||||
ctx.ui.notify("Cancelled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setEditorText(result);
|
||||
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
343
packages/coding-agent/examples/hooks/snake.ts
Normal file
343
packages/coding-agent/examples/hooks/snake.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* Snake game hook - play snake with /snake command
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
const GAME_WIDTH = 40;
|
||||
const GAME_HEIGHT = 15;
|
||||
const TICK_MS = 100;
|
||||
|
||||
type Direction = "up" | "down" | "left" | "right";
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
interface GameState {
|
||||
snake: Point[];
|
||||
food: Point;
|
||||
direction: Direction;
|
||||
nextDirection: Direction;
|
||||
score: number;
|
||||
gameOver: boolean;
|
||||
highScore: number;
|
||||
}
|
||||
|
||||
function createInitialState(): GameState {
|
||||
const startX = Math.floor(GAME_WIDTH / 2);
|
||||
const startY = Math.floor(GAME_HEIGHT / 2);
|
||||
return {
|
||||
snake: [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX - 1, y: startY },
|
||||
{ x: startX - 2, y: startY },
|
||||
],
|
||||
food: spawnFood([{ x: startX, y: startY }]),
|
||||
direction: "right",
|
||||
nextDirection: "right",
|
||||
score: 0,
|
||||
gameOver: false,
|
||||
highScore: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnFood(snake: Point[]): Point {
|
||||
let food: Point;
|
||||
do {
|
||||
food = {
|
||||
x: Math.floor(Math.random() * GAME_WIDTH),
|
||||
y: Math.floor(Math.random() * GAME_HEIGHT),
|
||||
};
|
||||
} while (snake.some((s) => s.x === food.x && s.y === food.y));
|
||||
return food;
|
||||
}
|
||||
|
||||
class SnakeComponent {
|
||||
private state: GameState;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private onClose: () => void;
|
||||
private onSave: (state: GameState | null) => void;
|
||||
private tui: { requestRender: () => void };
|
||||
private cachedLines: string[] = [];
|
||||
private cachedWidth = 0;
|
||||
private version = 0;
|
||||
private cachedVersion = -1;
|
||||
private paused: boolean;
|
||||
|
||||
constructor(
|
||||
tui: { requestRender: () => void },
|
||||
onClose: () => void,
|
||||
onSave: (state: GameState | null) => void,
|
||||
savedState?: GameState,
|
||||
) {
|
||||
this.tui = tui;
|
||||
if (savedState && !savedState.gameOver) {
|
||||
// Resume from saved state, start paused
|
||||
this.state = savedState;
|
||||
this.paused = true;
|
||||
} else {
|
||||
// New game or saved game was over
|
||||
this.state = createInitialState();
|
||||
if (savedState) {
|
||||
this.state.highScore = savedState.highScore;
|
||||
}
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
}
|
||||
this.onClose = onClose;
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
private startGame(): void {
|
||||
this.interval = setInterval(() => {
|
||||
if (!this.state.gameOver) {
|
||||
this.tick();
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
// Apply queued direction change
|
||||
this.state.direction = this.state.nextDirection;
|
||||
|
||||
// Calculate new head position
|
||||
const head = this.state.snake[0];
|
||||
let newHead: Point;
|
||||
|
||||
switch (this.state.direction) {
|
||||
case "up":
|
||||
newHead = { x: head.x, y: head.y - 1 };
|
||||
break;
|
||||
case "down":
|
||||
newHead = { x: head.x, y: head.y + 1 };
|
||||
break;
|
||||
case "left":
|
||||
newHead = { x: head.x - 1, y: head.y };
|
||||
break;
|
||||
case "right":
|
||||
newHead = { x: head.x + 1, y: head.y };
|
||||
break;
|
||||
}
|
||||
|
||||
// Check wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check self collision
|
||||
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move snake
|
||||
this.state.snake.unshift(newHead);
|
||||
|
||||
// Check food collision
|
||||
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
|
||||
this.state.score += 10;
|
||||
if (this.state.score > this.state.highScore) {
|
||||
this.state.highScore = this.state.score;
|
||||
}
|
||||
this.state.food = spawnFood(this.state.snake);
|
||||
} else {
|
||||
this.state.snake.pop();
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// If paused (resuming), wait for any key
|
||||
if (this.paused) {
|
||||
if (isEscape(data) || data === "q" || data === "Q") {
|
||||
// Quit without clearing save
|
||||
this.dispose();
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
// Any other key resumes
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC to pause and save
|
||||
if (isEscape(data)) {
|
||||
this.dispose();
|
||||
this.onSave(this.state);
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Q to quit without saving (clears saved state)
|
||||
if (data === "q" || data === "Q") {
|
||||
this.dispose();
|
||||
this.onSave(null); // Clear saved state
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys or WASD
|
||||
if (isArrowUp(data) || data === "w" || data === "W") {
|
||||
if (this.state.direction !== "down") this.state.nextDirection = "up";
|
||||
} else if (isArrowDown(data) || data === "s" || data === "S") {
|
||||
if (this.state.direction !== "up") this.state.nextDirection = "down";
|
||||
} else if (isArrowRight(data) || data === "d" || data === "D") {
|
||||
if (this.state.direction !== "left") this.state.nextDirection = "right";
|
||||
} else if (isArrowLeft(data) || data === "a" || data === "A") {
|
||||
if (this.state.direction !== "right") this.state.nextDirection = "left";
|
||||
}
|
||||
|
||||
// Restart on game over
|
||||
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
||||
const highScore = this.state.highScore;
|
||||
this.state = createInitialState();
|
||||
this.state.highScore = highScore;
|
||||
this.onSave(null); // Clear saved state on restart
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = 0;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
|
||||
const cellWidth = 2;
|
||||
const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
|
||||
const effectiveHeight = GAME_HEIGHT;
|
||||
|
||||
// Colors
|
||||
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const boxWidth = effectiveWidth * cellWidth;
|
||||
|
||||
// Helper to pad content inside box
|
||||
const boxLine = (content: string) => {
|
||||
const contentLen = visibleWidth(content);
|
||||
const padding = Math.max(0, boxWidth - contentLen);
|
||||
return dim(" │") + content + " ".repeat(padding) + dim("│");
|
||||
};
|
||||
|
||||
// Top border
|
||||
lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
|
||||
|
||||
// Header with score
|
||||
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
||||
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
||||
const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`;
|
||||
lines.push(this.padLine(boxLine(title), width));
|
||||
|
||||
// Separator
|
||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||
|
||||
// Game grid
|
||||
for (let y = 0; y < effectiveHeight; y++) {
|
||||
let row = "";
|
||||
for (let x = 0; x < effectiveWidth; x++) {
|
||||
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
||||
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
||||
const isFood = this.state.food.x === x && this.state.food.y === y;
|
||||
|
||||
if (isHead) {
|
||||
row += green("██"); // Snake head (2 chars)
|
||||
} else if (isBody) {
|
||||
row += green("▓▓"); // Snake body (2 chars)
|
||||
} else if (isFood) {
|
||||
row += red("◆ "); // Food (2 chars)
|
||||
} else {
|
||||
row += " "; // Empty cell (2 spaces)
|
||||
}
|
||||
}
|
||||
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
||||
}
|
||||
|
||||
// Separator
|
||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||
|
||||
// Footer
|
||||
let footer: string;
|
||||
if (this.paused) {
|
||||
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
||||
} else if (this.state.gameOver) {
|
||||
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
||||
} else {
|
||||
footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
|
||||
}
|
||||
lines.push(this.padLine(boxLine(footer), width));
|
||||
|
||||
// Bottom border
|
||||
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
||||
|
||||
this.cachedLines = lines;
|
||||
this.cachedWidth = width;
|
||||
this.cachedVersion = this.version;
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private padLine(line: string, width: number): string {
|
||||
// Calculate visible length (strip ANSI codes)
|
||||
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
const padding = Math.max(0, width - visibleLen);
|
||||
return line + " ".repeat(padding);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SNAKE_SAVE_TYPE = "snake-save";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.registerCommand("snake", {
|
||||
description: "Play Snake!",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Snake requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved state from session
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
let savedState: GameState | undefined;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
|
||||
savedState = entry.data as GameState;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, _theme, done) => {
|
||||
return new SnakeComponent(
|
||||
tui,
|
||||
() => done(undefined),
|
||||
(state) => {
|
||||
// Save or clear state
|
||||
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
||||
},
|
||||
savedState,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
|
||||
*/
|
||||
|
||||
import { createAgentSession } from "../../src/index.js";
|
||||
import { createAgentSession } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const { session } = await createAgentSession();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { createAgentSession, discoverAuthStorage, discoverModels } from "../../src/index.js";
|
||||
import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Set up auth storage and model registry
|
||||
const authStorage = discoverAuthStorage();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Shows how to replace or modify the default system prompt.
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Option 1: Replace prompt entirely
|
||||
const { session: session1 } = await createAgentSession({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Discover, filter, merge, or replace them.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js";
|
||||
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
|
||||
const allSkills = discoverSkills();
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@
|
|||
* tools resolve paths relative to your cwd, not process.cwd().
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
bashTool, // read, bash, edit, write - uses process.cwd()
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createCodingTools, // Factory: creates tools for specific cwd
|
||||
|
|
@ -21,7 +20,8 @@ import {
|
|||
readOnlyTools, // read, grep, find, ls - uses process.cwd()
|
||||
readTool,
|
||||
SessionManager,
|
||||
} from "../../src/index.js";
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// Read-only mode (no edit/write) - uses process.cwd()
|
||||
await createAgentSession({
|
||||
|
|
@ -55,7 +55,7 @@ await createAgentSession({
|
|||
console.log("Specific tools with custom cwd session created");
|
||||
|
||||
// Inline custom tool (needs TypeBox schema)
|
||||
const weatherTool: CustomAgentTool = {
|
||||
const weatherTool: CustomTool = {
|
||||
name: "get_weather",
|
||||
label: "Get Weather",
|
||||
description: "Get current weather for a city",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Hooks intercept agent events for logging, blocking, or modification.
|
||||
*/
|
||||
|
||||
import { createAgentSession, type HookFactory, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Logging hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Context files provide project-specific instructions loaded into the system prompt.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover AGENTS.md files walking up from cwd
|
||||
const discovered = discoverContextFiles();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@
|
|||
* File-based commands that inject content when invoked with /commandname.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverSlashCommands, type FileSlashCommand, SessionManager } from "../../src/index.js";
|
||||
import {
|
||||
createAgentSession,
|
||||
discoverSlashCommands,
|
||||
type FileSlashCommand,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
|
||||
const discovered = discoverSlashCommands();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
discoverModels,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
} from "../../src/index.js";
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
|
||||
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Override settings using SettingsManager.
|
||||
*/
|
||||
|
||||
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js";
|
||||
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Load current settings (merged global + project)
|
||||
const settings = loadSettings();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Control session persistence: in-memory, new file, continue, or open specific.
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// In-memory (no persistence)
|
||||
const { session: inMemory } = await createAgentSession({
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@
|
|||
*/
|
||||
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
AuthStorage,
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createReadTool,
|
||||
|
|
@ -20,7 +19,8 @@ import {
|
|||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "../../src/index.js";
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// Custom auth storage location
|
||||
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
|
||||
|
|
@ -42,7 +42,7 @@ const auditHook: HookFactory = (api) => {
|
|||
};
|
||||
|
||||
// Inline custom tool
|
||||
const statusTool: CustomAgentTool = {
|
||||
const statusTool: CustomTool = {
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: "Get system status",
|
||||
|
|
@ -68,15 +68,12 @@ const cwd = process.cwd();
|
|||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir: "/tmp/my-agent",
|
||||
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
|
||||
systemPrompt: `You are a minimal assistant.
|
||||
Available: read, bash, status. Be concise.`,
|
||||
|
||||
// Use factory functions with the same cwd to ensure path resolution works correctly
|
||||
tools: [createReadTool(cwd), createBashTool(cwd)],
|
||||
customTools: [{ tool: statusTool }],
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@
|
|||
*/
|
||||
|
||||
import { access, readFile, stat } from "node:fs/promises";
|
||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { resolve } from "path";
|
||||
import { resolveReadPath } from "../core/tools/path-utils.js";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
|
||||
|
||||
export interface ProcessedFiles {
|
||||
textContent: string;
|
||||
imageAttachments: Attachment[];
|
||||
text: string;
|
||||
images: ImageContent[];
|
||||
}
|
||||
|
||||
/** Process @file arguments into text content and image attachments */
|
||||
export async function processFileArguments(fileArgs: string[]): Promise<ProcessedFiles> {
|
||||
let textContent = "";
|
||||
const imageAttachments: Attachment[] = [];
|
||||
let text = "";
|
||||
const images: ImageContent[] = [];
|
||||
|
||||
for (const fileArg of fileArgs) {
|
||||
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces)
|
||||
|
|
@ -45,24 +45,21 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
|
|||
const content = await readFile(absolutePath);
|
||||
const base64Content = content.toString("base64");
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
const attachment: ImageContent = {
|
||||
type: "image",
|
||||
fileName: absolutePath.split("/").pop() || absolutePath,
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
content: base64Content,
|
||||
data: base64Content,
|
||||
};
|
||||
|
||||
imageAttachments.push(attachment);
|
||||
images.push(attachment);
|
||||
|
||||
// Add text reference to image
|
||||
textContent += `<file name="${absolutePath}"></file>\n`;
|
||||
text += `<file name="${absolutePath}"></file>\n`;
|
||||
} else {
|
||||
// Handle text file
|
||||
try {
|
||||
const content = await readFile(absolutePath, "utf-8");
|
||||
textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
|
||||
text += `<file name="${absolutePath}">\n${content}\n</file>\n`;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`));
|
||||
|
|
@ -71,5 +68,5 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
|
|||
}
|
||||
}
|
||||
|
||||
return { textContent, imageAttachments };
|
||||
return { text, images };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ export function getDocsPath(): string {
|
|||
return resolve(join(getPackageDir(), "docs"));
|
||||
}
|
||||
|
||||
/** Get path to examples directory */
|
||||
export function getExamplesPath(): string {
|
||||
return resolve(join(getPackageDir(), "examples"));
|
||||
}
|
||||
|
||||
/** Get path to CHANGELOG.md */
|
||||
export function getChangelogPath(): string {
|
||||
return resolve(join(getPackageDir(), "CHANGELOG.md"));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -94,8 +94,8 @@ export class AuthStorage {
|
|||
/**
|
||||
* Get credential for a provider.
|
||||
*/
|
||||
get(provider: string): AuthCredential | null {
|
||||
return this.data[provider] ?? null;
|
||||
get(provider: string): AuthCredential | undefined {
|
||||
return this.data[provider] ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -191,7 +191,7 @@ export class AuthStorage {
|
|||
* 4. Environment variable
|
||||
* 5. Fallback resolver (models.json custom providers)
|
||||
*/
|
||||
async getApiKey(provider: string): Promise<string | null> {
|
||||
async getApiKey(provider: string): Promise<string | undefined> {
|
||||
// Runtime override takes highest priority
|
||||
const runtimeKey = this.runtimeOverrides.get(provider);
|
||||
if (runtimeKey) {
|
||||
|
|
@ -230,6 +230,6 @@ export class AuthStorage {
|
|||
if (envKey) return envKey;
|
||||
|
||||
// Fall back to custom resolver (e.g., models.json custom providers)
|
||||
return this.fallbackResolver?.(provider) ?? null;
|
||||
return this.fallbackResolver?.(provider) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ export interface BashExecutorOptions {
|
|||
export interface BashResult {
|
||||
/** Combined stdout + stderr output (sanitized, possibly truncated) */
|
||||
output: string;
|
||||
/** Process exit code (null if killed/cancelled) */
|
||||
exitCode: number | null;
|
||||
/** Process exit code (undefined if killed/cancelled) */
|
||||
exitCode: number | undefined;
|
||||
/** Whether the command was cancelled via signal */
|
||||
cancelled: boolean;
|
||||
/** Whether the output was truncated */
|
||||
|
|
@ -88,7 +88,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
|
|||
child.kill();
|
||||
resolve({
|
||||
output: "",
|
||||
exitCode: null,
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
truncated: false,
|
||||
});
|
||||
|
|
@ -154,7 +154,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
|
|||
|
||||
resolve({
|
||||
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
||||
exitCode: code,
|
||||
exitCode: cancelled ? undefined : code,
|
||||
cancelled,
|
||||
truncated: truncationResult.truncated,
|
||||
fullOutputPath: tempFilePath,
|
||||
|
|
|
|||
|
|
@ -1,530 +0,0 @@
|
|||
/**
|
||||
* Context compaction for long sessions.
|
||||
*
|
||||
* Pure functions for compaction logic. The session manager handles I/O,
|
||||
* and after compaction the session is reloaded.
|
||||
*/
|
||||
|
||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
||||
import { complete } from "@mariozechner/pi-ai";
|
||||
import { messageTransformer } from "./messages.js";
|
||||
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionSettings {
|
||||
enabled: boolean;
|
||||
reserveTokens: number;
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Token calculation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate total context tokens from usage.
|
||||
* Uses the native totalTokens field when available, falls back to computing from components.
|
||||
*/
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage from an assistant message if available.
|
||||
* Skips aborted and error messages as they don't have valid usage data.
|
||||
*/
|
||||
function getAssistantUsage(msg: AppMessage): Usage | null {
|
||||
if (msg.role === "assistant" && "usage" in msg) {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
||||
return assistantMsg.usage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last non-aborted assistant message usage from session entries.
|
||||
*/
|
||||
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const usage = getAssistantUsage(entry.message);
|
||||
if (usage) return usage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compaction should trigger based on context usage.
|
||||
*/
|
||||
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
|
||||
if (!settings.enabled) return false;
|
||||
return contextTokens > contextWindow - settings.reserveTokens;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cut point detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using chars/4 heuristic.
|
||||
* This is conservative (overestimates tokens).
|
||||
*/
|
||||
export function estimateTokens(message: AppMessage): number {
|
||||
let chars = 0;
|
||||
|
||||
// Handle bashExecution messages
|
||||
if (message.role === "bashExecution") {
|
||||
const bash = message as unknown as { command: string; output: string };
|
||||
chars = bash.command.length + bash.output.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Handle user messages
|
||||
if (message.role === "user") {
|
||||
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
if (message.role === "assistant") {
|
||||
const assistant = message as AssistantMessage;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + JSON.stringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Handle tool results
|
||||
if (message.role === "toolResult") {
|
||||
const toolResult = message as { content: Array<{ type: string; text?: string }> };
|
||||
for (const block of toolResult.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid cut points: indices of user, assistant, or bashExecution messages.
|
||||
* Never cut at tool results (they must follow their tool call).
|
||||
* When we cut at an assistant message with tool calls, its tool results follow it
|
||||
* and will be kept.
|
||||
* BashExecutionMessage is treated like a user message (user-initiated context).
|
||||
*/
|
||||
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
|
||||
const cutPoints: number[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
// user, assistant, and bashExecution are valid cut points
|
||||
// toolResult must stay with its preceding tool call
|
||||
if (role === "user" || role === "assistant" || role === "bashExecution") {
|
||||
cutPoints.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cutPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
||||
* Returns -1 if no turn start found before the index.
|
||||
* BashExecutionMessage is treated like a user message for turn boundaries.
|
||||
*/
|
||||
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
|
||||
for (let i = entryIndex; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
if (role === "user" || role === "bashExecution") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface CutPointResult {
|
||||
/** Index of first entry to keep */
|
||||
firstKeptEntryIndex: number;
|
||||
/** Index of user message that starts the turn being split, or -1 if not splitting */
|
||||
turnStartIndex: number;
|
||||
/** Whether this cut splits a turn (cut point is not a user message) */
|
||||
isSplitTurn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
|
||||
*
|
||||
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
|
||||
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
|
||||
*
|
||||
* Can cut at user OR assistant messages (never tool results). When cutting at an
|
||||
* assistant message with tool calls, its tool results come after and will be kept.
|
||||
*
|
||||
* Returns CutPointResult with:
|
||||
* - firstKeptEntryIndex: the entry index to start keeping from
|
||||
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
|
||||
* - isSplitTurn: whether we're cutting in the middle of a turn
|
||||
*
|
||||
* Only considers entries between `startIndex` and `endIndex` (exclusive).
|
||||
*/
|
||||
export function findCutPoint(
|
||||
entries: SessionEntry[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
keepRecentTokens: number,
|
||||
): CutPointResult {
|
||||
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
|
||||
|
||||
if (cutPoints.length === 0) {
|
||||
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
||||
}
|
||||
|
||||
// Walk backwards from newest, accumulating estimated message sizes
|
||||
let accumulatedTokens = 0;
|
||||
let cutIndex = startIndex; // Default: keep everything in range
|
||||
|
||||
for (let i = endIndex - 1; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Estimate this message's size
|
||||
const messageTokens = estimateTokens(entry.message);
|
||||
accumulatedTokens += messageTokens;
|
||||
|
||||
// Check if we've exceeded the budget
|
||||
if (accumulatedTokens >= keepRecentTokens) {
|
||||
// Find the closest valid cut point at or after this entry
|
||||
for (let c = 0; c < cutPoints.length; c++) {
|
||||
if (cutPoints[c] >= i) {
|
||||
cutIndex = cutPoints[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
|
||||
while (cutIndex > startIndex) {
|
||||
const prevEntry = entries[cutIndex - 1];
|
||||
// Stop at compaction boundaries
|
||||
if (prevEntry.type === "compaction") {
|
||||
break;
|
||||
}
|
||||
if (prevEntry.type === "message") {
|
||||
// Stop if we hit any message
|
||||
break;
|
||||
}
|
||||
// Include this non-message entry (bash, settings change, etc.)
|
||||
cutIndex--;
|
||||
}
|
||||
|
||||
// Determine if this is a split turn
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
|
||||
return {
|
||||
firstKeptEntryIndex: cutIndex,
|
||||
turnStartIndex,
|
||||
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization
|
||||
// ============================================================================
|
||||
|
||||
const SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
|
||||
|
||||
Include:
|
||||
- Current progress and key decisions made
|
||||
- Important context, constraints, or user preferences
|
||||
- Absolute file paths of any relevant files that were read or modified
|
||||
- What remains to be done (clear next steps)
|
||||
- Any critical data, examples, or references needed to continue
|
||||
|
||||
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of the conversation using the LLM.
|
||||
*/
|
||||
export async function generateSummary(
|
||||
currentMessages: AppMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.8 * reserveTokens);
|
||||
|
||||
const prompt = customInstructions
|
||||
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
|
||||
: SUMMARIZATION_PROMPT;
|
||||
|
||||
// Transform custom messages (like bashExecution) to LLM-compatible messages
|
||||
const transformedMessages = messageTransformer(currentMessages);
|
||||
|
||||
const summarizationMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: prompt }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
|
||||
|
||||
const textContent = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
return textContent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compaction Preparation (for hooks)
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionPreparation {
|
||||
cutPoint: CutPointResult;
|
||||
/** Messages that will be summarized and discarded */
|
||||
messagesToSummarize: AppMessage[];
|
||||
/** Messages that will be kept after the summary (recent turns) */
|
||||
messagesToKeep: AppMessage[];
|
||||
tokensBefore: number;
|
||||
boundaryStart: number;
|
||||
}
|
||||
|
||||
export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null {
|
||||
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const boundaryStart = prevCompactionIndex + 1;
|
||||
const boundaryEnd = entries.length;
|
||||
|
||||
const lastUsage = getLastAssistantUsage(entries);
|
||||
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
||||
|
||||
const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
|
||||
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
||||
|
||||
// Messages to summarize (will be discarded after summary)
|
||||
const messagesToSummarize: AppMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
messagesToSummarize.push(entry.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages to keep (recent turns, kept after summary)
|
||||
const messagesToKeep: AppMessage[] = [];
|
||||
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
messagesToKeep.push(entry.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { cutPoint, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main compaction function
|
||||
// ============================================================================
|
||||
|
||||
const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn.
|
||||
This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept.
|
||||
|
||||
Create a handoff summary that captures:
|
||||
- What the user originally asked for in this turn
|
||||
- Key decisions and progress made early in this turn
|
||||
- Important context needed to understand the kept suffix
|
||||
|
||||
Be concise. Focus on information needed to understand the retained recent work.`;
|
||||
|
||||
/**
|
||||
* Calculate compaction and generate summary.
|
||||
* Returns the CompactionEntry to append to the session file.
|
||||
*
|
||||
* @param entries - All session entries
|
||||
* @param model - Model to use for summarization
|
||||
* @param settings - Compaction settings
|
||||
* @param apiKey - API key for LLM
|
||||
* @param signal - Optional abort signal
|
||||
* @param customInstructions - Optional custom focus for the summary
|
||||
*/
|
||||
export async function compact(
|
||||
entries: SessionEntry[],
|
||||
model: Model<any>,
|
||||
settings: CompactionSettings,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
): Promise<CompactionEntry> {
|
||||
// Don't compact if the last entry is already a compaction
|
||||
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
|
||||
throw new Error("Already compacted");
|
||||
}
|
||||
|
||||
// Find previous compaction boundary
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const boundaryStart = prevCompactionIndex + 1;
|
||||
const boundaryEnd = entries.length;
|
||||
|
||||
// Get token count before compaction
|
||||
const lastUsage = getLastAssistantUsage(entries);
|
||||
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
||||
|
||||
// Find cut point (entry index) within the valid range
|
||||
const cutResult = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
|
||||
// Extract messages for history summary (before the turn that contains the cut point)
|
||||
const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex;
|
||||
const historyMessages: AppMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
historyMessages.push(entry.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Include previous summary if there was a compaction
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
historyMessages.unshift({
|
||||
role: "user",
|
||||
content: `Previous session summary:\n${prevCompaction.summary}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract messages for turn prefix summary (if splitting a turn)
|
||||
const turnPrefixMessages: AppMessage[] = [];
|
||||
if (cutResult.isSplitTurn) {
|
||||
for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
turnPrefixMessages.push(entry.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summaries (can be parallel if both needed) and merge into one
|
||||
let summary: string;
|
||||
|
||||
if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
// Generate both summaries in parallel
|
||||
const [historyResult, turnPrefixResult] = await Promise.all([
|
||||
historyMessages.length > 0
|
||||
? generateSummary(historyMessages, model, settings.reserveTokens, apiKey, signal, customInstructions)
|
||||
: Promise.resolve("No prior history."),
|
||||
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
|
||||
]);
|
||||
// Merge into single summary
|
||||
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
|
||||
} else {
|
||||
// Just generate history summary
|
||||
summary = await generateSummary(
|
||||
historyMessages,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
signal,
|
||||
customInstructions,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "compaction",
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryIndex: cutResult.firstKeptEntryIndex,
|
||||
tokensBefore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary for a turn prefix (when splitting a turn).
|
||||
*/
|
||||
async function generateTurnPrefixSummary(
|
||||
messages: AppMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
||||
|
||||
const transformedMessages = messageTransformer(messages);
|
||||
const summarizationMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* Branch summarization for tree navigation.
|
||||
*
|
||||
* When navigating to a different point in the session tree, this generates
|
||||
* a summary of the branch being left so context isn't lost.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { completeSimple } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createHookMessage,
|
||||
} from "../messages.js";
|
||||
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
|
||||
import { estimateTokens } from "./compaction.js";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
SUMMARIZATION_SYSTEM_PROMPT,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BranchSummaryResult {
|
||||
summary?: string;
|
||||
readFiles?: string[];
|
||||
modifiedFiles?: string[];
|
||||
aborted?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Details stored in BranchSummaryEntry.details for file tracking */
|
||||
export interface BranchSummaryDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
export type { FileOperations } from "./utils.js";
|
||||
|
||||
export interface BranchPreparation {
|
||||
/** Messages extracted for summarization, in chronological order */
|
||||
messages: AgentMessage[];
|
||||
/** File operations extracted from tool calls */
|
||||
fileOps: FileOperations;
|
||||
/** Total estimated tokens in messages */
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface CollectEntriesResult {
|
||||
/** Entries to summarize, in chronological order */
|
||||
entries: SessionEntry[];
|
||||
/** Common ancestor between old and new position, if any */
|
||||
commonAncestorId: string | null;
|
||||
}
|
||||
|
||||
export interface GenerateBranchSummaryOptions {
|
||||
/** Model to use for summarization */
|
||||
model: Model<any>;
|
||||
/** API key for the model */
|
||||
apiKey: string;
|
||||
/** Abort signal for cancellation */
|
||||
signal: AbortSignal;
|
||||
/** Optional custom instructions for summarization */
|
||||
customInstructions?: string;
|
||||
/** Tokens reserved for prompt + LLM response (default 16384) */
|
||||
reserveTokens?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entry Collection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Collect entries that should be summarized when navigating from one position to another.
|
||||
*
|
||||
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
|
||||
* along the way. Does NOT stop at compaction boundaries - those are included and their
|
||||
* summaries become context.
|
||||
*
|
||||
* @param session - Session manager (read-only access)
|
||||
* @param oldLeafId - Current position (where we're navigating from)
|
||||
* @param targetId - Target position (where we're navigating to)
|
||||
* @returns Entries to summarize and the common ancestor
|
||||
*/
|
||||
export function collectEntriesForBranchSummary(
|
||||
session: ReadonlySessionManager,
|
||||
oldLeafId: string | null,
|
||||
targetId: string,
|
||||
): CollectEntriesResult {
|
||||
// If no old position, nothing to summarize
|
||||
if (!oldLeafId) {
|
||||
return { entries: [], commonAncestorId: null };
|
||||
}
|
||||
|
||||
// Find common ancestor (deepest node that's on both paths)
|
||||
const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));
|
||||
const targetPath = session.getBranch(targetId);
|
||||
|
||||
// targetPath is root-first, so iterate backwards to find deepest common ancestor
|
||||
let commonAncestorId: string | null = null;
|
||||
for (let i = targetPath.length - 1; i >= 0; i--) {
|
||||
if (oldPath.has(targetPath[i].id)) {
|
||||
commonAncestorId = targetPath[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect entries from old leaf back to common ancestor
|
||||
const entries: SessionEntry[] = [];
|
||||
let current: string | null = oldLeafId;
|
||||
|
||||
while (current && current !== commonAncestorId) {
|
||||
const entry = session.getEntry(current);
|
||||
if (!entry) break;
|
||||
entries.push(entry);
|
||||
current = entry.parentId;
|
||||
}
|
||||
|
||||
// Reverse to get chronological order
|
||||
entries.reverse();
|
||||
|
||||
return { entries, commonAncestorId };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entry to Message Conversion
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract AgentMessage from a session entry.
|
||||
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
|
||||
*/
|
||||
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
// Skip tool results - context is in assistant's tool call
|
||||
if (entry.message.role === "toolResult") return undefined;
|
||||
return entry.message;
|
||||
|
||||
case "custom_message":
|
||||
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
|
||||
case "branch_summary":
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
|
||||
case "compaction":
|
||||
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
|
||||
|
||||
// These don't contribute to conversation content
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "custom":
|
||||
case "label":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare entries for summarization with token budget.
|
||||
*
|
||||
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
|
||||
* This ensures we keep the most recent context when the branch is too long.
|
||||
*
|
||||
* Also collects file operations from:
|
||||
* - Tool calls in assistant messages
|
||||
* - Existing branch_summary entries' details (for cumulative tracking)
|
||||
*
|
||||
* @param entries - Entries in chronological order
|
||||
* @param tokenBudget - Maximum tokens to include (0 = no limit)
|
||||
*/
|
||||
export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {
|
||||
const messages: AgentMessage[] = [];
|
||||
const fileOps = createFileOps();
|
||||
let totalTokens = 0;
|
||||
|
||||
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
|
||||
// This ensures we capture cumulative file tracking from nested branch summaries
|
||||
// Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
|
||||
const details = entry.details as BranchSummaryDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) fileOps.read.add(f);
|
||||
}
|
||||
if (Array.isArray(details.modifiedFiles)) {
|
||||
// Modified files go into both edited and written for proper deduplication
|
||||
for (const f of details.modifiedFiles) {
|
||||
fileOps.edited.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: walk from newest to oldest, adding messages until token budget
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
const message = getMessageFromEntry(entry);
|
||||
if (!message) continue;
|
||||
|
||||
// Extract file ops from assistant messages (tool calls)
|
||||
extractFileOpsFromMessage(message, fileOps);
|
||||
|
||||
const tokens = estimateTokens(message);
|
||||
|
||||
// Check budget before adding
|
||||
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
|
||||
// If this is a summary entry, try to fit it anyway as it's important context
|
||||
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
||||
if (totalTokens < tokenBudget * 0.9) {
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
}
|
||||
// Stop - we've hit the budget
|
||||
break;
|
||||
}
|
||||
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
|
||||
return { messages, fileOps, totalTokens };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summary Generation
|
||||
// ============================================================================
|
||||
|
||||
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
|
||||
Summary of that exploration:
|
||||
|
||||
`;
|
||||
|
||||
const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What was the user trying to accomplish in this branch?]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Work that was started but not finished]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [What should happen next to continue this work]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of abandoned branch entries.
|
||||
*
|
||||
* @param entries - Session entries to summarize (chronological order)
|
||||
* @param options - Generation options
|
||||
*/
|
||||
export async function generateBranchSummary(
|
||||
entries: SessionEntry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
): Promise<BranchSummaryResult> {
|
||||
const { model, apiKey, signal, customInstructions, reserveTokens = 16384 } = options;
|
||||
|
||||
// Token budget = context window minus reserved space for prompt + response
|
||||
const contextWindow = model.contextWindow || 128000;
|
||||
const tokenBudget = contextWindow - reserveTokens;
|
||||
|
||||
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { summary: "No content to summarize" };
|
||||
}
|
||||
|
||||
// Transform to LLM-compatible messages, then serialize to text
|
||||
// Serialization prevents the model from treating it as a conversation to continue
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
// Build prompt
|
||||
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
|
||||
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
// Call LLM for summarization
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ apiKey, signal, maxTokens: 2048 },
|
||||
);
|
||||
|
||||
// Check if aborted or errored
|
||||
if (response.stopReason === "aborted") {
|
||||
return { aborted: true };
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return { error: response.errorMessage || "Summarization failed" };
|
||||
}
|
||||
|
||||
let summary = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
// Prepend preamble to provide context about the branch summary
|
||||
summary = BRANCH_SUMMARY_PREAMBLE + summary;
|
||||
|
||||
// Compute file lists and append to summary
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
return {
|
||||
summary: summary || "No summary generated",
|
||||
readFiles,
|
||||
modifiedFiles,
|
||||
};
|
||||
}
|
||||
742
packages/coding-agent/src/core/compaction/compaction.ts
Normal file
742
packages/coding-agent/src/core/compaction/compaction.ts
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
/**
|
||||
* Context compaction for long sessions.
|
||||
*
|
||||
* Pure functions for compaction logic. The session manager handles I/O,
|
||||
* and after compaction the session is reloaded.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
||||
import { complete, completeSimple } from "@mariozechner/pi-ai";
|
||||
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
|
||||
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
SUMMARIZATION_SYSTEM_PROMPT,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
|
||||
// ============================================================================
|
||||
// File Operation Tracking
|
||||
// ============================================================================
|
||||
|
||||
/** Details stored in CompactionEntry.details for file tracking */
|
||||
export interface CompactionDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file operations from messages and previous compaction entries.
|
||||
*/
|
||||
function extractFileOperations(
|
||||
messages: AgentMessage[],
|
||||
entries: SessionEntry[],
|
||||
prevCompactionIndex: number,
|
||||
): FileOperations {
|
||||
const fileOps = createFileOps();
|
||||
|
||||
// Collect from previous compaction's details (if pi-generated)
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
if (!prevCompaction.fromHook && prevCompaction.details) {
|
||||
const details = prevCompaction.details as CompactionDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) fileOps.read.add(f);
|
||||
}
|
||||
if (Array.isArray(details.modifiedFiles)) {
|
||||
for (const f of details.modifiedFiles) fileOps.edited.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from tool calls in messages
|
||||
for (const msg of messages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
|
||||
return fileOps;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Extraction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract AgentMessage from an entry if it produces one.
|
||||
* Returns undefined for entries that don't contribute to LLM context.
|
||||
*/
|
||||
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
||||
if (entry.type === "message") {
|
||||
return entry.message;
|
||||
}
|
||||
if (entry.type === "custom_message") {
|
||||
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
}
|
||||
if (entry.type === "branch_summary") {
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
|
||||
export interface CompactionResult<T = unknown> {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
||||
details?: T;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionSettings {
|
||||
enabled: boolean;
|
||||
reserveTokens: number;
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Token calculation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate total context tokens from usage.
|
||||
* Uses the native totalTokens field when available, falls back to computing from components.
|
||||
*/
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage from an assistant message if available.
|
||||
* Skips aborted and error messages as they don't have valid usage data.
|
||||
*/
|
||||
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
|
||||
if (msg.role === "assistant" && "usage" in msg) {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
||||
return assistantMsg.usage;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last non-aborted assistant message usage from session entries.
|
||||
*/
|
||||
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const usage = getAssistantUsage(entry.message);
|
||||
if (usage) return usage;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compaction should trigger based on context usage.
|
||||
*/
|
||||
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
|
||||
if (!settings.enabled) return false;
|
||||
return contextTokens > contextWindow - settings.reserveTokens;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cut point detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using chars/4 heuristic.
|
||||
* This is conservative (overestimates tokens).
|
||||
*/
|
||||
export function estimateTokens(message: AgentMessage): number {
|
||||
let chars = 0;
|
||||
|
||||
switch (message.role) {
|
||||
case "user": {
|
||||
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "assistant": {
|
||||
const assistant = message as AssistantMessage;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + JSON.stringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "hookMessage":
|
||||
case "toolResult": {
|
||||
if (typeof message.content === "string") {
|
||||
chars = message.content.length;
|
||||
} else {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "bashExecution": {
|
||||
chars = message.command.length + message.output.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "branchSummary":
|
||||
case "compactionSummary": {
|
||||
chars = message.summary.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
|
||||
* Never cut at tool results (they must follow their tool call).
|
||||
* When we cut at an assistant message with tool calls, its tool results follow it
|
||||
* and will be kept.
|
||||
* BashExecutionMessage is treated like a user message (user-initiated context).
|
||||
*/
|
||||
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
|
||||
const cutPoints: number[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const entry = entries[i];
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const role = entry.message.role;
|
||||
switch (role) {
|
||||
case "bashExecution":
|
||||
case "hookMessage":
|
||||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
case "user":
|
||||
case "assistant":
|
||||
cutPoints.push(i);
|
||||
break;
|
||||
case "toolResult":
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "compaction":
|
||||
case "branch_summary":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "label":
|
||||
}
|
||||
// branch_summary and custom_message are user-role messages, valid cut points
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
cutPoints.push(i);
|
||||
}
|
||||
}
|
||||
return cutPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
||||
* Returns -1 if no turn start found before the index.
|
||||
* BashExecutionMessage is treated like a user message for turn boundaries.
|
||||
*/
|
||||
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
|
||||
for (let i = entryIndex; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
// branch_summary and custom_message are user-role messages, can start a turn
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
return i;
|
||||
}
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
if (role === "user" || role === "bashExecution") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface CutPointResult {
|
||||
/** Index of first entry to keep */
|
||||
firstKeptEntryIndex: number;
|
||||
/** Index of user message that starts the turn being split, or -1 if not splitting */
|
||||
turnStartIndex: number;
|
||||
/** Whether this cut splits a turn (cut point is not a user message) */
|
||||
isSplitTurn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
|
||||
*
|
||||
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
|
||||
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
|
||||
*
|
||||
* Can cut at user OR assistant messages (never tool results). When cutting at an
|
||||
* assistant message with tool calls, its tool results come after and will be kept.
|
||||
*
|
||||
* Returns CutPointResult with:
|
||||
* - firstKeptEntryIndex: the entry index to start keeping from
|
||||
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
|
||||
* - isSplitTurn: whether we're cutting in the middle of a turn
|
||||
*
|
||||
* Only considers entries between `startIndex` and `endIndex` (exclusive).
|
||||
*/
|
||||
export function findCutPoint(
|
||||
entries: SessionEntry[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
keepRecentTokens: number,
|
||||
): CutPointResult {
|
||||
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
|
||||
|
||||
if (cutPoints.length === 0) {
|
||||
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
||||
}
|
||||
|
||||
// Walk backwards from newest, accumulating estimated message sizes
|
||||
let accumulatedTokens = 0;
|
||||
let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
|
||||
|
||||
for (let i = endIndex - 1; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Estimate this message's size
|
||||
const messageTokens = estimateTokens(entry.message);
|
||||
accumulatedTokens += messageTokens;
|
||||
|
||||
// Check if we've exceeded the budget
|
||||
if (accumulatedTokens >= keepRecentTokens) {
|
||||
// Find the closest valid cut point at or after this entry
|
||||
for (let c = 0; c < cutPoints.length; c++) {
|
||||
if (cutPoints[c] >= i) {
|
||||
cutIndex = cutPoints[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
|
||||
while (cutIndex > startIndex) {
|
||||
const prevEntry = entries[cutIndex - 1];
|
||||
// Stop at session header or compaction boundaries
|
||||
if (prevEntry.type === "compaction") {
|
||||
break;
|
||||
}
|
||||
if (prevEntry.type === "message") {
|
||||
// Stop if we hit any message
|
||||
break;
|
||||
}
|
||||
// Include this non-message entry (bash, settings change, etc.)
|
||||
cutIndex--;
|
||||
}
|
||||
|
||||
// Determine if this is a split turn
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
|
||||
return {
|
||||
firstKeptEntryIndex: cutIndex,
|
||||
turnStartIndex,
|
||||
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization
|
||||
// ============================================================================
|
||||
|
||||
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned by user]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [Ordered list of what should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
|
||||
|
||||
Update the existing structured summary with new information. RULES:
|
||||
- PRESERVE all existing information from the previous summary
|
||||
- ADD new progress, decisions, and context from the new messages
|
||||
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
|
||||
- UPDATE "Next Steps" based on what was accomplished
|
||||
- PRESERVE exact file paths, function names, and error messages
|
||||
- If something is no longer relevant, you may remove it
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[Preserve existing goals, add new ones if the task expanded]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Preserve existing, add new ones discovered]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Include previously done items AND newly completed items]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work - update based on progress]
|
||||
|
||||
### Blocked
|
||||
- [Current blockers - remove if resolved]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
|
||||
|
||||
## Next Steps
|
||||
1. [Update based on current state]
|
||||
|
||||
## Critical Context
|
||||
- [Preserve important context, add new if needed]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of the conversation using the LLM.
|
||||
* If previousSummary is provided, uses the update prompt to merge.
|
||||
*/
|
||||
export async function generateSummary(
|
||||
currentMessages: AgentMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.8 * reserveTokens);
|
||||
|
||||
// Use update prompt if we have a previous summary, otherwise initial prompt
|
||||
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
|
||||
if (customInstructions) {
|
||||
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
|
||||
}
|
||||
|
||||
// Serialize conversation to text so model doesn't try to continue it
|
||||
// Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.)
|
||||
const llmMessages = convertToLlm(currentMessages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
// Build the prompt with conversation wrapped in tags
|
||||
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
||||
if (previousSummary) {
|
||||
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
|
||||
}
|
||||
promptText += basePrompt;
|
||||
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ maxTokens, signal, apiKey, reasoning: "high" },
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
|
||||
}
|
||||
|
||||
const textContent = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
return textContent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compaction Preparation (for hooks)
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionPreparation {
|
||||
/** UUID of first entry to keep */
|
||||
firstKeptEntryId: string;
|
||||
/** Messages that will be summarized and discarded */
|
||||
messagesToSummarize: AgentMessage[];
|
||||
/** Messages that will be turned into turn prefix summary (if splitting) */
|
||||
turnPrefixMessages: AgentMessage[];
|
||||
/** Whether this is a split turn (cut point in middle of turn) */
|
||||
isSplitTurn: boolean;
|
||||
tokensBefore: number;
|
||||
/** Summary from previous compaction, for iterative update */
|
||||
previousSummary?: string;
|
||||
/** File operations extracted from messagesToSummarize */
|
||||
fileOps: FileOperations;
|
||||
/** Compaction settions from settings.jsonl */
|
||||
settings: CompactionSettings;
|
||||
}
|
||||
|
||||
export function prepareCompaction(
|
||||
pathEntries: SessionEntry[],
|
||||
settings: CompactionSettings,
|
||||
): CompactionPreparation | undefined {
|
||||
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
||||
if (pathEntries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const boundaryStart = prevCompactionIndex + 1;
|
||||
const boundaryEnd = pathEntries.length;
|
||||
|
||||
const lastUsage = getLastAssistantUsage(pathEntries);
|
||||
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
||||
|
||||
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
|
||||
// Get UUID of first kept entry
|
||||
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
|
||||
if (!firstKeptEntry?.id) {
|
||||
return undefined; // Session needs migration
|
||||
}
|
||||
const firstKeptEntryId = firstKeptEntry.id;
|
||||
|
||||
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
||||
|
||||
// Messages to summarize (will be discarded after summary)
|
||||
const messagesToSummarize: AgentMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const msg = getMessageFromEntry(pathEntries[i]);
|
||||
if (msg) messagesToSummarize.push(msg);
|
||||
}
|
||||
|
||||
// Messages for turn prefix summary (if splitting a turn)
|
||||
const turnPrefixMessages: AgentMessage[] = [];
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
|
||||
const msg = getMessageFromEntry(pathEntries[i]);
|
||||
if (msg) turnPrefixMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Get previous summary for iterative update
|
||||
let previousSummary: string | undefined;
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
|
||||
previousSummary = prevCompaction.summary;
|
||||
}
|
||||
|
||||
// Extract file operations from messages and previous compaction
|
||||
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
||||
|
||||
// Also extract file ops from turn prefix if splitting
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (const msg of turnPrefixMessages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
isSplitTurn: cutPoint.isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main compaction function
|
||||
// ============================================================================
|
||||
|
||||
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
||||
|
||||
Summarize the prefix to provide context for the retained suffix:
|
||||
|
||||
## Original Request
|
||||
[What did the user ask for in this turn?]
|
||||
|
||||
## Early Progress
|
||||
- [Key decisions and work done in the prefix]
|
||||
|
||||
## Context for Suffix
|
||||
- [Information needed to understand the retained recent work]
|
||||
|
||||
Be concise. Focus on what's needed to understand the kept suffix.`;
|
||||
|
||||
/**
|
||||
* Generate summaries for compaction using prepared data.
|
||||
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
|
||||
*
|
||||
* @param preparation - Pre-calculated preparation from prepareCompaction()
|
||||
* @param customInstructions - Optional custom focus for the summary
|
||||
*/
|
||||
export async function compact(
|
||||
preparation: CompactionPreparation,
|
||||
model: Model<any>,
|
||||
apiKey: string,
|
||||
customInstructions?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
const {
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
} = preparation;
|
||||
|
||||
// Generate summaries (can be parallel if both needed) and merge into one
|
||||
let summary: string;
|
||||
|
||||
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
// Generate both summaries in parallel
|
||||
const [historyResult, turnPrefixResult] = await Promise.all([
|
||||
messagesToSummarize.length > 0
|
||||
? generateSummary(
|
||||
messagesToSummarize,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
)
|
||||
: Promise.resolve("No prior history."),
|
||||
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
|
||||
]);
|
||||
// Merge into single summary
|
||||
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
|
||||
} else {
|
||||
// Just generate history summary
|
||||
summary = await generateSummary(
|
||||
messagesToSummarize,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
);
|
||||
}
|
||||
|
||||
// Compute file lists and append to summary
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
if (!firstKeptEntryId) {
|
||||
throw new Error("First kept entry has no UUID - session may need migration");
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
details: { readFiles, modifiedFiles } as CompactionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary for a turn prefix (when splitting a turn).
|
||||
*/
|
||||
async function generateTurnPrefixSummary(
|
||||
messages: AgentMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
||||
|
||||
const transformedMessages = convertToLlm(messages);
|
||||
const summarizationMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
|
||||
}
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
7
packages/coding-agent/src/core/compaction/index.ts
Normal file
7
packages/coding-agent/src/core/compaction/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Compaction and summarization utilities.
|
||||
*/
|
||||
|
||||
export * from "./branch-summarization.js";
|
||||
export * from "./compaction.js";
|
||||
export * from "./utils.js";
|
||||
154
packages/coding-agent/src/core/compaction/utils.ts
Normal file
154
packages/coding-agent/src/core/compaction/utils.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Shared utilities for compaction and branch summarization.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
|
||||
// ============================================================================
|
||||
// File Operation Tracking
|
||||
// ============================================================================
|
||||
|
||||
export interface FileOperations {
|
||||
read: Set<string>;
|
||||
written: Set<string>;
|
||||
edited: Set<string>;
|
||||
}
|
||||
|
||||
export function createFileOps(): FileOperations {
|
||||
return {
|
||||
read: new Set(),
|
||||
written: new Set(),
|
||||
edited: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file operations from tool calls in an assistant message.
|
||||
*/
|
||||
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
||||
if (message.role !== "assistant") return;
|
||||
if (!("content" in message) || !Array.isArray(message.content)) return;
|
||||
|
||||
for (const block of message.content) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
if (!("type" in block) || block.type !== "toolCall") continue;
|
||||
if (!("arguments" in block) || !("name" in block)) continue;
|
||||
|
||||
const args = block.arguments as Record<string, unknown> | undefined;
|
||||
if (!args) continue;
|
||||
|
||||
const path = typeof args.path === "string" ? args.path : undefined;
|
||||
if (!path) continue;
|
||||
|
||||
switch (block.name) {
|
||||
case "read":
|
||||
fileOps.read.add(path);
|
||||
break;
|
||||
case "write":
|
||||
fileOps.written.add(path);
|
||||
break;
|
||||
case "edit":
|
||||
fileOps.edited.add(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute final file lists from file operations.
|
||||
* Returns readFiles (files only read, not modified) and modifiedFiles.
|
||||
*/
|
||||
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
|
||||
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
||||
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
||||
const modifiedFiles = [...modified].sort();
|
||||
return { readFiles: readOnly, modifiedFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file operations as XML tags for summary.
|
||||
*/
|
||||
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
|
||||
const sections: string[] = [];
|
||||
if (readFiles.length > 0) {
|
||||
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
||||
}
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
||||
}
|
||||
if (sections.length === 0) return "";
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Serialization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Serialize LLM messages to text for summarization.
|
||||
* This prevents the model from treating it as a conversation to continue.
|
||||
* Call convertToLlm() first to handle custom message types.
|
||||
*/
|
||||
export function serializeConversation(messages: Message[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (content) parts.push(`[User]: ${content}`);
|
||||
} else if (msg.role === "assistant") {
|
||||
const textParts: string[] = [];
|
||||
const thinkingParts: string[] = [];
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
textParts.push(block.text);
|
||||
} else if (block.type === "thinking") {
|
||||
thinkingParts.push(block.thinking);
|
||||
} else if (block.type === "toolCall") {
|
||||
const args = block.arguments as Record<string, unknown>;
|
||||
const argsStr = Object.entries(args)
|
||||
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
||||
.join(", ");
|
||||
toolCalls.push(`${block.name}(${argsStr})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkingParts.length > 0) {
|
||||
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
const content = msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (content) {
|
||||
parts.push(`[Tool result]: ${content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization System Prompt
|
||||
// ============================================================================
|
||||
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
|
@ -4,14 +4,18 @@
|
|||
|
||||
export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js";
|
||||
export type {
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
CustomAgentTool,
|
||||
CustomTool,
|
||||
CustomToolAPI,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolResult,
|
||||
CustomToolSessionEvent,
|
||||
CustomToolsLoadResult,
|
||||
CustomToolUIContext,
|
||||
ExecResult,
|
||||
LoadedCustomTool,
|
||||
RenderResultOptions,
|
||||
SessionEvent,
|
||||
ToolAPI,
|
||||
ToolUIContext,
|
||||
} from "./types.js";
|
||||
export { wrapCustomTool, wrapCustomTools } from "./wrapper.js";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
* for custom tools that depend on pi packages.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as os from "node:os";
|
||||
|
|
@ -15,15 +14,10 @@ import * as path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import { getAgentDir, isBunBinary } from "../../config.js";
|
||||
import type { ExecOptions } from "../exec.js";
|
||||
import { execCommand } from "../exec.js";
|
||||
import type { HookUIContext } from "../hooks/types.js";
|
||||
import type {
|
||||
CustomToolFactory,
|
||||
CustomToolsLoadResult,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
LoadedCustomTool,
|
||||
ToolAPI,
|
||||
} from "./types.js";
|
||||
import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js";
|
||||
|
||||
// Create require function to resolve module paths at runtime
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
@ -87,97 +81,18 @@ function resolveToolPath(toolPath: string, cwd: string): string {
|
|||
return path.resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout/stderr/code.
|
||||
* Supports cancellation via AbortSignal and timeout.
|
||||
*/
|
||||
async function execCommand(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, {
|
||||
cwd,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killed = false;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const killProcess = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
proc.kill("SIGTERM");
|
||||
// Force kill after 5 seconds if SIGTERM doesn't work
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle abort signal
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
killProcess();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", killProcess, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle timeout
|
||||
if (options?.timeout && options.timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
killProcess();
|
||||
}, options.timeout);
|
||||
}
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
code: code ?? 0,
|
||||
killed,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
stderr: stderr || err.message,
|
||||
code: 1,
|
||||
killed,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op UI context for headless modes.
|
||||
*/
|
||||
function createNoOpUIContext(): HookUIContext {
|
||||
return {
|
||||
select: async () => null,
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => null,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +106,7 @@ function createNoOpUIContext(): HookUIContext {
|
|||
*/
|
||||
async function loadToolWithBun(
|
||||
resolvedPath: string,
|
||||
sharedApi: ToolAPI,
|
||||
sharedApi: CustomToolAPI,
|
||||
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
|
||||
try {
|
||||
// Try to import directly - will work for tools without @mariozechner/* imports
|
||||
|
|
@ -236,7 +151,7 @@ async function loadToolWithBun(
|
|||
async function loadTool(
|
||||
toolPath: string,
|
||||
cwd: string,
|
||||
sharedApi: ToolAPI,
|
||||
sharedApi: CustomToolAPI,
|
||||
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
|
||||
const resolvedPath = resolveToolPath(toolPath, cwd);
|
||||
|
||||
|
|
@ -296,9 +211,10 @@ export async function loadCustomTools(
|
|||
const seenNames = new Set<string>(builtInToolNames);
|
||||
|
||||
// Shared API object - all tools get the same instance
|
||||
const sharedApi: ToolAPI = {
|
||||
const sharedApi: CustomToolAPI = {
|
||||
cwd,
|
||||
exec: (command: string, args: string[], options?: ExecOptions) => execCommand(command, args, cwd, options),
|
||||
exec: (command: string, args: string[], options?: ExecOptions) =>
|
||||
execCommand(command, args, options?.cwd ?? cwd, options),
|
||||
ui: createNoOpUIContext(),
|
||||
hasUI: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,56 +5,56 @@
|
|||
* They can provide custom rendering for tool calls and results in the TUI.
|
||||
*/
|
||||
|
||||
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-ai";
|
||||
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { HookUIContext } from "../hooks/types.js";
|
||||
import type { SessionEntry } from "../session-manager.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { ReadonlySessionManager } from "../session-manager.js";
|
||||
|
||||
/** Alias for clarity */
|
||||
export type ToolUIContext = HookUIContext;
|
||||
export type CustomToolUIContext = HookUIContext;
|
||||
|
||||
/** Re-export for custom tools to use in execute signature */
|
||||
export type { AgentToolUpdateCallback };
|
||||
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
/** True if the process was killed due to signal or timeout */
|
||||
killed?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
/** AbortSignal to cancel the process */
|
||||
signal?: AbortSignal;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
// Re-export for backward compatibility
|
||||
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||
|
||||
/** API passed to custom tool factory (stable across session changes) */
|
||||
export interface ToolAPI {
|
||||
export interface CustomToolAPI {
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Execute a command */
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
/** UI methods for user interaction (select, confirm, input, notify) */
|
||||
ui: ToolUIContext;
|
||||
/** UI methods for user interaction (select, confirm, input, notify, custom) */
|
||||
ui: CustomToolUIContext;
|
||||
/** Whether UI is available (false in print/RPC mode) */
|
||||
hasUI: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to tool execute and onSession callbacks.
|
||||
* Provides access to session state and model information.
|
||||
*/
|
||||
export interface CustomToolContext {
|
||||
/** Session manager (read-only) */
|
||||
sessionManager: ReadonlySessionManager;
|
||||
/** Model registry - use for API key resolution and model retrieval */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Current model (may be undefined if no model is selected yet) */
|
||||
model: Model<any> | undefined;
|
||||
}
|
||||
|
||||
/** Session event passed to onSession callback */
|
||||
export interface SessionEvent {
|
||||
/** All session entries (including pre-compaction history) */
|
||||
entries: SessionEntry[];
|
||||
/** Current session file path, or null in --no-session mode */
|
||||
sessionFile: string | null;
|
||||
/** Previous session file path, or null for "start" and "new" */
|
||||
previousSessionFile: string | null;
|
||||
export interface CustomToolSessionEvent {
|
||||
/** Reason for the session event */
|
||||
reason: "start" | "switch" | "branch" | "new";
|
||||
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
|
||||
/** Previous session file path, or undefined for "start", "new", and "shutdown" */
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Rendering options passed to renderResult */
|
||||
|
|
@ -65,60 +65,89 @@ export interface RenderResultOptions {
|
|||
isPartial: boolean;
|
||||
}
|
||||
|
||||
export type CustomToolResult<TDetails = any> = AgentToolResult<TDetails>;
|
||||
|
||||
/**
|
||||
* Custom tool with optional lifecycle and rendering methods.
|
||||
* Custom tool definition.
|
||||
*
|
||||
* The execute signature inherited from AgentTool includes an optional onUpdate callback
|
||||
* for streaming progress updates during long-running operations:
|
||||
* - The callback emits partial results to subscribers (e.g. TUI/RPC), not to the LLM.
|
||||
* - Partial updates should use the same TDetails type as the final result (use a union if needed).
|
||||
* Custom tools are standalone - they don't extend AgentTool directly.
|
||||
* When loaded, they are wrapped in an AgentTool for the agent to use.
|
||||
*
|
||||
* The execute callback receives a ToolContext with access to session state,
|
||||
* model registry, and current model.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* type Details =
|
||||
* | { status: "running"; step: number; total: number }
|
||||
* | { status: "done"; count: number };
|
||||
* const factory: CustomToolFactory = (pi) => ({
|
||||
* name: "my_tool",
|
||||
* label: "My Tool",
|
||||
* description: "Does something useful",
|
||||
* parameters: Type.Object({ input: Type.String() }),
|
||||
*
|
||||
* async execute(toolCallId, params, signal, onUpdate) {
|
||||
* const items = params.items || [];
|
||||
* for (let i = 0; i < items.length; i++) {
|
||||
* onUpdate?.({
|
||||
* content: [{ type: "text", text: `Step ${i + 1}/${items.length}...` }],
|
||||
* details: { status: "running", step: i + 1, total: items.length },
|
||||
* });
|
||||
* await processItem(items[i], signal);
|
||||
* async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
* // Access session state via ctx.sessionManager
|
||||
* // Access model registry via ctx.modelRegistry
|
||||
* // Current model via ctx.model
|
||||
* return { content: [{ type: "text", text: "Done" }] };
|
||||
* },
|
||||
*
|
||||
* onSession(event, ctx) {
|
||||
* if (event.reason === "shutdown") {
|
||||
* // Cleanup
|
||||
* }
|
||||
* // Reconstruct state from ctx.sessionManager.getEntries()
|
||||
* }
|
||||
* return { content: [{ type: "text", text: "Done" }], details: { status: "done", count: items.length } };
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Progress updates are rendered via renderResult with isPartial: true.
|
||||
*/
|
||||
export interface CustomAgentTool<TParams extends TSchema = TSchema, TDetails = any>
|
||||
extends AgentTool<TParams, TDetails> {
|
||||
/** Called on session start/switch/branch/clear - use to reconstruct state from entries */
|
||||
onSession?: (event: SessionEvent) => void | Promise<void>;
|
||||
export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
|
||||
/** Tool name (used in LLM tool calls) */
|
||||
name: string;
|
||||
/** Human-readable label for UI */
|
||||
label: string;
|
||||
/** Description for LLM */
|
||||
description: string;
|
||||
/** Parameter schema (TypeBox) */
|
||||
parameters: TParams;
|
||||
|
||||
/**
|
||||
* Execute the tool.
|
||||
* @param toolCallId - Unique ID for this tool call
|
||||
* @param params - Parsed parameters matching the schema
|
||||
* @param onUpdate - Callback for streaming partial results (for UI, not LLM)
|
||||
* @param ctx - Context with session manager, model registry, and current model
|
||||
* @param signal - Optional abort signal for cancellation
|
||||
*/
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParams>,
|
||||
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||
ctx: CustomToolContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
|
||||
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
|
||||
onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
|
||||
/** Custom rendering for tool call display - return a Component */
|
||||
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
|
||||
|
||||
/** Custom rendering for tool result display - return a Component */
|
||||
renderResult?: (result: AgentToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
|
||||
/** Called when session ends - cleanup resources */
|
||||
dispose?: () => Promise<void> | void;
|
||||
renderResult?: (result: CustomToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
|
||||
}
|
||||
|
||||
/** Factory function that creates a custom tool or array of tools */
|
||||
export type CustomToolFactory = (
|
||||
pi: ToolAPI,
|
||||
) => CustomAgentTool<any> | CustomAgentTool[] | Promise<CustomAgentTool | CustomAgentTool[]>;
|
||||
pi: CustomToolAPI,
|
||||
) => CustomTool<any, any> | CustomTool<any, any>[] | Promise<CustomTool<any, any> | CustomTool<any, any>[]>;
|
||||
|
||||
/** Loaded custom tool with metadata */
|
||||
/** Loaded custom tool with metadata and wrapped AgentTool */
|
||||
export interface LoadedCustomTool {
|
||||
/** Original path (as specified) */
|
||||
path: string;
|
||||
/** Resolved absolute path */
|
||||
resolvedPath: string;
|
||||
/** The tool instance */
|
||||
tool: CustomAgentTool;
|
||||
/** The original custom tool instance */
|
||||
tool: CustomTool;
|
||||
}
|
||||
|
||||
/** Result from loading custom tools */
|
||||
|
|
@ -126,5 +155,5 @@ export interface CustomToolsLoadResult {
|
|||
tools: LoadedCustomTool[];
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
/** Update the UI context for all loaded tools. Call when mode initializes. */
|
||||
setUIContext(uiContext: ToolUIContext, hasUI: boolean): void;
|
||||
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
|
||||
}
|
||||
|
|
|
|||
28
packages/coding-agent/src/core/custom-tools/wrapper.ts
Normal file
28
packages/coding-agent/src/core/custom-tools/wrapper.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Wraps CustomTool instances into AgentTool for use with the agent.
|
||||
*/
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Wrap a CustomTool into an AgentTool.
|
||||
* The wrapper injects the ToolContext into execute calls.
|
||||
*/
|
||||
export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool {
|
||||
return {
|
||||
name: tool.name,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
execute: (toolCallId, params, signal, onUpdate) =>
|
||||
tool.execute(toolCallId, params, onUpdate, getContext(), signal),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap all loaded custom tools into AgentTools.
|
||||
*/
|
||||
export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] {
|
||||
return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext));
|
||||
}
|
||||
104
packages/coding-agent/src/core/exec.ts
Normal file
104
packages/coding-agent/src/core/exec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Shared command execution utilities for hooks and custom tools.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Options for executing shell commands.
|
||||
*/
|
||||
export interface ExecOptions {
|
||||
/** AbortSignal to cancel the command */
|
||||
signal?: AbortSignal;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a shell command.
|
||||
*/
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
killed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command and return stdout/stderr/code.
|
||||
* Supports timeout and abort signal.
|
||||
*/
|
||||
export async function execCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
options?: ExecOptions,
|
||||
): Promise<ExecResult> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, {
|
||||
cwd,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killed = false;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const killProcess = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
proc.kill("SIGTERM");
|
||||
// Force kill after 5 seconds if SIGTERM doesn't work
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle abort signal
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
killProcess();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", killProcess, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle timeout
|
||||
if (options?.timeout && options.timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
killProcess();
|
||||
}, options.timeout);
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: code ?? 0, killed });
|
||||
});
|
||||
|
||||
proc.on("error", (_err) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: 1, killed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AgentState } from "@mariozechner/pi-agent-core";
|
||||
import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import hljs from "highlight.js";
|
||||
|
|
@ -7,7 +7,6 @@ import { homedir } from "os";
|
|||
import * as path from "path";
|
||||
import { basename } from "path";
|
||||
import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js";
|
||||
import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js";
|
||||
import type { SessionManager } from "./session-manager.js";
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -122,7 +121,7 @@ function resolveColorValue(
|
|||
}
|
||||
|
||||
/** Load theme JSON from built-in or custom themes directory. */
|
||||
function loadThemeJson(name: string): ThemeJson | null {
|
||||
function loadThemeJson(name: string): ThemeJson | undefined {
|
||||
// Try built-in themes first
|
||||
const themesDir = getThemesDir();
|
||||
const builtinPath = path.join(themesDir, `${name}.json`);
|
||||
|
|
@ -130,7 +129,7 @@ function loadThemeJson(name: string): ThemeJson | null {
|
|||
try {
|
||||
return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson;
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,11 +140,11 @@ function loadThemeJson(name: string): ThemeJson | null {
|
|||
try {
|
||||
return JSON.parse(readFileSync(customPath, "utf-8")) as ThemeJson;
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Build complete theme colors object, resolving theme JSON values against defaults. */
|
||||
|
|
@ -821,110 +820,138 @@ function formatToolExecution(
|
|||
return { html, bgColor };
|
||||
}
|
||||
|
||||
function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>, colors: ThemeColors): string {
|
||||
function formatMessage(
|
||||
message: AgentMessage,
|
||||
toolResultsMap: Map<string, ToolResultMessage>,
|
||||
colors: ThemeColors,
|
||||
): string {
|
||||
let html = "";
|
||||
const timestamp = (message as { timestamp?: number }).timestamp;
|
||||
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
|
||||
|
||||
// Handle bash execution messages (user-executed via ! command)
|
||||
if (isBashExecutionMessage(message)) {
|
||||
const bashMsg = message as unknown as BashExecutionMessage;
|
||||
const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
|
||||
switch (message.role) {
|
||||
case "bashExecution": {
|
||||
const isError =
|
||||
message.cancelled ||
|
||||
(message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined);
|
||||
|
||||
html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
|
||||
html += timestampHtml;
|
||||
html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
|
||||
html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
|
||||
html += timestampHtml;
|
||||
html += `<div class="tool-command">$ ${escapeHtml(message.command)}</div>`;
|
||||
|
||||
if (bashMsg.output) {
|
||||
const lines = bashMsg.output.split("\n");
|
||||
html += formatExpandableOutput(lines, 10);
|
||||
if (message.output) {
|
||||
const lines = message.output.split("\n");
|
||||
html += formatExpandableOutput(lines, 10);
|
||||
}
|
||||
|
||||
if (message.cancelled) {
|
||||
html += `<div class="bash-status warning">(cancelled)</div>`;
|
||||
} else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) {
|
||||
html += `<div class="bash-status error">(exit ${message.exitCode})</div>`;
|
||||
}
|
||||
|
||||
if (message.truncated && message.fullOutputPath) {
|
||||
html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(message.fullOutputPath)}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
break;
|
||||
}
|
||||
case "user": {
|
||||
const userMsg = message as UserMessage;
|
||||
let textContent = "";
|
||||
const images: ImageContent[] = [];
|
||||
|
||||
if (bashMsg.cancelled) {
|
||||
html += `<div class="bash-status warning">(cancelled)</div>`;
|
||||
} else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
|
||||
html += `<div class="bash-status error">(exit ${bashMsg.exitCode})</div>`;
|
||||
}
|
||||
|
||||
if (bashMsg.truncated && bashMsg.fullOutputPath) {
|
||||
html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
const userMsg = message as UserMessage;
|
||||
let textContent = "";
|
||||
const images: ImageContent[] = [];
|
||||
|
||||
if (typeof userMsg.content === "string") {
|
||||
textContent = userMsg.content;
|
||||
} else {
|
||||
for (const block of userMsg.content) {
|
||||
if (block.type === "text") {
|
||||
textContent += block.text;
|
||||
} else if (block.type === "image") {
|
||||
images.push(block as ImageContent);
|
||||
if (typeof userMsg.content === "string") {
|
||||
textContent = userMsg.content;
|
||||
} else {
|
||||
for (const block of userMsg.content) {
|
||||
if (block.type === "text") {
|
||||
textContent += block.text;
|
||||
} else if (block.type === "image") {
|
||||
images.push(block as ImageContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += `<div class="user-message">${timestampHtml}`;
|
||||
html += `<div class="user-message">${timestampHtml}`;
|
||||
|
||||
// Render images first
|
||||
if (images.length > 0) {
|
||||
html += `<div class="message-images">`;
|
||||
for (const img of images) {
|
||||
html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
|
||||
// Render images first
|
||||
if (images.length > 0) {
|
||||
html += `<div class="message-images">`;
|
||||
for (const img of images) {
|
||||
html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Render text as markdown (server-side)
|
||||
if (textContent.trim()) {
|
||||
html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
|
||||
|
||||
// Render text as markdown (server-side)
|
||||
if (textContent.trim()) {
|
||||
html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
} else if (message.role === "assistant") {
|
||||
const assistantMsg = message as AssistantMessage;
|
||||
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
|
||||
|
||||
for (const content of assistantMsg.content) {
|
||||
if (content.type === "text" && content.text.trim()) {
|
||||
// Render markdown server-side
|
||||
html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
|
||||
} else if (content.type === "thinking" && content.thinking.trim()) {
|
||||
html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text" && content.text.trim()) {
|
||||
// Render markdown server-side
|
||||
html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
|
||||
} else if (content.type === "thinking" && content.thinking.trim()) {
|
||||
html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const content of assistantMsg.content) {
|
||||
if (content.type === "toolCall") {
|
||||
const toolResult = toolResultsMap.get(content.id);
|
||||
const { html: toolHtml, bgColor } = formatToolExecution(
|
||||
content.name,
|
||||
content.arguments as Record<string, unknown>,
|
||||
toolResult,
|
||||
colors,
|
||||
);
|
||||
html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
|
||||
for (const content of message.content) {
|
||||
if (content.type === "toolCall") {
|
||||
const toolResult = toolResultsMap.get(content.id);
|
||||
const { html: toolHtml, bgColor } = formatToolExecution(
|
||||
content.name,
|
||||
content.arguments as Record<string, unknown>,
|
||||
toolResult,
|
||||
colors,
|
||||
);
|
||||
html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall");
|
||||
if (!hasToolCalls) {
|
||||
if (assistantMsg.stopReason === "aborted") {
|
||||
html += '<div class="error-text">Aborted</div>';
|
||||
} else if (assistantMsg.stopReason === "error") {
|
||||
html += `<div class="error-text">Error: ${escapeHtml(assistantMsg.errorMessage || "Unknown error")}</div>`;
|
||||
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
|
||||
if (!hasToolCalls) {
|
||||
if (message.stopReason === "aborted") {
|
||||
html += '<div class="error-text">Aborted</div>';
|
||||
} else if (message.stopReason === "error") {
|
||||
html += `<div class="error-text">Error: ${escapeHtml(message.errorMessage || "Unknown error")}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timestampHtml) {
|
||||
html += "</div>";
|
||||
if (timestampHtml) {
|
||||
html += "</div>";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "toolResult":
|
||||
// Tool results are rendered inline with tool calls
|
||||
break;
|
||||
case "hookMessage":
|
||||
// Hook messages with display:true shown as info boxes
|
||||
if (message.display) {
|
||||
const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
||||
html += `<div class="hook-message">${timestampHtml}<div class="hook-type">[${escapeHtml(message.customType)}]</div><div class="markdown-content">${renderMarkdown(content)}</div></div>`;
|
||||
}
|
||||
break;
|
||||
case "compactionSummary":
|
||||
// Rendered separately via formatCompaction
|
||||
break;
|
||||
case "branchSummary":
|
||||
// Rendered as compaction-like summary
|
||||
html += `<div class="compaction-container expanded"><div class="compaction-content"><div class="compaction-summary"><div class="compaction-summary-header">Branch Summary</div><div class="compaction-summary-content">${escapeHtml(message.summary).replace(/\n/g, "<br>")}</div></div></div></div>`;
|
||||
break;
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustive: never = message;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -995,7 +1022,7 @@ function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeCo
|
|||
const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
|
||||
|
||||
const contextWindow = data.contextWindow || 0;
|
||||
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null;
|
||||
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : undefined;
|
||||
|
||||
let messagesHtml = "";
|
||||
for (const event of data.sessionEvents) {
|
||||
|
|
@ -1343,6 +1370,9 @@ export function exportSessionToHtml(
|
|||
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
|
||||
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
if (!sessionFile) {
|
||||
throw new Error("Cannot export in-memory session to HTML");
|
||||
}
|
||||
const content = readFileSync(sessionFile, "utf8");
|
||||
const data = parseSessionFile(content);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,13 @@
|
|||
export { discoverAndLoadHooks, type LoadedHook, type LoadHooksResult, loadHooks, type SendHandler } from "./loader.js";
|
||||
export { type HookErrorListener, HookRunner } from "./runner.js";
|
||||
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
||||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
ExecResult,
|
||||
FindToolResultEvent,
|
||||
GrepToolResultEvent,
|
||||
HookAPI,
|
||||
HookError,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookFactory,
|
||||
HookUIContext,
|
||||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
SessionEventResult,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEvent,
|
||||
ToolResultEventResult,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./types.js";
|
||||
// biome-ignore assist/source/organizeImports: biome is not smart
|
||||
export {
|
||||
isBashToolResult,
|
||||
isEditToolResult,
|
||||
isFindToolResult,
|
||||
isGrepToolResult,
|
||||
isLsToolResult,
|
||||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./types.js";
|
||||
discoverAndLoadHooks,
|
||||
loadHooks,
|
||||
type AppendEntryHandler,
|
||||
type LoadedHook,
|
||||
type LoadHooksResult,
|
||||
type SendMessageHandler,
|
||||
} from "./loader.js";
|
||||
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
|
||||
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
|
||||
export type * from "./types.js";
|
||||
export type { ReadonlySessionManager } from "../session-manager.js";
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import { createRequire } from "node:module";
|
|||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||
import { createJiti } from "jiti";
|
||||
import { getAgentDir } from "../../config.js";
|
||||
import type { HookAPI, HookFactory } from "./types.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import { execCommand } from "./runner.js";
|
||||
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js";
|
||||
|
||||
// Create require function to resolve module paths at runtime
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
@ -47,9 +48,17 @@ function getAliases(): Record<string, string> {
|
|||
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Send handler type for pi.send().
|
||||
* Send message handler type for pi.sendMessage().
|
||||
*/
|
||||
export type SendHandler = (text: string, attachments?: Attachment[]) => void;
|
||||
export type SendMessageHandler = <T = unknown>(
|
||||
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
triggerTurn?: boolean,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Append entry handler type for pi.appendEntry().
|
||||
*/
|
||||
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
|
||||
|
||||
/**
|
||||
* Registered handlers for a loaded hook.
|
||||
|
|
@ -61,8 +70,14 @@ export interface LoadedHook {
|
|||
resolvedPath: string;
|
||||
/** Map of event type to handler functions */
|
||||
handlers: Map<string, HandlerFn[]>;
|
||||
/** Set the send handler for this hook's pi.send() */
|
||||
setSendHandler: (handler: SendHandler) => void;
|
||||
/** Map of customType to hook message renderer */
|
||||
messageRenderers: Map<string, HookMessageRenderer>;
|
||||
/** Map of command name to registered command */
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
/** Set the send message handler for this hook's pi.sendMessage() */
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
/** Set the append entry handler for this hook's pi.appendEntry() */
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,32 +125,62 @@ function resolveHookPath(hookPath: string, cwd: string): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a HookAPI instance that collects handlers.
|
||||
* Returns the API and a function to set the send handler later.
|
||||
* Create a HookAPI instance that collects handlers, renderers, and commands.
|
||||
* Returns the API, maps, and a function to set the send message handler later.
|
||||
*/
|
||||
function createHookAPI(handlers: Map<string, HandlerFn[]>): {
|
||||
function createHookAPI(
|
||||
handlers: Map<string, HandlerFn[]>,
|
||||
cwd: string,
|
||||
): {
|
||||
api: HookAPI;
|
||||
setSendHandler: (handler: SendHandler) => void;
|
||||
messageRenderers: Map<string, HookMessageRenderer>;
|
||||
commands: Map<string, RegisteredCommand>;
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => void;
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
|
||||
} {
|
||||
let sendHandler: SendHandler = () => {
|
||||
let sendMessageHandler: SendMessageHandler = () => {
|
||||
// Default no-op until mode sets the handler
|
||||
};
|
||||
let appendEntryHandler: AppendEntryHandler = () => {
|
||||
// Default no-op until mode sets the handler
|
||||
};
|
||||
const messageRenderers = new Map<string, HookMessageRenderer>();
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
|
||||
const api: HookAPI = {
|
||||
// Cast to HookAPI - the implementation is more general (string event names)
|
||||
// but the interface has specific overloads for type safety in hooks
|
||||
const api = {
|
||||
on(event: string, handler: HandlerFn): void {
|
||||
const list = handlers.get(event) ?? [];
|
||||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
send(text: string, attachments?: Attachment[]): void {
|
||||
sendHandler(text, attachments);
|
||||
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void {
|
||||
sendMessageHandler(message, triggerTurn);
|
||||
},
|
||||
appendEntry<T = unknown>(customType: string, data?: T): void {
|
||||
appendEntryHandler(customType, data);
|
||||
},
|
||||
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void {
|
||||
messageRenderers.set(customType, renderer as HookMessageRenderer);
|
||||
},
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
|
||||
commands.set(name, { name, ...options });
|
||||
},
|
||||
exec(command: string, args: string[], options?: ExecOptions) {
|
||||
return execCommand(command, args, options?.cwd ?? cwd, options);
|
||||
},
|
||||
} as HookAPI;
|
||||
|
||||
return {
|
||||
api,
|
||||
setSendHandler: (handler: SendHandler) => {
|
||||
sendHandler = handler;
|
||||
messageRenderers,
|
||||
commands,
|
||||
setSendMessageHandler: (handler: SendMessageHandler) => {
|
||||
sendMessageHandler = handler;
|
||||
},
|
||||
setAppendEntryHandler: (handler: AppendEntryHandler) => {
|
||||
appendEntryHandler = handler;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -164,13 +209,24 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
|
|||
|
||||
// Create handlers map and API
|
||||
const handlers = new Map<string, HandlerFn[]>();
|
||||
const { api, setSendHandler } = createHookAPI(handlers);
|
||||
const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
|
||||
handlers,
|
||||
cwd,
|
||||
);
|
||||
|
||||
// Call factory to register handlers
|
||||
factory(api);
|
||||
|
||||
return {
|
||||
hook: { path: hookPath, resolvedPath, handlers, setSendHandler },
|
||||
hook: {
|
||||
path: hookPath,
|
||||
resolvedPath,
|
||||
handlers,
|
||||
messageRenderers,
|
||||
commands,
|
||||
setSendMessageHandler,
|
||||
setAppendEntryHandler,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -2,120 +2,46 @@
|
|||
* Hook runner - executes hooks and manages their lifecycle.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import type { LoadedHook, SendHandler } from "./loader.js";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { SessionManager } from "../session-manager.js";
|
||||
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
|
||||
import type {
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
ContextEvent,
|
||||
ContextEventResult,
|
||||
HookContext,
|
||||
HookError,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookMessageRenderer,
|
||||
HookUIContext,
|
||||
SessionEvent,
|
||||
SessionEventResult,
|
||||
RegisteredCommand,
|
||||
SessionBeforeCompactResult,
|
||||
SessionBeforeTreeResult,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEventResult,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Default timeout for hook execution (30 seconds).
|
||||
*/
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
/**
|
||||
* Listener for hook errors.
|
||||
*/
|
||||
export type HookErrorListener = (error: HookError) => void;
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout/stderr/code.
|
||||
* Supports cancellation via AbortSignal and timeout.
|
||||
*/
|
||||
async function exec(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, { cwd, shell: false });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killed = false;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const killProcess = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
proc.kill("SIGTERM");
|
||||
// Force kill after 5 seconds if SIGTERM doesn't work
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle abort signal
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
killProcess();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", killProcess, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle timeout
|
||||
if (options?.timeout && options.timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
killProcess();
|
||||
}, options.timeout);
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: code ?? 0, killed });
|
||||
});
|
||||
|
||||
proc.on("error", (_err) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({ stdout, stderr, code: 1, killed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a promise that rejects after a timeout.
|
||||
*/
|
||||
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
clear: () => clearTimeout(timeoutId),
|
||||
};
|
||||
}
|
||||
// Re-export execCommand for backward compatibility
|
||||
export { execCommand } from "../exec.js";
|
||||
|
||||
/** No-op UI context used when no UI is available */
|
||||
const noOpUIContext: HookUIContext = {
|
||||
select: async () => null,
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => null,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -126,26 +52,57 @@ export class HookRunner {
|
|||
private uiContext: HookUIContext;
|
||||
private hasUI: boolean;
|
||||
private cwd: string;
|
||||
private sessionFile: string | null;
|
||||
private timeout: number;
|
||||
private sessionManager: SessionManager;
|
||||
private modelRegistry: ModelRegistry;
|
||||
private errorListeners: Set<HookErrorListener> = new Set();
|
||||
private getModel: () => Model<any> | undefined = () => undefined;
|
||||
|
||||
constructor(hooks: LoadedHook[], cwd: string, timeout: number = DEFAULT_TIMEOUT) {
|
||||
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
|
||||
this.hooks = hooks;
|
||||
this.uiContext = noOpUIContext;
|
||||
this.hasUI = false;
|
||||
this.cwd = cwd;
|
||||
this.sessionFile = null;
|
||||
this.timeout = timeout;
|
||||
this.sessionManager = sessionManager;
|
||||
this.modelRegistry = modelRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the UI context for hooks.
|
||||
* Call this when the mode initializes and UI is available.
|
||||
* Initialize HookRunner with all required context.
|
||||
* Modes call this once the agent session is fully set up.
|
||||
*/
|
||||
setUIContext(uiContext: HookUIContext, hasUI: boolean): void {
|
||||
this.uiContext = uiContext;
|
||||
this.hasUI = hasUI;
|
||||
initialize(options: {
|
||||
/** Function to get the current model */
|
||||
getModel: () => Model<any> | undefined;
|
||||
/** Handler for hooks to send messages */
|
||||
sendMessageHandler: SendMessageHandler;
|
||||
/** Handler for hooks to append entries */
|
||||
appendEntryHandler: AppendEntryHandler;
|
||||
/** UI context for interactive prompts */
|
||||
uiContext?: HookUIContext;
|
||||
/** Whether UI is available */
|
||||
hasUI?: boolean;
|
||||
}): void {
|
||||
this.getModel = options.getModel;
|
||||
for (const hook of this.hooks) {
|
||||
hook.setSendMessageHandler(options.sendMessageHandler);
|
||||
hook.setAppendEntryHandler(options.appendEntryHandler);
|
||||
}
|
||||
this.uiContext = options.uiContext ?? noOpUIContext;
|
||||
this.hasUI = options.hasUI ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UI context (set by mode).
|
||||
*/
|
||||
getUIContext(): HookUIContext | null {
|
||||
return this.uiContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether UI is available.
|
||||
*/
|
||||
getHasUI(): boolean {
|
||||
return this.hasUI;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,23 +112,6 @@ export class HookRunner {
|
|||
return this.hooks.map((h) => h.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session file path.
|
||||
*/
|
||||
setSessionFile(sessionFile: string | null): void {
|
||||
this.sessionFile = sessionFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the send handler for all hooks' pi.send().
|
||||
* Call this when the mode initializes.
|
||||
*/
|
||||
setSendHandler(handler: SendHandler): void {
|
||||
for (const hook of this.hooks) {
|
||||
hook.setSendHandler(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to hook errors.
|
||||
* @returns Unsubscribe function
|
||||
|
|
@ -184,7 +124,10 @@ export class HookRunner {
|
|||
/**
|
||||
* Emit an error to all listeners.
|
||||
*/
|
||||
private emitError(error: HookError): void {
|
||||
/**
|
||||
* Emit an error to all error listeners.
|
||||
*/
|
||||
emitError(error: HookError): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(error);
|
||||
}
|
||||
|
|
@ -203,26 +146,90 @@ export class HookRunner {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a message renderer for the given customType.
|
||||
* Returns the first renderer found across all hooks, or undefined if none.
|
||||
*/
|
||||
getMessageRenderer(customType: string): HookMessageRenderer | undefined {
|
||||
for (const hook of this.hooks) {
|
||||
const renderer = hook.messageRenderers.get(customType);
|
||||
if (renderer) {
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered commands from all hooks.
|
||||
*/
|
||||
getRegisteredCommands(): RegisteredCommand[] {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
for (const hook of this.hooks) {
|
||||
for (const command of hook.commands.values()) {
|
||||
commands.push(command);
|
||||
}
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered command by name.
|
||||
* Returns the first command found across all hooks, or undefined if none.
|
||||
*/
|
||||
getCommand(name: string): RegisteredCommand | undefined {
|
||||
for (const hook of this.hooks) {
|
||||
const command = hook.commands.get(name);
|
||||
if (command) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the event context for handlers.
|
||||
*/
|
||||
private createContext(): HookEventContext {
|
||||
private createContext(): HookContext {
|
||||
return {
|
||||
exec: (command: string, args: string[], options?: ExecOptions) => exec(command, args, this.cwd, options),
|
||||
ui: this.uiContext,
|
||||
hasUI: this.hasUI,
|
||||
cwd: this.cwd,
|
||||
sessionFile: this.sessionFile,
|
||||
sessionManager: this.sessionManager,
|
||||
modelRegistry: this.modelRegistry,
|
||||
model: this.getModel(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all hooks.
|
||||
* Returns the result from session/tool_result events (if any handler returns one).
|
||||
* Check if event type is a session "before_*" event that can be cancelled.
|
||||
*/
|
||||
async emit(event: HookEvent): Promise<SessionEventResult | ToolResultEventResult | undefined> {
|
||||
private isSessionBeforeEvent(
|
||||
type: string,
|
||||
): type is
|
||||
| "session_before_switch"
|
||||
| "session_before_new"
|
||||
| "session_before_branch"
|
||||
| "session_before_compact"
|
||||
| "session_before_tree" {
|
||||
return (
|
||||
type === "session_before_switch" ||
|
||||
type === "session_before_new" ||
|
||||
type === "session_before_branch" ||
|
||||
type === "session_before_compact" ||
|
||||
type === "session_before_tree"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all hooks.
|
||||
* Returns the result from session before_* / tool_result events (if any handler returns one).
|
||||
*/
|
||||
async emit(
|
||||
event: HookEvent,
|
||||
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let result: SessionEventResult | ToolResultEventResult | undefined;
|
||||
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get(event.type);
|
||||
|
|
@ -230,21 +237,11 @@ export class HookRunner {
|
|||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
// No timeout for before_compact events (like tool_call, they may take a while)
|
||||
const isBeforeCompact = event.type === "session" && (event as SessionEvent).reason === "before_compact";
|
||||
let handlerResult: unknown;
|
||||
const handlerResult = await handler(event, ctx);
|
||||
|
||||
if (isBeforeCompact) {
|
||||
handlerResult = await handler(event, ctx);
|
||||
} else {
|
||||
const timeout = createTimeout(this.timeout);
|
||||
handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
||||
timeout.clear();
|
||||
}
|
||||
|
||||
// For session events, capture the result (for before_* cancellation)
|
||||
if (event.type === "session" && handlerResult) {
|
||||
result = handlerResult as SessionEventResult;
|
||||
// For session before_* events, capture the result (for cancellation)
|
||||
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
|
||||
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
|
||||
// If cancelled, stop processing further hooks
|
||||
if (result.cancel) {
|
||||
return result;
|
||||
|
|
@ -298,4 +295,79 @@ export class HookRunner {
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a context event to all hooks.
|
||||
* Handlers are chained - each gets the previous handler's output (if any).
|
||||
* Returns the final modified messages, or the original if no modifications.
|
||||
*
|
||||
* Note: Messages are already deep-copied by the caller (pi-ai preprocessor).
|
||||
*/
|
||||
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
|
||||
const ctx = this.createContext();
|
||||
let currentMessages = messages;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get("context");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const event: ContextEvent = { type: "context", messages: currentMessages };
|
||||
const handlerResult = await handler(event, ctx);
|
||||
|
||||
if (handlerResult && (handlerResult as ContextEventResult).messages) {
|
||||
currentMessages = (handlerResult as ContextEventResult).messages!;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.emitError({
|
||||
hookPath: hook.path,
|
||||
event: "context",
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit before_agent_start event to all hooks.
|
||||
* Returns the first message to inject (if any handler returns one).
|
||||
*/
|
||||
async emitBeforeAgentStart(
|
||||
prompt: string,
|
||||
images?: import("@mariozechner/pi-ai").ImageContent[],
|
||||
): Promise<BeforeAgentStartEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let result: BeforeAgentStartEventResult | undefined;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get("before_agent_start");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images };
|
||||
const handlerResult = await handler(event, ctx);
|
||||
|
||||
// Take the first message returned
|
||||
if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) {
|
||||
result = handlerResult as BeforeAgentStartEventResult;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.emitError({
|
||||
hookPath: hook.path,
|
||||
event: "before_agent_start",
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Tool wrapper - wraps tools with hook callbacks for interception.
|
||||
*/
|
||||
|
||||
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||
import type { HookRunner } from "./runner.js";
|
||||
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";
|
||||
|
||||
|
|
@ -46,30 +46,46 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
|
|||
}
|
||||
|
||||
// Execute the actual tool, forwarding onUpdate for progress streaming
|
||||
const result = await tool.execute(toolCallId, params, signal, onUpdate);
|
||||
try {
|
||||
const result = await tool.execute(toolCallId, params, signal, onUpdate);
|
||||
|
||||
// Emit tool_result event - hooks can modify the result
|
||||
if (hookRunner.hasHandlers("tool_result")) {
|
||||
const resultResult = (await hookRunner.emit({
|
||||
type: "tool_result",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError: false,
|
||||
})) as ToolResultEventResult | undefined;
|
||||
// Emit tool_result event - hooks can modify the result
|
||||
if (hookRunner.hasHandlers("tool_result")) {
|
||||
const resultResult = (await hookRunner.emit({
|
||||
type: "tool_result",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError: false,
|
||||
})) as ToolResultEventResult | undefined;
|
||||
|
||||
// Apply modifications if any
|
||||
if (resultResult) {
|
||||
return {
|
||||
content: resultResult.content ?? result.content,
|
||||
details: (resultResult.details ?? result.details) as T,
|
||||
};
|
||||
// Apply modifications if any
|
||||
if (resultResult) {
|
||||
return {
|
||||
content: resultResult.content ?? result.content,
|
||||
details: (resultResult.details ?? result.details) as T,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Emit tool_result event for errors so hooks can observe failures
|
||||
if (hookRunner.hasHandlers("tool_result")) {
|
||||
await hookRunner.emit({
|
||||
type: "tool_result",
|
||||
toolName: tool.name,
|
||||
toolCallId,
|
||||
input: params,
|
||||
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
||||
details: undefined,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
throw err; // Re-throw original error for agent-loop
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,17 @@
|
|||
* and interact with the user via UI primitives.
|
||||
*/
|
||||
|
||||
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { CutPointResult } from "../compaction.js";
|
||||
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { Component, TUI } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { BranchSummaryEntry, CompactionEntry, ReadonlySessionManager, SessionEntry } from "../session-manager.js";
|
||||
|
||||
import type { EditToolDetails } from "../tools/edit.js";
|
||||
import type {
|
||||
BashToolDetails,
|
||||
FindToolDetails,
|
||||
|
|
@ -17,27 +24,8 @@ import type {
|
|||
ReadToolDetails,
|
||||
} from "../tools/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// Execution Context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of executing a command via ctx.exec()
|
||||
*/
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
/** True if the process was killed due to signal or timeout */
|
||||
killed?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
/** AbortSignal to cancel the process */
|
||||
signal?: AbortSignal;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
// Re-export for backward compatibility
|
||||
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||
|
||||
/**
|
||||
* UI context for hooks to request interactive UI from the harness.
|
||||
|
|
@ -50,7 +38,7 @@ export interface HookUIContext {
|
|||
* @param options - Array of string options
|
||||
* @returns Selected option string, or null if cancelled
|
||||
*/
|
||||
select(title: string, options: string[]): Promise<string | null>;
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog.
|
||||
|
|
@ -60,97 +48,225 @@ export interface HookUIContext {
|
|||
|
||||
/**
|
||||
* Show a text input dialog.
|
||||
* @returns User input, or null if cancelled
|
||||
* @returns User input, or undefined if cancelled
|
||||
*/
|
||||
input(title: string, placeholder?: string): Promise<string | null>;
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Show a notification to the user.
|
||||
*/
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
|
||||
/**
|
||||
* Show a custom component with keyboard focus.
|
||||
* The factory receives TUI, theme, and a done() callback to close the component.
|
||||
* Can be async for fire-and-forget work (don't await the work, just start it).
|
||||
*
|
||||
* @param factory - Function that creates the component. Call done() when finished.
|
||||
* @returns Promise that resolves with the value passed to done()
|
||||
*
|
||||
* @example
|
||||
* // Sync factory
|
||||
* const result = await ctx.ui.custom((tui, theme, done) => {
|
||||
* const component = new MyComponent(tui, theme);
|
||||
* component.onFinish = (value) => done(value);
|
||||
* return component;
|
||||
* });
|
||||
*
|
||||
* // Async factory with fire-and-forget work
|
||||
* const result = await ctx.ui.custom(async (tui, theme, done) => {
|
||||
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
|
||||
* loader.onAbort = () => done(null);
|
||||
* doWork(loader.signal).then(done); // Don't await - fire and forget
|
||||
* return loader;
|
||||
* });
|
||||
*/
|
||||
custom<T>(
|
||||
factory: (
|
||||
tui: TUI,
|
||||
theme: Theme,
|
||||
done: (result: T) => void,
|
||||
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||
): Promise<T>;
|
||||
|
||||
/**
|
||||
* Set the text in the core input editor.
|
||||
* Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions).
|
||||
* @param text - Text to set in the editor
|
||||
*/
|
||||
setEditorText(text: string): void;
|
||||
|
||||
/**
|
||||
* Get the current text from the core input editor.
|
||||
* @returns Current editor text
|
||||
*/
|
||||
getEditorText(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to hook event handlers.
|
||||
* Context passed to hook event and command handlers.
|
||||
*/
|
||||
export interface HookEventContext {
|
||||
/** Execute a command and return stdout/stderr/code */
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
export interface HookContext {
|
||||
/** UI methods for user interaction */
|
||||
ui: HookUIContext;
|
||||
/** Whether UI is available (false in print mode) */
|
||||
hasUI: boolean;
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Path to session file, or null if --no-session */
|
||||
sessionFile: string | null;
|
||||
/** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */
|
||||
sessionManager: ReadonlySessionManager;
|
||||
/** Model registry - use for API key resolution and model retrieval */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Current model (may be undefined if no model is selected yet) */
|
||||
model: Model<any> | undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Events
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base fields shared by all session events.
|
||||
*/
|
||||
interface SessionEventBase {
|
||||
type: "session";
|
||||
/** All session entries (including pre-compaction history) */
|
||||
entries: SessionEntry[];
|
||||
/** Current session file path, or null in --no-session mode */
|
||||
sessionFile: string | null;
|
||||
/** Previous session file path, or null for "start" and "new" */
|
||||
previousSessionFile: string | null;
|
||||
/** Fired on initial session load */
|
||||
export interface SessionStartEvent {
|
||||
type: "session_start";
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for session events.
|
||||
* Discriminated union based on reason.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - start: Initial session load
|
||||
* - before_switch / switch: Session switch (e.g., /resume command)
|
||||
* - before_new / new: New session (e.g., /new command)
|
||||
* - before_branch / branch: Session branch (e.g., /branch command)
|
||||
* - before_compact / compact: Before/after context compaction
|
||||
* - shutdown: Process exit (SIGINT/SIGTERM)
|
||||
*
|
||||
* "before_*" events fire before the action and can be cancelled via SessionEventResult.
|
||||
* Other events fire after the action completes.
|
||||
*/
|
||||
/** Fired before switching to another session (can be cancelled) */
|
||||
export interface SessionBeforeSwitchEvent {
|
||||
type: "session_before_switch";
|
||||
/** Session file we're switching to */
|
||||
targetSessionFile: string;
|
||||
}
|
||||
|
||||
/** Fired after switching to another session */
|
||||
export interface SessionSwitchEvent {
|
||||
type: "session_switch";
|
||||
/** Session file we came from */
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before creating a new session (can be cancelled) */
|
||||
export interface SessionBeforeNewEvent {
|
||||
type: "session_before_new";
|
||||
}
|
||||
|
||||
/** Fired after creating a new session */
|
||||
export interface SessionNewEvent {
|
||||
type: "session_new";
|
||||
}
|
||||
|
||||
/** Fired before branching a session (can be cancelled) */
|
||||
export interface SessionBeforeBranchEvent {
|
||||
type: "session_before_branch";
|
||||
/** ID of the entry to branch from */
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
/** Fired after branching a session */
|
||||
export interface SessionBranchEvent {
|
||||
type: "session_branch";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before context compaction (can be cancelled or customized) */
|
||||
export interface SessionBeforeCompactEvent {
|
||||
type: "session_before_compact";
|
||||
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
|
||||
preparation: CompactionPreparation;
|
||||
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
|
||||
branchEntries: SessionEntry[];
|
||||
/** Optional user-provided instructions for the summary */
|
||||
customInstructions?: string;
|
||||
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fired after context compaction */
|
||||
export interface SessionCompactEvent {
|
||||
type: "session_compact";
|
||||
compactionEntry: CompactionEntry;
|
||||
/** Whether the compaction entry was provided by a hook */
|
||||
fromHook: boolean;
|
||||
}
|
||||
|
||||
/** Fired on process exit (SIGINT/SIGTERM) */
|
||||
export interface SessionShutdownEvent {
|
||||
type: "session_shutdown";
|
||||
}
|
||||
|
||||
/** Preparation data for tree navigation (used by session_before_tree event) */
|
||||
export interface TreePreparation {
|
||||
/** Node being switched to */
|
||||
targetId: string;
|
||||
/** Current active leaf (being abandoned), null if no current position */
|
||||
oldLeafId: string | null;
|
||||
/** Common ancestor of target and old leaf, null if no common ancestor */
|
||||
commonAncestorId: string | null;
|
||||
/** Entries to summarize (old leaf back to common ancestor or compaction) */
|
||||
entriesToSummarize: SessionEntry[];
|
||||
/** Whether user chose to summarize */
|
||||
userWantsSummary: boolean;
|
||||
}
|
||||
|
||||
/** Fired before navigating to a different node in the session tree (can be cancelled) */
|
||||
export interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
/** Preparation data for the navigation */
|
||||
preparation: TreePreparation;
|
||||
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fired after navigating to a different node in the session tree */
|
||||
export interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
/** The new active leaf, null if navigated to before first entry */
|
||||
newLeafId: string | null;
|
||||
/** Previous active leaf, null if there was no position */
|
||||
oldLeafId: string | null;
|
||||
/** Branch summary entry if one was created */
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
/** Whether summary came from hook */
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
/** Union of all session event types */
|
||||
export type SessionEvent =
|
||||
| (SessionEventBase & {
|
||||
reason: "start" | "switch" | "new" | "before_switch" | "before_new" | "shutdown";
|
||||
})
|
||||
| (SessionEventBase & {
|
||||
reason: "branch" | "before_branch";
|
||||
/** Index of the turn to branch from */
|
||||
targetTurnIndex: number;
|
||||
})
|
||||
| (SessionEventBase & {
|
||||
reason: "before_compact";
|
||||
cutPoint: CutPointResult;
|
||||
/** Summary from previous compaction, if any. Include this in your summary to preserve context. */
|
||||
previousSummary?: string;
|
||||
/** Messages that will be summarized and discarded */
|
||||
messagesToSummarize: AppMessage[];
|
||||
/** Messages that will be kept after the summary (recent turns) */
|
||||
messagesToKeep: AppMessage[];
|
||||
tokensBefore: number;
|
||||
customInstructions?: string;
|
||||
model: Model<any>;
|
||||
/** Resolve API key for any model (checks settings, OAuth, env vars) */
|
||||
resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
|
||||
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
|
||||
signal: AbortSignal;
|
||||
})
|
||||
| (SessionEventBase & {
|
||||
reason: "compact";
|
||||
compactionEntry: CompactionEntry;
|
||||
tokensBefore: number;
|
||||
/** Whether the compaction entry was provided by a hook */
|
||||
fromHook: boolean;
|
||||
});
|
||||
| SessionStartEvent
|
||||
| SessionBeforeSwitchEvent
|
||||
| SessionSwitchEvent
|
||||
| SessionBeforeNewEvent
|
||||
| SessionNewEvent
|
||||
| SessionBeforeBranchEvent
|
||||
| SessionBranchEvent
|
||||
| SessionBeforeCompactEvent
|
||||
| SessionCompactEvent
|
||||
| SessionShutdownEvent
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent;
|
||||
|
||||
/**
|
||||
* Event data for context event.
|
||||
* Fired before each LLM call, allowing hooks to modify context non-destructively.
|
||||
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
|
||||
*/
|
||||
export interface ContextEvent {
|
||||
type: "context";
|
||||
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for before_agent_start event.
|
||||
* Fired after user submits a prompt but before the agent loop starts.
|
||||
* Allows hooks to inject context that will be persisted and visible in TUI.
|
||||
*/
|
||||
export interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
/** The user's prompt text */
|
||||
prompt: string;
|
||||
/** Any images attached to the prompt */
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event data for agent_start event.
|
||||
|
|
@ -165,7 +281,7 @@ export interface AgentStartEvent {
|
|||
*/
|
||||
export interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
messages: AppMessage[];
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -183,7 +299,7 @@ export interface TurnStartEvent {
|
|||
export interface TurnEndEvent {
|
||||
type: "turn_end";
|
||||
turnIndex: number;
|
||||
message: AppMessage;
|
||||
message: AgentMessage;
|
||||
toolResults: ToolResultMessage[];
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +347,7 @@ export interface ReadToolResultEvent extends ToolResultEventBase {
|
|||
/** Tool result event for edit tool */
|
||||
export interface EditToolResultEvent extends ToolResultEventBase {
|
||||
toolName: "edit";
|
||||
details: undefined;
|
||||
details: EditToolDetails | undefined;
|
||||
}
|
||||
|
||||
/** Tool result event for write tool */
|
||||
|
|
@ -307,6 +423,8 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
|||
*/
|
||||
export type HookEvent =
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| BeforeAgentStartEvent
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| TurnStartEvent
|
||||
|
|
@ -318,6 +436,15 @@ export type HookEvent =
|
|||
// Event Results
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Return type for context event handlers.
|
||||
* Allows hooks to modify messages before they're sent to the LLM.
|
||||
*/
|
||||
export interface ContextEventResult {
|
||||
/** Modified messages to send instead of the original */
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for tool_call event handlers.
|
||||
* Allows hooks to block tool execution.
|
||||
|
|
@ -343,16 +470,68 @@ export interface ToolResultEventResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return type for session event handlers.
|
||||
* Allows hooks to cancel "before_*" actions.
|
||||
* Return type for before_agent_start event handlers.
|
||||
* Allows hooks to inject context before the agent runs.
|
||||
*/
|
||||
export interface SessionEventResult {
|
||||
/** If true, cancel the pending action (switch, clear, or branch) */
|
||||
export interface BeforeAgentStartEventResult {
|
||||
/** Message to inject into context (persisted to session, visible in TUI) */
|
||||
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
|
||||
}
|
||||
|
||||
/** Return type for session_before_switch handlers */
|
||||
export interface SessionBeforeSwitchResult {
|
||||
/** If true, cancel the switch */
|
||||
cancel?: boolean;
|
||||
/** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */
|
||||
}
|
||||
|
||||
/** Return type for session_before_new handlers */
|
||||
export interface SessionBeforeNewResult {
|
||||
/** If true, cancel the new session */
|
||||
cancel?: boolean;
|
||||
}
|
||||
|
||||
/** Return type for session_before_branch handlers */
|
||||
export interface SessionBeforeBranchResult {
|
||||
/**
|
||||
* If true, abort the branch entirely. No new session file is created,
|
||||
* conversation stays unchanged.
|
||||
*/
|
||||
cancel?: boolean;
|
||||
/**
|
||||
* If true, the branch proceeds (new session file created, session state updated)
|
||||
* but the in-memory conversation is NOT rewound to the branch point.
|
||||
*
|
||||
* Use case: git-checkpoint hook that restores code state separately.
|
||||
* The hook handles state restoration itself, so it doesn't want the
|
||||
* agent's conversation to be rewound (which would lose recent context).
|
||||
*
|
||||
* - `cancel: true` → nothing happens, user stays in current session
|
||||
* - `skipConversationRestore: true` → branch happens, but messages stay as-is
|
||||
* - neither → branch happens AND messages rewind to branch point (default)
|
||||
*/
|
||||
skipConversationRestore?: boolean;
|
||||
/** Custom compaction entry (for before_compact event) */
|
||||
compactionEntry?: CompactionEntry;
|
||||
}
|
||||
|
||||
/** Return type for session_before_compact handlers */
|
||||
export interface SessionBeforeCompactResult {
|
||||
/** If true, cancel the compaction */
|
||||
cancel?: boolean;
|
||||
/** Custom compaction result - SessionManager adds id/parentId */
|
||||
compaction?: CompactionResult;
|
||||
}
|
||||
|
||||
/** Return type for session_before_tree handlers */
|
||||
export interface SessionBeforeTreeResult {
|
||||
/** If true, cancel the navigation entirely */
|
||||
cancel?: boolean;
|
||||
/**
|
||||
* Custom summary (skips default summarizer).
|
||||
* Only used if preparation.userWantsSummary is true.
|
||||
*/
|
||||
summary?: {
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -361,29 +540,134 @@ export interface SessionEventResult {
|
|||
|
||||
/**
|
||||
* Handler function type for each event.
|
||||
* Handlers can return R, undefined, or void (bare return statements).
|
||||
*/
|
||||
export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Promise<R>;
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
|
||||
export type HookHandler<E, R = undefined> = (event: E, ctx: HookContext) => Promise<R | void> | R | void;
|
||||
|
||||
export interface HookMessageRenderOptions {
|
||||
/** Whether the view is expanded */
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for hook messages.
|
||||
* Hooks register these to provide custom TUI rendering for their message types.
|
||||
*/
|
||||
export type HookMessageRenderer<T = unknown> = (
|
||||
message: HookMessage<T>,
|
||||
options: HookMessageRenderOptions,
|
||||
theme: Theme,
|
||||
) => Component | undefined;
|
||||
|
||||
/**
|
||||
* Command registration options.
|
||||
*/
|
||||
export interface RegisteredCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
handler: (args: string, ctx: HookContext) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HookAPI passed to hook factory functions.
|
||||
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
|
||||
* Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages.
|
||||
*/
|
||||
export interface HookAPI {
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
|
||||
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): void;
|
||||
// Session events
|
||||
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
|
||||
on(event: "session_before_switch", handler: HookHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>): void;
|
||||
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
|
||||
on(event: "session_before_new", handler: HookHandler<SessionBeforeNewEvent, SessionBeforeNewResult>): void;
|
||||
on(event: "session_new", handler: HookHandler<SessionNewEvent>): void;
|
||||
on(event: "session_before_branch", handler: HookHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>): void;
|
||||
on(event: "session_branch", handler: HookHandler<SessionBranchEvent>): void;
|
||||
on(
|
||||
event: "session_before_compact",
|
||||
handler: HookHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
|
||||
): void;
|
||||
on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void;
|
||||
on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void;
|
||||
on(event: "session_before_tree", handler: HookHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
|
||||
on(event: "session_tree", handler: HookHandler<SessionTreeEvent>): void;
|
||||
|
||||
// Context and agent events
|
||||
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void;
|
||||
on(event: "before_agent_start", handler: HookHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
|
||||
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
||||
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
||||
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
||||
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
|
||||
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
|
||||
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
|
||||
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
|
||||
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult>): void;
|
||||
|
||||
/**
|
||||
* Send a message to the agent.
|
||||
* If the agent is streaming, the message is queued.
|
||||
* If the agent is idle, a new agent loop is started.
|
||||
* Send a custom message to the session. Creates a CustomMessageEntry that
|
||||
* participates in LLM context and can be displayed in the TUI.
|
||||
*
|
||||
* Use this when you want the LLM to see the message content.
|
||||
* For hook state that should NOT be sent to the LLM, use appendEntry() instead.
|
||||
*
|
||||
* @param message - The message to send
|
||||
* @param message.customType - Identifier for your hook (used for filtering on reload)
|
||||
* @param message.content - Message content (string or TextContent/ImageContent array)
|
||||
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
|
||||
* @param message.details - Optional hook-specific metadata (not sent to LLM)
|
||||
* @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
|
||||
* If agent is streaming, message is queued and triggerTurn is ignored.
|
||||
*/
|
||||
send(text: string, attachments?: Attachment[]): void;
|
||||
sendMessage<T = unknown>(
|
||||
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
||||
triggerTurn?: boolean,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Append a custom entry to the session for hook state persistence.
|
||||
* Creates a CustomEntry that does NOT participate in LLM context.
|
||||
*
|
||||
* Use this to store hook-specific data that should persist across session reloads
|
||||
* but should NOT be sent to the LLM. On reload, scan session entries for your
|
||||
* customType to reconstruct hook state.
|
||||
*
|
||||
* For messages that SHOULD be sent to the LLM, use sendMessage() instead.
|
||||
*
|
||||
* @param customType - Identifier for your hook (used for filtering on reload)
|
||||
* @param data - Hook-specific data to persist (must be JSON-serializable)
|
||||
*
|
||||
* @example
|
||||
* // Store permission state
|
||||
* pi.appendEntry("permissions", { level: "full", grantedAt: Date.now() });
|
||||
*
|
||||
* // On reload, reconstruct state from entries
|
||||
* pi.on("session", async (event, ctx) => {
|
||||
* if (event.reason === "start") {
|
||||
* const entries = event.sessionManager.getEntries();
|
||||
* const myEntries = entries.filter(e => e.type === "custom" && e.customType === "permissions");
|
||||
* // Reconstruct state from myEntries...
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
appendEntry<T = unknown>(customType: string, data?: T): void;
|
||||
|
||||
/**
|
||||
* Register a custom renderer for CustomMessageEntry with a specific customType.
|
||||
* The renderer is called when rendering the entry in the TUI.
|
||||
* Return nothing to use the default renderer.
|
||||
*/
|
||||
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;
|
||||
|
||||
/**
|
||||
* Register a custom slash command.
|
||||
* Handler receives HookCommandContext.
|
||||
*/
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
|
||||
|
||||
/**
|
||||
* Execute a shell command and return stdout/stderr/code.
|
||||
* Supports timeout and abort signal.
|
||||
*/
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,29 +7,29 @@ export {
|
|||
type AgentSessionConfig,
|
||||
type AgentSessionEvent,
|
||||
type AgentSessionEventListener,
|
||||
type CompactionResult,
|
||||
type ModelCycleResult,
|
||||
type PromptOptions,
|
||||
type SessionStats,
|
||||
} from "./agent-session.js";
|
||||
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
|
||||
export type { CompactionResult } from "./compaction/index.js";
|
||||
export {
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
type CustomToolAPI,
|
||||
type CustomToolFactory,
|
||||
type CustomToolsLoadResult,
|
||||
type CustomToolUIContext,
|
||||
discoverAndLoadCustomTools,
|
||||
type ExecResult,
|
||||
type LoadedCustomTool,
|
||||
loadCustomTools,
|
||||
type RenderResultOptions,
|
||||
type ToolAPI,
|
||||
type ToolUIContext,
|
||||
} from "./custom-tools/index.js";
|
||||
export {
|
||||
type HookAPI,
|
||||
type HookContext,
|
||||
type HookError,
|
||||
type HookEvent,
|
||||
type HookEventContext,
|
||||
type HookFactory,
|
||||
HookRunner,
|
||||
type HookUIContext,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
/**
|
||||
* Custom message types and transformers for the coding agent.
|
||||
*
|
||||
* Extends the base AppMessage type with coding-agent specific message types,
|
||||
* Extends the base AgentMessage type with coding-agent specific message types,
|
||||
* and provides a transformer to convert them to LLM-compatible messages.
|
||||
*/
|
||||
|
||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
|
||||
|
||||
// ============================================================================
|
||||
// Custom Message Types
|
||||
// ============================================================================
|
||||
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
||||
|
||||
<summary>
|
||||
`;
|
||||
|
||||
export const COMPACTION_SUMMARY_SUFFIX = `
|
||||
</summary>`;
|
||||
|
||||
export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:
|
||||
|
||||
<summary>
|
||||
`;
|
||||
|
||||
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
|
||||
|
||||
/**
|
||||
* Message type for bash executions via the ! command.
|
||||
|
|
@ -19,35 +30,50 @@ export interface BashExecutionMessage {
|
|||
role: "bashExecution";
|
||||
command: string;
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
exitCode: number | undefined;
|
||||
cancelled: boolean;
|
||||
truncated: boolean;
|
||||
fullOutputPath?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Extend CustomMessages via declaration merging
|
||||
/**
|
||||
* Message type for hook-injected messages via sendMessage().
|
||||
* These are custom messages that hooks can inject into the conversation.
|
||||
*/
|
||||
export interface HookMessage<T = unknown> {
|
||||
role: "hookMessage";
|
||||
customType: string;
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
display: boolean;
|
||||
details?: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface BranchSummaryMessage {
|
||||
role: "branchSummary";
|
||||
summary: string;
|
||||
fromId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface CompactionSummaryMessage {
|
||||
role: "compactionSummary";
|
||||
summary: string;
|
||||
tokensBefore: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Extend CustomAgentMessages via declaration merging
|
||||
declare module "@mariozechner/pi-agent-core" {
|
||||
interface CustomMessages {
|
||||
interface CustomAgentMessages {
|
||||
bashExecution: BashExecutionMessage;
|
||||
hookMessage: HookMessage;
|
||||
branchSummary: BranchSummaryMessage;
|
||||
compactionSummary: CompactionSummaryMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Guards
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard for BashExecutionMessage.
|
||||
*/
|
||||
export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage {
|
||||
return (msg as BashExecutionMessage).role === "bashExecution";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert a BashExecutionMessage to user message text for LLM context.
|
||||
*/
|
||||
|
|
@ -60,7 +86,7 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
|
|||
}
|
||||
if (msg.cancelled) {
|
||||
text += "\n\n(command cancelled)";
|
||||
} else if (msg.exitCode !== null && msg.exitCode !== 0) {
|
||||
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
|
||||
text += `\n\nCommand exited with code ${msg.exitCode}`;
|
||||
}
|
||||
if (msg.truncated && msg.fullOutputPath) {
|
||||
|
|
@ -69,34 +95,95 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
|
|||
return text;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Transformer
|
||||
// ============================================================================
|
||||
export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage {
|
||||
return {
|
||||
role: "branchSummary",
|
||||
summary,
|
||||
fromId,
|
||||
timestamp: new Date(timestamp).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompactionSummaryMessage(
|
||||
summary: string,
|
||||
tokensBefore: number,
|
||||
timestamp: string,
|
||||
): CompactionSummaryMessage {
|
||||
return {
|
||||
role: "compactionSummary",
|
||||
summary: summary,
|
||||
tokensBefore,
|
||||
timestamp: new Date(timestamp).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert CustomMessageEntry to AgentMessage format */
|
||||
export function createHookMessage(
|
||||
customType: string,
|
||||
content: string | (TextContent | ImageContent)[],
|
||||
display: boolean,
|
||||
details: unknown | undefined,
|
||||
timestamp: string,
|
||||
): HookMessage {
|
||||
return {
|
||||
role: "hookMessage",
|
||||
customType,
|
||||
content,
|
||||
display,
|
||||
details,
|
||||
timestamp: new Date(timestamp).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform AppMessages (including custom types) to LLM-compatible Messages.
|
||||
* Transform AgentMessages (including custom types) to LLM-compatible Messages.
|
||||
*
|
||||
* This is used by:
|
||||
* - Agent's messageTransformer option (for prompt calls)
|
||||
* - Agent's transormToLlm option (for prompt calls and queued messages)
|
||||
* - Compaction's generateSummary (for summarization)
|
||||
* - Custom hooks and tools
|
||||
*/
|
||||
export function messageTransformer(messages: AppMessage[]): Message[] {
|
||||
export function convertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages
|
||||
.map((m): Message | null => {
|
||||
if (isBashExecutionMessage(m)) {
|
||||
// Convert bash execution to user message
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: bashExecutionToText(m) }],
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
.map((m): Message | undefined => {
|
||||
switch (m.role) {
|
||||
case "bashExecution":
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: bashExecutionToText(m) }],
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
case "hookMessage": {
|
||||
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
|
||||
return {
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
}
|
||||
case "branchSummary":
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }],
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
case "compactionSummary":
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX },
|
||||
],
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
case "user":
|
||||
case "assistant":
|
||||
case "toolResult":
|
||||
return m;
|
||||
default:
|
||||
// biome-ignore lint/correctness/noSwitchDeclarations: fine
|
||||
const _exhaustiveCheck: never = m;
|
||||
return undefined;
|
||||
}
|
||||
// Pass through standard LLM roles
|
||||
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
|
||||
return m as Message;
|
||||
}
|
||||
// Filter out unknown message types
|
||||
return null;
|
||||
})
|
||||
.filter((m): m is Message => m !== null);
|
||||
.filter((m) => m !== undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,11 +90,11 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
|
|||
export class ModelRegistry {
|
||||
private models: Model<Api>[] = [];
|
||||
private customProviderApiKeys: Map<string, string> = new Map();
|
||||
private loadError: string | null = null;
|
||||
private loadError: string | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
readonly authStorage: AuthStorage,
|
||||
private modelsJsonPath: string | null = null,
|
||||
private modelsJsonPath: string | undefined = undefined,
|
||||
) {
|
||||
// Set up fallback resolver for custom provider API keys
|
||||
this.authStorage.setFallbackResolver((provider) => {
|
||||
|
|
@ -114,14 +114,14 @@ export class ModelRegistry {
|
|||
*/
|
||||
refresh(): void {
|
||||
this.customProviderApiKeys.clear();
|
||||
this.loadError = null;
|
||||
this.loadError = undefined;
|
||||
this.loadModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any error from loading models.json (null if no error).
|
||||
* Get any error from loading models.json (undefined if no error).
|
||||
*/
|
||||
getError(): string | null {
|
||||
getError(): string | undefined {
|
||||
return this.loadError;
|
||||
}
|
||||
|
||||
|
|
@ -160,9 +160,9 @@ export class ModelRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | null } {
|
||||
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | undefined } {
|
||||
if (!existsSync(modelsJsonPath)) {
|
||||
return { models: [], error: null };
|
||||
return { models: [], error: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -186,7 +186,7 @@ export class ModelRegistry {
|
|||
this.validateConfig(config);
|
||||
|
||||
// Parse models
|
||||
return { models: this.parseModels(config), error: null };
|
||||
return { models: this.parseModels(config), error: undefined };
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return {
|
||||
|
|
@ -294,14 +294,14 @@ export class ModelRegistry {
|
|||
/**
|
||||
* Find a model by provider and ID.
|
||||
*/
|
||||
find(provider: string, modelId: string): Model<Api> | null {
|
||||
return this.models.find((m) => m.provider === provider && m.id === modelId) ?? null;
|
||||
find(provider: string, modelId: string): Model<Api> | undefined {
|
||||
return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key for a model.
|
||||
*/
|
||||
async getApiKey(model: Model<Api>): Promise<string | null> {
|
||||
async getApiKey(model: Model<Api>): Promise<string | undefined> {
|
||||
return this.authStorage.getApiKey(model.provider);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,9 @@ function isAlias(id: string): boolean {
|
|||
|
||||
/**
|
||||
* Try to match a pattern to a model from the available models list.
|
||||
* Returns the matched model or null if no match found.
|
||||
* Returns the matched model or undefined if no match found.
|
||||
*/
|
||||
function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | null {
|
||||
function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | undefined {
|
||||
// Check for provider/modelId format (provider is everything before the first /)
|
||||
const slashIndex = modelPattern.indexOf("/");
|
||||
if (slashIndex !== -1) {
|
||||
|
|
@ -75,7 +75,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
|
|||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Separate into aliases and dated versions
|
||||
|
|
@ -94,9 +94,9 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
|
|||
}
|
||||
|
||||
export interface ParsedModelResult {
|
||||
model: Model<Api> | null;
|
||||
model: Model<Api> | undefined;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
warning: string | null;
|
||||
warning: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -116,14 +116,14 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
|
|||
// Try exact match first
|
||||
const exactMatch = tryMatchModel(pattern, availableModels);
|
||||
if (exactMatch) {
|
||||
return { model: exactMatch, thinkingLevel: "off", warning: null };
|
||||
return { model: exactMatch, thinkingLevel: "off", warning: undefined };
|
||||
}
|
||||
|
||||
// No match - try splitting on last colon if present
|
||||
const lastColonIndex = pattern.lastIndexOf(":");
|
||||
if (lastColonIndex === -1) {
|
||||
// No colons, pattern simply doesn't match any model
|
||||
return { model: null, thinkingLevel: "off", warning: null };
|
||||
return { model: undefined, thinkingLevel: "off", warning: undefined };
|
||||
}
|
||||
|
||||
const prefix = pattern.substring(0, lastColonIndex);
|
||||
|
|
@ -193,9 +193,9 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
|
|||
}
|
||||
|
||||
export interface InitialModelResult {
|
||||
model: Model<Api> | null;
|
||||
model: Model<Api> | undefined;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
fallbackMessage: string | null;
|
||||
fallbackMessage: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -227,7 +227,7 @@ export async function findInitialModel(options: {
|
|||
modelRegistry,
|
||||
} = options;
|
||||
|
||||
let model: Model<Api> | null = null;
|
||||
let model: Model<Api> | undefined;
|
||||
let thinkingLevel: ThinkingLevel = "off";
|
||||
|
||||
// 1. CLI args take priority
|
||||
|
|
@ -237,7 +237,7 @@ export async function findInitialModel(options: {
|
|||
console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
|
||||
process.exit(1);
|
||||
}
|
||||
return { model: found, thinkingLevel: "off", fallbackMessage: null };
|
||||
return { model: found, thinkingLevel: "off", fallbackMessage: undefined };
|
||||
}
|
||||
|
||||
// 2. Use first model from scoped models (skip if continuing/resuming)
|
||||
|
|
@ -245,7 +245,7 @@ export async function findInitialModel(options: {
|
|||
return {
|
||||
model: scopedModels[0].model,
|
||||
thinkingLevel: scopedModels[0].thinkingLevel,
|
||||
fallbackMessage: null,
|
||||
fallbackMessage: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ export async function findInitialModel(options: {
|
|||
if (defaultThinkingLevel) {
|
||||
thinkingLevel = defaultThinkingLevel;
|
||||
}
|
||||
return { model, thinkingLevel, fallbackMessage: null };
|
||||
return { model, thinkingLevel, fallbackMessage: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,16 +270,16 @@ export async function findInitialModel(options: {
|
|||
const defaultId = defaultModelPerProvider[provider];
|
||||
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
|
||||
if (match) {
|
||||
return { model: match, thinkingLevel: "off", fallbackMessage: null };
|
||||
return { model: match, thinkingLevel: "off", fallbackMessage: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// If no default found, use first available
|
||||
return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: null };
|
||||
return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: undefined };
|
||||
}
|
||||
|
||||
// 5. No model found
|
||||
return { model: null, thinkingLevel: "off", fallbackMessage: null };
|
||||
return { model: undefined, thinkingLevel: "off", fallbackMessage: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -288,10 +288,10 @@ export async function findInitialModel(options: {
|
|||
export async function restoreModelFromSession(
|
||||
savedProvider: string,
|
||||
savedModelId: string,
|
||||
currentModel: Model<Api> | null,
|
||||
currentModel: Model<Api> | undefined,
|
||||
shouldPrintMessages: boolean,
|
||||
modelRegistry: ModelRegistry,
|
||||
): Promise<{ model: Model<Api> | null; fallbackMessage: string | null }> {
|
||||
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
|
||||
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
|
||||
|
||||
// Check if restored model exists and has a valid API key
|
||||
|
|
@ -301,7 +301,7 @@ export async function restoreModelFromSession(
|
|||
if (shouldPrintMessages) {
|
||||
console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));
|
||||
}
|
||||
return { model: restoredModel, fallbackMessage: null };
|
||||
return { model: restoredModel, fallbackMessage: undefined };
|
||||
}
|
||||
|
||||
// Model not found or no API key - fall back
|
||||
|
|
@ -327,7 +327,7 @@ export async function restoreModelFromSession(
|
|||
|
||||
if (availableModels.length > 0) {
|
||||
// Try to find a default model from known providers
|
||||
let fallbackModel: Model<Api> | null = null;
|
||||
let fallbackModel: Model<Api> | undefined;
|
||||
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
|
||||
const defaultId = defaultModelPerProvider[provider];
|
||||
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
|
||||
|
|
@ -353,5 +353,5 @@ export async function restoreModelFromSession(
|
|||
}
|
||||
|
||||
// No models available
|
||||
return { model: null, fallbackMessage: null };
|
||||
return { model: undefined, fallbackMessage: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,17 +29,22 @@
|
|||
* ```
|
||||
*/
|
||||
|
||||
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { join } from "path";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import { AgentSession } from "./agent-session.js";
|
||||
import { AuthStorage } from "./auth-storage.js";
|
||||
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js";
|
||||
import type { CustomAgentTool } from "./custom-tools/types.js";
|
||||
import {
|
||||
type CustomToolsLoadResult,
|
||||
discoverAndLoadCustomTools,
|
||||
type LoadedCustomTool,
|
||||
wrapCustomTools,
|
||||
} from "./custom-tools/index.js";
|
||||
import type { CustomTool } from "./custom-tools/types.js";
|
||||
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js";
|
||||
import type { HookFactory } from "./hooks/types.js";
|
||||
import { messageTransformer } from "./messages.js";
|
||||
import { convertToLlm } from "./messages.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
|
||||
|
|
@ -99,7 +104,7 @@ export interface CreateAgentSessionOptions {
|
|||
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
|
||||
tools?: Tool[];
|
||||
/** Custom tools (replaces discovery). */
|
||||
customTools?: Array<{ path?: string; tool: CustomAgentTool }>;
|
||||
customTools?: Array<{ path?: string; tool: CustomTool }>;
|
||||
/** Additional custom tool paths to load (merged with discovery). */
|
||||
additionalCustomToolPaths?: string[];
|
||||
|
||||
|
|
@ -127,18 +132,15 @@ export interface CreateAgentSessionResult {
|
|||
/** The created session */
|
||||
session: AgentSession;
|
||||
/** Custom tools result (for UI context setup in interactive mode) */
|
||||
customToolsResult: {
|
||||
tools: LoadedCustomTool[];
|
||||
setUIContext: (uiContext: any, hasUI: boolean) => void;
|
||||
};
|
||||
customToolsResult: CustomToolsLoadResult;
|
||||
/** Warning if session was restored with a different model than saved */
|
||||
modelFallbackMessage?: string;
|
||||
}
|
||||
|
||||
// Re-exports
|
||||
|
||||
export type { CustomAgentTool } from "./custom-tools/types.js";
|
||||
export type { HookAPI, HookFactory } from "./hooks/types.js";
|
||||
export type { CustomTool } from "./custom-tools/types.js";
|
||||
export type { HookAPI, HookContext, HookFactory } from "./hooks/types.js";
|
||||
export type { Settings, SkillsSettings } from "./settings-manager.js";
|
||||
export type { Skill } from "./skills.js";
|
||||
export type { FileSlashCommand } from "./slash-commands.js";
|
||||
|
|
@ -219,7 +221,7 @@ export async function discoverHooks(
|
|||
export async function discoverCustomTools(
|
||||
cwd?: string,
|
||||
agentDir?: string,
|
||||
): Promise<Array<{ path: string; tool: CustomAgentTool }>> {
|
||||
): Promise<Array<{ path: string; tool: CustomTool }>> {
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
||||
|
||||
|
|
@ -311,7 +313,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
|||
shellPath: manager.getShellPath(),
|
||||
collapseChangelog: manager.getCollapseChangelog(),
|
||||
hooks: manager.getHookPaths(),
|
||||
hookTimeout: manager.getHookTimeout(),
|
||||
customTools: manager.getCustomToolPaths(),
|
||||
skills: manager.getSkillsSettings(),
|
||||
terminal: { showImages: manager.getShowImages() },
|
||||
|
|
@ -340,7 +341,10 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
|
|||
function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] {
|
||||
return definitions.map((def) => {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
|
||||
let sendHandler: (text: string, attachments?: any[]) => void = () => {};
|
||||
const messageRenderers = new Map<string, any>();
|
||||
const commands = new Map<string, any>();
|
||||
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
|
||||
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
|
||||
|
||||
const api = {
|
||||
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
|
|
@ -348,8 +352,17 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
|
|||
list.push(handler);
|
||||
handlers.set(event, list);
|
||||
},
|
||||
send: (text: string, attachments?: any[]) => {
|
||||
sendHandler(text, attachments);
|
||||
sendMessage: (message: any, triggerTurn?: boolean) => {
|
||||
sendMessageHandler(message, triggerTurn);
|
||||
},
|
||||
appendEntry: (customType: string, data?: any) => {
|
||||
appendEntryHandler(customType, data);
|
||||
},
|
||||
registerMessageRenderer: (customType: string, renderer: any) => {
|
||||
messageRenderers.set(customType, renderer);
|
||||
},
|
||||
registerCommand: (name: string, options: any) => {
|
||||
commands.set(name, { name, ...options });
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -359,8 +372,13 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
|
|||
path: def.path ?? "<inline>",
|
||||
resolvedPath: def.path ?? "<inline>",
|
||||
handlers,
|
||||
setSendHandler: (handler: (text: string, attachments?: any[]) => void) => {
|
||||
sendHandler = handler;
|
||||
messageRenderers,
|
||||
commands,
|
||||
setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => {
|
||||
sendMessageHandler = handler;
|
||||
},
|
||||
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
|
||||
appendEntryHandler = handler;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -490,7 +508,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
const builtInTools = options.tools ?? createCodingTools(cwd);
|
||||
time("createCodingTools");
|
||||
|
||||
let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void };
|
||||
let customToolsResult: CustomToolsLoadResult;
|
||||
if (options.customTools !== undefined) {
|
||||
// Use provided custom tools
|
||||
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
|
||||
|
|
@ -500,24 +518,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
}));
|
||||
customToolsResult = {
|
||||
tools: loadedTools,
|
||||
errors: [],
|
||||
setUIContext: () => {},
|
||||
};
|
||||
} else {
|
||||
// Discover custom tools, merging with additional paths
|
||||
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
|
||||
const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
|
||||
customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
|
||||
time("discoverAndLoadCustomTools");
|
||||
for (const { path, error } of result.errors) {
|
||||
for (const { path, error } of customToolsResult.errors) {
|
||||
console.error(`Failed to load custom tool "${path}": ${error}`);
|
||||
}
|
||||
customToolsResult = result;
|
||||
}
|
||||
|
||||
let hookRunner: HookRunner | null = null;
|
||||
let hookRunner: HookRunner | undefined;
|
||||
if (options.hooks !== undefined) {
|
||||
if (options.hooks.length > 0) {
|
||||
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
|
||||
hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout());
|
||||
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
|
||||
}
|
||||
} else {
|
||||
// Discover hooks, merging with additional paths
|
||||
|
|
@ -528,11 +546,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
console.error(`Failed to load hook "${path}": ${error}`);
|
||||
}
|
||||
if (hooks.length > 0) {
|
||||
hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout());
|
||||
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry);
|
||||
}
|
||||
}
|
||||
|
||||
let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)];
|
||||
// Wrap custom tools with context getter (agent is assigned below, accessed at execute time)
|
||||
let agent: Agent;
|
||||
const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
model: agent.state.model,
|
||||
}));
|
||||
|
||||
let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
|
||||
time("combineTools");
|
||||
if (hookRunner) {
|
||||
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[];
|
||||
|
|
@ -564,34 +590,43 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
|
||||
time("discoverSlashCommands");
|
||||
|
||||
const agent = new Agent({
|
||||
agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel,
|
||||
tools: allToolsArray,
|
||||
},
|
||||
messageTransformer,
|
||||
convertToLlm,
|
||||
transformContext: hookRunner
|
||||
? async (messages) => {
|
||||
return hookRunner.emitContext(messages);
|
||||
}
|
||||
: undefined,
|
||||
queueMode: settingsManager.getQueueMode(),
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => {
|
||||
const currentModel = agent.state.model;
|
||||
if (!currentModel) {
|
||||
throw new Error("No model selected");
|
||||
}
|
||||
const key = await modelRegistry.getApiKey(currentModel);
|
||||
if (!key) {
|
||||
throw new Error(`No API key found for provider "${currentModel.provider}"`);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
getApiKey: async () => {
|
||||
const currentModel = agent.state.model;
|
||||
if (!currentModel) {
|
||||
throw new Error("No model selected");
|
||||
}
|
||||
const key = await modelRegistry.getApiKey(currentModel);
|
||||
if (!key) {
|
||||
throw new Error(`No API key found for provider "${currentModel.provider}"`);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
});
|
||||
time("createAgent");
|
||||
|
||||
// Restore messages if session has existing data
|
||||
if (hasExistingSession) {
|
||||
agent.replaceMessages(existingSession.messages);
|
||||
} else {
|
||||
// Save initial model and thinking level for new sessions so they can be restored on resume
|
||||
if (model) {
|
||||
sessionManager.appendModelChange(model.provider, model.id);
|
||||
}
|
||||
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
||||
}
|
||||
|
||||
const session = new AgentSession({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,10 @@ export interface CompactionSettings {
|
|||
keepRecentTokens?: number; // default: 20000
|
||||
}
|
||||
|
||||
export interface BranchSummarySettings {
|
||||
reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response)
|
||||
}
|
||||
|
||||
export interface RetrySettings {
|
||||
enabled?: boolean; // default: true
|
||||
maxRetries?: number; // default: 3
|
||||
|
|
@ -38,12 +42,12 @@ export interface Settings {
|
|||
queueMode?: "all" | "one-at-a-time";
|
||||
theme?: string;
|
||||
compaction?: CompactionSettings;
|
||||
branchSummary?: BranchSummarySettings;
|
||||
retry?: RetrySettings;
|
||||
hideThinkingBlock?: boolean;
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
hooks?: string[]; // Array of hook file paths
|
||||
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
|
||||
customTools?: string[]; // Array of custom tool file paths
|
||||
skills?: SkillsSettings;
|
||||
terminal?: TerminalSettings;
|
||||
|
|
@ -255,6 +259,12 @@ export class SettingsManager {
|
|||
};
|
||||
}
|
||||
|
||||
getBranchSummarySettings(): { reserveTokens: number } {
|
||||
return {
|
||||
reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,
|
||||
};
|
||||
}
|
||||
|
||||
getRetryEnabled(): boolean {
|
||||
return this.settings.retry?.enabled ?? true;
|
||||
}
|
||||
|
|
@ -303,7 +313,7 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
getHookPaths(): string[] {
|
||||
return this.settings.hooks ?? [];
|
||||
return [...(this.settings.hooks ?? [])];
|
||||
}
|
||||
|
||||
setHookPaths(paths: string[]): void {
|
||||
|
|
@ -311,17 +321,8 @@ export class SettingsManager {
|
|||
this.save();
|
||||
}
|
||||
|
||||
getHookTimeout(): number {
|
||||
return this.settings.hookTimeout ?? 30000;
|
||||
}
|
||||
|
||||
setHookTimeout(timeout: number): void {
|
||||
this.globalSettings.hookTimeout = timeout;
|
||||
this.save();
|
||||
}
|
||||
|
||||
getCustomToolPaths(): string[] {
|
||||
return this.settings.customTools ?? [];
|
||||
return [...(this.settings.customTools ?? [])];
|
||||
}
|
||||
|
||||
setCustomToolPaths(paths: string[]): void {
|
||||
|
|
@ -349,9 +350,9 @@ export class SettingsManager {
|
|||
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
|
||||
enablePiUser: this.settings.skills?.enablePiUser ?? true,
|
||||
enablePiProject: this.settings.skills?.enablePiProject ?? true,
|
||||
customDirectories: this.settings.skills?.customDirectories ?? [],
|
||||
ignoredSkills: this.settings.skills?.ignoredSkills ?? [],
|
||||
includeSkills: this.settings.skills?.includeSkills ?? [],
|
||||
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
|
||||
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
|
||||
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import chalk from "chalk";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
|
||||
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";
|
||||
|
|
@ -202,9 +202,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
return prompt;
|
||||
}
|
||||
|
||||
// Get absolute paths to documentation
|
||||
// Get absolute paths to documentation and examples
|
||||
const readmePath = getReadmePath();
|
||||
const docsPath = getDocsPath();
|
||||
const examplesPath = getExamplesPath();
|
||||
|
||||
// Build tools list based on selected tools
|
||||
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
|
||||
|
|
@ -279,7 +280,9 @@ ${guidelines}
|
|||
Documentation:
|
||||
- Main documentation: ${readmePath}
|
||||
- Additional docs: ${docsPath}
|
||||
- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`;
|
||||
- Examples: ${examplesPath} (hooks, custom tools, SDK)
|
||||
- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md)
|
||||
- Always read the doc, examples, AND follow .md cross-references before implementing`;
|
||||
|
||||
if (appendSection) {
|
||||
prompt += appendSection;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
|
|||
import { createWriteStream } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import * as Diff from "diff";
|
||||
import { constants } from "fs";
|
||||
|
|
@ -23,8 +23,13 @@ function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
|
|||
|
||||
/**
|
||||
* Generate a unified diff string with line numbers and context
|
||||
* Returns both the diff string and the first changed line number (in the new file)
|
||||
*/
|
||||
function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {
|
||||
function generateDiffString(
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
contextLines = 4,
|
||||
): { diff: string; firstChangedLine: number | undefined } {
|
||||
const parts = Diff.diffLines(oldContent, newContent);
|
||||
const output: string[] = [];
|
||||
|
||||
|
|
@ -36,6 +41,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
|||
let oldLineNum = 1;
|
||||
let newLineNum = 1;
|
||||
let lastWasChange = false;
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
|
|
@ -45,6 +51,11 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
|||
}
|
||||
|
||||
if (part.added || part.removed) {
|
||||
// Capture the first changed line (in the new file)
|
||||
if (firstChangedLine === undefined) {
|
||||
firstChangedLine = newLineNum;
|
||||
}
|
||||
|
||||
// Show the change
|
||||
for (const line of raw) {
|
||||
if (part.added) {
|
||||
|
|
@ -113,7 +124,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
|
|||
}
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
return { diff: output.join("\n"), firstChangedLine };
|
||||
}
|
||||
|
||||
const editSchema = Type.Object({
|
||||
|
|
@ -122,6 +133,13 @@ const editSchema = Type.Object({
|
|||
newText: Type.String({ description: "New text to replace the old text with" }),
|
||||
});
|
||||
|
||||
export interface EditToolDetails {
|
||||
/** Unified diff of the changes made */
|
||||
diff: string;
|
||||
/** Line number of the first change in the new file (for editor navigation) */
|
||||
firstChangedLine?: number;
|
||||
}
|
||||
|
||||
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
||||
return {
|
||||
name: "edit",
|
||||
|
|
@ -138,7 +156,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
|||
|
||||
return new Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: { diff: string } | undefined;
|
||||
details: EditToolDetails | undefined;
|
||||
}>((resolve, reject) => {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
|
|
@ -257,6 +275,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
|||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
|
|
@ -264,7 +283,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
|
|||
text: `Successfully replaced text in ${path}.`,
|
||||
},
|
||||
],
|
||||
details: { diff: generateDiffString(normalizedContent, normalizedNewContent) },
|
||||
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Clean up abort handler
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync, type Stats, statSync } from "fs";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
|
||||
export { type BashToolDetails, bashTool, createBashTool } from "./bash.js";
|
||||
export { createEditTool, editTool } from "./edit.js";
|
||||
export { createFindTool, type FindToolDetails, findTool } from "./find.js";
|
||||
|
|
@ -9,6 +7,7 @@ export { createReadTool, type ReadToolDetails, readTool } from "./read.js";
|
|||
export type { TruncationResult } from "./truncate.js";
|
||||
export { createWriteTool, writeTool } from "./write.js";
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { bashTool, createBashTool } from "./bash.js";
|
||||
import { createEditTool, editTool } from "./edit.js";
|
||||
import { createFindTool, findTool } from "./find.js";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import nodePath from "path";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { constants } from "fs";
|
||||
import { access, readFile } from "fs/promises";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ export {
|
|||
type AgentSessionConfig,
|
||||
type AgentSessionEvent,
|
||||
type AgentSessionEventListener,
|
||||
type CompactionResult,
|
||||
type ModelCycleResult,
|
||||
type PromptOptions,
|
||||
type SessionStats,
|
||||
|
|
@ -13,56 +12,43 @@ export {
|
|||
export { type ApiKeyCredential, type AuthCredential, AuthStorage, type OAuthCredential } from "./core/auth-storage.js";
|
||||
// Compaction
|
||||
export {
|
||||
type BranchPreparation,
|
||||
type BranchSummaryResult,
|
||||
type CollectEntriesResult,
|
||||
type CompactionResult,
|
||||
type CutPointResult,
|
||||
calculateContextTokens,
|
||||
collectEntriesForBranchSummary,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateTokens,
|
||||
type FileOperations,
|
||||
findCutPoint,
|
||||
findTurnStartIndex,
|
||||
type GenerateBranchSummaryOptions,
|
||||
generateBranchSummary,
|
||||
generateSummary,
|
||||
getLastAssistantUsage,
|
||||
prepareBranchEntries,
|
||||
serializeConversation,
|
||||
shouldCompact,
|
||||
} from "./core/compaction.js";
|
||||
} from "./core/compaction/index.js";
|
||||
// Custom tools
|
||||
export type {
|
||||
AgentToolUpdateCallback,
|
||||
CustomAgentTool,
|
||||
CustomTool,
|
||||
CustomToolAPI,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
CustomToolsLoadResult,
|
||||
CustomToolUIContext,
|
||||
ExecResult,
|
||||
LoadedCustomTool,
|
||||
RenderResultOptions,
|
||||
SessionEvent as ToolSessionEvent,
|
||||
ToolAPI,
|
||||
ToolUIContext,
|
||||
} from "./core/custom-tools/index.js";
|
||||
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
|
||||
export type {
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
BashToolResultEvent,
|
||||
CustomToolResultEvent,
|
||||
EditToolResultEvent,
|
||||
FindToolResultEvent,
|
||||
GrepToolResultEvent,
|
||||
HookAPI,
|
||||
HookEvent,
|
||||
HookEventContext,
|
||||
HookFactory,
|
||||
HookUIContext,
|
||||
LsToolResultEvent,
|
||||
ReadToolResultEvent,
|
||||
SessionEvent,
|
||||
SessionEventResult,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEvent,
|
||||
ToolResultEventResult,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./core/hooks/index.js";
|
||||
export type * from "./core/hooks/index.js";
|
||||
// Hook system types and type guards
|
||||
export {
|
||||
isBashToolResult,
|
||||
|
|
@ -73,7 +59,7 @@ export {
|
|||
isReadToolResult,
|
||||
isWriteToolResult,
|
||||
} from "./core/hooks/index.js";
|
||||
export { messageTransformer } from "./core/messages.js";
|
||||
export { convertToLlm } from "./core/messages.js";
|
||||
export { ModelRegistry } from "./core/model-registry.js";
|
||||
// SDK for programmatic usage
|
||||
export {
|
||||
|
|
@ -102,25 +88,33 @@ export {
|
|||
discoverSkills,
|
||||
discoverSlashCommands,
|
||||
type FileSlashCommand,
|
||||
// Hook types
|
||||
type HookAPI,
|
||||
type HookContext,
|
||||
type HookFactory,
|
||||
loadSettings,
|
||||
// Pre-built tools (use process.cwd())
|
||||
readOnlyTools,
|
||||
} from "./core/sdk.js";
|
||||
export {
|
||||
type BranchSummaryEntry,
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
createSummaryMessage,
|
||||
CURRENT_SESSION_VERSION,
|
||||
type CustomEntry,
|
||||
type CustomMessageEntry,
|
||||
type FileEntry,
|
||||
getLatestCompactionEntry,
|
||||
type ModelChangeEntry,
|
||||
migrateSessionEntries,
|
||||
parseSessionEntries,
|
||||
type SessionContext as LoadedSession,
|
||||
type SessionContext,
|
||||
type SessionEntry,
|
||||
type SessionEntryBase,
|
||||
type SessionHeader,
|
||||
type SessionInfo,
|
||||
SessionManager,
|
||||
type SessionMessageEntry,
|
||||
SUMMARY_PREFIX,
|
||||
SUMMARY_SUFFIX,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "./core/session-manager.js";
|
||||
export {
|
||||
|
|
@ -160,5 +154,7 @@ export {
|
|||
} from "./core/tools/index.js";
|
||||
// Main entry point
|
||||
export { main } from "./main.js";
|
||||
// UI components for hooks
|
||||
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
|
||||
// Theme utilities for custom tools
|
||||
export { getMarkdownTheme } from "./modes/interactive/theme/theme.js";
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
* createAgentSession() options. The SDK does the heavy lifting.
|
||||
*/
|
||||
|
||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||
import { supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
|
@ -34,10 +33,10 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"
|
|||
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
||||
import { ensureTool } from "./utils/tools-manager.js";
|
||||
|
||||
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
|
||||
async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi -coding-agent/latest");
|
||||
if (!response.ok) return null;
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const data = (await response.json()) as { version?: string };
|
||||
const latestVersion = data.version;
|
||||
|
|
@ -46,26 +45,26 @@ async function checkForNewVersion(currentVersion: string): Promise<string | null
|
|||
return latestVersion;
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
} catch {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function runInteractiveMode(
|
||||
session: AgentSession,
|
||||
version: string,
|
||||
changelogMarkdown: string | null,
|
||||
changelogMarkdown: string | undefined,
|
||||
modelFallbackMessage: string | undefined,
|
||||
modelsJsonError: string | null,
|
||||
modelsJsonError: string | undefined,
|
||||
migratedProviders: string[],
|
||||
versionCheckPromise: Promise<string | null>,
|
||||
versionCheckPromise: Promise<string | undefined>,
|
||||
initialMessages: string[],
|
||||
customTools: LoadedCustomTool[],
|
||||
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
|
||||
initialMessage?: string,
|
||||
initialAttachments?: Attachment[],
|
||||
fdPath: string | null = null,
|
||||
initialImages?: ImageContent[],
|
||||
fdPath: string | undefined = undefined,
|
||||
): Promise<void> {
|
||||
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
|
||||
|
||||
|
|
@ -77,7 +76,7 @@ async function runInteractiveMode(
|
|||
}
|
||||
});
|
||||
|
||||
mode.renderInitialMessages(session.state);
|
||||
mode.renderInitialMessages();
|
||||
|
||||
if (migratedProviders.length > 0) {
|
||||
mode.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
|
||||
|
|
@ -93,7 +92,7 @@ async function runInteractiveMode(
|
|||
|
||||
if (initialMessage) {
|
||||
try {
|
||||
await session.prompt(initialMessage, { attachments: initialAttachments });
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
mode.showError(errorMessage);
|
||||
|
|
@ -122,31 +121,31 @@ async function runInteractiveMode(
|
|||
|
||||
async function prepareInitialMessage(parsed: Args): Promise<{
|
||||
initialMessage?: string;
|
||||
initialAttachments?: Attachment[];
|
||||
initialImages?: ImageContent[];
|
||||
}> {
|
||||
if (parsed.fileArgs.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs);
|
||||
const { text, images } = await processFileArguments(parsed.fileArgs);
|
||||
|
||||
let initialMessage: string;
|
||||
if (parsed.messages.length > 0) {
|
||||
initialMessage = textContent + parsed.messages[0];
|
||||
initialMessage = text + parsed.messages[0];
|
||||
parsed.messages.shift();
|
||||
} else {
|
||||
initialMessage = textContent;
|
||||
initialMessage = text;
|
||||
}
|
||||
|
||||
return {
|
||||
initialMessage,
|
||||
initialAttachments: imageAttachments.length > 0 ? imageAttachments : undefined,
|
||||
initialImages: images.length > 0 ? images : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
|
||||
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined {
|
||||
if (parsed.continue || parsed.resume) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastVersion = settingsManager.getLastChangelogVersion();
|
||||
|
|
@ -166,10 +165,10 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager):
|
|||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createSessionManager(parsed: Args, cwd: string): SessionManager | null {
|
||||
function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined {
|
||||
if (parsed.noSession) {
|
||||
return SessionManager.inMemory();
|
||||
}
|
||||
|
|
@ -184,8 +183,8 @@ function createSessionManager(parsed: Args, cwd: string): SessionManager | null
|
|||
if (parsed.sessionDir) {
|
||||
return SessionManager.create(cwd, parsed.sessionDir);
|
||||
}
|
||||
// Default case (new session) returns null, SDK will create one
|
||||
return null;
|
||||
// Default case (new session) returns undefined, SDK will create one
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Discover SYSTEM.md file if no CLI system prompt was provided */
|
||||
|
|
@ -208,7 +207,7 @@ function discoverSystemPromptFile(): string | undefined {
|
|||
function buildSessionOptions(
|
||||
parsed: Args,
|
||||
scopedModels: ScopedModel[],
|
||||
sessionManager: SessionManager | null,
|
||||
sessionManager: SessionManager | undefined,
|
||||
modelRegistry: ModelRegistry,
|
||||
): CreateAgentSessionOptions {
|
||||
const options: CreateAgentSessionOptions = {};
|
||||
|
|
@ -330,7 +329,7 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
|
||||
const { initialMessage, initialImages } = await prepareInitialMessage(parsed);
|
||||
time("prepareInitialMessage");
|
||||
const isInteractive = !parsed.print && parsed.mode === undefined;
|
||||
const mode = parsed.mode || "text";
|
||||
|
|
@ -409,7 +408,7 @@ export async function main(args: string[]) {
|
|||
if (mode === "rpc") {
|
||||
await runRpcMode(session);
|
||||
} else if (isInteractive) {
|
||||
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
|
||||
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
|
||||
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
|
||||
|
||||
if (scopedModels.length > 0) {
|
||||
|
|
@ -438,11 +437,11 @@ export async function main(args: string[]) {
|
|||
customToolsResult.tools,
|
||||
customToolsResult.setUIContext,
|
||||
initialMessage,
|
||||
initialAttachments,
|
||||
initialImages,
|
||||
fdPath,
|
||||
);
|
||||
} else {
|
||||
await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
|
||||
await runPrintMode(session, mode, parsed.messages, initialMessage, initialImages);
|
||||
stopThemeWatcher();
|
||||
if (process.stdout.writableLength > 0) {
|
||||
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
|
||||
|
|
|
|||
|
|
@ -53,16 +53,15 @@ export class AssistantMessageComponent extends Container {
|
|||
|
||||
if (this.hideThinkingBlock) {
|
||||
// Show static "Thinking..." label when hidden
|
||||
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
|
||||
this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
|
||||
if (hasTextAfter) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
} else {
|
||||
// Thinking traces in muted color, italic
|
||||
// Use Markdown component with default text style for consistent styling
|
||||
// Thinking traces in thinkingText color, italic
|
||||
this.contentContainer.addChild(
|
||||
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("muted", text),
|
||||
color: (text: string) => theme.fg("thinkingText", text),
|
||||
italic: true,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export class BashExecutionComponent extends Container {
|
|||
private command: string;
|
||||
private outputLines: string[] = [];
|
||||
private status: "running" | "complete" | "cancelled" | "error" = "running";
|
||||
private exitCode: number | null = null;
|
||||
private exitCode: number | undefined = undefined;
|
||||
private loader: Loader;
|
||||
private truncationResult?: TruncationResult;
|
||||
private fullOutputPath?: string;
|
||||
|
|
@ -90,13 +90,17 @@ export class BashExecutionComponent extends Container {
|
|||
}
|
||||
|
||||
setComplete(
|
||||
exitCode: number | null,
|
||||
exitCode: number | undefined,
|
||||
cancelled: boolean,
|
||||
truncationResult?: TruncationResult,
|
||||
fullOutputPath?: string,
|
||||
): void {
|
||||
this.exitCode = exitCode;
|
||||
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
|
||||
this.status = cancelled
|
||||
? "cancelled"
|
||||
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
|
||||
? "error"
|
||||
: "complete";
|
||||
this.truncationResult = truncationResult;
|
||||
this.fullOutputPath = fullOutputPath;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/** Loader wrapped with borders for hook UI */
|
||||
export class BorderedLoader extends Container {
|
||||
private loader: CancellableLoader;
|
||||
|
||||
constructor(tui: TUI, theme: Theme, message: string) {
|
||||
super();
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
this.loader = new CancellableLoader(
|
||||
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(theme.fg("muted", "esc cancel"), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
get signal(): AbortSignal {
|
||||
return this.loader.signal;
|
||||
}
|
||||
|
||||
set onAbort(fn: (() => void) | undefined) {
|
||||
this.loader.onAbort = fn;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.loader.handleInput(data);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.loader.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { BranchSummaryMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a branch summary message with collapsed/expanded state.
|
||||
* Uses same background color as hook messages for visual consistency.
|
||||
*/
|
||||
export class BranchSummaryMessageComponent extends Box {
|
||||
private expanded = false;
|
||||
private message: BranchSummaryMessage;
|
||||
|
||||
constructor(message: BranchSummaryMessage) {
|
||||
super(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
this.message = message;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.clear();
|
||||
|
||||
const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`);
|
||||
this.addChild(new Text(label, 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
if (this.expanded) {
|
||||
const header = "**Branch Summary**\n\n";
|
||||
this.addChild(
|
||||
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("customMessageText", text),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { CompactionSummaryMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a compaction message with collapsed/expanded state.
|
||||
* Uses same background color as hook messages for visual consistency.
|
||||
*/
|
||||
export class CompactionSummaryMessageComponent extends Box {
|
||||
private expanded = false;
|
||||
private message: CompactionSummaryMessage;
|
||||
|
||||
constructor(message: CompactionSummaryMessage) {
|
||||
super(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
this.message = message;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.clear();
|
||||
|
||||
const tokenStr = this.message.tokensBefore.toLocaleString();
|
||||
const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`);
|
||||
this.addChild(new Text(label, 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
if (this.expanded) {
|
||||
const header = `**Compacted from ${tokenStr} tokens**\n\n`;
|
||||
this.addChild(
|
||||
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("customMessageText", text),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.addChild(
|
||||
new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a compaction indicator with collapsed/expanded state.
|
||||
* Collapsed: shows "Context compacted from X tokens"
|
||||
* Expanded: shows the full summary rendered as markdown (like a user message)
|
||||
*/
|
||||
export class CompactionComponent extends Container {
|
||||
private expanded = false;
|
||||
private tokensBefore: number;
|
||||
private summary: string;
|
||||
|
||||
constructor(tokensBefore: number, summary: string) {
|
||||
super();
|
||||
this.tokensBefore = tokensBefore;
|
||||
this.summary = summary;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.clear();
|
||||
|
||||
if (this.expanded) {
|
||||
// Show header + summary as markdown (like user message)
|
||||
this.addChild(new Spacer(1));
|
||||
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
|
||||
this.addChild(
|
||||
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
|
||||
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||
color: (text: string) => theme.fg("userMessageText", text),
|
||||
}),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
} else {
|
||||
// Collapsed: simple text in warning color with token count
|
||||
const tokenStr = this.tokensBefore.toLocaleString();
|
||||
this.addChild(
|
||||
new Text(
|
||||
theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`),
|
||||
1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import type { TextContent } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { HookMessageRenderer } from "../../../core/hooks/types.js";
|
||||
import type { HookMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a custom message entry from hooks.
|
||||
* Uses distinct styling to differentiate from user messages.
|
||||
*/
|
||||
export class HookMessageComponent extends Container {
|
||||
private message: HookMessage<unknown>;
|
||||
private customRenderer?: HookMessageRenderer;
|
||||
private box: Box;
|
||||
private customComponent?: Component;
|
||||
private _expanded = false;
|
||||
|
||||
constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
|
||||
super();
|
||||
this.message = message;
|
||||
this.customRenderer = customRenderer;
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create box with purple background (used for default rendering)
|
||||
this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
if (this._expanded !== expanded) {
|
||||
this._expanded = expanded;
|
||||
this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
// Remove previous content component
|
||||
if (this.customComponent) {
|
||||
this.removeChild(this.customComponent);
|
||||
this.customComponent = undefined;
|
||||
}
|
||||
this.removeChild(this.box);
|
||||
|
||||
// Try custom renderer first - it handles its own styling
|
||||
if (this.customRenderer) {
|
||||
try {
|
||||
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
|
||||
if (component) {
|
||||
// Custom renderer provides its own styled component
|
||||
this.customComponent = component;
|
||||
this.addChild(component);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default rendering
|
||||
}
|
||||
}
|
||||
|
||||
// Default rendering uses our box
|
||||
this.addChild(this.box);
|
||||
this.box.clear();
|
||||
|
||||
// Default rendering: label + content
|
||||
const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);
|
||||
this.box.addChild(new Text(label, 0, 0));
|
||||
this.box.addChild(new Spacer(1));
|
||||
|
||||
// Extract text content
|
||||
let text: string;
|
||||
if (typeof this.message.content === "string") {
|
||||
text = this.message.content;
|
||||
} else {
|
||||
text = this.message.content
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Limit lines when collapsed
|
||||
if (!this._expanded) {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length > 5) {
|
||||
text = `${lines.slice(0, 5).join("\n")}\n...`;
|
||||
}
|
||||
}
|
||||
|
||||
this.box.addChild(
|
||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("customMessageText", text),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,18 +36,18 @@ export class ModelSelectorComponent extends Container {
|
|||
private allModels: ModelItem[] = [];
|
||||
private filteredModels: ModelItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private currentModel: Model<any> | null;
|
||||
private currentModel?: Model<any>;
|
||||
private settingsManager: SettingsManager;
|
||||
private modelRegistry: ModelRegistry;
|
||||
private onSelectCallback: (model: Model<any>) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private errorMessage: string | null = null;
|
||||
private errorMessage?: string;
|
||||
private tui: TUI;
|
||||
private scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
currentModel: Model<any> | null,
|
||||
currentModel: Model<any> | undefined,
|
||||
settingsManager: SettingsManager,
|
||||
modelRegistry: ModelRegistry,
|
||||
scopedModels: ReadonlyArray<ScopedModelItem>,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
|
||||
import type { CustomTool } from "../../../core/custom-tools/types.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
|
||||
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
|
||||
import { renderDiff } from "./diff.js";
|
||||
|
|
@ -55,7 +55,7 @@ export class ToolExecutionComponent extends Container {
|
|||
private expanded = false;
|
||||
private showImages: boolean;
|
||||
private isPartial = true;
|
||||
private customTool?: CustomAgentTool;
|
||||
private customTool?: CustomTool;
|
||||
private ui: TUI;
|
||||
private result?: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
|
|
@ -67,7 +67,7 @@ export class ToolExecutionComponent extends Container {
|
|||
toolName: string,
|
||||
args: any,
|
||||
options: ToolExecutionOptions = {},
|
||||
customTool: CustomAgentTool | undefined,
|
||||
customTool: CustomTool | undefined,
|
||||
ui: TUI,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -415,10 +415,14 @@ export class ToolExecutionComponent extends Container {
|
|||
} else if (this.toolName === "edit") {
|
||||
const rawPath = this.args?.file_path || this.args?.path || "";
|
||||
const path = shortenPath(rawPath);
|
||||
text =
|
||||
theme.fg("toolTitle", theme.bold("edit")) +
|
||||
" " +
|
||||
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
|
||||
|
||||
// Build path display, appending :line if we have a successful result with line info
|
||||
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
if (this.result && !this.result.isError && this.result.details?.firstChangedLine) {
|
||||
pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`);
|
||||
}
|
||||
|
||||
text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
|
||||
|
||||
if (this.result) {
|
||||
if (this.result.isError) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,866 @@
|
|||
import {
|
||||
type Component,
|
||||
Container,
|
||||
Input,
|
||||
isArrowDown,
|
||||
isArrowLeft,
|
||||
isArrowRight,
|
||||
isArrowUp,
|
||||
isBackspace,
|
||||
isCtrlC,
|
||||
isCtrlO,
|
||||
isEnter,
|
||||
isEscape,
|
||||
isShiftCtrlO,
|
||||
Spacer,
|
||||
Text,
|
||||
TruncatedText,
|
||||
truncateToWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { SessionTreeNode } from "../../../core/session-manager.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
|
||||
interface GutterInfo {
|
||||
position: number; // displayIndent level where the connector was shown
|
||||
show: boolean; // true = show │, false = show spaces
|
||||
}
|
||||
|
||||
/** Flattened tree node for navigation */
|
||||
interface FlatNode {
|
||||
node: SessionTreeNode;
|
||||
/** Indentation level (each level = 3 chars) */
|
||||
indent: number;
|
||||
/** Whether to show connector (├─ or └─) - true if parent has multiple children */
|
||||
showConnector: boolean;
|
||||
/** If showConnector, true = last sibling (└─), false = not last (├─) */
|
||||
isLast: boolean;
|
||||
/** Gutter info for each ancestor branch point */
|
||||
gutters: GutterInfo[];
|
||||
/** True if this node is a root under a virtual branching root (multiple roots) */
|
||||
isVirtualRootChild: boolean;
|
||||
}
|
||||
|
||||
/** Filter mode for tree display */
|
||||
type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
||||
|
||||
/**
|
||||
* Tree list component with selection and ASCII art visualization
|
||||
*/
|
||||
/** Tool call info for lookup */
|
||||
interface ToolCallInfo {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class TreeList implements Component {
|
||||
private flatNodes: FlatNode[] = [];
|
||||
private filteredNodes: FlatNode[] = [];
|
||||
private selectedIndex = 0;
|
||||
private currentLeafId: string | null;
|
||||
private maxVisibleLines: number;
|
||||
private filterMode: FilterMode = "default";
|
||||
private searchQuery = "";
|
||||
private toolCallMap: Map<string, ToolCallInfo> = new Map();
|
||||
private multipleRoots = false;
|
||||
private activePathIds: Set<string> = new Set();
|
||||
|
||||
public onSelect?: (entryId: string) => void;
|
||||
public onCancel?: () => void;
|
||||
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
|
||||
|
||||
constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) {
|
||||
this.currentLeafId = currentLeafId;
|
||||
this.maxVisibleLines = maxVisibleLines;
|
||||
this.multipleRoots = tree.length > 1;
|
||||
this.flatNodes = this.flattenTree(tree);
|
||||
this.buildActivePath();
|
||||
this.applyFilter();
|
||||
|
||||
// Start with current leaf selected
|
||||
const leafIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === currentLeafId);
|
||||
if (leafIndex !== -1) {
|
||||
this.selectedIndex = leafIndex;
|
||||
} else {
|
||||
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the set of entry IDs on the path from root to current leaf */
|
||||
private buildActivePath(): void {
|
||||
this.activePathIds.clear();
|
||||
if (!this.currentLeafId) return;
|
||||
|
||||
// Build a map of id -> entry for parent lookup
|
||||
const entryMap = new Map<string, FlatNode>();
|
||||
for (const flatNode of this.flatNodes) {
|
||||
entryMap.set(flatNode.node.entry.id, flatNode);
|
||||
}
|
||||
|
||||
// Walk from leaf to root
|
||||
let currentId: string | null = this.currentLeafId;
|
||||
while (currentId) {
|
||||
this.activePathIds.add(currentId);
|
||||
const node = entryMap.get(currentId);
|
||||
if (!node) break;
|
||||
currentId = node.node.entry.parentId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
private flattenTree(roots: SessionTreeNode[]): FlatNode[] {
|
||||
const result: FlatNode[] = [];
|
||||
this.toolCallMap.clear();
|
||||
|
||||
// Indentation rules:
|
||||
// - At indent 0: stay at 0 unless parent has >1 children (then +1)
|
||||
// - At indent 1: children always go to indent 2 (visual grouping of subtree)
|
||||
// - At indent 2+: stay flat for single-child chains, +1 only if parent branches
|
||||
|
||||
// Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
|
||||
type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean];
|
||||
const stack: StackItem[] = [];
|
||||
|
||||
// Determine which subtrees contain the active leaf (to sort current branch first)
|
||||
// Use iterative post-order traversal to avoid stack overflow
|
||||
const containsActive = new Map<SessionTreeNode, boolean>();
|
||||
const leafId = this.currentLeafId;
|
||||
{
|
||||
// Build list in pre-order, then process in reverse for post-order effect
|
||||
const allNodes: SessionTreeNode[] = [];
|
||||
const preOrderStack: SessionTreeNode[] = [...roots];
|
||||
while (preOrderStack.length > 0) {
|
||||
const node = preOrderStack.pop()!;
|
||||
allNodes.push(node);
|
||||
// Push children in reverse so they're processed left-to-right
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
preOrderStack.push(node.children[i]);
|
||||
}
|
||||
}
|
||||
// Process in reverse (post-order): children before parents
|
||||
for (let i = allNodes.length - 1; i >= 0; i--) {
|
||||
const node = allNodes[i];
|
||||
let has = leafId !== null && node.entry.id === leafId;
|
||||
for (const child of node.children) {
|
||||
if (containsActive.get(child)) {
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
}
|
||||
}
|
||||
|
||||
// Add roots in reverse order, prioritizing the one containing the active leaf
|
||||
// If multiple roots, treat them as children of a virtual root that branches
|
||||
const multipleRoots = roots.length > 1;
|
||||
const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)));
|
||||
for (let i = orderedRoots.length - 1; i >= 0; i--) {
|
||||
const isLast = i === orderedRoots.length - 1;
|
||||
stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;
|
||||
|
||||
// Extract tool calls from assistant messages for later lookup
|
||||
const entry = node.entry;
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const content = (entry.message as { content?: unknown }).content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (typeof block === "object" && block !== null && "type" in block && block.type === "toolCall") {
|
||||
const tc = block as { id: string; name: string; arguments: Record<string, unknown> };
|
||||
this.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild });
|
||||
|
||||
const children = node.children;
|
||||
const multipleChildren = children.length > 1;
|
||||
|
||||
// Order children so the branch containing the active leaf comes first
|
||||
const orderedChildren = (() => {
|
||||
const prioritized: SessionTreeNode[] = [];
|
||||
const rest: SessionTreeNode[] = [];
|
||||
for (const child of children) {
|
||||
if (containsActive.get(child)) {
|
||||
prioritized.push(child);
|
||||
} else {
|
||||
rest.push(child);
|
||||
}
|
||||
}
|
||||
return [...prioritized, ...rest];
|
||||
})();
|
||||
|
||||
// Calculate child indent
|
||||
let childIndent: number;
|
||||
if (multipleChildren) {
|
||||
// Parent branches: children get +1
|
||||
childIndent = indent + 1;
|
||||
} else if (justBranched && indent > 0) {
|
||||
// First generation after a branch: +1 for visual grouping
|
||||
childIndent = indent + 1;
|
||||
} else {
|
||||
// Single-child chain: stay flat
|
||||
childIndent = indent;
|
||||
}
|
||||
|
||||
// Build gutters for children
|
||||
// If this node showed a connector, add a gutter entry for descendants
|
||||
// Only add gutter if connector is actually displayed (not suppressed for virtual root children)
|
||||
const connectorDisplayed = showConnector && !isVirtualRootChild;
|
||||
// When connector is displayed, add a gutter entry at the connector's position
|
||||
// Connector is at position (displayIndent - 1), so gutter should be there too
|
||||
const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;
|
||||
const connectorPosition = Math.max(0, currentDisplayIndent - 1);
|
||||
const childGutters: GutterInfo[] = connectorDisplayed
|
||||
? [...gutters, { position: connectorPosition, show: !isLast }]
|
||||
: gutters;
|
||||
|
||||
// Add children in reverse order
|
||||
for (let i = orderedChildren.length - 1; i >= 0; i--) {
|
||||
const childIsLast = i === orderedChildren.length - 1;
|
||||
stack.push([
|
||||
orderedChildren[i],
|
||||
childIndent,
|
||||
multipleChildren,
|
||||
multipleChildren,
|
||||
childIsLast,
|
||||
childGutters,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
// Remember currently selected node to preserve cursor position
|
||||
const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
|
||||
|
||||
const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
this.filteredNodes = this.flatNodes.filter((flatNode) => {
|
||||
const entry = flatNode.node.entry;
|
||||
const isCurrentLeaf = entry.id === this.currentLeafId;
|
||||
|
||||
// Skip assistant messages with only tool calls (no text) unless error/aborted
|
||||
// Always show current leaf so active position is visible
|
||||
if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) {
|
||||
const msg = entry.message as { stopReason?: string; content?: unknown };
|
||||
const hasText = this.hasTextContent(msg.content);
|
||||
const isErrorOrAborted = msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse";
|
||||
// Only hide if no text AND not an error/aborted message
|
||||
if (!hasText && !isErrorOrAborted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter mode
|
||||
let passesFilter = true;
|
||||
// Entry types hidden in default view (settings/bookkeeping)
|
||||
const isSettingsEntry =
|
||||
entry.type === "label" ||
|
||||
entry.type === "custom" ||
|
||||
entry.type === "model_change" ||
|
||||
entry.type === "thinking_level_change";
|
||||
|
||||
switch (this.filterMode) {
|
||||
case "user-only":
|
||||
// Just user messages
|
||||
passesFilter = entry.type === "message" && entry.message.role === "user";
|
||||
break;
|
||||
case "no-tools":
|
||||
// Default minus tool results
|
||||
passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult");
|
||||
break;
|
||||
case "labeled-only":
|
||||
// Just labeled entries
|
||||
passesFilter = flatNode.node.label !== undefined;
|
||||
break;
|
||||
case "all":
|
||||
// Show everything
|
||||
passesFilter = true;
|
||||
break;
|
||||
default:
|
||||
// Default mode: hide settings/bookkeeping entries
|
||||
passesFilter = !isSettingsEntry;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!passesFilter) return false;
|
||||
|
||||
// Apply search filter
|
||||
if (searchTokens.length > 0) {
|
||||
const nodeText = this.getSearchableText(flatNode.node).toLowerCase();
|
||||
return searchTokens.every((token) => nodeText.includes(token));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Try to preserve cursor on the same node after filtering
|
||||
if (previouslySelectedId) {
|
||||
const newIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === previouslySelectedId);
|
||||
if (newIndex !== -1) {
|
||||
this.selectedIndex = newIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back: clamp index if out of bounds
|
||||
if (this.selectedIndex >= this.filteredNodes.length) {
|
||||
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get searchable text content from a node */
|
||||
private getSearchableText(node: SessionTreeNode): string {
|
||||
const entry = node.entry;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (node.label) {
|
||||
parts.push(node.label);
|
||||
}
|
||||
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const msg = entry.message;
|
||||
parts.push(msg.role);
|
||||
if ("content" in msg && msg.content) {
|
||||
parts.push(this.extractContent(msg.content));
|
||||
}
|
||||
if (msg.role === "bashExecution") {
|
||||
const bashMsg = msg as { command?: string };
|
||||
if (bashMsg.command) parts.push(bashMsg.command);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "custom_message": {
|
||||
parts.push(entry.customType);
|
||||
if (typeof entry.content === "string") {
|
||||
parts.push(entry.content);
|
||||
} else {
|
||||
parts.push(this.extractContent(entry.content));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
parts.push("compaction");
|
||||
break;
|
||||
case "branch_summary":
|
||||
parts.push("branch summary", entry.summary);
|
||||
break;
|
||||
case "model_change":
|
||||
parts.push("model", entry.modelId);
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
parts.push("thinking", entry.thinkingLevel);
|
||||
break;
|
||||
case "custom":
|
||||
parts.push("custom", entry.customType);
|
||||
break;
|
||||
case "label":
|
||||
parts.push("label", entry.label ?? "");
|
||||
break;
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
getSearchQuery(): string {
|
||||
return this.searchQuery;
|
||||
}
|
||||
|
||||
getSelectedNode(): SessionTreeNode | undefined {
|
||||
return this.filteredNodes[this.selectedIndex]?.node;
|
||||
}
|
||||
|
||||
updateNodeLabel(entryId: string, label: string | undefined): void {
|
||||
for (const flatNode of this.flatNodes) {
|
||||
if (flatNode.node.entry.id === entryId) {
|
||||
flatNode.node.label = label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getFilterLabel(): string {
|
||||
switch (this.filterMode) {
|
||||
case "no-tools":
|
||||
return " [no-tools]";
|
||||
case "user-only":
|
||||
return " [user]";
|
||||
case "labeled-only":
|
||||
return " [labeled]";
|
||||
case "all":
|
||||
return " [all]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (this.filteredNodes.length === 0) {
|
||||
lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
|
||||
lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getFilterLabel()}`), width));
|
||||
return lines;
|
||||
}
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.selectedIndex - Math.floor(this.maxVisibleLines / 2),
|
||||
this.filteredNodes.length - this.maxVisibleLines,
|
||||
),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
|
||||
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const flatNode = this.filteredNodes[i];
|
||||
const entry = flatNode.node.entry;
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
// Build line: cursor + prefix + path marker + label + content
|
||||
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
|
||||
|
||||
// If multiple roots, shift display (roots at 0, not 1)
|
||||
const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
|
||||
|
||||
// Build prefix with gutters at their correct positions
|
||||
// Each gutter has a position (displayIndent where its connector was shown)
|
||||
const connector =
|
||||
flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : "";
|
||||
const connectorPosition = connector ? displayIndent - 1 : -1;
|
||||
|
||||
// Build prefix char by char, placing gutters and connector at their positions
|
||||
const totalChars = displayIndent * 3;
|
||||
const prefixChars: string[] = [];
|
||||
for (let i = 0; i < totalChars; i++) {
|
||||
const level = Math.floor(i / 3);
|
||||
const posInLevel = i % 3;
|
||||
|
||||
// Check if there's a gutter at this level
|
||||
const gutter = flatNode.gutters.find((g) => g.position === level);
|
||||
if (gutter) {
|
||||
if (posInLevel === 0) {
|
||||
prefixChars.push(gutter.show ? "│" : " ");
|
||||
} else {
|
||||
prefixChars.push(" ");
|
||||
}
|
||||
} else if (connector && level === connectorPosition) {
|
||||
// Connector at this level
|
||||
if (posInLevel === 0) {
|
||||
prefixChars.push(flatNode.isLast ? "└" : "├");
|
||||
} else if (posInLevel === 1) {
|
||||
prefixChars.push("─");
|
||||
} else {
|
||||
prefixChars.push(" ");
|
||||
}
|
||||
} else {
|
||||
prefixChars.push(" ");
|
||||
}
|
||||
}
|
||||
const prefix = prefixChars.join("");
|
||||
|
||||
// Active path marker - shown right before the entry text
|
||||
const isOnActivePath = this.activePathIds.has(entry.id);
|
||||
const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : "";
|
||||
|
||||
const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : "";
|
||||
const content = this.getEntryDisplayText(flatNode.node, isSelected);
|
||||
|
||||
let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content;
|
||||
if (isSelected) {
|
||||
line = theme.bg("selectedBg", line);
|
||||
}
|
||||
lines.push(truncateToWidth(line, width));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`),
|
||||
width,
|
||||
),
|
||||
);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string {
|
||||
const entry = node.entry;
|
||||
let result: string;
|
||||
|
||||
const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim();
|
||||
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const msg = entry.message;
|
||||
const role = msg.role;
|
||||
if (role === "user") {
|
||||
const msgWithContent = msg as { content?: unknown };
|
||||
const content = normalize(this.extractContent(msgWithContent.content));
|
||||
result = theme.fg("accent", "user: ") + content;
|
||||
} else if (role === "assistant") {
|
||||
const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
|
||||
const textContent = normalize(this.extractContent(msgWithContent.content));
|
||||
if (textContent) {
|
||||
result = theme.fg("success", "assistant: ") + textContent;
|
||||
} else if (msgWithContent.stopReason === "aborted") {
|
||||
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
|
||||
} else if (msgWithContent.errorMessage) {
|
||||
const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
|
||||
result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
|
||||
} else {
|
||||
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
|
||||
}
|
||||
} else if (role === "toolResult") {
|
||||
const toolMsg = msg as { toolCallId?: string; toolName?: string };
|
||||
const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined;
|
||||
if (toolCall) {
|
||||
result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments));
|
||||
} else {
|
||||
result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`);
|
||||
}
|
||||
} else if (role === "bashExecution") {
|
||||
const bashMsg = msg as { command?: string };
|
||||
result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`);
|
||||
} else {
|
||||
result = theme.fg("dim", `[${role}]`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "custom_message": {
|
||||
const content =
|
||||
typeof entry.content === "string"
|
||||
? entry.content
|
||||
: entry.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content);
|
||||
break;
|
||||
}
|
||||
case "compaction": {
|
||||
const tokens = Math.round(entry.tokensBefore / 1000);
|
||||
result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`);
|
||||
break;
|
||||
}
|
||||
case "branch_summary":
|
||||
result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary);
|
||||
break;
|
||||
case "model_change":
|
||||
result = theme.fg("dim", `[model: ${entry.modelId}]`);
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);
|
||||
break;
|
||||
case "custom":
|
||||
result = theme.fg("dim", `[custom: ${entry.customType}]`);
|
||||
break;
|
||||
case "label":
|
||||
result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`);
|
||||
break;
|
||||
default:
|
||||
result = "";
|
||||
}
|
||||
|
||||
return isSelected ? theme.bold(result) : result;
|
||||
}
|
||||
|
||||
private extractContent(content: unknown): string {
|
||||
const maxLen = 200;
|
||||
if (typeof content === "string") return content.slice(0, maxLen);
|
||||
if (Array.isArray(content)) {
|
||||
let result = "";
|
||||
for (const c of content) {
|
||||
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
|
||||
result += (c as { text: string }).text;
|
||||
if (result.length >= maxLen) return result.slice(0, maxLen);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private hasTextContent(content: unknown): boolean {
|
||||
if (typeof content === "string") return content.trim().length > 0;
|
||||
if (Array.isArray(content)) {
|
||||
for (const c of content) {
|
||||
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
|
||||
const text = (c as { text?: string }).text;
|
||||
if (text && text.trim().length > 0) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private formatToolCall(name: string, args: Record<string, unknown>): string {
|
||||
const shortenPath = (p: string): string => {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||
if (home && p.startsWith(home)) return `~${p.slice(home.length)}`;
|
||||
return p;
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "read": {
|
||||
const path = shortenPath(String(args.path || args.file_path || ""));
|
||||
const offset = args.offset as number | undefined;
|
||||
const limit = args.limit as number | undefined;
|
||||
let display = path;
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const start = offset ?? 1;
|
||||
const end = limit !== undefined ? start + limit - 1 : "";
|
||||
display += `:${start}${end ? `-${end}` : ""}`;
|
||||
}
|
||||
return `[read: ${display}]`;
|
||||
}
|
||||
case "write": {
|
||||
const path = shortenPath(String(args.path || args.file_path || ""));
|
||||
return `[write: ${path}]`;
|
||||
}
|
||||
case "edit": {
|
||||
const path = shortenPath(String(args.path || args.file_path || ""));
|
||||
return `[edit: ${path}]`;
|
||||
}
|
||||
case "bash": {
|
||||
const rawCmd = String(args.command || "");
|
||||
const cmd = rawCmd
|
||||
.replace(/[\n\t]/g, " ")
|
||||
.trim()
|
||||
.slice(0, 50);
|
||||
return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`;
|
||||
}
|
||||
case "grep": {
|
||||
const pattern = String(args.pattern || "");
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[grep: /${pattern}/ in ${path}]`;
|
||||
}
|
||||
case "find": {
|
||||
const pattern = String(args.pattern || "");
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[find: ${pattern} in ${path}]`;
|
||||
}
|
||||
case "ls": {
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[ls: ${path}]`;
|
||||
}
|
||||
default: {
|
||||
// Custom tool - show name and truncated JSON args
|
||||
const argsStr = JSON.stringify(args).slice(0, 40);
|
||||
return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (isArrowUp(keyData)) {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1;
|
||||
} else if (isArrowDown(keyData)) {
|
||||
this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
} else if (isArrowLeft(keyData)) {
|
||||
// Page up
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);
|
||||
} else if (isArrowRight(keyData)) {
|
||||
// Page down
|
||||
this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);
|
||||
} else if (isEnter(keyData)) {
|
||||
const selected = this.filteredNodes[this.selectedIndex];
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.node.entry.id);
|
||||
}
|
||||
} else if (isEscape(keyData)) {
|
||||
if (this.searchQuery) {
|
||||
this.searchQuery = "";
|
||||
this.applyFilter();
|
||||
} else {
|
||||
this.onCancel?.();
|
||||
}
|
||||
} else if (isCtrlC(keyData)) {
|
||||
this.onCancel?.();
|
||||
} else if (isShiftCtrlO(keyData)) {
|
||||
// Cycle filter backwards
|
||||
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
|
||||
const currentIndex = modes.indexOf(this.filterMode);
|
||||
this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];
|
||||
this.applyFilter();
|
||||
} else if (isCtrlO(keyData)) {
|
||||
// Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default
|
||||
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
|
||||
const currentIndex = modes.indexOf(this.filterMode);
|
||||
this.filterMode = modes[(currentIndex + 1) % modes.length];
|
||||
this.applyFilter();
|
||||
} else if (isBackspace(keyData)) {
|
||||
if (this.searchQuery.length > 0) {
|
||||
this.searchQuery = this.searchQuery.slice(0, -1);
|
||||
this.applyFilter();
|
||||
}
|
||||
} else if (keyData === "l" && !this.searchQuery) {
|
||||
const selected = this.filteredNodes[this.selectedIndex];
|
||||
if (selected && this.onLabelEdit) {
|
||||
this.onLabelEdit(selected.node.entry.id, selected.node.label);
|
||||
}
|
||||
} else {
|
||||
const hasControlChars = [...keyData].some((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
||||
});
|
||||
if (!hasControlChars && keyData.length > 0) {
|
||||
this.searchQuery += keyData;
|
||||
this.applyFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Component that displays the current search query */
|
||||
class SearchLine implements Component {
|
||||
constructor(private treeList: TreeList) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const query = this.treeList.getSearchQuery();
|
||||
if (query) {
|
||||
return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)];
|
||||
}
|
||||
return [truncateToWidth(` ${theme.fg("muted", "Search:")}`, width)];
|
||||
}
|
||||
|
||||
handleInput(_keyData: string): void {}
|
||||
}
|
||||
|
||||
/** Label input component shown when editing a label */
|
||||
class LabelInput implements Component {
|
||||
private input: Input;
|
||||
private entryId: string;
|
||||
public onSubmit?: (entryId: string, label: string | undefined) => void;
|
||||
public onCancel?: () => void;
|
||||
|
||||
constructor(entryId: string, currentLabel: string | undefined) {
|
||||
this.entryId = entryId;
|
||||
this.input = new Input();
|
||||
if (currentLabel) {
|
||||
this.input.setValue(currentLabel);
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const indent = " ";
|
||||
const availableWidth = width - indent.length;
|
||||
lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width));
|
||||
lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));
|
||||
lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width));
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (isEnter(keyData)) {
|
||||
const value = this.input.getValue().trim();
|
||||
this.onSubmit?.(this.entryId, value || undefined);
|
||||
} else if (isEscape(keyData)) {
|
||||
this.onCancel?.();
|
||||
} else {
|
||||
this.input.handleInput(keyData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a session tree selector for navigation
|
||||
*/
|
||||
export class TreeSelectorComponent extends Container {
|
||||
private treeList: TreeList;
|
||||
private labelInput: LabelInput | null = null;
|
||||
private labelInputContainer: Container;
|
||||
private treeContainer: Container;
|
||||
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
|
||||
|
||||
constructor(
|
||||
tree: SessionTreeNode[],
|
||||
currentLeafId: string | null,
|
||||
terminalHeight: number,
|
||||
onSelect: (entryId: string) => void,
|
||||
onCancel: () => void,
|
||||
onLabelChange?: (entryId: string, label: string | undefined) => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.onLabelChangeCallback = onLabelChange;
|
||||
const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));
|
||||
|
||||
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines);
|
||||
this.treeList.onSelect = onSelect;
|
||||
this.treeList.onCancel = onCancel;
|
||||
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
|
||||
|
||||
this.treeContainer = new Container();
|
||||
this.treeContainer.addChild(this.treeList);
|
||||
|
||||
this.labelInputContainer = new Container();
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
|
||||
this.addChild(
|
||||
new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O/⇧^O: filter. Type to search"), 0, 0),
|
||||
);
|
||||
this.addChild(new SearchLine(this.treeList));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.treeContainer);
|
||||
this.addChild(this.labelInputContainer);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
if (tree.length === 0) {
|
||||
setTimeout(() => onCancel(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
private showLabelInput(entryId: string, currentLabel: string | undefined): void {
|
||||
this.labelInput = new LabelInput(entryId, currentLabel);
|
||||
this.labelInput.onSubmit = (id, label) => {
|
||||
this.treeList.updateNodeLabel(id, label);
|
||||
this.onLabelChangeCallback?.(id, label);
|
||||
this.hideLabelInput();
|
||||
};
|
||||
this.labelInput.onCancel = () => this.hideLabelInput();
|
||||
|
||||
this.treeContainer.clear();
|
||||
this.labelInputContainer.clear();
|
||||
this.labelInputContainer.addChild(this.labelInput);
|
||||
}
|
||||
|
||||
private hideLabelInput(): void {
|
||||
this.labelInput = null;
|
||||
this.labelInputContainer.clear();
|
||||
this.treeContainer.clear();
|
||||
this.treeContainer.addChild(this.treeList);
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (this.labelInput) {
|
||||
this.labelInput.handleInput(keyData);
|
||||
} else {
|
||||
this.treeList.handleInput(keyData);
|
||||
}
|
||||
}
|
||||
|
||||
getTreeList(): TreeList {
|
||||
return this.treeList;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import { theme } from "../theme/theme.js";
|
|||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
interface UserMessageItem {
|
||||
index: number; // Index in the full messages array
|
||||
id: string; // Entry ID in the session
|
||||
text: string; // The message text
|
||||
timestamp?: string; // Optional timestamp if available
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ interface UserMessageItem {
|
|||
class UserMessageList implements Component {
|
||||
private messages: UserMessageItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
public onSelect?: (messageIndex: number) => void;
|
||||
public onSelect?: (entryId: string) => void;
|
||||
public onCancel?: () => void;
|
||||
private maxVisible: number = 10; // Max messages visible
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ class UserMessageList implements Component {
|
|||
else if (isEnter(keyData)) {
|
||||
const selected = this.messages[this.selectedIndex];
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.index);
|
||||
this.onSelect(selected.id);
|
||||
}
|
||||
}
|
||||
// Escape - cancel
|
||||
|
|
@ -125,7 +125,7 @@ class UserMessageList implements Component {
|
|||
export class UserMessageSelectorComponent extends Container {
|
||||
private messageList: UserMessageList;
|
||||
|
||||
constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) {
|
||||
constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
// Add header
|
||||
|
|
|
|||
|
|
@ -5,13 +5,9 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
|
|||
* Component that renders a user message
|
||||
*/
|
||||
export class UserMessageComponent extends Container {
|
||||
constructor(text: string, isFirst: boolean) {
|
||||
constructor(text: string) {
|
||||
super();
|
||||
|
||||
// Add spacer before user message (except first one)
|
||||
if (!isFirst) {
|
||||
this.addChild(new Spacer(1));
|
||||
}
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(
|
||||
new Markdown(text, 1, 1, getMarkdownTheme(), {
|
||||
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,10 +11,12 @@
|
|||
"dimGray": "#666666",
|
||||
"darkGray": "#505050",
|
||||
"accent": "#8abeb7",
|
||||
"selectedBg": "#3a3a4a",
|
||||
"userMsgBg": "#343541",
|
||||
"toolPendingBg": "#282832",
|
||||
"toolSuccessBg": "#283228",
|
||||
"toolErrorBg": "#3c2828"
|
||||
"toolErrorBg": "#3c2828",
|
||||
"customMsgBg": "#2d2838"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "accent",
|
||||
|
|
@ -27,9 +29,14 @@
|
|||
"muted": "gray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
"thinkingText": "gray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"customMessageBg": "customMsgBg",
|
||||
"customMessageText": "",
|
||||
"customMessageLabel": "#9575cd",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
"mediumGray": "#6c6c6c",
|
||||
"dimGray": "#8a8a8a",
|
||||
"lightGray": "#b0b0b0",
|
||||
"selectedBg": "#d0d0e0",
|
||||
"userMsgBg": "#e8e8e8",
|
||||
"toolPendingBg": "#e8e8f0",
|
||||
"toolSuccessBg": "#e8f0e8",
|
||||
"toolErrorBg": "#f0e8e8"
|
||||
"toolErrorBg": "#f0e8e8",
|
||||
"customMsgBg": "#ede7f6"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "teal",
|
||||
|
|
@ -26,9 +28,14 @@
|
|||
"muted": "mediumGray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
"thinkingText": "mediumGray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"customMessageBg": "customMsgBg",
|
||||
"customMessageText": "",
|
||||
"customMessageLabel": "#7e57c2",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@
|
|||
"text",
|
||||
"userMessageBg",
|
||||
"userMessageText",
|
||||
"customMessageBg",
|
||||
"customMessageText",
|
||||
"customMessageLabel",
|
||||
"toolPendingBg",
|
||||
"toolSuccessBg",
|
||||
"toolErrorBg",
|
||||
|
|
@ -122,6 +125,18 @@
|
|||
"$ref": "#/$defs/colorValue",
|
||||
"description": "User message text color"
|
||||
},
|
||||
"customMessageBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Custom message background (hook-injected messages)"
|
||||
},
|
||||
"customMessageText": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Custom message text color"
|
||||
},
|
||||
"customMessageLabel": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Custom message type label color"
|
||||
},
|
||||
"toolPendingBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Tool execution box (pending state)"
|
||||
|
|
|
|||
|
|
@ -34,9 +34,14 @@ const ThemeJsonSchema = Type.Object({
|
|||
muted: ColorValueSchema,
|
||||
dim: ColorValueSchema,
|
||||
text: ColorValueSchema,
|
||||
// Backgrounds & Content Text (7 colors)
|
||||
thinkingText: ColorValueSchema,
|
||||
// Backgrounds & Content Text (11 colors)
|
||||
selectedBg: ColorValueSchema,
|
||||
userMessageBg: ColorValueSchema,
|
||||
userMessageText: ColorValueSchema,
|
||||
customMessageBg: ColorValueSchema,
|
||||
customMessageText: ColorValueSchema,
|
||||
customMessageLabel: ColorValueSchema,
|
||||
toolPendingBg: ColorValueSchema,
|
||||
toolSuccessBg: ColorValueSchema,
|
||||
toolErrorBg: ColorValueSchema,
|
||||
|
|
@ -94,7 +99,10 @@ export type ThemeColor =
|
|||
| "muted"
|
||||
| "dim"
|
||||
| "text"
|
||||
| "thinkingText"
|
||||
| "userMessageText"
|
||||
| "customMessageText"
|
||||
| "customMessageLabel"
|
||||
| "toolTitle"
|
||||
| "toolOutput"
|
||||
| "mdHeading"
|
||||
|
|
@ -127,7 +135,13 @@ export type ThemeColor =
|
|||
| "thinkingXhigh"
|
||||
| "bashMode";
|
||||
|
||||
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
|
||||
export type ThemeBg =
|
||||
| "selectedBg"
|
||||
| "userMessageBg"
|
||||
| "customMessageBg"
|
||||
| "toolPendingBg"
|
||||
| "toolSuccessBg"
|
||||
| "toolErrorBg";
|
||||
|
||||
type ColorMode = "truecolor" | "256color";
|
||||
|
||||
|
|
@ -482,7 +496,14 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
|||
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
||||
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
||||
const bgColorKeys: Set<string> = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
|
||||
const bgColorKeys: Set<string> = new Set([
|
||||
"selectedBg",
|
||||
"userMessageBg",
|
||||
"customMessageBg",
|
||||
"toolPendingBg",
|
||||
"toolSuccessBg",
|
||||
"toolErrorBg",
|
||||
]);
|
||||
for (const [key, value] of Object.entries(resolvedColors)) {
|
||||
if (bgColorKeys.has(key)) {
|
||||
bgColors[key as ThemeBg] = value;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
* - `pi --mode json "prompt"` - JSON event stream
|
||||
*/
|
||||
|
||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "../core/agent-session.js";
|
||||
|
||||
/**
|
||||
|
|
@ -18,38 +17,36 @@ import type { AgentSession } from "../core/agent-session.js";
|
|||
* @param mode Output mode: "text" for final response only, "json" for all events
|
||||
* @param messages Array of prompts to send
|
||||
* @param initialMessage Optional first message (may contain @file content)
|
||||
* @param initialAttachments Optional attachments for the initial message
|
||||
* @param initialImages Optional images for the initial message
|
||||
*/
|
||||
export async function runPrintMode(
|
||||
session: AgentSession,
|
||||
mode: "text" | "json",
|
||||
messages: string[],
|
||||
initialMessage?: string,
|
||||
initialAttachments?: Attachment[],
|
||||
initialImages?: ImageContent[],
|
||||
): Promise<void> {
|
||||
// Load entries once for session start events
|
||||
const entries = session.sessionManager.getEntries();
|
||||
|
||||
// Hook runner already has no-op UI context by default (set in main.ts)
|
||||
// Set up hooks for print mode (no UI)
|
||||
const hookRunner = session.hookRunner;
|
||||
if (hookRunner) {
|
||||
// Use actual session file if configured (via --session), otherwise null
|
||||
hookRunner.setSessionFile(session.sessionFile);
|
||||
hookRunner.initialize({
|
||||
getModel: () => session.model,
|
||||
sendMessageHandler: (message, triggerTurn) => {
|
||||
session.sendHookMessage(message, triggerTurn).catch((e) => {
|
||||
console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
});
|
||||
},
|
||||
appendEntryHandler: (customType, data) => {
|
||||
session.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
});
|
||||
hookRunner.onError((err) => {
|
||||
console.error(`Hook error (${err.hookPath}): ${err.error}`);
|
||||
});
|
||||
// No-op send handler for print mode (single-shot, no async messages)
|
||||
hookRunner.setSendHandler(() => {
|
||||
console.error("Warning: pi.send() is not supported in print mode");
|
||||
});
|
||||
// Emit session event
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "start",
|
||||
type: "session_start",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -57,12 +54,17 @@ export async function runPrintMode(
|
|||
for (const { tool } of session.customTools) {
|
||||
if (tool.onSession) {
|
||||
try {
|
||||
await tool.onSession({
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "start",
|
||||
});
|
||||
await tool.onSession(
|
||||
{
|
||||
reason: "start",
|
||||
previousSessionFile: undefined,
|
||||
},
|
||||
{
|
||||
sessionManager: session.sessionManager,
|
||||
modelRegistry: session.modelRegistry,
|
||||
model: session.model,
|
||||
},
|
||||
);
|
||||
} catch (_err) {
|
||||
// Silently ignore tool errors
|
||||
}
|
||||
|
|
@ -79,7 +81,7 @@ export async function runPrintMode(
|
|||
|
||||
// Send initial message with attachments
|
||||
if (initialMessage) {
|
||||
await session.prompt(initialMessage, { attachments: initialAttachments });
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
|
||||
// Send remaining messages
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@
|
|||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import * as readline from "node:readline";
|
||||
import type { AgentEvent, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { CompactionResult, SessionStats } from "../../core/agent-session.js";
|
||||
import type { AgentEvent, AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { SessionStats } from "../../core/agent-session.js";
|
||||
import type { BashResult } from "../../core/bash-executor.js";
|
||||
import type { CompactionResult } from "../../core/compaction/index.js";
|
||||
import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -166,8 +168,8 @@ export class RpcClient {
|
|||
* Returns immediately after sending; use onEvent() to receive streaming events.
|
||||
* Use waitForIdle() to wait for completion.
|
||||
*/
|
||||
async prompt(message: string, attachments?: Attachment[]): Promise<void> {
|
||||
await this.send({ type: "prompt", message, attachments });
|
||||
async prompt(message: string, images?: ImageContent[]): Promise<void> {
|
||||
await this.send({ type: "prompt", message, images });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -324,17 +326,17 @@ export class RpcClient {
|
|||
* Branch from a specific message.
|
||||
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
|
||||
*/
|
||||
async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> {
|
||||
const response = await this.send({ type: "branch", entryIndex });
|
||||
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> {
|
||||
const response = await this.send({ type: "branch", entryId });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages available for branching.
|
||||
*/
|
||||
async getBranchMessages(): Promise<Array<{ entryIndex: number; text: string }>> {
|
||||
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
|
||||
const response = await this.send({ type: "get_branch_messages" });
|
||||
return this.getData<{ messages: Array<{ entryIndex: number; text: string }> }>(response).messages;
|
||||
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -348,9 +350,9 @@ export class RpcClient {
|
|||
/**
|
||||
* Get all messages in the session.
|
||||
*/
|
||||
async getMessages(): Promise<AppMessage[]> {
|
||||
async getMessages(): Promise<AgentMessage[]> {
|
||||
const response = await this.send({ type: "get_messages" });
|
||||
return this.getData<{ messages: AppMessage[] }>(response).messages;
|
||||
return this.getData<{ messages: AgentMessage[] }>(response).messages;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -403,9 +405,9 @@ export class RpcClient {
|
|||
/**
|
||||
* Send prompt and wait for completion, returning all events.
|
||||
*/
|
||||
async promptAndWait(message: string, attachments?: Attachment[], timeout = 60000): Promise<AgentEvent[]> {
|
||||
async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<AgentEvent[]> {
|
||||
const eventsPromise = this.collectEvents(timeout);
|
||||
await this.prompt(message, attachments);
|
||||
await this.prompt(message, images);
|
||||
return eventsPromise;
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue