Files
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2a6bf8c929 feat(pi-overflow): 新增统一的新工具结果展示截断扩展
此前截断分散在多层且互相冲突:Pi bash 保留尾部 50KB,RTK 兜底截断又只留
开头 12000 字符(砍掉报错与完整输出路径),pi-chrome 截开头 30K,web_fetch
各 provider 不一致。新扩展统一负责"新结果给模型看多少":
- capture.ts 最先加载暂存原文,index.ts 最后加载(system-prompt 之前)
- 超过 24K 字符时原文落盘 <agentDir>/my-pi/pi-overflow/(700/600、原子写),
  优先取 bash fullOutputPath 完整文件;模型看到 头 + 中段信号行 + 尾 的有界
  视图,行号基于落盘原文、结尾永远可见
- overflow_query:按 id 正则搜索、行区间、jsonPath 只读查询,id 校验防穿越
- 收编:RTK truncate 默认关闭、删除 pi-chrome truncateText、web_fetch 默认
  每页 20000(上限 100000)、web_search max_results 限定 1–20
- pi-compaction 遮蔽带 details.overflow.id 的结果时在占位符提示 overflow_query
- 登记 tools.jsonc / routing / 权限 allow / tool-search 常驻与分组

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 20:25:44 -07:00

168 lines
6.4 KiB
TypeScript

import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { stat } from "node:fs/promises";
import { join } from "node:path";
import { readOverflowSettings, type OverflowSettings } from "./settings.ts";
import { getStash } from "./src/stash.ts";
import { buildView, collectViewFromFile, collectViewFromText } from "./src/view.ts";
import { cleanStore, entryId, storeEntry } from "./src/store.ts";
import { runOverflowQuery } from "./src/query.ts";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1_048_576).toFixed(1)} MB`;
}
export interface OverflowExtensionOptions {
settings?: OverflowSettings;
storeRoot?: string;
}
/**
* Bound stage: loaded LAST among content extensions (before system-prompt).
* By the time this handler runs, every other tool_result handler — RTK
* included — has already transformed the result, so the bounded view is
* built from the final visible text while the stored original comes from
* capture.ts's stash or bash's fullOutputPath file.
*/
export default function overflowExtension(pi: ExtensionAPI, options: OverflowExtensionOptions = {}): void {
const settings = options.settings ?? readOverflowSettings();
const storeRoot = () => options.storeRoot ?? join(getAgentDir(), "my-pi", "pi-overflow");
pi.on("session_start", () => {
// Retention/size cleanup never throws (cleanStore swallows failures)
// and the returned promise lets tests await it.
return cleanStore(storeRoot(), settings.retentionDays, settings.maxStoreBytes);
});
pi.on("tool_result", async (event, ctx) => {
const stash = getStash();
const staged = stash.get(event.toolCallId);
try {
if (
!settings.enabled ||
settings.exemptTools.includes(event.toolName) ||
event.parentToolCallId
) {
return undefined;
}
const current = event.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n");
const fullOutputPath =
typeof event.details?.fullOutputPath === "string"
? event.details.fullOutputPath
: staged?.fullOutputPath;
// Resolve the original: bash's spilled file wins (it is complete even
// when the in-context text was already cut), then the capture stash,
// then the current text itself.
let source: { kind: "text"; text: string } | { kind: "file"; path: string };
let originalSize: number;
if (fullOutputPath) {
try {
const info = await stat(fullOutputPath);
if (!info.isFile()) throw new Error("not a file");
source = { kind: "file", path: fullOutputPath };
originalSize = info.size;
} catch {
const fallback = staged?.rawText ?? current;
source = { kind: "text", text: fallback };
originalSize = fallback.length;
}
} else {
const fallback = staged?.rawText ?? current;
source = { kind: "text", text: fallback };
originalSize = fallback.length;
}
if (current.length <= settings.thresholdChars && originalSize <= settings.thresholdChars) {
return undefined;
}
const sessionId = ctx.sessionManager?.getSessionId?.() ?? "session";
const id = entryId(sessionId, event.toolCallId);
const stored = await storeEntry(
storeRoot(),
id,
source,
{ tool: event.toolName, args: event.input },
settings.maxEntryBytes,
);
const overflow = {
id,
totalLines: stored.totalLines,
totalBytes: stored.totalBytes,
truncated: current.length > settings.thresholdChars,
};
const images = event.content.filter((part) => part.type !== "text");
let text: string;
if (current.length > settings.thresholdChars) {
// The view always describes the stored file — its line numbers and
// totals must match what overflow_query reads, which is not true
// of `current` when RTK rewrote or bash pre-truncated the result.
const viewInput =
stored.storedText !== undefined
? collectViewFromText(stored.storedText, settings)
: await collectViewFromFile(join(storeRoot(), `${id}.txt`), settings);
text = buildView(viewInput, settings, { id, tool: event.toolName });
} else {
text = `${current}\n[pi-overflow id=${id}: full original output stored (${stored.totalLines} lines, ${formatBytes(stored.totalBytes)}); use overflow_query to inspect it]`;
}
return {
content: [{ type: "text", text }, ...images],
structuredContent: event.structuredContent,
details: { ...event.details, overflow },
};
} finally {
stash.delete(event.toolCallId);
}
});
pi.registerTool({
name: "overflow_query",
label: "Overflow Query",
description:
"Inspect the full original of a tool output that pi-overflow stored because it was too long to show " +
"(search with regex, read line ranges, or extract a JSON path).",
promptSnippet: "Inspect a stored oversized tool output by id: regex search, line ranges, or JSON path.",
promptGuidelines: [
"When a tool result carries a [pi-overflow id=…] marker, the model is seeing a bounded view; the complete original is stored on disk.",
"Call overflow_query with the marker's id and a regex, line range, or jsonPath instead of re-running the tool or asking for the full dump.",
],
parameters: Type.Object({
id: Type.String({ description: "12-hex storage id from a [pi-overflow id=…] marker." }),
search: Type.Optional(
Type.String({ description: "Case-insensitive regular expression matched per line." }),
),
context: Type.Optional(
Type.Number({ minimum: 0, maximum: 10, description: "Context lines around each search hit (default 2)." }),
),
lines: Type.Optional(
Type.Object({
start: Type.Number({ minimum: 1, description: "First line to return (1-based, inclusive)." }),
end: Type.Number({ minimum: 1, description: "Last line to return (inclusive)." }),
}),
),
jsonPath: Type.Optional(
Type.String({ description: "Dot/bracket path into the stored JSON, e.g. snapshot.nodes[3].name." }),
),
maxResults: Type.Optional(
Type.Number({ minimum: 1, maximum: 200, description: "Maximum search hits to report (default 50)." }),
),
}),
async execute(_toolCallId, params) {
const result = await runOverflowQuery(storeRoot(), params, settings);
return {
content: [{ type: "text", text: result.text }],
details: { error: result.error === true, truncated: result.truncated },
isError: result.error === true,
};
},
});
}