mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
a6d8416c76
commit
2a6bf8c929
@@ -0,0 +1,167 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user