chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -36,7 +36,6 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `questionnaire.ts` | Multi-question input with tab bar navigation between questions |
| `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
| `dynamic-tools.ts` | Register tools after startup (`session_start`) and at runtime via command, with prompt snippets and tool-specific prompt guidelines |
| `kimi-deferred-tools.ts` | Search for and progressively activate tools for Kimi's deferred-tool loading protocol |
| `structured-output.ts` | Final structured-output tool that returns `terminate: true` so the agent can end on the tool call |
| `built-in-tool-renderer.ts` | Custom compact rendering for built-in tools (read, bash, edit, write) while keeping original behavior |
| `minimal-mode.ts` | Override built-in tool rendering for minimal display (only tool calls, no output in collapsed mode) |
@@ -114,6 +113,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|-----------|-------------|
| `message-renderer.ts` | Custom message rendering with colors and expandable details via `registerMessageRenderer` |
| `entry-renderer.ts` | TUI-only session entry rendering via `appendEntry` and `registerEntryRenderer` |
| `debug-provider.ts` | Toggle raw provider stream capture with `/debug-provider` and inspect each assistant message in a TUI-only session entry |
| `event-bus.ts` | Inter-extension communication via `pi.events` |
### Session Metadata
@@ -129,6 +129,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|-----------|-------------|
| `custom-provider-anthropic/` | Custom Anthropic provider with OAuth support and custom streaming implementation |
| `custom-provider-gitlab-duo/` | GitLab Duo provider using pi-ai's built-in Anthropic/OpenAI streaming via proxy |
| `jev-router.ts` | Virtual model via `registerVirtualModel` that plans on Codex Sol or Terra (picked by the Jev classifier) and switches to Luna after the first edit |
### External Dependencies

View File

@@ -12,8 +12,8 @@
*
* How it works:
* - registerTool() with the same name as a built-in replaces it entirely
* - We create instances of the original tools via createReadTool(), etc.
* and delegate execute() to them
* - We create the original definitions via createReadToolDefinition(), etc.
* and override only their renderers
* - renderCall() controls what's shown when the tool is invoked
* - renderResult() controls what's shown after execution completes
* - renderShell: "self" lets a tool render its own outer shell instead of
@@ -26,23 +26,21 @@
*/
import type { BashToolDetails, EditToolDetails, ExtensionAPI, ReadToolDetails } from "@earendil-works/pi-coding-agent";
import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent";
import {
createBashToolDefinition,
createEditToolDefinition,
createReadToolDefinition,
createWriteToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) {
const cwd = process.cwd();
// --- Read tool: show path and line count ---
const originalRead = createReadTool(cwd);
const originalRead = createReadToolDefinition(cwd);
pi.registerTool({
name: "read",
label: "read",
description: originalRead.description,
parameters: originalRead.parameters,
async execute(toolCallId, params, signal, onUpdate) {
return originalRead.execute(toolCallId, params, signal, onUpdate);
},
...originalRead,
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("read "));
@@ -92,16 +90,9 @@ export default function (pi: ExtensionAPI) {
});
// --- Bash tool: show command and exit code ---
const originalBash = createBashTool(cwd);
const originalBash = createBashToolDefinition(cwd);
pi.registerTool({
name: "bash",
label: "bash",
description: originalBash.description,
parameters: originalBash.parameters,
async execute(toolCallId, params, signal, onUpdate) {
return originalBash.execute(toolCallId, params, signal, onUpdate);
},
...originalBash,
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("$ "));
@@ -151,17 +142,9 @@ export default function (pi: ExtensionAPI) {
});
// --- Edit tool: show path and diff stats ---
const originalEdit = createEditTool(cwd);
const originalEdit = createEditToolDefinition(cwd);
pi.registerTool({
name: "edit",
label: "edit",
description: originalEdit.description,
parameters: originalEdit.parameters,
renderShell: "self",
async execute(toolCallId, params, signal, onUpdate) {
return originalEdit.execute(toolCallId, params, signal, onUpdate);
},
...originalEdit,
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("edit "));
@@ -216,16 +199,9 @@ export default function (pi: ExtensionAPI) {
});
// --- Write tool: show path and size ---
const originalWrite = createWriteTool(cwd);
const originalWrite = createWriteToolDefinition(cwd);
pi.registerTool({
name: "write",
label: "write",
description: originalWrite.description,
parameters: originalWrite.parameters,
async execute(toolCallId, params, signal, onUpdate) {
return originalWrite.execute(toolCallId, params, signal, onUpdate);
},
...originalWrite,
renderCall(args, theme, _context) {
let text = theme.fg("toolTitle", theme.bold("write "));

View File

@@ -27,9 +27,11 @@ import {
type Api,
type AssistantMessage,
type AssistantMessageEventStream,
type Context,
calculateCost,
collapseSystemMessages,
createAssistantMessageEventStream,
getCurrentSystemPrompt,
getCurrentTools,
type ImageContent,
type Message,
type Model,
@@ -42,6 +44,7 @@ import {
type Tool,
type ToolCall,
type ToolResultMessage,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -334,10 +337,15 @@ function mapStopReason(reason: string): StopReason {
function streamCustomAnthropic(
model: Model<Api>,
context: Context,
context: TranscriptContext,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream();
// The transcript carries the prompt and tools in its system messages. This provider sends
// one top-level system prompt, so fold later system messages into the leading one first.
const transcript = collapseSystemMessages(context);
const systemPrompt = getCurrentSystemPrompt(transcript.messages);
const tools = getCurrentTools(transcript.messages);
(async () => {
const output: AssistantMessage = {
@@ -393,7 +401,7 @@ function streamCustomAnthropic(
// Build request params
const params: MessageCreateParamsStreaming = {
model: model.id,
messages: convertMessages(context.messages, isOAuth, context.tools),
messages: convertMessages(transcript.messages, isOAuth, tools),
max_tokens: options?.maxTokens || Math.floor(model.maxTokens / 3),
stream: true,
};
@@ -407,25 +415,25 @@ function streamCustomAnthropic(
cache_control: { type: "ephemeral" },
},
];
if (context.systemPrompt) {
if (systemPrompt) {
params.system.push({
type: "text",
text: sanitizeSurrogates(context.systemPrompt),
text: sanitizeSurrogates(systemPrompt),
cache_control: { type: "ephemeral" },
});
}
} else if (context.systemPrompt) {
} else if (systemPrompt) {
params.system = [
{
type: "text",
text: sanitizeSurrogates(context.systemPrompt),
text: sanitizeSurrogates(systemPrompt),
cache_control: { type: "ephemeral" },
},
];
}
if (context.tools) {
params.tools = convertTools(context.tools, isOAuth);
if (tools.length > 0) {
params.tools = convertTools(tools, isOAuth);
}
// Handle thinking/reasoning
@@ -474,9 +482,7 @@ function streamCustomAnthropic(
output.content.push({
type: "toolCall",
id: event.content_block.id,
name: isOAuth
? fromClaudeCodeName(event.content_block.name, context.tools)
: event.content_block.name,
name: isOAuth ? fromClaudeCodeName(event.content_block.name, tools) : event.content_block.name,
arguments: {},
partialJson: "",
index: event.index,

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.85.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.85.1",
"version": "1.3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.85.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -13,7 +13,6 @@ import {
type Api,
type AssistantMessageEventStream,
anthropicMessagesApi,
type Context,
createAssistantMessageEventStream,
type Model,
type OAuthCredentials,
@@ -21,6 +20,7 @@ import {
openAIResponsesApi,
type SimpleStreamOptions,
type ThinkingLevelMap,
type TranscriptContext,
} from "@earendil-works/pi-ai/compat";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -306,7 +306,7 @@ async function refreshGitLabToken(credentials: OAuthCredentials, signal: AbortSi
export function streamGitLabDuo(
model: Model<Api>,
context: Context,
context: TranscriptContext,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream();

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.85.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,16 +1,16 @@
/**
* Test script for GitLab Duo extension
* Run: npx tsx test.ts [model-id] [--thinking]
* Run: node test.ts [model-id] [--thinking]
*
* Examples:
* npx tsx test.ts # Test default (claude-sonnet-4-5-20250929)
* npx tsx test.ts gpt-5-codex # Test GPT-5 Codex
* npx tsx test.ts claude-sonnet-4-5-20250929 --thinking
* node test.ts # Test default (claude-sonnet-4-5-20250929)
* node test.ts gpt-5-codex # Test GPT-5 Codex
* node test.ts claude-sonnet-4-5-20250929 --thinking
*/
import { type Api, type Context, type Model, registerApiProvider, streamSimple } from "@earendil-works/pi-ai/compat";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { readFileSync } from "fs";
import { getAgentDir } from "packages/coding-agent/src/config.js";
import { join } from "path";
import { MODELS, streamGitLabDuo } from "./index.ts";

View File

@@ -0,0 +1,90 @@
/**
* Raw provider event viewer.
*
* Usage: /debug-provider [on|off]
* With no argument, the command toggles capture. Captured events are persisted
* as expandable custom entries.
*/
import { type ExtensionAPI, keyHint } from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui";
const ENTRY_TYPE = "debug-provider-events";
const STATUS_KEY = "debug-provider";
interface ProviderDebugEntry {
provider: string;
api: string;
model: string;
events: unknown[];
}
export default function (pi: ExtensionAPI) {
let enabled = false;
let activeEvents: unknown[] | undefined;
let completedEntry: ProviderDebugEntry | undefined;
pi.registerEntryRenderer<ProviderDebugEntry>(ENTRY_TYPE, (entry, { expanded }, theme) => {
const data = entry.data;
if (!data) return new Text(theme.fg("warning", "[provider debug] Missing event data"), 0, 0);
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
const count = `${data.events.length} event${data.events.length === 1 ? "" : "s"}`;
const expandHint = expanded ? "" : ` (${keyHint("app.tools.expand", "to view events")})`;
box.addChild(
new Text(
`${theme.fg("accent", "[provider debug]")} ${data.provider}/${data.model} (${data.api}) · ${count}${expandHint}`,
0,
0,
),
);
if (expanded) {
box.addChild(new Text(JSON.stringify(data.events, null, 2), 0, 0));
}
return box;
});
pi.registerCommand("debug-provider", {
description: "Toggle capture of raw provider stream events",
handler: async (args, ctx) => {
const requestedState = args.trim().toLowerCase();
if (requestedState !== "" && requestedState !== "on" && requestedState !== "off") {
ctx.ui.notify("Usage: /debug-provider [on|off]", "warning");
return;
}
enabled = requestedState === "" ? !enabled : requestedState === "on";
if (!enabled) {
activeEvents = undefined;
completedEntry = undefined;
}
ctx.ui.setStatus(STATUS_KEY, enabled ? "provider debug" : undefined);
ctx.ui.notify(`Provider event capture ${enabled ? "enabled" : "disabled"}`, "info");
},
});
pi.on("turn_start", () => {
activeEvents = enabled ? [] : undefined;
});
pi.on("provider_stream_event", (event) => {
activeEvents?.push(structuredClone(event.data));
});
pi.on("message_end", (event) => {
if (event.message.role !== "assistant" || !activeEvents) return;
completedEntry = {
provider: event.message.provider,
api: event.message.api,
model: event.message.model,
events: activeEvents,
};
activeEvents = undefined;
});
pi.on("turn_end", () => {
if (!completedEntry) return;
pi.appendEntry<ProviderDebugEntry>(ENTRY_TYPE, completedEntry);
completedEntry = undefined;
});
}

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"$schema": "https://pi.dev/schemas/theme.schema.json",
"name": "dynamic-resources",
"vars": {
"cyan": "#00d7ff",

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-gondolin",
"version": "0.85.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-gondolin",
"version": "0.85.1",
"version": "1.3.0",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-gondolin",
"private": true,
"version": "0.85.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -0,0 +1,113 @@
/**
* Jev router - a virtual model that plans on a strong model and implements on a cheap one.
*
* Registers `jev/auto`, which routes between three OpenAI Codex models:
*
* - Planning: GPT-5.6 Sol for complex work, GPT-5.6 Terra otherwise. The Jev classifier rates the
* first user message; planning stays on the chosen model.
* - Implementation: GPT-5.6 Luna.
*
* The planning model explores, plans, and makes the first edit. After the first successful `edit`
* or `write` tool call, the next request of the same turn goes to Luna, and the session stays
* there. A session therefore switches models once and accepts a single prompt-cache miss.
*
* The phase is router state: Pi stores it on the session branch, so it follows the session tree
* and survives compaction. The selected thinking level passes through as the reasoning effort of
* the chosen model. Requests outside the agent loop, such as compaction summaries, go to Luna.
*
* Requires TypeSafe credentials (TYPESAFE_API_KEY) and an OpenAI Codex login.
* Usage: pi -e ./jev-router.ts --model jev/auto
*/
import type { Message } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, ModelRoute, ModelRouteRequest } from "@earendil-works/pi-coding-agent";
const PROVIDER = "openai-codex";
const SOL = "gpt-5.6-sol";
const TERRA = "gpt-5.6-terra";
const LUNA = "gpt-5.6-luna";
/** Tools whose successful result means implementation has started. */
const EDIT_TOOLS = new Set(["edit", "write"]);
interface JevState {
phase: "planning" | "implementation";
/** OpenAI Codex model for this phase. */
model: string;
}
type JevRequest = ModelRouteRequest<JevState>;
function routeTo(request: JevRequest, ctx: ExtensionContext, id: string, state?: JevState): ModelRoute<JevState> {
const model = ctx.modelRegistry.find(PROVIDER, id);
if (!model) throw new Error(`Model ${PROVIDER}/${id} is not in the catalog`);
return { model, thinkingLevel: request.thinkingLevel, state };
}
function lastUserText(messages: readonly Message[]): string {
const content = messages.filter((message) => message.role === "user").at(-1)?.content ?? "";
if (typeof content === "string") return content;
return content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n");
}
/** Whether a tool call since the last user message edited a file successfully. */
function editedThisTurn(messages: readonly Message[]): boolean {
const lastUser = messages.findLastIndex((message) => message.role === "user");
return messages
.slice(lastUser + 1)
.some((message) => message.role === "toolResult" && EDIT_TOOLS.has(message.toolName) && !message.isError);
}
/** Planning model for a new session: Sol for complex work, Terra otherwise or when Jev is unavailable. */
async function choosePlanningModel(request: JevRequest, ctx: ExtensionContext): Promise<string> {
// Keep a planning model the session already uses, so switching to jev/auto costs no cache miss.
const previous = request.previous?.model;
if (previous?.provider === PROVIDER && (previous.id === SOL || previous.id === TERRA)) return previous.id;
const jev = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
if (!jev) return TERRA;
const result = await ctx.modelRegistry.classify(
jev,
{
state: { prompt: lastUserText(request.messages).slice(0, 16_000) },
questions: {
complexity: {
type: "choice",
instructions: "How demanding is the software engineering work requested in `prompt`?",
criteria: {
standard: "Ordinary features, fixes, reviews, or questions",
complex: "Subtle design, cross-cutting changes, or hard debugging",
},
},
},
},
{ signal: request.signal },
);
const answer = result.stopReason === "stop" ? result.answers.complexity : undefined;
return answer?.type === "choice" && (answer.probabilities.complex ?? 0) >= 0.5 ? SOL : TERRA;
}
export default function (pi: ExtensionAPI) {
pi.registerVirtualModel<JevState>({
provider: "jev",
id: "auto",
name: "Auto (Jev)",
thinkingLevels: ["low", "medium", "high", "xhigh"],
// Shared by all three models; shown before the first response.
contextWindow: 272_000,
maxTokens: 128_000,
async route(request, ctx) {
if (request.reason === "direct") return routeTo(request, ctx, LUNA);
const state = request.state;
if (!state) {
const model = await choosePlanningModel(request, ctx);
return routeTo(request, ctx, model, { phase: "planning", model });
}
// The planning model made the first edit: hand the rest of the work to Luna.
if (state.phase === "planning" && editedThisTurn(request.messages)) {
return routeTo(request, ctx, LUNA, { phase: "implementation", model: LUNA });
}
return routeTo(request, ctx, state.model);
},
});
}

View File

@@ -1,61 +0,0 @@
/**
* Minimal Kimi deferred-tool loading demo.
*
* pi -e ./kimi-deferred-tools.ts
* example prompt: Use the available tools to calculate 100 + 500. Do not calculate it yourself.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
function calculate(_expr: string): string {
return "42";
}
export default function (pi: ExtensionAPI): void {
pi.registerTool({
name: "Calculator",
label: "Calculator",
description: "Evaluate a simple arithmetic expression.",
parameters: Type.Object({
expr: Type.String({ description: "An expression such as 100 + 500" }),
}),
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: calculate(params.expr) }],
details: {},
};
},
});
pi.registerTool({
name: "tool_search",
label: "Tool Search",
description: "Find and activate tools for a capability.",
promptSnippet: "Search for additional tools when the active tools cannot perform the task",
parameters: Type.Object({
query: Type.String({ description: "Capability to search for" }),
}),
async execute(_toolCallId, params) {
if (!params.query.toLowerCase().includes("calc")) {
return {
content: [{ type: "text", text: "The relevant tools do not exist." }],
details: { matches: [], added: [] },
};
}
const active = pi.getActiveTools();
const added = active.includes("Calculator") ? [] : ["Calculator"];
if (added.length > 0) pi.setActiveTools([...active, ...added]);
return {
content: [{ type: "text", text: "Success. Found 1 matching tool(s)" }],
details: { matches: ["Calculator"], added },
};
},
});
pi.on("session_start", () => {
pi.setActiveTools(["tool_search"]);
});
}

View File

@@ -18,13 +18,14 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
createBashTool,
createEditTool,
createFindTool,
createGrepTool,
createLsTool,
createReadTool,
createWriteTool,
createBashToolDefinition,
createEditToolDefinition,
createFindToolDefinition,
createGrepToolDefinition,
createLsToolDefinition,
createReadToolDefinition,
createWriteToolDefinition,
formatReadLineRange,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { homedir } from "os";
@@ -40,55 +41,34 @@ function shortenPath(path: string): string {
return path;
}
// Cache for built-in tools by cwd
const toolCache = new Map<string, ReturnType<typeof createBuiltInTools>>();
function createBuiltInTools(cwd: string) {
return {
read: createReadTool(cwd),
bash: createBashTool(cwd),
edit: createEditTool(cwd),
write: createWriteTool(cwd),
find: createFindTool(cwd),
grep: createGrepTool(cwd),
ls: createLsTool(cwd),
read: createReadToolDefinition(cwd),
bash: createBashToolDefinition(cwd),
edit: createEditToolDefinition(cwd),
write: createWriteToolDefinition(cwd),
find: createFindToolDefinition(cwd),
grep: createGrepToolDefinition(cwd),
ls: createLsToolDefinition(cwd),
};
}
function getBuiltInTools(cwd: string) {
let tools = toolCache.get(cwd);
if (!tools) {
tools = createBuiltInTools(cwd);
toolCache.set(cwd, tools);
}
return tools;
}
export default function (pi: ExtensionAPI) {
const tools = createBuiltInTools(process.cwd());
// =========================================================================
// Read Tool
// =========================================================================
pi.registerTool({
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 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.",
parameters: getBuiltInTools(process.cwd()).read.parameters,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tools = getBuiltInTools(ctx.cwd);
return tools.read.execute(toolCallId, params, signal, onUpdate);
},
...tools.read,
renderCall(args, theme, _context) {
const path = shortenPath(args.path || "");
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
// Show line range if specified
if (args.offset !== undefined || args.limit !== undefined) {
const startLine = args.offset ?? 1;
const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
const lineRange = formatReadLineRange(args.offset, args.limit);
if (lineRange) {
pathDisplay += theme.fg("warning", lineRange);
}
return new Text(`${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`, 0, 0);
@@ -116,16 +96,7 @@ export default function (pi: ExtensionAPI) {
// Bash Tool
// =========================================================================
pi.registerTool({
name: "bash",
label: "bash",
description:
"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first).",
parameters: getBuiltInTools(process.cwd()).bash.parameters,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tools = getBuiltInTools(ctx.cwd);
return tools.bash.execute(toolCallId, params, signal, onUpdate);
},
...tools.bash,
renderCall(args, theme, _context) {
const command = args.command || "...";
@@ -165,16 +136,7 @@ export default function (pi: ExtensionAPI) {
// Write Tool
// =========================================================================
pi.registerTool({
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.",
parameters: getBuiltInTools(process.cwd()).write.parameters,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tools = getBuiltInTools(ctx.cwd);
return tools.write.execute(toolCallId, params, signal, onUpdate);
},
...tools.write,
renderCall(args, theme, _context) {
const path = shortenPath(args.path || "");
@@ -207,16 +169,8 @@ export default function (pi: ExtensionAPI) {
// Edit Tool
// =========================================================================
pi.registerTool({
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.",
parameters: getBuiltInTools(process.cwd()).edit.parameters,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tools = getBuiltInTools(ctx.cwd);
return tools.edit.execute(toolCallId, params, signal, onUpdate);
},
...tools.edit,
renderShell: "default",
renderCall(args, theme, _context) {
const path = shortenPath(args.path || "");
@@ -252,16 +206,7 @@ export default function (pi: ExtensionAPI) {
// Find Tool
// =========================================================================
pi.registerTool({
name: "find",
label: "find",
description:
"Find files by name pattern (glob). Searches recursively from the specified path. Output limited to 200 results.",
parameters: getBuiltInTools(process.cwd()).find.parameters,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tools = getBuiltInTools(ctx.cwd);
return tools.find.execute(toolCallId, params, signal, onUpdate);
},
...tools.find,
renderCall(args, theme, _context) {
const pattern = args.pattern || "";
@@ -310,16 +255,7 @@ export default function (pi: ExtensionAPI) {
// Grep Tool
// =========================================================================
pi.registerTool({
name: "grep",
label: "grep",
description:
"Search file contents by regex pattern. Uses ripgrep for fast searching. Output limited to 200 matches.",
parameters: getBuiltInTools(process.cwd()).grep.parameters,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tools = getBuiltInTools(ctx.cwd);
return tools.grep.execute(toolCallId, params, signal, onUpdate);
},
...tools.grep,
renderCall(args, theme, _context) {
const pattern = args.pattern || "";
@@ -372,16 +308,7 @@ export default function (pi: ExtensionAPI) {
// Ls Tool
// =========================================================================
pi.registerTool({
name: "ls",
label: "ls",
description:
"List directory contents with file sizes. Shows files and directories with their sizes. Output limited to 500 entries.",
parameters: getBuiltInTools(process.cwd()).ls.parameters,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tools = getBuiltInTools(ctx.cwd);
return tools.ls.execute(toolCallId, params, signal, onUpdate);
},
...tools.ls,
renderCall(args, theme, _context) {
const path = shortenPath(args.path || ".");

View File

@@ -9,7 +9,7 @@
*/
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
import { CURSOR_MARKER, type Focusable, matchesKey, renderFakeCursor, visibleWidth } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) {
pi.registerCommand("overlay-test", {
@@ -131,7 +131,7 @@ class OverlayTestComponent implements Focusable {
const after = inputDisplay.slice(item.cursor + 1);
// Emit hardware cursor marker for IME support when focused
const marker = this.focused ? CURSOR_MARKER : "";
inputDisplay = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
inputDisplay = `${before}${marker}${renderFakeCursor(cursorChar)}${after}`;
}
content = `${prefix + label} ${inputDisplay}`;
} else {

View File

@@ -197,8 +197,12 @@ export default function planModeExtension(pi: ExtensionAPI): void {
};
});
// Inject plan/execution context before agent starts
pi.on("before_agent_start", async () => {
// Inject plan/execution context before a user prompt. Runs started by custom messages, such as
// background task notifications, skip it: the context from the last prompt is still in the
// conversation, and adding it again on every notification would only repeat it.
pi.on("before_agent_start", async (event) => {
if (event.messageType !== "user") return;
if (planModeEnabled) {
return {
message: {

View File

@@ -1,11 +1,8 @@
/**
* Prompt Customizer Extension
*
* Demonstrates using systemPromptOptions to make informed, context-aware
* modifications to the system prompt without re-discovering resources.
*
* This extension adds tool-specific guidance based on what tools and skills
* are currently active, respecting whatever the user has configured.
* Demonstrates using systemPromptOptions to add context-aware prompt sections
* without replacing or reparsing the complete rendered prompt.
*
* Usage:
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
@@ -14,84 +11,39 @@
import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
/**
* Adds tool-specific guidance that adapts to the active tool set.
* Instead of appending one-size-fits-all instructions, this reads what's
* actually loaded and tailors the guidance accordingly.
*/
function addToolGuidance(options: BuildSystemPromptOptions, basePrompt: string): string {
function buildToolGuidance(options: BuildSystemPromptOptions): string {
const hasTool = (name: string) => options.selectedTools?.includes(name) ?? false;
const parts: string[] = [];
const rules: string[] = [];
if (hasTool("read")) {
parts.push(
"• Use the `read` tool for file contents (supports text and images).",
" - For large files, use `offset` and `limit` to read in chunks.",
rules.push(
"- Use `read` for file contents; it supports text and images.",
"- For large files, use `offset` and `limit` to read in chunks.",
);
}
if (hasTool("bash")) {
parts.push("• Execute commands with the `bash` tool. Use it for file operations like `ls`, `find`, `grep`.");
rules.push("- Use `bash` for file operations such as `ls`, `find`, and `grep`.");
}
if (hasTool("edit")) {
parts.push(
"• Use the `edit` tool for precise text replacements in files. Match exact content including whitespace.",
);
rules.push("- Use `edit` for precise text replacements that match existing content exactly.");
}
if (hasTool("write")) {
parts.push("• Use the `write` tool to create new files or overwrite existing ones completely.");
rules.push("- Use `write` to create new files or replace existing files completely.");
}
if (options.skills && options.skills.length > 0) {
const skillNames = options.skills.map((s) => s.name).join(", ");
parts.push(`\nAvailable skills: ${skillNames}`, "Use skill documentation for best practices on specific tools.");
rules.push(`- Available skills: ${options.skills.map((skill) => skill.name).join(", ")}.`);
}
if (parts.length === 0) {
return basePrompt;
}
return `${basePrompt}
## Tool Guidance
${parts.join("\n")}
`;
}
/**
* Merges extension instructions with user-provided append prompts.
* This respects whatever the user configured via --append-system-prompt
* flags or files, rather than duplicating that work.
*/
function mergeWithUserAppend(options: BuildSystemPromptOptions): string {
const userAppend = options.appendSystemPrompt;
const extensionSpecific = `
## Extension-Added Context
This prompt includes tool guidance and skill information loaded dynamically.
If you have additional requirements, configure them via --append-system-prompt or project context files.
`;
if (userAppend) {
return `${userAppend}\n\n${extensionSpecific}`;
}
return extensionSpecific;
return rules.join("\n");
}
export default function promptCustomizer(pi: ExtensionAPI) {
pi.on("before_agent_start", async (event) => {
const { systemPrompt, systemPromptOptions } = event;
const customPrompt = addToolGuidance(systemPromptOptions, systemPrompt);
const appendSection = mergeWithUserAppend(systemPromptOptions);
return {
systemPrompt: `${customPrompt}${appendSection}`,
};
pi.on("before_agent_start", (event) => {
const guidance = buildToolGuidance(event.systemPromptOptions);
if (guidance) {
event.systemPromptOptions.sections.tool_guidance = guidance;
} else {
delete event.systemPromptOptions.sections.tool_guidance;
}
});
}

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.15.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.15.1",
"version": "1.3.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.15.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -22,6 +22,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
import {
CONFIG_DIR_NAME,
type ExtensionAPI,
formatReadLineRange,
getAgentDir,
getMarkdownTheme,
withFileMutationQueue,
@@ -87,13 +88,10 @@ function formatToolCall(
case "read": {
const rawPath = (args.file_path || args.path || "...") as string;
const filePath = shortenPath(rawPath);
const offset = args.offset as number | undefined;
const limit = args.limit as number | undefined;
const lineRange = formatReadLineRange(args.offset, args.limit);
let text = themeFg("accent", filePath);
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
if (lineRange) {
text += themeFg("warning", lineRange);
}
return themeFg("muted", "read ") + text;
}

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.85.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.85.1",
"version": "1.3.0",
"dependencies": {
"ms": "^2.1.3"
},

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.85.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",