decorated-pi 0.5.2 → 0.5.3

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/README.md CHANGED
@@ -14,12 +14,10 @@ pi install /path/to/decorated-pi
14
14
 
15
15
  ### 1. Patch Tool
16
16
 
17
- Replaces Pi's built-in `edit` / `write` with a stronger `patch` tool that adds unique safety and usability improvements on top of the native tools.
17
+ Replaces Pi's built-in `edit` with a stronger `patch` tool that adds unique safety and usability improvements on top of the native tools.
18
18
 
19
19
  | Capability | Pi native `edit` | `patch` |
20
20
  | ------ | :---: | :---: |
21
- | Exact string replacement | ✅ `oldText` | ✅ `old_str` |
22
- | Atomic overwrite | ✅ `write` | ✅ `overwrite` |
23
21
  | Syntax‑highlighted overwrite | ✅ streaming | ✅ incremental |
24
22
  | **Anchor‑based search** | ❌ extending `oldText` for uniqueness | ✅ `anchor` bounds scope for precise matching |
25
23
  | **Fuzzy whitespace match** | ❌ only reports "not found" | ✅ auto‑corrects tab↔space / trailing whitespace mismatches |
@@ -4,8 +4,6 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
 
7
- // Extend prompt cache TTL for all providers: Anthropic 1h, OpenAI 24h
8
- process.env.PI_CACHE_RETENTION ??= "long";
9
7
  import { setupSafety } from "./safety/index.js";
10
8
  import { setupModelIntegration } from "./model-integration";
11
9
  import { setupSlash } from "./slash";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared tool-output externalization helpers.
3
+ *
4
+ * Used by `setupIO` (for read / bash) and `maybeExternalizeMcpResult` (for MCP tools).
5
+ * Each writes to the same temp dir; MCP also adds a 2KB preview, while read / bash
6
+ * emit a single-line placeholder.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import { randomBytes } from "node:crypto";
12
+
13
+ export const TOOL_OUTPUT_TEMP_DIR = path.join(os.tmpdir(), "decorated-pi-results");
14
+
15
+ /** Write content to a temp file under TOOL_OUTPUT_TEMP_DIR.
16
+ * Returns the file path, or undefined on failure (e.g., /tmp full). */
17
+ export function writeOutputToTemp(
18
+ toolName: string,
19
+ toolCallId: string,
20
+ content: string,
21
+ ): string | undefined {
22
+ try {
23
+ if (!fs.existsSync(TOOL_OUTPUT_TEMP_DIR)) {
24
+ fs.mkdirSync(TOOL_OUTPUT_TEMP_DIR, { recursive: true });
25
+ }
26
+ const id = toolCallId ? toolCallId.slice(0, 12) : randomBytes(8).toString("hex");
27
+ const filePath = path.join(TOOL_OUTPUT_TEMP_DIR, `${toolName}-${id}.txt`);
28
+ fs.writeFileSync(filePath, content, "utf-8");
29
+ return filePath;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
package/extensions/io.ts CHANGED
@@ -26,9 +26,10 @@
26
26
  * 6. prepareArguments must handle literal newlines in JSON strings
27
27
  */
28
28
 
29
- import { defineTool, isReadToolResult, keyHint, getLanguageFromPath, highlightCode, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import { defineTool, isEditToolResult, isReadToolResult, isWriteToolResult, keyHint, type ExtensionAPI, type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
30
30
  import { renderDiff } from "@earendil-works/pi-coding-agent";
31
31
  import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
32
+ import { writeOutputToTemp } from "./io-tool-output.js";
32
33
  import { Type } from "typebox";
33
34
  import {
34
35
  applyPatch,
@@ -64,15 +65,9 @@ const PatchSchema = Type.Object({
64
65
  path: Type.String({
65
66
  description: "Path to the file to edit (relative or absolute).",
66
67
  }),
67
- edits: Type.Optional(Type.Array(EditSchema, {
68
- description: "Targeted replacements applied sequentially.",
69
- })),
70
- overwrite: Type.Optional(Type.Boolean({
71
- description: "If true, replace the entire file atomically (write temp → mv).",
72
- })),
73
- new_str: Type.Optional(Type.String({
74
- description: "Entire new file content when overwrite is true.",
75
- })),
68
+ edits: Type.Array(EditSchema, {
69
+ description: "Targeted replacements applied sequentially. Each edit does exact string replacement with optional anchor.",
70
+ }),
76
71
  });
77
72
 
78
73
  // ─── Argument repair ───────────────────────────────────────────────────────────────
@@ -105,26 +100,6 @@ export function preparePatchArguments(input: any): any {
105
100
 
106
101
  const args = input as Record<string, any>;
107
102
 
108
- // Legacy multi-file format: { patches: [{ path, edits }] } → extract first
109
- if (Array.isArray(args.patches) && !args.path) {
110
- const first = args.patches[0];
111
- if (first && typeof first === "object" && first.path) {
112
- Object.assign(args, first);
113
- delete args.patches;
114
- }
115
- } else if (typeof args.patches === "string" && !args.path) {
116
- try {
117
- const parsed = jsonParseWithNewlineFix(args.patches);
118
- if (Array.isArray(parsed) && parsed.length > 0 && parsed[0]?.path) {
119
- Object.assign(args, parsed[0]);
120
- delete args.patches;
121
- } else if (parsed && typeof parsed === "object" && parsed.path) {
122
- Object.assign(args, parsed);
123
- delete args.patches;
124
- }
125
- } catch { /* keep original */ }
126
- }
127
-
128
103
  // Edits serialized as JSON string
129
104
  if (typeof args.edits === "string") {
130
105
  try {
@@ -153,14 +128,6 @@ interface PatchCallComponent extends Box {
153
128
  previewArgsKey?: string;
154
129
  previewPending?: boolean;
155
130
  settledError: boolean;
156
- /** Overwrite streaming highlight cache (mirrors write tool design) */
157
- overwriteHighlightCache?: {
158
- rawPath: string;
159
- lang: string | undefined;
160
- rawContent: string;
161
- normalizedLines: string[];
162
- highlightedLines: string[];
163
- };
164
131
  }
165
132
 
166
133
  export function createPatchCallComponent(): PatchCallComponent {
@@ -184,88 +151,10 @@ function getPatchCallComponent(state: any, lastComponent: any): PatchCallCompone
184
151
  return comp;
185
152
  }
186
153
 
187
- // ─── Syntax highlighting for overwrite mode (mirrors write tool's incremental design) ──
188
-
189
- const OVERWRITE_PARTIAL_HIGHLIGHT_LINES = 50;
190
-
191
- function normalizeDisplayText(text: string): string {
192
- return text.replace(/\r/g, "");
193
- }
194
-
195
154
  function replaceTabs(text: string): string {
196
155
  return text.replace(/\t/g, " ");
197
156
  }
198
157
 
199
- function highlightSingleLine(line: string, lang: string | undefined): string {
200
- const highlighted = highlightCode(line, lang);
201
- return highlighted[0] ?? "";
202
- }
203
-
204
- function refreshOverwriteHighlightPrefix(cache: NonNullable<PatchCallComponent["overwriteHighlightCache"]>): void {
205
- const prefixCount = Math.min(OVERWRITE_PARTIAL_HIGHLIGHT_LINES, cache.normalizedLines.length);
206
- if (prefixCount === 0) return;
207
- const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
208
- const prefixHighlighted = highlightCode(prefixSource, cache.lang);
209
- for (let i = 0; i < prefixCount; i++) {
210
- cache.highlightedLines[i] =
211
- prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
212
- }
213
- }
214
-
215
- function rebuildOverwriteHighlightCache(rawPath: string, fileContent: string): PatchCallComponent["overwriteHighlightCache"] | undefined {
216
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
217
- if (!lang) return undefined;
218
- const normalized = replaceTabs(normalizeDisplayText(fileContent));
219
- return {
220
- rawPath,
221
- lang,
222
- rawContent: fileContent,
223
- normalizedLines: normalized.split("\n"),
224
- highlightedLines: highlightCode(normalized, lang),
225
- };
226
- }
227
-
228
- function updateOverwriteHighlightCache(
229
- cache: PatchCallComponent["overwriteHighlightCache"] | undefined,
230
- rawPath: string,
231
- fileContent: string,
232
- ): PatchCallComponent["overwriteHighlightCache"] | undefined {
233
- if (cache) {
234
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
235
- if (!lang || cache.lang !== lang || cache.rawPath !== rawPath) {
236
- return rebuildOverwriteHighlightCache(rawPath, fileContent);
237
- }
238
- if (!fileContent.startsWith(cache.rawContent)) {
239
- return rebuildOverwriteHighlightCache(rawPath, fileContent);
240
- }
241
- if (fileContent.length === cache.rawContent.length) return cache;
242
- const deltaRaw = fileContent.slice(cache.rawContent.length);
243
- const deltaNormalized = replaceTabs(normalizeDisplayText(deltaRaw));
244
- cache.rawContent = fileContent;
245
- if (cache.normalizedLines.length === 0) {
246
- cache.normalizedLines.push("");
247
- cache.highlightedLines.push("");
248
- }
249
- const segments = deltaNormalized.split("\n");
250
- const lastIndex = cache.normalizedLines.length - 1;
251
- cache.normalizedLines[lastIndex] += segments[0];
252
- cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex]!, cache.lang);
253
- for (let i = 1; i < segments.length; i++) {
254
- cache.normalizedLines.push(segments[i]!);
255
- cache.highlightedLines.push(highlightSingleLine(segments[i]!, cache.lang));
256
- }
257
- refreshOverwriteHighlightPrefix(cache);
258
- return cache;
259
- }
260
- return rebuildOverwriteHighlightCache(rawPath, fileContent);
261
- }
262
-
263
- function addHighlightedContent(parent: Box, lines: string[]): void {
264
- for (const line of lines) {
265
- parent.addChild(new Text(line, 0, 0));
266
- }
267
- }
268
-
269
158
  function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
270
159
  if (component.settledError) {
271
160
  return (text: string) => theme.bg("toolErrorBg", text);
@@ -335,9 +224,7 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
335
224
  let label = "";
336
225
  if (args?.path) {
337
226
  label = theme.fg("accent", args.path);
338
- if (args.overwrite) {
339
- label += theme.fg("error", " [overwrite]");
340
- } else if (args.edits?.length > 0) {
227
+ if (Array.isArray(args.edits) && args.edits.length > 0) {
341
228
  label += theme.fg("dim", ` (${args.edits.length} edit${args.edits.length > 1 ? "s" : ""})`);
342
229
  }
343
230
  }
@@ -348,9 +235,7 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
348
235
 
349
236
  const preview = component.preview;
350
237
  let body = "";
351
- if ("isOverwrite" in preview && preview.isOverwrite && preview.preview) {
352
- body = preview.preview;
353
- } else if ("diff" in preview && preview.diff) {
238
+ if ("diff" in preview && preview.diff) {
354
239
  body = preview.diff;
355
240
  } else if ("error" in preview && preview.error) {
356
241
  component.addChild(new Spacer(1));
@@ -364,36 +249,18 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
364
249
  const FOLD_THRESHOLD = 45;
365
250
 
366
251
  component.addChild(new Spacer(1));
367
- const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
368
252
 
369
253
  if (lines.length > FOLD_THRESHOLD && !expanded) {
370
254
  // 折叠态: 显示前几行 + 摘要
371
- if (isOverwrite) {
372
- if (component.overwriteHighlightCache) {
373
- const n = Math.min(10, component.overwriteHighlightCache.highlightedLines.length);
374
- addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines.slice(0, n));
375
- } else {
376
- component.addChild(new Text(theme.fg("toolDiffAdded", lines.slice(0, 10).join("\n")), 0, 0));
377
- }
378
- } else {
379
- const shown = lines.slice(0, 10).join("\n");
380
- appendPatchDiffChildren(component, shown, theme);
381
- }
255
+ const shown = lines.slice(0, 10).join("\n");
256
+ appendPatchDiffChildren(component, shown, theme);
382
257
  component.addChild(new Text(
383
258
  theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + keyHint("app.tools.expand", "expand") + theme.fg("dim", ")"),
384
259
  0, 0,
385
260
  ));
386
261
  } else {
387
262
  // 展开态: 完整显示
388
- if (isOverwrite) {
389
- if (component.overwriteHighlightCache) {
390
- addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines);
391
- } else {
392
- component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
393
- }
394
- } else {
395
- appendPatchDiffChildren(component, body, theme);
396
- }
263
+ appendPatchDiffChildren(component, body, theme);
397
264
  }
398
265
 
399
266
  return component;
@@ -401,17 +268,51 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
401
268
 
402
269
  // ─── Setup ──────────────────────────────────────────────────────────────────────────
403
270
 
271
+ export const OUTPUT_EXTERNALIZE_THRESHOLD = 30_000; // 30KB — match Claude Code's bash truncation threshold
272
+
273
+ /** If the tool result content is a single text string longer than the
274
+ * threshold, replace it with a one-line placeholder pointing at the
275
+ * full output on disk. Returns the modified result, or undefined to
276
+ * leave the original content untouched. */
277
+ export function maybeExternalizeToolResult(event: ToolResultEvent): ToolResultEventResult | undefined {
278
+ const content = event.content;
279
+ if (!Array.isArray(content) || content.length === 0) return undefined;
280
+ const first = content[0];
281
+ if (first?.type !== "text" || typeof first.text !== "string") return undefined;
282
+ const text = first.text;
283
+ if (text.length <= OUTPUT_EXTERNALIZE_THRESHOLD) return undefined;
284
+
285
+ const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
286
+ if (!filePath) return undefined; // write failed — keep original content
287
+
288
+ return {
289
+ content: [{
290
+ type: "text" as const,
291
+ text: `[Output truncated: ${text.length.toLocaleString()} chars. Full output: ${filePath}]`,
292
+ }],
293
+ };
294
+ }
295
+
404
296
  export function setupIO(pi: ExtensionAPI) {
405
297
  pi.on("session_start", (_event, ctx) => {
406
298
  restoreReadMarkersFromBranch(ctx.sessionManager.getBranch() as any[], ctx.cwd);
407
299
  const active = pi.getActiveTools();
408
- pi.setActiveTools(active.filter(t => !["edit", "write", "grep", "find", "ls"].includes(t)));
300
+ // Remove: edit (replaced by patch), grep/find/ls (replaced by bash)
301
+ // Keep: write (full-file write)
302
+ pi.setActiveTools(active.filter(t => !["edit", "grep", "find", "ls"].includes(t)));
409
303
  });
410
304
 
411
305
  pi.on("session_compact", () => {
412
306
  clearReadMarkers();
413
307
  });
414
308
 
309
+ // Externalize large tool results (read / bash) to a temp file.
310
+ // Keeps the messages segment small so prompt cache stays warm across turns.
311
+ pi.on("tool_result", (event) => {
312
+ if (event.toolName !== "read" && event.toolName !== "bash") return;
313
+ return maybeExternalizeToolResult(event);
314
+ });
315
+
415
316
  // Track file read times
416
317
  pi.on("tool_result", (event, ctx) => {
417
318
  if (!isReadToolResult(event)) return;
@@ -424,18 +325,31 @@ export function setupIO(pi: ExtensionAPI) {
424
325
  if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
425
326
  });
426
327
 
328
+ // Track file write times (for write and edit, in case patch module is disabled)
329
+ pi.on("tool_result", (event, ctx) => {
330
+ if (!isWriteToolResult(event) && !isEditToolResult(event)) return;
331
+ const filePath = event.input?.path;
332
+ if (typeof filePath !== "string" || !filePath.trim()) return;
333
+ const cwd: string = ctx.cwd ?? process.cwd();
334
+ const absPath = resolveAbsolutePath(cwd, filePath);
335
+ // Only record if the write succeeded (not an error result)
336
+ if (event.isError) return;
337
+ recordReadTime(absPath);
338
+ const marker = createFileTimeMarkerData(cwd, absPath);
339
+ if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
340
+ });
341
+
427
342
  pi.registerTool(defineTool({
428
343
  name: "patch",
429
344
  label: "Patch",
430
345
  description: [
431
- "Edits a file using exact string replacement, with anchor support and overwrite mode.",
346
+ "Edits a file using exact string replacement, with anchor support.",
432
347
  "When old_str is not unique, add more surrounding context or use anchor to narrow search.",
433
348
  "",
434
349
  "Examples:",
435
350
  ' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
436
351
  ' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return x + 1" }] }',
437
352
  ' { path: "src/foo.ts", edits: [{ anchor: "function init() {", old_str: "const DEBUG = true;", new_str: "const DEBUG = false;" }, { old_str: "log(\"debug\");", new_str: "// debug disabled" }] }',
438
- ' { path: "src/bar.ts", overwrite: true, new_str: "entire file content" }',
439
353
  "",
440
354
  "Anchor (optional): narrows old_str search to lines after a unique marker.",
441
355
  " Code: use the enclosing definition — function/class/struct/method signature.",
@@ -443,21 +357,20 @@ export function setupIO(pi: ExtensionAPI) {
443
357
  " Non-code (markdown, config, etc.): use section headings, key names, or distinctive lines.",
444
358
  ' e.g. "## API Reference" in .md or "[dependencies]" in .toml files.',
445
359
  ].join("\n"),
446
- promptSnippet: "Edits a file using exact string replacement, with anchor support and overwrite mode.",
360
+ promptSnippet: "Edits a file using exact string replacement, with anchor support.",
447
361
  promptGuidelines: [
448
- "Always prefer modifying files with PATCH tool over bash commands or python scripts.",
449
- "For full-file replacement, always use patch tool to prevent unintended edits or data loss.",
362
+ "Always prefer modifying files with patch tool over bash commands or python scripts.",
450
363
  "To prevent hallucinations: 1. Keep each edit batch ≤ 5 changes; 2. Process remaining revisions in sequential steps",
451
364
  "On repeated failures: read the file first to confirm information accuracy.",
452
365
  ],
453
366
  parameters: PatchSchema,
454
367
  renderShell: "self",
455
368
  prepareArguments: preparePatchArguments,
456
- execute: async (_toolCallId: string, input: { path: string; edits?: any[]; overwrite?: boolean; new_str?: string }, _signal: any, _onUpdate: any, ctx: any) => {
369
+ execute: async (_toolCallId: string, input: { path: string; edits: any[] }, _signal: any, _onUpdate: any, ctx: any) => {
457
370
  const cwd: string = ctx.cwd ?? process.cwd();
458
371
 
459
- // Stale-read protection (only for edits, not overwrite)
460
- if (!input.overwrite && input.path?.trim()) {
372
+ // Stale-read protection
373
+ if (input.path?.trim()) {
461
374
  const absPath = resolveAbsolutePath(cwd, input.path);
462
375
  const staleError = checkStaleFile(absPath, input.path);
463
376
  if (staleError) throw new Error(staleError);
@@ -491,28 +404,6 @@ export function setupIO(pi: ExtensionAPI) {
491
404
  component.previewArgsKey = argsKey;
492
405
  component.previewPending = false;
493
406
  component.settledError = false;
494
- component.overwriteHighlightCache = undefined;
495
- }
496
-
497
- // Overwrite streaming: incrementally highlight as new_str streams in
498
- if (args?.overwrite && typeof args?.path === "string" && typeof args?.new_str === "string") {
499
- if (context.argsComplete) {
500
- // Final: full rebuild for best quality
501
- component.overwriteHighlightCache = rebuildOverwriteHighlightCache(args.path, args.new_str);
502
- } else {
503
- // Streaming: incremental update
504
- component.overwriteHighlightCache = updateOverwriteHighlightCache(
505
- component.overwriteHighlightCache, args.path, args.new_str,
506
- );
507
- }
508
- // Inject highlighted content as synthetic preview for buildPatchCallComponent
509
- const cache = component.overwriteHighlightCache;
510
- if (cache) {
511
- component.preview = { preview: cache.rawContent, isOverwrite: true };
512
- } else {
513
- // No language detected: fall back to plain text
514
- component.preview = { preview: args.new_str, isOverwrite: true };
515
- }
516
407
  }
517
408
 
518
409
  // Preview diff is computed during execute and delivered via result.details.diff.
@@ -527,37 +418,17 @@ export function setupIO(pi: ExtensionAPI) {
527
418
  let changed = false;
528
419
 
529
420
  if (callComponent) {
530
- // overwrite: 保留完整文件预览,不要被空 diff 覆盖成只剩 header
531
- const overwriteContent = !context.isError && context.args?.overwrite && typeof context.args?.new_str === "string"
532
- ? context.args.new_str
533
- : undefined;
534
-
535
- if (typeof overwriteContent === "string") {
536
- const nextCache = rebuildOverwriteHighlightCache(context.args?.path, overwriteContent);
537
- const prevContent = (callComponent.overwriteHighlightCache?.rawContent ?? undefined);
538
- const prevPath = callComponent.overwriteHighlightCache?.rawPath;
539
- callComponent.overwriteHighlightCache = nextCache;
540
- if (
541
- !(callComponent.preview && "isOverwrite" in callComponent.preview && callComponent.preview.isOverwrite && callComponent.preview.preview === overwriteContent) ||
542
- prevContent !== overwriteContent ||
543
- prevPath !== context.args?.path
544
- ) {
545
- callComponent.preview = { preview: overwriteContent, isOverwrite: true };
421
+ // Use execute's returned diff (same design as Pi's native edit tool)
422
+ const resultDiff = !context.isError && result.details?.diff;
423
+ if (typeof resultDiff === "string") {
424
+ const newPreview = { diff: resultDiff };
425
+ if (callComponent.preview?.diff !== resultDiff) {
426
+ callComponent.preview = newPreview;
546
427
  changed = true;
547
428
  }
548
- } else {
549
- // 非 overwrite:优先使用 execute 返回的 diff(与 Pi 原生 edit 工具一致)
550
- const resultDiff = !context.isError && result.details?.diff;
551
- if (typeof resultDiff === "string") {
552
- const newPreview = { diff: resultDiff };
553
- if (callComponent.preview?.diff !== resultDiff) {
554
- callComponent.preview = newPreview;
555
- changed = true;
556
- }
557
- }
558
429
  }
559
430
 
560
- // 更新错误状态
431
+ // Update error state
561
432
  if (callComponent.settledError !== context.isError) {
562
433
  callComponent.settledError = context.isError;
563
434
  changed = true;
@@ -340,3 +340,37 @@ export function cleanupStaleCache(configs: McpServerConfig[], cwd?: string): voi
340
340
  }
341
341
  }
342
342
  }
343
+
344
+ // ── Large result externalization ────────────────────────────────────────
345
+
346
+ export const EXTERNALIZE_THRESHOLD = 50_000; // 50KB
347
+ export const EXTERNALIZE_PREVIEW_SIZE = 2_000; // 2KB preview
348
+ import { writeOutputToTemp } from "../io-tool-output.js";
349
+
350
+ /**
351
+ * If content exceeds threshold, save to temp file and return preview + path.
352
+ * Otherwise return original content unchanged.
353
+ * Falls back to returning full text if file write fails.
354
+ */
355
+ export function maybeExternalizeMcpResult(
356
+ text: string,
357
+ toolName: string,
358
+ toolCallId: string,
359
+ ): { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> } {
360
+ if (text.length <= EXTERNALIZE_THRESHOLD) {
361
+ return { content: [{ type: "text", text }], details: {} };
362
+ }
363
+
364
+ const filePath = writeOutputToTemp(toolName, toolCallId, text);
365
+ if (!filePath) {
366
+ return { content: [{ type: "text", text }], details: {} };
367
+ }
368
+ const preview = text.slice(0, EXTERNALIZE_PREVIEW_SIZE);
369
+ return {
370
+ content: [{ type: "text", text: `${preview}\n\n[Truncated: ${text.length.toLocaleString()} chars total. Full output saved to: ${filePath}]` }],
371
+ details: {
372
+ fullOutputPath: filePath,
373
+ truncation: { truncated: true, outputChars: EXTERNALIZE_PREVIEW_SIZE, totalChars: text.length, maxBytes: EXTERNALIZE_THRESHOLD },
374
+ },
375
+ };
376
+ }
@@ -4,6 +4,8 @@ import { McpConnection } from "./client.js";
4
4
  import {
5
5
  resolveMcpConfigs,
6
6
  loadMcpCache, updateServerCache, cleanupStaleCache,
7
+ maybeExternalizeMcpResult,
8
+ BUILTIN_MCP_SERVERS,
7
9
  type McpServerConfig, type McpToolCache,
8
10
  } from "./builtin.js";
9
11
 
@@ -51,7 +53,24 @@ function formatMcpResultText(text: string, expanded: boolean, theme: any): strin
51
53
  text,
52
54
  expanded ? Number.MAX_SAFE_INTEGER : MCP_RESULT_FOLD_LINES,
53
55
  );
54
- let rendered = displayLines.join("\n") ? theme.fg("toolOutput", displayLines.join("\n")) : "";
56
+
57
+ // Check for externalization message in the last line
58
+ const lastLine = displayLines[displayLines.length - 1] || "";
59
+ let outputLines = [...displayLines];
60
+ let truncationMsg = "";
61
+
62
+ if (lastLine.startsWith('[Truncated: ') && lastLine.endsWith(']')) {
63
+ truncationMsg = lastLine;
64
+ outputLines = outputLines.slice(0, -1);
65
+ }
66
+
67
+ const outputText = outputLines.join("\n");
68
+ let rendered = outputText ? theme.fg("toolOutput", outputText) : "";
69
+
70
+ if (truncationMsg) {
71
+ rendered += (rendered ? "\n" : "") + theme.fg("warning", truncationMsg);
72
+ }
73
+
55
74
  if (!expanded && remainingLines > 0) {
56
75
  rendered += `${theme.fg("muted", `\n... (${remainingLines} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`;
57
76
  }
@@ -100,10 +119,11 @@ function cacheScopeForSource(source: string): "global" | "project" {
100
119
 
101
120
  // ── register cached tools ─────────────────────────────────────────────────
102
121
 
103
- function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void {
104
- const cache = loadMcpCache(cachedCwd);
105
- if (!cache) return;
106
-
122
+ function registerCachedToolsFromCache(
123
+ pi: ExtensionAPI,
124
+ cache: McpCache,
125
+ configs: McpServerConfig[]
126
+ ): void {
107
127
  for (const config of configs) {
108
128
  if (!config.enabled) continue;
109
129
  const entry = cache.servers[config.name];
@@ -122,15 +142,19 @@ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void
122
142
  execute: async (_id, params, _signal, _update, _ctx) => {
123
143
  const conn = activeConnections.find(c => c.serverName === config.name);
124
144
  if (!conn) {
145
+ // Tool is registered from cache before connectAll finishes. Tell the
146
+ // model to retry — connection is establishing in the background.
147
+ const status = allServers.get(config.name);
148
+ const state = status?.state ?? "connecting";
125
149
  return {
126
- content: [{ type: "text", text: `MCP server "${config.name}" is not connected.` }],
127
- isError: true,
150
+ content: [{ type: "text", text: `MCP server "${config.name}" is ${state}. Please retry shortly.` }],
151
+ isError: false,
128
152
  details: {},
129
153
  };
130
154
  }
131
155
  try {
132
156
  const text = await conn.callTool(t.name, params as Record<string, unknown>);
133
- return { content: [{ type: "text", text }], isError: false, details: {} };
157
+ return maybeExternalizeMcpResult(text, toolName, _id);
134
158
  } catch (err) {
135
159
  const msg = err instanceof Error ? err.message : String(err);
136
160
  return {
@@ -145,9 +169,18 @@ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void
145
169
  }
146
170
  }
147
171
 
172
+ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void {
173
+ const cache = loadMcpCache(cachedCwd);
174
+ if (!cache) return;
175
+ registerCachedToolsFromCache(pi, cache, configs);
176
+ }
177
+
148
178
  // ── connect ───────────────────────────────────────────────────────────────
149
179
 
150
- async function connectAll(configs: McpServerConfig[], ui?: { notify: (msg: string, type: string) => void }): Promise<void> {
180
+ async function connectAll(
181
+ configs: McpServerConfig[],
182
+ ui?: { notify: (msg: string, type: string) => void }
183
+ ): Promise<{ schemaChanges: string[]; hasNewServer: boolean }> {
151
184
  // Load current cache for comparison
152
185
  const cache = loadMcpCache(cachedCwd);
153
186
  const schemaChanges: string[] = [];
@@ -241,10 +274,17 @@ async function connectAll(configs: McpServerConfig[], ui?: { notify: (msg: strin
241
274
  await connectPromise;
242
275
  connectPromise = null;
243
276
 
244
- // Notify about schema changes
245
- if (schemaChanges.length > 0 && ui) {
246
- ui.notify(`MCP schema updated: ${schemaChanges.join('; ')}. Run /reload to apply.`, "warning");
277
+ // Determine whether any server had no cached tools (first-time connection).
278
+ let hasNewServer = false;
279
+ for (const server of configs) {
280
+ const cachedEntry = cache?.servers[server.name];
281
+ if (!cachedEntry || cachedEntry.tools.length === 0) {
282
+ hasNewServer = true;
283
+ break;
284
+ }
247
285
  }
286
+
287
+ return { schemaChanges, hasNewServer };
248
288
  }
249
289
 
250
290
  // ── setup ─────────────────────────────────────────────────────────────────
@@ -263,11 +303,27 @@ export function setupMcp(pi: ExtensionAPI) {
263
303
 
264
304
  const enabledConfigs = configs.filter(s => s.enabled);
265
305
 
266
- // Register tools from cache prompt-stable, works even if MCP is down
267
- registerCachedTools(pi, enabledConfigs);
306
+ // Load cache and register tools immediately (before connectAll)
307
+ const cache = loadMcpCache(cachedCwd);
308
+ if (cache) {
309
+ registerCachedToolsFromCache(pi, cache, enabledConfigs);
310
+ }
268
311
 
269
- // Connect in background, pass UI for schema change notifications
270
- void connectAll(enabledConfigs, ctx.hasUI ? ctx.ui : undefined);
312
+ // Connect in background (fire-and-forget, don't block session_start)
313
+ void connectAll(enabledConfigs, ctx.hasUI ? ctx.ui : undefined).then(({ schemaChanges, hasNewServer }) => {
314
+ // Re-register tools when:
315
+ // - A server was not in cache before (first connection)
316
+ // - Schema changed (tools added/removed/changed)
317
+ if (hasNewServer || schemaChanges.length > 0) {
318
+ const cache = loadMcpCache(cachedCwd);
319
+ if (cache) {
320
+ registerCachedToolsFromCache(pi, cache, enabledConfigs);
321
+ }
322
+ }
323
+ if (schemaChanges.length > 0 && ctx.hasUI) {
324
+ ctx.ui.notify(`MCP schema updated: ${schemaChanges.join('; ')}.`, "info");
325
+ }
326
+ });
271
327
  });
272
328
 
273
329
  pi.on("session_shutdown", () => {
@@ -255,8 +255,11 @@ class McpStatusComponent extends Container {
255
255
  if (s.tools.length > 0) {
256
256
  lines.push(` ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}:`);
257
257
  for (const tool of s.tools.slice(0, 6)) {
258
- const td = tool.description
259
- ? ` ${tool.description.slice(0, 55)}`
258
+ const flat = (tool.description ?? "").replace(/\s+/g, " ").trim();
259
+ const td = flat
260
+ ? flat.length > 55
261
+ ? ` — ${flat.slice(0, 55)}…`
262
+ : ` — ${flat}`
260
263
  : "";
261
264
  lines.push(` ${tool.name}${td}`);
262
265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "decorated-pi is a practical enhancement pack for pi — smarter tools that are token efficient and cache friendly.",
5
5
  "keywords": [
6
6
  "pi",
@@ -13,7 +13,6 @@
13
13
  "language-server",
14
14
  "secret-detection"
15
15
  ],
16
- "type": "commonjs",
17
16
  "license": "MIT",
18
17
  "repository": {
19
18
  "type": "git",
@@ -46,6 +45,6 @@
46
45
  "scripts": {
47
46
  "prepack": "depcheck --fail-on-missing",
48
47
  "test": "vitest run",
49
- "prepublishOnly": "npm install --package-lock-only --no-audit --no-fund && npm test && depcheck --fail-on-missing && publint"
48
+ "prepublishOnly": "npm install --no-audit --no-fund && npm test && depcheck --fail-on-missing && publint"
50
49
  }
51
50
  }