@oh-my-pi/pi-coding-agent 13.9.2 → 13.9.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/examples/sdk/02-custom-model.ts +2 -1
  3. package/package.json +7 -7
  4. package/src/cli/args.ts +6 -5
  5. package/src/cli/list-models.ts +2 -2
  6. package/src/commands/launch.ts +3 -3
  7. package/src/config/model-registry.ts +85 -39
  8. package/src/config/model-resolver.ts +47 -21
  9. package/src/config/settings-schema.ts +56 -2
  10. package/src/discovery/helpers.ts +2 -2
  11. package/src/extensibility/custom-tools/types.ts +2 -0
  12. package/src/extensibility/extensions/loader.ts +3 -2
  13. package/src/extensibility/extensions/types.ts +10 -7
  14. package/src/extensibility/hooks/types.ts +2 -0
  15. package/src/main.ts +5 -22
  16. package/src/memories/index.ts +7 -3
  17. package/src/modes/components/footer.ts +10 -8
  18. package/src/modes/components/model-selector.ts +33 -38
  19. package/src/modes/components/settings-defs.ts +31 -2
  20. package/src/modes/components/settings-selector.ts +16 -5
  21. package/src/modes/components/status-line/context-thresholds.ts +68 -0
  22. package/src/modes/components/status-line/segments.ts +11 -12
  23. package/src/modes/components/thinking-selector.ts +7 -7
  24. package/src/modes/components/tree-selector.ts +3 -2
  25. package/src/modes/controllers/command-controller.ts +11 -26
  26. package/src/modes/controllers/event-controller.ts +16 -3
  27. package/src/modes/controllers/input-controller.ts +4 -2
  28. package/src/modes/controllers/selector-controller.ts +5 -4
  29. package/src/modes/interactive-mode.ts +2 -2
  30. package/src/modes/rpc/rpc-client.ts +5 -10
  31. package/src/modes/rpc/rpc-types.ts +5 -5
  32. package/src/modes/theme/theme.ts +8 -3
  33. package/src/priority.json +1 -0
  34. package/src/prompts/system/auto-handoff-threshold-focus.md +1 -0
  35. package/src/prompts/system/system-prompt.md +18 -2
  36. package/src/prompts/tools/hashline.md +139 -83
  37. package/src/sdk.ts +22 -14
  38. package/src/session/agent-session.ts +259 -117
  39. package/src/session/agent-storage.ts +14 -14
  40. package/src/session/compaction/compaction.ts +500 -13
  41. package/src/session/messages.ts +12 -1
  42. package/src/session/session-manager.ts +77 -19
  43. package/src/slash-commands/builtin-registry.ts +48 -0
  44. package/src/task/agents.ts +3 -2
  45. package/src/task/executor.ts +2 -2
  46. package/src/task/types.ts +2 -1
  47. package/src/thinking.ts +87 -0
  48. package/src/tools/browser.ts +15 -6
  49. package/src/tools/fetch.ts +118 -100
  50. package/src/web/search/providers/exa.ts +74 -3
@@ -74,6 +74,13 @@ const CONVERTIBLE_EXTENSIONS = new Set([
74
74
  ".ogg",
75
75
  ]);
76
76
 
77
+ const IMAGE_MIME_BY_EXTENSION = new Map<string, string>([
78
+ [".png", "image/png"],
79
+ [".jpg", "image/jpeg"],
80
+ [".jpeg", "image/jpeg"],
81
+ [".gif", "image/gif"],
82
+ [".webp", "image/webp"],
83
+ ]);
77
84
  const SUPPORTED_INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
78
85
  const MAX_INLINE_IMAGE_SOURCE_BYTES = 20 * 1024 * 1024;
79
86
  const MAX_INLINE_IMAGE_OUTPUT_BYTES = 0.75 * 1024 * 1024;
@@ -151,8 +158,12 @@ function isConvertible(mime: string, extensionHint: string): boolean {
151
158
  return false;
152
159
  }
153
160
 
154
- function resolveImageMimeType(mime: string): string | null {
155
- return mime.startsWith("image/") ? mime : null;
161
+ function resolveImageMimeType(mime: string, extensionHint: string): string | null {
162
+ if (mime.startsWith("image/")) return mime;
163
+ const shouldUseExtensionHint =
164
+ mime.length === 0 || mime === "application/octet-stream" || mime === "binary/octet-stream" || mime === "unknown";
165
+ if (!shouldUseExtensionHint) return null;
166
+ return IMAGE_MIME_BY_EXTENSION.get(extensionHint) ?? null;
156
167
  }
157
168
 
158
169
  function isInlineImageMimeTypeSupported(mimeType: string): boolean {
@@ -653,120 +664,127 @@ async function renderUrl(
653
664
  const mime = normalizeMime(response.contentType);
654
665
  const extHint = getExtensionHint(finalUrl);
655
666
 
656
- const imageMimeType = resolveImageMimeType(mime);
657
- const canInlineImage = Boolean(imageMimeType && isInlineImageMimeTypeSupported(imageMimeType));
667
+ const imageMimeType = resolveImageMimeType(mime, extHint);
658
668
  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}`);
669
+ if (imageMimeType) {
670
+ if (!isInlineImageMimeTypeSupported(imageMimeType)) {
671
+ notes.push(
672
+ `Image MIME type ${imageMimeType} is unsupported for inline model serialization; returning text metadata only`,
673
+ );
674
+ const shouldTryConvertibleFallback = isConvertible(mime, extHint);
675
+ if (shouldTryConvertibleFallback) {
676
+ notes.push("Attempting binary conversion fallback for unsupported image MIME type");
680
677
  } else {
681
- notes.push("markitdown conversion failed");
678
+ notes.push("Falling back to textual rendering from initial response");
682
679
  }
680
+ skipConvertibleBinaryRetry = !shouldTryConvertibleFallback;
681
+ } else {
682
+ const binary = await fetchBinary(finalUrl, timeout, signal);
683
+ if (binary.ok) {
684
+ notes.push("Fetched image binary");
685
+ const conversionExtension = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
686
+ let convertedText: string | null = null;
687
+ const converted = await convertWithMarkitdown(binary.buffer, conversionExtension, timeout, signal);
688
+ if (converted.ok) {
689
+ if (converted.content.trim().length > 50) {
690
+ notes.push("Converted with markitdown");
691
+ convertedText = converted.content;
692
+ } else {
693
+ notes.push("markitdown conversion produced no usable output");
694
+ }
695
+ } else if (converted.error) {
696
+ notes.push(`markitdown conversion failed: ${converted.error}`);
697
+ } else {
698
+ notes.push("markitdown conversion failed");
699
+ }
683
700
 
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
- }
701
+ if (binary.buffer.byteLength > MAX_INLINE_IMAGE_SOURCE_BYTES) {
702
+ notes.push(
703
+ `Image exceeds inline source limit (${binary.buffer.byteLength} bytes > ${MAX_INLINE_IMAGE_SOURCE_BYTES} bytes)`,
704
+ );
705
+ const output = finalizeOutput(
706
+ convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
707
+ );
708
+ return {
709
+ url,
710
+ finalUrl,
711
+ contentType: imageMimeType,
712
+ method: convertedText ? "markitdown" : "image-too-large",
713
+ content: output.content,
714
+ fetchedAt,
715
+ truncated: output.truncated,
716
+ notes,
717
+ };
718
+ }
702
719
 
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.`,
720
+ const resized = await resizeImage(
721
+ { type: "image", data: binary.buffer.toBase64(), mimeType: imageMimeType },
722
+ { maxBytes: MAX_INLINE_IMAGE_OUTPUT_BYTES },
733
723
  );
724
+ const isDecodedImage =
725
+ resized.originalWidth > 0 && resized.originalHeight > 0 && resized.width > 0 && resized.height > 0;
726
+ if (!isDecodedImage) {
727
+ notes.push(`Fetched payload could not be decoded as ${imageMimeType}; returning text metadata only`);
728
+ const output = finalizeOutput(
729
+ convertedText ??
730
+ rawContent ??
731
+ `Fetched payload was labeled ${imageMimeType}, but bytes were not a valid image.`,
732
+ );
733
+ return {
734
+ url,
735
+ finalUrl,
736
+ contentType: imageMimeType,
737
+ method: convertedText ? "markitdown" : "image-invalid",
738
+ content: output.content,
739
+ fetchedAt,
740
+ truncated: output.truncated,
741
+ notes,
742
+ };
743
+ }
744
+ if (resized.buffer.length > MAX_INLINE_IMAGE_OUTPUT_BYTES) {
745
+ notes.push(
746
+ `Image exceeds inline output limit after resize (${resized.buffer.length} bytes > ${MAX_INLINE_IMAGE_OUTPUT_BYTES} bytes)`,
747
+ );
748
+ const output = finalizeOutput(
749
+ convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
750
+ );
751
+ return {
752
+ url,
753
+ finalUrl,
754
+ contentType: imageMimeType,
755
+ method: convertedText ? "markitdown" : "image-too-large",
756
+ content: output.content,
757
+ fetchedAt,
758
+ truncated: output.truncated,
759
+ notes,
760
+ };
761
+ }
762
+
763
+ const dimensionNote = formatDimensionNote(resized);
764
+ let imageSummary = convertedText ?? `Fetched image content (${resized.mimeType}).`;
765
+ if (dimensionNote) {
766
+ imageSummary += `\n${dimensionNote}`;
767
+ }
768
+ const output = finalizeOutput(imageSummary);
734
769
  return {
735
770
  url,
736
771
  finalUrl,
737
- contentType: imageMimeType,
738
- method: convertedText ? "markitdown" : "image-too-large",
772
+ contentType: resized.mimeType,
773
+ method: "image",
739
774
  content: output.content,
740
775
  fetchedAt,
741
776
  truncated: output.truncated,
742
777
  notes,
778
+ image: {
779
+ data: resized.data,
780
+ mimeType: resized.mimeType,
781
+ },
743
782
  };
744
783
  }
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
- };
784
+ notes.push(binary.error ? `Binary fetch failed: ${binary.error}` : "Binary fetch failed");
785
+ notes.push("Falling back to textual rendering from initial response");
786
+ skipConvertibleBinaryRetry = true;
766
787
  }
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
788
  }
771
789
 
772
790
  // Step 3: Handle convertible binary files (PDF, DOCX, etc.)
@@ -48,6 +48,72 @@ interface ExaSearchResponse {
48
48
  searchTime?: number;
49
49
  }
50
50
 
51
+ function asRecord(value: unknown): Record<string, unknown> | null {
52
+ if (typeof value !== "object" || value === null) return null;
53
+ return value as Record<string, unknown>;
54
+ }
55
+
56
+ function parseOptionalField(section: string, label: string): string | null | undefined {
57
+ const regex = new RegExp(`(?:^|\\n)${label}:\\s*([^\\n]*)`);
58
+ const match = section.match(regex);
59
+ if (!match) return undefined;
60
+ const value = match[1].trim();
61
+ return value.length > 0 ? value : null;
62
+ }
63
+
64
+ function parseTextField(section: string): string | null | undefined {
65
+ const match = section.match(/(?:^|\n)Text:\s*([\s\S]*)$/);
66
+ if (!match) return undefined;
67
+ const value = match[1].trim();
68
+ return value.length > 0 ? value : null;
69
+ }
70
+
71
+ function parseExaMcpTextPayload(payload: unknown): ExaSearchResponse | null {
72
+ const root = asRecord(payload);
73
+ if (!root) return null;
74
+
75
+ const content = root.content;
76
+ if (!Array.isArray(content)) return null;
77
+
78
+ const textBlocks = content
79
+ .map(item => {
80
+ const part = asRecord(item);
81
+ const text = typeof part?.text === "string" ? part.text : "";
82
+ return text.replace(/\r\n?/g, "\n").trim();
83
+ })
84
+ .filter(text => text.length > 0);
85
+
86
+ if (textBlocks.length === 0) return null;
87
+
88
+ const sections = textBlocks
89
+ .join("\n\n")
90
+ .split(/\n{2,}(?=Title:\s*[^\n]*(?:\n(?:URL|Author|Published Date|Text):))/)
91
+ .map(section => section.trim())
92
+ .filter(section => section.startsWith("Title:"));
93
+
94
+ const results: ExaSearchResult[] = [];
95
+ for (const section of sections) {
96
+ const title = parseOptionalField(section, "Title");
97
+ const url = parseOptionalField(section, "URL");
98
+ const author = parseOptionalField(section, "Author");
99
+ const publishedDate = parseOptionalField(section, "Published Date");
100
+ const text = parseTextField(section);
101
+
102
+ if (!title && !url && !text) continue;
103
+
104
+ results.push({
105
+ title: title ?? undefined,
106
+ url: url ?? undefined,
107
+ author: author ?? undefined,
108
+ publishedDate: publishedDate ?? undefined,
109
+ text: text ?? undefined,
110
+ });
111
+ }
112
+
113
+ if (results.length === 0) return null;
114
+ return { results };
115
+ }
116
+
51
117
  export function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType {
52
118
  if (!type) return "auto";
53
119
  if (type === "keyword") return "fast";
@@ -124,11 +190,16 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
124
190
 
125
191
  async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchResponse> {
126
192
  const response = await callExaTool("web_search_exa", { ...params }, findApiKey());
127
- if (!isSearchResponse(response)) {
128
- throw new Error("Exa MCP search returned unexpected response shape.");
193
+ if (isSearchResponse(response)) {
194
+ return response as ExaSearchResponse;
195
+ }
196
+
197
+ const parsed = parseExaMcpTextPayload(response);
198
+ if (parsed) {
199
+ return parsed;
129
200
  }
130
201
 
131
- return response as ExaSearchResponse;
202
+ throw new Error("Exa MCP search returned unexpected response shape.");
132
203
  }
133
204
 
134
205
  /** Execute Exa web search */