@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.
Files changed (157) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/core/agent-session.d.ts.map +1 -1
  3. package/dist/core/agent-session.js +3 -0
  4. package/dist/core/agent-session.js.map +1 -1
  5. package/dist/core/session-manager.d.ts +5 -0
  6. package/dist/core/session-manager.d.ts.map +1 -1
  7. package/dist/core/session-manager.js +8 -0
  8. package/dist/core/session-manager.js.map +1 -1
  9. package/dist/extensions/agent-permissions/index.ts +235 -0
  10. package/dist/extensions/ask-tools/index.ts +115 -0
  11. package/dist/extensions/auto-memory/contract.d.ts +51 -0
  12. package/dist/extensions/auto-memory/contract.d.ts.map +1 -0
  13. package/dist/extensions/auto-memory/contract.js +2 -0
  14. package/dist/extensions/auto-memory/contract.js.map +1 -0
  15. package/dist/extensions/auto-memory/contract.ts +56 -0
  16. package/dist/extensions/auto-memory/index.ts +969 -0
  17. package/dist/extensions/auto-memory/prompts.ts +202 -0
  18. package/dist/extensions/auto-memory/skip-rules.ts +297 -0
  19. package/dist/extensions/auto-memory/utils.ts +208 -0
  20. package/dist/extensions/auto-session-title/index.ts +83 -0
  21. package/dist/extensions/bash-ext/contract.d.ts +79 -0
  22. package/dist/extensions/bash-ext/contract.d.ts.map +1 -0
  23. package/dist/extensions/bash-ext/contract.js +2 -0
  24. package/dist/extensions/bash-ext/contract.js.map +1 -0
  25. package/dist/extensions/bash-ext/contract.ts +69 -0
  26. package/dist/extensions/bash-ext/index.ts +858 -0
  27. package/dist/extensions/claude-hooks-compat/config-loader.ts +49 -0
  28. package/dist/extensions/claude-hooks-compat/handler-runner.ts +377 -0
  29. package/dist/extensions/claude-hooks-compat/if-parser.ts +53 -0
  30. package/dist/extensions/claude-hooks-compat/index.ts +178 -0
  31. package/dist/extensions/claude-hooks-compat/matcher.ts +17 -0
  32. package/dist/extensions/claude-hooks-compat/stdin-builder.ts +27 -0
  33. package/dist/extensions/claude-hooks-compat/types.ts +77 -0
  34. package/dist/extensions/compaction-manager/config.ts +47 -0
  35. package/dist/extensions/compaction-manager/context-fold.ts +63 -0
  36. package/dist/extensions/compaction-manager/index.ts +151 -0
  37. package/dist/extensions/compaction-manager/microcompact.ts +49 -0
  38. package/dist/extensions/compaction-manager/reactive.ts +9 -0
  39. package/dist/extensions/compaction-manager/session-memory.ts +48 -0
  40. package/dist/extensions/coordinator/INTEGRATION.md +376 -0
  41. package/dist/extensions/coordinator/handler.test.ts +277 -0
  42. package/dist/extensions/coordinator/handler.ts +189 -0
  43. package/dist/extensions/coordinator/index.ts +261 -0
  44. package/dist/extensions/coordinator/types.d.ts +100 -0
  45. package/dist/extensions/coordinator/types.d.ts.map +1 -0
  46. package/dist/extensions/coordinator/types.js +2 -0
  47. package/dist/extensions/coordinator/types.js.map +1 -0
  48. package/dist/extensions/coordinator/types.ts +72 -0
  49. package/dist/extensions/file-snapshot/index.ts +131 -0
  50. package/dist/extensions/file-time-guard/README.md +133 -0
  51. package/dist/extensions/file-time-guard/config.ts +13 -0
  52. package/dist/extensions/file-time-guard/index.ts +171 -0
  53. package/dist/extensions/hooks-engine/index.ts +117 -0
  54. package/dist/extensions/lsp/lsp/client/file-tracker.ts +70 -0
  55. package/dist/extensions/lsp/lsp/client/registry.ts +305 -0
  56. package/dist/extensions/lsp/lsp/client/runtime.ts +832 -0
  57. package/dist/extensions/lsp/lsp/config/resolver.ts +573 -0
  58. package/dist/extensions/lsp/lsp/contract.d.ts +101 -0
  59. package/dist/extensions/lsp/lsp/contract.d.ts.map +1 -0
  60. package/dist/extensions/lsp/lsp/contract.js +2 -0
  61. package/dist/extensions/lsp/lsp/contract.js.map +1 -0
  62. package/dist/extensions/lsp/lsp/contract.ts +103 -0
  63. package/dist/extensions/lsp/lsp/hooks/agent-end.ts +169 -0
  64. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts +10 -0
  65. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts.map +1 -0
  66. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js +30 -0
  67. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js.map +1 -0
  68. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.ts +41 -0
  69. package/dist/extensions/lsp/lsp/hooks/writethrough.ts +342 -0
  70. package/dist/extensions/lsp/lsp/index.ts +310 -0
  71. package/dist/extensions/lsp/lsp/lsp.test.ts +684 -0
  72. package/dist/extensions/lsp/lsp/monitoring/server-metrics.ts +176 -0
  73. package/dist/extensions/lsp/lsp/tools/lsp-tool.ts +402 -0
  74. package/dist/extensions/lsp/lsp/utils/dependency-resolver.ts +147 -0
  75. package/dist/extensions/lsp/lsp/utils/diagnostics-wait.ts +41 -0
  76. package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts +20 -0
  77. package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts.map +1 -0
  78. package/dist/extensions/lsp/lsp/utils/lsp-helpers.js +64 -0
  79. package/dist/extensions/lsp/lsp/utils/lsp-helpers.js.map +1 -0
  80. package/dist/extensions/lsp/lsp/utils/lsp-helpers.ts +76 -0
  81. package/dist/extensions/message-bridge/GUIDE.md +210 -0
  82. package/dist/extensions/message-bridge/index.ts +222 -0
  83. package/dist/extensions/output-guard/index.ts +446 -0
  84. package/dist/extensions/preview/index.ts +278 -0
  85. package/dist/extensions/rules-engine/MATCH_HISTORY_RECONCILIATION.md +111 -0
  86. package/dist/extensions/rules-engine/RULES-ENGINE-GUIDE.md +470 -0
  87. package/dist/extensions/rules-engine/cache.js +232 -0
  88. package/dist/extensions/rules-engine/cache.ts +38 -0
  89. package/dist/extensions/rules-engine/config.js +63 -0
  90. package/dist/extensions/rules-engine/config.ts +70 -0
  91. package/dist/extensions/rules-engine/index.js +1530 -0
  92. package/dist/extensions/rules-engine/index.ts +552 -0
  93. package/dist/extensions/rules-engine/injector.js +68 -0
  94. package/dist/extensions/rules-engine/injector.ts +74 -0
  95. package/dist/extensions/rules-engine/loader.js +179 -0
  96. package/dist/extensions/rules-engine/loader.ts +205 -0
  97. package/dist/extensions/rules-engine/matcher.js +534 -0
  98. package/dist/extensions/rules-engine/matcher.ts +52 -0
  99. package/dist/extensions/rules-engine/types.d.ts +156 -0
  100. package/dist/extensions/rules-engine/types.d.ts.map +1 -0
  101. package/dist/extensions/rules-engine/types.js +2 -0
  102. package/dist/extensions/rules-engine/types.js.map +1 -0
  103. package/dist/extensions/rules-engine/types.ts +169 -0
  104. package/dist/extensions/session-supervisor/checker.ts +116 -0
  105. package/dist/extensions/session-supervisor/config.ts +45 -0
  106. package/dist/extensions/session-supervisor/index.ts +726 -0
  107. package/dist/extensions/session-supervisor/prompts.ts +132 -0
  108. package/dist/extensions/session-supervisor/scheduler.ts +69 -0
  109. package/dist/extensions/session-supervisor/types.ts +215 -0
  110. package/dist/extensions/subagent/README.md +172 -0
  111. package/dist/extensions/subagent/agents/explorer.md +25 -0
  112. package/dist/extensions/subagent/agents/guide.md +27 -0
  113. package/dist/extensions/subagent/agents/planner.md +37 -0
  114. package/dist/extensions/subagent/agents/reviewer.md +35 -0
  115. package/dist/extensions/subagent/agents/scout.md +50 -0
  116. package/dist/extensions/subagent/agents/verification.md +35 -0
  117. package/dist/extensions/subagent/agents/worker.md +24 -0
  118. package/dist/extensions/subagent/agents.ts +25 -0
  119. package/dist/extensions/subagent/index.ts +987 -0
  120. package/dist/extensions/subagent/prompts/implement-and-review.md +10 -0
  121. package/dist/extensions/subagent/prompts/implement.md +10 -0
  122. package/dist/extensions/subagent/prompts/scout-and-plan.md +9 -0
  123. package/dist/extensions/subagent-ext/contract.d.ts +2 -0
  124. package/dist/extensions/subagent-ext/contract.d.ts.map +1 -0
  125. package/dist/extensions/subagent-ext/contract.js +2 -0
  126. package/dist/extensions/subagent-ext/contract.js.map +1 -0
  127. package/dist/extensions/subagent-ext/contract.ts +1 -0
  128. package/dist/extensions/subagent-ext/index.ts +347 -0
  129. package/dist/extensions/subagent-shared/contract.d.ts +25 -0
  130. package/dist/extensions/subagent-shared/contract.d.ts.map +1 -0
  131. package/dist/extensions/subagent-shared/contract.js +2 -0
  132. package/dist/extensions/subagent-shared/contract.js.map +1 -0
  133. package/dist/extensions/subagent-shared/contract.ts +28 -0
  134. package/dist/extensions/subagent-shared/index.ts +4 -0
  135. package/dist/extensions/subagent-shared/render.ts +166 -0
  136. package/dist/extensions/subagent-shared/types.ts +35 -0
  137. package/dist/extensions/subagent-shared/utils.ts +112 -0
  138. package/dist/extensions/subagent-v2/contract.d.ts +2 -0
  139. package/dist/extensions/subagent-v2/contract.d.ts.map +1 -0
  140. package/dist/extensions/subagent-v2/contract.js +2 -0
  141. package/dist/extensions/subagent-v2/contract.js.map +1 -0
  142. package/dist/extensions/subagent-v2/contract.ts +1 -0
  143. package/dist/extensions/subagent-v2/index.ts +599 -0
  144. package/dist/extensions/todo-ext/contract.d.ts +27 -0
  145. package/dist/extensions/todo-ext/contract.d.ts.map +1 -0
  146. package/dist/extensions/todo-ext/contract.js +2 -0
  147. package/dist/extensions/todo-ext/contract.js.map +1 -0
  148. package/dist/extensions/todo-ext/contract.ts +30 -0
  149. package/dist/extensions/todo-ext/index.ts +419 -0
  150. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  151. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  152. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  153. package/examples/extensions/sandbox/package-lock.json +2 -2
  154. package/examples/extensions/sandbox/package.json +1 -1
  155. package/examples/extensions/with-deps/package-lock.json +2 -2
  156. package/examples/extensions/with-deps/package.json +1 -1
  157. 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
+ }