@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.
- package/CHANGELOG.md +53 -0
- package/examples/sdk/02-custom-model.ts +2 -1
- package/package.json +7 -7
- package/src/cli/args.ts +6 -5
- package/src/cli/list-models.ts +2 -2
- package/src/commands/launch.ts +3 -3
- package/src/config/model-registry.ts +85 -39
- package/src/config/model-resolver.ts +47 -21
- package/src/config/settings-schema.ts +56 -2
- package/src/discovery/helpers.ts +2 -2
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/extensions/loader.ts +3 -2
- package/src/extensibility/extensions/types.ts +10 -7
- package/src/extensibility/hooks/types.ts +2 -0
- package/src/main.ts +5 -22
- package/src/memories/index.ts +7 -3
- package/src/modes/components/footer.ts +10 -8
- package/src/modes/components/model-selector.ts +33 -38
- package/src/modes/components/settings-defs.ts +31 -2
- package/src/modes/components/settings-selector.ts +16 -5
- package/src/modes/components/status-line/context-thresholds.ts +68 -0
- package/src/modes/components/status-line/segments.ts +11 -12
- package/src/modes/components/thinking-selector.ts +7 -7
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/controllers/command-controller.ts +11 -26
- package/src/modes/controllers/event-controller.ts +16 -3
- package/src/modes/controllers/input-controller.ts +4 -2
- package/src/modes/controllers/selector-controller.ts +5 -4
- package/src/modes/interactive-mode.ts +2 -2
- package/src/modes/rpc/rpc-client.ts +5 -10
- package/src/modes/rpc/rpc-types.ts +5 -5
- package/src/modes/theme/theme.ts +8 -3
- package/src/priority.json +1 -0
- package/src/prompts/system/auto-handoff-threshold-focus.md +1 -0
- package/src/prompts/system/system-prompt.md +18 -2
- package/src/prompts/tools/hashline.md +139 -83
- package/src/sdk.ts +22 -14
- package/src/session/agent-session.ts +259 -117
- package/src/session/agent-storage.ts +14 -14
- package/src/session/compaction/compaction.ts +500 -13
- package/src/session/messages.ts +12 -1
- package/src/session/session-manager.ts +77 -19
- package/src/slash-commands/builtin-registry.ts +48 -0
- package/src/task/agents.ts +3 -2
- package/src/task/executor.ts +2 -2
- package/src/task/types.ts +2 -1
- package/src/thinking.ts +87 -0
- package/src/tools/browser.ts +15 -6
- package/src/tools/fetch.ts +118 -100
- package/src/web/search/providers/exa.ts +74 -3
package/src/tools/fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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("
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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:
|
|
738
|
-
method:
|
|
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
|
-
|
|
747
|
-
|
|
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 (
|
|
128
|
-
|
|
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
|
-
|
|
202
|
+
throw new Error("Exa MCP search returned unexpected response shape.");
|
|
132
203
|
}
|
|
133
204
|
|
|
134
205
|
/** Execute Exa web search */
|