@oh-my-pi/pi-coding-agent 13.11.0 → 13.12.0

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 (74) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/package.json +7 -7
  3. package/src/capability/rule.ts +4 -0
  4. package/src/cli/commands/init-xdg.ts +27 -0
  5. package/src/cli/config-cli.ts +8 -3
  6. package/src/cli/shell-cli.ts +1 -1
  7. package/src/commands/config.ts +1 -1
  8. package/src/config/model-registry.ts +160 -26
  9. package/src/config/model-resolver.ts +84 -21
  10. package/src/config/settings-schema.ts +812 -647
  11. package/src/discovery/helpers.ts +11 -2
  12. package/src/exa/index.ts +1 -11
  13. package/src/exa/search.ts +1 -122
  14. package/src/exec/bash-executor.ts +62 -25
  15. package/src/extensibility/custom-tools/types.ts +2 -3
  16. package/src/extensibility/extensions/types.ts +2 -0
  17. package/src/extensibility/hooks/types.ts +2 -0
  18. package/src/index.ts +6 -6
  19. package/src/internal-urls/docs-index.generated.ts +3 -3
  20. package/src/lsp/config.ts +1 -0
  21. package/src/lsp/defaults.json +3 -3
  22. package/src/memories/index.ts +20 -7
  23. package/src/memories/storage.ts +46 -32
  24. package/src/modes/components/agent-dashboard.ts +23 -35
  25. package/src/modes/components/assistant-message.ts +25 -2
  26. package/src/modes/components/btw-panel.ts +104 -0
  27. package/src/modes/components/settings-defs.ts +5 -1
  28. package/src/modes/components/settings-selector.ts +6 -6
  29. package/src/modes/controllers/btw-controller.ts +193 -0
  30. package/src/modes/controllers/command-controller.ts +3 -1
  31. package/src/modes/controllers/event-controller.ts +4 -0
  32. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  33. package/src/modes/controllers/input-controller.ts +10 -1
  34. package/src/modes/controllers/selector-controller.ts +18 -17
  35. package/src/modes/interactive-mode.ts +22 -0
  36. package/src/modes/prompt-action-autocomplete.ts +17 -3
  37. package/src/modes/rpc/rpc-client.ts +30 -19
  38. package/src/modes/theme/theme.ts +28 -36
  39. package/src/modes/types.ts +4 -0
  40. package/src/modes/utils/ui-helpers.ts +3 -0
  41. package/src/patch/hashline.ts +120 -16
  42. package/src/prompts/system/btw-user.md +8 -0
  43. package/src/prompts/system/custom-system-prompt.md +1 -1
  44. package/src/prompts/system/system-prompt.md +1 -0
  45. package/src/prompts/tools/code-search.md +45 -0
  46. package/src/prompts/tools/hashline.md +3 -0
  47. package/src/prompts/tools/read.md +2 -2
  48. package/src/sdk.ts +36 -40
  49. package/src/session/agent-session.ts +65 -37
  50. package/src/session/blob-store.ts +32 -0
  51. package/src/session/compaction/compaction.ts +27 -6
  52. package/src/session/history-storage.ts +2 -2
  53. package/src/session/session-manager.ts +116 -44
  54. package/src/session/streaming-output.ts +17 -54
  55. package/src/slash-commands/builtin-registry.ts +11 -0
  56. package/src/system-prompt.ts +4 -17
  57. package/src/task/agents.ts +1 -1
  58. package/src/task/executor.ts +1 -1
  59. package/src/task/index.ts +9 -8
  60. package/src/tools/browser.ts +11 -0
  61. package/src/tools/exit-plan-mode.ts +6 -0
  62. package/src/tools/fetch.ts +1 -1
  63. package/src/tools/output-meta.ts +104 -9
  64. package/src/tools/read.ts +13 -26
  65. package/src/utils/title-generator.ts +70 -92
  66. package/src/utils/tools-manager.ts +1 -1
  67. package/src/web/scrapers/index.ts +7 -7
  68. package/src/web/scrapers/utils.ts +1 -0
  69. package/src/web/search/code-search.ts +385 -0
  70. package/src/web/search/index.ts +25 -280
  71. package/src/web/search/provider.ts +1 -1
  72. package/src/web/search/types.ts +28 -0
  73. package/src/exa/company.ts +0 -26
  74. package/src/exa/linkedin.ts +0 -26
@@ -36,16 +36,14 @@ export interface OutputSinkOptions {
36
36
 
37
37
  export interface TruncationResult {
38
38
  content: string;
39
- truncated: boolean;
40
- truncatedBy: "lines" | "bytes" | null;
39
+ truncated?: boolean;
40
+ truncatedBy?: "lines" | "bytes";
41
41
  totalLines: number;
42
42
  totalBytes: number;
43
- outputLines: number;
44
- outputBytes: number;
45
- lastLinePartial: boolean;
46
- firstLineExceedsLimit: boolean;
47
- maxLines: number;
48
- maxBytes: number;
43
+ outputLines?: number;
44
+ outputBytes?: number;
45
+ lastLinePartial?: boolean;
46
+ firstLineExceedsLimit?: boolean;
49
47
  }
50
48
 
51
49
  export interface TruncationOptions {
@@ -206,26 +204,10 @@ export function truncateLine(
206
204
  // =============================================================================
207
205
 
208
206
  /** Shared helper to build a no-truncation result. */
209
- function noTruncResult(
210
- content: string,
211
- totalLines: number,
212
- totalBytes: number,
213
- maxLines: number,
214
- maxBytes: number,
215
- ): TruncationResult {
216
- return {
217
- content,
218
- truncated: false,
219
- truncatedBy: null,
220
- totalLines,
221
- totalBytes,
222
- outputLines: totalLines,
223
- outputBytes: totalBytes,
224
- lastLinePartial: false,
225
- firstLineExceedsLimit: false,
226
- maxLines,
227
- maxBytes,
228
- };
207
+ export function noTruncResult(content: string, totalLines?: number, totalBytes?: number): TruncationResult {
208
+ if (totalLines == null) totalLines = countNewlines(content) + 1;
209
+ if (totalBytes == null) totalBytes = Buffer.byteLength(content, "utf-8");
210
+ return { content, totalLines, totalBytes };
229
211
  }
230
212
 
231
213
  /**
@@ -244,7 +226,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
244
226
  const totalLines = countNewlines(content) + 1;
245
227
 
246
228
  if (totalLines <= maxLines && totalBytes <= maxBytes) {
247
- return noTruncResult(content, totalLines, totalBytes, maxLines, maxBytes);
229
+ return noTruncResult(content, totalLines, totalBytes);
248
230
  }
249
231
 
250
232
  let includedLines = 0;
@@ -283,8 +265,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
283
265
  outputBytes: 0,
284
266
  lastLinePartial: false,
285
267
  firstLineExceedsLimit: true,
286
- maxLines,
287
- maxBytes,
288
268
  };
289
269
  }
290
270
  break;
@@ -307,8 +287,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
307
287
  outputBytes: 0,
308
288
  lastLinePartial: false,
309
289
  firstLineExceedsLimit: true,
310
- maxLines,
311
- maxBytes,
312
290
  };
313
291
  }
314
292
  break;
@@ -335,8 +313,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
335
313
  outputBytes: bytesUsed,
336
314
  lastLinePartial: false,
337
315
  firstLineExceedsLimit: false,
338
- maxLines,
339
- maxBytes,
340
316
  };
341
317
  }
342
318
 
@@ -354,7 +330,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
354
330
  const totalLines = countNewlines(content) + 1;
355
331
 
356
332
  if (totalLines <= maxLines && totalBytes <= maxBytes) {
357
- return noTruncResult(content, totalLines, totalBytes, maxLines, maxBytes);
333
+ return noTruncResult(content, totalLines, totalBytes);
358
334
  }
359
335
 
360
336
  let includedLines = 0;
@@ -396,8 +372,6 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
396
372
  outputBytes: tail.bytes,
397
373
  lastLinePartial: true,
398
374
  firstLineExceedsLimit: false,
399
- maxLines,
400
- maxBytes,
401
375
  };
402
376
  }
403
377
  break;
@@ -420,8 +394,6 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
420
394
  outputBytes: tail.bytes,
421
395
  lastLinePartial: true,
422
396
  firstLineExceedsLimit: false,
423
- maxLines,
424
- maxBytes,
425
397
  };
426
398
  }
427
399
  break;
@@ -447,8 +419,6 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
447
419
  outputBytes: bytesUsed,
448
420
  lastLinePartial: false,
449
421
  firstLineExceedsLimit: false,
450
- maxLines,
451
- maxBytes,
452
422
  };
453
423
  }
454
424
 
@@ -693,7 +663,7 @@ export function formatTailTruncationNotice(
693
663
  if (!truncation.truncated) return "";
694
664
 
695
665
  const { fullOutputPath, originalContent, suffix = "" } = options;
696
- const startLine = truncation.totalLines - truncation.outputLines + 1;
666
+ const startLine = truncation.totalLines - (truncation.outputLines ?? truncation.totalLines) + 1;
697
667
  const endLine = truncation.totalLines;
698
668
  const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : "";
699
669
 
@@ -705,11 +675,9 @@ export function formatTailTruncationNotice(
705
675
  const lastLine = lastNl === -1 ? originalContent : originalContent.substring(lastNl + 1);
706
676
  lastLineSizePart = ` (line is ${formatBytes(Buffer.byteLength(lastLine, "utf-8"))})`;
707
677
  }
708
- notice = `[Showing last ${formatBytes(truncation.outputBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
709
- } else if (truncation.truncatedBy === "lines") {
710
- notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
678
+ notice = `[Showing last ${formatBytes(truncation.outputBytes ?? truncation.totalBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
711
679
  } else {
712
- notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatBytes(truncation.maxBytes)} limit)${fullOutputPart}${suffix}]`;
680
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
713
681
  }
714
682
 
715
683
  return `\n\n${notice}`;
@@ -727,13 +695,8 @@ export function formatHeadTruncationNotice(
727
695
 
728
696
  const startLineDisplay = options.startLine ?? 1;
729
697
  const totalFileLines = options.totalFileLines ?? truncation.totalLines;
730
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
698
+ const endLineDisplay = startLineDisplay + (truncation.outputLines ?? truncation.totalLines) - 1;
731
699
  const nextOffset = endLineDisplay + 1;
732
-
733
- const notice =
734
- truncation.truncatedBy === "lines"
735
- ? `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`
736
- : `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatBytes(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue]`;
737
-
700
+ const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
738
701
  return `\n\n${notice}`;
739
702
  }
@@ -465,6 +465,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
465
465
  runtime.ctx.editor.setText("");
466
466
  },
467
467
  },
468
+ {
469
+ name: "btw",
470
+ description: "Ask an ephemeral side question using the current session context",
471
+ inlineHint: "<question>",
472
+ allowArgs: true,
473
+ handle: async (command, runtime) => {
474
+ const question = command.text.slice(`/${command.name}`.length).trim();
475
+ runtime.ctx.editor.setText("");
476
+ await runtime.ctx.handleBtwCommand(question);
477
+ },
478
+ },
468
479
  {
469
480
  name: "background",
470
481
  aliases: ["bg"],
@@ -447,22 +447,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
447
447
  skills = prepResult.value.skills;
448
448
  }
449
449
 
450
- const now = new Date();
451
- const date = now.toLocaleDateString("en-CA", {
452
- year: "numeric",
453
- month: "2-digit",
454
- day: "2-digit",
455
- });
456
- const dateTime = now.toLocaleString("en-US", {
457
- weekday: "long",
458
- year: "numeric",
459
- month: "long",
460
- day: "numeric",
461
- hour: "2-digit",
462
- minute: "2-digit",
463
- second: "2-digit",
464
- timeZoneName: "short",
465
- });
450
+ const date = new Date().toISOString().slice(0, 10);
451
+ const dateTime = date;
452
+ const promptCwd = resolvedCwd.replace(/\\/g, "/");
466
453
 
467
454
  // Build tool metadata for system prompt rendering
468
455
  // Priority: explicit list > tools map > defaults
@@ -504,7 +491,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
504
491
  rules: rules ?? [],
505
492
  date,
506
493
  dateTime,
507
- cwd: resolvedCwd,
494
+ cwd: promptCwd,
508
495
  intentTracing: !!intentField,
509
496
  intentField: intentField ?? "",
510
497
  eagerTasks,
@@ -53,7 +53,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
53
53
  name: "task",
54
54
  description: "General-purpose subagent with full capabilities for delegated multi-step tasks",
55
55
  spawns: "*",
56
- model: "default",
56
+ model: "pi/task",
57
57
  thinkingLevel: Effort.Medium,
58
58
  },
59
59
  template: taskMd,
@@ -1243,7 +1243,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1243
1243
  exitCode,
1244
1244
  output: truncatedOutput,
1245
1245
  stderr,
1246
- truncated,
1246
+ truncated: Boolean(truncated),
1247
1247
  durationMs: Date.now() - startTime,
1248
1248
  tokens: progress.tokens,
1249
1249
  modelOverride,
package/src/task/index.ts CHANGED
@@ -20,7 +20,7 @@ import type { Usage } from "@oh-my-pi/pi-ai";
20
20
  import { $env, Snowflake } from "@oh-my-pi/pi-utils";
21
21
  import { $ } from "bun";
22
22
  import type { ToolSession } from "..";
23
- import { isDefaultModelAlias } from "../config/model-resolver";
23
+ import { resolveAgentModelPatterns } from "../config/model-resolver";
24
24
  import { renderPromptTemplate } from "../config/prompt-templates";
25
25
  import type { Theme } from "../modes/theme/theme";
26
26
  import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
@@ -507,14 +507,15 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
507
507
  : agent;
508
508
 
509
509
  // Apply per-agent model override from settings (highest priority)
510
- const agentModelOverrides = this.session.settings.get("task.agentModelOverrides") as Record<string, string>;
510
+ const agentModelOverrides = this.session.settings.get("task.agentModelOverrides");
511
511
  const settingsModelOverride = agentModelOverrides[agentName];
512
- const effectiveAgentModel = isDefaultModelAlias(effectiveAgent.model) ? undefined : effectiveAgent.model;
513
- const modelOverride =
514
- settingsModelOverride ??
515
- effectiveAgentModel ??
516
- this.session.getActiveModelString?.() ??
517
- this.session.getModelString?.();
512
+ const modelOverride = resolveAgentModelPatterns({
513
+ settingsOverride: settingsModelOverride,
514
+ agentModel: effectiveAgent.model,
515
+ settings: this.session.settings,
516
+ activeModelPattern: this.session.getActiveModelString?.(),
517
+ fallbackModelPattern: this.session.getModelString?.(),
518
+ });
518
519
  const thinkingLevelOverride = effectiveAgent.thinkingLevel;
519
520
 
520
521
  // Output schema priority: agent frontmatter > params > inherited from parent session
@@ -526,6 +526,17 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
526
526
  const proxy = process.env.PUPPETEER_PROXY;
527
527
  if (proxy) {
528
528
  launchArgs.push(`--proxy-server=${proxy}`);
529
+ // Chrome (since v72) bypasses proxies for localhost by default. When PUPPETEER_PROXY_BYPASS_LOOPBACK
530
+ // is true, add <-loopback> so traffic to localhost reaches the proxy (e.g. for mitmdump/auth capture).
531
+ const bypassLoopback = process.env.PUPPETEER_PROXY_BYPASS_LOOPBACK?.toLowerCase();
532
+ if (
533
+ bypassLoopback === "true" ||
534
+ bypassLoopback === "1" ||
535
+ bypassLoopback === "yes" ||
536
+ bypassLoopback === "on"
537
+ ) {
538
+ launchArgs.push("--proxy-bypass-list=<-loopback>");
539
+ }
529
540
  }
530
541
  const ignoreCert = process.env.PUPPETEER_PROXY_IGNORE_CERT_ERRORS?.toLowerCase();
531
542
  if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") {
@@ -77,6 +77,12 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
77
77
  }
78
78
  }
79
79
 
80
+ if (!planExists) {
81
+ throw new ToolError(
82
+ `Plan file not found at ${state.planFilePath}. Write the finalized plan to ${state.planFilePath} before calling exit_plan_mode.`,
83
+ );
84
+ }
85
+
80
86
  return {
81
87
  content: [{ type: "text", text: "Plan ready for approval." }],
82
88
  details: {
@@ -1159,7 +1159,7 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
1159
1159
  finalUrl: result.finalUrl,
1160
1160
  contentType: result.contentType,
1161
1161
  method: result.method,
1162
- truncated: result.truncated || needsArtifact,
1162
+ truncated: Boolean(result.truncated || needsArtifact),
1163
1163
  notes: result.notes,
1164
1164
  };
1165
1165
 
@@ -14,7 +14,7 @@ import type {
14
14
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
15
15
  import { formatGroupedDiagnosticMessages } from "../lsp/utils";
16
16
  import type { Theme } from "../modes/theme/theme";
17
- import type { OutputSummary, TruncationResult } from "../session/streaming-output";
17
+ import { type OutputSummary, type TruncationResult, truncateTail } from "../session/streaming-output";
18
18
  import { formatBytes, wrapBrackets } from "./render-utils";
19
19
  import { renderError } from "./tool-errors";
20
20
 
@@ -117,26 +117,28 @@ export class OutputMetaBuilder {
117
117
  if (!result.truncated) return this;
118
118
 
119
119
  const { direction, startLine = 1, totalFileLines, artifactId } = options;
120
+ const outputLines = result.outputLines ?? result.totalLines;
121
+ const outputBytes = result.outputBytes ?? result.totalBytes;
122
+ const truncatedBy: "lines" | "bytes" = result.truncatedBy === "lines" ? "lines" : "bytes";
120
123
 
121
124
  let shownStart: number;
122
125
  let shownEnd: number;
123
126
 
124
127
  if (direction === "tail") {
125
- shownStart = result.totalLines - result.outputLines + 1;
128
+ shownStart = result.totalLines - outputLines + 1;
126
129
  shownEnd = result.totalLines;
127
130
  } else {
128
131
  shownStart = startLine;
129
- shownEnd = startLine + result.outputLines - 1;
132
+ shownEnd = startLine + outputLines - 1;
130
133
  }
131
134
 
132
135
  this.#meta.truncation = {
133
136
  direction,
134
- truncatedBy: result.truncatedBy!,
137
+ truncatedBy,
135
138
  totalLines: totalFileLines ?? result.totalLines,
136
139
  totalBytes: result.totalBytes,
137
- outputLines: result.outputLines,
138
- outputBytes: result.outputBytes,
139
- maxBytes: result.maxBytes,
140
+ outputLines,
141
+ outputBytes,
140
142
  shownRange: { start: shownStart, end: shownEnd },
141
143
  artifactId,
142
144
  nextOffset: direction === "head" ? shownEnd + 1 : undefined,
@@ -315,7 +317,7 @@ export function outputMeta(): OutputMetaBuilder {
315
317
  // =============================================================================
316
318
 
317
319
  export function formatFullOutputReference(artifactId: string): string {
318
- return `Full output: artifact://${artifactId}`;
320
+ return `Read artifact://${artifactId} for full output`;
319
321
  }
320
322
 
321
323
  export function formatTruncationMetaNotice(truncation: TruncationMeta): string {
@@ -432,6 +434,95 @@ function appendOutputNotice(
432
434
 
433
435
  const kUnwrappedExecute = Symbol("OutputMeta.UnwrappedExecute");
434
436
 
437
+ // =============================================================================
438
+ // Centralized artifact spill for large tool results
439
+ // =============================================================================
440
+
441
+ /** Text content above this byte threshold gets saved to an artifact. */
442
+ const RESULT_ARTIFACT_THRESHOLD = 50 * 1024; // 50KB
443
+
444
+ /** When spilling, keep this many bytes of tail in the result sent to the LLM. */
445
+ const RESULT_ARTIFACT_TAIL_BYTES = 20 * 1024; // 20KB
446
+
447
+ /** When spilling, keep at most this many lines of tail. */
448
+ const RESULT_ARTIFACT_TAIL_LINES = 500;
449
+
450
+ /**
451
+ * If the tool result text exceeds RESULT_ARTIFACT_THRESHOLD, save the full
452
+ * output as a session artifact and replace the content with a tail-truncated
453
+ * version plus an artifact reference. Skips when the tool already saved its
454
+ * own artifact (e.g. bash/python via OutputSink).
455
+ */
456
+ async function spillLargeResultToArtifact(
457
+ result: AgentToolResult,
458
+ toolName: string,
459
+ context: AgentToolContext | undefined,
460
+ ): Promise<AgentToolResult> {
461
+ const sessionManager = context?.sessionManager;
462
+ if (!sessionManager) return result;
463
+
464
+ // Skip if tool already saved an artifact
465
+ const existingMeta = (result.details as { meta?: OutputMeta } | undefined)?.meta;
466
+ if (existingMeta?.truncation?.artifactId) return result;
467
+
468
+ // Measure total text content
469
+ const textParts: string[] = [];
470
+ for (const block of result.content) {
471
+ if (block.type === "text" && block.text) {
472
+ textParts.push(block.text);
473
+ }
474
+ }
475
+ if (textParts.length === 0) return result;
476
+
477
+ const fullText = textParts.length === 1 ? textParts[0] : textParts.join("\n");
478
+ const totalBytes = Buffer.byteLength(fullText, "utf-8");
479
+ if (totalBytes <= RESULT_ARTIFACT_THRESHOLD) return result;
480
+
481
+ // Save full output as artifact
482
+ const artifactId = await sessionManager.saveArtifact(fullText, toolName);
483
+ if (!artifactId) return result;
484
+
485
+ // Truncate to tail
486
+ const truncated = truncateTail(fullText, {
487
+ maxBytes: RESULT_ARTIFACT_TAIL_BYTES,
488
+ maxLines: RESULT_ARTIFACT_TAIL_LINES,
489
+ });
490
+
491
+ // Replace text blocks with single tail-truncated block, keep images
492
+ const newContent: (TextContent | ImageContent)[] = [];
493
+ for (const block of result.content) {
494
+ if (block.type !== "text") {
495
+ newContent.push(block);
496
+ }
497
+ }
498
+ newContent.push({ type: "text", text: truncated.content });
499
+
500
+ // Build truncation meta
501
+ const outputLines = truncated.outputLines ?? truncated.totalLines;
502
+ const outputBytes = truncated.outputBytes ?? truncated.totalBytes;
503
+ const shownStart = truncated.totalLines - outputLines + 1;
504
+ const truncationMeta: TruncationMeta = {
505
+ direction: "tail",
506
+ truncatedBy: truncated.truncatedBy ?? "bytes",
507
+ totalLines: truncated.totalLines,
508
+ totalBytes: truncated.totalBytes,
509
+ outputLines,
510
+ outputBytes,
511
+ maxBytes: RESULT_ARTIFACT_TAIL_BYTES,
512
+ shownRange: { start: shownStart, end: truncated.totalLines },
513
+ artifactId,
514
+ };
515
+
516
+ const newMeta: OutputMeta = { ...(existingMeta ?? {}), truncation: truncationMeta };
517
+ const newDetails = { ...(result.details ?? {}), meta: newMeta };
518
+
519
+ return { ...result, content: newContent, details: newDetails };
520
+ }
521
+
522
+ // =============================================================================
523
+ // Tool wrapper
524
+ // =============================================================================
525
+
435
526
  async function wrappedExecute(
436
527
  this: AgentTool & { [kUnwrappedExecute]: AgentToolExecFn },
437
528
  toolCallId: string,
@@ -443,8 +534,12 @@ async function wrappedExecute(
443
534
  const originalExecute = this[kUnwrappedExecute];
444
535
 
445
536
  try {
537
+ let result = await originalExecute.call(this, toolCallId, params, signal, onUpdate, context);
538
+
539
+ // Spill large results to artifact, truncate to tail
540
+ result = await spillLargeResultToArtifact(result, this.name, context);
541
+
446
542
  // Append notices from meta
447
- const result = await originalExecute.call(this, toolCallId, params, signal, onUpdate, context);
448
543
  const meta = (result.details as { meta?: OutputMeta } | undefined)?.meta;
449
544
  if (meta) {
450
545
  return {
package/src/tools/read.ts CHANGED
@@ -16,6 +16,7 @@ import type { ToolSession } from "../sdk";
16
16
  import {
17
17
  DEFAULT_MAX_BYTES,
18
18
  DEFAULT_MAX_LINES,
19
+ noTruncResult,
19
20
  type TruncationResult,
20
21
  truncateHead,
21
22
  truncateHeadBytes,
@@ -592,15 +593,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
592
593
  const truncation: TruncationResult = {
593
594
  content: selectedContent,
594
595
  truncated: wasTruncated,
595
- truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : null,
596
+ truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
596
597
  totalLines: totalSelectedLines,
597
598
  totalBytes: totalSelectedBytes,
598
599
  outputLines: collectedLines.length,
599
600
  outputBytes: collectedBytes,
600
601
  lastLinePartial: false,
601
602
  firstLineExceedsLimit,
602
- maxLines: DEFAULT_MAX_LINES,
603
- maxBytes: DEFAULT_MAX_BYTES,
604
603
  };
605
604
 
606
605
  const shouldAddHashLines = displayMode.hashLines;
@@ -687,8 +686,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
687
686
  async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
688
687
  const internalRouter = this.session.internalRouter!;
689
688
 
690
- const displayMode = resolveFileDisplayMode(this.session);
691
-
692
689
  // Check if URL has query extraction (agent:// only)
693
690
  let parsed: URL;
694
691
  try {
@@ -716,7 +713,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
716
713
  return toolResult(details).text(resource.content).sourceInternal(url).done();
717
714
  }
718
715
 
719
- // Apply pagination similar to file reading
716
+ // Apply pagination similar to file reading.
720
717
  const allLines = resource.content.split("\n");
721
718
  const totalLines = allLines.length;
722
719
 
@@ -733,9 +730,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
733
730
  .done();
734
731
  }
735
732
 
733
+ const ignoreLimits = scheme === "skill";
736
734
  let selectedContent: string;
737
735
  let userLimitedLines: number | undefined;
738
- if (limit !== undefined) {
736
+ if (limit !== undefined && !ignoreLimits) {
739
737
  const endLine = Math.min(startLine + limit, allLines.length);
740
738
  selectedContent = allLines.slice(startLine, endLine).join("\n");
741
739
  userLimitedLines = endLine - startLine;
@@ -743,14 +741,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
743
741
  selectedContent = allLines.slice(startLine).join("\n");
744
742
  }
745
743
 
746
- // Apply truncation
747
- const truncation = truncateHead(selectedContent);
748
-
749
- const shouldAddHashLines = displayMode.hashLines;
750
- const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
751
- const formatText = (text: string, startNum: number): string => {
752
- return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
753
- };
744
+ const truncation: TruncationResult = ignoreLimits
745
+ ? noTruncResult(selectedContent)
746
+ : truncateHead(selectedContent);
754
747
 
755
748
  let outputText: string;
756
749
  let truncationInfo:
@@ -762,13 +755,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
762
755
  const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
763
756
  const snippet = truncateHeadBytes(firstLine, DEFAULT_MAX_BYTES);
764
757
 
765
- if (shouldAddHashLines) {
766
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
767
- firstLineBytes,
768
- )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
769
- } else {
770
- outputText = formatText(snippet.text, startLineDisplay);
771
- }
758
+ outputText = snippet.text;
772
759
  if (snippet.text.length === 0) {
773
760
  outputText = `[Line ${startLineDisplay} is ${formatBytes(
774
761
  firstLineBytes,
@@ -780,7 +767,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
780
767
  options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
781
768
  };
782
769
  } else if (truncation.truncated) {
783
- outputText = formatText(truncation.content, startLineDisplay);
770
+ outputText = truncation.content;
784
771
  details.truncation = truncation;
785
772
  truncationInfo = {
786
773
  result: truncation,
@@ -790,11 +777,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
790
777
  const remaining = allLines.length - (startLine + userLimitedLines);
791
778
  const nextOffset = startLine + userLimitedLines + 1;
792
779
 
793
- outputText = formatText(truncation.content, startLineDisplay);
780
+ outputText = truncation.content;
794
781
  outputText += `\n\n[${remaining} more lines in resource. Use offset=${nextOffset} to continue]`;
795
782
  details.truncation = truncation;
796
783
  } else {
797
- outputText = formatText(truncation.content, startLineDisplay);
784
+ outputText = truncation.content;
798
785
  }
799
786
 
800
787
  const resultBuilder = toolResult(details).text(outputText).sourceInternal(url);
@@ -924,7 +911,7 @@ export const readToolRenderer = {
924
911
  }
925
912
  if (truncation) {
926
913
  if (fallback?.firstLineExceedsLimit) {
927
- let warning = `First line exceeds ${formatBytes(fallback.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
914
+ let warning = `First line exceeds ${formatBytes(fallback.outputBytes ?? fallback.totalBytes)} limit`;
928
915
  if (truncation.artifactId) {
929
916
  warning += `. ${formatFullOutputReference(truncation.artifactId)}`;
930
917
  }