@dyyz1993/pi-coding-agent 0.74.24 → 0.74.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +3 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/extensions/agent-permissions/index.ts +235 -0
- package/dist/extensions/ask-tools/index.ts +115 -0
- package/dist/extensions/auto-memory/contract.d.ts +51 -0
- package/dist/extensions/auto-memory/contract.d.ts.map +1 -0
- package/dist/extensions/auto-memory/contract.js +2 -0
- package/dist/extensions/auto-memory/contract.js.map +1 -0
- package/dist/extensions/auto-memory/contract.ts +56 -0
- package/dist/extensions/auto-memory/index.ts +969 -0
- package/dist/extensions/auto-memory/prompts.ts +202 -0
- package/dist/extensions/auto-memory/skip-rules.ts +297 -0
- package/dist/extensions/auto-memory/utils.ts +208 -0
- package/dist/extensions/auto-session-title/index.ts +83 -0
- package/dist/extensions/bash-ext/contract.d.ts +79 -0
- package/dist/extensions/bash-ext/contract.d.ts.map +1 -0
- package/dist/extensions/bash-ext/contract.js +2 -0
- package/dist/extensions/bash-ext/contract.js.map +1 -0
- package/dist/extensions/bash-ext/contract.ts +69 -0
- package/dist/extensions/bash-ext/index.ts +858 -0
- package/dist/extensions/claude-hooks-compat/config-loader.ts +49 -0
- package/dist/extensions/claude-hooks-compat/handler-runner.ts +377 -0
- package/dist/extensions/claude-hooks-compat/if-parser.ts +53 -0
- package/dist/extensions/claude-hooks-compat/index.ts +178 -0
- package/dist/extensions/claude-hooks-compat/matcher.ts +17 -0
- package/dist/extensions/claude-hooks-compat/stdin-builder.ts +27 -0
- package/dist/extensions/claude-hooks-compat/types.ts +77 -0
- package/dist/extensions/compaction-manager/config.ts +47 -0
- package/dist/extensions/compaction-manager/context-fold.ts +63 -0
- package/dist/extensions/compaction-manager/index.ts +151 -0
- package/dist/extensions/compaction-manager/microcompact.ts +49 -0
- package/dist/extensions/compaction-manager/reactive.ts +9 -0
- package/dist/extensions/compaction-manager/session-memory.ts +48 -0
- package/dist/extensions/coordinator/INTEGRATION.md +376 -0
- package/dist/extensions/coordinator/handler.test.ts +277 -0
- package/dist/extensions/coordinator/handler.ts +189 -0
- package/dist/extensions/coordinator/index.ts +261 -0
- package/dist/extensions/coordinator/types.d.ts +100 -0
- package/dist/extensions/coordinator/types.d.ts.map +1 -0
- package/dist/extensions/coordinator/types.js +2 -0
- package/dist/extensions/coordinator/types.js.map +1 -0
- package/dist/extensions/coordinator/types.ts +72 -0
- package/dist/extensions/file-snapshot/index.ts +131 -0
- package/dist/extensions/file-time-guard/README.md +133 -0
- package/dist/extensions/file-time-guard/config.ts +13 -0
- package/dist/extensions/file-time-guard/index.ts +171 -0
- package/dist/extensions/hooks-engine/index.ts +117 -0
- package/dist/extensions/lsp/lsp/client/file-tracker.ts +70 -0
- package/dist/extensions/lsp/lsp/client/registry.ts +305 -0
- package/dist/extensions/lsp/lsp/client/runtime.ts +832 -0
- package/dist/extensions/lsp/lsp/config/resolver.ts +573 -0
- package/dist/extensions/lsp/lsp/contract.d.ts +101 -0
- package/dist/extensions/lsp/lsp/contract.d.ts.map +1 -0
- package/dist/extensions/lsp/lsp/contract.js +2 -0
- package/dist/extensions/lsp/lsp/contract.js.map +1 -0
- package/dist/extensions/lsp/lsp/contract.ts +103 -0
- package/dist/extensions/lsp/lsp/hooks/agent-end.ts +169 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts +10 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts.map +1 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js +30 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js.map +1 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.ts +41 -0
- package/dist/extensions/lsp/lsp/hooks/writethrough.ts +342 -0
- package/dist/extensions/lsp/lsp/index.ts +307 -0
- package/dist/extensions/lsp/lsp/lsp.test.ts +684 -0
- package/dist/extensions/lsp/lsp/monitoring/server-metrics.ts +176 -0
- package/dist/extensions/lsp/lsp/tools/lsp-tool.ts +402 -0
- package/dist/extensions/lsp/lsp/utils/dependency-resolver.ts +147 -0
- package/dist/extensions/lsp/lsp/utils/diagnostics-wait.ts +41 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts +20 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts.map +1 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.js +64 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.js.map +1 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.ts +76 -0
- package/dist/extensions/message-bridge/GUIDE.md +210 -0
- package/dist/extensions/message-bridge/index.ts +222 -0
- package/dist/extensions/output-guard/index.ts +384 -0
- package/dist/extensions/preview/index.ts +278 -0
- package/dist/extensions/rules-engine/MATCH_HISTORY_RECONCILIATION.md +111 -0
- package/dist/extensions/rules-engine/RULES-ENGINE-GUIDE.md +470 -0
- package/dist/extensions/rules-engine/cache.js +232 -0
- package/dist/extensions/rules-engine/cache.ts +38 -0
- package/dist/extensions/rules-engine/config.js +63 -0
- package/dist/extensions/rules-engine/config.ts +70 -0
- package/dist/extensions/rules-engine/index.js +1530 -0
- package/dist/extensions/rules-engine/index.ts +552 -0
- package/dist/extensions/rules-engine/injector.js +68 -0
- package/dist/extensions/rules-engine/injector.ts +74 -0
- package/dist/extensions/rules-engine/loader.js +179 -0
- package/dist/extensions/rules-engine/loader.ts +205 -0
- package/dist/extensions/rules-engine/matcher.js +534 -0
- package/dist/extensions/rules-engine/matcher.ts +52 -0
- package/dist/extensions/rules-engine/types.d.ts +156 -0
- package/dist/extensions/rules-engine/types.d.ts.map +1 -0
- package/dist/extensions/rules-engine/types.js +2 -0
- package/dist/extensions/rules-engine/types.js.map +1 -0
- package/dist/extensions/rules-engine/types.ts +169 -0
- package/dist/extensions/session-supervisor/checker.ts +116 -0
- package/dist/extensions/session-supervisor/config.ts +45 -0
- package/dist/extensions/session-supervisor/index.ts +726 -0
- package/dist/extensions/session-supervisor/prompts.ts +132 -0
- package/dist/extensions/session-supervisor/scheduler.ts +69 -0
- package/dist/extensions/session-supervisor/types.ts +215 -0
- package/dist/extensions/subagent/README.md +172 -0
- package/dist/extensions/subagent/agents/explorer.md +25 -0
- package/dist/extensions/subagent/agents/guide.md +27 -0
- package/dist/extensions/subagent/agents/planner.md +37 -0
- package/dist/extensions/subagent/agents/reviewer.md +35 -0
- package/dist/extensions/subagent/agents/scout.md +50 -0
- package/dist/extensions/subagent/agents/verification.md +35 -0
- package/dist/extensions/subagent/agents/worker.md +24 -0
- package/dist/extensions/subagent/agents.ts +25 -0
- package/dist/extensions/subagent/index.ts +987 -0
- package/dist/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/dist/extensions/subagent/prompts/implement.md +10 -0
- package/dist/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/dist/extensions/subagent-ext/contract.d.ts +2 -0
- package/dist/extensions/subagent-ext/contract.d.ts.map +1 -0
- package/dist/extensions/subagent-ext/contract.js +2 -0
- package/dist/extensions/subagent-ext/contract.js.map +1 -0
- package/dist/extensions/subagent-ext/contract.ts +1 -0
- package/dist/extensions/subagent-ext/index.ts +347 -0
- package/dist/extensions/subagent-shared/contract.d.ts +25 -0
- package/dist/extensions/subagent-shared/contract.d.ts.map +1 -0
- package/dist/extensions/subagent-shared/contract.js +2 -0
- package/dist/extensions/subagent-shared/contract.js.map +1 -0
- package/dist/extensions/subagent-shared/contract.ts +28 -0
- package/dist/extensions/subagent-shared/index.ts +4 -0
- package/dist/extensions/subagent-shared/render.ts +166 -0
- package/dist/extensions/subagent-shared/types.ts +35 -0
- package/dist/extensions/subagent-shared/utils.ts +112 -0
- package/dist/extensions/subagent-v2/contract.d.ts +2 -0
- package/dist/extensions/subagent-v2/contract.d.ts.map +1 -0
- package/dist/extensions/subagent-v2/contract.js +2 -0
- package/dist/extensions/subagent-v2/contract.js.map +1 -0
- package/dist/extensions/subagent-v2/contract.ts +1 -0
- package/dist/extensions/subagent-v2/index.ts +599 -0
- package/dist/extensions/todo-ext/contract.d.ts +27 -0
- package/dist/extensions/todo-ext/contract.d.ts.map +1 -0
- package/dist/extensions/todo-ext/contract.js +2 -0
- package/dist/extensions/todo-ext/contract.js.map +1 -0
- package/dist/extensions/todo-ext/contract.ts +30 -0
- package/dist/extensions/todo-ext/index.ts +419 -0
- package/package.json +6 -5
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Bridge Extension for pi
|
|
3
|
+
*
|
|
4
|
+
* 1. 拦截 ctx.ui.confirm/select/input 调用,转发到 Message Bridge 服务。
|
|
5
|
+
* 用 ctx.respondUI 异步注入远程回复,与本地 UI 竞争(race 模式)。
|
|
6
|
+
*
|
|
7
|
+
* 2. 监听 message_end 事件,将 Assistant 回复作为纯文本推送到 Message Bridge。
|
|
8
|
+
* 如果用户在移动端回复,调用 pi.sendUserMessage 将回复注入回 Agent。
|
|
9
|
+
*
|
|
10
|
+
* 类型映射:
|
|
11
|
+
* confirm → {type: "confirm", question: ...}
|
|
12
|
+
* select → {type: "radio", question, options}
|
|
13
|
+
* input → 纯文本推送
|
|
14
|
+
* notify → 纯文本推送(fire-and-forget,不等待回复)
|
|
15
|
+
*
|
|
16
|
+
* answer 解析:
|
|
17
|
+
* confirm → "【确认】: 确定" / "【确认】: 取消" → confirmed: true/false
|
|
18
|
+
* radio → "【问题】: 选项A" → value: "选项A"
|
|
19
|
+
* 纯文本 → 直接返回 answer
|
|
20
|
+
*
|
|
21
|
+
* 用法:
|
|
22
|
+
* --extension ./extensions/message-bridge/index.ts
|
|
23
|
+
* 或 settings.json: { "extensions": ["./extensions/message-bridge/index.ts"] }
|
|
24
|
+
*
|
|
25
|
+
* 环境变量:
|
|
26
|
+
* MESSAGE_BRIDGE_URL - 服务地址(默认 https://message-bridge.docker.19930810.xyz:8443)
|
|
27
|
+
* MESSAGE_BRIDGE_SESSION_ID - 可选 session 过滤
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const BRIDGE_URL = process.env.MESSAGE_BRIDGE_URL || "https://message-bridge.docker.19930810.xyz:8443";
|
|
31
|
+
|
|
32
|
+
interface PushResponse {
|
|
33
|
+
id: string;
|
|
34
|
+
status: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface PullResponse {
|
|
38
|
+
id: string;
|
|
39
|
+
answer: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function pushQuestion(question: unknown, sessionId?: string): Promise<string> {
|
|
43
|
+
const resp = await fetch(`${BRIDGE_URL}/push`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ question, session_id: sessionId }),
|
|
47
|
+
});
|
|
48
|
+
if (!resp.ok) throw new Error(`Message Bridge push failed: ${resp.status}`);
|
|
49
|
+
const data = (await resp.json()) as PushResponse;
|
|
50
|
+
return data.id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function pullAnswer(msgId: string): Promise<string> {
|
|
54
|
+
const resp = await fetch(`${BRIDGE_URL}/pull/${msgId}`);
|
|
55
|
+
if (!resp.ok) throw new Error(`Message Bridge pull failed: ${resp.status}`);
|
|
56
|
+
const data = (await resp.json()) as PullResponse;
|
|
57
|
+
return data.answer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function pushAndWait(question: unknown, sessionId?: string): Promise<string> {
|
|
61
|
+
const id = await pushQuestion(question, sessionId);
|
|
62
|
+
return pullAnswer(id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildConfirmQuestion(title: string, message?: string): Record<string, unknown> {
|
|
66
|
+
const question = message ? `${title} - ${message}` : title;
|
|
67
|
+
return { type: "confirm", question };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildSelectQuestion(title: string, options: string[], multiple?: boolean): Record<string, unknown> {
|
|
71
|
+
return {
|
|
72
|
+
type: multiple ? "checkbox" : "radio",
|
|
73
|
+
question: title,
|
|
74
|
+
options: options.map((label) => ({ label, description: "" })),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseConfirmAnswer(answer: string): boolean {
|
|
79
|
+
const trimmed = answer.trim();
|
|
80
|
+
if (trimmed.includes("取消") || trimmed.includes("拒绝")) return false;
|
|
81
|
+
if (trimmed.includes("确定") || trimmed.includes("确认")) return true;
|
|
82
|
+
const normalized = trimmed.toLowerCase();
|
|
83
|
+
return normalized === "yes" || normalized === "y" || normalized === "true" || normalized === "1";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseSelectAnswer(answer: string): string {
|
|
87
|
+
const trimmed = answer.trim();
|
|
88
|
+
const colonIdx = trimmed.indexOf("】:");
|
|
89
|
+
if (colonIdx !== -1) {
|
|
90
|
+
const value = trimmed.slice(colonIdx + 2).trim();
|
|
91
|
+
const parts = value.split(",").map((s) => s.trim());
|
|
92
|
+
return parts[0];
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(trimmed);
|
|
96
|
+
if (Array.isArray(parsed)) return String(parsed[0] ?? trimmed);
|
|
97
|
+
if (typeof parsed === "string") return parsed;
|
|
98
|
+
return trimmed;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.debug("[message-bridge] JSON parse fallback:", err instanceof Error ? err.message : err);
|
|
101
|
+
return trimmed;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseMultiSelectAnswer(answer: string, options: string[]): string[] {
|
|
106
|
+
const trimmed = answer.trim();
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(trimmed);
|
|
109
|
+
if (Array.isArray(parsed)) {
|
|
110
|
+
return parsed.map(String).filter((v) => options.includes(v));
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.debug("[message-bridge] multi-select parse failed:", err instanceof Error ? err.message : err);
|
|
114
|
+
}
|
|
115
|
+
const colonIdx = trimmed.indexOf("】:");
|
|
116
|
+
if (colonIdx !== -1) {
|
|
117
|
+
const value = trimmed.slice(colonIdx + 2).trim();
|
|
118
|
+
return value
|
|
119
|
+
.split(",")
|
|
120
|
+
.map((s) => s.trim())
|
|
121
|
+
.filter((v) => options.includes(v));
|
|
122
|
+
}
|
|
123
|
+
return options.includes(trimmed) ? [trimmed] : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractMessageText(message: unknown): string {
|
|
127
|
+
if (!message || typeof message !== "object" || !("content" in message)) return "";
|
|
128
|
+
const content = (message as { content?: string | Array<{ type: string; text?: string }> }).content;
|
|
129
|
+
if (content === undefined) return "";
|
|
130
|
+
if (typeof content === "string") return content;
|
|
131
|
+
return content
|
|
132
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
133
|
+
.map((part) => part.text)
|
|
134
|
+
.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default function messageBridgeExtension(pi: any) {
|
|
138
|
+
const sessionId = process.env.MESSAGE_BRIDGE_SESSION_ID || undefined;
|
|
139
|
+
|
|
140
|
+
pi.on("ui", async (event: any, ctx: any) => {
|
|
141
|
+
if (event.method === "notify") {
|
|
142
|
+
pushAndWait(event.message, sessionId).catch((err) => console.debug("[message-bridge] notify push failed:", err instanceof Error ? err.message : err));
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (event.method === "confirm") {
|
|
147
|
+
const question = buildConfirmQuestion(event.title, event.message);
|
|
148
|
+
pushAndWait(question, sessionId)
|
|
149
|
+
.then((answer) => {
|
|
150
|
+
const confirmed = parseConfirmAnswer(answer);
|
|
151
|
+
ctx.respondUI(event.id, { action: "responded", confirmed });
|
|
152
|
+
})
|
|
153
|
+
.catch((err) => console.debug("[message-bridge] confirm push failed:", err instanceof Error ? err.message : err));
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event.method === "select") {
|
|
158
|
+
const options: string[] = event.options ?? [];
|
|
159
|
+
const multiple: boolean = event.multiple === true;
|
|
160
|
+
const question = buildSelectQuestion(event.title, options, multiple);
|
|
161
|
+
pushAndWait(question, sessionId)
|
|
162
|
+
.then((answer) => {
|
|
163
|
+
if (multiple) {
|
|
164
|
+
const values = parseMultiSelectAnswer(answer, options);
|
|
165
|
+
ctx.respondUI(event.id, { action: "responded", value: values });
|
|
166
|
+
} else {
|
|
167
|
+
const value = parseSelectAnswer(answer);
|
|
168
|
+
ctx.respondUI(event.id, { action: "responded", value });
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
.catch((err) => console.debug("[message-bridge] select push failed:", err instanceof Error ? err.message : err));
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (event.method === "input") {
|
|
176
|
+
const question = event.placeholder
|
|
177
|
+
? `${event.title}\n\nPlaceholder: ${event.placeholder}`
|
|
178
|
+
: event.title;
|
|
179
|
+
pushAndWait(question, sessionId)
|
|
180
|
+
.then((answer) => {
|
|
181
|
+
ctx.respondUI(event.id, { action: "responded", value: answer });
|
|
182
|
+
})
|
|
183
|
+
.catch((err) => console.debug("[message-bridge] input push failed:", err instanceof Error ? err.message : err));
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (event.method === "editor") {
|
|
188
|
+
const question = event.prefill
|
|
189
|
+
? `${event.title}\n\nPre-filled content:\n${event.prefill}`
|
|
190
|
+
: event.title;
|
|
191
|
+
pushAndWait(question, sessionId)
|
|
192
|
+
.then((answer) => {
|
|
193
|
+
ctx.respondUI(event.id, { action: "responded", value: answer });
|
|
194
|
+
})
|
|
195
|
+
.catch((err) => console.debug("[message-bridge] editor push failed:", err instanceof Error ? err.message : err));
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return undefined;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
pi.on("agent_end", async (event: any) => {
|
|
203
|
+
if (!event?.messages) return;
|
|
204
|
+
|
|
205
|
+
const assistantTexts = event.messages
|
|
206
|
+
.filter((m: any) => m.role === "assistant")
|
|
207
|
+
.map((m: any) => extractMessageText(m))
|
|
208
|
+
.filter((t: string) => t.trim());
|
|
209
|
+
if (assistantTexts.length === 0) return;
|
|
210
|
+
|
|
211
|
+
const text = assistantTexts.join("\n\n---\n\n");
|
|
212
|
+
|
|
213
|
+
pushQuestion(text, sessionId)
|
|
214
|
+
.then((id) => pullAnswer(id))
|
|
215
|
+
.then((answer) => {
|
|
216
|
+
if (answer?.trim()) {
|
|
217
|
+
pi.sendUserMessage(answer.trim());
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
.catch((err) => console.debug("[message-bridge] agent_end push failed:", err instanceof Error ? err.message : err));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Guard Extension - Global fallback truncation + tool limit optimization.
|
|
3
|
+
*
|
|
4
|
+
* Provides three capabilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Global truncation fallback**: Hooks into `tool_result` events. When a tool
|
|
7
|
+
* (especially extension/plugin/MCP tools) returns output exceeding limits
|
|
8
|
+
* without self-managing truncation, this extension truncates the output and
|
|
9
|
+
* saves the full content to a temp file.
|
|
10
|
+
*
|
|
11
|
+
* 2. **Tool limit optimization**: Hooks into `tool_call` events to enforce lower
|
|
12
|
+
* result limits on find (1000 -> 100) and ls (500 -> 100), matching OpenCode's
|
|
13
|
+
* defaults. Reduces unnecessary context consumption.
|
|
14
|
+
*
|
|
15
|
+
* 3. **PDF text extraction**: Registers a `pdf_read` tool that extracts text content
|
|
16
|
+
* from PDF files using pdf-parse, since the built-in read tool does not support PDFs.
|
|
17
|
+
*
|
|
18
|
+
* Configuration (via .pi/settings.json or global settings):
|
|
19
|
+
* outputGuard.maxLines: number (default: 2000)
|
|
20
|
+
* outputGuard.maxBytes: number (default: 51200 = 50KB)
|
|
21
|
+
* outputGuard.findLimit: number (default: 100)
|
|
22
|
+
* outputGuard.lsLimit: number (default: 100)
|
|
23
|
+
* outputGuard.saveToFile: boolean (default: true - save truncated output to disk)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { randomBytes } from "node:crypto";
|
|
27
|
+
import { createWriteStream, mkdirSync, existsSync } from "node:fs";
|
|
28
|
+
import { writeFile } from "node:fs/promises";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { Type } from "typebox";
|
|
32
|
+
import type {
|
|
33
|
+
ExtensionAPI,
|
|
34
|
+
ToolResultEvent,
|
|
35
|
+
ToolResultEventResult,
|
|
36
|
+
ToolCallEvent,
|
|
37
|
+
ToolCallEventResult,
|
|
38
|
+
ExtensionContext,
|
|
39
|
+
} from "@dyyz1993/pi-coding-agent";
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Configuration
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MAX_LINES = 2000;
|
|
46
|
+
const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
|
47
|
+
const DEFAULT_FIND_LIMIT = 100;
|
|
48
|
+
const DEFAULT_LS_LIMIT = 100;
|
|
49
|
+
|
|
50
|
+
interface OutputGuardConfig {
|
|
51
|
+
maxLines: number;
|
|
52
|
+
maxBytes: number;
|
|
53
|
+
findLimit: number;
|
|
54
|
+
lsLimit: number;
|
|
55
|
+
saveToFile: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadConfig(ctx: ExtensionContext): OutputGuardConfig {
|
|
59
|
+
const settings = (ctx as unknown as { settings?: Record<string, unknown> }).settings;
|
|
60
|
+
const guard = settings?.outputGuard as Partial<OutputGuardConfig> | undefined;
|
|
61
|
+
return {
|
|
62
|
+
maxLines: guard?.maxLines ?? DEFAULT_MAX_LINES,
|
|
63
|
+
maxBytes: guard?.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
64
|
+
findLimit: guard?.findLimit ?? DEFAULT_FIND_LIMIT,
|
|
65
|
+
lsLimit: guard?.lsLimit ?? DEFAULT_LS_LIMIT,
|
|
66
|
+
saveToFile: guard?.saveToFile ?? true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Truncation Logic
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
interface TruncationInfo {
|
|
75
|
+
truncated: boolean;
|
|
76
|
+
content: string;
|
|
77
|
+
totalLines: number;
|
|
78
|
+
totalBytes: number;
|
|
79
|
+
truncatedBy: "lines" | "bytes" | null;
|
|
80
|
+
fullOutputPath?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Truncate text content from the tail (keep the end - more useful for tool output).
|
|
85
|
+
* Saves full content to a temp file when truncation occurs.
|
|
86
|
+
*/
|
|
87
|
+
function truncateOutput(
|
|
88
|
+
content: string,
|
|
89
|
+
config: OutputGuardConfig,
|
|
90
|
+
ctx: ExtensionContext,
|
|
91
|
+
): TruncationInfo {
|
|
92
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
93
|
+
const lines = content.split("\n");
|
|
94
|
+
const totalLines = lines.length;
|
|
95
|
+
|
|
96
|
+
// No truncation needed
|
|
97
|
+
if (totalLines <= config.maxLines && totalBytes <= config.maxBytes) {
|
|
98
|
+
return {
|
|
99
|
+
truncated: false,
|
|
100
|
+
content,
|
|
101
|
+
totalLines,
|
|
102
|
+
totalBytes,
|
|
103
|
+
truncatedBy: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Collect lines from the end
|
|
108
|
+
const outputLines: string[] = [];
|
|
109
|
+
let outputBytes = 0;
|
|
110
|
+
let truncatedBy: "lines" | "bytes" = "lines";
|
|
111
|
+
|
|
112
|
+
for (let i = lines.length - 1; i >= 0 && outputLines.length < config.maxLines; i--) {
|
|
113
|
+
const line = lines[i];
|
|
114
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLines.length > 0 ? 1 : 0);
|
|
115
|
+
|
|
116
|
+
if (outputBytes + lineBytes > config.maxBytes) {
|
|
117
|
+
truncatedBy = "bytes";
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
outputLines.unshift(line);
|
|
122
|
+
outputBytes += lineBytes;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (outputLines.length >= config.maxLines && outputBytes <= config.maxBytes) {
|
|
126
|
+
truncatedBy = "lines";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const truncatedContent = outputLines.join("\n");
|
|
130
|
+
let fullOutputPath: string | undefined;
|
|
131
|
+
|
|
132
|
+
// Save full output to disk
|
|
133
|
+
if (config.saveToFile) {
|
|
134
|
+
fullOutputPath = saveFullOutput(content, ctx);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
truncated: true,
|
|
139
|
+
content: truncatedContent,
|
|
140
|
+
totalLines,
|
|
141
|
+
totalBytes,
|
|
142
|
+
truncatedBy,
|
|
143
|
+
fullOutputPath,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Save full output content to a temp file.
|
|
149
|
+
*/
|
|
150
|
+
function saveFullOutput(content: string, ctx: ExtensionContext): string | undefined {
|
|
151
|
+
try {
|
|
152
|
+
const id = randomBytes(8).toString("hex");
|
|
153
|
+
const dir = join(tmpdir(), "pi-output-guard");
|
|
154
|
+
if (!existsSync(dir)) {
|
|
155
|
+
mkdirSync(dir, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
const filePath = join(dir, `output-${id}.log`);
|
|
158
|
+
writeFileSync(filePath, content);
|
|
159
|
+
return filePath;
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Synchronous write for saveFullOutput.
|
|
167
|
+
*/
|
|
168
|
+
function writeFileSync(filePath: string, content: string): void {
|
|
169
|
+
const stream = createWriteStream(filePath);
|
|
170
|
+
stream.write(content);
|
|
171
|
+
stream.end();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// Extension Entry Point
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
export default function outputGuard(pi: ExtensionAPI) {
|
|
179
|
+
// ------------------------------------------------------------------
|
|
180
|
+
// 1. Global truncation fallback via tool_result hook
|
|
181
|
+
// ------------------------------------------------------------------
|
|
182
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx: ExtensionContext): Promise<ToolResultEventResult | void> => {
|
|
183
|
+
const config = loadConfig(ctx);
|
|
184
|
+
|
|
185
|
+
// Only process text content
|
|
186
|
+
const textParts = event.content.filter((p): p is { type: "text"; text: string } => p.type === "text");
|
|
187
|
+
if (textParts.length === 0) return;
|
|
188
|
+
|
|
189
|
+
// Check if the tool already self-managed truncation via details
|
|
190
|
+
if (hasSelfManagedTruncation(event)) return;
|
|
191
|
+
|
|
192
|
+
// Check if image content is present - images have their own size management
|
|
193
|
+
const hasImages = event.content.some((p) => p.type === "image");
|
|
194
|
+
if (hasImages) return;
|
|
195
|
+
|
|
196
|
+
// Concatenate all text parts
|
|
197
|
+
const fullText = textParts.map((p) => p.text).join("\n");
|
|
198
|
+
const totalBytes = Buffer.byteLength(fullText, "utf-8");
|
|
199
|
+
const totalLines = fullText.split("\n").length;
|
|
200
|
+
|
|
201
|
+
// Skip if within limits
|
|
202
|
+
if (totalLines <= config.maxLines && totalBytes <= config.maxBytes) return;
|
|
203
|
+
|
|
204
|
+
// Truncate
|
|
205
|
+
const result = truncateOutput(fullText, config, ctx);
|
|
206
|
+
|
|
207
|
+
let finalContent = result.content;
|
|
208
|
+
if (result.truncated) {
|
|
209
|
+
const notice = buildTruncationNotice(result, config);
|
|
210
|
+
finalContent = finalContent + "\n\n" + notice;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: "text" as const, text: finalContent }],
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ------------------------------------------------------------------
|
|
219
|
+
// 2. Tool limit optimization via tool_call hook
|
|
220
|
+
// ------------------------------------------------------------------
|
|
221
|
+
pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
|
|
222
|
+
const config = loadConfig(ctx);
|
|
223
|
+
|
|
224
|
+
// Enforce lower limits on find tool
|
|
225
|
+
if (event.toolName === "find") {
|
|
226
|
+
const input = event.input as { limit?: number };
|
|
227
|
+
if (input.limit === undefined || input.limit > config.findLimit) {
|
|
228
|
+
input.limit = config.findLimit;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Enforce lower limits on ls tool
|
|
233
|
+
if (event.toolName === "ls") {
|
|
234
|
+
const input = event.input as { limit?: number };
|
|
235
|
+
if (input.limit === undefined || input.limit > config.lsLimit) {
|
|
236
|
+
input.limit = config.lsLimit;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ------------------------------------------------------------------
|
|
242
|
+
// 3. PDF text extraction tool
|
|
243
|
+
// ------------------------------------------------------------------
|
|
244
|
+
pi.registerTool({
|
|
245
|
+
name: "pdf_read",
|
|
246
|
+
description:
|
|
247
|
+
"Read and extract text content from a PDF file. " +
|
|
248
|
+
"Returns the text content of the PDF, paginated with page markers. " +
|
|
249
|
+
"Use this instead of the read tool for PDF files.",
|
|
250
|
+
parameters: Type.Object({
|
|
251
|
+
path: Type.String({ description: "Path to the PDF file" }),
|
|
252
|
+
maxPages: Type.Optional(
|
|
253
|
+
Type.Number({ description: "Maximum number of pages to extract (default: all pages)" }),
|
|
254
|
+
),
|
|
255
|
+
}),
|
|
256
|
+
execute: async (
|
|
257
|
+
args: { path: string; maxPages?: number },
|
|
258
|
+
ctx: ExtensionContext,
|
|
259
|
+
) => {
|
|
260
|
+
const fs = await import("node:fs/promises");
|
|
261
|
+
const nodePath = await import("node:path");
|
|
262
|
+
const absolutePath = nodePath.resolve(ctx.cwd, args.path);
|
|
263
|
+
|
|
264
|
+
// Check file exists
|
|
265
|
+
try {
|
|
266
|
+
const stat = await fs.stat(absolutePath);
|
|
267
|
+
if (!stat.isFile()) {
|
|
268
|
+
return { content: [{ type: "text" as const, text: `Error: ${args.path} is not a file` }], isError: true };
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
return { content: [{ type: "text" as const, text: `Error: File not found: ${args.path}` }], isError: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Read PDF
|
|
275
|
+
try {
|
|
276
|
+
const buffer = await fs.readFile(absolutePath);
|
|
277
|
+
|
|
278
|
+
// Dynamic import of pdf-parse (optional dependency)
|
|
279
|
+
let pdfParse: typeof import("pdf-parse") | undefined;
|
|
280
|
+
try {
|
|
281
|
+
pdfParse = (await import("pdf-parse")).default;
|
|
282
|
+
} catch {
|
|
283
|
+
return {
|
|
284
|
+
content: [
|
|
285
|
+
{
|
|
286
|
+
type: "text" as const,
|
|
287
|
+
text: "Error: pdf-parse is not installed. Install it with: npm install pdf-parse",
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
isError: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const data = await pdfParse(buffer);
|
|
295
|
+
let text = data.text;
|
|
296
|
+
|
|
297
|
+
// Add metadata header
|
|
298
|
+
const header = [
|
|
299
|
+
`PDF: ${args.path}`,
|
|
300
|
+
`Pages: ${data.numpages}`,
|
|
301
|
+
data.info?.Title ? `Title: ${data.info.Title}` : "",
|
|
302
|
+
data.info?.Author ? `Author: ${data.info.Author}` : "",
|
|
303
|
+
"---",
|
|
304
|
+
]
|
|
305
|
+
.filter(Boolean)
|
|
306
|
+
.join("\n");
|
|
307
|
+
|
|
308
|
+
// Truncate if needed
|
|
309
|
+
const config = loadConfig(ctx);
|
|
310
|
+
const totalBytes = Buffer.byteLength(text, "utf-8");
|
|
311
|
+
const totalLines = text.split("\n").length;
|
|
312
|
+
|
|
313
|
+
if (totalLines > config.maxLines || totalBytes > config.maxBytes) {
|
|
314
|
+
const truncResult = truncateOutput(text, config, ctx);
|
|
315
|
+
text = truncResult.content;
|
|
316
|
+
if (truncResult.truncated) {
|
|
317
|
+
text += "\n\n" + buildTruncationNotice(truncResult, config);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
content: [{ type: "text" as const, text: header + "\n" + text }],
|
|
323
|
+
};
|
|
324
|
+
} catch (err) {
|
|
325
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
326
|
+
return {
|
|
327
|
+
content: [{ type: "text" as const, text: `Error reading PDF: ${message}` }],
|
|
328
|
+
isError: true,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ============================================================================
|
|
336
|
+
// Helpers
|
|
337
|
+
// ============================================================================
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Check if a tool already self-manages truncation via its details field.
|
|
341
|
+
* Built-in tools (read, bash, grep, find, ls) set details.truncation,
|
|
342
|
+
* so we skip them and only catch unprotected tools.
|
|
343
|
+
*/
|
|
344
|
+
function hasSelfManagedTruncation(event: ToolResultEvent): boolean {
|
|
345
|
+
// Built-in tools that self-manage truncation
|
|
346
|
+
const selfManagedTools = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
347
|
+
if (selfManagedTools.has(event.toolName)) return true;
|
|
348
|
+
|
|
349
|
+
// Check if details has a truncation field (any tool can opt in)
|
|
350
|
+
const details = event.details as Record<string, unknown> | undefined;
|
|
351
|
+
if (details && typeof details === "object" && "truncation" in details) return true;
|
|
352
|
+
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Build a human-readable truncation notice with actionable instructions.
|
|
358
|
+
*/
|
|
359
|
+
function buildTruncationNotice(info: TruncationInfo, config: OutputGuardConfig): string {
|
|
360
|
+
const parts: string[] = [];
|
|
361
|
+
|
|
362
|
+
if (info.truncatedBy === "lines") {
|
|
363
|
+
parts.push(
|
|
364
|
+
`Output truncated: ${info.totalLines} lines exceeded limit of ${config.maxLines}.`,
|
|
365
|
+
);
|
|
366
|
+
} else if (info.truncatedBy === "bytes") {
|
|
367
|
+
parts.push(
|
|
368
|
+
`Output truncated: ${formatBytes(info.totalBytes)} exceeded limit of ${formatBytes(config.maxBytes)}.`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (info.fullOutputPath) {
|
|
373
|
+
parts.push(`Full output saved to: ${info.fullOutputPath}`);
|
|
374
|
+
parts.push(`Use the read tool to view the full output.`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return parts.join(" ");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function formatBytes(bytes: number): string {
|
|
381
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
382
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
383
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
384
|
+
}
|