@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.2
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 +142 -1
- package/dist/types/cli/dry-balance-cli.d.ts +15 -1
- package/dist/types/cli/startup-cwd.d.ts +2 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/commit/analysis/conventional.d.ts +2 -2
- package/dist/types/commit/analysis/summary.d.ts +2 -2
- package/dist/types/commit/changelog/generate.d.ts +2 -2
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/index.d.ts +3 -3
- package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
- package/dist/types/commit/model-selection.d.ts +10 -4
- package/dist/types/config/api-key-resolver.d.ts +34 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-provider-priority.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +17 -1
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/config/settings.d.ts +7 -2
- package/dist/types/dap/config.d.ts +14 -1
- package/dist/types/dap/types.d.ts +10 -0
- package/dist/types/debug/report-bundle.d.ts +3 -0
- package/dist/types/edit/file-snapshot-store.d.ts +18 -10
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -1
- package/dist/types/lsp/client.d.ts +10 -0
- package/dist/types/lsp/utils.d.ts +3 -2
- package/dist/types/main.d.ts +3 -9
- package/dist/types/mcp/tool-bridge.d.ts +2 -0
- package/dist/types/modes/components/chat-block.d.ts +64 -0
- package/dist/types/modes/components/custom-editor.d.ts +4 -1
- package/dist/types/modes/components/overlay-box.d.ts +17 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
- package/dist/types/modes/components/plan-toc.d.ts +41 -0
- package/dist/types/modes/components/read-tool-group.d.ts +2 -0
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +11 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +17 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
- package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +16 -5
- package/dist/types/modes/magic-keywords.d.ts +1 -1
- package/dist/types/modes/markdown-prose.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +21 -5
- package/dist/types/modes/utils/copy-targets.d.ts +21 -1
- package/dist/types/modes/workflow.d.ts +3 -3
- package/dist/types/plan-mode/approved-plan.d.ts +27 -8
- package/dist/types/plan-mode/plan-protection.d.ts +4 -4
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +21 -0
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/messages.d.ts +12 -0
- package/dist/types/session/session-manager.d.ts +8 -3
- package/dist/types/slash-commands/types.d.ts +4 -6
- package/dist/types/task/executor.d.ts +17 -0
- package/dist/types/task/index.d.ts +1 -0
- package/dist/types/task/render.d.ts +3 -2
- package/dist/types/tools/archive-reader.d.ts +5 -0
- package/dist/types/tools/ast-edit.d.ts +3 -0
- package/dist/types/tools/ast-grep.d.ts +3 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/eval.d.ts +8 -0
- package/dist/types/tools/find.d.ts +8 -4
- package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
- package/dist/types/tools/github-cache.d.ts +12 -0
- package/dist/types/tools/grouped-file-output.d.ts +95 -12
- package/dist/types/tools/memory-render.d.ts +4 -1
- package/dist/types/tools/path-utils.d.ts +8 -0
- package/dist/types/tools/plan-mode-guard.d.ts +8 -9
- package/dist/types/tools/render-utils.d.ts +5 -9
- package/dist/types/tools/search.d.ts +6 -2
- package/dist/types/tools/sqlite-reader.d.ts +1 -0
- package/dist/types/tools/todo.d.ts +3 -2
- package/dist/types/tools/write.d.ts +3 -0
- package/dist/types/tools/yield.d.ts +8 -0
- package/dist/types/tui/output-block.d.ts +16 -4
- package/dist/types/tui/status-line.d.ts +3 -0
- package/dist/types/utils/enhanced-paste.d.ts +20 -0
- package/dist/types/web/search/providers/kimi.d.ts +1 -1
- package/package.json +9 -9
- package/src/auto-thinking/classifier.ts +5 -1
- package/src/cli/args.ts +3 -1
- package/src/cli/dry-balance-cli.ts +54 -21
- package/src/cli/gallery-cli.ts +4 -1
- package/src/cli/gallery-fixtures/misc.ts +29 -0
- package/src/cli/startup-cwd.ts +68 -0
- package/src/commands/launch.ts +3 -0
- package/src/commit/analysis/conventional.ts +2 -2
- package/src/commit/analysis/summary.ts +2 -2
- package/src/commit/changelog/generate.ts +2 -2
- package/src/commit/changelog/index.ts +2 -2
- package/src/commit/map-reduce/index.ts +3 -3
- package/src/commit/map-reduce/map-phase.ts +2 -2
- package/src/commit/map-reduce/reduce-phase.ts +2 -2
- package/src/commit/model-selection.ts +36 -11
- package/src/commit/pipeline.ts +4 -4
- package/src/config/api-key-resolver.ts +58 -0
- package/src/config/model-provider-priority.ts +55 -0
- package/src/config/model-registry.ts +29 -24
- package/src/config/model-resolver.ts +39 -7
- package/src/config/settings-schema.ts +10 -0
- package/src/config/settings.ts +106 -43
- package/src/dap/config.ts +41 -2
- package/src/dap/defaults.json +1 -0
- package/src/dap/session.ts +1 -0
- package/src/dap/types.ts +10 -0
- package/src/debug/index.ts +47 -53
- package/src/debug/raw-sse-buffer.ts +7 -4
- package/src/debug/report-bundle.ts +9 -0
- package/src/edit/file-snapshot-store.ts +33 -1
- package/src/edit/hashline/filesystem.ts +2 -1
- package/src/edit/renderer.ts +82 -78
- package/src/eval/__tests__/llm-bridge.test.ts +110 -31
- package/src/eval/js/context-manager.ts +32 -15
- package/src/eval/llm-bridge.ts +22 -6
- package/src/eval/py/__tests__/prelude.test.ts +19 -0
- package/src/eval/py/executor.ts +23 -11
- package/src/eval/py/prelude.py +1 -1
- package/src/extensibility/extensions/types.ts +10 -1
- package/src/goals/tools/goal-tool.ts +36 -26
- package/src/internal-urls/docs-index.generated.ts +8 -8
- package/src/lsp/client.ts +23 -11
- package/src/lsp/config.ts +11 -1
- package/src/lsp/index.ts +61 -9
- package/src/lsp/utils.ts +3 -2
- package/src/main.ts +100 -72
- package/src/mcp/tool-bridge.ts +2 -0
- package/src/memories/index.ts +14 -7
- package/src/mnemopi/backend.ts +5 -1
- package/src/modes/acp/acp-agent.ts +33 -26
- package/src/modes/components/assistant-message.ts +2 -9
- package/src/modes/components/chat-block.ts +111 -0
- package/src/modes/components/copy-selector.ts +1 -44
- package/src/modes/components/custom-editor.ts +164 -109
- package/src/modes/components/custom-message.ts +1 -3
- package/src/modes/components/execution-shared.ts +1 -2
- package/src/modes/components/hook-message.ts +1 -3
- package/src/modes/components/model-selector.ts +59 -13
- package/src/modes/components/oauth-selector.ts +33 -7
- package/src/modes/components/overlay-box.ts +108 -0
- package/src/modes/components/plan-review-overlay.ts +799 -0
- package/src/modes/components/plan-toc.ts +138 -0
- package/src/modes/components/read-tool-group.ts +20 -4
- package/src/modes/components/skill-message.ts +0 -1
- package/src/modes/components/status-line.ts +19 -4
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/todo-reminder.ts +0 -2
- package/src/modes/components/tool-execution.ts +68 -88
- package/src/modes/components/transcript-container.ts +84 -24
- package/src/modes/components/user-message.ts +2 -3
- package/src/modes/controllers/command-controller-shared.ts +7 -6
- package/src/modes/controllers/command-controller.ts +57 -55
- package/src/modes/controllers/event-controller.ts +67 -40
- package/src/modes/controllers/extension-ui-controller.ts +10 -73
- package/src/modes/controllers/input-controller.ts +170 -126
- package/src/modes/controllers/mcp-command-controller.ts +69 -60
- package/src/modes/controllers/selector-controller.ts +23 -25
- package/src/modes/controllers/streaming-reveal.ts +212 -0
- package/src/modes/controllers/tan-command-controller.ts +173 -0
- package/src/modes/interactive-mode.ts +274 -112
- package/src/modes/magic-keywords.ts +1 -1
- package/src/modes/markdown-prose.ts +1 -1
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/shimmer.ts +20 -9
- package/src/modes/theme/theme-schema.json +1 -1
- package/src/modes/theme/theme.ts +8 -4
- package/src/modes/types.ts +21 -7
- package/src/modes/utils/copy-targets.ts +133 -27
- package/src/modes/utils/ui-helpers.ts +44 -46
- package/src/modes/workflow.ts +10 -10
- package/src/plan-mode/approved-plan.ts +66 -43
- package/src/plan-mode/plan-protection.ts +4 -4
- package/src/prompts/system/background-tan-dispatch.md +8 -0
- package/src/prompts/system/plan-mode-active.md +67 -58
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +9 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/eval.md +2 -1
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +37 -46
- package/src/session/agent-session.ts +119 -18
- package/src/session/auth-storage.ts +2 -0
- package/src/session/messages.ts +26 -0
- package/src/session/session-manager.ts +109 -28
- package/src/slash-commands/builtin-registry.ts +36 -9
- package/src/slash-commands/types.ts +4 -6
- package/src/task/executor.ts +76 -38
- package/src/task/index.ts +4 -0
- package/src/task/render.ts +211 -147
- package/src/tools/archive-reader.ts +64 -0
- package/src/tools/ask.ts +119 -164
- package/src/tools/ast-edit.ts +98 -71
- package/src/tools/ast-grep.ts +37 -43
- package/src/tools/bash.ts +57 -6
- package/src/tools/browser/tab-supervisor.ts +13 -1
- package/src/tools/browser/tab-worker.ts +33 -4
- package/src/tools/debug.ts +20 -8
- package/src/tools/eval.ts +13 -2
- package/src/tools/fetch.ts +297 -7
- package/src/tools/find.ts +51 -30
- package/src/tools/gh-cache-invalidation.ts +200 -0
- package/src/tools/gh-renderer.ts +81 -42
- package/src/tools/github-cache.ts +25 -0
- package/src/tools/grouped-file-output.ts +272 -48
- package/src/tools/image-gen.ts +150 -103
- package/src/tools/inspect-image-renderer.ts +63 -41
- package/src/tools/inspect-image.ts +10 -3
- package/src/tools/job.ts +3 -4
- package/src/tools/memory-render.ts +4 -1
- package/src/tools/path-utils.ts +28 -2
- package/src/tools/plan-mode-guard.ts +66 -39
- package/src/tools/read.ts +48 -28
- package/src/tools/render-utils.ts +21 -37
- package/src/tools/resolve.ts +14 -0
- package/src/tools/search-tool-bm25.ts +36 -23
- package/src/tools/search.ts +118 -81
- package/src/tools/sqlite-reader.ts +9 -12
- package/src/tools/todo.ts +118 -52
- package/src/tools/write.ts +83 -64
- package/src/tools/yield.ts +10 -1
- package/src/tui/output-block.ts +60 -13
- package/src/tui/status-line.ts +5 -1
- package/src/utils/commit-message-generator.ts +11 -3
- package/src/utils/enhanced-paste.ts +230 -0
- package/src/utils/title-generator.ts +2 -1
- package/src/web/search/providers/anthropic.ts +25 -19
- package/src/web/search/providers/codex.ts +37 -8
- package/src/web/search/providers/exa.ts +11 -3
- package/src/web/search/providers/kimi.ts +28 -17
- package/src/web/search/providers/parallel.ts +35 -24
- package/src/web/search/providers/synthetic.ts +8 -6
- package/src/web/search/providers/tavily.ts +9 -8
- package/src/web/search/providers/zai.ts +8 -6
package/src/tui/output-block.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
5
5
|
import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
6
|
-
import type { Theme } from "../modes/theme/theme";
|
|
6
|
+
import type { Theme, ThemeColor } from "../modes/theme/theme";
|
|
7
7
|
import { getSixelLineMask } from "../utils/sixel";
|
|
8
8
|
import type { State } from "./types";
|
|
9
9
|
import type { RenderCache } from "./utils";
|
|
@@ -13,11 +13,15 @@ export interface OutputBlockOptions {
|
|
|
13
13
|
header?: string;
|
|
14
14
|
headerMeta?: string;
|
|
15
15
|
state?: State;
|
|
16
|
-
sections?: Array<{ label?: string; lines: string[] }>;
|
|
16
|
+
sections?: Array<{ label?: string; lines: string[]; separator?: boolean }>;
|
|
17
17
|
width: number;
|
|
18
18
|
applyBg?: boolean;
|
|
19
|
+
contentPaddingLeft?: number;
|
|
19
20
|
/** Animate the border with a sweeping dark segment (pending/running state). */
|
|
20
21
|
animate?: boolean;
|
|
22
|
+
/** Override the state-derived border color. Used for muted "legacy" tool
|
|
23
|
+
* frames that should not visually compete with framed-output tools. */
|
|
24
|
+
borderColor?: ThemeColor;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
const FRAMED_BLOCK_COMPONENT = Symbol("framedBlockComponent");
|
|
@@ -33,7 +37,7 @@ export function isFramedBlockComponent(component: Component): boolean {
|
|
|
33
37
|
return (component as FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] === true;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
const BORDER_SHIMMER_TICK_MS =
|
|
40
|
+
const BORDER_SHIMMER_TICK_MS = 1000 / 30;
|
|
37
41
|
/** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
|
|
38
42
|
* ms. Position is derived from the wall clock against this fixed cycle so a
|
|
39
43
|
* resize only nudges the segment proportionally instead of teleporting it. */
|
|
@@ -42,9 +46,9 @@ const BORDER_BOUNCE_MS = 3000;
|
|
|
42
46
|
const BORDER_SEGMENT_LEN = 8;
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
|
-
* Monotonic frame counter for animated borders, quantized to the TUI's ~
|
|
46
|
-
* render cap so the cache key advances once per
|
|
47
|
-
* smooth segment sweep, coarse enough to coalesce multiple render passes
|
|
49
|
+
* Monotonic frame counter for animated borders, quantized to the TUI's ~30fps
|
|
50
|
+
* render cap so the cache key advances once per animation frame — fine enough
|
|
51
|
+
* for a smooth segment sweep, coarse enough to coalesce multiple render passes
|
|
48
52
|
* land inside the same frame.
|
|
49
53
|
*/
|
|
50
54
|
export function borderShimmerTick(): number {
|
|
@@ -92,6 +96,11 @@ type BlockRow =
|
|
|
92
96
|
| { kind: "content"; inner: string }
|
|
93
97
|
| { kind: "sixel"; raw: string };
|
|
94
98
|
|
|
99
|
+
function normalizeContentPaddingLeft(value: number | undefined): number {
|
|
100
|
+
if (value === undefined || !Number.isFinite(value)) return 1;
|
|
101
|
+
return Math.max(0, Math.floor(value));
|
|
102
|
+
}
|
|
103
|
+
|
|
95
104
|
export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
|
|
96
105
|
const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
|
|
97
106
|
const h = theme.boxSharp.horizontal;
|
|
@@ -99,14 +108,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
99
108
|
const cap = h.repeat(3);
|
|
100
109
|
const lineWidth = Math.max(0, width);
|
|
101
110
|
// Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors
|
|
102
|
-
const borderColor:
|
|
103
|
-
|
|
111
|
+
const borderColor: ThemeColor =
|
|
112
|
+
options.borderColor ??
|
|
113
|
+
(state === "error"
|
|
104
114
|
? "error"
|
|
105
115
|
: state === "warning"
|
|
106
116
|
? "warning"
|
|
107
117
|
: state === "running" || state === "pending"
|
|
108
118
|
? "accent"
|
|
109
|
-
: "dim";
|
|
119
|
+
: "dim");
|
|
110
120
|
const border = (text: string) => theme.fg(borderColor, text);
|
|
111
121
|
const bgFn = (() => {
|
|
112
122
|
if (!state || !applyBg) return undefined;
|
|
@@ -121,7 +131,9 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
121
131
|
};
|
|
122
132
|
})();
|
|
123
133
|
|
|
124
|
-
const
|
|
134
|
+
const contentPaddingLeft = normalizeContentPaddingLeft(options.contentPaddingLeft);
|
|
135
|
+
const contentWidth = Math.max(0, lineWidth - visibleWidth(v) - contentPaddingLeft - visibleWidth(v));
|
|
136
|
+
const contentLeftPadding = contentPaddingLeft > 0 ? padding(contentPaddingLeft) : "";
|
|
125
137
|
|
|
126
138
|
// ── Layout pass: collect row descriptors so the border perimeter length is
|
|
127
139
|
// known before the moving segment is positioned. ──
|
|
@@ -135,7 +147,11 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
135
147
|
});
|
|
136
148
|
|
|
137
149
|
const normalizedSections = sections.length > 0 ? sections : [{ lines: [] as string[] }];
|
|
138
|
-
for (
|
|
150
|
+
for (let sectionIndex = 0; sectionIndex < normalizedSections.length; sectionIndex++) {
|
|
151
|
+
const section = normalizedSections[sectionIndex]!;
|
|
152
|
+
// A labeled section always draws its titled separator bar. A label-less
|
|
153
|
+
// section can still request a plain divider via `separator`, but only
|
|
154
|
+
// between sections — leading with one would just double the header bar.
|
|
139
155
|
if (section.label) {
|
|
140
156
|
rows.push({
|
|
141
157
|
kind: "bar",
|
|
@@ -143,6 +159,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
143
159
|
rightChar: theme.boxSharp.teeLeft,
|
|
144
160
|
label: section.label,
|
|
145
161
|
});
|
|
162
|
+
} else if (section.separator && sectionIndex > 0) {
|
|
163
|
+
rows.push({
|
|
164
|
+
kind: "bar",
|
|
165
|
+
leftChar: theme.boxSharp.teeRight,
|
|
166
|
+
rightChar: theme.boxSharp.teeLeft,
|
|
167
|
+
});
|
|
146
168
|
}
|
|
147
169
|
const allLines = section.lines.flatMap(l => l.split("\n"));
|
|
148
170
|
const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(allLines) : undefined;
|
|
@@ -202,7 +224,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
202
224
|
const rightGlyph = row.rightChar;
|
|
203
225
|
if (lineWidth <= 0) return border(leftGlyphs) + border(rightGlyph);
|
|
204
226
|
const labelText = [row.label, row.meta].filter(Boolean).join(theme.sep.dot);
|
|
205
|
-
|
|
227
|
+
if (!labelText) {
|
|
228
|
+
// No header: draw a clean, continuous top/separator bar (no 1-col gap).
|
|
229
|
+
const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
|
|
230
|
+
return `${border(leftGlyphs)}${border(h.repeat(fillCount))}${border(rightGlyph)}`;
|
|
231
|
+
}
|
|
232
|
+
const rawLabel = ` ${labelText} `;
|
|
206
233
|
const leftWidth = visibleWidth(leftGlyphs);
|
|
207
234
|
const rightWidth = visibleWidth(rightGlyph);
|
|
208
235
|
const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth);
|
|
@@ -225,7 +252,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
225
252
|
return `${leftStr}${fillStr}${rightStr}`;
|
|
226
253
|
};
|
|
227
254
|
|
|
228
|
-
const renderContent = (inner: string): string => `${border(
|
|
255
|
+
const renderContent = (inner: string): string => `${border(v)}${contentLeftPadding}${inner}${border(v)}`;
|
|
229
256
|
|
|
230
257
|
const lines: string[] = [];
|
|
231
258
|
for (let r = 0; r < H; r++) {
|
|
@@ -269,15 +296,18 @@ export class CachedOutputBlock {
|
|
|
269
296
|
#buildKey(options: OutputBlockOptions): bigint {
|
|
270
297
|
const h = new Hasher();
|
|
271
298
|
h.u32(options.width);
|
|
299
|
+
h.u32(normalizeContentPaddingLeft(options.contentPaddingLeft));
|
|
272
300
|
h.optional(options.header);
|
|
273
301
|
h.optional(options.headerMeta);
|
|
274
302
|
h.optional(options.state);
|
|
303
|
+
h.optional(options.borderColor);
|
|
275
304
|
h.bool(options.applyBg ?? true);
|
|
276
305
|
h.bool(options.animate ?? false);
|
|
277
306
|
if (options.animate) h.u32(borderShimmerTick());
|
|
278
307
|
if (options.sections) {
|
|
279
308
|
for (const s of options.sections) {
|
|
280
309
|
h.optional(s.label);
|
|
310
|
+
h.bool(s.separator ?? false);
|
|
281
311
|
for (const line of s.lines) {
|
|
282
312
|
h.str(line);
|
|
283
313
|
}
|
|
@@ -286,3 +316,20 @@ export class CachedOutputBlock {
|
|
|
286
316
|
return h.digest();
|
|
287
317
|
}
|
|
288
318
|
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Build a self-framing tool component backed by a cached output block. The
|
|
322
|
+
* `build` callback returns the block options for a given width; the cache
|
|
323
|
+
* dedupes re-renders. Pass `borderColor: "borderMuted"` for the dim "legacy"
|
|
324
|
+
* look that does not compete with the state-colored framed tools.
|
|
325
|
+
*/
|
|
326
|
+
export function framedBlock(theme: Theme, build: (width: number) => OutputBlockOptions): Component {
|
|
327
|
+
const block = new CachedOutputBlock();
|
|
328
|
+
// Marked so the tool-execution container treats it as self-framing (renders
|
|
329
|
+
// flush, no extra padding/background) the same way `markFramedBlockComponent`
|
|
330
|
+
// blocks are treated.
|
|
331
|
+
return markFramedBlockComponent({
|
|
332
|
+
render: (width: number): string[] => block.render(build(width), theme),
|
|
333
|
+
invalidate: () => block.invalidate(),
|
|
334
|
+
});
|
|
335
|
+
}
|
package/src/tui/status-line.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { formatStatusIcon } from "../tools/render-utils";
|
|
|
7
7
|
|
|
8
8
|
export interface StatusLineOptions {
|
|
9
9
|
icon?: ToolUIStatus;
|
|
10
|
+
/** Pre-rendered glyph that replaces the status icon (e.g. a magnifier for
|
|
11
|
+
* search-family tools). Takes precedence over `icon`. */
|
|
12
|
+
iconOverride?: string;
|
|
10
13
|
spinnerFrame?: number;
|
|
11
14
|
title: string;
|
|
12
15
|
titleColor?: ThemeColor;
|
|
@@ -27,7 +30,8 @@ function flattenForHeader(text: string): string {
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function renderStatusLine(options: StatusLineOptions, theme: Theme): string {
|
|
30
|
-
const icon =
|
|
33
|
+
const icon =
|
|
34
|
+
options.iconOverride ?? (options.icon ? formatStatusIcon(options.icon, theme, options.spinnerFrame) : "");
|
|
31
35
|
const titleColor = options.titleColor ?? "accent";
|
|
32
36
|
const title = theme.fg(titleColor, flattenForHeader(options.title));
|
|
33
37
|
let line = icon ? `${icon} ${title}` : title;
|
|
@@ -6,8 +6,9 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
|
6
6
|
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import { completeSimple } from "@oh-my-pi/pi-ai";
|
|
8
8
|
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
9
|
+
|
|
9
10
|
import type { ModelRegistry } from "../config/model-registry";
|
|
10
|
-
import { resolveModelRoleValue } from "../config/model-resolver";
|
|
11
|
+
import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
|
|
11
12
|
import type { Settings } from "../config/settings";
|
|
12
13
|
import MODEL_PRIO from "../priority.json" with { type: "json" };
|
|
13
14
|
import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
|
|
@@ -50,7 +51,7 @@ function getSmolModelCandidates(
|
|
|
50
51
|
candidates.push({ model, thinkingLevel });
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
const matchPreferences =
|
|
54
|
+
const matchPreferences = getModelMatchPreferences(settings);
|
|
54
55
|
const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, {
|
|
55
56
|
settings,
|
|
56
57
|
matchPreferences,
|
|
@@ -110,7 +111,14 @@ export async function generateCommitMessage(
|
|
|
110
111
|
systemPrompt: [COMMIT_SYSTEM_PROMPT],
|
|
111
112
|
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
|
|
112
113
|
},
|
|
113
|
-
{
|
|
114
|
+
{
|
|
115
|
+
apiKey: registry.resolver(candidate.model.provider, {
|
|
116
|
+
sessionId,
|
|
117
|
+
baseUrl: candidate.model.baseUrl,
|
|
118
|
+
}),
|
|
119
|
+
maxTokens,
|
|
120
|
+
reasoning: toReasoningEffort(candidate.thinkingLevel),
|
|
121
|
+
},
|
|
114
122
|
);
|
|
115
123
|
|
|
116
124
|
if (response.stopReason === "error") {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
|
|
3
|
+
const OSC5522_PREFIX = "\x1b]5522;";
|
|
4
|
+
const OSC_TERMINATOR_ST = "\x1b\\";
|
|
5
|
+
const OSC_TERMINATOR_BEL = "\x07";
|
|
6
|
+
const PASTE_EVENT_NAME_BASE64 = Buffer.from("Paste event", "utf8").toString("base64");
|
|
7
|
+
|
|
8
|
+
const IMAGE_MIME_PRIORITY = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
|
9
|
+
const TEXT_MIME_TYPE = "text/plain";
|
|
10
|
+
/** Kitty's "give me the list of available MIME types" sentinel — see `TARGETS_MIME` in `kitty/clipboard.py`. */
|
|
11
|
+
const MIME_LISTING_TARGET = ".";
|
|
12
|
+
|
|
13
|
+
type PasteReadKind = "image" | "text";
|
|
14
|
+
|
|
15
|
+
export interface Osc5522Packet {
|
|
16
|
+
metadata: Map<string, string>;
|
|
17
|
+
payload: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PasteListingState {
|
|
21
|
+
phase: "listing";
|
|
22
|
+
mimes: string[];
|
|
23
|
+
kittyDotPayload?: true;
|
|
24
|
+
pw?: string;
|
|
25
|
+
loc?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PasteReadState {
|
|
29
|
+
phase: "reading";
|
|
30
|
+
kind: PasteReadKind;
|
|
31
|
+
mimeType: string;
|
|
32
|
+
chunks: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type PasteState = PasteListingState | PasteReadState;
|
|
36
|
+
|
|
37
|
+
export interface EnhancedPasteHandlers {
|
|
38
|
+
write(data: string): void;
|
|
39
|
+
pasteText(text: string): void;
|
|
40
|
+
pasteImage(image: ImageContent): void | Promise<void>;
|
|
41
|
+
showStatus(message: string): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isOsc5522Packet(data: string): boolean {
|
|
45
|
+
return data.startsWith(OSC5522_PREFIX) && (data.endsWith(OSC_TERMINATOR_ST) || data.endsWith(OSC_TERMINATOR_BEL));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function decodeBase64Utf8(value: string): string | undefined {
|
|
49
|
+
try {
|
|
50
|
+
return Buffer.from(value, "base64").toString("utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseMetadata(raw: string): Map<string, string> {
|
|
57
|
+
const metadata = new Map<string, string>();
|
|
58
|
+
for (const part of raw.split(":")) {
|
|
59
|
+
const eq = part.indexOf("=");
|
|
60
|
+
if (eq <= 0) continue;
|
|
61
|
+
metadata.set(part.slice(0, eq), part.slice(eq + 1));
|
|
62
|
+
}
|
|
63
|
+
return metadata;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseOsc5522Packet(data: string): Osc5522Packet | undefined {
|
|
67
|
+
if (!isOsc5522Packet(data)) return undefined;
|
|
68
|
+
const bodyEnd = data.endsWith(OSC_TERMINATOR_BEL) ? data.length - 1 : data.length - OSC_TERMINATOR_ST.length;
|
|
69
|
+
const body = data.slice(OSC5522_PREFIX.length, bodyEnd);
|
|
70
|
+
const separator = body.indexOf(";");
|
|
71
|
+
const metadataRaw = separator === -1 ? body : body.slice(0, separator);
|
|
72
|
+
const payload = separator === -1 ? "" : body.slice(separator + 1);
|
|
73
|
+
return { metadata: parseMetadata(metadataRaw), payload };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function choosePasteMime(mimes: readonly string[]): { kind: PasteReadKind; mimeType: string } | undefined {
|
|
77
|
+
for (const mimeType of IMAGE_MIME_PRIORITY) {
|
|
78
|
+
if (mimes.includes(mimeType)) return { kind: "image", mimeType };
|
|
79
|
+
}
|
|
80
|
+
return mimes.includes(TEXT_MIME_TYPE) ? { kind: "text", mimeType: TEXT_MIME_TYPE } : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class EnhancedPasteController {
|
|
84
|
+
#state: PasteState | undefined;
|
|
85
|
+
#handlers: EnhancedPasteHandlers;
|
|
86
|
+
|
|
87
|
+
constructor(handlers: EnhancedPasteHandlers) {
|
|
88
|
+
this.#handlers = handlers;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
enable(): void {
|
|
92
|
+
this.#handlers.write("\x1b[?5522h");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
disable(): void {
|
|
96
|
+
this.#handlers.write("\x1b[?5522l");
|
|
97
|
+
this.#state = undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
handleInput(data: string): boolean {
|
|
101
|
+
const packet = parseOsc5522Packet(data);
|
|
102
|
+
if (!packet) return false;
|
|
103
|
+
void this.#handlePacket(packet);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async #handlePacket(packet: Osc5522Packet): Promise<void> {
|
|
108
|
+
const type = packet.metadata.get("type");
|
|
109
|
+
if (type !== "read") return;
|
|
110
|
+
|
|
111
|
+
const status = packet.metadata.get("status");
|
|
112
|
+
if (status === "OK") {
|
|
113
|
+
this.#handleOk(packet);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (status === "DATA") {
|
|
117
|
+
this.#handleData(packet);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (status === "DONE") {
|
|
121
|
+
await this.#handleDone();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (status) {
|
|
125
|
+
this.#state = undefined;
|
|
126
|
+
this.#handlers.showStatus(`Enhanced paste failed: ${status}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#handleOk(packet: Osc5522Packet): void {
|
|
131
|
+
if (this.#state?.phase === "reading") return;
|
|
132
|
+
const loc = packet.metadata.get("loc");
|
|
133
|
+
this.#state = {
|
|
134
|
+
phase: "listing",
|
|
135
|
+
mimes: [],
|
|
136
|
+
pw: packet.metadata.get("pw"),
|
|
137
|
+
loc: loc === "primary" ? loc : undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#handleData(packet: Osc5522Packet): void {
|
|
142
|
+
const state = this.#state;
|
|
143
|
+
if (!state) return;
|
|
144
|
+
const encodedMime = packet.metadata.get("mime");
|
|
145
|
+
if (!encodedMime) return;
|
|
146
|
+
const mimeType = decodeBase64Utf8(encodedMime);
|
|
147
|
+
if (!mimeType) return;
|
|
148
|
+
|
|
149
|
+
if (state.phase === "listing") {
|
|
150
|
+
// Kitty (as of writing) implements the "list available MIME types"
|
|
151
|
+
// response shape by sending a single DATA packet with `mime="."` and
|
|
152
|
+
// the available types packed into the payload as a whitespace-
|
|
153
|
+
// separated list (see `fulfill_read_request` in
|
|
154
|
+
// kovidgoyal/kitty:kitty/clipboard.py). The 5522-mode ancillary
|
|
155
|
+
// spec instead encodes each type as its own DATA packet with an
|
|
156
|
+
// empty payload. Support both — fall through to the per-packet
|
|
157
|
+
// form when the dot sentinel has no payload, or when the packet
|
|
158
|
+
// already names a concrete MIME type.
|
|
159
|
+
if (mimeType === MIME_LISTING_TARGET) {
|
|
160
|
+
if (!packet.payload) return;
|
|
161
|
+
const listing = decodeBase64Utf8(packet.payload);
|
|
162
|
+
if (!listing) return;
|
|
163
|
+
state.kittyDotPayload = true;
|
|
164
|
+
for (const candidate of listing.split(/\s+/)) {
|
|
165
|
+
if (candidate && candidate !== MIME_LISTING_TARGET) state.mimes.push(candidate);
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
state.mimes.push(mimeType);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (state.mimeType === mimeType && packet.payload) {
|
|
174
|
+
state.chunks.push(packet.payload);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #handleDone(): Promise<void> {
|
|
179
|
+
const state = this.#state;
|
|
180
|
+
if (!state) return;
|
|
181
|
+
if (state.phase === "listing") {
|
|
182
|
+
this.#finishListing(state);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.#state = undefined;
|
|
186
|
+
const bytes = Buffer.concat(state.chunks.map(chunk => Buffer.from(chunk, "base64")));
|
|
187
|
+
if (bytes.byteLength === 0) {
|
|
188
|
+
this.#handlers.showStatus("Clipboard paste was empty");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (state.kind === "text") {
|
|
192
|
+
this.#handlers.pasteText(bytes.toString("utf8"));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
await this.#handlers.pasteImage({
|
|
196
|
+
type: "image",
|
|
197
|
+
data: bytes.toString("base64"),
|
|
198
|
+
mimeType: state.mimeType,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#finishListing(state: PasteListingState): void {
|
|
203
|
+
const selected = choosePasteMime(state.mimes);
|
|
204
|
+
if (!selected) {
|
|
205
|
+
this.#state = undefined;
|
|
206
|
+
this.#handlers.showStatus("Clipboard paste has no supported text or image data");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.#state = {
|
|
211
|
+
phase: "reading",
|
|
212
|
+
kind: selected.kind,
|
|
213
|
+
mimeType: selected.mimeType,
|
|
214
|
+
chunks: [],
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const encodedMime = Buffer.from(selected.mimeType, "utf8").toString("base64");
|
|
218
|
+
const metadata = ["type=read"];
|
|
219
|
+
if (state.loc) metadata.push(`loc=${state.loc}`);
|
|
220
|
+
if (state.pw) {
|
|
221
|
+
metadata.push(`pw=${state.pw}`, `name=${PASTE_EVENT_NAME_BASE64}`);
|
|
222
|
+
}
|
|
223
|
+
if (state.kittyDotPayload) {
|
|
224
|
+
this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")};${encodedMime}${OSC_TERMINATOR_BEL}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
metadata.push(`mime=${encodedMime}`);
|
|
228
|
+
this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_BEL}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -6,6 +6,7 @@ import * as path from "node:path";
|
|
|
6
6
|
import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import type { ModelRegistry } from "../config/model-registry";
|
|
9
|
+
|
|
9
10
|
import { resolveRoleSelection } from "../config/model-resolver";
|
|
10
11
|
import type { Settings } from "../config/settings";
|
|
11
12
|
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
|
|
@@ -238,7 +239,7 @@ export async function generateTitleOnline(
|
|
|
238
239
|
tools: [setTitleTool],
|
|
239
240
|
},
|
|
240
241
|
{
|
|
241
|
-
apiKey,
|
|
242
|
+
apiKey: registry.resolver(model.provider, { sessionId, baseUrl: model.baseUrl }),
|
|
242
243
|
maxTokens,
|
|
243
244
|
disableReasoning: true,
|
|
244
245
|
toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
import {
|
|
8
8
|
type AnthropicAuthConfig,
|
|
9
9
|
type AnthropicSystemBlock,
|
|
10
|
+
type ApiKey,
|
|
10
11
|
type AuthStorage,
|
|
11
12
|
buildAnthropicAuthConfig,
|
|
12
13
|
buildAnthropicSearchHeaders,
|
|
13
14
|
buildAnthropicSystemBlocks,
|
|
14
15
|
buildAnthropicUrl,
|
|
15
16
|
stripClaudeToolPrefix,
|
|
17
|
+
withAuth,
|
|
16
18
|
} from "@oh-my-pi/pi-ai";
|
|
17
19
|
import { $env } from "@oh-my-pi/pi-utils";
|
|
18
20
|
import type {
|
|
@@ -247,18 +249,13 @@ export async function searchAnthropic(
|
|
|
247
249
|
): Promise<SearchResponse> {
|
|
248
250
|
const searchApiKey = $env.ANTHROPIC_SEARCH_API_KEY;
|
|
249
251
|
const searchBaseUrl = $env.ANTHROPIC_SEARCH_BASE_URL;
|
|
250
|
-
|
|
252
|
+
const keyOrResolver: ApiKey | undefined = searchApiKey
|
|
253
|
+
? searchApiKey
|
|
254
|
+
: "authStorage" in params
|
|
255
|
+
? params.authStorage.resolver("anthropic", { sessionId: params.sessionId })
|
|
256
|
+
: undefined;
|
|
251
257
|
|
|
252
|
-
if (
|
|
253
|
-
auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
|
|
254
|
-
} else if ("authStorage" in params) {
|
|
255
|
-
const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
|
|
256
|
-
signal: params.signal,
|
|
257
|
-
});
|
|
258
|
-
if (apiKey) auth = buildAnthropicAuthConfig(apiKey, searchBaseUrl);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (!auth) {
|
|
258
|
+
if (!keyOrResolver) {
|
|
262
259
|
throw new Error(
|
|
263
260
|
"No Anthropic credentials found. Set ANTHROPIC_SEARCH_API_KEY or ANTHROPIC_API_KEY, or configure Anthropic OAuth.",
|
|
264
261
|
);
|
|
@@ -267,14 +264,23 @@ export async function searchAnthropic(
|
|
|
267
264
|
const model = getModel();
|
|
268
265
|
const systemPrompt = "authStorage" in params ? params.systemPrompt : params.system_prompt;
|
|
269
266
|
const maxTokens = "authStorage" in params ? params.maxOutputTokens : params.max_tokens;
|
|
270
|
-
const response = await
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
267
|
+
const response = await withAuth(
|
|
268
|
+
keyOrResolver,
|
|
269
|
+
key =>
|
|
270
|
+
callSearch(
|
|
271
|
+
buildAnthropicAuthConfig(key, searchBaseUrl),
|
|
272
|
+
model,
|
|
273
|
+
params.query,
|
|
274
|
+
systemPrompt,
|
|
275
|
+
maxTokens,
|
|
276
|
+
params.temperature,
|
|
277
|
+
params.signal,
|
|
278
|
+
),
|
|
279
|
+
{
|
|
280
|
+
signal: params.signal,
|
|
281
|
+
missingKeyMessage:
|
|
282
|
+
"No Anthropic credentials found. Set ANTHROPIC_SEARCH_API_KEY or ANTHROPIC_API_KEY, or configure Anthropic OAuth.",
|
|
283
|
+
},
|
|
278
284
|
);
|
|
279
285
|
|
|
280
286
|
const result = parseResponse(response);
|
|
@@ -114,8 +114,34 @@ interface CodexResponse {
|
|
|
114
114
|
usage?: CodexUsage;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Known Codex "image placeholder" answers — short prose the assistant emits in
|
|
119
|
+
* place of a real answer when it produced a screenshot instead of text. These
|
|
120
|
+
* carry no information, so callers treat them as non-answers and advance the
|
|
121
|
+
* chain to a provider that returns text. Extend by adding the normalized
|
|
122
|
+
* literal below; no regex tuning required.
|
|
123
|
+
*/
|
|
124
|
+
const IMAGE_PLACEHOLDER_ANSWERS: ReadonlySet<string> = new Set([
|
|
125
|
+
"see attached image",
|
|
126
|
+
"attached image",
|
|
127
|
+
"see the attached image",
|
|
128
|
+
"see image",
|
|
129
|
+
"see image above",
|
|
130
|
+
"image above",
|
|
131
|
+
"see image below",
|
|
132
|
+
"image below",
|
|
133
|
+
]);
|
|
134
|
+
|
|
117
135
|
function isImagePlaceholderAnswer(text: string): boolean {
|
|
118
|
-
|
|
136
|
+
// Strip surrounding brackets/quotes and trailing punctuation, lowercase,
|
|
137
|
+
// then match against the known-placeholder set.
|
|
138
|
+
const normalized = text
|
|
139
|
+
.trim()
|
|
140
|
+
.replace(/^[[("'`*_]+/, "")
|
|
141
|
+
.replace(/[\])"'`*_.!?]+$/, "")
|
|
142
|
+
.trim()
|
|
143
|
+
.toLowerCase();
|
|
144
|
+
return IMAGE_PLACEHOLDER_ANSWERS.has(normalized);
|
|
119
145
|
}
|
|
120
146
|
|
|
121
147
|
function addSource(sources: SearchSource[], source: SearchSource): void {
|
|
@@ -423,15 +449,18 @@ async function callCodexSearch(
|
|
|
423
449
|
|
|
424
450
|
const finalAnswer = answerParts.join("\n\n").trim();
|
|
425
451
|
const streamedAnswer = streamedAnswerParts.join("").trim();
|
|
426
|
-
|
|
452
|
+
// Throw to advance the chain whenever Codex emitted nothing but image
|
|
453
|
+
// placeholder prose — including the case where the streamed delta itself
|
|
454
|
+
// is the placeholder (the model occasionally streams the same text it
|
|
455
|
+
// publishes as the final output_text).
|
|
456
|
+
const finalIsPlaceholder = finalAnswer.length > 0 && isImagePlaceholderAnswer(finalAnswer);
|
|
457
|
+
const streamedIsPlaceholder = streamedAnswer.length > 0 && isImagePlaceholderAnswer(streamedAnswer);
|
|
458
|
+
const hasFinalText = finalAnswer.length > 0 && !finalIsPlaceholder;
|
|
459
|
+
const hasStreamedText = streamedAnswer.length > 0 && !streamedIsPlaceholder;
|
|
460
|
+
if (!hasFinalText && !hasStreamedText && sources.length === 0) {
|
|
427
461
|
throw new SearchProviderError("codex", "Codex returned image-only response", 502);
|
|
428
462
|
}
|
|
429
|
-
const answer =
|
|
430
|
-
finalAnswer.length > 0 && !isImagePlaceholderAnswer(finalAnswer)
|
|
431
|
-
? finalAnswer
|
|
432
|
-
: streamedAnswer.length > 0
|
|
433
|
-
? streamedAnswer
|
|
434
|
-
: finalAnswer;
|
|
463
|
+
const answer = hasFinalText ? finalAnswer : hasStreamedText ? streamedAnswer : "";
|
|
435
464
|
|
|
436
465
|
// Fallback: when Codex omits url_citation annotations, scrape markdown links
|
|
437
466
|
// and bare URLs from the synthesized answer so callers still receive sources.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Requests per-result summaries via `contents.summary` and synthesizes
|
|
7
7
|
* them into a combined `answer` string on the SearchResponse.
|
|
8
8
|
*/
|
|
9
|
-
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
|
9
|
+
import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import { settings } from "../../../config/settings";
|
|
11
11
|
import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
|
|
12
12
|
|
|
@@ -228,11 +228,19 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
|
|
|
228
228
|
|
|
229
229
|
/** Execute Exa web search */
|
|
230
230
|
export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
|
|
231
|
+
// AuthStorage-backed key takes precedence (existing behavior); probe it once
|
|
232
|
+
// so the env-key and keyless-MCP fallbacks below stay intact, then drive the
|
|
233
|
+
// authStorage path through the central force-refresh/rotate retry policy.
|
|
231
234
|
const storedKey = params.authStorage
|
|
232
235
|
? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal })
|
|
233
236
|
: undefined;
|
|
234
|
-
const
|
|
235
|
-
|
|
237
|
+
const keyOrResolver: ApiKey | undefined =
|
|
238
|
+
storedKey && params.authStorage
|
|
239
|
+
? params.authStorage.resolver("exa", { sessionId: params.sessionId })
|
|
240
|
+
: getEnvApiKey("exa");
|
|
241
|
+
const response = keyOrResolver
|
|
242
|
+
? await withAuth(keyOrResolver, key => callExaSearch(key, params), { signal: params.signal })
|
|
243
|
+
: await callExaMcpSearch(params);
|
|
236
244
|
|
|
237
245
|
// Convert to unified SearchResponse
|
|
238
246
|
const sources: SearchSource[] = [];
|