fix(coding-agent): built-in tools work like extension tools

Export readToolDefinition / createReadToolDefinition and the equivalent built-in ToolDefinition APIs from @mariozechner/pi-coding-agent.
This commit is contained in:
Mario Zechner
2026-03-22 04:20:28 +01:00
parent 80f527ec22
commit 235b247f1f
32 changed files with 2594 additions and 1408 deletions

View File

@@ -2,6 +2,15 @@
## [Unreleased]
### Breaking Changes
- Changed `ToolDefinition.renderCall` and `renderResult` semantics. Fallback rendering now happens only when a renderer is not defined for that slot. If `renderCall` or `renderResult` is defined, it must return a `Component`.
### Changed
- Built-in tools now work like custom tools in extensions. To get built-in tool definitions, import `readToolDefinition` / `createReadToolDefinition()` and the equivalent `bash`, `edit`, `write`, `grep`, `find`, and `ls` exports from `@mariozechner/pi-coding-agent`.
- Cleaned up `buildSystemPrompt()` so built-in tool snippets and tool-local guidelines come from built-in `ToolDefinition` metadata, while cross-tool and global prompt rules stay in system prompt construction.
## [0.61.1] - 2026-03-20
### New Features

View File

@@ -976,8 +976,8 @@ pi.registerTool({
},
// Optional: Custom rendering
renderCall(args, theme) { ... },
renderResult(result, options, theme) { ... },
renderCall(args, theme, context) { ... },
renderResult(result, options, theme, context) { ... },
});
```
@@ -1427,8 +1427,8 @@ pi.registerTool({
},
// Optional: Custom rendering
renderCall(args, theme) { ... },
renderResult(result, options, theme) { ... },
renderCall(args, theme, context) { ... },
renderResult(result, options, theme, context) { ... },
});
```
@@ -1463,7 +1463,9 @@ pi --no-tools -e ./my-extension.ts
See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control.
**Rendering:** If your override doesn't provide custom `renderCall`/`renderResult` functions, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.
**Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.
**Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly.
**Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking.
@@ -1597,44 +1599,52 @@ export default function (pi: ExtensionAPI) {
### Custom Rendering
Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how built-in tools render.
Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how tool rows are composed.
Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`).
Tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, `tool-execution.ts` uses fallback rendering for that slot.
`renderCall` and `renderResult` each receive a `context` object with:
- `args` - the current tool call arguments
- `state` - shared row-local state across `renderCall` and `renderResult`
- `lastComponent` - the previously returned component for that slot, if any
- `invalidate()` - request a rerender of this tool row
- `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError`
Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders.
#### renderCall
Renders the tool call (before/during execution):
Renders the tool call or header:
```typescript
import { Text } from "@mariozechner/pi-tui";
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("my_tool "));
text += theme.fg("muted", args.action);
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
let content = theme.fg("toolTitle", theme.bold("my_tool "));
content += theme.fg("muted", args.action);
if (args.text) {
text += " " + theme.fg("dim", `"${args.text}"`);
content += " " + theme.fg("dim", `"${args.text}"`);
}
return new Text(text, 0, 0); // 0,0 padding - Box handles it
text.setText(content);
return text;
}
```
#### renderResult
Renders the tool result:
Renders the tool result or output:
```typescript
renderResult(result, { expanded, isPartial }, theme) {
// Handle streaming
renderResult(result, { expanded, isPartial }, theme, context) {
if (isPartial) {
return new Text(theme.fg("warning", "Processing..."), 0, 0);
}
// Handle errors
if (result.details?.error) {
return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
}
// Normal result - support expanded view (Ctrl+O)
let text = theme.fg("success", "✓ Done");
if (expanded && result.details?.items) {
for (const item of result.details.items) {
@@ -1645,6 +1655,8 @@ renderResult(result, { expanded, isPartial }, theme) {
}
```
If a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`.
#### Keybinding Hints
Use `keyHint()` to display keybinding hints that respect the active keybinding configuration:
@@ -1652,7 +1664,7 @@ Use `keyHint()` to display keybinding hints that respect the active keybinding c
```typescript
import { keyHint } from "@mariozechner/pi-coding-agent";
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, context) {
let text = theme.fg("success", "✓ Done");
if (!expanded) {
text += ` (${keyHint("app.tools.expand", "to expand")})`;
@@ -1676,16 +1688,19 @@ Custom editors and `ctx.ui.custom()` components receive `keybindings: Keybinding
#### Best Practices
- Use `Text` with padding `(0, 0)` - the Box handles padding
- Use `\n` for multi-line content
- Handle `isPartial` for streaming progress
- Support `expanded` for detail on demand
- Keep default view compact
- Use `Text` with padding `(0, 0)`. The Box handles padding.
- Use `\n` for multi-line content.
- Handle `isPartial` for streaming progress.
- Support `expanded` for detail on demand.
- Keep default view compact.
- Read `context.args` in `renderResult` instead of copying args into `context.state`.
- Use `context.state` only for data that must be shared across call and result slots.
- Reuse `context.lastComponent` when the same component instance can be updated in place.
#### Fallback
If `renderCall`/`renderResult` is not defined or throws:
- `renderCall`: Shows tool name
If a slot renderer is not defined or throws:
- `renderCall`: Shows the tool name
- `renderResult`: Shows raw text from `content`
## Custom UI

View File

@@ -394,7 +394,7 @@ Components accept theme objects for styling.
**In `renderCall`/`renderResult`**, use the `theme` parameter:
```typescript
renderResult(result, options, theme) {
renderResult(result, options, theme, context) {
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);
@@ -428,7 +428,7 @@ renderResult(result, options, theme) {
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Markdown } from "@mariozechner/pi-tui";
renderResult(result, options, theme) {
renderResult(result, options, theme, context) {
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
}

View File

@@ -42,7 +42,7 @@ export default function (pi: ExtensionAPI) {
return originalRead.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("read "));
text += theme.fg("accent", args.path);
if (args.offset || args.limit) {
@@ -54,7 +54,7 @@ export default function (pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
renderResult(result, { expanded, isPartial }, theme, _context) {
if (isPartial) return new Text(theme.fg("warning", "Reading..."), 0, 0);
const details = result.details as ReadToolDetails | undefined;
@@ -101,7 +101,7 @@ export default function (pi: ExtensionAPI) {
return originalBash.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("$ "));
const cmd = args.command.length > 80 ? `${args.command.slice(0, 77)}...` : args.command;
text += theme.fg("accent", cmd);
@@ -111,7 +111,7 @@ export default function (pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
renderResult(result, { expanded, isPartial }, theme, _context) {
if (isPartial) return new Text(theme.fg("warning", "Running..."), 0, 0);
const details = result.details as BashToolDetails | undefined;
@@ -160,13 +160,13 @@ export default function (pi: ExtensionAPI) {
return originalEdit.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("edit "));
text += theme.fg("accent", args.path);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
renderResult(result, { expanded, isPartial }, theme, _context) {
if (isPartial) return new Text(theme.fg("warning", "Editing..."), 0, 0);
const details = result.details as EditToolDetails | undefined;
@@ -224,7 +224,7 @@ export default function (pi: ExtensionAPI) {
return originalWrite.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("write "));
text += theme.fg("accent", args.path);
const lineCount = args.content.split("\n").length;
@@ -232,7 +232,7 @@ export default function (pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
renderResult(result, { isPartial }, theme, _context) {
if (isPartial) return new Text(theme.fg("warning", "Writing..."), 0, 0);
const content = result.content[0];

View File

@@ -80,7 +80,7 @@ export default function (pi: ExtensionAPI) {
return tools.read.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const path = shortenPath(args.path || "");
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
@@ -94,7 +94,7 @@ export default function (pi: ExtensionAPI) {
return new Text(`${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
// Minimal mode: show nothing in collapsed state
if (!expanded) {
return new Text("", 0, 0);
@@ -127,7 +127,7 @@ export default function (pi: ExtensionAPI) {
return tools.bash.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const command = args.command || "...";
const timeout = args.timeout as number | undefined;
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
@@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) {
return new Text(theme.fg("toolTitle", theme.bold(`$ ${command}`)) + timeoutSuffix, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
// Minimal mode: show nothing in collapsed state
if (!expanded) {
return new Text("", 0, 0);
@@ -176,7 +176,7 @@ export default function (pi: ExtensionAPI) {
return tools.write.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const path = shortenPath(args.path || "");
const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
const lineCount = args.content ? args.content.split("\n").length : 0;
@@ -185,7 +185,7 @@ export default function (pi: ExtensionAPI) {
return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}${lineInfo}`, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
// Minimal mode: show nothing (file was written)
if (!expanded) {
return new Text("", 0, 0);
@@ -218,14 +218,14 @@ export default function (pi: ExtensionAPI) {
return tools.edit.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const path = shortenPath(args.path || "");
const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
return new Text(`${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
// Minimal mode: show nothing in collapsed state
if (!expanded) {
return new Text("", 0, 0);
@@ -263,7 +263,7 @@ export default function (pi: ExtensionAPI) {
return tools.find.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const pattern = args.pattern || "";
const path = shortenPath(args.path || ".");
const limit = args.limit;
@@ -277,7 +277,7 @@ export default function (pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
if (!expanded) {
// Minimal: just show count
const textContent = result.content.find((c) => c.type === "text");
@@ -321,7 +321,7 @@ export default function (pi: ExtensionAPI) {
return tools.grep.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const pattern = args.pattern || "";
const path = shortenPath(args.path || ".");
const glob = args.glob;
@@ -339,7 +339,7 @@ export default function (pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
if (!expanded) {
// Minimal: just show match count
const textContent = result.content.find((c) => c.type === "text");
@@ -383,7 +383,7 @@ export default function (pi: ExtensionAPI) {
return tools.ls.execute(toolCallId, params, signal, onUpdate);
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const path = shortenPath(args.path || ".");
const limit = args.limit;
@@ -395,7 +395,7 @@ export default function (pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
if (!expanded) {
// Minimal: just show entry count
const textContent = result.content.find((c) => c.type === "text");

View File

@@ -227,7 +227,7 @@ export default function question(pi: ExtensionAPI) {
};
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
const opts = Array.isArray(args.options) ? args.options : [];
if (opts.length) {
@@ -238,7 +238,7 @@ export default function question(pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
renderResult(result, _options, theme, _context) {
const details = result.details as QuestionDetails | undefined;
if (!details) {
const text = result.content[0];

View File

@@ -393,7 +393,7 @@ export default function questionnaire(pi: ExtensionAPI) {
};
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const qs = (args.questions as Question[]) || [];
const count = qs.length;
const labels = qs.map((q) => q.label || q.id).join(", ");
@@ -405,7 +405,7 @@ export default function questionnaire(pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
renderResult(result, _options, theme, _context) {
const details = result.details as QuestionnaireResult | undefined;
if (!details) {
const text = result.content[0];

View File

@@ -668,7 +668,7 @@ export default function (pi: ExtensionAPI) {
};
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
const scope: AgentScope = args.agentScope ?? "user";
if (args.chain && args.chain.length > 0) {
let text =
@@ -712,7 +712,7 @@ export default function (pi: ExtensionAPI) {
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
const details = result.details as SubagentDetails | undefined;
if (!details || details.results.length === 0) {
const text = result.content[0];

View File

@@ -220,14 +220,14 @@ export default function (pi: ExtensionAPI) {
}
},
renderCall(args, theme) {
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
renderResult(result, { expanded }, theme, _context) {
const details = result.details as TodoDetails | undefined;
if (!details) {
const text = result.content[0];

View File

@@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) {
},
// Custom rendering of the tool call (shown before/during execution)
renderCall(args, theme) {
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("rg "));
text += theme.fg("accent", `"${args.pattern}"`);
if (args.path) {
@@ -148,7 +148,7 @@ export default function (pi: ExtensionAPI) {
},
// Custom rendering of the tool result
renderResult(result, { expanded, isPartial }, theme) {
renderResult(result, { expanded, isPartial }, theme, _context) {
const details = result.details as RgDetails | undefined;
// Handle streaming/partial results

View File

@@ -78,7 +78,8 @@ import type { SettingsManager } from "./settings-manager.js";
import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js";
import { buildSystemPrompt } from "./system-prompt.js";
import type { BashOperations } from "./tools/bash.js";
import { createAllTools } from "./tools/index.js";
import { createAllToolDefinitions } from "./tools/index.js";
import { createToolDefinitionFromAgentTool, wrapToolDefinition } from "./tools/tool-definition-wrapper.js";
// ============================================================================
// Skill Block Parsing
@@ -143,7 +144,12 @@ export interface AgentSessionConfig {
modelRegistry: ModelRegistry;
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
initialActiveToolNames?: string[];
/** Override base tools (useful for custom runtimes). */
/**
* Override base tools (useful for custom runtimes).
*
* These are synthesized into minimal ToolDefinitions internally so AgentSession can keep
* a definition-first registry even when callers provide plain AgentTool instances.
*/
baseToolsOverride?: Record<string, AgentTool>;
/** Mutable ref used by Agent to access the current ExtensionRunner */
extensionRunnerRef?: { current?: ExtensionRunner };
@@ -252,7 +258,7 @@ export class AgentSession {
private _resourceLoader: ResourceLoader;
private _customTools: ToolDefinition[];
private _baseToolRegistry: Map<string, AgentTool> = new Map();
private _baseToolDefinitions: Map<string, ToolDefinition> = new Map();
private _cwd: string;
private _extensionRunnerRef?: { current?: ExtensionRunner };
private _initialActiveToolNames?: string[];
@@ -268,6 +274,7 @@ export class AgentSession {
// Tool registry for extension getTools/setTools
private _toolRegistry: Map<string, AgentTool> = new Map();
private _toolDefinitions: Map<string, ToolDefinition> = new Map();
private _toolPromptSnippets: Map<string, string> = new Map();
private _toolPromptGuidelines: Map<string, string[]> = new Map();
@@ -706,13 +713,17 @@ export class AgentSession {
* Get all configured tools with name, description, and parameter schema.
*/
getAllTools(): ToolInfo[] {
return Array.from(this._toolRegistry.values()).map((t) => ({
return Array.from(this._toolDefinitions.values()).map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
}));
}
getToolDefinition(name: string): ToolDefinition | undefined {
return this._toolDefinitions.get(name);
}
/**
* Set active tools by name.
* Only tools in the registry can be enabled. Unknown tool names are ignored.
@@ -2202,19 +2213,24 @@ export class AgentSession {
...registeredTools,
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })),
];
const definitionRegistry = new Map(this._baseToolDefinitions);
for (const { definition } of allCustomTools) {
definitionRegistry.set(definition.name, definition);
}
this._toolDefinitions = definitionRegistry;
this._toolPromptSnippets = new Map(
allCustomTools
.map((registeredTool) => {
const snippet = this._normalizePromptSnippet(registeredTool.definition.promptSnippet);
return snippet ? ([registeredTool.definition.name, snippet] as const) : undefined;
Array.from(definitionRegistry.values())
.map((definition) => {
const snippet = this._normalizePromptSnippet(definition.promptSnippet);
return snippet ? ([definition.name, snippet] as const) : undefined;
})
.filter((entry): entry is readonly [string, string] => entry !== undefined),
);
this._toolPromptGuidelines = new Map(
allCustomTools
.map((registeredTool) => {
const guidelines = this._normalizePromptGuidelines(registeredTool.definition.promptGuidelines);
return guidelines.length > 0 ? ([registeredTool.definition.name, guidelines] as const) : undefined;
Array.from(definitionRegistry.values())
.map((definition) => {
const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);
return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined;
})
.filter((entry): entry is readonly [string, string[]] => entry !== undefined),
);
@@ -2222,7 +2238,12 @@ export class AgentSession {
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
: [];
const toolRegistry = new Map(this._baseToolRegistry);
const toolRegistry = new Map(
Array.from(this._baseToolDefinitions.values()).map((definition) => [
definition.name,
wrapToolDefinition(definition),
]),
);
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
@@ -2254,14 +2275,21 @@ export class AgentSession {
}): void {
const autoResizeImages = this.settingsManager.getImageAutoResize();
const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
const baseTools = this._baseToolsOverride
? this._baseToolsOverride
: createAllTools(this._cwd, {
const baseToolDefinitions = this._baseToolsOverride
? Object.fromEntries(
Object.entries(this._baseToolsOverride).map(([name, tool]) => [
name,
createToolDefinitionFromAgentTool(tool),
]),
)
: createAllToolDefinitions(this._cwd, {
read: { autoResizeImages },
bash: { commandPrefix: shellCommandPrefix },
});
this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool]));
this._baseToolDefinitions = new Map(
Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]),
);
const extensionsResult = this._resourceLoader.getExtensions();
if (options.flagValues) {
@@ -3052,13 +3080,10 @@ export class AgentSession {
const themeName = this.settingsManager.getTheme();
// Create tool renderer if we have an extension runner (for custom tool HTML rendering)
let toolRenderer: ToolHtmlRenderer | undefined;
if (this._extensionRunner) {
toolRenderer = createToolHtmlRenderer({
getToolDefinition: (name) => this._extensionRunner!.getToolDefinition(name),
theme,
});
}
const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
getToolDefinition: (name) => this.getToolDefinition(name),
theme,
});
return await exportSessionToHtml(this.sessionManager, this.state, {
outputPath,

View File

@@ -13,9 +13,10 @@ import { SessionManager } from "../session-manager.js";
*/
export interface ToolHtmlRenderer {
/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */
renderCall(toolName: string, args: unknown): string | undefined;
renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;
/** Render a tool result to HTML. Returns collapsed/expanded or undefined if tool has no custom renderer. */
renderResult(
toolCallId: string,
toolName: string,
result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
details: unknown,
@@ -191,7 +192,7 @@ function preRenderCustomTools(
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "toolCall" && !BUILTIN_TOOLS.has(block.name)) {
const callHtml = toolRenderer.renderCall(block.name, block.arguments);
const callHtml = toolRenderer.renderCall(block.id, block.name, block.arguments);
if (callHtml) {
renderedTools[block.id] = { callHtml };
}
@@ -205,7 +206,13 @@ function preRenderCustomTools(
// Only render if we have a pre-rendered call OR it's not a built-in tool
const existing = renderedTools[msg.toolCallId];
if (existing || !BUILTIN_TOOLS.has(toolName)) {
const rendered = toolRenderer.renderResult(toolName, msg.content, msg.details, msg.isError || false);
const rendered = toolRenderer.renderResult(
msg.toolCallId,
toolName,
msg.content,
msg.details,
msg.isError || false,
);
if (rendered) {
renderedTools[msg.toolCallId] = {
...existing,

View File

@@ -6,8 +6,9 @@
*/
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition } from "../extensions/types.js";
import type { ToolDefinition, ToolRenderContext } from "../extensions/types.js";
import { ansiLinesToHtml } from "./ansi-to-html.js";
export interface ToolHtmlRendererDeps {
@@ -21,9 +22,10 @@ export interface ToolHtmlRendererDeps {
export interface ToolHtmlRenderer {
/** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */
renderCall(toolName: string, args: unknown): string | undefined;
renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined;
/** Render a tool result to collapsed/expanded HTML. Returns undefined if tool has no custom renderer. */
renderResult(
toolCallId: string,
toolName: string,
result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
details: unknown,
@@ -40,27 +42,68 @@ export interface ToolHtmlRenderer {
export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer {
const { getToolDefinition, theme, width = 100 } = deps;
const renderedCallComponents = new Map<string, Component>();
const renderedResultComponents = new Map<string, Component>();
const renderedStates = new Map<string, any>();
const renderedArgs = new Map<string, unknown>();
const getState = (toolCallId: string): any => {
let state = renderedStates.get(toolCallId);
if (!state) {
state = {};
renderedStates.set(toolCallId, state);
}
return state;
};
const createRenderContext = (
toolCallId: string,
lastComponent: Component | undefined,
expanded: boolean,
isPartial: boolean,
isError: boolean,
): ToolRenderContext => {
return {
args: renderedArgs.get(toolCallId),
toolCallId,
invalidate: () => {},
lastComponent,
state: getState(toolCallId),
cwd: process.cwd(),
executionStarted: true,
argsComplete: true,
isPartial,
expanded,
showImages: false,
isError,
};
};
return {
renderCall(toolName: string, args: unknown): string | undefined {
renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined {
try {
renderedArgs.set(toolCallId, args);
const toolDef = getToolDefinition(toolName);
if (!toolDef?.renderCall) {
return undefined;
}
const component = toolDef.renderCall(args, theme);
if (!component) {
return undefined;
}
const component = toolDef.renderCall(
args,
theme,
createRenderContext(toolCallId, renderedCallComponents.get(toolCallId), false, true, false),
);
renderedCallComponents.set(toolCallId, component);
const lines = component.render(width);
return ansiLinesToHtml(lines);
} catch {
// On error, return undefined to trigger JSON fallback
// On error, return undefined so HTML export can fall back to structured result rendering
return undefined;
}
},
renderResult(
toolCallId: string,
toolName: string,
result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
details: unknown,
@@ -85,28 +128,27 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
agentToolResult,
{ expanded: false, isPartial: false },
theme,
createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), false, false, isError),
);
const collapsed = collapsedComponent ? ansiLinesToHtml(collapsedComponent.render(width)) : undefined;
renderedResultComponents.set(toolCallId, collapsedComponent);
const collapsed = ansiLinesToHtml(collapsedComponent.render(width));
// Render expanded
const expandedComponent = toolDef.renderResult(
agentToolResult,
{ expanded: true, isPartial: false },
theme,
createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), true, false, isError),
);
const expanded = expandedComponent ? ansiLinesToHtml(expandedComponent.render(width)) : undefined;
// Return collapsed only if it exists and differs from expanded
if (!expanded) {
return undefined;
}
renderedResultComponents.set(toolCallId, expandedComponent);
const expanded = ansiLinesToHtml(expandedComponent.render(width));
return {
...(collapsed && collapsed !== expanded ? { collapsed } : {}),
expanded,
};
} catch {
// On error, return undefined to trigger JSON fallback
// On error, return undefined so HTML export can fall back to structured result rendering
return undefined;
}
},

View File

@@ -329,10 +329,38 @@ export interface ToolRenderResultOptions {
isPartial: boolean;
}
/** Context passed to tool renderers. */
export interface ToolRenderContext<TState = any, TArgs = any> {
/** Current tool call arguments. Shared across call/result renders for the same tool call. */
args: TArgs;
/** Unique id for this tool execution. Stable across call/result renders for the same tool call. */
toolCallId: string;
/** Invalidate just this tool execution component for redraw. */
invalidate: () => void;
/** Previously returned component for this render slot, if any. */
lastComponent: Component | undefined;
/** Shared renderer state for this tool row. Initialized by tool-execution.ts. */
state: TState;
/** Working directory for this tool execution. */
cwd: string;
/** Whether the tool execution has started. */
executionStarted: boolean;
/** Whether the tool call arguments are complete. */
argsComplete: boolean;
/** Whether the tool result is partial/streaming. */
isPartial: boolean;
/** Whether the result view is expanded. */
expanded: boolean;
/** Whether inline images are currently shown in the TUI. */
showImages: boolean;
/** Whether the current result is an error. */
isError: boolean;
}
/**
* Tool definition for registerTool().
*/
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown> {
export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = unknown, TState = any> {
/** Tool name (used in LLM tool calls) */
name: string;
/** Human-readable label for UI */
@@ -356,14 +384,15 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
): Promise<AgentToolResult<TDetails>>;
/** Custom rendering for tool call display */
renderCall?: (args: Static<TParams>, theme: Theme) => Component | undefined;
renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext<TState, Static<TParams>>) => Component;
/** Custom rendering for tool result display */
renderResult?: (
result: AgentToolResult<TDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) => Component | undefined;
context: ToolRenderContext<TState, Static<TParams>>,
) => Component;
}
// ============================================================================
@@ -997,7 +1026,9 @@ export interface ExtensionAPI {
// =========================================================================
/** Register a tool that the LLM can call. */
registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
registerTool<TParams extends TSchema = TSchema, TDetails = unknown, TState = any>(
tool: ToolDefinition<TParams, TDetails, TState>,
): void;
// =========================================================================
// Command, Shortcut, Flag Registration

View File

@@ -6,6 +6,7 @@
*/
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { wrapToolDefinition } from "../tools/tool-definition-wrapper.js";
import type { ExtensionRunner } from "./runner.js";
import type { RegisteredTool } from "./types.js";
@@ -14,15 +15,7 @@ import type { RegisteredTool } from "./types.js";
* Uses the runner's createContext() for consistent context across tools and event handlers.
*/
export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool {
const { definition } = registeredTool;
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate, runner.createContext()),
};
return wrapToolDefinition(registeredTool.definition, () => runner.createContext());
}
/**

View File

@@ -5,17 +5,6 @@
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import { formatSkillsForPrompt, type Skill } from "./skills.js";
/** Tool descriptions for system prompt */
const toolDescriptions: Record<string, string> = {
read: "Read file contents",
bash: "Execute bash commands (ls, grep, find, etc.)",
edit: "Make surgical edits to files (find exact text and replace)",
write: "Create or overwrite files",
grep: "Search file contents for patterns (respects .gitignore)",
find: "Find files by glob pattern (respects .gitignore)",
ls: "List directory contents",
};
export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */
customPrompt?: string;
@@ -92,18 +81,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
const examplesPath = getExamplesPath();
// Build tools list based on selected tools.
// Built-ins use toolDescriptions. Custom tools can provide one-line snippets.
// A tool appears in Available tools only when the caller provides a one-line snippet.
const tools = selectedTools || ["read", "bash", "edit", "write"];
const visibleTools = tools.filter((name) => name in toolDescriptions || toolSnippets?.[name]);
const visibleTools = tools.filter((name) => !!toolSnippets?.[name]);
const toolsList =
visibleTools.length > 0
? visibleTools
.map((name) => {
const snippet = toolSnippets?.[name] ?? toolDescriptions[name] ?? name;
return `- ${name}: ${snippet}`;
})
.join("\n")
: "(none)";
visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join("\n") : "(none)";
// Build guidelines based on which tools are actually available
const guidelinesList: string[] = [];
@@ -117,8 +99,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
};
const hasBash = tools.includes("bash");
const hasEdit = tools.includes("edit");
const hasWrite = tools.includes("write");
const hasGrep = tools.includes("grep");
const hasFind = tools.includes("find");
const hasLs = tools.includes("ls");
@@ -131,28 +111,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
addGuideline("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
}
// Read before edit guideline
if (hasRead && hasEdit) {
addGuideline("Use read to examine files before editing. You must use this tool instead of cat or sed.");
}
// Edit guideline
if (hasEdit) {
addGuideline("Use edit for precise changes (old text must match exactly)");
}
// Write guideline
if (hasWrite) {
addGuideline("Use write only for new files or complete rewrites");
}
// Output guideline (only when actually writing or executing)
if (hasEdit || hasWrite) {
addGuideline(
"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
);
}
for (const guideline of promptGuidelines ?? []) {
const normalized = guideline.trim();
if (normalized.length > 0) {

View File

@@ -3,14 +3,21 @@ import { createWriteStream, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
import { theme } from "../../modes/interactive/theme/theme.js";
import { waitForChildProcess } from "../../utils/child-process.js";
import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
* Generate a unique temp file path for bash output
* Generate a unique temp file path for bash output.
*/
function getTempFilePath(): string {
const id = randomBytes(8).toString("hex");
@@ -31,14 +38,14 @@ export interface BashToolDetails {
/**
* Pluggable operations for the bash tool.
* Override these to delegate command execution to remote systems (e.g., SSH).
* Override these to delegate command execution to remote systems (for example SSH).
*/
export interface BashOperations {
/**
* Execute a command and stream output.
* @param command - The command to execute
* @param cwd - Working directory
* @param options - Execution options
* @param command The command to execute
* @param cwd Working directory
* @param options Execution options
* @returns Promise resolving to exit code (null if killed)
*/
exec: (
@@ -56,81 +63,58 @@ export interface BashOperations {
/**
* Create bash operations using pi's built-in local shell execution backend.
*
* This is useful for extensions that intercept user_bash and want to keep
* pi's standard local shell behavior while still wrapping or rewriting
* commands before execution.
* This is useful for extensions that intercept user_bash and still want pi's
* standard local shell behavior while wrapping or rewriting commands.
*/
export function createLocalBashOperations(): BashOperations {
return {
exec: (command, cwd, { onData, signal, timeout, env }) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
if (!existsSync(cwd)) {
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
return;
}
const child = spawn(shell, [...args, command], {
cwd,
detached: true,
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
// Set timeout if provided
let timeoutHandle: NodeJS.Timeout | undefined;
// Set timeout if provided.
if (timeout !== undefined && timeout > 0) {
timeoutHandle = setTimeout(() => {
timedOut = true;
if (child.pid) {
killProcessTree(child.pid);
}
if (child.pid) killProcessTree(child.pid);
}, timeout * 1000);
}
// Stream stdout and stderr
if (child.stdout) {
child.stdout.on("data", onData);
}
if (child.stderr) {
child.stderr.on("data", onData);
}
// Handle abort signal - kill entire process tree
// Stream stdout and stderr.
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
// Handle abort signal by killing the entire process tree.
const onAbort = () => {
if (child.pid) {
killProcessTree(child.pid);
}
if (child.pid) killProcessTree(child.pid);
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
// Handle shell spawn errors and wait for the process to terminate without hanging
// on inherited stdio handles held by detached descendants.
waitForChildProcess(child)
.then((code) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (signal) signal.removeEventListener("abort", onAbort);
if (signal?.aborted) {
reject(new Error("aborted"));
return;
}
if (timedOut) {
reject(new Error(`timeout:${timeout}`));
return;
}
resolve({ exitCode: code });
})
.catch((err) => {
@@ -152,85 +136,182 @@ export interface BashSpawnContext {
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext {
const baseContext: BashSpawnContext = {
command,
cwd,
env: { ...getShellEnv() },
};
const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } };
return spawnHook ? spawnHook(baseContext) : baseContext;
}
export interface BashToolOptions {
/** Custom operations for command execution. Default: local shell */
operations?: BashOperations;
/** Command prefix prepended to every command (e.g., "shopt -s expand_aliases" for alias support) */
/** Command prefix prepended to every command (for example shell setup commands) */
commandPrefix?: string;
/** Hook to adjust command, cwd, or env before execution */
spawnHook?: BashSpawnHook;
}
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
const BASH_PREVIEW_LINES = 5;
type BashRenderState = {
startedAt: number | undefined;
endedAt: number | undefined;
interval: NodeJS.Timeout | undefined;
};
type BashResultRenderState = {
cachedWidth: number | undefined;
cachedLines: string[] | undefined;
cachedSkipped: number | undefined;
};
class BashResultRenderComponent extends Container {
state: BashResultRenderState = {
cachedWidth: undefined,
cachedLines: undefined,
cachedSkipped: undefined,
};
}
function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
function formatBashCall(args: { command?: string; timeout?: number } | undefined): string {
const command = str(args?.command);
const timeout = args?.timeout as number | undefined;
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "...");
return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix;
}
function rebuildBashResultRenderComponent(
component: BashResultRenderComponent,
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: BashToolDetails;
},
options: ToolRenderResultOptions,
showImages: boolean,
startedAt: number | undefined,
endedAt: number | undefined,
): void {
const state = component.state;
component.clear();
const output = getTextOutput(result as any, showImages).trim();
if (output) {
const styledOutput = output
.split("\n")
.map((line) => theme.fg("toolOutput", line))
.join("\n");
if (options.expanded) {
component.addChild(new Text(`\n${styledOutput}`, 0, 0));
} else {
component.addChild({
render: (width: number) => {
if (state.cachedLines === undefined || state.cachedWidth !== width) {
const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
state.cachedLines = preview.visualLines;
state.cachedSkipped = preview.skippedCount;
state.cachedWidth = width;
}
if (state.cachedSkipped && state.cachedSkipped > 0) {
const hint =
theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
` ${keyHint("app.tools.expand", "to expand")})`;
return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
}
return ["", ...(state.cachedLines ?? [])];
},
invalidate: () => {
state.cachedWidth = undefined;
state.cachedLines = undefined;
state.cachedSkipped = undefined;
},
});
}
}
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
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 ?? DEFAULT_MAX_BYTES)} limit)`,
);
}
}
component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
}
if (startedAt !== undefined) {
const label = options.isPartial ? "Elapsed" : "Took";
const endTime = endedAt ?? Date.now();
component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0));
}
}
export function createBashToolDefinition(
cwd: string,
options?: BashToolOptions,
): ToolDefinition<typeof bashSchema, BashToolDetails | undefined, BashRenderState> {
const ops = options?.operations ?? createLocalBashOperations();
const commandPrefix = options?.commandPrefix;
const spawnHook = options?.spawnHook;
return {
name: "bash",
label: "bash",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
parameters: bashSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ command, timeout }: { command: string; timeout?: number },
signal?: AbortSignal,
onUpdate?,
) => {
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
_ctx?,
) {
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
if (onUpdate) {
onUpdate({ content: [], details: undefined });
}
return new Promise((resolve, reject) => {
// We'll stream to a temp file if output gets large
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
let totalBytes = 0;
// Keep a rolling buffer of the last chunk for tail truncation
const chunks: Buffer[] = [];
let chunksBytes = 0;
// Keep more than we need so we have enough for truncation
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Start writing to temp file once we exceed the threshold
// Start writing to a temp file once output exceeds the in-memory threshold.
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
tempFilePath = getTempFilePath();
tempFileStream = createWriteStream(tempFilePath);
// Write all buffered chunks to the file
for (const chunk of chunks) {
tempFileStream.write(chunk);
}
// Write all buffered chunks to the file.
for (const chunk of chunks) tempFileStream.write(chunk);
}
// Write to temp file if we have one
if (tempFileStream) {
tempFileStream.write(data);
}
// Keep rolling buffer of recent data
// Write to temp file if we have one.
if (tempFileStream) tempFileStream.write(data);
// Keep a rolling buffer of recent output for tail truncation.
chunks.push(data);
chunksBytes += data.length;
// Trim old chunks if buffer is too large
// Trim old chunks if the rolling buffer grows too large.
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
// Stream partial output to callback (truncated rolling buffer)
// Stream partial output using the rolling tail buffer.
if (onUpdate) {
const fullBuffer = Buffer.concat(chunks);
const fullText = fullBuffer.toString("utf-8");
@@ -252,34 +333,22 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
env: spawnContext.env,
})
.then(({ exitCode }) => {
// Close temp file stream
if (tempFileStream) {
tempFileStream.end();
}
// Combine all buffered chunks
// Close temp file stream before building the final result.
if (tempFileStream) tempFileStream.end();
// Combine the rolling buffer chunks.
const fullBuffer = Buffer.concat(chunks);
const fullOutput = fullBuffer.toString("utf-8");
// Apply tail truncation
// Apply tail truncation for the final display payload.
const truncation = truncateTail(fullOutput);
let outputText = truncation.content || "(no output)";
// Build details with truncation info
let details: BashToolDetails | undefined;
if (truncation.truncated) {
details = {
truncation,
fullOutputPath: tempFilePath,
};
// Build actionable notice
// Build truncation details and an actionable notice.
details = { truncation, fullOutputPath: tempFilePath };
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
// Edge case: last line alone > 30KB
// Edge case: the last line alone is larger than the byte limit.
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
} else if (truncation.truncatedBy === "lines") {
@@ -288,7 +357,6 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
}
}
if (exitCode !== 0 && exitCode !== null) {
outputText += `\n\nCommand exited with code ${exitCode}`;
reject(new Error(outputText));
@@ -297,15 +365,10 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
}
})
.catch((err: Error) => {
// Close temp file stream
if (tempFileStream) {
tempFileStream.end();
}
// Combine all buffered chunks for error output
// Close temp file stream and include buffered output in the error message.
if (tempFileStream) tempFileStream.end();
const fullBuffer = Buffer.concat(chunks);
let output = fullBuffer.toString("utf-8");
if (err.message === "aborted") {
if (output) output += "\n\n";
output += "Command aborted";
@@ -321,8 +384,48 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
});
});
},
renderCall(args, _theme, context) {
const state = context.state;
if (context.executionStarted && state.startedAt === undefined) {
state.startedAt = Date.now();
state.endedAt = undefined;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatBashCall(args));
return text;
},
renderResult(result, options, _theme, context) {
const state = context.state;
if (state.startedAt !== undefined && options.isPartial && !state.interval) {
state.interval = setInterval(() => context.invalidate(), 1000);
}
if (!options.isPartial || context.isError) {
state.endedAt ??= Date.now();
if (state.interval) {
clearInterval(state.interval);
state.interval = undefined;
}
}
const component =
(context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent();
rebuildBashResultRenderComponent(
component,
result as any,
options,
context.showImages,
state.startedAt,
state.endedAt,
);
component.invalidate();
return component;
},
};
}
/** Default bash tool using process.cwd() - for backwards compatibility */
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
return wrapToolDefinition(createBashToolDefinition(cwd, options));
}
/** Default bash tool using process.cwd() for backwards compatibility. */
export const bashToolDefinition = createBashToolDefinition(process.cwd());
export const bashTool = createBashTool(process.cwd());

View File

@@ -1,9 +1,15 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { renderDiff } from "../../modes/interactive/components/diff.js";
import type { ToolDefinition } from "../extensions/types.js";
import {
computeEditDiff,
detectLineEnding,
type EditDiffError,
type EditDiffResult,
fuzzyFindText,
generateDiffString,
normalizeForFuzzyMatch,
@@ -13,6 +19,13 @@ import {
} from "./edit-diff.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
type EditRenderState = {
argsKey?: string;
preview?: EditDiffResult | EditDiffError;
};
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
@@ -31,7 +44,7 @@ export interface EditToolDetails {
/**
* Pluggable operations for the edit tool.
* Override these to delegate file editing to remote systems (e.g., SSH).
* Override these to delegate file editing to remote systems (for example SSH).
*/
export interface EditOperations {
/** Read file contents as a Buffer */
@@ -53,20 +66,75 @@ export interface EditToolOptions {
operations?: EditOperations;
}
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
const ops = options?.operations ?? defaultEditOperations;
function formatEditCall(
args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined,
state: EditRenderState,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const invalidArg = invalidArgText(theme);
const rawPath = str(args?.file_path ?? args?.path);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
if (state.preview) {
if ("error" in state.preview) {
text += `\n\n${theme.fg("error", state.preview.error)}`;
} else if (state.preview.diff) {
text += `\n\n${renderDiff(state.preview.diff, { filePath: rawPath ?? undefined })}`;
}
}
return text;
}
function formatEditResult(
args: { path?: string; file_path?: string; oldText?: string; newText?: string } | undefined,
state: EditRenderState,
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: EditToolDetails;
},
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
isError: boolean,
): string | undefined {
const rawPath = str(args?.file_path ?? args?.path);
if (isError) {
const errorText = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
return errorText ? `\n${theme.fg("error", errorText)}` : undefined;
}
const previewDiff = state.preview && !("error" in state.preview) ? state.preview.diff : undefined;
const resultDiff = result.details?.diff;
if (!resultDiff || resultDiff === previewDiff) {
return undefined;
}
return `\n${renderDiff(resultDiff, { filePath: rawPath ?? undefined })}`;
}
export function createEditToolDefinition(
cwd: string,
options?: EditToolOptions,
): ToolDefinition<typeof editSchema, EditToolDetails | undefined, EditRenderState> {
const ops = options?.operations ?? defaultEditOperations;
return {
name: "edit",
label: "edit",
description:
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
promptSnippet: "Make surgical edits to files (find exact text and replace)",
promptGuidelines: ["Use edit for precise changes (old text must match exactly)."],
parameters: editSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
const absolutePath = resolveToCwd(path, cwd);
return withFileMutationQueue(
@@ -76,7 +144,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
content: Array<{ type: "text"; text: string }>;
details: EditToolDetails | undefined;
}>((resolve, reject) => {
// Check if already aborted
// Check if already aborted.
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
@@ -84,7 +152,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
let aborted = false;
// Set up abort handler
// Set up abort handler.
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
@@ -94,10 +162,10 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the edit operation
// Perform the edit operation.
(async () => {
try {
// Check if file exists
// Check if file exists.
try {
await ops.access(absolutePath);
} catch {
@@ -108,21 +176,21 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
return;
}
// Check if aborted before reading
// Check if aborted before reading.
if (aborted) {
return;
}
// Read the file
// Read the file.
const buffer = await ops.readFile(absolutePath);
const rawContent = buffer.toString("utf-8");
// Check if aborted after reading
// Check if aborted after reading.
if (aborted) {
return;
}
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
@@ -130,7 +198,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
// Find the old text using fuzzy matching (tries exact match first, then fuzzy)
// Find the old text using fuzzy matching. This tries exact match first, then a normalized fallback.
const matchResult = fuzzyFindText(normalizedContent, normalizedOldText);
if (!matchResult.found) {
@@ -145,7 +213,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
return;
}
// Count occurrences using fuzzy-normalized content for consistency
// Count occurrences using fuzzy-normalized content for consistency with the matcher.
const fuzzyContent = normalizeForFuzzyMatch(normalizedContent);
const fuzzyOldText = normalizeForFuzzyMatch(normalizedOldText);
const occurrences = fuzzyContent.split(fuzzyOldText).length - 1;
@@ -162,20 +230,20 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
return;
}
// Check if aborted before writing
// Check if aborted before writing.
if (aborted) {
return;
}
// Perform replacement using the matched text position
// When fuzzy matching was used, contentForReplacement is the normalized version
// Perform replacement using the matched text position.
// When fuzzy matching was used, contentForReplacement is the normalized version.
const baseContent = matchResult.contentForReplacement;
const newContent =
baseContent.substring(0, matchResult.index) +
normalizedNewText +
baseContent.substring(matchResult.index + matchResult.matchLength);
// Verify the replacement actually changed something
// Verify the replacement actually changed something.
if (baseContent === newContent) {
if (signal) {
signal.removeEventListener("abort", onAbort);
@@ -191,12 +259,12 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
await ops.writeFile(absolutePath, finalContent);
// Check if aborted after writing
// Check if aborted after writing.
if (aborted) {
return;
}
// Clean up abort handler
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
@@ -212,7 +280,7 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: any) {
// Clean up abort handler
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
@@ -225,8 +293,43 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
}),
);
},
renderCall(args, theme, context) {
const isSingleMode =
typeof args?.path === "string" && typeof args?.oldText === "string" && typeof args?.newText === "string";
if (context.argsComplete && isSingleMode) {
const argsKey = JSON.stringify({ path: args.path, oldText: args.oldText, newText: args.newText });
if (context.state.argsKey !== argsKey) {
context.state.argsKey = argsKey;
computeEditDiff(args.path!, args.oldText!, args.newText!, context.cwd).then((preview) => {
if (context.state.argsKey === argsKey) {
context.state.preview = preview;
context.invalidate();
}
});
}
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatEditCall(args, context.state, theme));
return text;
},
renderResult(result, _options, theme, context) {
const output = formatEditResult(context.args, context.state, result as any, theme, context.isError);
if (!output) {
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
return component;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(output);
return text;
},
};
}
/** Default edit tool using process.cwd() - for backwards compatibility */
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
return wrapToolDefinition(createEditToolDefinition(cwd, options));
}
/** Default edit tool using process.cwd() for backwards compatibility. */
export const editToolDefinition = createEditToolDefinition(process.cwd());
export const editTool = createEditTool(process.cwd());

View File

@@ -1,11 +1,16 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { existsSync } from "fs";
import { globSync } from "glob";
import path from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
function toPosixPath(value: string): string {
@@ -31,41 +36,97 @@ export interface FindToolDetails {
/**
* Pluggable operations for the find tool.
* Override these to delegate file search to remote systems (e.g., SSH).
* Override these to delegate file search to remote systems (for example SSH).
*/
export interface FindOperations {
/** Check if path exists */
exists: (absolutePath: string) => Promise<boolean> | boolean;
/** Find files matching glob pattern. Returns relative paths. */
/** Find files matching glob pattern. Returns relative or absolute paths. */
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
}
const defaultFindOperations: FindOperations = {
exists: existsSync,
glob: (_pattern, _searchCwd, _options) => {
// This is a placeholder - actual fd execution happens in execute
return [];
},
// This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided.
glob: () => [],
};
export interface FindToolOptions {
/** Custom operations for find. Default: local filesystem + fd */
/** Custom operations for find. Default: local filesystem plus fd */
operations?: FindOperations;
}
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
const customOps = options?.operations;
function formatFindCall(
args: { pattern: string; path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("find")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatFindResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: FindToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
const resultLimit = result.details?.resultLimitReached;
const truncation = result.details?.truncation;
if (resultLimit || truncation?.truncated) {
const warnings: string[] = [];
if (resultLimit) warnings.push(`${resultLimit} results limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createFindToolDefinition(
cwd: string,
options?: FindToolOptions,
): ToolDefinition<typeof findSchema, FindToolDetails | undefined> {
const customOps = options?.operations;
return {
name: "find",
label: "find",
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
promptSnippet: "Find files by glob pattern (respects .gitignore)",
parameters: findSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
@@ -81,20 +142,17 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
const effectiveLimit = limit ?? DEFAULT_LIMIT;
const ops = customOps ?? defaultFindOperations;
// If custom operations provided with glob, use that
// If custom operations provide glob(), use that instead of fd.
if (customOps?.glob) {
if (!(await ops.exists(searchPath))) {
reject(new Error(`Path not found: ${searchPath}`));
return;
}
const results = await ops.glob(pattern, searchPath, {
ignore: ["**/node_modules/**", "**/.git/**"],
limit: effectiveLimit,
});
signal?.removeEventListener("abort", onAbort);
if (results.length === 0) {
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
@@ -103,36 +161,28 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
return;
}
// Relativize paths
// Relativize paths against the search root for stable output.
const relativized = results.map((p) => {
if (p.startsWith(searchPath)) {
return toPosixPath(p.slice(searchPath.length + 1));
}
if (p.startsWith(searchPath)) return toPosixPath(p.slice(searchPath.length + 1));
return toPosixPath(path.relative(searchPath, p));
});
const resultLimitReached = relativized.length >= effectiveLimit;
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = {};
const notices: string[] = [];
if (resultLimitReached) {
notices.push(`${effectiveLimit} results limit reached`);
details.resultLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
@@ -140,14 +190,14 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
return;
}
// Default: use fd
// Default implementation uses fd.
const fdPath = await ensureTool("fd", true);
if (!fdPath) {
reject(new Error("fd is not available and could not be downloaded"));
return;
}
// Build fd arguments
// Build fd arguments.
const args: string[] = [
"--glob",
"--color=never",
@@ -155,14 +205,10 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
"--max-results",
String(effectiveLimit),
];
// Include .gitignore files
// Include .gitignore files from the search tree.
const gitignoreFiles = new Set<string>();
const rootGitignore = path.join(searchPath, ".gitignore");
if (existsSync(rootGitignore)) {
gitignoreFiles.add(rootGitignore);
}
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
try {
const nestedGitignores = globSync("**/.gitignore", {
cwd: searchPath,
@@ -170,33 +216,21 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
absolute: true,
ignore: ["**/node_modules/**", "**/.git/**"],
});
for (const file of nestedGitignores) {
gitignoreFiles.add(file);
}
for (const file of nestedGitignores) gitignoreFiles.add(file);
} catch {
// Ignore glob errors
// ignore
}
for (const gitignorePath of gitignoreFiles) {
args.push("--ignore-file", gitignorePath);
}
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
args.push(pattern, searchPath);
const result = spawnSync(fdPath, args, {
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
});
const result = spawnSync(fdPath, args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
signal?.removeEventListener("abort", onAbort);
if (result.error) {
reject(new Error(`Failed to run fd: ${result.error.message}`));
return;
}
const output = result.stdout?.trim() || "";
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
if (!output) {
@@ -204,7 +238,6 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
return;
}
}
if (!output) {
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
@@ -215,11 +248,9 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) continue;
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
@@ -227,38 +258,29 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) {
relativePath += "/";
}
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
relativized.push(toPosixPath(relativePath));
}
const resultLimitReached = relativized.length >= effectiveLimit;
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = {};
const notices: string[] = [];
if (resultLimitReached) {
notices.push(
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.resultLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
@@ -270,8 +292,23 @@ export function createFindTool(cwd: string, options?: FindToolOptions): AgentToo
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindResult(result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default find tool using process.cwd() - for backwards compatibility */
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
return wrapToolDefinition(createFindToolDefinition(cwd, options));
}
/** Default find tool using process.cwd() for backwards compatibility. */
export const findToolDefinition = createFindToolDefinition(process.cwd());
export const findTool = createFindTool(process.cwd());

View File

@@ -1,11 +1,16 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { readFileSync, statSync } from "fs";
import path from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import {
DEFAULT_MAX_BYTES,
formatSize,
@@ -30,7 +35,6 @@ const grepSchema = Type.Object({
});
export type GrepToolInput = Static<typeof grepSchema>;
const DEFAULT_LIMIT = 100;
export interface GrepToolDetails {
@@ -41,10 +45,10 @@ export interface GrepToolDetails {
/**
* Pluggable operations for the grep tool.
* Override these to delegate search to remote systems (e.g., SSH).
* Override these to delegate search to remote systems (for example SSH).
*/
export interface GrepOperations {
/** Check if path is a directory. Throws if path doesn't exist. */
/** Check if path is a directory. Throws if path does not exist. */
isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
/** Read file contents for context lines */
readFile: (absolutePath: string) => Promise<string> | string;
@@ -56,20 +60,78 @@ const defaultGrepOperations: GrepOperations = {
};
export interface GrepToolOptions {
/** Custom operations for grep. Default: local filesystem + ripgrep */
/** Custom operations for grep. Default: local filesystem plus ripgrep */
operations?: GrepOperations;
}
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
const customOps = options?.operations;
function formatGrepCall(
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const glob = str(args?.glob);
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("grep")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (glob) text += theme.fg("toolOutput", ` (${glob})`);
if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`);
return text;
}
function formatGrepResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: GrepToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 15;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
const matchLimit = result.details?.matchLimitReached;
const truncation = result.details?.truncation;
const linesTruncated = result.details?.linesTruncated;
if (matchLimit || truncation?.truncated || linesTruncated) {
const warnings: string[] = [];
if (matchLimit) warnings.push(`${matchLimit} matches limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
if (linesTruncated) warnings.push("some lines truncated");
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createGrepToolDefinition(
cwd: string,
options?: GrepToolOptions,
): ToolDefinition<typeof grepSchema, GrepToolDetails | undefined> {
const customOps = options?.operations;
return {
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.`,
promptSnippet: "Search file contents for patterns (respects .gitignore)",
parameters: grepSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{
pattern,
path: searchDir,
@@ -88,13 +150,14 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
limit?: number;
},
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let settled = false;
const settle = (fn: () => void) => {
if (!settled) {
@@ -113,17 +176,16 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
const searchPath = resolveToCwd(searchDir || ".", cwd);
const ops = customOps ?? defaultGrepOperations;
let isDirectory: boolean;
try {
isDirectory = await ops.isDirectory(searchPath);
} catch (_err) {
} catch {
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
return;
}
const contextValue = context && context > 0 ? context : 0;
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
const formatPath = (filePath: string): string => {
if (isDirectory) {
const relative = path.relative(searchPath, filePath);
@@ -150,19 +212,9 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
};
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
if (ignoreCase) {
args.push("--ignore-case");
}
if (literal) {
args.push("--fixed-strings");
}
if (glob) {
args.push("--glob", glob);
}
if (ignoreCase) args.push("--ignore-case");
if (literal) args.push("--fixed-strings");
if (glob) args.push("--glob", glob);
args.push(pattern, searchPath);
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -179,21 +231,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
rl.close();
signal?.removeEventListener("abort", onAbort);
};
const stopChild = (dueToLimit: boolean = false) => {
const stopChild = (dueToLimit = false) => {
if (!child.killed) {
killedDueToLimit = dueToLimit;
child.kill();
}
};
const onAbort = () => {
aborted = true;
stopChild();
};
signal?.addEventListener("abort", onAbort, { once: true });
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
@@ -201,59 +249,38 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
const relativePath = formatPath(filePath);
const lines = await getFileLines(filePath);
if (!lines.length) {
return [`${relativePath}:${lineNumber}: (unable to read file)`];
}
if (!lines.length) return [`${relativePath}:${lineNumber}: (unable to read file)`];
const block: string[] = [];
const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
for (let current = start; current <= end; current++) {
const lineText = lines[current - 1] ?? "";
const sanitized = lineText.replace(/\r/g, "");
const isMatchLine = current === lineNumber;
// Truncate long lines
// Truncate long lines so grep output stays compact.
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
if (wasTruncated) {
linesTruncated = true;
}
if (isMatchLine) {
block.push(`${relativePath}:${current}: ${truncatedText}`);
} else {
block.push(`${relativePath}-${current}- ${truncatedText}`);
}
if (wasTruncated) linesTruncated = true;
if (isMatchLine) block.push(`${relativePath}:${current}: ${truncatedText}`);
else block.push(`${relativePath}-${current}- ${truncatedText}`);
}
return block;
};
// Collect matches during streaming, format after
// Collect matches during streaming, then format them after rg exits.
const matches: Array<{ filePath: string; lineNumber: number }> = [];
rl.on("line", (line) => {
if (!line.trim() || matchCount >= effectiveLimit) {
return;
}
if (!line.trim() || matchCount >= effectiveLimit) return;
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "match") {
matchCount++;
const filePath = event.data?.path?.text;
const lineNumber = event.data?.line_number;
if (filePath && typeof lineNumber === "number") {
matches.push({ filePath, lineNumber });
}
if (filePath && typeof lineNumber === "number") matches.push({ filePath, lineNumber });
if (matchCount >= effectiveLimit) {
matchLimitReached = true;
stopChild(true);
@@ -265,21 +292,17 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
cleanup();
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
});
child.on("close", async (code) => {
cleanup();
if (aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (!killedDueToLimit && code !== 0 && code !== 1) {
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
settle(() => reject(new Error(errorMsg)));
return;
}
if (matchCount === 0) {
settle(() =>
resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }),
@@ -287,45 +310,36 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
return;
}
// Format matches (async to support remote file reading)
// Format matches after streaming finishes so custom readFile() backends can be async.
for (const match of matches) {
const block = await formatBlock(match.filePath, match.lineNumber);
outputLines.push(...block);
}
// Apply byte truncation (no line limit since we already have match limit)
const rawOutput = outputLines.join("\n");
// Apply byte truncation. There is no line limit here because the match limit already capped rows.
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: GrepToolDetails = {};
// Build notices
// Build actionable notices for truncation and match limits.
const notices: string[] = [];
if (matchLimitReached) {
notices.push(
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.matchLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (linesTruncated) {
notices.push(
`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
);
details.linesTruncated = true;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
settle(() =>
resolve({
content: [{ type: "text", text: output }],
@@ -339,8 +353,23 @@ export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentToo
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepResult(result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default grep tool using process.cwd() - for backwards compatibility */
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
return wrapToolDefinition(createGrepToolDefinition(cwd, options));
}
/** Default grep tool using process.cwd() for backwards compatibility. */
export const grepToolDefinition = createGrepToolDefinition(process.cwd());
export const grepTool = createGrepTool(process.cwd());

View File

@@ -6,49 +6,61 @@ export {
type BashToolInput,
type BashToolOptions,
bashTool,
bashToolDefinition,
createBashTool,
createBashToolDefinition,
createLocalBashOperations,
} from "./bash.js";
export {
createEditTool,
createEditToolDefinition,
type EditOperations,
type EditToolDetails,
type EditToolInput,
type EditToolOptions,
editTool,
editToolDefinition,
} from "./edit.js";
export { withFileMutationQueue } from "./file-mutation-queue.js";
export {
createFindTool,
createFindToolDefinition,
type FindOperations,
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
findTool,
findToolDefinition,
} from "./find.js";
export {
createGrepTool,
createGrepToolDefinition,
type GrepOperations,
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
grepTool,
grepToolDefinition,
} from "./grep.js";
export {
createLsTool,
createLsToolDefinition,
type LsOperations,
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
lsTool,
lsToolDefinition,
} from "./ls.js";
export {
createReadTool,
createReadToolDefinition,
type ReadOperations,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
readTool,
readToolDefinition,
} from "./read.js";
export {
DEFAULT_MAX_BYTES,
@@ -62,31 +74,42 @@ export {
} from "./truncate.js";
export {
createWriteTool,
createWriteToolDefinition,
type WriteOperations,
type WriteToolInput,
type WriteToolOptions,
writeTool,
writeToolDefinition,
} from "./write.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { type BashToolOptions, bashTool, createBashTool } from "./bash.js";
import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js";
import { createGrepTool, grepTool } from "./grep.js";
import { createLsTool, lsTool } from "./ls.js";
import { createReadTool, type ReadToolOptions, readTool } from "./read.js";
import { createWriteTool, writeTool } from "./write.js";
import type { ToolDefinition } from "../extensions/types.js";
import {
type BashToolOptions,
bashTool,
bashToolDefinition,
createBashTool,
createBashToolDefinition,
} from "./bash.js";
import { createEditTool, createEditToolDefinition, editTool, editToolDefinition } from "./edit.js";
import { createFindTool, createFindToolDefinition, findTool, findToolDefinition } from "./find.js";
import { createGrepTool, createGrepToolDefinition, grepTool, grepToolDefinition } from "./grep.js";
import { createLsTool, createLsToolDefinition, lsTool, lsToolDefinition } from "./ls.js";
import {
createReadTool,
createReadToolDefinition,
type ReadToolOptions,
readTool,
readToolDefinition,
} from "./read.js";
import { createWriteTool, createWriteToolDefinition, writeTool, writeToolDefinition } from "./write.js";
/** Tool type (AgentTool from pi-ai) */
export type Tool = AgentTool<any>;
export type ToolDef = ToolDefinition<any, any>;
// Default tools for full access mode (using process.cwd())
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool];
// Read-only tools for exploration without modification (using process.cwd())
export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
// All available tools (using process.cwd())
export const allTools = {
read: readTool,
bash: bashTool,
@@ -97,18 +120,53 @@ export const allTools = {
ls: lsTool,
};
export const allToolDefinitions = {
read: readToolDefinition,
bash: bashToolDefinition,
edit: editToolDefinition,
write: writeToolDefinition,
grep: grepToolDefinition,
find: findToolDefinition,
ls: lsToolDefinition,
};
export type ToolName = keyof typeof allTools;
export interface ToolsOptions {
/** Options for the read tool */
read?: ReadToolOptions;
/** Options for the bash tool */
bash?: BashToolOptions;
}
/**
* Create coding tools configured for a specific working directory.
*/
export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {
return [
createReadToolDefinition(cwd, options?.read),
createBashToolDefinition(cwd, options?.bash),
createEditToolDefinition(cwd),
createWriteToolDefinition(cwd),
];
}
export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] {
return [
createReadToolDefinition(cwd, options?.read),
createGrepToolDefinition(cwd),
createFindToolDefinition(cwd),
createLsToolDefinition(cwd),
];
}
export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): Record<ToolName, ToolDef> {
return {
read: createReadToolDefinition(cwd, options?.read),
bash: createBashToolDefinition(cwd, options?.bash),
edit: createEditToolDefinition(cwd),
write: createWriteToolDefinition(cwd),
grep: createGrepToolDefinition(cwd),
find: createFindToolDefinition(cwd),
ls: createLsToolDefinition(cwd),
};
}
export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] {
return [
createReadTool(cwd, options?.read),
@@ -118,16 +176,10 @@ export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] {
];
}
/**
* Create read-only tools configured for a specific working directory.
*/
export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] {
return [createReadTool(cwd, options?.read), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)];
}
/**
* Create all tools configured for a specific working directory.
*/
export function createAllTools(cwd: string, options?: ToolsOptions): Record<ToolName, Tool> {
return {
read: createReadTool(cwd, options?.read),

View File

@@ -1,8 +1,13 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
const lsSchema = Type.Object({
@@ -21,12 +26,12 @@ export interface LsToolDetails {
/**
* Pluggable operations for the ls tool.
* Override these to delegate directory listing to remote systems (e.g., SSH).
* Override these to delegate directory listing to remote systems (for example SSH).
*/
export interface LsOperations {
/** Check if path exists */
exists: (absolutePath: string) => Promise<boolean> | boolean;
/** Get file/directory stats. Throws if not found. */
/** Get file or directory stats. Throws if not found. */
stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean };
/** Read directory entries */
readdir: (absolutePath: string) => Promise<string[]> | string[];
@@ -43,19 +48,72 @@ export interface LsToolOptions {
operations?: LsOperations;
}
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
const ops = options?.operations ?? defaultLsOperations;
function formatLsCall(
args: { path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`;
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatLsResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: LsToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
const entryLimit = result.details?.entryLimitReached;
const truncation = result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings: string[] = [];
if (entryLimit) warnings.push(`${entryLimit} entries limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createLsToolDefinition(
cwd: string,
options?: LsToolOptions,
): ToolDefinition<typeof lsSchema, LsToolDetails | undefined> {
const ops = options?.operations ?? defaultLsOperations;
return {
name: "ls",
label: "ls",
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
promptSnippet: "List directory contents",
parameters: lsSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, limit }: { path?: string; limit?: number },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
@@ -70,20 +128,20 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
const dirPath = resolveToCwd(path || ".", cwd);
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Check if path exists
// Check if path exists.
if (!(await ops.exists(dirPath))) {
reject(new Error(`Path not found: ${dirPath}`));
return;
}
// Check if path is a directory
// Check if path is a directory.
const stat = await ops.stat(dirPath);
if (!stat.isDirectory()) {
reject(new Error(`Not a directory: ${dirPath}`));
return;
}
// Read directory entries
// Read directory entries.
let entries: string[];
try {
entries = await ops.readdir(dirPath);
@@ -92,13 +150,12 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
return;
}
// Sort alphabetically (case-insensitive)
// Sort alphabetically, case-insensitive.
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Format entries with directory indicators
// Format entries with directory indicators.
const results: string[] = [];
let entryLimitReached = false;
for (const entry of entries) {
if (results.length >= effectiveLimit) {
entryLimitReached = true;
@@ -107,17 +164,13 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
const fullPath = nodePath.join(dirPath, entry);
let suffix = "";
try {
const entryStat = await ops.stat(fullPath);
if (entryStat.isDirectory()) {
suffix = "/";
}
if (entryStat.isDirectory()) suffix = "/";
} catch {
// Skip entries we can't stat
// Skip entries we cannot stat.
continue;
}
results.push(entry + suffix);
}
@@ -128,26 +181,21 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
return;
}
// Apply byte truncation (no line limit since we already have entry limit)
const rawOutput = results.join("\n");
// Apply byte truncation. There is no separate line limit because entry count is already capped.
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: LsToolDetails = {};
// Build notices
// Build actionable notices for truncation and entry limits.
const notices: string[] = [];
if (entryLimitReached) {
notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
details.entryLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
@@ -163,8 +211,23 @@ export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<ty
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsResult(result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default ls tool using process.cwd() - for backwards compatibility */
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
return wrapToolDefinition(createLsToolDefinition(cwd, options));
}
/** Default ls tool using process.cwd() for backwards compatibility. */
export const lsToolDefinition = createLsToolDefinition(process.cwd());
export const lsTool = createLsTool(process.cwd());

View File

@@ -1,11 +1,17 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveReadPath } from "./path-utils.js";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
const readSchema = Type.Object({
@@ -22,14 +28,14 @@ export interface ReadToolDetails {
/**
* Pluggable operations for the read tool.
* Override these to delegate file reading to remote systems (e.g., SSH).
* Override these to delegate file reading to remote systems (for example SSH).
*/
export interface ReadOperations {
/** Read file contents as a Buffer */
readFile: (absolutePath: string) => Promise<Buffer>;
/** Check if file is readable (throw if not) */
access: (absolutePath: string) => Promise<void>;
/** Detect image MIME type, return null/undefined for non-images */
/** Detect image MIME type, return null or undefined for non-images */
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
}
@@ -46,104 +52,143 @@ export interface ReadToolOptions {
operations?: ReadOperations;
}
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
function formatReadCall(
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const offset = args?.offset;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0 && lines[end - 1] === "") {
end--;
}
return lines.slice(0, end);
}
function formatReadResult(
args: { path?: string; file_path?: string; offset?: number; limit?: number } | undefined,
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
showImages: boolean,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const output = getTextOutput(result as any, showImages);
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
const truncation = result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`;
} else if (truncation.truncatedBy === "lines") {
text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`;
} else {
text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;
}
}
return text;
}
export function createReadToolDefinition(
cwd: string,
options?: ReadToolOptions,
): ToolDefinition<typeof readSchema, ReadToolDetails | undefined> {
const autoResizeImages = options?.autoResizeImages ?? true;
const ops = options?.operations ?? defaultReadOperations;
return {
name: "read",
label: "read",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
promptSnippet: "Read file contents",
promptGuidelines: ["Use read to examine files instead of cat or sed."],
parameters: readSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, offset, limit }: { path: string; offset?: number; limit?: number },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
const absolutePath = resolveReadPath(path, cwd);
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
(resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
// Check if file exists and is readable.
await ops.access(absolutePath);
// Check if aborted before reading
if (aborted) {
return;
}
if (aborted) return;
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined;
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails | undefined;
if (mimeType) {
// Read as image (binary)
// Read image as binary.
const buffer = await ops.readFile(absolutePath);
const base64 = buffer.toString("base64");
if (autoResizeImages) {
// Resize image if needed
// Resize image if needed before sending it back to the model.
const resized = await resizeImage({ type: "image", data: base64, mimeType });
const dimensionNote = formatDimensionNote(resized);
let textNote = `Read image file [${resized.mimeType}]`;
if (dimensionNote) {
textNote += `\n${dimensionNote}`;
}
if (dimensionNote) textNote += `\n${dimensionNote}`;
content = [
{ type: "text", text: textNote },
{ type: "image", data: resized.data, mimeType: resized.mimeType },
];
} else {
const textNote = `Read image file [${mimeType}]`;
content = [
{ type: "text", text: textNote },
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
}
} else {
// Read as text
// Read text content.
const buffer = await ops.readFile(absolutePath);
const textContent = buffer.toString("utf-8");
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
// Apply offset if specified (1-indexed to 0-indexed)
// Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1; // For display (1-indexed)
// Check if offset is out of bounds
const startLineDisplay = startLine + 1;
// Check if offset is out of bounds.
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
let selectedContent: string;
let userLimitedLines: number | undefined;
// If limit is specified by the user, honor it first. Otherwise truncateHead decides.
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
@@ -151,24 +196,19 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
// Apply truncation (respects both line and byte limits)
// Apply truncation, respecting both line and byte limits.
const truncation = truncateHead(selectedContent);
let outputText: string;
if (truncation.firstLineExceedsLimit) {
// First line at offset exceeds 30KB - tell model to use bash
// First line alone exceeds the byte limit. Point the model at a bash fallback.
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
details = { truncation };
} else if (truncation.truncated) {
// Truncation occurred - build actionable notice
// Truncation occurred. Build an actionable continuation notice.
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} else {
@@ -176,47 +216,45 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
}
details = { truncation };
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
// User specified limit, there's more content, but no truncation
// User-specified limit stopped early, but the file still has more content.
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = truncation.content;
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
} else {
// No truncation, no user limit exceeded
// No truncation and no remaining user-limited content.
outputText = truncation.content;
}
content = [{ type: "text", text: outputText }];
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (aborted) return;
signal?.removeEventListener("abort", onAbort);
resolve({ content, details });
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
signal?.removeEventListener("abort", onAbort);
if (!aborted) reject(error);
}
})();
},
);
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatReadCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatReadResult(context.args, result as any, options, theme, context.showImages));
return text;
},
};
}
/** Default read tool using process.cwd() - for backwards compatibility */
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
return wrapToolDefinition(createReadToolDefinition(cwd, options));
}
/** Default read tool using process.cwd() for backwards compatibility. */
export const readToolDefinition = createReadToolDefinition(process.cwd());
export const readTool = createReadTool(process.cwd());

View File

@@ -0,0 +1,64 @@
import * as os from "node:os";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { getCapabilities, getImageDimensions, imageFallback } from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../../utils/shell.js";
export function shortenPath(path: unknown): string {
if (typeof path !== "string") return "";
const home = os.homedir();
if (path.startsWith(home)) {
return `~${path.slice(home.length)}`;
}
return path;
}
export function str(value: unknown): string | null {
if (typeof value === "string") return value;
if (value == null) return "";
return null;
}
export function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
export function normalizeDisplayText(text: string): string {
return text.replace(/\r/g, "");
}
export function getTextOutput(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } | undefined,
showImages: boolean,
): string {
if (!result) return "";
const textBlocks = result.content.filter((c) => c.type === "text");
const imageBlocks = result.content.filter((c) => c.type === "image");
let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n");
const caps = getCapabilities();
if (imageBlocks.length > 0 && (!caps.images || !showImages)) {
const imageIndicators = imageBlocks
.map((img) => {
const mimeType = img.mimeType ?? "image/unknown";
const dims =
img.data && img.mimeType ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
return imageFallback(mimeType, dims);
})
.join("\n");
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
}
return output;
}
export type ToolRenderResultLike<TDetails> = {
content: (TextContent | ImageContent)[];
details: TDetails;
};
export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string {
return theme.fg("error", "[invalid arg]");
}

View File

@@ -0,0 +1,41 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.js";
/** Wrap a ToolDefinition into an AgentTool for the core runtime. */
export function wrapToolDefinition<TDetails = unknown>(
definition: ToolDefinition<any, TDetails>,
ctxFactory?: () => ExtensionContext,
): AgentTool<any, TDetails> {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext),
};
}
/** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */
export function wrapToolDefinitions(
definitions: ToolDefinition<any, any>[],
ctxFactory?: () => ExtensionContext,
): AgentTool<any>[] {
return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory));
}
/**
* Synthesize a minimal ToolDefinition from an AgentTool.
*
* This keeps AgentSession's internal registry definition-first even when a caller
* provides plain AgentTool overrides that do not include prompt metadata or renderers.
*/
export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDefinition<any, unknown> {
return {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters as any,
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
};
}

View File

@@ -1,9 +1,15 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
@@ -14,12 +20,12 @@ export type WriteToolInput = Static<typeof writeSchema>;
/**
* Pluggable operations for the write tool.
* Override these to delegate file writing to remote systems (e.g., SSH).
* Override these to delegate file writing to remote systems (for example SSH).
*/
export interface WriteOperations {
/** Write content to a file */
writeFile: (absolutePath: string, content: string) => Promise<void>;
/** Create directory (recursively) */
/** Create directory recursively */
mkdir: (dir: string) => Promise<void>;
}
@@ -33,70 +39,191 @@ export interface WriteToolOptions {
operations?: WriteOperations;
}
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
const ops = options?.operations ?? defaultWriteOperations;
type WriteHighlightCache = {
rawPath: string | null;
lang: string;
rawContent: string;
normalizedLines: string[];
highlightedLines: string[];
};
class WriteCallRenderComponent extends Text {
cache?: WriteHighlightCache;
constructor() {
super("", 0, 0);
}
}
const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;
function highlightSingleLine(line: string, lang: string): string {
const highlighted = highlightCode(line, lang);
return highlighted[0] ?? "";
}
function refreshWriteHighlightPrefix(cache: WriteHighlightCache): void {
const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
if (prefixCount === 0) return;
const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
const prefixHighlighted = highlightCode(prefixSource, cache.lang);
for (let i = 0; i < prefixCount; i++) {
cache.highlightedLines[i] =
prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
}
}
function rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
if (!lang) return undefined;
const displayContent = normalizeDisplayText(fileContent);
const normalized = replaceTabs(displayContent);
return {
rawPath,
lang,
rawContent: fileContent,
normalizedLines: normalized.split("\n"),
highlightedLines: highlightCode(normalized, lang),
};
}
function updateWriteHighlightCacheIncremental(
cache: WriteHighlightCache | undefined,
rawPath: string | null,
fileContent: string,
): WriteHighlightCache | undefined {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
if (!lang) return undefined;
if (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
if (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
if (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent);
if (fileContent.length === cache.rawContent.length) return cache;
const deltaRaw = fileContent.slice(cache.rawContent.length);
const deltaDisplay = normalizeDisplayText(deltaRaw);
const deltaNormalized = replaceTabs(deltaDisplay);
cache.rawContent = fileContent;
if (cache.normalizedLines.length === 0) {
cache.normalizedLines.push("");
cache.highlightedLines.push("");
}
const segments = deltaNormalized.split("\n");
const lastIndex = cache.normalizedLines.length - 1;
cache.normalizedLines[lastIndex] += segments[0];
cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
for (let i = 1; i < segments.length; i++) {
cache.normalizedLines.push(segments[i]);
cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang));
}
refreshWriteHighlightPrefix(cache);
return cache;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0 && lines[end - 1] === "") {
end--;
}
return lines.slice(0, end);
}
function formatWriteCall(
args: { path?: string; file_path?: string; content?: string } | undefined,
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
cache: WriteHighlightCache | undefined,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const fileContent = str(args?.content);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const invalidArg = invalidArgText(theme);
let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`;
if (fileContent === null) {
text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
} else if (fileContent) {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang
? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))
: normalizeDisplayText(fileContent).split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const totalLines = lines.length;
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
}
return text;
}
function formatWriteResult(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
): string | undefined {
if (!result.isError) {
return undefined;
}
const output = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
if (!output) {
return undefined;
}
return `\n${theme.fg("error", output)}`;
}
export function createWriteToolDefinition(
cwd: string,
options?: WriteToolOptions,
): ToolDefinition<typeof writeSchema, undefined> {
const ops = options?.operations ?? defaultWriteOperations;
return {
name: "write",
label: "write",
description:
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
promptSnippet: "Create or overwrite files",
promptGuidelines: ["Use write only for new files or complete rewrites."],
parameters: writeSchema,
execute: async (
_toolCallId: string,
async execute(
_toolCallId,
{ path, content }: { path: string; content: string },
signal?: AbortSignal,
) => {
_onUpdate?,
_ctx?,
) {
const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(
absolutePath,
() =>
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
(resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the write operation
signal?.addEventListener("abort", onAbort, { once: true });
(async () => {
try {
// Create parent directories if needed
// Create parent directories if needed.
await ops.mkdir(dir);
// Check if aborted before writing
if (aborted) {
return;
}
// Write the file
if (aborted) return;
// Write the file contents.
await ops.writeFile(absolutePath, content);
// Check if aborted after writing
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (aborted) return;
signal?.removeEventListener("abort", onAbort);
resolve({
content: [
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
@@ -104,22 +231,55 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT
details: undefined,
});
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
signal?.removeEventListener("abort", onAbort);
if (!aborted) reject(error);
}
})();
},
),
);
},
renderCall(args, theme, context) {
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
const rawPath = str(renderArgs?.file_path ?? renderArgs?.path);
const fileContent = str(renderArgs?.content);
const component =
(context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent();
if (fileContent !== null) {
component.cache = context.argsComplete
? rebuildWriteHighlightCacheFull(rawPath, fileContent)
: updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent);
} else {
component.cache = undefined;
}
component.setText(
formatWriteCall(
renderArgs,
{ expanded: context.expanded, isPartial: context.isPartial },
theme,
component.cache,
),
);
return component;
},
renderResult(result, _options, theme, context) {
const output = formatWriteResult({ ...result, isError: context.isError }, theme);
if (!output) {
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
return component;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(output);
return text;
},
};
}
/** Default write tool using process.cwd() - for backwards compatibility */
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
return wrapToolDefinition(createWriteToolDefinition(cwd, options));
}
/** Default write tool using process.cwd() for backwards compatibility. */
export const writeToolDefinition = createWriteToolDefinition(process.cwd());
export const writeTool = createWriteTool(process.cwd());

View File

@@ -223,8 +223,16 @@ export {
type BashToolInput,
type BashToolOptions,
bashTool,
bashToolDefinition,
codingTools,
createBashToolDefinition,
createEditToolDefinition,
createFindToolDefinition,
createGrepToolDefinition,
createLocalBashOperations,
createLsToolDefinition,
createReadToolDefinition,
createWriteToolDefinition,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
type EditOperations,
@@ -232,27 +240,32 @@ export {
type EditToolInput,
type EditToolOptions,
editTool,
editToolDefinition,
type FindOperations,
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
findTool,
findToolDefinition,
formatSize,
type GrepOperations,
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
grepTool,
grepToolDefinition,
type LsOperations,
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
lsTool,
lsToolDefinition,
type ReadOperations,
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
readTool,
readToolDefinition,
type ToolsOptions,
type TruncationOptions,
type TruncationResult,
@@ -264,6 +277,7 @@ export {
type WriteToolOptions,
withFileMutationQueue,
writeTool,
writeToolDefinition,
} from "./core/tools/index.js";
// Main entry point
export { main } from "./main.js";

View File

@@ -1181,9 +1181,7 @@ export class InteractiveMode {
* Get a registered tool definition by name (for custom rendering).
*/
private getRegisteredToolDefinition(toolName: string) {
const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? [];
const registeredTool = tools.find((t) => t.definition.name === toolName);
return registeredTool?.definition;
return this.session.getToolDefinition(toolName);
}
/**
@@ -2198,6 +2196,7 @@ export class InteractiveMode {
if (!this.pendingTools.has(content.id)) {
const component = new ToolExecutionComponent(
content.name,
content.id,
content.arguments,
{
showImages: this.settingsManager.getShowImages(),
@@ -2264,6 +2263,7 @@ export class InteractiveMode {
if (!component) {
component = new ToolExecutionComponent(
event.toolName,
event.toolCallId,
event.args,
{
showImages: this.settingsManager.getShowImages(),
@@ -2568,6 +2568,7 @@ export class InteractiveMode {
if (content.type === "toolCall") {
const component = new ToolExecutionComponent(
content.name,
content.id,
content.arguments,
{ showImages: this.settingsManager.getShowImages() },
this.getRegisteredToolDefinition(content.name),

View File

@@ -25,8 +25,14 @@ describe("buildSystemPrompt", () => {
});
describe("default tools", () => {
test("includes all default tools", () => {
test("includes all default tools when snippets are provided", () => {
const prompt = buildSystemPrompt({
toolSnippets: {
read: "Read file contents",
bash: "Execute bash commands",
edit: "Make surgical edits",
write: "Create or overwrite files",
},
contextFiles: [],
skills: [],
});

View File

@@ -3,13 +3,16 @@ import { Type } from "@sinclair/typebox";
import stripAnsi from "strip-ansi";
import { beforeAll, describe, expect, test } from "vitest";
import type { ToolDefinition } from "../src/core/extensions/types.js";
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js";
import { createReadToolDefinition } from "../src/core/tools/read.js";
import { createWriteToolDefinition } from "../src/core/tools/write.js";
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";
import { initTheme } from "../src/modes/interactive/theme/theme.js";
function createBaseToolDefinition(): ToolDefinition {
function createBaseToolDefinition(name = "custom_tool"): ToolDefinition {
return {
name: "custom_tool",
label: "custom_tool",
name,
label: name,
description: "custom tool",
parameters: Type.Any(),
execute: async () => ({
@@ -25,48 +28,235 @@ function createFakeTui(): TUI {
} as unknown as TUI;
}
describe("ToolExecutionComponent custom renderer suppression", () => {
describe("ToolExecutionComponent parity", () => {
beforeAll(() => {
initTheme("dark");
});
test("renders no lines when custom renderers return undefined", () => {
test("stacks custom call and result renderers like the old implementation", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: () => undefined,
renderResult: () => undefined,
renderCall: () => new Text("custom call", 0, 0),
renderResult: () => new Text("custom result", 0, 0),
};
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
expect(component.render(120)).toEqual([]);
const component = new ToolExecutionComponent("custom_tool", "tool-1", {}, {}, toolDefinition, createFakeTui());
expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call");
component.updateResult(
{
content: [{ type: "text", text: "hidden" }],
content: [{ type: "text", text: "done" }],
details: {},
isError: false,
},
false,
);
expect(component.render(120)).toEqual([]);
});
test("keeps built-in tool rendering visible", () => {
const component = new ToolExecutionComponent("read", { path: "README.md" }, {}, undefined, createFakeTui());
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read");
});
test("keeps custom tool rendering visible when renderer returns a component", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: () => new Text("custom call", 0, 0),
renderResult: () => undefined,
};
const component = new ToolExecutionComponent("custom_tool", {}, {}, toolDefinition, createFakeTui());
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom call");
expect(rendered).toContain("custom result");
});
test("uses built-in rendering for built-in overrides without custom renderers", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("edit"),
};
const component = new ToolExecutionComponent(
"edit",
"tool-2",
{ path: "README.md", oldText: "before", newText: "after" },
{},
overrideDefinition,
createFakeTui(),
);
component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false });
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("edit");
expect(rendered).toContain("README.md");
expect(rendered).not.toContain(":1");
});
test("preserves legacy file_path rendering compatibility for built-in tools", () => {
const component = new ToolExecutionComponent(
"read",
"tool-3",
{ file_path: "README.md" },
{},
undefined,
createFakeTui(),
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read");
expect(rendered).toContain("README.md");
});
test("bash execute emits an initial empty partial update before output arrives", async () => {
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
const operations: BashOperations = {
exec: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return { exitCode: 0 };
},
};
const tool = createBashToolDefinition(process.cwd(), { operations });
const promise = tool.execute(
"tool-bash-1",
{ command: "sleep 10" },
undefined,
(update) => updates.push(update as { content: Array<{ type: string; text?: string }>; details?: unknown }),
{} as never,
);
expect(updates).toEqual([{ content: [], details: undefined }]);
await promise;
});
test("does not duplicate built-in headers when passed the active built-in definition", () => {
const component = new ToolExecutionComponent(
"read",
"tool-4",
{ path: "README.md" },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered.match(/\bread\b/g)?.length ?? 0).toBe(1);
});
test("inherits missing built-in result renderer slot from the built-in tool", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("read"),
renderCall: () => new Text("override call", 0, 0),
};
const component = new ToolExecutionComponent(
"read",
"tool-4b",
{ path: "README.md" },
{},
overrideDefinition,
createFakeTui(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("override call");
expect(rendered).toContain("hello");
});
test("inherits missing built-in call renderer slot from the built-in tool", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("read"),
renderResult: () => new Text("override result", 0, 0),
};
const component = new ToolExecutionComponent(
"read",
"tool-4c",
{ path: "README.md" },
{},
overrideDefinition,
createFakeTui(),
);
component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("read");
expect(rendered).toContain("README.md");
expect(rendered).toContain("override result");
});
test("shares renderer state across custom call and result slots", () => {
type RenderState = { token?: string };
const toolDefinition: ToolDefinition<any, unknown, RenderState> = {
...createBaseToolDefinition(),
renderCall: (_args, _theme, context) => {
context.state.token ??= "shared-token";
return new Text(`custom call ${context.state.token}`, 0, 0);
},
renderResult: (_result, _options, _theme, context) => {
return new Text(`custom result ${context.state.token}`, 0, 0);
},
};
const component = new ToolExecutionComponent("custom_tool", "tool-5", {}, {}, toolDefinition, createFakeTui());
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom call shared-token");
expect(rendered).toContain("custom result shared-token");
});
test("exposes args in render result context", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: () => new Text("call", 0, 0),
renderResult: (_result, _options, _theme, context) =>
new Text(`arg:${String((context.args as { foo: string }).foo)}`, 0, 0),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-5b",
{ foo: "bar" },
{},
toolDefinition,
createFakeTui(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("arg:bar");
});
test("falls back when custom renderers are absent", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-6",
{ foo: "bar" },
{},
toolDefinition,
createFakeTui(),
);
component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("custom_tool");
expect(rendered).toContain("done");
});
test("trims trailing blank display lines from write previews", () => {
const component = new ToolExecutionComponent(
"write",
"tool-7",
{ path: "README.md", content: "one\ntwo\n" },
{},
createWriteToolDefinition(process.cwd()),
createFakeTui(),
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("one");
expect(rendered).toContain("two");
expect(rendered).not.toContain("two\n\n");
});
test("trims trailing blank display lines from read results", () => {
const component = new ToolExecutionComponent(
"read",
"tool-8",
{ path: "README.md" },
{},
createReadToolDefinition(process.cwd()),
createFakeTui(),
);
component.updateResult(
{ content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false },
false,
);
const rendered = stripAnsi(component.render(120).join("\n"));
expect(rendered).toContain("one");
expect(rendered).toContain("two");
expect(rendered).not.toContain("two\n\n");
});
});

834
scripts/edit-tool-stats.mjs Normal file
View File

@@ -0,0 +1,834 @@
#!/usr/bin/env node
import { createReadStream } from "node:fs";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { createInterface } from "node:readline";
const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions");
const DEFAULT_ACTIVE_EDIT_EXTENSION_PATH = path.join(homedir(), ".pi/agent/extensions/edit.ts");
const DEFAULT_TOP = 20;
function parseArgs(argv) {
const options = {
sessionsDir: DEFAULT_SESSIONS_DIR,
json: false,
includeRecords: false,
failedOnly: false,
modelFilter: undefined,
extFilter: undefined,
top: DEFAULT_TOP,
help: false,
allSessions: false,
since: undefined,
autoSincePath: DEFAULT_ACTIVE_EDIT_EXTENSION_PATH,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
options.help = true;
} else if (arg === "--json") {
options.json = true;
} else if (arg === "--include-records") {
options.includeRecords = true;
} else if (arg === "--failed-only") {
options.failedOnly = true;
} else if (arg === "--model") {
options.modelFilter = argv[++i];
} else if (arg === "--ext") {
options.extFilter = argv[++i]?.toLowerCase();
} else if (arg === "--top") {
const value = Number.parseInt(argv[++i] ?? "", 10);
if (!Number.isFinite(value) || value <= 0) {
throw new Error("--top must be a positive integer");
}
options.top = value;
} else if (arg === "--sessions-dir") {
options.sessionsDir = argv[++i];
} else if (arg === "--all-sessions") {
options.allSessions = true;
} else if (arg === "--since") {
options.since = argv[++i];
} else if (arg === "--auto-since-path") {
options.autoSincePath = argv[++i];
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printHelp() {
console.log(`Usage: node scripts/edit-tool-stats.mjs [options]
Options:
--sessions-dir <path> Sessions directory (default: ~/.pi/agent/sessions)
--model <substring> Filter provider/model by substring
--ext <extension> Filter by file extension, e.g. .ts
--failed-only Include only failed edit calls
--top <n> Number of examples to show (default: ${DEFAULT_TOP})
--since <iso> Only scan session files created at or after this ISO time
--all-sessions Disable the automatic since filter
--auto-since-path <p> Use birth time of this file for the automatic since filter
--json Print JSON summary instead of human report
--include-records Include raw records in JSON output
-h, --help Show this help
`);
}
function parseSessionFileTimestamp(sessionFile) {
const base = path.basename(sessionFile);
const rawTimestamp = base.split("_")[0];
if (!rawTimestamp) return null;
const isoTimestamp = rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
const ms = Date.parse(isoTimestamp);
if (!Number.isFinite(ms)) return null;
return ms;
}
function formatIso(ms) {
return new Date(ms).toISOString();
}
async function resolveAutoSinceMs(options) {
if (options.allSessions) return null;
if (options.since) {
const ms = Date.parse(options.since);
if (!Number.isFinite(ms)) {
throw new Error(`Invalid --since value: ${options.since}`);
}
return { ms, source: `--since ${options.since}` };
}
if (!options.autoSincePath) return null;
try {
const stats = await fs.stat(options.autoSincePath);
const birthtimeMs = Number.isFinite(stats.birthtimeMs) && stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.mtimeMs;
if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0) return null;
return { ms: birthtimeMs, source: `birth time of ${options.autoSincePath}` };
} catch {
return null;
}
}
async function* walkJsonlFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walkJsonlFiles(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
yield fullPath;
}
}
}
function getPathExtension(filePath) {
if (typeof filePath !== "string" || filePath.length === 0) return "[unknown]";
const ext = path.extname(filePath).toLowerCase();
if (ext) return ext;
const base = path.basename(filePath);
if (base.startsWith(".") && !base.slice(1).includes(".")) return base.toLowerCase();
return "[no_ext]";
}
function utf8Bytes(value) {
return Buffer.byteLength(value ?? "", "utf8");
}
function longestCommonPrefixLength(a, b) {
const max = Math.min(a.length, b.length);
let index = 0;
while (index < max && a[index] === b[index]) index++;
return index;
}
function longestCommonSuffixLength(a, b) {
const max = Math.min(a.length, b.length);
let index = 0;
while (index < max && a[a.length - 1 - index] === b[b.length - 1 - index]) index++;
return index;
}
function analyzeReplacement(oldText, newText) {
const prefixChars = longestCommonPrefixLength(oldText, newText);
const oldRemainder = oldText.slice(prefixChars);
const newRemainder = newText.slice(prefixChars);
const suffixChars = longestCommonSuffixLength(oldRemainder, newRemainder);
const oldCore = suffixChars > 0 ? oldRemainder.slice(0, -suffixChars) : oldRemainder;
const newCore = suffixChars > 0 ? newRemainder.slice(0, -suffixChars) : newRemainder;
const prefix = oldText.slice(0, prefixChars);
const suffix = suffixChars > 0 ? oldRemainder.slice(-suffixChars) : "";
const oldBytes = utf8Bytes(oldText);
const newBytes = utf8Bytes(newText);
const sharedPrefixBytes = utf8Bytes(prefix);
const sharedSuffixBytes = utf8Bytes(suffix);
const sharedContextBytes = sharedPrefixBytes + sharedSuffixBytes;
const coreOldBytes = utf8Bytes(oldCore);
const coreNewBytes = utf8Bytes(newCore);
const coreBytes = coreOldBytes + coreNewBytes;
const totalEditBytes = oldBytes + newBytes;
const wrapperPayloadBytes = totalEditBytes - coreBytes;
const inflationRatio = coreBytes === 0 ? null : totalEditBytes / coreBytes;
return {
oldBytes,
newBytes,
totalEditBytes,
sharedPrefixBytes,
sharedSuffixBytes,
sharedContextBytes,
coreOldBytes,
coreNewBytes,
coreBytes,
wrapperPayloadBytes,
inflationRatio,
noCoreChange: coreBytes === 0,
};
}
function median(numbers) {
return quantile(numbers, 0.5);
}
function quantile(numbers, q) {
const finite = numbers.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
if (finite.length === 0) return null;
if (finite.length === 1) return finite[0];
const position = (finite.length - 1) * q;
const lower = Math.floor(position);
const upper = Math.ceil(position);
if (lower === upper) return finite[lower];
const weight = position - lower;
return finite[lower] * (1 - weight) + finite[upper] * weight;
}
function formatInt(value) {
return new Intl.NumberFormat("en-US").format(value);
}
function formatPercent(part, total) {
if (total === 0) return "n/a";
return `${((part / total) * 100).toFixed(1)}%`;
}
function formatRatio(value) {
if (value === null) return "no-core-change";
if (!Number.isFinite(value)) return "∞";
if (value >= 100) return `${value.toFixed(0)}x`;
if (value >= 10) return `${value.toFixed(1)}x`;
return `${value.toFixed(2)}x`;
}
function formatBytes(value) {
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value >= 10 * 1024 ? 0 : 1)}KB`;
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
}
function extractTextContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((block) => block?.type === "text" && typeof block.text === "string")
.map((block) => block.text)
.join("\n");
}
function classifyErrorKind(text, isError, matchedResult) {
if (!matchedResult) return "missing_result";
if (!isError) return null;
const normalized = text.toLowerCase();
if (normalized.includes("file not found")) return "file_not_found";
if (normalized.includes("could not find the exact text")) return "not_found_exact_text";
if (normalized.includes("found multiple occurrences") || /^found \d+ occurrences/m.test(normalized)) {
return "multiple_occurrences";
}
if (normalized.includes("no changes made")) return "no_changes_made";
if (normalized.includes("input is invalid")) return "invalid_input";
if (normalized.includes("must not overlap")) return "overlapping_edits";
if (normalized.includes("aborted")) return "aborted";
return "other";
}
function getArgStyle(args) {
const hasEdits = Array.isArray(args?.edits);
const hasOldText = typeof args?.oldText === "string" || typeof args?.newText === "string";
const hasOldString = typeof args?.old_string === "string" || typeof args?.new_string === "string";
if (hasEdits) return "edits";
if (hasOldText && hasOldString) return "mixed";
if (hasOldText) return "oldText/newText";
if (hasOldString) return "old_string/new_string";
return "unknown";
}
function analyzeToolArguments(args) {
const normalizedArgs = args && typeof args === "object" ? args : {};
const filePath = typeof normalizedArgs.path === "string" ? normalizedArgs.path : "";
const extension = getPathExtension(filePath);
const argStyle = getArgStyle(normalizedArgs);
if (Array.isArray(normalizedArgs.edits)) {
const perEdit = normalizedArgs.edits.map((edit) =>
analyzeReplacement(typeof edit?.oldText === "string" ? edit.oldText : "", typeof edit?.newText === "string" ? edit.newText : "")
);
const inflations = perEdit.map((edit) => edit.inflationRatio).filter((value) => value !== null);
const totals = perEdit.reduce(
(acc, edit) => ({
oldBytes: acc.oldBytes + edit.oldBytes,
newBytes: acc.newBytes + edit.newBytes,
totalEditBytes: acc.totalEditBytes + edit.totalEditBytes,
sharedPrefixBytes: acc.sharedPrefixBytes + edit.sharedPrefixBytes,
sharedSuffixBytes: acc.sharedSuffixBytes + edit.sharedSuffixBytes,
sharedContextBytes: acc.sharedContextBytes + edit.sharedContextBytes,
coreOldBytes: acc.coreOldBytes + edit.coreOldBytes,
coreNewBytes: acc.coreNewBytes + edit.coreNewBytes,
coreBytes: acc.coreBytes + edit.coreBytes,
wrapperPayloadBytes: acc.wrapperPayloadBytes + edit.wrapperPayloadBytes,
noCoreChangeCount: acc.noCoreChangeCount + (edit.noCoreChange ? 1 : 0),
}),
{
oldBytes: 0,
newBytes: 0,
totalEditBytes: 0,
sharedPrefixBytes: 0,
sharedSuffixBytes: 0,
sharedContextBytes: 0,
coreOldBytes: 0,
coreNewBytes: 0,
coreBytes: 0,
wrapperPayloadBytes: 0,
noCoreChangeCount: 0,
}
);
return {
path: filePath,
extension,
mode: "multi",
argStyle,
editsCount: normalizedArgs.edits.length,
...totals,
inflationRatio: totals.coreBytes === 0 ? null : totals.totalEditBytes / totals.coreBytes,
medianEditInflationRatio: median(inflations),
maxEditInflationRatio: inflations.length > 0 ? Math.max(...inflations) : null,
perEdit,
};
}
const oldText = typeof normalizedArgs.oldText === "string" ? normalizedArgs.oldText : typeof normalizedArgs.old_string === "string" ? normalizedArgs.old_string : "";
const newText = typeof normalizedArgs.newText === "string" ? normalizedArgs.newText : typeof normalizedArgs.new_string === "string" ? normalizedArgs.new_string : "";
const replacement = analyzeReplacement(oldText, newText);
return {
path: filePath,
extension,
mode: "single",
argStyle,
editsCount: 1,
...replacement,
medianEditInflationRatio: replacement.inflationRatio,
maxEditInflationRatio: replacement.inflationRatio,
perEdit: [replacement],
};
}
function groupCounts(records, keyFn) {
const counts = new Map();
for (const record of records) {
const key = keyFn(record);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
function collectInflations(records) {
return records.map((record) => record.inflationRatio).filter((value) => value !== null);
}
function summarizeGroups(records, keyFn) {
const groups = new Map();
for (const record of records) {
const key = keyFn(record);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(record);
}
return [...groups.entries()]
.map(([key, group]) => {
const resolved = group.filter((record) => record.success !== null);
const success = resolved.filter((record) => record.success).length;
const failed = resolved.filter((record) => record.success === false).length;
const multi = group.filter((record) => record.mode === "multi").length;
const inflations = collectInflations(group);
return {
key,
calls: group.length,
resolved: resolved.length,
success,
failed,
unresolved: group.length - resolved.length,
multi,
multiRate: group.length === 0 ? null : multi / group.length,
successRate: resolved.length === 0 ? null : success / resolved.length,
medianInflation: quantile(inflations, 0.5),
p95Inflation: quantile(inflations, 0.95),
};
})
.sort((a, b) => b.calls - a.calls || a.key.localeCompare(b.key));
}
function buildSameFileClusterStats(records) {
const groups = new Map();
for (const record of records) {
const key = `${record.sessionFile}::${record.assistantEntryId}::${record.path}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(record);
}
const clusters = [...groups.values()].filter((group) => group.length >= 2);
const assistantMessagesWithCluster = new Set(clusters.map((group) => `${group[0].sessionFile}::${group[0].assistantEntryId}`));
const assistantMessagesWithMultiEdit = new Set(
records
.filter((record) => record.mode === "multi" && record.editsCount > 1)
.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)
);
const callsInsideClusters = clusters.reduce((sum, group) => sum + group.length, 0);
return {
clustersCount: clusters.length,
assistantMessagesWithCluster: assistantMessagesWithCluster.size,
assistantMessagesWithMultiEdit: assistantMessagesWithMultiEdit.size,
callsInsideClusters,
averageCallsPerCluster: clusters.length === 0 ? null : callsInsideClusters / clusters.length,
ratioClusterAssistantMessagesToMultiEditAssistantMessages:
assistantMessagesWithMultiEdit.size === 0 ? null : assistantMessagesWithCluster.size / assistantMessagesWithMultiEdit.size,
};
}
function buildInflationBuckets(records) {
const buckets = [
{ key: "no_core_change", label: "no-core-change", test: (record) => record.inflationRatio === null },
{ key: "lt4", label: "<4x", test: (record) => record.inflationRatio !== null && record.inflationRatio < 4 },
{ key: "4to10", label: "4-10x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 4 && record.inflationRatio < 10 },
{ key: "10to25", label: "10-25x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 10 && record.inflationRatio < 25 },
{ key: "gte25", label: "25x+", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 25 },
];
return buckets.map((bucket) => {
const bucketRecords = records.filter(bucket.test);
const resolved = bucketRecords.filter((record) => record.success !== null);
const failed = resolved.filter((record) => record.success === false).length;
return {
key: bucket.key,
label: bucket.label,
count: bucketRecords.length,
resolved: resolved.length,
failed,
failureRate: resolved.length === 0 ? null : failed / resolved.length,
};
});
}
function buildHugeReplacementStats(records) {
const thresholds = [1024, 4096, 16384, 65536];
return thresholds.map((threshold) => ({
threshold,
count: records.filter((record) => record.totalEditBytes > threshold).length,
}));
}
function buildWorstExamples(records, top) {
const scored = [...records].sort((a, b) => {
const aScore = a.inflationRatio === null ? Number.POSITIVE_INFINITY : a.inflationRatio;
const bScore = b.inflationRatio === null ? Number.POSITIVE_INFINITY : b.inflationRatio;
if (aScore !== bScore) return bScore - aScore;
if (a.totalEditBytes !== b.totalEditBytes) return b.totalEditBytes - a.totalEditBytes;
return a.path.localeCompare(b.path);
});
return scored.slice(0, top).map((record) => ({
providerModel: record.providerModel,
extension: record.extension,
path: record.path,
inflationRatio: record.inflationRatio,
totalEditBytes: record.totalEditBytes,
coreBytes: record.coreBytes,
mode: record.mode,
editsCount: record.editsCount,
failed: record.success === false,
errorKind: record.errorKind,
sessionFile: record.sessionFile,
}));
}
function buildSummary(records, meta, options) {
const uniqueAssistantMessages = new Set(records.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)).size;
const resolved = records.filter((record) => record.success !== null);
const success = resolved.filter((record) => record.success).length;
const failed = resolved.filter((record) => record.success === false).length;
const unresolved = records.length - resolved.length;
const single = records.filter((record) => record.mode === "single").length;
const multi = records.filter((record) => record.mode === "multi").length;
const modeStats = ["single", "multi"].map((mode) => {
const modeRecords = records.filter((record) => record.mode === mode);
const modeResolved = modeRecords.filter((record) => record.success !== null);
const modeSuccess = modeResolved.filter((record) => record.success).length;
const modeFailed = modeResolved.filter((record) => record.success === false).length;
return {
mode,
calls: modeRecords.length,
resolved: modeResolved.length,
success: modeSuccess,
failed: modeFailed,
unresolved: modeRecords.length - modeResolved.length,
successRate: modeResolved.length === 0 ? null : modeSuccess / modeResolved.length,
failureRate: modeResolved.length === 0 ? null : modeFailed / modeResolved.length,
};
});
const multiEditLengthBuckets = [
{ key: "1", label: "edits.length === 1", test: (record) => record.mode === "multi" && record.editsCount === 1 },
{ key: "2", label: "edits.length === 2", test: (record) => record.mode === "multi" && record.editsCount === 2 },
{ key: "3plus", label: "edits.length >= 3", test: (record) => record.mode === "multi" && record.editsCount >= 3 },
].map((bucket) => {
const bucketRecords = records.filter(bucket.test);
const bucketResolved = bucketRecords.filter((record) => record.success !== null);
const bucketSuccess = bucketResolved.filter((record) => record.success).length;
const bucketFailed = bucketResolved.filter((record) => record.success === false).length;
return {
key: bucket.key,
label: bucket.label,
calls: bucketRecords.length,
resolved: bucketResolved.length,
success: bucketSuccess,
failed: bucketFailed,
unresolved: bucketRecords.length - bucketResolved.length,
successRate: bucketResolved.length === 0 ? null : bucketSuccess / bucketResolved.length,
failureRate: bucketResolved.length === 0 ? null : bucketFailed / bucketResolved.length,
};
});
const argStyles = [...groupCounts(records, (record) => record.argStyle).entries()]
.map(([style, count]) => ({ style, count }))
.sort((a, b) => b.count - a.count || a.style.localeCompare(b.style));
const providerStats = summarizeGroups(records, (record) => record.providerModel);
const extensionStats = summarizeGroups(records, (record) => record.extension);
const inflations = collectInflations(records);
const noCoreChange = records.filter((record) => record.inflationRatio === null).length;
const pathologicalThresholds = [10, 25, 100].map((threshold) => ({
threshold,
count: records.filter((record) => record.inflationRatio !== null && record.inflationRatio >= threshold).length,
}));
const failureKinds = [...groupCounts(records.filter((record) => record.success === false), (record) => record.errorKind ?? "other").entries()]
.map(([kind, count]) => ({ kind, count }))
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
return {
filters: {
model: options.modelFilter ?? null,
extension: options.extFilter ?? null,
failedOnly: options.failedOnly,
},
scan: {
sessionsDir: meta.sessionsDir,
sessionFilesScanned: meta.sessionFilesScanned,
sessionFilesIncluded: meta.sessionFilesIncluded,
sessionFilesSkippedOlderThanSince: meta.sessionFilesSkippedOlderThanSince,
sessionFilesWithEditCalls: meta.sessionFilesWithEditCalls,
since: meta.since ? { ms: meta.since.ms, iso: formatIso(meta.since.ms), source: meta.since.source } : null,
malformedLines: meta.malformedLines,
unmatchedToolResults: meta.unmatchedToolResults,
},
counts: {
assistantMessagesWithEditCalls: uniqueAssistantMessages,
totalEditCalls: records.length,
resolvedEditCalls: resolved.length,
success,
failed,
unresolved,
single,
multi,
noCoreChange,
},
modeStats,
multiEditLengthBuckets,
argStyles,
providerStats,
extensionStats,
inflation: {
median: quantile(inflations, 0.5),
p90: quantile(inflations, 0.9),
p95: quantile(inflations, 0.95),
p99: quantile(inflations, 0.99),
pathologicalThresholds,
hugeReplacements: buildHugeReplacementStats(records),
failureByBucket: buildInflationBuckets(records),
},
sameFileClusters: buildSameFileClusterStats(records),
failureKinds,
worstExamples: buildWorstExamples(records, options.top),
};
}
function printGroupTable(title, groups, formatter) {
if (groups.length === 0) return;
console.log(`\n${title}`);
for (const group of groups) {
console.log(formatter(group));
}
}
function printHumanReport(summary) {
const { scan, counts, modeStats, multiEditLengthBuckets, argStyles, providerStats, extensionStats, inflation, sameFileClusters, failureKinds, worstExamples, filters } = summary;
console.log(`Scanned ${formatInt(scan.sessionFilesIncluded)} session files in ${scan.sessionsDir}`);
if (scan.since) {
console.log(`Session filter: files created at or after ${scan.since.iso} (${scan.since.source})`);
console.log(`Skipped older session files: ${formatInt(scan.sessionFilesSkippedOlderThanSince)} of ${formatInt(scan.sessionFilesScanned)}`);
}
console.log(`Found ${formatInt(counts.totalEditCalls)} edit tool calls in ${formatInt(counts.assistantMessagesWithEditCalls)} assistant messages`);
if (filters.model || filters.extension || filters.failedOnly) {
const filterParts = [];
if (filters.model) filterParts.push(`model contains \"${filters.model}\"`);
if (filters.extension) filterParts.push(`extension = ${filters.extension}`);
if (filters.failedOnly) filterParts.push("failed only");
console.log(`Filters: ${filterParts.join(", ")}`);
}
console.log("\nSuccess rate");
console.log(` success: ${formatInt(counts.success)} ${formatPercent(counts.success, counts.resolvedEditCalls)}`);
console.log(` failed: ${formatInt(counts.failed)} ${formatPercent(counts.failed, counts.resolvedEditCalls)}`);
console.log(` unresolved: ${formatInt(counts.unresolved)}`);
console.log("\nMode usage");
console.log(` single replacement: ${formatInt(counts.single)} ${formatPercent(counts.single, counts.totalEditCalls)}`);
console.log(` multi-edit (edits): ${formatInt(counts.multi)} ${formatPercent(counts.multi, counts.totalEditCalls)}`);
console.log("\nFailures by edit type");
for (const mode of modeStats) {
console.log(
` ${mode.mode.padEnd(6)} calls=${formatInt(mode.calls).padStart(4)} success=${mode.successRate === null ? "n/a" : formatPercent(mode.success, mode.resolved).padStart(6)} failed=${mode.failureRate === null ? "n/a" : formatPercent(mode.failed, mode.resolved).padStart(6)} unresolved=${formatInt(mode.unresolved)}`
);
}
console.log("\nMulti-edit bucket split");
for (const bucket of multiEditLengthBuckets) {
console.log(
` ${bucket.label.padEnd(20)} ${formatInt(bucket.calls).padStart(4)} calls success=${bucket.successRate === null ? "n/a" : formatPercent(bucket.success, bucket.resolved).padStart(6)} failed=${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)}`
);
}
console.log("\nArgument style");
for (const entry of argStyles) {
console.log(` ${entry.style.padEnd(22)} ${formatInt(entry.count).padStart(8)} ${formatPercent(entry.count, counts.totalEditCalls)}`);
}
printGroupTable("By provider/model", providerStats, (group) => {
return [
` ${group.key}`,
` calls: ${formatInt(group.calls)}`,
` success: ${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved)}`,
` multi-edit: ${group.multiRate === null ? "n/a" : formatPercent(group.multi, group.calls)}`,
` median inflation: ${formatRatio(group.medianInflation)}`,
` p95 inflation: ${formatRatio(group.p95Inflation)}`,
].join("\n");
});
printGroupTable("By file extension", extensionStats, (group) => {
return ` ${group.key.padEnd(10)} calls=${formatInt(group.calls).padStart(6)} success=${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved).padStart(6)} medianInflation=${formatRatio(group.medianInflation)}`;
});
console.log("\nContext inflation");
console.log(` median inflation: ${formatRatio(inflation.median)}`);
console.log(` p90 inflation: ${formatRatio(inflation.p90)}`);
console.log(` p95 inflation: ${formatRatio(inflation.p95)}`);
console.log(` p99 inflation: ${formatRatio(inflation.p99)}`);
console.log(` no-core-change: ${formatInt(counts.noCoreChange)}`);
console.log("\nHuge replacements");
for (const entry of inflation.hugeReplacements) {
console.log(` >${formatBytes(entry.threshold).padEnd(6)} ${formatInt(entry.count).padStart(8)}`);
}
console.log("\nPathological wrappers");
for (const entry of inflation.pathologicalThresholds) {
console.log(` inflation >= ${String(entry.threshold).padEnd(3)}x ${formatInt(entry.count).padStart(8)}`);
}
console.log("\nSame-file multi-call behavior");
console.log(` assistant msgs with 2+ edit calls to same file: ${formatInt(sameFileClusters.assistantMessagesWithCluster)}`);
console.log(` total same-file clusters: ${formatInt(sameFileClusters.clustersCount)}`);
console.log(` calls inside those clusters: ${formatInt(sameFileClusters.callsInsideClusters)}`);
console.log(` average calls per cluster: ${sameFileClusters.averageCallsPerCluster === null ? "n/a" : sameFileClusters.averageCallsPerCluster.toFixed(2)}`);
console.log(` assistant msgs using one multi-edit call: ${formatInt(sameFileClusters.assistantMessagesWithMultiEdit)}`);
console.log(` ratio multi-call / multi-edit assistant msgs: ${sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages === null ? "n/a" : sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages.toFixed(2)}`);
console.log("\nFailures by kind");
if (failureKinds.length === 0) {
console.log(" none");
} else {
for (const failure of failureKinds) {
console.log(` ${failure.kind.padEnd(22)} ${formatInt(failure.count).padStart(8)}`);
}
}
console.log("\nFailure rate by inflation bucket");
for (const bucket of inflation.failureByBucket) {
console.log(` ${bucket.label.padEnd(14)} ${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)} (${formatInt(bucket.count)} calls)`);
}
console.log(`\nWorst ${formatInt(worstExamples.length)} examples`);
for (let i = 0; i < worstExamples.length; i++) {
const example = worstExamples[i];
console.log(
` ${i + 1}. ${example.providerModel} ${example.extension} inflation=${formatRatio(example.inflationRatio)} failed=${example.failed ? example.errorKind : "false"}`
);
console.log(` path: ${example.path}`);
console.log(` totalEditBytes=${formatBytes(example.totalEditBytes)} coreBytes=${formatBytes(example.coreBytes)} mode=${example.mode} edits=${example.editsCount}`);
}
if (scan.malformedLines > 0 || scan.unmatchedToolResults > 0) {
console.log("\nParser notes");
if (scan.malformedLines > 0) console.log(` malformed lines skipped: ${formatInt(scan.malformedLines)}`);
if (scan.unmatchedToolResults > 0) console.log(` unmatched edit tool results: ${formatInt(scan.unmatchedToolResults)}`);
}
}
async function scanSessions(sessionsDir, since) {
const records = [];
const meta = {
sessionsDir,
sessionFilesScanned: 0,
sessionFilesIncluded: 0,
sessionFilesSkippedOlderThanSince: 0,
sessionFilesWithEditCalls: 0,
since,
malformedLines: 0,
unmatchedToolResults: 0,
};
for await (const sessionFile of walkJsonlFiles(sessionsDir)) {
meta.sessionFilesScanned++;
const sessionTimestampMs = parseSessionFileTimestamp(sessionFile);
if (since && sessionTimestampMs !== null && sessionTimestampMs < since.ms) {
meta.sessionFilesSkippedOlderThanSince++;
continue;
}
meta.sessionFilesIncluded++;
const pending = new Map();
let fileHadEditCall = false;
const input = createReadStream(sessionFile, { encoding: "utf8" });
const rl = createInterface({ input, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
meta.malformedLines++;
continue;
}
if (entry?.type !== "message" || !entry.message) continue;
const message = entry.message;
if (message.role === "assistant" && Array.isArray(message.content)) {
for (const block of message.content) {
if (block?.type !== "toolCall" || block.name !== "edit") continue;
fileHadEditCall = true;
const analysis = analyzeToolArguments(block.arguments);
const record = {
sessionFile,
assistantEntryId: entry.id,
toolCallId: typeof block.id === "string" ? block.id : "",
timestamp: entry.timestamp,
api: typeof message.api === "string" ? message.api : null,
provider: typeof message.provider === "string" ? message.provider : "[unknown]",
model: typeof message.model === "string" ? message.model : "[unknown]",
providerModel: `${typeof message.provider === "string" ? message.provider : "[unknown]"}/${typeof message.model === "string" ? message.model : "[unknown]"}`,
success: null,
errorKind: null,
errorText: "",
resultSummary: "",
matchedResult: false,
...analysis,
};
records.push(record);
if (record.toolCallId) pending.set(record.toolCallId, record);
}
}
if (message.role === "toolResult" && message.toolName === "edit") {
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
const record = pending.get(toolCallId);
if (!record) {
meta.unmatchedToolResults++;
continue;
}
const text = extractTextContent(message.content);
record.matchedResult = true;
record.success = message.isError === true ? false : true;
record.resultSummary = text;
record.errorText = message.isError === true ? text : "";
record.errorKind = classifyErrorKind(text, message.isError === true, true);
pending.delete(toolCallId);
}
}
for (const record of pending.values()) {
record.matchedResult = false;
record.success = null;
record.errorKind = classifyErrorKind("", false, false);
}
if (fileHadEditCall) meta.sessionFilesWithEditCalls++;
}
return { records, meta };
}
function applyFilters(records, options) {
return records.filter((record) => {
if (options.modelFilter && !record.providerModel.toLowerCase().includes(options.modelFilter.toLowerCase())) {
return false;
}
if (options.extFilter && record.extension !== options.extFilter) {
return false;
}
if (options.failedOnly && record.success !== false) {
return false;
}
return true;
});
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const sessionsDir = path.resolve(options.sessionsDir);
await fs.access(sessionsDir);
const since = await resolveAutoSinceMs(options);
const { records, meta } = await scanSessions(sessionsDir, since);
const filteredRecords = applyFilters(records, options);
const summary = buildSummary(filteredRecords, meta, options);
if (options.json) {
const output = options.includeRecords ? { summary, records: filteredRecords } : { summary };
console.log(JSON.stringify(output, null, 2));
return;
}
printHumanReport(summary);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});