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