@dyyz1993/pi-coding-agent 0.74.24 → 0.74.27
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/CHANGELOG.md +9 -0
- 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/core/session-manager.d.ts +5 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +8 -0
- package/dist/core/session-manager.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 +310 -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 +446 -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/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- 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,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Guard Extension - Global fallback truncation + tool limit optimization.
|
|
3
|
+
*
|
|
4
|
+
* Aligns pi-momo-fork's truncation strategy with OpenCode's approach:
|
|
5
|
+
*
|
|
6
|
+
* OpenCode has a global truncation layer in `Tool.define()` that checks
|
|
7
|
+
* `metadata.truncated` - if undefined, applies 50KB/2000-line truncation
|
|
8
|
+
* and saves full output to disk. Plugin/MCP tools are wrapped in
|
|
9
|
+
* `fromPlugin()` with `Truncate.output()` built in.
|
|
10
|
+
*
|
|
11
|
+
* Pi lacks this global layer. This extension fills the gap via `tool_result`
|
|
12
|
+
* event hooks, providing equivalent protection for:
|
|
13
|
+
* - Extension/plugin tools (no built-in truncation)
|
|
14
|
+
* - MCP tools (no built-in truncation)
|
|
15
|
+
* - Any future tool that forgets to self-manage
|
|
16
|
+
*
|
|
17
|
+
* Three capabilities:
|
|
18
|
+
*
|
|
19
|
+
* 1. **Global truncation fallback**: Intercepts `tool_result` for tools that
|
|
20
|
+
* don't self-manage truncation. Applies 50KB/2000-line limit, saves full
|
|
21
|
+
* output to `<sessionDataDir>/tool-output/`, returns truncated preview
|
|
22
|
+
* with actionable file path hint.
|
|
23
|
+
*
|
|
24
|
+
* 2. **Tool limit optimization**: Intercepts `tool_call` to enforce lower
|
|
25
|
+
* result limits on find (1000 -> 100) and ls (500 -> 100), matching
|
|
26
|
+
* OpenCode's glob/ls defaults. Reduces unnecessary context consumption.
|
|
27
|
+
*
|
|
28
|
+
* 3. **PDF text extraction**: Registers a `pdf_read` tool that extracts text
|
|
29
|
+
* from PDF files. OpenCode sends PDFs as raw base64 to the model; Pi's
|
|
30
|
+
* read tool doesn't support PDFs at all. This tool uses pdf-parse for
|
|
31
|
+
* text extraction, which is more token-efficient than base64 encoding.
|
|
32
|
+
*
|
|
33
|
+
* Configuration (via .pi/settings.json `outputGuard` key):
|
|
34
|
+
* maxLines: number (default: 2000)
|
|
35
|
+
* maxBytes: number (default: 51200 = 50KB)
|
|
36
|
+
* findLimit: number (default: 100)
|
|
37
|
+
* lsLimit: number (default: 100)
|
|
38
|
+
* saveToFile: boolean (default: true)
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { randomBytes } from "node:crypto";
|
|
42
|
+
import { mkdirSync, existsSync, writeFileSync as fsWriteFileSync } from "node:fs";
|
|
43
|
+
import { tmpdir } from "node:os";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
import { Type } from "typebox";
|
|
46
|
+
import type {
|
|
47
|
+
ExtensionAPI,
|
|
48
|
+
ToolResultEvent,
|
|
49
|
+
ToolResultEventResult,
|
|
50
|
+
ToolCallEvent,
|
|
51
|
+
ToolCallEventResult,
|
|
52
|
+
ExtensionContext,
|
|
53
|
+
} from "@dyyz1993/pi-coding-agent";
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Constants
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/** Matches OpenCode's MAX_LINES */
|
|
60
|
+
const DEFAULT_MAX_LINES = 2000;
|
|
61
|
+
/** Matches OpenCode's MAX_BYTES */
|
|
62
|
+
const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
|
63
|
+
/** Matches OpenCode's glob limit of 100 */
|
|
64
|
+
const DEFAULT_FIND_LIMIT = 100;
|
|
65
|
+
/** Matches OpenCode's ls limit of 100 */
|
|
66
|
+
const DEFAULT_LS_LIMIT = 100;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Built-in tools that self-manage truncation.
|
|
70
|
+
* These tools set details.truncation and handle their own size limits,
|
|
71
|
+
* so the global fallback must skip them (matches OpenCode's
|
|
72
|
+
* `metadata.truncated !== undefined` check).
|
|
73
|
+
*/
|
|
74
|
+
const SELF_MANAGED_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Configuration
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
interface OutputGuardConfig {
|
|
81
|
+
maxLines: number;
|
|
82
|
+
maxBytes: number;
|
|
83
|
+
findLimit: number;
|
|
84
|
+
lsLimit: number;
|
|
85
|
+
saveToFile: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function loadConfig(ctx: ExtensionContext): OutputGuardConfig {
|
|
89
|
+
const settings = (ctx as unknown as { settings?: Record<string, unknown> }).settings;
|
|
90
|
+
const guard = settings?.outputGuard as Partial<OutputGuardConfig> | undefined;
|
|
91
|
+
return {
|
|
92
|
+
maxLines: guard?.maxLines ?? DEFAULT_MAX_LINES,
|
|
93
|
+
maxBytes: guard?.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
94
|
+
findLimit: guard?.findLimit ?? DEFAULT_FIND_LIMIT,
|
|
95
|
+
lsLimit: guard?.lsLimit ?? DEFAULT_LS_LIMIT,
|
|
96
|
+
saveToFile: guard?.saveToFile ?? true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Truncation Logic (mirrors OpenCode's Truncate.output)
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
interface TruncationInfo {
|
|
105
|
+
truncated: boolean;
|
|
106
|
+
content: string;
|
|
107
|
+
totalLines: number;
|
|
108
|
+
totalBytes: number;
|
|
109
|
+
outputLines: number;
|
|
110
|
+
outputBytes: number;
|
|
111
|
+
truncatedBy: "lines" | "bytes" | null;
|
|
112
|
+
fullOutputPath?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Truncate text content, keeping the tail (last N lines).
|
|
117
|
+
* Mirrors OpenCode's `Truncate.output()` with direction="tail".
|
|
118
|
+
* Saves full content to `<sessionDataDir>/tool-output/` when truncated
|
|
119
|
+
* (matches OpenCode's `<data-dir>/tool-output/` pattern).
|
|
120
|
+
*/
|
|
121
|
+
function truncateOutput(
|
|
122
|
+
content: string,
|
|
123
|
+
config: OutputGuardConfig,
|
|
124
|
+
ctx: ExtensionContext,
|
|
125
|
+
): TruncationInfo {
|
|
126
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
127
|
+
const lines = content.split("\n");
|
|
128
|
+
const totalLines = lines.length;
|
|
129
|
+
|
|
130
|
+
// No truncation needed
|
|
131
|
+
if (totalLines <= config.maxLines && totalBytes <= config.maxBytes) {
|
|
132
|
+
return {
|
|
133
|
+
truncated: false,
|
|
134
|
+
content,
|
|
135
|
+
totalLines,
|
|
136
|
+
totalBytes,
|
|
137
|
+
outputLines: totalLines,
|
|
138
|
+
outputBytes: totalBytes,
|
|
139
|
+
truncatedBy: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Collect lines from the end (tail direction)
|
|
144
|
+
const outputLinesArr: string[] = [];
|
|
145
|
+
let outputBytesCount = 0;
|
|
146
|
+
let truncatedBy: "lines" | "bytes" = "lines";
|
|
147
|
+
|
|
148
|
+
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < config.maxLines; i--) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
|
|
151
|
+
|
|
152
|
+
if (outputBytesCount + lineBytes > config.maxBytes) {
|
|
153
|
+
truncatedBy = "bytes";
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
outputLinesArr.unshift(line);
|
|
158
|
+
outputBytesCount += lineBytes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (outputLinesArr.length >= config.maxLines && outputBytesCount <= config.maxBytes) {
|
|
162
|
+
truncatedBy = "lines";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const truncatedContent = outputLinesArr.join("\n");
|
|
166
|
+
const finalOutputBytes = Buffer.byteLength(truncatedContent, "utf-8");
|
|
167
|
+
let fullOutputPath: string | undefined;
|
|
168
|
+
|
|
169
|
+
// Save full output to disk (matches OpenCode's behavior)
|
|
170
|
+
if (config.saveToFile) {
|
|
171
|
+
fullOutputPath = saveFullOutput(content, ctx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
truncated: true,
|
|
176
|
+
content: truncatedContent,
|
|
177
|
+
totalLines,
|
|
178
|
+
totalBytes,
|
|
179
|
+
outputLines: outputLinesArr.length,
|
|
180
|
+
outputBytes: finalOutputBytes,
|
|
181
|
+
truncatedBy,
|
|
182
|
+
fullOutputPath,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Save full output to disk.
|
|
188
|
+
* Uses sessionDataDir/tool-output/ to match OpenCode's <data-dir>/tool-output/.
|
|
189
|
+
* Falls back to tmpdir if sessionDataDir is unavailable.
|
|
190
|
+
*/
|
|
191
|
+
function saveFullOutput(content: string, ctx: ExtensionContext): string | undefined {
|
|
192
|
+
try {
|
|
193
|
+
const id = `output-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
194
|
+
// Prefer sessionDataDir if it's an absolute path (production),
|
|
195
|
+
// otherwise fall back to tmpdir (works reliably in tests too)
|
|
196
|
+
const rawBaseDir = ctx.sessionDataDir;
|
|
197
|
+
let baseDir: string;
|
|
198
|
+
if (rawBaseDir && rawBaseDir.startsWith("/")) {
|
|
199
|
+
baseDir = rawBaseDir;
|
|
200
|
+
} else {
|
|
201
|
+
baseDir = join(tmpdir(), "pi-output-guard");
|
|
202
|
+
}
|
|
203
|
+
const dir = join(baseDir, "tool-output");
|
|
204
|
+
if (!existsSync(dir)) {
|
|
205
|
+
mkdirSync(dir, { recursive: true });
|
|
206
|
+
}
|
|
207
|
+
const filePath = join(dir, `${id}.log`);
|
|
208
|
+
fsWriteFileSync(filePath, content);
|
|
209
|
+
return filePath;
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// Extension Entry Point
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
export default function outputGuard(pi: ExtensionAPI) {
|
|
220
|
+
// ------------------------------------------------------------------
|
|
221
|
+
// 1. Global truncation fallback via tool_result hook
|
|
222
|
+
//
|
|
223
|
+
// Mirrors OpenCode's Tool.define() wrapper:
|
|
224
|
+
// if (result.metadata.truncated === undefined) {
|
|
225
|
+
// result.output = Truncate.output(result.output)
|
|
226
|
+
// }
|
|
227
|
+
//
|
|
228
|
+
// In pi, the equivalent is: if a tool's details doesn't have a
|
|
229
|
+
// truncation field AND the tool isn't a known self-managing tool,
|
|
230
|
+
// apply truncation.
|
|
231
|
+
// ------------------------------------------------------------------
|
|
232
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx: ExtensionContext): Promise<ToolResultEventResult | void> => {
|
|
233
|
+
const config = loadConfig(ctx);
|
|
234
|
+
|
|
235
|
+
// Only process text content
|
|
236
|
+
const textParts = event.content.filter((p): p is { type: "text"; text: string } => p.type === "text");
|
|
237
|
+
if (textParts.length === 0) return;
|
|
238
|
+
|
|
239
|
+
// Skip tools that self-manage truncation
|
|
240
|
+
// (matches OpenCode's `metadata.truncated !== undefined` check)
|
|
241
|
+
if (hasSelfManagedTruncation(event)) return;
|
|
242
|
+
|
|
243
|
+
// Skip image content - images have their own size management
|
|
244
|
+
// (matches OpenCode's `metadata.truncated = false` for images)
|
|
245
|
+
const hasImages = event.content.some((p) => p.type === "image");
|
|
246
|
+
if (hasImages) return;
|
|
247
|
+
|
|
248
|
+
// Concatenate all text parts
|
|
249
|
+
const fullText = textParts.map((p) => p.text).join("\n");
|
|
250
|
+
const totalBytes = Buffer.byteLength(fullText, "utf-8");
|
|
251
|
+
const totalLines = fullText.split("\n").length;
|
|
252
|
+
|
|
253
|
+
// Skip if within limits
|
|
254
|
+
if (totalLines <= config.maxLines && totalBytes <= config.maxBytes) return;
|
|
255
|
+
|
|
256
|
+
// Truncate
|
|
257
|
+
const result = truncateOutput(fullText, config, ctx);
|
|
258
|
+
|
|
259
|
+
let finalContent = result.content;
|
|
260
|
+
if (result.truncated) {
|
|
261
|
+
const notice = buildTruncationNotice(result, config);
|
|
262
|
+
finalContent = finalContent + "\n\n" + notice;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text" as const, text: finalContent }],
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ------------------------------------------------------------------
|
|
271
|
+
// 2. Tool limit optimization via tool_call hook
|
|
272
|
+
//
|
|
273
|
+
// OpenCode: glob=100, ls=100
|
|
274
|
+
// Pi default: find=1000, ls=500
|
|
275
|
+
// This hook reduces Pi's limits to match OpenCode.
|
|
276
|
+
// ------------------------------------------------------------------
|
|
277
|
+
pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
|
|
278
|
+
const config = loadConfig(ctx);
|
|
279
|
+
|
|
280
|
+
// Enforce lower limits on find tool
|
|
281
|
+
if (event.toolName === "find") {
|
|
282
|
+
const input = event.input as { limit?: number };
|
|
283
|
+
if (input.limit === undefined || input.limit > config.findLimit) {
|
|
284
|
+
input.limit = config.findLimit;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Enforce lower limits on ls tool
|
|
289
|
+
if (event.toolName === "ls") {
|
|
290
|
+
const input = event.input as { limit?: number };
|
|
291
|
+
if (input.limit === undefined || input.limit > config.lsLimit) {
|
|
292
|
+
input.limit = config.lsLimit;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ------------------------------------------------------------------
|
|
298
|
+
// 3. PDF text extraction tool
|
|
299
|
+
//
|
|
300
|
+
// OpenCode sends PDFs as raw base64 attachments (no text extraction).
|
|
301
|
+
// Pi's read tool doesn't support PDFs at all (outputs binary garbage).
|
|
302
|
+
// This tool uses pdf-parse to extract text, which is more token-efficient.
|
|
303
|
+
// ------------------------------------------------------------------
|
|
304
|
+
pi.registerTool({
|
|
305
|
+
name: "pdf_read",
|
|
306
|
+
description:
|
|
307
|
+
"Read and extract text content from a PDF file. " +
|
|
308
|
+
"Returns the text content of the PDF with metadata. " +
|
|
309
|
+
"Use this instead of the read tool for PDF files.",
|
|
310
|
+
parameters: Type.Object({
|
|
311
|
+
path: Type.String({ description: "Path to the PDF file" }),
|
|
312
|
+
maxPages: Type.Optional(
|
|
313
|
+
Type.Number({ description: "Maximum number of pages to extract (default: all pages)" }),
|
|
314
|
+
),
|
|
315
|
+
}),
|
|
316
|
+
execute: async (
|
|
317
|
+
args: { path: string; maxPages?: number },
|
|
318
|
+
ctx: ExtensionContext,
|
|
319
|
+
) => {
|
|
320
|
+
const fs = await import("node:fs/promises");
|
|
321
|
+
const nodePath = await import("node:path");
|
|
322
|
+
const absolutePath = nodePath.resolve(ctx.cwd, args.path);
|
|
323
|
+
|
|
324
|
+
// Check file exists
|
|
325
|
+
try {
|
|
326
|
+
const stat = await fs.stat(absolutePath);
|
|
327
|
+
if (!stat.isFile()) {
|
|
328
|
+
return { content: [{ type: "text" as const, text: `Error: ${args.path} is not a file` }], isError: true };
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
return { content: [{ type: "text" as const, text: `Error: File not found: ${args.path}` }], isError: true };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Read PDF
|
|
335
|
+
try {
|
|
336
|
+
const buffer = await fs.readFile(absolutePath);
|
|
337
|
+
|
|
338
|
+
// Dynamic import of pdf-parse (optional dependency)
|
|
339
|
+
let pdfParse: typeof import("pdf-parse") | undefined;
|
|
340
|
+
try {
|
|
341
|
+
pdfParse = (await import("pdf-parse")).default;
|
|
342
|
+
} catch {
|
|
343
|
+
return {
|
|
344
|
+
content: [
|
|
345
|
+
{
|
|
346
|
+
type: "text" as const,
|
|
347
|
+
text: "Error: pdf-parse is not installed. Install it with: npm install pdf-parse",
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
isError: true,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const data = await pdfParse(buffer);
|
|
355
|
+
let text = data.text;
|
|
356
|
+
|
|
357
|
+
// Add metadata header
|
|
358
|
+
const header = [
|
|
359
|
+
`PDF: ${args.path}`,
|
|
360
|
+
`Pages: ${data.numpages}`,
|
|
361
|
+
data.info?.Title ? `Title: ${data.info.Title}` : "",
|
|
362
|
+
data.info?.Author ? `Author: ${data.info.Author}` : "",
|
|
363
|
+
"---",
|
|
364
|
+
]
|
|
365
|
+
.filter(Boolean)
|
|
366
|
+
.join("\n");
|
|
367
|
+
|
|
368
|
+
// Truncate if needed
|
|
369
|
+
const config = loadConfig(ctx);
|
|
370
|
+
const totalBytes = Buffer.byteLength(text, "utf-8");
|
|
371
|
+
const totalLines = text.split("\n").length;
|
|
372
|
+
|
|
373
|
+
if (totalLines > config.maxLines || totalBytes > config.maxBytes) {
|
|
374
|
+
const truncResult = truncateOutput(text, config, ctx);
|
|
375
|
+
text = truncResult.content;
|
|
376
|
+
if (truncResult.truncated) {
|
|
377
|
+
text += "\n\n" + buildTruncationNotice(truncResult, config);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
content: [{ type: "text" as const, text: header + "\n" + text }],
|
|
383
|
+
};
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
386
|
+
return {
|
|
387
|
+
content: [{ type: "text" as const, text: `Error reading PDF: ${message}` }],
|
|
388
|
+
isError: true,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============================================================================
|
|
396
|
+
// Helpers
|
|
397
|
+
// ============================================================================
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Check if a tool already self-manages truncation.
|
|
401
|
+
*
|
|
402
|
+
* Mirrors OpenCode's check: `result.metadata.truncated !== undefined`.
|
|
403
|
+
* In pi, built-in tools set `details.truncation`, and any tool can opt in
|
|
404
|
+
* by including a `truncation` field in its details.
|
|
405
|
+
*/
|
|
406
|
+
function hasSelfManagedTruncation(event: ToolResultEvent): boolean {
|
|
407
|
+
// Built-in tools that self-manage truncation
|
|
408
|
+
if (SELF_MANAGED_TOOLS.has(event.toolName)) return true;
|
|
409
|
+
|
|
410
|
+
// Check if details has a truncation field (any tool can opt in)
|
|
411
|
+
const details = event.details as Record<string, unknown> | undefined;
|
|
412
|
+
if (details && typeof details === "object" && "truncation" in details) return true;
|
|
413
|
+
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Build a truncation notice with actionable file path hint.
|
|
419
|
+
* Matches OpenCode's output format which tells the model where to find
|
|
420
|
+
* the full output and suggests using read/grep tools.
|
|
421
|
+
*/
|
|
422
|
+
function buildTruncationNotice(info: TruncationInfo, config: OutputGuardConfig): string {
|
|
423
|
+
const parts: string[] = [];
|
|
424
|
+
|
|
425
|
+
if (info.truncatedBy === "lines") {
|
|
426
|
+
const omitted = info.totalLines - info.outputLines;
|
|
427
|
+
parts.push(`...${omitted} lines truncated.`);
|
|
428
|
+
parts.push(`Output exceeded ${config.maxLines} line limit (${info.totalLines} total lines).`);
|
|
429
|
+
} else if (info.truncatedBy === "bytes") {
|
|
430
|
+
parts.push(`...output truncated at ${formatBytes(info.outputBytes)}.`);
|
|
431
|
+
parts.push(`Output exceeded ${formatBytes(config.maxBytes)} byte limit (${formatBytes(info.totalBytes)} total).`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (info.fullOutputPath) {
|
|
435
|
+
parts.push(`Full output saved to: ${info.fullOutputPath}`);
|
|
436
|
+
parts.push("Use the read tool to view the full output.");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return parts.join("\n");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function formatBytes(bytes: number): string {
|
|
443
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
444
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
445
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
446
|
+
}
|