diff --git a/.pi/skills/create-specialized-subagent/SKILL.md b/.pi/skills/create-specialized-subagent/SKILL.md deleted file mode 100644 index eaf4d529..00000000 --- a/.pi/skills/create-specialized-subagent/SKILL.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: create-specialized-subagent -description: Create specialized subagents within the subagents extension. Use when asked to create a new subagent like scout, librarian, oracle, etc. ---- - -# Create Specialized Subagent - -Create subagents within `extensions/subagents/subagents/`. - -Subagents are autonomous agents with their own tools, system prompt, and model. They run as Pi tools, streaming progress and rendering results. - -## Directory Structure - -``` -extensions/subagents/subagents// -├── index.ts # Main tool definition (createXxxTool, executeXxx, XXX_GUIDANCE) -├── system-prompt.ts # System prompt string -├── types.ts # XxxInput, XxxDetails interfaces -├── tool-formatter.ts # formatXxxToolCall() for display -└── tools/ - ├── index.ts # createXxxTools() aggregator - └── .ts # Individual tool definitions -``` - -Note: Subagents do not use a separate `config.ts` file. Model configuration is handled in `index.ts` or imported from shared configuration. - -## Files Overview - -### types.ts - -- `XxxInput`: Parameters the parent agent provides - - Include `skills?: string[]` for optional skill passthrough -- `XxxDetails`: State for UI rendering, must include: - - `toolCalls: SubagentToolCall[]` - - `spinnerFrame: number` - - `response?: string` - - `aborted?: boolean` - - `error?: string` - - `usage?: SubagentUsage` - - `skills?: string[]` - Requested skill names (from input) - - `skillsResolved?: number` - Number of skills successfully resolved - - `skillsNotFound?: string[]` - Skill names that were not found - -### config.ts - -- `MODEL`: Provider and model ID -- Optional provider type aliases for external APIs - -### system-prompt.ts - -- Role definition -- Available tools list -- Behavior rules per input combination -- Response format guidelines -- Constraints and guardrails - -### tool-formatter.ts - -- `formatXxxToolCall(tc)`: Returns `{ label, detail }` for human-readable display -- `label`: Action name ("Search", "Fetch") -- `detail`: Context (hostname, query, repo path) - -### tools/ - -- Individual tool definitions -- Must return `cost` in details for cost aggregation: - ```typescript - return { - content: [...], - details: { ..., cost: response.costDollars?.total }, - }; - ``` - -### index.ts - -Exports: -- `createXxxTool()`: Tool definition with `execute`, `renderCall`, `renderResult` -- `executeXxx()`: Direct execution without tool wrapper -- `XXX_GUIDANCE`: Markdown guidance for parent agent's system prompt - -## Execute Function - -1. Resolve skills if provided: - ```typescript - const { skills: skillNames } = args; - let resolvedSkills: Skill[] = []; - let notFoundSkills: string[] = []; - if (skillNames && skillNames.length > 0) { - const result = resolveSkillsByName(skillNames, ctx.cwd); - resolvedSkills = result.skills; - notFoundSkills = result.notFound; - } - ``` -2. Validate inputs, return error in details if invalid (include skill info) -3. Set up spinner interval (80ms), clear in `finally` -4. Build user message, append warning if skills not found: - ```typescript - if (notFoundSkills.length > 0) { - userMessage += `\n\n**Note:** The following skills were not found and could not be loaded: ${notFoundSkills.join(", ")}`; - } - ``` -5. Call `executeSubagent()` with: - - `skills: resolvedSkills` in config - - `onTextUpdate` and `onToolUpdate` callbacks that include skill info in details -6. Handle abort/error states (include skill info in all return paths) -7. Use final tool calls from `result.toolCalls` for failure checks -8. Check if all tool calls failed → return error -9. Return `usage` from result (include skill info) - -## Tool Rendering Guidelines (required) - -Use shared UI abstractions from `@aliou/pi-utils-ui`. Do not hand-roll tool header/body/footer for new subagents. - -### renderCall pattern - -Always use this header shape: - -- First line: `[Tool Name]: [Action] [Main arg] [Option args]` -- Following lines: long args only (wrapped naturally) - -In subagents, use `ToolCallHeader`: - -```ts -return new ToolCallHeader( - { - toolName: "Scout", // display name, not snake_case tool id - // action only when meaningful (e.g. process start/output/kill) - mainArg: "short primary arg", - optionArgs: [{ label: "cwd", value: args.cwd ?? "" }], - longArgs: [{ label: "prompt", value: args.prompt }], - }, - theme, -); -``` - -Rules: -- Keep main arg short and useful. -- Move long text (prompt/task/question/instructions/context) to `longArgs`. -- Do not truncate when wrapping gives better readability (e.g. query/question). -- Tool name should be human display text (`Scout`, `Read Session`), not raw tool id. - -### renderResult structure - -Use `ToolBody` + footer component (`SubagentFooter` for model-backed subagents). - -```ts -return new ToolBody({ fields, footer }, options, theme); -``` - -Footer spacing is standardized. Keep footer data concise and machine-skim-friendly. - -## renderResult States - -| State | Display | -|-------|---------| -| Aborted | Warning "Aborted" + optional completion count | -| Error | Error message | -| Running + collapsed | Spinner + current tool name | -| Running + expanded | Status line + all tool calls with indicators | -| Done + collapsed | `✓` or `✗` (if all failed) + stats | -| Done + expanded | Stats + tool summary + failed tool details + markdown response | - -## Registration - -In `extensions/subagents/index.ts`: - -1. Import the tool and guidance -2. Add guidance to `SUBAGENT_GUIDANCES` array -3. Register tool with `pi.registerTool(createXxxTool())` -4. If the subagent requires API keys, add them to `checkApiKeys()` - -See the existing registration pattern in the file. - -## API Key Validation - -If your subagent requires external API keys, validate them at extension load time. This prevents the extension from loading if required keys are missing. - -Add your required keys to `checkApiKeys()` in `extensions/subagents/index.ts`. See the existing implementation for the pattern. - -## Checklist - -1. Create directory: `subagents//` -2. Create `types.ts` with Input and Details interfaces - - Add `skills?: string[]` to Input interface - - Add `skills?`, `skillsResolved?`, `skillsNotFound?` to Details interface -3. Create `config.ts` with model configuration -4. Create `system-prompt.ts` with subagent instructions -5. Create `tools/` directory with subagent's tools -6. Create `tool-formatter.ts` for display formatting -7. Create `index.ts` with createXxxTool, executeXxx, XXX_GUIDANCE - - Import `resolveSkillsByName` and `Skill` type from lib - - Add `skills` parameter to TypeBox schema - - Update tool description to mention skill support - - Resolve skills in execute function - - Pass `skills: resolvedSkills` to executeSubagent - - Include skill info in all details returns - - Use `ToolCallHeader` in `renderCall` and follow standard line pattern - - Use `ToolBody` + `SubagentFooter` in `renderResult` -8. Register in `extensions/subagents/index.ts` -9. Run `pnpm typecheck` - -## Key Points - -- **Details interface**: Must include `toolCalls`, `spinnerFrame`, `response`, `aborted`, `error`, `usage`, and skill tracking fields -- **Skills support**: All subagents should support optional `skills` parameter for specialized context -- **Cost tracking**: Tools must return `cost` in details for aggregation -- **Spinner**: 80ms interval, clear in finally block -- **Extensions disabled**: Subagents don't run user extensions (`extensions: []`) -- **Failed tools**: Show individually in done+expanded state -- **All failed**: Show error indicator only when ALL tools failed (partial success = success indicator) -- **Final tool calls**: Use `result.toolCalls` if present to decide failure state -- **Mark tool as failed**: When all internal tools fail, return error so parent agent sees failure -- **Skill resolution**: Use `resolveSkillsByName()` from lib to convert skill names to Skill objects -- **Skill warnings**: Append warning to user message if skills not found (don't fail the request) - -## Notifications - -Subagents can emit notifications to alert users. This is useful for long-running subagent tasks or when user attention is needed. - -### Emitting Notifications - -```typescript -const NOTIFICATION_EVENT = "ad:notification"; - -interface NotificationEvent { - message: string; - sound?: string; -} - -// In execute function, after completion -pi.events.emit(NOTIFICATION_EVENT, { - message: "Research completed", - sound: "/System/Library/Sounds/Blow.aiff", -}); -``` - -### When to Notify - -- Subagent completes a long-running task -- Subagent encounters errors that need user attention -- Subagent needs user input (though prefer interactive tools like `ask_user`) - -## Reference - -Refer to the scout subagent for complete implementation: -- [subagents/scout/](../../extensions/subagents/subagents/scout/) diff --git a/AGENTS.md b/AGENTS.md index 3bc92c60..35bb5a91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,25 +2,94 @@ My personal harness around [Pi](https://github.com/badlogic/pi-mono/) for coding-agent work. -All packages in this repository use the `@aliou` scope where applicable, not `@anthropic` or `@anthropic-ai`. +Internal workspace packages use the `@harness` scope and are private to this repository. ## Structure -- `extensions/` - Private Pi extensions bundled in this repository -- `packages/` - Shared internal package code -- `tests/` - Test utilities and harness. See [tests/README.md](tests/README.md) for usage. +- `commands/` - Slash commands (`/spawn`, `/qq`, `/projects:init`, etc.) +- `hooks/` - Event hooks (session protection, autocomplete, chrome, etc.) +- `tools/` - Agent tools (`bash`, `grep`, `find`, `read_url`, `oracle`, etc.) +- `extensions/` - Monolithic extensions still using their own `package.json` (`breadcrumbs`, `providers`) +- `packages/` - Shared internal workspace packages (`@harness/*`) +- `tests/` - Test setup and docs. Shared test utilities live in `packages/test-utils`. -## Extensions +## Entry point convention -- `breadcrumbs` - Session history tools. Search past sessions, extract information, and hand off context to new sessions. -- `qq` - `/qq` quick-question command with custom message rendering and context filtering. -- `defaults` - Personal sensible defaults and quality-of-life improvements. -- `editor` - Owns the custom editor component and shared border-decoration rendering. -- `modes` - Hardcoded execution modes (balanced/plan/implement) with prompt families, tool gating, model/thinking defaults, and branch-aware restore. -- `palette` - Command palette with keyboard-driven UI for running commands and shortcuts. -- `planning` - Turn conversations into implementation plans and manage saved plans. -- `providers` - Register custom providers and show unified rate-limit and usage dashboards. -- `subagents` - Framework for spawning specialized subagents with custom tools, consistent UI rendering, and logging. +Every extension entry point is `export default function(pi: ExtensionAPI)`, calling `pi.registerCommand(...)`, `pi.registerTool(...)`, or `pi.on(...)` directly. No wrapper functions. + +Pi discovers extensions from the root `package.json` `pi.extensions` array. Directories in that list are scanned for `index.ts` entry points. + +## File layout convention + +Within an extension directory: + +- `index.ts` - Registration code. All `pi.*` and `ctx.*` calls live here. +- `types.ts` - Type definitions and TypeBox schemas +- `render.ts` / `renderers.ts` - Tool/message render functions +- `helpers.ts` / `fetch.ts` / `sanitize.ts` / `blocked-paths.ts` - Pure functions and utilities (names vary by domain) + +No `pi.*` or `ctx.*` calls outside `index.ts`. Other files contain only pure functions, types, components, or utils. + +### Subagent-based tools + +Tools that spawn a subagent (oracle, reviewer, read-session, librarian) follow a different layout: + +- `index.ts` - Registration code. Spawns the subagent via `ctx.newSession()`. +- `types.ts` - Subagent details schema and shared types +- `prompt.ts` - System prompt builder for the subagent +- `models/index.ts` - Model selection (picks provider/model for the subagent) +- `tools/` - Subagent tool definitions (only visible inside the subagent, not the parent) +- `lib/` - Supporting logic (GitHub clients, etc.) + +## Commands + +| Directory | Commands | Notes | +|---|---|---| +| `continue/` | `/continue` | Continue from a linked parent session | +| `introspection/` | `/introspection` | Inspect extension internals | +| `label/` | `/label ` | Label the current session entry | +| `projects/` | `/projects:init`, `/projects:settings` | Multi-step project setup wizard | +| `qq/` | `/qq ` | Quick question without interrupting main session | +| `session-copy-id/` | `/session:copy-id` | Copy session ID to clipboard | +| `session-copy-path/` | `/session:copy-path` | Copy session file path to clipboard | +| `spawn/` | `/spawn [note]` | Create a linked child session | +| `theme/` | `/theme` | Cycle color theme | + +## Hooks + +| Directory | Purpose | Key files | +|---|---|---| +| `chrome/` | Header, footer, terminal title, notifications, auto-naming | `hooks/`, `components/`, `lib/`, `native/` | +| `context-window-overrides/` | Override context window size | `index.ts` | +| `event-compat/` | Backwards-compatible event aliases | `index.ts` | +| `protect-sessions-dir/` | Gate agent access to sessions directory | `gate.ts`, `session-gate-dialog.ts`, `bash-parser.ts` | +| `session-autocomplete/` | `@` autocomplete for session names | `db.ts`, `search.ts`, `provider.ts` | +| `session-title/` | Auto-name sessions | `index.ts` | + +## Tools + +| Directory | Tool name | Notes | +|---|---|---| +| `ask-user/` | `ask_user` | Passthrough | +| `bash/` | `bash` | Adds `cwd` param, spawn hooks, sanitization | +| `edit/` | `edit` | Passthrough | +| `find/` | `find` | Adds `glob`, blocked paths | +| `get-current-time/` | `get_current_time` | Passthrough | +| `grep/` | `grep` | Adds `literal`, `context`, blocked paths, custom render | +| `librarian/` | `librarian` | File search by description | +| `look-at/` | `look_at` | Image analysis | +| `oracle/` | `oracle` | GPT-5 advisor | +| `read/` | `read` | Passthrough | +| `read-session/` | `read_session` | Subagent session reader | +| `read-url/` | `read_url` | URL fetch with handler chain and preview | +| `reviewer/` | `reviewer` | Diff/code review | + +## Monolithic extensions + +These extensions still use their own `package.json` with `pi.extensions` and are discovered separately: + +- `breadcrumbs/` - Session history tools (`find_sessions`, `list_sessions`, `read_session`) +- `providers/` - Rate-limit alerts, usage widgets, dashboards ## Development @@ -30,13 +99,22 @@ Uses pnpm workspaces. Nix environment available via `flake.nix`. pnpm install pnpm typecheck pnpm lint +pnpm test ``` +Workspace packages: + +- `@harness/agent-kit` - Subagent framework used by harness tools and hooks. +- `@harness/events` - Shared event names and event payload types. +- `@harness/utils` - Shared generic utilities. +- `@harness/test-utils` - Shared Vitest/Pi extension test harness utilities. + ## Custom header -The startup header (`extensions/defaults/components/header.ts`) shows a curated list of harness shortcuts and commands. When adding a new `registerShortcut` or `registerCommand`, ask the user whether it should be added to the header. +The startup header (`hooks/chrome/components/header.ts`) shows a curated list of harness shortcuts and commands. When adding a new `registerShortcut` or `registerCommand`, ask the user whether it should be added to the header. ## Notes - This repo is my private Pi harness infrastructure first. Not every package here is intended to be published as a standalone package. - Keep repository-level docs focused on my Pi harness. Extension-specific details belong in the extension README files. +- Monolithic extensions (`breadcrumbs`, `providers`) may be migrated to the `commands/`/`hooks/`/`tools/` layout in the future. diff --git a/README.md b/README.md deleted file mode 100644 index 1c27bfa9..00000000 --- a/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# pi-harness - -My personal harness around [Pi](https://github.com/badlogic/pi-mono/) for coding-agent work. - -> [!WARNING] -> Feel free to use these, but they're mainly for my personal use and I might not read/merge your pr. Also, I haven't read a single line of code so I can't be held responsible if something bad happens. Godspeed ✌️ - -## Install - -Install the repository as a Pi package: - -```bash -pi install git:github.com/aliou/pi-harness -``` - -To install selectively, or disable specific extensions, edit your `settings.json`: - -```json -{ - "packages": [ - { - "source": "git:github.com/aliou/pi-harness", - "extensions": [ - "extensions/qq/index.ts", - "extensions/defaults/index.ts", - "extensions/providers/index.ts" - ] - } - ] -} -``` - -Extension paths should match the directory names in `extensions/` exactly. - -## Scope - -This repo contains my Pi harness extensions, shared package code, and test utilities. diff --git a/extensions/breadcrumbs/commands/continue/index.ts b/commands/continue/index.ts similarity index 100% rename from extensions/breadcrumbs/commands/continue/index.ts rename to commands/continue/index.ts diff --git a/extensions/introspection/components/introspect-panel.ts b/commands/introspection/components/introspect-panel.ts similarity index 100% rename from extensions/introspection/components/introspect-panel.ts rename to commands/introspection/components/introspect-panel.ts diff --git a/extensions/introspection/index.test.ts b/commands/introspection/index.test.ts similarity index 97% rename from extensions/introspection/index.test.ts rename to commands/introspection/index.test.ts index be4e93b9..de098081 100644 --- a/extensions/introspection/index.test.ts +++ b/commands/introspection/index.test.ts @@ -1,10 +1,10 @@ -import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent"; -import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPiTestHarness, type PiTestHarness, -} from "../../tests/utils/pi-test-harness"; -import { NOOP_THEME } from "../../tests/utils/theme"; +} from "@harness/test-utils/pi-test-harness"; +import { NOOP_THEME } from "@harness/test-utils/theme"; +import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { IntrospectPanel } from "./components/introspect-panel"; import introspectionExtension from "./index"; diff --git a/extensions/introspection/commands/introspect.ts b/commands/introspection/index.ts similarity index 92% rename from extensions/introspection/commands/introspect.ts rename to commands/introspection/index.ts index 008ceb39..7482b866 100644 --- a/extensions/introspection/commands/introspect.ts +++ b/commands/introspection/index.ts @@ -5,9 +5,9 @@ import type { import { IntrospectPanel, type IntrospectSnapshot, -} from "../components/introspect-panel"; +} from "./components/introspect-panel"; -export function registerIntrospectCommand(pi: ExtensionAPI) { +export default function (pi: ExtensionAPI) { pi.registerCommand("introspect", { description: "Show introspection info: system prompt, tools, skills, prompts", diff --git a/extensions/breadcrumbs/commands/label/index.test.ts b/commands/label/index.test.ts similarity index 95% rename from extensions/breadcrumbs/commands/label/index.test.ts rename to commands/label/index.test.ts index 8aefebf1..40a7eb6e 100644 --- a/extensions/breadcrumbs/commands/label/index.test.ts +++ b/commands/label/index.test.ts @@ -1,6 +1,6 @@ +import { createPiTestHarness } from "@harness/test-utils/pi-test-harness"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; -import { createPiTestHarness } from "../../../../tests/utils/pi-test-harness"; import setupLabelCommand from "./index"; describe("breadcrumbs /label command", () => { diff --git a/extensions/breadcrumbs/commands/label/index.ts b/commands/label/index.ts similarity index 100% rename from extensions/breadcrumbs/commands/label/index.ts rename to commands/label/index.ts diff --git a/extensions/projects/commands/init/agents-prompt.ts b/commands/projects/commands/init/agents-prompt.ts similarity index 100% rename from extensions/projects/commands/init/agents-prompt.ts rename to commands/projects/commands/init/agents-prompt.ts diff --git a/extensions/projects/commands/init/catalog.ts b/commands/projects/commands/init/catalog.ts similarity index 100% rename from extensions/projects/commands/init/catalog.ts rename to commands/projects/commands/init/catalog.ts diff --git a/extensions/projects/commands/init/installer.ts b/commands/projects/commands/init/installer.ts similarity index 100% rename from extensions/projects/commands/init/installer.ts rename to commands/projects/commands/init/installer.ts diff --git a/extensions/projects/commands/init/nix.ts b/commands/projects/commands/init/nix.ts similarity index 100% rename from extensions/projects/commands/init/nix.ts rename to commands/projects/commands/init/nix.ts diff --git a/extensions/projects/commands/init/scanner.ts b/commands/projects/commands/init/scanner.ts similarity index 100% rename from extensions/projects/commands/init/scanner.ts rename to commands/projects/commands/init/scanner.ts diff --git a/extensions/projects/commands/init/wizard.ts b/commands/projects/commands/init/wizard.ts similarity index 100% rename from extensions/projects/commands/init/wizard.ts rename to commands/projects/commands/init/wizard.ts diff --git a/extensions/projects/config.ts b/commands/projects/config.ts similarity index 100% rename from extensions/projects/config.ts rename to commands/projects/config.ts diff --git a/extensions/projects/commands/init.ts b/commands/projects/index.ts similarity index 83% rename from extensions/projects/commands/init.ts rename to commands/projects/index.ts index 2e5fdbde..24676cf5 100644 --- a/extensions/projects/commands/init.ts +++ b/commands/projects/index.ts @@ -1,18 +1,17 @@ -/** - * /projects:init command. - * - * Shows a multi-step wizard to configure packages, skills, and AGENTS.md - * for the current project. - */ - import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { configLoader } from "../config"; -import { buildAgentsPrompt } from "./init/agents-prompt"; -import { applySelections, getInstalled, readSettings } from "./init/installer"; -import { buildNixPrompt } from "./init/nix"; -import { showWizard } from "./init/wizard"; +import { buildAgentsPrompt } from "./commands/init/agents-prompt"; +import { + applySelections, + getInstalled, + readSettings, +} from "./commands/init/installer"; +import { buildNixPrompt } from "./commands/init/nix"; +import { showWizard } from "./commands/init/wizard"; +import { configLoader } from "./config"; + +export default async function (pi: ExtensionAPI): Promise { + await configLoader.load(); -export function registerProjectInitCommand(pi: ExtensionAPI): void { pi.registerCommand("projects:init", { description: "Initialize project with skills, packages, and AGENTS.md", handler: async (_args, ctx) => { diff --git a/commands/qq/components/widget.ts b/commands/qq/components/widget.ts new file mode 100644 index 00000000..3d72031d --- /dev/null +++ b/commands/qq/components/widget.ts @@ -0,0 +1,119 @@ +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import { Loader, Markdown, Text, visibleWidth } from "@mariozechner/pi-tui"; + +export const QQ_WIDGET_ID = "qq"; + +type QqModel = { provider: string; id: string }; +type QqWidgetContext = Pick; + +/** + * Wrap content lines in a rounded border with 1-char inner padding. + */ +function wrapInRoundedBorder( + lines: string[], + width: number, + colorFn: (t: string) => string, +): string[] { + const innerWidth = Math.max(1, width - 2); + const hBar = "\u2500".repeat(innerWidth); + const top = colorFn(`\u256D${hBar}\u256E`); + const bottom = colorFn(`\u2570${hBar}\u256F`); + const left = colorFn("\u2502"); + const right = colorFn("\u2502"); + + const wrapped = lines.map((line) => { + const contentWidth = visibleWidth(line); + const fill = Math.max(0, innerWidth - contentWidth); + return `${left}${line}${" ".repeat(fill)}${right}`; + }); + + return [top, ...wrapped, bottom]; +} + +export function showLoadingWidget( + ctx: ExtensionCommandContext, + question: string, +): void { + ctx.ui.setWidget( + QQ_WIDGET_ID, + (tui, theme) => { + const borderColor = (t: string) => theme.fg("warning", t); + const loader = new Loader( + tui, + (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), + `qq: ${question}`, + ); + loader.start(); + + return { + render(width: number) { + const contentWidth = Math.max(1, width - 4); + const loaderLines = loader.render(contentWidth); + const padded = loaderLines.map((line) => ` ${line} `); + return wrapInRoundedBorder(padded, width, borderColor); + }, + handleInput() {}, + invalidate() { + loader.invalidate(); + }, + dispose() { + loader.stop(); + }, + }; + }, + { placement: "aboveEditor" }, + ); +} + +export function showResultWidget( + ctx: ExtensionCommandContext, + question: string, + answer: string, + model: QqModel, +): void { + ctx.ui.setWidget( + QQ_WIDGET_ID, + (_tui, theme) => { + const borderColor = (t: string) => theme.fg("success", t); + const mdTheme = getMarkdownTheme(); + + return { + render(width: number) { + const contentWidth = Math.max(1, width - 4); + const content: string[] = []; + + content.push( + theme.fg("customMessageLabel", `\x1b[1mqq:\x1b[22m `) + question, + ); + content.push(""); + + const paragraphs = answer.split(/\n\n/).filter((p) => p.trim()); + const firstParagraph = paragraphs[0] ?? ""; + try { + const md = new Markdown(firstParagraph, 0, 0, mdTheme); + content.push(...md.render(contentWidth)); + } catch { + content.push( + ...new Text(firstParagraph, 0, 0).render(contentWidth), + ); + } + + content.push(""); + content.push(theme.fg("dim", `(${model.provider}/${model.id})`)); + + const padded = content.map((line) => ` ${line} `); + return wrapInRoundedBorder(padded, width, borderColor); + }, + handleInput() {}, + invalidate() {}, + }; + }, + { placement: "aboveEditor" }, + ); +} + +export function clearQqWidget(ctx: QqWidgetContext): void { + ctx.ui.setWidget(QQ_WIDGET_ID, undefined); +} diff --git a/commands/qq/index.ts b/commands/qq/index.ts new file mode 100644 index 00000000..c7bc43d0 --- /dev/null +++ b/commands/qq/index.ts @@ -0,0 +1,58 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + clearQqWidget, + showLoadingWidget, + showResultWidget, +} from "./components/widget"; +import { buildQqPrompt } from "./prompt"; +import { runQqSubagent } from "./subagent"; + +export default async function (pi: ExtensionAPI): Promise { + pi.registerCommand("qq", { + description: "Ask a quick question without interrupting the agent", + handler: async (args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("/qq requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + const question = args?.trim(); + if (!question) { + ctx.ui.notify("Usage: /qq ", "warning"); + return; + } + + const { userMessage, systemPrompt } = buildQqPrompt(ctx, question); + const model = ctx.model; + + showLoadingWidget(ctx, question); + + try { + const answer = await runQqSubagent(pi, ctx, systemPrompt, userMessage); + + clearQqWidget(ctx); + if (!answer) { + ctx.ui.notify("No response generated", "warning"); + return; + } + + showResultWidget(ctx, question, answer, model); + } catch (err) { + clearQqWidget(ctx); + ctx.ui.notify( + `qq error: ${err instanceof Error ? err.message : String(err)}`, + "error", + ); + } + }, + }); + + pi.on("agent_start", async (_event, ctx) => { + clearQqWidget(ctx); + }); +} diff --git a/extensions/qq/lib/system-prompt.ts b/commands/qq/lib/system-prompt.ts similarity index 100% rename from extensions/qq/lib/system-prompt.ts rename to commands/qq/lib/system-prompt.ts diff --git a/commands/qq/prompt.ts b/commands/qq/prompt.ts new file mode 100644 index 00000000..6248042b --- /dev/null +++ b/commands/qq/prompt.ts @@ -0,0 +1,36 @@ +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { + buildSessionContext, + convertToLlm, + serializeConversation, +} from "@mariozechner/pi-coding-agent"; +import { QQ_SYSTEM_REMINDER } from "./lib/system-prompt"; + +export function buildQqPrompt( + ctx: ExtensionCommandContext, + question: string, +): { userMessage: string; systemPrompt: string } { + const entries = ctx.sessionManager.getBranch(); + const sessionContext = buildSessionContext( + entries, + ctx.sessionManager.getLeafId(), + ); + const llmMessages = convertToLlm(sessionContext.messages); + + const filtered = llmMessages.filter((msg) => { + if ( + msg.role === "assistant" && + (msg.stopReason === undefined || msg.stopReason === null) + ) { + return false; + } + return true; + }); + + const serialized = serializeConversation(filtered); + + return { + userMessage: `${serialized}\n\n---\n\nSide question: ${question}`, + systemPrompt: ctx.getSystemPrompt() + QQ_SYSTEM_REMINDER, + }; +} diff --git a/commands/qq/subagent.ts b/commands/qq/subagent.ts new file mode 100644 index 00000000..215ac448 --- /dev/null +++ b/commands/qq/subagent.ts @@ -0,0 +1,43 @@ +import { defineSubagent } from "@harness/agent-kit"; +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@mariozechner/pi-coding-agent"; +import { QqParams } from "./types"; + +export async function runQqSubagent( + pi: ExtensionAPI, + ctx: ExtensionCommandContext, + systemPrompt: string, + userMessage: string, +): Promise { + if (!ctx.model) return undefined; + + const qqSubagent = defineSubagent(pi, { + name: "qq", + label: "QQ", + description: "Answer a quick side question", + systemPrompt, + tools: [], + models: [ + { + provider: ctx.model.provider, + model: ctx.model.id, + thinking: "off", + weight: 1, + }, + ], + parameters: QqParams, + buildPrompt: (params) => ({ text: params.prompt }), + }); + + const result = await qqSubagent.execute( + "qq", + { prompt: userMessage }, + undefined, + undefined, + ctx, + ); + + return result.details.response; +} diff --git a/commands/qq/types.ts b/commands/qq/types.ts new file mode 100644 index 00000000..a7892126 --- /dev/null +++ b/commands/qq/types.ts @@ -0,0 +1,7 @@ +import { type Static, Type } from "typebox"; + +export const QqParams = Type.Object({ + prompt: Type.String(), +}); + +export type QqParamsType = Static; diff --git a/extensions/breadcrumbs/commands/session-copy-id/index.ts b/commands/session-copy-id/index.ts similarity index 100% rename from extensions/breadcrumbs/commands/session-copy-id/index.ts rename to commands/session-copy-id/index.ts diff --git a/extensions/breadcrumbs/commands/session-copy-path/index.ts b/commands/session-copy-path/index.ts similarity index 100% rename from extensions/breadcrumbs/commands/session-copy-path/index.ts rename to commands/session-copy-path/index.ts diff --git a/commands/spawn/helpers.ts b/commands/spawn/helpers.ts new file mode 100644 index 00000000..fd2e7bdf --- /dev/null +++ b/commands/spawn/helpers.ts @@ -0,0 +1,60 @@ +/** + * Helper functions for the spawn command. + */ + +import type { SessionEntry } from "@mariozechner/pi-coding-agent"; + +/** + * Extract plain text from a message content array or string. + */ +export function messageContentToText( + content: string | Array<{ type: string; text?: string }>, +): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text) + .join("\n"); +} + +/** + * Get the text of the last assistant message from a branch's entries. + */ +export function getLastAssistantTextFromEntries( + entries: SessionEntry[], +): string | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.type !== "message") continue; + + const msg = entry.message; + if (msg.role !== "assistant") continue; + + const text = messageContentToText(msg.content).trim(); + if (text) return text; + } + return undefined; +} + +/** + * Build the content for the source entry in the child session. + */ +export function buildSpawnSourceContent(params: { + parentSessionId: string; + parentLastMessage?: string; +}): string { + const { parentSessionId, parentLastMessage } = params; + + if (parentLastMessage) { + return `Session spawned from ${parentSessionId}. + +## Last message in parent session + +${parentLastMessage}`; + } + + return `Session spawned from ${parentSessionId}. Use \`read_session\` to access the parent session context: + +read_session({ sessionId: "${parentSessionId}", goal: "Get the last assistant message with context" })`; +} diff --git a/extensions/breadcrumbs/commands/spawn/index.test.ts b/commands/spawn/index.test.ts similarity index 84% rename from extensions/breadcrumbs/commands/spawn/index.test.ts rename to commands/spawn/index.test.ts index 6f84dfb0..67c63ab9 100644 --- a/extensions/breadcrumbs/commands/spawn/index.test.ts +++ b/commands/spawn/index.test.ts @@ -1,18 +1,18 @@ +import { + createPiTestHarness, + type PiTestHarness, +} from "@harness/test-utils/pi-test-harness"; import type { Message } from "@mariozechner/pi-ai"; import { type CustomMessageEntry, SessionManager, } from "@mariozechner/pi-coding-agent"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - createPiTestHarness, - type PiTestHarness, -} from "../../../../tests/utils/pi-test-harness"; +import { assert, beforeEach, describe, expect, it, vi } from "vitest"; +import setupSpawnCommand from "./index"; import { SESSION_LINK_SOURCE_TYPE, type SessionLinkSourceDetails, -} from "../../lib/session-link"; -import setupSpawnCommand from "./index"; +} from "./types"; /** * Seed a real in-memory SessionManager with messages and return it. @@ -87,8 +87,7 @@ describe("breadcrumbs /spawn command", () => { expect(setEditorText).toHaveBeenCalledWith("focus on tests"); const childSm = pi.getChildSessionManager(); - expect(childSm).toBeDefined(); - if (!childSm) throw new Error("childSm should be defined"); + assert(childSm, "childSm should be defined"); const entries = childSm.getEntries(); const sourceEntry = entries.find( @@ -97,11 +96,11 @@ describe("breadcrumbs /spawn command", () => { (e as CustomMessageEntry).customType === SESSION_LINK_SOURCE_TYPE, ); - expect(sourceEntry).toBeDefined(); - expect(sourceEntry?.display).toBe(true); + assert(sourceEntry, "sourceEntry should be defined"); + expect(sourceEntry.display).toBe(true); const content = - typeof sourceEntry?.content === "string" ? sourceEntry?.content : ""; + typeof sourceEntry.content === "string" ? sourceEntry.content : ""; expect(content).toContain( `Session spawned from ${parentSm.getSessionId()}.`, ); @@ -110,11 +109,9 @@ describe("breadcrumbs /spawn command", () => { expect(content).not.toContain("read_session("); expect(content).not.toContain("Role: assistant"); - expect(sourceEntry?.details).toEqual({ - parentSessionId: parentSm.getSessionId(), - goal: "focus on tests", - linkType: "continue", - }); + const details = sourceEntry.details as SessionLinkSourceDetails; + expect(details.goal).toBe("focus on tests"); + expect(details.linkType).toBe("continue"); }); it("omits the last-message section when there is no assistant message", async () => { @@ -133,8 +130,7 @@ describe("breadcrumbs /spawn command", () => { await pi.command("spawn").execute(""); const childSm = pi.getChildSessionManager(); - expect(childSm).toBeDefined(); - if (!childSm) throw new Error("childSm should be defined"); + assert(childSm, "childSm should be defined"); const entries = childSm.getEntries(); const sourceEntry = entries.find( @@ -143,9 +139,9 @@ describe("breadcrumbs /spawn command", () => { (e as CustomMessageEntry).customType === SESSION_LINK_SOURCE_TYPE, ); - expect(sourceEntry).toBeDefined(); + assert(sourceEntry, "sourceEntry should be defined"); const content = - typeof sourceEntry?.content === "string" ? sourceEntry?.content : ""; + typeof sourceEntry.content === "string" ? sourceEntry.content : ""; expect(content).not.toContain("## Last message in parent session"); }); diff --git a/commands/spawn/index.ts b/commands/spawn/index.ts new file mode 100644 index 00000000..9c1c5a1e --- /dev/null +++ b/commands/spawn/index.ts @@ -0,0 +1,104 @@ +/** + * Spawn command - /spawn [note] + * + * Creates a new session linked to the current one, without context extraction. + * Optionally accepts a note describing the focus for the new session. + * + * Also registers renderers for session-link-marker and session-link-source + * custom message types so they display nicely in the TUI. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { + buildSpawnSourceContent, + getLastAssistantTextFromEntries, +} from "./helpers"; +import { renderMarker, renderSource } from "./renderers"; +import { + SESSION_LINK_MARKER_TYPE, + SESSION_LINK_SOURCE_TYPE, + type SessionLinkMarkerDetails, + type SessionLinkSourceDetails, +} from "./types"; + +export { SESSION_LINK_MARKER_TYPE, SESSION_LINK_SOURCE_TYPE }; +export type { SessionLinkMarkerDetails, SessionLinkSourceDetails }; + +export default async function (pi: ExtensionAPI) { + pi.registerMessageRenderer(SESSION_LINK_MARKER_TYPE, renderMarker); + pi.registerMessageRenderer(SESSION_LINK_SOURCE_TYPE, renderSource); + + pi.registerCommand("spawn", { + description: + "Create a new session linked to the current one (no context extraction)", + handler: async (args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("spawn requires interactive mode", "error"); + return; + } + + const note = args.trim() || ""; + const parentSessionId = ctx.sessionManager.getSessionId() ?? "unknown"; + const parentLeafId = ctx.sessionManager.getLeafId(); + const currentSessionFile = ctx.sessionManager.getSessionFile(); + + if (!parentLeafId) { + ctx.ui.notify("Failed to get parent session leaf ID", "error"); + return; + } + + // Extract the last assistant message from the active parent branch + const parentBranch = ctx.sessionManager.getBranch(parentLeafId); + const lastAssistantText = getLastAssistantTextFromEntries(parentBranch); + + const result = await ctx.newSession({ + parentSession: currentSessionFile, + setup: async (sm) => { + const parentFile = sm.getHeader()?.parentSession; + if (parentFile) { + // Write marker entry in parent session + SessionManager.open( + parentFile, + ).appendCustomMessageEntry( + SESSION_LINK_MARKER_TYPE, + "", + true, + { + targetSessionFile: sm.getSessionFile() ?? "", + goal: note, + linkType: "continue", + }, + ); + } + + // Write source entry in child session + const sourceContent = buildSpawnSourceContent({ + parentSessionId, + parentLastMessage: lastAssistantText, + }); + sm.appendCustomMessageEntry( + SESSION_LINK_SOURCE_TYPE, + sourceContent, + true, + { + parentSessionFile: parentFile ?? "", + goal: note, + linkType: "continue", + }, + ); + }, + withSession: async (newCtx) => { + if (note) { + newCtx.ui.setEditorText(note); + } + }, + }); + + if (result.cancelled) { + ctx.ui.notify("Session creation cancelled", "info"); + return; + } + }, + }); +} diff --git a/commands/spawn/renderers.ts b/commands/spawn/renderers.ts new file mode 100644 index 00000000..440aa785 --- /dev/null +++ b/commands/spawn/renderers.ts @@ -0,0 +1,82 @@ +import type { + MessageRenderOptions, + Theme, +} from "@mariozechner/pi-coding-agent"; +import { + getMarkdownTheme, + SessionManager, +} from "@mariozechner/pi-coding-agent"; +import { Box, Markdown, Text } from "@mariozechner/pi-tui"; +import { messageContentToText } from "./helpers"; +import type { + SessionLinkMarkerDetails, + SessionLinkMessage, + SessionLinkSourceDetails, + SessionLinkType, +} from "./types"; + +function resolveSessionName(sessionFile: string): string { + try { + const sm = SessionManager.open(sessionFile); + return sm.getSessionName() ?? sm.getSessionId().slice(0, 8); + } catch { + return sessionFile; + } +} + +export function renderMarker( + message: SessionLinkMessage, + _options: MessageRenderOptions, + theme: Theme, +) { + const details = message.details as SessionLinkMarkerDetails | undefined; + if (!details?.targetSessionFile) return undefined; + + const displayName = resolveSessionName(details.targetSessionFile); + const linkType: SessionLinkType = details.linkType ?? "handoff"; + const labelText = + linkType === "continue" ? "Continues in " : "Handed off to "; + const displayText = `${theme.fg("muted", labelText)}${theme.fg("accent", displayName)}`; + + const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + box.addChild(new Text(displayText, 0, 0)); + return box; +} + +export function renderSource( + message: SessionLinkMessage, + options: MessageRenderOptions, + theme: Theme, +) { + const details = message.details as SessionLinkSourceDetails | undefined; + if (!details?.parentSessionFile) return undefined; + + const { expanded } = options; + const displayName = resolveSessionName(details.parentSessionFile); + const linkType: SessionLinkType = details.linkType ?? "handoff"; + const labelText = + linkType === "continue" ? "Continued from " : "Continuing from "; + const header = `${theme.fg("muted", labelText)}${theme.fg("accent", displayName)}`; + + const content = messageContentToText(message.content); + + const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + box.addChild(new Text(header, 0, 0)); + + if (content) { + if (expanded) { + box.addChild(new Text("", 0, 0)); + + try { + const md = new Markdown(content, 0, 0, getMarkdownTheme()); + box.addChild(md); + } catch { + box.addChild(new Text(theme.fg("muted", content), 0, 0)); + } + } else { + box.addChild(new Text(theme.fg("dim", "Press Ctrl+O to expand"), 0, 0)); + } + } + + return box; +} diff --git a/commands/spawn/types.ts b/commands/spawn/types.ts new file mode 100644 index 00000000..a1c60bdb --- /dev/null +++ b/commands/spawn/types.ts @@ -0,0 +1,29 @@ +/** + * Session link types and constants for the spawn command. + * + * Marker entries go in the parent session (pointing to the child). + * Source entries go in the child session (pointing to the parent). + */ + +export type SessionLinkType = "handoff" | "continue"; + +export const SESSION_LINK_MARKER_TYPE = "session-link-marker"; +export const SESSION_LINK_SOURCE_TYPE = "session-link-source"; + +export interface SessionLinkMarkerDetails { + targetSessionFile: string; + goal: string; + linkType: SessionLinkType; +} + +export interface SessionLinkSourceDetails { + parentSessionFile: string; + goal: string; + linkType: SessionLinkType; +} + +export interface SessionLinkMessage { + customType: string; + content: string | Array<{ type: string; text?: string }>; + details?: Record; +} diff --git a/extensions/defaults/components/theme-selector.ts b/commands/theme/components/theme-selector.ts similarity index 100% rename from extensions/defaults/components/theme-selector.ts rename to commands/theme/components/theme-selector.ts diff --git a/extensions/defaults/commands/theme.ts b/commands/theme/index.ts similarity index 93% rename from extensions/defaults/commands/theme.ts rename to commands/theme/index.ts index e214de89..3d6ff75d 100644 --- a/extensions/defaults/commands/theme.ts +++ b/commands/theme/index.ts @@ -1,8 +1,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { SelectItem } from "@mariozechner/pi-tui"; -import { ThemeSelector } from "../components/theme-selector"; +import { ThemeSelector } from "./components/theme-selector"; -export function registerThemeCommand(pi: ExtensionAPI) { +export default async function (pi: ExtensionAPI) { pi.registerCommand("theme", { description: "Select theme with preview", handler: async (_args, ctx) => { diff --git a/extensions/breadcrumbs/README.md b/extensions/breadcrumbs/README.md index e8be64ae..3bef04e2 100644 --- a/extensions/breadcrumbs/README.md +++ b/extensions/breadcrumbs/README.md @@ -26,30 +26,7 @@ List recent Pi sessions for a directory. Use this when you want recent sessions for a project without keyword search. -### `read_session` +## Notes -Extract information from a past session using a subagent. - -**Parameters:** -- `sessionId` (required): Session UUID or file path -- `goal` (required): What information to extract - -The subagent has access to session-specific tools (get_session_overview, get_messages, find_messages, etc.) and uses them to answer the goal. - -## Commands - -- `session:copy-path` - Copy the current session file path to clipboard -- `session:copy-id` - Copy the current session ID to clipboard -- `/spawn [note]` - Create a linked session with parent-session instructions -- `/continue` - Continue work from a linked parent session -- `/label ` - Label the current session entry for later navigation - -## Session Protection - -The extension gates direct agent access to the sessions directory (`~/.pi/agent/sessions`). - -- Direct **read** attempts trigger a user confirmation prompt (UI required). Approval is remembered for the rest of the current Pi session. -- Direct **write/edit** attempts remain blocked. -- Direct **bash** commands referencing the sessions directory trigger a user confirmation prompt (UI required). Approval is remembered for the rest of the current Pi session. - -Agents should prefer `find_sessions` and `read_session` instead of reading raw session JSONL. +- Session protection (`protect-sessions-dir`) and session commands (`/spawn`, `/continue`, `/label`, `/session:copy-id`, `/session:copy-path`) have moved to `hooks/` and `commands/` at the repository root. +- `read_session` has moved to `tools/read-session/`. diff --git a/extensions/breadcrumbs/commands/spawn/index.ts b/extensions/breadcrumbs/commands/spawn/index.ts deleted file mode 100644 index f1f7cc3d..00000000 --- a/extensions/breadcrumbs/commands/spawn/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Spawn command - /spawn [note] - * - * Creates a new session linked to the current one, without context extraction. - * Optionally accepts a note describing the focus for the new session. - */ - -import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; -import { - messageContentToText, - writeSessionLinkMarker, - writeSessionLinkSource, -} from "../../lib/session-link"; - -/** - * Extract the text of the last assistant message from a branch. - * Walks backward through entries, finds the last "message" entry - * with role "assistant", and returns its text content. - */ -function getLastAssistantMessage(entries: SessionEntry[]): string | undefined { - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]; - if (entry?.type !== "message") continue; - - const msg = entry.message; - if (msg.role !== "assistant") continue; - - const text = messageContentToText(msg.content).trim(); - if (text) { - return text; - } - } - return undefined; -} - -function buildSpawnSourceContent(params: { - parentSessionId: string; - parentLastMessage?: string; -}): string { - const { parentSessionId, parentLastMessage } = params; - - if (parentLastMessage) { - return `Session spawned from ${parentSessionId}. - -## Last message in parent session - -${parentLastMessage}`; - } - - return `Session spawned from ${parentSessionId}. Use \`read_session\` to access the parent session context: - -read_session({ sessionId: "${parentSessionId}", goal: "Get the last assistant message with context" })`; -} - -export default async function (pi: ExtensionAPI) { - pi.registerCommand("spawn", { - description: - "Create a new session linked to the current one (no context extraction)", - handler: async (args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("spawn requires interactive mode", "error"); - return; - } - - const note = args.trim() || ""; - const parentSessionId = ctx.sessionManager.getSessionId() ?? "unknown"; - const parentLeafId = ctx.sessionManager.getLeafId(); - const currentSessionFile = ctx.sessionManager.getSessionFile(); - - // Extract the last assistant message from the active parent branch - const parentBranch = ctx.sessionManager.getBranch( - parentLeafId ?? undefined, - ); - const lastMessage = getLastAssistantMessage(parentBranch); - - if (!parentLeafId) { - ctx.ui.notify("Failed to get parent session leaf ID", "error"); - return; - } - - const result = await ctx.newSession({ - parentSession: currentSessionFile, - setup: async (sm) => { - const newSessionId = sm.getSessionId(); - if (currentSessionFile && newSessionId) { - writeSessionLinkMarker( - currentSessionFile, - newSessionId, - note, - "continue", - parentLeafId, - ); - } - const sourceContent = buildSpawnSourceContent({ - parentSessionId, - parentLastMessage: lastMessage, - }); - writeSessionLinkSource( - sm, - parentSessionId, - note, - "continue", - sourceContent, - ); - }, - withSession: async (newCtx) => { - if (note) { - newCtx.ui.setEditorText(note); - } - }, - }); - - if (result.cancelled) { - ctx.ui.notify("Session creation cancelled", "info"); - return; - } - }, - }); -} diff --git a/extensions/breadcrumbs/hooks/protect-sessions-dir/index.ts b/extensions/breadcrumbs/hooks/protect-sessions-dir/index.ts deleted file mode 100644 index e69f4d14..00000000 --- a/extensions/breadcrumbs/hooks/protect-sessions-dir/index.ts +++ /dev/null @@ -1,516 +0,0 @@ -/** - * Prevent direct agent access to the sessions directory. - * - * Gates read, write, edit, and bash commands that target session files. - * Agents should use find_sessions and read_session tools instead. - * - * Unified gating: both file tools and bash go through the same approval - * mechanism — `allowAll` flag and `approvedSubtrees` path set. - * write/edit are hard-blocked unconditionally. - */ - -import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; -import type { - Command, - DblQuoted, - Program, - Statement, - Word, - WordPart, -} from "@aliou/sh"; -import { parse } from "@aliou/sh"; -import type { - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { DynamicBorder } from "@mariozechner/pi-coding-agent"; -import { - Container, - Key, - matchesKey, - Spacer, - Text, - wrapTextWithAnsi, -} from "@mariozechner/pi-tui"; -import { AD_NOTIFY_ATTENTION_EVENT } from "../../../../packages/events"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type SessionAccessRequest = { - /** Absolute session-dir paths extracted from the tool call. */ - targets: string[]; - /** Path or command string shown in the dialog and events. */ - displayTarget: string; - /** True when no specific paths could be extracted (e.g. variable expansion). */ - ambiguous: boolean; -}; - -type SessionGateResult = "allow-once" | "allow-path" | "allow-all" | "deny"; - -// --------------------------------------------------------------------------- -// Approval state (module scope, per Pi runtime) -// --------------------------------------------------------------------------- - -let allowAll = false; -const approvedSubtrees = new Set(); - -/** @internal Reset approval state for testing. */ -export function _resetForTesting(): void { - allowAll = false; - approvedSubtrees.clear(); -} - -// --------------------------------------------------------------------------- -// Session dir helpers -// --------------------------------------------------------------------------- - -function getSessionsDir(): string { - const agentDir = - process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); - return join(agentDir, "sessions"); -} - -/** - * Check if a resolved absolute path falls within the sessions directory. - */ -function isInSessionsDir(path: string): boolean { - const sessionsDir = getSessionsDir(); - const absolutePath = resolve(path); - const rel = relative(sessionsDir, absolutePath); - return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); -} - -/** - * Check if a path is covered by any approved subtree. - */ -function isApprovedPath(targetPath: string): boolean { - if (allowAll) return true; - for (const approved of approvedSubtrees) { - const rel = relative(approved, resolve(targetPath)); - if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) return true; - } - return false; -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const BLOCK_MESSAGE = - "Direct access to session files is restricted. " + - "Prefer find_sessions + read_session. " + - "Direct reads may be allowed via runtime toggle or explicit user confirmation."; - -// --------------------------------------------------------------------------- -// Event emission -// --------------------------------------------------------------------------- - -function emitSessionGateEvent( - pi: ExtensionAPI, - description: string, - command = "", - toolName?: string, - toolCallId?: string, -): void { - const payload = { - source: "breadcrumbs:protect-sessions-dir", - command, - description, - toolName, - toolCallId, - }; - pi.events.emit(AD_NOTIFY_ATTENTION_EVENT, payload); -} - -// --------------------------------------------------------------------------- -// Target extraction -// --------------------------------------------------------------------------- - -/** - * Extract session-dir targets from a tool call. - */ -function extractSessionTargets( - toolName: string, - input: Record, -): SessionAccessRequest { - if (toolName === "bash") { - return extractBashTargets(String(input.command ?? "")); - } - - // File tools: read, write, edit - const rawPath = String(input.path ?? input.file_path ?? ""); - if (!rawPath) { - return { targets: [], displayTarget: "", ambiguous: false }; - } - - if (isAbsolute(rawPath)) { - const resolvedPath = resolve(rawPath); - if (isInSessionsDir(resolvedPath)) { - return { - targets: [resolvedPath], - displayTarget: resolvedPath, - ambiguous: false, - }; - } - return { targets: [], displayTarget: "", ambiguous: false }; - } - - // Relative path containing sessions dir reference — suspicious, block. - if (rawPath.includes("/.pi/agent/sessions")) { - return { targets: [], displayTarget: rawPath, ambiguous: true }; - } - - // Relative path without sessions dir reference — not gated. - return { targets: [], displayTarget: "", ambiguous: false }; -} - -/** - * Extract session-dir paths from a bash command string by parsing the AST. - */ -function extractBashTargets(command: string): SessionAccessRequest { - const paths = extractPathsFromBashCommand(command); - const sessionPaths = paths.filter((p) => isInSessionsDir(p)); - - if (sessionPaths.length > 0) { - return { targets: sessionPaths, displayTarget: command, ambiguous: false }; - } - - // Zero paths extracted — check for ambiguous references. - const sessionsDir = getSessionsDir(); - if ( - command.includes(sessionsDir) || - command.includes("/.pi/agent/sessions") - ) { - return { targets: [], displayTarget: command, ambiguous: true }; - } - - return { targets: [], displayTarget: "", ambiguous: false }; -} - -/** - * Parse a bash command and extract candidate file paths from the AST. - */ -function extractPathsFromBashCommand(command: string): string[] { - let ast: Program; - try { - ast = parse(command, { dialect: "bash" }).ast; - } catch { - return []; - } - - const candidates: string[] = []; - for (const stmt of ast.body) { - collectPathsFromStatement(stmt, candidates); - } - - const resolved: string[] = []; - for (const c of candidates) { - const expanded = c.startsWith("~") ? join(homedir(), c.slice(1)) : c; - if (isAbsolute(expanded)) resolved.push(resolve(expanded)); - } - return resolved; -} - -// --------------------------------------------------------------------------- -// AST walkers -// --------------------------------------------------------------------------- - -function collectPathsFromStatement(stmt: Statement, out: string[]): void { - collectPathsFromCommand(stmt.command, out); -} - -function collectPathsFromCommand(cmd: Command, out: string[]): void { - switch (cmd.type) { - case "SimpleCommand": { - const words = cmd.words ?? []; - // Skip first word — it's the command name. - for (let i = 1; i < words.length; i++) { - const word = words[i]; - if (!word) continue; - const reconstructed = reconstructWord(word); - if (reconstructed && looksLikePath(reconstructed)) - out.push(reconstructed); - } - // Redirect targets. - for (const redir of cmd.redirects ?? []) { - if (redir.target) { - const target = reconstructWord(redir.target); - if (target && looksLikePath(target)) out.push(target); - } - } - break; - } - case "Pipeline": - for (const sub of cmd.commands) collectPathsFromStatement(sub, out); - break; - case "Logical": - collectPathsFromStatement(cmd.left, out); - collectPathsFromStatement(cmd.right, out); - break; - case "Subshell": - case "Block": - for (const sub of cmd.body) collectPathsFromStatement(sub, out); - break; - case "IfClause": - for (const sub of cmd.then) collectPathsFromStatement(sub, out); - if (cmd.else) - for (const sub of cmd.else) collectPathsFromStatement(sub, out); - break; - case "WhileClause": - for (const sub of cmd.body) collectPathsFromStatement(sub, out); - break; - case "ForClause": - for (const sub of cmd.body) collectPathsFromStatement(sub, out); - break; - // Skip FunctionDecl, CaseClause, DeclClause, LetClause, - // CStyleLoop, TimeClause, TestClause, ArithCmd, CoprocClause, SelectClause. - } -} - -/** - * Reconstruct a Word into a plain string. - * Returns null if any part is unresolvable (ParamExp, CmdSubst, etc.). - */ -function reconstructWord(word: Word): string | null { - let result = ""; - for (const part of word.parts) { - const s = reconstructWordPart(part); - if (s === null) return null; - result += s; - } - return result; -} - -/** - * Reconstruct a single WordPart. Returns null for unresolvable parts. - */ -function reconstructWordPart(part: WordPart): string | null { - switch (part.type) { - case "Literal": - return part.value; - case "SglQuoted": - return part.value; - case "DblQuoted": - return reconstructDblQuoted(part); - case "ParamExp": - case "CmdSubst": - case "ArithExp": - case "ProcSubst": - return null; - default: - return null; - } -} - -/** - * Reconstruct a double-quoted word. Returns null if any sub-part is - * unresolvable (ParamExp, CmdSubst, etc.). - */ -function reconstructDblQuoted(part: DblQuoted): string | null { - let result = ""; - for (const sub of part.parts) { - const s = reconstructWordPart(sub); - if (s === null) return null; - result += s; - } - return result; -} - -/** - * Heuristic: does a string look like a file path? - */ -function looksLikePath(s: string): boolean { - return s.startsWith("/") || s.startsWith("~") || s.includes("/"); -} - -// --------------------------------------------------------------------------- -// Dialog -// --------------------------------------------------------------------------- - -/** - * Show a styled confirmation dialog for session file access. - */ -async function showSessionGateDialog( - ctx: ExtensionContext, - description: string, - target: string, - ambiguous: boolean, -): Promise { - const hintText = ambiguous - ? "y/enter: allow once | a: allow all session access | n/esc: deny" - : "y/enter: allow once | p: allow this directory for session | a: allow all session access | n/esc: deny"; - - const result = await ctx.ui.custom( - (_tui, theme, _kb, done) => { - const container = new Container(); - const warnBorder = (s: string) => theme.fg("warning", s); - - container.addChild(new DynamicBorder(warnBorder)); - container.addChild( - new Text(theme.fg("warning", theme.bold("Session File Access")), 1, 0), - ); - container.addChild(new Spacer(1)); - container.addChild( - new Text( - theme.fg("text", `The agent is trying to ${description}.`), - 1, - 0, - ), - ); - container.addChild(new Spacer(1)); - - container.addChild( - new DynamicBorder((s: string) => theme.fg("muted", s)), - ); - const targetText = new Text("", 1, 0); - container.addChild(targetText); - container.addChild( - new DynamicBorder((s: string) => theme.fg("muted", s)), - ); - - container.addChild(new Spacer(1)); - container.addChild( - new Text( - theme.fg("muted", "Prefer find_sessions + read_session instead."), - 1, - 0, - ), - ); - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("dim", hintText), 1, 0)); - container.addChild(new DynamicBorder(warnBorder)); - - return { - render: (width: number) => { - targetText.setText( - wrapTextWithAnsi(theme.fg("text", target), width - 4).join("\n"), - ); - return container.render(width); - }, - invalidate: () => container.invalidate(), - handleInput: (data: string) => { - if (matchesKey(data, Key.enter) || data === "y" || data === "Y") { - done("allow-once"); - return; - } - if (!ambiguous && (data === "p" || data === "P")) { - done("allow-path"); - return; - } - if (data === "a" || data === "A") { - done("allow-all"); - return; - } - if (matchesKey(data, Key.escape) || data === "n" || data === "N") { - done("deny"); - } - }, - }; - }, - ); - - if (result === undefined) return "deny"; - return result; -} - -// --------------------------------------------------------------------------- -// Hook setup -// --------------------------------------------------------------------------- - -/** - * Hook that gates direct access to the sessions directory. - * - * Unified flow for all tools: - * - write/edit: hard-blocked unconditionally - * - read/bash: check approval state, then prompt via dialog if needed - * - * Approval state: - * - `allowAll`: all session-dir access allowed for runtime - * - `approvedSubtrees`: specific paths approved for runtime - */ -export default async function (pi: ExtensionAPI) { - pi.on("tool_call", async (event, ctx) => { - const input = event.input as Record; - const request = extractSessionTargets(event.toolName, input); - - // 1. write/edit — hard-block unconditionally when targeting session dir - // Checked first so ambiguous write/edit paths are never silently allowed. - if (event.toolName === "write" || event.toolName === "edit") { - if (request.targets.length > 0 || request.ambiguous) { - emitSessionGateEvent( - pi, - `Blocked: direct session file ${event.toolName}`, - request.displayTarget, - event.toolName, - event.toolCallId, - ); - return { block: true, reason: BLOCK_MESSAGE }; - } - return; // Non-session write/edit — not gated. - } - - // 2. No targets, not ambiguous — nothing to gate for read/bash - if (request.targets.length === 0 && !request.ambiguous) return; - - // 3. Already approved - if (allowAll) return; - if ( - request.targets.length > 0 && - request.targets.every((t) => isApprovedPath(t)) - ) - return; - - // 4. No UI — block - if (!ctx.hasUI) { - emitSessionGateEvent( - pi, - "Blocked: session access requires confirmation, but no UI is available", - request.displayTarget, - event.toolName, - event.toolCallId, - ); - return { - block: true, - reason: - "Direct access to session files requires explicit user confirmation, but no UI is available.", - }; - } - - // 5. Show dialog - const description = - event.toolName === "bash" - ? request.ambiguous - ? "may reference session files" - : "access session files via bash" - : "read a session file directly"; - - emitSessionGateEvent( - pi, - `Confirmation required: ${description}`, - request.displayTarget, - event.toolName, - event.toolCallId, - ); - - const decision = await showSessionGateDialog( - ctx, - description, - request.displayTarget, - request.ambiguous, - ); - - if (decision === "deny") { - return { block: true, reason: "User denied session file access" }; - } - if (decision === "allow-path") { - // Store parent directory of each target so sibling files are covered. - for (const t of request.targets) approvedSubtrees.add(dirname(t)); - } - if (decision === "allow-all") allowAll = true; - - return; // allow - }); -} diff --git a/extensions/breadcrumbs/hooks/session-autocomplete/index.ts b/extensions/breadcrumbs/hooks/session-autocomplete/index.ts deleted file mode 100644 index a7af6374..00000000 --- a/extensions/breadcrumbs/hooks/session-autocomplete/index.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * `@@` session autocomplete provider. - * - * On `@@` in the input editor, searches the Sesame index for sessions - * matching the token (or lists recent sessions for bare `@@`). On accept, the - * completion inserts `@@`. The `@@` marker stays in the user - * message and is resolved to hidden context in `before_agent_start`. - */ - -import { join } from "node:path"; -import type { SearchResult as SesameSearchResult } from "@aliou/sesame"; -import { getXDGPaths, openDatabase, search } from "@aliou/sesame"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { - AutocompleteItem, - AutocompleteProvider, - AutocompleteSuggestions, -} from "@mariozechner/pi-tui"; - -// --------------------------------------------------------------------------- -// Utility helpers -// --------------------------------------------------------------------------- - -/** Match `@@` plus an optional token at end of text before cursor. */ -const AT_TOKEN_RE = /@@([^\s@]*)$/; - -/** Match `@@` markers anywhere in text. */ -const AT_UUID_RE = - /@@([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; - -/** Debounce window for autocomplete searches (ms). */ -const DEBOUNCE_MS = 150; - -/** Minimum token length to use FTS. Shorter tokens use name LIKE instead. */ -const FTS_MIN_TOKEN_LEN = 3; - -/** - * Relative time string from an ISO date. - */ -function timeAgo(isoDate: string): string { - const now = Date.now(); - const then = new Date(isoDate).getTime(); - if (Number.isNaN(then)) return ""; - const seconds = Math.floor((now - then) / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - const years = Math.floor(months / 12); - return `${years}y ago`; -} - -/** - * Collapse `$HOME` prefix to `~`. - */ -function tildePath(p: string): string { - const home = process.env.HOME || ""; - if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; - return p; -} - -/** - * Extract the `@@` at the end of `textBeforeCursor`. - * Returns the token (empty string for bare `@@`) or undefined if no match. - */ -function extractSessionToken(textBeforeCursor: string): string | undefined { - const match = textBeforeCursor.match(AT_TOKEN_RE); - return match ? (match[1] ?? "") : undefined; -} - -/** - * Open the Sesame DB. Returns undefined if the DB can't be opened. - */ -function openSesameDb() { - try { - const paths = getXDGPaths(); - const dbPath = join(paths.data, "index.sqlite"); - return openDatabase(dbPath); - } catch { - return undefined; - } -} - -/** - * Resolve a session UUID to metadata via the DB directly. - * Returns null if the session is not found. - */ -function resolveSessionRefFromDb( - db: NonNullable>, - sessionId: string, -): { - id: string; - name: string; - cwd: string; - created: string; - modified: string; -} | null { - try { - const stmt = db.prepare( - "SELECT id, cwd, name, created_at, modified_at FROM sessions WHERE id = ?", - ); - const row = stmt.get(sessionId) as - | { - id: string; - cwd: string | null; - name: string | null; - created_at: string | null; - modified_at: string | null; - } - | undefined; - - if (!row) return null; - - return { - id: row.id, - name: row.name || "(untitled)", - cwd: row.cwd || "", - created: row.created_at || "", - modified: row.modified_at || "", - }; - } catch { - return null; - } -} - -/** - * Search sessions by name using SQL LIKE. Fast alternative to FTS for - * short tokens (avoids 40K+ FTS matches for single characters). - */ -function searchByName( - db: NonNullable>, - token: string, - cwd: string, - limit: number, -): SesameSearchResult[] { - const stmt = db.prepare( - `SELECT id as sessionId, source, path, cwd, name, created_at as createdAt, modified_at as modifiedAt - FROM sessions - WHERE cwd LIKE ? AND name LIKE ? - ORDER BY modified_at DESC - LIMIT ?`, - ); - const rows = stmt.all(`${cwd}%`, `%${token}%`, limit) as Array<{ - sessionId: string; - source: string; - path: string; - cwd: string | null; - name: string | null; - createdAt: string | null; - modifiedAt: string | null; - }>; - - return rows.map((row) => ({ - sessionId: row.sessionId, - source: row.source, - path: row.path, - cwd: row.cwd, - name: row.name, - score: 0, - createdAt: row.createdAt, - modifiedAt: row.modifiedAt, - matchedSnippet: row.name || "(recent session)", - })); -} - -// --------------------------------------------------------------------------- -// Resolved session references (module-scoped) -// --------------------------------------------------------------------------- - -interface ResolvedRef { - id: string; - name: string; - cwd: string; - created: string; - modified: string; -} - -/** Pending `@@` refs resolved during `input`, consumed in `before_agent_start`. */ -let pendingRefs: ResolvedRef[] = []; - -// --------------------------------------------------------------------------- -// Autocomplete provider factory -// --------------------------------------------------------------------------- - -function createSessionAutocompleteProvider( - current: AutocompleteProvider, - cwd: string, - currentSessionId: string, -): AutocompleteProvider { - // Debounce: incrementing generation counter. Only the latest call - // survives the debounce window. - let generation = 0; - - return { - async getSuggestions( - lines: string[], - cursorLine: number, - cursorCol: number, - options, - ): Promise { - const currentLine = lines[cursorLine] ?? ""; - const textBeforeCursor = currentLine.slice(0, cursorCol); - const token = extractSessionToken(textBeforeCursor); - - if (token === undefined) { - return current.getSuggestions(lines, cursorLine, cursorCol, options); - } - - // Debounce: wait, then check if we're still the latest call - const thisGen = ++generation; - await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS)); - - if (thisGen !== generation) { - return null; // superseded by a newer keystroke - } - - if (options.signal.aborted) { - return current.getSuggestions(lines, cursorLine, cursorCol, options); - } - - const db = openSesameDb(); - if (!db) { - return current.getSuggestions(lines, cursorLine, cursorCol, options); - } - - try { - const query = token === "" ? "*" : token; - - // Use session-name LIKE for short tokens (FTS is ~10s for single chars) - const useFts = token === "" || token.length >= FTS_MIN_TOKEN_LEN; - const results = useFts - ? search(db, query, { cwd, limit: 20 }) - : searchByName(db, token, cwd, 20); - - if (options.signal.aborted) { - return current.getSuggestions(lines, cursorLine, cursorCol, options); - } - - // Filter out current session from results - const filtered = results.filter( - (r: SesameSearchResult) => r.sessionId !== currentSessionId, - ); - - if (filtered.length === 0) { - return current.getSuggestions(lines, cursorLine, cursorCol, options); - } - - const items: AutocompleteItem[] = filtered.map( - (r: SesameSearchResult) => { - const name = r.name || "(untitled session)"; - const modified = r.modifiedAt || ""; - - return { - value: `@@${r.sessionId}`, - label: name, - description: modified ? timeAgo(modified) : undefined, - }; - }, - ); - - return { - items, - prefix: `@@${token}`, - }; - } catch { - return current.getSuggestions(lines, cursorLine, cursorCol, options); - } finally { - db.close(); - } - }, - - applyCompletion( - lines: string[], - cursorLine: number, - cursorCol: number, - item: AutocompleteItem, - prefix: string, - ) { - return current.applyCompletion( - lines, - cursorLine, - cursorCol, - item, - prefix, - ); - }, - - shouldTriggerFileCompletion( - lines: string[], - cursorLine: number, - cursorCol: number, - ) { - // Don't trigger file completion when typing `@@` tokens - const currentLine = lines[cursorLine] ?? ""; - const textBeforeCursor = currentLine.slice(0, cursorCol); - if (extractSessionToken(textBeforeCursor) !== undefined) { - return false; - } - return ( - current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? - true - ); - }, - }; -} - -// --------------------------------------------------------------------------- -// Extension entry point -// --------------------------------------------------------------------------- - -export default async function (pi: ExtensionAPI) { - pi.on("session_start", async (_event, ctx) => { - const currentSessionId = ctx.sessionManager.getSessionId(); - const cwd = ctx.cwd; - - // Register the stacked autocomplete provider - ctx.ui.addAutocompleteProvider((current) => - createSessionAutocompleteProvider(current, cwd, currentSessionId), - ); - }); - - // On `input`, resolve `@@` markers via DB - pi.on("input", async (event) => { - const text = event.text; - const db = openSesameDb(); - if (!db) { - pendingRefs = []; - return { action: "continue" } as const; - } - - try { - const refs: ResolvedRef[] = []; - const seen = new Set(); - - const re = new RegExp(AT_UUID_RE.source, "g"); - let match: RegExpExecArray | null = re.exec(text); - while (match !== null) { - const sessionId = match[1]; - if (sessionId && !seen.has(sessionId)) { - seen.add(sessionId); - const ref = resolveSessionRefFromDb(db, sessionId); - if (ref) { - refs.push(ref); - } - } - match = re.exec(text); - } - - pendingRefs = refs; - } finally { - db.close(); - } - - // Text is NOT modified — `@@` stays as-is in the user message - return { action: "continue" } as const; - }); - - // On `before_agent_start`, inject hidden context for resolved refs - pi.on("before_agent_start", async () => { - if (pendingRefs.length === 0) return; - - const lines = pendingRefs.map((ref) => { - const name = ref.name || "(untitled)"; - const cwdDisplay = tildePath(ref.cwd); - return `- session ${ref.id}: name="${name}", cwd=${cwdDisplay}, created=${ref.created}, modified=${ref.modified}\n Use read_session({ sessionId: "${ref.id}", goal: "..." }) to access its content.`; - }); - - const content = `The user referenced the following sessions:\n${lines.join("\n")}`; - - pendingRefs = []; - - return { - message: { - customType: "breadcrumbs:session-ref", - content, - display: false, - }, - } as const; - }); -} diff --git a/extensions/breadcrumbs/hooks/session-link-renderers/index.ts b/extensions/breadcrumbs/hooks/session-link-renderers/index.ts deleted file mode 100644 index 7c72854a..00000000 --- a/extensions/breadcrumbs/hooks/session-link-renderers/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { - setupSessionLinkMarkerRenderer, - setupSessionLinkSourceRenderer, -} from "../../lib/session-link"; - -export default async function (pi: ExtensionAPI) { - setupSessionLinkMarkerRenderer(pi); - setupSessionLinkSourceRenderer(pi); -} diff --git a/extensions/breadcrumbs/lib/session-link.test.ts b/extensions/breadcrumbs/lib/session-link.test.ts deleted file mode 100644 index a2d20c4e..00000000 --- a/extensions/breadcrumbs/lib/session-link.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { messageContentToText } from "./session-link"; - -describe("breadcrumbs session-link", () => { - describe("messageContentToText", () => { - it("returns string content unchanged", () => { - expect(messageContentToText("hello world")).toBe("hello world"); - }); - - it("joins multiple text parts with newlines", () => { - expect( - messageContentToText([ - { type: "text", text: "first paragraph" }, - { type: "text", text: "second paragraph" }, - ]), - ).toBe("first paragraph\nsecond paragraph"); - }); - - it("ignores non-text parts", () => { - expect( - messageContentToText([ - { type: "text", text: "before" }, - { type: "toolCall" }, - { type: "thinking" }, - { type: "text", text: "after" }, - ]), - ).toBe("before\nafter"); - }); - - it("returns empty string when no text is extractable", () => { - expect( - messageContentToText([{ type: "toolCall" }, { type: "thinking" }]), - ).toBe(""); - }); - }); -}); diff --git a/extensions/breadcrumbs/lib/session-link.ts b/extensions/breadcrumbs/lib/session-link.ts deleted file mode 100644 index 78b823ad..00000000 --- a/extensions/breadcrumbs/lib/session-link.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { appendFileSync, readdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { - ExtensionAPI, - MessageRenderOptions, - SessionManager, - Theme, -} from "@mariozechner/pi-coding-agent"; -import { getMarkdownTheme } from "@mariozechner/pi-coding-agent"; -import { Box, Markdown, Text } from "@mariozechner/pi-tui"; - -export type SessionLinkType = "handoff" | "continue"; - -export const SESSION_LINK_MARKER_TYPE = "session-link-marker"; -export const SESSION_LINK_SOURCE_TYPE = "session-link-source"; - -export interface SessionLinkMarkerDetails { - targetSessionId: string; - goal: string; - linkType: SessionLinkType; -} - -export interface SessionLinkSourceDetails { - parentSessionId: string; - goal: string; - linkType: SessionLinkType; -} - -interface SessionLinkMessage { - customType: string; - content: string | Array<{ type: string; text?: string }>; - details?: Record; -} - -/** - * Find a session JSONL file by session ID and extract its display name. - * Falls back to the session ID if the file can't be found or has no name. - */ -function resolveSessionName(sessionId: string): string { - try { - const sessionsDir = join(homedir(), ".pi", "agent", "sessions"); - const suffix = `_${sessionId}.jsonl`; - - // Scan subdirectories for a file ending with _.jsonl - for (const subdir of readdirSync(sessionsDir, { withFileTypes: true })) { - if (!subdir.isDirectory()) continue; - const dirPath = join(sessionsDir, subdir.name); - for (const file of readdirSync(dirPath)) { - if (!file.endsWith(suffix)) continue; - - // Found the file -- read and look for session_info with name - const content = readFileSync(join(dirPath, file), "utf-8"); - const lines = content.split("\n"); - - // Check lines in reverse -- latest name wins - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i]?.trim(); - if (!line || !line.includes("session_info")) continue; - try { - const entry = JSON.parse(line); - if (entry.type === "session_info" && entry.name) { - return entry.name; - } - } catch { - // skip malformed lines - } - } - - // No name found -- use first user message as fallback - for (const line of lines) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type === "message" && entry.message?.role === "user") { - const content = entry.message.content; - const text = - typeof content === "string" - ? content - : Array.isArray(content) - ? content - .filter( - (c: { type: string; text?: string }) => - c.type === "text", - ) - .map((c: { type: string; text?: string }) => c.text) - .join("") - : ""; - if (text) return text.slice(0, 60); - } - } catch { - // skip - } - } - - return sessionId; - } - } - } catch { - // fs errors -- fall back to ID - } - return sessionId; -} - -/** - * Register the session link marker message renderer. - * Displays "Handed off to {name}" or "Continues in {name}" depending on linkType. - */ -export function setupSessionLinkMarkerRenderer(pi: ExtensionAPI) { - const renderMarker = ( - message: SessionLinkMessage, - _options: MessageRenderOptions, - theme: Theme, - ) => { - const details = message.details; - - if (!details) { - return undefined; - } - - const targetSessionId = details.targetSessionId as string | undefined; - if (!targetSessionId) { - return undefined; - } - - const linkType = - (details.linkType as SessionLinkType | undefined) ?? "handoff"; - const displayName = resolveSessionName(targetSessionId); - const labelText = - linkType === "continue" ? "Continues in " : "Handed off to "; - const label = theme.fg("muted", labelText); - const displayText = `${label}${theme.fg("accent", displayName)}`; - - const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); - box.addChild(new Text(displayText, 0, 0)); - return box; - }; - - pi.registerMessageRenderer(SESSION_LINK_MARKER_TYPE, renderMarker); -} - -/** - * Register the session link source message renderer. - * Collapsed: header line with optional expand hint (only when content is non-empty). - * Expanded: header + full context content. - */ -export function setupSessionLinkSourceRenderer(pi: ExtensionAPI) { - const renderSource = ( - message: SessionLinkMessage, - options: MessageRenderOptions, - theme: Theme, - ) => { - const details = message.details; - - if (!details) { - return undefined; - } - - const parentSessionId = details.parentSessionId as string | undefined; - if (!parentSessionId) { - return undefined; - } - - const { expanded } = options; - const linkType = - (details.linkType as SessionLinkType | undefined) ?? "handoff"; - const displayName = resolveSessionName(parentSessionId); - const labelText = - linkType === "continue" ? "Continued from " : "Continuing from "; - const label = theme.fg("muted", labelText); - const header = `${label}${theme.fg("accent", displayName)}`; - - const content = messageContentToText(message.content); - - const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); - box.addChild(new Text(header, 0, 0)); - - if (content) { - if (expanded) { - // Show the full content below the header - box.addChild(new Text("", 0, 0)); // spacer - - try { - const md = new Markdown(content, 0, 0, getMarkdownTheme()); - box.addChild(md); - } catch { - // Fallback to plain text if markdown rendering fails - box.addChild(new Text(theme.fg("muted", content), 0, 0)); - } - } else { - box.addChild(new Text(theme.fg("dim", "Press Ctrl+O to expand"), 0, 0)); - } - } - - return box; - }; - - pi.registerMessageRenderer(SESSION_LINK_SOURCE_TYPE, renderSource); -} - -/** - * Flatten message content into plain text. - * Handles both string content and content part arrays, - * keeping only text parts. - */ -export function messageContentToText( - content: string | Array<{ type: string; text?: string }>, -): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .filter((c) => c.type === "text" && c.text) - .map((c) => c.text) - .join("\n"); -} - -/** - * Write a session link source entry to the new session. - * - * @param sm - The new session's SessionManager - * @param parentSessionId - The ID of the session being linked from - * @param goal - The session link goal - * @param linkType - Whether this is a "handoff" or "continue" link - * @param content - Optional content to display in the expanded view - */ -export function writeSessionLinkSource( - sm: SessionManager, - parentSessionId: string, - goal: string, - linkType: SessionLinkType, - content?: string, -): void { - sm.appendCustomMessageEntry( - SESSION_LINK_SOURCE_TYPE, - content ?? "", - true, - { parentSessionId, goal, linkType }, - ); -} - -/** - * Write a session link marker entry directly to the parent session file. - * - * Appends a well-formed `custom_message` JSONL entry with the new session ID. - * - * @param sessionFile - Path to the parent session JSONL file - * @param targetSessionId - The new session ID to link to - * @param goal - The session link goal - * @param linkType - Whether this is a "handoff" or "continue" link - * @param parentId - The leaf ID of the parent session (for tree structure) - */ -export function writeSessionLinkMarker( - sessionFile: string, - targetSessionId: string, - goal: string, - linkType: SessionLinkType, - parentId: string, -): void { - const entry = { - type: "custom_message", - customType: SESSION_LINK_MARKER_TYPE, - id: randomUUID(), - timestamp: new Date().toISOString(), - content: "", - display: true, - parentId, - details: { - targetSessionId, - goal, - linkType, - } satisfies SessionLinkMarkerDetails, - }; - appendFileSync(sessionFile, `\n${JSON.stringify(entry)}`); -} diff --git a/extensions/breadcrumbs/package.json b/extensions/breadcrumbs/package.json index 13fbc1e1..c84800e3 100644 --- a/extensions/breadcrumbs/package.json +++ b/extensions/breadcrumbs/package.json @@ -1,16 +1,8 @@ { "pi": { "extensions": [ - "./commands/continue/index.ts", - "./commands/session-copy-id/index.ts", - "./commands/session-copy-path/index.ts", - "./commands/spawn/index.ts", - "./hooks/protect-sessions-dir/index.ts", - "./hooks/session-link-renderers/index.ts", - "./hooks/session-autocomplete/index.ts", "./tools/find-sessions/index.ts", - "./tools/list-sessions/index.ts", - "./tools/read-session/index.ts" + "./tools/list-sessions/index.ts" ] } } diff --git a/extensions/breadcrumbs/tools/read-session-types.ts b/extensions/breadcrumbs/tools/read-session-types.ts deleted file mode 100644 index 484004dc..00000000 --- a/extensions/breadcrumbs/tools/read-session-types.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Types for the read_session tool. - */ - -import type { - SubagentToolCall, - SubagentUsage, -} from "../../subagents/lib/types"; - -export interface ReadSessionInput { - sessionId: string; - goal: string; -} - -export interface ReadSessionDetails { - sessionId: string; - goal: string; - resolvedPath?: string; - toolCalls: SubagentToolCall[]; - response?: string; - aborted?: boolean; - error?: string; - usage?: SubagentUsage; - resolvedModel?: { provider: string; id: string }; - totalDurationMs?: number; -} diff --git a/extensions/breadcrumbs/tools/read-session/index.ts b/extensions/breadcrumbs/tools/read-session/index.ts deleted file mode 100644 index c02b0109..00000000 --- a/extensions/breadcrumbs/tools/read-session/index.ts +++ /dev/null @@ -1,672 +0,0 @@ -/** - * Read Session tool - extract information from a past session using a Gemini Flash subagent. - * - * The subagent gets access to session-specific tools (get_session_overview, get_messages, etc.) - * and uses them to extract information based on a goal. - */ - -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { - FailedToolCalls, - MarkdownResponse, - renderToolTextFallback, - SubagentFooter, - type ToolCallFormatter, - ToolCallHeader, - ToolDetails, - type ToolDetailsField, -} from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - AgentToolUpdateCallback, - ExtensionAPI, - ExtensionContext, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import type { Component } from "@mariozechner/pi-tui"; -import { Text, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; -import { Type } from "typebox"; -import { - createExecutionTimer, - wrapToolDefinitionsWithTiming, -} from "../../../../packages/agent-kit"; -import { - executeSubagent, - resolveModel, - type SubagentToolCall, - shouldFailToolCallForModelIssue, -} from "../../../subagents/lib"; -import { - createSessionTools, - loadSession, - type ParsedSession, -} from "../../lib/session-reader"; -import { getSessionsDir } from "../../lib/session-search"; -import type { ReadSessionDetails } from "../read-session-types"; - -const MODEL = "gpt-5.4-mini"; -// const MODEL = "gpt-5.3-codex-spark"; - -const SYSTEM_PROMPT = `You are a session analyzer. Your task is to extract specific information from a Pi coding agent session. - -You have access to tools that let you query the session: -- \`get_session_overview\`: Get basic session metadata -- \`get_messages\`: Paginate through messages (user or assistant) -- \`get_tool_calls\`: Look at specific tool calls -- \`get_tool_results\`: Look at tool results -- \`get_compactions\`: See session compactions -- \`find_messages\`: Search for messages by keyword - -Guidelines: -1. Always start with \`get_session_overview\` to understand the session -2. Always begin your response with a brief header: session name (if available), working directory, and date -3. For keyword-based goals, use \`find_messages\` first -4. Use \`get_compactions\` to understand session history and context -5. Paginate through results using offset/limit - never request everything at once -6. Focus only on extracting what's relevant to the goal -7. Respond in markdown with clear, concise extraction -8. Be specific: quote relevant snippets or summarize findings -9. Include the list of tools used in the session (from toolNames in overview) when relevant to the goal`; - -/** - * Resolve a session ID (UUID or path) to an absolute file path. - * Returns null if not found. - */ -function resolveSessionPath(sessionId: string): string | null { - // If it looks like a path, use it directly - if (sessionId.includes("/") || sessionId.endsWith(".jsonl")) { - return sessionId; - } - - // Otherwise, search for UUID in session filenames - const sessionsDir = getSessionsDir(); - - try { - const cwdDirs = readdirSync(sessionsDir); - - for (const cwdDir of cwdDirs) { - const cwdPath = join(sessionsDir, cwdDir); - const stat = statSync(cwdPath); - - if (!stat.isDirectory()) continue; - - try { - const files = readdirSync(cwdPath); - - for (const file of files) { - if (file.includes(sessionId) && file.endsWith(".jsonl")) { - return join(cwdPath, file); - } - } - } catch { - // Skip directories we can't read - } - } - } catch { - // Skip errors reading sessions dir - } - - return null; -} - -const parameters = Type.Object({ - sessionId: Type.String({ - description: "Session UUID or file path", - }), - goal: Type.String({ - description: "What information to extract from the session", - }), -}); - -type ReadSessionInputType = { - sessionId: string; - goal: string; -}; - -export const READ_SESSION_GUIDANCE = ` -## read_session - -Use read_session to extract specific information from a past session identified by find_sessions. - -**When to use:** -- User wants to recall what was decided in a previous session -- User needs details extracted from a past conversation -- Following up after find_sessions located the relevant session - -**When NOT to use:** -- The current session already has the needed context -- You haven't identified which session to read yet (use find_sessions first) -`; - -/** - * Extract a text summary of a tool call's result content. - * Returns truncated text or undefined if no content available. - */ -function extractToolCallContent( - tc: SubagentToolCall, - maxLen = 300, -): string | undefined { - if (tc.error) return `Error: ${tc.error}`; - - const result = tc.result; - if (result === undefined || result === null) return undefined; - - // String result - if (typeof result === "string") { - return result.length > maxLen ? `${result.slice(0, maxLen)}...` : result; - } - - // Object with content array (standard tool result shape) - if (typeof result === "object" && !Array.isArray(result)) { - const obj = result as Record; - if (Array.isArray(obj.content)) { - const texts = (obj.content as Array<{ type?: string; text?: string }>) - .filter((c) => c.type === "text" && typeof c.text === "string") - .map((c) => c.text as string); - if (texts.length > 0) { - const joined = texts.join("\n"); - return joined.length > maxLen - ? `${joined.slice(0, maxLen)}...` - : joined; - } - } - } - - // Fallback: JSON stringify - const serialized = JSON.stringify(result); - return serialized.length > maxLen - ? `${serialized.slice(0, maxLen)}...` - : serialized; -} - -/** - * Custom component that renders tool calls with their result content, - * not just the tool call names. - */ -class ToolCallContentList implements Component { - constructor( - private toolCalls: SubagentToolCall[], - private formatter: ToolCallFormatter, - private theme: Theme, - ) {} - - handleInput(_data: string): boolean { - return false; - } - - invalidate(): void {} - - render(width: number): string[] { - if (!this.toolCalls || this.toolCalls.length === 0) return []; - - const th = this.theme; - const lines: string[] = []; - - lines.push(th.fg("muted", `Tool calls (${this.toolCalls.length}):`)); - - for (const toolCall of this.toolCalls) { - const indicator = - toolCall.status === "running" - ? " " - : toolCall.status === "done" - ? th.fg("success", "✓") - : th.fg("error", "✗"); - - const { label, detail } = this.formatter(toolCall); - const text = detail ? `${th.bold(label)} ${detail}` : th.bold(label); - - const prefix = ` ${indicator} `; - const prefixWidth = visibleWidth(prefix); - const textWidth = Math.max(1, width - prefixWidth); - const wrapped = wrapTextWithAnsi(text, textWidth); - const indent = " ".repeat(prefixWidth); - - for (let i = 0; i < wrapped.length; i++) { - lines.push(i === 0 ? prefix + wrapped[i] : indent + wrapped[i]); - } - - // Show result content for completed tool calls - if (toolCall.status === "done" || toolCall.status === "error") { - const content = extractToolCallContent(toolCall); - if (content) { - const contentIndent = " "; - const contentWidth = Math.max(1, width - contentIndent.length); - // Truncate to a few lines max - const contentLines = content.split("\n").slice(0, 4); - for (const contentLine of contentLines) { - const wrappedContent = wrapTextWithAnsi( - th.fg("muted", contentLine), - contentWidth, - ); - for (const wl of wrappedContent) { - lines.push(contentIndent + wl); - } - } - } - } - } - - return lines; - } -} - -/** - * Collapsed summary that includes tool names and counts only. - * Used in the done state to avoid large content in collapsed view. - */ -class ToolCallContentSummary implements Component { - constructor( - private toolCalls: SubagentToolCall[], - private formatter: ToolCallFormatter, - private theme: Theme, - ) {} - - handleInput(_data: string): boolean { - return false; - } - - invalidate(): void {} - - render(width: number): string[] { - if (!this.toolCalls || this.toolCalls.length === 0) return []; - - const th = this.theme; - const names = this.toolCalls.map((tc) => this.formatter(tc).label); - const counts: Record = {}; - for (const name of names) { - counts[name] = (counts[name] ?? 0) + 1; - } - - const summary = Object.entries(counts) - .map(([name, count]) => (count > 1 ? `${name} x${count}` : name)) - .join(", "); - - const prefix = `${this.toolCalls.length} tool call${this.toolCalls.length === 1 ? "" : "s"}`; - const line = new Text(th.fg("muted", `${prefix}: `) + summary, 0, 0); - return line.render(width); - } -} - -/** - * Setup the read_session tool. - */ -export default async function (pi: ExtensionAPI) { - pi.registerTool({ - name: "read_session", - label: "Read Session", - description: `Extract specific information from a past Pi coding session. - -The tool spins up a subagent that uses session-specific tools to analyze a session file based on your goal. - -Examples: -- Goal: "What was the main issue discussed?" -- Goal: "List all tool calls made during this session" -- Goal: "Find where we discussed authentication" -- Goal: "Summarize the final solution implemented" - -Input the session ID (UUID or path) and what you want to learn about it.`, - promptSnippet: - "Read a past session and extract a specific answer or summary.", - promptGuidelines: [ - "Use this tool to extract specific information from a session found with find_sessions.", - "Use this when the user wants to recall a decision or summary from a past conversation.", - "Do not use this until you know which session to inspect.", - ], - - parameters, - - async execute( - _toolCallId: string, - args: ReadSessionInputType, - signal: AbortSignal | undefined, - onUpdate: AgentToolUpdateCallback | undefined, - ctx: ExtensionContext, - ) { - const { sessionId, goal } = args; - const executionTimer = createExecutionTimer(); - - let resolvedPath: string | null = null; - let resolvedModel: { provider: string; id: string } | undefined; - let currentToolCalls: SubagentToolCall[] = []; - - // Resolve session path - resolvedPath = resolveSessionPath(sessionId); - - if (!resolvedPath) { - const error = `Session not found: ${sessionId}`; - return { - content: [{ type: "text", text: `Error: ${error}` }], - details: { - sessionId, - goal, - resolvedPath: undefined, - toolCalls: [], - error, - totalDurationMs: executionTimer.getDurationMs(), - }, - }; - } - - // Load session - let session: ParsedSession; - try { - session = loadSession(resolvedPath); - } catch (err) { - const error = - err instanceof Error ? err.message : `Failed to load session`; - return { - content: [{ type: "text", text: `Error: ${error}` }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: [], - error, - totalDurationMs: executionTimer.getDurationMs(), - }, - }; - } - - // Create session tools - const sessionTools = wrapToolDefinitionsWithTiming( - createSessionTools(session), - ); - - try { - const model = resolveModel("openai-codex", MODEL, ctx); - resolvedModel = { provider: model.provider, id: model.id }; - - // Publish resolved model early - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: currentToolCalls, - resolvedModel, - }, - }); - - const userMessage = `Please analyze this session and help me with the following goal:\n\n${goal}`; - - const result = await executeSubagent( - { - name: "read_session", - model, - systemPrompt: SYSTEM_PROMPT, - customTools: sessionTools, - thinkingLevel: "off", - logging: { - enabled: true, - debug: false, - }, - }, - userMessage, - ctx, - // onTextUpdate - (_delta, _accumulated) => { - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: currentToolCalls, - resolvedModel, - }, - }); - }, - signal, - // onToolUpdate - (toolCalls: SubagentToolCall[]) => { - currentToolCalls = toolCalls; - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: currentToolCalls, - resolvedModel, - }, - }); - }, - ); - - const finalToolCalls = - result.toolCalls.length > 0 ? result.toolCalls : currentToolCalls; - - if (result.aborted) { - return { - content: [{ type: "text", text: "Aborted" }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: finalToolCalls, - - aborted: true, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } - - if (result.error) { - if (shouldFailToolCallForModelIssue(result)) { - throw new Error(result.error); - } - - return { - content: [{ type: "text", text: `Error: ${result.error}` }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: finalToolCalls, - - error: result.error, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } - - // Check if all tool calls failed - const errorCount = finalToolCalls.filter( - (tc) => tc.status === "error", - ).length; - const allFailed = - finalToolCalls.length > 0 && errorCount === finalToolCalls.length; - - if (allFailed) { - const error = "All tool calls failed"; - return { - content: [{ type: "text", text: `Error: ${error}` }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: finalToolCalls, - - error, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } - - return { - content: [{ type: "text", text: result.content }], - details: { - sessionId, - goal, - resolvedPath, - toolCalls: finalToolCalls, - response: result.content, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } finally { - } - }, - - renderCall(args: ReadSessionInputType, theme: Theme) { - const goal = args.goal.trim(); - const shortGoal = goal.length > 80 ? `${goal.slice(0, 77)}...` : goal; - - return new ToolCallHeader( - { - toolName: "Read Session", - mainArg: args.sessionId, - optionArgs: - goal.length <= 80 - ? [{ label: "goal", value: shortGoal }] - : undefined, - longArgs: - goal.length > 80 - ? [ - { - label: "goal", - value: goal, - }, - ] - : undefined, - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const { details } = result; - - // Fallback if details missing - if (!details) { - return renderToolTextFallback(result, theme); - } - - const { - toolCalls, - response, - aborted, - error, - usage, - resolvedModel, - totalDurationMs, - } = details; - - const footer = new SubagentFooter(theme, { - resolvedModel, - usage, - toolCalls, - totalDurationMs, - }); - - // Build fields based on state - const fields: ToolDetailsField[] = []; - - if (aborted) { - fields.push({ label: "Status", value: "Aborted" }); - } else if (error) { - fields.push({ label: "Error", value: error }); - } else if (response) { - // Done state: show summary in collapsed, content list in expanded - fields.push( - new ToolCallContentSummary(toolCalls, toolCallFormatter, theme), - ); - fields.push(new FailedToolCalls(toolCalls, toolCallFormatter, theme)); - fields.push(new MarkdownResponse(response, theme)); - } else { - // Running state: show tool calls with content - fields.push( - new ToolCallContentList(toolCalls, toolCallFormatter, theme), - ); - } - - return new ToolDetails({ fields, footer }, options, theme); - }, - }); -} - -/** - * Tool call formatter for read_session subagent tools. - */ -const toolCallFormatter: ToolCallFormatter = ( - tc: SubagentToolCall, -) => { - const { toolName, args } = tc; - - let formatted: { label: string; detail?: string }; - - // Format tool calls by name - switch (toolName) { - case "get_session_overview": - formatted = { label: "Overview" }; - break; - - case "get_messages": { - const role = args.role ? ` (${args.role})` : ""; - const limit = args.limit ? ` - ${args.limit} items` : ""; - formatted = { label: "Messages", detail: `${role}${limit}` }; - break; - } - - case "get_tool_calls": { - const name = args.toolName ? String(args.toolName) : "unknown"; - formatted = { label: "Tool Calls", detail: name }; - break; - } - - case "get_tool_results": { - const name = args.toolName ? String(args.toolName) : "unknown"; - formatted = { label: "Tool Results", detail: name }; - break; - } - - case "get_compactions": - formatted = { label: "Compactions" }; - break; - - case "find_messages": { - const query = args.query ? ` "${String(args.query).slice(0, 30)}"` : ""; - formatted = { label: "Find", detail: query }; - break; - } - - default: - formatted = { label: toolName }; - break; - } - - return appendDurationToDetail(formatted, tc.durationMs); -}; - -function appendDurationToDetail( - formatted: { label: string; detail?: string }, - durationMs?: number, -): { label: string; detail?: string } { - if (durationMs === undefined) return formatted; - - const duration = formatDuration(durationMs); - return { - ...formatted, - detail: formatted.detail ? `${formatted.detail} · ${duration}` : duration, - }; -} - -function formatDuration(durationMs: number): string { - if (durationMs < 1000) return `${durationMs}ms`; - return `${(durationMs / 1000).toFixed(2)}s`; -} diff --git a/extensions/chrome/AGENTS.md b/extensions/chrome/AGENTS.md deleted file mode 100644 index 45c4d055..00000000 --- a/extensions/chrome/AGENTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# chrome - -Chrome extension for Pi — owns the header, footer, terminal title, notifications, and auto-naming. - -## Layout - -- `hooks/` - Event hooks (header, footer, terminal title, notification, session naming) -- `components/` - UI components (header, footer) -- `lib/` - Shared logic (git status, model display, path parts, stats, title generation, utils) -- `bin/` - Native binaries (play-alert-sound.swift) -- `index.ts` - Entry point diff --git a/extensions/chrome/README.md b/extensions/chrome/README.md deleted file mode 100644 index c4e1ed65..00000000 --- a/extensions/chrome/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# chrome - -Chrome extension for Pi — owns the visual chrome around the coding agent. - -## Features - -### Header - -Custom header showing harness shortcuts and commands. Displays only the custom shortcuts and commands defined in harness extensions, replacing the built-in keybinding hints. - -### Footer - -Two-line footer layout: -- Line 1: Stash indicator + path + git branch/status + stats (cost, context usage) -- Line 2: Session name + model info with thinking level - -Progressive degradation for narrow terminals: drops branch, truncates path, switches to minimal stats. - -### Terminal title - -Updates the terminal title with a project breadcrumb and current activity: -- Session start: `pi: ` -- Agent running: `pi: (thinking...)` -- Tool call: `pi: ()` -- Attention marker: appends ` [!]` for attention/dangerous events - -### Notifications - -Sends OS-level terminal notifications using OSC escape sequences with optional macOS sounds: -- Plays attention sound when `ask_user` tool is invoked -- Sends summary notification when agent finishes -- Listens for `ad:notify:dangerous` and `ad:notify:attention` events - -### Auto session naming - -Automatically names sessions after the first completed agent loop, using `google/gemini-2.5-flash-lite` to generate a concise title. diff --git a/extensions/chrome/hooks/index.ts b/extensions/chrome/hooks/index.ts deleted file mode 100644 index 573572a0..00000000 --- a/extensions/chrome/hooks/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { setupFooterHook } from "./footer"; -import { setupHeaderHook } from "./header"; -import { setupNotificationHook } from "./notification"; -import { setupSessionNameHook } from "./session-name"; - -export function setupHooks(pi: ExtensionAPI) { - setupSessionNameHook(pi); - setupNotificationHook(pi); - setupFooterHook(pi); - setupHeaderHook(pi); -} diff --git a/extensions/chrome/hooks/session-name.ts b/extensions/chrome/hooks/session-name.ts deleted file mode 100644 index 9415177d..00000000 --- a/extensions/chrome/hooks/session-name.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { generateAndSetTitle } from "../lib/title"; - -interface SessionNameState { - hasAutoNamed: boolean; -} - -function isTurnCompleted(event: unknown): boolean { - if (!event || typeof event !== "object") return false; - const message = (event as { message?: unknown }).message; - if (!message || typeof message !== "object") return false; - const stopReason = (message as { stopReason?: unknown }).stopReason; - return typeof stopReason === "string" && stopReason.toLowerCase() === "stop"; -} - -export function setupSessionNameHook(pi: ExtensionAPI) { - const state: SessionNameState = { - hasAutoNamed: false, - }; - - pi.on("session_start", async () => { - state.hasAutoNamed = false; - }); - - pi.on("turn_end", async (event, ctx) => { - if (state.hasAutoNamed) return; - - if (pi.getSessionName()) { - state.hasAutoNamed = true; - return; - } - - if (!isTurnCompleted(event)) { - return; - } - - await generateAndSetTitle(pi, ctx); - state.hasAutoNamed = true; - }); -} diff --git a/extensions/chrome/index.ts b/extensions/chrome/index.ts deleted file mode 100644 index 816b013b..00000000 --- a/extensions/chrome/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { setupHooks } from "./hooks"; - -export default async function (pi: ExtensionAPI) { - setupHooks(pi); -} diff --git a/extensions/chrome/lib/title.ts b/extensions/chrome/lib/title.ts deleted file mode 100644 index fb42c7d0..00000000 --- a/extensions/chrome/lib/title.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { getModel, type TextContent } from "@mariozechner/pi-ai"; -import type { - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { executeSubagent, resolveModel } from "../../../packages/agent-kit"; - -const TITLE_MODEL = { - provider: "openrouter", - model: "google/gemini-2.5-flash-lite", -} as const; - -const MAX_TITLE_LENGTH = 50; -const MAX_RETRIES = 2; -const FALLBACK_LENGTH = 50; -const TITLE_ENTRY_TYPE = "ad:session-title"; - -const TITLE_SYSTEM_PROMPT = `You are generating a succinct title for a coding session based on the provided conversation. - -Requirements: -- Maximum 50 characters -- Sentence case (capitalize only first word and proper nouns) -- Capture the main intent or task -- Reuse the user's exact words and technical terms -- Match the user's language (if they write in French, respond in French) -- No quotes, colons, or markdown formatting -- No generic titles like "Coding session" or "Help with code" -- No explanations or commentary - -Output ONLY the title text. Nothing else. - -Examples: -- Debug 500 errors in auth middleware -- Add refresh token support -- Refactor user service tests -- Migrer la base de donnees vers Postgres`; - -export function buildFallbackTitle(userText: string): string { - const text = userText.trim(); - if (text.length <= FALLBACK_LENGTH) return text; - const truncated = text.slice(0, FALLBACK_LENGTH - 3); - const lastSpace = truncated.lastIndexOf(" "); - return `${lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated}...`; -} - -export function postProcessTitle(raw: string): string { - let title = raw; - - // Strip tags (some models leak these) - title = title.replace(/\s*/g, ""); - - // Strip wrapping quotes (single, double, backticks) - title = title.replace(/^["'`]+|["'`]+$/g, ""); - - // Strip markdown formatting (bold, italic, headers) - title = title.replace(/^#+\s*/, ""); - title = title.replace(/\*{1,2}(.*?)\*{1,2}/g, "$1"); - title = title.replace(/_{1,2}(.*?)_{1,2}/g, "$1"); - - // Strip meta-prefixes the model might add despite instructions - title = title.replace(/^(Title|Summary|Session)\s*:\s*/i, ""); - - // Take first non-empty line only - title = - title - .split("\n") - .map((l) => l.trim()) - .find((l) => l.length > 0) ?? title; - - // Trim whitespace - title = title.trim(); - - // Enforce max length: truncate at word boundary, add "..." if truncated - if (title.length > MAX_TITLE_LENGTH) { - const truncated = title.slice(0, MAX_TITLE_LENGTH - 3); - const lastSpace = truncated.lastIndexOf(" "); - title = `${lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated}...`; - } - - return title; -} - -export async function generateTitle( - userText: string, - assistantText: string, - ctx: ExtensionContext, -): Promise { - const model = getModel(TITLE_MODEL.provider, TITLE_MODEL.model); - if (!model) { - throw new Error( - `Model not found: ${TITLE_MODEL.provider}/${TITLE_MODEL.model}`, - ); - } - - const resolvedModel = resolveModel( - TITLE_MODEL.provider, - TITLE_MODEL.model, - ctx, - ); - - // Build the conversation description using XML-style tags (like Claude Code) - const description = assistantText - ? `${userText}\n${assistantText}` - : userText; - - const userMessage = `\n${description}\n\n\nGenerate a title:`; - - const result = await executeSubagent( - { - name: "title-generation", - model: resolvedModel, - systemPrompt: TITLE_SYSTEM_PROMPT, - thinkingLevel: "off", - }, - userMessage, - ctx, - ); - - if (result.error) { - throw new Error(result.error); - } - - return postProcessTitle(result.content); -} - -export function getLatestUserText(ctx: ExtensionContext): string | null { - const entries = ctx.sessionManager.getEntries(); - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]; - if (!entry || entry.type !== "message") continue; - if (entry.message.role !== "user") continue; - - const msg = entry.message as { content: string | TextContent[] }; - if (typeof msg.content === "string") { - return msg.content; - } - return msg.content - .filter((c): c is TextContent => c.type === "text") - .map((c) => c.text) - .join(" "); - } - - return null; -} - -export function getLatestAssistantText(ctx: ExtensionContext): string | null { - const entries = ctx.sessionManager.getEntries(); - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]; - if (!entry || entry.type !== "message") continue; - if (entry.message.role !== "assistant") continue; - - const msg = entry.message as { content: TextContent[] }; - // Filter for text content only -- this naturally excludes thinking blocks - return msg.content - .filter((c): c is TextContent => c.type === "text") - .map((c) => c.text) - .join("\n"); - } - - return null; -} - -export async function generateAndSetTitle( - pi: ExtensionAPI, - ctx: ExtensionContext, -): Promise { - const userText = getLatestUserText(ctx); - if (!userText?.trim()) { - ctx.ui.notify("No user message to generate title from", "warning"); - return; - } - - const assistantText = getLatestAssistantText(ctx) ?? ""; - if (!assistantText.trim()) { - ctx.ui.notify("No assistant response to generate title from", "warning"); - return; - } - let lastError: Error | null = null; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - const title = await generateTitle(userText, assistantText, ctx); - if (title) { - pi.setSessionName(title); - pi.appendEntry(TITLE_ENTRY_TYPE, { - title, - rawUserText: userText, - rawAssistantText: assistantText, - attempt, - }); - ctx.ui.notify(`Session: ${title}`, "info"); - return; - } - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - if (attempt < MAX_RETRIES) { - ctx.ui.notify( - `Title generation failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`, - "warning", - ); - } - } - } - - // All retries exhausted -- fallback - const fallback = buildFallbackTitle(userText); - pi.setSessionName(fallback); - pi.appendEntry(TITLE_ENTRY_TYPE, { - title: fallback, - fallback: true, - error: lastError?.message ?? "Unknown error", - rawUserText: userText, - rawAssistantText: assistantText, - }); - ctx.ui.notify( - `Title generation failed, using fallback: ${fallback}`, - "error", - ); -} diff --git a/extensions/defaults/AGENTS.md b/extensions/defaults/AGENTS.md deleted file mode 100644 index 10636bd0..00000000 --- a/extensions/defaults/AGENTS.md +++ /dev/null @@ -1,13 +0,0 @@ -# defaults - -Sensible defaults and quality-of-life improvements for Pi. - -## Layout - -- `tools/` - Tool overrides and custom tools (`read`, `edit`, `find`, `bash`, `get_current_time`, `read_url`) -- `hooks/` - Event hooks (event compat bridge) -- `commands/` - Slash commands (`/theme`) -- `components/` - UI components (theme selector) -- `lib/` - Shared logic (tool setup) -- `setup-commands.ts` - Registers extension commands -- `index.ts` - Entry point, wires everything together diff --git a/extensions/defaults/README.md b/extensions/defaults/README.md deleted file mode 100644 index 075f9edd..00000000 --- a/extensions/defaults/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# defaults - -Sensible defaults and quality-of-life improvements for Pi. - -## Tools - -### `bash` with `cwd` parameter - -Overrides the built-in bash tool to add an optional `cwd` parameter. This avoids fragile `cd dir && command` patterns and fails explicitly when the target directory does not exist. - -Also supports bash spawn hook contributors. Other extensions can register spawn hooks via the `ad:bash:spawn-hook:request` event to compose modifications to the spawn context (e.g. injecting environment variables or wrapping the shell command). - -Custom renderers: themed call header with `$ command` display, cwd/timeout indicators, collapsed output with visual-line truncation, truncation warnings, and elapsed duration. - -### `read` with directory support - -Overrides the built-in read tool to handle directories. If the path is a directory, delegates to the native `ls` tool instead of throwing EISDIR. - -### `grep` with custom renderers - -Overrides the built-in grep tool with `ToolCallHeader`, `ToolBody`, and `ToolFooter` rendering. Shows the pattern and option flags in the call header. Results are truncated when collapsed (15 lines) with a footer showing match count, limit/truncation warnings, and relative-to path. - -### `find` with custom renderers - -Overrides the built-in find tool with `ToolCallHeader`, `ToolBody`, and `ToolFooter` rendering. Shows the pattern and search path in the call header. Results are truncated when collapsed (20 lines) with a footer showing result count, limit warnings, and relative-to path. - -### `get_current_time` - -Returns the current date and time with structured fields: formatted string, date, time, timezone, timezone name, day of week, and unix timestamp. Supports format parameter: `iso8601` (default), `unix`, `date`, `time`. - -Custom renderers: themed call header, compact date/time display in result. - -### `read_url` - -Fetches pages as Markdown through a handler pipeline. Domain-specific handlers for `x.com`/`twitter.com` (via `api.fxtwitter.com`), `github.com` (via GitHub CLI), and `gist.github.com` (via Gist API). Falls back to `markdown.new` for everything else. Attaches inline images from remote URLs. - -Custom renderers: themed call header, Markdown rendering with expand/collapse, handler/HTTP status footer. - -## Commands - -### `/theme` - -Theme selector with live preview. Browse all available themes (built-in and custom), preview each one in real-time, and apply with Enter or cancel with Escape to restore the original. - -## Hooks - -### Event compatibility bridge - -Bridges external extension events into harness-native events for backwards compatibility. Currently maps `guardrails:dangerous` -> `ad:notify:dangerous`. diff --git a/extensions/defaults/commands/index.ts b/extensions/defaults/commands/index.ts deleted file mode 100644 index 2ca302f3..00000000 --- a/extensions/defaults/commands/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { registerThemeCommand } from "./theme"; - -export function registerCommands(pi: ExtensionAPI) { - registerThemeCommand(pi); -} diff --git a/extensions/defaults/hooks/index.ts b/extensions/defaults/hooks/index.ts deleted file mode 100644 index 5a6d2d90..00000000 --- a/extensions/defaults/hooks/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { setupEventCompatHook } from "./event-compat"; - -export function setupHooks(pi: ExtensionAPI) { - setupEventCompatHook(pi); -} diff --git a/extensions/defaults/index.ts b/extensions/defaults/index.ts deleted file mode 100644 index 719959ac..00000000 --- a/extensions/defaults/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { setupHooks } from "./hooks"; -import { setupCommands } from "./setup-commands"; - -export default async function (pi: ExtensionAPI) { - setupHooks(pi); - setupCommands(pi); -} diff --git a/extensions/defaults/lib/tree.ts b/extensions/defaults/lib/tree.ts deleted file mode 100644 index 252ef7cf..00000000 --- a/extensions/defaults/lib/tree.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Theme } from "@mariozechner/pi-coding-agent"; -import type { Component } from "@mariozechner/pi-tui"; -import { visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; - -export type TreeTone = - | "muted" - | "accent" - | "success" - | "warning" - | "error" - | "toolOutput" - | "dim"; - -export interface TreeNode { - /** Display label for this node */ - label: string; - /** Theme tone for the label. Defaults to "toolOutput". */ - tone?: TreeTone; - /** Child nodes */ - children?: TreeNode[]; -} - -export interface TreeOptions { - /** Theme for styling */ - theme: Theme; - /** Whether to show root-level connectors (├──/└──). Default: false. */ - rootConnectors?: boolean; - /** Suffix appended to nodes that have children. Default: "/" */ - dirSuffix?: string; -} - -/** - * Tree component — renders nested TreeNode data as a tree with box-drawing characters. - * - * Tree chars (├──, └──, │) are always rendered in the "muted" tone. - * Each node's label is rendered in its own tone (default: "toolOutput"). - * Long labels are word-wrapped, with continuation lines indented to - * align with the label start (after the tree prefix). - * - * Collapse/expand is handled outside this component — callers slice - * their data before constructing the tree. - */ -export class Tree implements Component { - private options: TreeOptions; - private roots: TreeNode[]; - - constructor(roots: TreeNode[], options: TreeOptions) { - this.roots = roots; - this.options = options; - } - - update(roots: TreeNode[], options: TreeOptions): void { - this.roots = roots; - this.options = options; - } - - invalidate(): void {} - - render(width: number): string[] { - const lines: string[] = []; - const { theme, rootConnectors = false, dirSuffix = "/" } = this.options; - - for (const [i, node] of this.roots.entries()) { - const isLast = i === this.roots.length - 1; - - if (rootConnectors) { - renderChild(node, isLast, [], width, theme, dirSuffix, lines); - } else { - renderRoot(node, width, theme, dirSuffix, lines); - } - } - - return lines; - } -} - -/** Render a root node (no connector prefix). */ -function renderRoot( - node: TreeNode, - width: number, - theme: Theme, - dirSuffix: string, - lines: string[], -): void { - const hasChildren = node.children && node.children.length > 0; - const label = hasChildren ? `${node.label}${dirSuffix}` : node.label; - const tone = node.tone ?? "toolOutput"; - - const wrapped = wrapTextWithAnsi(label, width); - for (const line of wrapped) { - lines.push(theme.fg(tone, line)); - } - - if (!node.children || node.children.length === 0) return; - - for (const [i, child] of node.children.entries()) { - const isLast = i === node.children.length - 1; - renderChild(child, isLast, [], width, theme, dirSuffix, lines); - } -} - -/** - * Render a child node with a connector prefix. - * `ancestorBars` tracks whether each ancestor level has a continuation bar (│). - */ -function renderChild( - node: TreeNode, - isLast: boolean, - ancestorBars: boolean[], - width: number, - theme: Theme, - dirSuffix: string, - lines: string[], -): void { - let prefix = ""; - for (const hasBar of ancestorBars) { - prefix += hasBar ? "│ " : " "; - } - prefix += isLast ? "└── " : "├── "; - - const hasChildren = node.children && node.children.length > 0; - const label = hasChildren ? `${node.label}${dirSuffix}` : node.label; - const tone = node.tone ?? "toolOutput"; - - const prefixVisible = visibleWidth(prefix); - const available = Math.max(1, width - prefixVisible); - - // Wrap the label, then indent continuation lines to align with label start - const wrapIndent = " ".repeat(prefixVisible); - const wrapped = wrapTextWithAnsi(label, available); - - for (const [i, line] of wrapped.entries()) { - if (i === 0) { - lines.push(`${theme.fg("muted", prefix)}${theme.fg(tone, line)}`); - } else { - lines.push(`${wrapIndent}${theme.fg(tone, line)}`); - } - } - - if (!node.children || node.children.length === 0) return; - - const childBars = [...ancestorBars, !isLast]; - - for (const [i, child] of node.children.entries()) { - const childIsLast = i === node.children.length - 1; - renderChild(child, childIsLast, childBars, width, theme, dirSuffix, lines); - } -} diff --git a/extensions/defaults/package.json b/extensions/defaults/package.json deleted file mode 100644 index 1a34255a..00000000 --- a/extensions/defaults/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "pi": { - "extensions": [ - "./index.ts", - "./tools/bash/index.ts", - "./tools/edit/index.ts", - "./tools/find/index.ts", - "./tools/grep/index.ts", - "./tools/read/index.ts" - ] - } -} diff --git a/extensions/defaults/setup-commands.ts b/extensions/defaults/setup-commands.ts deleted file mode 100644 index 94cca4d7..00000000 --- a/extensions/defaults/setup-commands.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { registerCommands } from "./commands"; - -export function setupCommands(pi: ExtensionAPI) { - registerCommands(pi); -} diff --git a/extensions/defaults/tools/bash/index.ts b/extensions/defaults/tools/bash/index.ts deleted file mode 100644 index 6e7cdf2f..00000000 --- a/extensions/defaults/tools/bash/index.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { homedir as getHomedir } from "node:os"; -import { resolve } from "node:path"; -import type { - AgentToolResult, - BashSpawnContext, - ExtensionAPI, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { - createBashTool, - DEFAULT_MAX_BYTES, - formatSize, - keyHint, - truncateToVisualLines, -} from "@mariozechner/pi-coding-agent"; -import { Box, Text, truncateToWidth } from "@mariozechner/pi-tui"; -import { Type } from "typebox"; - -/** Lines to show when collapsed. Matches the native bash tool. */ -const BASH_PREVIEW_LINES = 5; - -const AD_BASH_SPAWN_HOOK_REQUEST_EVENT = "ad:bash:spawn-hook:request"; - -type SpawnHookContributor = { - id: string; - priority?: number; - spawnHook: (ctx: BashSpawnContext) => BashSpawnContext; -}; - -type SpawnHookRequestPayload = { - register: (contributor: SpawnHookContributor) => void; -}; - -const homedir = getHomedir(); -/** - * Override the built-in bash tool to add a cwd parameter. - * - * Models often use `cd dir && command` which silently skips the command - * if the directory doesn't exist. The cwd parameter is passed to spawn() - * which fails explicitly if the directory is missing. - */ -export default function (pi: ExtensionAPI): void { - const cwd = process.cwd(); - const nativeBash = createBashTool(cwd); - - const contributors = new Map(); - const getContributors = () => - Array.from(contributors.values()).sort( - (a, b) => (a.priority ?? 100) - (b.priority ?? 100), - ); - - const registerContributor = (contributor: SpawnHookContributor) => { - contributors.set(contributor.id, contributor); - }; - - const composedSpawnHook = (ctx: BashSpawnContext): BashSpawnContext => { - let next = ctx; - for (const contributor of getContributors()) { - next = contributor.spawnHook(next); - } - return next; - }; - - const schema = Type.Object({ - command: Type.String({ description: "Bash command to execute" }), - timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })), - cwd: Type.Optional( - Type.String({ - description: - "Working directory for the command. Prefer this over shell wrappers like 'cd dir && command', 'pushd', or 'cd ../..; ...'.", - }), - ), - }); - - pi.registerTool({ - ...nativeBash, - parameters: schema, - promptGuidelines: [ - "When a command should run in another directory, set cwd and keep command free of leading 'cd', 'pushd', or similar directory-changing shell wrappers.", - "Do not use patterns like 'cd dir && command', 'cd dir; command', or 'pushd dir && command'.", - "Use the cwd parameter instead of 'cd dir && command'.", - "Reserve bash for git, build/test, package managers, ssh, curl, and process management.", - "Prefer native tools like read, find, grep, edit, and write over shell commands when available.", - ], - renderCall(args, theme) { - const command = args.command ?? ""; - const timeout = args.timeout as number | undefined; - const cwdArg = args.cwd as string | undefined; - - const commandDisplay = command ? command : theme.fg("toolOutput", "..."); - const cwdDisplay = cwdArg?.startsWith(homedir) - ? `~${cwdArg.slice(homedir.length)}` - : cwdArg; - const cwdSuffix = cwdDisplay - ? theme.fg("muted", ` (cwd: ${cwdDisplay})`) - : ""; - const timeoutSuffix = timeout - ? theme.fg("muted", ` (timeout ${timeout}s)`) - : ""; - - return new Text( - `${theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`))}${cwdSuffix}${timeoutSuffix}`, - 0, - 0, - ); - }, - renderResult( - result: AgentToolResult>, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const box = new Box(0, 0); - const output = getTextOutput(result); - - if (output) { - const styledOutput = output - .split("\n") - .map((line: string) => theme.fg("toolOutput", line)) - .join("\n"); - - if (options.expanded) { - box.addChild(new Text(`\n${styledOutput}`, 0, 0)); - } else { - // Visual line truncation with width-aware caching (matches native) - let cachedWidth: number | undefined; - let cachedLines: string[] | undefined; - let cachedSkipped: number | undefined; - - box.addChild({ - render: (width: number) => { - if (cachedLines === undefined || cachedWidth !== width) { - const r = truncateToVisualLines( - styledOutput, - BASH_PREVIEW_LINES, - width, - ); - cachedLines = r.visualLines; - cachedSkipped = r.skippedCount; - cachedWidth = width; - } - if (cachedSkipped && cachedSkipped > 0) { - const hint = `${theme.fg("muted", `... (${cachedSkipped} earlier lines,`)} ${keyHint("app.tools.expand", "to expand")})`; - return [ - "", - truncateToWidth(hint, width, "..."), - ...cachedLines, - ]; - } - return ["", ...cachedLines]; - }, - invalidate: () => { - cachedWidth = undefined; - cachedLines = undefined; - cachedSkipped = undefined; - }, - }); - } - } - - // Truncation warnings - const details = result.details as Record | undefined; - const truncation = details?.truncation as - | Record - | undefined; - const fullOutputPath = details?.fullOutputPath as string | undefined; - if (truncation?.truncated || fullOutputPath) { - const warnings: string[] = []; - if (fullOutputPath) { - warnings.push(`Full output: ${fullOutputPath}`); - } - if (truncation?.truncated) { - if (truncation.truncatedBy === "lines") { - warnings.push( - `Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`, - ); - } else { - warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${formatSize((truncation.maxBytes as number) ?? DEFAULT_MAX_BYTES)} limit)`, - ); - } - } - box.addChild( - new Text( - `\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, - 0, - 0, - ), - ); - } - - // Elapsed / Took duration - const durationMs = details?._durationMs as number | undefined; - if (!options.isPartial && durationMs !== undefined) { - box.addChild( - new Text( - `\n${theme.fg("muted", `Took ${(durationMs / 1000).toFixed(1)}s`)}`, - 0, - 0, - ), - ); - } - - return box; - }, - async execute(toolCallId, params, signal, onUpdate, ctx) { - const effectiveCwd = params.cwd ? resolve(ctx.cwd, params.cwd) : ctx.cwd; - const bashForCwd = createBashTool(effectiveCwd, { - spawnHook: composedSpawnHook, - }); - const start = Date.now(); - const result = await bashForCwd.execute( - toolCallId, - { command: params.command, timeout: params.timeout }, - signal, - onUpdate, - ); - // Attach duration to details so renderResult can display it - const durationMs = Date.now() - start; - result.details = { ...result.details, _durationMs: durationMs }; - return result; - }, - }); - - // Request hook contributors from other extensions. - const requestContributors = () => { - pi.events.emit(AD_BASH_SPAWN_HOOK_REQUEST_EVENT, { - register: registerContributor, - } satisfies SpawnHookRequestPayload); - }; - - // Fire once at setup and once on session start to avoid load-order misses. - requestContributors(); - pi.on("session_start", () => { - requestContributors(); - }); -} - -const ESC = "\u001B"; -const OSC_REGEX = new RegExp(`${ESC}\\][\\s\\S]*?(?:\\u0007|${ESC}\\\\)`, "g"); -const CSI_REGEX = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "g"); -const SINGLE_ESC_REGEX = new RegExp(`${ESC}[@-_]`, "g"); -const C1_ST_REGEX = /\u009C/g; - -/** - * Remove terminal escape/control sequences that leak into tool output UI. - * Mirrors native bash rendering behavior in pi-mono (strip + sanitize). - */ -function sanitizeShellOutput(value: string): string { - let text = value; - - // OSC sequences: ESC ] ... BEL or ESC \\ - text = text.replace(OSC_REGEX, ""); - // CSI/SGR and other control sequences: ESC [ ... command - text = text.replace(CSI_REGEX, ""); - // Other single-character escapes - text = text.replace(SINGLE_ESC_REGEX, ""); - // Standalone String Terminator and leftover ESC - text = text.replace(C1_ST_REGEX, "").replace(new RegExp(ESC, "g"), ""); - - // Drop control chars except tab/newline/carriage return. - return Array.from(text) - .filter((char) => { - const code = char.codePointAt(0); - if (code === undefined) return false; - if (code === 0x09 || code === 0x0a || code === 0x0d) return true; - return !(code <= 0x1f || (code >= 0x7f && code <= 0x9f)); - }) - .join(""); -} - -/** Extract text content from a tool result. */ -function getTextOutput(result: AgentToolResult): string { - const textBlocks = result.content?.filter((c) => c.type === "text") || []; - return textBlocks - .map((c) => { - const text = "text" in c && c.text ? c.text : ""; - return sanitizeShellOutput(text).replace(/\r/g, ""); - }) - .join("\n") - .trim(); -} diff --git a/extensions/defaults/tools/find/index.ts b/extensions/defaults/tools/find/index.ts deleted file mode 100644 index d8946550..00000000 --- a/extensions/defaults/tools/find/index.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { relative, resolve } from "node:path"; -import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - ExtensionAPI, - FindToolDetails, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { keyHint } from "@mariozechner/pi-coding-agent"; -import { type Component, Spacer, Text } from "@mariozechner/pi-tui"; -import { Type } from "typebox"; -import { Tree, type TreeNode, type TreeTone } from "../../lib/tree"; - -const DEFAULT_LIMIT = 1000; -const COLLAPSED_RESULT_LIMIT = 40; - -const BLOCKED_PATHS = new Set([ - homedir(), - "/", - "/Users", - "/home", - "/tmp", - "/var", - "/etc", - "/opt", - "/usr", - "/System", - "/Library", - "/Applications", - "/Volumes", - "/nix", - "/snap", - "/proc", - "/sys", - "/dev", - "/run", - "/boot", - "/sbin", - "/bin", -]); - -interface HarnessFindDetails extends FindToolDetails { - relativeTo?: string; - totalResults?: number; - paths?: string[]; -} - -export default function (pi: ExtensionAPI): void { - const wrappedSchema = Type.Object({ - pattern: Type.String({ - description: "The pattern to search for (glob or regex)", - }), - path: Type.Optional( - Type.String({ - description: "The directory to search in (defaults to cwd)", - }), - ), - limit: Type.Optional( - Type.Number({ - description: `Maximum number of results (defaults to ${DEFAULT_LIMIT})`, - }), - ), - }); - - pi.registerTool({ - name: "find", - label: "Find Files", - description: `Find files by name using the \`fd\` command-line tool. Supports glob patterns and regex. Searches recursively from the specified path. Respects .gitignore. Results are truncated to ${DEFAULT_LIMIT} entries.`, - parameters: wrappedSchema, - promptGuidelines: [ - "Use find instead of shell find or fd when locating files in the project.", - "Prefer passing path explicitly instead of scanning broad roots.", - ], - async execute( - _toolCallId: string, - params: { - pattern: string; - path?: string; - limit?: number; - }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - ctx: { - cwd: string; - }, - ): Promise<{ - content: Array<{ type: "text"; text: string }>; - details: HarnessFindDetails; - }> { - const pattern = params.pattern; - const searchPath = params.path; - const limit = params.limit ?? DEFAULT_LIMIT; - - if (signal?.aborted) { - throw new Error("Search was aborted"); - } - - let resolvedPath = searchPath || "."; - if (resolvedPath === "~" || resolvedPath.startsWith("~/")) { - resolvedPath = resolvedPath.replace(/^~/, homedir()); - } - const absoluteSearchPath = resolve(ctx.cwd, resolvedPath); - - if (BLOCKED_PATHS.has(absoluteSearchPath)) { - throw new Error( - `Searching '${absoluteSearchPath}' is not allowed — too broad. Narrow the search to a specific project or subdirectory.`, - ); - } - - if (!existsSync(absoluteSearchPath)) { - throw new Error(`Path not found: ${absoluteSearchPath}`); - } - - const fdArgs = [ - "--glob", - "--color=never", - "--hidden", - "--max-results", - String(limit), - pattern, - absoluteSearchPath, - ]; - - const result = await pi.exec("fd", fdArgs, { - signal: signal ?? undefined, - cwd: ctx.cwd, - }); - - if (result.killed && signal?.aborted) { - throw new Error("Search was aborted"); - } - - if (result.code !== 0 && !result.stdout) { - throw new Error(result.stderr || "Unknown error"); - } - - const allResults = result.stdout - .trim() - .split("\n") - .filter((line) => line.trim()); - - if (allResults.length === 0) { - return { - content: [ - { - type: "text" as const, - text: "No files found matching the pattern.", - }, - ], - details: {}, - }; - } - - const results = allResults.map((absolutePath) => { - if (absolutePath.startsWith(absoluteSearchPath)) { - return absolutePath.slice(absoluteSearchPath.length + 1); - } - return absolutePath; - }); - - const wasTruncated = results.length >= limit; - - const details: HarnessFindDetails = { - resultLimitReached: wasTruncated ? results.length : undefined, - totalResults: results.length, - paths: results, - relativeTo: - searchPath && searchPath !== "." && searchPath !== "./" - ? relative(ctx.cwd, absoluteSearchPath) || "." - : undefined, - }; - - const outputText = results.join("\n"); - - return { - content: [{ type: "text", text: outputText }], - details, - }; - }, - - renderCall( - args: { - pattern: string; - path?: string; - limit?: number; - }, - theme: Theme, - ) { - return new ToolCallHeader( - { - toolName: "Find", - mainArg: args.pattern, - optionArgs: [ - ...(args.path ? [{ label: "in", value: args.path }] : []), - ...(args.limit - ? [{ label: "limit", value: String(args.limit) }] - : []), - ], - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const textContent = result.content[0]; - const output = ( - textContent?.type === "text" ? textContent.text : "" - ).trim(); - - if (!output || output === "No files found matching the pattern.") { - return new Text(theme.fg("muted", output || "No result"), 0, 0); - } - - const fields: Array< - { label: string; value: string; showCollapsed?: boolean } | Text - > = []; - - const spacer = new Spacer(1) as Component & { showCollapsed?: boolean }; - spacer.showCollapsed = true; - fields.push(spacer as unknown as Text); - - // Build tree from structured path data - const details = result.details; - const allPaths = details?.paths ?? []; - - // Collapse by slicing paths, not rendered lines - const maxPaths = options.expanded - ? allPaths.length - : COLLAPSED_RESULT_LIMIT; - const displayPaths = allPaths.slice(0, maxPaths); - const remainingPaths = allPaths.length - maxPaths; - - const tree = buildFindTree(displayPaths); - const treeComponent = new Tree(tree, { theme, dirSuffix: "/" }); - (treeComponent as Component & { showCollapsed?: boolean }).showCollapsed = - true; - fields.push(treeComponent as unknown as Text); - - const footerItems: Array<{ - label?: string; - value: string; - tone?: "muted" | "accent" | "success" | "warning" | "error"; - }> = []; - - if (remainingPaths > 0) { - footerItems.push({ - value: `${remainingPaths} more results, ${keyHint("app.tools.expand", "to expand")}`, - tone: "muted", - }); - } - if (details?.totalResults) { - footerItems.push({ - label: "results", - value: String(details.totalResults), - tone: "success", - }); - } - if (details?.resultLimitReached) { - footerItems.push({ - label: "limit", - value: String(details.resultLimitReached), - tone: "warning", - }); - } - - const footer = - footerItems.length > 0 - ? new ToolFooter(theme, { items: footerItems }) - : undefined; - - return new ToolBody({ fields, footer }, options, theme); - }, - }); -} - -/** - * Build a TreeNode tree from a flat list of relative file paths. - * Creates a trie from the path segments, then converts to TreeNode format. - * Directories are sorted before files, both alphabetically. - */ -function buildFindTree(paths: string[]): TreeNode[] { - const root: TrieNode = { name: "", children: new Map(), isFile: false }; - - for (const path of paths) { - const clean = path.replace(/^\.\/?/, ""); - if (!clean) continue; - const segments = clean.split("/"); - insertPath(root, segments); - } - - return trieToTreeNodes(root); -} - -interface TrieNode { - name: string; - children: Map; - isFile: boolean; -} - -function insertPath(root: TrieNode, segments: string[]): void { - let current = root; - for (const [i, segment] of segments.entries()) { - const isFile = i === segments.length - 1; - const existing = current.children.get(segment); - if (!existing) { - const child: TrieNode = { name: segment, children: new Map(), isFile }; - current.children.set(segment, child); - current = child; - } else { - if (isFile) existing.isFile = true; - current = existing; - } - } -} - -function trieToTreeNodes(node: TrieNode): TreeNode[] { - const children = [...node.children.values()].sort((a, b) => { - if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; - return a.name.localeCompare(b.name); - }); - - return children.map((child) => ({ - label: child.name, - tone: (child.isFile ? "toolOutput" : "accent") as TreeTone, - children: child.children.size > 0 ? trieToTreeNodes(child) : undefined, - })); -} diff --git a/extensions/defaults/tools/grep/index.ts b/extensions/defaults/tools/grep/index.ts deleted file mode 100644 index 0882c616..00000000 --- a/extensions/defaults/tools/grep/index.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { existsSync, lstatSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { relative, resolve } from "node:path"; -import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - ExtensionAPI, - GrepToolDetails, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { - DEFAULT_MAX_BYTES, - keyHint, - truncateLine, -} from "@mariozechner/pi-coding-agent"; -import { type Component, Spacer, Text } from "@mariozechner/pi-tui"; -import { Type } from "typebox"; -import { Tree, type TreeNode, type TreeTone } from "../../lib/tree"; - -const DEFAULT_LIMIT = 100; -const GREP_MAX_LINE_LENGTH = 500; -const COLLAPSED_MATCH_LIMIT = 30; - -const BLOCKED_PATHS = new Set([ - homedir(), - "/", - "/Users", - "/home", - "/tmp", - "/var", - "/etc", - "/opt", - "/usr", - "/System", - "/Library", - "/Applications", - "/Volumes", - "/nix", - "/snap", - "/proc", - "/sys", - "/dev", - "/run", - "/boot", - "/sbin", - "/bin", -]); - -interface RgMatch { - filePath: string; - lineNumber: number; -} - -interface GrepMatchData { - path: string; - line: number; - text: string; -} - -interface HarnessGrepDetails extends GrepToolDetails { - relativeTo?: string; - matchCount?: number; - matches?: GrepMatchData[]; -} - -export default function (pi: ExtensionAPI): void { - const wrappedSchema = Type.Object({ - pattern: Type.String({ - description: "Search pattern (regex or literal string)", - }), - path: Type.Optional( - Type.String({ - description: "Directory or file to search (default: current directory)", - }), - ), - glob: Type.Optional( - Type.String({ - description: - "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'", - }), - ), - ignoreCase: Type.Optional( - Type.Boolean({ - description: "Case-insensitive search (default: false)", - }), - ), - literal: Type.Optional( - Type.Boolean({ - description: - "Treat pattern as literal string instead of regex (default: false)", - }), - ), - context: Type.Optional( - Type.Number({ - description: - "Number of lines to show before and after each match (default: 0)", - }), - ), - limit: Type.Optional( - Type.Number({ - description: `Maximum number of matches to return (default: ${DEFAULT_LIMIT})`, - }), - ), - }); - - pi.registerTool({ - name: "grep", - label: "grep", - description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, - parameters: wrappedSchema, - promptGuidelines: [ - "Search file contents for patterns (respects .gitignore)", - ], - async execute( - _toolCallId: string, - params: { - pattern: string; - path?: string; - glob?: string; - ignoreCase?: boolean; - literal?: boolean; - context?: number; - limit?: number; - }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - ctx: { - cwd: string; - }, - ): Promise<{ - content: Array<{ type: "text"; text: string }>; - details: HarnessGrepDetails | undefined; - }> { - const { - pattern, - path: searchDir, - glob, - ignoreCase, - literal, - context, - limit, - } = params; - - if (signal?.aborted) { - throw new Error("Operation aborted"); - } - - let resolvedPath = searchDir || "."; - if (resolvedPath === "~" || resolvedPath.startsWith("~/")) { - resolvedPath = resolvedPath.replace(/^~/, homedir()); - } - const absoluteSearchPath = resolve(ctx.cwd, resolvedPath); - - if (BLOCKED_PATHS.has(absoluteSearchPath)) { - throw new Error( - `Searching '${absoluteSearchPath}' is not allowed — too broad. Narrow the search to a specific project or subdirectory.`, - ); - } - - if (!existsSync(absoluteSearchPath)) { - throw new Error(`Path not found: ${absoluteSearchPath}`); - } - - let isDirectory = false; - try { - isDirectory = lstatSync(absoluteSearchPath).isDirectory(); - } catch { - throw new Error(`Cannot stat path: ${absoluteSearchPath}`); - } - - const contextValue = context && context > 0 ? context : 0; - const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); - - const rgArgs = ["--json", "--line-number", "--color=never", "--hidden"]; - if (ignoreCase) rgArgs.push("--ignore-case"); - if (literal) rgArgs.push("--fixed-strings"); - if (glob) rgArgs.push("--glob", glob); - rgArgs.push(pattern, absoluteSearchPath); - - const result = await pi.exec("rg", rgArgs, { - signal: signal ?? undefined, - cwd: ctx.cwd, - }); - - if (result.killed && signal?.aborted) { - throw new Error("Operation aborted"); - } - - if (result.code !== 0 && result.code !== 1) { - throw new Error( - result.stderr || `ripgrep exited with code ${result.code}`, - ); - } - - // Parse rg JSON output to collect matches - const matches: RgMatch[] = []; - let matchCount = 0; - let matchLimitReached = false; - - for (const line of result.stdout.split("\n")) { - if (!line.trim()) continue; - let event: { - type: string; - data?: { path?: { text: string }; line_number?: number }; - }; - try { - event = JSON.parse(line); - } catch { - continue; - } - if (event.type === "match") { - matchCount++; - const filePath = event.data?.path?.text; - const lineNumber = event.data?.line_number; - if (filePath && typeof lineNumber === "number") { - if (matches.length < effectiveLimit) { - matches.push({ filePath, lineNumber }); - } - } - if (matchCount >= effectiveLimit) { - matchLimitReached = true; - } - } - } - - if (matches.length === 0) { - return { - content: [{ type: "text", text: "No matches found" }], - details: undefined, - }; - } - - // Format path relative to search directory - const formatPath = (filePath: string): string => { - if (isDirectory) { - const rel = filePath - .slice(absoluteSearchPath.length) - .replace(/^[/\\]/, ""); - if (rel) return rel.replace(/\\/g, "/"); - } - return filePath.replace(/\\/g, "/").split("/").pop() ?? filePath; - }; - - // Read match text from files - const fileCache = new Map(); - const getFileLines = (filePath: string): string[] => { - let lines = fileCache.get(filePath); - if (!lines) { - try { - const content = readFileSync(filePath, "utf-8"); - lines = content - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n") - .split("\n"); - } catch { - lines = []; - } - fileCache.set(filePath, lines); - } - return lines; - }; - - let linesTruncated = false; - const matchData: GrepMatchData[] = []; - - for (const match of matches) { - const relativePath = formatPath(match.filePath); - const lines = getFileLines(match.filePath); - - if (!lines.length) { - matchData.push({ - path: relativePath, - line: match.lineNumber, - text: "(unable to read file)", - }); - continue; - } - - const start = - contextValue > 0 - ? Math.max(1, match.lineNumber - contextValue) - : match.lineNumber; - const end = - contextValue > 0 - ? Math.min(lines.length, match.lineNumber + contextValue) - : match.lineNumber; - - for (let current = start; current <= end; current++) { - const lineText = (lines[current - 1] ?? "").replace(/\r/g, ""); - const isMatchLine = current === match.lineNumber; - const { text: truncatedText, wasTruncated } = truncateLine(lineText); - if (wasTruncated) linesTruncated = true; - - if (contextValue > 0 && !isMatchLine) { - matchData.push({ - path: relativePath, - line: current, - text: ` ${truncatedText.trim()}`, - }); - } else { - matchData.push({ - path: relativePath, - line: current, - text: truncatedText.trim(), - }); - } - } - } - - const details: HarnessGrepDetails = { - matchCount, - matches: matchData, - relativeTo: - isDirectory && searchDir && searchDir !== "." && searchDir !== "./" - ? relative(ctx.cwd, absoluteSearchPath) || "." - : undefined, - }; - if (matchLimitReached) details.matchLimitReached = effectiveLimit; - if (linesTruncated) details.linesTruncated = true; - - // Text content for LLM consumption (flat format) - const textContent = matchData - .map((m) => `${m.path}:${m.line}: ${m.text}`) - .join("\n"); - - return { - content: [{ type: "text", text: textContent }], - details, - }; - }, - - renderCall( - args: { - pattern: string; - path?: string; - glob?: string; - ignoreCase?: boolean; - literal?: boolean; - context?: number; - limit?: number; - }, - theme: Theme, - ) { - return new ToolCallHeader( - { - toolName: "Grep", - mainArg: args.literal - ? `\`${args.pattern}\`` - : `/${args.pattern || ""}/`, - optionArgs: [ - ...(args.path ? [{ label: "in", value: args.path }] : []), - ...(args.glob ? [{ label: "glob", value: args.glob }] : []), - ...(args.limit - ? [{ label: "limit", value: String(args.limit) }] - : []), - ...(args.ignoreCase - ? [{ label: "icase", value: "true", tone: "accent" as const }] - : []), - ...(args.literal ? [{ label: "literal", value: "true" }] : []), - ...(args.context - ? [{ label: "ctx", value: String(args.context) }] - : []), - ], - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const textContent = result.content[0]; - const output = ( - textContent?.type === "text" ? textContent.text : "" - ).trim(); - - if (!output || output === "No matches found") { - return new Text(theme.fg("muted", output || "No result"), 0, 0); - } - - const fields: Array< - { label: string; value: string; showCollapsed?: boolean } | Text - > = []; - - const spacer = new Spacer(1) as Component & { showCollapsed?: boolean }; - spacer.showCollapsed = true; - fields.push(spacer as unknown as Text); - - // Build tree from structured match data - const details = result.details; - const allMatches = details?.matches ?? []; - - // Collapse by slicing matches, not rendered lines - const maxMatches = options.expanded - ? allMatches.length - : COLLAPSED_MATCH_LIMIT; - const displayMatches = allMatches.slice(0, maxMatches); - const remainingMatches = allMatches.length - maxMatches; - - const tree = buildGrepTree(displayMatches); - const treeComponent = new Tree(tree, { theme, dirSuffix: ":" }); - (treeComponent as Component & { showCollapsed?: boolean }).showCollapsed = - true; - fields.push(treeComponent as unknown as Text); - - const footerItems: Array<{ - label?: string; - value: string; - tone?: "muted" | "accent" | "success" | "warning" | "error"; - }> = []; - - if (remainingMatches > 0) { - footerItems.push({ - value: `${remainingMatches} more matches, ${keyHint("app.tools.expand", "to expand")}`, - tone: "muted", - }); - } - if (details?.matchCount) { - footerItems.push({ - label: "matches", - value: String(details.matchCount), - tone: "success", - }); - } - if (details?.matchLimitReached) { - footerItems.push({ - label: "limit", - value: String(details.matchLimitReached), - tone: "warning", - }); - } - if (details?.linesTruncated) { - footerItems.push({ - value: "lines truncated", - tone: "warning", - }); - } - - const footer = - footerItems.length > 0 - ? new ToolFooter(theme, { items: footerItems }) - : undefined; - - return new ToolBody({ fields, footer }, options, theme); - }, - }); -} - -/** Group match data by file path and build a TreeNode tree. */ -function buildGrepTree(matches: GrepMatchData[]): TreeNode[] { - const byPath = new Map(); - for (const m of matches) { - let group = byPath.get(m.path); - if (!group) { - group = []; - byPath.set(m.path, group); - } - group.push(m); - } - - const roots: TreeNode[] = []; - const entries = [...byPath.entries()].sort(([a], [b]) => a.localeCompare(b)); - - for (const [path, fileMatches] of entries) { - roots.push({ - label: path, - tone: "accent", - children: fileMatches.map((m) => ({ - label: `${m.line}: ${m.text}`, - tone: "toolOutput" as TreeTone, - })), - }); - } - - return roots; -} diff --git a/extensions/editor/README.md b/extensions/editor/README.md deleted file mode 100644 index ea61bba7..00000000 --- a/extensions/editor/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# editor - -Editor UI extension for Pi. - -This extension owns `setEditorComponent` and renders editor border decorations from shared events. - -## Responsibilities - -- Install the custom editor component on `session_start` for startup, reload, new, resume, and fork flows -- Emit `ad:editor:ready` when the editor instance is created -- Emit `ad:editor:draft:changed` when draft text changes -- Parse native scroll markers from `super.render(...)` and publish right-side indicators -- Render top and bottom border lines from resolved decoration state -- Shell indicator: publishes `$` + `bashMode` band colors when draft starts with `!` or `!!` -- Editor stash: in-memory LIFO stack for editor text stashing (`ctrl+shift+s` / `ctrl+shift+r`) -- Palette registration: registers stash/unstash commands with the palette extension -- Commands: `/stash` and `/unstash` - -## Decoration model - -Decorations are published through `ad:editor:border-decoration:changed` with a `source` and `writes`. - -- Slot writes target a location: `top-start`, `top-end`, `bottom-start`, `bottom-end` -- Band writes target border color bands: `top` or `bottom` -- Resolution is last-writer-wins per target - -This keeps producers decoupled from layout. Extensions publish intent (text and color), while this extension owns rendering. - -## Producers in this repo - -- `modes`: publishes mode label and mode band colors -- `editor` shell indicator: publishes `$` + `bashMode` band colors when draft starts with `!` or `!!` -- `editor` itself: publishes scroll indicators (`↑ N more ───`, `↓ N more ───`) to end slots diff --git a/extensions/editor/components/editor.ts b/extensions/editor/components/editor.ts deleted file mode 100644 index 7a262c8d..00000000 --- a/extensions/editor/components/editor.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { - CustomEditor, - type Theme, - type ThemeColor, -} from "@mariozechner/pi-coding-agent"; -import type { - BorderBand, - BorderSlot, - ModeColor, -} from "../../../packages/events"; - -const ESC = "\u001b"; -const RESET = "\u001b[0m"; - -type BorderScroll = { - top?: number; - bottom?: number; -}; - -type ColorFn = (text: string) => string; - -export type SlotState = { - text: string; - color?: ModeColor; -}; - -export type ResolvedBorderDecorations = { - slots: Partial>; - bands: Partial>; -}; - -export class BorderEditor extends CustomEditor { - public appTheme?: Theme; - public getDecorations?: () => ResolvedBorderDecorations; - public onScrollIndicators?: (scroll: BorderScroll) => void; - public onDraftChanged?: (text: string) => void; - private lastDraftText = ""; - - override render(width: number): string[] { - const lines = super.render(width); - if (width < 1 || lines.length === 0) { - return lines; - } - - const draftText = this.getText(); - if (draftText !== this.lastDraftText) { - this.lastDraftText = draftText; - this.onDraftChanged?.(draftText); - } - - const decorations = this.getDecorations?.() ?? { slots: {}, bands: {} }; - const topPlain = stripAnsi(lines[0] ?? ""); - const topScroll = parseTopScrollBorder(topPlain); - - let bottomScroll: number | undefined; - for (let i = lines.length - 1; i >= 0; i -= 1) { - const plain = stripAnsi(lines[i] ?? ""); - if (!plain.startsWith("─")) { - continue; - } - - bottomScroll = parseBottomScrollBorder(plain); - lines[i] = this.buildBottomBorder(width, decorations); - break; - } - - lines[0] = this.buildTopBorder(width, decorations); - this.onScrollIndicators?.({ top: topScroll, bottom: bottomScroll }); - - return lines; - } - - requestRenderNow(): void { - this.tui.requestRender(); - } - - private buildTopBorder( - width: number, - decorations: ResolvedBorderDecorations, - ): string { - const topStart = decorations.slots["top-start"]; - const topEnd = decorations.slots["top-end"]; - - const left = topStart?.text ? `── ${topStart.text} ` : ""; - const right = topEnd?.text ?? ""; - - return buildBorderLine({ - width, - left, - right, - leftColor: this.resolveColor( - topStart?.color ?? decorations.bands.top, - (text) => this.resolveBandColor("top", text), - ), - rightColor: this.resolveColor( - topEnd?.color ?? decorations.bands.top, - (text) => this.resolveBandColor("top", text), - ), - frameColor: (text) => this.resolveBandColor("top", text), - }); - } - - private buildBottomBorder( - width: number, - decorations: ResolvedBorderDecorations, - ): string { - const bottomStart = decorations.slots["bottom-start"]; - const bottomEnd = decorations.slots["bottom-end"]; - - const right = bottomEnd?.text ?? ""; - - return buildBorderLine({ - width, - left: bottomStart?.text ?? "", - right, - leftColor: this.resolveColor( - bottomStart?.color ?? decorations.bands.bottom, - (text) => this.resolveBandColor("bottom", text), - ), - rightColor: this.resolveColor( - bottomEnd?.color ?? decorations.bands.bottom, - (text) => this.resolveBandColor("bottom", text), - ), - frameColor: (text) => this.resolveBandColor("bottom", text), - }); - } - - private resolveBandColor(band: BorderBand, text: string): string { - const decorations = this.getDecorations?.(); - const bandColor = decorations?.bands[band]; - if (!bandColor) { - return this.borderColor(text); - } - - return this.resolveColor(bandColor, (fallbackText) => - this.borderColor(fallbackText), - )(text); - } - - private resolveColor( - color: ModeColor | undefined, - fallback: ColorFn, - ): ColorFn { - if (!color) { - return fallback; - } - - if (color.source === "raw") { - const hex = color.color; - if (hex.startsWith("#") && (hex.length === 7 || hex.length === 4)) { - const r = Number.parseInt(hex.slice(1, 3), 16); - const g = Number.parseInt(hex.slice(3, 5), 16); - const b = Number.parseInt(hex.slice(5, 7), 16); - const prefix = `${ESC}[38;2;${r};${g};${b}m`; - return (text: string) => `${prefix}${text}${RESET}`; - } - return (text: string) => `${hex}${text}${RESET}`; - } - - return (text: string) => - this.appTheme?.fg(color.color as ThemeColor, text) ?? fallback(text); - } -} - -function buildBorderLine(options: { - width: number; - left: string; - right: string; - leftColor: ColorFn; - rightColor: ColorFn; - frameColor: ColorFn; -}): string { - const width = options.width; - if (width <= 0) { - return ""; - } - - const right = trimStartToWidth(options.right, width); - const maxLeft = Math.max(0, width - right.length); - const left = trimEndToWidth(options.left, maxLeft); - const fill = "─".repeat(Math.max(0, width - left.length - right.length)); - - const parts: string[] = []; - if (left.length > 0) { - parts.push(options.leftColor(left)); - } - if (fill.length > 0) { - parts.push(options.frameColor(fill)); - } - if (right.length > 0) { - parts.push(options.rightColor(right)); - } - - return parts.join(""); -} - -function trimEndToWidth(value: string, width: number): string { - if (width <= 0) { - return ""; - } - - return value.length > width ? value.slice(0, width) : value; -} - -function trimStartToWidth(value: string, width: number): string { - if (width <= 0) { - return ""; - } - - return value.length > width ? value.slice(value.length - width) : value; -} - -function parseTopScrollBorder(line: string): number | undefined { - const match = line.match(/↑ (\d+) more/); - if (!match) { - return undefined; - } - - return Number.parseInt(match[1] ?? "0", 10); -} - -function parseBottomScrollBorder(line: string): number | undefined { - const match = line.match(/↓ (\d+) more/); - if (!match) { - return undefined; - } - - return Number.parseInt(match[1] ?? "0", 10); -} - -function stripAnsi(value: string): string { - let result = ""; - - for (let i = 0; i < value.length; i += 1) { - const char = value[i]; - if (char !== ESC) { - result += char; - continue; - } - - if (value[i + 1] !== "[") { - continue; - } - - i += 2; - while (i < value.length && value[i] !== "m") { - i += 1; - } - } - - return result; -} diff --git a/extensions/editor/hooks/editor-stash.ts b/extensions/editor/hooks/editor-stash.ts deleted file mode 100644 index 25e22fc1..00000000 --- a/extensions/editor/hooks/editor-stash.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { - AD_EDITOR_STASH_CHANGED_EVENT, - AD_EDITOR_STASH_READY_EVENT, - AD_EDITOR_STASH_REQUEST_EVENT, -} from "../../../packages/events"; -import { stashCount, stashPop, stashPush } from "../lib/stash"; - -function emitStashState(pi: ExtensionAPI): void { - pi.events.emit(AD_EDITOR_STASH_CHANGED_EVENT, { - count: stashCount(), - }); -} - -export function setupEditorStashHook(pi: ExtensionAPI) { - // Respond to stash state requests (e.g. from footer on setup) - pi.events.on(AD_EDITOR_STASH_REQUEST_EVENT, () => { - emitStashState(pi); - }); - - // Signal readiness so consumers can request initial state - pi.events.emit(AD_EDITOR_STASH_READY_EVENT, {}); - - pi.registerShortcut("ctrl+shift+s", { - description: "Stash editor content", - handler: async (ctx) => { - const text = ctx.ui.getEditorText(); - if (!text) return; - stashPush(text); - ctx.ui.setEditorText(""); - emitStashState(pi); - }, - }); - - pi.registerShortcut("ctrl+shift+r", { - description: "Pop stashed editor content (swaps if editor has content)", - handler: async (ctx) => { - const popped = stashPop(); - if (popped === undefined) return; - const current = ctx.ui.getEditorText(); - if (current) { - stashPush(current); - } - ctx.ui.setEditorText(popped); - emitStashState(pi); - }, - }); -} diff --git a/extensions/editor/hooks/editor.ts b/extensions/editor/hooks/editor.ts deleted file mode 100644 index f4b75b2c..00000000 --- a/extensions/editor/hooks/editor.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { - AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, - AD_EDITOR_DRAFT_CHANGED_EVENT, - AD_EDITOR_READY_EVENT, - AD_EDITOR_STASH_CHANGED_EVENT, - type AdEditorBorderDecorationChangedEvent, - type BorderBand, - type BorderSlot, - type EditorBorderWrite, - type ModeColor, -} from "../../../packages/events"; -import { - BorderEditor, - type ResolvedBorderDecorations, - type SlotState, -} from "../components/editor"; -import { stashCount } from "../lib/stash"; - -type SourceState = { - seq: number; - writes: EditorBorderWrite[]; -}; - -let activeEditor: ReturnType | undefined; - -const STASH_WIDGET_ID = "editor:stash"; - -type BorderScroll = { - top?: number; - bottom?: number; -}; - -function updateStashWidget(ctx: ExtensionContext, scroll: BorderScroll): void { - const count = stashCount(); - const hasOverflow = (scroll.top ?? 0) > 0 || (scroll.bottom ?? 0) > 0; - - const text = - count > 0 - ? "ctrl+shift+r to unstash" - : hasOverflow - ? "ctrl+shift+s to stash" - : undefined; - - if (!text) { - ctx.ui.setWidget(STASH_WIDGET_ID, undefined); - return; - } - - ctx.ui.setWidget( - STASH_WIDGET_ID, - (_tui, theme) => ({ - render(width: number) { - const dimmed = theme.fg("dim", text); - const padding = Math.max(0, width - text.length); - return [" ".repeat(padding) + dimmed]; - }, - handleInput() {}, - invalidate() {}, - }), - { placement: "aboveEditor" }, - ); -} - -export function createEditorRuntime(pi: ExtensionAPI) { - let editorRef: BorderEditor | undefined; - const sourceStates = new Map(); - let sequence = 0; - let lastScrollTop: number | undefined; - let lastScrollBottom: number | undefined; - - const resolveDecorations = (): ResolvedBorderDecorations => { - const entries = [...sourceStates.values()].sort((a, b) => a.seq - b.seq); - - const slots: Partial> = {}; - const bands: Partial> = {}; - - for (const entry of entries) { - for (const write of entry.writes) { - if (write.kind === "slot") { - slots[write.slot] = { - text: write.text, - color: write.color, - }; - continue; - } - - bands[write.band] = { color: write.color }; - } - } - - return { - slots, - bands: { - top: bands.top?.color, - bottom: bands.bottom?.color, - }, - }; - }; - - const emitScrollWrites = (top?: number, bottom?: number) => { - if (top === lastScrollTop && bottom === lastScrollBottom) { - return; - } - - lastScrollTop = top; - lastScrollBottom = bottom; - - const writes: EditorBorderWrite[] = []; - - if (typeof top === "number") { - writes.push({ - kind: "slot", - slot: "top-end", - text: `↑ ${top} more ───`, - }); - } - - if (typeof bottom === "number") { - writes.push({ - kind: "slot", - slot: "bottom-end", - text: `↓ ${bottom} more ───`, - }); - } - - pi.events.emit(AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, { - source: "editor:scroll", - writes, - } satisfies AdEditorBorderDecorationChangedEvent); - }; - - pi.events.on(AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, (data: unknown) => { - const event = (data ?? {}) as Partial; - if (typeof event.source !== "string" || !Array.isArray(event.writes)) { - return; - } - - sourceStates.set(event.source, { - seq: ++sequence, - writes: event.writes, - }); - - editorRef?.requestRenderNow(); - }); - - // Re-render editor when stash state changes (e.g. after shortcut). - // This triggers onScrollIndicators which calls updateStashWidget. - pi.events.on(AD_EDITOR_STASH_CHANGED_EVENT, () => { - editorRef?.requestRenderNow(); - }); - - return { - setup: (ctx: ExtensionContext) => { - if (!ctx.hasUI) { - return; - } - - ctx.ui.setEditorComponent((tui, theme, keybindings) => { - const editor = new BorderEditor(tui, theme, keybindings); - editor.appTheme = ctx.ui.theme; - editor.getDecorations = resolveDecorations; - editor.onDraftChanged = (text: string) => { - pi.events.emit(AD_EDITOR_DRAFT_CHANGED_EVENT, { text }); - }; - editor.onScrollIndicators = (scroll) => { - // Only act if this editor is still the active instance. - // After session_shutdown, cleanup() nulls editorRef — callbacks - // from the old TUI component must become no-ops. - if (editorRef !== editor) return; - emitScrollWrites(scroll.top, scroll.bottom); - updateStashWidget(ctx, scroll); - }; - - editorRef = editor; - pi.events.emit(AD_EDITOR_READY_EVENT, {}); - pi.events.emit(AD_EDITOR_DRAFT_CHANGED_EVENT, { - text: editor.getText(), - }); - - return editor; - }); - }, - cleanup: () => { - editorRef = undefined; - sourceStates.clear(); - lastScrollTop = undefined; - lastScrollBottom = undefined; - sequence = 0; - }, - }; -} - -export function setupEditorHook(pi: ExtensionAPI) { - const runtime = createEditorRuntime(pi); - activeEditor = runtime; - - pi.on("session_start", async (_event, ctx) => { - _event.reason; - runtime.setup(ctx); - }); - - pi.on("session_shutdown", async () => { - runtime.cleanup(); - }); -} - -export function restoreDefaultEditor(ctx: ExtensionContext): void { - if (!ctx.hasUI) { - return; - } - - activeEditor?.setup(ctx); -} diff --git a/extensions/editor/hooks/index.ts b/extensions/editor/hooks/index.ts deleted file mode 100644 index 8c81ffd1..00000000 --- a/extensions/editor/hooks/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { setupEditorHook } from "./editor"; -import { setupEditorStashHook } from "./editor-stash"; -import { setupShellIndicatorHook } from "./shell-indicator"; - -export function setupHooks(pi: ExtensionAPI) { - setupEditorHook(pi); - setupEditorStashHook(pi); - setupShellIndicatorHook(pi); -} diff --git a/extensions/editor/hooks/shell-indicator.ts b/extensions/editor/hooks/shell-indicator.ts deleted file mode 100644 index 04a8a080..00000000 --- a/extensions/editor/hooks/shell-indicator.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { - AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, - AD_EDITOR_DRAFT_CHANGED_EVENT, - AD_EDITOR_READY_EVENT, - type AdEditorBorderDecorationChangedEvent, - type AdEditorDraftChangedEvent, - type EditorBorderWrite, -} from "../../../packages/events"; - -const SOURCE = "editor:shell-indicator"; - -function isShellDraft(text: string): boolean { - const trimmed = text.trimStart(); - return trimmed.startsWith("!!") || trimmed.startsWith("!"); -} - -function writesForText(text: string): EditorBorderWrite[] { - if (!isShellDraft(text)) { - return []; - } - - const shellColor = { source: "theme", color: "bashMode" } as const; - - return [ - { - kind: "slot", - slot: "top-start", - text: "$", - }, - { - kind: "band", - band: "top", - color: shellColor, - }, - { - kind: "band", - band: "bottom", - color: shellColor, - }, - ]; -} - -export function setupShellIndicatorHook(pi: ExtensionAPI) { - let lastText = ""; - - const publish = () => { - pi.events.emit(AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, { - source: SOURCE, - writes: writesForText(lastText), - } satisfies AdEditorBorderDecorationChangedEvent); - }; - - pi.events.on(AD_EDITOR_DRAFT_CHANGED_EVENT, (data: unknown) => { - const event = (data ?? {}) as Partial; - if (typeof event.text !== "string") { - return; - } - - lastText = event.text; - publish(); - }); - - pi.events.on(AD_EDITOR_READY_EVENT, () => { - publish(); - }); -} diff --git a/extensions/editor/index.ts b/extensions/editor/index.ts deleted file mode 100644 index 816b013b..00000000 --- a/extensions/editor/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { setupHooks } from "./hooks"; - -export default async function (pi: ExtensionAPI) { - setupHooks(pi); -} diff --git a/extensions/editor/lib/stash.ts b/extensions/editor/lib/stash.ts deleted file mode 100644 index 24a12ea9..00000000 --- a/extensions/editor/lib/stash.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * In-memory LIFO stack for editor text stashing. - * Ephemeral: resets on restart. - */ - -const stack: string[] = []; - -export function stashPush(text: string): void { - stack.push(text); -} - -export function stashPop(): string | undefined { - return stack.pop(); -} - -export function stashCount(): number { - return stack.length; -} diff --git a/extensions/introspection/index.ts b/extensions/introspection/index.ts deleted file mode 100644 index 4da227cd..00000000 --- a/extensions/introspection/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { registerIntrospectCommand } from "./commands/introspect"; - -export default function (pi: ExtensionAPI) { - registerIntrospectCommand(pi); -} diff --git a/extensions/modes/AGENTS.md b/extensions/modes/AGENTS.md deleted file mode 100644 index 92ee84a6..00000000 --- a/extensions/modes/AGENTS.md +++ /dev/null @@ -1,38 +0,0 @@ -# modes - -Hardcoded mode system with prompt families, tool policy, and model switching. - -## Modes - -- `balanced` (default): all tools, no model override, no gating. -- `research`: read-only + research tools, `bash` gated, Claude Opus, high thinking. - -## Prompt families - -- `claude`, `openai-codex`, `kimi`, `glm` -- resolved from model provider/ID. -- Mode system prompt replaces the family prompt (not append). -- Requires `` marker in `~/.pi/agent/APPEND_SYSTEM.md`. - -## Controls - -- `/mode`, `/mode `, `Ctrl+U` cycle, `--agent-mode ` -- `switch_mode` tool with explicit in-tool confirmation - -## Tool policy - -Each mode defines `allowedTools` (enabled freely) and `gatedTools` (enabled but require confirmation per call). When both arrays are empty, all tools are available (balanced mode). - -The `tool_call` hook enforces gating at runtime. `gatedTools` and `allowedTools` are assumed disjoint. - -## Branch persistence - -Mode is persisted to the session branch via `mode-state` entries. On restore, the last `mode-state` entry determines the mode. - -User-initiated switches (Ctrl+U, /mode) defer persistence to the next turn boundary (`before_agent_start`). Agent-initiated switches (switch_mode tool) persist immediately. This avoids accumulating entries during rapid mode cycling. - -## Notes - -- No config file and no `enabled` toggle by design. -- Balanced mode has no model/thinking override -- Pi's default selection applies. -- Research mode sets `anthropic / claude-opus-4-6` with high thinking on switch. -- Border colors are raw hex (editor extension converts to ANSI RGB). diff --git a/extensions/modes/README.md b/extensions/modes/README.md deleted file mode 100644 index 126d6f12..00000000 --- a/extensions/modes/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# modes - -Two-mode system for Pi with prompt families, tool policy, and per-branch restore. - -## Modes - -- `balanced` (default) - - All tools enabled, no gating - - No model override (uses Pi's default) - - Label color: `#777777` - -- `research` - - Allowed: `read`, `ls`, `find`, `grep`, `get_current_time`, `read_url`, `find_sessions`, `list_sessions`, `read_session`, `ask_user`, `synthetic_web_search`, `linkup_web_search`, `linkup_web_answer`, `linkup_web_fetch`, `scout`, `lookout`, `oracle`, `reviewer`, `switch_mode` - - Gated: `bash` (requires confirmation per call) - - Provider/model: `anthropic / claude-opus-4-6` - - Thinking: `high` - - Label color: `#5f8faf` - -## Prompt families - -Model-family-aware system prompts that tune behavioral patterns per model family. Resolved from the active model's provider and ID. - -- `claude` - Light touch for Claude models (good instruction following) -- `openai-codex` - Explicit structure and guardrails for GPT-5.x -- `kimi` - Aggressive concision steering for Kimi K2.5 -- `glm` - Structured guidance for GLM-5/GLM-4.7 - -Mode system prompt replaces the family prompt when a mode is active. Family prompts serve as fallback when no mode system prompt exists. - -Resolution order: -1. Provider `openai-codex` or `openai` -> `openai-codex` -2. Provider `anthropic` -> `claude` -3. Model ID containing `kimi` -> `kimi` -4. Model ID containing `glm` -> `glm` -5. Fallback -> `claude` - -Requires `` marker in `~/.pi/agent/APPEND_SYSTEM.md`. The `system-md-check` hook prompts to create it on first run if missing. - -## Controls - -- `/mode` opens selector -- `/mode ` switches directly -- `switch_mode` tool switches between modes with explicit in-tool confirmation -- `Ctrl+U` cycles modes -- `--agent-mode ` sets startup mode - -## Behavior - -- Tool access is list-based: `allowedTools` are enabled freely, `gatedTools` require confirmation per call. -- Empty arrays mean all tools are available (balanced mode). -- `pi.setActiveTools()` activates tools from the lists. -- `tool_call` hook enforces gating for `gatedTools` at runtime. -- Mode switch sets model, thinking level, active tools, and system prompt. -- Mode state persisted with `appendEntry("mode-state", ...)`. -- Restores mode per branch using `sessionManager.getBranch()`. -- User-initiated switches defer persistence to next turn boundary. -- Agent-initiated switches (switch_mode tool) persist immediately. -- Sends UI-visible custom `mode-switch` messages. -- Filters `mode-switch` messages out of LLM context via `context` hook. - -## Event compatibility pattern - -For cross-extension notification and sound interoperability, emit this event shape: - -```ts -pi.events.emit("ad:notify:dangerous", { - command: string, - description: string, - pattern: string, - toolName?: string, - toolCallId?: string, -}); -``` - -`defaults` listens for this event, plays the attention sound, and uses `toolName`/`toolCallId` (when present) to keep terminal-title attention aligned with the exact triggering tool call. diff --git a/extensions/modes/commands/mode-command.ts b/extensions/modes/commands/mode-command.ts deleted file mode 100644 index 3a05d3ff..00000000 --- a/extensions/modes/commands/mode-command.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { Key } from "@mariozechner/pi-tui"; -import { showModeSelector } from "../components/mode-selector"; -import type { ApplyModeOptions } from "../lib/mode-lifecycle"; -import { DEFAULT_MODE, MODE_ORDER, MODES } from "../modes"; -import { getCurrentMode } from "../state"; - -export type ApplyModeFn = ( - pi: ExtensionAPI, - ctx: ExtensionContext, - modeName: string, - options?: ApplyModeOptions, -) => Promise; - -export function registerModeControls( - pi: ExtensionAPI, - applyMode: ApplyModeFn, -): void { - pi.registerCommand("mode", { - description: "Switch mode (balanced, research)", - handler: async (args, ctx) => { - const requested = args?.trim(); - - if (requested) { - if (!MODES[requested]) { - ctx.ui.notify( - `Unknown mode. Available: ${MODE_ORDER.join(", ")}`, - "error", - ); - return; - } - - await applyMode(pi, ctx, requested, { persist: false }); - return; - } - - const selected = await showModeSelector(ctx); - if (!selected) return; - await applyMode(pi, ctx, selected, { persist: false }); - }, - }); - - pi.registerShortcut(Key.ctrl("u"), { - description: "Cycle modes", - handler: async (ctx) => { - const current = getCurrentMode().name; - const index = MODE_ORDER.indexOf(current); - const nextIndex = index === -1 ? 0 : (index + 1) % MODE_ORDER.length; - const nextMode = MODE_ORDER[nextIndex] ?? DEFAULT_MODE.name; - await applyMode(pi, ctx, nextMode, { persist: false }); - }, - }); -} diff --git a/extensions/modes/components/mode-confirm.ts b/extensions/modes/components/mode-confirm.ts deleted file mode 100644 index 8137725a..00000000 --- a/extensions/modes/components/mode-confirm.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { DynamicBorder } from "@mariozechner/pi-coding-agent"; -import { - Container, - Key, - matchesKey, - Spacer, - Text, - wrapTextWithAnsi, -} from "@mariozechner/pi-tui"; - -export type ConfirmResult = "allow" | "allow-session" | "deny"; - -export async function showModeConfirmDialog( - ctx: ExtensionContext, - modeName: string, - toolName: string, - bashCommand?: string, - allowSession = true, - reasonText?: string, -): Promise { - const result = await ctx.ui.custom( - (_tui, theme, _kb, done) => { - const container = new Container(); - const redBorder = (s: string) => theme.fg("error", s); - - container.addChild(new DynamicBorder(redBorder)); - container.addChild( - new Text(theme.fg("error", theme.bold("Tool Not in Allowlist")), 1, 0), - ); - container.addChild(new Spacer(1)); - const message = - reasonText ?? - `The tool ${toolName} is not in the allowlist for ${modeName} mode.`; - - container.addChild(new Text(theme.fg("warning", message), 1, 0)); - - let commandText: Text | undefined; - if (bashCommand) { - container.addChild(new Spacer(1)); - container.addChild( - new DynamicBorder((s: string) => theme.fg("muted", s)), - ); - commandText = new Text("", 1, 0); - container.addChild(commandText); - container.addChild( - new DynamicBorder((s: string) => theme.fg("muted", s)), - ); - } - - container.addChild(new Spacer(1)); - container.addChild( - new Text( - theme.fg( - "dim", - allowSession - ? "y/enter: allow | a: allow for session | n/esc: deny" - : "y/enter: allow | n/esc: deny", - ), - 1, - 0, - ), - ); - container.addChild(new DynamicBorder(redBorder)); - - return { - render: (width: number) => { - if (commandText && bashCommand) { - commandText.setText( - wrapTextWithAnsi(theme.fg("text", bashCommand), width - 4).join( - "\n", - ), - ); - } - return container.render(width); - }, - invalidate: () => container.invalidate(), - handleInput: (data: string) => { - if (matchesKey(data, Key.enter) || data === "y" || data === "Y") { - done("allow"); - return; - } - if (allowSession && (data === "a" || data === "A")) { - done("allow-session"); - return; - } - if (matchesKey(data, Key.escape) || data === "n" || data === "N") { - done("deny"); - } - }, - }; - }, - ); - - if (result === undefined) return "deny"; - return result; -} diff --git a/extensions/modes/components/mode-selector.ts b/extensions/modes/components/mode-selector.ts deleted file mode 100644 index 6f63d894..00000000 --- a/extensions/modes/components/mode-selector.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - DynamicBorder, - type ExtensionContext, - getSelectListTheme, -} from "@mariozechner/pi-coding-agent"; -import { - Container, - type SelectItem, - SelectList, - Spacer, - Text, -} from "@mariozechner/pi-tui"; -import { MODE_ORDER, MODES } from "../modes"; -import { getCurrentMode } from "../state"; - -export async function showModeSelector( - ctx: ExtensionContext, -): Promise { - const current = getCurrentMode().name; - - if (!ctx.hasUI) { - const labels = MODE_ORDER.map((name) => - name === current ? `- ${name} (active)` : `- ${name}`, - ); - console.log(`Available modes:\n${labels.join("\n")}`); - return null; - } - - const items: SelectItem[] = MODE_ORDER.map((name) => { - const mode = MODES[name]; - return { - value: name, - label: name === current ? `${name} (active)` : name, - description: mode?.description ?? name, - }; - }); - - const selected = await ctx.ui.custom( - (tui, theme, _kb, done) => { - const container = new Container(); - container.addChild( - new DynamicBorder((s: string) => theme.fg("accent", s)), - ); - container.addChild( - new Text(theme.fg("accent", theme.bold("Select Mode")), 1, 0), - ); - container.addChild(new Spacer(1)); - - const list = new SelectList( - items, - Math.min(items.length, 8), - getSelectListTheme(), - ); - list.onSelect = (item) => done(item.value); - list.onCancel = () => done(null); - - const selectedIndex = Math.max(0, MODE_ORDER.indexOf(current)); - list.setSelectedIndex(selectedIndex); - - container.addChild(list); - container.addChild(new Spacer(1)); - container.addChild( - new Text( - theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), - 1, - 0, - ), - ); - container.addChild( - new DynamicBorder((s: string) => theme.fg("accent", s)), - ); - - return { - render: (width: number) => container.render(width), - invalidate: () => container.invalidate(), - handleInput: (data: string) => { - list.handleInput(data); - tui.requestRender(); - }, - }; - }, - ); - - if (selected === undefined) { - const choice = await ctx.ui.select("Select mode", MODE_ORDER); - return choice ?? null; - } - - if (!selected) return null; - return MODES[selected] ? selected : null; -} diff --git a/extensions/modes/hooks/context-filter.ts b/extensions/modes/hooks/context-filter.ts deleted file mode 100644 index a627e826..00000000 --- a/extensions/modes/hooks/context-filter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - -export function setupContextFilterHook(pi: ExtensionAPI): void { - pi.on("context", async (event) => { - const messages = event.messages.filter((message) => { - const maybeCustom = message as { customType?: unknown }; - return maybeCustom.customType !== "mode-switch"; - }); - - return { messages }; - }); -} diff --git a/extensions/modes/hooks/index.ts b/extensions/modes/hooks/index.ts deleted file mode 100644 index 276292e2..00000000 --- a/extensions/modes/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { setupContextFilterHook } from "./context-filter"; -export { setupSessionSyncHooks } from "./session-sync"; -export { setupSystemPromptHook } from "./system-prompt"; -export { setupToolGateHook } from "./tool-gate"; diff --git a/extensions/modes/hooks/session-sync.ts b/extensions/modes/hooks/session-sync.ts deleted file mode 100644 index d582ac0f..00000000 --- a/extensions/modes/hooks/session-sync.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { - flushPendingModeState, - restoreModeForSession, -} from "../lib/mode-lifecycle"; - -export function setupSessionSyncHooks(pi: ExtensionAPI): void { - // Flush deferred mode-state at turn boundaries. - pi.on("before_agent_start", () => { - flushPendingModeState(pi); - }); - - pi.on("session_start", async (event, ctx) => { - const reason = (event as { reason?: string }).reason; - await restoreModeForSession(pi, ctx, reason === "startup", reason); - }); -} diff --git a/extensions/modes/hooks/system-prompt.ts b/extensions/modes/hooks/system-prompt.ts deleted file mode 100644 index 53d792c4..00000000 --- a/extensions/modes/hooks/system-prompt.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { - DEFAULT_FAMILY, - getPromptForFamily, - PROMPT_FAMILY_MARKER, - resolvePromptFamily, -} from "../lib/prompt-families"; -import { getCurrentMode } from "../state"; - -/** - * Extract the "Available tools:" through end of "Guidelines:" sections - * from pi's base prompt. Returns empty string if markers not found. - */ -function extractToolsAndGuidelines(aboveMarker: string): string { - const toolsStart = aboveMarker.indexOf("\nAvailable tools:\n"); - if (toolsStart === -1) return ""; - - const piDocsStart = aboveMarker.indexOf("\nPi documentation"); - const end = piDocsStart !== -1 ? piDocsStart : aboveMarker.length; - - return aboveMarker.slice(toolsStart, end).trim(); -} - -/** Tracks providers we've already warned about for default-family fallback. */ -const warnedProviders = new Set(); - -export function setupSystemPromptHook(pi: ExtensionAPI): void { - pi.on("before_agent_start", async (event, ctx) => { - const mode = getCurrentMode(); - - // Not into a cat and mouse game, so just append the mode system prompt to the default - // system prompt for ant models. This triggers the filter on ant's side and forces the use - // of the extra usage thingy. - if (ctx.model?.provider === "anthropic") { - if (!mode.systemPrompt) return; - return { - systemPrompt: `${event.systemPrompt}\n\n${mode.systemPrompt}`, - }; - } - - // If marker not found, skip family replacement -- just append mode prompt. - if (!event.systemPrompt.includes(PROMPT_FAMILY_MARKER)) { - if (!mode.systemPrompt) return; - return { - systemPrompt: `${event.systemPrompt}\n\n${mode.systemPrompt}`, - }; - } - - // Resolve family from current model - const { family, isDefault } = resolvePromptFamily( - ctx.model?.provider, - ctx.model?.id, - ); - - if ( - isDefault && - ctx.model?.provider && - !warnedProviders.has(ctx.model.provider) - ) { - warnedProviders.add(ctx.model.provider); - ctx.ui.notify( - `No prompt family for ${ctx.model.provider}, using ${DEFAULT_FAMILY}`, - "warning", - ); - } - - // Split at marker - const markerIdx = event.systemPrompt.indexOf(PROMPT_FAMILY_MARKER); - const aboveMarker = event.systemPrompt.slice(0, markerIdx); - const belowMarker = event.systemPrompt.slice( - markerIdx + PROMPT_FAMILY_MARKER.length, - ); - - // Extract tools + guidelines from pi's base prompt - const toolsAndGuidelines = extractToolsAndGuidelines(aboveMarker); - - // Mode prompt replaces family prompt. Family prompt is fallback. - const basePrompt = mode.systemPrompt - ? mode.systemPrompt - : getPromptForFamily(family); - - const parts = [basePrompt]; - if (toolsAndGuidelines) { - parts.push(toolsAndGuidelines); - } - parts.push(belowMarker.trimStart()); - - const systemPrompt = parts.join("\n\n"); - - return { systemPrompt }; - }); -} diff --git a/extensions/modes/hooks/tool-gate.ts b/extensions/modes/hooks/tool-gate.ts deleted file mode 100644 index d70bd696..00000000 --- a/extensions/modes/hooks/tool-gate.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { AD_NOTIFY_DANGEROUS_EVENT } from "../../../packages/events"; - -import { showModeConfirmDialog } from "../components/mode-confirm"; -import { - addSessionAllowedTool, - getCurrentMode, - getSessionAllowedTools, -} from "../state"; - -function getBashCommand(input: unknown): string { - if (!input || typeof input !== "object") return ""; - const value = (input as { command?: unknown }).command; - return typeof value === "string" ? value : ""; -} - -function emitDangerousEvent( - pi: ExtensionAPI, - description: string, - command = "", - toolName?: string, - toolCallId?: string, -): void { - pi.events.emit(AD_NOTIFY_DANGEROUS_EVENT, { - source: "modes:tool-gate", - command, - description, - pattern: "(mode-gate)", - toolName, - toolCallId, - }); -} - -export function setupToolGateHook(pi: ExtensionAPI): void { - pi.on("tool_call", async (event, ctx) => { - const mode = getCurrentMode(); - - // gatedTools and allowedTools are assumed disjoint. - if (!mode.gatedTools.includes(event.toolName)) { - return; - } - - const sessionAllowed = getSessionAllowedTools(); - if (sessionAllowed.has(event.toolName)) { - return; - } - - const bashCommand = - event.toolName === "bash" ? getBashCommand(event.input) : undefined; - - if (!ctx.hasUI) { - return { - block: true, - reason: `${mode.name} mode: ${event.toolName} requires confirmation (no UI to confirm)`, - }; - } - - emitDangerousEvent( - pi, - `Confirmation required by ${mode.name} mode: ${event.toolName}`, - bashCommand ?? event.toolName, - event.toolName, - event.toolCallId, - ); - - const decision = await showModeConfirmDialog( - ctx, - mode.name, - event.toolName, - bashCommand, - true, - ); - - if (decision === "allow") { - return; - } - - if (decision === "allow-session") { - addSessionAllowedTool(event.toolName); - return; - } - - return { block: true, reason: "Blocked by user" }; - }); -} diff --git a/extensions/modes/index.ts b/extensions/modes/index.ts deleted file mode 100644 index fc0d8ed5..00000000 --- a/extensions/modes/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { - AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, - AD_EDITOR_READY_EVENT, - AD_MODES_READY_EVENT, -} from "../../packages/events"; -import { registerModeControls } from "./commands/mode-command"; -import { - setupContextFilterHook, - setupSessionSyncHooks, - setupSystemPromptHook, - setupToolGateHook, -} from "./hooks"; -import { applyMode } from "./lib/mode-lifecycle"; -import { registerModeSwitchRenderer } from "./lib/mode-switch"; -import { setupAppendSystemMdCheck } from "./lib/system-md-check"; -import { getCurrentMode } from "./state"; -import { setupSwitchModeTool } from "./tools/switch-mode"; - -export default async function (pi: ExtensionAPI): Promise { - pi.registerFlag("agent-mode", { - description: "Starting modes extension mode", - type: "string", - }); - - setupToolGateHook(pi); - setupContextFilterHook(pi); - setupSessionSyncHooks(pi); - setupSystemPromptHook(pi); - setupAppendSystemMdCheck(pi); - - const emitCurrentMode = () => { - const mode = getCurrentMode(); - pi.events.emit(AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, { - source: "modes", - writes: [ - { - kind: "slot", - slot: "top-start", - text: mode.label, - }, - { - kind: "band", - band: "top", - color: mode.labelColor, - }, - { - kind: "band", - band: "bottom", - color: mode.labelColor, - }, - ], - }); - }; - - pi.events.on(AD_EDITOR_READY_EVENT, () => { - emitCurrentMode(); - }); - - registerModeControls(pi, applyMode); - setupSwitchModeTool(pi, applyMode); - registerModeSwitchRenderer(pi); - - emitCurrentMode(); - pi.events.emit(AD_MODES_READY_EVENT, {}); -} diff --git a/extensions/modes/lib/mode-lifecycle.test.ts b/extensions/modes/lib/mode-lifecycle.test.ts deleted file mode 100644 index 0e33bc1c..00000000 --- a/extensions/modes/lib/mode-lifecycle.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; -import { SessionManager } from "@mariozechner/pi-coding-agent"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createCommandContext } from "../../../tests/utils/pi-context"; -import { - createPiTestHarness, - type PiTestHarness, -} from "../../../tests/utils/pi-test-harness"; -import modesExtension from "../index"; - -function makeSessionManager( - opts: { withMessages?: boolean } = {}, -): SessionManager { - const sm = SessionManager.inMemory(); - if (opts.withMessages) { - sm.appendMessage({ - role: "user", - content: "hello", - timestamp: Date.now(), - }); - } - return sm; -} - -async function emitSessionStart( - pi: PiTestHarness, - reason: string, - sm: SessionManager, - modelRegistry?: ExtensionCommandContext["modelRegistry"], -): Promise { - const handlers = pi.extension.handlers.get("session_start") ?? []; - const ctx = createCommandContext({ sessionManager: sm, modelRegistry }); - for (const handler of handlers) { - await handler({ type: "session_start", reason }, ctx); - } -} - -describe("restoreModeForSession - new session defaults", () => { - let pi: PiTestHarness; - let setModel: ReturnType; - let setActiveTools: ReturnType; - - beforeEach(async () => { - pi = await createPiTestHarness(modesExtension); - setModel = vi.fn(async () => true); - setActiveTools = vi.fn(); - pi.runtime.setModel = setModel as unknown as typeof pi.runtime.setModel; - pi.runtime.getAllTools = vi.fn( - () => [], - ) as unknown as typeof pi.runtime.getAllTools; - pi.runtime.setActiveTools = - setActiveTools as unknown as typeof pi.runtime.setActiveTools; - pi.runtime.sendMessage = - vi.fn() as unknown as typeof pi.runtime.sendMessage; - pi.runtime.appendEntry = - vi.fn() as unknown as typeof pi.runtime.appendEntry; - }); - - function makeModelRegistry(): ExtensionCommandContext["modelRegistry"] { - return { - find: vi.fn((_provider: string, id: string) => ({ - provider: _provider, - id, - })), - } as unknown as ExtensionCommandContext["modelRegistry"]; - } - - it("does NOT set model on new startup for balanced mode (no model configured)", async () => { - const sm = makeSessionManager({ withMessages: false }); - await emitSessionStart(pi, "startup", sm, makeModelRegistry()); - expect(setModel).not.toHaveBeenCalled(); - }); - - it("applies active tools on startup even when already in balanced mode", async () => { - const sm = makeSessionManager({ withMessages: false }); - await emitSessionStart(pi, "startup", sm, makeModelRegistry()); - expect(setActiveTools).toHaveBeenCalled(); - }); - - it("does NOT force defaults on resume (reason=resume, has messages)", async () => { - const sm = makeSessionManager({ withMessages: true }); - await emitSessionStart(pi, "resume", sm); - expect(setModel).not.toHaveBeenCalled(); - }); - - it("does NOT force defaults when reopening existing session (startup + has messages)", async () => { - const sm = makeSessionManager({ withMessages: true }); - await emitSessionStart(pi, "startup", sm); - expect(setModel).not.toHaveBeenCalled(); - }); - - it("does NOT force defaults for unknown future reason values", async () => { - const sm = makeSessionManager({ withMessages: false }); - await emitSessionStart(pi, "some-future-reason", sm); - expect(setModel).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/modes/lib/mode-lifecycle.ts b/extensions/modes/lib/mode-lifecycle.ts deleted file mode 100644 index fc9dc2ac..00000000 --- a/extensions/modes/lib/mode-lifecycle.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT } from "../../../packages/events"; -import type { ModeSpec } from "../modes"; -import { DEFAULT_MODE, MODE_ORDER, MODES } from "../modes"; -import { - clearSessionAllowedTools, - getCurrentMode, - getPendingModeState, - setCurrentMode, - setPendingModeState, -} from "../state"; -import { sendModeSwitchMessage } from "./mode-switch"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function getToolsForMode(mode: ModeSpec, allToolNames: string[]): string[] { - return mode.allowedTools.length === 0 && mode.gatedTools.length === 0 - ? allToolNames - : [...mode.allowedTools, ...mode.gatedTools]; -} - -function resolveModelId( - mode: ModeSpec | undefined, - ctx: ExtensionContext, -): string | undefined { - if (mode?.provider && mode.model) { - return ctx.modelRegistry.find(mode.provider, mode.model)?.id ?? mode.model; - } - return undefined; -} - -// --------------------------------------------------------------------------- -// applyMode -// --------------------------------------------------------------------------- - -export interface ApplyModeOptions { - /** Don't show mode-switch message. Used for internal restores. */ - silent?: boolean; - /** Force apply even if already in this mode (sets model, thinking, tools). */ - force?: boolean; - /** - * Persist mode-state to branch immediately. - * true = persist now (agent switches via switch_mode tool). - * false/undefined = defer until next turn boundary (user switches via Ctrl+U, /mode). - */ - persist?: boolean; -} - -export async function applyMode( - pi: ExtensionAPI, - ctx: ExtensionContext, - modeName: string, - options?: ApplyModeOptions, -): Promise { - const mode = MODES[modeName]; - if (!mode) { - ctx.ui.notify(`Unknown mode. Available: ${MODE_ORDER.join(", ")}`, "error"); - return; - } - - const previousModeName = getCurrentMode().name; - const sameMode = previousModeName === modeName; - - // Apply tools regardless of whether the mode changed. - clearSessionAllowedTools(); - pi.setActiveTools( - getToolsForMode( - mode, - pi.getAllTools().map((t) => t.name), - ), - ); - - // When already in this mode, only re-apply if forced. - if (sameMode && !options?.force) { - return; - } - - setCurrentMode(mode); - - if (mode.thinkingLevel) { - pi.setThinkingLevel(mode.thinkingLevel); - } - - const targetModelId = resolveModelId(mode, ctx); - - if (!options?.silent) { - if (options?.persist) { - pi.appendEntry("mode-state", { mode: modeName }); - } else { - setPendingModeState(modeName); - } - sendModeSwitchMessage( - pi, - { mode: modeName, from: previousModeName, model: targetModelId }, - `Switched to ${modeName.toUpperCase()} mode.`, - ); - } - - // Update border decoration before async model switch. - pi.events.emit(AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT, { - source: "modes", - writes: [ - { - kind: "slot", - slot: "top-start", - text: mode.label, - }, - { - kind: "band", - band: "top", - color: mode.labelColor, - }, - { - kind: "band", - band: "bottom", - color: mode.labelColor, - }, - ], - }); - - if (mode.provider && mode.model) { - const found = ctx.modelRegistry.find(mode.provider, mode.model); - if (found) { - await pi.setModel(found); - } else { - ctx.ui.notify( - `Model ${mode.provider}/${mode.model} not found`, - "warning", - ); - } - } -} - -// --------------------------------------------------------------------------- -// Pending state flush -// --------------------------------------------------------------------------- - -/** Flush deferred mode-state to the branch. Called at turn boundaries. */ -export function flushPendingModeState(pi: ExtensionAPI): void { - const pending = getPendingModeState(); - if (pending !== null) { - setPendingModeState(null); - pi.appendEntry("mode-state", { mode: pending }); - } -} - -// --------------------------------------------------------------------------- -// Branch restore -// --------------------------------------------------------------------------- - -export function getLastModeFromBranch(ctx: ExtensionContext): string | null { - const entries = ctx.sessionManager.getBranch() as Array<{ - type?: string; - customType?: string; - data?: { mode?: unknown }; - }>; - - const last = entries - .filter( - (entry) => entry.type === "custom" && entry.customType === "mode-state", - ) - .at(-1); - - const mode = last?.data?.mode; - return typeof mode === "string" ? mode : null; -} - -function isNewSession( - reason: string | undefined, - ctx: ExtensionContext, -): boolean { - if (reason !== "startup" && reason !== "new") return false; - const branch = ctx.sessionManager.getBranch() as Array<{ type?: string }>; - return !branch.some((e) => e.type === "message"); -} - -// --------------------------------------------------------------------------- -// Session restore -// --------------------------------------------------------------------------- - -export async function restoreModeForSession( - pi: ExtensionAPI, - ctx: ExtensionContext, - honorFlagOverride: boolean, - reason?: string, -): Promise { - const restored = getLastModeFromBranch(ctx); - const baseMode = restored ?? DEFAULT_MODE.name; - const from = getCurrentMode().name; - - await applyMode(pi, ctx, baseMode, { - silent: true, - force: isNewSession(reason, ctx), - }); - - if (from !== baseMode && restored) { - const mode = MODES[baseMode]; - sendModeSwitchMessage( - pi, - { mode: baseMode, from, model: resolveModelId(mode, ctx) }, - `Restored ${baseMode.toUpperCase()} mode.`, - ); - } - - if (honorFlagOverride) { - const modeFlag = pi.getFlag("agent-mode"); - if (typeof modeFlag === "string" && modeFlag.trim()) { - const requested = modeFlag.trim(); - const fromFlag = getCurrentMode().name; - await applyMode(pi, ctx, requested, { silent: true, persist: false }); - if (fromFlag !== requested) { - const mode = MODES[requested]; - sendModeSwitchMessage( - pi, - { mode: requested, from: fromFlag, model: resolveModelId(mode, ctx) }, - `Flag set ${requested.toUpperCase()} mode.`, - ); - } - } - } -} diff --git a/extensions/modes/lib/mode-switch.ts b/extensions/modes/lib/mode-switch.ts deleted file mode 100644 index b8f6188b..00000000 --- a/extensions/modes/lib/mode-switch.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { Box, Text } from "@mariozechner/pi-tui"; -import { DEFAULT_MODE } from "../modes"; - -export type ModeSwitchDetails = { mode: string; from: string; model?: string }; - -export function sendModeSwitchMessage( - pi: ExtensionAPI, - details: ModeSwitchDetails, - content: string, -): void { - pi.sendMessage( - { - customType: "mode-switch", - content, - display: true, - details, - }, - { triggerTurn: false }, - ); -} - -export function registerModeSwitchRenderer(pi: ExtensionAPI): void { - pi.registerMessageRenderer( - "mode-switch", - (message, _options, theme) => { - const details = message.details; - const fromRaw = details?.from ?? DEFAULT_MODE.name; - const toRaw = details?.mode ?? DEFAULT_MODE.name; - - const from = fromRaw ?? DEFAULT_MODE.name; - const to = toRaw ?? DEFAULT_MODE.name; - const model = - typeof details?.model === "string" ? details.model : undefined; - - const tag = theme.fg("customMessageLabel", theme.bold("[Mode]")); - const fromText = theme.fg("accent", from); - const toText = theme.fg("accent", to); - const modelText = model ? theme.fg("muted", ` (${model})`) : ""; - const text = `${theme.fg("muted", "Switch from ")}${fromText}${theme.fg("muted", " to ")}${toText}${modelText}`; - - const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); - box.addChild(new Text(`${tag} ${text}`, 0, 0)); - return box; - }, - ); -} diff --git a/extensions/modes/lib/prompt-families/claude.ts b/extensions/modes/lib/prompt-families/claude.ts deleted file mode 100644 index 911a9c82..00000000 --- a/extensions/modes/lib/prompt-families/claude.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** System prompt for Claude models (Sonnet 4.6, Opus 4.6). */ -export const CLAUDE_SYSTEM_PROMPT = `You are Pi, an expert coding assistant. - -- Be concise and direct. -- Prefer native tools over bash for file work. Never use bash to read files. -- Read relevant files before editing or claiming behavior. -- For implementation requests, act once enough context is read. -- Make small focused changes. Match existing patterns. -- Preserve the original code structure and logic. Only change what is strictly necessary. -- Do not rename variables, add helper functions, or introduce new abstractions unless explicitly required. -- Do not add unrelated cleanup, abstractions, or files. -- Verify relevant checks before claiming completion.`; diff --git a/extensions/modes/lib/prompt-families/glm.ts b/extensions/modes/lib/prompt-families/glm.ts deleted file mode 100644 index 4834581e..00000000 --- a/extensions/modes/lib/prompt-families/glm.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** System prompt for GLM models (GLM-5, GLM-5.1, GLM-4.7). */ -export const GLM_SYSTEM_PROMPT = `You are Pi, an expert coding assistant. - -- Be concise. Skip filler. -- Prefer native tools over bash for file work. Never use bash to read files. -- Read relevant code before editing or proposing changes. -- Plan briefly, then act. For straightforward tasks, do not spend multiple turns planning. -- If the user asked to implement and enough context is read, start changing code. -- Follow user corrections exactly across turns: names, paths, config keys, commands, scope. -- Before renames, moves, deletions, or path changes, trace imports, config, build, registrations, and runtime usage. -- Treat deletion as high risk. Prove unused first. -- Make small focused diffs. Match existing conventions. -- Preserve the original code structure and logic. Only change what is strictly necessary. -- Do not rename variables, add helper functions, or introduce new abstractions unless explicitly required. -- Prefer to work autonomously; maintain goal alignment across extended sessions. -- Verify after changes, but do not repeat unchanged checks.`; diff --git a/extensions/modes/lib/prompt-families/index.ts b/extensions/modes/lib/prompt-families/index.ts deleted file mode 100644 index 05513feb..00000000 --- a/extensions/modes/lib/prompt-families/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { CLAUDE_SYSTEM_PROMPT } from "./claude"; -import { GLM_SYSTEM_PROMPT } from "./glm"; -import { KIMI_SYSTEM_PROMPT } from "./kimi"; -import { OPENAI_CODEX_SYSTEM_PROMPT } from "./openai-codex"; - -export type PromptFamily = "claude" | "openai-codex" | "kimi" | "glm"; - -export const PROMPT_FAMILY_MARKER = ""; - -/** Change this single line to change the fallback family. */ -export const DEFAULT_FAMILY: PromptFamily = "claude"; - -export interface ResolvedFamily { - family: PromptFamily; - /** True when the family is a fallback (provider/model didn't match any known family). */ - isDefault: boolean; -} - -/** - * Resolve a prompt family from the active model's provider and ID. - * - * Resolution order: - * 1. Provider "openai-codex" or "openai" -> "openai-codex" - * 2. Provider "anthropic" -> "claude" - * 3. Model ID containing "kimi" (case-insensitive) -> "kimi" - * 4. Model ID containing "glm" (case-insensitive) -> "glm" - * 5. Everything else -> DEFAULT_FAMILY with isDefault: true - */ -export function resolvePromptFamily( - provider?: string, - modelId?: string, -): ResolvedFamily { - if (provider === "openai-codex" || provider === "openai") { - return { family: "openai-codex", isDefault: false }; - } - if (provider === "anthropic") { - return { family: "claude", isDefault: false }; - } - if (modelId?.toLowerCase().includes("kimi")) { - return { family: "kimi", isDefault: false }; - } - if (modelId?.toLowerCase().includes("glm")) { - return { family: "glm", isDefault: false }; - } - return { family: DEFAULT_FAMILY, isDefault: true }; -} - -const FAMILY_PROMPTS: Record = { - claude: CLAUDE_SYSTEM_PROMPT, - "openai-codex": OPENAI_CODEX_SYSTEM_PROMPT, - kimi: KIMI_SYSTEM_PROMPT, - glm: GLM_SYSTEM_PROMPT, -}; - -export function getPromptForFamily(family: PromptFamily): string { - return FAMILY_PROMPTS[family]; -} diff --git a/extensions/modes/lib/prompt-families/kimi.ts b/extensions/modes/lib/prompt-families/kimi.ts deleted file mode 100644 index 391cc595..00000000 --- a/extensions/modes/lib/prompt-families/kimi.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** System prompt for Kimi models (K2.5). */ -export const KIMI_SYSTEM_PROMPT = `You are Pi, an expert coding assistant. - -- Be concise and direct. -- Prefer native tools over bash for file work. Never use bash to read files. -- Read relevant files before editing or claiming behavior. -- For implementation requests, act once enough context is read. -- Make small focused changes. Match existing patterns. -- Preserve the original code structure and logic. Only change what is strictly necessary. -- Do not rename variables, add helper functions, or introduce new abstractions unless explicitly required. -- Leverage the large context window to read multiple files in parallel when needed. -- Verify relevant checks before claiming completion.`; diff --git a/extensions/modes/lib/prompt-families/openai-codex.ts b/extensions/modes/lib/prompt-families/openai-codex.ts deleted file mode 100644 index 43b11389..00000000 --- a/extensions/modes/lib/prompt-families/openai-codex.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** System prompt for OpenAI/Codex models (GPT-5.x). */ -export const OPENAI_CODEX_SYSTEM_PROMPT = `You are Pi, an expert coding assistant. - -- Be concise. -- Follow explicit constraints exactly. -- Prefer native tools over bash for file work. Never use bash to read files. -- Read relevant code before editing. -- Use a clear loop: inspect, edit, verify. -- Start implementing once enough context is read. Do not churn on planning. -- Preserve the original code and logic of the original code as much as possible. Only change what is strictly necessary. -- Make small focused diffs. Reuse existing patterns. No unrelated changes. -- Do not rename variables, add helper functions, or introduce new abstractions unless explicitly required. -- Do not add error handling, fallbacks, or validation for scenarios that can't happen. -- Do not add docstrings, comments, or type annotations to code you didn't change. -- Run relevant checks before claiming completion.`; diff --git a/extensions/modes/lib/system-md-check.ts b/extensions/modes/lib/system-md-check.ts deleted file mode 100644 index 50b6ee0b..00000000 --- a/extensions/modes/lib/system-md-check.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { getAgentDir } from "@mariozechner/pi-coding-agent"; -import { PROMPT_FAMILY_MARKER } from "./prompt-families"; - -async function fileContains(path: string, substring: string): Promise { - try { - const content = await readFile(path, "utf-8"); - return content.includes(substring); - } catch { - return false; - } -} - -export function setupAppendSystemMdCheck(pi: ExtensionAPI): void { - pi.on("session_start", async (_event, ctx) => { - const agentDir = getAgentDir(); - const appendSystemMdPath = join(agentDir, "APPEND_SYSTEM.md"); - - if (await fileContains(appendSystemMdPath, PROMPT_FAMILY_MARKER)) return; - - ctx.ui.notify( - "APPEND_SYSTEM.md is missing or invalid. Prompt family switching requires it.", - "warning", - ); - - const confirmed = await ctx.ui.confirm( - "Create APPEND_SYSTEM.md?", - `This will write the prompt family marker to ${appendSystemMdPath}.\nWithout it, pi's default system prompt is used and family switching is disabled.`, - ); - - if (!confirmed) return; - - try { - await writeFile(appendSystemMdPath, `${PROMPT_FAMILY_MARKER}\n`, "utf-8"); - ctx.ui.notify( - "Created APPEND_SYSTEM.md with prompt family marker.", - "info", - ); - } catch (err) { - ctx.ui.notify( - `Failed to write APPEND_SYSTEM.md: ${err instanceof Error ? err.message : String(err)}`, - "error", - ); - } - }); -} diff --git a/extensions/modes/modes.ts b/extensions/modes/modes.ts deleted file mode 100644 index aacef8ce..00000000 --- a/extensions/modes/modes.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { ModeColor } from "../../packages/events"; - -export interface ModeSpec { - name: string; - label: string; - labelColor: ModeColor; - provider?: string; - model?: string; - thinkingLevel?: ThinkingLevel; - systemPrompt?: string; - description?: string; - /** Tools enabled without gating. Empty = all tools allowed. */ - allowedTools: string[]; - /** Tools enabled but requiring confirmation per call. */ - gatedTools: string[]; -} - -export const MODE_ORDER: string[] = ["balanced", "research"]; - -// --------------------------------------------------------------------------- -// System prompts -// --------------------------------------------------------------------------- - -const BALANCED_PROMPT = `You are Pi, an expert coding assistant. - -Be concise. Sacrifice grammar for brevity. Let code speak for itself. - -- Prefer parallel tool calls for independent operations. -- Use specialized tools (read, grep, find, ls) over bash for file exploration. -- Never propose changes to code you have not read. -- Match existing code style, conventions, and libraries. -- Work incrementally: small change, verify, continue. -- Do not add features, refactor code, or make improvements beyond what was asked.`; - -const RESEARCH_PROMPT = `You are Pi, an expert coding assistant in RESEARCH MODE. - -Analyze, research, and plan. Do not modify files or system state. - -- Use read, grep, find, ls for local code exploration. -- Use scout, lookout, oracle for deep investigation. -- Read relevant code before drawing conclusions. -- Prefer deep exploration and evidence-backed findings. -- Cite file paths for non-obvious claims. -- Give the smallest answer that fully covers the question. -- End with open questions only if there are real blockers or ambiguities.`; - -// --------------------------------------------------------------------------- -// Mode definitions -// --------------------------------------------------------------------------- - -export const MODES: Record = { - balanced: { - name: "balanced", - label: "balanced", - labelColor: { source: "raw", color: "#777777" }, - description: "All tools, default model", - systemPrompt: BALANCED_PROMPT, - allowedTools: [], - gatedTools: [], - }, - research: { - name: "research", - label: "research", - labelColor: { source: "raw", color: "#5f8faf" }, - description: "Read-only + research, high thinking (Claude Opus)", - provider: "anthropic", - model: "claude-opus-4-6", - thinkingLevel: "medium", - systemPrompt: RESEARCH_PROMPT, - allowedTools: [ - "read", - "ls", - "find", - "grep", - "get_current_time", - "read_url", - "find_sessions", - "list_sessions", - "read_session", - "ask_user", - "synthetic_web_search", - "linkup_web_search", - "linkup_web_answer", - "linkup_web_fetch", - "scout", - "lookout", - "oracle", - "reviewer", - "switch_mode", - ], - gatedTools: ["bash"], - }, -}; - -export const DEFAULT_MODE = MODES.balanced as ModeSpec; diff --git a/extensions/modes/package.json b/extensions/modes/package.json deleted file mode 100644 index 469d9ef5..00000000 --- a/extensions/modes/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@aliou/pi-modes-next", - "private": true, - "pi": { - "extensions": [ - "./index.ts" - ] - }, - "peerDependencies": { - "@mariozechner/pi-coding-agent": "0.69.0", - "@mariozechner/pi-tui": "0.69.0", - "typebox": "^1.0.0" - }, - "peerDependenciesMeta": { - "@mariozechner/pi-coding-agent": { - "optional": true - }, - "@mariozechner/pi-tui": { - "optional": true - }, - "typebox": { - "optional": true - } - } -} diff --git a/extensions/modes/state.ts b/extensions/modes/state.ts deleted file mode 100644 index 58d1cb2f..00000000 --- a/extensions/modes/state.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ModeSpec } from "./modes"; -import { DEFAULT_MODE } from "./modes"; - -let currentMode: ModeSpec = DEFAULT_MODE; -const sessionAllowedTools: Set = new Set(); - -/** Pending mode-state to persist on next turn boundary. */ -let pendingModeState: string | null = null; - -export function getCurrentMode(): ModeSpec { - return currentMode; -} - -export function setCurrentMode(mode: ModeSpec): void { - currentMode = mode; -} - -export function getSessionAllowedTools(): Set { - return sessionAllowedTools; -} - -export function clearSessionAllowedTools(): void { - sessionAllowedTools.clear(); -} - -export function addSessionAllowedTool(toolName: string): void { - sessionAllowedTools.add(toolName); -} - -export function getPendingModeState(): string | null { - return pendingModeState; -} - -export function setPendingModeState(modeName: string | null): void { - pendingModeState = modeName; -} diff --git a/extensions/modes/tools/switch-mode.ts b/extensions/modes/tools/switch-mode.ts deleted file mode 100644 index eca07136..00000000 --- a/extensions/modes/tools/switch-mode.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { - AgentToolResult, - ExtensionAPI, - ExtensionContext, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { type Static, Type } from "typebox"; -import { AD_NOTIFY_ATTENTION_EVENT } from "../../../packages/events"; - -import type { ApplyModeFn } from "../commands/mode-command"; -import { MODE_ORDER, MODES } from "../modes"; -import { getCurrentMode } from "../state"; - -const SwitchModeParams = Type.Object({ - mode: Type.String({ - description: `Target mode (${MODE_ORDER.join(", ")}).`, - }), -}); - -type SwitchModeParamsType = Static; - -type SwitchModeDetails = { - ok: boolean; - from: string; - to: string; - message: string; -}; - -function emitSwitchModeGateEvent( - pi: ExtensionAPI, - description: string, - from: string, - to: string, - toolCallId: string, -): void { - pi.events.emit(AD_NOTIFY_ATTENTION_EVENT, { - source: "modes:switch-mode", - description, - reason: `${from} -> ${to}`, - toolName: "switch_mode", - toolCallId, - }); -} - -function toResult( - details: SwitchModeDetails, -): AgentToolResult { - return { - content: [{ type: "text", text: details.message }], - details, - }; -} - -export function setupSwitchModeTool( - pi: ExtensionAPI, - applyMode: ApplyModeFn, -): void { - pi.registerTool({ - name: "switch_mode", - label: "Switch Mode", - description: - "Switch agent mode to another mode. Always available, with explicit confirmation.", - parameters: SwitchModeParams, - - async execute( - toolCallId, - params: SwitchModeParamsType, - // unused: _signal, _onUpdate - _signal, - _onUpdate, - ctx: ExtensionContext, - ): Promise> { - const current = getCurrentMode().name; - const requested = params.mode.trim(); - - if (!requested || !MODES[requested]) { - return toResult({ - ok: false, - from: current, - to: requested || current, - message: `Unknown mode. Available: ${MODE_ORDER.join(", ")}`, - }); - } - - if (requested === current) { - return toResult({ - ok: true, - from: current, - to: requested, - message: `Already in ${requested} mode.`, - }); - } - - const description = `Confirmation required: switch mode from ${current} to ${requested}`; - - emitSwitchModeGateEvent(pi, description, current, requested, toolCallId); - - if (!ctx.hasUI) { - return toResult({ - ok: false, - from: current, - to: requested, - message: - "Mode switch requires explicit confirmation, but no UI is available.", - }); - } - - const confirmed = await ctx.ui.confirm( - "Switch mode?", - `Switch from ${current} to ${requested}?`, - ); - - if (!confirmed) { - return toResult({ - ok: false, - from: current, - to: requested, - message: "Blocked by user.", - }); - } - - await applyMode(pi, ctx, requested, { persist: true }); - - return toResult({ - ok: true, - from: current, - to: requested, - message: `Switched from ${current} to ${requested} mode.`, - }); - }, - - renderCall(args: SwitchModeParamsType, theme: Theme): Text { - const to = args.mode?.trim() || ""; - return new Text( - `${theme.fg("dim", "[Switch Mode]")} ${theme.fg("accent", to)}`, - 0, - 0, - ); - }, - - renderResult( - result: AgentToolResult, - _options: ToolRenderResultOptions, - theme: Theme, - ): Text { - const details = result.details; - if (!details) { - const text = result.content[0]; - return new Text( - text?.type === "text" && text.text ? text.text : "No result", - 0, - 0, - ); - } - - const status = details.ok - ? theme.fg("success", "ok") - : theme.fg("error", "blocked"); - return new Text( - `${theme.fg("dim", "status:")} ${status}\n${details.message}`, - 0, - 0, - ); - }, - }); -} diff --git a/extensions/projects/AGENTS.md b/extensions/projects/AGENTS.md deleted file mode 100644 index 802ed211..00000000 --- a/extensions/projects/AGENTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# projects - -Project initialization extension for Pi. - -## Layout - -- `commands/init.ts` - /projects:init command registration -- `commands/init/` - Wizard and supporting modules (catalog scanner, installer, nix/AGENTS.md prompt builders, scanner) -- `commands/settings.ts` - /projects:settings command -- `config.ts` - Extension config (catalog paths, scan depths) -- `index.ts` - Entry point diff --git a/extensions/projects/README.md b/extensions/projects/README.md deleted file mode 100644 index 5def84fb..00000000 --- a/extensions/projects/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# projects - -Project initialization extension for Pi. - -## Features - -### `/projects:init` command - -Multi-step wizard to configure packages, skills, and AGENTS.md for the current project. - -- Scans catalog directories for available skills and packages -- Multi-select for packages and skills (with auto-lock for bundled skills) -- Nix dev shell configuration (shell.nix or flake.nix) -- AGENTS.md generation with directory targeting and custom prompts -- Detects project tech stack from manifest files - -### `/projects:settings` command - -Interactive editor for project extension settings: - -- `catalog`: directories to scan for skills and packages -- `catalogDepth`: how many directory levels deep to scan -- `childProjectDepth`: depth for detecting child project roots diff --git a/extensions/projects/index.ts b/extensions/projects/index.ts deleted file mode 100644 index 7a35e511..00000000 --- a/extensions/projects/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { registerProjectInitCommand } from "./commands/init"; -import { configLoader } from "./config"; - -export default async function (pi: ExtensionAPI) { - await configLoader.load(); - registerProjectInitCommand(pi); -} diff --git a/extensions/providers/hooks/codex/fast-mode.ts b/extensions/providers/hooks/codex/fast-mode.ts index f8a21be3..64840533 100644 --- a/extensions/providers/hooks/codex/fast-mode.ts +++ b/extensions/providers/hooks/codex/fast-mode.ts @@ -1,12 +1,12 @@ -import type { - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; import { AD_PROVIDERS_CODEX_FAST_MODE_CHANGED_EVENT, AD_PROVIDERS_CODEX_FAST_MODE_READY_EVENT, AD_PROVIDERS_CODEX_FAST_MODE_REQUEST_EVENT, -} from "../../../../packages/events"; +} from "@harness/events"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; import { CODEX_FAST_ENTRY_TYPE, DEFAULT_CODEX_FAST_MODE_ENABLED, diff --git a/extensions/providers/hooks/context-window-overrides.ts b/extensions/providers/hooks/context-window-overrides.ts deleted file mode 100644 index 1fceece6..00000000 --- a/extensions/providers/hooks/context-window-overrides.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { getAgentDir } from "@mariozechner/pi-coding-agent"; -import { AD_NOTIFY_ATTENTION_EVENT } from "../../../packages/events"; - -/** - * Map of "provider/modelId" to the desired context window size in tokens. - */ -const CONTEXT_WINDOW_OVERRIDES: Record = { - "anthropic/claude-opus-4-6": 272_000, - "anthropic/claude-opus-4-7": 272_000, - "anthropic/claude-sonnet-4-6": 272_000, -}; - -interface ModelsJsonConfig { - providers: Record< - string, - { - baseUrl?: string; - apiKey?: string; - headers?: Record; - modelOverrides?: Record< - string, - { contextWindow?: number; maxTokens?: number } - >; - [key: string]: unknown; - } - >; -} - -export function setupContextWindowOverrides(pi: ExtensionAPI): void { - if (Object.keys(CONTEXT_WINDOW_OVERRIDES).length === 0) return; - - pi.on("session_start", async (event, ctx) => { - // Only prompt on fresh starts, not resumes/switches - if (event.reason !== "startup" && event.reason !== "new") return; - const modelsJsonPath = join(getAgentDir(), "models.json"); - - let config: ModelsJsonConfig = { providers: {} }; - if (existsSync(modelsJsonPath)) { - try { - config = JSON.parse( - readFileSync(modelsJsonPath, "utf-8"), - ) as ModelsJsonConfig; - if (!config.providers) config.providers = {}; - } catch { - config = { providers: {} }; - } - } - - // Collect drifted entries - const drifted: Array<{ - provider: string; - modelId: string; - current: number | undefined; - desired: number; - }> = []; - - for (const [key, desired] of Object.entries(CONTEXT_WINDOW_OVERRIDES)) { - const slashIdx = key.indexOf("/"); - if (slashIdx === -1) continue; - const provider = key.slice(0, slashIdx); - const modelId = key.slice(slashIdx + 1); - - const current = - config.providers[provider]?.modelOverrides?.[modelId]?.contextWindow; - if (current !== desired) { - drifted.push({ provider, modelId, current, desired }); - } - } - - if (drifted.length === 0) return; - - // Build human-readable list - const lines = drifted.map(({ provider, modelId, current, desired }) => { - const desiredStr = desired.toLocaleString(); - if (current === undefined) { - return ` ${provider} / ${modelId}: missing (should be ${desiredStr})`; - } - return ` ${provider} / ${modelId}: ${current.toLocaleString()} → ${desiredStr}`; - }); - - ctx.ui.notify( - "Context window overrides in models.json are out of date:\n" + - lines.join("\n"), - "warning", - ); - pi.events.emit(AD_NOTIFY_ATTENTION_EVENT, { - description: "Context window overrides in models.json are out of date.", - }); - - const confirmed = await ctx.ui.confirm( - "Update models.json?", - `The following context window overrides will be written to models.json:\n${lines.join("\n")}`, - ); - - if (!confirmed) return; - - // Deep-merge overrides into config - for (const { provider, modelId, desired } of drifted) { - if (!config.providers[provider]) { - config.providers[provider] = {}; - } - const providerConfig = config.providers[provider]; - if (!providerConfig.modelOverrides) { - providerConfig.modelOverrides = {}; - } - if (!providerConfig.modelOverrides[modelId]) { - providerConfig.modelOverrides[modelId] = {}; - } - providerConfig.modelOverrides[modelId].contextWindow = desired; - } - - writeFileSync(modelsJsonPath, JSON.stringify(config, null, 2), "utf-8"); - await ctx.modelRegistry.refresh(); - ctx.ui.notify( - "models.json updated. Context window overrides applied.", - "info", - ); - }); -} diff --git a/extensions/providers/hooks/index.ts b/extensions/providers/hooks/index.ts index 14f4e6e8..7ab9348d 100644 --- a/extensions/providers/hooks/index.ts +++ b/extensions/providers/hooks/index.ts @@ -1,10 +1,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { setupCodexFastModeHooks } from "./codex/fast-mode"; -import { setupContextWindowOverrides } from "./context-window-overrides"; import { setupWarningHooks } from "./warnings"; export function setupHooks(pi: ExtensionAPI): void { - setupContextWindowOverrides(pi); setupWarningHooks(pi); setupCodexFastModeHooks(pi); } diff --git a/extensions/providers/hooks/warnings.ts b/extensions/providers/hooks/warnings.ts index 29a2d034..4e60e1a9 100644 --- a/extensions/providers/hooks/warnings.ts +++ b/extensions/providers/hooks/warnings.ts @@ -1,3 +1,4 @@ +import { formatTimeRemaining } from "@harness/utils/formatters"; import type { ExtensionAPI, ExtensionContext, @@ -5,7 +6,6 @@ import type { import { getProviderSettings } from "../config"; import { fetchProvider, toProviderKey } from "../lib/adapters"; import { findHighRiskLimits } from "../lib/engine"; -import { formatTimeRemaining } from "../lib/formatters"; import type { NormalizedLimit, RiskAssessment, Severity } from "../lib/types"; const COOLDOWN_MS = 60 * 60 * 1000; // 60 minutes diff --git a/extensions/providers/lib/view.ts b/extensions/providers/lib/view.ts index 23cb5ff5..4ff8ae1d 100644 --- a/extensions/providers/lib/view.ts +++ b/extensions/providers/lib/view.ts @@ -1,5 +1,5 @@ +import { formatCurrency, formatTimeRemaining } from "@harness/utils/formatters"; import { assessRisk, getPacePercent, getProjectedPercent } from "./engine"; -import { formatCurrency, formatTimeRemaining } from "./formatters"; import type { FixedWindowLimit, LimitViewModel, diff --git a/extensions/qq/README.md b/extensions/qq/README.md deleted file mode 100644 index 32d48ea0..00000000 --- a/extensions/qq/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# qq - -Ask quick questions without interrupting the main session flow. - -## Features - -- `/qq ` command for short side investigations -- Reuses current session context, but filters out prior `qq` messages and in-progress assistant output -- Renders answers as custom bordered messages with provider, model, token, and cost metadata -- Shows a temporary loading widget above the editor while the side question runs - -## Command - -- `/qq ` - Ask a quick question without interrupting the main agent flow - -## Notes - -- Requires interactive mode -- Uses the current session model -- Runs as a small subagent invocation with no tools diff --git a/extensions/qq/commands/qq.ts b/extensions/qq/commands/qq.ts deleted file mode 100644 index d6c34727..00000000 --- a/extensions/qq/commands/qq.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { - ExtensionAPI, - ExtensionCommandContext, -} from "@mariozechner/pi-coding-agent"; -import { - buildSessionContext, - convertToLlm, - getMarkdownTheme, - serializeConversation, -} from "@mariozechner/pi-coding-agent"; -import { Loader, Markdown, Text, visibleWidth } from "@mariozechner/pi-tui"; -import { executeSubagent } from "../../subagents/lib"; -import { QQ_SYSTEM_REMINDER } from "../lib/system-prompt"; -import { QQ_MESSAGE_TYPE, type QqDetails, qqPending } from "../lib/types"; - -const WIDGET_ID = "qq"; - -/** - * Wrap content lines in a rounded border with 1-char inner padding. - */ -function wrapInRoundedBorder( - lines: string[], - width: number, - colorFn: (t: string) => string, -): string[] { - const innerWidth = Math.max(1, width - 2); - const hBar = "\u2500".repeat(innerWidth); - const top = colorFn(`\u256D${hBar}\u256E`); - const bottom = colorFn(`\u2570${hBar}\u256F`); - const left = colorFn("\u2502"); - const right = colorFn("\u2502"); - - const wrapped = lines.map((line) => { - const contentWidth = visibleWidth(line); - const fill = Math.max(0, innerWidth - contentWidth); - return `${left}${line}${" ".repeat(fill)}${right}`; - }); - - return [top, ...wrapped, bottom]; -} - -/** - * Show a result widget above the editor while the agent is still working. - * The widget renders the QQ answer with the same bordered style used by - * the message renderer, so the visual transition is seamless when the - * pending state is cleared and the renderer takes over. - */ -function showResultWidget( - ctx: ExtensionCommandContext, - question: string, - answer: string, - model: { provider: string; id: string }, -): void { - ctx.ui.setWidget( - WIDGET_ID, - (_tui, theme) => { - const borderColor = (t: string) => theme.fg("success", t); - const mdTheme = getMarkdownTheme(); - - return { - render(width: number) { - const contentWidth = Math.max(1, width - 4); - const content: string[] = []; - - // Header line - content.push( - theme.fg("customMessageLabel", `\x1b[1mqq:\x1b[22m `) + question, - ); - content.push(""); - - // Answer (first paragraph for brevity in widget) - const paragraphs = answer.split(/\n\n/).filter((p) => p.trim()); - const firstParagraph = paragraphs[0] ?? ""; - try { - const md = new Markdown(firstParagraph, 0, 0, mdTheme); - content.push(...md.render(contentWidth)); - } catch { - content.push( - ...new Text(firstParagraph, 0, 0).render(contentWidth), - ); - } - - // Footer with model info - content.push(""); - content.push(theme.fg("dim", `(${model.provider}/${model.id})`)); - - const padded = content.map((line) => ` ${line} `); - return wrapInRoundedBorder(padded, width, borderColor); - }, - handleInput() {}, - invalidate() {}, - }; - }, - { placement: "aboveEditor" }, - ); -} - -export function registerQqCommand(pi: ExtensionAPI): void { - pi.registerCommand("qq", { - description: "Ask a quick question without interrupting the agent", - handler: async (args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("/qq requires interactive mode", "error"); - return; - } - - if (!ctx.model) { - ctx.ui.notify("No model selected", "error"); - return; - } - - const question = args?.trim(); - if (!question) { - ctx.ui.notify("Usage: /qq ", "warning"); - return; - } - - // Build conversation context - const entries = ctx.sessionManager.getBranch(); - const sessionContext = buildSessionContext( - entries, - ctx.sessionManager.getLeafId(), - ); - const llmMessages = convertToLlm(sessionContext.messages); - - // Filter out qq messages and in-progress assistant messages - const filtered = llmMessages.filter((msg) => { - const maybeCustom = msg as { customType?: unknown }; - if (maybeCustom.customType === QQ_MESSAGE_TYPE) return false; - if ( - msg.role === "assistant" && - (msg.stopReason === undefined || msg.stopReason === null) - ) { - return false; - } - return true; - }); - - const serialized = serializeConversation(filtered); - const userMessage = `${serialized}\n\n---\n\nSide question: ${question}`; - const systemPrompt = ctx.getSystemPrompt() + QQ_SYSTEM_REMINDER; - const model = ctx.model; - - // Show loading widget with rounded border - ctx.ui.setWidget( - WIDGET_ID, - (tui, theme) => { - const borderColor = (t: string) => theme.fg("warning", t); - const loader = new Loader( - tui, - (s) => theme.fg("accent", s), - (s) => theme.fg("muted", s), - `qq: ${question}`, - ); - loader.start(); - - return { - render(width: number) { - const contentWidth = Math.max(1, width - 4); - const loaderLines = loader.render(contentWidth); - const padded = loaderLines.map((line) => ` ${line} `); - return wrapInRoundedBorder(padded, width, borderColor); - }, - handleInput() {}, - invalidate() { - loader.invalidate(); - }, - dispose() { - loader.stop(); - }, - }; - }, - { placement: "aboveEditor" }, - ); - - try { - const result = await executeSubagent( - { - name: "qq", - model, - systemPrompt, - tools: [], - customTools: [], - thinkingLevel: "off", - logging: { enabled: true, debug: false }, - }, - userMessage, - ctx, - ); - - // Clear widget - ctx.ui.setWidget(WIDGET_ID, undefined); - - if (result.aborted) return; - - if (result.error) { - ctx.ui.notify(`qq error: ${result.error}`, "error"); - return; - } - - if (!result.content) { - ctx.ui.notify("No response generated", "warning"); - return; - } - - const timestamp = Date.now(); - - pi.sendMessage( - { - customType: QQ_MESSAGE_TYPE, - content: result.content, - display: true, - details: { - question, - answer: result.content, - provider: model.provider, - model: model.id, - timestamp, - usage: result.usage, - runId: result.runId, - totalDurationMs: result.totalDurationMs, - }, - }, - { triggerTurn: false }, - ); - - // If the agent is still working, mark the message as pending so - // the renderer hides it. Show the result in a widget above the - // editor instead. On the next turn (or agent end), the pending - // state is cleared, the widget is removed, and the renderer - // displays the message in its proper session position. - if (!ctx.isIdle()) { - qqPending.add(timestamp); - showResultWidget(ctx, question, result.content, model); - } - } catch (err) { - ctx.ui.setWidget(WIDGET_ID, undefined); - ctx.ui.notify( - `qq error: ${err instanceof Error ? err.message : String(err)}`, - "error", - ); - } - }, - }); -} diff --git a/extensions/qq/hooks/context-filter.ts b/extensions/qq/hooks/context-filter.ts deleted file mode 100644 index 922f0e4a..00000000 --- a/extensions/qq/hooks/context-filter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { QQ_MESSAGE_TYPE } from "../lib/types"; - -export function setupQqContextFilter(pi: ExtensionAPI): void { - pi.on("context", async (event) => { - const messages = event.messages.filter((message) => { - const maybeCustom = message as { customType?: unknown }; - return maybeCustom.customType !== QQ_MESSAGE_TYPE; - }); - - return { messages }; - }); -} diff --git a/extensions/qq/index.ts b/extensions/qq/index.ts deleted file mode 100644 index 30801aa3..00000000 --- a/extensions/qq/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { registerQqCommand } from "./commands/qq"; -import { setupQqContextFilter } from "./hooks/context-filter"; -import { registerQqRenderer } from "./lib/renderer"; -import { qqPending } from "./lib/types"; - -const QQ_WIDGET_ID = "qq"; - -export default async function (pi: ExtensionAPI): Promise { - registerQqCommand(pi); - registerQqRenderer(pi); - setupQqContextFilter(pi); - - // When the user sends a new message, clear any pending QQ messages - // and remove the result widget above the editor. This makes the - // custom renderer display them in their proper session position. - // We use agent_start (not turn_start/agent_end) because the - // widget should stay visible while the user reads the result, even - // after the agent finishes — only transitioning to in-session - // rendering when the user actually sends a new message. - pi.on("agent_start", async (_event, ctx) => { - if (qqPending.size > 0) { - qqPending.clear(); - ctx.ui.setWidget(QQ_WIDGET_ID, undefined); - } - }); -} diff --git a/extensions/qq/lib/renderer.ts b/extensions/qq/lib/renderer.ts deleted file mode 100644 index 8a246870..00000000 --- a/extensions/qq/lib/renderer.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; -import { - Container, - Markdown, - type MarkdownTheme, - Text, - truncateToWidth, - visibleWidth, -} from "@mariozechner/pi-tui"; -import { QQ_MESSAGE_TYPE, type QqDetails, qqPending } from "./types"; - -function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - -function buildFooterLine(details: QqDetails): string { - const parts: string[] = []; - const u = details.usage; - - if (u) { - if (u.inputTokens != null) { - parts.push(`\u2191${formatTokenCount(u.inputTokens)}`); - } - if (u.outputTokens != null) { - parts.push(`\u2193${formatTokenCount(u.outputTokens)}`); - } - if (u.cacheReadTokens != null && u.cacheReadTokens > 0) { - parts.push(`R${formatTokenCount(u.cacheReadTokens)}`); - } - if (u.cacheWriteTokens != null && u.cacheWriteTokens > 0) { - parts.push(`W${formatTokenCount(u.cacheWriteTokens)}`); - } - if (u.llmCost != null && u.llmCost > 0) { - parts.push( - u.llmCost < 1 ? `$${u.llmCost.toFixed(4)}` : `$${u.llmCost.toFixed(2)}`, - ); - } - } - - parts.push(`(${details.provider}/${details.model})`); - - return parts.join(" "); -} - -/** - * Wrap content lines in a rounded Unicode box border with 1-char inner padding. - */ -function wrapInRoundedBorder( - lines: string[], - width: number, - colorFn: (t: string) => string, -): string[] { - const innerWidth = Math.max(1, width - 2); - const hBar = "\u2500".repeat(innerWidth); - const top = colorFn(`\u256D${hBar}\u256E`); - const bottom = colorFn(`\u2570${hBar}\u256F`); - const left = colorFn("\u2502"); - const right = colorFn("\u2502"); - - const wrapped = lines.map((line) => { - const contentWidth = visibleWidth(line); - const fill = Math.max(0, innerWidth - contentWidth); - return `${left}${line}${" ".repeat(fill)}${right}`; - }); - - return [top, ...wrapped, bottom]; -} - -export function registerQqRenderer(pi: ExtensionAPI): void { - pi.registerMessageRenderer( - QQ_MESSAGE_TYPE, - (message, options, theme) => { - const details = message.details; - - // While the message is pending (added during an active turn), - // hide it in the session view. The result is shown in a widget - // above the editor instead. When the next turn starts, the pending - // state is cleared and this renderer displays normally. - if (details?.timestamp && qqPending.has(details.timestamp)) { - return new Container(); // renders zero lines — invisible - } - const question = details?.question ?? ""; - const answer = details?.answer ?? ""; - const expanded = options.expanded ?? false; - - const header = new ToolCallHeader( - { toolName: "qq", showColon: true, mainArg: question }, - theme, - ); - - let mdTheme: MarkdownTheme | null = null; - let md: Markdown | null = null; - - const footerLine = details ? buildFooterLine(details) : ""; - const borderColor = (t: string) => theme.fg("success", t); - - return { - render(width: number) { - // border (2) + inner padding (2) - const contentWidth = Math.max(1, width - 4); - const content: string[] = []; - - content.push(...header.render(contentWidth)); - - if (expanded) { - // Expanded: show full answer - if (answer) { - content.push(""); - try { - if (!mdTheme) mdTheme = getMarkdownTheme(); - if (!md) md = new Markdown(answer, 0, 0, mdTheme); - content.push(...md.render(contentWidth)); - } catch { - content.push(...new Text(answer, 0, 0).render(contentWidth)); - } - } - - // Footer on its own line in expanded mode - if (footerLine) { - content.push(""); - content.push( - theme.fg("muted", truncateToWidth(footerLine, contentWidth)), - ); - } - } else { - // Collapsed: show first paragraph of answer - const paragraphs = answer.split(/\n\n/).filter((p) => p.trim()); - const firstParagraph = paragraphs[0] ?? ""; - const remainingParagraphs = paragraphs.length - 1; - - if (firstParagraph) { - content.push(""); - try { - if (!mdTheme) mdTheme = getMarkdownTheme(); - if (!md) md = new Markdown(firstParagraph, 0, 0, mdTheme); - content.push(...md.render(contentWidth)); - } catch { - content.push( - ...new Text(firstParagraph, 0, 0).render(contentWidth), - ); - } - } - - // Footer line with more paragraphs hint and token/cost/model info - const hint = keyHint("app.tools.expand", "to expand"); - const moreParagraphsHint = - remainingParagraphs > 0 - ? `(${remainingParagraphs} more paragraphs, ${hint}) ` - : `(${hint}) `; - const collapsedLine = moreParagraphsHint + (footerLine ?? ""); - content.push(""); - content.push( - theme.fg("dim", truncateToWidth(collapsedLine, contentWidth)), - ); - } - - const padded = content.map((line) => ` ${line} `); - return wrapInRoundedBorder(padded, width, borderColor); - }, - handleInput(_data: string) { - return false; - }, - invalidate() { - md?.invalidate(); - }, - }; - }, - ); -} diff --git a/extensions/qq/lib/types.ts b/extensions/qq/lib/types.ts deleted file mode 100644 index 2fcc731d..00000000 --- a/extensions/qq/lib/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { SubagentUsage } from "../../subagents/lib/types"; - -export const QQ_MESSAGE_TYPE = "qq"; - -export type QqDetails = { - question: string; - answer: string; - provider: string; - model: string; - timestamp: number; - usage?: SubagentUsage; - runId?: string; - totalDurationMs?: number; -}; - -/** - * Registry of QQ message timestamps that are "pending" — added during an - * active turn and not yet ready for normal rendering. - * - * While a message is pending, the custom renderer returns an invisible - * component and the result is shown in a widget above the editor instead. - * On the next `turn_start` (or `agent_end`), pending entries are cleared, - * the widget is removed, and the renderer displays the message normally. - */ -export class QqPendingMessages { - private pending = new Set(); - - add(timestamp: number): void { - this.pending.add(timestamp); - } - - has(timestamp: number): boolean { - return this.pending.has(timestamp); - } - - clear(): number[] { - const entries = [...this.pending]; - this.pending.clear(); - return entries; - } - - get size(): number { - return this.pending.size; - } -} - -/** - * Shared singleton — both the command handler and the renderer need - * access to the same pending state. - */ -export const qqPending = new QqPendingMessages(); diff --git a/extensions/subagents/README.md b/extensions/subagents/README.md deleted file mode 100644 index 84fef0b6..00000000 --- a/extensions/subagents/README.md +++ /dev/null @@ -1,221 +0,0 @@ -# Specialized Subagents Extension - -Framework for running specialized subagents behind first-class Pi tools. - -This extension registers five tools: - -- `scout` -- `lookout` -- `oracle` -- `reviewer` -- `worker` - -Each tool delegates to a subagent with its own system prompt, model selection, optional skills, logging, and UI rendering. - -## What it does - -- Registers all subagent tools at extension load. -- Resets per-session model selections on `session_start`. -- Applies enable/disable toggles before each agent turn. -- Logs each subagent run under `~/.pi/agent/subagents/...`. -- Supports optional debug logs via `debug.jsonl`. -- Supports passing Pi skills into subagents by exact skill name. - -## Registered subagents - -### Scout - -Deep web and GitHub research subagent. - -Inputs: - -- `url?` -- `query?` -- `repo?` -- `prompt` -- `skills?` - -At least one of `url`, `query`, or `repo` is required. - -Scout uses custom tools, not Pi built-ins. Current internal toolset: - -- `webSearch` -- `webFetch` -- `githubContent` -- `githubSearch` -- `githubCommits` -- `githubIssue` -- `githubIssues` -- `githubPrDiff` -- `githubPrReviews` -- `githubCompare` -- `listUserRepos` -- `downloadGist` -- `uploadGist` - -Default routed web config: - -- Search order: `synthetic -> exa -> linkup` -- Fetch order: `markdownDotNew -> exa -> linkup` - -Required env depends on which providers you actually use. The extension only checks `SCOUT_GITHUB_TOKEN` at load time. In the default config, Scout also expects `SYNTHETIC_API_KEY` for search. - -Relevant env vars: - -- `SCOUT_GITHUB_TOKEN` -- `SYNTHETIC_API_KEY` -- `EXA_API_KEY` -- `LINKUP_API_KEY` - -### Lookout - -Local codebase search subagent. - -Inputs: - -- `query` -- `cwd?` -- `skills?` - -Lookout uses Pi read-only tools created by `createReadOnlyTools(workingDir)`: - -- `grep` -- `find` -- `read` -- `ls` - -It does not use `osgrep`. - -If the model returns an answer without using search tools, the response is discarded to avoid hallucinated file paths. - -### Oracle - -Advisory subagent for planning, debugging, architecture review, and deep reasoning. - -Inputs: - -- `task` -- `context?` -- `files?` -- `skills?` - -Oracle is advisory-only. It does not use tools. If `files` are provided, their contents are read by the tool wrapper and embedded into the subagent prompt. - -### Reviewer - -Diff review subagent. - -Inputs: - -- `diff` -- `focus?` -- `context?` -- `skills?` - -Reviewer uses: - -- Pi read-only tools for repository inspection -- Pi `bash` for git/diff commands -- additional reviewer-specific custom tools - -It is intended for review of staged changes, commits, or scoped diffs. - -### Worker - -Sandboxed implementation subagent for known files. - -Inputs: - -- `task` -- `instructions` -- `files` -- `context?` -- `skills?` - -Worker does not explore the repo. It gets a restricted toolset: - -- scoped `read` -- scoped `edit` -- scoped `write` -- guarded `bash` - -The worker bash wrapper blocks policy-violating commands. Worker is intended to run verification before finishing and must not bypass checks with flags like `--no-verify`. - -## Models - -Each subagent has its own candidate model list in `extensions/subagents/config.ts`. - -Configured subagents: - -- `scout` -- `lookout` -- `oracle` -- `reviewer` -- `worker` - -The extension resolves model candidates per subagent and resets per-session selections on session start. - -## Skills - -`scout`, `lookout`, `oracle`, `reviewer`, and `worker` accept `skills`. - -Skill resolution is exact-name only and uses Pi skill discovery for the current cwd. Missing skill names are reported back to the subagent call. - -## Logging - -Each run gets its own log directory: - -`~/.pi/agent/subagents////` - -Files: - -- `stream.log` - human-readable run log -- `debug.jsonl` - raw event log when debug is enabled - -Run IDs look like: - -`--` - -## Settings - -This extension registers the `/subagents:settings` command. - -Current settings cover: - -- global debug logging -- per-subagent enabled/disabled state -- Scout web routing - - search order - - fetch order - - provider enable flags - - Exa search mode - - Linkup search depth - - Linkup `renderJsDefault` - -All tools are registered up front. Before each agent turn, disabled subagents are removed from the active tool list. - -## Files worth reading - -- `extensions/subagents/index.ts` - extension registration and activation logic -- `extensions/subagents/config.ts` - model candidates and Scout web config -- `extensions/subagents/commands/settings-command.ts` - `/subagents:settings` -- `extensions/subagents/lib/executor.ts` - shared subagent execution path -- `extensions/subagents/lib/logging/` - run log layout and writers -- `extensions/subagents/lib/skills.ts` - skill resolution -- `extensions/subagents/subagents/*` - per-subagent implementation - -## Adding a new subagent - -Use the `create-specialized-subagent` skill. - -Reference structure from an existing subagent such as: - -- `extensions/subagents/subagents/scout/` -- `extensions/subagents/subagents/worker/` - -Typical pieces: - -- `index.ts` -- `system-prompt.ts` -- `types.ts` -- `tools/` when the subagent needs custom tools diff --git a/extensions/subagents/commands/settings-command.ts b/extensions/subagents/commands/settings-command.ts deleted file mode 100644 index f6bb4d5b..00000000 --- a/extensions/subagents/commands/settings-command.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { - registerSettingsCommand, - type SettingsSection, -} from "@aliou/pi-utils-settings"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { - configLoader, - type ResolvedSubagentsConfig, - SCOUT_WEB_FETCH_PROVIDERS, - SCOUT_WEB_SEARCH_PROVIDERS, - SUBAGENT_NAMES, - type SubagentName, - type SubagentsConfig, -} from "../config"; - -const SUBAGENT_UI: Record< - SubagentName, - { label: string; description: string } -> = { - scout: { - label: "Scout", - description: "Web research and GitHub codebase exploration", - }, - lookout: { - label: "Lookout", - description: "Local codebase search by functionality/concept", - }, - oracle: { - label: "Oracle", - description: "Expert AI advisor for complex reasoning", - }, - reviewer: { - label: "Reviewer", - description: "Code review feedback on diffs", - }, - worker: { - label: "Worker", - description: - "Focused implementation agent with mandatory lint/typecheck/test verification", - }, -}; - -function parseCsvOrder(value: string): string[] { - return value - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0); -} - -export function registerSubagentsSettings(pi: ExtensionAPI): void { - registerSettingsCommand(pi, { - commandName: "subagents:settings", - commandDescription: "Configure subagent toggles and scout web routing", - title: "Subagents Settings", - configStore: configLoader, - buildSections: ( - tabConfig: SubagentsConfig | null, - resolved: ResolvedSubagentsConfig, - ): SettingsSection[] => { - const generalSection: SettingsSection = { - label: "General", - items: [ - { - id: "debug", - label: "Debug logging", - description: - "Write raw events to debug.jsonl for each subagent run", - currentValue: - (tabConfig?.debug ?? resolved.debug) ? "enabled" : "disabled", - values: ["enabled", "disabled"], - }, - ], - }; - - const subagentSections = SUBAGENT_NAMES.map((name) => { - const ui = SUBAGENT_UI[name]; - const currentEnabled = - tabConfig?.subagents?.[name]?.enabled ?? - resolved.subagents[name].enabled; - - const baseSection: SettingsSection = { - label: `${ui.label} - ${ui.description}`, - items: [ - { - id: `subagents.${name}.enabled`, - label: "Enabled", - description: `Enable or disable ${ui.label}`, - currentValue: currentEnabled ? "enabled" : "disabled", - values: ["enabled", "disabled"], - }, - ], - }; - - if (name !== "scout") return baseSection; - - const scoutWeb = resolved.subagents.scout.web; - const scoutWebTab = tabConfig?.subagents?.scout?.web; - - baseSection.items.push( - { - id: "subagents.scout.web.searchOrder", - label: "Search order", - description: "CSV order, first provider tried first", - currentValue: ( - scoutWebTab?.searchOrder ?? - scoutWeb?.searchOrder ?? - [] - ).join(","), - values: [ - "exa,linkup,synthetic", - "exa,synthetic,linkup", - "synthetic,exa,linkup", - ], - }, - { - id: "subagents.scout.web.fetchOrder", - label: "Fetch order", - description: "CSV order, first provider tried first", - currentValue: ( - scoutWebTab?.fetchOrder ?? - scoutWeb?.fetchOrder ?? - [] - ).join(","), - values: [ - "markdownDotNew,exa,linkup", - "exa,markdownDotNew,linkup", - "exa,linkup,markdownDotNew", - ], - }, - { - id: "subagents.scout.web.providers.exa.enabled", - label: "Exa enabled", - description: "Enable Exa for search/fetch", - currentValue: - (scoutWebTab?.providers?.exa?.enabled ?? - scoutWeb?.providers.exa.enabled) - ? "enabled" - : "disabled", - values: ["enabled", "disabled"], - }, - { - id: "subagents.scout.web.providers.linkup.enabled", - label: "Linkup enabled", - description: "Enable Linkup for search/fetch", - currentValue: - (scoutWebTab?.providers?.linkup?.enabled ?? - scoutWeb?.providers.linkup.enabled) - ? "enabled" - : "disabled", - values: ["enabled", "disabled"], - }, - { - id: "subagents.scout.web.providers.markdownDotNew.enabled", - label: "Markdown New enabled", - description: - "Enable Markdown New for web fetch (free, 500 req/day/IP)", - currentValue: - (scoutWebTab?.providers?.markdownDotNew?.enabled ?? - scoutWeb?.providers.markdownDotNew.enabled) - ? "enabled" - : "disabled", - values: ["enabled", "disabled"], - }, - { - id: "subagents.scout.web.providers.synthetic.enabled", - label: "Synthetic enabled", - description: "Enable Synthetic for web search", - currentValue: - (scoutWebTab?.providers?.synthetic?.enabled ?? - scoutWeb?.providers.synthetic.enabled) - ? "enabled" - : "disabled", - values: ["enabled", "disabled"], - }, - { - id: "subagents.scout.web.providers.exa.searchMode", - label: "Exa search mode", - description: "Exa /search mode", - currentValue: - scoutWebTab?.providers?.exa?.searchMode ?? - scoutWeb?.providers.exa.searchMode ?? - "auto", - values: ["auto", "fast", "deep", "instant"], - }, - { - id: "subagents.scout.web.providers.linkup.searchDepth", - label: "Linkup depth", - description: "Linkup /search depth", - currentValue: - scoutWebTab?.providers?.linkup?.searchDepth ?? - scoutWeb?.providers.linkup.searchDepth ?? - "fast", - values: ["standard", "deep", "fast"], - }, - { - id: "subagents.scout.web.providers.linkup.renderJsDefault", - label: "Linkup render JS", - description: "Default renderJs for Linkup fetch", - currentValue: - (scoutWebTab?.providers?.linkup?.renderJsDefault ?? - scoutWeb?.providers.linkup.renderJsDefault) - ? "enabled" - : "disabled", - values: ["enabled", "disabled"], - }, - ); - - return baseSection; - }); - - return [generalSection, ...subagentSections]; - }, - onSettingChange: ( - id: string, - newValue: string, - config: SubagentsConfig, - ): SubagentsConfig | null => { - const updated = structuredClone(config); - - if (id === "debug") { - updated.debug = newValue === "enabled"; - return updated; - } - - if (!updated.subagents) updated.subagents = {}; - - const parts = id.split("."); - if (parts[0] !== "subagents") return null; - - if (parts.length === 3) { - const name = parts[1] as SubagentName; - const field = parts[2] as "enabled"; - - const existing = updated.subagents[name] ?? {}; - updated.subagents[name] = existing; - - if (field === "enabled") { - existing.enabled = newValue === "enabled"; - return updated; - } - - return null; - } - - if (parts[1] !== "scout") return null; - if (!updated.subagents.scout) { - updated.subagents.scout = {}; - } - const scout = updated.subagents.scout; - if (!scout.web) { - scout.web = {}; - } - const web = scout.web; - - if (id === "subagents.scout.web.searchOrder") { - const values = parseCsvOrder(newValue).filter((p) => - SCOUT_WEB_SEARCH_PROVIDERS.includes( - p as (typeof SCOUT_WEB_SEARCH_PROVIDERS)[number], - ), - ) as (typeof SCOUT_WEB_SEARCH_PROVIDERS)[number][]; - if (values.length > 0) web.searchOrder = values; - return updated; - } - - if (id === "subagents.scout.web.fetchOrder") { - const values = parseCsvOrder(newValue).filter((p) => - SCOUT_WEB_FETCH_PROVIDERS.includes( - p as (typeof SCOUT_WEB_FETCH_PROVIDERS)[number], - ), - ) as (typeof SCOUT_WEB_FETCH_PROVIDERS)[number][]; - if (values.length > 0) web.fetchOrder = values; - return updated; - } - - if (!web.providers) { - web.providers = {}; - } - if (!web.providers.exa) { - web.providers.exa = {}; - } - if (!web.providers.linkup) { - web.providers.linkup = {}; - } - if (!web.providers.synthetic) { - web.providers.synthetic = {}; - } - if (!web.providers.markdownDotNew) { - web.providers.markdownDotNew = {}; - } - - const exa = web.providers.exa; - const linkup = web.providers.linkup; - const synthetic = web.providers.synthetic; - const markdownDotNew = web.providers.markdownDotNew; - - if (id === "subagents.scout.web.providers.exa.enabled") { - exa.enabled = newValue === "enabled"; - return updated; - } - - if (id === "subagents.scout.web.providers.linkup.enabled") { - linkup.enabled = newValue === "enabled"; - return updated; - } - - if (id === "subagents.scout.web.providers.synthetic.enabled") { - synthetic.enabled = newValue === "enabled"; - return updated; - } - - if (id === "subagents.scout.web.providers.markdownDotNew.enabled") { - markdownDotNew.enabled = newValue === "enabled"; - return updated; - } - - if (id === "subagents.scout.web.providers.exa.searchMode") { - exa.searchMode = newValue as "auto" | "fast" | "deep" | "instant"; - return updated; - } - - if (id === "subagents.scout.web.providers.linkup.searchDepth") { - linkup.searchDepth = newValue as "standard" | "deep" | "fast"; - return updated; - } - - if (id === "subagents.scout.web.providers.linkup.renderJsDefault") { - linkup.renderJsDefault = newValue === "enabled"; - return updated; - } - - return null; - }, - }); -} diff --git a/extensions/subagents/components/index.ts b/extensions/subagents/components/index.ts deleted file mode 100644 index 5e90fa51..00000000 --- a/extensions/subagents/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@aliou/pi-utils-ui"; diff --git a/extensions/subagents/config.ts b/extensions/subagents/config.ts deleted file mode 100644 index 8c0ef45a..00000000 --- a/extensions/subagents/config.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Configuration for the specialized subagents extension. - */ - -import { ConfigLoader } from "@aliou/pi-utils-settings"; - -/** Supported providers for subagents. */ -export const SUPPORTED_PROVIDERS = [ - "openrouter", - "anthropic", - "openai-codex", - "mistral", - "synthetic", - "neuralwatt", -] as const; -export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; - -export interface SubagentModelCandidate { - provider: SupportedProvider; - model: string; -} - -/** Subagent names that can be configured. */ -export const SUBAGENT_NAMES = [ - "scout", - "lookout", - "oracle", - "reviewer", - "worker", -] as const; -export type SubagentName = (typeof SUBAGENT_NAMES)[number]; - -export const SCOUT_WEB_SEARCH_PROVIDERS = [ - "exa", - "linkup", - "synthetic", -] as const; -export const SCOUT_WEB_FETCH_PROVIDERS = [ - "exa", - "linkup", - "markdownDotNew", -] as const; - -export type ScoutWebSearchProvider = - (typeof SCOUT_WEB_SEARCH_PROVIDERS)[number]; -export type ScoutWebFetchProvider = (typeof SCOUT_WEB_FETCH_PROVIDERS)[number]; - -export interface ScoutWebConfig { - searchOrder?: ScoutWebSearchProvider[]; - fetchOrder?: ScoutWebFetchProvider[]; - providers?: { - exa?: { - enabled?: boolean; - searchMode?: "auto" | "fast" | "deep" | "instant"; - }; - linkup?: { - enabled?: boolean; - searchDepth?: "standard" | "deep" | "fast"; - renderJsDefault?: boolean; - }; - synthetic?: { enabled?: boolean }; - markdownDotNew?: { enabled?: boolean }; - }; -} - -export interface ResolvedScoutWebConfig { - searchOrder: ScoutWebSearchProvider[]; - fetchOrder: ScoutWebFetchProvider[]; - providers: { - exa: { enabled: boolean; searchMode: "auto" | "fast" | "deep" | "instant" }; - linkup: { - enabled: boolean; - searchDepth: "standard" | "deep" | "fast"; - renderJsDefault: boolean; - }; - synthetic: { enabled: boolean }; - markdownDotNew: { enabled: boolean }; - }; -} - -export interface SubagentModelConfig { - candidates?: SubagentModelCandidate[]; - enabled?: boolean; - web?: ScoutWebConfig; -} - -export interface SubagentsConfig { - debug?: boolean; - subagents?: Partial>; -} - -export interface ResolvedSubagentModelConfig { - candidates: SubagentModelCandidate[]; - enabled: boolean; - web?: ResolvedScoutWebConfig; -} - -export interface ResolvedSubagentsConfig { - debug: boolean; - subagents: Record; -} - -const DEFAULT_SCOUT_WEB_CONFIG: ResolvedScoutWebConfig = { - searchOrder: ["synthetic", "exa", "linkup"], - fetchOrder: ["markdownDotNew", "exa", "linkup"], - providers: { - exa: { enabled: true, searchMode: "auto" }, - linkup: { enabled: true, searchDepth: "fast", renderJsDefault: false }, - synthetic: { enabled: true }, - markdownDotNew: { enabled: true }, - }, -}; - -const DEFAULT_CONFIG: ResolvedSubagentsConfig = { - debug: false, - subagents: { - scout: { - candidates: [ - { provider: "synthetic", model: "hf:zai-org/GLM-4.7-Flash" }, - { provider: "neuralwatt", model: "glm-5.1-fast" }, - { provider: "openai-codex", model: "gpt-5.4-mini" }, - ], - enabled: true, - web: DEFAULT_SCOUT_WEB_CONFIG, - }, - lookout: { - candidates: [ - { provider: "synthetic", model: "hf:zai-org/GLM-4.7-Flash" }, - { provider: "neuralwatt", model: "glm-5.1-fast" }, - { provider: "openai-codex", model: "gpt-5.4-mini" }, - { provider: "openai-codex", model: "gpt-5.3-codex-spark" }, - ], - enabled: true, - }, - oracle: { - candidates: [ - { provider: "openai-codex", model: "gpt-5.4" }, - { provider: "mistral", model: "magistral-medium-2509" }, - ], - enabled: true, - }, - reviewer: { - candidates: [ - { provider: "anthropic", model: "claude-sonnet-4-6" }, - { provider: "openai-codex", model: "gpt-5.3-codex" }, - ], - enabled: true, - }, - worker: { - candidates: [ - { provider: "anthropic", model: "claude-sonnet-4-6" }, - { provider: "synthetic", model: "hf:moonshotai/Kimi-K2.5" }, - { provider: "synthetic", model: "hf:zai-org/GLM-5.1" }, - ], - enabled: true, - }, - }, -}; - -export const configLoader = new ConfigLoader< - SubagentsConfig, - ResolvedSubagentsConfig ->("subagents", DEFAULT_CONFIG, { - scopes: ["global", "memory"], -}); - -/** Get the resolved model config for a subagent. */ -export function getSubagentModelConfig( - name: SubagentName, -): ResolvedSubagentModelConfig { - const resolved = configLoader.getConfig().subagents[name]; - if (resolved.candidates && resolved.candidates.length > 0) return resolved; - return { - ...resolved, - candidates: DEFAULT_CONFIG.subagents[name].candidates, - }; -} - -/** Whether debug logging is enabled for subagents. */ -export function isDebugEnabled(): boolean { - return configLoader.getConfig().debug; -} - -/** Whether a subagent is enabled. */ -export function isSubagentEnabled(name: SubagentName): boolean { - return configLoader.getConfig().subagents[name].enabled; -} - -function normalizeSearchOrder( - order: ScoutWebSearchProvider[] | undefined, -): ScoutWebSearchProvider[] { - const valid = new Set(SCOUT_WEB_SEARCH_PROVIDERS); - const filtered = (order ?? []).filter((p): p is ScoutWebSearchProvider => - valid.has(p), - ); - const deduped = [...new Set(filtered)]; - for (const provider of DEFAULT_SCOUT_WEB_CONFIG.searchOrder) { - if (!deduped.includes(provider)) deduped.push(provider); - } - return deduped; -} - -function normalizeFetchOrder( - order: ScoutWebFetchProvider[] | undefined, -): ScoutWebFetchProvider[] { - const valid = new Set(SCOUT_WEB_FETCH_PROVIDERS); - const filtered = (order ?? []).filter((p): p is ScoutWebFetchProvider => - valid.has(p), - ); - const deduped = [...new Set(filtered)]; - for (const provider of DEFAULT_SCOUT_WEB_CONFIG.fetchOrder) { - if (!deduped.includes(provider)) deduped.push(provider); - } - return deduped; -} - -export function getScoutWebConfig(): ResolvedScoutWebConfig { - const config = configLoader.getConfig(); - const web = config.subagents.scout.web; - - return { - searchOrder: normalizeSearchOrder(web?.searchOrder), - fetchOrder: normalizeFetchOrder(web?.fetchOrder), - providers: { - exa: { - enabled: - web?.providers?.exa?.enabled ?? - DEFAULT_SCOUT_WEB_CONFIG.providers.exa.enabled, - searchMode: - web?.providers?.exa?.searchMode ?? - DEFAULT_SCOUT_WEB_CONFIG.providers.exa.searchMode, - }, - linkup: { - enabled: - web?.providers?.linkup?.enabled ?? - DEFAULT_SCOUT_WEB_CONFIG.providers.linkup.enabled, - searchDepth: - web?.providers?.linkup?.searchDepth ?? - DEFAULT_SCOUT_WEB_CONFIG.providers.linkup.searchDepth, - renderJsDefault: - web?.providers?.linkup?.renderJsDefault ?? - DEFAULT_SCOUT_WEB_CONFIG.providers.linkup.renderJsDefault, - }, - synthetic: { - enabled: - web?.providers?.synthetic?.enabled ?? - DEFAULT_SCOUT_WEB_CONFIG.providers.synthetic.enabled, - }, - markdownDotNew: { - enabled: - web?.providers?.markdownDotNew?.enabled ?? - DEFAULT_SCOUT_WEB_CONFIG.providers.markdownDotNew.enabled, - }, - }, - }; -} diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts deleted file mode 100644 index 95c6b614..00000000 --- a/extensions/subagents/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { registerSubagentsSettings } from "./commands/settings-command"; -import { - configLoader, - isSubagentEnabled, - SUBAGENT_NAMES, - type SubagentName, -} from "./config"; -import { clearSubagentModelSelections } from "./lib/subagent-model-selection"; -import { createLookoutTool } from "./subagents/lookout"; -import { createOracleTool } from "./subagents/oracle"; -import { createReviewerTool } from "./subagents/reviewer"; -import { createScoutTool } from "./subagents/scout"; -import { createWorkerTool } from "./subagents/worker"; - -/** - * Subagents Extension - * - * Provides specialized subagents with custom tools: - * - scout: Web research and GitHub codebase exploration - * - lookout: Local codebase search by functionality/concept (uses osgrep) - * - oracle: Expert AI advisor for complex reasoning and planning - * - reviewer: Code review feedback on diffs - * - worker: Focused implementation agent for well-defined tasks on specific files - * - */ - -/** Check required API keys, throw if missing */ -function checkApiKeys(): string[] { - const missing: string[] = []; - - if (!process.env.SCOUT_GITHUB_TOKEN) { - missing.push("SCOUT_GITHUB_TOKEN"); - } - - return missing; -} - -export default async function (pi: ExtensionAPI) { - // Load config - await configLoader.load(); - - // Don't hard-fail extension load on missing API keys. - // This keeps no-external-deps tools (e.g. jester) easy to test. - const missing = checkApiKeys(); - if (missing.length > 0) { - console.warn( - `subagents: missing env vars (${missing.join(", ")}). Some tools may fail when invoked.`, - ); - } - - // Reset per-session model selections so each session can re-pick randomly. - pi.on("session_start", async () => { - clearSubagentModelSelections(); - }); - - // Register settings command - registerSubagentsSettings(pi); - - // Always register all tools so they can be toggled on/off dynamically. - pi.registerTool(createScoutTool()); - pi.registerTool(createLookoutTool()); - pi.registerTool(createOracleTool()); - pi.registerTool(createReviewerTool()); - pi.registerTool(createWorkerTool()); - - // Before each agent turn: sync active tools with current config. - pi.on("before_agent_start", async () => { - const disabledSubagents = new Set( - SUBAGENT_NAMES.filter((name) => !isSubagentEnabled(name)), - ); - - const activeTools = pi - .getActiveTools() - .filter((tool) => !disabledSubagents.has(tool as SubagentName)); - pi.setActiveTools(activeTools); - }); -} diff --git a/extensions/subagents/lib/clients/github.ts b/extensions/subagents/lib/clients/github.ts deleted file mode 100644 index 5faebe6c..00000000 --- a/extensions/subagents/lib/clients/github.ts +++ /dev/null @@ -1,1273 +0,0 @@ -/** - * Lightweight GitHub API client. - */ - -function createTimeoutSignal( - timeoutMs: number, - signal?: AbortSignal, -): AbortSignal { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - if (!signal) return timeoutSignal; - return AbortSignal.any([signal, timeoutSignal]); -} - -const GITHUB_BASE_URL = "https://api.github.com"; - -/** GitHub repository info */ -export interface GitHubRepository { - full_name: string; - description: string | null; - stargazers_count: number; - forks_count: number; - language: string | null; - license: { name: string } | null; - default_branch: string; - open_issues_count: number; - topics: string[]; -} - -/** GitHub directory item */ -export interface GitHubDirectoryItem { - name: string; - path: string; - type: "file" | "dir"; - size: number; - html_url: string; -} - -/** GitHub file content */ -export interface GitHubFileContent { - name: string; - path: string; - type: string; - size: number; - content?: string; - encoding?: string; -} - -/** GitHub README */ -export interface GitHubReadme { - content: string; - encoding: string; -} - -/** GitHub user */ -export interface GitHubUser { - login: string; - html_url: string; -} - -/** GitHub label */ -export interface GitHubLabel { - name: string; - color: string; -} - -/** GitHub issue */ -export interface GitHubIssue { - number: number; - title: string; - state: "open" | "closed"; - body: string | null; - user: GitHubUser; - labels: GitHubLabel[]; - assignees: GitHubUser[]; - created_at: string; - updated_at: string; - closed_at: string | null; - comments: number; - html_url: string; -} - -/** GitHub PR */ -export interface GitHubPullRequest { - number: number; - title: string; - state: "open" | "closed" | "merged"; - body: string | null; - user: GitHubUser; - labels: GitHubLabel[]; - assignees: GitHubUser[]; - created_at: string; - updated_at: string; - closed_at: string | null; - merged_at: string | null; - comments: number; - commits: number; - additions: number; - deletions: number; - changed_files: number; - html_url: string; - head: { ref: string; sha: string }; - base: { ref: string; sha: string }; - mergeable: boolean | null; - draft: boolean; -} - -/** GitHub comment */ -export interface GitHubComment { - id: number; - body: string; - user: GitHubUser; - created_at: string; - updated_at: string; -} - -/** GitHub PR review comment (inline on code) */ -export interface GitHubReviewComment { - id: number; - body: string; - user: GitHubUser; - path: string; - line: number | null; - original_line: number | null; - side: "LEFT" | "RIGHT"; - diff_hunk: string; - created_at: string; - updated_at: string; - in_reply_to_id?: number; - pull_request_review_id: number | null; -} - -/** GitHub PR review */ -export interface GitHubReview { - id: number; - user: GitHubUser; - body: string | null; - state: - | "APPROVED" - | "CHANGES_REQUESTED" - | "COMMENTED" - | "DISMISSED" - | "PENDING"; - submitted_at: string; - html_url: string; -} - -/** GitHub compare response */ -export interface GitHubCompareResponse { - status: "diverged" | "ahead" | "behind" | "identical"; - ahead_by: number; - behind_by: number; - total_commits: number; - commits: GitHubCommit[]; - files: GitHubCommitFile[]; -} - -/** GitHub code search result item */ -export interface GitHubCodeSearchItem { - name: string; - path: string; - sha: string; - html_url: string; - repository: { - full_name: string; - html_url: string; - }; -} - -/** GitHub code search response */ -export interface GitHubCodeSearchResponse { - total_count: number; - incomplete_results: boolean; - items: GitHubCodeSearchItem[]; -} - -/** GitHub commit author */ -export interface GitHubCommitAuthor { - name: string; - email: string; - date: string; -} - -/** GitHub commit */ -export interface GitHubCommit { - sha: string; - html_url: string; - commit: { - message: string; - author: GitHubCommitAuthor; - committer: GitHubCommitAuthor; - }; - author: GitHubUser | null; - committer: GitHubUser | null; -} - -/** GitHub commit search result item */ -export interface GitHubCommitSearchItem { - sha: string; - html_url: string; - commit: { - message: string; - author: GitHubCommitAuthor; - committer: GitHubCommitAuthor; - }; - author: GitHubUser | null; - repository: { - full_name: string; - }; -} - -/** GitHub commit search response */ -export interface GitHubCommitSearchResponse { - total_count: number; - incomplete_results: boolean; - items: GitHubCommitSearchItem[]; -} - -/** GitHub commit file (for diff) */ -export interface GitHubCommitFile { - sha: string; - filename: string; - status: - | "added" - | "removed" - | "modified" - | "renamed" - | "copied" - | "changed" - | "unchanged"; - additions: number; - deletions: number; - changes: number; - patch?: string; - previous_filename?: string; -} - -/** GitHub commit detail (with diff) */ -export interface GitHubCommitDetail { - sha: string; - html_url: string; - commit: { - message: string; - author: GitHubCommitAuthor; - committer: GitHubCommitAuthor; - }; - author: GitHubUser | null; - committer: GitHubUser | null; - stats: { - additions: number; - deletions: number; - total: number; - }; - files: GitHubCommitFile[]; -} - -/** Parsed GitHub URL */ -export interface ParsedGitHubUrl { - owner: string; - repo: string; - type: "repo" | "file" | "directory" | "tree" | "issue" | "pull"; - path?: string; - ref?: string; - number?: number; -} - -/** Language mapping for syntax highlighting */ -const EXTENSION_TO_LANGUAGE: Record = { - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - py: "python", - rb: "ruby", - go: "go", - rs: "rust", - java: "java", - kt: "kotlin", - swift: "swift", - c: "c", - cpp: "cpp", - h: "c", - hpp: "cpp", - cs: "csharp", - php: "php", - sh: "bash", - bash: "bash", - zsh: "bash", - yml: "yaml", - yaml: "yaml", - json: "json", - xml: "xml", - html: "html", - css: "css", - scss: "scss", - less: "less", - sql: "sql", - md: "markdown", - markdown: "markdown", -}; - -export class GitHubClient { - private token: string; - - constructor() { - const token = process.env.SCOUT_GITHUB_TOKEN; - if (!token) { - throw new Error("SCOUT_GITHUB_TOKEN environment variable is not set."); - } - this.token = token; - } - - async get( - endpoint: string, - params?: Record, - signal?: AbortSignal, - ): Promise { - const url = new URL(`${GITHUB_BASE_URL}${endpoint}`); - if (params) { - for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, value); - } - } - - const response = await fetch(url.toString(), { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - Authorization: `Bearer ${this.token}`, - }, - signal: createTimeoutSignal(5000, signal), - }); - - if (!response.ok) { - const text = await response.text(); - if (response.status === 404) { - throw new Error(`Not found: ${endpoint}`); - } - if (response.status === 403 || response.status === 429) { - throw new Error(`Rate limit exceeded or access denied: ${text}`); - } - if (response.status === 401) { - throw new Error( - "Authentication failed. Check SCOUT_GITHUB_TOKEN validity.", - ); - } - throw new Error(`GitHub API error (${response.status}): ${text}`); - } - - return response.json() as Promise; - } - - /** Fetch repository info with README */ - async fetchRepoInfo( - owner: string, - repo: string, - signal?: AbortSignal, - ): Promise { - const repoData = await this.get( - `/repos/${owner}/${repo}`, - undefined, - signal, - ); - - let markdown = `# ${repoData.full_name}\n\n`; - - if (repoData.description) { - markdown += `${repoData.description}\n\n`; - } - - markdown += `## Repository Info\n\n`; - markdown += `- **Stars:** ${repoData.stargazers_count}\n`; - markdown += `- **Forks:** ${repoData.forks_count}\n`; - markdown += `- **Language:** ${repoData.language || "Not specified"}\n`; - markdown += `- **License:** ${repoData.license?.name || "Not specified"}\n`; - markdown += `- **Default Branch:** ${repoData.default_branch}\n`; - markdown += `- **Open Issues:** ${repoData.open_issues_count}\n`; - - if (repoData.topics.length > 0) { - markdown += `- **Topics:** ${repoData.topics.join(", ")}\n`; - } - - markdown += `\n`; - - // Try to fetch README - try { - const readme = await this.get( - `/repos/${owner}/${repo}/readme`, - undefined, - signal, - ); - const readmeContent = Buffer.from(readme.content, "base64").toString( - "utf-8", - ); - markdown += `## README\n\n${readmeContent}\n`; - } catch { - // README not found, skip - } - - // Fetch root directory structure - try { - const items = await this.get( - `/repos/${owner}/${repo}/contents`, - undefined, - signal, - ); - - markdown += `## Repository Structure\n\n`; - for (const item of items) { - const icon = item.type === "dir" ? "[d]" : "[f]"; - markdown += `- ${icon} ${item.name}\n`; - } - } catch { - // Failed to fetch structure, skip - } - - return markdown; - } - - /** Fetch file content */ - async fetchFileContent( - owner: string, - repo: string, - path: string, - ref?: string, - signal?: AbortSignal, - ): Promise { - const params: Record = {}; - if (ref) { - params.ref = ref; - } - - const data = await this.get( - `/repos/${owner}/${repo}/contents/${path}`, - Object.keys(params).length > 0 ? params : undefined, - signal, - ); - - if (data.type !== "file") { - throw new Error(`Path ${path} is not a file`); - } - - if (!data.content || !data.encoding) { - throw new Error(`File ${path} has no content`); - } - - const content = Buffer.from(data.content, "base64").toString("utf-8"); - - let markdown = `# ${data.name}\n\n`; - markdown += `**Path:** \`${data.path}\`\n`; - markdown += `**Size:** ${data.size} bytes\n\n`; - - const ext = path.split(".").pop()?.toLowerCase() || ""; - const lang = EXTENSION_TO_LANGUAGE[ext] || ""; - markdown += `\`\`\`${lang}\n${content}\n\`\`\`\n`; - - return markdown; - } - - /** Fetch directory listing */ - async fetchDirectoryContent( - owner: string, - repo: string, - path: string, - ref?: string, - signal?: AbortSignal, - ): Promise { - const params: Record = {}; - if (ref) { - params.ref = ref; - } - - const items = await this.get( - `/repos/${owner}/${repo}/contents/${path}`, - Object.keys(params).length > 0 ? params : undefined, - signal, - ); - - let markdown = `# ${owner}/${repo}/${path}\n\n`; - markdown += `## Contents\n\n`; - - // Sort: directories first, then files - const sorted = items.sort((a, b) => { - if (a.type === "dir" && b.type !== "dir") return -1; - if (a.type !== "dir" && b.type === "dir") return 1; - return a.name.localeCompare(b.name); - }); - - for (const item of sorted) { - const icon = item.type === "dir" ? "[d]" : "[f]"; - markdown += `- ${icon} ${item.name}`; - if (item.type === "file" && item.size > 0) { - markdown += ` (${item.size} bytes)`; - } - markdown += `\n`; - } - - return markdown; - } - - /** Fetch issue with comments */ - async fetchIssue( - owner: string, - repo: string, - issueNumber: number, - signal?: AbortSignal, - ): Promise { - const issue = await this.get( - `/repos/${owner}/${repo}/issues/${issueNumber}`, - undefined, - signal, - ); - - let markdown = `# Issue #${issue.number}: ${issue.title}\n\n`; - - markdown += `**State:** ${issue.state}\n`; - markdown += `**Author:** [@${issue.user.login}](${issue.user.html_url})\n`; - markdown += `**Created:** ${issue.created_at}\n`; - markdown += `**Updated:** ${issue.updated_at}\n`; - - if (issue.closed_at) { - markdown += `**Closed:** ${issue.closed_at}\n`; - } - - if (issue.labels.length > 0) { - markdown += `**Labels:** ${issue.labels.map((l) => l.name).join(", ")}\n`; - } - - if (issue.assignees.length > 0) { - markdown += `**Assignees:** ${issue.assignees.map((a) => `@${a.login}`).join(", ")}\n`; - } - - markdown += `**URL:** ${issue.html_url}\n\n`; - - markdown += `## Description\n\n`; - markdown += issue.body || "_No description provided._"; - markdown += `\n\n`; - - // Fetch comments if any - if (issue.comments > 0) { - try { - const comments = await this.get( - `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, - { per_page: "100" }, - signal, - ); - - markdown += `## Comments (${comments.length})\n\n`; - - for (const comment of comments) { - markdown += `### @${comment.user.login} - ${comment.created_at}\n\n`; - markdown += `${comment.body}\n\n`; - markdown += `---\n\n`; - } - } catch { - markdown += `_Failed to load ${issue.comments} comments._\n\n`; - } - } - - return markdown; - } - - /** Fetch pull request with comments */ - async fetchPullRequest( - owner: string, - repo: string, - prNumber: number, - signal?: AbortSignal, - ): Promise { - const pr = await this.get( - `/repos/${owner}/${repo}/pulls/${prNumber}`, - undefined, - signal, - ); - - let markdown = `# PR #${pr.number}: ${pr.title}\n\n`; - - // Determine state - let state = pr.state; - if (pr.merged_at) { - state = "merged"; - } - - markdown += `**State:** ${state}${pr.draft ? " (draft)" : ""}\n`; - markdown += `**Author:** [@${pr.user.login}](${pr.user.html_url})\n`; - markdown += `**Branch:** \`${pr.head.ref}\` → \`${pr.base.ref}\`\n`; - markdown += `**Created:** ${pr.created_at}\n`; - markdown += `**Updated:** ${pr.updated_at}\n`; - - if (pr.merged_at) { - markdown += `**Merged:** ${pr.merged_at}\n`; - } else if (pr.closed_at) { - markdown += `**Closed:** ${pr.closed_at}\n`; - } - - if (pr.labels.length > 0) { - markdown += `**Labels:** ${pr.labels.map((l) => l.name).join(", ")}\n`; - } - - if (pr.assignees.length > 0) { - markdown += `**Assignees:** ${pr.assignees.map((a) => `@${a.login}`).join(", ")}\n`; - } - - markdown += `\n`; - markdown += `**Stats:** +${pr.additions} -${pr.deletions} in ${pr.changed_files} files (${pr.commits} commits)\n`; - markdown += `**URL:** ${pr.html_url}\n\n`; - - markdown += `## Description\n\n`; - markdown += pr.body || "_No description provided._"; - markdown += `\n\n`; - - // Fetch comments if any - if (pr.comments > 0) { - try { - const comments = await this.get( - `/repos/${owner}/${repo}/issues/${prNumber}/comments`, - { per_page: "100" }, - signal, - ); - - markdown += `## Comments (${comments.length})\n\n`; - - for (const comment of comments) { - markdown += `### @${comment.user.login} - ${comment.created_at}\n\n`; - markdown += `${comment.body}\n\n`; - markdown += `---\n\n`; - } - } catch { - markdown += `_Failed to load comments._\n\n`; - } - } - - return markdown; - } - - /** Search code across GitHub */ - async searchCode( - query: string, - repo?: string, - signal?: AbortSignal, - ): Promise { - // Build search query - let q = query; - if (repo) { - q = `${query} repo:${repo}`; - } - - const data = await this.get( - "/search/code", - { q, per_page: "30" }, - signal, - ); - - let markdown = `# Code Search Results\n\n`; - markdown += `**Query:** \`${query}\`\n`; - if (repo) { - markdown += `**Repository:** ${repo}\n`; - } - markdown += `**Total Results:** ${data.total_count}${data.incomplete_results ? " (incomplete)" : ""}\n\n`; - - if (data.items.length === 0) { - markdown += `_No code matching the query was found._\n`; - return markdown; - } - - markdown += `## Results\n\n`; - - for (const item of data.items) { - markdown += `### ${item.path}\n\n`; - markdown += `- **Repository:** ${item.repository.full_name}\n`; - markdown += `- **URL:** ${item.html_url}\n\n`; - } - - return markdown; - } - - /** List repositories for a user */ - async listUserRepos( - username: string, - options?: { - language?: string; - namePrefix?: string; - sort?: string; - order?: string; - per_page?: number; - page?: number; - }, - signal?: AbortSignal, - ): Promise { - // Build search query - let q = `user:${username}`; - if (options?.language) { - q += ` language:${options.language}`; - } - if (options?.namePrefix) { - q += ` ${options.namePrefix} in:name`; - } - - const params: Record = { - q, - per_page: String(options?.per_page ?? 30), - page: String(options?.page ?? 1), - }; - if (options?.sort) { - params.sort = options.sort; - } - if (options?.order) { - params.order = options.order; - } - - const data = await this.get<{ - total_count: number; - incomplete_results: boolean; - items: GitHubRepository[]; - }>("/search/repositories", params, signal); - - let markdown = `# Repositories for @${username}\n\n`; - markdown += `**Total Results:** ${data.total_count}${data.incomplete_results ? " (incomplete)" : ""}\n\n`; - - if (data.items.length === 0) { - markdown += `_No repositories found._\n`; - return markdown; - } - - markdown += `## Repositories\n\n`; - - for (const repo of data.items) { - markdown += `### [${repo.full_name}](${`https://github.com/${repo.full_name}`})\n\n`; - markdown += `- **Stars:** ${repo.stargazers_count}\n`; - markdown += `- **Forks:** ${repo.forks_count}\n`; - if (repo.language) { - markdown += `- **Language:** ${repo.language}\n`; - } - if (repo.license) { - markdown += `- **License:** ${repo.license.name}\n`; - } - if (repo.description) { - markdown += `- **Description:** ${repo.description}\n`; - } - if (repo.topics.length > 0) { - markdown += `- **Topics:** ${repo.topics.join(", ")}\n`; - } - markdown += `\n`; - } - - return markdown; - } - - /** Search commits in a repository */ - async searchCommits( - owner: string, - repo: string, - options?: { query?: string; author?: string; path?: string }, - signal?: AbortSignal, - ): Promise { - // If we have a query, use the search API - if (options?.query) { - let q = `${options.query} repo:${owner}/${repo}`; - if (options.author) { - q += ` author:${options.author}`; - } - - const data = await this.get( - "/search/commits", - { q, per_page: "30" }, - signal, - ); - - let markdown = `# Commit Search Results\n\n`; - markdown += `**Repository:** ${owner}/${repo}\n`; - markdown += `**Query:** \`${options.query}\`\n`; - if (options.author) { - markdown += `**Author:** ${options.author}\n`; - } - markdown += `**Total Results:** ${data.total_count}${data.incomplete_results ? " (incomplete)" : ""}\n\n`; - - if (data.items.length === 0) { - markdown += `_No commits matching the query were found._\n`; - return markdown; - } - - markdown += `## Commits\n\n`; - - for (const item of data.items) { - const shortSha = item.sha.substring(0, 7); - const message = item.commit.message.split("\n")[0]; // First line only - const date = item.commit.author.date.split("T")[0]; - const author = item.author?.login || item.commit.author.name; - - markdown += `- **${shortSha}** (${date}) - ${author}: ${message}\n`; - } - - return markdown; - } - - // Otherwise use the commits list API which supports path and author filtering - const params: Record = { per_page: "30" }; - if (options?.author) { - params.author = options.author; - } - if (options?.path) { - params.path = options.path; - } - - const commits = await this.get( - `/repos/${owner}/${repo}/commits`, - params, - signal, - ); - - let markdown = `# Commits\n\n`; - markdown += `**Repository:** ${owner}/${repo}\n`; - if (options?.author) { - markdown += `**Author:** ${options.author}\n`; - } - if (options?.path) { - markdown += `**Path:** ${options.path}\n`; - } - markdown += `\n`; - - if (commits.length === 0) { - markdown += `_No commits found._\n`; - return markdown; - } - - markdown += `## Commits\n\n`; - - for (const commit of commits) { - const shortSha = commit.sha.substring(0, 7); - const message = commit.commit.message.split("\n")[0]; // First line only - const date = commit.commit.author.date.split("T")[0]; - const author = commit.author?.login || commit.commit.author.name; - - markdown += `- **${shortSha}** (${date}) - ${author}: ${message}\n`; - } - - return markdown; - } - - /** List issues and/or PRs in a repository */ - async listIssues( - owner: string, - repo: string, - options?: { - state?: "open" | "closed" | "all"; - labels?: string; - sort?: "created" | "updated" | "comments"; - direction?: "asc" | "desc"; - type?: "issue" | "pr" | "all"; - author?: string; - assignee?: string; - milestone?: string; - per_page?: number; - page?: number; - }, - signal?: AbortSignal, - ): Promise { - const params: Record = { - state: options?.state ?? "open", - sort: options?.sort ?? "created", - direction: options?.direction ?? "desc", - per_page: String(options?.per_page ?? 30), - page: String(options?.page ?? 1), - }; - if (options?.labels) { - params.labels = options.labels; - } - if (options?.assignee) { - params.assignee = options.assignee; - } - if (options?.milestone) { - params.milestone = options.milestone; - } - - const issues = await this.get( - `/repos/${owner}/${repo}/issues`, - params, - signal, - ); - - // GitHub Issues API returns both issues and PRs. PRs have a pull_request key. - const typeFilter = options?.type ?? "all"; - const authorFilter = options?.author; - - const filtered = issues.filter((issue) => { - const isPr = "pull_request" in issue; - if (typeFilter === "issue" && isPr) return false; - if (typeFilter === "pr" && !isPr) return false; - if (authorFilter && issue.user.login !== authorFilter) return false; - return true; - }); - - const stateLabel = options?.state ?? "open"; - const typeLabel = - typeFilter === "issue" - ? "Issues" - : typeFilter === "pr" - ? "Pull Requests" - : "Issues & PRs"; - - let markdown = `# ${typeLabel} - ${owner}/${repo}\n\n`; - markdown += `**State:** ${stateLabel}\n`; - if (options?.labels) { - markdown += `**Labels:** ${options.labels}\n`; - } - if (authorFilter) { - markdown += `**Author:** ${authorFilter}\n`; - } - if (options?.assignee) { - markdown += `**Assignee:** ${options.assignee}\n`; - } - markdown += `**Showing:** ${filtered.length} results\n\n`; - - if (filtered.length === 0) { - markdown += `_No matching items found._\n`; - return markdown; - } - - for (const issue of filtered) { - const isPr = "pull_request" in issue; - const prefix = isPr ? "PR" : "Issue"; - const labels = - issue.labels.length > 0 - ? ` [${issue.labels.map((l) => l.name).join(", ")}]` - : ""; - const date = issue.created_at.split("T")[0]; - markdown += `- **#${issue.number}** (${prefix}) ${issue.title}${labels} - @${issue.user.login} (${date})\n`; - } - - return markdown; - } - - /** Get PR diff (changed files with patches) */ - async getPullRequestDiff( - owner: string, - repo: string, - prNumber: number, - signal?: AbortSignal, - ): Promise { - const files = await this.get( - `/repos/${owner}/${repo}/pulls/${prNumber}/files`, - { per_page: "100" }, - signal, - ); - - // Also fetch PR metadata for context - const pr = await this.get( - `/repos/${owner}/${repo}/pulls/${prNumber}`, - undefined, - signal, - ); - - let state = pr.state; - if (pr.merged_at) { - state = "merged"; - } - - let markdown = `# PR #${pr.number} Diff: ${pr.title}\n\n`; - markdown += `**State:** ${state}${pr.draft ? " (draft)" : ""}\n`; - markdown += `**Branch:** \`${pr.head.ref}\` -> \`${pr.base.ref}\`\n`; - markdown += `**Stats:** +${pr.additions} -${pr.deletions} in ${pr.changed_files} files\n`; - markdown += `**URL:** ${pr.html_url}\n\n`; - - markdown += `## Changed Files (${files.length})\n\n`; - - for (const file of files) { - const statusIcon = - file.status === "added" - ? "[+]" - : file.status === "removed" - ? "[-]" - : file.status === "renamed" - ? "[R]" - : "[M]"; - - markdown += `### ${statusIcon} ${file.filename}\n\n`; - - if (file.previous_filename) { - markdown += `_Renamed from: ${file.previous_filename}_\n\n`; - } - - markdown += `**Status:** ${file.status} (+${file.additions} -${file.deletions})\n\n`; - - if (file.patch) { - markdown += `\`\`\`diff\n${file.patch}\n\`\`\`\n\n`; - } - } - - return markdown; - } - - /** Get PR review comments (inline code comments) */ - async getPullRequestReviews( - owner: string, - repo: string, - prNumber: number, - signal?: AbortSignal, - ): Promise { - // Fetch reviews and inline comments in parallel - const [reviews, reviewComments] = await Promise.all([ - this.get( - `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, - { per_page: "100" }, - signal, - ), - this.get( - `/repos/${owner}/${repo}/pulls/${prNumber}/comments`, - { per_page: "100" }, - signal, - ), - ]); - - // Also fetch PR metadata for context - const pr = await this.get( - `/repos/${owner}/${repo}/pulls/${prNumber}`, - undefined, - signal, - ); - - let markdown = `# PR #${pr.number} Reviews: ${pr.title}\n\n`; - markdown += `**Branch:** \`${pr.head.ref}\` -> \`${pr.base.ref}\`\n`; - markdown += `**URL:** ${pr.html_url}\n\n`; - - // Reviews summary - if (reviews.length > 0) { - markdown += `## Reviews (${reviews.length})\n\n`; - for (const review of reviews) { - const stateIcon = - review.state === "APPROVED" - ? "[APPROVED]" - : review.state === "CHANGES_REQUESTED" - ? "[CHANGES REQUESTED]" - : review.state === "COMMENTED" - ? "[COMMENTED]" - : review.state === "DISMISSED" - ? "[DISMISSED]" - : "[PENDING]"; - markdown += `### @${review.user.login} ${stateIcon}\n\n`; - markdown += `**Submitted:** ${review.submitted_at}\n\n`; - if (review.body) { - markdown += `${review.body}\n\n`; - } - markdown += `---\n\n`; - } - } else { - markdown += `## Reviews\n\n_No reviews yet._\n\n`; - } - - // Inline review comments - if (reviewComments.length > 0) { - // Group by file - const byFile = new Map(); - for (const comment of reviewComments) { - const existing = byFile.get(comment.path) ?? []; - existing.push(comment); - byFile.set(comment.path, existing); - } - - markdown += `## Inline Comments (${reviewComments.length})\n\n`; - - for (const [filePath, comments] of byFile) { - markdown += `### ${filePath}\n\n`; - for (const comment of comments) { - const line = comment.line ?? comment.original_line; - const lineInfo = line ? ` (line ${line})` : ""; - const replyInfo = comment.in_reply_to_id ? " (reply)" : ""; - markdown += `#### @${comment.user.login}${lineInfo}${replyInfo} - ${comment.created_at}\n\n`; - if (comment.diff_hunk) { - markdown += `\`\`\`diff\n${comment.diff_hunk}\n\`\`\`\n\n`; - } - markdown += `${comment.body}\n\n`; - markdown += `---\n\n`; - } - } - } else { - markdown += `## Inline Comments\n\n_No inline comments._\n\n`; - } - - return markdown; - } - - /** Compare two branches/refs */ - async compareRefs( - owner: string, - repo: string, - base: string, - head: string, - signal?: AbortSignal, - ): Promise { - const data = await this.get( - `/repos/${owner}/${repo}/compare/${base}...${head}`, - undefined, - signal, - ); - - let markdown = `# Compare: ${base}...${head}\n\n`; - markdown += `**Repository:** ${owner}/${repo}\n`; - markdown += `**Status:** ${data.status}\n`; - markdown += `**Ahead by:** ${data.ahead_by} commits\n`; - markdown += `**Behind by:** ${data.behind_by} commits\n`; - markdown += `**Total commits:** ${data.total_commits}\n`; - markdown += `**Files changed:** ${data.files.length}\n\n`; - - // Commits - if (data.commits.length > 0) { - markdown += `## Commits (${data.commits.length})\n\n`; - for (const commit of data.commits) { - const shortSha = commit.sha.substring(0, 7); - const message = commit.commit.message.split("\n")[0]; - const date = commit.commit.author.date.split("T")[0]; - const author = commit.author?.login || commit.commit.author.name; - markdown += `- **${shortSha}** (${date}) - ${author}: ${message}\n`; - } - markdown += `\n`; - } - - // Files changed - if (data.files.length > 0) { - markdown += `## Files Changed (${data.files.length})\n\n`; - for (const file of data.files) { - const statusIcon = - file.status === "added" - ? "[+]" - : file.status === "removed" - ? "[-]" - : file.status === "renamed" - ? "[R]" - : "[M]"; - - markdown += `### ${statusIcon} ${file.filename}\n\n`; - - if (file.previous_filename) { - markdown += `_Renamed from: ${file.previous_filename}_\n\n`; - } - - markdown += `**Status:** ${file.status} (+${file.additions} -${file.deletions})\n\n`; - - if (file.patch) { - markdown += `\`\`\`diff\n${file.patch}\n\`\`\`\n\n`; - } - } - } - - return markdown; - } - - /** Get diff for a specific commit */ - async getCommitDiff( - owner: string, - repo: string, - sha: string, - signal?: AbortSignal, - ): Promise { - const commit = await this.get( - `/repos/${owner}/${repo}/commits/${sha}`, - undefined, - signal, - ); - - const shortSha = commit.sha.substring(0, 7); - const message = commit.commit.message; - const date = commit.commit.author.date; - const author = commit.author?.login || commit.commit.author.name; - - let markdown = `# Commit ${shortSha}\n\n`; - markdown += `**SHA:** ${commit.sha}\n`; - markdown += `**Author:** ${author}\n`; - markdown += `**Date:** ${date}\n`; - markdown += `**URL:** ${commit.html_url}\n\n`; - - markdown += `## Message\n\n`; - markdown += `${message}\n\n`; - - markdown += `## Stats\n\n`; - markdown += `- **Additions:** +${commit.stats.additions}\n`; - markdown += `- **Deletions:** -${commit.stats.deletions}\n`; - markdown += `- **Total Changes:** ${commit.stats.total}\n`; - markdown += `- **Files Changed:** ${commit.files.length}\n\n`; - - markdown += `## Files Changed\n\n`; - - for (const file of commit.files) { - const statusIcon = - file.status === "added" - ? "+" - : file.status === "removed" - ? "-" - : file.status === "renamed" - ? "~" - : " "; - - markdown += `### ${statusIcon} ${file.filename}\n\n`; - - if (file.previous_filename) { - markdown += `_Renamed from: ${file.previous_filename}_\n\n`; - } - - markdown += `**Status:** ${file.status} (+${file.additions} -${file.deletions})\n\n`; - - if (file.patch) { - markdown += `\`\`\`diff\n${file.patch}\n\`\`\`\n\n`; - } - } - - return markdown; - } -} - -/** Create a GitHub client (throws if GITHUB_TOKEN not set) */ -export function createGitHubClient(): GitHubClient { - return new GitHubClient(); -} - -/** Parse GitHub URL to extract owner, repo, and path info */ -export function parseGitHubUrl(url: string): ParsedGitHubUrl { - const parsed = new URL(url); - if (parsed.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${url}`); - } - - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${url}`); - } - - const owner = parts[0]; - const repo = parts[1]; - if (!owner || !repo) { - throw new Error(`Invalid GitHub URL: ${url}`); - } - - // Just owner/repo - if (parts.length === 2) { - return { owner, repo, type: "repo" }; - } - - const part2 = parts[2]; - const part3 = parts[3]; - - // Issues: owner/repo/issues/123 - if (part2 === "issues" && parts.length >= 4 && part3) { - const number = parseInt(part3, 10); - if (Number.isNaN(number)) { - throw new Error(`Invalid issue number: ${part3}`); - } - return { owner, repo, type: "issue", number }; - } - - // Pull requests: owner/repo/pull/123 - if (part2 === "pull" && parts.length >= 4 && part3) { - const number = parseInt(part3, 10); - if (Number.isNaN(number)) { - throw new Error(`Invalid PR number: ${part3}`); - } - return { owner, repo, type: "pull", number }; - } - - // owner/repo/blob/ref/path (file) - if (part2 === "blob" && parts.length >= 4 && part3) { - const ref = part3; - const path = parts.slice(4).join("/"); - return { owner, repo, type: "file", path, ref }; - } - - // owner/repo/tree/ref/path (directory or tree) - if (part2 === "tree" && parts.length >= 4 && part3) { - const ref = part3; - const path = parts.slice(4).join("/") || undefined; - return { owner, repo, type: path ? "directory" : "tree", path, ref }; - } - - // Fallback: treat as repo - return { owner, repo, type: "repo" }; -} diff --git a/extensions/subagents/lib/clients/index.ts b/extensions/subagents/lib/clients/index.ts deleted file mode 100644 index d09c9df0..00000000 --- a/extensions/subagents/lib/clients/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * API clients for external services. - */ - -export { - createGitHubClient, - GitHubClient, - type GitHubComment, - type GitHubDirectoryItem, - type GitHubFileContent, - type GitHubIssue, - type GitHubLabel, - type GitHubPullRequest, - type GitHubReadme, - type GitHubRepository, - type GitHubUser, - type ParsedGitHubUrl, - parseGitHubUrl, -} from "./github"; diff --git a/extensions/subagents/lib/constants.ts b/extensions/subagents/lib/constants.ts deleted file mode 100644 index 33f1a075..00000000 --- a/extensions/subagents/lib/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** - * Shared constants for specialized subagents. - */ diff --git a/extensions/subagents/lib/error-classification.ts b/extensions/subagents/lib/error-classification.ts deleted file mode 100644 index 3d545c30..00000000 --- a/extensions/subagents/lib/error-classification.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Error classification helpers for subagent failures. - */ - -import type { SubagentResult } from "./types"; - -/** - * Detect model availability/routing/auth failures that should fail the whole tool call. - */ -export function isModelAvailabilityError(message: string): boolean { - const text = message.toLowerCase(); - - return [ - "no route found for model", - "model_not_found", - "unknown model", - "not found on provider", - "no valid api key is configured", - "exists on", - ].some((needle) => text.includes(needle)); -} - -/** - * True when a subagent failed because the configured model is unavailable. - */ -export function shouldFailToolCallForModelIssue( - result: SubagentResult, -): boolean { - const stopReason = result.stopReason?.toLowerCase(); - const combined = [result.providerErrorMessage, result.error] - .filter(Boolean) - .join("\n"); - - if (!combined) return false; - - // Prefer hard signal from model turn error, but allow fallback when stopReason isn't populated. - if (stopReason && stopReason !== "error") return false; - - return isModelAvailabilityError(combined); -} diff --git a/extensions/subagents/lib/executor.ts b/extensions/subagents/lib/executor.ts deleted file mode 100644 index 0ce52713..00000000 --- a/extensions/subagents/lib/executor.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Core subagent executor. - * - * Uses createAgentSession from the SDK for all subagent patterns. - * Supports streaming text updates, tool execution tracking, logging, and usage tracking. - */ - -import type { AssistantMessage } from "@mariozechner/pi-ai"; -import type { - CreateAgentSessionOptions, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - DefaultResourceLoader, - getAgentDir, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; -import { - createExecutionTimer, - markExecutionEnd, - markExecutionStart, -} from "../../../packages/agent-kit"; -import { createRunLogger, generateRunId, type RunLogger } from "./logging"; -import { - getToolResultDetails, - type OnTextUpdate, - type OnToolUpdate, - type SubagentConfig, - type SubagentResult, - type SubagentToolCall, - type SubagentUsage, -} from "./types"; - -/** - * Execute a subagent with the given configuration. - * - * Key features: - * - Skills support via config.skills - * - Logging support via config.logging - * - Cost/usage tracking in result - * - Returns runId and logFiles paths - */ -export async function executeSubagent( - config: SubagentConfig, - userMessage: string, - ctx: ExtensionContext, - onTextUpdate?: OnTextUpdate, - signal?: AbortSignal, - onToolUpdate?: OnToolUpdate, -): Promise { - let logger: RunLogger | null = null; - let runId: string; - - // Setup logging if enabled - if (config.logging?.enabled) { - try { - logger = await createRunLogger( - ctx.cwd, - config.name, - config.logging.debug ?? false, - ); - runId = logger.runId; - } catch (err) { - // Log warning but continue without logging - console.warn("Failed to create subagent logger:", err); - runId = generateRunId(config.name); - } - } else { - runId = generateRunId(config.name); - } - - const executionTimer = createExecutionTimer(); - - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.inMemory(); - const resourceLoader = new DefaultResourceLoader({ - cwd: ctx.cwd, - agentDir, - settingsManager, - noExtensions: true, - additionalExtensionPaths: config.extensionPaths ?? [], - noPromptTemplates: true, - noThemes: true, - noSkills: true, - systemPromptOverride: () => config.systemPrompt, - appendSystemPromptOverride: () => [], - agentsFilesOverride: () => ({ agentsFiles: [] }), - skillsOverride: () => ({ - skills: config.skills ?? [], - diagnostics: [], - }), - }); - await resourceLoader.reload(); - - const sessionConfig: CreateAgentSessionOptions = { - model: config.model, - customTools: config.customTools ?? [], - sessionManager: SessionManager.inMemory(), - thinkingLevel: config.thinkingLevel ?? "low", - modelRegistry: ctx.modelRegistry, - resourceLoader, - }; - - // Only pass tools array if explicitly provided - // Undefined allows all tools; empty array [] blocks all tools - if (config.tools !== undefined) { - sessionConfig.tools = config.tools; - } - - const { session } = await createAgentSession(sessionConfig); - - let accumulated = ""; - let finalResponse = ""; - let aborted = false; - let stopReason: string | undefined; - let providerErrorMessage: string | undefined; - const toolCalls = new Map(); - - // Track tool execution state to capture only the final response - let toolsHaveStarted = false; - let toolsHaveCompleted = false; - - // Usage tracking - accumulate across all turns - const usage: SubagentUsage = { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - estimatedTokens: 0, - llmCost: 0, - toolCostUsd: 0, - toolCostEur: 0, - totalCostUsd: 0, - }; - - // Subscribe to events for streaming output - const unsubscribe = session.subscribe((event) => { - // Log raw events if debug logging enabled - if (logger && config.logging?.debug) { - logger.logEventRaw(event).catch(() => {}); - } - - // Handle text streaming (ignore thinking deltas) - if (event.type === "message_update") { - if (event.assistantMessageEvent.type === "text_delta") { - const delta = event.assistantMessageEvent.delta; - accumulated += delta; - - // Only accumulate final response (after tools complete) - if (toolsHaveCompleted) { - finalResponse += delta; - } - - onTextUpdate?.(delta, accumulated); - logger?.logTextDelta(delta, accumulated).catch(() => {}); - } - } - - // Handle tool execution events - if (event.type === "tool_execution_start") { - toolsHaveStarted = true; - toolsHaveCompleted = false; - finalResponse = ""; - const toolCall: SubagentToolCall = { - toolCallId: event.toolCallId, - toolName: event.toolName, - args: event.args ?? {}, - status: "running", - }; - markExecutionStart(toolCall); - toolCalls.set(event.toolCallId, toolCall); - onToolUpdate?.([...toolCalls.values()]); - logger?.logToolStart(toolCall).catch(() => {}); - } - - if (event.type === "tool_execution_update") { - const existing = toolCalls.get(event.toolCallId); - if (existing) { - existing.args = event.args ?? existing.args; - // Capture partial result for progress display - if (event.partialResult) { - existing.partialResult = event.partialResult as { - content: Array<{ type: string; text?: string }>; - details?: unknown; - }; - } - onToolUpdate?.([...toolCalls.values()]); - } - } - - if (event.type === "tool_execution_end") { - const existing = toolCalls.get(event.toolCallId); - if (existing) { - existing.status = event.isError ? "error" : "done"; - existing.result = event.result; - markExecutionEnd(existing); - if (event.isError && event.result) { - existing.error = - typeof event.result === "string" - ? event.result - : JSON.stringify(event.result); - } - onToolUpdate?.([...toolCalls.values()]); - logger?.logToolEnd(existing).catch(() => {}); - - // Capture tool cost from result details (e.g., Exa/Linkup API costs) - const resultDetails = getToolResultDetails(existing.result); - if (resultDetails?.cost !== undefined) { - const currency = resultDetails.costCurrency ?? "USD"; - if (currency === "EUR") { - usage.toolCostEur = (usage.toolCostEur ?? 0) + resultDetails.cost; - } else { - usage.toolCostUsd = (usage.toolCostUsd ?? 0) + resultDetails.cost; - } - } - } - - // Check if all tools are now complete - const allDone = [...toolCalls.values()].every( - (tc) => tc.status === "done" || tc.status === "error", - ); - if (allDone) { - toolsHaveCompleted = true; - } - } - - // Capture usage and stop reason from assistant messages at turn end - if (event.type === "turn_end") { - const msg = event.message; - if (msg.role === "assistant") { - const assistantMsg = msg as AssistantMessage & { - stopReason?: string; - errorMessage?: string; - }; - - stopReason = assistantMsg.stopReason; - providerErrorMessage = assistantMsg.errorMessage; - - const msgUsage = assistantMsg.usage; - if (msgUsage) { - usage.inputTokens = (usage.inputTokens ?? 0) + msgUsage.input; - usage.outputTokens = (usage.outputTokens ?? 0) + msgUsage.output; - usage.cacheReadTokens = - (usage.cacheReadTokens ?? 0) + msgUsage.cacheRead; - usage.cacheWriteTokens = - (usage.cacheWriteTokens ?? 0) + msgUsage.cacheWrite; - usage.llmCost = (usage.llmCost ?? 0) + msgUsage.cost.total; - } - } - } - }); - - // Handle abort signal - if (signal) { - if (signal.aborted) { - // Already aborted before we started - return immediately - unsubscribe(); - session.dispose(); - await logger?.close().catch(() => {}); - return { - content: "", - aborted: true, - toolCalls: [], - totalDurationMs: executionTimer.getDurationMs(), - runId, - usage, - }; - } else { - signal.addEventListener( - "abort", - () => { - session.abort(); - aborted = true; - }, - { once: true }, - ); - } - } - - let error: string | undefined; - - try { - await session.prompt(userMessage); - } catch (err) { - if (signal?.aborted) { - aborted = true; - } else { - error = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : JSON.stringify(err); - } - } finally { - unsubscribe(); - session.dispose(); - await logger?.close().catch(() => {}); - } - - // Use finalResponse if tools were used, otherwise use full accumulated text - const responseText = toolsHaveStarted ? finalResponse : accumulated; - const cleanedContent = filterThinkingTags(responseText); - - // Calculate estimated tokens (fallback if real usage not available) - const totalRealTokens = - (usage.inputTokens ?? 0) + - (usage.outputTokens ?? 0) + - (usage.cacheReadTokens ?? 0) + - (usage.cacheWriteTokens ?? 0); - usage.estimatedTokens = - totalRealTokens > 0 - ? totalRealTokens - : Math.round(cleanedContent.length / 4); - - // Compute cost summaries - usage.totalCostUsd = (usage.llmCost ?? 0) + (usage.toolCostUsd ?? 0); - - // Build result - const result: SubagentResult = { - content: cleanedContent, - aborted, - toolCalls: [...toolCalls.values()], - totalDurationMs: executionTimer.getDurationMs(), - error, - stopReason, - providerErrorMessage, - runId, - usage, - }; - - // Add log file paths if logging was enabled - if (logger) { - result.logFiles = { - stream: logger.streamPath, - debug: logger.debugPath, - }; - } - - return result; -} - -/** - * Filter out ... tags from text. - * Some models leak thinking as text tags even when thinkingLevel is set. - */ -export function filterThinkingTags(text: string): string { - return text.replace(/[\s\S]*?<\/thinking>\s*/g, ""); -} diff --git a/extensions/subagents/lib/gh.ts b/extensions/subagents/lib/gh.ts deleted file mode 100644 index c825caf4..00000000 --- a/extensions/subagents/lib/gh.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * GitHub CLI (gh) wrapper utilities. - */ - -import { spawn } from "node:child_process"; - -/** - * Run gh CLI command and return stdout. - */ -export async function runGh( - args: string[], - signal?: AbortSignal, - stdin?: string, -): Promise { - return new Promise((resolve, reject) => { - const child = spawn("gh", args, { - stdio: ["pipe", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - if (signal) { - signal.addEventListener( - "abort", - () => { - child.kill("SIGTERM"); - reject(new Error("Operation aborted")); - }, - { once: true }, - ); - } - - if (stdin) { - child.stdin.write(stdin); - } - child.stdin.end(); - - child.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - - child.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - child.on("close", (code) => { - if (code === 0) { - resolve(stdout); - } else { - reject(new Error(stderr || `gh exited with code ${code}`)); - } - }); - - child.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") { - reject(new Error("gh CLI is not installed")); - } else { - reject(err); - } - }); - }); -} diff --git a/extensions/subagents/lib/index.ts b/extensions/subagents/lib/index.ts deleted file mode 100644 index 51aed22c..00000000 --- a/extensions/subagents/lib/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Specialized subagents library. - */ - -// API clients -export { - createGitHubClient, - GitHubClient, - type GitHubComment, - type GitHubDirectoryItem, - type GitHubFileContent, - type GitHubIssue, - type GitHubLabel, - type GitHubPullRequest, - type GitHubReadme, - type GitHubRepository, - type GitHubUser, - type ParsedGitHubUrl, - parseGitHubUrl, -} from "./clients"; -// Error classification -export { - isModelAvailabilityError, - shouldFailToolCallForModelIssue, -} from "./error-classification"; -// Core executor -export { executeSubagent, filterThinkingTags } from "./executor"; -// Logging -export { - createRunLogger, - generateRunId, - getLogDirectory, - type RunLogger, - sanitizePath, -} from "./logging"; -// Model resolution -export { resolveModel } from "./model-resolver"; -// Path utilities -export { shortenPath } from "./paths"; -// Skills -export { type ResolveSkillsResult, resolveSkillsByName } from "./skills"; -// Types -export type { - BaseSubagentDetails, - OnTextUpdate, - OnToolUpdate, - SubagentConfig, - SubagentResponseDetails, - SubagentResult, - SubagentSkillDetails, - SubagentToolCall, - SubagentToolCallDetails, - SubagentUsage, -} from "./types"; -// UI -export { - formatCost, - formatSubagentStats, - formatTokenCount, - formatToolCallCompact, - formatToolCallExpanded, - getCurrentRunningTool, - INDICATOR, - type ModelRef, - pluralize, - renderDoneResult, - renderStreamingStatus, - renderSubagentCallHeader, -} from "./ui"; diff --git a/extensions/subagents/lib/logging/index.ts b/extensions/subagents/lib/logging/index.ts deleted file mode 100644 index 99fbd9a8..00000000 --- a/extensions/subagents/lib/logging/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { generateRunId, getLogDirectory, sanitizePath } from "./paths"; -export { createRunLogger, type RunLogger } from "./run-logger"; diff --git a/extensions/subagents/lib/logging/paths.ts b/extensions/subagents/lib/logging/paths.ts deleted file mode 100644 index 61dc5160..00000000 --- a/extensions/subagents/lib/logging/paths.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as crypto from "node:crypto"; -import * as os from "node:os"; -import * as path from "node:path"; - -/** - * Sanitize a path for use as a directory name. - * Mirrors Pi's session storage: /Users/foo/bar -> --Users-foo-bar-- - */ -export function sanitizePath(p: string): string { - // Replace path separators with dashes, wrap with double dashes - const sanitized = p.replace(/[/\\]/g, "-"); - return `--${sanitized}--`; -} - -/** - * Generate a unique run ID. - * Format: -- - * Example: oracle-20260112-100000-a1b2c3 - */ -export function generateRunId(subagentName: string): string { - const now = new Date(); - const timestamp = (now.toISOString().split(".")[0] ?? "") // Remove milliseconds: 2026-01-12T11:28:17 - .replace(/[-:T]/g, ""); // YYYYMMDDHHMMSS - const formatted = timestamp.replace(/(\d{8})(\d{6})/, "$1-$2"); // YYYYMMDD-HHMMSS - const random = crypto.randomBytes(3).toString("hex"); // 6 chars - return `${subagentName}-${formatted}-${random}`; -} - -/** - * Get the log directory for a subagent run. - * - * Structure mirrors Pi sessions: - * ~/.pi/agent/subagents//// - * - * @param cwd - Current working directory - * @param subagentName - Name of the subagent (e.g., "oracle") - * @param runId - Unique run identifier - * @param agentDir - Agent config directory (default: ~/.pi/agent) - */ -export function getLogDirectory( - cwd: string, - subagentName: string, - runId: string, - agentDir?: string, -): string { - const baseDir = agentDir ?? path.join(os.homedir(), ".pi", "agent"); - const sanitizedCwd = sanitizePath(cwd); - return path.join(baseDir, "subagents", sanitizedCwd, subagentName, runId); -} diff --git a/extensions/subagents/lib/logging/run-logger.ts b/extensions/subagents/lib/logging/run-logger.ts deleted file mode 100644 index 02c7e09f..00000000 --- a/extensions/subagents/lib/logging/run-logger.ts +++ /dev/null @@ -1,164 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { getToolResultDetails, type SubagentToolCall } from "../types"; -import { generateRunId, getLogDirectory } from "./paths"; - -export interface RunLogger { - /** Unique run identifier */ - runId: string; - /** Path to human-readable stream log */ - streamPath: string; - /** Path to JSONL debug log */ - debugPath: string; - - /** Log a text delta to stream log */ - logTextDelta(delta: string, accumulated: string): Promise; - - /** Log tool execution start */ - logToolStart(call: SubagentToolCall): Promise; - - /** Log tool execution end */ - logToolEnd(call: SubagentToolCall): Promise; - - /** Log raw event to debug log (JSONL) */ - logEventRaw(event: unknown): Promise; - - /** Flush and close log files */ - close(): Promise; -} - -function formatTimestamp(): string { - const now = new Date(); - return ( - now.toTimeString().split(" ")[0] + - "." + - String(now.getMilliseconds()).padStart(3, "0") - ); -} - -class RunLoggerImpl implements RunLogger { - public readonly runId: string; - public readonly streamPath: string; - public readonly debugPath: string; - - private streamHandle: fs.FileHandle | null = null; - private debugHandle: fs.FileHandle | null = null; - private enableDebug: boolean; - private lastTextLength = 0; - - constructor(runId: string, logDir: string, enableDebug: boolean) { - this.runId = runId; - this.streamPath = path.join(logDir, "stream.log"); - this.debugPath = path.join(logDir, "debug.jsonl"); - this.enableDebug = enableDebug; - } - - async init(): Promise { - const dir = path.dirname(this.streamPath); - await fs.mkdir(dir, { recursive: true }); - this.streamHandle = await fs.open(this.streamPath, "a"); - if (this.enableDebug) { - this.debugHandle = await fs.open(this.debugPath, "a"); - } - await this.writeStream(`[${formatTimestamp()}] Starting subagent\n`); - } - - async logTextDelta(_delta: string, accumulated: string): Promise { - // Only log final response on first meaningful content - if (this.lastTextLength === 0 && accumulated.trim().length > 0) { - await this.writeStream(`[${formatTimestamp()}] Response:\n`); - } - this.lastTextLength = accumulated.length; - } - - async logToolStart(call: SubagentToolCall): Promise { - const argsStr = - Object.keys(call.args).length > 0 - ? ` ${JSON.stringify(call.args).slice(0, 100)}` - : ""; - await this.writeStream( - `[${formatTimestamp()}] Tool: ${call.toolName}${argsStr}\n`, - ); - } - - async logToolEnd(call: SubagentToolCall): Promise { - const status = call.status === "error" ? "error" : "completed"; - const errorSuffix = call.error ? ` - ${call.error.slice(0, 100)}` : ""; - - const details = getToolResultDetails(call.result); - const provider = - typeof details?.provider === "string" ? details.provider : undefined; - const cost = typeof details?.cost === "number" ? details.cost : undefined; - const currency = - details?.costCurrency === "EUR" || details?.costCurrency === "USD" - ? details.costCurrency - : undefined; - - const telemetryParts: string[] = []; - if (provider) telemetryParts.push(`provider=${provider}`); - if (call.durationMs !== undefined) - telemetryParts.push(`duration=${call.durationMs}ms`); - if (cost !== undefined) { - if (currency === "EUR") { - telemetryParts.push(`cost=€${cost}`); - } else { - telemetryParts.push(`cost=$${cost}`); - } - } - - const telemetrySuffix = - telemetryParts.length > 0 ? ` (${telemetryParts.join(" ")})` : ""; - - await this.writeStream( - `[${formatTimestamp()}] Tool: ${call.toolName} ${status}${telemetrySuffix}${errorSuffix}\n`, - ); - } - - async logEventRaw(event: unknown): Promise { - if (this.debugHandle) { - const line = `${JSON.stringify(event)}\n`; - await this.debugHandle.write(line); - } - } - - async close(): Promise { - await this.writeStream(`[${formatTimestamp()}] Subagent finished\n`); - try { - await this.streamHandle?.close(); - } catch { - /* best effort */ - } - try { - await this.debugHandle?.close(); - } catch { - /* best effort */ - } - this.streamHandle = null; - this.debugHandle = null; - } - - private async writeStream(content: string): Promise { - if (this.streamHandle) { - await this.streamHandle.write(content); - } - } -} - -/** - * Create a run logger for a subagent execution. - * - * @param cwd - Current working directory - * @param subagentName - Name of the subagent - * @param enableDebug - Whether to write debug.jsonl - */ -export async function createRunLogger( - cwd: string, - subagentName: string, - enableDebug: boolean, -): Promise { - const runId = generateRunId(subagentName); - const logDir = getLogDirectory(cwd, subagentName, runId); - const logger = new RunLoggerImpl(runId, logDir, enableDebug); - await logger.init(); - return logger; -} diff --git a/extensions/subagents/lib/model-resolver.ts b/extensions/subagents/lib/model-resolver.ts deleted file mode 100644 index 44895371..00000000 --- a/extensions/subagents/lib/model-resolver.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Model resolution helper for subagents. - * - * Resolves a model by provider + ID from the model registry. - */ - -import type { Model } from "@mariozechner/pi-ai"; -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; - -/** - * Find a model by provider and ID. - * - * @param provider - Provider name (e.g., "openrouter", "anthropic", "openai-codex") - * @param modelId - Model ID (e.g., "anthropic/claude-haiku-4.5") - * @param ctx - Extension context with modelRegistry - * @returns The resolved model - * @throws Error if model not found or API key not configured - */ -export function resolveModel( - provider: string, - modelId: string, - ctx: ExtensionContext, - // biome-ignore lint/suspicious/noExplicitAny: Model type requires any for generic API -): Model { - const available = ctx.modelRegistry.getAvailable(); - const model = available.find( - (m) => m.id === modelId && m.provider === provider, - ); - - if (model) { - return model; - } - - // Check if the model exists but the API key is missing - const all = ctx.modelRegistry.getAll(); - const existsWithoutKey = all.some( - (m) => m.id === modelId && m.provider === provider, - ); - - if (existsWithoutKey) { - throw new Error( - `Model "${modelId}" exists on ${provider} but no valid API key is configured.`, - ); - } - - throw new Error(`Model "${modelId}" not found on provider "${provider}".`); -} diff --git a/extensions/subagents/lib/paths.ts b/extensions/subagents/lib/paths.ts deleted file mode 100644 index eb5c4cd0..00000000 --- a/extensions/subagents/lib/paths.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Path display utilities for tool formatters. - */ - -import { homedir } from "node:os"; -import { relative } from "node:path"; - -/** - * Shorten a path for display. Tries cwd-relative first, then tilde for home. - * - * - If cwd is provided and the result is short, returns relative path (e.g. "src/foo.ts") - * - If the relative path is long (many "../"), replaces $HOME with ~ instead - * - If path is already relative, returns as-is - */ -export function shortenPath(path: string, cwd?: string): string { - if (!path.startsWith("/")) return path; - - if (cwd) { - const rel = relative(cwd, path); - // Use relative if it doesn't escape too far up - if (!rel.startsWith("../../..")) { - return rel || "."; - } - } - - // Fall back to tilde notation - const home = homedir(); - if (home && path.startsWith(home)) { - return `~${path.slice(home.length)}`; - } - - return path; -} diff --git a/extensions/subagents/lib/skills.ts b/extensions/subagents/lib/skills.ts deleted file mode 100644 index 13fb4f25..00000000 --- a/extensions/subagents/lib/skills.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - getAgentDir, - loadSkills, - type Skill, -} from "@mariozechner/pi-coding-agent"; - -export interface ResolveSkillsResult { - /** Successfully resolved skills */ - skills: Skill[]; - /** Skill names that were not found */ - notFound: string[]; -} - -/** - * Resolve skill names to Skill objects. - * - * Discovers skills from the given cwd using the Pi SDK and filters - * by exact name match. No glob patterns supported. - * - * @param skillNames - Array of skill names to resolve - * @param cwd - Working directory for skill discovery - * @returns Object with resolved skills and list of not-found names - */ -export function resolveSkillsByName( - skillNames: string[], - cwd: string, -): ResolveSkillsResult { - // Discover all available skills from standard locations - const { skills: allSkills } = loadSkills({ - cwd, - agentDir: getAgentDir(), - skillPaths: [], - includeDefaults: true, - }); - - const found: Skill[] = []; - const notFound: string[] = []; - - for (const name of skillNames) { - const skill = allSkills.find((s) => s.name === name); - if (skill) { - found.push(skill); - } else { - notFound.push(name); - } - } - - return { skills: found, notFound }; -} diff --git a/extensions/subagents/lib/subagent-model-selection.ts b/extensions/subagents/lib/subagent-model-selection.ts deleted file mode 100644 index 845e6fef..00000000 --- a/extensions/subagents/lib/subagent-model-selection.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { - getSubagentModelConfig, - type SubagentModelCandidate, - type SubagentName, -} from "../config"; -import { resolveModel } from "./model-resolver"; - -const selectedBySession = new Map(); - -export function clearSubagentModelSelections(): void { - selectedBySession.clear(); -} - -function buildAttemptOrder( - candidates: SubagentModelCandidate[], -): SubagentModelCandidate[] { - if (candidates.length <= 1) return [...candidates]; - - const randomIndex = Math.floor(Math.random() * candidates.length); - const randomCandidate = candidates[randomIndex]; - if (!randomCandidate) return [...candidates]; - - return [ - randomCandidate, - ...candidates.filter((_, index) => index !== randomIndex), - ]; -} - -export function selectModelForSubagent( - name: SubagentName, - ctx: ExtensionContext, -): ReturnType { - const config = getSubagentModelConfig(name); - const candidates = config.candidates; - - if (!candidates || candidates.length === 0) { - throw new Error(`No configured model candidates for subagent "${name}".`); - } - - const cached = selectedBySession.get(name); - if (cached) { - try { - return resolveModel(cached.provider, cached.model, ctx); - } catch { - selectedBySession.delete(name); - } - } - - const attempts = buildAttemptOrder(candidates); - const errors: string[] = []; - - for (const candidate of attempts) { - try { - const model = resolveModel(candidate.provider, candidate.model, ctx); - selectedBySession.set(name, candidate); - return model; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(`${candidate.provider}/${candidate.model}: ${message}`); - } - } - - throw new Error( - `No available models for subagent "${name}". Tried ${attempts.length} candidate(s):\n- ${errors.join("\n- ")}`, - ); -} diff --git a/extensions/subagents/lib/types.ts b/extensions/subagents/lib/types.ts deleted file mode 100644 index c40d11ec..00000000 --- a/extensions/subagents/lib/types.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Model } from "@mariozechner/pi-ai"; -import type { Skill, ToolDefinition } from "@mariozechner/pi-coding-agent"; - -/** - * Configuration for a subagent. - * No ToolPolicy abstraction - just pass tools directly. - */ -export interface SubagentConfig { - /** Subagent name (for logging and run ID) */ - name: string; - - /** Model instance to use */ - // biome-ignore lint/suspicious/noExplicitAny: Model type requires any for generic API - model: Model; - - /** System prompt for the subagent */ - systemPrompt: string; - - /** Built-in tool-name allowlist - e.g., ["read", "grep"] */ - tools?: string[]; - - /** Custom tools (ToolDefinition[]) - e.g., GitHub tools */ - customTools?: ToolDefinition[]; - - /** Skills to load into system prompt */ - skills?: Skill[]; - - /** Extension paths to load (filesystem, npm:, or git URLs). Resolved by DefaultResourceLoader. */ - extensionPaths?: string[]; - - /** Thinking level. Default: "low" */ - thinkingLevel?: ThinkingLevel; - - /** Logging options */ - logging?: { - /** Enable logging. Default: false */ - enabled: boolean; - /** Include raw events in debug.jsonl. Default: false */ - debug?: boolean; - }; -} - -export type ToolCostCurrency = "USD" | "EUR"; - -export interface SubagentToolResultDetails { - provider?: string; - cost?: number; - costCurrency?: ToolCostCurrency; - [key: string]: unknown; -} - -export interface SubagentToolResultObject { - content?: Array<{ type: string; text?: string }>; - details?: SubagentToolResultDetails; - [key: string]: unknown; -} - -export type SubagentToolResultValue = - | SubagentToolResultObject - | string - | number - | boolean - | null - | Array; - -/** - * Tool call state for tracking subagent tool executions. - */ -export interface SubagentToolCall { - toolCallId: string; - toolName: string; - args: Record; - status: "running" | "done" | "error"; - /** Epoch ms when tool execution started */ - startedAt?: number; - /** Epoch ms when tool execution ended */ - endedAt?: number; - /** Duration in milliseconds (set when ended) */ - durationMs?: number; - result?: SubagentToolResultValue; - error?: string; - /** Partial result from tool updates (for progress display) */ - partialResult?: { - content: Array<{ type: string; text?: string }>; - details?: unknown; - }; -} - -/** - * Usage/cost information from the model response. - */ -export interface SubagentUsage { - /** Input tokens from API (if available) */ - inputTokens?: number; - /** Output tokens from API (if available) */ - outputTokens?: number; - /** Cache read tokens (if available) */ - cacheReadTokens?: number; - /** Cache write tokens (if available) */ - cacheWriteTokens?: number; - /** Estimated tokens from response length (chars/4) */ - estimatedTokens: number; - /** LLM cost in USD (if available) */ - llmCost?: number; - /** Tool cost in USD */ - toolCostUsd?: number; - /** Tool cost in EUR */ - toolCostEur?: number; - /** Total USD side cost (llmCost + toolCostUsd) */ - totalCostUsd?: number; -} - -/** - * Result from executing a subagent. - */ -export interface SubagentResult { - /** Final text content from the subagent */ - content: string; - - /** Whether the subagent was aborted */ - aborted: boolean; - - /** Final tool call states */ - toolCalls: SubagentToolCall[]; - - /** Total subagent execution duration in milliseconds */ - totalDurationMs: number; - - /** Error message if the subagent failed */ - error?: string; - - /** Final stop reason from the assistant turn (if available) */ - stopReason?: string; - - /** Provider-level error message from the assistant turn (if available) */ - providerErrorMessage?: string; - - /** Unique run identifier */ - runId: string; - - /** Log file paths (if logging enabled) */ - logFiles?: { - stream: string; // Human-readable log - debug: string; // JSONL raw events - }; - - /** Usage/cost information */ - usage: SubagentUsage; -} - -/** Callback for text streaming updates */ -export type OnTextUpdate = (delta: string, accumulated: string) => void; - -/** Callback for tool execution updates */ -export type OnToolUpdate = (toolCalls: SubagentToolCall[]) => void; - -/** Safe helper for extracting typed details from a tool result value. */ -export function getToolResultDetails( - result: SubagentToolResultValue | undefined, -): SubagentToolResultDetails | undefined { - if (!result || typeof result !== "object" || Array.isArray(result)) { - return undefined; - } - - const details = (result as { details?: unknown }).details; - if (!details || typeof details !== "object" || Array.isArray(details)) { - return undefined; - } - - return details as SubagentToolResultDetails; -} - -// --------------------------------------------------------------------------- -// Shared detail interfaces - composed into BaseSubagentDetails -// --------------------------------------------------------------------------- - -/** Skill resolution state for rendering */ -export interface SubagentSkillDetails { - /** Requested skill names (from input) */ - skills?: string[]; - /** Number of skills successfully resolved */ - skillsResolved?: number; - /** Skill names that were not found */ - skillsNotFound?: string[]; -} - -/** Tool call tracking state for rendering */ -export interface SubagentToolCallDetails { - /** Tool calls made by the subagent */ - toolCalls: SubagentToolCall[]; -} - -/** Response / completion state for rendering */ -export interface SubagentResponseDetails { - /** The subagent's final response */ - response?: string; - /** Whether the request was aborted */ - aborted?: boolean; - /** Error message if failed */ - error?: string; - /** Usage stats from the subagent */ - usage?: SubagentUsage; - /** Resolved model used for this run (provider + model id) */ - resolvedModel?: { provider: string; id: string }; - /** Total subagent execution duration in milliseconds */ - totalDurationMs?: number; -} - -/** - * Base details shared by all subagent tool renderers. - * - * Each subagent's Details type extends this with its own input-specific fields. - */ -export interface BaseSubagentDetails - extends SubagentSkillDetails, - SubagentToolCallDetails, - SubagentResponseDetails { - /** Tool call ID used as cache key for render component reuse */ - _renderKey?: string; -} diff --git a/extensions/subagents/lib/ui/index.ts b/extensions/subagents/lib/ui/index.ts deleted file mode 100644 index a9d0484c..00000000 --- a/extensions/subagents/lib/ui/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { - type ModelRef, - renderDoneResult, - renderStreamingStatus, - renderSubagentCallHeader, -} from "./renderer"; -export { INDICATOR } from "./spinner"; -export { - formatCost, - formatSubagentStats, - formatTokenCount, - pluralize, -} from "./stats"; -export { - formatToolCallCompact, - formatToolCallExpanded, - getCurrentRunningTool, -} from "./tool-formatters"; diff --git a/extensions/subagents/lib/ui/renderer.ts b/extensions/subagents/lib/ui/renderer.ts deleted file mode 100644 index bbe0e310..00000000 --- a/extensions/subagents/lib/ui/renderer.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { getMarkdownTheme, type Theme } from "@mariozechner/pi-coding-agent"; -import { Container, Markdown, Text } from "@mariozechner/pi-tui"; -import type { SubagentToolCall, SubagentUsage } from "../types"; -import { INDICATOR } from "./spinner"; -import { formatSubagentStats, pluralize } from "./stats"; -import { - formatToolCallExpanded, - getCurrentRunningTool, -} from "./tool-formatters"; - -/** - * Model reference for display. - */ -export interface ModelRef { - provider: string; - model: string; -} - -/** - * Render subagent call header. - * - * Format: "Label (provider/model)" - * Followed by primary input lines. - */ -export function renderSubagentCallHeader( - label: string, - modelRef: ModelRef, - primaryLines: Array<{ label: string; value: string }>, - theme: Theme, -): Container { - const container = new Container(); - - // Title line: Label (provider/model) - const titleText = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg( - "muted", - `(${modelRef.provider}/${modelRef.model})`, - )}`; - container.addChild(new Text(titleText, 0, 0)); - - // Primary input lines - for (const line of primaryLines) { - const lineText = ` ${theme.fg("muted", `${line.label}:`)} ${theme.fg( - "accent", - line.value, - )}`; - container.addChild(new Text(lineText, 0, 0)); - } - - return container; -} - -/** - * Render streaming status (current tool + counts). - */ -export function renderStreamingStatus( - toolCalls: SubagentToolCall[], - theme: Theme, -): Container | Text { - const currentTool = getCurrentRunningTool(toolCalls); - - if (!currentTool) { - // No tools yet, just show thinking - return new Text(` thinking...`, 0, 0); - } - - const container = new Container(); - - // Show current tool - const toolLine = formatToolCallExpanded(currentTool, theme); - container.addChild(new Text(toolLine, 0, 0)); - - // Show counts if multiple tools - if (toolCalls.length > 1) { - const done = toolCalls.filter((t) => t.status !== "running").length; - const running = toolCalls.filter((t) => t.status === "running").length; - const countText = theme.fg("muted", ` (${done} done, ${running} running)`); - container.addChild(new Text(countText, 0, 0)); - } - - return container; -} - -/** - * Render done result. - * - * Collapsed: "check stats" - * Expanded: stats + tool summary + markdown response + footer - */ -export function renderDoneResult( - response: string, - toolCalls: SubagentToolCall[], - usage: SubagentUsage, - expanded: boolean, - theme: Theme, -): Container | Text { - const stats = formatSubagentStats(usage, toolCalls.length); - - if (!expanded) { - // Collapsed view: just show stats - return new Text(`${INDICATOR.done} ${theme.fg("muted", stats)}`, 0, 0); - } - - // Expanded view - const container = new Container(); - - // Stats line - container.addChild( - new Text(`${INDICATOR.done} ${theme.fg("muted", stats)}`, 0, 0), - ); - - // Tool summary if any - if (toolCalls.length > 0) { - const errors = toolCalls.filter((t) => t.status === "error").length; - const toolSummary = - errors > 0 - ? `${toolCalls.length} ${pluralize(toolCalls.length, "tool call")}, ${errors} ${pluralize(errors, "error")}` - : `${toolCalls.length} ${pluralize(toolCalls.length, "tool call")}`; - container.addChild(new Text(theme.fg("muted", toolSummary), 0, 0)); - } - - // Response content - if (response.trim()) { - try { - const mdTheme = getMarkdownTheme(); - const md = new Markdown(response, 0, 0, mdTheme); - container.addChild(md); - } catch { - // Fallback to plain text if markdown fails - container.addChild(new Text(response, 0, 0)); - } - } - - return container; -} diff --git a/extensions/subagents/lib/ui/spinner.ts b/extensions/subagents/lib/ui/spinner.ts deleted file mode 100644 index e512eb84..00000000 --- a/extensions/subagents/lib/ui/spinner.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Status indicators */ -export const INDICATOR = { - done: "✓", - error: "✗", - pending: "○", -} as const; diff --git a/extensions/subagents/lib/ui/stats.ts b/extensions/subagents/lib/ui/stats.ts deleted file mode 100644 index 9c6ea222..00000000 --- a/extensions/subagents/lib/ui/stats.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SubagentUsage } from "../types"; - -/** Pluralize a word based on count */ -export function pluralize( - count: number, - singular: string, - plural?: string, -): string { - return count === 1 ? singular : (plural ?? `${singular}s`); -} - -/** Format token count (e.g., "1.2k") */ -export function formatTokenCount(tokens: number): string { - if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`; - return `${tokens}`; -} - -/** Format cost with currency suffix (e.g., "0.0023 USD") */ -export function formatCost(cost: number, currency: "USD" | "EUR"): string { - if (cost < 1) return `${cost.toFixed(4)} ${currency}`; - return `${cost.toFixed(2)} ${currency}`; -} - -/** - * Format subagent stats for display. - * Shows tokens, tool calls, and cost. - * - * Examples: - * - "1.2k tokens, 3 tools" - * - "1.2k tokens, 3 tools, $0.02" - * - "1.2k tokens (est), 3 tools" (when using estimated tokens) - */ -export function formatSubagentStats( - usage: SubagentUsage, - toolCallCount: number, - suffix?: string, -): string { - const parts: string[] = []; - - // Prefer actual tokens if available, otherwise use estimate - const hasActualTokens = usage.outputTokens !== undefined; - const tokenCount = hasActualTokens - ? (usage.outputTokens ?? 0) - : usage.estimatedTokens; - const tokenText = formatTokenCount(tokenCount); - const estSuffix = hasActualTokens ? "" : " (est)"; - parts.push(`${tokenText} ${pluralize(tokenCount, "token")}${estSuffix}`); - - if (toolCallCount > 0) { - parts.push(`${toolCallCount} ${pluralize(toolCallCount, "tool")}`); - } - - if ((usage.totalCostUsd ?? 0) > 0 || (usage.toolCostEur ?? 0) > 0) { - const costParts: string[] = []; - if ((usage.totalCostUsd ?? 0) > 0) { - costParts.push(formatCost(usage.totalCostUsd ?? 0, "USD")); - } - if ((usage.toolCostEur ?? 0) > 0) { - costParts.push(formatCost(usage.toolCostEur ?? 0, "EUR")); - } - parts.push(costParts.join(" + ")); - } - - if (suffix) { - parts.push(suffix); - } - - return parts.join(", "); -} diff --git a/extensions/subagents/lib/ui/tool-formatters.ts b/extensions/subagents/lib/ui/tool-formatters.ts deleted file mode 100644 index 7dc4bad7..00000000 --- a/extensions/subagents/lib/ui/tool-formatters.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { Theme } from "@mariozechner/pi-coding-agent"; -import type { SubagentToolCall } from "../types"; -import { INDICATOR } from "./spinner"; - -/** - * Truncate string with ellipsis. - */ -function truncate(str: string, maxLength: number): string { - if (str.length <= maxLength) return str; - return `${str.slice(0, maxLength - 1)}…`; -} - -/** - * Format tool call arguments as a compact string. - */ -function formatArgs(args: Record, maxLength: number): string { - if (!args || Object.keys(args).length === 0) return ""; - - const keys = Object.keys(args); - if (keys.length === 1) { - const firstKey = keys[0]; - if (!firstKey) return ""; - const value = args[firstKey]; - const str = typeof value === "string" ? value : JSON.stringify(value); - return truncate(str, maxLength); - } - - const pairs = keys.map((k) => { - const v = args[k]; - const vStr = typeof v === "string" ? v : JSON.stringify(v); - return `${k}=${truncate(vStr, 20)}`; - }); - - return truncate(pairs.join(" "), maxLength); -} - -/** - * Format a tool call for collapsed display. - * - * Examples: - * - "read: src/auth.ts" - * - "bash: npm test" - * - "grep: \"validateToken\" in src/" - */ -export function formatToolCallCompact( - toolCall: SubagentToolCall, - _theme: Theme, -): string { - const argsStr = formatArgs(toolCall.args, 50); - if (argsStr) { - return `${toolCall.toolName}: ${argsStr}`; - } - return toolCall.toolName; -} - -/** - * Format a tool call for expanded display with status indicator. - * - * Examples: - * - " read src/auth.ts" - * - "✓ bash npm test" - * - "✗ grep \"missing\" (file not found)" - */ -export function formatToolCallExpanded( - toolCall: SubagentToolCall, - _theme: Theme, -): string { - const indicator = - toolCall.status === "running" - ? " " - : toolCall.status === "done" - ? INDICATOR.done - : INDICATOR.error; - - const argsStr = formatArgs(toolCall.args, 50); - let text = argsStr ? `${toolCall.toolName} ${argsStr}` : toolCall.toolName; - - if (toolCall.status === "error" && toolCall.error) { - text += ` (${truncate(toolCall.error, 30)})`; - } - - return `${indicator} ${text}`; -} - -/** - * Get the currently running tool call, or the last one if none running. - */ -export function getCurrentRunningTool( - toolCalls: SubagentToolCall[], -): SubagentToolCall | undefined { - if (toolCalls.length === 0) return undefined; - - // Find the last running tool - for (let i = toolCalls.length - 1; i >= 0; i--) { - const tool = toolCalls[i]; - if (tool && tool.status === "running") { - return tool; - } - } - - // If none running, return the last tool - return toolCalls[toolCalls.length - 1]; -} diff --git a/extensions/subagents/subagents/.gitkeep b/extensions/subagents/subagents/.gitkeep deleted file mode 100644 index 3b1a3162..00000000 --- a/extensions/subagents/subagents/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Placeholder for future subagent implementations diff --git a/extensions/subagents/subagents/lookout/index.ts b/extensions/subagents/subagents/lookout/index.ts deleted file mode 100644 index 51af0894..00000000 --- a/extensions/subagents/subagents/lookout/index.ts +++ /dev/null @@ -1,490 +0,0 @@ -/** - * Lookout subagent - local codebase search by functionality or concept. - * - * Uses Pi's built-in tools (grep, find, read, ls) for code discovery. - */ - -import { - createRenderCache, - FailedToolCalls, - MarkdownResponse, - renderToolTextFallback, - SubagentFooter, - ToolCallHeader, - ToolCallList, - ToolCallSummary, - ToolDetails, - type ToolDetailsField, -} from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - AgentToolUpdateCallback, - ExtensionContext, - Skill, - ToolDefinition, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { createReadOnlyTools, type Theme } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { isDebugEnabled } from "../../config"; -import { - executeSubagent, - resolveSkillsByName, - shouldFailToolCallForModelIssue, -} from "../../lib"; -import { selectModelForSubagent } from "../../lib/subagent-model-selection"; -import type { SubagentToolCall } from "../../lib/types"; -import { LOOKOUT_SYSTEM_PROMPT } from "./system-prompt"; -import { createLookoutToolFormatter } from "./tool-formatter"; -import { generateProjectContext } from "./tools/project-context"; -import type { LookoutDetails, LookoutInput } from "./types"; - -/** System prompt guidance for lookout tool usage */ -export const LOOKOUT_GUIDANCE = ` -## Lookout - Local Code Search - -Use the \`lookout\` tool to find code by functionality or concept in the local codebase. - -**When to use:** -- Locate code by behavior: "Where do we validate JWT tokens?" -- Find implementations: "Which module handles retry logic?" -- Understand code flow: "How does the auth flow work?" - -**When NOT to use:** -- Known file path or existing doc/plan -> use \`read\` directly -- Simple exact string search -> use \`grep\` directly -- Planning, strategy, or request for an implementation plan -> use \`oracle\` -- External/web research -> use \`scout\` instead - -**Example:** -\`\`\`json -{ "query": "Where is the database connection pool configured?" } -\`\`\` - -**Custom directory:** Pass \`cwd\` to search a specific directory instead of the current project: -\`\`\`json -{ "query": "auth implementation", "cwd": "/path/to/other/project" } -\`\`\` -`; - -const parameters = Type.Object({ - query: Type.String({ - description: "Search query describing what to find in the codebase", - }), - cwd: Type.Optional( - Type.String({ - description: - "Working directory to search in (defaults to current project directory)", - }), - ), - skills: Type.Optional( - Type.Array(Type.String(), { - description: - "Skill names to provide specialized context (e.g., 'ios-26', 'drizzle-orm')", - }), - ), -}); - -/** Create the lookout tool definition for use in extensions */ -export function createLookoutTool(): ToolDefinition< - typeof parameters, - LookoutDetails -> { - // Render cache for reusing components across updates - const renderCache = createRenderCache< - string, - { - toolDetails: ToolDetails; - footer: SubagentFooter; - markdownResponse: MarkdownResponse | null; - } - >(); - - return { - name: "lookout", - label: "Lookout", - description: `Local codebase search by functionality or concept. - -Uses grep/find/read for comprehensive code discovery. -Returns relevant files with line ranges. - -Example: { "query": "where do we handle authentication" } - -Pass relevant skills (e.g., 'ios-26', 'drizzle-orm') to provide specialized context for the task.`, - promptSnippet: "Search the local codebase by functionality or concept.", - promptGuidelines: [ - "Use this tool to find code by behavior, implementation, or code flow in the local codebase.", - "Prefer direct read when the file path is already known.", - "Prefer exact grep when searching for a literal string.", - "Do not use this for external research.", - ], - parameters, - - async execute( - toolCallId: string, - args: LookoutInput, - signal: AbortSignal | undefined, - onUpdate: AgentToolUpdateCallback | undefined, - ctx: ExtensionContext, - ) { - const { query, cwd: customCwd, skills: skillNames } = args; - const effectiveSkillNames = skillNames ?? []; - - // Resolve skills if provided - let resolvedSkills: Skill[] = []; - let notFoundSkills: string[] = []; - - if (effectiveSkillNames.length > 0) { - const result = resolveSkillsByName(effectiveSkillNames, ctx.cwd); - resolvedSkills = result.skills; - notFoundSkills = result.notFound; - } - - // Validate: query is required - if (!query) { - const error = "Query is required."; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - query: "", - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - error, - cwd: customCwd ?? ctx.cwd, - }, - }; - } - - // Use custom cwd if provided, otherwise use context cwd - const workingDir = customCwd ?? ctx.cwd; - - let resolvedModel: { provider: string; id: string } | undefined; - - let currentToolCalls: SubagentToolCall[] = []; - - try { - const model = selectModelForSubagent("lookout", ctx); - resolvedModel = { provider: model.provider, id: model.id }; - - // Publish resolved provider/model as early as possible for footer rendering. - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: workingDir, - }, - }); - - // Build system prompt with project context injected - const projectContext = await generateProjectContext(workingDir); - let systemPrompt = LOOKOUT_SYSTEM_PROMPT.replace("{cwd}", workingDir); - if (projectContext) { - systemPrompt += `\n\n${projectContext}`; - } - - let userMessage = query; - - // Append warning if skills not found - if (notFoundSkills.length > 0) { - userMessage += `\n\n**Note:** The following skills were not found and could not be loaded: ${notFoundSkills.join(", ")}`; - } - - const result = await executeSubagent( - { - name: "lookout", - model, - systemPrompt, - skills: resolvedSkills, - tools: ["read", "grep", "find", "ls"], - customTools: createReadOnlyTools(workingDir), - thinkingLevel: "off", - logging: { - enabled: true, - debug: isDebugEnabled(), - }, - }, - userMessage, - ctx, - // onTextUpdate - (_delta, _accumulated) => { - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: workingDir, - }, - }); - }, - signal, - // onToolUpdate - (toolCalls: SubagentToolCall[]) => { - currentToolCalls = toolCalls; - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: workingDir, - }, - }); - }, - ); - - const finalToolCalls = - result.toolCalls.length > 0 ? result.toolCalls : currentToolCalls; - - // If the model responded without using any tools, the response is - // hallucinated. This happens with weaker models (e.g. gemini-flash-lite) - // that ignore tool-use instructions and fabricate file paths. - if (finalToolCalls.length === 0 && !result.aborted && !result.error) { - const error = - "Model responded without using any search tools. Response discarded to prevent hallucinated file paths."; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error, - usage: result.usage, - resolvedModel, - cwd: workingDir, - }, - }; - } - - if (result.aborted) { - return { - content: [{ type: "text" as const, text: "Aborted" }], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - aborted: true, - usage: result.usage, - resolvedModel, - cwd: workingDir, - }, - }; - } - - if (result.error) { - if (shouldFailToolCallForModelIssue(result)) { - throw new Error(result.error); - } - - return { - content: [ - { type: "text" as const, text: `Error: ${result.error}` }, - ], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error: result.error, - usage: result.usage, - resolvedModel, - cwd: workingDir, - }, - }; - } - - // Check if all tool calls failed - const errorCount = finalToolCalls.filter( - (tc) => tc.status === "error", - ).length; - const allFailed = - finalToolCalls.length > 0 && errorCount === finalToolCalls.length; - - if (allFailed) { - const error = "All tool calls failed"; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error, - usage: result.usage, - resolvedModel, - cwd: workingDir, - }, - }; - } - - return { - content: [{ type: "text" as const, text: result.content }], - details: { - _renderKey: toolCallId, - query, - skills: effectiveSkillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - response: result.content, - usage: result.usage, - resolvedModel, - cwd: workingDir, - }, - }; - } finally { - } - }, - - renderCall(args, theme) { - const query = args.query?.trim() ?? ""; - - return new ToolCallHeader( - { - toolName: "Lookout", - mainArg: query, - optionArgs: [ - ...(args.cwd ? [{ label: "cwd", value: args.cwd }] : []), - ...(args.skills?.length - ? [{ label: "skills", value: args.skills.join(",") }] - : []), - ], - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const { details } = result; - - // Fallback if details missing - if (!details) { - return renderToolTextFallback(result, theme); - } - - const { - _renderKey, - toolCalls, - response, - aborted, - error, - usage, - resolvedModel, - cwd, - } = details; - - const renderKey = _renderKey ?? "_default_"; - const cached = renderCache.get(renderKey); - - // Footer - reuse or create - const footerData = { resolvedModel, usage, toolCalls }; - let footer: SubagentFooter; - if (cached) { - footer = cached.footer; - footer.updateData(footerData); - } else { - footer = new SubagentFooter(theme, footerData); - } - - // MarkdownResponse - reuse or create - let mdResponse = cached?.markdownResponse ?? null; - - // Build fields based on state - const fields: ToolDetailsField[] = []; - const formatToolCall = createLookoutToolFormatter(cwd); - - if (aborted) { - fields.push({ label: "Status", value: "Aborted" }); - } else if (error) { - fields.push({ label: "Error", value: error }); - } else if (response) { - // Done state - if (isDebugEnabled()) { - fields.push(new ToolCallList(toolCalls, formatToolCall, theme)); - } else { - fields.push(new ToolCallSummary(toolCalls, formatToolCall, theme)); - } - fields.push(new FailedToolCalls(toolCalls, formatToolCall, theme)); - - if (mdResponse) { - mdResponse.setContent(response); - } else { - mdResponse = new MarkdownResponse(response, theme); - } - fields.push(mdResponse); - } else { - // Running state - fields.push(new ToolCallList(toolCalls, formatToolCall, theme)); - } - - // ToolDetails - reuse or create - let toolDetails: ToolDetails; - if (cached) { - toolDetails = cached.toolDetails; - toolDetails.update({ fields, footer }, options); - } else { - toolDetails = new ToolDetails({ fields, footer }, options, theme); - } - - // Update cache - renderCache.set(renderKey, { - toolDetails, - footer, - markdownResponse: mdResponse, - }); - - return toolDetails; - }, - }; -} - -/** Execute the lookout subagent directly (without tool wrapper) */ -export async function executeLookout( - input: LookoutInput, - ctx: ExtensionContext, - onUpdate?: AgentToolUpdateCallback, - signal?: AbortSignal, -): Promise> { - const tool = createLookoutTool(); - return tool.execute("direct", input, signal, onUpdate, ctx); -} diff --git a/extensions/subagents/subagents/lookout/system-prompt.ts b/extensions/subagents/subagents/lookout/system-prompt.ts deleted file mode 100644 index 8f168a5a..00000000 --- a/extensions/subagents/subagents/lookout/system-prompt.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * System prompt for the Lookout subagent. - */ - -export const LOOKOUT_SYSTEM_PROMPT = `You are a code search agent. You MUST use tools to find code - NEVER answer from memory or make up file paths. - -## CRITICAL RULES -1. NEVER fabricate file paths or line numbers -2. Only report files that tools actually found -3. Verify exact line ranges with read before citing them when needed - -## Zero-shot execution policy -- You are invoked zero-shot. Do not ask follow-up questions unless you are completely blocked after fallback attempts. -- Execute first, clarify last: prefer best-effort search iterations over requesting more detail. -- If results are weak, run additional searches before asking for clarification. - -Fallback sequence before asking user for more info: -1. Run a broader \`grep\` query variant and a narrower identifier/path-oriented variant. -2. Use \`find\` to discover likely files, then \`read\` to verify. -3. Return best candidates with confidence notes, even if not perfect. - -Only ask for clarification when no credible candidates remain after these steps. - -## Working Directory -{cwd} - -## Available Tools -- **grep**: Pattern search - exact strings, symbols, imports -- **find**: Find files by name pattern -- **read**: Read file contents to verify and get exact line ranges -- **ls**: List directory contents - -## Strategy -- Start with the tool most likely to produce evidence fast. -- Use \`grep\` for exact strings, identifiers, log text, config keys, or imports. -- Use \`find\` to narrow by filenames, then \`read\` to verify. -- Use multiple tools as needed, but only cite files and lines confirmed by tool output. -- Do at least one fallback pass (broaden/narrow query, try different terms) before concluding not found. - -## Output Format -Ultra concise: 1-2 line summary then markdown links. -Format: [relativePath#L{start}-L{end}](file://{absolutePath}#L{start}-L{end}) - -Example: -JWT tokens validated in auth middleware, claims extracted via token service. - -Relevant files: -- [src/middleware/auth.ts#L45-L82](file:///project/src/middleware/auth.ts#L45-L82) -- [src/services/token.ts#L12-L58](file:///project/src/services/token.ts#L12-L58)`; diff --git a/extensions/subagents/subagents/lookout/tool-formatter.ts b/extensions/subagents/subagents/lookout/tool-formatter.ts deleted file mode 100644 index bb862f5b..00000000 --- a/extensions/subagents/subagents/lookout/tool-formatter.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Tool call formatter for Lookout subagent. - */ - -import { shortenPath } from "../../lib/paths"; -import type { SubagentToolCall } from "../../lib/types"; - -/** Create a lookout tool formatter with shortened path display */ -export function createLookoutToolFormatter( - cwd?: string, -): (tc: SubagentToolCall) => { label: string; detail: string } { - const sp = (p: string) => shortenPath(p, cwd); - - return (tc: SubagentToolCall) => { - const { toolName, args } = tc; - - switch (toolName) { - case "ast_grep": { - const pattern = args.pattern as string | undefined; - const lang = args.lang as string | undefined; - const paths = Array.isArray(args.paths) - ? (args.paths as string[]) - : undefined; - const truncated = pattern - ? `"${pattern.slice(0, 50)}${pattern.length > 50 ? "..." : ""}"` - : "..."; - const scope = paths?.length - ? ` in ${paths - .slice(0, 2) - .map((p) => sp(p)) - .join(", ")}${paths.length > 2 ? ", ..." : ""}` - : ""; - return { - label: "AST Grep", - detail: `${truncated}${lang ? ` (${lang})` : ""}${scope}`, - }; - } - case "grep": { - const pattern = args.pattern as string | undefined; - const path = args.path as string | undefined; - return { - label: "Grep", - detail: pattern - ? `"${pattern}"${path ? ` in ${sp(path)}` : ""}` - : "...", - }; - } - case "find": { - const name = args.name as string | undefined; - const path = args.path as string | undefined; - return { - label: "Find", - detail: name ? `"${name}"${path ? ` in ${sp(path)}` : ""}` : "...", - }; - } - case "read": { - const path = args.path as string | undefined; - return { - label: "Read", - detail: path ? sp(path) : "...", - }; - } - case "ls": { - const path = args.path as string | undefined; - return { - label: "List", - detail: path ? sp(path) : ".", - }; - } - default: - return { - label: toolName, - detail: JSON.stringify(args).slice(0, 50), - }; - } - }; -} diff --git a/extensions/subagents/subagents/lookout/tools/ast-grep-search.ts b/extensions/subagents/subagents/lookout/tools/ast-grep-search.ts deleted file mode 100644 index 278c5e8e..00000000 --- a/extensions/subagents/subagents/lookout/tools/ast-grep-search.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * AST search tool wrapping ast-grep CLI. - */ - -import { spawn } from "node:child_process"; -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; - -const DEFAULT_MAX_RESULTS = 10; -const MAX_SNIPPET_LENGTH = 160; - -const parameters = Type.Object({ - pattern: Type.String({ - description: - "ast-grep pattern to match. Use metavariables like $VAR or $$$ARGS.", - }), - lang: Type.Optional( - Type.String({ - description: - "Optional language for parsing the pattern, e.g. 'typescript' or 'tsx'.", - }), - ), - paths: Type.Optional( - Type.Array(Type.String(), { - description: - "Optional paths to search within. Relative paths are resolved from the current working directory.", - }), - ), - maxResults: Type.Optional( - Type.Number({ - description: "Maximum number of results to return (default: 10)", - default: DEFAULT_MAX_RESULTS, - }), - ), -}); - -type AstGrepParams = { - pattern: string; - lang?: string; - paths?: string[]; - maxResults?: number; -}; - -type AstGrepJsonMatch = { - file?: string; - lines?: string; - range?: { - start?: { line?: number }; - end?: { line?: number }; - }; -}; - -function truncateSnippet(snippet: string): string { - const normalized = snippet.replace(/\s+/g, " ").trim(); - if (normalized.length <= MAX_SNIPPET_LENGTH) { - return normalized; - } - return `${normalized.slice(0, MAX_SNIPPET_LENGTH - 3)}...`; -} - -function formatMatch(match: AstGrepJsonMatch): string | null { - if (!match.file) { - return null; - } - - const startLine = (match.range?.start?.line ?? 0) + 1; - const endLine = (match.range?.end?.line ?? startLine - 1) + 1; - const lineRange = - endLine > startLine ? `L${startLine}-L${endLine}` : `L${startLine}`; - const snippet = truncateSnippet(match.lines ?? ""); - - return snippet - ? `${match.file}:${lineRange} ${snippet}` - : `${match.file}:${lineRange}`; -} - -async function runAstGrep( - cwd: string, - pattern: string, - lang: string | undefined, - paths: string[] | undefined, - maxResults: number, - signal?: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - const args = ["run", "-p", pattern]; - - if (lang) { - args.push("--lang", lang); - } - - args.push("--json=stream"); - - if (paths && paths.length > 0) { - args.push(...paths); - } - - const child = spawn("ast-grep", args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - if (signal) { - signal.addEventListener( - "abort", - () => { - child.kill("SIGTERM"); - reject(new Error("Search aborted")); - }, - { once: true }, - ); - } - - child.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - - child.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - child.on("close", (code) => { - if (code !== 0) { - reject( - new Error( - stderr.trim() || `ast-grep exited with code ${code ?? "unknown"}`, - ), - ); - return; - } - - const lines = stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - const formatted: string[] = []; - - for (const line of lines) { - try { - const parsed = JSON.parse(line) as AstGrepJsonMatch; - const formattedMatch = formatMatch(parsed); - if (formattedMatch) { - formatted.push(formattedMatch); - } - } catch { - reject(new Error(`Failed to parse ast-grep JSON output: ${line}`)); - return; - } - } - - if (formatted.length === 0) { - resolve("No results found."); - return; - } - - const limited = formatted.slice(0, maxResults); - const omittedCount = formatted.length - limited.length; - const summary = - omittedCount > 0 ? `\n... ${omittedCount} more matches omitted.` : ""; - resolve(`${limited.join("\n")}${summary}`); - }); - - child.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") { - reject( - new Error( - "ast-grep is not installed or not in PATH. Install ast-grep before using ast_grep.", - ), - ); - return; - } - reject(err); - }); - }); -} - -export function createAstGrepSearchTool( - cwd: string, -): ToolDefinition { - return { - name: "ast_grep", - label: "AST Grep", - description: `Structural AST search powered by ast-grep. - -Use code-shaped patterns with metavariables. -- $VAR matches one AST node -- $$$ARGS matches zero or more AST nodes - -Returns compact file paths, line ranges, and snippets.`, - parameters, - - async execute(_toolCallId, args, signal, _onUpdate, _ctx) { - const { - pattern, - lang, - paths, - maxResults = DEFAULT_MAX_RESULTS, - } = args as AstGrepParams; - - try { - const output = await runAstGrep( - cwd, - pattern, - lang, - paths, - maxResults, - signal, - ); - - return { - content: [{ type: "text" as const, text: output }], - details: undefined, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - details: undefined, - }; - } - }, - }; -} diff --git a/extensions/subagents/subagents/lookout/tools/index.ts b/extensions/subagents/subagents/lookout/tools/index.ts deleted file mode 100644 index 4545cf66..00000000 --- a/extensions/subagents/subagents/lookout/tools/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Lookout tools aggregator. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; - -/** Create all custom tools for the Lookout subagent */ -// biome-ignore lint/suspicious/noExplicitAny: ToolDefinition requires any for generic tool arrays -export function createLookoutTools(_cwd: string): ToolDefinition[] { - return []; -} diff --git a/extensions/subagents/subagents/lookout/tools/project-context.ts b/extensions/subagents/subagents/lookout/tools/project-context.ts deleted file mode 100644 index b1a82f77..00000000 --- a/extensions/subagents/subagents/lookout/tools/project-context.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Project context generator for the lookout subagent. - * - * Uses `git ls-files` to get the list of tracked and untracked-but-not-ignored - * files, then produces a compact summary: file extension counts and directory - * tree. Injected into the system prompt so the model knows the repo layout - * before it starts searching. - * - * Returns an empty string for non-git directories. - */ - -import { spawn } from "node:child_process"; -import { dirname, extname } from "node:path"; - -const MAX_TREE_DEPTH = 3; -const MAX_TREE_ENTRIES = 80; - -/** - * Get tracked + untracked (but not ignored) files via git. - * Returns relative paths, or null if not a git repo. - */ -async function gitListFiles(cwd: string): Promise { - return new Promise((resolve) => { - const child = spawn( - "git", - ["ls-files", "--cached", "--others", "--exclude-standard"], - { cwd, stdio: ["ignore", "pipe", "pipe"] }, - ); - - let stdout = ""; - - child.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - - child.on("close", (code) => { - if (code !== 0) { - resolve(null); - return; - } - resolve( - stdout - .split("\n") - .map((f) => f.trim()) - .filter(Boolean), - ); - }); - - child.on("error", () => resolve(null)); - }); -} - -/** - * Build a compact directory tree from a flat list of file paths. - * Shows directories up to MAX_TREE_DEPTH and individual files at depth 0-1. - */ -function buildTree(files: string[]): string[] { - const dirs = new Set(); - const topFiles: string[] = []; - - for (const file of files) { - let dir = dirname(file); - while (dir !== ".") { - if (dir.split("/").length <= MAX_TREE_DEPTH) { - dirs.add(dir); - } - dir = dirname(dir); - } - - if (file.split("/").length <= 2) { - topFiles.push(file); - } - } - - const sortedDirs = [...dirs] - .sort() - .slice(0, MAX_TREE_ENTRIES) - .map((d) => `${d}/`); - const remaining = MAX_TREE_ENTRIES - sortedDirs.length; - const sortedFiles = topFiles.sort().slice(0, remaining); - - return [...sortedDirs, ...sortedFiles]; -} - -/** - * Generate a project context string for injection into the lookout system prompt. - * Returns an empty string if the directory is not a git repository. - */ -export async function generateProjectContext(cwd: string): Promise { - const files = await gitListFiles(cwd); - if (!files || files.length === 0) return ""; - - const extCounts = new Map(); - for (const file of files) { - const ext = extname(file).toLowerCase(); - if (ext) { - extCounts.set(ext, (extCounts.get(ext) ?? 0) + 1); - } - } - - const tree = buildTree(files); - - const lines: string[] = ["## Project Context"]; - - if (extCounts.size > 0) { - const sorted = [...extCounts.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 15); - const parts = sorted.map(([ext, count]) => `${count} ${ext}`); - lines.push(`Files: ${parts.join(", ")}`); - } - - if (tree.length > 0) { - lines.push("Structure:"); - for (const entry of tree) { - lines.push(` ${entry}`); - } - if (tree.length >= MAX_TREE_ENTRIES) { - lines.push(" ... (truncated)"); - } - } - - return lines.join("\n"); -} diff --git a/extensions/subagents/subagents/lookout/tools/semantic-search.ts b/extensions/subagents/subagents/lookout/tools/semantic-search.ts deleted file mode 100644 index 344d2da6..00000000 --- a/extensions/subagents/subagents/lookout/tools/semantic-search.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Semantic search tool wrapping osgrep CLI. - */ - -import { spawn } from "node:child_process"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; - -/** - * Progress information during osgrep indexing. - */ -interface IndexingProgress { - status: "starting" | "indexing" | "complete"; - filesProcessed?: number; - totalFiles?: number; - currentFile?: string; -} - -const parameters = Type.Object({ - query: Type.String({ - description: - "Natural language question describing what you're looking for. More words = better results. Example: 'where does the server validate JWT tokens'", - }), - maxResults: Type.Optional( - Type.Number({ - description: "Maximum results to return (default: 10)", - default: 10, - }), - ), -}); - -type SemanticSearchParams = { - query: string; - maxResults?: number; -}; - -/** - * Check if the repository needs indexing by looking for .osgrep directory. - */ -function needsIndexing(cwd: string): boolean { - const osgrepDir = path.join(cwd, ".osgrep"); - if (!fs.existsSync(osgrepDir)) { - return true; - } - // Check if directory has actual data (not just empty) - const contents = fs.readdirSync(osgrepDir); - return contents.length === 0; -} - -/** - * Check if index is stale (not modified in 3 days) and needs full reset. - * Checks the lancedb _versions directory which always gets touched during indexing. - */ -function needsResetIndexing(cwd: string): boolean { - const versionsDir = path.join( - cwd, - ".osgrep", - "lancedb", - "chunks.lance", - "_versions", - ); - if (!fs.existsSync(versionsDir)) { - return false; // doesn't exist, not stale - } - - try { - const stats = fs.statSync(versionsDir); - const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000; - return stats.mtimeMs < threeDaysAgo; - } catch { - return false; - } -} - -/** - * Parse osgrep stderr output to extract indexing progress. - */ -function parseIndexingProgress(text: string): IndexingProgress | null { - // Progress with verbose: "- Indexing files (N files) • filename" - // Check this FIRST - more specific than generic "- Indexing" - const progressMatch = text.match(/- Indexing files \((\d+) files\) • (.+)/); - if (progressMatch?.[1] && progressMatch[2]) { - return { - status: "indexing", - filesProcessed: parseInt(progressMatch[1], 10), - currentFile: progressMatch[2].trim(), - }; - } - - // Starting: "- Indexing..." (generic, check after specific patterns) - if (/^- Indexing/.test(text)) { - return { status: "starting" }; - } - - // Complete: "✔ Indexing complete(N / M) • indexed N" - const completeMatch = text.match(/Indexing complete\((\d+)\s*\/\s*(\d+)\)/); - if (completeMatch?.[1] && completeMatch[2]) { - return { - status: "complete", - filesProcessed: parseInt(completeMatch[1], 10), - totalFiles: parseInt(completeMatch[2], 10), - }; - } - - // Alternative complete format: "✔ Initial indexing complete (N/M)" - const altCompleteMatch = text.match( - /Initial indexing complete \((\d+)\/(\d+)\)/, - ); - if (altCompleteMatch?.[1] && altCompleteMatch[2]) { - return { - status: "complete", - filesProcessed: parseInt(altCompleteMatch[1], 10), - totalFiles: parseInt(altCompleteMatch[2], 10), - }; - } - - return null; -} - -/** - * Run osgrep index command asynchronously with progress streaming. - */ -async function runOsgrepIndex( - cwd: string, - onProgress: (progress: IndexingProgress) => void, - signal?: AbortSignal, - reset?: boolean, -): Promise { - return new Promise((resolve, reject) => { - const args = ["index", "--verbose"]; - if (reset) { - args.push("--reset"); - } - const child = spawn("osgrep", args, { - cwd, - stdio: ["pipe", "pipe", "pipe"], - }); - - // Handle abort - if (signal) { - signal.addEventListener( - "abort", - () => { - child.kill("SIGTERM"); - reject(new Error("Indexing aborted")); - }, - { once: true }, - ); - } - - // Buffer stderr to handle line-split chunks - let stderrBuffer = ""; - child.stderr.on("data", (data: Buffer) => { - stderrBuffer += data.toString(); - // Process complete lines - const lines = stderrBuffer.split("\n"); - // Keep incomplete line in buffer - stderrBuffer = lines.pop() ?? ""; - for (const line of lines) { - const progress = parseIndexingProgress(line); - if (progress) { - onProgress(progress); - } - } - }); - - child.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`osgrep index exited with code ${code}`)); - } - }); - - child.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") { - reject( - new Error( - "osgrep is not installed. Install with: npm install -g osgrep", - ), - ); - } else { - reject(err); - } - }); - }); -} - -/** - * Run osgrep search command asynchronously. - */ -async function runOsgrepSearch( - cwd: string, - query: string, - maxResults: number, - signal?: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - const child = spawn( - "osgrep", - [query, "-m", String(maxResults), "--plain", "--sync"], - { - cwd, - stdio: ["pipe", "pipe", "pipe"], - }, - ); - - let stdout = ""; - let stderr = ""; - - if (signal) { - signal.addEventListener( - "abort", - () => { - child.kill("SIGTERM"); - reject(new Error("Search aborted")); - }, - { once: true }, - ); - } - - child.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - - child.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - child.on("close", (code) => { - if (code === 0) { - resolve(stdout || "No results found."); - } else { - reject(new Error(stderr || `osgrep exited with code ${code}`)); - } - }); - - child.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") { - reject( - new Error( - "osgrep is not installed. Install with: npm install -g osgrep", - ), - ); - } else { - reject(err); - } - }); - }); -} - -export function createSemanticSearchTool( - cwd: string, -): ToolDefinition { - return { - name: "semantic_search", - label: "Semantic Search", - description: `Semantic code search - finds code by meaning, not just string matching. - -Query with natural language questions, not keywords. More words = better results. -- Good: "where does the server validate JWT tokens" -- Bad: "auth" or "JWT" - -Returns file paths, line ranges, roles (ORCHESTRATION = logic, DEFINITION = types), and relevance scores.`, - parameters, - - async execute(_toolCallId, args, signal, onUpdate, _ctx) { - const { query, maxResults = 10 } = args as SemanticSearchParams; - - try { - // Check if index is stale (>3 days old) and needs full reset - const needsReset = needsResetIndexing(cwd); - const needsInit = needsIndexing(cwd); - - if (needsInit || needsReset) { - // Update UI to show indexing status - const message = needsReset - ? "Index stale (>3 days), re-indexing from scratch..." - : "Indexing repository..."; - onUpdate?.({ - content: [{ type: "text", text: message }], - details: { indexing: true }, - }); - - // Run indexing with progress streaming - await runOsgrepIndex( - cwd, - (progress) => { - const progressMessage = - progress.status === "complete" - ? `Indexing complete (${progress.filesProcessed} files)` - : progress.currentFile - ? `Indexing (${progress.filesProcessed} files): ${progress.currentFile}` - : "Indexing..."; - - onUpdate?.({ - content: [{ type: "text", text: progressMessage }], - details: { indexing: true }, - }); - }, - signal, - needsReset, - ); - } - - // Clear indexing status after indexing completes - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { indexing: false }, - }); - - // Run the actual search - const output = await runOsgrepSearch(cwd, query, maxResults, signal); - - return { - content: [{ type: "text" as const, text: output }], - details: undefined, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - details: undefined, - }; - } - }, - }; -} diff --git a/extensions/subagents/subagents/lookout/types.ts b/extensions/subagents/subagents/lookout/types.ts deleted file mode 100644 index b34121b3..00000000 --- a/extensions/subagents/subagents/lookout/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Lookout subagent types. - */ - -import type { BaseSubagentDetails } from "../../lib/types"; - -/** Input parameters for the lookout subagent */ -export interface LookoutInput { - /** Search query describing what to find */ - query: string; - /** Optional working directory (defaults to current project cwd) */ - cwd?: string; - /** Optional skill names to provide specialized context */ - skills?: string[]; -} - -/** Details structure for lookout tool rendering */ -export interface LookoutDetails extends BaseSubagentDetails { - /** The search query */ - query: string; - /** Working directory for relative path display */ - cwd?: string; -} diff --git a/extensions/subagents/subagents/oracle/index.ts b/extensions/subagents/subagents/oracle/index.ts deleted file mode 100644 index dd0c9f39..00000000 --- a/extensions/subagents/subagents/oracle/index.ts +++ /dev/null @@ -1,421 +0,0 @@ -/** - * Oracle subagent - expert AI advisor for complex reasoning. - * - * Uses GPT-5.2 for architecture planning, code review, and strategic guidance. - * Advisory-only (no tools) - invoked zero-shot with no follow-ups. - */ - -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { - createRenderCache, - MarkdownResponse, - renderToolTextFallback, - SubagentFooter, - ToolCallHeader, - ToolDetails, - type ToolDetailsField, -} from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - AgentToolUpdateCallback, - ExtensionContext, - Skill, - Theme, - ToolDefinition, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { isDebugEnabled } from "../../config"; -import { executeSubagent, resolveSkillsByName } from "../../lib"; -import { selectModelForSubagent } from "../../lib/subagent-model-selection"; -import { ORACLE_SYSTEM_PROMPT } from "./system-prompt"; -import { createOracleTools } from "./tools"; -import type { OracleDetails, OracleInput } from "./types"; - -/** System prompt guidance for oracle tool usage */ -export const ORACLE_GUIDANCE = ` -## Oracle - -Use oracle when making plans, reviewing your own work, understanding existing code behavior, or debugging code that does not work. - -When calling oracle, tell the user why: "I'm going to ask the oracle for advice" or "I need to consult with the oracle." - -### Oracle Examples - -**Architecture review:** -- User: "review the authentication system we just built" -- Action: use oracle with relevant files to analyze architecture, then improve based on response - -**Debugging:** -- User: "I'm getting race conditions when I run this test" -- Action: run test to confirm, then use oracle with files and context about test run and race condition - -**Planning:** -- User: "plan the implementation of real-time collaboration features" -- Action: use lookout to locate relevant files, then use oracle to plan implementation - -**Implementation guidance:** -- User: "implement a new user authentication system with JWT tokens" -- Action: use oracle to analyze current patterns and plan approach, then proceed with implementation - -**Optimization:** -- User: "I need to optimize this slow database query" -- Action: use oracle to analyze performance issues and get recommendations, then implement -`; - -const parameters = Type.Object({ - task: Type.String({ description: "What to help with" }), - context: Type.Optional(Type.String({ description: "Background info" })), - files: Type.Optional( - Type.Array(Type.String(), { description: "Files to examine" }), - ), - skills: Type.Optional( - Type.Array(Type.String(), { - description: - "Skill names to provide specialized context (e.g., 'ios-26', 'drizzle-orm')", - }), - ), -}); - -/** Format files for context */ -async function formatFilesForContext( - files: string[], - cwd: string, -): Promise { - const contents: string[] = []; - - for (const file of files) { - const fullPath = path.resolve(cwd, file); - try { - const content = await fs.readFile(fullPath, "utf-8"); - contents.push(`### ${file}\n\`\`\`\n${content}\n\`\`\``); - } catch { - contents.push(`### ${file}\n(file not found or unreadable)`); - } - } - - return contents.join("\n\n"); -} - -/** Build the user message for the subagent based on inputs */ -function buildUserMessage(input: OracleInput, filesContent?: string): string { - let userMessage = `## Task\n${input.task}`; - - if (input.context) { - userMessage += `\n\n## Context\n${input.context}`; - } - - if (filesContent) { - userMessage += `\n\n## Files\n${filesContent}`; - } - - return userMessage; -} - -/** Create the oracle tool definition for use in extensions */ -export function createOracleTool(): ToolDefinition< - typeof parameters, - OracleDetails -> { - // Render cache for reusing components across updates - const renderCache = createRenderCache< - string, - { - toolDetails: ToolDetails; - footer: SubagentFooter; - markdownResponse: MarkdownResponse | null; - } - >(); - - return { - name: "oracle", - label: "Oracle", - description: `Consult the Oracle - an AI advisor powered by GPT-5 for complex reasoning. - -WHEN TO USE: -- Code reviews and architecture feedback -- Finding bugs across multiple files -- Planning complex implementations or refactoring -- Deep technical questions requiring reasoning - -WHEN NOT TO USE: -- Simple file reading (use read) -- Codebase searches (use lookout) -- Basic code modifications (do it yourself or use task) - -Pass relevant skills (e.g., 'ios-26', 'drizzle-orm') to provide specialized context for the task.`, - promptSnippet: - "Ask an expert advisor for planning, debugging, or architecture feedback.", - promptGuidelines: [ - "Use this tool for plans, architecture review, debugging guidance, and complex reasoning across files.", - "Before calling it, tell the user you are consulting the oracle and why.", - "Prefer read or lookout for simple file inspection or code search first.", - ], - - parameters, - - async execute( - toolCallId: string, - args: OracleInput, - signal: AbortSignal | undefined, - onUpdate: AgentToolUpdateCallback | undefined, - ctx: ExtensionContext, - ) { - const { task, context, files, skills: skillNames } = args; - - // Resolve skills if provided - let resolvedSkills: Skill[] = []; - let notFoundSkills: string[] = []; - - if (skillNames && skillNames.length > 0) { - const result = resolveSkillsByName(skillNames, ctx.cwd); - resolvedSkills = result.skills; - notFoundSkills = result.notFound; - } - - let resolvedModel: { provider: string; id: string } | undefined; - - const model = selectModelForSubagent("oracle", ctx); - resolvedModel = { provider: model.provider, id: model.id }; - - // Publish resolved provider/model as early as possible for footer rendering. - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - task, - context, - files, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - resolvedModel, - }, - }); - - // Format files if provided - let filesContent: string | undefined; - if (files && files.length > 0) { - filesContent = await formatFilesForContext(files, ctx.cwd); - } - - let userMessage = buildUserMessage(args, filesContent); - - // Append warning if skills not found - if (notFoundSkills.length > 0) { - userMessage += `\n\n**Note:** The following skills were not found and could not be loaded: ${notFoundSkills.join(", ")}`; - } - - const result = await executeSubagent( - { - name: "oracle", - model, - systemPrompt: ORACLE_SYSTEM_PROMPT, - skills: resolvedSkills, - tools: ["read"], - customTools: createOracleTools(), - thinkingLevel: "low", - logging: { - enabled: true, - debug: isDebugEnabled(), - }, - }, - userMessage, - ctx, - // onTextUpdate - (_delta, accumulated) => { - onUpdate?.({ - content: [{ type: "text", text: accumulated }], - details: { - _renderKey: toolCallId, - task, - context, - files, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - response: accumulated, - resolvedModel, - }, - }); - }, - signal, - ); - - if (result.aborted) { - return { - content: [{ type: "text" as const, text: "Aborted" }], - details: { - _renderKey: toolCallId, - task, - context, - files, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - aborted: true, - usage: result.usage, - resolvedModel, - }, - }; - } - - // Throw on error so the tool call is marked as failed - if (result.error) { - throw new Error(result.error); - } - - return { - content: [{ type: "text" as const, text: result.content }], - details: { - _renderKey: toolCallId, - task, - context, - files, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - response: result.content, - usage: result.usage, - resolvedModel, - }, - }; - }, - - renderCall(args, theme) { - const task = args.task?.trim() ?? ""; - - return new ToolCallHeader( - { - toolName: "Oracle", - optionArgs: [ - ...(args.files?.length - ? [ - { - label: "files", - value: String(args.files.length), - }, - ] - : []), - ...(args.skills?.length - ? [{ label: "skills", value: args.skills.join(",") }] - : []), - ], - longArgs: [ - ...(task ? [{ label: "task", value: task }] : []), - ...(args.context - ? [{ label: "context", value: args.context }] - : []), - ], - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const { details } = result; - - // Fallback if details missing - if (!details) { - return renderToolTextFallback(result, theme); - } - - const { - _renderKey, - response, - aborted, - error, - usage, - toolCalls, - resolvedModel, - task, - context, - files, - } = details; - - const renderKey = _renderKey ?? "_default_"; - const cached = renderCache.get(renderKey); - - // Footer - reuse or create - const footerData = { resolvedModel, usage, toolCalls }; - let footer: SubagentFooter; - if (cached) { - footer = cached.footer; - footer.updateData(footerData); - } else { - footer = new SubagentFooter(theme, footerData); - } - - // MarkdownResponse - reuse or create - let mdResponse = cached?.markdownResponse ?? null; - - // Build fields based on state - const fields: ToolDetailsField[] = []; - - if (aborted) { - fields.push({ label: "Status", value: "Aborted" }); - } else if (error) { - fields.push({ label: "Error", value: error }); - } else if (response) { - if (task) { - fields.push({ label: "Task", value: task }); - } - if (context) { - fields.push({ label: "Context", value: context }); - } - if (files?.length) { - fields.push({ label: "Files", value: files.join(", ") }); - } - - if (mdResponse) { - mdResponse.setContent(response); - } else { - mdResponse = new MarkdownResponse(response, theme); - } - fields.push(mdResponse); - } - - // ToolDetails - reuse or create - let toolDetails: ToolDetails; - if (cached) { - toolDetails = cached.toolDetails; - toolDetails.update({ fields, footer }, options); - } else { - toolDetails = new ToolDetails({ fields, footer }, options, theme); - } - - // Update cache - renderCache.set(renderKey, { - toolDetails, - footer, - markdownResponse: mdResponse, - }); - - return toolDetails; - }, - }; -} - -/** Execute the oracle subagent directly (without tool wrapper) */ -export async function executeOracle( - input: OracleInput, - ctx: ExtensionContext, - onUpdate?: AgentToolUpdateCallback, - signal?: AbortSignal, -): Promise> { - const tool = createOracleTool(); - return tool.execute("direct", input, signal, onUpdate, ctx); -} diff --git a/extensions/subagents/subagents/oracle/system-prompt.ts b/extensions/subagents/subagents/oracle/system-prompt.ts deleted file mode 100644 index 2ce72b55..00000000 --- a/extensions/subagents/subagents/oracle/system-prompt.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * System prompt for the Oracle subagent. - */ - -export const ORACLE_SYSTEM_PROMPT = `You are the Oracle - an expert AI advisor with advanced reasoning capabilities. - -Your role is to provide high-quality technical guidance, code reviews, architectural advice, and strategic planning. - -You are a subagent inside an AI coding system, invoked zero-shot (no follow-ups possible). - -Key responsibilities: -- Analyze code and architecture patterns -- Provide specific, actionable recommendations -- Plan implementations and refactoring strategies -- Identify potential issues and propose solutions - -Operating principles: -- Default to the simplest viable solution -- Prefer minimal, incremental changes reusing existing patterns -- Apply YAGNI and KISS; avoid premature optimization -- Provide one primary recommendation with at most one alternative -- Include rough effort signal (S <1h, M 1-3h, L 1-2d, XL >2d) - -Response format: -1. TL;DR: 1-3 sentences with recommended approach -2. Recommended approach: numbered steps or checklist -3. Rationale and trade-offs: brief justification -4. Risks and guardrails: key caveats -5. When to consider advanced path: triggers for more complexity - -IMPORTANT: Only your last message is returned. Make it comprehensive and actionable.`; diff --git a/extensions/subagents/subagents/oracle/tool-formatter.ts b/extensions/subagents/subagents/oracle/tool-formatter.ts deleted file mode 100644 index be2ad72c..00000000 --- a/extensions/subagents/subagents/oracle/tool-formatter.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Tool call formatter for oracle subagent. - * - * Oracle doesn't use tools - this is a placeholder for consistency. - */ - -import type { SubagentToolCall } from "../../lib/types"; - -/** - * Format a tool call for human-readable display. - * Oracle doesn't use tools, so this should never be called. - */ -export function formatOracleToolCall(_toolCall: SubagentToolCall): { - label: string; - detail?: string; -} { - return { - label: "Unknown", - }; -} diff --git a/extensions/subagents/subagents/oracle/tools/index.ts b/extensions/subagents/subagents/oracle/tools/index.ts deleted file mode 100644 index 0f202635..00000000 --- a/extensions/subagents/subagents/oracle/tools/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Oracle tools aggregator. - * - * Oracle is advisory-only and doesn't use tools. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; - -/** - * Create oracle tools (empty - oracle is advisory only). - */ -export function createOracleTools(): ToolDefinition[] { - return []; -} diff --git a/extensions/subagents/subagents/oracle/types.ts b/extensions/subagents/subagents/oracle/types.ts deleted file mode 100644 index 0386a0ac..00000000 --- a/extensions/subagents/subagents/oracle/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Oracle subagent types. - */ - -import type { BaseSubagentDetails } from "../../lib/types"; - -/** Input parameters for the oracle subagent */ -export interface OracleInput { - /** The task/question to consult the Oracle about */ - task: string; - /** Optional context or background information */ - context?: string; - /** Optional files to examine */ - files?: string[]; - /** Optional skill names to provide specialized context */ - skills?: string[]; -} - -/** Details structure for oracle tool rendering */ -export interface OracleDetails extends BaseSubagentDetails { - /** Task input */ - task: string; - /** Context input */ - context?: string; - /** Files input */ - files?: string[]; -} diff --git a/extensions/subagents/subagents/reviewer/index.ts b/extensions/subagents/subagents/reviewer/index.ts deleted file mode 100644 index 3c19c388..00000000 --- a/extensions/subagents/subagents/reviewer/index.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * Reviewer subagent - code review feedback on diffs. - */ - -import { - createRenderCache, - FailedToolCalls, - MarkdownResponse, - renderToolTextFallback, - SubagentFooter, - ToolCallHeader, - ToolCallList, - ToolCallSummary, - ToolDetails, - type ToolDetailsField, -} from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - AgentToolUpdateCallback, - ExtensionContext, - Skill, - ToolDefinition, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { - createBashTool, - createReadOnlyTools, - type Theme, -} from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { isDebugEnabled } from "../../config"; -import { - executeSubagent, - resolveSkillsByName, - shouldFailToolCallForModelIssue, -} from "../../lib"; -import { selectModelForSubagent } from "../../lib/subagent-model-selection"; -import type { SubagentToolCall } from "../../lib/types"; -import { REVIEWER_SYSTEM_PROMPT } from "./system-prompt"; -import { createReviewerToolFormatter } from "./tool-formatter"; -import { createReviewerTools } from "./tools"; -import type { ReviewerDetails, ReviewerInput } from "./types"; - -/** System prompt guidance for reviewer tool usage */ -export const REVIEWER_GUIDANCE = ` -## Reviewer - -Use reviewer for fast, high-signal code review feedback on diffs. It acts like a senior reviewer: calls out risks, correctness issues, test gaps, and maintainability concerns. - -**Inputs:** -- \`diff\`: Freeform description of what to review (e.g., "staged changes", "last commit", "changes in src/auth/") -- \`focus\`: Optional focus area (security, performance, style, general) -- \`context\`: Optional description of the change intent - -**Behavior:** -- Parse \`diff\` to determine the right git diff command -- Only flag issues introduced in the diff -- Avoid nitpicks unless style-only feedback requested - -**Output format:** -Summary, Findings with [P0-P3], Verdict. -`; - -const parameters = Type.Object({ - diff: Type.String({ - description: - "Freeform description of what to review (e.g., staged changes, last commit, changes in src/auth/)", - }), - focus: Type.Optional( - Type.String({ - description: "Focus area: security, performance, style, or general", - }), - ), - context: Type.Optional( - Type.String({ - description: "What the change is trying to achieve", - }), - ), - skills: Type.Optional( - Type.Array(Type.String(), { - description: - "Skill names to provide specialized context (e.g., 'ios-26', 'drizzle-orm')", - }), - ), -}); - -/** Build the user message for the subagent based on inputs */ -function buildUserMessage(input: ReviewerInput): string { - const parts: string[] = []; - - parts.push(`Diff scope: ${input.diff}`); - - if (input.focus) { - parts.push(`Focus: ${input.focus}`); - } - - if (input.context) { - parts.push(`Context: ${input.context}`); - } - - return parts.join("\n"); -} - -/** Create the reviewer tool definition for use in extensions */ -export function createReviewerTool(): ToolDefinition< - typeof parameters, - ReviewerDetails -> { - // Render cache for reusing components across updates - const renderCache = createRenderCache< - string, - { - toolDetails: ToolDetails; - footer: SubagentFooter; - markdownResponse: MarkdownResponse | null; - } - >(); - - return { - name: "reviewer", - label: "Reviewer", - description: `Code review agent that analyzes diffs and returns structured feedback. - -Inputs: -- diff: Freeform description of what to review (e.g., staged changes, last commit, changes in src/auth/) -- focus: Optional focus area (security, performance, style, general) -- context: Optional description of the change intent - -Pass relevant skills (e.g., 'ios-26', 'drizzle-orm') to provide specialized context for the task.`, - promptSnippet: - "Review a diff or change set for correctness, risks, and test gaps.", - promptGuidelines: [ - "Use this tool for fast review of diffs, staged changes, or recent commits.", - "Prefer it when the user wants risks, correctness issues, test gaps, or a verdict on a change.", - "Focus on issues introduced by the diff, not unrelated code.", - ], - - parameters, - - async execute( - toolCallId: string, - args: ReviewerInput, - signal: AbortSignal | undefined, - onUpdate: AgentToolUpdateCallback | undefined, - ctx: ExtensionContext, - ) { - const { diff, focus, context, skills: skillNames } = args; - - // Resolve skills if provided - let resolvedSkills: Skill[] = []; - let notFoundSkills: string[] = []; - - if (skillNames && skillNames.length > 0) { - const result = resolveSkillsByName(skillNames, ctx.cwd); - resolvedSkills = result.skills; - notFoundSkills = result.notFound; - } - - // Validate: diff is required - if (!diff) { - const error = "Diff scope is required."; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - diff: "", - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - error, - cwd: ctx.cwd, - }, - }; - } - - let resolvedModel: { provider: string; id: string } | undefined; - - let currentToolCalls: SubagentToolCall[] = []; - - try { - const model = selectModelForSubagent("reviewer", ctx); - resolvedModel = { provider: model.provider, id: model.id }; - - // Publish resolved provider/model as early as possible for footer rendering. - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - diff, - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: ctx.cwd, - }, - }); - - let userMessage = buildUserMessage(args); - - // Append warning if skills not found - if (notFoundSkills.length > 0) { - userMessage += `\n\n**Note:** The following skills were not found and could not be loaded: ${notFoundSkills.join(", ")}`; - } - - const bashTool = createBashTool(ctx.cwd) as ReturnType< - typeof createReadOnlyTools - >[number]; - const tools = [...createReadOnlyTools(ctx.cwd), bashTool]; - - const result = await executeSubagent( - { - name: "reviewer", - model, - systemPrompt: REVIEWER_SYSTEM_PROMPT, - skills: resolvedSkills, - tools: ["read", "grep", "find", "ls", "bash"], - customTools: [...tools, ...createReviewerTools()], - thinkingLevel: "low", - logging: { - enabled: true, - debug: isDebugEnabled(), - }, - }, - userMessage, - ctx, - // onTextUpdate - (_delta, _accumulated) => { - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - diff, - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: ctx.cwd, - }, - }); - }, - signal, - // onToolUpdate - (toolCalls: SubagentToolCall[]) => { - currentToolCalls = toolCalls; - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - diff, - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: ctx.cwd, - }, - }); - }, - ); - - const finalToolCalls = - result.toolCalls.length > 0 ? result.toolCalls : currentToolCalls; - - if (result.aborted) { - return { - content: [{ type: "text" as const, text: "Aborted" }], - details: { - _renderKey: toolCallId, - diff, - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - aborted: true, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } - - if (result.error) { - if (shouldFailToolCallForModelIssue(result)) { - throw new Error(result.error); - } - - return { - content: [ - { type: "text" as const, text: `Error: ${result.error}` }, - ], - details: { - _renderKey: toolCallId, - diff, - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error: result.error, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } - - // Check if all tool calls failed - const errorCount = finalToolCalls.filter( - (tc) => tc.status === "error", - ).length; - const allFailed = - finalToolCalls.length > 0 && errorCount === finalToolCalls.length; - - if (allFailed) { - const error = "All tool calls failed"; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - diff, - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } - - return { - content: [{ type: "text" as const, text: result.content }], - details: { - _renderKey: toolCallId, - diff, - focus, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - response: result.content, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } finally { - } - }, - - renderCall(args, theme) { - const diff = args.diff?.trim() ?? ""; - const shortDiff = diff.length > 80 ? `${diff.slice(0, 77)}...` : diff; - - return new ToolCallHeader( - { - toolName: "Reviewer", - mainArg: shortDiff, - optionArgs: [ - ...(args.focus ? [{ label: "focus", value: args.focus }] : []), - ...(args.skills?.length - ? [{ label: "skills", value: args.skills.join(",") }] - : []), - ], - longArgs: [ - ...(diff.length > 80 ? [{ label: "diff", value: diff }] : []), - ...(args.context - ? [{ label: "context", value: args.context }] - : []), - ], - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const { details } = result; - - // Fallback if details missing - if (!details) { - return renderToolTextFallback(result, theme); - } - - const { - _renderKey, - toolCalls, - response, - aborted, - error, - usage, - resolvedModel, - cwd, - } = details; - - const renderKey = _renderKey ?? "_default_"; - const cached = renderCache.get(renderKey); - - // Footer - reuse or create - const footerData = { resolvedModel, usage, toolCalls }; - let footer: SubagentFooter; - if (cached) { - footer = cached.footer; - footer.updateData(footerData); - } else { - footer = new SubagentFooter(theme, footerData); - } - - // MarkdownResponse - reuse or create - let mdResponse = cached?.markdownResponse ?? null; - - // Build fields based on state - const fields: ToolDetailsField[] = []; - const formatToolCall = createReviewerToolFormatter(cwd); - - if (aborted) { - fields.push({ label: "Status", value: "Aborted" }); - } else if (error) { - fields.push({ label: "Error", value: error }); - } else if (response) { - // Done state - if (isDebugEnabled()) { - fields.push(new ToolCallList(toolCalls, formatToolCall, theme)); - } else { - fields.push(new ToolCallSummary(toolCalls, formatToolCall, theme)); - } - fields.push(new FailedToolCalls(toolCalls, formatToolCall, theme)); - - if (mdResponse) { - mdResponse.setContent(response); - } else { - mdResponse = new MarkdownResponse(response, theme); - } - fields.push(mdResponse); - } else { - // Running state - fields.push(new ToolCallList(toolCalls, formatToolCall, theme)); - } - - // ToolDetails - reuse or create - let toolDetails: ToolDetails; - if (cached) { - toolDetails = cached.toolDetails; - toolDetails.update({ fields, footer }, options); - } else { - toolDetails = new ToolDetails({ fields, footer }, options, theme); - } - - // Update cache - renderCache.set(renderKey, { - toolDetails, - footer, - markdownResponse: mdResponse, - }); - - return toolDetails; - }, - }; -} - -/** Execute the reviewer subagent directly (without tool wrapper) */ -export async function executeReviewer( - input: ReviewerInput, - ctx: ExtensionContext, - onUpdate?: AgentToolUpdateCallback, - signal?: AbortSignal, -): Promise> { - const tool = createReviewerTool(); - return tool.execute("direct", input, signal, onUpdate, ctx); -} diff --git a/extensions/subagents/subagents/reviewer/system-prompt.ts b/extensions/subagents/subagents/reviewer/system-prompt.ts deleted file mode 100644 index e4c1278c..00000000 --- a/extensions/subagents/subagents/reviewer/system-prompt.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * System prompt for the Reviewer subagent. - */ - -export const REVIEWER_SYSTEM_PROMPT = `You are a code review specialist. You provide fast, high-signal feedback on diffs. - -## CRITICAL RULES -1. You MUST run the appropriate git diff command first based on the user's diff scope. -2. Only flag issues introduced in the diff (do not report pre-existing issues). -3. Focus on correctness, security, performance, and maintainability. -4. Avoid style/formatting/nits unless the user asked for style-only feedback. -5. If no issues, output "No findings" under Findings. - - -## Zero-shot execution policy -- You are invoked zero-shot. Do not ask follow-up questions. -- If diff scope is ambiguous, infer the most reasonable git diff command and proceed. -- Execute first, clarify last: produce the best review possible from available evidence. -## Diff command mapping -- "staged changes" -> git diff --staged -- "last commit" -> git diff HEAD~1 -- "changes in " -> git diff -- -- Other freeform scopes -> infer the closest equivalent git diff command - -## Available tools -- **bash**: run git diff, git show, git log, and other shell commands -- **read**: read file contents for context -- **grep**: search for exact strings -- **find**: find files by name pattern -- **ls**: list directory contents - -## Output format -Summary: 1-2 bullets on risk and intent. - -Findings: -- [P0] - <file:line> - <rationale> -- [P1] <title> - <file:line> - <rationale> -- ... -(or "No findings" if clean) - -Verdict: "Patch is correct" or "Patch is incorrect" + one sentence. - -Severity tags: -- [P0] Blocker -- [P1] Important -- [P2] Nice-to-have -- [P3] Nit -`; diff --git a/extensions/subagents/subagents/reviewer/tool-formatter.ts b/extensions/subagents/subagents/reviewer/tool-formatter.ts deleted file mode 100644 index 4f04aa78..00000000 --- a/extensions/subagents/subagents/reviewer/tool-formatter.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Tool call formatter for Reviewer subagent. - */ - -import { shortenPath } from "../../lib/paths"; -import type { SubagentToolCall } from "../../lib/types"; - -/** Create a reviewer tool formatter with shortened path display */ -export function createReviewerToolFormatter( - cwd?: string, -): (tc: SubagentToolCall) => { label: string; detail: string } { - const sp = (p: string) => shortenPath(p, cwd); - - return (tc: SubagentToolCall) => { - const { toolName, args } = tc; - - switch (toolName) { - case "bash": { - const command = args.command as string | undefined; - const truncated = command - ? command.length > 60 - ? `${command.slice(0, 60)}...` - : command - : "..."; - return { - label: "Bash", - detail: truncated, - }; - } - case "grep": { - const pattern = args.pattern as string | undefined; - const path = args.path as string | undefined; - return { - label: "Grep", - detail: pattern - ? `"${pattern}"${path ? ` in ${sp(path)}` : ""}` - : "...", - }; - } - case "find": { - const name = args.name as string | undefined; - const path = args.path as string | undefined; - return { - label: "Find", - detail: name ? `"${name}"${path ? ` in ${sp(path)}` : ""}` : "...", - }; - } - case "read": { - const path = args.path as string | undefined; - return { - label: "Read", - detail: path ? sp(path) : "...", - }; - } - case "ls": { - const path = args.path as string | undefined; - return { - label: "List", - detail: path ? sp(path) : ".", - }; - } - default: - return { - label: toolName, - detail: JSON.stringify(args).slice(0, 50), - }; - } - }; -} diff --git a/extensions/subagents/subagents/reviewer/tools/index.ts b/extensions/subagents/subagents/reviewer/tools/index.ts deleted file mode 100644 index 82e45033..00000000 --- a/extensions/subagents/subagents/reviewer/tools/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Reviewer tools aggregator. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; - -/** Create all custom tools for the Reviewer subagent */ -// biome-ignore lint/suspicious/noExplicitAny: ToolDefinition requires any for generic tool arrays -export function createReviewerTools(): ToolDefinition<any, any>[] { - return []; -} diff --git a/extensions/subagents/subagents/reviewer/types.ts b/extensions/subagents/subagents/reviewer/types.ts deleted file mode 100644 index 204ec68c..00000000 --- a/extensions/subagents/subagents/reviewer/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Reviewer subagent types. - */ - -import type { BaseSubagentDetails } from "../../lib/types"; - -/** Input parameters for the reviewer subagent */ -export interface ReviewerInput { - /** Diff scope to review (e.g., "staged changes", "last commit") */ - diff: string; - /** Optional focus area: security, performance, style, or general */ - focus?: string; - /** Optional context about the change intent */ - context?: string; - /** Optional skill names to provide specialized context */ - skills?: string[]; -} - -/** Details structure for reviewer tool rendering */ -export interface ReviewerDetails extends BaseSubagentDetails { - /** Diff scope */ - diff: string; - /** Optional focus area */ - focus?: string; - /** Optional context */ - context?: string; - /** Working directory for relative path display */ - cwd?: string; -} diff --git a/extensions/subagents/subagents/scout/index.ts b/extensions/subagents/subagents/scout/index.ts deleted file mode 100644 index 002776bc..00000000 --- a/extensions/subagents/subagents/scout/index.ts +++ /dev/null @@ -1,611 +0,0 @@ -/** - * Scout subagent - web research and URL fetching. - * - * Takes a URL and/or query with a prompt, and returns a detailed - * answer based on fetched information. - */ - -import { - createRenderCache, - FailedToolCalls, - MarkdownResponse, - renderToolTextFallback, - SubagentFooter, - ToolCallHeader, - ToolCallList, - ToolCallSummary, - ToolDetails, - type ToolDetailsField, -} from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - AgentToolUpdateCallback, - ExtensionContext, - Skill, - Theme, - ToolDefinition, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { - createExecutionTimer, - wrapToolDefinitionsWithTiming, -} from "../../../../packages/agent-kit"; -import { isDebugEnabled } from "../../config"; -import { - executeSubagent, - resolveSkillsByName, - shouldFailToolCallForModelIssue, -} from "../../lib"; -import { selectModelForSubagent } from "../../lib/subagent-model-selection"; -import type { SubagentToolCall } from "../../lib/types"; -import { SCOUT_SYSTEM_PROMPT } from "./system-prompt"; -import { formatScoutToolCall } from "./tool-formatter"; -import { createScoutTools } from "./tools"; -import type { ScoutDetails, ScoutInput } from "./types"; - -/** System prompt guidance for scout tool usage */ -export const SCOUT_GUIDANCE = ` -## Scout - -Use scout for deep web research and deep GitHub repository/code research. - -**When to use:** -- Multi-source research requiring synthesis/citations -- Deep repository exploration (architecture, code patterns, commit history, issue/PR context) -- Questions that require traversing many files/pages or comparing multiple sources -- Open-ended investigations (best practices, ecosystem scans, implementation studies) - -**When NOT to use (prefer direct tools):** -- Quick checks or single-fact lookups -- Simple GitHub metadata/status checks (use \`gh\` CLI) -- Straightforward URL retrieval from one page (use \`curl\` or \`web_fetch\` directly) -- Local codebase search (use lookout instead) -- Testing API endpoints (use curl instead) -- Making POST/PUT/DELETE requests - -**Rule of thumb:** -- If one direct command can answer it, do not use scout. -- Use scout when the task needs research depth, synthesis, or broad traversal. - -**Inputs:** -- \`url\`: Specific URL to fetch -- \`query\`: Search query for web or GitHub research -- \`repo\`: GitHub repository to focus on (owner/repo format) -- \`prompt\`: Question to answer based on fetched content - -At least one of url, query, or repo is required. - -**Note:** Scout always provides LLM-analyzed responses. For raw markdown content without analysis, use the \`web_fetch\` tool instead. - -**Examples:** -- Deep web research: \`{ query: "oauth token exchange security best practices 2026", prompt: "Compare top recommendations and trade-offs with sources" }\` -- Deep repo research: \`{ repo: "facebook/react", prompt: "Explain how hooks scheduling evolved across recent commits" }\` -- Cross-source investigation: \`{ repo: "owner/repo", query: "related RFC discussion", prompt: "Correlate code changes with issue/PR decisions" }\` - -**GitHub capabilities:** -- Read files and list directories -- Search code across repositories -- Search commits by message, author, or path -- View commit diffs -- List/filter issues and PRs in a repository -- Fetch individual issues and PRs with comments -- View PR diffs (changed files with patches) -- View PR reviews and inline code comments -- Compare branches, tags, or commits -`; - -const parameters = Type.Object({ - url: Type.Optional( - Type.String({ - description: "Specific URL to fetch content from", - }), - ), - query: Type.Optional( - Type.String({ - description: "Search query for web or GitHub research", - }), - ), - repo: Type.Optional( - Type.String({ - description: "GitHub repository to focus on (owner/repo format)", - }), - ), - prompt: Type.String({ - description: "What to analyze or answer based on the fetched content.", - }), - skills: Type.Optional( - Type.Array(Type.String(), { - description: - "Skill names to provide specialized context (e.g., 'ios-26', 'drizzle-orm')", - }), - ), -}); - -/** Build the user message for the subagent based on inputs */ -function buildUserMessage(input: ScoutInput): string { - const parts: string[] = []; - - if (input.url) { - parts.push(`URL to fetch: ${input.url}`); - } - - if (input.query) { - parts.push(`Search query: ${input.query}`); - } - - if (input.repo) { - parts.push(`GitHub repository to explore: ${input.repo}`); - } - - parts.push(`\nQuestion/Task: ${input.prompt}`); - - return parts.join("\n"); -} - -/** Create the scout tool definition for use in extensions */ -export function createScoutTool(): ToolDefinition< - typeof parameters, - ScoutDetails -> { - // Render cache for reusing components across updates - const renderCache = createRenderCache< - string, - { - toolDetails: ToolDetails; - footer: SubagentFooter; - markdownResponse: MarkdownResponse | null; - } - >(); - - return { - name: "scout", - label: "Scout", - description: `Deep research assistant for web content and GitHub codebase exploration. - -Use this for multi-source synthesis and deep repo/code investigations. -Do not use this for quick checks that one direct command can answer. - -Prefer direct tools for quick checks: -- Simple GitHub metadata/status -> gh CLI -- Single-page URL retrieval -> curl or web_fetch - -Inputs (at least one of url, query, or repo required): -- url: Specific URL to fetch -- query: Search query for web or GitHub research -- repo: GitHub repository to focus on (owner/repo format) -- prompt: Question to answer based on content - -Good use cases: -- Deep web research: { query: "oauth security best practices 2026", prompt: "Compare recommendations with citations" } -- Deep repo research: { repo: "facebook/react", prompt: "Explain how hooks scheduling evolved across commits" } -- Cross-source investigation: { repo: "owner/repo", query: "related RFC", prompt: "Correlate code changes with issue/PR decisions" } - -Pass relevant skills (e.g., 'ios-26', 'drizzle-orm') to provide specialized context for the task.`, - promptSnippet: - "Do deep web or GitHub research that needs synthesis across sources.", - promptGuidelines: [ - "Use this tool for multi-source research, deep repository exploration, or broad investigations that direct tools cannot answer quickly.", - "Do not use it for simple single-page fetches, exact local code search, or quick checks one direct command can answer.", - "Include a focused prompt explaining what to analyze from the fetched content.", - ], - - parameters, - - async execute( - toolCallId: string, - args: ScoutInput, - signal: AbortSignal | undefined, - onUpdate: AgentToolUpdateCallback<ScoutDetails> | undefined, - ctx: ExtensionContext, - ) { - const { url, query, repo, prompt, skills: skillNames } = args; - const executionTimer = createExecutionTimer(); - - // Resolve skills if provided - let resolvedSkills: Skill[] = []; - let notFoundSkills: string[] = []; - - if (skillNames && skillNames.length > 0) { - const result = resolveSkillsByName(skillNames, ctx.cwd); - resolvedSkills = result.skills; - notFoundSkills = result.notFound; - } - - // Validate: at least one of url, query, or repo required - if (!url && !query && !repo) { - const error = "At least one of 'url', 'query', or 'repo' is required."; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - error, - totalDurationMs: executionTimer.getDurationMs(), - }, - }; - } - - let resolvedModel: { provider: string; id: string } | undefined; - - let currentToolCalls: SubagentToolCall[] = []; - - try { - const model = selectModelForSubagent("scout", ctx); - resolvedModel = { provider: model.provider, id: model.id }; - - // Publish resolved provider/model as early as possible for footer rendering. - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - }, - }); - - let userMessage = buildUserMessage(args); - - // Append warning if skills not found - if (notFoundSkills.length > 0) { - userMessage += `\n\n**Note:** The following skills were not found and could not be loaded: ${notFoundSkills.join(", ")}`; - } - - const result = await executeSubagent( - { - name: "scout", - model, - systemPrompt: SCOUT_SYSTEM_PROMPT, - skills: resolvedSkills, - tools: [], - customTools: wrapToolDefinitionsWithTiming(createScoutTools()), - thinkingLevel: "off", - logging: { - enabled: true, - debug: isDebugEnabled(), - }, - }, - userMessage, - ctx, - // onTextUpdate - (_delta, _accumulated) => { - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - }, - }); - }, - signal, - // onToolUpdate - (toolCalls: SubagentToolCall[]) => { - currentToolCalls = toolCalls; - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - }, - }); - }, - ); - - const finalToolCalls = - result.toolCalls.length > 0 ? result.toolCalls : currentToolCalls; - - if (result.aborted) { - return { - content: [{ type: "text" as const, text: "Aborted" }], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - aborted: true, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } - - if (result.error) { - if (shouldFailToolCallForModelIssue(result)) { - throw new Error(result.error); - } - - return { - content: [ - { type: "text" as const, text: `Error: ${result.error}` }, - ], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error: result.error, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } - - // Check if all tool calls failed - const errorCount = finalToolCalls.filter( - (tc) => tc.status === "error", - ).length; - const allFailed = - finalToolCalls.length > 0 && errorCount === finalToolCalls.length; - - if (allFailed) { - const error = "All tool calls failed"; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } - - // Check for failed tool calls and append notification - const failedTools = finalToolCalls.filter( - (tc) => tc.status === "error", - ); - let finalContent = result.content; - - if (failedTools.length > 0) { - finalContent += "\n\n---\n\n"; - finalContent += `**Note:** ${failedTools.length} tool call${failedTools.length > 1 ? "s" : ""} failed:\n\n`; - - for (const tc of failedTools) { - finalContent += `- **${tc.toolName}**`; - - // Extract clean error message - if (tc.error) { - let errorText = tc.error; - try { - const parsed = JSON.parse(tc.error); - if (parsed.content?.[0]?.text) { - errorText = parsed.content[0].text; - - // Extract clean error from API responses - const apiErrorMatch = errorText.match( - /API error \(\d+\): ({.+})$/, - ); - if (apiErrorMatch?.[1]) { - try { - const apiError = JSON.parse(apiErrorMatch[1]); - if (apiError.error) { - errorText = apiError.error; - } - } catch { - // Keep original - } - } - } - } catch { - // Keep original - } - - // Truncate long errors - if (errorText.length > 120) { - errorText = `${errorText.slice(0, 117)}...`; - } - - finalContent += `: ${errorText}`; - } - - finalContent += "\n"; - } - } - - return { - content: [{ type: "text" as const, text: finalContent }], - details: { - _renderKey: toolCallId, - url, - query, - repo, - prompt, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - response: result.content, - usage: result.usage, - resolvedModel, - totalDurationMs: result.totalDurationMs, - }, - }; - } finally { - } - }, - - renderCall(args, theme) { - const prompt = args.prompt?.trim() ?? ""; - - return new ToolCallHeader( - { - toolName: "Scout", - optionArgs: [ - ...(args.url ? [{ label: "url", value: args.url }] : []), - ...(args.query ? [{ label: "query", value: args.query }] : []), - ...(args.repo ? [{ label: "repo", value: args.repo }] : []), - ...(args.skills?.length - ? [{ label: "skills", value: args.skills.join(",") }] - : []), - ], - longArgs: prompt - ? [ - { - label: "prompt", - value: prompt, - }, - ] - : undefined, - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult<ScoutDetails>, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const { details } = result; - - // Fallback if details missing - if (!details) { - return renderToolTextFallback(result, theme); - } - - const { - _renderKey, - toolCalls, - response, - aborted, - error, - usage, - resolvedModel, - totalDurationMs, - } = details; - - const renderKey = _renderKey ?? "_default_"; - const cached = renderCache.get(renderKey); - - // Footer - reuse or create - const footerData = { resolvedModel, usage, toolCalls, totalDurationMs }; - let footer: SubagentFooter; - if (cached) { - footer = cached.footer; - footer.updateData(footerData); - } else { - footer = new SubagentFooter(theme, footerData); - } - - // MarkdownResponse - reuse or create - let mdResponse = cached?.markdownResponse ?? null; - - // Build fields based on state - const fields: ToolDetailsField[] = []; - - if (aborted) { - fields.push({ label: "Status", value: "Aborted" }); - } else if (error) { - fields.push({ label: "Error", value: error }); - } else if (response) { - // Done state - if (isDebugEnabled()) { - fields.push(new ToolCallList(toolCalls, formatScoutToolCall, theme)); - } else { - fields.push( - new ToolCallSummary(toolCalls, formatScoutToolCall, theme), - ); - } - fields.push(new FailedToolCalls(toolCalls, formatScoutToolCall, theme)); - - if (mdResponse) { - mdResponse.setContent(response); - } else { - mdResponse = new MarkdownResponse(response, theme); - } - fields.push(mdResponse); - } else { - // Running state - fields.push(new ToolCallList(toolCalls, formatScoutToolCall, theme)); - } - - // ToolDetails - reuse or create - let toolDetails: ToolDetails; - if (cached) { - toolDetails = cached.toolDetails; - toolDetails.update({ fields, footer }, options); - } else { - toolDetails = new ToolDetails({ fields, footer }, options, theme); - } - - // Update cache - renderCache.set(renderKey, { - toolDetails, - footer, - markdownResponse: mdResponse, - }); - - return toolDetails; - }, - }; -} - -/** Execute the scout subagent directly (without tool wrapper) */ -export async function executeScout( - input: ScoutInput, - ctx: ExtensionContext, - onUpdate?: AgentToolUpdateCallback<ScoutDetails>, - signal?: AbortSignal, -): Promise<AgentToolResult<ScoutDetails>> { - const tool = createScoutTool(); - return tool.execute("direct", input, signal, onUpdate, ctx); -} diff --git a/extensions/subagents/subagents/scout/providers/exa.ts b/extensions/subagents/subagents/scout/providers/exa.ts deleted file mode 100644 index cd6c5dec..00000000 --- a/extensions/subagents/subagents/scout/providers/exa.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { getScoutWebConfig } from "../../../config"; -import type { - Availability, - FetchInput, - FetchResult, - ScoutFetchProvider, - ScoutSearchProvider, - SearchInput, - SearchResult, -} from "./types"; - -function createTimeoutSignal( - timeoutMs: number, - signal?: AbortSignal, -): AbortSignal { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - if (!signal) return timeoutSignal; - return AbortSignal.any([signal, timeoutSignal]); -} - -const EXA_BASE_URL = "https://api.exa.ai"; - -export class ExaProvider implements ScoutSearchProvider, ScoutFetchProvider { - readonly id = "exa" as const; - readonly label = "Exa"; - readonly capabilities = ["web_search", "web_fetch"] as const; - - private get apiKey(): string | undefined { - return process.env.EXA_API_KEY; - } - - isAvailable(): Availability { - if (!this.apiKey) { - return { ok: false, reason: "Missing EXA_API_KEY" }; - } - return { ok: true }; - } - - async search( - input: SearchInput, - signal?: AbortSignal, - ): Promise<SearchResult> { - const config = getScoutWebConfig(); - const response = await fetch(`${EXA_BASE_URL}/search`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - query: input.query, - type: config.providers.exa.searchMode, - numResults: 10, - }), - signal: createTimeoutSignal(5000, signal), - }); - - const data = (await this.parseJson(response)) as { - results?: Array<{ - title?: string; - url?: string; - text?: string; - publishedDate?: string; - }>; - costDollars?: { total?: number }; - }; - - const items = (data.results ?? []) - .filter((item) => !!item.url) - .map((item) => ({ - title: item.title ?? item.url ?? "Untitled", - url: item.url ?? "", - text: item.text, - published: item.publishedDate, - })); - - return { - provider: this.id, - items, - cost: - typeof data.costDollars?.total === "number" - ? { - amount: data.costDollars.total, - currency: "USD", - source: "exa.costDollars.total", - } - : undefined, - }; - } - - async fetch(input: FetchInput, signal?: AbortSignal): Promise<FetchResult> { - const response = await fetch(`${EXA_BASE_URL}/contents`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - urls: [input.url], - text: true, - }), - signal: createTimeoutSignal(5000, signal), - }); - - const data = (await this.parseJson(response)) as { - results?: Array<{ text?: string; markdown?: string; url?: string }>; - costDollars?: { total?: number }; - }; - - const first = data.results?.[0]; - const markdown = first?.text ?? first?.markdown; - if (!markdown) { - throw new Error("Exa fetch returned no content"); - } - - return { - provider: this.id, - markdown, - cost: - typeof data.costDollars?.total === "number" - ? { - amount: data.costDollars.total, - currency: "USD", - source: "exa.costDollars.total", - } - : undefined, - }; - } - - private get headers(): Record<string, string> { - if (!this.apiKey) { - throw new Error("Missing EXA_API_KEY"); - } - return { - "content-type": "application/json", - "x-api-key": this.apiKey, - }; - } - - private async parseJson(response: Response): Promise<unknown> { - const text = await response.text(); - if (!response.ok) { - throw new Error(`Exa API error (${response.status}): ${text}`); - } - try { - return text.length > 0 ? (JSON.parse(text) as unknown) : {}; - } catch { - throw new Error("Exa API returned invalid JSON"); - } - } -} diff --git a/extensions/subagents/subagents/scout/providers/index.ts b/extensions/subagents/subagents/scout/providers/index.ts deleted file mode 100644 index 21258c58..00000000 --- a/extensions/subagents/subagents/scout/providers/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ExaProvider } from "./exa"; -import { LinkupProvider } from "./linkup"; -import { MarkdownNewProvider } from "./markdown-dot-new"; -import { SyntheticProvider } from "./synthetic"; -import type { ScoutProviderBase, ScoutProviderId } from "./types"; - -export function createScoutProviders(): ScoutProviderBase[] { - return [ - new ExaProvider(), - new LinkupProvider(), - new MarkdownNewProvider(), - new SyntheticProvider(), - ]; -} - -export function getProviderById( - id: ScoutProviderId, -): ScoutProviderBase | undefined { - return createScoutProviders().find((provider) => provider.id === id); -} diff --git a/extensions/subagents/subagents/scout/providers/linkup.ts b/extensions/subagents/subagents/scout/providers/linkup.ts deleted file mode 100644 index e3fb4abc..00000000 --- a/extensions/subagents/subagents/scout/providers/linkup.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { getScoutWebConfig } from "../../../config"; -import type { - Availability, - FetchInput, - FetchResult, - ScoutFetchProvider, - ScoutSearchProvider, - SearchInput, - SearchResult, -} from "./types"; - -function createTimeoutSignal( - timeoutMs: number, - signal?: AbortSignal, -): AbortSignal { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - if (!signal) return timeoutSignal; - return AbortSignal.any([signal, timeoutSignal]); -} - -const LINKUP_BASE_URL = "https://api.linkup.so/v1"; - -export class LinkupProvider implements ScoutSearchProvider, ScoutFetchProvider { - readonly id = "linkup" as const; - readonly label = "Linkup"; - readonly capabilities = ["web_search", "web_fetch"] as const; - - private get apiKey(): string | undefined { - return process.env.LINKUP_API_KEY; - } - - isAvailable(): Availability { - if (!this.apiKey) { - return { ok: false, reason: "Missing LINKUP_API_KEY" }; - } - return { ok: true }; - } - - async search( - input: SearchInput, - signal?: AbortSignal, - ): Promise<SearchResult> { - const config = getScoutWebConfig(); - const response = await fetch(`${LINKUP_BASE_URL}/search`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - query: input.query, - depth: config.providers.linkup.searchDepth, - }), - signal: createTimeoutSignal(5000, signal), - }); - - const data = (await this.parseJson(response)) as { - results?: Array<{ - name?: string; - title?: string; - url?: string; - content?: string; - publishedDate?: string; - }>; - }; - - const items = (data.results ?? []) - .filter((item) => !!item.url) - .map((item) => ({ - title: item.title ?? item.name ?? item.url ?? "Untitled", - url: item.url ?? "", - text: item.content, - published: item.publishedDate, - })); - - const searchDepth = config.providers.linkup.searchDepth; - - return { - provider: this.id, - items, - cost: { - amount: searchDepth === "deep" ? 0.05 : 0.005, - currency: "EUR", - source: `linkup.estimated.search.${searchDepth}`, - }, - }; - } - - async fetch(input: FetchInput, signal?: AbortSignal): Promise<FetchResult> { - const config = getScoutWebConfig(); - const defaultRenderJs = config.providers.linkup.renderJsDefault; - - try { - return await this.fetchOnce(input.url, defaultRenderJs, signal, false); - } catch (error) { - if (defaultRenderJs) throw error; - return this.fetchOnce(input.url, true, signal, true); - } - } - - private async fetchOnce( - url: string, - renderJs: boolean, - signal: AbortSignal | undefined, - retried: boolean, - ): Promise<FetchResult> { - const response = await fetch(`${LINKUP_BASE_URL}/fetch`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - url, - renderJs, - }), - signal: createTimeoutSignal(5000, signal), - }); - - const data = (await this.parseJson(response)) as { - content?: string; - markdown?: string; - data?: { content?: string; markdown?: string }; - }; - - const markdown = - data.content ?? - data.markdown ?? - data.data?.content ?? - data.data?.markdown; - if (!markdown) { - throw new Error("Linkup fetch returned no content"); - } - - return { - provider: this.id, - markdown, - cost: { - amount: renderJs ? 0.02 : 0.01, - currency: "EUR", - source: `linkup.estimated.fetch.${renderJs ? "renderJs" : "standard"}`, - }, - meta: retried ? { retryWithRenderJs: true } : undefined, - }; - } - - private get headers(): Record<string, string> { - if (!this.apiKey) { - throw new Error("Missing LINKUP_API_KEY"); - } - return { - "content-type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }; - } - - private async parseJson(response: Response): Promise<unknown> { - const text = await response.text(); - if (!response.ok) { - throw new Error(`Linkup API error (${response.status}): ${text}`); - } - try { - return text.length > 0 ? (JSON.parse(text) as unknown) : {}; - } catch { - throw new Error("Linkup API returned invalid JSON"); - } - } -} diff --git a/extensions/subagents/subagents/scout/providers/markdown-dot-new.ts b/extensions/subagents/subagents/scout/providers/markdown-dot-new.ts deleted file mode 100644 index 3c786acd..00000000 --- a/extensions/subagents/subagents/scout/providers/markdown-dot-new.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - Availability, - FetchInput, - FetchResult, - ScoutFetchProvider, -} from "./types"; - -function createTimeoutSignal( - timeoutMs: number, - signal?: AbortSignal, -): AbortSignal { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - if (!signal) return timeoutSignal; - return AbortSignal.any([signal, timeoutSignal]); -} - -const MARKDOWNNEW_BASE_URL = "https://markdown.new"; - -/** - * Markdown New provider - free URL-to-markdown conversion. - * Fetch only (no search). 500 requests/day/IP limit. - */ -export class MarkdownNewProvider implements ScoutFetchProvider { - readonly id = "markdownDotNew" as const; - readonly label = "Markdown New"; - readonly capabilities = ["web_fetch"] as const; - - isAvailable(): Availability { - return { ok: true }; - } - - async fetch(input: FetchInput, signal?: AbortSignal): Promise<FetchResult> { - const response = await fetch(MARKDOWNNEW_BASE_URL, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ url: input.url, retain_images: true }), - signal: createTimeoutSignal(5000, signal), - }); - - const text = await response.text(); - if (!response.ok) { - throw new Error(`Markdown New API error (${response.status}): ${text}`); - } - - const data = JSON.parse(text) as { - content?: string; - tokens?: number; - method?: string; - }; - - const markdown = data.content; - if (!markdown) { - throw new Error("Markdown New returned no content"); - } - - const rateLimitRemaining = response.headers.get("x-rate-limit-remaining"); - - return { - provider: this.id, - markdown, - meta: { - ...(data.tokens != null ? { markdownTokens: data.tokens } : {}), - ...(data.method ? { method: data.method } : {}), - ...(rateLimitRemaining != null - ? { rateLimitRemaining: Number(rateLimitRemaining) } - : {}), - }, - }; - } -} diff --git a/extensions/subagents/subagents/scout/providers/router.ts b/extensions/subagents/subagents/scout/providers/router.ts deleted file mode 100644 index ba6e57ee..00000000 --- a/extensions/subagents/subagents/scout/providers/router.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { getScoutWebConfig } from "../../../config"; -import { createScoutProviders } from "./index"; -import type { - FetchResult, - ScoutCapability, - ScoutFetchProvider, - ScoutProviderBase, - ScoutProviderId, - ScoutSearchProvider, - SearchResult, -} from "./types"; - -export interface RouterAttempt { - provider: ScoutProviderId; - startedAt: number; - endedAt?: number; - durationMs?: number; - ok: boolean; - error?: string; - cost?: { amount: number; currency: "USD" | "EUR" }; -} - -export interface RouterDiagnostics { - capability: ScoutCapability; - order: ScoutProviderId[]; - unavailable: Array<{ provider: ScoutProviderId; reason: string }>; - attempts: RouterAttempt[]; - selected?: ScoutProviderId; -} - -export class ScoutRoutingError extends Error { - constructor( - message: string, - readonly diagnostics: RouterDiagnostics, - ) { - super(message); - this.name = "ScoutRoutingError"; - } -} - -function isSearchProvider( - provider: ScoutProviderBase, -): provider is ScoutSearchProvider { - return provider.capabilities.includes("web_search"); -} - -function isFetchProvider( - provider: ScoutProviderBase, -): provider is ScoutFetchProvider { - return provider.capabilities.includes("web_fetch"); -} - -export async function routeSearch(input: { - query: string; - signal?: AbortSignal; -}): Promise<{ result: SearchResult; diag: RouterDiagnostics }> { - const config = getScoutWebConfig(); - const providers = createScoutProviders(); - - const diag: RouterDiagnostics = { - capability: "web_search", - order: [...config.searchOrder], - unavailable: [], - attempts: [], - }; - - for (const providerId of config.searchOrder) { - const provider = providers.find((p) => p.id === providerId); - if (!provider || !isSearchProvider(provider)) { - continue; - } - - if (!isEnabled(provider.id)) { - diag.unavailable.push({ - provider: provider.id, - reason: "Disabled in settings", - }); - continue; - } - - const availability = provider.isAvailable(); - if (!availability.ok) { - diag.unavailable.push({ - provider: provider.id, - reason: availability.reason, - }); - continue; - } - - const attempt: RouterAttempt = { - provider: provider.id, - startedAt: Date.now(), - ok: false, - }; - diag.attempts.push(attempt); - - try { - const result = await provider.search( - { query: input.query }, - input.signal, - ); - attempt.ok = true; - attempt.endedAt = Date.now(); - attempt.durationMs = attempt.endedAt - attempt.startedAt; - attempt.cost = result.cost - ? { amount: result.cost.amount, currency: result.cost.currency } - : undefined; - diag.selected = provider.id; - return { result, diag }; - } catch (error) { - attempt.ok = false; - attempt.endedAt = Date.now(); - attempt.durationMs = attempt.endedAt - attempt.startedAt; - attempt.error = error instanceof Error ? error.message : String(error); - } - } - - throw new ScoutRoutingError( - "No web search provider could fulfill request", - diag, - ); -} - -export async function routeFetch(input: { - url: string; - signal?: AbortSignal; -}): Promise<{ result: FetchResult; diag: RouterDiagnostics }> { - const config = getScoutWebConfig(); - const providers = createScoutProviders(); - - const diag: RouterDiagnostics = { - capability: "web_fetch", - order: [...config.fetchOrder], - unavailable: [], - attempts: [], - }; - - for (const providerId of config.fetchOrder) { - const provider = providers.find((p) => p.id === providerId); - if (!provider || !isFetchProvider(provider)) { - continue; - } - - if (!isEnabled(provider.id)) { - diag.unavailable.push({ - provider: provider.id, - reason: "Disabled in settings", - }); - continue; - } - - const availability = provider.isAvailable(); - if (!availability.ok) { - diag.unavailable.push({ - provider: provider.id, - reason: availability.reason, - }); - continue; - } - - const attempt: RouterAttempt = { - provider: provider.id, - startedAt: Date.now(), - ok: false, - }; - diag.attempts.push(attempt); - - try { - const result = await provider.fetch({ url: input.url }, input.signal); - attempt.ok = true; - attempt.endedAt = Date.now(); - attempt.durationMs = attempt.endedAt - attempt.startedAt; - attempt.cost = result.cost - ? { amount: result.cost.amount, currency: result.cost.currency } - : undefined; - diag.selected = provider.id; - return { result, diag }; - } catch (error) { - attempt.ok = false; - attempt.endedAt = Date.now(); - attempt.durationMs = attempt.endedAt - attempt.startedAt; - attempt.error = error instanceof Error ? error.message : String(error); - } - } - - throw new ScoutRoutingError( - "No web fetch provider could fulfill request", - diag, - ); -} - -function isEnabled(providerId: ScoutProviderId): boolean { - const config = getScoutWebConfig(); - if (providerId === "exa") return config.providers.exa.enabled; - if (providerId === "linkup") return config.providers.linkup.enabled; - if (providerId === "markdownDotNew") - return config.providers.markdownDotNew.enabled; - return config.providers.synthetic.enabled; -} diff --git a/extensions/subagents/subagents/scout/providers/synthetic.ts b/extensions/subagents/subagents/scout/providers/synthetic.ts deleted file mode 100644 index 2efa9bc5..00000000 --- a/extensions/subagents/subagents/scout/providers/synthetic.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { - Availability, - ScoutSearchProvider, - SearchInput, - SearchResult, -} from "./types"; - -function createTimeoutSignal( - timeoutMs: number, - signal?: AbortSignal, -): AbortSignal { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - if (!signal) return timeoutSignal; - return AbortSignal.any([signal, timeoutSignal]); -} - -const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; - -export class SyntheticProvider implements ScoutSearchProvider { - readonly id = "synthetic" as const; - readonly label = "Synthetic"; - readonly capabilities = ["web_search"] as const; - - private get apiKey(): string | undefined { - return process.env.SYNTHETIC_API_KEY; - } - - isAvailable(): Availability { - if (!this.apiKey) { - return { ok: false, reason: "Missing SYNTHETIC_API_KEY" }; - } - return { ok: true }; - } - - async search( - input: SearchInput, - signal?: AbortSignal, - ): Promise<SearchResult> { - const response = await fetch(`${SYNTHETIC_BASE_URL}/search`, { - method: "POST", - headers: this.headers, - body: JSON.stringify({ query: input.query }), - signal: createTimeoutSignal(5000, signal), - }); - - const data = (await this.parseJson(response)) as { - results?: Array<{ - title?: string; - url?: string; - snippet?: string; - content?: string; - publishedAt?: string; - }>; - }; - - const items = (data.results ?? []) - .filter((item) => !!item.url) - .map((item) => ({ - title: item.title ?? item.url ?? "Untitled", - url: item.url ?? "", - text: item.content ?? item.snippet, - published: item.publishedAt, - })); - - return { - provider: this.id, - items, - }; - } - - private get headers(): Record<string, string> { - if (!this.apiKey) { - throw new Error("Missing SYNTHETIC_API_KEY"); - } - return { - "content-type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }; - } - - private async parseJson(response: Response): Promise<unknown> { - const text = await response.text(); - if (!response.ok) { - throw new Error(`Synthetic API error (${response.status}): ${text}`); - } - try { - return text.length > 0 ? (JSON.parse(text) as unknown) : {}; - } catch { - throw new Error("Synthetic API returned invalid JSON"); - } - } -} diff --git a/extensions/subagents/subagents/scout/providers/types.ts b/extensions/subagents/subagents/scout/providers/types.ts deleted file mode 100644 index 7d69328b..00000000 --- a/extensions/subagents/subagents/scout/providers/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -export type ScoutProviderId = "exa" | "linkup" | "synthetic" | "markdownDotNew"; -export type ScoutCapability = "web_search" | "web_fetch"; - -export interface AvailabilityOk { - ok: true; -} - -export interface AvailabilityFail { - ok: false; - reason: string; -} - -export type Availability = AvailabilityOk | AvailabilityFail; - -export interface SearchInput { - query: string; -} - -export interface FetchInput { - url: string; -} - -export interface SearchResultItem { - title: string; - url: string; - text?: string; - published?: string; -} - -export interface ProviderCost { - amount: number; - currency: "USD" | "EUR"; - source?: string; -} - -export interface SearchResult { - provider: ScoutProviderId; - items: SearchResultItem[]; - cost?: ProviderCost; - meta?: Record<string, unknown>; -} - -export interface FetchResult { - provider: ScoutProviderId; - markdown: string; - cost?: ProviderCost; - meta?: Record<string, unknown>; -} - -export interface ScoutProviderBase { - id: ScoutProviderId; - label: string; - capabilities: readonly ScoutCapability[]; - isAvailable(): Availability; -} - -export interface ScoutSearchProvider extends ScoutProviderBase { - search(input: SearchInput, signal?: AbortSignal): Promise<SearchResult>; -} - -export interface ScoutFetchProvider extends ScoutProviderBase { - fetch(input: FetchInput, signal?: AbortSignal): Promise<FetchResult>; -} diff --git a/extensions/subagents/subagents/scout/system-prompt.ts b/extensions/subagents/subagents/scout/system-prompt.ts deleted file mode 100644 index dbd73fec..00000000 --- a/extensions/subagents/subagents/scout/system-prompt.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * System prompt for the scout subagent. - */ - -export const SCOUT_SYSTEM_PROMPT = `You are Scout, a research assistant specializing in web research and GitHub codebase exploration. - -## Your Tools - -### Web Tools -- **web_fetch**: Fetch raw content from any URL (webpages, articles, documentation). Returns provider content directly. -- **web_search**: Search the web for information. Returns a list of normalized results. - -### GitHub Tools -- **github_content**: Read files, list directories, or get repository info. Provide repo and optionally a path. Supports a \`ref\` parameter for specific branches/tags/SHAs. -- **github_search**: Search code across GitHub repositories. Supports GitHub code search syntax. -- **github_commits**: Search commits by message/author/path, or get diff for a specific commit (provide sha). -- **github_issue**: Fetch a single issue or pull request by number, with discussion comments. Works for both issues and PRs. -- **github_issues**: List/filter issues and PRs in a repository. Supports filtering by state, type (issue/pr), labels, author, assignee. Use this to discover issues/PRs; use github_issue to read a specific one. -- **github_pr_diff**: Fetch the diff (changed files with patches) for a pull request. Use this to see actual code changes. -- **github_pr_reviews**: Fetch reviews (approved/changes requested) and inline code comments for a pull request. -- **github_compare**: Compare two branches, tags, or commits. Shows commits and file diffs between two refs. -- **list_user_repos**: List repositories for a GitHub user. Supports filtering by language, name prefix, and sorting. - -### Repo Clone Tools -- **clone_repo**: Clone a GitHub repository to a temporary directory. Use for deep codebase exploration when GitHub API tools are too slow or limited (e.g., reading many files, complex cross-file searches). Returns the local path. -- **repo_read**: Read a file from a previously cloned repository. Paths are scoped to the clone directory — you cannot escape it. Also lists directory contents if given a directory path. - -### Gist Tools -- **download_gist**: Clone a GitHub Gist to a temporary directory. Returns the local path. -- **upload_gist**: Commit and push changes from a cloned gist directory. Gists are flat (no subdirectories). - -## Behavior - -Based on your input, decide what to do: - -1. **URL provided**: Fetch the URL content using \`web_fetch\` - -2. **Search query provided**: Search the web using \`web_search\` - -3. **GitHub exploration**: Use GitHub tools to explore repositories: - - Start with \`github_content\` to understand repo structure - - Use \`github_search\` to find specific code patterns - - Use \`github_commits\` to understand code evolution - - Use \`github_issues\` to discover/list issues and PRs - - Use \`github_issue\` to read a specific issue or PR with comments - - Use \`github_pr_diff\` to see actual code changes in a PR - - Use \`github_pr_reviews\` to see review verdicts and inline code comments - - Use \`github_compare\` to see differences between branches or tags - - **For deep exploration** (reading many files, complex cross-file analysis): use \`clone_repo\` then \`repo_read\` - -4. **Answer the prompt**: After gathering content, analyze it and provide a detailed answer to the question - -## Mappings - -- For packages/repositories named \`mariozechner/pi-*\`, use GitHub repository \`badlogic/pi-mono\`. -- OpenClaw documentation is hosted at \`https://docs.openclaw.ai\` (not \`https://docs.openclaw.com\`). - -## Codebase Exploration Patterns - -When exploring a codebase: -1. Start by getting repo info to understand structure and purpose -2. Search for relevant code patterns across the repo -3. Read specific files to understand implementation details -4. Check commit history to understand how code evolved -5. Look at related issues/PRs for context on decisions -6. **For deep exploration**: clone the repo with \`clone_repo\`, then use \`repo_read\` to read files. Prefer this over making many individual \`github_content\` calls when you need to read many files from the same repo. - -## Response Format - -- Provide a clear, detailed answer based on the gathered content and the prompt -- Link to source files with full GitHub URLs when referencing code -- Format your response in markdown - -## Important - -- You are invoked zero-shot. Do not ask the user for clarification unless completely blocked after trying fallback strategies. -- Execute first, clarify last: when inputs are imperfect, make the best reasonable assumptions and continue. -- If a fetch source fails (JS challenge, anti-bot page, timeout, paywall preview, empty content), do NOT stop immediately. -- For URL tasks, try at least two fallback strategies before asking for more info, for example: - 1) Use \`web_search\` to find equivalent sources (official docs, mirrors, raw content links) and fetch those. - 2) Try alternate pages likely to contain the same content (release notes, changelog, docs pages, tagged files). - 3) If relevant, use GitHub tools (\`github_content\`, \`github_search\`, commits/issues/PRs) to reconstruct the answer. -- If still blocked, return partial findings plus a precise blocker and the minimum missing input needed. -- Always cite sources (URLs) when answering prompts. -- Do not make up information - only use what you fetched. -`; diff --git a/extensions/subagents/subagents/scout/tool-formatter.ts b/extensions/subagents/subagents/scout/tool-formatter.ts deleted file mode 100644 index 5d6fe83a..00000000 --- a/extensions/subagents/subagents/scout/tool-formatter.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Format scout tool calls for display. - */ - -import { getToolResultDetails, type SubagentToolCall } from "../../lib/types"; - -export interface FormattedToolCall { - label: string; - detail?: string; -} - -/** - * Format a scout tool call for display. - * - * Examples: - * - web_fetch: "Fetch example.com/path via exa" - * - web_search: "Search 'typescript best practices' via exa" - * - github_content: "Content owner/repo/path" - * - github_search: "Search 'query'" - * - github_commits: "Commits owner/repo" or "Diff abc1234" - * - github_issue: "Issue owner/repo#123" - * - github_issues: "Issues owner/repo (open, pr)" - * - github_pr_diff: "PR Diff owner/repo#123" - * - github_pr_reviews: "PR Reviews owner/repo#123" - * - github_compare: "Compare owner/repo main...feature" - */ -export function formatScoutToolCall( - toolCall: SubagentToolCall, -): FormattedToolCall { - const { toolName, args } = toolCall; - - let formatted: FormattedToolCall; - - switch (toolName) { - case "web_fetch": { - const url = args.url as string | undefined; - const provider = getProviderFromToolCall(toolCall); - if (url) { - try { - const parsed = new URL(url); - formatted = { - label: "Fetch", - detail: withProvider(parsed.hostname + parsed.pathname, provider), - }; - } catch { - formatted = { label: "Fetch", detail: withProvider(url, provider) }; - } - } else { - formatted = { - label: "Fetch", - detail: withProvider(undefined, provider), - }; - } - break; - } - - case "web_search": { - const query = args.query as string | undefined; - const provider = getProviderFromToolCall(toolCall); - formatted = { - label: "Search", - detail: withProvider(query ? `'${query}'` : undefined, provider), - }; - break; - } - - case "github_content": { - const repo = args.repo as string | undefined; - const path = args.path as string | undefined; - if (repo) { - // Extract owner/repo from URL or use as-is - let repoName = repo; - try { - const parsed = new URL(repo); - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - repoName = `${parts[0]}/${parts[1]}`; - } - } catch { - // Not a URL, use as-is - } - const detail = path ? `${repoName}/${path}` : repoName; - formatted = { label: "Content", detail }; - } else { - formatted = { label: "Content" }; - } - break; - } - - case "github_search": { - const query = args.query as string | undefined; - const repo = args.repo as string | undefined; - let detail = query ? `'${query}'` : undefined; - if (repo && detail) { - detail += ` in ${repo}`; - } - formatted = { label: "Code Search", detail }; - break; - } - - case "github_commits": { - const repo = args.repo as string | undefined; - const sha = args.sha as string | undefined; - if (sha) { - formatted = { label: "Diff", detail: sha.slice(0, 7) }; - } else if (repo) { - // Extract owner/repo from URL or use as-is - let repoName = repo; - try { - const parsed = new URL(repo); - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - repoName = `${parts[0]}/${parts[1]}`; - } - } catch { - // Not a URL, use as-is - } - formatted = { label: "Commits", detail: repoName }; - } else { - formatted = { label: "Commits" }; - } - break; - } - - case "github_issue": { - const repo = args.repo as string | undefined; - const number = args.number as number | undefined; - if (repo && number) { - // Extract owner/repo from URL or use as-is - let repoName = repo; - try { - const parsed = new URL(repo); - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - repoName = `${parts[0]}/${parts[1]}`; - } - } catch { - // Not a URL, use as-is - } - formatted = { label: "Issue", detail: `${repoName}#${number}` }; - } else { - formatted = { label: "Issue" }; - } - break; - } - - case "github_issues": { - const repo = args.repo as string | undefined; - const state = args.state as string | undefined; - const type = args.type as string | undefined; - if (repo) { - let repoName = repo; - try { - const parsed = new URL(repo); - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - repoName = `${parts[0]}/${parts[1]}`; - } - } catch { - // Not a URL, use as-is - } - const filters: string[] = []; - if (state && state !== "open") { - filters.push(state); - } - if (type && type !== "all") { - filters.push(type); - } - const detail = - filters.length > 0 ? `${repoName} (${filters.join(", ")})` : repoName; - formatted = { label: "Issues", detail }; - } else { - formatted = { label: "Issues" }; - } - break; - } - - case "github_pr_diff": { - const repo = args.repo as string | undefined; - const number = args.number as number | undefined; - if (repo && number) { - let repoName = repo; - try { - const parsed = new URL(repo); - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - repoName = `${parts[0]}/${parts[1]}`; - } - } catch { - // Not a URL, use as-is - } - formatted = { label: "PR Diff", detail: `${repoName}#${number}` }; - } else { - formatted = { label: "PR Diff" }; - } - break; - } - - case "github_pr_reviews": { - const repo = args.repo as string | undefined; - const number = args.number as number | undefined; - if (repo && number) { - let repoName = repo; - try { - const parsed = new URL(repo); - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - repoName = `${parts[0]}/${parts[1]}`; - } - } catch { - // Not a URL, use as-is - } - formatted = { label: "PR Reviews", detail: `${repoName}#${number}` }; - } else { - formatted = { label: "PR Reviews" }; - } - break; - } - - case "github_compare": { - const repo = args.repo as string | undefined; - const base = args.base as string | undefined; - const head = args.head as string | undefined; - if (repo && base && head) { - let repoName = repo; - try { - const parsed = new URL(repo); - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - repoName = `${parts[0]}/${parts[1]}`; - } - } catch { - // Not a URL, use as-is - } - formatted = { - label: "Compare", - detail: `${repoName} ${base}...${head}`, - }; - } else { - formatted = { label: "Compare" }; - } - break; - } - - case "list_user_repos": { - const username = args.username as string | undefined; - const language = args.language as string | undefined; - const namePrefix = args.namePrefix as string | undefined; - if (username) { - let detail = `@${username}`; - const filters: string[] = []; - if (language) { - filters.push(language); - } - if (namePrefix) { - filters.push(`${namePrefix}*`); - } - if (filters.length > 0) { - detail += ` (${filters.join(", ")})`; - } - formatted = { label: "List Repos", detail }; - } else { - formatted = { label: "List Repos" }; - } - break; - } - - case "download_gist": { - const gist = args.gist as string | undefined; - formatted = { label: "Download Gist", detail: gist }; - break; - } - - case "upload_gist": { - const gist = args.gist as string | undefined; - formatted = { label: "Upload Gist", detail: gist }; - break; - } - - default: - formatted = { label: toolName }; - break; - } - - return appendDurationToDetail(formatted, toolCall.durationMs); -} - -function getProviderFromToolCall( - toolCall: SubagentToolCall, -): string | undefined { - const details = getToolResultDetails(toolCall.result); - return typeof details?.provider === "string" ? details.provider : undefined; -} - -function withProvider( - detail: string | undefined, - provider: string | undefined, -): string | undefined { - if (!provider) return detail; - if (!detail) return `via ${provider}`; - return `${detail} via ${provider}`; -} - -function appendDurationToDetail( - formatted: FormattedToolCall, - durationMs?: number, -): FormattedToolCall { - if (durationMs === undefined) return formatted; - - const duration = formatDuration(durationMs); - return { - ...formatted, - detail: formatted.detail ? `${formatted.detail} · ${duration}` : duration, - }; -} - -function formatDuration(durationMs: number): string { - if (durationMs < 1000) return `${durationMs}ms`; - return `${(durationMs / 1000).toFixed(2)}s`; -} diff --git a/extensions/subagents/subagents/scout/tools/clone-repo.ts b/extensions/subagents/subagents/scout/tools/clone-repo.ts deleted file mode 100644 index 27d1ce98..00000000 --- a/extensions/subagents/subagents/scout/tools/clone-repo.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Clone a GitHub repository to a temporary directory. - * - * Returns the local path so the scout can use repo_read to explore files. - */ - -import { execFile } from "node:child_process"; -import { mkdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; - -const execFileAsync = promisify(execFile); - -interface CloneResult { - cloneDir: string; - repo: string; - ref?: string; -} - -/** Track cloned repos so repo_read can validate paths. */ -const activeClones = new Map<string, string>(); // repo -> cloneDir - -export function getCloneDir(repo: string): string | undefined { - return activeClones.get(repo); -} - -const parameters = Type.Object({ - repo: Type.String({ - description: "Repository in owner/repo format (e.g., 'openai/codex')", - }), - ref: Type.Optional( - Type.String({ - description: - "Branch, tag, or commit SHA to clone. Defaults to the default branch.", - }), - ), - depth: Type.Optional( - Type.Number({ - description: - "Clone depth for shallow clone. Defaults to 1. Use 0 for full history.", - }), - ), -}); - -export const cloneRepoTool: ToolDefinition<typeof parameters> = { - name: "clone_repo", - label: "Clone Repo", - description: `Clone a GitHub repository to a temporary directory for deep exploration. - -Use when you need to read many files or grep/search through a codebase that the GitHub API cannot efficiently serve (e.g., large repos, complex cross-file searches). - -After cloning, use repo_read to read files from the cloned directory. -The clone is ephemeral and will be cleaned up automatically. - -Examples: -- clone_repo(repo="openai/codex") -- clone_repo(repo="facebook/react", ref="main", depth=0)`, - - parameters, - - async execute( - _toolCallId: string, - args: { repo: string; ref?: string; depth?: number }, - signal: AbortSignal | undefined, - ) { - const { repo, ref, depth } = args; - const cloneDepth = depth ?? 1; - - // Validate repo format - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return { - content: [ - { - type: "text" as const, - text: `Error: Invalid repository format: ${repo}. Expected 'owner/repo'.`, - }, - ], - details: { error: "invalid_repo_format" }, - }; - } - - // Check if already cloned - const existing = activeClones.get(repo); - if (existing) { - return { - content: [ - { - type: "text" as const, - text: `Repository ${repo} already cloned at: ${existing}`, - }, - ], - details: { cloneDir: existing, repo, ref, reused: true }, - }; - } - - // Create temp directory - const baseDir = join(tmpdir(), "pi-scout-clones"); - await mkdir(baseDir, { recursive: true }); - const cloneDir = join(baseDir, repo.replace("/", "--")); - - // Clean up if exists from a previous run - try { - await rm(cloneDir, { recursive: true, force: true }); - } catch { - // ignore - } - - // Build git clone args - const gitUrl = `https://github.com/${repo}.git`; - const cloneArgs = ["clone", "--single-branch"]; - if (cloneDepth > 0) { - cloneArgs.push("--depth", String(cloneDepth)); - } - if (ref) { - cloneArgs.push("--branch", ref); - } - cloneArgs.push(gitUrl, cloneDir); - - try { - const { stderr } = await execFileAsync("git", cloneArgs, { - signal, - timeout: 120_000, - }); - - // Track the clone - activeClones.set(repo, cloneDir); - - return { - content: [ - { - type: "text" as const, - text: `Cloned ${repo} to ${cloneDir}${ref ? ` (ref: ${ref})` : ""}${cloneDepth > 0 ? ` (depth: ${cloneDepth})` : " (full history)"}.${stderr ? `\n${stderr.trim()}` : ""}`, - }, - ], - details: { cloneDir, repo, ref } satisfies CloneResult, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content: [ - { - type: "text" as const, - text: `Error cloning ${repo}: ${message}`, - }, - ], - details: { error: message, repo }, - }; - } - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/download-gist.ts b/extensions/subagents/subagents/scout/tools/download-gist.ts deleted file mode 100644 index ae6a9505..00000000 --- a/extensions/subagents/subagents/scout/tools/download-gist.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Download Gist tool - fetches a GitHub Gist to a temporary directory. - */ - -import { mkdir, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { runGh } from "../../../lib/gh"; - -const parameters = Type.Object({ - gist: Type.String({ - description: - "GitHub Gist ID or full URL (e.g., 'abc123' or 'https://gist.github.com/user/abc123')", - }), -}); - -interface GistFile { - filename: string; - content: string; -} - -interface GistResponse { - id: string; - files: Record<string, GistFile>; -} - -/** - * Extract Gist ID from input string. - */ -export function extractGistId(input: string): string { - if (input.startsWith("http://") || input.startsWith("https://")) { - const url = new URL(input); - if (!url.hostname.includes("gist.github.com")) { - throw new Error(`Not a GitHub Gist URL: ${input}`); - } - const parts = url.pathname.split("/").filter(Boolean); - const gistId = parts[parts.length - 1]; - if (!gistId) { - throw new Error(`Invalid Gist URL: ${input}`); - } - return gistId; - } - - if (!/^[a-zA-Z0-9]+$/.test(input)) { - throw new Error(`Invalid Gist ID format: ${input}`); - } - - return input; -} - -export const downloadGistTool: ToolDefinition<typeof parameters> = { - name: "download_gist", - label: "Download Gist", - description: `Download a GitHub Gist to a temporary directory. - -Returns the path to the directory containing the gist files. - -Usage: -- Gist ID: gist="abc123def456" -- Full URL: gist="https://gist.github.com/username/abc123def456"`, - - parameters, - - async execute( - _toolCallId: string, - args: { gist: string }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const gistId = extractGistId(args.gist); - - // Fetch gist via gh api - const json = await runGh(["api", `gists/${gistId}`], signal); - const gist: GistResponse = JSON.parse(json); - - // Create temp directory - const tempDir = join(tmpdir(), `gist-${gistId}-${Date.now()}`); - await mkdir(tempDir, { recursive: true }); - - // Write all files - for (const file of Object.values(gist.files)) { - await writeFile(join(tempDir, file.filename), file.content); - } - - return { - content: [{ type: "text" as const, text: tempDir }], - details: { - gistId, - directory: tempDir, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-commits.ts b/extensions/subagents/subagents/scout/tools/github-commits.ts deleted file mode 100644 index 00e9b5a5..00000000 --- a/extensions/subagents/subagents/scout/tools/github-commits.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * GitHub Commits tool for searching commits or getting diff for a specific commit. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (e.g., 'facebook/react') or full GitHub URL (e.g., 'https://github.com/facebook/react')", - }), - query: Type.Optional( - Type.String({ - description: "Search in commit messages", - }), - ), - author: Type.Optional( - Type.String({ - description: "Filter by author (username or email)", - }), - ), - path: Type.Optional( - Type.String({ - description: "Filter by file path", - }), - ), - sha: Type.Optional( - Type.String({ - description: - "If provided, get diff for this specific commit instead of searching", - }), - ), -}); - -/** - * Parse repository input to extract owner and repo. - * Handles both 'owner/repo' format and full GitHub URLs. - */ -function parseRepo(repo: string): { owner: string; repo: string } { - // Check if it's a full URL - if (repo.startsWith("http://") || repo.startsWith("https://")) { - try { - const url = new URL(repo); - if (url.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${repo}`); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - const owner = parts[0]; - const repoName = parts[1]; - if (!owner || !repoName) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - return { owner, repo: repoName }; - } catch (error) { - if (error instanceof Error && error.message.includes("GitHub")) { - throw error; - } - throw new Error(`Invalid GitHub URL: ${repo}`); - } - } - - // Handle owner/repo format - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format: ${repo}. Expected 'owner/repo' or a full GitHub URL.`, - ); - } - - return { owner: parts[0], repo: parts[1] }; -} - -export const githubCommitsTool: ToolDefinition<typeof parameters> = { - name: "github_commits", - label: "GitHub Commits", - description: `Search commits or get diff for a specific commit. - -Usage: -- Search commits: provide repo and optionally query, author, or path -- Get commit diff: provide repo and sha - -Examples: -- Search by message: repo="facebook/react", query="fix bug" -- Filter by author: repo="facebook/react", author="gaearon" -- Filter by path: repo="facebook/react", path="packages/react/src" -- Get diff: repo="facebook/react", sha="abc1234" - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { - repo: string; - query?: string; - author?: string; - path?: string; - sha?: string; - }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { repo: repoInput, query, author, path, sha } = args; - const client = createGitHubClient(); - const { owner, repo } = parseRepo(repoInput); - - let markdown: string; - let mode: "diff" | "search"; - - if (sha) { - // Get diff for specific commit - markdown = await client.getCommitDiff(owner, repo, sha, signal); - mode = "diff"; - } else { - // Search commits - markdown = await client.searchCommits( - owner, - repo, - { query, author, path }, - signal, - ); - mode = "search"; - } - - return { - content: [{ type: "text" as const, text: markdown }], - details: { - owner, - repo, - mode, - sha: sha || null, - query: query || null, - author: author || null, - path: path || null, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-compare.ts b/extensions/subagents/subagents/scout/tools/github-compare.ts deleted file mode 100644 index 0503f965..00000000 --- a/extensions/subagents/subagents/scout/tools/github-compare.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * GitHub Compare tool for comparing two branches, tags, or commits. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (e.g., 'facebook/react') or full GitHub URL", - }), - base: Type.String({ - description: "Base branch, tag, or commit SHA", - }), - head: Type.String({ - description: "Head branch, tag, or commit SHA to compare against base", - }), -}); - -function parseRepo(repo: string): { owner: string; repo: string } { - if (repo.startsWith("http://") || repo.startsWith("https://")) { - try { - const url = new URL(repo); - if (url.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${repo}`); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - const owner = parts[0]; - const repoName = parts[1]; - if (!owner || !repoName) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - return { owner, repo: repoName }; - } catch (error) { - if (error instanceof Error && error.message.includes("GitHub")) { - throw error; - } - throw new Error(`Invalid GitHub URL: ${repo}`); - } - } - - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format: ${repo}. Expected 'owner/repo' or a full GitHub URL.`, - ); - } - - return { owner: parts[0], repo: parts[1] }; -} - -export const githubCompareTool: ToolDefinition<typeof parameters> = { - name: "github_compare", - label: "GitHub Compare", - description: `Compare two branches, tags, or commits in a repository. - -Shows the commits and file diffs between two refs. Useful for seeing what changed between branches or releases. - -Examples: -- Compare branches: repo="facebook/react", base="main", head="feature-branch" -- Compare tags: repo="facebook/react", base="v18.0.0", head="v18.1.0" -- Compare commits: repo="facebook/react", base="abc1234", head="def5678" - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { repo: string; base: string; head: string }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { repo: repoInput, base, head } = args; - const client = createGitHubClient(); - const { owner, repo } = parseRepo(repoInput); - - const result = await client.compareRefs(owner, repo, base, head, signal); - - return { - content: [{ type: "text" as const, text: result }], - details: { - owner, - repo, - base, - head, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-content.ts b/extensions/subagents/subagents/scout/tools/github-content.ts deleted file mode 100644 index 41618bf7..00000000 --- a/extensions/subagents/subagents/scout/tools/github-content.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * GitHub Content tool for reading files, listing directories, or getting repo info. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (e.g., 'facebook/react') or full GitHub URL (e.g., 'https://github.com/facebook/react')", - }), - path: Type.Optional( - Type.String({ - description: - "File or directory path within the repository. If omitted, returns repository info with README.", - }), - ), - ref: Type.Optional( - Type.String({ - description: - "Branch name, tag, or commit SHA. Defaults to the repository's default branch.", - }), - ), -}); - -/** - * Parse repository input to extract owner and repo. - * Handles both 'owner/repo' format and full GitHub URLs. - */ -function parseRepo(repo: string): { owner: string; repo: string } { - // Check if it's a full URL - if (repo.startsWith("http://") || repo.startsWith("https://")) { - try { - const url = new URL(repo); - if (url.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${repo}`); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - const owner = parts[0]; - const repoName = parts[1]; - if (!owner || !repoName) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - return { owner, repo: repoName }; - } catch (error) { - if (error instanceof Error && error.message.includes("GitHub")) { - throw error; - } - throw new Error(`Invalid GitHub URL: ${repo}`); - } - } - - // Handle owner/repo format - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format: ${repo}. Expected 'owner/repo' or a full GitHub URL.`, - ); - } - - return { owner: parts[0], repo: parts[1] }; -} - -export const githubContentTool: ToolDefinition<typeof parameters> = { - name: "github_content", - label: "GitHub Content", - description: `Read files, list directories, or get repository info from GitHub. - -Usage: -- Get repo info: provide just the repo (returns README and structure) -- Read a file: provide repo and path to a file -- List directory: provide repo and path to a directory - -Examples: -- Repo info: repo="facebook/react" -- Read file: repo="facebook/react", path="README.md" -- List dir: repo="facebook/react", path="packages" -- Specific branch: repo="facebook/react", path="src", ref="main" - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { repo: string; path?: string; ref?: string }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { repo: repoInput, path, ref } = args; - const client = createGitHubClient(); - const { owner, repo } = parseRepo(repoInput); - - let markdown: string; - let contentType: "repo" | "file" | "directory"; - - if (!path) { - // No path provided: fetch repository info - markdown = await client.fetchRepoInfo(owner, repo, signal); - contentType = "repo"; - } else { - // Path provided: try file first, fall back to directory - try { - markdown = await client.fetchFileContent( - owner, - repo, - path, - ref, - signal, - ); - contentType = "file"; - } catch (error) { - // Check if the error indicates it's not a file (i.e., it's a directory) - if (error instanceof Error && error.message.includes("is not a file")) { - markdown = await client.fetchDirectoryContent( - owner, - repo, - path, - ref, - signal, - ); - contentType = "directory"; - } else { - // Re-throw other errors - throw error; - } - } - } - - return { - content: [{ type: "text" as const, text: markdown }], - details: { - owner, - repo, - path: path || null, - ref: ref || null, - contentType, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-issue.ts b/extensions/subagents/subagents/scout/tools/github-issue.ts deleted file mode 100644 index 0dae094f..00000000 --- a/extensions/subagents/subagents/scout/tools/github-issue.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * GitHub Issue tool for fetching issues or pull requests with comments. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (e.g., 'facebook/react') or full GitHub URL (e.g., 'https://github.com/facebook/react')", - }), - number: Type.Number({ - description: "Issue or pull request number", - }), -}); - -/** - * Parse repository input to extract owner and repo. - * Handles both 'owner/repo' format and full GitHub URLs. - */ -function parseRepo(repo: string): { owner: string; repo: string } { - // Check if it's a full URL - if (repo.startsWith("http://") || repo.startsWith("https://")) { - try { - const url = new URL(repo); - if (url.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${repo}`); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - const owner = parts[0]; - const repoName = parts[1]; - if (!owner || !repoName) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - return { owner, repo: repoName }; - } catch (error) { - if (error instanceof Error && error.message.includes("GitHub")) { - throw error; - } - throw new Error(`Invalid GitHub URL: ${repo}`); - } - } - - // Handle owner/repo format - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format: ${repo}. Expected 'owner/repo' or a full GitHub URL.`, - ); - } - - return { owner: parts[0], repo: parts[1] }; -} - -export const githubIssueTool: ToolDefinition<typeof parameters> = { - name: "github_issue", - label: "GitHub Issue", - description: `Fetch an issue or pull request with comments. Note: On GitHub, PRs are a type of issue, so this works for both. - -Examples: -- Issue: repo="facebook/react", number=1234 -- PR: repo="facebook/react", number=5678 -- With URL: repo="https://github.com/facebook/react", number=1234 - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { repo: string; number: number }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { repo: repoInput, number } = args; - const client = createGitHubClient(); - const { owner, repo } = parseRepo(repoInput); - - let markdown: string; - let contentType: "issue" | "pull_request"; - - // Try fetchPullRequest first (more complete for PRs with additions/deletions/etc) - // If 404, fall back to fetchIssue - try { - markdown = await client.fetchPullRequest(owner, repo, number, signal); - contentType = "pull_request"; - } catch (error) { - // Check if it's a 404 (not a PR, might be an issue) - if (error instanceof Error && error.message.includes("Not found")) { - markdown = await client.fetchIssue(owner, repo, number, signal); - contentType = "issue"; - } else { - // Re-throw other errors - throw error; - } - } - - return { - content: [{ type: "text" as const, text: markdown }], - details: { - owner, - repo, - number, - contentType, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-issues.ts b/extensions/subagents/subagents/scout/tools/github-issues.ts deleted file mode 100644 index 3224c232..00000000 --- a/extensions/subagents/subagents/scout/tools/github-issues.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * GitHub Issues list tool for listing issues and/or pull requests. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (e.g., 'facebook/react') or full GitHub URL", - }), - state: Type.Optional( - Type.String({ - description: "Filter by state: open, closed, or all (default: open)", - }), - ), - type: Type.Optional( - Type.String({ - description: - "Filter by type: issue, pr, or all (default: all). Issues API returns both; this filters client-side.", - }), - ), - labels: Type.Optional( - Type.String({ - description: - "Comma-separated list of label names to filter by (e.g., 'bug,enhancement')", - }), - ), - author: Type.Optional( - Type.String({ - description: "Filter by author username", - }), - ), - assignee: Type.Optional( - Type.String({ - description: "Filter by assignee username", - }), - ), - sort: Type.Optional( - Type.String({ - description: "Sort by: created, updated, or comments (default: created)", - }), - ), - direction: Type.Optional( - Type.String({ - description: "Sort direction: asc or desc (default: desc)", - }), - ), - per_page: Type.Optional( - Type.Number({ - description: "Results per page, max 100 (default: 30)", - }), - ), -}); - -function parseRepo(repo: string): { owner: string; repo: string } { - if (repo.startsWith("http://") || repo.startsWith("https://")) { - try { - const url = new URL(repo); - if (url.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${repo}`); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - const owner = parts[0]; - const repoName = parts[1]; - if (!owner || !repoName) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - return { owner, repo: repoName }; - } catch (error) { - if (error instanceof Error && error.message.includes("GitHub")) { - throw error; - } - throw new Error(`Invalid GitHub URL: ${repo}`); - } - } - - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format: ${repo}. Expected 'owner/repo' or a full GitHub URL.`, - ); - } - - return { owner: parts[0], repo: parts[1] }; -} - -export const githubIssuesTool: ToolDefinition<typeof parameters> = { - name: "github_issues", - label: "GitHub Issues", - description: `List issues and/or pull requests in a repository. - -Use this to discover issues and PRs. To read a specific issue/PR by number, use github_issue instead. - -Examples: -- Open issues: repo="facebook/react" -- Open PRs: repo="facebook/react", type="pr" -- Closed bugs: repo="facebook/react", state="closed", labels="bug" -- By author: repo="facebook/react", author="gaearon", type="pr" - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { - repo: string; - state?: string; - type?: string; - labels?: string; - author?: string; - assignee?: string; - sort?: string; - direction?: string; - per_page?: number; - }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { repo: repoInput, ...rest } = args; - const client = createGitHubClient(); - const { owner, repo } = parseRepo(repoInput); - - const result = await client.listIssues( - owner, - repo, - { - state: rest.state as "open" | "closed" | "all" | undefined, - type: rest.type as "issue" | "pr" | "all" | undefined, - labels: rest.labels, - author: rest.author, - assignee: rest.assignee, - sort: rest.sort as "created" | "updated" | "comments" | undefined, - direction: rest.direction as "asc" | "desc" | undefined, - per_page: rest.per_page, - }, - signal, - ); - - return { - content: [{ type: "text" as const, text: result }], - details: { - owner, - repo, - state: rest.state || "open", - type: rest.type || "all", - labels: rest.labels || null, - author: rest.author || null, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-pr-diff.ts b/extensions/subagents/subagents/scout/tools/github-pr-diff.ts deleted file mode 100644 index 026a69dc..00000000 --- a/extensions/subagents/subagents/scout/tools/github-pr-diff.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * GitHub PR Diff tool for fetching changed files and patches for a pull request. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (e.g., 'facebook/react') or full GitHub URL", - }), - number: Type.Number({ - description: "Pull request number", - }), -}); - -function parseRepo(repo: string): { owner: string; repo: string } { - if (repo.startsWith("http://") || repo.startsWith("https://")) { - try { - const url = new URL(repo); - if (url.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${repo}`); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - const owner = parts[0]; - const repoName = parts[1]; - if (!owner || !repoName) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - return { owner, repo: repoName }; - } catch (error) { - if (error instanceof Error && error.message.includes("GitHub")) { - throw error; - } - throw new Error(`Invalid GitHub URL: ${repo}`); - } - } - - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format: ${repo}. Expected 'owner/repo' or a full GitHub URL.`, - ); - } - - return { owner: parts[0], repo: parts[1] }; -} - -export const githubPrDiffTool: ToolDefinition<typeof parameters> = { - name: "github_pr_diff", - label: "GitHub PR Diff", - description: `Fetch the diff (changed files with patches) for a pull request. - -Use this to see the actual code changes in a PR. For PR metadata and comments, use github_issue. For inline review comments, use github_pr_reviews. - -Examples: -- PR diff: repo="facebook/react", number=1234 - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { repo: string; number: number }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { repo: repoInput, number } = args; - const client = createGitHubClient(); - const { owner, repo } = parseRepo(repoInput); - - const result = await client.getPullRequestDiff(owner, repo, number, signal); - - return { - content: [{ type: "text" as const, text: result }], - details: { - owner, - repo, - number, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-pr-reviews.ts b/extensions/subagents/subagents/scout/tools/github-pr-reviews.ts deleted file mode 100644 index b8404e61..00000000 --- a/extensions/subagents/subagents/scout/tools/github-pr-reviews.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * GitHub PR Reviews tool for fetching reviews and inline code comments. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (e.g., 'facebook/react') or full GitHub URL", - }), - number: Type.Number({ - description: "Pull request number", - }), -}); - -function parseRepo(repo: string): { owner: string; repo: string } { - if (repo.startsWith("http://") || repo.startsWith("https://")) { - try { - const url = new URL(repo); - if (url.hostname !== "github.com") { - throw new Error(`Not a GitHub URL: ${repo}`); - } - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length < 2) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - const owner = parts[0]; - const repoName = parts[1]; - if (!owner || !repoName) { - throw new Error(`Invalid GitHub URL: ${repo}`); - } - return { owner, repo: repoName }; - } catch (error) { - if (error instanceof Error && error.message.includes("GitHub")) { - throw error; - } - throw new Error(`Invalid GitHub URL: ${repo}`); - } - } - - const parts = repo.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format: ${repo}. Expected 'owner/repo' or a full GitHub URL.`, - ); - } - - return { owner: parts[0], repo: parts[1] }; -} - -export const githubPrReviewsTool: ToolDefinition<typeof parameters> = { - name: "github_pr_reviews", - label: "GitHub PR Reviews", - description: `Fetch reviews and inline code comments for a pull request. - -Returns review verdicts (approved, changes requested, etc.) and inline comments on specific lines of code. For the PR diff itself, use github_pr_diff. For PR metadata and discussion comments, use github_issue. - -Examples: -- PR reviews: repo="facebook/react", number=1234 - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { repo: string; number: number }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { repo: repoInput, number } = args; - const client = createGitHubClient(); - const { owner, repo } = parseRepo(repoInput); - - const result = await client.getPullRequestReviews( - owner, - repo, - number, - signal, - ); - - return { - content: [{ type: "text" as const, text: result }], - details: { - owner, - repo, - number, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/github-search.ts b/extensions/subagents/subagents/scout/tools/github-search.ts deleted file mode 100644 index 637dccc3..00000000 --- a/extensions/subagents/subagents/scout/tools/github-search.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * GitHub code search tool. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - query: Type.String({ - description: "Search query (supports GitHub code search syntax)", - }), - repo: Type.Optional( - Type.String({ - description: "Limit search to specific repo (owner/repo format or URL)", - }), - ), -}); - -export const githubSearchTool: ToolDefinition<typeof parameters> = { - name: "github_search", - label: "GitHub Search", - description: `Search code across GitHub repositories. - -Supports GitHub code search syntax including: -- Language filters: \`language:typescript\` -- Path filters: \`path:src/\` -- Extension filters: \`extension:ts\` -- Filename filters: \`filename:package.json\` - -Requires: SCOUT_GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { query: string; repo?: string }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { query, repo } = args; - const client = createGitHubClient(); - - const result = await client.searchCode(query, repo, signal); - - return { - content: [{ type: "text" as const, text: result }], - details: { - query, - repo, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/index.ts b/extensions/subagents/subagents/scout/tools/index.ts deleted file mode 100644 index edc7ecc4..00000000 --- a/extensions/subagents/subagents/scout/tools/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Scout subagent tools. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { cloneRepoTool } from "./clone-repo"; -import { downloadGistTool } from "./download-gist"; -import { githubCommitsTool } from "./github-commits"; -import { githubCompareTool } from "./github-compare"; -import { githubContentTool } from "./github-content"; -import { githubIssueTool } from "./github-issue"; -import { githubIssuesTool } from "./github-issues"; -import { githubPrDiffTool } from "./github-pr-diff"; -import { githubPrReviewsTool } from "./github-pr-reviews"; -import { githubSearchTool } from "./github-search"; -import { listUserReposTool } from "./list-user-repos"; -import { repoReadTool } from "./repo-read"; -import { uploadGistTool } from "./upload-gist"; -import { webFetchTool } from "./web-fetch"; -import { webSearchTool } from "./web-search"; - -/** Create scout tools array */ -export function createScoutTools(): ToolDefinition[] { - return [ - webSearchTool, - webFetchTool, - githubContentTool, - githubSearchTool, - githubCommitsTool, - githubIssueTool, - githubIssuesTool, - githubPrDiffTool, - githubPrReviewsTool, - githubCompareTool, - listUserReposTool, - downloadGistTool, - uploadGistTool, - cloneRepoTool, - repoReadTool, - ] as unknown as ToolDefinition[]; -} - -export { - cloneRepoTool, - downloadGistTool, - githubCommitsTool, - githubCompareTool, - githubContentTool, - githubIssueTool, - githubIssuesTool, - githubPrDiffTool, - githubPrReviewsTool, - githubSearchTool, - listUserReposTool, - repoReadTool, - uploadGistTool, - webFetchTool, - webSearchTool, -}; diff --git a/extensions/subagents/subagents/scout/tools/list-user-repos.ts b/extensions/subagents/subagents/scout/tools/list-user-repos.ts deleted file mode 100644 index 49149489..00000000 --- a/extensions/subagents/subagents/scout/tools/list-user-repos.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * GitHub list user repositories tool. - */ - -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { createGitHubClient } from "../../../lib/clients"; - -const parameters = Type.Object({ - username: Type.String({ - description: "GitHub username to search", - }), - language: Type.Optional( - Type.String({ - description: "Filter by programming language", - }), - ), - namePrefix: Type.Optional( - Type.String({ - description: "Filter by repository name prefix", - }), - ), - sort: Type.Optional( - Type.String({ - description: "Sort by: stars, forks, updated", - }), - ), - order: Type.Optional( - Type.String({ - description: "Order: asc or desc", - }), - ), - per_page: Type.Optional( - Type.Number({ - description: "Results per page (max 100)", - default: 30, - }), - ), - page: Type.Optional( - Type.Number({ - description: "Page number", - default: 1, - }), - ), -}); - -export const listUserReposTool: ToolDefinition<typeof parameters> = { - name: "list_user_repos", - label: "List User Repos", - description: `List repositories for a GitHub user. - -Supports filtering by language, name prefix, and sorting options. - -Requires: GITHUB_TOKEN environment variable`, - - parameters, - - async execute( - _toolCallId: string, - args: { - username: string; - language?: string; - namePrefix?: string; - sort?: string; - order?: string; - per_page?: number; - page?: number; - }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const { - username, - language, - namePrefix, - sort, - order, - per_page = 30, - page = 1, - } = args; - const client = createGitHubClient(); - - const result = await client.listUserRepos( - username, - { language, namePrefix, sort, order, per_page, page }, - signal, - ); - - return { - content: [{ type: "text" as const, text: result }], - details: { - username, - language, - namePrefix, - sort, - order, - per_page, - page, - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/repo-read.ts b/extensions/subagents/subagents/scout/tools/repo-read.ts deleted file mode 100644 index b5d06a96..00000000 --- a/extensions/subagents/subagents/scout/tools/repo-read.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Read files from a cloned repository. - * - * Paths are scoped to the clone directory — paths outside the clone are rejected. - */ - -import { readdir, readFile, stat } from "node:fs/promises"; -import { basename, join, relative } from "node:path"; -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { getCloneDir } from "./clone-repo"; - -const MAX_FILE_SIZE = 512 * 1024; // 512 KB - -const parameters = Type.Object({ - repo: Type.String({ - description: - "Repository in owner/repo format (must be cloned first with clone_repo)", - }), - path: Type.String({ - description: - "File path relative to the repo root (e.g., 'src/index.ts', 'README.md')", - }), -}); - -export const repoReadTool: ToolDefinition<typeof parameters> = { - name: "repo_read", - label: "Repo Read", - description: `Read a file from a previously cloned repository. - -The repository must be cloned first using clone_repo. Paths are scoped to the clone directory — you cannot read files outside the cloned repo. - -Examples: -- repo_read(repo="openai/codex", path="README.md") -- repo_read(repo="facebook/react", path="packages/react/src/React.js")`, - - parameters, - - async execute( - _toolCallId: string, - args: { repo: string; path: string }, - _signal: AbortSignal | undefined, - ) { - const { repo, path: filePath } = args; - - const cloneDir = getCloneDir(repo); - if (!cloneDir) { - return { - content: [ - { - type: "text" as const, - text: `Error: Repository ${repo} has not been cloned. Use clone_repo first.`, - }, - ], - details: { error: "not_cloned", repo }, - }; - } - - // Resolve and validate the path stays within cloneDir - const resolvedPath = join(cloneDir, filePath); - const relativePath = relative(cloneDir, resolvedPath); - - if (relativePath.startsWith("..") || relativePath.startsWith("/")) { - return { - content: [ - { - type: "text" as const, - text: `Error: Path '${filePath}' is outside the cloned repository. All paths must be relative to the repo root.`, - }, - ], - details: { error: "path_escape", repo, path: filePath }, - }; - } - - try { - const fileStat = await stat(resolvedPath); - - if (fileStat.isDirectory()) { - // List directory contents - const entries = await readdir(resolvedPath, { withFileTypes: true }); - const lines = entries - .sort((a, b) => { - // Directories first, then files - if (a.isDirectory() && !b.isDirectory()) return -1; - if (!a.isDirectory() && b.isDirectory()) return 1; - return a.name.localeCompare(b.name); - }) - .map((entry) => - entry.isDirectory() ? `${entry.name}/` : entry.name, - ); - return { - content: [ - { - type: "text" as const, - text: lines.join("\n") || "(empty directory)", - }, - ], - details: { repo, path: filePath, type: "directory" }, - }; - } - - if (fileStat.size > MAX_FILE_SIZE) { - return { - content: [ - { - type: "text" as const, - text: `Error: File '${filePath}' is ${Math.round(fileStat.size / 1024)}KB, exceeding the ${MAX_FILE_SIZE / 1024}KB limit. Use the GitHub API tools or request specific line ranges.`, - }, - ], - details: { - error: "file_too_large", - repo, - path: filePath, - sizeBytes: fileStat.size, - }, - }; - } - - const content = await readFile(resolvedPath, "utf-8"); - const fileName = basename(resolvedPath); - const lineCount = content.split("\n").length; - - return { - content: [ - { - type: "text" as const, - text: `// ${filePath} (${lineCount} lines)\n${content}`, - }, - ], - details: { repo, path: filePath, type: "file", fileName, lineCount }, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { - content: [ - { - type: "text" as const, - text: `Error: File not found: ${filePath}`, - }, - ], - details: { error: "not_found", repo, path: filePath }, - }; - } - const message = error instanceof Error ? error.message : String(error); - return { - content: [ - { - type: "text" as const, - text: `Error reading ${filePath}: ${message}`, - }, - ], - details: { error: message, repo, path: filePath }, - }; - } - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/upload-gist.ts b/extensions/subagents/subagents/scout/tools/upload-gist.ts deleted file mode 100644 index 856ac568..00000000 --- a/extensions/subagents/subagents/scout/tools/upload-gist.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Upload Gist tool - updates a GitHub Gist from a local directory. - */ - -import { readdir, readFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { runGh } from "../../../lib/gh"; -import { extractGistId } from "./download-gist"; - -const parameters = Type.Object({ - gist: Type.String({ - description: - "GitHub Gist ID or full URL (e.g., 'abc123' or 'https://gist.github.com/user/abc123')", - }), - directory: Type.String({ - description: "Path to the directory containing the files to upload", - }), -}); - -export const uploadGistTool: ToolDefinition<typeof parameters> = { - name: "upload_gist", - label: "Upload Gist", - description: `Update a GitHub Gist from a local directory. - -All files in the directory will be uploaded to the gist. - -Note: Gists are flat - subdirectories and hidden files are ignored.`, - - parameters, - - async execute( - _toolCallId: string, - args: { gist: string; directory: string }, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: unknown, - ) { - const gistId = extractGistId(args.gist); - const { directory } = args; - - // Read all files (excluding hidden files) - const entries = await readdir(directory, { withFileTypes: true }); - const files: Record<string, { content: string }> = {}; - - for (const entry of entries) { - if (!entry.isFile() || entry.name.startsWith(".")) { - continue; - } - const content = await readFile(join(directory, entry.name), "utf-8"); - files[entry.name] = { content }; - } - - if (Object.keys(files).length === 0) { - throw new Error("No files to upload"); - } - - // Update gist via API - const payload = JSON.stringify({ files }); - await runGh( - ["api", "-X", "PATCH", `gists/${gistId}`, "--input", "-"], - signal, - payload, - ); - - return { - content: [ - { - type: "text" as const, - text: `Updated https://gist.github.com/${gistId}`, - }, - ], - details: { - gistId, - directory, - files: Object.keys(files), - }, - }; - }, -}; diff --git a/extensions/subagents/subagents/scout/tools/web-fetch.ts b/extensions/subagents/subagents/scout/tools/web-fetch.ts deleted file mode 100644 index 754f169b..00000000 --- a/extensions/subagents/subagents/scout/tools/web-fetch.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { routeFetch, ScoutRoutingError } from "../providers/router"; -import type { ScoutProviderId } from "../providers/types"; - -const parameters = Type.Object({ - url: Type.String({ description: "URL to fetch" }), -}); - -interface WebFetchDetails { - provider?: ScoutProviderId; - router?: unknown; - cost?: number; - costCurrency?: "USD" | "EUR"; - error?: string; -} - -function normalizeUrl(raw: string): string { - return new URL(raw).toString(); -} - -export const webFetchTool: ToolDefinition<typeof parameters, WebFetchDetails> = - { - name: "web_fetch", - label: "Web Fetch", - description: - "Fetch raw webpage/article content using configured provider order with fallback.", - parameters, - async execute(_toolCallId, args, signal) { - let normalizedUrl: string; - try { - normalizedUrl = normalizeUrl(args.url); - } catch { - return { - content: [{ type: "text" as const, text: "Error: Invalid URL" }], - details: { error: "Invalid URL" }, - }; - } - - try { - const { result, diag } = await routeFetch({ - url: normalizedUrl, - signal, - }); - return { - content: [{ type: "text" as const, text: result.markdown }], - details: { - provider: result.provider, - router: diag, - cost: result.cost?.amount, - costCurrency: result.cost?.currency, - }, - }; - } catch (error) { - const details: WebFetchDetails = { - error: error instanceof Error ? error.message : String(error), - }; - - if (error instanceof ScoutRoutingError) { - details.router = error.diagnostics; - } - - return { - content: [{ type: "text" as const, text: `Error: ${details.error}` }], - details, - }; - } - }, - }; diff --git a/extensions/subagents/subagents/scout/tools/web-search.ts b/extensions/subagents/subagents/scout/tools/web-search.ts deleted file mode 100644 index b915ed78..00000000 --- a/extensions/subagents/subagents/scout/tools/web-search.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { routeSearch, ScoutRoutingError } from "../providers/router"; -import type { ScoutProviderId } from "../providers/types"; - -const parameters = Type.Object({ - query: Type.String({ description: "Search query for web content" }), -}); - -interface WebSearchDetails { - provider?: ScoutProviderId; - resultCount?: number; - router?: unknown; - cost?: number; - costCurrency?: "USD" | "EUR"; - error?: string; -} - -function toMarkdown( - items: Array<{ - title: string; - url: string; - text?: string; - published?: string; - }>, -): string { - if (items.length === 0) return "No results found."; - - return items - .map((item, index) => { - const lines = [`${index + 1}. [${item.title}](${item.url})`]; - if (item.published) lines.push(` - Published: ${item.published}`); - if (item.text) lines.push(` - ${item.text}`); - return lines.join("\n"); - }) - .join("\n\n"); -} - -export const webSearchTool: ToolDefinition< - typeof parameters, - WebSearchDetails -> = { - name: "web_search", - label: "Web Search", - description: "Search the web using configured provider order with fallback.", - parameters, - async execute(_toolCallId, args, signal) { - try { - const { result, diag } = await routeSearch({ query: args.query, signal }); - return { - content: [{ type: "text" as const, text: toMarkdown(result.items) }], - details: { - provider: result.provider, - resultCount: result.items.length, - router: diag, - cost: result.cost?.amount, - costCurrency: result.cost?.currency, - }, - }; - } catch (error) { - const details: WebSearchDetails = { - error: error instanceof Error ? error.message : String(error), - }; - - if (error instanceof ScoutRoutingError) { - details.router = error.diagnostics; - } - - return { - content: [{ type: "text" as const, text: `Error: ${details.error}` }], - details, - }; - } - }, -}; diff --git a/extensions/subagents/subagents/scout/types.ts b/extensions/subagents/subagents/scout/types.ts deleted file mode 100644 index 835d8eb1..00000000 --- a/extensions/subagents/subagents/scout/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Scout subagent types. - */ - -import type { BaseSubagentDetails } from "../../lib/types"; - -/** Input parameters for the scout subagent */ -export interface ScoutInput { - /** URL to fetch content from */ - url?: string; - /** Search query for web or GitHub research */ - query?: string; - /** GitHub repository to focus on (owner/repo format) */ - repo?: string; - /** Question to answer based on fetched content */ - prompt: string; - /** Optional skill names to provide specialized context */ - skills?: string[]; -} - -/** Details structure for scout tool rendering */ -export interface ScoutDetails extends BaseSubagentDetails { - /** URL input */ - url?: string; - /** Query input */ - query?: string; - /** Repository input */ - repo?: string; - /** Prompt input */ - prompt?: string; -} diff --git a/extensions/subagents/subagents/worker/index.ts b/extensions/subagents/subagents/worker/index.ts deleted file mode 100644 index 7151c949..00000000 --- a/extensions/subagents/subagents/worker/index.ts +++ /dev/null @@ -1,564 +0,0 @@ -/** - * Worker subagent - focused implementation agent for well-defined tasks. - * - * Sandboxed to specific files. Reads, edits, writes, and runs bash for - * verification. Does not search or explore the codebase. - */ - -import { - createRenderCache, - FileList, - MarkdownField, - MarkdownResponse, - renderToolTextFallback, - SubagentFooter, - ToolCallHeader, - ToolCallList, - ToolDetails, - type ToolDetailsField, -} from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - AgentToolUpdateCallback, - ExtensionContext, - Skill, - Theme, - ToolDefinition, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import { isDebugEnabled } from "../../config"; -import { - executeSubagent, - resolveSkillsByName, - shouldFailToolCallForModelIssue, -} from "../../lib"; -import { selectModelForSubagent } from "../../lib/subagent-model-selection"; -import type { SubagentToolCall } from "../../lib/types"; -import { pluralize } from "../../lib/ui/stats"; -import { WORKER_SYSTEM_PROMPT } from "./system-prompt"; -import { createWorkerToolFormatter } from "./tool-formatter"; -import { createWorkerTools } from "./tools"; -import type { WorkerDetails, WorkerInput } from "./types"; - -/** System prompt guidance for worker tool usage */ -export const WORKER_GUIDANCE = ` -## Worker - -Delegate implementation work to the worker instead of doing it yourself when the task is well-defined and the files are known. The worker is a focused implementation agent: it reads, edits, writes files and runs mandatory verification commands. It is sandboxed to the files you provide. - -**You SHOULD delegate to the worker when:** -- You already know which files need to change and what the change is -- The task is implementation, not exploration or planning -- Examples: migrating files to TypeScript, adding documentation, adding error handling, applying a refactoring pattern, fixing a known bug in specific files - -**You should NOT delegate to the worker when:** -- You need to explore or search the codebase first (use lookout or scout) -- The scope is unclear or you don't know which files are involved -- The task is architectural planning (use oracle) - -**Worker verification policy (enforced):** -- Run relevant lint checks before finishing -- Run relevant type checks before finishing -- Run relevant tests before finishing -- Never use \`--no-verify\` for commits -- Never bypass checks by disabling lint/type/test gates unless explicitly authorized -- If checks cannot be run or cannot pass without forbidden bypasses, worker must call this out to the parent agent - -**Inputs:** -- \`task\`: Short description (~50 chars, for display only, not sent to the worker) -- \`instructions\`: Full instructions for the worker (be specific and complete) -- \`files\`: Array of file paths the worker should operate on -- \`context\`: Optional background info (e.g., patterns to follow, constraints) -- \`skills\`: Optional skill names for specialized context - -**After the worker completes:** Review its output yourself. If the worker reports unrun/failed checks or forbidden bypass pressure, resolve that before shipping. - -**Example:** -\`\`\`json -{ - "task": "Convert helpers.js to TypeScript", - "instructions": "Convert this file from JavaScript to TypeScript. Add proper type annotations for all function parameters and return types. Use generics where appropriate.", - "files": ["src/utils/helpers.js"], - "context": "Follow the typing patterns used in src/utils/types.ts" -} -\`\`\` -`; - -const parameters = Type.Object({ - task: Type.String({ - description: - "Short description of the task (~50 chars, for display only, not sent to the worker)", - }), - instructions: Type.String({ - description: "Full instructions for the worker (be specific and complete)", - }), - files: Type.Array(Type.String(), { - description: "Files the worker should operate on", - }), - context: Type.Optional( - Type.String({ - description: - "Optional background info (e.g., patterns to follow, constraints)", - }), - ), - skills: Type.Optional( - Type.Array(Type.String(), { - description: - "Skill names to provide specialized context (e.g., 'ios-26', 'drizzle-orm')", - }), - ), -}); - -/** Build the user message for the subagent based on inputs */ -function buildUserMessage(input: WorkerInput): string { - const parts: string[] = []; - - parts.push(`## Instructions\n${input.instructions}`); - parts.push(`## Files\n${input.files.map((f) => `- ${f}`).join("\n")}`); - - if (input.context) { - parts.push(`## Context\n${input.context}`); - } - - return parts.join("\n\n"); -} - -/** Create the worker tool definition for use in extensions */ -export function createWorkerTool(): ToolDefinition< - typeof parameters, - WorkerDetails -> { - // Render cache for reusing components across updates - const renderCache = createRenderCache< - string, - { - toolDetails: ToolDetails; - footer: SubagentFooter; - markdownResponse: MarkdownResponse | null; - } - >(); - - return { - name: "worker", - label: "Worker", - description: `Focused implementation agent for well-defined tasks on specific files. - -The worker reads, edits, writes files and runs mandatory verification (lint, typecheck, tests) before finishing. It is sandboxed to the files you provide. It does not search or explore the codebase. - -It must never use commit bypass flags like \`--no-verify\` and must not disable lint/type/test gates unless explicitly authorized. If checks cannot run/pass under those constraints, it reports that to the parent agent. - -Use for: file migrations, adding docs/types, applying refactoring patterns, adding error handling, fixing known bugs in specific files. - -Pass relevant skills (e.g., 'ios-26', 'drizzle-orm') to provide specialized context for the task.`, - promptSnippet: - "Delegate a well-defined implementation task on known files.", - promptGuidelines: [ - "Use this tool when the task is implementation work and the exact files are already known.", - "Do not use it for exploration or planning; use lookout, scout, or oracle first when scope is unclear.", - "Expect it to run lint, typecheck, and tests before finishing.", - ], - - parameters, - - async execute( - toolCallId: string, - args: WorkerInput, - signal: AbortSignal | undefined, - onUpdate: AgentToolUpdateCallback<WorkerDetails> | undefined, - ctx: ExtensionContext, - ) { - const { task, instructions, files, context, skills: skillNames } = args; - - // Resolve skills if provided - let resolvedSkills: Skill[] = []; - let notFoundSkills: string[] = []; - - if (skillNames && skillNames.length > 0) { - const result = resolveSkillsByName(skillNames, ctx.cwd); - resolvedSkills = result.skills; - notFoundSkills = result.notFound; - } - - // Validate: instructions and files are required - if (!instructions) { - const error = "Instructions are required."; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - task, - instructions: "", - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - error, - cwd: ctx.cwd, - }, - }; - } - - if (!files || files.length === 0) { - const error = "At least one file is required."; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - task, - instructions, - files: [], - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: [], - error, - cwd: ctx.cwd, - }, - }; - } - - let resolvedModel: { provider: string; id: string } | undefined; - - let currentToolCalls: SubagentToolCall[] = []; - - try { - const model = selectModelForSubagent("worker", ctx); - resolvedModel = { provider: model.provider, id: model.id }; - - // Publish resolved provider/model as early as possible - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - task, - instructions, - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: ctx.cwd, - }, - }); - - let userMessage = buildUserMessage(args); - - // Append warning if skills not found - if (notFoundSkills.length > 0) { - userMessage += `\n\n**Note:** The following skills were not found and could not be loaded: ${notFoundSkills.join(", ")}`; - } - - // Sandboxed tools: read/edit/write hard-scoped to provided files. - // Bash is guarded for worker policy (no exploration, no --no-verify). - const tools = createWorkerTools(ctx.cwd, files); - - const result = await executeSubagent( - { - name: "worker", - model, - systemPrompt: WORKER_SYSTEM_PROMPT, - skills: resolvedSkills, - tools: ["read", "edit", "write", "bash"], - customTools: tools, - thinkingLevel: "low", - logging: { - enabled: true, - debug: isDebugEnabled(), - }, - }, - userMessage, - ctx, - // onTextUpdate - (_delta, _accumulated) => { - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - task, - instructions, - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: ctx.cwd, - }, - }); - }, - signal, - // onToolUpdate - (toolCalls: SubagentToolCall[]) => { - currentToolCalls = toolCalls; - onUpdate?.({ - content: [{ type: "text", text: "" }], - details: { - _renderKey: toolCallId, - task, - instructions, - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: currentToolCalls, - resolvedModel, - cwd: ctx.cwd, - }, - }); - }, - ); - - const finalToolCalls = - result.toolCalls.length > 0 ? result.toolCalls : currentToolCalls; - - if (result.aborted) { - return { - content: [{ type: "text" as const, text: "Aborted" }], - details: { - _renderKey: toolCallId, - task, - instructions, - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - aborted: true, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } - - if (result.error) { - if (shouldFailToolCallForModelIssue(result)) { - throw new Error(result.error); - } - - return { - content: [ - { type: "text" as const, text: `Error: ${result.error}` }, - ], - details: { - _renderKey: toolCallId, - task, - instructions, - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error: result.error, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } - - // Check if all tool calls failed - const errorCount = finalToolCalls.filter( - (tc) => tc.status === "error", - ).length; - const allFailed = - finalToolCalls.length > 0 && errorCount === finalToolCalls.length; - - if (allFailed) { - const error = "All tool calls failed"; - return { - content: [{ type: "text" as const, text: `Error: ${error}` }], - details: { - _renderKey: toolCallId, - task, - instructions, - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - error, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } - - return { - content: [{ type: "text" as const, text: result.content }], - details: { - _renderKey: toolCallId, - task, - instructions, - files, - context, - skills: skillNames, - skillsResolved: resolvedSkills.length, - skillsNotFound: - notFoundSkills.length > 0 ? notFoundSkills : undefined, - toolCalls: finalToolCalls, - response: result.content, - usage: result.usage, - resolvedModel, - cwd: ctx.cwd, - }, - }; - } finally { - } - }, - - renderCall(args, theme) { - const task = args.task?.trim() ?? ""; - const shortTask = task.length > 80 ? `${task.slice(0, 77)}...` : task; - - return new ToolCallHeader( - { - toolName: "Worker", - mainArg: shortTask, - optionArgs: [ - { label: "files", value: String(args.files?.length ?? 0) }, - ...(args.skills?.length - ? [{ label: "skills", value: args.skills.join(",") }] - : []), - ], - longArgs: [ - ...(task.length > 80 ? [{ label: "task", value: task }] : []), - ...(args.context - ? [{ label: "context", value: args.context }] - : []), - ], - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult<WorkerDetails>, - options: ToolRenderResultOptions, - theme: Theme, - ) { - const { details } = result; - - // Fallback if details missing - if (!details) { - return renderToolTextFallback(result, theme); - } - - const { - _renderKey, - toolCalls, - response, - aborted, - error, - usage, - resolvedModel, - files, - instructions, - cwd, - } = details; - - // Counts - const doneCount = toolCalls.filter((tc) => tc.status === "done").length; - - const renderKey = _renderKey ?? "_default_"; - const cached = renderCache.get(renderKey); - - // Footer - reuse or create - const footerData = { resolvedModel, usage, toolCalls }; - let footer: SubagentFooter; - if (cached) { - footer = cached.footer; - footer.updateData(footerData); - } else { - footer = new SubagentFooter(theme, footerData); - } - - // MarkdownResponse - reuse or create - let mdResponse = cached?.markdownResponse ?? null; - - // Build fields based on state - const fields: ToolDetailsField[] = []; - const formatToolCall = createWorkerToolFormatter(cwd); - - // Instructions - fields.push(new MarkdownField("Instructions", instructions, theme)); - - // State-specific fields - if (aborted) { - const suffix = - doneCount > 0 - ? ` (${doneCount} ${pluralize(doneCount, "tool call")} completed)` - : ""; - fields.push({ - label: "Status", - value: theme.fg("warning", "Aborted") + theme.fg("muted", suffix), - }); - } else if (error) { - fields.push({ label: "Error", value: error }); - } else if (response) { - // Done state - fields.push(new FileList(files, theme, cwd)); - fields.push(new ToolCallList(toolCalls, formatToolCall, theme)); - - if (mdResponse) { - mdResponse.setContent(response); - } else { - mdResponse = new MarkdownResponse(response, theme); - } - fields.push(mdResponse); - } else { - // Running state - fields.push(new ToolCallList(toolCalls, formatToolCall, theme)); - } - - // ToolDetails - reuse or create - let toolDetails: ToolDetails; - if (cached) { - toolDetails = cached.toolDetails; - toolDetails.update({ fields, footer }, options); - } else { - toolDetails = new ToolDetails({ fields, footer }, options, theme); - } - - // Update cache - renderCache.set(renderKey, { - toolDetails, - footer, - markdownResponse: mdResponse, - }); - - return toolDetails; - }, - }; -} - -/** Execute the worker subagent directly (without tool wrapper) */ -export async function executeWorker( - input: WorkerInput, - ctx: ExtensionContext, - onUpdate?: AgentToolUpdateCallback<WorkerDetails>, - signal?: AbortSignal, -): Promise<AgentToolResult<WorkerDetails>> { - const tool = createWorkerTool(); - return tool.execute("direct", input, signal, onUpdate, ctx); -} diff --git a/extensions/subagents/subagents/worker/system-prompt.ts b/extensions/subagents/subagents/worker/system-prompt.ts deleted file mode 100644 index 7c2530c6..00000000 --- a/extensions/subagents/subagents/worker/system-prompt.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * System prompt for the Worker subagent. - */ - -export const WORKER_SYSTEM_PROMPT = `You are a Worker - a focused implementation agent. - -You receive a well-defined task and a specific set of files to operate on. Your job is to execute the task precisely and completely. - -You are a subagent inside an AI coding system, invoked zero-shot (no follow-ups possible). - -## Scope - -You are sandboxed. You only work on the files explicitly provided to you. -- Do NOT search the codebase. You will not use grep, find, or ls. -- Do NOT explore or read files outside the ones given to you. -- If you need information not present in your files, state that clearly in your response instead of guessing. - -## Tools - -You have four tools: -- **read**: Read the contents of the files you were given. -- **edit**: Make surgical find-and-replace edits to existing files. -- **write**: Create new files or overwrite existing ones entirely. -- **bash**: Run commands (e.g., tests, linters, formatters). Use only for verification, not exploration. - -## Required verification policy - -Before finishing, always run relevant checks for the updated code. -- First detect the package manager by checking lockfiles: pnpm-lock.yaml → pnpm, yarn.lock → yarn, bun.lockb → bun, package-lock.json → npm. -- Use package manager scripts (e.g., \`pnpm lint\`, \`npm run test\`) when available, otherwise run tools directly. -- Always run lint/format checks relevant to changed files. -- Always run type checks relevant to changed files. -- Always run tests relevant to changed files (or the smallest reliable test scope). -- If project scripts exist for lint/typecheck/test, prefer those scripts. - -Never bypass verification unless explicitly authorized in instructions. -- Never run \`git commit --no-verify\` or any equivalent bypass. -- Never disable linting/typechecking/tests to make checks pass (e.g., eslint-disable, ts-ignore/ts-nocheck, skipping tests, turning checks off in config) unless explicitly authorized. -- Never claim checks passed if they were not run. - -If a required check cannot run (missing dependency/tool, env issue, time/resource constraint) or cannot pass without an unauthorized bypass, you must explicitly report it to the parent agent in your final response. - -## Minimal editing constraints - -- Preserve the original code and logic of the original code as much as possible. Only modify what is strictly necessary. -- Do not rename variables, add helper functions, or introduce new abstractions unless explicitly required. -- Do not add error handling, fallbacks, or validation for scenarios that can't happen. -- Do not add docstrings, comments, or type annotations to code you didn't change. - -## Workflow - -1. Read all provided files first to understand the current state. -2. Execute the task using edit (preferred for targeted changes) or write (for new files or full rewrites). -3. Run required verification commands (lint, typecheck, tests) for the updated code. -4. If verification fails, analyze the error and fix the issue. Repeat until checks pass or you hit a real blocker. - -## Response - -When done, provide a brief summary: -1. What you changed and why. -2. Exact verification commands run and outcomes (lint, typecheck, tests). -3. Any issues you could not resolve, assumptions made, and any required note for the parent agent (especially unrun/failed checks or forbidden bypass pressure). - -IMPORTANT: Only your last message is returned. Make it a clear summary of all work done.`; diff --git a/extensions/subagents/subagents/worker/tool-formatter.ts b/extensions/subagents/subagents/worker/tool-formatter.ts deleted file mode 100644 index b4df644e..00000000 --- a/extensions/subagents/subagents/worker/tool-formatter.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Tool call formatter for Worker subagent. - */ - -import { shortenPath } from "../../lib/paths"; -import type { SubagentToolCall } from "../../lib/types"; - -/** Create a worker tool formatter with shortened path display */ -export function createWorkerToolFormatter( - cwd?: string, -): (tc: SubagentToolCall) => { label: string; detail: string } { - const sp = (p: string) => shortenPath(p, cwd); - - return (tc: SubagentToolCall) => { - const { toolName, args } = tc; - - switch (toolName) { - case "read": { - const path = args.path as string | undefined; - return { label: "Read", detail: path ? sp(path) : "..." }; - } - case "edit": { - const path = args.path as string | undefined; - return { label: "Edit", detail: path ? sp(path) : "..." }; - } - case "write": { - const path = args.path as string | undefined; - return { label: "Write", detail: path ? sp(path) : "..." }; - } - case "bash": { - const command = args.command as string | undefined; - return { label: "Bash", detail: command ?? "..." }; - } - default: - return { - label: toolName, - detail: JSON.stringify(args).slice(0, 50), - }; - } - }; -} diff --git a/extensions/subagents/subagents/worker/tools/guarded-bash.ts b/extensions/subagents/subagents/worker/tools/guarded-bash.ts deleted file mode 100644 index cdb7a124..00000000 --- a/extensions/subagents/subagents/worker/tools/guarded-bash.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createBashTool } from "@mariozechner/pi-coding-agent"; -import { getBashPolicyViolation } from "../utils/bash-policy"; -import { blockedCommandResult } from "../utils/results"; - -type BashExecArgs = Parameters<ReturnType<typeof createBashTool>["execute"]>; - -export function createGuardedBashTool( - cwd: string, -): ReturnType<typeof createBashTool> { - const bashTool = createBashTool(cwd); - - return { - ...bashTool, - async execute(...execArgs: BashExecArgs) { - const [, args] = execArgs; - const command = ((args as { command?: string }).command ?? "").trim(); - - const violation = getBashPolicyViolation(command); - if (violation) { - return blockedCommandResult(violation, command); - } - - return bashTool.execute(...execArgs); - }, - }; -} diff --git a/extensions/subagents/subagents/worker/tools/index.ts b/extensions/subagents/subagents/worker/tools/index.ts deleted file mode 100644 index 628c9c95..00000000 --- a/extensions/subagents/subagents/worker/tools/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Worker tool wrappers with scope and policy enforcement. - */ - -import type { createReadOnlyTools } from "@mariozechner/pi-coding-agent"; -import { resolveAllowedPaths } from "../utils/path-scope"; -import { createGuardedBashTool } from "./guarded-bash"; -import { createScopedEditTool } from "./scoped-edit"; -import { createScopedReadTool } from "./scoped-read"; -import { createScopedWriteTool } from "./scoped-write"; - -export type WorkerBuiltinTool = ReturnType<typeof createReadOnlyTools>[number]; - -export function createWorkerTools( - cwd: string, - files: string[], -): WorkerBuiltinTool[] { - const allowedPaths = resolveAllowedPaths(cwd, files); - - const scopedReadTool: WorkerBuiltinTool = createScopedReadTool( - cwd, - files, - allowedPaths, - ); - const scopedEditTool: WorkerBuiltinTool = createScopedEditTool( - cwd, - files, - allowedPaths, - ); - const scopedWriteTool: WorkerBuiltinTool = createScopedWriteTool( - cwd, - files, - allowedPaths, - ); - const guardedBashTool: WorkerBuiltinTool = createGuardedBashTool(cwd); - - return [scopedReadTool, scopedEditTool, scopedWriteTool, guardedBashTool]; -} diff --git a/extensions/subagents/subagents/worker/tools/scoped-edit.ts b/extensions/subagents/subagents/worker/tools/scoped-edit.ts deleted file mode 100644 index 8e814607..00000000 --- a/extensions/subagents/subagents/worker/tools/scoped-edit.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createEditTool } from "@mariozechner/pi-coding-agent"; -import { isAllowedPath } from "../utils/path-scope"; -import { blockedPathResult } from "../utils/results"; - -type EditExecArgs = Parameters<ReturnType<typeof createEditTool>["execute"]>; - -export function createScopedEditTool( - cwd: string, - files: string[], - allowedPaths: Set<string>, -): ReturnType<typeof createEditTool> { - const editTool = createEditTool(cwd); - - return { - ...editTool, - async execute(...execArgs: EditExecArgs) { - const [, args] = execArgs; - const targetPath = (args as { path?: string }).path; - if (!targetPath || !isAllowedPath(cwd, allowedPaths, targetPath)) { - return blockedPathResult(targetPath ?? "(missing)", files); - } - return editTool.execute(...execArgs); - }, - }; -} diff --git a/extensions/subagents/subagents/worker/tools/scoped-read.ts b/extensions/subagents/subagents/worker/tools/scoped-read.ts deleted file mode 100644 index d8c49293..00000000 --- a/extensions/subagents/subagents/worker/tools/scoped-read.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createReadTool } from "@mariozechner/pi-coding-agent"; -import { isAllowedPath } from "../utils/path-scope"; -import { blockedPathResult } from "../utils/results"; - -type ReadExecArgs = Parameters<ReturnType<typeof createReadTool>["execute"]>; - -export function createScopedReadTool( - cwd: string, - files: string[], - allowedPaths: Set<string>, -): ReturnType<typeof createReadTool> { - const readTool = createReadTool(cwd); - - return { - ...readTool, - async execute(...execArgs: ReadExecArgs) { - const [, args] = execArgs; - const targetPath = (args as { path?: string }).path; - if (!targetPath || !isAllowedPath(cwd, allowedPaths, targetPath)) { - return blockedPathResult(targetPath ?? "(missing)", files); - } - return readTool.execute(...execArgs); - }, - }; -} diff --git a/extensions/subagents/subagents/worker/tools/scoped-write.ts b/extensions/subagents/subagents/worker/tools/scoped-write.ts deleted file mode 100644 index abe9b7f1..00000000 --- a/extensions/subagents/subagents/worker/tools/scoped-write.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createWriteTool } from "@mariozechner/pi-coding-agent"; -import { isAllowedPath } from "../utils/path-scope"; -import { blockedPathResult } from "../utils/results"; - -type WriteExecArgs = Parameters<ReturnType<typeof createWriteTool>["execute"]>; - -export function createScopedWriteTool( - cwd: string, - files: string[], - allowedPaths: Set<string>, -): ReturnType<typeof createWriteTool> { - const writeTool = createWriteTool(cwd); - - return { - ...writeTool, - async execute(...execArgs: WriteExecArgs) { - const [, args] = execArgs; - const targetPath = (args as { path?: string }).path; - if (!targetPath || !isAllowedPath(cwd, allowedPaths, targetPath)) { - return blockedPathResult(targetPath ?? "(missing)", files); - } - return writeTool.execute(...execArgs); - }, - }; -} diff --git a/extensions/subagents/subagents/worker/types.ts b/extensions/subagents/subagents/worker/types.ts deleted file mode 100644 index 17cc2907..00000000 --- a/extensions/subagents/subagents/worker/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Worker subagent types. - */ - -import type { BaseSubagentDetails } from "../../lib/types"; - -/** Input parameters for the worker subagent */ -export interface WorkerInput { - /** Short description of the task (~50 chars, display only, not sent to model) */ - task: string; - /** Full instructions for the worker */ - instructions: string; - /** Files the worker should operate on */ - files: string[]; - /** Optional context or background information */ - context?: string; - /** Optional skill names to provide specialized context */ - skills?: string[]; -} - -/** Details structure for worker tool rendering */ -export interface WorkerDetails extends BaseSubagentDetails { - /** Short task description (display only) */ - task: string; - /** Full instructions sent to the worker */ - instructions: string; - /** Files the worker is operating on */ - files: string[]; - /** Context input */ - context?: string; - /** Working directory for relative path display */ - cwd?: string; -} diff --git a/extensions/subagents/subagents/worker/utils/bash-policy.ts b/extensions/subagents/subagents/worker/utils/bash-policy.ts deleted file mode 100644 index 30725719..00000000 --- a/extensions/subagents/subagents/worker/utils/bash-policy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Worker bash policy checks. - */ - -import { parse } from "@aliou/sh"; -import { walkCommands, wordToString } from "./shell-utils"; - -const EXPLORATION_COMMANDS = new Set(["ls", "find", "grep", "rg", "tree"]); - -const NO_VERIFY_PATTERN = /\b--no-verify\b/; -const EXPLORATION_PATTERN = /(^|[;&|()]\s*)(ls|find|grep|rg|tree)\b/; - -export function getBashPolicyViolation(command: string): string | null { - try { - const { ast } = parse(command); - - let violation: string | null = null; - walkCommands(ast, (cmd) => { - const words = cmd.words ?? []; - const commandName = words[0] ? wordToString(words[0]) : undefined; - - if (commandName && EXPLORATION_COMMANDS.has(commandName)) { - violation = "Exploration commands are forbidden for worker bash usage."; - return true; - } - - for (const word of words) { - const value = wordToString(word); - if (value === "--no-verify" || value.startsWith("--no-verify=")) { - violation = "'--no-verify' is forbidden by worker policy."; - return true; - } - } - - return false; - }); - - return violation; - } catch { - if (NO_VERIFY_PATTERN.test(command)) { - return "'--no-verify' is forbidden by worker policy."; - } - if (EXPLORATION_PATTERN.test(command)) { - return "Exploration commands are forbidden for worker bash usage."; - } - return null; - } -} diff --git a/extensions/subagents/subagents/worker/utils/path-scope.ts b/extensions/subagents/subagents/worker/utils/path-scope.ts deleted file mode 100644 index d166010e..00000000 --- a/extensions/subagents/subagents/worker/utils/path-scope.ts +++ /dev/null @@ -1,14 +0,0 @@ -import path from "node:path"; - -export function resolveAllowedPaths(cwd: string, files: string[]): Set<string> { - return new Set(files.map((file) => path.resolve(cwd, file))); -} - -export function isAllowedPath( - cwd: string, - allowedPaths: Set<string>, - targetPath: string, -): boolean { - const resolved = path.resolve(cwd, targetPath); - return allowedPaths.has(resolved); -} diff --git a/extensions/subagents/subagents/worker/utils/results.ts b/extensions/subagents/subagents/worker/utils/results.ts deleted file mode 100644 index b3738c67..00000000 --- a/extensions/subagents/subagents/worker/utils/results.ts +++ /dev/null @@ -1,35 +0,0 @@ -export function blockedPathResult(targetPath: string, files: string[]) { - return { - content: [ - { - type: "text" as const, - text: `Error: Path '${targetPath}' is outside worker scope. Allowed files:\n${files - .map((f) => `- ${f}`) - .join("\n")}`, - }, - ], - details: { - blocked: true, - kind: "path-scope", - targetPath, - allowedFiles: files, - }, - }; -} - -export function blockedCommandResult(reason: string, command?: string) { - return { - content: [ - { - type: "text" as const, - text: `Error: ${reason}`, - }, - ], - details: { - blocked: true, - kind: "command-policy", - reason, - command, - }, - }; -} diff --git a/extensions/subagents/subagents/worker/utils/shell-utils.ts b/extensions/subagents/subagents/worker/utils/shell-utils.ts deleted file mode 100644 index 4ff142b4..00000000 --- a/extensions/subagents/subagents/worker/utils/shell-utils.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Shell AST helpers for worker command policy checks. - */ - -import type { - Command, - Program, - SimpleCommand, - Statement, - Word, - WordPart, -} from "@aliou/sh"; - -export function wordToString(word: Word): string { - return word.parts.map(partToString).join(""); -} - -function partToString(part: WordPart): string { - switch (part.type) { - case "Literal": - return part.value; - case "SglQuoted": - return part.value; - case "DblQuoted": - return part.parts.map(partToString).join(""); - case "ParamExp": - return part.short - ? `$${part.param.value}` - : `\${${part.param.value}${part.op ?? ""}${part.value ? wordToString(part.value) : ""}}`; - case "CmdSubst": - return "$(...)"; - case "ArithExp": - return `$((...))`; - case "ProcSubst": - return `${part.op}(...)`; - } -} - -export function walkCommands( - node: Program, - callback: (cmd: SimpleCommand) => boolean | undefined, -): void { - for (const stmt of node.body) { - if (walkStatement(stmt, callback)) return; - } -} - -function walkStatement( - stmt: Statement, - callback: (cmd: SimpleCommand) => boolean | undefined, -): boolean { - return walkCommand(stmt.command, callback); -} - -function walkStatements( - stmts: Statement[], - callback: (cmd: SimpleCommand) => boolean | undefined, -): boolean { - for (const stmt of stmts) { - if (walkStatement(stmt, callback)) return true; - } - return false; -} - -function walkCommand( - cmd: Command, - callback: (cmd: SimpleCommand) => boolean | undefined, -): boolean { - switch (cmd.type) { - case "SimpleCommand": - return callback(cmd) === true; - case "Pipeline": - return walkStatements(cmd.commands, callback); - case "Logical": - return ( - walkStatement(cmd.left, callback) || walkStatement(cmd.right, callback) - ); - case "Subshell": - case "Block": - return walkStatements(cmd.body, callback); - case "IfClause": - return ( - walkStatements(cmd.cond, callback) || - walkStatements(cmd.then, callback) || - (cmd.else ? walkStatements(cmd.else, callback) : false) - ); - case "ForClause": - case "SelectClause": - case "WhileClause": - return ( - ("cond" in cmd && cmd.cond - ? walkStatements(cmd.cond, callback) - : false) || walkStatements(cmd.body, callback) - ); - case "CaseClause": - for (const item of cmd.items) { - if (walkStatements(item.body, callback)) return true; - } - return false; - case "FunctionDecl": - return walkStatements(cmd.body, callback); - case "TimeClause": - return walkStatement(cmd.command, callback); - case "CoprocClause": - return walkStatement(cmd.body, callback); - case "CStyleLoop": - return walkStatements(cmd.body, callback); - case "TestClause": - case "ArithCmd": - case "DeclClause": - case "LetClause": - return false; - } -} diff --git a/extensions/tools/get-current-time/index.ts b/extensions/tools/get-current-time/index.ts deleted file mode 100644 index a1a0068d..00000000 --- a/extensions/tools/get-current-time/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - ExtensionAPI, - ExtensionContext, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { type Static, Type } from "typebox"; - -const GetCurrentTimeParams = Type.Object({ - format: Type.Optional( - Type.String({ - description: - "Output format: 'iso8601' (default), 'unix', 'date', 'time', or custom strftime-like pattern", - }), - ), -}); -type GetCurrentTimeParamsType = Static<typeof GetCurrentTimeParams>; - -interface TimeDetails { - formatted: string; - date: string; - time: string; - timezone: string; - timezone_name: string; - day_of_week: string; - unix: number; -} - -type ExecuteResult = AgentToolResult<TimeDetails>; - -function formatDate(date: Date, format: string): string { - switch (format.toLowerCase()) { - case "iso8601": - case "iso": - return date.toISOString(); - case "unix": - return Math.floor(date.getTime() / 1000).toString(); - case "date": - return date.toLocaleDateString(); - case "time": - return date.toLocaleTimeString(); - default: - // For custom formats, return ISO8601 - return date.toISOString(); - } -} - -export default function (pi: ExtensionAPI) { - pi.registerTool<typeof GetCurrentTimeParams, TimeDetails>({ - name: "get_current_time", - label: "Get Current Time", - description: - "Get the current date and time. Returns formatted time along with date, time, timezone, and day of week as separate fields.", - parameters: GetCurrentTimeParams, - - async execute( - _toolCallId: string, - params: GetCurrentTimeParamsType, - _signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: ExtensionContext, - ): Promise<ExecuteResult> { - const now = new Date(); - const format = params.format || "iso8601"; - - const formatted = formatDate(now, format); - const timezoneOffset = -now.getTimezoneOffset(); - const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60); - const offsetMinutes = Math.abs(timezoneOffset) % 60; - const offsetSign = timezoneOffset >= 0 ? "+" : "-"; - const timezone = `UTC${offsetSign}${String(offsetHours).padStart(2, "0")}:${String(offsetMinutes).padStart(2, "0")}`; - - const details: TimeDetails = { - formatted, - date: now.toLocaleDateString("en-CA"), // YYYY-MM-DD format - time: now.toLocaleTimeString("en-GB", { hour12: false }), // HH:MM:SS format - timezone, - timezone_name: Intl.DateTimeFormat().resolvedOptions().timeZone, - day_of_week: now.toLocaleDateString("en-US", { weekday: "long" }), - unix: Math.floor(now.getTime() / 1000), - }; - - const text = [ - `Formatted: ${details.formatted}`, - `Date: ${details.date}`, - `Time: ${details.time}`, - `Timezone: ${details.timezone} (${details.timezone_name})`, - `Day: ${details.day_of_week}`, - `Unix: ${details.unix}`, - ].join("\n"); - - return { - content: [{ type: "text", text }], - details, - }; - }, - - renderCall(args: GetCurrentTimeParamsType, theme: Theme) { - return new ToolCallHeader( - { - toolName: "Current Time", - optionArgs: args.format - ? [{ label: "format", value: args.format }] - : [], - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult<TimeDetails>, - _options: ToolRenderResultOptions, - theme: Theme, - ): Text { - const { details } = result; - - if (!details) { - const text = result.content[0]; - return new Text( - text?.type === "text" && text.text ? text.text : "No result", - 0, - 0, - ); - } - - const lines: string[] = []; - lines.push( - `${theme.fg("dim", "Date:")} ${theme.fg("accent", details.date)} ${theme.fg("dim", `(${details.day_of_week})`)}`, - ); - lines.push( - `${theme.fg("dim", "Time:")} ${theme.fg("accent", details.time)} ${theme.fg("dim", details.timezone_name)}`, - ); - - return new Text(lines.join("\n"), 0, 0); - }, - }); -} diff --git a/extensions/tools/look-at/index.ts b/extensions/tools/look-at/index.ts deleted file mode 100644 index 325d40da..00000000 --- a/extensions/tools/look-at/index.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Look At - Analyze image files using a vision-capable model. - * - * When using a non-vision model, this tool lets the agent "see" images by - * delegating to a vision-capable model via createAgentSession. The vision - * model's analysis is returned as text. - * - * Also registers an `input` event hook that nudges the agent to use look_at - * when the user message references image files and the current model lacks - * vision support. - * - * Inspired by Amp's "Look At" tool. - */ - -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import type { AssistantMessage } from "@mariozechner/pi-ai"; -import type { - AgentToolResult, - ExtensionAPI, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - DefaultResourceLoader, - getAgentDir, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; - -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- - -const ANALYSIS_SYSTEM_PROMPT = `You are an AI assistant that analyzes images for a software engineer. - -# Core Principles - -- Be concise and direct. Minimize output while maintaining accuracy. -- Focus only on the user's objective. Do not add tangential information. -- No preamble, disclaimers, or summaries unless specifically relevant. -- Never start with flattery ("great question", "interesting file", etc.). -- A wrong answer is worse than no answer. When uncertain, say so. - -# Precision Guidelines - -- Describe exactly what you see. Do not guess or infer beyond what is visible. -- When analyzing code screenshots: reference specific line numbers and symbols. -- When analyzing UI: describe layout, components, text, colors, and hierarchy. -- When analyzing errors: extract the exact error message, stack trace, and root cause. -- When analyzing diagrams: describe nodes, relationships, labels, and flow. - -# Output Format - -- Use GitHub-flavored Markdown. -- Use code fences with language tags for code snippets. -- No emojis or decorative symbols. -- Keep responses focused and brief.`; - -const NUDGE_TEXT = - "\n\nNote: the current model cannot see images. Use look_at to analyze any image files referenced above."; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const EXT_TO_MIME: Record<string, string> = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - bmp: "image/bmp", - svg: "image/svg+xml", -}; - -function mimeTypeFromPath(path: string): string | null { - const ext = path.split(".").pop()?.toLowerCase(); - if (!ext) return null; - return EXT_TO_MIME[ext] ?? null; -} - -import { referencesImageFiles } from "./utils"; - -/** Find a vision-capable model from the registry. */ -function findVisionModel(ctx: ExtensionContext) { - const available = ctx.modelRegistry.getAvailable(); - // Prefer models from the same provider as the current model - const currentProvider = ctx.model?.provider; - if (currentProvider) { - const sameProvider = available.find( - (m) => - m.provider === currentProvider && m.input.includes("image" as never), - ); - if (sameProvider) return sameProvider; - } - // Fall back to any vision model - return available.find((m) => m.input.includes("image" as never)); -} - -// --------------------------------------------------------------------------- -// Tool parameters -// --------------------------------------------------------------------------- - -const LookAtParams = Type.Object({ - path: Type.String({ - description: "Path to the image file to analyze (relative or absolute).", - }), - objective: Type.String({ - description: - "What you want to learn from this image (e.g., 'describe the UI layout', 'extract the error message', 'read the text in this diagram').", - }), - context: Type.Optional( - Type.String({ - description: - "Broader context for why you need this analysis. Helps the vision model focus on what matters.", - }), - ), -}); - -interface LookAtDetails { - path: string; - objective: string; - visionModel: string; - visionProvider: string; - usage?: { - inputTokens: number; - outputTokens: number; - }; -} - -// --------------------------------------------------------------------------- -// Core execution -// --------------------------------------------------------------------------- - -async function analyzeImage( - absolutePath: string, - objective: string, - context: string | undefined, - visionModel: { id: string; provider: string }, - ctx: ExtensionContext, - signal?: AbortSignal, -): Promise<{ - text: string; - usage?: { inputTokens: number; outputTokens: number }; -}> { - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.inMemory(); - const resourceLoader = new DefaultResourceLoader({ - cwd: ctx.cwd, - agentDir, - settingsManager, - noExtensions: true, - noPromptTemplates: true, - noThemes: true, - noSkills: true, - systemPromptOverride: () => ANALYSIS_SYSTEM_PROMPT, - appendSystemPromptOverride: () => [], - agentsFilesOverride: () => ({ agentsFiles: [] }), - skillsOverride: () => ({ skills: [], diagnostics: [] }), - }); - await resourceLoader.reload(); - - const model = ctx.modelRegistry.find(visionModel.provider, visionModel.id); - if (!model) { - throw new Error( - `Vision model ${visionModel.provider}/${visionModel.id} not found in registry`, - ); - } - - const { session } = await createAgentSession({ - model, - thinkingLevel: "low", - tools: [], - customTools: [], - sessionManager: SessionManager.inMemory(), - modelRegistry: ctx.modelRegistry, - resourceLoader, - }); - - // Read the image file - const buffer = await readFile(absolutePath); - const base64 = buffer.toString("base64"); - const mimeType = mimeTypeFromPath(absolutePath); - - if (!mimeType) { - throw new Error(`Unsupported image format for ${absolutePath}`); - } - - let accumulated = ""; - let aborted = false; - const usage = { - inputTokens: 0, - outputTokens: 0, - }; - - const unsubscribe = session.subscribe((event) => { - if (event.type === "message_update") { - if (event.assistantMessageEvent.type === "text_delta") { - accumulated += event.assistantMessageEvent.delta; - } - } - if (event.type === "turn_end") { - const msg = event.message; - if (msg.role === "assistant") { - const assistantMsg = msg as AssistantMessage & { - usage?: { input: number; output: number }; - }; - if (assistantMsg.usage) { - usage.inputTokens += assistantMsg.usage.input; - usage.outputTokens += assistantMsg.usage.output; - } - } - } - }); - - if (signal) { - if (signal.aborted) { - unsubscribe(); - session.dispose(); - throw new Error("Operation aborted"); - } - signal.addEventListener( - "abort", - () => { - session.abort(); - aborted = true; - }, - { once: true }, - ); - } - - try { - const userText = context - ? `Context: ${context}\n\nObjective: ${objective}` - : objective; - - await session.prompt(userText, { - images: [{ type: "image" as const, data: base64, mimeType }], - }); - } catch (err) { - if (signal?.aborted) { - aborted = true; - } else { - throw err; - } - } finally { - unsubscribe(); - session.dispose(); - } - - if (aborted) { - throw new Error("Operation aborted"); - } - - return { text: accumulated, usage }; -} - -// --------------------------------------------------------------------------- -// Extension -// --------------------------------------------------------------------------- - -export default function (pi: ExtensionAPI): void { - // --- look_at tool --- - - pi.registerTool<typeof LookAtParams, LookAtDetails>({ - name: "look_at", - label: "Look At", - description: `Analyze an image file using a vision-capable model. Returns a text description of the image content. - -Use this tool when you need to understand or extract information from an image file (PNG, JPG, GIF, WebP, etc.). The current model cannot see images directly -- this tool delegates to a vision model that can. - -Always provide a clear objective describing what you want to learn from the image. - -## When to use this tool -- Analyzing screenshots, diagrams, charts, or photographs -- Extracting text or error messages from images -- Describing visual content that the Read tool cannot interpret -- Comparing visual elements (use context to describe what to compare) - -## When NOT to use this tool -- For source code or plain text files where you need exact contents -- use read instead -- When you need to edit the file afterward -- For simple file reading where no interpretation is needed`, - promptSnippet: "Analyze an image file", - promptGuidelines: [ - "Use look_at when you need to understand the content of an image file.", - "Always provide a clear objective.", - ], - parameters: LookAtParams, - - async execute( - _toolCallId: string, - params, - signal: AbortSignal | undefined, - _onUpdate: unknown, - ctx: ExtensionContext, - ): Promise<AgentToolResult<LookAtDetails>> { - const { path: rawPath, objective, context } = params; - const absolutePath = resolve(ctx.cwd, rawPath); - - // Find a vision model - const visionModel = findVisionModel(ctx); - if (!visionModel) { - return { - content: [ - { - type: "text", - text: `No vision-capable model available. Configure a model that supports images (check your API keys).`, - }, - ], - details: { - path: absolutePath, - objective, - visionModel: "none", - visionProvider: "none", - }, - }; - } - - try { - const result = await analyzeImage( - absolutePath, - objective, - context, - { id: visionModel.id, provider: visionModel.provider }, - ctx, - signal, - ); - - return { - content: [{ type: "text", text: result.text }], - details: { - path: absolutePath, - objective, - visionModel: visionModel.id, - visionProvider: visionModel.provider, - usage: result.usage, - }, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Image analysis failed: ${message}`); - } - }, - }); - - // --- Input nudge: when user references an image and model lacks vision --- - - pi.on("input", (event, ctx) => { - const model = ctx.model; - const hasImageRefs = referencesImageFiles(event.text); - const hasAttachedImages = Boolean(event.images?.length); - const modelHasVision = Boolean(model?.input.includes("image" as never)); - - if (!model) return; - if (modelHasVision) return; - if (!hasImageRefs && !hasAttachedImages) return; - - return { action: "transform", text: event.text + NUDGE_TEXT }; - }); -} diff --git a/extensions/tools/package.json b/extensions/tools/package.json deleted file mode 100644 index ef210e08..00000000 --- a/extensions/tools/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "pi": { - "extensions": [ - "./ask-user/index.ts", - "./get-current-time/index.ts", - "./read-url/index.ts", - "./look-at/index.ts" - ] - } -} diff --git a/extensions/tools/read-url/index.ts b/extensions/tools/read-url/index.ts deleted file mode 100644 index 50c99763..00000000 --- a/extensions/tools/read-url/index.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { basename, extname, join } from "node:path"; -import { ToolCallHeader } from "@aliou/pi-utils-ui"; -import type { - AgentToolResult, - ExtensionAPI, - ExtensionContext, - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import { - createReadTool, - getMarkdownTheme, - keyText, -} from "@mariozechner/pi-coding-agent"; -import { Container, Markdown, Text } from "@mariozechner/pi-tui"; -import { type Static, Type } from "typebox"; -import { - DEFAULT_PREVIEW_MAX_LINES, - writeTempFilePreview, -} from "../utils/temp-file-preview"; -import { - createGistHandler, - createGitHubHandler, - createMarkdownNewHandler, - createTailscaleHandler, - createTwitterHandler, - type ReadUrlHandler, -} from "./handlers"; -import type { HandlerImage } from "./handlers/types"; - -const ReadUrlParams = Type.Object({ - url: Type.String({ - description: "URL to fetch as Markdown via markdown.new", - }), -}); - -type ReadUrlParamsType = Static<typeof ReadUrlParams>; - -type NativeReadTool = ReturnType<typeof createReadTool>; -type ReadContentBlock = ExecuteResult["content"][number]; - -type FetchLike = typeof fetch; - -interface ReadUrlDetails { - url: string; - sourceUrl: string; - title?: string; - handler: string; - statusCode?: number; - statusText?: string; - failed: boolean; - imageCount?: number; - attachedImageCount?: number; - skippedImageCount?: number; - tempFilePath?: string; - totalLines?: number; -} - -type ExecuteResult = AgentToolResult<ReadUrlDetails>; - -const COLLAPSED_PREVIEW_LINES = 8; - -export async function executeReadUrlRequest( - input: string, - signal: AbortSignal | undefined, - handlers: ReadUrlHandler[], - nativeRead: Pick<NativeReadTool, "execute">, - fetchImpl: FetchLike = fetch, -): Promise<ExecuteResult> { - const trimmedInput = input.trim(); - - if (!trimmedInput) { - throw new Error("url is required"); - } - - let parsedUrl: URL; - try { - parsedUrl = new URL(trimmedInput); - } catch { - throw new Error(`Invalid URL: ${trimmedInput}`); - } - - const handler = handlers.find((candidate) => candidate.matches(parsedUrl)); - if (!handler) { - throw new Error("No handler available for this URL"); - } - - const data = await handler.fetchData(parsedUrl, signal); - const markdown = data.markdown; - - // Write full content to a temp file so the agent can read it with offset/limit. - // Only the preview goes into the LLM context to avoid blowing it up. - const { preview, tempFilePath, totalLines } = await writeTempFilePreview( - markdown, - { slug: trimmedInput }, - ); - - const content: ReadContentBlock[] = [{ type: "text", text: preview }]; - - let attachedImageCount = 0; - let skippedImageCount = 0; - const images = data.images ?? []; - - if (images.length > 0) { - const tempDir = join(tempFilePath, ".."); - for (const [index, image] of images.entries()) { - try { - const tempPath = await fetchRemoteImageToTempFile( - image, - tempDir, - index, - signal, - fetchImpl, - ); - - const imageResult = await nativeRead.execute( - `read-url-image-${index + 1}`, - { path: tempPath }, - signal, - undefined, - ); - - if ( - !imageResult || - typeof imageResult !== "object" || - !("content" in imageResult) || - !Array.isArray(imageResult.content) || - ("isError" in imageResult && imageResult.isError) - ) { - skippedImageCount += 1; - continue; - } - - content.push(...(imageResult.content as ReadContentBlock[])); - attachedImageCount += 1; - } catch { - skippedImageCount += 1; - } - } - } - - return { - content, - details: { - url: trimmedInput, - sourceUrl: data.sourceUrl, - title: data.title, - handler: handler.name, - statusCode: data.statusCode, - statusText: data.statusText, - failed: false, - imageCount: images.length, - attachedImageCount, - skippedImageCount, - tempFilePath, - totalLines, - }, - }; -} - -export default function (pi: ExtensionAPI): void { - const handlers: ReadUrlHandler[] = [ - createTwitterHandler(), - createGitHubHandler(), - createGistHandler(), - createTailscaleHandler(), - createMarkdownNewHandler(), - ]; - const nativeRead = createReadTool(process.cwd()); - - pi.registerTool<typeof ReadUrlParams, ReadUrlDetails>({ - name: "read_url", - label: "Read URL", - description: - "Fetch a URL as Markdown via handlers with markdown.new fallback.", - parameters: ReadUrlParams, - - async execute( - _toolCallId: string, - params: ReadUrlParamsType, - signal: AbortSignal | undefined, - _onUpdate: unknown, - _ctx: ExtensionContext, - ): Promise<ExecuteResult> { - return executeReadUrlRequest( - params.url, - signal, - handlers, - nativeRead, - fetch, - ); - }, - - renderCall(args: ReadUrlParamsType, theme: Theme) { - return new ToolCallHeader( - { - toolName: "Read URL", - mainArg: args.url.trim(), - showColon: true, - }, - theme, - ); - }, - - renderResult( - result: AgentToolResult<ReadUrlDetails>, - options: ToolRenderResultOptions, - theme: Theme, - ) { - if (options.isPartial) { - return new Text(theme.fg("muted", "Read URL: fetching..."), 0, 0); - } - - const isError = Boolean((result as { isError?: boolean }).isError); - const textBlock = result.content.find((c) => c.type === "text"); - const markdownText = - textBlock?.type === "text" && textBlock.text ? textBlock.text : ""; - const tempFilePath = result.details?.tempFilePath; - const totalLines = result.details?.totalLines; - - const container = new Container(); - - if (isError) { - const errorText = markdownText || "Read URL failed"; - container.addChild(new Text(theme.fg("error", errorText), 0, 0)); - } else if (markdownText) { - const collapsed = !options.expanded; - - if (collapsed) { - const lines = markdownText.split("\n"); - const visibleText = lines - .slice(0, COLLAPSED_PREVIEW_LINES) - .join("\n"); - const remaining = Math.max(lines.length - COLLAPSED_PREVIEW_LINES, 0); - - container.addChild( - new Markdown(visibleText, 0, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("toolOutput", text), - }), - ); - - if (remaining > 0) { - container.addChild( - new Text( - theme.fg( - "muted", - `... (${remaining} more lines, ${keyText("app.tools.expand")} to expand)`, - ), - 0, - 0, - ), - ); - } - } else { - container.addChild( - new Markdown(markdownText, 0, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("toolOutput", text), - }), - ); - } - - // Show temp file path so the user knows where the full content lives. - if ( - tempFilePath && - totalLines && - totalLines > DEFAULT_PREVIEW_MAX_LINES - ) { - container.addChild( - new Text( - theme.fg( - "muted", - `Full content (${totalLines} lines) saved to: ${tempFilePath}`, - ), - 0, - 0, - ), - ); - } - } else { - container.addChild( - new Text(theme.fg("muted", "Read URL: no content"), 0, 0), - ); - } - - const status = result.details?.statusCode - ? `${result.details.statusCode}${ - result.details.statusText ? ` ${result.details.statusText}` : "" - }` - : "n/a"; - const failed = isError || result.details?.failed === true ? "yes" : "no"; - const handler = result.details?.handler ?? "unknown"; - - container.addChild(new Text("", 0, 0)); - container.addChild( - new Text( - `${theme.fg("muted", "handler=")}${theme.fg("dim", handler)} ${theme.fg("muted", "HTTP:")} ${theme.fg("dim", status)} ${theme.fg("muted", "failed=")}${theme.fg(failed === "yes" ? "error" : "success", failed)}`, - 0, - 0, - ), - ); - - return container; - }, - }); -} - -async function fetchRemoteImageToTempFile( - image: HandlerImage, - tempDir: string, - index: number, - signal: AbortSignal | undefined, - fetchImpl: FetchLike, -): Promise<string> { - const response = await fetchImpl(image.sourceUrl, { signal }); - if (!response.ok) { - throw new Error( - `HTTP ${response.status} ${response.statusText || "Error"} while fetching image`, - ); - } - - const contentType = response.headers.get("content-type"); - const extension = guessImageExtension(contentType, image.sourceUrl); - const bytes = Buffer.from(await response.arrayBuffer()); - const baseName = sanitizeTempBaseName( - image.label || - basename(new URL(image.sourceUrl).pathname) || - `image-${index + 1}`, - ); - const tempPath = join(tempDir, `${index + 1}-${baseName}${extension}`); - - await writeFile(tempPath, bytes); - return tempPath; -} - -export function guessImageExtension( - contentType: string | null | undefined, - imageUrl: string, -): string { - const normalizedContentType = contentType - ?.split(";")[0] - ?.trim() - .toLowerCase(); - const byContentType: Record<string, string> = { - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "image/avif": ".avif", - "image/heic": ".heic", - "image/heif": ".heif", - "image/bmp": ".bmp", - "image/tiff": ".tiff", - "image/svg+xml": ".svg", - }; - - if (normalizedContentType && byContentType[normalizedContentType]) { - return byContentType[normalizedContentType]; - } - - try { - const pathname = new URL(imageUrl).pathname; - const extension = extname(pathname).toLowerCase(); - if (extension) { - return extension; - } - } catch { - // Ignore invalid URL here. Caller already validated/fetched it. - } - - return ".img"; -} - -function sanitizeTempBaseName(value: string): string { - return value.replace(/\.[a-z0-9]+$/i, "").replace(/[^a-z0-9_-]+/gi, "-"); -} diff --git a/extensions/chrome/components/footer.ts b/hooks/chrome/components/footer.ts similarity index 82% rename from extensions/chrome/components/footer.ts rename to hooks/chrome/components/footer.ts index b2c9fee3..85b910af 100644 --- a/extensions/chrome/components/footer.ts +++ b/hooks/chrome/components/footer.ts @@ -5,6 +5,12 @@ * Line 2: Session name (left) + Model (right aligned) */ +import { + AD_PROVIDERS_CODEX_FAST_MODE_CHANGED_EVENT, + AD_PROVIDERS_CODEX_FAST_MODE_READY_EVENT, + AD_PROVIDERS_CODEX_FAST_MODE_REQUEST_EVENT, + type AdProvidersCodexFastModeChangedEvent, +} from "@harness/events"; import type { ExtensionAPI, ExtensionContext, @@ -12,16 +18,6 @@ import type { Theme, } from "@mariozechner/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { - AD_EDITOR_STASH_CHANGED_EVENT, - AD_EDITOR_STASH_READY_EVENT, - AD_EDITOR_STASH_REQUEST_EVENT, - AD_PROVIDERS_CODEX_FAST_MODE_CHANGED_EVENT, - AD_PROVIDERS_CODEX_FAST_MODE_READY_EVENT, - AD_PROVIDERS_CODEX_FAST_MODE_REQUEST_EVENT, - type AdEditorStashChangedEvent, - type AdProvidersCodexFastModeChangedEvent, -} from "../../../packages/events"; import { GitStatusWatcher } from "../lib/git-status"; import { buildModelIdLine, buildModelLine } from "../lib/model"; import { buildPathParts } from "../lib/path-parts"; @@ -53,19 +49,6 @@ export function createCustomFooter(pi: ExtensionAPI) { requestRender?.(); }); - let currentStashCount = 0; - - pi.events.on(AD_EDITOR_STASH_CHANGED_EVENT, (data: unknown) => { - const event = (data ?? {}) as Partial<AdEditorStashChangedEvent>; - currentStashCount = event.count ?? currentStashCount; - requestRender?.(); - }); - - // Request current stash state when editor signals readiness - pi.events.on(AD_EDITOR_STASH_READY_EVENT, () => { - pi.events.emit(AD_EDITOR_STASH_REQUEST_EVENT, {}); - }); - const renderFooter = ( width: number, theme: Theme, @@ -79,12 +62,6 @@ export function createCustomFooter(pi: ExtensionAPI) { const usage = getCumulativeUsage(ctx); const contextUsage = getContextUsage(ctx); - // Stash indicator (before path) - const stashN = currentStashCount; - const stashPart = - stashN > 0 ? `${theme.fg("warning", `stash:${stashN}`)} ` : ""; - const stashPartWidth = stashN > 0 ? visibleWidth(`stash:${stashN}`) + 1 : 0; - const gitStatus = gitStatusWatcher?.getStatus(); const pathData = buildPathParts(theme, branch, gitStatus); @@ -94,7 +71,7 @@ export function createCustomFooter(pi: ExtensionAPI) { const minPadding = 2; // Build line 1 with progressive degradation: - // 1. Full: stash + path + branch + stats + // 1. Full: path + branch + stats // 2. Drop branch // 3. Truncate path let line1: string; @@ -114,20 +91,18 @@ export function createCustomFooter(pi: ExtensionAPI) { ); }; - // Full left side: stash + path + branch + // Full left side: path + branch const fullLeft = - stashPart + - pathData.path + - (pathData.branch ? ` ${pathData.branch}` : ""); - const fullLeftWidth = stashPartWidth + pathData.width; + pathData.path + (pathData.branch ? ` ${pathData.branch}` : ""); + const fullLeftWidth = pathData.width; if (fullLeftWidth + minPadding + statsWidth <= width) { // Everything fits line1 = buildLine1(fullLeft, fullLeftWidth, statsLine, statsWidth); } else { // Drop branch, keep path + stats - const noBranchLeft = stashPart + pathData.path; - const noBranchLeftWidth = stashPartWidth + pathData.pathWidth; + const noBranchLeft = pathData.path; + const noBranchLeftWidth = pathData.pathWidth; if (noBranchLeftWidth + minPadding + statsWidth <= width) { line1 = buildLine1( @@ -149,12 +124,12 @@ export function createCustomFooter(pi: ExtensionAPI) { const availForPath = Math.max( 0, - width - stashPartWidth - minPadding - minimalStatsWidth, + width - minPadding - minimalStatsWidth, ); const truncPath = truncateToWidth(pathData.path, availForPath, "..."); const truncPathWidth = visibleWidth(truncPath); - const truncLeft = stashPart + truncPath; - const truncLeftWidth = stashPartWidth + truncPathWidth; + const truncLeft = truncPath; + const truncLeftWidth = truncPathWidth; line1 = buildLine1( truncLeft, @@ -235,8 +210,6 @@ export function createCustomFooter(pi: ExtensionAPI) { setup: (context: ExtensionContext) => { ctx = context; pi.events.emit(AD_PROVIDERS_CODEX_FAST_MODE_REQUEST_EVENT, { ctx }); - pi.events.emit(AD_EDITOR_STASH_REQUEST_EVENT, {}); - ctx.ui.setFooter((tui, theme, footerData) => { requestRender = () => tui.requestRender?.(); diff --git a/extensions/chrome/components/header.ts b/hooks/chrome/components/header.ts similarity index 77% rename from extensions/chrome/components/header.ts rename to hooks/chrome/components/header.ts index 7443398c..65c08296 100644 --- a/extensions/chrome/components/header.ts +++ b/hooks/chrome/components/header.ts @@ -13,12 +13,6 @@ import type { import { rawKeyHint } from "@mariozechner/pi-coding-agent"; import { Container, Spacer, Text } from "@mariozechner/pi-tui"; -// Custom shortcuts defined in harness extensions. -const SHORTCUTS: { key: string; description: string }[] = [ - { key: "ctrl+shift+s", description: "stash editor" }, - { key: "ctrl+shift+r", description: "pop stash" }, -]; - // Custom commands defined in harness extensions. const COMMANDS: { name: string; description: string }[] = [ { name: "qq", description: "quick question" }, @@ -36,15 +30,6 @@ function createHeaderComponent(theme: Theme): Container { container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", "Shortcuts"), 0, 0)); - for (const shortcut of SHORTCUTS) { - container.addChild( - new Text(rawKeyHint(shortcut.key, shortcut.description), 0, 0), - ); - } - - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", "Commands"), 0, 0)); for (const command of COMMANDS) { container.addChild( diff --git a/extensions/chrome/hooks/footer.ts b/hooks/chrome/hooks/footer.ts similarity index 100% rename from extensions/chrome/hooks/footer.ts rename to hooks/chrome/hooks/footer.ts diff --git a/extensions/chrome/hooks/header.ts b/hooks/chrome/hooks/header.ts similarity index 100% rename from extensions/chrome/hooks/header.ts rename to hooks/chrome/hooks/header.ts diff --git a/extensions/chrome/hooks/notification.ts b/hooks/chrome/hooks/notification.ts similarity index 99% rename from extensions/chrome/hooks/notification.ts rename to hooks/chrome/hooks/notification.ts index 951f74ab..2e6bc1a8 100644 --- a/extensions/chrome/hooks/notification.ts +++ b/hooks/chrome/hooks/notification.ts @@ -7,17 +7,17 @@ import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { + AD_NOTIFY_ATTENTION_EVENT, + AD_NOTIFY_DANGEROUS_EVENT, + AD_NOTIFY_DONE_EVENT, +} from "@harness/events"; import type { ToolResultMessage } from "@mariozechner/pi-ai"; import type { ExtensionAPI, ExtensionContext, ToolCallEvent, } from "@mariozechner/pi-coding-agent"; -import { - AD_NOTIFY_ATTENTION_EVENT, - AD_NOTIFY_DANGEROUS_EVENT, - AD_NOTIFY_DONE_EVENT, -} from "../../../packages/events"; // Path to the native binary (resolved relative to this file) const PLAY_ALERT_SOUND_BINARY = fileURLToPath( diff --git a/hooks/chrome/index.ts b/hooks/chrome/index.ts new file mode 100644 index 00000000..5c75e760 --- /dev/null +++ b/hooks/chrome/index.ts @@ -0,0 +1,10 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { setupFooterHook } from "./hooks/footer"; +import { setupHeaderHook } from "./hooks/header"; +import { setupNotificationHook } from "./hooks/notification"; + +export default async function (pi: ExtensionAPI) { + setupNotificationHook(pi); + setupFooterHook(pi); + setupHeaderHook(pi); +} diff --git a/extensions/chrome/lib/git-status.ts b/hooks/chrome/lib/git-status.ts similarity index 100% rename from extensions/chrome/lib/git-status.ts rename to hooks/chrome/lib/git-status.ts diff --git a/extensions/chrome/lib/model.ts b/hooks/chrome/lib/model.ts similarity index 100% rename from extensions/chrome/lib/model.ts rename to hooks/chrome/lib/model.ts diff --git a/extensions/chrome/lib/path-parts.ts b/hooks/chrome/lib/path-parts.ts similarity index 100% rename from extensions/chrome/lib/path-parts.ts rename to hooks/chrome/lib/path-parts.ts diff --git a/extensions/chrome/lib/stats.ts b/hooks/chrome/lib/stats.ts similarity index 100% rename from extensions/chrome/lib/stats.ts rename to hooks/chrome/lib/stats.ts diff --git a/extensions/chrome/lib/utils.ts b/hooks/chrome/lib/utils.ts similarity index 100% rename from extensions/chrome/lib/utils.ts rename to hooks/chrome/lib/utils.ts diff --git a/extensions/chrome/native/play-alert-sound.swift b/hooks/chrome/native/play-alert-sound.swift similarity index 100% rename from extensions/chrome/native/play-alert-sound.swift rename to hooks/chrome/native/play-alert-sound.swift diff --git a/hooks/context-window-overrides/config.ts b/hooks/context-window-overrides/config.ts new file mode 100644 index 00000000..607a3dd7 --- /dev/null +++ b/hooks/context-window-overrides/config.ts @@ -0,0 +1,13 @@ +/** + * Map of provider -> modelId -> desired context window size in tokens. + */ +export const CONTEXT_WINDOW_OVERRIDES: Record< + string, + Record<string, number> +> = { + anthropic: { + "claude-opus-4-6": 272_000, + "claude-opus-4-7": 272_000, + "claude-sonnet-4-6": 272_000, + }, +}; diff --git a/hooks/context-window-overrides/drift.ts b/hooks/context-window-overrides/drift.ts new file mode 100644 index 00000000..bd1c959b --- /dev/null +++ b/hooks/context-window-overrides/drift.ts @@ -0,0 +1,46 @@ +import type { ModelsJsonConfig } from "./models-json"; + +export interface DriftedContextWindowOverride { + provider: string; + modelId: string; + current: number | undefined; + desired: number; +} + +export function collectDriftedContextWindowOverrides( + config: ModelsJsonConfig, + overrides: Record<string, Record<string, number>>, +): DriftedContextWindowOverride[] { + const drifted: DriftedContextWindowOverride[] = []; + + for (const [provider, modelOverrides] of Object.entries(overrides)) { + for (const [modelId, desired] of Object.entries(modelOverrides)) { + const current = + config.providers[provider]?.modelOverrides?.[modelId]?.contextWindow; + if (current !== desired) { + drifted.push({ provider, modelId, current, desired }); + } + } + } + + return drifted; +} + +export function applyContextWindowOverrides( + config: ModelsJsonConfig, + overrides: DriftedContextWindowOverride[], +): void { + for (const { provider, modelId, desired } of overrides) { + if (!config.providers[provider]) { + config.providers[provider] = {}; + } + const providerConfig = config.providers[provider]; + if (!providerConfig.modelOverrides) { + providerConfig.modelOverrides = {}; + } + if (!providerConfig.modelOverrides[modelId]) { + providerConfig.modelOverrides[modelId] = {}; + } + providerConfig.modelOverrides[modelId].contextWindow = desired; + } +} diff --git a/hooks/context-window-overrides/format.ts b/hooks/context-window-overrides/format.ts new file mode 100644 index 00000000..55891e4a --- /dev/null +++ b/hooks/context-window-overrides/format.ts @@ -0,0 +1,13 @@ +import type { DriftedContextWindowOverride } from "./drift"; + +export function formatContextWindowOverrideLines( + drifted: DriftedContextWindowOverride[], +): string[] { + return drifted.map(({ provider, modelId, current, desired }) => { + const desiredStr = desired.toLocaleString(); + if (current === undefined) { + return ` ${provider} / ${modelId}: missing (should be ${desiredStr})`; + } + return ` ${provider} / ${modelId}: ${current.toLocaleString()} → ${desiredStr}`; + }); +} diff --git a/hooks/context-window-overrides/index.ts b/hooks/context-window-overrides/index.ts new file mode 100644 index 00000000..c28bada3 --- /dev/null +++ b/hooks/context-window-overrides/index.ts @@ -0,0 +1,55 @@ +import { join } from "node:path"; +import { AD_NOTIFY_ATTENTION_EVENT } from "@harness/events"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { getAgentDir } from "@mariozechner/pi-coding-agent"; +import { CONTEXT_WINDOW_OVERRIDES } from "./config"; +import { + applyContextWindowOverrides, + collectDriftedContextWindowOverrides, +} from "./drift"; +import { formatContextWindowOverrideLines } from "./format"; +import { readModelsJson, writeModelsJson } from "./models-json"; + +export default function contextWindowOverrides(pi: ExtensionAPI): void { + if (Object.keys(CONTEXT_WINDOW_OVERRIDES).length === 0) return; + + pi.on("session_start", async (event, ctx) => { + // Only prompt on fresh starts, not resumes/switches + if (event.reason !== "startup" && event.reason !== "new") return; + + const modelsJsonPath = join(getAgentDir(), "models.json"); + const config = readModelsJson(modelsJsonPath); + const drifted = collectDriftedContextWindowOverrides( + config, + CONTEXT_WINDOW_OVERRIDES, + ); + + if (drifted.length === 0) return; + + const lines = formatContextWindowOverrideLines(drifted); + + ctx.ui.notify( + "Context window overrides in models.json are out of date:\n" + + lines.join("\n"), + "warning", + ); + pi.events.emit(AD_NOTIFY_ATTENTION_EVENT, { + description: "Context window overrides in models.json are out of date.", + }); + + const confirmed = await ctx.ui.confirm( + "Update models.json?", + `The following context window overrides will be written to models.json:\n${lines.join("\n")}`, + ); + + if (!confirmed) return; + + applyContextWindowOverrides(config, drifted); + writeModelsJson(modelsJsonPath, config); + ctx.modelRegistry.refresh(); + ctx.ui.notify( + "models.json updated. Context window overrides applied.", + "info", + ); + }); +} diff --git a/hooks/context-window-overrides/models-json.ts b/hooks/context-window-overrides/models-json.ts new file mode 100644 index 00000000..f99c2d07 --- /dev/null +++ b/hooks/context-window-overrides/models-json.ts @@ -0,0 +1,33 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +export interface ModelsJsonConfig { + providers: Record< + string, + { + baseUrl?: string; + apiKey?: string; + headers?: Record<string, string>; + modelOverrides?: Record< + string, + { contextWindow?: number; maxTokens?: number } + >; + [key: string]: unknown; + } + >; +} + +export function readModelsJson(path: string): ModelsJsonConfig { + if (!existsSync(path)) return { providers: {} }; + + try { + const config = JSON.parse(readFileSync(path, "utf-8")) as ModelsJsonConfig; + if (!config.providers) config.providers = {}; + return config; + } catch { + return { providers: {} }; + } +} + +export function writeModelsJson(path: string, config: ModelsJsonConfig): void { + writeFileSync(path, JSON.stringify(config, null, 2), "utf-8"); +} diff --git a/extensions/defaults/hooks/event-compat.ts b/hooks/event-compat/index.ts similarity index 91% rename from extensions/defaults/hooks/event-compat.ts rename to hooks/event-compat/index.ts index b7463fa5..795da070 100644 --- a/extensions/defaults/hooks/event-compat.ts +++ b/hooks/event-compat/index.ts @@ -1,5 +1,5 @@ +import { AD_NOTIFY_DANGEROUS_EVENT } from "@harness/events"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { AD_NOTIFY_DANGEROUS_EVENT } from "../../../packages/events"; type EventMapper = (data: unknown) => Record<string, unknown> | undefined; @@ -45,7 +45,7 @@ const BRIDGES: EventBridge[] = [ * Goal: keep one stable internal event API (`ad:*`) while allowing * backwards compatibility with older/public extension events. */ -export function setupEventCompatHook(pi: ExtensionAPI): void { +export default function (pi: ExtensionAPI): void { for (const bridge of BRIDGES) { pi.events.on(bridge.from, (data: unknown) => { const mapped = bridge.map(data); diff --git a/hooks/protect-sessions-dir/bash-parser.ts b/hooks/protect-sessions-dir/bash-parser.ts new file mode 100644 index 00000000..d2b7437f --- /dev/null +++ b/hooks/protect-sessions-dir/bash-parser.ts @@ -0,0 +1,167 @@ +/** + * Bash AST parsing and path extraction. + * + * Parses bash commands to find candidate file paths that reference + * the sessions directory. Used by the protect-sessions-dir hook + * to gate direct bash access to session files. + */ + +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import type { + Command, + DblQuoted, + Program, + Statement, + Word, + WordPart, +} from "@aliou/sh"; +import { parse } from "@aliou/sh"; +import type { SessionAccessRequest } from "./types"; + +/** + * Extract session-dir paths from a bash command string by parsing the AST. + */ +export function extractBashTargets( + command: string, + isInSessionsDir: (p: string) => boolean, +): SessionAccessRequest { + const paths = extractPathsFromBashCommand(command); + const sessionPaths = paths.filter(isInSessionsDir); + + if (sessionPaths.length > 0) { + return { targets: sessionPaths, displayTarget: command, ambiguous: false }; + } + + // Zero paths extracted — check for ambiguous references. + if (command.includes("/.pi/agent/sessions")) { + return { targets: [], displayTarget: command, ambiguous: true }; + } + + return { targets: [], displayTarget: "", ambiguous: false }; +} + +/** + * Parse a bash command and extract candidate file paths from the AST. + */ +function extractPathsFromBashCommand(command: string): string[] { + let ast: Program; + try { + ast = parse(command, { dialect: "bash" }).ast; + } catch { + return []; + } + + const candidates: string[] = []; + for (const stmt of ast.body) { + collectPathsFromStatement(stmt, candidates); + } + + const resolved: string[] = []; + for (const c of candidates) { + const expanded = c.startsWith("~") ? join(homedir(), c.slice(1)) : c; + if (isAbsolute(expanded)) resolved.push(resolve(expanded)); + } + return resolved; +} + +function collectPathsFromStatement(stmt: Statement, out: string[]): void { + collectPathsFromCommand(stmt.command, out); +} + +function collectPathsFromCommand(cmd: Command, out: string[]): void { + switch (cmd.type) { + case "SimpleCommand": { + const words = cmd.words ?? []; + // Skip first word — it's the command name. + for (let i = 1; i < words.length; i++) { + const word = words[i]; + if (!word) continue; + const reconstructed = reconstructWord(word); + if (reconstructed && looksLikePath(reconstructed)) + out.push(reconstructed); + } + // Redirect targets. + for (const redir of cmd.redirects ?? []) { + if (redir.target) { + const target = reconstructWord(redir.target); + if (target && looksLikePath(target)) out.push(target); + } + } + break; + } + case "Pipeline": + for (const sub of cmd.commands) collectPathsFromStatement(sub, out); + break; + case "Logical": + collectPathsFromStatement(cmd.left, out); + collectPathsFromStatement(cmd.right, out); + break; + case "Subshell": + case "Block": + for (const sub of cmd.body) collectPathsFromStatement(sub, out); + break; + case "IfClause": + for (const sub of cmd.then) collectPathsFromStatement(sub, out); + if (cmd.else) + for (const sub of cmd.else) collectPathsFromStatement(sub, out); + break; + case "WhileClause": + for (const sub of cmd.body) collectPathsFromStatement(sub, out); + break; + case "ForClause": + for (const sub of cmd.body) collectPathsFromStatement(sub, out); + break; + // Skip FunctionDecl, CaseClause, DeclClause, LetClause, + // CStyleLoop, TimeClause, TestClause, ArithCmd, CoprocClause, SelectClause. + } +} + +/** + * Reconstruct a Word into a plain string. + * Returns null if any part is unresolvable (ParamExp, CmdSubst, etc.). + */ +function reconstructWord(word: Word): string | null { + let result = ""; + for (const part of word.parts) { + const s = reconstructWordPart(part); + if (s === null) return null; + result += s; + } + return result; +} + +function reconstructWordPart(part: WordPart): string | null { + switch (part.type) { + case "Literal": + return part.value; + case "SglQuoted": + return part.value; + case "DblQuoted": + return reconstructDblQuoted(part); + case "ParamExp": + case "CmdSubst": + case "ArithExp": + case "ProcSubst": + return null; + default: + return null; + } +} + +function reconstructDblQuoted(part: DblQuoted): string | null { + let result = ""; + for (const sub of part.parts) { + const s = reconstructWordPart(sub); + if (s === null) return null; + result += s; + } + return result; +} + +/** + * Heuristic: does a string look like a file path? + */ +export function looksLikePath(s: string): boolean { + return s.startsWith("/") || s.startsWith("~") || s.includes("/"); +} diff --git a/hooks/protect-sessions-dir/gate.ts b/hooks/protect-sessions-dir/gate.ts new file mode 100644 index 00000000..c280a7d1 --- /dev/null +++ b/hooks/protect-sessions-dir/gate.ts @@ -0,0 +1,70 @@ +/** + * Session gate event emission and target extraction. + */ + +import { isAbsolute, resolve } from "node:path"; +import { AD_NOTIFY_ATTENTION_EVENT } from "@harness/events"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { extractBashTargets } from "./bash-parser"; +import { isInSessionsDir } from "./path-utils"; +import type { SessionAccessRequest } from "./types"; + +export const BLOCK_MESSAGE = + "Direct access to session files is restricted. " + + "Prefer find_sessions + read_session. " + + "Direct reads may be allowed via runtime toggle or explicit user confirmation."; + +export function emitSessionGateEvent( + pi: ExtensionAPI, + description: string, + command = "", + toolName?: string, + toolCallId?: string, +): void { + const payload = { + source: "breadcrumbs:protect-sessions-dir", + command, + description, + toolName, + toolCallId, + }; + pi.events.emit(AD_NOTIFY_ATTENTION_EVENT, payload); +} + +/** + * Extract session-dir targets from a tool call. + */ +export function extractSessionTargets( + toolName: string, + input: Record<string, unknown>, +): SessionAccessRequest { + if (toolName === "bash") { + return extractBashTargets(String(input.command ?? ""), isInSessionsDir); + } + + // File tools: read, write, edit + const rawPath = String(input.path ?? input.file_path ?? ""); + if (!rawPath) { + return { targets: [], displayTarget: "", ambiguous: false }; + } + + if (isAbsolute(rawPath)) { + const resolvedPath = resolve(rawPath); + if (isInSessionsDir(resolvedPath)) { + return { + targets: [resolvedPath], + displayTarget: resolvedPath, + ambiguous: false, + }; + } + return { targets: [], displayTarget: "", ambiguous: false }; + } + + // Relative path containing sessions dir reference — suspicious, block. + if (rawPath.includes("/.pi/agent/sessions")) { + return { targets: [], displayTarget: rawPath, ambiguous: true }; + } + + // Relative path without sessions dir reference — not gated. + return { targets: [], displayTarget: "", ambiguous: false }; +} diff --git a/extensions/breadcrumbs/hooks/protect-sessions-dir/index.test.ts b/hooks/protect-sessions-dir/index.test.ts similarity index 99% rename from extensions/breadcrumbs/hooks/protect-sessions-dir/index.test.ts rename to hooks/protect-sessions-dir/index.test.ts index 17d0b98c..6bd51190 100644 --- a/extensions/breadcrumbs/hooks/protect-sessions-dir/index.test.ts +++ b/hooks/protect-sessions-dir/index.test.ts @@ -1,12 +1,12 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; +import { AD_NOTIFY_ATTENTION_EVENT } from "@harness/events"; import type { ExtensionAPI, ExtensionContext, ToolCallEvent, } from "@mariozechner/pi-coding-agent"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AD_NOTIFY_ATTENTION_EVENT } from "../../../../packages/events"; import protectSessionsDirHook, { _resetForTesting } from "./index"; // --------------------------------------------------------------------------- diff --git a/hooks/protect-sessions-dir/index.ts b/hooks/protect-sessions-dir/index.ts new file mode 100644 index 00000000..b264d93f --- /dev/null +++ b/hooks/protect-sessions-dir/index.ts @@ -0,0 +1,118 @@ +/** + * Prevent direct agent access to the sessions directory. + * + * Gates read, write, edit, and bash commands that target session files. + * Agents should use find_sessions and read_session tools instead. + * + * Unified gating: both file tools and bash go through the same approval + * mechanism — `allowAll` flag and `approvedSubtrees` path set. + * write/edit are hard-blocked unconditionally. + */ + +import { dirname } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + BLOCK_MESSAGE, + emitSessionGateEvent, + extractSessionTargets, +} from "./gate"; +import { + approveSubtree, + getAllowAll, + isApprovedPath, + setAllowAll, +} from "./path-utils"; +import { SessionGateDialog } from "./session-gate-dialog"; +import type { SessionGateResult } from "./types"; + +export { _resetForTesting } from "./path-utils"; + +export default async function (pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + const input = event.input as Record<string, unknown>; + const request = extractSessionTargets(event.toolName, input); + + // 1. write/edit — hard-block unconditionally when targeting session dir + // Checked first so ambiguous write/edit paths are never silently allowed. + if (event.toolName === "write" || event.toolName === "edit") { + if (request.targets.length > 0 || request.ambiguous) { + emitSessionGateEvent( + pi, + `Blocked: direct session file ${event.toolName}`, + request.displayTarget, + event.toolName, + event.toolCallId, + ); + return { block: true, reason: BLOCK_MESSAGE }; + } + return; // Non-session write/edit — not gated. + } + + // 2. No targets, not ambiguous — nothing to gate for read/bash + if (request.targets.length === 0 && !request.ambiguous) return; + + // 3. Already approved + if (getAllowAll()) return; + if ( + request.targets.length > 0 && + request.targets.every((t) => isApprovedPath(t)) + ) + return; + + // 4. No UI — block + if (!ctx.hasUI) { + emitSessionGateEvent( + pi, + "Blocked: session access requires confirmation, but no UI is available", + request.displayTarget, + event.toolName, + event.toolCallId, + ); + return { + block: true, + reason: + "Direct access to session files requires explicit user confirmation, but no UI is available.", + }; + } + + // 5. Show dialog + const description = + event.toolName === "bash" + ? request.ambiguous + ? "may reference session files" + : "access session files via bash" + : "read a session file directly"; + + emitSessionGateEvent( + pi, + `Confirmation required: ${description}`, + request.displayTarget, + event.toolName, + event.toolCallId, + ); + + const decision = await ctx.ui.custom<SessionGateResult>( + (_tui, theme, _kb, done) => + new SessionGateDialog( + theme, + description, + request.displayTarget, + request.ambiguous, + done, + ), + ); + + const result: SessionGateResult = decision ?? "deny"; + + if (result === "deny") { + return { block: true, reason: "User denied session file access" }; + } + if (result === "allow-path") { + // Store parent directory of each target so sibling files are covered. + for (const t of request.targets) approveSubtree(dirname(t)); + } + if (result === "allow-all") setAllowAll(); + + return; // allow + }); +} diff --git a/hooks/protect-sessions-dir/path-utils.ts b/hooks/protect-sessions-dir/path-utils.ts new file mode 100644 index 00000000..7261c7df --- /dev/null +++ b/hooks/protect-sessions-dir/path-utils.ts @@ -0,0 +1,63 @@ +/** + * Session directory path utilities and approval state. + */ + +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Approval state (module scope, per Pi runtime) +// --------------------------------------------------------------------------- + +let allowAll = false; +const approvedSubtrees = new Set<string>(); + +/** @internal Reset approval state for testing. */ +export function _resetForTesting(): void { + allowAll = false; + approvedSubtrees.clear(); +} + +// --------------------------------------------------------------------------- +// Session dir helpers +// --------------------------------------------------------------------------- + +export function getSessionsDir(): string { + const agentDir = + process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); + return join(agentDir, "sessions"); +} + +/** + * Check if a resolved absolute path falls within the sessions directory. + */ +export function isInSessionsDir(path: string): boolean { + const sessionsDir = getSessionsDir(); + const absolutePath = resolve(path); + const rel = relative(sessionsDir, absolutePath); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +} + +/** + * Check if a path is covered by any approved subtree. + */ +export function isApprovedPath(targetPath: string): boolean { + if (allowAll) return true; + for (const approved of approvedSubtrees) { + const rel = relative(approved, resolve(targetPath)); + if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) return true; + } + return false; +} + +export function getAllowAll(): boolean { + return allowAll; +} + +export function setAllowAll(): void { + allowAll = true; +} + +export function approveSubtree(dir: string): void { + approvedSubtrees.add(dir); +} diff --git a/hooks/protect-sessions-dir/session-gate-dialog.ts b/hooks/protect-sessions-dir/session-gate-dialog.ts new file mode 100644 index 00000000..a83297c9 --- /dev/null +++ b/hooks/protect-sessions-dir/session-gate-dialog.ts @@ -0,0 +1,103 @@ +/** + * Session gate confirmation dialog. + * + * A TUI Component that asks the user to confirm or deny + * direct access to session files. + */ + +import type { Theme } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import type { Component } from "@mariozechner/pi-tui"; +import { + Container, + Key, + matchesKey, + Spacer, + Text, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; +import type { SessionGateResult } from "./types"; + +export class SessionGateDialog implements Component { + private readonly container = new Container(); + private readonly targetText: Text; + + constructor( + private readonly theme: Theme, + description: string, + private readonly target: string, + private readonly ambiguous: boolean, + private readonly done: (result: SessionGateResult) => void, + ) { + const warnBorder = (s: string) => theme.fg("warning", s); + const hintText = ambiguous + ? "y/enter: allow once | a: allow all session access | n/esc: deny" + : "y/enter: allow once | p: allow this directory for session | a: allow all session access | n/esc: deny"; + + this.container.addChild(new DynamicBorder(warnBorder)); + this.container.addChild( + new Text(theme.fg("warning", theme.bold("Session File Access")), 1, 0), + ); + this.container.addChild(new Spacer(1)); + this.container.addChild( + new Text( + theme.fg("text", `The agent is trying to ${description}.`), + 1, + 0, + ), + ); + this.container.addChild(new Spacer(1)); + + this.container.addChild( + new DynamicBorder((s: string) => theme.fg("muted", s)), + ); + this.targetText = new Text("", 1, 0); + this.container.addChild(this.targetText); + this.container.addChild( + new DynamicBorder((s: string) => theme.fg("muted", s)), + ); + + this.container.addChild(new Spacer(1)); + this.container.addChild( + new Text( + theme.fg("muted", "Prefer find_sessions + read_session instead."), + 1, + 0, + ), + ); + this.container.addChild(new Spacer(1)); + this.container.addChild(new Text(theme.fg("dim", hintText), 1, 0)); + this.container.addChild(new DynamicBorder(warnBorder)); + } + + render(width: number): string[] { + this.targetText.setText( + wrapTextWithAnsi(this.theme.fg("text", this.target), width - 4).join( + "\n", + ), + ); + return this.container.render(width); + } + + invalidate(): void { + this.container.invalidate(); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.enter) || data === "y" || data === "Y") { + this.done("allow-once"); + return; + } + if (!this.ambiguous && (data === "p" || data === "P")) { + this.done("allow-path"); + return; + } + if (data === "a" || data === "A") { + this.done("allow-all"); + return; + } + if (matchesKey(data, Key.escape) || data === "n" || data === "N") { + this.done("deny"); + } + } +} diff --git a/hooks/protect-sessions-dir/types.ts b/hooks/protect-sessions-dir/types.ts new file mode 100644 index 00000000..c49901ca --- /dev/null +++ b/hooks/protect-sessions-dir/types.ts @@ -0,0 +1,14 @@ +export type SessionAccessRequest = { + /** Absolute session-dir paths extracted from the tool call. */ + targets: string[]; + /** Path or command string shown in the dialog and events. */ + displayTarget: string; + /** True when no specific paths could be extracted (e.g. variable expansion). */ + ambiguous: boolean; +}; + +export type SessionGateResult = + | "allow-once" + | "allow-path" + | "allow-all" + | "deny"; diff --git a/hooks/session-autocomplete/db.ts b/hooks/session-autocomplete/db.ts new file mode 100644 index 00000000..cdcdcb0f --- /dev/null +++ b/hooks/session-autocomplete/db.ts @@ -0,0 +1,55 @@ +/** + * Sesame database access for session autocomplete. + */ + +import { join } from "node:path"; +import { getXDGPaths, openDatabase } from "@aliou/sesame"; +import type { ResolvedRef } from "./types"; + +export type SesameDb = NonNullable<ReturnType<typeof openDatabase>>; + +export function openSesameDb(): SesameDb | undefined { + try { + const paths = getXDGPaths(); + const dbPath = join(paths.data, "index.sqlite"); + return openDatabase(dbPath); + } catch { + return undefined; + } +} + +/** + * Resolve a session UUID to metadata via the DB directly. + * Returns null if the session is not found. + */ +export function resolveSessionRefFromDb( + db: SesameDb, + sessionId: string, +): ResolvedRef | null { + try { + const stmt = db.prepare( + "SELECT id, cwd, name, created_at, modified_at FROM sessions WHERE id = ?", + ); + const row = stmt.get(sessionId) as + | { + id: string; + cwd: string | null; + name: string | null; + created_at: string | null; + modified_at: string | null; + } + | undefined; + + if (!row) return null; + + return { + id: row.id, + name: row.name || "(untitled)", + cwd: row.cwd || "", + created: row.created_at || "", + modified: row.modified_at || "", + }; + } catch { + return null; + } +} diff --git a/hooks/session-autocomplete/index.ts b/hooks/session-autocomplete/index.ts new file mode 100644 index 00000000..a919f906 --- /dev/null +++ b/hooks/session-autocomplete/index.ts @@ -0,0 +1,88 @@ +/** + * `@@` session autocomplete provider. + * + * On `@@<token>` in the input editor, searches the Sesame index for sessions + * matching the token (or lists recent sessions for bare `@@`). On accept, the + * completion inserts `@@<uuid>`. The `@@<uuid>` marker stays in the user + * message and is resolved to hidden context in `before_agent_start`. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { openSesameDb, resolveSessionRefFromDb } from "./db"; +import { createSessionAutocompleteProvider } from "./provider"; +import { tildePath } from "./search"; +import { AT_UUID_RE, type ResolvedRef } from "./types"; + +/** Pending `@@<uuid>` refs resolved during `input`, consumed in `before_agent_start`. */ +// TODO: this is not needed, the before_agent_start includes the prompt, and so we can deduce the refs from there. +let pendingRefs: ResolvedRef[] = []; + +export default async function (pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + const currentSessionId = ctx.sessionManager.getSessionId(); + const cwd = ctx.cwd; + + ctx.ui.addAutocompleteProvider((current) => + createSessionAutocompleteProvider(current, cwd, currentSessionId), + ); + }); + + // On `input`, resolve `@@<uuid>` markers via DB + pi.on("input", async (event) => { + const text = event.text; + const db = openSesameDb(); + if (!db) { + pendingRefs = []; + return { action: "continue" } as const; + } + + try { + const refs: ResolvedRef[] = []; + const seen = new Set<string>(); + + const re = new RegExp(AT_UUID_RE.source, "g"); + let match: RegExpExecArray | null = re.exec(text); + while (match !== null) { + const sessionId = match[1]; + if (sessionId && !seen.has(sessionId)) { + seen.add(sessionId); + const ref = resolveSessionRefFromDb(db, sessionId); + if (ref) { + refs.push(ref); + } + } + match = re.exec(text); + } + + pendingRefs = refs; + } finally { + db.close(); + } + + // Text is NOT modified — `@@<uuid>` stays as-is in the user message + return { action: "continue" } as const; + }); + + // On `before_agent_start`, inject hidden context for resolved refs + pi.on("before_agent_start", async () => { + if (pendingRefs.length === 0) return; + + const lines = pendingRefs.map((ref) => { + const name = ref.name || "(untitled)"; + const cwdDisplay = tildePath(ref.cwd); + return `- session ${ref.id}: name="${name}", cwd=${cwdDisplay}, created=${ref.created}, modified=${ref.modified}\n Use read_session({ sessionId: "${ref.id}", goal: "..." }) to access its content.`; + }); + + const content = `The user referenced the following sessions:\n${lines.join("\n")}`; + + pendingRefs = []; + + return { + message: { + customType: "breadcrumbs:session-ref", + content, + display: false, + }, + } as const; + }); +} diff --git a/hooks/session-autocomplete/provider.ts b/hooks/session-autocomplete/provider.ts new file mode 100644 index 00000000..0bc934f0 --- /dev/null +++ b/hooks/session-autocomplete/provider.ts @@ -0,0 +1,145 @@ +/** + * Session autocomplete provider for `@@<token>` completion. + */ + +import type { SearchResult as SesameSearchResult } from "@aliou/sesame"; +import { search } from "@aliou/sesame"; +import type { + AutocompleteItem, + AutocompleteProvider, + AutocompleteSuggestions, +} from "@mariozechner/pi-tui"; +import { openSesameDb } from "./db"; +import { searchByName, timeAgo } from "./search"; +import { AT_TOKEN_RE, DEBOUNCE_MS, FTS_MIN_TOKEN_LEN } from "./types"; + +/** + * Extract the `@@<token>` at the end of `textBeforeCursor`. + * Returns the token (empty string for bare `@@`) or undefined if no match. + */ +function extractSessionToken(textBeforeCursor: string): string | undefined { + const match = textBeforeCursor.match(AT_TOKEN_RE); + return match ? (match[1] ?? "") : undefined; +} + +export function createSessionAutocompleteProvider( + current: AutocompleteProvider, + cwd: string, + currentSessionId: string, +): AutocompleteProvider { + // Debounce: incrementing generation counter. Only the latest call + // survives the debounce window. + let generation = 0; + + return { + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options, + ): Promise<AutocompleteSuggestions | null> { + const currentLine = lines[cursorLine] ?? ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + const token = extractSessionToken(textBeforeCursor); + + if (token === undefined) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + // Debounce: wait, then check if we're still the latest call + const thisGen = ++generation; + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS)); + + if (thisGen !== generation) { + return null; // superseded by a newer keystroke + } + + if (options.signal.aborted) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const db = openSesameDb(); + if (!db) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + try { + const query = token === "" ? "*" : token; + + // Use session-name LIKE for short tokens (FTS is ~10s for single chars) + const useFts = token === "" || token.length >= FTS_MIN_TOKEN_LEN; + const results = useFts + ? search(db, query, { cwd, limit: 20 }) + : searchByName(db, token, cwd, 20); + + if (options.signal.aborted) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + // Filter out current session from results + const filtered = results.filter( + (r: SesameSearchResult) => r.sessionId !== currentSessionId, + ); + + if (filtered.length === 0) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const items: AutocompleteItem[] = filtered.map( + (r: SesameSearchResult) => { + const name = r.name || "(untitled session)"; + const modified = r.modifiedAt || ""; + + return { + value: `@@${r.sessionId}`, + label: name, + description: modified ? timeAgo(modified) : undefined, + }; + }, + ); + + return { + items, + prefix: `@@${token}`, + }; + } catch { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } finally { + db.close(); + } + }, + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) { + return current.applyCompletion( + lines, + cursorLine, + cursorCol, + item, + prefix, + ); + }, + + shouldTriggerFileCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + ) { + // Don't trigger file completion when typing `@@` tokens + const currentLine = lines[cursorLine] ?? ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + if (extractSessionToken(textBeforeCursor) !== undefined) { + return false; + } + return ( + current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? + true + ); + }, + }; +} diff --git a/hooks/session-autocomplete/search.ts b/hooks/session-autocomplete/search.ts new file mode 100644 index 00000000..421875fc --- /dev/null +++ b/hooks/session-autocomplete/search.ts @@ -0,0 +1,76 @@ +/** + * Session search and display helpers. + */ + +import type { SearchResult as SesameSearchResult } from "@aliou/sesame"; +import type { SesameDb } from "./db"; + +/** + * Relative time string from an ISO date. + */ +export function timeAgo(isoDate: string): string { + const now = Date.now(); + const then = new Date(isoDate).getTime(); + if (Number.isNaN(then)) return ""; + const seconds = Math.floor((now - then) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(months / 12); + return `${years}y ago`; +} + +/** + * Collapse `$HOME` prefix to `~`. + */ +export function tildePath(p: string): string { + const home = process.env.HOME || ""; + if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; + return p; +} + +/** + * Search sessions by name using SQL LIKE. Fast alternative to FTS for + * short tokens (avoids 40K+ FTS matches for single characters). + */ +export function searchByName( + db: SesameDb, + token: string, + cwd: string, + limit: number, +): SesameSearchResult[] { + const stmt = db.prepare( + `SELECT id as sessionId, source, path, cwd, name, created_at as createdAt, modified_at as modifiedAt + FROM sessions + WHERE cwd LIKE ? AND name LIKE ? + ORDER BY modified_at DESC + LIMIT ?`, + ); + const rows = stmt.all(`${cwd}%`, `%${token}%`, limit) as Array<{ + sessionId: string; + source: string; + path: string; + cwd: string | null; + name: string | null; + createdAt: string | null; + modifiedAt: string | null; + }>; + + return rows.map((row) => ({ + sessionId: row.sessionId, + source: row.source, + path: row.path, + cwd: row.cwd, + name: row.name, + score: 0, + createdAt: row.createdAt, + modifiedAt: row.modifiedAt, + matchedSnippet: row.name || "(recent session)", + })); +} diff --git a/hooks/session-autocomplete/types.ts b/hooks/session-autocomplete/types.ts new file mode 100644 index 00000000..81060018 --- /dev/null +++ b/hooks/session-autocomplete/types.ts @@ -0,0 +1,20 @@ +export interface ResolvedRef { + id: string; + name: string; + cwd: string; + created: string; + modified: string; +} + +/** Match `@@` plus an optional token at end of text before cursor. */ +export const AT_TOKEN_RE = /@@([^\s@]*)$/; + +/** Match `@@<uuid>` markers anywhere in text. */ +export const AT_UUID_RE = + /@@([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; + +/** Debounce window for autocomplete searches (ms). */ +export const DEBOUNCE_MS = 150; + +/** Minimum token length to use FTS. Shorter tokens use name LIKE instead. */ +export const FTS_MIN_TOKEN_LEN = 3; diff --git a/hooks/session-title/index.ts b/hooks/session-title/index.ts new file mode 100644 index 00000000..265f8e4c --- /dev/null +++ b/hooks/session-title/index.ts @@ -0,0 +1,108 @@ +import { defineSubagent } from "@harness/agent-kit"; +import { isBlank } from "@harness/utils/string"; +import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { MODEL_CANDIDATES } from "./models"; +import { + buildPrompt, + SESSION_TITLE_SYSTEM_PROMPT, + type SessionTitleTurn, +} from "./prompt"; +import { createSessionTitleTools } from "./tools"; + +const MAX_TURNS = 5; + +export default async function sessionTitle(pi: ExtensionAPI): Promise<void> { + const subagent = defineSubagent(pi, { + name: "session_title", + label: "Session Title", + description: "Generate a concise session title.", + systemPrompt: SESSION_TITLE_SYSTEM_PROMPT, + tools: createSessionTitleTools(pi), + models: MODEL_CANDIDATES, + parameters: Type.Object({ + turns: Type.Array( + Type.Object({ + userMessage: Type.String(), + assistantResponse: Type.String(), + }), + ), + }), + buildPrompt: (params) => ({ text: buildPrompt(params) }), + }); + + pi.on("turn_end", async (event, ctx) => { + if (!isBlank(pi.getSessionName())) return; + + const message = event.message; + if (message.role !== "assistant") return; + if (message.stopReason !== "stop") return; + + const turns = getRecentTurns(ctx.sessionManager.getBranch()); + if (turns.length === 0) return; + + try { + ctx.ui.notify("Generating session title...", "info"); + + await subagent.execute( + "session-title", + { turns }, + ctx.signal, + undefined, + ctx, + ); + + const title = pi.getSessionName(); + if (!isBlank(title)) ctx.ui.notify(`Session title: ${title}`, "info"); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + ctx.ui.notify(`Session title generation failed: ${message}`, "error"); + } + }); +} + +function getRecentTurns(entries: SessionEntry[]): SessionTitleTurn[] { + const turns: SessionTitleTurn[] = []; + let currentUserMessage: string | null = null; + + for (const entry of entries) { + if (entry.type !== "message") continue; + + const message = entry.message; + if (message.role === "user") { + currentUserMessage = getUserText(message); + continue; + } + + if (message.role !== "assistant") continue; + if (!currentUserMessage) continue; + if (message.stopReason !== "stop") continue; + + const assistantResponse = getAssistantText(message as AssistantMessage); + if (isBlank(assistantResponse)) continue; + + turns.push({ + userMessage: currentUserMessage, + assistantResponse, + }); + } + + return turns.slice(-MAX_TURNS); +} + +function getUserText(message: UserMessage): string { + if (typeof message.content === "string") return message.content; + + return message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join(""); +} + +function getAssistantText(message: AssistantMessage): string { + return message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join(""); +} diff --git a/hooks/session-title/models/index.ts b/hooks/session-title/models/index.ts new file mode 100644 index 00000000..4175a540 --- /dev/null +++ b/hooks/session-title/models/index.ts @@ -0,0 +1,10 @@ +import type { SubagentModel } from "@harness/agent-kit/models"; + +export const MODEL_CANDIDATES: SubagentModel[] = [ + { + provider: "neuralwatt", + model: "glm-5-fast", + thinking: "off", + weight: 1, + }, +]; diff --git a/hooks/session-title/prompt.ts b/hooks/session-title/prompt.ts new file mode 100644 index 00000000..a69ea1d6 --- /dev/null +++ b/hooks/session-title/prompt.ts @@ -0,0 +1,32 @@ +export const SESSION_TITLE_SYSTEM_PROMPT = `You name Pi coding-agent sessions. + +You must call the set_title tool exactly once with a concise title for the provided exchange. +Do not produce normal assistant text. + +Title rules: +- 4-7 words when possible. +- Be specific to the user's task. +- No quotes. +- No markdown. +- No trailing punctuation.`; + +export interface SessionTitleTurn { + userMessage: string; + assistantResponse: string; +} + +export function buildPrompt(params: { turns: SessionTitleTurn[] }): string { + return params.turns + .map( + (turn, index) => `<turn index="${index + 1}"> +<user_message> +${turn.userMessage} +</user_message> + +<assistant_response> +${turn.assistantResponse} +</assistant_response> +</turn>`, + ) + .join("\n\n"); +} diff --git a/hooks/session-title/tools/index.ts b/hooks/session-title/tools/index.ts new file mode 100644 index 00000000..fea5d6d5 --- /dev/null +++ b/hooks/session-title/tools/index.ts @@ -0,0 +1,33 @@ +import type { SubagentToolSpec } from "@harness/agent-kit/types"; +import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; + +export function createSessionTitleTools(pi: ExtensionAPI): SubagentToolSpec[] { + return [ + { + name: "set_title", + type: "custom", + spec: () => + defineTool({ + name: "set_title", + label: "Set Title", + description: "Set the current Pi session title.", + parameters: Type.Object({ + title: Type.String({ + description: "The session title to set.", + }), + }), + + async execute(_toolCallId, params) { + const title = params.title.trim().slice(0, 80); + pi.setSessionName(title); + + return { + content: [{ type: "text" as const, text: title }], + details: { title }, + }; + }, + }), + }, + ]; +} diff --git a/hooks/session-title/tools/set-title.ts b/hooks/session-title/tools/set-title.ts new file mode 100644 index 00000000..e69de29b diff --git a/package.json b/package.json index a7447296..37f605d8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ }, "pi": { "extensions": [ - "./extensions" + "./commands", + "./hooks", + "./tools", + "./extensions/breadcrumbs", + "./extensions/providers" ] }, "scripts": { @@ -24,6 +28,9 @@ "postinstall": "./scripts/build-native-tools.sh" }, "dependencies": { + "@harness/agent-kit": "workspace:*", + "@harness/events": "workspace:*", + "@harness/utils": "workspace:*", "@aliou/pi-linkup": "^0.7.1", "@aliou/pi-utils-settings": "^0.11.2", "@aliou/pi-utils-ui": "^0.1.4", @@ -35,16 +42,18 @@ "yaml": "^2.8.2" }, "devDependencies": { + "@harness/test-utils": "workspace:*", "@aliou/biome-plugins": "^0.4.0", "@biomejs/biome": "^2.0.0", "@mariozechner/pi-agent-core": "0.69.0", "@mariozechner/pi-ai": "0.69.0", "@mariozechner/pi-coding-agent": "0.69.0", "@mariozechner/pi-tui": "0.69.0", - "typebox": "*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", + "@typescript/native-preview": "7.0.0-dev.20260501.1", "tsx": "^4.20.5", + "typebox": "*", "typescript": "^5.7.0", "vitest": "^4.0.18" }, diff --git a/packages/agent-kit/README.md b/packages/agent-kit/README.md deleted file mode 100644 index 6f5aec8a..00000000 --- a/packages/agent-kit/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# agent-kit - -Shared subagent infrastructure for my Pi harness. Import it via a normal relative or package path from the consumer, not via a root `package.json#imports` alias. - -## What's included - -- **`executeSubagent`** - Core executor with streaming, tool tracking, usage/cost accumulation, and injectable logging -- **`resolveModel`** - Resolve a model by provider + ID from the model registry -- **Types** - `SubagentToolCall`, `SubagentUsage`, `SubagentConfig`, `SubagentResult`, `BaseSubagentDetails`, etc. -- **Components** - `ToolDetails` and `ToolPreview` for rendering subagent tool results in the TUI - -## Usage - -```ts -import { - executeSubagent, - resolveModel, - ToolDetails, - ToolPreview, - type SubagentToolCall, - type SubagentUsage, -} from "../../../packages/agent-kit"; -``` - -## Logging - -The executor accepts an optional `createLogger` factory. If not provided (or if `config.logging.enabled` is false), no logging occurs. This keeps the package free of file I/O dependencies. - -```ts -const result = await executeSubagent( - config, - userMessage, - ctx, - onTextUpdate, - signal, - onToolUpdate, - async (opts) => createRunLogger(opts.cwd, opts.name, opts.debug), -); -``` - -## Components - -`ToolDetails` accepts a generic `Component` for its footer (not `SubagentFooter` specifically), so consumers can provide any footer implementation. diff --git a/packages/agent-kit/components/ToolDetails.ts b/packages/agent-kit/components/ToolDetails.ts deleted file mode 100644 index 1663de32..00000000 --- a/packages/agent-kit/components/ToolDetails.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { - Theme, - ToolRenderResultOptions, -} from "@mariozechner/pi-coding-agent"; -import type { Component } from "@mariozechner/pi-tui"; -import { Text } from "@mariozechner/pi-tui"; - -/** A field is either a plain label/value or a custom Component */ -export type ToolDetailsField = - | { label: string; value: string; showCollapsed?: boolean } - | (Component & { showCollapsed?: boolean }); - -export interface ToolDetailsConfig { - /** Fields to display when expanded */ - fields: ToolDetailsField[]; - /** Footer component -- always displayed */ - footer: Component; -} - -/** - * Collapsed: empty line + footer. - * Expanded: fields + empty line + footer. - */ -export class ToolDetails implements Component { - constructor( - private config: ToolDetailsConfig, - private options: ToolRenderResultOptions, - private theme: Theme, - ) {} - - handleInput(_data: string): boolean { - return false; - } - - invalidate(): void {} - - update(config: ToolDetailsConfig, options: ToolRenderResultOptions): void { - this.config = config; - this.options = options; - } - - render(width: number): string[] { - const lines: string[] = []; - const th = this.theme; - - const fieldsToRender = this.options.expanded - ? this.config.fields - : this.config.fields.filter( - (f) => "showCollapsed" in f && f.showCollapsed, - ); - - for (const field of fieldsToRender) { - if (isComponent(field)) { - lines.push(...field.render(width)); - } else { - const text = new Text( - `${th.fg("muted", `${field.label}: `)}${field.value}`, - 0, - 0, - ); - lines.push(...text.render(width)); - } - } - - lines.push(""); - lines.push(...this.config.footer.render(width)); - return lines; - } -} - -function isComponent(field: ToolDetailsField): field is Component { - return "render" in field && typeof (field as Component).render === "function"; -} diff --git a/packages/agent-kit/components/ToolPreview.ts b/packages/agent-kit/components/ToolPreview.ts deleted file mode 100644 index 28e4bc72..00000000 --- a/packages/agent-kit/components/ToolPreview.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Theme } from "@mariozechner/pi-coding-agent"; -import type { Component } from "@mariozechner/pi-tui"; -import { Text, TruncatedText } from "@mariozechner/pi-tui"; - -export interface ToolPreviewField { - label: string; - value: string; -} - -export interface ToolPreviewConfig { - title: string; - fields: ToolPreviewField[]; -} - -/** - * Renders: - * Title (bold, toolTitle color) - * Label: value (wraps if long) - * Label: value - */ -export class ToolPreview implements Component { - constructor( - private config: ToolPreviewConfig, - private theme: Theme, - ) {} - - handleInput(_data: string): boolean { - return false; - } - - invalidate(): void {} - - render(width: number): string[] { - const lines: string[] = []; - const th = this.theme; - - const title = new TruncatedText( - th.fg("toolTitle", th.bold(this.config.title)), - ); - lines.push(...title.render(width)); - - for (const field of this.config.fields) { - const prefix = `${th.fg("muted", `${field.label}: `)}`; - const text = new Text(prefix + field.value, 0, 0); - lines.push(...text.render(width)); - } - - return lines; - } -} diff --git a/packages/agent-kit/components/index.ts b/packages/agent-kit/components/index.ts deleted file mode 100644 index 0fe1a157..00000000 --- a/packages/agent-kit/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./ToolDetails"; -export * from "./ToolPreview"; diff --git a/packages/agent-kit/executor.ts b/packages/agent-kit/executor.ts deleted file mode 100644 index 93ee80ff..00000000 --- a/packages/agent-kit/executor.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Core subagent executor. - * - * Uses createAgentSession from the SDK for all subagent patterns. - * Supports streaming text updates, tool execution tracking, and usage tracking. - * Logging is injectable via the SubagentLogger interface. - */ - -import type { AssistantMessage } from "@mariozechner/pi-ai"; -import type { - CreateAgentSessionOptions, - ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { - createAgentSession, - DefaultResourceLoader, - getAgentDir, - SessionManager, - SettingsManager, -} from "@mariozechner/pi-coding-agent"; -import { generateRunId } from "./logging/paths"; -import { - createExecutionTimer, - markExecutionEnd, - markExecutionStart, -} from "./timing"; -import type { - OnTextUpdate, - OnToolUpdate, - SubagentConfig, - SubagentResult, - SubagentToolCall, - SubagentUsage, -} from "./types"; - -/** - * Injectable logger interface for subagent execution. - * Implementations can write to files, console, or no-op. - */ -export interface SubagentLogger { - /** Unique run identifier */ - runId: string; - /** Path to the human-readable stream log */ - streamPath: string; - /** Path to the debug JSONL log */ - debugPath: string; - /** Log a raw event (debug mode only) */ - logEventRaw(event: unknown): Promise<void>; - /** Log a text delta */ - logTextDelta(delta: string, accumulated: string): Promise<void>; - /** Log tool execution start */ - logToolStart(toolCall: SubagentToolCall): Promise<void>; - /** Log tool execution end */ - logToolEnd(toolCall: SubagentToolCall): Promise<void>; - /** Close the logger */ - close(): Promise<void>; -} - -/** - * Options for creating a subagent logger. - * Passed to the factory so implementations can set up logging as needed. - */ -export interface CreateLoggerOptions { - cwd: string; - name: string; - debug: boolean; -} - -/** - * Execute a subagent with the given configuration. - * - * @param config - Subagent configuration - * @param userMessage - The user's prompt - * @param ctx - Extension context - * @param onTextUpdate - Callback for streaming text - * @param signal - Abort signal - * @param onToolUpdate - Callback for tool execution updates - * @param createLogger - Optional factory to create a logger - */ -export async function executeSubagent( - config: SubagentConfig, - userMessage: string, - ctx: ExtensionContext, - onTextUpdate?: OnTextUpdate, - signal?: AbortSignal, - onToolUpdate?: OnToolUpdate, - createLogger?: (opts: CreateLoggerOptions) => Promise<SubagentLogger | null>, -): Promise<SubagentResult> { - let logger: SubagentLogger | null = null; - let runId: string; - - // Setup logging if enabled - if (config.logging?.enabled && createLogger) { - try { - logger = await createLogger({ - cwd: ctx.cwd, - name: config.name, - debug: config.logging.debug ?? false, - }); - runId = logger?.runId ?? generateRunId(config.name); - } catch (err) { - console.warn("Failed to create subagent logger:", err); - runId = generateRunId(config.name); - } - } else { - runId = generateRunId(config.name); - } - - const executionTimer = createExecutionTimer(); - - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(ctx.cwd, agentDir); - const resourceLoader = new DefaultResourceLoader({ - cwd: ctx.cwd, - agentDir, - settingsManager, - noExtensions: true, - noPromptTemplates: true, - noThemes: true, - noSkills: true, - systemPromptOverride: () => config.systemPrompt, - appendSystemPromptOverride: () => [], - agentsFilesOverride: () => ({ agentsFiles: [] }), - skillsOverride: () => ({ - skills: config.skills ?? [], - diagnostics: [], - }), - }); - await resourceLoader.reload(); - - // Only pass tools array if explicitly provided - // Undefined allows all tools; empty array [] blocks all tools - const sessionConfig: CreateAgentSessionOptions = { - model: config.model, - customTools: config.customTools ?? [], - sessionManager: SessionManager.inMemory(), - thinkingLevel: config.thinkingLevel ?? "low", - modelRegistry: ctx.modelRegistry, - resourceLoader, - }; - - if (config.tools !== undefined) { - sessionConfig.tools = config.tools; - } - - const { session } = await createAgentSession(sessionConfig); - - let accumulated = ""; - let finalResponse = ""; - let aborted = false; - const toolCalls = new Map<string, SubagentToolCall>(); - - let toolsHaveStarted = false; - let toolsHaveCompleted = false; - - const usage: SubagentUsage = { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - estimatedTokens: 0, - llmCost: 0, - toolCost: 0, - totalCost: 0, - }; - - const unsubscribe = session.subscribe((event) => { - if (logger && config.logging?.debug) { - logger.logEventRaw(event).catch(() => {}); - } - - if (event.type === "message_update") { - if (event.assistantMessageEvent.type === "text_delta") { - const delta = event.assistantMessageEvent.delta; - accumulated += delta; - - if (toolsHaveCompleted) { - finalResponse += delta; - } - - onTextUpdate?.(delta, accumulated); - logger?.logTextDelta(delta, accumulated).catch(() => {}); - } - } - - if (event.type === "tool_execution_start") { - toolsHaveStarted = true; - toolsHaveCompleted = false; - finalResponse = ""; - const toolCall: SubagentToolCall = { - toolCallId: event.toolCallId, - toolName: event.toolName, - args: event.args ?? {}, - status: "running", - }; - markExecutionStart(toolCall); - toolCalls.set(event.toolCallId, toolCall); - onToolUpdate?.([...toolCalls.values()]); - logger?.logToolStart(toolCall).catch(() => {}); - } - - if (event.type === "tool_execution_update") { - const existing = toolCalls.get(event.toolCallId); - if (existing) { - existing.args = event.args ?? existing.args; - if (event.partialResult) { - existing.partialResult = event.partialResult as { - content: Array<{ type: string; text?: string }>; - details?: unknown; - }; - } - onToolUpdate?.([...toolCalls.values()]); - } - } - - if (event.type === "tool_execution_end") { - const existing = toolCalls.get(event.toolCallId); - if (existing) { - existing.status = event.isError ? "error" : "done"; - existing.result = event.result; - markExecutionEnd(existing); - if (event.isError && event.result) { - existing.error = - typeof event.result === "string" - ? event.result - : JSON.stringify(event.result); - } - onToolUpdate?.([...toolCalls.values()]); - logger?.logToolEnd(existing).catch(() => {}); - - const resultDetails = event.result?.details as - | { cost?: number } - | undefined; - if (resultDetails?.cost !== undefined) { - usage.toolCost = (usage.toolCost ?? 0) + resultDetails.cost; - } - } - - const allDone = [...toolCalls.values()].every( - (tc) => tc.status === "done" || tc.status === "error", - ); - if (allDone) { - toolsHaveCompleted = true; - } - } - - if (event.type === "turn_end") { - const msg = event.message; - if (msg.role === "assistant") { - const assistantMsg = msg as AssistantMessage; - const msgUsage = assistantMsg.usage; - if (msgUsage) { - usage.inputTokens = (usage.inputTokens ?? 0) + msgUsage.input; - usage.outputTokens = (usage.outputTokens ?? 0) + msgUsage.output; - usage.cacheReadTokens = - (usage.cacheReadTokens ?? 0) + msgUsage.cacheRead; - usage.cacheWriteTokens = - (usage.cacheWriteTokens ?? 0) + msgUsage.cacheWrite; - usage.llmCost = (usage.llmCost ?? 0) + msgUsage.cost.total; - } - } - } - }); - - if (signal) { - if (signal.aborted) { - unsubscribe(); - session.dispose(); - await logger?.close().catch(() => {}); - return { - content: "", - aborted: true, - toolCalls: [], - totalDurationMs: executionTimer.getDurationMs(), - runId, - usage, - }; - } else { - signal.addEventListener( - "abort", - () => { - session.abort(); - aborted = true; - }, - { once: true }, - ); - } - } - - let error: string | undefined; - - try { - await session.prompt(userMessage); - } catch (err) { - if (signal?.aborted) { - aborted = true; - } else { - error = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : JSON.stringify(err); - } - } finally { - unsubscribe(); - session.dispose(); - await logger?.close().catch(() => {}); - } - - const responseText = toolsHaveStarted ? finalResponse : accumulated; - const cleanedContent = filterThinkingTags(responseText); - - const totalRealTokens = - (usage.inputTokens ?? 0) + - (usage.outputTokens ?? 0) + - (usage.cacheReadTokens ?? 0) + - (usage.cacheWriteTokens ?? 0); - usage.estimatedTokens = - totalRealTokens > 0 - ? totalRealTokens - : Math.round(cleanedContent.length / 4); - - usage.totalCost = (usage.llmCost ?? 0) + (usage.toolCost ?? 0); - - const result: SubagentResult = { - content: cleanedContent, - aborted, - toolCalls: [...toolCalls.values()], - totalDurationMs: executionTimer.getDurationMs(), - error, - runId, - usage, - }; - - if (logger) { - result.logFiles = { - stream: logger.streamPath, - debug: logger.debugPath, - }; - } - - return result; -} - -/** - * Filter out <thinking>...</thinking> tags from text. - */ -export function filterThinkingTags(text: string): string { - return text.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, ""); -} diff --git a/packages/agent-kit/index.ts b/packages/agent-kit/index.ts index 5911de39..fb7526f3 100644 --- a/packages/agent-kit/index.ts +++ b/packages/agent-kit/index.ts @@ -1,62 +1,95 @@ -/** - * @aliou/pi-agent-kit - * - * Shared subagent infrastructure for my Pi harness: - * - executeSubagent: core executor with streaming, tool tracking, and usage - * - resolveModel: model resolution by provider + ID - * - Logging: run logger, path utilities - * - Types: SubagentToolCall, SubagentUsage, SubagentConfig, SubagentResult, etc. - * - Components: ToolDetails, ToolPreview for rendering tool results - */ - -// Components -export { - ToolDetails, - type ToolDetailsConfig, - type ToolDetailsField, - ToolPreview, - type ToolPreviewConfig, - type ToolPreviewField, -} from "./components"; -// Executor -export { - type CreateLoggerOptions, - executeSubagent, - filterThinkingTags, - type SubagentLogger, -} from "./executor"; -// Logging -export { - createRunLogger, - generateRunId, - getLogDirectory, - sanitizePath, -} from "./logging"; -// Model resolution -export { resolveModel } from "./model-resolver"; -// Timing -export { - createExecutionTimer, - markExecutionEnd, - markExecutionStart, - type TimedExecution, -} from "./timing"; -// Tool wrappers -export { - type ToolTimingMeta, - type WrapToolDefinitionsWithTimingOptions, - wrapToolDefinitionsWithTiming, -} from "./tool-wrappers"; -// Types -export type { - BaseSubagentDetails, - OnTextUpdate, - OnToolUpdate, - SubagentConfig, - SubagentResponseDetails, - SubagentResult, - SubagentSkillDetails, - SubagentToolCall, - SubagentToolCallDetails, - SubagentUsage, -} from "./types"; +import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import type { Static, TSchema } from "typebox"; +import { SubagentModelResolver } from "./models"; +import { + renderSubagentCall, + renderSubagentResult, + SubagentRuntime, +} from "./runtime"; +import { + createResumeSubagentParamsSchema, + type ResumeSubagentParams, +} from "./schemas"; +import { SubagentSessionManager } from "./session-manager"; +import { SubagentSessionRecordStore } from "./session-records"; +import type { SubagentConfig } from "./types"; + +export function defineSubagent<Params extends TSchema>( + pi: ExtensionAPI, + config: SubagentConfig<Params>, +) { + const models = new SubagentModelResolver(config.models); + const records = new SubagentSessionRecordStore(pi); + const sessions = new SubagentSessionManager(config, models, records); + + const execute = async ( + toolCallId: string, + params: Static<Params>, + signal: AbortSignal | undefined, + onUpdate: Parameters<SubagentRuntime<Params>["execute"]>[2], + ctx: Parameters<SubagentRuntime<Params>["execute"]>[3], + ) => { + const invocationSkills = config.resolveSkills?.(params, ctx) ?? []; + return sessions.withNewSession(ctx, invocationSkills, async (session) => { + return new SubagentRuntime(config, session, signal).execute( + toolCallId, + params, + onUpdate, + ctx, + ); + }); + }; + + const tool = defineTool({ + name: config.name, + label: config.label, + description: config.description, + parameters: config.parameters, + renderCall: (args, theme, ctx) => + renderSubagentCall(config, args, theme, ctx), + renderResult: (result, options, theme, ctx) => + renderSubagentResult(result, options, theme, ctx), + execute, + }); + + const resumeTool = defineTool({ + name: `resume_${config.name}`, + label: `Resume ${config.label}`, + description: `Resume a previous ${config.label} session using its sessionId`, + parameters: createResumeSubagentParamsSchema(config.parameters), + renderCall: (args, theme, ctx) => + renderSubagentCall(config, args, theme, ctx), + renderResult: (result, options, theme, ctx) => + renderSubagentResult(result, options, theme, ctx), + + async execute( + toolCallId, + params: ResumeSubagentParams<Params>, + signal, + onUpdate, + ctx, + ) { + const { sessionId, ...restParams } = params; + const session = await sessions.resume(sessionId, ctx); + const runtime = new SubagentRuntime<Params>(config, session, signal); + return runtime.execute( + toolCallId, + restParams as Static<Params>, + onUpdate, + ctx, + ); + }, + }); + + const subscribe = (pi: ExtensionAPI) => { + pi.on("session_start", (event, ctx) => { + sessions.handleSessionStart(event, ctx); + }); + + pi.on("session_shutdown", () => { + sessions.handleSessionShutdown(); + }); + }; + + return { tool, resumeTool, execute, subscribe }; +} diff --git a/packages/agent-kit/logging/index.ts b/packages/agent-kit/logging/index.ts deleted file mode 100644 index d6935cf9..00000000 --- a/packages/agent-kit/logging/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { generateRunId, getLogDirectory, sanitizePath } from "./paths"; -export { createRunLogger } from "./run-logger"; diff --git a/packages/agent-kit/logging/paths.ts b/packages/agent-kit/logging/paths.ts deleted file mode 100644 index 83813db4..00000000 --- a/packages/agent-kit/logging/paths.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as crypto from "node:crypto"; -import * as os from "node:os"; -import * as path from "node:path"; - -/** - * Sanitize a path for use as a directory name. - * Mirrors Pi's session storage: /Users/foo/bar -> --Users-foo-bar-- - */ -export function sanitizePath(p: string): string { - const sanitized = p.replace(/[/\\]/g, "-"); - return `--${sanitized}--`; -} - -/** - * Generate a unique run ID. - * Format: <name>-<YYYYMMDD-HHMMSS>-<random6> - */ -export function generateRunId(subagentName: string): string { - const now = new Date(); - const timestamp = (now.toISOString().split(".")[0] ?? "").replace( - /[-:T]/g, - "", - ); - const formatted = timestamp.replace(/(\d{8})(\d{6})/, "$1-$2"); - const random = crypto.randomBytes(3).toString("hex"); - return `${subagentName}-${formatted}-${random}`; -} - -/** - * Get the log directory for a subagent run. - * - * Structure: ~/.pi/agent/subagents/<sanitized-cwd>/<subagent-name>/<run-id>/ - */ -export function getLogDirectory( - cwd: string, - subagentName: string, - runId: string, - agentDir?: string, -): string { - const baseDir = agentDir ?? path.join(os.homedir(), ".pi", "agent"); - const sanitizedCwd = sanitizePath(cwd); - return path.join(baseDir, "subagents", sanitizedCwd, subagentName, runId); -} diff --git a/packages/agent-kit/logging/run-logger.ts b/packages/agent-kit/logging/run-logger.ts deleted file mode 100644 index fa3d399c..00000000 --- a/packages/agent-kit/logging/run-logger.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import type { SubagentLogger } from "../executor"; -import type { SubagentToolCall } from "../types"; -import { generateRunId, getLogDirectory } from "./paths"; - -function formatTimestamp(): string { - const now = new Date(); - return `${now.toTimeString().split(" ")[0]}.${String(now.getMilliseconds()).padStart(3, "0")}`; -} - -class RunLoggerImpl implements SubagentLogger { - public readonly runId: string; - public readonly streamPath: string; - public readonly debugPath: string; - - private streamHandle: fs.FileHandle | null = null; - private debugHandle: fs.FileHandle | null = null; - private enableDebug: boolean; - private lastTextLength = 0; - - constructor(runId: string, logDir: string, enableDebug: boolean) { - this.runId = runId; - this.streamPath = path.join(logDir, "stream.log"); - this.debugPath = path.join(logDir, "debug.jsonl"); - this.enableDebug = enableDebug; - } - - async init(): Promise<void> { - const dir = path.dirname(this.streamPath); - await fs.mkdir(dir, { recursive: true }); - this.streamHandle = await fs.open(this.streamPath, "a"); - if (this.enableDebug) { - this.debugHandle = await fs.open(this.debugPath, "a"); - } - await this.writeStream(`[${formatTimestamp()}] Starting subagent\n`); - } - - async logTextDelta(_delta: string, accumulated: string): Promise<void> { - if (this.lastTextLength === 0 && accumulated.trim().length > 0) { - await this.writeStream(`[${formatTimestamp()}] Response:\n`); - } - this.lastTextLength = accumulated.length; - } - - async logToolStart(call: SubagentToolCall): Promise<void> { - const argsStr = - Object.keys(call.args).length > 0 - ? ` ${JSON.stringify(call.args).slice(0, 100)}` - : ""; - await this.writeStream( - `[${formatTimestamp()}] Tool: ${call.toolName}${argsStr}\n`, - ); - } - - async logToolEnd(call: SubagentToolCall): Promise<void> { - const status = call.status === "error" ? "error" : "completed"; - const errorSuffix = call.error ? ` - ${call.error.slice(0, 100)}` : ""; - await this.writeStream( - `[${formatTimestamp()}] Tool: ${call.toolName} ${status}${errorSuffix}\n`, - ); - } - - async logEventRaw(event: unknown): Promise<void> { - if (this.debugHandle) { - await this.debugHandle.write(`${JSON.stringify(event)}\n`); - } - } - - async close(): Promise<void> { - await this.writeStream(`[${formatTimestamp()}] Subagent finished\n`); - try { - await this.streamHandle?.close(); - } catch { - /* best effort */ - } - try { - await this.debugHandle?.close(); - } catch { - /* best effort */ - } - this.streamHandle = null; - this.debugHandle = null; - } - - private async writeStream(content: string): Promise<void> { - if (this.streamHandle) { - await this.streamHandle.write(content); - } - } -} - -/** - * Create a run logger for a subagent execution. - * Returns a SubagentLogger that writes to ~/.pi/agent/subagents/... - */ -export async function createRunLogger( - cwd: string, - subagentName: string, - enableDebug: boolean, -): Promise<SubagentLogger> { - const runId = generateRunId(subagentName); - const logDir = getLogDirectory(cwd, subagentName, runId); - const logger = new RunLoggerImpl(runId, logDir, enableDebug); - await logger.init(); - return logger; -} diff --git a/packages/agent-kit/model-resolver.ts b/packages/agent-kit/model-resolver.ts deleted file mode 100644 index 44895371..00000000 --- a/packages/agent-kit/model-resolver.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Model resolution helper for subagents. - * - * Resolves a model by provider + ID from the model registry. - */ - -import type { Model } from "@mariozechner/pi-ai"; -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; - -/** - * Find a model by provider and ID. - * - * @param provider - Provider name (e.g., "openrouter", "anthropic", "openai-codex") - * @param modelId - Model ID (e.g., "anthropic/claude-haiku-4.5") - * @param ctx - Extension context with modelRegistry - * @returns The resolved model - * @throws Error if model not found or API key not configured - */ -export function resolveModel( - provider: string, - modelId: string, - ctx: ExtensionContext, - // biome-ignore lint/suspicious/noExplicitAny: Model type requires any for generic API -): Model<any> { - const available = ctx.modelRegistry.getAvailable(); - const model = available.find( - (m) => m.id === modelId && m.provider === provider, - ); - - if (model) { - return model; - } - - // Check if the model exists but the API key is missing - const all = ctx.modelRegistry.getAll(); - const existsWithoutKey = all.some( - (m) => m.id === modelId && m.provider === provider, - ); - - if (existsWithoutKey) { - throw new Error( - `Model "${modelId}" exists on ${provider} but no valid API key is configured.`, - ); - } - - throw new Error(`Model "${modelId}" not found on provider "${provider}".`); -} diff --git a/packages/agent-kit/models/index.ts b/packages/agent-kit/models/index.ts new file mode 100644 index 00000000..d83b7f32 --- /dev/null +++ b/packages/agent-kit/models/index.ts @@ -0,0 +1,6 @@ +export { + isSubagentResolvedModel, + SubagentModelResolver, + type SubagentModelSelection, +} from "./model-resolver"; +export type { SubagentModel, SubagentResolvedModel } from "./types"; diff --git a/packages/agent-kit/models/model-resolver.ts b/packages/agent-kit/models/model-resolver.ts new file mode 100644 index 00000000..548a0073 --- /dev/null +++ b/packages/agent-kit/models/model-resolver.ts @@ -0,0 +1,105 @@ +import type { Api, Model, ThinkingLevel } from "@mariozechner/pi-ai"; +import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import type { SubagentModel, SubagentResolvedModel } from "./types"; + +export interface SubagentModelSelection { + model: Model<Api>; + thinkingLevel: ThinkingLevel; + record: SubagentResolvedModel; +} + +export function isSubagentResolvedModel( + data: unknown, +): data is SubagentResolvedModel { + return ( + typeof data === "object" && + data !== null && + "provider" in data && + "model" in data && + "thinkingLevel" in data && + typeof data.provider === "string" && + typeof data.model === "string" && + typeof data.thinkingLevel === "string" + ); +} + +export class SubagentModelResolver { + constructor(private candidates: SubagentModel[]) {} + + pick(modelRegistry: ModelRegistry): SubagentModelSelection | null { + const candidate = this.pickCandidate(modelRegistry); + if (!candidate) return null; + + return this.toSelection(candidate); + } + + resolve( + stored: SubagentResolvedModel | undefined, + modelRegistry: ModelRegistry, + ): SubagentModelSelection | null { + if (stored) { + const model = modelRegistry.find(stored.provider, stored.model); + if (model) { + return { + model, + thinkingLevel: stored.thinkingLevel, + record: stored, + }; + } + } + + return this.pick(modelRegistry); + } + + private pickCandidate( + modelRegistry: ModelRegistry, + ): { model: Model<Api>; config: SubagentModel } | null { + if (this.candidates.length === 0) return null; + + const totalWeight = this.candidates.reduce( + (sum, candidate) => sum + candidate.weight, + 0, + ); + let roll = Math.random() * totalWeight; + + for (const candidate of this.candidates) { + roll -= candidate.weight; + if (roll <= 0) { + const model = modelRegistry.find(candidate.provider, candidate.model); + if (model) return { model, config: candidate }; + } + } + + for (const candidate of this.candidates) { + const model = modelRegistry.find(candidate.provider, candidate.model); + if (model) return { model, config: candidate }; + } + + return null; + } + + private toSelection(selection: { + model: Model<Api>; + config: SubagentModel; + }): SubagentModelSelection { + const thinkingLevel = this.normalizeThinkingLevel( + selection.config.thinking, + ); + + return { + model: selection.model, + thinkingLevel, + record: { + provider: selection.config.provider, + model: selection.config.model, + thinkingLevel, + }, + }; + } + + private normalizeThinkingLevel( + thinkingLevel: ThinkingLevel | "off", + ): ThinkingLevel { + return thinkingLevel === "off" ? "low" : thinkingLevel; + } +} diff --git a/packages/agent-kit/models/types.ts b/packages/agent-kit/models/types.ts new file mode 100644 index 00000000..4b0d3793 --- /dev/null +++ b/packages/agent-kit/models/types.ts @@ -0,0 +1,14 @@ +import type { ThinkingLevel } from "@mariozechner/pi-ai"; + +export interface SubagentModel { + provider: string; + model: string; + thinking: ThinkingLevel | "off"; + weight: number; +} + +export interface SubagentResolvedModel { + provider: string; + model: string; + thinkingLevel: ThinkingLevel; +} diff --git a/packages/agent-kit/package.json b/packages/agent-kit/package.json new file mode 100644 index 00000000..a8bb35be --- /dev/null +++ b/packages/agent-kit/package.json @@ -0,0 +1,21 @@ +{ + "name": "@harness/agent-kit", + "private": true, + "type": "module", + "exports": { + ".": "./index.ts", + "./models": "./models/index.ts", + "./runtime": "./runtime/index.ts", + "./schemas": "./schemas/index.ts", + "./session-manager": "./session-manager/index.ts", + "./session-records": "./session-records/index.ts", + "./types": "./types.ts" + }, + "dependencies": { + "@harness/utils": "workspace:*", + "@mariozechner/pi-ai": "0.69.0", + "@mariozechner/pi-coding-agent": "0.69.0", + "@mariozechner/pi-tui": "0.69.0", + "typebox": "*" + } +} diff --git a/packages/agent-kit/resources/loader.ts b/packages/agent-kit/resources/loader.ts new file mode 100644 index 00000000..7e070c4e --- /dev/null +++ b/packages/agent-kit/resources/loader.ts @@ -0,0 +1,67 @@ +import { + createExtensionRuntime, + discoverAndLoadExtensions, + type LoadExtensionsResult, + type PromptTemplate, + type ResourceDiagnostic, + type ResourceLoader, + type Skill, + type Theme, +} from "@mariozechner/pi-coding-agent"; + +export class SubagentResourceLoader implements ResourceLoader { + private extensionsResult: LoadExtensionsResult = { + extensions: [], + errors: [], + runtime: createExtensionRuntime(), + }; + + constructor( + private cwd: string, + private agentDir: string, + private systemPrompt: string, + private skills: Skill[], + private extensionPaths: string[] = [], + ) {} + + getExtensions(): LoadExtensionsResult { + return this.extensionsResult; + } + + getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } { + return { skills: this.skills, diagnostics: [] }; + } + + getPrompts(): { + prompts: PromptTemplate[]; + diagnostics: ResourceDiagnostic[]; + } { + return { prompts: [], diagnostics: [] }; + } + + getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { + return { themes: [], diagnostics: [] }; + } + + getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } { + return { agentsFiles: [] }; + } + + getSystemPrompt(): string | undefined { + return this.systemPrompt; + } + + getAppendSystemPrompt(): string[] { + return []; + } + + extendResources() {} + + async reload(): Promise<void> { + this.extensionsResult = await discoverAndLoadExtensions( + this.extensionPaths, + this.cwd, + this.agentDir, + ); + } +} diff --git a/packages/agent-kit/runtime/content.ts b/packages/agent-kit/runtime/content.ts new file mode 100644 index 00000000..d7feb62b --- /dev/null +++ b/packages/agent-kit/runtime/content.ts @@ -0,0 +1,19 @@ +import type { TextContent } from "@mariozechner/pi-ai"; + +export function textContent(text: string): TextContent { + return { type: "text", text }; +} + +export function appendSubagentSessionFooter( + text: string, + toolName: string, + sessionId: string, +) { + return `${text} + +--- +Subagent session: +- sessionId: ${sessionId} + +To continue this session, call resume_${toolName} with this sessionId.`; +} diff --git a/packages/agent-kit/runtime/cost-tracker.ts b/packages/agent-kit/runtime/cost-tracker.ts new file mode 100644 index 00000000..e4e9713b --- /dev/null +++ b/packages/agent-kit/runtime/cost-tracker.ts @@ -0,0 +1,69 @@ +import type { AssistantMessage, Usage } from "@mariozechner/pi-ai"; +import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent"; + +type MessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>; + +export function emptyUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; +} + +export class SubagentCostTracker { + private usage = emptyUsage(); + private lastAssistantUsage = emptyUsage(); + + get value() { + return this.usage; + } + + get responseTokens() { + return this.lastAssistantUsage.output; + } + + update(event: MessageEndEvent) { + if (!isAssistantMessage(event.message)) return false; + + this.lastAssistantUsage = event.message.usage; + this.usage = addUsage(this.usage, event.message.usage); + return true; + } +} + +function isAssistantMessage(message: unknown): message is AssistantMessage { + return ( + typeof message === "object" && + message !== null && + "role" in message && + message.role === "assistant" && + "usage" in message + ); +} + +function addUsage(current: Usage, next: Usage): Usage { + return { + input: current.input + next.input, + output: current.output + next.output, + cacheRead: current.cacheRead + next.cacheRead, + cacheWrite: current.cacheWrite + next.cacheWrite, + totalTokens: current.totalTokens + next.totalTokens, + cost: { + input: current.cost.input + next.cost.input, + output: current.cost.output + next.cost.output, + cacheRead: current.cost.cacheRead + next.cost.cacheRead, + cacheWrite: current.cost.cacheWrite + next.cost.cacheWrite, + total: current.cost.total + next.cost.total, + }, + }; +} diff --git a/packages/agent-kit/runtime/index.ts b/packages/agent-kit/runtime/index.ts new file mode 100644 index 00000000..a8b196e7 --- /dev/null +++ b/packages/agent-kit/runtime/index.ts @@ -0,0 +1,9 @@ +export { renderSubagentCall, renderSubagentResult } from "./render"; +export { SubagentRuntime } from "./runtime"; +export type { + SubagentActivityItem, + SubagentDetails, + SubagentStatus, + SubagentToolCall, + SubagentToolCallStatus, +} from "./types"; diff --git a/packages/agent-kit/runtime/message-tracker.ts b/packages/agent-kit/runtime/message-tracker.ts new file mode 100644 index 00000000..547f7685 --- /dev/null +++ b/packages/agent-kit/runtime/message-tracker.ts @@ -0,0 +1,67 @@ +import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent"; +import type { SubagentActivityItem } from "./types"; + +type MessageUpdateEvent = Extract< + AgentSessionEvent, + { type: "message_update" } +>; + +export class SubagentMessageTracker { + thinking = false; + readonly activity: SubagentActivityItem[] = []; + private currentThinking?: Extract<SubagentActivityItem, { type: "thinking" }>; + + update(event: MessageUpdateEvent) { + const messageEvent = event.assistantMessageEvent; + + if (messageEvent.type === "thinking_start") { + this.startThinking(); + return true; + } + + if (messageEvent.type === "thinking_delta") { + this.appendThinking(messageEvent.delta); + return true; + } + + if (messageEvent.type === "thinking_end") { + this.stopThinking(messageEvent.content); + return true; + } + + return false; + } + + private startThinking() { + if (this.currentThinking) return; + + this.thinking = true; + this.currentThinking = { + type: "thinking", + startedAt: Date.now(), + endedAt: null, + content: "", + }; + this.activity.push(this.currentThinking); + } + + private appendThinking(delta: string) { + if (!this.currentThinking) { + this.startThinking(); + } + + if (!this.currentThinking) return; + this.currentThinking.content += delta; + } + + stopThinking(content?: string) { + this.thinking = false; + if (!this.currentThinking) return; + + if (content !== undefined) { + this.currentThinking.content = content; + } + this.currentThinking.endedAt = Date.now(); + this.currentThinking = undefined; + } +} diff --git a/packages/agent-kit/runtime/render/activity.ts b/packages/agent-kit/runtime/render/activity.ts new file mode 100644 index 00000000..c3baa33a --- /dev/null +++ b/packages/agent-kit/runtime/render/activity.ts @@ -0,0 +1,47 @@ +import { getMarkdownTheme, type Theme } from "@mariozechner/pi-coding-agent"; +import { Markdown, Text } from "@mariozechner/pi-tui"; +import type { SubagentToolCall } from "../types"; +import { extractParagraphs } from "./utils"; + +export function renderThinking( + running: boolean, + content: string, + theme: Theme, +) { + const indicator = running + ? theme.fg("accent", "・") + : theme.fg("success", "✓"); + const reasoning = extractParagraphs(content, 1) || "Thinking"; + return new Markdown(`${indicator} ${reasoning}`, 0, 0, getMarkdownTheme()); +} + +export function renderToolCall(toolCall: SubagentToolCall, theme: Theme) { + const indicator = formatToolCallIndicator(toolCall, theme); + return new Text( + `${indicator} ${theme.fg("toolTitle", toolCall.toolName)} ${theme.fg( + "toolOutput", + formatArgs(toolCall.args), + )}`, + 0, + 0, + ); +} + +function formatToolCallIndicator(toolCall: SubagentToolCall, theme: Theme) { + switch (toolCall.status) { + case "running": + return theme.fg("accent", "・"); + case "success": + return theme.fg("success", "✓"); + case "error": + return theme.fg("error", "✗"); + } +} + +function formatArgs(args: Record<string, unknown>) { + try { + return JSON.stringify(args); + } catch { + return "[unserializable args]"; + } +} diff --git a/packages/agent-kit/runtime/render/content.ts b/packages/agent-kit/runtime/render/content.ts new file mode 100644 index 00000000..f8dec045 --- /dev/null +++ b/packages/agent-kit/runtime/render/content.ts @@ -0,0 +1,139 @@ +import { isNotNil } from "@harness/utils"; +import type { + AgentToolResult, + ToolRenderResultOptions, +} from "@mariozechner/pi-coding-agent"; +import { getMarkdownTheme, type Theme } from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import type { SubagentDetails } from "../types"; +import { renderThinking, renderToolCall } from "./activity"; +import { formatCollapsedHint } from "./footer"; +import { Separator } from "./separator"; +import type { ToolRenderContext } from "./types"; + +export function renderSubagentResult( + result: AgentToolResult<unknown>, + options: ToolRenderResultOptions, + theme: Theme, + _ctx: ToolRenderContext, +) { + const container = new Container(); + container.addChild(new Spacer(1)); + + const details = result.details as SubagentDetails | undefined; + if (!details) { + const text = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + container.addChild( + new Text(theme.fg("muted", text || "Starting..."), 0, 0), + ); + return container; + } + + if (details.status === "running" || options.isPartial) { + container.addChild(renderRunning(details, options, theme)); + } + + container.addChild(renderFinished(details, options, theme)); + return container; +} + +function renderRunning( + details: SubagentDetails, + options: ToolRenderResultOptions, + theme: Theme, +) { + if (!options.expanded) { + const running = details.toolCalls.filter( + (toolCall) => toolCall.status === "running", + ).length; + const failed = details.toolCalls.filter( + (toolCall) => toolCall.status === "error", + ).length; + + const total = details.toolCalls.length; + + const result = [ + running + ? `${theme.fg("success", running.toString())} running` + : undefined, + failed ? `${theme.fg("error", failed.toString())} failed` : undefined, + total ? `${details.toolCalls.length} total` : undefined, + ] + .filter(isNotNil) + .join(", "); + + return new Text(result, 0, 0); + } + + const activity = renderActivity(details, theme); + + if (!activity) { + return new Text( + theme.fg("muted", "Waiting for subagent activity..."), + 0, + 0, + ); + } + + return activity; +} + +function renderActivity(details: SubagentDetails, theme: Theme) { + const toolCallsById = new Map( + details.toolCalls.map((toolCall) => [toolCall.toolCallId, toolCall]), + ); + const container = new Container(); + let renderedCount = 0; + + for (const item of details.activity) { + switch (item.type) { + case "thinking": + container.addChild( + renderThinking(item.endedAt === null, item.content, theme), + ); + renderedCount += 1; + break; + case "tool_call": { + const toolCall = toolCallsById.get(item.toolCallId); + if (!toolCall) break; + + container.addChild(renderToolCall(toolCall, theme)); + renderedCount += 1; + break; + } + default: + throw new Error(`Unknown activity type: ${item}`); + } + } + + if (renderedCount === 0) { + return undefined; + } + + return container; +} + +function renderFinished( + details: SubagentDetails, + options: ToolRenderResultOptions, + theme: Theme, +) { + const text = details.response ?? details.error ?? ""; + + const container = new Container(); + const activity = options.expanded + ? renderActivity(details, theme) + : undefined; + if (activity) { + container.addChild(activity); + container.addChild(new Separator(theme)); + } + + container.addChild(new Markdown(text, 0, 0, getMarkdownTheme())); + container.addChild(new Spacer(1)); + container.addChild(new Text(formatCollapsedHint(details, options), 0, 0)); + return container; +} diff --git a/packages/agent-kit/runtime/render/footer.ts b/packages/agent-kit/runtime/render/footer.ts new file mode 100644 index 00000000..8e0c9223 --- /dev/null +++ b/packages/agent-kit/runtime/render/footer.ts @@ -0,0 +1,31 @@ +import { isNotNil } from "@harness/utils"; +import type { ToolRenderResultOptions } from "@mariozechner/pi-coding-agent"; +import { keyHint } from "@mariozechner/pi-coding-agent"; +import type { SubagentDetails } from "../types"; +import { + formatCost, + formatDuration, + formatModel, + formatResponseTokens, +} from "./utils"; + +export function formatCollapsedHint( + details: SubagentDetails, + options: ToolRenderResultOptions, +) { + const hint = keyHint( + "app.tools.expand", + options.expanded ? "to collapse" : "to expand", + ); + const metadata = [ + hint, + formatModel(details.model), + formatDuration(details.startedAt, details.endedAt), + formatCost(details.usage.cost.total), + formatResponseTokens(details.responseTokens), + ] + .filter(isNotNil) + .join(" · "); + + return metadata; +} diff --git a/packages/agent-kit/runtime/render/header.ts b/packages/agent-kit/runtime/render/header.ts new file mode 100644 index 00000000..eb641b6c --- /dev/null +++ b/packages/agent-kit/runtime/render/header.ts @@ -0,0 +1,33 @@ +import { isNotNil, truncate } from "@harness/utils"; +import type { Theme } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import type { SubagentConfig } from "../../types"; +import type { ToolRenderContext } from "./types"; + +export function renderSubagentCall( + config: SubagentConfig, + args: Record<string, unknown>, + theme: Theme, + _ctx: ToolRenderContext, +) { + const displayArgs = Object.entries(args) + .filter(([key]) => key !== "sessionId") + .map( + ([key, value]) => + `${theme.fg("dim", key)}: ${truncate(String(value), 70)}`, + ) + .join(", "); + const sessionId = args.sessionId as string | undefined; + const isResuming = isNotNil(sessionId); + + const header = [ + theme.fg("toolTitle", theme.bold(`${config.label}`)), + displayArgs ? theme.fg("text", displayArgs) : undefined, + isResuming && + theme.fg("muted", `(Resuming session ${sessionId ?? "none"})`), + ] + .filter(Boolean) + .join(" "); + + return new Text(header, 0, 0); +} diff --git a/packages/agent-kit/runtime/render/index.ts b/packages/agent-kit/runtime/render/index.ts new file mode 100644 index 00000000..bf06a5b2 --- /dev/null +++ b/packages/agent-kit/runtime/render/index.ts @@ -0,0 +1,3 @@ +export { renderSubagentResult } from "./content"; +export { formatCollapsedHint } from "./footer"; +export { renderSubagentCall } from "./header"; diff --git a/packages/agent-kit/runtime/render/separator.ts b/packages/agent-kit/runtime/render/separator.ts new file mode 100644 index 00000000..339ec444 --- /dev/null +++ b/packages/agent-kit/runtime/render/separator.ts @@ -0,0 +1,12 @@ +import type { Theme } from "@mariozechner/pi-coding-agent"; +import type { Component } from "@mariozechner/pi-tui"; + +export class Separator implements Component { + constructor(private theme: Theme) {} + + render(width: number) { + return [this.theme.fg("muted", "─".repeat(width))]; + } + + invalidate() {} +} diff --git a/packages/agent-kit/runtime/render/types.ts b/packages/agent-kit/runtime/render/types.ts new file mode 100644 index 00000000..9ccd2335 --- /dev/null +++ b/packages/agent-kit/runtime/render/types.ts @@ -0,0 +1,8 @@ +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import type { SubagentDetails } from "../types"; + +export type ToolRenderContext = Parameters< + NonNullable< + ToolDefinition<Record<string, unknown>, SubagentDetails>["renderCall"] + > +>[2]; diff --git a/packages/agent-kit/runtime/render/utils.ts b/packages/agent-kit/runtime/render/utils.ts new file mode 100644 index 00000000..6053e18b --- /dev/null +++ b/packages/agent-kit/runtime/render/utils.ts @@ -0,0 +1,44 @@ +import type { Maybe } from "@harness/utils"; +import { isNil } from "@harness/utils"; +import type { SubagentModel } from "../../types"; + +export function extractParagraphs(content: string, count: number) { + return content + .trim() + .split(/\n\s*\n/) + .slice(0, count) + .join("\n\n") + .trim(); +} + +export function formatDuration( + startedAt: Maybe<number>, + endedAt: Maybe<number>, +) { + if (startedAt === null || endedAt === null) return undefined; + const seconds = Math.round((endedAt - startedAt) / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + return `${minutes}m ${remaining}s`; +} + +export function formatResponseTokens(tokens: number) { + if (tokens === 0) return undefined; + + return `${tokens.toLocaleString()} tokens`; +} + +export function formatCost(cost: number) { + if (cost === 0) return "$0"; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(2)}`; +} + +export function formatModel(model: SubagentModel | undefined) { + if (isNil(model)) { + return null; + } + + return `${model.provider}/${model.model}:${model.thinking}`; +} diff --git a/packages/agent-kit/runtime/runtime-state.ts b/packages/agent-kit/runtime/runtime-state.ts new file mode 100644 index 00000000..c5cd7a88 --- /dev/null +++ b/packages/agent-kit/runtime/runtime-state.ts @@ -0,0 +1,166 @@ +import type { + AgentSession, + AgentSessionEvent, +} from "@mariozechner/pi-coding-agent"; +import type { TSchema } from "typebox"; +import type { SubagentConfig } from "../types"; +import { emptyUsage, SubagentCostTracker } from "./cost-tracker"; +import { SubagentMessageTracker } from "./message-tracker"; +import { SubagentToolCallTracker } from "./tool-call-tracker"; +import type { SubagentDetails } from "./types"; + +export class SubagentRuntimeState<Params extends TSchema = TSchema> { + private messageTracker = new SubagentMessageTracker(); + private toolCallTracker = new SubagentToolCallTracker(); + private costTracker = new SubagentCostTracker(); + private details: SubagentDetails; + + constructor(config: SubagentConfig<Params>, session: AgentSession) { + this.details = { + sessionId: session.sessionId, + sessionFile: session.sessionFile ?? "", + prompt: "", + model: config.models.find( + (model) => + model.model === session.model?.id && + model.provider === session.model?.provider, + ), + + status: "pending", + thinking: false, + toolCalls: this.toolCallTracker.calls, + activity: [], + usage: emptyUsage(), + responseTokens: 0, + startedAt: null, + endedAt: null, + }; + } + + get value() { + return this.details; + } + + setPrompt(prompt: string) { + this.details.prompt = prompt; + } + + applyEvent(event: AgentSessionEvent) { + switch (event.type) { + case "message_update": { + const changed = this.messageTracker.update(event); + if (!changed) return false; + + this.syncActivity(); + this.details.thinking = this.messageTracker.thinking; + this.markRunning(); + return true; + } + + case "tool_execution_start": { + this.toolCallTracker.start(event); + this.syncActivity(); + this.markRunning(); + return true; + } + + case "tool_execution_end": { + this.toolCallTracker.end(event); + this.markRunning(); + return true; + } + + case "turn_end": { + this.stopThinking(); + this.syncActivity(); + this.markRunning(); + return true; + } + + case "message_end": { + const changed = this.costTracker.update(event); + if (!changed) return false; + + this.details.usage = this.costTracker.value; + this.details.responseTokens = this.costTracker.responseTokens; + return true; + } + + default: + return false; + } + } + + markSuccess(response: string) { + this.stopThinking(); + this.details = { + ...this.details, + response, + thinking: false, + status: "success", + endedAt: Date.now(), + }; + } + + markEmptyResponse() { + this.stopThinking(); + this.details = { + ...this.details, + error: "No response from subagent", + thinking: false, + status: "error", + endedAt: Date.now(), + }; + } + + markError(error: string) { + this.stopThinking(); + this.details = { + ...this.details, + status: "error", + thinking: false, + error, + endedAt: Date.now(), + }; + } + + markAborted() { + this.stopThinking(); + this.details = { + ...this.details, + thinking: false, + status: "aborted", + error: "Subagent aborted", + endedAt: Date.now(), + }; + } + + snapshot(): SubagentDetails { + return { + ...this.details, + toolCalls: [...this.details.toolCalls], + activity: [...this.details.activity], + usage: this.details.usage, + }; + } + + private syncActivity() { + this.details.activity = [ + ...this.messageTracker.activity, + ...this.toolCallTracker.activity, + ].sort((a, b) => a.startedAt - b.startedAt); + } + + private markRunning() { + if (this.details.startedAt === null) { + this.details.startedAt = Date.now(); + } + this.details.status = "running"; + } + + private stopThinking() { + this.messageTracker.stopThinking(); + this.details.thinking = false; + this.syncActivity(); + } +} diff --git a/packages/agent-kit/runtime/runtime.ts b/packages/agent-kit/runtime/runtime.ts new file mode 100644 index 00000000..6b2f85c1 --- /dev/null +++ b/packages/agent-kit/runtime/runtime.ts @@ -0,0 +1,127 @@ +import { isBlank } from "@harness/utils"; +import type { Optional } from "@harness/utils/types"; +import type { + AgentSession, + AgentSessionEvent, + AgentToolResult, + AgentToolUpdateCallback, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import type { Static, TSchema } from "typebox"; +import type { SubagentConfig } from "../types"; +import { appendSubagentSessionFooter, textContent } from "./content"; +import { SubagentRuntimeState } from "./runtime-state"; +import { formatSubagentStatus } from "./status"; +import type { SubagentDetails } from "./types"; + +export class SubagentRuntime<Params extends TSchema = TSchema> { + private unsubscribe?: () => void; + private state: SubagentRuntimeState; + + constructor( + private config: SubagentConfig<Params>, + private session: AgentSession, + private signal: Optional<AbortSignal>, + ) { + this.signal = signal; + this.signal?.addEventListener?.("abort", this.onAbort); + this.state = new SubagentRuntimeState(config, session); + } + + async execute( + _toolCallId: string, + params: Static<Params>, + onUpdate: Optional<AgentToolUpdateCallback<SubagentDetails>>, + ctx: ExtensionContext, + ): Promise<AgentToolResult<SubagentDetails>> { + try { + this.signal?.throwIfAborted(); + const promptResult = this.config.buildPrompt(params, ctx); + this.state.setPrompt(promptResult.text); + this.unsubscribe = this.session.subscribe((event) => { + this.handleEvent(event, onUpdate); + }); + + if (this.config.beforeExecute) { + try { + await this.config.beforeExecute(params, this.session, ctx); + } catch (err) { + throw new Error(`An error occureed during \`beforeExecute\`: ${err}`); + } + } + + await this.session.prompt(promptResult.text, { + images: promptResult.images, + }); + + if (this.signal?.aborted) { + this.state.markAborted(); + throw new Error(this.state.value.error ?? "Subagent aborted"); + } + + const response = this.session.getLastAssistantText(); + if (isBlank(response)) { + this.state.markEmptyResponse(); + } else { + this.state.markSuccess(response); + } + + return { + content: [ + textContent( + appendSubagentSessionFooter( + response ?? "", + this.config.name, + this.session.sessionId, + ), + ), + ], + details: this.state.snapshot(), + }; + } catch (err: unknown) { + if (this.signal?.aborted) { + this.state.markAborted(); + throw new Error(this.state.value.error ?? "Subagent aborted"); + } + + if (err instanceof Error) { + this.state.markError(err.message); + } else { + this.state.markError("Unknown error"); + } + + throw new Error(this.state.value.error ?? "Unknown error"); + } finally { + if (this.signal && !this.signal.aborted) { + this.signal.removeEventListener("abort", this.onAbort); + } + + this.unsubscribe?.(); + this.session.dispose(); + } + } + + private handleEvent( + event: AgentSessionEvent, + onUpdate: Optional<AgentToolUpdateCallback<SubagentDetails>>, + ) { + const changed = this.state.applyEvent(event); + if (!changed) return; + + this.emitUpdate(onUpdate); + } + + private emitUpdate( + onUpdate?: Optional<AgentToolUpdateCallback<SubagentDetails>>, + ) { + onUpdate?.({ + content: [textContent(formatSubagentStatus(this.state.value))], + details: this.state.snapshot(), + }); + } + + private onAbort = () => { + this.unsubscribe?.(); + this.session.abort(); + }; +} diff --git a/packages/agent-kit/runtime/status.ts b/packages/agent-kit/runtime/status.ts new file mode 100644 index 00000000..930b1c57 --- /dev/null +++ b/packages/agent-kit/runtime/status.ts @@ -0,0 +1,23 @@ +import type { SubagentDetails } from "./types"; + +const isSoleElement = <T>(arr: T[]): arr is [T] => arr.length === 1; + +export function formatSubagentStatus(details: SubagentDetails) { + if (details.thinking) { + return "Thinking..."; + } + + const runningTools = details.toolCalls.filter( + (toolCall) => toolCall.status === "running", + ); + + if (runningTools.length > 1) { + return `Running ${runningTools.length} tools`; + } + + if (isSoleElement(runningTools)) { + return `Running ${runningTools[0].toolName}`; + } + + return "Running..."; +} diff --git a/packages/agent-kit/runtime/tool-call-tracker.ts b/packages/agent-kit/runtime/tool-call-tracker.ts new file mode 100644 index 00000000..846b5e4e --- /dev/null +++ b/packages/agent-kit/runtime/tool-call-tracker.ts @@ -0,0 +1,49 @@ +import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent"; +import type { SubagentActivityItem, SubagentToolCall } from "./types"; + +type ToolExecutionStartEvent = Extract< + AgentSessionEvent, + { type: "tool_execution_start" } +>; + +type ToolExecutionEndEvent = Extract< + AgentSessionEvent, + { type: "tool_execution_end" } +>; + +export class SubagentToolCallTracker { + readonly calls: SubagentToolCall[] = []; + readonly activity: SubagentActivityItem[] = []; + private callsById = new Map<string, SubagentToolCall>(); + + start(event: ToolExecutionStartEvent) { + if (this.callsById.has(event.toolCallId)) return; + + const call: SubagentToolCall = { + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + status: "running", + startedAt: Date.now(), + endedAt: null, + error: null, + }; + + this.calls.push(call); + this.callsById.set(call.toolCallId, call); + this.activity.push({ + type: "tool_call", + toolCallId: call.toolCallId, + startedAt: call.startedAt, + }); + } + + end(event: ToolExecutionEndEvent) { + const call = this.callsById.get(event.toolCallId); + if (!call) return; + + call.status = event.isError ? "error" : "success"; + call.endedAt = Date.now(); + call.error = event.isError ? String(event.result) : null; + } +} diff --git a/packages/agent-kit/runtime/types.ts b/packages/agent-kit/runtime/types.ts new file mode 100644 index 00000000..fc7f6332 --- /dev/null +++ b/packages/agent-kit/runtime/types.ts @@ -0,0 +1,60 @@ +import type { Maybe } from "@harness/utils"; +import type { Usage } from "@mariozechner/pi-ai"; +import type { SubagentModel } from "../models"; + +export type SubagentToolCallStatus = "running" | "success" | "error"; + +export type SubagentActivityItem = + | { + type: "thinking"; + startedAt: number; + endedAt: Maybe<number>; + content: string; + } + | { + type: "tool_call"; + toolCallId: string; + startedAt: number; + }; + +export interface SubagentToolCall { + toolCallId: string; + toolName: string; + args: Record<string, unknown>; + + status: SubagentToolCallStatus; + + startedAt: number; + endedAt: Maybe<number>; + + error: Maybe<string>; +} + +export type SubagentStatus = + | "pending" + | "running" + | "success" + | "error" + | "aborted"; + +export interface SubagentDetails { + sessionId: string; + sessionFile: string; + + model?: SubagentModel; + prompt: string; + + status: SubagentStatus; + thinking: boolean; + + toolCalls: SubagentToolCall[]; + activity: SubagentActivityItem[]; + usage: Usage; + responseTokens: number; + + response?: string; + error?: string; + + startedAt: Maybe<number>; + endedAt: Maybe<number>; +} diff --git a/packages/agent-kit/schemas/index.ts b/packages/agent-kit/schemas/index.ts new file mode 100644 index 00000000..4c92cf66 --- /dev/null +++ b/packages/agent-kit/schemas/index.ts @@ -0,0 +1,14 @@ +import type { Static, TSchema } from "typebox"; +import Type from "typebox"; + +export function createResumeSubagentParamsSchema(parameters: TSchema) { + return Type.Interface([parameters], { + sessionId: Type.String({ + description: `Existing subagent session ID to resume. Use a sessionId returned by a previous call.`, + }), + }); +} + +export type ResumeSubagentParams<Params extends TSchema> = Static<Params> & { + sessionId: string; +}; diff --git a/packages/agent-kit/session-manager/index.ts b/packages/agent-kit/session-manager/index.ts new file mode 100644 index 00000000..93100768 --- /dev/null +++ b/packages/agent-kit/session-manager/index.ts @@ -0,0 +1 @@ +export { SubagentSessionManager } from "./session-manager"; diff --git a/packages/agent-kit/session-manager/session-manager.ts b/packages/agent-kit/session-manager/session-manager.ts new file mode 100644 index 00000000..9aa22768 --- /dev/null +++ b/packages/agent-kit/session-manager/session-manager.ts @@ -0,0 +1,244 @@ +import * as path from "node:path"; +import { isNil } from "@harness/utils/nil"; +import { + type AgentSession, + createAgentSession, + type ExtensionContext, + getAgentDir, + SessionManager, + type SessionStartEvent, + SettingsManager, + type Skill, +} from "@mariozechner/pi-coding-agent"; +import type { TSchema } from "typebox"; +import type { SubagentModelResolver, SubagentModelSelection } from "../models"; +import { SubagentResourceLoader } from "../resources/loader"; +import { + SUBAGENT_SESSION_CUSTOM_TYPE, + type SubagentSessionRecord, + type SubagentSessionRecordStore, +} from "../session-records"; +import type { SubagentConfig } from "../types"; + +export class SubagentSessionManager<Params extends TSchema = TSchema> { + private settingsManager = SettingsManager.inMemory({ + compaction: { enabled: false }, + }); + private sessionFilesById = new Map<string, string>(); + private subagentDir = path.join(getAgentDir(), "subagents"); + + constructor( + private config: SubagentConfig<Params>, + private models: SubagentModelResolver, + private records: SubagentSessionRecordStore, + ) {} + + async withNewSession<T>( + ctx: ExtensionContext, + invocationSkills: Skill[], + fn: (session: AgentSession) => Promise<T>, + ): Promise<T> { + const selection = this.pickModelOrThrow(ctx); + const parentSessionId = ctx.sessionManager.getSessionId(); + const sessionManager = this.createSessionManager(ctx.cwd); + const session = await this.createAgentSession( + ctx, + selection, + sessionManager, + invocationSkills, + ); + + try { + return await fn(session); + } finally { + this.records.append({ + type: SUBAGENT_SESSION_CUSTOM_TYPE, + name: this.config.name, + sessionId: session.sessionId, + sessionFile: session.sessionFile ?? "", + parentSessionId, + model: selection.record, + skills: invocationSkills, + }); + } + } + + async resume(sessionId: string, ctx: ExtensionContext) { + const record = this.records.findBySessionId( + ctx, + this.config.name, + sessionId, + ); + const selection = this.resolveModelOrThrow(ctx, record); + const sessionManager = this.openSessionManager(sessionId, record); + + return this.createAgentSession( + ctx, + selection, + sessionManager, + record?.skills ?? [], + ); + } + + handleSessionStart(evt: SessionStartEvent, ctx: ExtensionContext) { + if (evt.reason === "new" || evt.reason === "startup") { + this.sessionFilesById.clear(); + return; + } + + if (this.sessionFilesById.size > 0) { + ctx.ui.notify( + `[${this.config.name}] Subagent cache was not empty on session start; clearing`, + "warning", + ); + this.sessionFilesById.clear(); + } + + const records = this.records.findBySubagent(ctx, this.config.name); + + if (evt.reason === "fork") { + const sourceSessionId = this.getParentSessionId(ctx); + const sourceRecords = records + .filter((entry) => entry.data?.parentSessionId === sourceSessionId) + .map((entry) => entry.data) + .filter((data) => !isNil(data)); + + sourceRecords.forEach((record) => { + const sm = SessionManager.forkFrom( + record.sessionFile, + ctx.cwd, + this.subagentDir, + ); + const sessionFile = sm.getSessionFile() ?? ""; + + this.sessionFilesById.set(sm.getSessionId(), sessionFile); + this.records.append({ + type: SUBAGENT_SESSION_CUSTOM_TYPE, + name: this.config.name, + sessionId: sm.getSessionId(), + sessionFile, + parentSessionId: ctx.sessionManager.getSessionId(), + model: record.model, + skills: record.skills, + }); + }); + return; + } + + if (evt.reason === "reload" || evt.reason === "resume") { + const currentParentSessionId = ctx.sessionManager.getSessionId(); + const currentRecords = records + .filter( + (entry) => entry.data?.parentSessionId === currentParentSessionId, + ) + .map((entry) => entry.data) + .filter((data) => !isNil(data)); + + currentRecords.forEach((record) => { + this.sessionFilesById.set(record.sessionId, record.sessionFile); + }); + return; + } + + ctx.ui.notify( + `[${this.config.name}] Unknown session event, subagent cache not rebuilt`, + "warning", + ); + } + + handleSessionShutdown() { + this.sessionFilesById.clear(); + } + + private async createAgentSession( + ctx: ExtensionContext, + selection: SubagentModelSelection, + sessionManager: SessionManager, + invocationSkills: Skill[] = [], + ) { + const cwd = ctx.cwd; + const tools = this.config.tools.map((tool) => tool.name); + const customTools = this.config.tools + .filter((tool) => tool.type === "custom") + .map((tool) => tool.spec(cwd)); + + const resourceLoader = new SubagentResourceLoader( + cwd, + this.subagentDir, + this.config.systemPrompt, + [...(this.config.skills ?? []), ...invocationSkills], + this.config.extensionPaths, + ); + await resourceLoader.reload(); + + const { session } = await createAgentSession({ + cwd, + model: selection.model, + thinkingLevel: selection.thinkingLevel, + sessionManager, + tools, + customTools, + agentDir: this.subagentDir, + resourceLoader, + modelRegistry: ctx.modelRegistry, + settingsManager: this.settingsManager, + }); + + return session; + } + + private createSessionManager(cwd: string): SessionManager { + const session = SessionManager.create(cwd, this.subagentDir); + this.sessionFilesById.set( + session.getSessionId(), + session.getSessionFile() ?? "", + ); + return session; + } + + private openSessionManager( + sessionId: string, + record?: SubagentSessionRecord, + ): SessionManager { + const sessionFile = + this.sessionFilesById.get(sessionId) ?? record?.sessionFile; + if (isNil(sessionFile)) { + throw new Error(`Unknown session ${sessionId}`); + } + + this.sessionFilesById.set(sessionId, sessionFile); + return SessionManager.open(sessionFile); + } + + private pickModelOrThrow(ctx: ExtensionContext) { + const selection = this.models.pick(ctx.modelRegistry); + if (!selection) { + throw new Error(`No model available for ${this.config.label} subagent`); + } + + return selection; + } + + private resolveModelOrThrow( + ctx: ExtensionContext, + record?: SubagentSessionRecord, + ) { + const selection = this.models.resolve(record?.model, ctx.modelRegistry); + if (!selection) { + throw new Error(`No model available for ${this.config.label} subagent`); + } + + return selection; + } + + private getParentSessionId(ctx: ExtensionContext): string | undefined { + const parentSessionFile = ctx.sessionManager.getHeader()?.parentSession; + if (isNil(parentSessionFile)) return undefined; + + try { + return SessionManager.open(parentSessionFile).getSessionId(); + } catch { + return undefined; + } + } +} diff --git a/packages/agent-kit/session-records/index.ts b/packages/agent-kit/session-records/index.ts new file mode 100644 index 00000000..317ea63e --- /dev/null +++ b/packages/agent-kit/session-records/index.ts @@ -0,0 +1,5 @@ +export { SubagentSessionRecordStore } from "./record-store"; +export { + SUBAGENT_SESSION_CUSTOM_TYPE, + type SubagentSessionRecord, +} from "./types"; diff --git a/packages/agent-kit/session-records/record-store.ts b/packages/agent-kit/session-records/record-store.ts new file mode 100644 index 00000000..1ec75165 --- /dev/null +++ b/packages/agent-kit/session-records/record-store.ts @@ -0,0 +1,80 @@ +import type { + CustomEntry, + ExtensionAPI, + ExtensionContext, + SessionEntry, +} from "@mariozechner/pi-coding-agent"; +import { isSubagentResolvedModel } from "../models"; +import { + SUBAGENT_SESSION_CUSTOM_TYPE, + type SubagentSessionRecord, +} from "./types"; + +function isSubagentSessionRecordData( + data: unknown, +): data is SubagentSessionRecord { + if ( + typeof data !== "object" || + data === null || + !("type" in data) || + !("name" in data) || + !("sessionId" in data) || + !("sessionFile" in data) || + !("parentSessionId" in data) + ) { + return false; + } + + return ( + data.type === SUBAGENT_SESSION_CUSTOM_TYPE && + typeof data.name === "string" && + typeof data.sessionId === "string" && + typeof data.sessionFile === "string" && + typeof data.parentSessionId === "string" && + (!("model" in data) || isSubagentResolvedModel(data.model)) && + (!("skills" in data) || + (Array.isArray(data.skills) && + data.skills.every( + (s: unknown) => + typeof s === "object" && s !== null && "name" in s && "path" in s, + ))) + ); +} + +function isSubagentSessionRecordEntry( + entry: SessionEntry, +): entry is CustomEntry<SubagentSessionRecord> { + return ( + entry.type === "custom" && + entry.customType === SUBAGENT_SESSION_CUSTOM_TYPE && + isSubagentSessionRecordData(entry.data) + ); +} + +export class SubagentSessionRecordStore { + constructor(private pi: ExtensionAPI) {} + + append(record: SubagentSessionRecord) { + this.pi.appendEntry<SubagentSessionRecord>( + SUBAGENT_SESSION_CUSTOM_TYPE, + record, + ); + } + + findBySubagent(ctx: ExtensionContext, subagentName: string) { + return ctx.sessionManager + .getEntries() + .filter(isSubagentSessionRecordEntry) + .filter((entry) => entry.data?.name === subagentName); + } + + findBySessionId( + ctx: ExtensionContext, + subagentName: string, + sessionId: string, + ) { + return this.findBySubagent(ctx, subagentName).find( + (entry) => entry.data?.sessionId === sessionId, + )?.data; + } +} diff --git a/packages/agent-kit/session-records/types.ts b/packages/agent-kit/session-records/types.ts new file mode 100644 index 00000000..e0ba341c --- /dev/null +++ b/packages/agent-kit/session-records/types.ts @@ -0,0 +1,14 @@ +import type { Skill } from "@mariozechner/pi-coding-agent"; +import type { SubagentResolvedModel } from "../models"; + +export const SUBAGENT_SESSION_CUSTOM_TYPE = "subagent_session" as const; + +export interface SubagentSessionRecord { + type: typeof SUBAGENT_SESSION_CUSTOM_TYPE; + name: string; + sessionId: string; + sessionFile: string; + parentSessionId: string; + model?: SubagentResolvedModel; + skills?: Skill[]; +} diff --git a/packages/agent-kit/timing.ts b/packages/agent-kit/timing.ts deleted file mode 100644 index 6c925c97..00000000 --- a/packages/agent-kit/timing.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Shared timing utilities for tool and subagent execution. - */ - -/** Minimal shape that supports timing fields. */ -export interface TimedExecution { - startedAt?: number; - endedAt?: number; - durationMs?: number; -} - -/** Mark execution start time (epoch ms). */ -export function markExecutionStart<T extends TimedExecution>( - target: T, - startedAt = Date.now(), -): T { - target.startedAt = startedAt; - return target; -} - -/** Mark execution end time and compute duration (epoch ms / ms). */ -export function markExecutionEnd<T extends TimedExecution>( - target: T, - endedAt = Date.now(), -): T { - target.endedAt = endedAt; - if (target.startedAt !== undefined) { - target.durationMs = Math.max(0, endedAt - target.startedAt); - } - return target; -} - -/** Simple wall-clock timer for a full operation (e.g., subagent call). */ -export function createExecutionTimer(startedAt = Date.now()): { - startedAt: number; - getDurationMs: (endedAt?: number) => number; -} { - return { - startedAt, - getDurationMs: (endedAt = Date.now()) => Math.max(0, endedAt - startedAt), - }; -} diff --git a/packages/agent-kit/tool-wrappers.ts b/packages/agent-kit/tool-wrappers.ts deleted file mode 100644 index d15ebaf6..00000000 --- a/packages/agent-kit/tool-wrappers.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { - AgentToolResult, - ToolDefinition, -} from "@mariozechner/pi-coding-agent"; -import { markExecutionEnd, markExecutionStart } from "./timing"; - -export interface ToolTimingMeta { - startedAt: number; - endedAt: number; - durationMs: number; -} - -export interface WrapToolDefinitionsWithTimingOptions { - /** - * Key used on details object for timing metadata. - * Default: "__meta" - */ - detailsMetaKey?: string; -} - -/** - * Wrap ToolDefinition execute handlers and inject timing metadata into result details. - * - * Metadata shape: - * details[detailsMetaKey].timing = { startedAt, endedAt, durationMs } - */ -export function wrapToolDefinitionsWithTiming( - tools: ToolDefinition[], - options: WrapToolDefinitionsWithTimingOptions = {}, -): ToolDefinition[] { - const detailsMetaKey = options.detailsMetaKey ?? "__meta"; - - return tools.map((tool) => { - const originalExecute = tool.execute.bind(tool); - - return { - ...tool, - async execute(toolCallId, args, signal, onUpdate, ctx) { - const timing: Partial<ToolTimingMeta> = {}; - markExecutionStart(timing, Date.now()); - - const result = (await originalExecute( - toolCallId, - args, - signal, - onUpdate, - ctx, - )) as AgentToolResult<Record<string, unknown> | undefined>; - - markExecutionEnd(timing, Date.now()); - - return injectTimingIntoResult( - result, - timing as ToolTimingMeta, - detailsMetaKey, - ); - }, - } as ToolDefinition; - }); -} - -function injectTimingIntoResult( - result: AgentToolResult<Record<string, unknown> | undefined>, - timing: ToolTimingMeta, - detailsMetaKey: string, -): AgentToolResult<Record<string, unknown>> { - const details = result.details ?? {}; - const detailsRecord = details as Record<string, unknown>; - const existingMeta = - typeof detailsRecord[detailsMetaKey] === "object" && - detailsRecord[detailsMetaKey] !== null - ? (detailsRecord[detailsMetaKey] as Record<string, unknown>) - : {}; - - return { - ...result, - details: { - ...detailsRecord, - [detailsMetaKey]: { - ...existingMeta, - timing, - }, - }, - }; -} diff --git a/packages/agent-kit/types.ts b/packages/agent-kit/types.ts index 787a2821..e3270991 100644 --- a/packages/agent-kit/types.ts +++ b/packages/agent-kit/types.ts @@ -1,158 +1,45 @@ -import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Model } from "@mariozechner/pi-ai"; -import type { Skill, ToolDefinition } from "@mariozechner/pi-coding-agent"; - -/** - * Configuration for a subagent. - */ -export interface SubagentConfig { - /** Subagent name (for logging and run ID) */ +import type { ImageContent } from "@mariozechner/pi-ai"; +import type { + AgentSession, + ExtensionContext, + Skill, + ToolDefinition, +} from "@mariozechner/pi-coding-agent"; +import type { Static, TSchema } from "typebox"; +import type { SubagentModel } from "./models"; + +export type { SubagentModel } from "./models"; +export type { SubagentDetails, SubagentToolCall } from "./runtime"; +export type { SubagentSessionRecord } from "./session-records"; + +export type SubagentToolSpec = + | { name: string; type: "native" } + | { name: string; type: "custom"; spec: (cwd: string) => ToolDefinition }; + +export interface SubagentPromptResult { + text: string; + images?: ImageContent[]; +} + +export interface SubagentConfig<Params extends TSchema = TSchema> { name: string; - - /** Model instance to use */ - // biome-ignore lint/suspicious/noExplicitAny: Model type requires any for generic API - model: Model<any>; - - /** System prompt for the subagent */ + label: string; + description: string; systemPrompt: string; - - /** Built-in tool-name allowlist - e.g., ["read", "grep"] */ - tools?: string[]; - - /** Custom tools (ToolDefinition[]) - e.g., GitHub tools */ - customTools?: ToolDefinition[]; - - /** Skills to load into system prompt */ + tools: SubagentToolSpec[]; skills?: Skill[]; - - /** Thinking level. Default: "low" */ - thinkingLevel?: ThinkingLevel; - - /** Logging options */ - logging?: { - /** Enable logging. Default: false */ - enabled: boolean; - /** Include raw events in debug.jsonl. Default: false */ - debug?: boolean; - }; -} - -/** - * Tool call state for tracking subagent tool executions. - */ -export interface SubagentToolCall { - toolCallId: string; - toolName: string; - args: Record<string, unknown>; - status: "running" | "done" | "error"; - /** Epoch ms when tool execution started */ - startedAt?: number; - /** Epoch ms when tool execution ended */ - endedAt?: number; - /** Duration in milliseconds (set when ended) */ - durationMs?: number; - result?: unknown; - error?: string; - /** Partial result from tool updates (for progress display) */ - partialResult?: { - content: Array<{ type: string; text?: string }>; - details?: unknown; - }; -} - -/** - * Usage/cost information from the model response. - */ -export interface SubagentUsage { - /** Input tokens from API (if available) */ - inputTokens?: number; - /** Output tokens from API (if available) */ - outputTokens?: number; - /** Cache read tokens (if available) */ - cacheReadTokens?: number; - /** Cache write tokens (if available) */ - cacheWriteTokens?: number; - /** Estimated tokens from response length (chars/4) */ - estimatedTokens: number; - /** LLM cost in USD (if available) */ - llmCost?: number; - /** Tool/API cost in USD (e.g., Exa, GitHub) */ - toolCost?: number; - /** Total cost in USD (llmCost + toolCost) */ - totalCost?: number; -} - -/** - * Result from executing a subagent. - */ -export interface SubagentResult { - /** Final text content from the subagent */ - content: string; - - /** Whether the subagent was aborted */ - aborted: boolean; - - /** Final tool call states */ - toolCalls: SubagentToolCall[]; - - /** Total subagent execution duration in milliseconds */ - totalDurationMs: number; - - /** Error message if the subagent failed */ - error?: string; - - /** Unique run identifier */ - runId: string; - - /** Log file paths (if logging enabled) */ - logFiles?: { - stream: string; - debug: string; - }; - - /** Usage/cost information */ - usage: SubagentUsage; -} - -/** Callback for text streaming updates */ -export type OnTextUpdate = (delta: string, accumulated: string) => void; - -/** Callback for tool execution updates */ -export type OnToolUpdate = (toolCalls: SubagentToolCall[]) => void; - -// --------------------------------------------------------------------------- -// Shared detail interfaces - composed into BaseSubagentDetails -// --------------------------------------------------------------------------- - -/** Skill resolution state for rendering */ -export interface SubagentSkillDetails { - skills?: string[]; - skillsResolved?: number; - skillsNotFound?: string[]; -} - -/** Tool call tracking state for rendering */ -export interface SubagentToolCallDetails { - toolCalls: SubagentToolCall[]; -} - -/** Response / completion state for rendering */ -export interface SubagentResponseDetails { - response?: string; - aborted?: boolean; - error?: string; - usage?: SubagentUsage; - resolvedModel?: { provider: string; id: string }; - totalDurationMs?: number; -} - -/** - * Base details shared by all subagent tool renderers. - * Each subagent's Details type extends this with its own input-specific fields. - */ -export interface BaseSubagentDetails - extends SubagentSkillDetails, - SubagentToolCallDetails, - SubagentResponseDetails { - _renderKey?: string; + extensionPaths?: string[]; + models: SubagentModel[]; + + parameters: Params; + buildPrompt: ( + params: Static<Params>, + ctx: ExtensionContext, + ) => SubagentPromptResult; + resolveSkills?: (params: Static<Params>, ctx: ExtensionContext) => Skill[]; + beforeExecute?: ( + params: Static<Params>, + session: AgentSession, + ctx: ExtensionContext, + ) => Promise<void>; } diff --git a/packages/events/index.ts b/packages/events/index.ts index bab19295..4e075ecc 100644 --- a/packages/events/index.ts +++ b/packages/events/index.ts @@ -14,59 +14,3 @@ export const AD_PROVIDERS_CODEX_FAST_MODE_CHANGED_EVENT = export type AdProvidersCodexFastModeChangedEvent = { enabled: boolean; }; - -export const AD_MODES_READY_EVENT = "ad:modes:ready"; - -export type ModeColor = - | { source: "theme"; color: string } - | { source: "raw"; color: string }; - -export type BorderSlot = - | "top-start" - | "top-end" - | "bottom-start" - | "bottom-end"; - -export type BorderBand = "top" | "bottom"; - -export type EditorBorderWrite = - | { - kind: "slot"; - slot: BorderSlot; - text: string; - color?: ModeColor; - } - | { - kind: "band"; - band: BorderBand; - color: ModeColor; - }; - -export type AdEditorBorderDecorationChangedEvent = { - source: string; - writes: EditorBorderWrite[]; -}; - -export type AdEditorDraftChangedEvent = { - text: string; -}; - -export const AD_EDITOR_STASH_CHANGED_EVENT = "ad:editor:stash-changed"; -export const AD_EDITOR_STASH_READY_EVENT = "ad:editor:stash:ready"; -export const AD_EDITOR_STASH_REQUEST_EVENT = "ad:editor:stash:request"; - -export type AdEditorStashChangedEvent = { - count: number; -}; - -export const AD_EDITOR_BORDER_DECORATION_CHANGED_EVENT = - "ad:editor:border-decoration:changed"; -export const AD_EDITOR_READY_EVENT = "ad:editor:ready"; -export const AD_EDITOR_DRAFT_CHANGED_EVENT = "ad:editor:draft:changed"; - -export type AdEditorScrollOverflowEvent = { - top: number; - bottom: number; -}; - -export const AD_EDITOR_SCROLL_OVERFLOW_EVENT = "ad:editor:scroll-overflow"; diff --git a/packages/events/package.json b/packages/events/package.json new file mode 100644 index 00000000..e2daa98f --- /dev/null +++ b/packages/events/package.json @@ -0,0 +1,8 @@ +{ + "name": "@harness/events", + "private": true, + "type": "module", + "exports": { + ".": "./index.ts" + } +} diff --git a/tests/utils/load-extension.ts b/packages/test-utils/load-extension.ts similarity index 100% rename from tests/utils/load-extension.ts rename to packages/test-utils/load-extension.ts diff --git a/tests/utils/matchers.ts b/packages/test-utils/matchers.ts similarity index 100% rename from tests/utils/matchers.ts rename to packages/test-utils/matchers.ts diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 00000000..1dc6329e --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,17 @@ +{ + "name": "@harness/test-utils", + "private": true, + "type": "module", + "exports": { + "./load-extension": "./load-extension.ts", + "./matchers": "./matchers.ts", + "./pi-context": "./pi-context.ts", + "./pi-test-harness": "./pi-test-harness.ts", + "./theme": "./theme.ts", + "./tmpdir": "./tmpdir.ts" + }, + "dependencies": { + "@mariozechner/pi-coding-agent": "0.69.0", + "vitest": "^4.0.18" + } +} diff --git a/tests/utils/pi-context.ts b/packages/test-utils/pi-context.ts similarity index 100% rename from tests/utils/pi-context.ts rename to packages/test-utils/pi-context.ts diff --git a/tests/utils/pi-internal.d.ts b/packages/test-utils/pi-internal.d.ts similarity index 100% rename from tests/utils/pi-internal.d.ts rename to packages/test-utils/pi-internal.d.ts diff --git a/tests/utils/pi-test-harness.ts b/packages/test-utils/pi-test-harness.ts similarity index 100% rename from tests/utils/pi-test-harness.ts rename to packages/test-utils/pi-test-harness.ts diff --git a/tests/utils/theme.ts b/packages/test-utils/theme.ts similarity index 100% rename from tests/utils/theme.ts rename to packages/test-utils/theme.ts diff --git a/tests/utils/tmpdir.ts b/packages/test-utils/tmpdir.ts similarity index 100% rename from tests/utils/tmpdir.ts rename to packages/test-utils/tmpdir.ts diff --git a/packages/utils/array.test.ts b/packages/utils/array.test.ts new file mode 100644 index 00000000..3f83a704 --- /dev/null +++ b/packages/utils/array.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + chunksOf, + findFirst, + get, + isEmptyArray, + isNotEmptyArray, + partition, + pluck, + wrap, +} from "./array"; + +describe("array utilities", () => { + describe("findFirst", () => { + it("should return the first element that matches the predicate", () => { + const numbers = [1, 2, 3, 4, 5]; + const result = findFirst(numbers, (n) => n > 3); + expect(result).toBe(4); + }); + + it("should return undefined if no element matches", () => { + const numbers = [1, 2, 3]; + const result = findFirst(numbers, (n) => n > 5); + expect(result).toBeUndefined(); + }); + + it("should work with empty array", () => { + const result = findFirst([], (n) => n > 0); + expect(result).toBeUndefined(); + }); + }); + + describe("get", () => { + it("should return the value for matching key", () => { + const items = [ + { value: "a", label: "Apple" }, + { value: "b", label: "Banana" }, + ]; + const result = get(items, "a", "label"); + expect(result).toBe("Apple"); + }); + + it("should return undefined for non-matching value", () => { + const items = [ + { value: "a", label: "Apple" }, + { value: "b", label: "Banana" }, + ]; + const result = get(items, "c", "label"); + expect(result).toBeUndefined(); + }); + + it("should work with empty array", () => { + // @ts-expect-error + const result = get([], "a", "label"); + expect(result).toBeUndefined(); + }); + }); + + describe("isEmptyArray", () => { + it("should return true for null", () => { + expect(isEmptyArray(null)).toBe(true); + }); + + it("should return true for undefined", () => { + expect(isEmptyArray(undefined)).toBe(true); + }); + + it("should return true for empty array", () => { + expect(isEmptyArray([])).toBe(true); + }); + + it("should return false for non-empty array", () => { + expect(isEmptyArray([1, 2, 3])).toBe(false); + }); + }); + + describe("isNotEmptyArray", () => { + it("should return false for null", () => { + expect(isNotEmptyArray(null)).toBe(false); + }); + + it("should return false for undefined", () => { + expect(isNotEmptyArray(undefined)).toBe(false); + }); + + it("should return false for empty array", () => { + expect(isNotEmptyArray([])).toBe(false); + }); + + it("should return true for non-empty array", () => { + expect(isNotEmptyArray([1, 2, 3])).toBe(true); + }); + }); + + describe("partition", () => { + it("should partition array based on predicate", () => { + const numbers = [1, 2, 3, 4, 5, 6]; + const [even, odd] = partition(numbers, (n) => n % 2 === 0); + expect(even).toEqual([2, 4, 6]); + expect(odd).toEqual([1, 3, 5]); + }); + + it("should handle empty array", () => { + const [truthy, falsy] = partition([], (n) => n > 0); + expect(truthy).toEqual([]); + expect(falsy).toEqual([]); + }); + + it("should handle all truthy", () => { + const [truthy, falsy] = partition([1, 2, 3], (n) => n > 0); + expect(truthy).toEqual([1, 2, 3]); + expect(falsy).toEqual([]); + }); + + it("should handle all falsy", () => { + const [truthy, falsy] = partition([1, 2, 3], (n) => n > 10); + expect(truthy).toEqual([]); + expect(falsy).toEqual([1, 2, 3]); + }); + }); + + describe("wrap", () => { + it("should wrap single value in array", () => { + expect(wrap(5)).toEqual([5]); + expect(wrap("hello")).toEqual(["hello"]); + }); + + it("should return existing array as-is", () => { + const arr = [1, 2, 3]; + expect(wrap(arr)).toBe(arr); + }); + + it("should return empty array for undefined", () => { + expect(wrap(undefined)).toEqual([]); + }); + + it("should return empty array for null", () => { + expect(wrap(null)).toEqual([]); + }); + }); + + describe("pluck", () => { + it("should extract values by key", () => { + const objects = [ + { name: "Alice", age: 25 }, + { name: "Bob", age: 30 }, + { name: "Charlie", age: 35 }, + ]; + expect(pluck(objects, "name")).toEqual(["Alice", "Bob", "Charlie"]); + expect(pluck(objects, "age")).toEqual([25, 30, 35]); + }); + + it("should work with empty array", () => { + expect(pluck([], "name")).toEqual([]); + }); + }); + + describe("chunksOf", () => { + it("should split array into chunks of specified size", () => { + const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const chunks = chunksOf(numbers, 3); + expect(chunks).toEqual([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]); + }); + + it("should handle incomplete last chunk", () => { + const numbers = [1, 2, 3, 4, 5]; + const chunks = chunksOf(numbers, 2); + expect(chunks).toEqual([[1, 2], [3, 4], [5]]); + }); + + it("should handle empty array", () => { + expect(chunksOf([], 3)).toEqual([]); + }); + + it("should handle single element", () => { + expect(chunksOf([1], 3)).toEqual([[1]]); + }); + }); +}); diff --git a/packages/utils/array.ts b/packages/utils/array.ts new file mode 100644 index 00000000..51abadf3 --- /dev/null +++ b/packages/utils/array.ts @@ -0,0 +1,80 @@ +import { isNil } from "./nil"; + +/** + * Split an array into chunks of a given size. + * Vendored from radash (MIT license). + */ +const cluster = <T>(list: readonly T[], size = 2): T[][] => { + const clusterCount = Math.ceil(list.length / size); + return new Array(clusterCount) + .fill(null) + .map((_c: null, i: number) => list.slice(i * size, i * size + size)); +}; + +export const findFirst = <T>( + array: T[], + predicate: (value: T) => boolean, +): T | undefined => { + for (const value of array) { + if (predicate(value)) { + return value; + } + } + + return; +}; + +export const get = <T extends object & { value: unknown }>( + array: T[], + check: T["value"], + key: keyof T, +) => { + for (const item of array) { + if (item.value === check) { + return item[key]; + } + } + + return; +}; + +export const isEmptyArray = <T>(arg: T[] | null | undefined): arg is [] => { + return arg === null || arg === undefined || arg.length === 0; +}; + +export const isNotEmptyArray = <T>( + arg: T[] | null | undefined, +): arg is [T, ...T[]] => { + return arg !== null && arg !== undefined && arg.length > 0; +}; + +export const isSoleArray = <T>(arg: T[] | null | undefined): arg is [T] => { + return arg !== null && arg !== undefined && arg.length === 1; +}; + +export const partition = <T>( + array: T[], + fn: (item: T) => boolean, +): [T[], T[]] => { + const { truthy, falsy } = array.reduce( + (acc, item) => { + const key = fn(item) ? "truthy" : "falsy"; + acc[key].push(item); + return acc; + }, + { truthy: [] as T[], falsy: [] as T[] }, + ); + + return [truthy ?? [], falsy ?? []]; +}; + +export const wrap = <T>(value: T | T[] | undefined): T[] => + isNil(value) ? [] : Array.isArray(value) ? value : [value]; + +export const pluck = <T extends object, K extends keyof T>( + array: T[], + key: K, +): T[K][] => array.map((item) => item[key]); + +export const chunksOf = <T>(array: T[], size: number): T[][] => + cluster(array, size); diff --git a/packages/utils/formatters.test.ts b/packages/utils/formatters.test.ts new file mode 100644 index 00000000..70235e7f --- /dev/null +++ b/packages/utils/formatters.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatCurrency, + formatResetTime, + formatTimeRemaining, +} from "./formatters"; + +describe("formatter utilities", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-02T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("formatCurrency", () => { + it("formats USD minor units", () => { + expect(formatCurrency(1234)).toBe("$12.34"); + }); + + it("formats non-USD minor units", () => { + expect(formatCurrency(1234, "EUR")).toBe("12.34 EUR"); + }); + }); + + describe("formatTimeRemaining", () => { + it("formats null dates", () => { + expect(formatTimeRemaining(null)).toBe("Unknown"); + }); + + it("formats elapsed dates", () => { + expect(formatTimeRemaining(new Date("2026-05-02T11:59:00Z"))).toBe( + "soon", + ); + }); + + it("formats minutes", () => { + expect(formatTimeRemaining(new Date("2026-05-02T12:30:00Z"))).toBe("30m"); + }); + + it("formats hours and minutes", () => { + expect(formatTimeRemaining(new Date("2026-05-02T14:05:00Z"))).toBe( + "2h05m", + ); + }); + + it("formats days and hours", () => { + expect(formatTimeRemaining(new Date("2026-05-04T15:00:00Z"))).toBe( + "2d3h", + ); + }); + }); + + describe("formatResetTime", () => { + it("formats null dates", () => { + expect(formatResetTime(null)).toBe("Unknown"); + }); + + it("formats dates as lowercase display strings", () => { + expect(formatResetTime(new Date("2026-05-02T12:30:00Z"))).toBe( + formatResetTime(new Date("2026-05-02T12:30:00Z")).toLowerCase(), + ); + }); + }); +}); diff --git a/extensions/providers/lib/formatters.ts b/packages/utils/formatters.ts similarity index 100% rename from extensions/providers/lib/formatters.ts rename to packages/utils/formatters.ts diff --git a/packages/utils/index.ts b/packages/utils/index.ts new file mode 100644 index 00000000..d79189eb --- /dev/null +++ b/packages/utils/index.ts @@ -0,0 +1,10 @@ +export * from "./array"; +export { + formatCurrency, + formatResetTime, + formatTimeRemaining, +} from "./formatters"; +export { isNil, isNotNil } from "./nil"; +export { encodePathSegments } from "./path"; +export { isBlank, isPresent, truncate } from "./string"; +export type { Maybe, Optional } from "./types"; diff --git a/packages/utils/nil.test.ts b/packages/utils/nil.test.ts new file mode 100644 index 00000000..40d9ad99 --- /dev/null +++ b/packages/utils/nil.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { isNil, isNotNil } from "./nil"; + +describe("nil utilities", () => { + describe("isNil", () => { + it("should return true for null", () => { + expect(isNil(null)).toBe(true); + }); + + it("should return true for undefined", () => { + expect(isNil(undefined)).toBe(true); + }); + + it("should return false for falsy values that are not null/undefined", () => { + expect(isNil(false)).toBe(false); + expect(isNil(0)).toBe(false); + expect(isNil("")).toBe(false); + expect(isNil(Number.NaN)).toBe(false); + }); + + it("should return false for truthy values", () => { + expect(isNil(true)).toBe(false); + expect(isNil(1)).toBe(false); + expect(isNil("hello")).toBe(false); + expect(isNil([])).toBe(false); + expect(isNil({})).toBe(false); + }); + + it("should work with objects", () => { + expect(isNil({ key: "value" })).toBe(false); + }); + + it("should work with arrays", () => { + expect(isNil([1, 2, 3])).toBe(false); + expect(isNil([])).toBe(false); + }); + }); + + describe("isNotNil", () => { + it("should return false for null", () => { + expect(isNotNil(null)).toBe(false); + }); + + it("should return false for undefined", () => { + expect(isNotNil(undefined)).toBe(false); + }); + + it("should return true for falsy values that are not null/undefined", () => { + expect(isNotNil(false)).toBe(true); + expect(isNotNil(0)).toBe(true); + expect(isNotNil("")).toBe(true); + expect(isNotNil(Number.NaN)).toBe(true); + }); + + it("should return true for truthy values", () => { + expect(isNotNil(true)).toBe(true); + expect(isNotNil(1)).toBe(true); + expect(isNotNil("hello")).toBe(true); + expect(isNotNil([])).toBe(true); + expect(isNotNil({})).toBe(true); + }); + + it("should work with objects", () => { + expect(isNotNil({ key: "value" })).toBe(true); + }); + + it("should work with arrays", () => { + expect(isNotNil([1, 2, 3])).toBe(true); + expect(isNotNil([])).toBe(true); + }); + }); + + describe("type guards", () => { + it("should properly narrow types for isNil", () => { + const value: string | null | undefined = "hello"; + + if (isNil(value)) { + expect(value).toBeNull(); + } else { + expect(typeof value).toBe("string"); + } + }); + + it("should properly narrow types for isNotNil", () => { + const value: string | null | undefined = "hello"; + + if (isNotNil(value)) { + expect(typeof value).toBe("string"); + } else { + expect(value).toBeNull(); + } + }); + }); +}); diff --git a/packages/utils/nil.ts b/packages/utils/nil.ts new file mode 100644 index 00000000..580fa070 --- /dev/null +++ b/packages/utils/nil.ts @@ -0,0 +1,9 @@ +export const isNil = <T>( + arg: T | null | undefined, +): arg is null | undefined => { + return arg === null || arg === undefined; +}; + +export const isNotNil = <T>(arg: T | null | undefined): arg is T => { + return arg !== null && arg !== undefined; +}; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 00000000..6fcf3bff --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@harness/utils", + "private": true, + "type": "module", + "exports": { + ".": "./index.ts", + "./formatters": "./formatters.ts", + "./nil": "./nil.ts", + "./path": "./path.ts", + "./string": "./string.ts", + "./types": "./types.ts" + } +} diff --git a/packages/utils/path.test.ts b/packages/utils/path.test.ts new file mode 100644 index 00000000..b3ac68c8 --- /dev/null +++ b/packages/utils/path.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { encodePathSegments } from "./path"; + +describe("path utilities", () => { + describe("encodePathSegments", () => { + it("encodes each segment without escaping separators", () => { + expect(encodePathSegments("src/a file.ts")).toBe("src/a%20file.ts"); + }); + + it("preserves empty segments", () => { + expect(encodePathSegments("/docs//hello world.md")).toBe( + "/docs//hello%20world.md", + ); + }); + }); +}); diff --git a/packages/utils/path.ts b/packages/utils/path.ts new file mode 100644 index 00000000..b697e5f0 --- /dev/null +++ b/packages/utils/path.ts @@ -0,0 +1,6 @@ +export function encodePathSegments(path: string): string { + return path + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} diff --git a/packages/utils/string.test.ts b/packages/utils/string.test.ts new file mode 100644 index 00000000..1f4fecd9 --- /dev/null +++ b/packages/utils/string.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { isBlank, isPresent, truncate } from "./string"; + +describe("string utilities", () => { + describe("truncate", () => { + it("should truncate long strings", () => { + const result = truncate("This is a very long string", 10); + expect(result).toBe("This is a ..."); + }); + + it("should return original string if within limit", () => { + const result = truncate("Short", 10); + expect(result).toBe("Short"); + }); + + it("should handle exact length", () => { + const result = truncate("Exactly10!", 10); + expect(result).toBe("Exactly10!"); + }); + + it("should handle empty string", () => { + const result = truncate("", 10); + expect(result).toBe(""); + }); + + it("should handle zero max length", () => { + const result = truncate("Test", 0); + expect(result).toBe("..."); + }); + + it("should handle very short max length", () => { + const result = truncate("Test", 2); + expect(result).toBe("Te..."); + }); + + it("should handle negative max length", () => { + const result = truncate("Test", -5); + expect(result).toBe("..."); + }); + }); + + describe("isBlank", () => { + it("should return true for empty strings", () => { + expect(isBlank("")).toBe(true); + expect(isBlank(" ")).toBe(true); + expect(isBlank("\t\n\r")).toBe(true); + }); + + it("should return true for null and undefined", () => { + expect(isBlank(null)).toBe(true); + expect(isBlank(undefined)).toBe(true); + }); + + it("should return false for non-empty strings", () => { + expect(isBlank("hello")).toBe(false); + expect(isBlank("0")).toBe(false); + expect(isBlank("false")).toBe(false); + expect(isBlank(" hello ")).toBe(false); + }); + + it("should handle numbers", () => { + expect(isBlank(0)).toBe(false); + expect(isBlank(1)).toBe(false); + expect(isBlank(-1)).toBe(false); + expect(isBlank(Number.NaN)).toBe(false); + }); + + it("should handle mixed whitespace", () => { + expect(isBlank(" \t \n ")).toBe(true); + expect(isBlank(" hello ")).toBe(false); + }); + }); + + describe("isPresent", () => { + it("should return true for non-empty strings", () => { + expect(isPresent("hello")).toBe(true); + expect(isPresent("0")).toBe(true); + expect(isPresent("false")).toBe(true); + expect(isPresent(" hello ")).toBe(true); + }); + + it("should return false for empty strings", () => { + expect(isPresent("")).toBe(false); + expect(isPresent(" ")).toBe(false); + expect(isPresent("\t\n\r")).toBe(false); + }); + + it("should return false for null and undefined", () => { + expect(isPresent(null)).toBe(false); + expect(isPresent(undefined)).toBe(false); + }); + + it("should handle mixed whitespace", () => { + expect(isPresent(" \t \n ")).toBe(false); + expect(isPresent(" hello ")).toBe(true); + }); + + it("should return true for strings with only non-whitespace", () => { + expect(isPresent("a")).toBe(true); + expect(isPresent("1")).toBe(true); + expect(isPresent("!")).toBe(true); + }); + }); + + describe("type guards", () => { + it("should properly narrow types for isBlank", () => { + const value: string | null | undefined = ""; + + if (isBlank(value)) { + expect(value === "" || value === null || value === undefined).toBe( + true, + ); + } + }); + + it("should properly narrow types for isPresent", () => { + const value: string | null | undefined = "hello"; + + if (isPresent(value)) { + expect(value.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/packages/utils/string.ts b/packages/utils/string.ts new file mode 100644 index 00000000..730b9004 --- /dev/null +++ b/packages/utils/string.ts @@ -0,0 +1,23 @@ +import { isNil } from "./nil"; + +export const truncate = (input: string, maxLength: number): string => { + if (input.length <= maxLength) { + return input; + } + + return `${input.slice(0, maxLength)}...`; +}; + +const isString = (value: unknown): value is string => { + return typeof value === "string"; +}; + +export const isBlank = ( + value: string | undefined | null | number, +): value is "" | null | undefined => + isString(value) ? value.trim() === "" : isNil(value); + +type NonEmptyString<T extends string> = T extends "" ? never : T; +export const isPresent = ( + value: string | undefined | null, +): value is NonEmptyString<string> => !isBlank(value); diff --git a/packages/utils/types.ts b/packages/utils/types.ts new file mode 100644 index 00000000..ec69e9c3 --- /dev/null +++ b/packages/utils/types.ts @@ -0,0 +1,3 @@ +export type Maybe<T> = T | null; + +export type Optional<T> = T | null | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8758804b..0f48b870 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: '@aliou/sh': specifier: ^0.1.0 version: 0.1.0 + '@harness/agent-kit': + specifier: workspace:* + version: link:packages/agent-kit + '@harness/events': + specifier: workspace:* + version: link:packages/events + '@harness/utils': + specifier: workspace:* + version: link:packages/utils better-sqlite3: specifier: ^12.6.2 version: 12.6.2 @@ -48,6 +57,9 @@ importers: '@biomejs/biome': specifier: ^2.0.0 version: 2.3.11 + '@harness/test-utils': + specifier: workspace:* + version: link:packages/test-utils '@mariozechner/pi-agent-core': specifier: 0.69.0 version: 0.69.0(ws@8.19.0)(zod@3.25.76) @@ -66,6 +78,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.7 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260501.1 + version: 7.0.0-dev.20260501.1 tsx: specifier: ^4.20.5 version: 4.21.0 @@ -79,6 +94,37 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + packages/agent-kit: + dependencies: + '@harness/utils': + specifier: workspace:* + version: link:../utils + '@mariozechner/pi-ai': + specifier: 0.69.0 + version: 0.69.0(ws@8.19.0)(zod@3.25.76) + '@mariozechner/pi-coding-agent': + specifier: 0.69.0 + version: 0.69.0(ws@8.19.0)(zod@3.25.76) + '@mariozechner/pi-tui': + specifier: 0.69.0 + version: 0.69.0 + typebox: + specifier: '*' + version: 1.1.31 + + packages/events: {} + + packages/test-utils: + dependencies: + '@mariozechner/pi-coding-agent': + specifier: 0.69.0 + version: 0.69.0(ws@8.19.0)(zod@3.25.76) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/utils: {} + packages: '@aliou/biome-plugins@0.4.0': @@ -985,6 +1031,53 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-OIYsqKouI2U7W5Q6VgUz7+t9FpIXNFk30xSUG7gGlN1bdDniWfW7t5n6mzEtiHUVTxRgJQBjXGAlhVa6A9h+pg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-hQ5UsEyOz3ErQE3sKKHMCfJJGQenD0DSCi2ob+ywElXirG2NyFNA8cmx1g+MIm1lpQeEQslWZhe9EGwo9DJAbg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-fbaFKE1UvtsQ6i1eJjBiNbglR9ywXrW/CH1sqYPEtr0WgTUpixbE6inQOXjB0jlEA9RzQq+QMzDyaCDmU82Dkw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-agkTW/t85XSJKWGcXdUV9ZmSi3Akh3POK+HhWehigEJR3W/jebiO9njifETfoUF6cpoYkFn+CZvfAJ00IWGZfA==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-Sd8D+S88P7K0IH1U+a8pK20ZD+GM54t48/GLw9ebSklfCdt0iKdHgprjKIcl54C3SocGCcvEBPr1thwtTO9Vtg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-07sJNDnU7KHfo/trv/cBXpgFBELDYJAsTx5kNvBckSQUxbX+p/b9oQ3eFbtK3zDP4EEKdeiD9EelIy22atBnzA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-8rzd/eQZyBuR+IRiPnIQrCwSuXIGBFiL8LsUMFqQt2WAUlQ0gGWBlLJHUVU4YNlju9QROjNHUGpJ52XGZbFv0Q==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260501.1': + resolution: {integrity: sha512-skD0ig8IzPwSY1L8VmNgfaxkfT8ImBwKeIypfZyJA+zHzWvroRKbRbT2GryOSREl22ZqLOuDfcq+7BdA0rjF2Q==} + engines: {node: '>=16.20.0'} + hasBin: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -3145,6 +3238,37 @@ snapshots: '@types/node': 22.19.7 optional: true + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260501.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260501.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260501.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260501.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260501.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260501.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260501.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260501.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260501.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260501.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260501.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260501.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260501.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260501.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260501.1 + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..07da0b21 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "." + - "packages/*" diff --git a/scripts/build-native-tools.sh b/scripts/build-native-tools.sh index 0adba61d..2d0f243f 100755 --- a/scripts/build-native-tools.sh +++ b/scripts/build-native-tools.sh @@ -24,7 +24,7 @@ if ! command -v swiftc &> /dev/null; then exit 0 fi -TOOLS_DIR="extensions/chrome/native" +TOOLS_DIR="hooks/chrome/native" OUTPUT_DIR="bin" # Create output directory diff --git a/tests/README.md b/tests/README.md index 1c7b1a19..35ff3e3a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,7 +5,7 @@ Test utilities for pi-harness extensions. Uses real Pi internals (`SessionManage ## Quick start ```ts -import { createPiTestHarness, type PiTestHarness } from "tests/utils/pi-test-harness"; +import { createPiTestHarness, type PiTestHarness } from "@harness/test-utils/pi-test-harness"; import { setupMyTool } from "./my-tool"; let pi: PiTestHarness; @@ -30,10 +30,10 @@ const result = await pi.tool("read").execute({ path: "file.txt" }); expect(result.content).toBeDefined(); ``` -Access `renderCall` and `renderResult` on the `registered` definition for testing tool UI rendering. Use `NOOP_THEME` from `tests/utils/theme.ts` when calling render functions: +Access `renderCall` and `renderResult` on the `registered` definition for testing tool UI rendering. Use `NOOP_THEME` from `@harness/test-utils/theme` when calling render functions: ```ts -import { NOOP_THEME } from "tests/utils/theme"; +import { NOOP_THEME } from "@harness/test-utils/theme"; const { registered } = pi.tool("read"); const rendered = registered.renderCall({ path: "file.txt" }, NOOP_THEME); @@ -108,13 +108,13 @@ Loaded automatically via `tests/vitest.setup.ts`. Available on any `PiTestHarnes | File | Purpose | |---|---| -| `utils/pi-test-harness.ts` | Main entry point. Creates the harness, loads the extension, exposes command/tool executors and the `newSession` spy. | -| `utils/pi-context.ts` | Builds spy-based `ExtensionCommandContext` and tool context objects. All methods are `vi.fn()` with safe defaults. | -| `utils/matchers.ts` | Custom vitest matchers (`toHaveRegisteredTool`, `toHaveRegisteredCommand`). | -| `utils/theme.ts` | `NOOP_THEME` constant for testing render functions without a real terminal theme. | -| `utils/load-extension.ts` | Thin wrapper around pi-coding-agent's internal `loadExtensionFromFactory`. Single consumer of the `#pi-internal/extensions-loader` alias defined in `vitest.config.ts`. | -| `utils/pi-internal.d.ts` | Type declarations for the aliased internal module. | -| `vitest.setup.ts` | Setup file that loads custom matchers. Referenced in `vitest.config.ts`. | +| `packages/test-utils/pi-test-harness.ts` | Main entry point. Creates the harness, loads the extension, exposes command/tool executors and the `newSession` spy. | +| `packages/test-utils/pi-context.ts` | Builds spy-based `ExtensionCommandContext` and tool context objects. All methods are `vi.fn()` with safe defaults. | +| `packages/test-utils/matchers.ts` | Custom vitest matchers (`toHaveRegisteredTool`, `toHaveRegisteredCommand`). | +| `packages/test-utils/theme.ts` | `NOOP_THEME` constant for testing render functions without a real terminal theme. | +| `packages/test-utils/load-extension.ts` | Thin wrapper around pi-coding-agent's internal `loadExtensionFromFactory`. Single consumer of the `#pi-internal/extensions-loader` alias defined in `vitest.config.ts`. | +| `packages/test-utils/pi-internal.d.ts` | Type declarations for the aliased internal module. | +| `tests/vitest.setup.ts` | Setup file that loads custom matchers. Referenced in `vitest.config.ts`. | ## Design principles diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 9f79ac00..f28a0afc 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -1 +1 @@ -import "./utils/matchers"; +import "@harness/test-utils/matchers"; diff --git a/extensions/tools/ask-user/component.ts b/tools/ask-user/component.ts similarity index 100% rename from extensions/tools/ask-user/component.ts rename to tools/ask-user/component.ts diff --git a/extensions/tools/ask-user/execute.ts b/tools/ask-user/execute.ts similarity index 100% rename from extensions/tools/ask-user/execute.ts rename to tools/ask-user/execute.ts diff --git a/extensions/tools/ask-user/index.ts b/tools/ask-user/index.ts similarity index 100% rename from extensions/tools/ask-user/index.ts rename to tools/ask-user/index.ts diff --git a/extensions/tools/ask-user/render.ts b/tools/ask-user/render.ts similarity index 100% rename from extensions/tools/ask-user/render.ts rename to tools/ask-user/render.ts diff --git a/extensions/tools/ask-user/schema.ts b/tools/ask-user/schema.ts similarity index 100% rename from extensions/tools/ask-user/schema.ts rename to tools/ask-user/schema.ts diff --git a/extensions/tools/ask-user/tool.ts b/tools/ask-user/tool.ts similarity index 100% rename from extensions/tools/ask-user/tool.ts rename to tools/ask-user/tool.ts diff --git a/extensions/tools/ask-user/types.ts b/tools/ask-user/types.ts similarity index 100% rename from extensions/tools/ask-user/types.ts rename to tools/ask-user/types.ts diff --git a/tools/bash/index.ts b/tools/bash/index.ts new file mode 100644 index 00000000..222814ea --- /dev/null +++ b/tools/bash/index.ts @@ -0,0 +1,105 @@ +import { homedir as getHomedir } from "node:os"; +import { resolve } from "node:path"; +import type { + BashSpawnContext, + ExtensionAPI, +} from "@mariozechner/pi-coding-agent"; +import { createBashTool } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { renderCall, renderResult } from "./render"; +import { + AD_BASH_SPAWN_HOOK_REQUEST_EVENT, + type SpawnHookContributor, + type SpawnHookRequestPayload, +} from "./types"; + +const homedir = getHomedir(); + +/** + * Override the built-in bash tool to add a cwd parameter. + * + * Models often use `cd dir && command` which silently skips the command + * if the directory doesn't exist. The cwd parameter is passed to spawn() + * which fails explicitly if the directory is missing. + */ +export default function (pi: ExtensionAPI): void { + const cwd = process.cwd(); + const nativeBash = createBashTool(cwd); + + const contributors = new Map<string, SpawnHookContributor>(); + const getContributors = () => + Array.from(contributors.values()).sort( + (a, b) => (a.priority ?? 100) - (b.priority ?? 100), + ); + + const registerContributor = (contributor: SpawnHookContributor) => { + contributors.set(contributor.id, contributor); + }; + + const composedSpawnHook = (ctx: BashSpawnContext): BashSpawnContext => { + let next = ctx; + for (const contributor of getContributors()) { + next = contributor.spawnHook(next); + } + return next; + }; + + const schema = Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })), + cwd: Type.Optional( + Type.String({ + description: + "Working directory for the command. Prefer this over shell wrappers like 'cd dir && command', 'pushd', or 'cd ../..; ...'.", + }), + ), + }); + + pi.registerTool({ + ...nativeBash, + parameters: schema, + promptGuidelines: [ + "When a command should run in another directory, set cwd and keep command free of leading 'cd', 'pushd', or similar directory-changing shell wrappers.", + "Do not use patterns like 'cd dir && command', 'cd dir; command', or 'pushd dir && command'.", + "Use the cwd parameter instead of 'cd dir && command'.", + "Reserve bash for git, build/test, package managers, ssh, curl, and process management.", + "Prefer native tools like read, find, grep, edit, and write over shell commands when available.", + ], + renderCall(args, theme) { + return renderCall(args, theme, homedir); + }, + renderResult(result, options, theme) { + return renderResult(result, options, theme); + }, + async execute(toolCallId, params, signal, onUpdate, ctx) { + const effectiveCwd = params.cwd ? resolve(ctx.cwd, params.cwd) : ctx.cwd; + const bashForCwd = createBashTool(effectiveCwd, { + spawnHook: composedSpawnHook, + }); + const start = Date.now(); + const result = await bashForCwd.execute( + toolCallId, + { command: params.command, timeout: params.timeout }, + signal, + onUpdate, + ); + // Attach duration to details so renderResult can display it + const durationMs = Date.now() - start; + result.details = { ...result.details, _durationMs: durationMs }; + return result; + }, + }); + + // Request hook contributors from other extensions. + const requestContributors = () => { + pi.events.emit(AD_BASH_SPAWN_HOOK_REQUEST_EVENT, { + register: registerContributor, + } satisfies SpawnHookRequestPayload); + }; + + // Fire once at setup and once on session start to avoid load-order misses. + requestContributors(); + pi.on("session_start", () => { + requestContributors(); + }); +} diff --git a/tools/bash/render.ts b/tools/bash/render.ts new file mode 100644 index 00000000..08e8ec78 --- /dev/null +++ b/tools/bash/render.ts @@ -0,0 +1,148 @@ +/** + * Bash tool render functions. + */ + +import type { + AgentToolResult, + Theme, + ToolRenderResultOptions, +} from "@mariozechner/pi-coding-agent"; +import { + DEFAULT_MAX_BYTES, + formatSize, + keyHint, + truncateToVisualLines, +} from "@mariozechner/pi-coding-agent"; +import { Box, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { sanitizeShellOutput } from "./sanitize"; + +/** Lines to show when collapsed. Matches the native bash tool. */ +const BASH_PREVIEW_LINES = 5; + +/** Extract text content from a tool result. */ +export function getTextOutput(result: AgentToolResult<unknown>): string { + const textBlocks = result.content?.filter((c) => c.type === "text") || []; + return textBlocks + .map((c) => { + const text = "text" in c && c.text ? c.text : ""; + return sanitizeShellOutput(text).replace(/\r/g, ""); + }) + .join("\n") + .trim(); +} + +export function renderCall( + args: Record<string, unknown>, + theme: Theme, + homedir: string, +) { + const command = args.command ?? ""; + const timeout = args.timeout as number | undefined; + const cwdArg = args.cwd as string | undefined; + + const commandDisplay = command ? command : theme.fg("toolOutput", "..."); + const cwdDisplay = cwdArg?.startsWith(homedir) + ? `~${cwdArg.slice(homedir.length)}` + : cwdArg; + const cwdSuffix = cwdDisplay + ? theme.fg("muted", ` (cwd: ${cwdDisplay})`) + : ""; + const timeoutSuffix = timeout + ? theme.fg("muted", ` (timeout ${timeout}s)`) + : ""; + + return new Text( + `${theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`))}${cwdSuffix}${timeoutSuffix}`, + 0, + 0, + ); +} + +export function renderResult( + result: AgentToolResult<unknown>, + options: ToolRenderResultOptions, + theme: Theme, +) { + const box = new Box(0, 0); + const output = getTextOutput(result); + + if (output) { + const styledOutput = output + .split("\n") + .map((line: string) => theme.fg("toolOutput", line)) + .join("\n"); + + if (options.expanded) { + box.addChild(new Text(`\n${styledOutput}`, 0, 0)); + } else { + // Visual line truncation with width-aware caching (matches native) + let cachedWidth: number | undefined; + let cachedLines: string[] | undefined; + let cachedSkipped: number | undefined; + + box.addChild({ + render: (width: number) => { + if (cachedLines === undefined || cachedWidth !== width) { + const r = truncateToVisualLines( + styledOutput, + BASH_PREVIEW_LINES, + width, + ); + cachedLines = r.visualLines; + cachedSkipped = r.skippedCount; + cachedWidth = width; + } + if (cachedSkipped && cachedSkipped > 0) { + const hint = `${theme.fg("muted", `... (${cachedSkipped} earlier lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + return ["", truncateToWidth(hint, width, "..."), ...cachedLines]; + } + return ["", ...cachedLines]; + }, + invalidate: () => { + cachedWidth = undefined; + cachedLines = undefined; + cachedSkipped = undefined; + }, + }); + } + } + + // Truncation warnings + const details = result.details as Record<string, unknown> | undefined; + const truncation = details?.truncation as Record<string, unknown> | undefined; + const fullOutputPath = details?.fullOutputPath as string | undefined; + if (truncation?.truncated || fullOutputPath) { + const warnings: string[] = []; + if (fullOutputPath) { + warnings.push(`Full output: ${fullOutputPath}`); + } + if (truncation?.truncated) { + if (truncation.truncatedBy === "lines") { + warnings.push( + `Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`, + ); + } else { + warnings.push( + `Truncated: ${truncation.outputLines} lines shown (${formatSize((truncation.maxBytes as number) ?? DEFAULT_MAX_BYTES)} limit)`, + ); + } + } + box.addChild( + new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0), + ); + } + + // Elapsed / Took duration + const durationMs = details?._durationMs as number | undefined; + if (!options.isPartial && durationMs !== undefined) { + box.addChild( + new Text( + `\n${theme.fg("muted", `Took ${(durationMs / 1000).toFixed(1)}s`)}`, + 0, + 0, + ), + ); + } + + return box; +} diff --git a/tools/bash/sanitize.ts b/tools/bash/sanitize.ts new file mode 100644 index 00000000..591f83d0 --- /dev/null +++ b/tools/bash/sanitize.ts @@ -0,0 +1,33 @@ +/** + * Remove terminal escape/control sequences that leak into tool output UI. + * Mirrors native bash rendering behavior in pi-mono (strip + sanitize). + */ + +const ESC = "\u001B"; +const OSC_REGEX = new RegExp(`${ESC}\\][\\s\\S]*?(?:\\u0007|${ESC}\\\\)`, "g"); +const CSI_REGEX = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "g"); +const SINGLE_ESC_REGEX = new RegExp(`${ESC}[@-_]`, "g"); +const C1_ST_REGEX = /\u009C/g; + +export function sanitizeShellOutput(value: string): string { + let text = value; + + // OSC sequences: ESC ] ... BEL or ESC \\ + text = text.replace(OSC_REGEX, ""); + // CSI/SGR and other control sequences: ESC [ ... command + text = text.replace(CSI_REGEX, ""); + // Other single-character escapes + text = text.replace(SINGLE_ESC_REGEX, ""); + // Standalone String Terminator and leftover ESC + text = text.replace(C1_ST_REGEX, "").replace(new RegExp(ESC, "g"), ""); + + // Drop control chars except tab/newline/carriage return. + return Array.from(text) + .filter((char) => { + const code = char.codePointAt(0); + if (code === undefined) return false; + if (code === 0x09 || code === 0x0a || code === 0x0d) return true; + return !(code <= 0x1f || (code >= 0x7f && code <= 0x9f)); + }) + .join(""); +} diff --git a/tools/bash/types.ts b/tools/bash/types.ts new file mode 100644 index 00000000..bf852c85 --- /dev/null +++ b/tools/bash/types.ts @@ -0,0 +1,13 @@ +import type { BashSpawnContext } from "@mariozechner/pi-coding-agent"; + +export type SpawnHookContributor = { + id: string; + priority?: number; + spawnHook: (ctx: BashSpawnContext) => BashSpawnContext; +}; + +export type SpawnHookRequestPayload = { + register: (contributor: SpawnHookContributor) => void; +}; + +export const AD_BASH_SPAWN_HOOK_REQUEST_EVENT = "ad:bash:spawn-hook:request"; diff --git a/extensions/defaults/tools/edit/index.test.ts b/tools/edit/index.test.ts similarity index 95% rename from extensions/defaults/tools/edit/index.test.ts rename to tools/edit/index.test.ts index 1126a958..778b322c 100644 --- a/extensions/defaults/tools/edit/index.test.ts +++ b/tools/edit/index.test.ts @@ -1,9 +1,9 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createToolContext } from "@harness/test-utils/pi-context"; +import { createPiTestHarness } from "@harness/test-utils/pi-test-harness"; import { afterEach, assert, describe, expect, it, vi } from "vitest"; -import { createToolContext } from "../../../../tests/utils/pi-context"; -import { createPiTestHarness } from "../../../../tests/utils/pi-test-harness"; import editExtension, { prepareEditArguments } from "./index"; const tempDirs: string[] = []; diff --git a/extensions/defaults/tools/edit/index.ts b/tools/edit/index.ts similarity index 100% rename from extensions/defaults/tools/edit/index.ts rename to tools/edit/index.ts diff --git a/tools/find/blocked-paths.ts b/tools/find/blocked-paths.ts new file mode 100644 index 00000000..26a0cd7e --- /dev/null +++ b/tools/find/blocked-paths.ts @@ -0,0 +1,26 @@ +import { homedir } from "node:os"; + +export const BLOCKED_PATHS = new Set([ + homedir(), + "/", + "/Users", + "/home", + "/tmp", + "/var", + "/etc", + "/opt", + "/usr", + "/System", + "/Library", + "/Applications", + "/Volumes", + "/nix", + "/snap", + "/proc", + "/sys", + "/dev", + "/run", + "/boot", + "/sbin", + "/bin", +]); diff --git a/tools/find/index.ts b/tools/find/index.ts new file mode 100644 index 00000000..b0595715 --- /dev/null +++ b/tools/find/index.ts @@ -0,0 +1,139 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { relative, resolve } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { defineTool } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { BLOCKED_PATHS } from "./blocked-paths"; +import { renderCall } from "./render"; +import type { HarnessFindDetails } from "./types"; + +const DEFAULT_LIMIT = 1000; + +const WrappedSchema = Type.Object({ + pattern: Type.String({ + description: "The pattern to search for (glob or regex)", + }), + path: Type.Optional( + Type.String({ + description: "The directory to search in (defaults to cwd)", + }), + ), + limit: Type.Optional( + Type.Number({ + description: `Maximum number of results (defaults to ${DEFAULT_LIMIT})`, + }), + ), +}); + +function createFindTool(pi: ExtensionAPI) { + return defineTool({ + name: "find", + label: "Find Files", + description: `Find files by name using the \`fd\` command-line tool. Supports glob patterns and regex. Searches recursively from the specified path. Respects .gitignore. Results are truncated to ${DEFAULT_LIMIT} entries.`, + parameters: WrappedSchema, + promptGuidelines: [ + "Use find instead of shell find or fd when locating files in the project.", + "Prefer passing path explicitly instead of scanning broad roots.", + ], + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const pattern = params.pattern; + const searchPath = params.path; + const limit = params.limit ?? DEFAULT_LIMIT; + + if (signal?.aborted) { + throw new Error("Search was aborted"); + } + + let resolvedPath = searchPath || "."; + if (resolvedPath === "~" || resolvedPath.startsWith("~/")) { + resolvedPath = resolvedPath.replace(/^~/, homedir()); + } + const absoluteSearchPath = resolve(ctx.cwd, resolvedPath); + + if (BLOCKED_PATHS.has(absoluteSearchPath)) { + throw new Error( + `Searching '${absoluteSearchPath}' is not allowed — too broad. Narrow the search to a specific project or subdirectory.`, + ); + } + + if (!existsSync(absoluteSearchPath)) { + throw new Error(`Path not found: ${absoluteSearchPath}`); + } + + const fdArgs = [ + "--glob", + "--color=never", + "--hidden", + "--max-results", + String(limit), + pattern, + absoluteSearchPath, + ]; + + const result = await pi.exec("fd", fdArgs, { + signal: signal ?? undefined, + cwd: ctx.cwd, + }); + + if (result.killed && signal?.aborted) { + throw new Error("Search was aborted"); + } + + if (result.code !== 0 && !result.stdout) { + throw new Error(result.stderr || "Unknown error"); + } + + const allResults = result.stdout + .trim() + .split("\n") + .filter((line) => line.trim()); + + if (allResults.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No files found matching the pattern.", + }, + ], + details: {}, + }; + } + + const results = allResults.map((absolutePath) => { + if (absolutePath.startsWith(absoluteSearchPath)) { + return absolutePath.slice(absoluteSearchPath.length + 1); + } + return absolutePath; + }); + + const wasTruncated = results.length >= limit; + + const details: HarnessFindDetails = { + resultLimitReached: wasTruncated ? results.length : undefined, + totalResults: results.length, + paths: results, + relativeTo: + searchPath && searchPath !== "." && searchPath !== "./" + ? relative(ctx.cwd, absoluteSearchPath) || "." + : undefined, + }; + + const outputText = results.join("\n"); + + return { + content: [{ type: "text", text: outputText }], + details, + }; + }, + + renderCall(args, theme) { + return renderCall(args, theme); + }, + }); +} + +export default function (pi: ExtensionAPI): void { + pi.registerTool(createFindTool(pi)); +} diff --git a/tools/find/render.ts b/tools/find/render.ts new file mode 100644 index 00000000..6a8ff2e3 --- /dev/null +++ b/tools/find/render.ts @@ -0,0 +1,27 @@ +/** + * Find tool render functions. + */ + +import { ToolCallHeader } from "@aliou/pi-utils-ui"; +import type { Theme } from "@mariozechner/pi-coding-agent"; + +export function renderCall( + args: { + pattern: string; + path?: string; + limit?: number; + }, + theme: Theme, +) { + return new ToolCallHeader( + { + toolName: "Find", + mainArg: args.pattern, + optionArgs: [ + ...(args.path ? [{ label: "in", value: args.path }] : []), + ...(args.limit ? [{ label: "limit", value: String(args.limit) }] : []), + ], + }, + theme, + ); +} diff --git a/tools/find/types.ts b/tools/find/types.ts new file mode 100644 index 00000000..e01576d9 --- /dev/null +++ b/tools/find/types.ts @@ -0,0 +1,7 @@ +import type { FindToolDetails } from "@mariozechner/pi-coding-agent"; + +export interface HarnessFindDetails extends FindToolDetails { + relativeTo?: string; + totalResults?: number; + paths?: string[]; +} diff --git a/tools/get-current-time/index.ts b/tools/get-current-time/index.ts new file mode 100644 index 00000000..ac416b76 --- /dev/null +++ b/tools/get-current-time/index.ts @@ -0,0 +1,126 @@ +import { ToolCallHeader } from "@aliou/pi-utils-ui"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { defineTool } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "typebox"; + +const GetCurrentTimeParams = Type.Object({ + format: Type.Optional( + Type.String({ + description: + "Output format: 'iso8601' (default), 'unix', 'date', 'time', or custom strftime-like pattern", + }), + ), +}); + +interface TimeDetails { + formatted: string; + date: string; + time: string; + timezone: string; + timezone_name: string; + day_of_week: string; + unix: number; +} + +function formatDate(date: Date, format: string): string { + switch (format.toLowerCase()) { + case "iso8601": + case "iso": + return date.toISOString(); + case "unix": + return Math.floor(date.getTime() / 1000).toString(); + case "date": + return date.toLocaleDateString(); + case "time": + return date.toLocaleTimeString(); + default: + return date.toISOString(); + } +} + +const getCurrentTimeTool = defineTool({ + name: "get_current_time", + label: "Get Current Time", + description: + "Get the current date and time. Returns formatted time along with date, time, timezone, and day of week as separate fields.", + parameters: GetCurrentTimeParams, + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + const now = new Date(); + const format = params.format || "iso8601"; + + const formatted = formatDate(now, format); + const timezoneOffset = -now.getTimezoneOffset(); + const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60); + const offsetMinutes = Math.abs(timezoneOffset) % 60; + const offsetSign = timezoneOffset >= 0 ? "+" : "-"; + const timezone = `UTC${offsetSign}${String(offsetHours).padStart(2, "0")}:${String(offsetMinutes).padStart(2, "0")}`; + + const details: TimeDetails = { + formatted, + date: now.toLocaleDateString("en-CA"), + time: now.toLocaleTimeString("en-GB", { hour12: false }), + timezone, + timezone_name: Intl.DateTimeFormat().resolvedOptions().timeZone, + day_of_week: now.toLocaleDateString("en-US", { weekday: "long" }), + unix: Math.floor(now.getTime() / 1000), + }; + + const text = [ + `Formatted: ${details.formatted}`, + `Date: ${details.date}`, + `Time: ${details.time}`, + `Timezone: ${details.timezone} (${details.timezone_name})`, + `Day: ${details.day_of_week}`, + `Unix: ${details.unix}`, + ].join("\n"); + + return { + content: [{ type: "text", text }], + details, + }; + }, + + renderCall(args, theme) { + return new ToolCallHeader( + { + toolName: "Current Time", + optionArgs: args.format + ? [{ label: "format", value: args.format }] + : [], + }, + theme, + ); + }, + + renderResult(result, _options, theme) { + const { details } = result as { + details?: TimeDetails; + content: Array<{ type: string; text?: string }>; + }; + + if (!details) { + const text = result.content[0]; + return new Text( + text?.type === "text" && text.text ? text.text : "No result", + 0, + 0, + ); + } + + const lines: string[] = []; + lines.push( + `${theme.fg("dim", "Date:")} ${theme.fg("accent", details.date)} ${theme.fg("dim", `(${details.day_of_week})`)}`, + ); + lines.push( + `${theme.fg("dim", "Time:")} ${theme.fg("accent", details.time)} ${theme.fg("dim", details.timezone_name)}`, + ); + + return new Text(lines.join("\n"), 0, 0); + }, +}); + +export default function (pi: ExtensionAPI) { + pi.registerTool(getCurrentTimeTool); +} diff --git a/tools/grep/blocked-paths.ts b/tools/grep/blocked-paths.ts new file mode 100644 index 00000000..26a0cd7e --- /dev/null +++ b/tools/grep/blocked-paths.ts @@ -0,0 +1,26 @@ +import { homedir } from "node:os"; + +export const BLOCKED_PATHS = new Set([ + homedir(), + "/", + "/Users", + "/home", + "/tmp", + "/var", + "/etc", + "/opt", + "/usr", + "/System", + "/Library", + "/Applications", + "/Volumes", + "/nix", + "/snap", + "/proc", + "/sys", + "/dev", + "/run", + "/boot", + "/sbin", + "/bin", +]); diff --git a/tools/grep/index.ts b/tools/grep/index.ts new file mode 100644 index 00000000..e5c318c6 --- /dev/null +++ b/tools/grep/index.ts @@ -0,0 +1,268 @@ +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { relative, resolve } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { defineTool, truncateLine } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { BLOCKED_PATHS } from "./blocked-paths"; +import { renderCall } from "./render"; +import type { GrepMatchData, HarnessGrepDetails, RgMatch } from "./types"; + +const DEFAULT_LIMIT = 100; + +const WrappedSchema = Type.Object({ + pattern: Type.String({ + description: "Search pattern (regex or literal string)", + }), + path: Type.Optional( + Type.String({ + description: "Directory or file to search (default: current directory)", + }), + ), + glob: Type.Optional( + Type.String({ + description: + "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'", + }), + ), + ignoreCase: Type.Optional( + Type.Boolean({ + description: "Case-insensitive search (default: false)", + }), + ), + literal: Type.Optional( + Type.Boolean({ + description: + "Treat pattern as literal string instead of regex (default: false)", + }), + ), + context: Type.Optional( + Type.Number({ + description: + "Number of lines to show before and after each match (default: 0)", + }), + ), + limit: Type.Optional( + Type.Number({ + description: `Maximum number of matches to return (default: ${DEFAULT_LIMIT})`, + }), + ), +}); + +function createGrepTool(pi: ExtensionAPI) { + return defineTool({ + name: "grep", + label: "grep", + description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or 50KB (whichever is hit first). Long lines are truncated to 500 chars.`, + parameters: WrappedSchema, + promptGuidelines: [ + "Search file contents for patterns (respects .gitignore)", + ], + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const { + pattern, + path: searchDir, + glob, + ignoreCase, + literal, + context, + limit, + } = params; + + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + let resolvedPath = searchDir || "."; + if (resolvedPath === "~" || resolvedPath.startsWith("~/")) { + resolvedPath = resolvedPath.replace(/^~/, homedir()); + } + const absoluteSearchPath = resolve(ctx.cwd, resolvedPath); + + if (BLOCKED_PATHS.has(absoluteSearchPath)) { + throw new Error( + `Searching '${absoluteSearchPath}' is not allowed — too broad. Narrow the search to a specific project or subdirectory.`, + ); + } + + if (!existsSync(absoluteSearchPath)) { + throw new Error(`Path not found: ${absoluteSearchPath}`); + } + + let isDirectory = false; + try { + isDirectory = lstatSync(absoluteSearchPath).isDirectory(); + } catch { + throw new Error(`Cannot stat path: ${absoluteSearchPath}`); + } + + const contextValue = context && context > 0 ? context : 0; + const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + + const rgArgs = ["--json", "--line-number", "--color=never", "--hidden"]; + if (ignoreCase) rgArgs.push("--ignore-case"); + if (literal) rgArgs.push("--fixed-strings"); + if (glob) rgArgs.push("--glob", glob); + rgArgs.push(pattern, absoluteSearchPath); + + const result = await pi.exec("rg", rgArgs, { + signal: signal ?? undefined, + cwd: ctx.cwd, + }); + + if (result.killed && signal?.aborted) { + throw new Error("Operation aborted"); + } + + if (result.code !== 0 && result.code !== 1) { + throw new Error( + result.stderr || `ripgrep exited with code ${result.code}`, + ); + } + + // Parse rg JSON output to collect matches + const matches: RgMatch[] = []; + let matchCount = 0; + let matchLimitReached = false; + + for (const line of result.stdout.split("\n")) { + if (!line.trim()) continue; + let event: { + type: string; + data?: { path?: { text: string }; line_number?: number }; + }; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (event.type === "match") { + matchCount++; + const filePath = event.data?.path?.text; + const lineNumber = event.data?.line_number; + if (filePath && typeof lineNumber === "number") { + if (matches.length < effectiveLimit) { + matches.push({ filePath, lineNumber }); + } + } + if (matchCount >= effectiveLimit) { + matchLimitReached = true; + } + } + } + + if (matches.length === 0) { + return { + content: [{ type: "text", text: "No matches found" }], + details: undefined, + }; + } + + // Format path relative to search directory + const formatPath = (filePath: string): string => { + if (isDirectory) { + const rel = filePath + .slice(absoluteSearchPath.length) + .replace(/^[/\\]/, ""); + if (rel) return rel.replace(/\\/g, "/"); + } + return filePath.replace(/\\/g, "/").split("/").pop() ?? filePath; + }; + + // Read match text from files + const fileCache = new Map<string, string[]>(); + const getFileLines = (filePath: string): string[] => { + let lines = fileCache.get(filePath); + if (!lines) { + try { + const content = readFileSync(filePath, "utf-8"); + lines = content + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n"); + } catch { + lines = []; + } + fileCache.set(filePath, lines); + } + return lines; + }; + + let linesTruncated = false; + const matchData: GrepMatchData[] = []; + + for (const match of matches) { + const relativePath = formatPath(match.filePath); + const lines = getFileLines(match.filePath); + + if (!lines.length) { + matchData.push({ + path: relativePath, + line: match.lineNumber, + text: "(unable to read file)", + }); + continue; + } + + const start = + contextValue > 0 + ? Math.max(1, match.lineNumber - contextValue) + : match.lineNumber; + const end = + contextValue > 0 + ? Math.min(lines.length, match.lineNumber + contextValue) + : match.lineNumber; + + for (let current = start; current <= end; current++) { + const lineText = (lines[current - 1] ?? "").replace(/\r/g, ""); + const isMatchLine = current === match.lineNumber; + const { text: truncatedText, wasTruncated } = truncateLine(lineText); + if (wasTruncated) linesTruncated = true; + + if (contextValue > 0 && !isMatchLine) { + matchData.push({ + path: relativePath, + line: current, + text: ` ${truncatedText.trim()}`, + }); + } else { + matchData.push({ + path: relativePath, + line: current, + text: truncatedText.trim(), + }); + } + } + } + + const details: HarnessGrepDetails = { + matchCount, + matches: matchData, + relativeTo: + isDirectory && searchDir && searchDir !== "." && searchDir !== "./" + ? relative(ctx.cwd, absoluteSearchPath) || "." + : undefined, + }; + if (matchLimitReached) details.matchLimitReached = effectiveLimit; + if (linesTruncated) details.linesTruncated = true; + + // Text content for LLM consumption (flat format) + const textContent = matchData + .map((m) => `${m.path}:${m.line}: ${m.text}`) + .join("\n"); + + return { + content: [{ type: "text", text: textContent }], + details, + }; + }, + + renderCall(args, theme) { + return renderCall(args, theme); + }, + }); +} + +export default function (pi: ExtensionAPI): void { + pi.registerTool(createGrepTool(pi)); +} diff --git a/tools/grep/render.ts b/tools/grep/render.ts new file mode 100644 index 00000000..05770be4 --- /dev/null +++ b/tools/grep/render.ts @@ -0,0 +1,39 @@ +/** + * Grep tool render functions. + */ + +import { ToolCallHeader } from "@aliou/pi-utils-ui"; +import type { Theme } from "@mariozechner/pi-coding-agent"; + +export function renderCall( + args: { + pattern: string; + path?: string; + glob?: string; + ignoreCase?: boolean; + literal?: boolean; + context?: number; + limit?: number; + }, + theme: Theme, +) { + return new ToolCallHeader( + { + toolName: "Grep", + mainArg: args.literal ? `\`${args.pattern}\`` : `/${args.pattern || ""}/`, + optionArgs: [ + ...(args.path ? [{ label: "in", value: args.path }] : []), + ...(args.glob ? [{ label: "glob", value: args.glob }] : []), + ...(args.limit ? [{ label: "limit", value: String(args.limit) }] : []), + ...(args.ignoreCase + ? [{ label: "icase", value: "true", tone: "accent" as const }] + : []), + ...(args.literal ? [{ label: "literal", value: "true" }] : []), + ...(args.context + ? [{ label: "ctx", value: String(args.context) }] + : []), + ], + }, + theme, + ); +} diff --git a/tools/grep/types.ts b/tools/grep/types.ts new file mode 100644 index 00000000..21124f47 --- /dev/null +++ b/tools/grep/types.ts @@ -0,0 +1,18 @@ +import type { GrepToolDetails } from "@mariozechner/pi-coding-agent"; + +export interface RgMatch { + filePath: string; + lineNumber: number; +} + +export interface GrepMatchData { + path: string; + line: number; + text: string; +} + +export interface HarnessGrepDetails extends GrepToolDetails { + relativeTo?: string; + matchCount?: number; + matches?: GrepMatchData[]; +} diff --git a/tools/librarian/index.ts b/tools/librarian/index.ts new file mode 100644 index 00000000..a11728fc --- /dev/null +++ b/tools/librarian/index.ts @@ -0,0 +1,27 @@ +import { defineSubagent } from "@harness/agent-kit"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { MODEL_CANDIDATES } from "./models"; +import { buildPrompt, LIBRAIAN_SYSTEM_PROMPT } from "./prompt"; +import { createLibrarianGitHubTools } from "./tools"; +import { LibrarianParams } from "./types"; + +export default async function librarian(pi: ExtensionAPI): Promise<void> { + const tools = createLibrarianGitHubTools(pi); + + const subagent = defineSubagent(pi, { + name: "librarian", + label: "Librarian", + description: + "Remote codebase-understanding subagent for deep multi-repository analysis.", + systemPrompt: LIBRAIAN_SYSTEM_PROMPT, + parameters: LibrarianParams, + buildPrompt, + tools, + models: MODEL_CANDIDATES, + }); + + subagent.subscribe(pi); + + pi.registerTool(subagent.tool); + pi.registerTool(subagent.resumeTool); +} diff --git a/tools/librarian/lib/github-client.ts b/tools/librarian/lib/github-client.ts new file mode 100644 index 00000000..4d3569df --- /dev/null +++ b/tools/librarian/lib/github-client.ts @@ -0,0 +1,75 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export interface GitHubClient { + api( + path: string, + cwd: string, + options?: { + fields?: Record<string, string>; + headers?: string[]; + method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + signal?: AbortSignal; + }, + ): Promise<string>; +} + +export function createGitHubClient(pi: ExtensionAPI): GitHubClient { + return { + async api(path, cwd, options = {}) { + const args = ["api"]; + + if (options.method) { + args.push("--method", options.method); + } + + args.push(path); + + for (const header of options.headers ?? []) { + args.push("-H", header); + } + + for (const [key, value] of Object.entries(options.fields ?? {})) { + args.push("-f", `${key}=${value}`); + } + + const result = await pi.exec("gh", args, { + cwd, + signal: options.signal, + }); + + if (result.code !== 0) { + const message = result.stderr.trim() || result.stdout.trim(); + throw new Error(`gh ${args.join(" ")} failed: ${message}`); + } + + return result.stdout.trimEnd(); + }, + }; +} + +export function textResult(text: string, details?: unknown) { + return { content: [{ type: "text" as const, text }], details }; +} + +export function parseJson<T>(text: string): T { + return JSON.parse(text) as T; +} + +export function normalizeRepository(repository: string): string { + let normalized = repository.trim(); + if (normalized.includes("://")) { + const url = new URL(normalized); + if (url.hostname !== "github.com") { + throw new Error("Only github.com repositories are supported"); + } + normalized = url.pathname; + } + normalized = normalized.replace(/\.git$/, "").replace(/^\/+|\/+$/g, ""); + const parts = normalized.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid repository: expected "owner/repo" but got "${repository}"`, + ); + } + return `${parts[0]}/${parts[1]}`; +} diff --git a/tools/librarian/models/index.ts b/tools/librarian/models/index.ts new file mode 100644 index 00000000..3bfef57d --- /dev/null +++ b/tools/librarian/models/index.ts @@ -0,0 +1,22 @@ +import type { SubagentModel } from "@harness/agent-kit/models"; + +export const MODEL_CANDIDATES: SubagentModel[] = [ + { + provider: "synthetic", + model: "hf:zai-org/GLM-4.7-Flash", + thinking: "off", + weight: 1, + }, + { + provider: "neuralwatt", + model: "glm-5.1-fast", + thinking: "off", + weight: 1, + }, + { + provider: "openai-codex", + model: "gpt-5.4-mini", + thinking: "off", + weight: 1, + }, +]; diff --git a/tools/librarian/prompt.ts b/tools/librarian/prompt.ts new file mode 100644 index 00000000..a12df357 --- /dev/null +++ b/tools/librarian/prompt.ts @@ -0,0 +1,34 @@ +import type { SubagentPromptResult } from "@harness/agent-kit/types"; +import { isNotNil } from "@harness/utils"; +import type { LibrarianParamsType } from "./types"; + +export const LIBRAIAN_SYSTEM_PROMPT = `You are the Librarian, a specialized codebase understanding agent that helps users answer questions about large, complex codebases across repositories. + +Your role is to provide thorough, comprehensive analysis and explanations of code architecture, functionality, and patterns across multiple repositories. + +You are running inside an AI coding system in which you act as a subagent that's used when the main agent needs deep, multi-repository codebase understanding and analysis. + +Key responsibilities: +- Explore repositories to answer questions +- Understand and explain architectural patterns and relationships across repositories +- Find specific implementations and trace code flow across codebases +- Explain how features work end-to-end across multiple repositories +- Understand code evolution through commit history +- Create visual diagrams when helpful for understanding complex systems`; + +export function buildPrompt(params: LibrarianParamsType): SubagentPromptResult { + const prompt = `Answer this codebase query: +<query> +${params.query} +</query> + `; + + const context = params.context + ? `Additional context: +<context> +${params.context} +</context>` + : undefined; + + return { text: [prompt, context].filter(isNotNil).join("\n\n") }; +} diff --git a/tools/librarian/tools/commit-search.ts b/tools/librarian/tools/commit-search.ts new file mode 100644 index 00000000..cc801b22 --- /dev/null +++ b/tools/librarian/tools/commit-search.ts @@ -0,0 +1,156 @@ +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { + type GitHubClient, + normalizeRepository, + parseJson, + textResult, +} from "../lib/github-client"; + +const Params = Type.Object({ + query: Type.Optional( + Type.String({ + description: + "Optional text search over commit messages and author information. If omitted, returns commits matching the other filters.", + }), + ), + author: Type.Optional( + Type.String({ description: "Filter commits by author username or email" }), + ), + since: Type.Optional( + Type.String({ + description: + 'ISO 8601 date string for earliest commit date (e.g., "2024-01-01T00:00:00Z")', + }), + ), + until: Type.Optional( + Type.String({ + description: + 'ISO 8601 date string for latest commit date (e.g., "2024-02-01T00:00:00Z")', + }), + ), + path: Type.Optional( + Type.String({ + description: "Filter commits that changed specific files or directories", + }), + ), + repository: Type.String({ + description: + 'Single GitHub repository to search. Use "owner/repo" or "https://github.com/owner/repo". Do not pass GitHub search pages such as "https://github.com/search".', + }), + limit: Type.Optional( + Type.Number({ + description: + "Maximum number of commits to return (default: 50, max: 100)", + minimum: 1, + maximum: 100, + }), + ), + offset: Type.Optional( + Type.Number({ + description: + "Number of commits to skip for pagination (default: 0). Must be divisible by limit.", + minimum: 0, + }), + ), +}); + +interface Commit { + sha: string; + commit: { + message: string; + author: { name: string; email: string; date: string }; + }; +} + +interface CommitSearchResponse { + total_count?: number; + items?: Commit[]; +} + +export function createCommitSearchTool( + client: GitHubClient, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "commit_search", + label: "Commit Search", + description: "Search commit history in a single GitHub repository.", + parameters: Params, + async execute(_id, params, signal) { + const limit = params.limit ?? 50; + const offset = params.offset ?? 0; + if (offset % limit !== 0) + throw new Error( + `offset (${offset}) must be divisible by limit (${limit})`, + ); + const perPage = Math.min(limit, 100); + const page = String(Math.floor(offset / perPage) + 1); + const repository = normalizeRepository(params.repository); + let commits: Commit[]; + let totalCount = 0; + + if (params.path || !params.query) { + const fields: Record<string, string> = { + per_page: String(perPage), + page, + }; + if (params.author) fields.author = params.author; + if (params.since) fields.since = params.since; + if (params.until) fields.until = params.until; + if (params.path) fields.path = params.path; + commits = parseJson<Commit[]>( + await client.api(`repos/${repository}/commits`, cwd, { + method: "GET", + fields, + signal, + }), + ); + if (params.query) { + const q = params.query.toLowerCase(); + commits = commits.filter( + (commit) => + commit.commit.message.toLowerCase().includes(q) || + commit.commit.author.name.toLowerCase().includes(q) || + commit.commit.author.email.toLowerCase().includes(q), + ); + } + totalCount = commits.length; + } else { + const query = [params.query, `repo:${repository}`]; + if (params.author) query.push(`author:${params.author}`); + if (params.since) query.push(`author-date:>=${params.since}`); + if (params.until) query.push(`author-date:<=${params.until}`); + const response = parseJson<CommitSearchResponse>( + await client.api("search/commits", cwd, { + method: "GET", + fields: { q: query.join(" "), per_page: String(perPage), page }, + headers: ["Accept: application/vnd.github+json"], + signal, + }), + ); + commits = response.items ?? []; + totalCount = response.total_count ?? commits.length; + } + + const details = { + commits: commits.map((commit) => ({ + sha: commit.sha, + message: commit.commit.message, + author: commit.commit.author, + })), + totalCount, + }; + const text = details.commits + .map((commit) => + [ + `${commit.sha.slice(0, 12)} ${commit.author.date}`, + commit.message.split("\n")[0], + `${commit.author.name} <${commit.author.email}>`, + ].join("\n"), + ) + .join("\n\n"); + return textResult(text || "No commits", details); + }, + }; +} diff --git a/tools/librarian/tools/diff.ts b/tools/librarian/tools/diff.ts new file mode 100644 index 00000000..88c0f102 --- /dev/null +++ b/tools/librarian/tools/diff.ts @@ -0,0 +1,95 @@ +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { + type GitHubClient, + normalizeRepository, + parseJson, + textResult, +} from "../lib/github-client"; + +const Params = Type.Object({ + base: Type.String({ + description: + 'The base commit SHA, branch name, or tag to compare from (e.g., "main", "v1.0.0", or commit SHA)', + }), + head: Type.String({ + description: + 'The head commit SHA, branch name, or tag to compare to (e.g., "feature-branch", "v2.0.0", or commit SHA)', + }), + repository: Type.String({ + description: + 'Single GitHub repository to compare. Use "owner/repo" or "https://github.com/owner/repo". Do not pass GitHub search pages such as "https://github.com/search".', + }), + includePatches: Type.Optional( + Type.Boolean({ + description: + "Include unified diff patches per file (token heavy, truncated to ~4k characters per file). Default false.", + }), + ), +}); + +interface CompareResponse { + files?: Array<Record<string, unknown> & { patch?: string }>; + base_commit?: { sha?: string; commit?: { message?: string } }; + commits?: Array<{ sha?: string; commit?: { message?: string } }>; + ahead_by?: number; + behind_by?: number; + total_commits?: number; +} + +export function createDiffTool( + client: GitHubClient, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "diff", + label: "GitHub Diff", + description: + "Get a diff between two commits, branches, or tags in a single GitHub repository.", + parameters: Params, + async execute(_id, params, signal) { + const repository = normalizeRepository(params.repository); + const json = await client.api( + `repos/${repository}/compare/${params.base}...${params.head}`, + cwd, + { signal }, + ); + const response = parseJson<CompareResponse>(json); + const files = (response.files ?? []).map((file) => { + if (!params.includePatches) { + const { patch: _patch, ...rest } = file; + return rest; + } + return file.patch && file.patch.length > 4096 + ? { ...file, patch: `${file.patch.slice(0, 4096)}\n... [truncated]` } + : file; + }); + const headCommit = response.commits?.[response.commits.length - 1]; + const details = { + files, + base_commit: { + sha: response.base_commit?.sha ?? params.base, + message: response.base_commit?.commit?.message?.trim() ?? "", + }, + head_commit: { + sha: headCommit?.sha ?? params.head, + message: headCommit?.commit?.message?.trim() ?? "", + }, + ahead_by: response.ahead_by, + behind_by: response.behind_by, + total_commits: response.total_commits, + }; + const text = [ + `Base: ${details.base_commit.sha} ${details.base_commit.message}`, + `Head: ${details.head_commit.sha} ${details.head_commit.message}`, + `Commits: ${details.total_commits ?? 0} ahead: ${details.ahead_by ?? 0} behind: ${details.behind_by ?? 0}`, + "", + ...files.map( + (file) => + `${String(file.status)} ${String(file.filename)} (+${String(file.additions)} -${String(file.deletions)})`, + ), + ].join("\n"); + return textResult(text, details); + }, + }; +} diff --git a/tools/librarian/tools/glob-github.ts b/tools/librarian/tools/glob-github.ts new file mode 100644 index 00000000..f739eb12 --- /dev/null +++ b/tools/librarian/tools/glob-github.ts @@ -0,0 +1,118 @@ +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { + type GitHubClient, + normalizeRepository, + parseJson, + textResult, +} from "../lib/github-client"; + +const Params = Type.Object({ + filePattern: Type.String({ + description: + 'Glob pattern to match within the selected repository (e.g., "**/*.ts", "src/**/*.test.js")', + }), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of results to return (default = 100).", + }), + ), + offset: Type.Optional( + Type.Number({ description: "Number of results to skip for pagination" }), + ), + repository: Type.String({ + description: + 'Single GitHub repository to search. Use "owner/repo" or "https://github.com/owner/repo". Do not pass GitHub search pages such as "https://github.com/search".', + }), +}); + +interface TreeResponse { + tree?: Array<{ path: string; type: "blob" | "tree" | string }>; + truncated?: boolean; +} + +export function createGlobGitHubTool( + client: GitHubClient, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "glob_github", + label: "Glob GitHub", + description: "Find files matching a glob pattern in a GitHub repository.", + parameters: Params, + async execute(_id, params, signal) { + const repository = normalizeRepository(params.repository); + const json = await client.api(`repos/${repository}/git/trees/HEAD`, cwd, { + method: "GET", + fields: { recursive: "1" }, + signal, + }); + const response = parseJson<TreeResponse>(json); + if (response.truncated) { + throw new Error( + "Repository tree is too large for recursive listing. Try a more specific search or use search_github instead.", + ); + } + const offset = params.offset ?? 0; + const limit = params.limit ?? 100; + const matches = (response.tree ?? []) + .filter( + (entry) => + entry.type === "blob" && matchGlob(params.filePattern, entry.path), + ) + .slice(offset, offset + limit) + .map((entry) => entry.path); + return textResult(matches.join("\n") || "No matches", { matches }); + }, + }; +} + +function matchGlob(glob: string, path: string): boolean { + let source = ""; + for (let index = 0; index < glob.length; index++) { + const char = glob[index] ?? ""; + if (char === "*") { + if (glob[index + 1] === "*") { + if (glob[index + 2] === "/") { + source += "(?:.+/)?"; + index += 2; + } else { + source += ".*"; + index++; + } + } else { + source += "[^/]*"; + } + } else if (char === "?") { + source += "[^/]"; + } else if (char === "{") { + const end = glob.indexOf("}", index); + if (end === -1) { + source += escapeRegExp(char); + } else { + const alternatives = glob + .slice(index + 1, end) + .split(",") + .map(escapeRegExp) + .join("|"); + source += `(?:${alternatives})`; + index = end; + } + } else if (char === "[") { + const end = glob.indexOf("]", index); + if (end === -1) { + source += escapeRegExp(char); + } else { + source += glob.slice(index, end + 1); + index = end; + } + } else { + source += escapeRegExp(char); + } + } + return new RegExp(`^${source}$`).test(path); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tools/librarian/tools/index.ts b/tools/librarian/tools/index.ts new file mode 100644 index 00000000..897e9174 --- /dev/null +++ b/tools/librarian/tools/index.ts @@ -0,0 +1,54 @@ +import type { SubagentToolSpec } from "@harness/agent-kit/types"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { createGitHubClient } from "../lib/github-client"; +import { createCommitSearchTool } from "./commit-search"; +import { createDiffTool } from "./diff"; +import { createGlobGitHubTool } from "./glob-github"; +import { createListDirectoryGitHubTool } from "./list-directory-github"; +import { createListRepositoriesTool } from "./list-repositories"; +import { createReadGitHubTool } from "./read-github"; +import { createSearchGitHubTool } from "./search-github"; + +export function createLibrarianGitHubTools( + pi: ExtensionAPI, +): SubagentToolSpec[] { + const client = createGitHubClient(pi); + + return [ + { + name: "read_github", + type: "custom", + spec: (cwd) => createReadGitHubTool(client, cwd), + }, + { + name: "search_github", + type: "custom", + spec: (cwd) => createSearchGitHubTool(client, cwd), + }, + { + name: "commit_search", + type: "custom", + spec: (cwd) => createCommitSearchTool(client, cwd), + }, + { + name: "diff", + type: "custom", + spec: (cwd) => createDiffTool(client, cwd), + }, + { + name: "list_directory_github", + type: "custom", + spec: (cwd) => createListDirectoryGitHubTool(client, cwd), + }, + { + name: "list_repositories", + type: "custom", + spec: (cwd) => createListRepositoriesTool(client, cwd), + }, + { + name: "glob_github", + type: "custom", + spec: (cwd) => createGlobGitHubTool(client, cwd), + }, + ]; +} diff --git a/tools/librarian/tools/list-directory-github.ts b/tools/librarian/tools/list-directory-github.ts new file mode 100644 index 00000000..e94f66ec --- /dev/null +++ b/tools/librarian/tools/list-directory-github.ts @@ -0,0 +1,72 @@ +import { encodePathSegments } from "@harness/utils/path"; +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { + type GitHubClient, + normalizeRepository, + parseJson, + textResult, +} from "../lib/github-client"; + +const Params = Type.Object({ + path: Type.String({ + description: "Directory path within the selected repository to list", + }), + repository: Type.String({ + description: + 'Single GitHub repository to inspect. Use "owner/repo" or "https://github.com/owner/repo". Do not pass GitHub search pages such as "https://github.com/search".', + }), + limit: Type.Optional( + Type.Number({ + description: + "Maximum number of entries to return (default: 100, max: 1000)", + minimum: 1, + maximum: 1000, + }), + ), +}); + +interface Entry { + name: string; + type: "file" | "dir" | string; +} + +interface FileMetadata { + type?: string; +} + +export function createListDirectoryGitHubTool( + client: GitHubClient, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "list_directory_github", + label: "List GitHub Directory", + description: "List the contents of a directory in a GitHub repository.", + parameters: Params, + async execute(_id, params, signal) { + const repository = normalizeRepository(params.repository); + const path = params.path === "." ? "" : params.path.replace(/^\//, ""); + const encodedPath = encodePathSegments(path); + const data = parseJson<Entry[] | FileMetadata>( + await client.api(`repos/${repository}/contents/${encodedPath}`, cwd, { + signal, + }), + ); + if (!Array.isArray(data)) { + throw new Error( + `Cannot list "${path || "/"}" because GitHub returned ${data.type ?? "file"} metadata instead of a directory listing.`, + ); + } + const names = data + .map((entry) => (entry.type === "dir" ? `${entry.name}/` : entry.name)) + .sort( + (a, b) => + Number(b.endsWith("/")) - Number(a.endsWith("/")) || + a.localeCompare(b), + ) + .slice(0, params.limit ?? 100); + return textResult(names.join("\n"), { entries: names }); + }, + }; +} diff --git a/tools/librarian/tools/list-repositories.ts b/tools/librarian/tools/list-repositories.ts new file mode 100644 index 00000000..7c74146f --- /dev/null +++ b/tools/librarian/tools/list-repositories.ts @@ -0,0 +1,169 @@ +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { type GitHubClient, parseJson, textResult } from "../lib/github-client"; + +const Params = Type.Object({ + pattern: Type.Optional( + Type.String({ + description: "Optional pattern to match in repository names", + }), + ), + organization: Type.Optional( + Type.String({ + description: "Optional organization name to filter repositories", + }), + ), + language: Type.Optional( + Type.String({ + description: "Optional programming language to filter repositories", + }), + ), + limit: Type.Optional( + Type.Number({ + description: + "Maximum number of repositories to return (default: 30, max: 100)", + minimum: 1, + maximum: 100, + }), + ), + offset: Type.Optional( + Type.Number({ + description: + "Number of results to skip for pagination (default: 0). Must be divisible by limit.", + minimum: 0, + }), + ), +}); + +interface Repo { + full_name: string; + description?: string | null; + language?: string | null; + stargazers_count?: number; + forks_count?: number; + private: boolean; +} + +interface SearchResponse { + total_count?: number; + items?: Repo[]; +} + +export function createListRepositoriesTool( + client: GitHubClient, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "list_repositories", + label: "List GitHub Repositories", + description: + "List repositories on GitHub, prioritizing repositories the user can already access.", + parameters: Params, + async execute(_id, params, signal) { + const limit = params.limit ?? 30; + const offset = params.offset ?? 0; + if (offset % limit !== 0) { + throw new Error( + `offset (${offset}) must be divisible by limit (${limit})`, + ); + } + + const repositories = await listRepositories(client, cwd, params, signal); + const details = { + repositories: repositories.slice(0, limit).map(formatRepo), + totalCount: repositories.length, + }; + const text = details.repositories + .map( + (repo) => + `${repo.name}${repo.private ? " private" : ""}${repo.language ? ` ${repo.language}` : ""}\n${repo.description ?? ""}\nstars: ${repo.stargazersCount ?? 0} forks: ${repo.forksCount ?? 0}`, + ) + .join("\n\n"); + return textResult(text || "No repositories", details); + }, + }; +} + +async function listRepositories( + client: GitHubClient, + cwd: string, + params: { + pattern?: string; + organization?: string; + language?: string; + limit?: number; + offset?: number; + }, + signal?: AbortSignal, +): Promise<Repo[]> { + const limit = params.limit ?? 30; + const offset = params.offset ?? 0; + const page = String(Math.floor(offset / limit) + 1); + const perPage = String(Math.min(limit, 100)); + + if (!params.pattern) { + const endpoint = params.organization + ? `orgs/${params.organization}/repos` + : "user/repos"; + const fields: Record<string, string> = params.organization + ? { per_page: perPage, page, sort: "updated" } + : { + per_page: perPage, + page, + sort: "updated", + affiliation: "owner,collaborator,organization_member", + }; + const repos = parseJson<Repo[]>( + await client.api(endpoint, cwd, { method: "GET", fields, signal }), + ); + return filterRepos(repos, params); + } + + const query = [`${params.pattern} in:name`]; + if (params.organization) query.push(`org:${params.organization}`); + if (params.language) query.push(`language:${params.language}`); + const response = parseJson<SearchResponse>( + await client.api("search/repositories", cwd, { + method: "GET", + fields: { + q: query.join(" "), + per_page: perPage, + page, + sort: "stars", + order: "desc", + }, + signal, + }), + ); + return response.items ?? []; +} + +function filterRepos( + repos: Repo[], + params: { organization?: string; language?: string }, +): Repo[] { + return repos.filter((repo) => { + if (params.organization) { + const owner = repo.full_name.split("/")[0]?.toLowerCase(); + if (owner !== params.organization.toLowerCase()) return false; + } + if ( + params.language && + repo.language?.toLowerCase() !== params.language.toLowerCase() + ) { + return false; + } + return true; + }); +} + +function formatRepo(repo: Repo) { + return { + name: repo.full_name, + description: repo.description, + language: repo.language, + stargazersCount: repo.stargazers_count, + forksCount: repo.forks_count, + private: repo.private, + }; +} diff --git a/tools/librarian/tools/read-github.ts b/tools/librarian/tools/read-github.ts new file mode 100644 index 00000000..413228f7 --- /dev/null +++ b/tools/librarian/tools/read-github.ts @@ -0,0 +1,113 @@ +import { encodePathSegments } from "@harness/utils/path"; +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import type { GitHubClient } from "../lib/github-client"; +import { + normalizeRepository, + parseJson, + textResult, +} from "../lib/github-client"; + +const Params = Type.Object({ + path: Type.String({ + description: "Path within the selected repository to read", + }), + read_range: Type.Optional( + Type.Array(Type.Number(), { + minItems: 2, + maxItems: 2, + description: + "Optional [start_line, end_line] to limit the read to specific lines", + }), + ), + repository: Type.String({ + description: + 'Single GitHub repository to read from. Use "owner/repo" or "https://github.com/owner/repo". Do not pass GitHub search pages such as "https://github.com/search".', + }), +}); + +interface ContentResponse { + content: string; + encoding: string; + type?: string; +} + +interface DirectoryEntry { + name: string; + type: "file" | "dir" | string; +} + +const MAX_READ_BYTES = 128 * 1024; + +export function createReadGitHubTool( + client: GitHubClient, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "read_github", + label: "Read GitHub", + description: `Read a file from a GitHub repository. +If the path resolves to a directory, return a directory listing instead. + +Use this when you need the contents of a specific file, or a quick listing for a path that may be a file or directory. + +Returned file contents include line numbers. Directory listings use a trailing "/" for subdirectories. Files larger than 128KB require read_range.`, + parameters: Params, + async execute(_id, params, signal) { + const repository = normalizeRepository(params.repository); + const path = params.path.replace(/^\//, ""); + const encodedPath = encodePathSegments(path); + const json = await client.api( + `repos/${repository}/contents/${encodedPath}`, + cwd, + { signal }, + ); + const data = parseJson<ContentResponse | DirectoryEntry[]>(json); + + if (Array.isArray(data)) { + const entries = data + .map((entry) => + entry.type === "dir" ? `${entry.name}/` : entry.name, + ) + .sort(); + const text = entries.join("\n"); + if (Buffer.byteLength(text, "utf8") > MAX_READ_BYTES) { + throw new Error( + `Directory listing is too large (${Math.round(Buffer.byteLength(text, "utf8") / 1024)}KB). Use read_range or list_directory_github with a limit.`, + ); + } + return textResult(text, { entries, isDirectory: true }); + } + + if (!data.content || !data.encoding) { + throw new Error( + `Cannot read "${path || "/"}" because GitHub returned ${data.type ?? "unsupported"} metadata instead of file contents.`, + ); + } + + const decoded = + data.encoding === "base64" + ? Buffer.from(data.content.replace(/\n/g, ""), "base64").toString( + "utf8", + ) + : data.content; + const lines = decoded.split("\n"); + const start = params.read_range?.[0] ?? 1; + const end = params.read_range?.[1] ?? lines.length; + const content = lines + .slice(Math.max(0, start - 1), end) + .map((line, index) => `${start + index}: ${line}`) + .join("\n"); + if (Buffer.byteLength(content, "utf8") > MAX_READ_BYTES) { + throw new Error( + `File is too large (${Math.round(Buffer.byteLength(content, "utf8") / 1024)}KB). The file has ${lines.length} lines. Please retry with read_range.`, + ); + } + return textResult(content, { + path, + range: [start, end], + lineCount: lines.length, + }); + }, + }; +} diff --git a/tools/librarian/tools/search-github.ts b/tools/librarian/tools/search-github.ts new file mode 100644 index 00000000..0ec62c5c --- /dev/null +++ b/tools/librarian/tools/search-github.ts @@ -0,0 +1,101 @@ +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { + type GitHubClient, + normalizeRepository, + parseJson, + textResult, +} from "../lib/github-client"; + +const Params = Type.Object({ + pattern: Type.String({ + description: + "GitHub code search query to run inside the selected repository. Put search terms, operators, and qualifiers here.", + }), + path: Type.Optional( + Type.String({ + description: "Optional path within the repository to limit the search", + }), + ), + repository: Type.String({ + description: + 'Single GitHub repository to search. Use "owner/repo" or "https://github.com/owner/repo". Do not pass GitHub search pages such as "https://github.com/search".', + }), + limit: Type.Optional( + Type.Number({ + description: + "Maximum number of search results to return (default: 30, max: 100)", + minimum: 1, + maximum: 100, + }), + ), + offset: Type.Optional( + Type.Number({ + description: + "Number of results to skip for pagination (default: 0). Must be divisible by limit.", + minimum: 0, + }), + ), +}); + +interface SearchResponse { + total_count?: number; + items?: Array<{ + path: string; + text_matches?: Array<{ property?: string; fragment?: string }>; + }>; +} + +export function createSearchGitHubTool( + client: GitHubClient, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "search_github", + label: "Search GitHub", + description: + "Search for code patterns inside a single GitHub repository and return matches grouped by file, with surrounding context.", + parameters: Params, + async execute(_id, params, signal) { + const limit = params.limit ?? 30; + const offset = params.offset ?? 0; + if (offset % limit !== 0) + throw new Error( + `offset (${offset}) must be divisible by limit (${limit})`, + ); + const repository = normalizeRepository(params.repository); + const query = `${params.pattern} repo:${repository}${params.path ? ` path:${params.path}` : ""}`; + const json = await client.api("search/code", cwd, { + method: "GET", + fields: { + q: query, + per_page: String(Math.min(limit, 100)), + page: String(Math.floor(offset / limit) + 1), + }, + headers: ["Accept: application/vnd.github.v3.text-match+json"], + signal, + }); + const response = parseJson<SearchResponse>(json); + const byFile = new Map<string, string[]>(); + for (const item of response.items ?? []) { + const chunks = byFile.get(item.path) ?? []; + for (const match of item.text_matches ?? []) { + if (match.property === "content" && match.fragment) + chunks.push(match.fragment.trim()); + } + byFile.set(item.path, chunks); + } + const details = { + results: Array.from(byFile, ([file, chunks]) => ({ file, chunks })), + totalCount: response.total_count ?? 0, + }; + const text = details.results + .map( + (result) => + `${result.file}\n${result.chunks.map((chunk) => ` ${chunk}`).join("\n")}`, + ) + .join("\n\n"); + return textResult(text || "No results", details); + }, + }; +} diff --git a/tools/librarian/types.ts b/tools/librarian/types.ts new file mode 100644 index 00000000..aea485ad --- /dev/null +++ b/tools/librarian/types.ts @@ -0,0 +1,14 @@ +import { type Static, Type } from "typebox"; + +export const LibrarianParams = Type.Object({ + query: Type.String({ + description: "Question about the codebase.", + }), + context: Type.Optional( + Type.String({ + description: "Optional context.", + }), + ), +}); + +export type LibrarianParamsType = Static<typeof LibrarianParams>; diff --git a/tools/look-at/index.ts b/tools/look-at/index.ts new file mode 100644 index 00000000..1268a524 --- /dev/null +++ b/tools/look-at/index.ts @@ -0,0 +1,179 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { defineSubagent } from "@harness/agent-kit"; +import type { SubagentModel } from "@harness/agent-kit/types"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { referencesImageFiles } from "./utils"; + +const MODEL_CANDIDATES: SubagentModel[] = [ + { + provider: "neuralwatt", + model: "kimi-k2.5-fast", + thinking: "off", + weight: 1, + }, + { + provider: "neuralwatt", + model: "kimi-k2.6-fast", + thinking: "off", + weight: 1, + }, + { + provider: "neuralwatt", + model: "qwen3.6-35b-fast", + thinking: "off", + weight: 1, + }, + { + provider: "synthetic", + model: "moonshotai/Kimi-K2.6", + thinking: "off", + weight: 1, + }, + { + provider: "openai-codex", + model: "gpt-5.3-codex-spark", + thinking: "off", + weight: 1, + }, +]; + +const ANALYSIS_SYSTEM_PROMPT = `You are an AI assistant that analyzes images for a software engineer. + +# Core Principles + +- Be concise and direct. Minimize output while maintaining accuracy. +- Focus only on the user's objective. Do not add tangential information. +- No preamble, disclaimers, or summaries unless specifically relevant. +- Never start with flattery ("great question", "interesting file", etc.). +- A wrong answer is worse than no answer. When uncertain, say so. + +# Precision Guidelines + +- Describe exactly what you see. Do not guess or infer beyond what is visible. +- When analyzing code screenshots: reference specific line numbers and symbols. +- When analyzing UI: describe layout, components, text, colors, and hierarchy. +- When analyzing errors: extract the exact error message, stack trace, and root cause. +- When analyzing diagrams: describe nodes, relationships, labels, and flow. + +# Output Format + +- Use GitHub-flavored Markdown. +- Use code fences with language tags for code snippets. +- No emojis or decorative symbols. +- Keep responses focused and brief.`; + +const NUDGE_TEXT = + "\n\nNote: the current model cannot see images. Use look_at to analyze any image files referenced above."; + +const EXT_TO_MIME: Record<string, string> = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + svg: "image/svg+xml", +}; + +const LookAtParams = Type.Object({ + path: Type.String({ + description: "Path to the image file to analyze (relative or absolute).", + }), + objective: Type.String({ + description: + "What you want to learn from this image (e.g., 'describe the UI layout', 'extract the error message', 'read the text in this diagram').", + }), + context: Type.Optional( + Type.String({ + description: + "Broader context for why you need this analysis. Helps the vision model focus on what matters.", + }), + ), +}); + +function mimeTypeFromPath(path: string): string | null { + const ext = path.split(".").pop()?.toLowerCase(); + if (!ext) return null; + return EXT_TO_MIME[ext] ?? null; +} + +export default function lookAt(pi: ExtensionAPI): void { + const subagent = defineSubagent(pi, { + name: "look_at", + label: "Look At", + description: `Analyze an image file using a vision-capable model. Returns a text description of the image content. + +Use this tool when you need to understand or extract information from an image file (PNG, JPG, GIF, WebP, etc.). The current model cannot see images directly -- this tool delegates to a vision model that can. + +Always provide a clear objective describing what you want to learn from the image. + +## When to use this tool +- Analyzing screenshots, diagrams, charts, or photographs +- Extracting text or error messages from images +- Describing visual content that the Read tool cannot interpret +- Comparing visual elements (use context to describe what to compare) + +## When NOT to use this tool +- For source code or plain text files where you need exact contents -- use read instead +- When you need to edit the file afterward +- For simple file reading where no interpretation is needed`, + systemPrompt: ANALYSIS_SYSTEM_PROMPT, + tools: [], + models: MODEL_CANDIDATES, + parameters: LookAtParams, + buildPrompt(params, ctx) { + const absolutePath = resolve(ctx.cwd, params.path); + const mimeType = mimeTypeFromPath(absolutePath); + if (!mimeType) + throw new Error(`Unsupported image format for ${absolutePath}`); + + const buffer = readFileSync(absolutePath); + const userText = params.context + ? `Context: ${params.context}\n\nObjective: ${params.objective}` + : params.objective; + + return { + text: userText, + images: [ + { + type: "image" as const, + data: buffer.toString("base64"), + mimeType, + }, + ], + }; + }, + }); + + subagent.subscribe(pi); + pi.registerTool(subagent.tool); + pi.registerTool(subagent.resumeTool); + + pi.on("input", (event, ctx) => { + const warn = (message: string) => + ctx.ui.notify(`[look_at] ${message}`, "warning"); + const model = ctx.model; + if (!model) return; + + const modelHasVision = model.input.includes("image"); + + if (modelHasVision) { + warn("Model with vision called `look_at` tool."); + return; + } + + const hasImageRefs = referencesImageFiles(event.text); + const hasAttachedImages = Boolean(event.images?.length); + + if (!hasImageRefs && !hasAttachedImages) { + warn( + "Model called `look_at` tool when message didn't include image reference or image attachments.", + ); + return; + } + + return { action: "transform", text: event.text + NUDGE_TEXT }; + }); +} diff --git a/extensions/tools/look-at/utils.test.ts b/tools/look-at/utils.test.ts similarity index 100% rename from extensions/tools/look-at/utils.test.ts rename to tools/look-at/utils.test.ts diff --git a/extensions/tools/look-at/utils.ts b/tools/look-at/utils.ts similarity index 100% rename from extensions/tools/look-at/utils.ts rename to tools/look-at/utils.ts diff --git a/tools/oracle/index.ts b/tools/oracle/index.ts new file mode 100644 index 00000000..79263d34 --- /dev/null +++ b/tools/oracle/index.ts @@ -0,0 +1,42 @@ +import { defineSubagent } from "@harness/agent-kit"; +import type { SubagentToolSpec } from "@harness/agent-kit/types"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { MODEL_CANDIDATES } from "./models"; +import { buildPrompt, ORACLE_SYSTEM_PROMPT } from "./prompt"; +import { OracleParams } from "./types"; + +const tools: SubagentToolSpec[] = [ + { name: "read", type: "native" }, + { name: "grep", type: "native" }, + { name: "find", type: "native" }, + { name: "read_url", type: "native" }, + { name: "find_sessions", type: "native" }, + { name: "read_session", type: "native" }, + { name: "synthetic_web_search", type: "native" }, +]; + +const extensionPaths = [ + "./tools", + "./extensions/breadcrumbs", + "npm:@aliou/pi-synthetic", +]; + +export default async function oracle(pi: ExtensionAPI): Promise<void> { + const subagent = defineSubagent(pi, { + name: "oracle", + label: "Oracle", + description: + "Senior advisor subagent for technical guidance, code review, architecture advice, and planning.", + systemPrompt: ORACLE_SYSTEM_PROMPT, + parameters: OracleParams, + buildPrompt, + tools, + extensionPaths, + models: MODEL_CANDIDATES, + }); + + subagent.subscribe(pi); + + pi.registerTool(subagent.tool); + pi.registerTool(subagent.resumeTool); +} diff --git a/tools/oracle/models/index.ts b/tools/oracle/models/index.ts new file mode 100644 index 00000000..db3df16a --- /dev/null +++ b/tools/oracle/models/index.ts @@ -0,0 +1,10 @@ +import type { SubagentModel } from "@harness/agent-kit/models"; + +export const MODEL_CANDIDATES: SubagentModel[] = [ + { + provider: "openai-codex", + model: "gpt-5.4", + thinking: "medium", + weight: 1, + }, +]; diff --git a/tools/oracle/prompt.ts b/tools/oracle/prompt.ts new file mode 100644 index 00000000..51cc74f0 --- /dev/null +++ b/tools/oracle/prompt.ts @@ -0,0 +1,78 @@ +import type { SubagentPromptResult } from "@harness/agent-kit/types"; +import { isNotNil } from "@harness/utils"; +import type { OracleParamsType } from "./types"; + +export const ORACLE_SYSTEM_PROMPT = `You are the Oracle - an expert AI advisor with advanced reasoning capabilities. + +Your role is to provide high-quality technical guidance, code reviews, architectural advice, and strategic planning for software engineering tasks. + +You are a subagent inside an AI coding system, called when the main agent needs a smarter, more capable model. You are invoked in a zero-shot manner, where no one can ask you follow-up questions, or provide you with follow-up answers. + +Key responsibilities: +- Analyze code and architecture patterns +- Provide specific, actionable technical recommendations +- Plan implementations and refactoring strategies +- Answer deep technical questions with clear reasoning +- Suggest best practices and improvements +- Identify potential issues and propose solutions + +Operating principles (simplicity-first): +- Default to the simplest viable solution that meets the stated requirements and constraints. +- Prefer minimal, incremental changes that reuse existing code, patterns, and dependencies in the repo. Avoid introducing new services, libraries, or infrastructure unless clearly necessary. +- Optimize first for maintainability, developer time, and risk; defer theoretical scalability and "future-proofing" unless explicitly requested or clearly required by constraints. +- Apply YAGNI and KISS; avoid premature optimization. +- Provide one primary recommendation. Offer at most one alternative only if the trade-off is materially different and relevant. +- Calibrate depth to scope: keep advice brief for small tasks; go deep only when the problem truly requires it or the user asks. +- Include a rough effort/scope signal (e.g., S <1h, M 1–3h, L 1–2d, XL >2d) when proposing changes. +- Stop when the solution is "good enough." Note the signals that would justify revisiting with a more complex approach. + +Tool usage: +- Use attached files and provided context first. Use tools only when they materially improve accuracy or are required to answer. +- Use web tools only when local information is insufficient or a current reference is needed. +- When calling local file tools, construct paths from the exact working directory or workspace root above. +- Never invent placeholder roots like /workspace, /repo, or /project. +- If you only know a repo-relative path, join it to the workspace root above before calling local file tools. +- If the working directory or workspace root is unknown, use file-search tools first instead of guessing absolute paths. + +Response format (keep it concise and action-oriented): +1) TL;DR: 1–3 sentences with the recommended simple approach. +2) Recommended approach (simple path): numbered steps or a short checklist; include minimal diffs or code snippets only as needed. +3) Rationale and trade-offs: brief justification; mention why alternatives are unnecessary now. +4) Risks and guardrails: key caveats and how to mitigate them. +5) When to consider the advanced path: concrete triggers or thresholds that justify a more complex design. +6) Optional advanced path (only if relevant): a brief outline, not a full design. + +Guidelines: +- Use your reasoning to provide thoughtful, well-structured, and pragmatic advice. +- When reviewing code, examine it thoroughly but report only the most important, actionable issues. +- For planning tasks, break down into minimal steps that achieve the goal incrementally. +- Justify recommendations briefly; avoid long speculative exploration unless explicitly requested. +- Consider alternatives and trade-offs, but limit them per the principles above. +- Be thorough but concise—focus on the highest-leverage insights. + +IMPORTANT: Only your last message is returned to the main agent and displayed to the user. Your last message should be comprehensive yet focused, with a clear, simple recommendation that helps the user act immediately.`; + +export function buildPrompt(params: OracleParamsType): SubagentPromptResult { + const task = `Task: +<task> +${params.task} +</task>`; + + const context = params.context + ? `Context: +<context> +${params.context} +</context>` + : undefined; + + const files = params.files?.length + ? `Files to inspect: +<files> +${params.files.map((file) => `- ${file}`).join("\n")} +</files> + +If files are provided, read them before giving file-specific recommendations.` + : undefined; + + return { text: [task, context, files].filter(isNotNil).join("\n\n") }; +} diff --git a/tools/oracle/types.ts b/tools/oracle/types.ts new file mode 100644 index 00000000..ea6e536a --- /dev/null +++ b/tools/oracle/types.ts @@ -0,0 +1,21 @@ +import { type Static, Type } from "typebox"; + +export const OracleParams = Type.Object({ + task: Type.String({ + description: "The task or question you want the oracle to help with.", + }), + context: Type.Optional( + Type.String({ + description: "Optional background context.", + }), + ), + files: Type.Optional( + Type.Array( + Type.String({ + description: "Optional attached files for analysis.", + }), + ), + ), +}); + +export type OracleParamsType = Static<typeof OracleParams>; diff --git a/tools/read-session/index.ts b/tools/read-session/index.ts new file mode 100644 index 00000000..be34500b --- /dev/null +++ b/tools/read-session/index.ts @@ -0,0 +1,40 @@ +import { defineSubagent } from "@harness/agent-kit"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { MODEL_CANDIDATES } from "./models"; +import { SYSTEM_PROMPT } from "./prompt"; +import { tools } from "./tools"; +import { ReadSessionParams } from "./types"; + +export default async function readSession(pi: ExtensionAPI): Promise<void> { + const { tool, subscribe } = defineSubagent(pi, { + name: "read_session", + label: "Read Session", + description: "Extract specific information from a past Pi coding session.", + systemPrompt: SYSTEM_PROMPT, + // TODO: Tools from the extension + tools: tools.map((t) => ({ type: "custom", name: t.name, spec: () => t })), + models: MODEL_CANDIDATES, + parameters: ReadSessionParams, + buildPrompt({ targetSessionId: sessionId, goal }) { + // Log + return { + text: [ + `<target_session_id>${sessionId}</target_session_id>`, + `<goal>${goal}</goal>`, + ].join("\n"), + }; + }, + + // Store a custom entry with the target session id. + beforeExecute: async (params, session, _ctx) => { + // Log + session.sessionManager.appendCustomEntry("read-session-state", { + targetSessionId: params.targetSessionId, + goal: params.goal, + }); + }, + }); + + pi.registerTool(tool); + subscribe(pi); +} diff --git a/tools/read-session/models/index.ts b/tools/read-session/models/index.ts new file mode 100644 index 00000000..7d50984a --- /dev/null +++ b/tools/read-session/models/index.ts @@ -0,0 +1,34 @@ +import type { SubagentModel } from "@harness/agent-kit/types"; + +export const MODEL_CANDIDATES: SubagentModel[] = [ + { + provider: "neuralwatt", + model: "kimi-k2.5-fast", + thinking: "off", + weight: 1, + }, + { + provider: "neuralwatt", + model: "kimi-k2.6-fast", + thinking: "off", + weight: 1, + }, + { + provider: "neuralwatt", + model: "qwen3.6-35b-fast", + thinking: "off", + weight: 1, + }, + { + provider: "synthetic", + model: "moonshotai/Kimi-K2.6", + thinking: "off", + weight: 1, + }, + { + provider: "openai-codex", + model: "gpt-5.3-codex-spark", + thinking: "off", + weight: 1, + }, +]; diff --git a/tools/read-session/prompt.ts b/tools/read-session/prompt.ts new file mode 100644 index 00000000..2606bd6e --- /dev/null +++ b/tools/read-session/prompt.ts @@ -0,0 +1,20 @@ +export const SYSTEM_PROMPT = `You are a session analyzer. Your task is to extract specific information from a Pi coding agent session. + +You have access to tools that let you query the session: +- \`get_session_overview\`: Get basic session metadata +- \`get_messages\`: Paginate through messages (user or assistant) +- \`get_tool_calls\`: Look at specific tool calls +- \`get_tool_results\`: Look at tool results +- \`get_compactions\`: See session compactions +- \`find_messages\`: Search for messages by keyword + +Guidelines: +1. Always start with \`get_session_overview\` to understand the session +2. Always begin your response with a brief header: session name (if available), working directory, and date +3. For keyword-based goals, use \`find_messages\` first +4. Use \`get_compactions\` to understand session history and context +5. Paginate through results using offset/limit - never request everything at once +6. Focus only on extracting what's relevant to the goal +7. Respond in markdown with clear, concise extraction +8. Be specific: quote relevant snippets or summarize findings +9. Include the list of tools used in the session (from toolNames in overview) when relevant to the goal`; diff --git a/tools/read-session/tools/index.ts b/tools/read-session/tools/index.ts new file mode 100644 index 00000000..b02b45c2 --- /dev/null +++ b/tools/read-session/tools/index.ts @@ -0,0 +1,3 @@ +import { sessionOverview } from "./session-overview"; + +export const tools = [sessionOverview]; diff --git a/tools/read-session/tools/session-overview.ts b/tools/read-session/tools/session-overview.ts new file mode 100644 index 00000000..21fe1a62 --- /dev/null +++ b/tools/read-session/tools/session-overview.ts @@ -0,0 +1,32 @@ +import { defineTool, SessionManager } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { getTargetSessionPath } from "./utils"; + +export const sessionOverview = defineTool({ + name: "get_session_overview", + label: "Get Session Overview", + description: "Get an overview of a session", + parameters: Type.Object({}), + execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => { + const sessionPath = await getTargetSessionPath(ctx); + const sm = SessionManager.open(sessionPath); + const entries = sm.getEntries(); + + const overview = { + id: sm.getSessionId(), + cwd: sm.getCwd(), + name: sm.getSessionName(), + created: sm.getHeader()?.timestamp, + messageCount: entries.filter( + (e) => e.type === "message" || e.type === "custom_message", + ).length, + compactionCount: entries.filter((e) => e.type === "compaction").length, + parentSessionPath: sm.getHeader()?.parentSession, + }; + + return { + content: [{ type: "text", text: JSON.stringify(overview) }], + details: { overview }, + }; + }, +}); diff --git a/tools/read-session/tools/utils.test.ts b/tools/read-session/tools/utils.test.ts new file mode 100644 index 00000000..280f46ad --- /dev/null +++ b/tools/read-session/tools/utils.test.ts @@ -0,0 +1,107 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getTargetSessionPath } from "./utils"; + +vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { + const actual = await importOriginal<object>(); + return { + ...actual, + getAgentDir: () => mockSessionsRoot, + }; +}); + +let mockSessionsRoot: string; + +const UUID_A = "019df481-d4ff-707d-855a-123f134ab466"; +const UUID_B = "019df486-1371-749f-b2b0-f70783cd80e7"; +const UUID_C = "019df499-aaaa-749f-b2b0-f70783cd80e7"; + +const FILE_A = `2026-05-04T19-40-42-624Z_${UUID_A}.jsonl`; +const FILE_B = `2026-05-04T19-45-20-754Z_${UUID_B}.jsonl`; +const FILE_C = `2026-05-04T20-10-00-000Z_${UUID_C}.jsonl`; + +function makeCtx(targetSessionId: string): ExtensionContext { + return { + sessionManager: { + getEntries: () => [ + { + type: "custom", + customType: "read-session-state", + data: { targetSessionId, goal: "test" }, + }, + ], + }, + } as unknown as ExtensionContext; +} + +beforeEach(() => { + mockSessionsRoot = mkdtempSync(join(tmpdir(), "read-session-test-")); + const dirA = join(mockSessionsRoot, "sessions", "--project-a--"); + const dirB = join(mockSessionsRoot, "sessions", "--project-b--"); + mkdirSync(dirA, { recursive: true }); + mkdirSync(dirB, { recursive: true }); + + const mkHeader = (id: string, ts: string, cwd: string) => + JSON.stringify({ type: "session", version: 3, id, timestamp: ts, cwd }); + + writeFileSync( + join(dirA, FILE_A), + `${mkHeader(UUID_A, "2026-05-04T19:40:42.624Z", "/project-a")}\n`, + ); + writeFileSync( + join(dirB, FILE_B), + `${mkHeader(UUID_B, "2026-05-04T19:45:20.754Z", "/project-b")}\n`, + ); + writeFileSync( + join(dirA, FILE_C), + `${mkHeader(UUID_C, "2026-05-04T20:10:00.000Z", "/project-a")}\n`, + ); +}); + +afterEach(() => { + rmSync(mockSessionsRoot, { recursive: true, force: true }); +}); + +describe("getTargetSessionPath", () => { + it.each([ + ["full UUID in project A", UUID_A, FILE_A, "--project-a--"], + ["full UUID in project B", UUID_B, FILE_B, "--project-b--"], + ["UUID prefix", "019df481-d4ff", FILE_A, "--project-a--"], + ] as const)("resolves %s", async (_label, id, filename, dir) => { + const result = await getTargetSessionPath(makeCtx(id)); + expect(result).toBe(join(mockSessionsRoot, "sessions", dir, filename)); + }); + + it("passes through absolute file paths", async () => { + const path = "/some/absolute/path.jsonl"; + const result = await getTargetSessionPath(makeCtx(path)); + expect(result).toBe(path); + }); + + it("passes through .jsonl filenames without slashes", async () => { + const result = await getTargetSessionPath(makeCtx("session.jsonl")); + expect(result).toBe("session.jsonl"); + }); + + it("throws on non-existent UUID", async () => { + await expect( + getTargetSessionPath(makeCtx("deadbeef-0000-0000-0000-000000000000")), + ).rejects.toThrow(/No session found/); + }); + + it("throws on ambiguous prefix matching multiple sessions", async () => { + await expect(getTargetSessionPath(makeCtx("019df4"))).rejects.toThrow( + /Ambiguous/, + ); + }); + + it("resolves exact UUID when prefix is ambiguous", async () => { + const result = await getTargetSessionPath(makeCtx(UUID_C)); + expect(result).toBe( + join(mockSessionsRoot, "sessions", "--project-a--", FILE_C), + ); + }); +}); diff --git a/tools/read-session/tools/utils.ts b/tools/read-session/tools/utils.ts new file mode 100644 index 00000000..1aecdb12 --- /dev/null +++ b/tools/read-session/tools/utils.ts @@ -0,0 +1,66 @@ +import { globSync } from "node:fs"; +import { basename, join } from "node:path"; +import { isEmptyArray, isNil, isSoleArray } from "@harness/utils"; +import { + type CustomEntry, + type ExtensionContext, + getAgentDir, + type SessionEntry, +} from "@mariozechner/pi-coding-agent"; +import type { ReadSessionState } from "../types"; + +const isReadSessionStateEntry = ( + e: SessionEntry, +): e is CustomEntry<ReadSessionState> => + e.type === "custom" && e.customType === "read-session-state"; + +export const getTargetSessionId = (ctx: ExtensionContext) => { + const stateEntry: CustomEntry<ReadSessionState> | undefined = + ctx.sessionManager.getEntries().filter(isReadSessionStateEntry).at(0); + + if (isNil(stateEntry) || isNil(stateEntry.data)) { + throw new Error("Missing session state"); + } + + return stateEntry.data.targetSessionId; +}; + +const resolveSessionPath = ( + sessionIdOrPath: string, + sessionsDir: string, +): string => { + // If it looks like a file path, use as-is + if (sessionIdOrPath.includes("/") || sessionIdOrPath.endsWith(".jsonl")) { + return sessionIdOrPath; + } + + // Fast lookup: session filenames are <timestamp>_<uuid>.jsonl, + // so glob the sessions dir for the UUID instead of parsing all 2000+ files. + const pattern = `**/*${sessionIdOrPath}*.jsonl`; + const matches = globSync(pattern, { cwd: sessionsDir }); + + if (isEmptyArray(matches)) { + throw new Error(`No session found with id matching '${sessionIdOrPath}'`); + } + + if (!isSoleArray(matches)) { + // Multiple matches — try exact UUID match (filename format: <timestamp>_<uuid>.jsonl) + const exact = matches.find((m) => { + const base = basename(m, ".jsonl"); + const uuid = base.split("_").slice(1).join("_"); + return uuid === sessionIdOrPath; + }); + if (exact) return join(sessionsDir, exact); + throw new Error( + `Ambiguous session id '${sessionIdOrPath}' matched ${matches.length} sessions. Provide a longer prefix.`, + ); + } + + return join(sessionsDir, matches[0]); +}; + +export const getTargetSessionPath = async (ctx: ExtensionContext) => { + const targetSessionId = getTargetSessionId(ctx); + const sessionsDir = join(getAgentDir(), "sessions"); + return resolveSessionPath(targetSessionId, sessionsDir); +}; diff --git a/tools/read-session/types.ts b/tools/read-session/types.ts new file mode 100644 index 00000000..5b545830 --- /dev/null +++ b/tools/read-session/types.ts @@ -0,0 +1,15 @@ +import { type Static, Type } from "typebox"; + +export const ReadSessionParams = Type.Object({ + targetSessionId: Type.String({ description: "Session UUID" }), + goal: Type.String({ + description: "What information to extract from the session", + }), +}); + +export type ReadSessionParamsType = Static<typeof ReadSessionParams>; + +export interface ReadSessionState { + targetSessionId: string; + goal: string; +} diff --git a/tools/read-url/fetch.ts b/tools/read-url/fetch.ts new file mode 100644 index 00000000..0749a9b3 --- /dev/null +++ b/tools/read-url/fetch.ts @@ -0,0 +1,184 @@ +/** + * Read URL fetch logic and image handling. + */ + +import { writeFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; +import type { ReadUrlHandler } from "./handlers"; +import type { HandlerImage } from "./handlers/types"; +import type { + ExecuteResult, + FetchLike, + NativeReadTool, + ReadContentBlock, +} from "./types"; +import { writeTempFilePreview } from "./utils/temp-file-preview"; + +export async function executeReadUrlRequest( + input: string, + signal: AbortSignal | undefined, + handlers: ReadUrlHandler[], + nativeRead: NativeReadTool, + fetchImpl: FetchLike = fetch, +): Promise<ExecuteResult> { + const trimmedInput = input.trim(); + + if (!trimmedInput) { + throw new Error("url is required"); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmedInput); + } catch { + throw new Error(`Invalid URL: ${trimmedInput}`); + } + + const handler = handlers.find((candidate) => candidate.matches(parsedUrl)); + if (!handler) { + throw new Error("No handler available for this URL"); + } + + const data = await handler.fetchData(parsedUrl, signal); + const markdown = data.markdown; + + // Write full content to a temp file so the agent can read it with offset/limit. + // Only the preview goes into the LLM context to avoid blowing it up. + const { preview, tempFilePath, totalLines } = await writeTempFilePreview( + markdown, + { slug: trimmedInput }, + ); + + const content: ReadContentBlock[] = [{ type: "text", text: preview }]; + + let attachedImageCount = 0; + let skippedImageCount = 0; + const images = data.images ?? []; + + if (images.length > 0) { + const tempDir = join(tempFilePath, ".."); + for (const [index, image] of images.entries()) { + try { + const tempPath = await fetchRemoteImageToTempFile( + image, + tempDir, + index, + signal, + fetchImpl, + ); + + const imageResult = await nativeRead.execute( + `read-url-image-${index + 1}`, + { path: tempPath }, + signal, + undefined, + ); + + if ( + !imageResult || + typeof imageResult !== "object" || + !("content" in imageResult) || + !Array.isArray(imageResult.content) || + ("isError" in imageResult && imageResult.isError) + ) { + skippedImageCount += 1; + continue; + } + + content.push(...(imageResult.content as ReadContentBlock[])); + attachedImageCount += 1; + } catch { + skippedImageCount += 1; + } + } + } + + return { + content, + details: { + url: trimmedInput, + sourceUrl: data.sourceUrl, + title: data.title, + handler: handler.name, + statusCode: data.statusCode, + statusText: data.statusText, + failed: false, + imageCount: images.length, + attachedImageCount, + skippedImageCount, + tempFilePath, + totalLines, + }, + }; +} + +async function fetchRemoteImageToTempFile( + image: HandlerImage, + tempDir: string, + index: number, + signal: AbortSignal | undefined, + fetchImpl: FetchLike, +): Promise<string> { + const response = await fetchImpl(image.sourceUrl, { signal }); + if (!response.ok) { + throw new Error( + `HTTP ${response.status} ${response.statusText || "Error"} while fetching image`, + ); + } + + const contentType = response.headers.get("content-type"); + const extension = guessImageExtension(contentType, image.sourceUrl); + const bytes = Buffer.from(await response.arrayBuffer()); + const baseName = sanitizeTempBaseName( + image.label || + basename(new URL(image.sourceUrl).pathname) || + `image-${index + 1}`, + ); + const tempPath = join(tempDir, `${index + 1}-${baseName}${extension}`); + + await writeFile(tempPath, bytes); + return tempPath; +} + +export function guessImageExtension( + contentType: string | null | undefined, + imageUrl: string, +): string { + const normalizedContentType = contentType + ?.split(";")[0] + ?.trim() + .toLowerCase(); + const byContentType: Record<string, string> = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/avif": ".avif", + "image/heic": ".heic", + "image/heif": ".heif", + "image/bmp": ".bmp", + "image/tiff": ".tiff", + "image/svg+xml": ".svg", + }; + + if (normalizedContentType && byContentType[normalizedContentType]) { + return byContentType[normalizedContentType]; + } + + try { + const pathname = new URL(imageUrl).pathname; + const extension = extname(pathname).toLowerCase(); + if (extension) { + return extension; + } + } catch { + // Ignore invalid URL here. Caller already validated/fetched it. + } + + return ".img"; +} + +function sanitizeTempBaseName(value: string): string { + return value.replace(/\.[a-z0-9]+$/i, "").replace(/[^a-z0-9_-]+/gi, "-"); +} diff --git a/extensions/tools/read-url/handlers/gist.test.ts b/tools/read-url/handlers/gist.test.ts similarity index 100% rename from extensions/tools/read-url/handlers/gist.test.ts rename to tools/read-url/handlers/gist.test.ts diff --git a/extensions/tools/read-url/handlers/gist.ts b/tools/read-url/handlers/gist.ts similarity index 100% rename from extensions/tools/read-url/handlers/gist.ts rename to tools/read-url/handlers/gist.ts diff --git a/extensions/tools/read-url/handlers/github.test.ts b/tools/read-url/handlers/github.test.ts similarity index 100% rename from extensions/tools/read-url/handlers/github.test.ts rename to tools/read-url/handlers/github.test.ts diff --git a/extensions/tools/read-url/handlers/github.ts b/tools/read-url/handlers/github.ts similarity index 98% rename from extensions/tools/read-url/handlers/github.ts rename to tools/read-url/handlers/github.ts index 6f5e7d68..cce75684 100644 --- a/extensions/tools/read-url/handlers/github.ts +++ b/tools/read-url/handlers/github.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { basename, extname } from "node:path"; +import { encodePathSegments } from "@harness/utils/path"; import type { HandlerData, ReadUrlHandler } from "./types"; interface GitHubRepoResponse { @@ -269,7 +270,7 @@ async function fetchGitHubBlobMarkdown( throw new Error(`Invalid GitHub code URL: ${url.toString()}`); } - const encodedPath = encodePath(info.path); + const encodedPath = encodePathSegments(info.path); const content = await ghApi<GitHubContentResponse>( `/repos/${info.owner}/${info.repo}/contents/${encodedPath}?ref=${encodeURIComponent(info.ref)}`, signal, @@ -315,7 +316,7 @@ async function fetchGitHubTreeMarkdown( } const baseEndpoint = info.path - ? `/repos/${info.owner}/${info.repo}/contents/${encodePath(info.path)}?ref=${encodeURIComponent(info.ref)}` + ? `/repos/${info.owner}/${info.repo}/contents/${encodePathSegments(info.path)}?ref=${encodeURIComponent(info.ref)}` : `/repos/${info.owner}/${info.repo}/contents?ref=${encodeURIComponent(info.ref)}`; const items = await ghApi<GitHubDirectoryItem[]>(baseEndpoint, signal); @@ -719,13 +720,6 @@ function normalizeHost(hostname: string): string { return hostname.toLowerCase().replace(/^www\./, ""); } -function encodePath(path: string): string { - return path - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/"); -} - function stripLeadingSlash(value: string): string { return value.replace(/^\//, ""); } diff --git a/extensions/tools/read-url/handlers/index.ts b/tools/read-url/handlers/index.ts similarity index 100% rename from extensions/tools/read-url/handlers/index.ts rename to tools/read-url/handlers/index.ts diff --git a/extensions/tools/read-url/handlers/markdown-new.ts b/tools/read-url/handlers/markdown-new.ts similarity index 100% rename from extensions/tools/read-url/handlers/markdown-new.ts rename to tools/read-url/handlers/markdown-new.ts diff --git a/extensions/tools/read-url/handlers/tailscale.test.ts b/tools/read-url/handlers/tailscale.test.ts similarity index 100% rename from extensions/tools/read-url/handlers/tailscale.test.ts rename to tools/read-url/handlers/tailscale.test.ts diff --git a/extensions/tools/read-url/handlers/tailscale.ts b/tools/read-url/handlers/tailscale.ts similarity index 100% rename from extensions/tools/read-url/handlers/tailscale.ts rename to tools/read-url/handlers/tailscale.ts diff --git a/extensions/tools/read-url/handlers/twitter.test.ts b/tools/read-url/handlers/twitter.test.ts similarity index 100% rename from extensions/tools/read-url/handlers/twitter.test.ts rename to tools/read-url/handlers/twitter.test.ts diff --git a/extensions/tools/read-url/handlers/twitter.ts b/tools/read-url/handlers/twitter.ts similarity index 100% rename from extensions/tools/read-url/handlers/twitter.ts rename to tools/read-url/handlers/twitter.ts diff --git a/extensions/tools/read-url/handlers/types.ts b/tools/read-url/handlers/types.ts similarity index 100% rename from extensions/tools/read-url/handlers/types.ts rename to tools/read-url/handlers/types.ts diff --git a/tools/read-url/index.ts b/tools/read-url/index.ts new file mode 100644 index 00000000..1d205381 --- /dev/null +++ b/tools/read-url/index.ts @@ -0,0 +1,67 @@ +import type { + AgentToolResult, + ExtensionAPI, +} from "@mariozechner/pi-coding-agent"; +import { createReadTool, defineTool } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import { executeReadUrlRequest } from "./fetch"; +import { + createGistHandler, + createGitHubHandler, + createMarkdownNewHandler, + createTailscaleHandler, + createTwitterHandler, + type ReadUrlHandler, +} from "./handlers"; +import { renderCall, renderResult } from "./render"; +import type { ReadUrlDetails } from "./types"; + +const ReadUrlParams = Type.Object({ + url: Type.String({ + description: "URL to fetch as Markdown via markdown.new", + }), +}); + +function createReadUrlTool(_pi: ExtensionAPI) { + const handlers: ReadUrlHandler[] = [ + createTwitterHandler(), + createGitHubHandler(), + createGistHandler(), + createTailscaleHandler(), + createMarkdownNewHandler(), + ]; + const nativeRead = createReadTool(process.cwd()); + + return defineTool({ + name: "read_url", + label: "Read URL", + description: + "Fetch a URL as Markdown via handlers with markdown.new fallback.", + parameters: ReadUrlParams, + + async execute(_toolCallId, params, signal, _onUpdate, _ctx) { + return executeReadUrlRequest( + params.url, + signal, + handlers, + nativeRead, + fetch, + ); + }, + + renderCall(args, theme) { + return renderCall(args, theme); + }, + renderResult(result, options, theme) { + return renderResult( + result as AgentToolResult<ReadUrlDetails>, + options, + theme, + ); + }, + }); +} + +export default function (pi: ExtensionAPI): void { + pi.registerTool(createReadUrlTool(pi)); +} diff --git a/extensions/tools/read-url/read-url.test.ts b/tools/read-url/read-url.test.ts similarity index 98% rename from extensions/tools/read-url/read-url.test.ts rename to tools/read-url/read-url.test.ts index db9bf547..f5a86373 100644 --- a/extensions/tools/read-url/read-url.test.ts +++ b/tools/read-url/read-url.test.ts @@ -1,9 +1,9 @@ import { readFile, rm } from "node:fs/promises"; import { dirname } from "node:path"; import { afterEach, assert, describe, expect, it, vi } from "vitest"; -import { DEFAULT_PREVIEW_MAX_BYTES } from "../utils/temp-file-preview"; -import { executeReadUrlRequest, guessImageExtension } from "./"; +import { executeReadUrlRequest, guessImageExtension } from "./fetch"; import type { ReadUrlHandler } from "./handlers"; +import { DEFAULT_PREVIEW_MAX_BYTES } from "./utils/temp-file-preview"; function createHandler(markdown = "tweet markdown"): ReadUrlHandler { return { diff --git a/tools/read-url/render.ts b/tools/read-url/render.ts new file mode 100644 index 00000000..5dce8069 --- /dev/null +++ b/tools/read-url/render.ts @@ -0,0 +1,119 @@ +/** + * Read URL tool render functions. + */ + +import { ToolCallHeader } from "@aliou/pi-utils-ui"; +import type { + AgentToolResult, + Theme, + ToolRenderResultOptions, +} from "@mariozechner/pi-coding-agent"; +import { getMarkdownTheme, keyText } from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Text } from "@mariozechner/pi-tui"; +import type { ReadUrlDetails } from "./types"; +import { DEFAULT_PREVIEW_MAX_LINES } from "./utils/temp-file-preview"; + +export function renderCall(args: { url: string }, theme: Theme) { + return new ToolCallHeader( + { + toolName: "Read URL", + mainArg: args.url.trim(), + showColon: true, + }, + theme, + ); +} + +export function renderResult( + result: AgentToolResult<ReadUrlDetails>, + options: ToolRenderResultOptions, + theme: Theme, +) { + if (options.isPartial) { + return new Text(theme.fg("muted", "Read URL: fetching..."), 0, 0); + } + + const isError = Boolean((result as { isError?: boolean }).isError); + const textBlock = result.content.find((c) => c.type === "text"); + const markdownText = + textBlock?.type === "text" && textBlock.text ? textBlock.text : ""; + const tempFilePath = result.details?.tempFilePath; + const totalLines = result.details?.totalLines; + + const container = new Container(); + + if (isError) { + const errorText = markdownText || "Read URL failed"; + container.addChild(new Text(theme.fg("error", errorText), 0, 0)); + } else if (markdownText) { + const collapsed = !options.expanded; + + if (collapsed) { + const lines = markdownText.split("\n"); + const visibleText = lines.slice(0, 8).join("\n"); + const remaining = Math.max(lines.length - 8, 0); + + container.addChild( + new Markdown(visibleText, 0, 0, getMarkdownTheme(), { + color: (text: string) => theme.fg("toolOutput", text), + }), + ); + + if (remaining > 0) { + container.addChild( + new Text( + theme.fg( + "muted", + `... (${remaining} more lines, ${keyText("app.tools.expand")} to expand)`, + ), + 0, + 0, + ), + ); + } + } else { + container.addChild( + new Markdown(markdownText, 0, 0, getMarkdownTheme(), { + color: (text: string) => theme.fg("toolOutput", text), + }), + ); + } + + // Show temp file path so the user knows where the full content lives. + if (tempFilePath && totalLines && totalLines > DEFAULT_PREVIEW_MAX_LINES) { + container.addChild( + new Text( + theme.fg( + "muted", + `Full content (${totalLines} lines) saved to: ${tempFilePath}`, + ), + 0, + 0, + ), + ); + } + } else { + container.addChild( + new Text(theme.fg("muted", "Read URL: no content"), 0, 0), + ); + } + + const status = result.details?.statusCode + ? `${result.details.statusCode}${ + result.details.statusText ? ` ${result.details.statusText}` : "" + }` + : "n/a"; + const failed = isError || result.details?.failed === true ? "yes" : "no"; + const handler = result.details?.handler ?? "unknown"; + + container.addChild(new Text("", 0, 0)); + container.addChild( + new Text( + `${theme.fg("muted", "handler=")}${theme.fg("dim", handler)} ${theme.fg("muted", "HTTP:")} ${theme.fg("dim", status)} ${theme.fg("muted", "failed=")}${theme.fg(failed === "yes" ? "error" : "success", failed)}`, + 0, + 0, + ), + ); + + return container; +} diff --git a/tools/read-url/types.ts b/tools/read-url/types.ts new file mode 100644 index 00000000..73ddd223 --- /dev/null +++ b/tools/read-url/types.ts @@ -0,0 +1,42 @@ +import type { AgentToolResult } from "@mariozechner/pi-coding-agent"; +import { type Static, Type } from "typebox"; + +export const ReadUrlParams = Type.Object({ + url: Type.String({ + description: "URL to fetch as Markdown via markdown.new", + }), +}); + +export type ReadUrlParamsType = Static<typeof ReadUrlParams>; + +export interface NativeReadTool { + execute( + toolCallId: string, + params: { path: string; offset?: number; limit?: number }, + signal?: AbortSignal, + onUpdate?: unknown, + ): Promise<AgentToolResult<unknown>>; +} + +export type ReadContentBlock = ExecuteResult["content"][number]; + +export type FetchLike = typeof fetch; + +export interface ReadUrlDetails { + url: string; + sourceUrl: string; + title?: string; + handler: string; + statusCode?: number; + statusText?: string; + failed: boolean; + imageCount?: number; + attachedImageCount?: number; + skippedImageCount?: number; + tempFilePath?: string; + totalLines?: number; +} + +export type ExecuteResult = AgentToolResult<ReadUrlDetails>; + +export const COLLAPSED_PREVIEW_LINES = 8; diff --git a/extensions/tools/utils/temp-file-preview.test.ts b/tools/read-url/utils/temp-file-preview.test.ts similarity index 100% rename from extensions/tools/utils/temp-file-preview.test.ts rename to tools/read-url/utils/temp-file-preview.test.ts diff --git a/extensions/tools/utils/temp-file-preview.ts b/tools/read-url/utils/temp-file-preview.ts similarity index 100% rename from extensions/tools/utils/temp-file-preview.ts rename to tools/read-url/utils/temp-file-preview.ts diff --git a/extensions/defaults/tools/read/index.ts b/tools/read/index.ts similarity index 100% rename from extensions/defaults/tools/read/index.ts rename to tools/read/index.ts diff --git a/tools/reviewer/index.ts b/tools/reviewer/index.ts new file mode 100644 index 00000000..62ea48e5 --- /dev/null +++ b/tools/reviewer/index.ts @@ -0,0 +1,39 @@ +import { defineSubagent } from "@harness/agent-kit"; +import type { SubagentToolSpec } from "@harness/agent-kit/types"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { MODEL_CANDIDATES } from "./models"; +import { buildPrompt, REVIEWER_SYSTEM_PROMPT } from "./prompt"; +import { createReviewerTools } from "./tools"; +import { ReviewerParams } from "./types"; + +const nativeTools: SubagentToolSpec[] = [ + { name: "read", type: "native" }, + { name: "grep", type: "native" }, + { name: "find", type: "native" }, + { name: "read_url", type: "native" }, + { name: "synthetic_web_search", type: "native" }, +]; + +const extensionPaths = ["./tools", "npm:@aliou/pi-synthetic"]; + +export default async function reviewer(pi: ExtensionAPI): Promise<void> { + const tools = [...nativeTools, ...createReviewerTools(pi)]; + + const subagent = defineSubagent(pi, { + name: "reviewer", + label: "Reviewer", + description: + "Formal code review subagent for reviewing diffs without running checks.", + systemPrompt: REVIEWER_SYSTEM_PROMPT, + parameters: ReviewerParams, + buildPrompt, + tools, + extensionPaths, + models: MODEL_CANDIDATES, + }); + + subagent.subscribe(pi); + + pi.registerTool(subagent.tool); + pi.registerTool(subagent.resumeTool); +} diff --git a/tools/reviewer/models/index.ts b/tools/reviewer/models/index.ts new file mode 100644 index 00000000..fab82aa3 --- /dev/null +++ b/tools/reviewer/models/index.ts @@ -0,0 +1,10 @@ +import type { SubagentModel } from "@harness/agent-kit/models"; + +export const MODEL_CANDIDATES: SubagentModel[] = [ + { + provider: "openrouter", + model: "google/gemini-3.1-pro-preview", + thinking: "medium", + weight: 1, + }, +]; diff --git a/tools/reviewer/prompt.ts b/tools/reviewer/prompt.ts new file mode 100644 index 00000000..1c129e39 --- /dev/null +++ b/tools/reviewer/prompt.ts @@ -0,0 +1,38 @@ +import type { SubagentPromptResult } from "@harness/agent-kit/types"; +import { isNotNil } from "@harness/utils"; +import type { ReviewerParamsType } from "./types"; + +export const REVIEWER_SYSTEM_PROMPT = `You are an expert senior engineer with deep knowledge of software engineering best practices, security, performance, and maintainability. + +Your task is to perform a thorough code review of the provided diff description. The diff description might be a git or bash command that generates the diff or a description of the diff which can then be used to generate the git or bash command to generate the full diff. + +Use the git_diff tool when you need to generate the diff from the provided diff description. + +After reading the diff, do the following: +1. Generate a high-level summary of the changes in the diff. +2. Go file-by-file and review each changed hunk. +3. Comment on what changed in that hunk (including the line range) and how it relates to other + changed hunks and code, reading any other relevant files. Also call out bugs, hackiness, + unnecessary code, or too much shared mutable state. +4. Evaluate abstraction fit in both directions: flag unnecessary indirection (over-abstraction) + and missing abstractions (duplication or branching complexity). For each finding, cite concrete + locations and recommend exactly one action—simplify/inline or introduce/extract a shared + concept—only when it improves current code (avoid speculative refactors).`; + +export function buildPrompt(params: ReviewerParamsType): SubagentPromptResult { + const diffDescription = `Diff description: +<diff_description> +${params.diff_description} +</diff_description>`; + + const instructions = params.instructions + ? `Additional instructions: +<instructions> +${params.instructions} +</instructions>` + : undefined; + + return { + text: [diffDescription, instructions].filter(isNotNil).join("\n\n"), + }; +} diff --git a/tools/reviewer/tools/git-diff.ts b/tools/reviewer/tools/git-diff.ts new file mode 100644 index 00000000..835d0370 --- /dev/null +++ b/tools/reviewer/tools/git-diff.ts @@ -0,0 +1,41 @@ +import type { + ExtensionAPI, + ToolDefinition, +} from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; + +const Params = Type.Object({ + args: Type.Optional( + Type.Array(Type.String(), { + description: + "Arguments to pass to git diff, excluding the leading 'git diff'. Examples: ['--staged'], ['HEAD~1'], ['main...HEAD'], ['--', 'src/foo.ts'].", + }), + ), +}); + +export function createGitDiffTool( + pi: ExtensionAPI, + cwd: string, +): ToolDefinition<typeof Params> { + return { + name: "git_diff", + label: "Git Diff", + description: + "Run git diff with the provided arguments. Pass only arguments after 'git diff'.", + parameters: Params, + async execute(_id, params, signal) { + const args = ["diff", ...(params.args ?? [])]; + const result = await pi.exec("git", args, { cwd, signal }); + + if (result.code !== 0) { + const message = result.stderr.trim() || result.stdout.trim(); + throw new Error(`git ${args.join(" ")} failed: ${message}`); + } + + return { + content: [{ type: "text" as const, text: result.stdout.trimEnd() }], + details: { args, cwd }, + }; + }, + }; +} diff --git a/tools/reviewer/tools/index.ts b/tools/reviewer/tools/index.ts new file mode 100644 index 00000000..82ef8ae7 --- /dev/null +++ b/tools/reviewer/tools/index.ts @@ -0,0 +1,13 @@ +import type { SubagentToolSpec } from "@harness/agent-kit/types"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { createGitDiffTool } from "./git-diff"; + +export function createReviewerTools(pi: ExtensionAPI): SubagentToolSpec[] { + return [ + { + name: "git_diff", + type: "custom", + spec: (cwd) => createGitDiffTool(pi, cwd), + }, + ]; +} diff --git a/tools/reviewer/types.ts b/tools/reviewer/types.ts new file mode 100644 index 00000000..48435eb1 --- /dev/null +++ b/tools/reviewer/types.ts @@ -0,0 +1,15 @@ +import { type Static, Type } from "typebox"; + +export const ReviewerParams = Type.Object({ + diff_description: Type.String({ + description: + "Description of the diff or code change to review. Can be a git/bash command that generates the diff.", + }), + instructions: Type.Optional( + Type.String({ + description: "Additional instructions for the review.", + }), + ), +}); + +export type ReviewerParamsType = Static<typeof ReviewerParams>; diff --git a/tsconfig.json b/tsconfig.json index 2a3fed03..dd1732f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,12 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true }, - "include": ["extensions/**/*.ts", "packages/**/*.ts", "tests/**/*.ts"] + "include": [ + "commands/**/*.ts", + "extensions/**/*.ts", + "hooks/**/*.ts", + "packages/**/*.ts", + "tests/**/*.ts", + "tools/**/*.ts" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index f23d6e79..abed8858 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ alias: { // Internal pi-coding-agent module not exposed via package "exports". // Mapped here so tests can import it; the single wrapper in - // tests/utils/load-extension.ts is the only consumer. + // packages/test-utils/load-extension.ts is the only consumer. "#pi-internal/extensions-loader": resolve( "node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/loader.js", ), @@ -14,7 +14,13 @@ export default defineConfig({ }, test: { environment: "node", - include: ["extensions/**/*.test.ts", "packages/**/*.test.ts"], + include: [ + "commands/**/*.test.ts", + "extensions/**/*.test.ts", + "hooks/**/*.test.ts", + "packages/**/*.test.ts", + "tools/**/*.test.ts", + ], setupFiles: ["./tests/vitest.setup.ts"], mockReset: true, },