@oh-my-pi/pi-coding-agent 13.9.2 → 13.9.4

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 (53) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/examples/sdk/02-custom-model.ts +2 -1
  3. package/package.json +7 -7
  4. package/src/cli/args.ts +10 -6
  5. package/src/cli/list-models.ts +2 -2
  6. package/src/commands/launch.ts +3 -3
  7. package/src/config/model-registry.ts +136 -38
  8. package/src/config/model-resolver.ts +47 -21
  9. package/src/config/settings-schema.ts +56 -2
  10. package/src/discovery/helpers.ts +3 -3
  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 +32 -3
  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/status-line.ts +2 -6
  24. package/src/modes/components/thinking-selector.ts +7 -7
  25. package/src/modes/components/tree-selector.ts +3 -2
  26. package/src/modes/controllers/command-controller.ts +11 -26
  27. package/src/modes/controllers/event-controller.ts +16 -3
  28. package/src/modes/controllers/input-controller.ts +4 -2
  29. package/src/modes/controllers/selector-controller.ts +5 -4
  30. package/src/modes/interactive-mode.ts +2 -2
  31. package/src/modes/rpc/rpc-client.ts +5 -10
  32. package/src/modes/rpc/rpc-types.ts +5 -5
  33. package/src/modes/theme/theme.ts +8 -3
  34. package/src/priority.json +1 -0
  35. package/src/prompts/system/auto-handoff-threshold-focus.md +1 -0
  36. package/src/prompts/system/system-prompt.md +18 -2
  37. package/src/prompts/tools/hashline.md +139 -83
  38. package/src/sdk.ts +24 -16
  39. package/src/session/agent-session.ts +261 -118
  40. package/src/session/agent-storage.ts +14 -14
  41. package/src/session/compaction/compaction.ts +500 -13
  42. package/src/session/messages.ts +12 -1
  43. package/src/session/session-manager.ts +77 -19
  44. package/src/slash-commands/builtin-registry.ts +48 -0
  45. package/src/task/agents.ts +3 -2
  46. package/src/task/executor.ts +2 -2
  47. package/src/task/types.ts +2 -1
  48. package/src/thinking.ts +87 -0
  49. package/src/tools/browser.ts +15 -6
  50. package/src/tools/fetch.ts +118 -100
  51. package/src/tools/index.ts +2 -1
  52. package/src/web/kagi.ts +62 -7
  53. 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.)
@@ -227,7 +227,8 @@ function getPythonModeFromEnv(): PythonToolMode | null {
227
227
  export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
228
228
  const includeSubmitResult = session.requireSubmitResultTool === true;
229
229
  const enableLsp = session.enableLsp ?? true;
230
- const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined;
230
+ const requestedTools =
231
+ toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
231
232
  if (requestedTools && !requestedTools.includes("exit_plan_mode")) {
232
233
  requestedTools.push("exit_plan_mode");
233
234
  }
package/src/web/kagi.ts CHANGED
@@ -28,15 +28,23 @@ interface KagiRelatedSearchesObject {
28
28
 
29
29
  type KagiSearchObject = KagiSearchResultObject | KagiRelatedSearchesObject;
30
30
 
31
+ interface KagiErrorEntry {
32
+ code?: number;
33
+ msg?: string;
34
+ }
35
+
31
36
  interface KagiSearchResponse {
32
37
  meta: {
33
38
  id: string;
34
39
  };
35
40
  data: KagiSearchObject[];
36
- error?: Array<{
37
- code: number;
38
- msg: string;
39
- }>;
41
+ error?: KagiErrorEntry[];
42
+ }
43
+
44
+ interface KagiErrorResponse {
45
+ error?: string | KagiErrorEntry[];
46
+ message?: string;
47
+ detail?: string;
40
48
  }
41
49
 
42
50
  export class KagiApiError extends Error {
@@ -49,6 +57,54 @@ export class KagiApiError extends Error {
49
57
  }
50
58
  }
51
59
 
60
+ function extractKagiErrorMessage(payload: unknown): string | null {
61
+ if (!payload || typeof payload !== "object") return null;
62
+ const record = payload as Record<string, unknown>;
63
+
64
+ for (const value of [record.message, record.detail]) {
65
+ if (typeof value === "string" && value.trim().length > 0) {
66
+ return value.trim();
67
+ }
68
+ }
69
+
70
+ if (typeof record.error === "string" && record.error.trim().length > 0) {
71
+ return record.error.trim();
72
+ }
73
+
74
+ if (Array.isArray(record.error)) {
75
+ for (const entry of record.error) {
76
+ if (!entry || typeof entry !== "object") continue;
77
+ const message = (entry as Record<string, unknown>).msg;
78
+ if (typeof message === "string" && message.trim().length > 0) {
79
+ return message.trim();
80
+ }
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ function createKagiApiError(statusCode: number, detail?: string): KagiApiError {
88
+ return new KagiApiError(
89
+ detail ? `Kagi API error (${statusCode}): ${detail}` : `Kagi API error (${statusCode})`,
90
+ statusCode,
91
+ );
92
+ }
93
+
94
+ function parseKagiErrorResponse(statusCode: number, responseText: string): KagiApiError {
95
+ const trimmedResponseText = responseText.trim();
96
+ if (trimmedResponseText.length === 0) {
97
+ return createKagiApiError(statusCode);
98
+ }
99
+
100
+ try {
101
+ const payload = JSON.parse(trimmedResponseText) as KagiErrorResponse;
102
+ return createKagiApiError(statusCode, extractKagiErrorMessage(payload) ?? trimmedResponseText);
103
+ } catch {
104
+ return createKagiApiError(statusCode, trimmedResponseText);
105
+ }
106
+ }
107
+
52
108
  export interface KagiSummarizeOptions {
53
109
  engine?: string;
54
110
  summaryType?: string;
@@ -127,14 +183,13 @@ export async function searchWithKagi(query: string, options: KagiSearchOptions =
127
183
  signal: options.signal,
128
184
  });
129
185
  if (!response.ok) {
130
- const errorText = await response.text();
131
- throw new KagiApiError(`Kagi API error (${response.status}): ${errorText}`, response.status);
186
+ throw parseKagiErrorResponse(response.status, await response.text());
132
187
  }
133
188
 
134
189
  const payload = (await response.json()) as KagiSearchResponse;
135
190
  if (payload.error && payload.error.length > 0) {
136
191
  const firstError = payload.error[0];
137
- throw new KagiApiError(`Kagi API error: ${firstError.msg}`, firstError.code);
192
+ throw createKagiApiError(firstError.code ?? response.status, extractKagiErrorMessage(payload) ?? undefined);
138
193
  }
139
194
 
140
195
  const sources: KagiSearchSource[] = [];
@@ -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 */