@oh-my-pi/pi-coding-agent 13.7.6 → 13.8.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.
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
+ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
3
4
  import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
4
5
  import { type Component, Text } from "@oh-my-pi/pi-tui";
5
6
  import { ptree, truncate } from "@oh-my-pi/pi-utils";
@@ -12,6 +13,7 @@ import fetchDescription from "../prompts/tools/fetch.md" with { type: "text" };
12
13
  import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
13
14
  import { renderStatusLine } from "../tui";
14
15
  import { CachedOutputBlock } from "../tui/output-block";
16
+ import { formatDimensionNote, resizeImage } from "../utils/image-resize";
15
17
  import { ensureTool } from "../utils/tools-manager";
16
18
  import { summarizeUrlWithKagi } from "../web/kagi";
17
19
  import { specialHandlers } from "../web/scrapers";
@@ -72,6 +74,10 @@ const CONVERTIBLE_EXTENSIONS = new Set([
72
74
  ".ogg",
73
75
  ]);
74
76
 
77
+ const SUPPORTED_INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
78
+ const MAX_INLINE_IMAGE_SOURCE_BYTES = 20 * 1024 * 1024;
79
+ const MAX_INLINE_IMAGE_OUTPUT_BYTES = 0.75 * 1024 * 1024;
80
+
75
81
  // =============================================================================
76
82
  // Utilities
77
83
  // =============================================================================
@@ -145,6 +151,14 @@ function isConvertible(mime: string, extensionHint: string): boolean {
145
151
  return false;
146
152
  }
147
153
 
154
+ function resolveImageMimeType(mime: string): string | null {
155
+ return mime.startsWith("image/") ? mime : null;
156
+ }
157
+
158
+ function isInlineImageMimeTypeSupported(mimeType: string): boolean {
159
+ return SUPPORTED_INLINE_IMAGE_MIME_TYPES.has(mimeType);
160
+ }
161
+
148
162
  /**
149
163
  * Check if content looks like HTML
150
164
  */
@@ -542,6 +556,15 @@ function formatJson(content: string): string {
542
556
  }
543
557
  }
544
558
 
559
+ interface FetchImagePayload {
560
+ data: string;
561
+ mimeType: string;
562
+ }
563
+
564
+ type FetchRenderResult = RenderResult & {
565
+ image?: FetchImagePayload;
566
+ };
567
+
545
568
  // =============================================================================
546
569
  // Unified Special Handler Dispatch
547
570
  // =============================================================================
@@ -549,7 +572,11 @@ function formatJson(content: string): string {
549
572
  /**
550
573
  * Try all special handlers
551
574
  */
552
- async function handleSpecialUrls(url: string, timeout: number, signal?: AbortSignal): Promise<RenderResult | null> {
575
+ async function handleSpecialUrls(
576
+ url: string,
577
+ timeout: number,
578
+ signal?: AbortSignal,
579
+ ): Promise<FetchRenderResult | null> {
553
580
  for (const handler of specialHandlers) {
554
581
  if (signal?.aborted) {
555
582
  throw new ToolAbortError();
@@ -573,7 +600,7 @@ async function renderUrl(
573
600
  raw: boolean,
574
601
  useKagiSummarizer: boolean,
575
602
  signal?: AbortSignal,
576
- ): Promise<RenderResult> {
603
+ ): Promise<FetchRenderResult> {
577
604
  const notes: string[] = [];
578
605
  const fetchedAt = new Date().toISOString();
579
606
  if (signal?.aborted) {
@@ -626,8 +653,124 @@ async function renderUrl(
626
653
  const mime = normalizeMime(response.contentType);
627
654
  const extHint = getExtensionHint(finalUrl);
628
655
 
656
+ const imageMimeType = resolveImageMimeType(mime);
657
+ const canInlineImage = Boolean(imageMimeType && isInlineImageMimeTypeSupported(imageMimeType));
658
+ let skipConvertibleBinaryRetry = false;
659
+ if (imageMimeType && !canInlineImage) {
660
+ notes.push(
661
+ `Image MIME type ${imageMimeType} is unsupported for inline model serialization; falling back to textual rendering`,
662
+ );
663
+ }
664
+ if (canInlineImage && imageMimeType) {
665
+ const binary = await fetchBinary(finalUrl, timeout, signal);
666
+ if (binary.ok) {
667
+ notes.push("Fetched image binary");
668
+ const conversionExtension = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
669
+ let convertedText: string | null = null;
670
+ const converted = await convertWithMarkitdown(binary.buffer, conversionExtension, timeout, signal);
671
+ if (converted.ok) {
672
+ if (converted.content.trim().length > 50) {
673
+ notes.push("Converted with markitdown");
674
+ convertedText = converted.content;
675
+ } else {
676
+ notes.push("markitdown conversion produced no usable output");
677
+ }
678
+ } else if (converted.error) {
679
+ notes.push(`markitdown conversion failed: ${converted.error}`);
680
+ } else {
681
+ notes.push("markitdown conversion failed");
682
+ }
683
+
684
+ if (binary.buffer.byteLength > MAX_INLINE_IMAGE_SOURCE_BYTES) {
685
+ notes.push(
686
+ `Image exceeds inline source limit (${binary.buffer.byteLength} bytes > ${MAX_INLINE_IMAGE_SOURCE_BYTES} bytes)`,
687
+ );
688
+ const output = finalizeOutput(
689
+ convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
690
+ );
691
+ return {
692
+ url,
693
+ finalUrl,
694
+ contentType: imageMimeType,
695
+ method: convertedText ? "markitdown" : "image-too-large",
696
+ content: output.content,
697
+ fetchedAt,
698
+ truncated: output.truncated,
699
+ notes,
700
+ };
701
+ }
702
+
703
+ const resized = await resizeImage(
704
+ { type: "image", data: binary.buffer.toBase64(), mimeType: imageMimeType },
705
+ { maxBytes: MAX_INLINE_IMAGE_OUTPUT_BYTES },
706
+ );
707
+ const isDecodedImage =
708
+ resized.originalWidth > 0 && resized.originalHeight > 0 && resized.width > 0 && resized.height > 0;
709
+ if (!isDecodedImage) {
710
+ notes.push(`Fetched payload could not be decoded as ${imageMimeType}; returning text metadata only`);
711
+ const output = finalizeOutput(
712
+ convertedText ??
713
+ rawContent ??
714
+ `Fetched payload was labeled ${imageMimeType}, but bytes were not a valid image.`,
715
+ );
716
+ return {
717
+ url,
718
+ finalUrl,
719
+ contentType: imageMimeType,
720
+ method: convertedText ? "markitdown" : "image-invalid",
721
+ content: output.content,
722
+ fetchedAt,
723
+ truncated: output.truncated,
724
+ notes,
725
+ };
726
+ }
727
+ if (resized.buffer.length > MAX_INLINE_IMAGE_OUTPUT_BYTES) {
728
+ notes.push(
729
+ `Image exceeds inline output limit after resize (${resized.buffer.length} bytes > ${MAX_INLINE_IMAGE_OUTPUT_BYTES} bytes)`,
730
+ );
731
+ const output = finalizeOutput(
732
+ convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
733
+ );
734
+ return {
735
+ url,
736
+ finalUrl,
737
+ contentType: imageMimeType,
738
+ method: convertedText ? "markitdown" : "image-too-large",
739
+ content: output.content,
740
+ fetchedAt,
741
+ truncated: output.truncated,
742
+ notes,
743
+ };
744
+ }
745
+
746
+ const dimensionNote = formatDimensionNote(resized);
747
+ let imageSummary = convertedText ?? `Fetched image content (${resized.mimeType}).`;
748
+ if (dimensionNote) {
749
+ imageSummary += `\n${dimensionNote}`;
750
+ }
751
+ const output = finalizeOutput(imageSummary);
752
+ return {
753
+ url,
754
+ finalUrl,
755
+ contentType: resized.mimeType,
756
+ method: "image",
757
+ content: output.content,
758
+ fetchedAt,
759
+ truncated: output.truncated,
760
+ notes,
761
+ image: {
762
+ data: resized.data,
763
+ mimeType: resized.mimeType,
764
+ },
765
+ };
766
+ }
767
+ notes.push(binary.error ? `Binary fetch failed: ${binary.error}` : "Binary fetch failed");
768
+ notes.push("Falling back to textual rendering from initial response");
769
+ skipConvertibleBinaryRetry = true;
770
+ }
771
+
629
772
  // Step 3: Handle convertible binary files (PDF, DOCX, etc.)
630
- if (isConvertible(mime, extHint)) {
773
+ if (!skipConvertibleBinaryRetry && isConvertible(mime, extHint)) {
631
774
  const binary = await fetchBinary(finalUrl, timeout, signal);
632
775
  if (binary.ok) {
633
776
  const ext = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
@@ -976,7 +1119,12 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
976
1119
  notes: result.notes,
977
1120
  };
978
1121
 
979
- const resultBuilder = toolResult(details).text(output).sourceUrl(result.finalUrl);
1122
+ const contentBlocks: Array<TextContent | ImageContent> = [{ type: "text", text: output }];
1123
+ if (result.image) {
1124
+ contentBlocks.push({ type: "image", data: result.image.data, mimeType: result.image.mimeType });
1125
+ }
1126
+
1127
+ const resultBuilder = toolResult(details).content(contentBlocks).sourceUrl(result.finalUrl);
980
1128
  if (needsArtifact) {
981
1129
  resultBuilder.truncation(truncation, { direction: "head", artifactId });
982
1130
  } else if (result.truncated) {