@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.16
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 +48 -0
- package/dist/cli.js +3725 -4029
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +15 -0
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/session-picker.ts +17 -3
- package/src/commands/launch.ts +3 -3
- package/src/config/settings-schema.ts +13 -1
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/controllers/command-controller.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +16 -0
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/todo.md +1 -1
- package/src/session/agent-session.ts +231 -50
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +20 -0
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/web/search/providers/exa.ts +85 -1
package/src/tools/todo.ts
CHANGED
|
@@ -52,21 +52,18 @@ const InitListEntry = type({
|
|
|
52
52
|
items: type("string").describe("task content").array().atLeastLength(1).describe("tasks for this phase"),
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
const
|
|
55
|
+
const todoSchema = type({
|
|
56
56
|
op: TodoOp,
|
|
57
57
|
"list?": InitListEntry.array().describe("phased task list (init)"),
|
|
58
58
|
"task?": type("string").describe("task content"),
|
|
59
59
|
"phase?": type("string").describe("phase name"),
|
|
60
60
|
"items?": type("string").describe("task content").array().atLeastLength(1).describe("tasks to append"),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const todoSchema = type({
|
|
64
|
-
ops: TodoOpEntry.array().atLeastLength(1).describe("ordered todo operations"),
|
|
65
|
-
}).describe("apply ordered todo operations");
|
|
61
|
+
}).describe("apply a single todo operation");
|
|
66
62
|
|
|
67
63
|
type TodoParams = TodoSchema;
|
|
68
64
|
type TodoSchema = typeof todoSchema.infer;
|
|
69
|
-
|
|
65
|
+
/** A single todo op entry (the params object itself). */
|
|
66
|
+
type TodoOpEntryValue = TodoParams;
|
|
70
67
|
|
|
71
68
|
// =============================================================================
|
|
72
69
|
// State helpers
|
|
@@ -402,10 +399,7 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
|
|
|
402
399
|
|
|
403
400
|
function applyParams(phases: TodoPhase[], params: TodoParams): { phases: TodoPhase[]; errors: string[] } {
|
|
404
401
|
const errors: string[] = [];
|
|
405
|
-
|
|
406
|
-
for (const entry of params.ops) {
|
|
407
|
-
next = applyEntry(next, entry, errors);
|
|
408
|
-
}
|
|
402
|
+
const next = applyEntry(phases, params, errors);
|
|
409
403
|
normalizeInProgressTask(next);
|
|
410
404
|
return { phases: next, errors };
|
|
411
405
|
}
|
|
@@ -413,9 +407,15 @@ function applyParams(phases: TodoPhase[], params: TodoParams): { phases: TodoPha
|
|
|
413
407
|
/** Apply an array of `todo`-style ops to existing phases. Used by /todo slash command. */
|
|
414
408
|
export function applyOpsToPhases(
|
|
415
409
|
currentPhases: TodoPhase[],
|
|
416
|
-
ops: TodoParams[
|
|
410
|
+
ops: TodoParams[],
|
|
417
411
|
): { phases: TodoPhase[]; errors: string[] } {
|
|
418
|
-
|
|
412
|
+
const errors: string[] = [];
|
|
413
|
+
let next = clonePhases(currentPhases);
|
|
414
|
+
for (const op of ops) {
|
|
415
|
+
next = applyEntry(next, op, errors);
|
|
416
|
+
}
|
|
417
|
+
normalizeInProgressTask(next);
|
|
418
|
+
return { phases: next, errors };
|
|
419
419
|
}
|
|
420
420
|
|
|
421
421
|
// =============================================================================
|
|
@@ -572,64 +572,44 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
|
|
|
572
572
|
{
|
|
573
573
|
caption: "Initial setup (multi-phase)",
|
|
574
574
|
call: {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
|
|
581
|
-
{ phase: "Verification", items: ["Run cargo test"] },
|
|
582
|
-
],
|
|
583
|
-
},
|
|
575
|
+
op: "init",
|
|
576
|
+
list: [
|
|
577
|
+
{ phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
|
|
578
|
+
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
|
|
579
|
+
{ phase: "Verification", items: ["Run cargo test"] },
|
|
584
580
|
],
|
|
585
581
|
},
|
|
586
582
|
},
|
|
587
583
|
{
|
|
588
584
|
caption: "View current state (read-only)",
|
|
589
|
-
call: {
|
|
590
|
-
ops: [{ op: "view" }],
|
|
591
|
-
},
|
|
585
|
+
call: { op: "view" },
|
|
592
586
|
},
|
|
593
587
|
{
|
|
594
588
|
caption: "Initial setup (single phase)",
|
|
595
589
|
call: {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
op: "init",
|
|
599
|
-
list: [{ phase: "Implementation", items: ["Apply fix", "Run tests"] }],
|
|
600
|
-
},
|
|
601
|
-
],
|
|
590
|
+
op: "init",
|
|
591
|
+
list: [{ phase: "Implementation", items: ["Apply fix", "Run tests"] }],
|
|
602
592
|
},
|
|
603
593
|
},
|
|
604
594
|
{
|
|
605
595
|
caption: "Complete one task",
|
|
606
|
-
call: {
|
|
607
|
-
ops: [{ op: "done", task: "Wire workspace" }],
|
|
608
|
-
},
|
|
596
|
+
call: { op: "done", task: "Wire workspace" },
|
|
609
597
|
},
|
|
610
598
|
{
|
|
611
599
|
caption: "Complete a whole phase",
|
|
612
|
-
call: {
|
|
613
|
-
ops: [{ op: "done", phase: "Auth" }],
|
|
614
|
-
},
|
|
600
|
+
call: { op: "done", phase: "Auth" },
|
|
615
601
|
},
|
|
616
602
|
{
|
|
617
603
|
caption: "Remove all tasks",
|
|
618
|
-
call: {
|
|
619
|
-
ops: [{ op: "rm" }],
|
|
620
|
-
},
|
|
604
|
+
call: { op: "rm" },
|
|
621
605
|
},
|
|
622
606
|
{
|
|
623
607
|
caption: "Drop one task",
|
|
624
|
-
call: {
|
|
625
|
-
ops: [{ op: "drop", task: "Run cargo test" }],
|
|
626
|
-
},
|
|
608
|
+
call: { op: "drop", task: "Run cargo test" },
|
|
627
609
|
},
|
|
628
610
|
{
|
|
629
611
|
caption: "Append tasks to a phase",
|
|
630
|
-
call: {
|
|
631
|
-
ops: [{ op: "append", phase: "Auth", items: ["Handle retries", "Run tests"] }],
|
|
632
|
-
},
|
|
612
|
+
call: { op: "append", phase: "Auth", items: ["Handle retries", "Run tests"] },
|
|
633
613
|
},
|
|
634
614
|
];
|
|
635
615
|
readonly loadMode = "discoverable";
|
|
@@ -646,7 +626,7 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
|
|
|
646
626
|
): Promise<AgentToolResult<TodoToolDetails>> {
|
|
647
627
|
const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []);
|
|
648
628
|
// Pure-view calls are reads: no normalization, no state write.
|
|
649
|
-
const readOnly = params.
|
|
629
|
+
const readOnly = params.op === "view";
|
|
650
630
|
const { phases: updated, errors } = readOnly
|
|
651
631
|
? { phases: previousPhases, errors: [] as string[] }
|
|
652
632
|
: applyParams(clonePhases(previousPhases), params);
|
|
@@ -673,15 +653,32 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
|
|
|
673
653
|
// TUI Renderer
|
|
674
654
|
// =============================================================================
|
|
675
655
|
|
|
676
|
-
type
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
656
|
+
type TodoRenderOp = {
|
|
657
|
+
op?: string;
|
|
658
|
+
task?: string;
|
|
659
|
+
phase?: string;
|
|
660
|
+
items?: string[];
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
/** New single-op shape `{op,...}`; legacy `{ops:[...]}` still seen in old transcripts. */
|
|
664
|
+
type TodoRenderArgs = TodoRenderOp & {
|
|
665
|
+
ops?: TodoRenderOp[];
|
|
683
666
|
};
|
|
684
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Normalize streaming/legacy render args to a flat op list. Accepts the new
|
|
670
|
+
* top-level `{op,...}` shape (returned as a one-element list), the legacy
|
|
671
|
+
* `{ops:[...]}` batch from old transcripts/collab-web, and partially-parsed
|
|
672
|
+
* streaming deltas (non-array `ops`, non-object entries) without crashing.
|
|
673
|
+
*/
|
|
674
|
+
function normalizeTodoArg(args: TodoRenderArgs | undefined): TodoRenderOp[] {
|
|
675
|
+
if (!args || typeof args !== "object") return [];
|
|
676
|
+
if (Array.isArray(args.ops)) {
|
|
677
|
+
return args.ops.filter((entry): entry is TodoRenderOp => !!entry && typeof entry === "object");
|
|
678
|
+
}
|
|
679
|
+
return typeof args.op === "string" ? [args] : [];
|
|
680
|
+
}
|
|
681
|
+
|
|
685
682
|
// =============================================================================
|
|
686
683
|
// Phase numbering (display-only)
|
|
687
684
|
// =============================================================================
|
|
@@ -794,7 +791,7 @@ function computeTouchedPhases(
|
|
|
794
791
|
for (const transition of completedTasks) touched.add(transition.phase);
|
|
795
792
|
// Phases explicitly named by the ops that ran. `init` replaces the whole
|
|
796
793
|
// list, so the entire plan is fresh and every phase counts as touched.
|
|
797
|
-
const ops =
|
|
794
|
+
const ops = normalizeTodoArg(args);
|
|
798
795
|
for (const op of ops) {
|
|
799
796
|
if (!op || typeof op !== "object") continue;
|
|
800
797
|
if (op.op === "init") {
|
|
@@ -823,18 +820,17 @@ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Th
|
|
|
823
820
|
|
|
824
821
|
export const todoToolRenderer = {
|
|
825
822
|
renderCall(args: TodoRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
|
|
826
|
-
// `args`
|
|
827
|
-
//
|
|
828
|
-
// `parseStreamingJson` can hand back `{
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
const opsList =
|
|
823
|
+
// `args` is the raw partially-parsed JSON from the streaming tool-call
|
|
824
|
+
// delta and may not satisfy `TodoRenderArgs` at runtime:
|
|
825
|
+
// `parseStreamingJson` can hand back `{ op: 1 }` mid-delta, or a legacy
|
|
826
|
+
// `{ ops: "[" }` shape before fields stream. `normalizeTodoArg` guards
|
|
827
|
+
// both the new single-op and legacy batch shapes so a malformed delta
|
|
828
|
+
// never breaks the TUI render loop (#2005).
|
|
829
|
+
const opsList = normalizeTodoArg(args);
|
|
833
830
|
const ops =
|
|
834
831
|
opsList.length === 0
|
|
835
832
|
? ["update"]
|
|
836
|
-
: opsList.map(
|
|
837
|
-
const e = entry && typeof entry === "object" ? entry : ({} as NonNullable<typeof entry>);
|
|
833
|
+
: opsList.map(e => {
|
|
838
834
|
const parts = [e.op ?? "update"];
|
|
839
835
|
if (e.task) parts.push(e.task);
|
|
840
836
|
if (e.phase) parts.push(e.phase);
|
package/src/tui/code-cell.ts
CHANGED
|
@@ -80,7 +80,7 @@ function formatHeader(options: CodeCellOptions, theme: Theme): { title: string;
|
|
|
80
80
|
parts.push(icon);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
if (index !== undefined && total !== undefined) {
|
|
83
|
+
if (index !== undefined && total !== undefined && total > 1) {
|
|
84
84
|
parts.push(theme.fg("accent", `[${index + 1}/${total}]`));
|
|
85
85
|
}
|
|
86
86
|
if (title) {
|
package/src/tui/output-block.ts
CHANGED
|
@@ -46,6 +46,17 @@ function normalizeContentPaddingLeft(value: number | undefined): number {
|
|
|
46
46
|
return Math.max(0, Math.floor(value));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Inner content width that {@link renderOutputBlock} wraps its body to, for a
|
|
51
|
+
* given outer `width`: both vertical borders (1 cell each) plus the left
|
|
52
|
+
* content padding. Renderers that size a tail window MUST budget visual rows
|
|
53
|
+
* against this, not the outer width — otherwise the block re-wraps their lines
|
|
54
|
+
* into more rows than they counted and the box overflows its intended height.
|
|
55
|
+
*/
|
|
56
|
+
export function outputBlockContentWidth(width: number, contentPaddingLeft?: number): number {
|
|
57
|
+
return Math.max(1, width - 2 - normalizeContentPaddingLeft(contentPaddingLeft));
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
|
|
50
61
|
const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
|
|
51
62
|
const h = theme.boxRound.horizontal;
|
|
@@ -3,6 +3,8 @@ import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
|
3
3
|
export interface ImageResizeOptions {
|
|
4
4
|
maxWidth?: number;
|
|
5
5
|
maxHeight?: number;
|
|
6
|
+
/** Smallest allowed edge length (px). Inputs below this are scaled up. */
|
|
7
|
+
minDimension?: number;
|
|
6
8
|
maxBytes?: number;
|
|
7
9
|
jpegQuality?: number;
|
|
8
10
|
excludeWebP?: boolean;
|
|
@@ -23,6 +25,13 @@ export interface ResizedImage {
|
|
|
23
25
|
// binding constraint once images are downsized to 1568px (Anthropic's internal threshold).
|
|
24
26
|
const DEFAULT_MAX_BYTES = 500 * 1024;
|
|
25
27
|
|
|
28
|
+
// Smallest edge length (px) vision backends reliably accept. They tile images into
|
|
29
|
+
// fixed patches (Anthropic uses 28px) and reject degenerate sub-patch images — e.g.
|
|
30
|
+
// the 1x1 PNG an empty chart render emits — with a hard 400 ("Could not process
|
|
31
|
+
// image") that can poison the whole request. 200px is the smallest size Anthropic
|
|
32
|
+
// documents as valid (200x200 = 64 visual tokens); undersized images are scaled up.
|
|
33
|
+
const DEFAULT_MIN_DIMENSION = 200;
|
|
34
|
+
|
|
26
35
|
const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
|
|
27
36
|
// Anthropic's "internal recommended size" — Claude internally caps images at
|
|
28
37
|
// 1568px on the longest edge before vision processing.
|
|
@@ -30,6 +39,7 @@ const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
|
|
|
30
39
|
maxHeight: 1568,
|
|
31
40
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
32
41
|
jpegQuality: 80,
|
|
42
|
+
minDimension: DEFAULT_MIN_DIMENSION,
|
|
33
43
|
};
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -87,7 +97,12 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
87
97
|
// still get JPEG-compressed.
|
|
88
98
|
const originalSize = inputBuffer.length;
|
|
89
99
|
const comfortableSize = opts.maxBytes / 4;
|
|
100
|
+
// Clamp the floor to the caps so an unusually small max can't demand an
|
|
101
|
+
// impossible "≥ min and ≤ max" target.
|
|
102
|
+
const minDimension = Math.min(opts.minDimension, opts.maxWidth, opts.maxHeight);
|
|
90
103
|
if (
|
|
104
|
+
originalWidth >= minDimension &&
|
|
105
|
+
originalHeight >= minDimension &&
|
|
91
106
|
originalWidth <= opts.maxWidth &&
|
|
92
107
|
originalHeight <= opts.maxHeight &&
|
|
93
108
|
originalSize <= comfortableSize &&
|
|
@@ -120,6 +135,21 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
120
135
|
targetHeight = opts.maxHeight;
|
|
121
136
|
}
|
|
122
137
|
|
|
138
|
+
// Lift undersized inputs up to the minimum. A uniform scale covers the
|
|
139
|
+
// common case (icons, the 1x1 chart) without distortion; an aspect ratio
|
|
140
|
+
// too extreme to satisfy both floor and cap falls back to stretching the
|
|
141
|
+
// lagging edge up to the floor via the default fit:"fill" resize.
|
|
142
|
+
if (targetWidth < minDimension || targetHeight < minDimension) {
|
|
143
|
+
const shortEdge = Math.min(targetWidth, targetHeight);
|
|
144
|
+
const upscale = Math.min(minDimension / shortEdge, opts.maxWidth / targetWidth, opts.maxHeight / targetHeight);
|
|
145
|
+
if (upscale > 1) {
|
|
146
|
+
targetWidth = Math.round(targetWidth * upscale);
|
|
147
|
+
targetHeight = Math.round(targetHeight * upscale);
|
|
148
|
+
}
|
|
149
|
+
targetWidth = Math.min(opts.maxWidth, Math.max(minDimension, targetWidth));
|
|
150
|
+
targetHeight = Math.min(opts.maxHeight, Math.max(minDimension, targetHeight));
|
|
151
|
+
}
|
|
152
|
+
|
|
123
153
|
// First-attempt encoder: try PNG and JPEG (+ WebP if not excluded) — return smallest.
|
|
124
154
|
// PNG wins for line art / few-color UI; JPEG wins for photographic content;
|
|
125
155
|
// WebP usually beats JPEG by 25–35% but is disabled when OMP_NO_WEBP is set
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* them into a combined `answer` string on the SearchResponse.
|
|
8
8
|
*/
|
|
9
9
|
import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
|
10
|
-
import { settings } from "../../../config/settings";
|
|
10
|
+
import { getDefault, settings } from "../../../config/settings";
|
|
11
11
|
import { findApiKey, isSearchResponse } from "../../../exa/mcp-client";
|
|
12
12
|
import { parseSSE } from "../../../mcp/json-rpc";
|
|
13
13
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
@@ -18,6 +18,88 @@ import { SearchProvider } from "./base";
|
|
|
18
18
|
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
19
19
|
|
|
20
20
|
const EXA_API_URL = "https://api.exa.ai/search";
|
|
21
|
+
const DEFAULT_EXA_SEARCH_DELAY_MS = getDefault("exa.searchDelayMs");
|
|
22
|
+
|
|
23
|
+
let nextExaSearchRequestAt = 0;
|
|
24
|
+
let exaSearchThrottle = Promise.resolve();
|
|
25
|
+
|
|
26
|
+
function configuredExaSearchDelayMs(): number {
|
|
27
|
+
try {
|
|
28
|
+
const delayMs = settings.get("exa.searchDelayMs");
|
|
29
|
+
return Number.isFinite(delayMs) && delayMs > 0 ? Math.floor(delayMs) : 0;
|
|
30
|
+
} catch {
|
|
31
|
+
return DEFAULT_EXA_SEARCH_DELAY_MS;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rejectWithAbortReason(reject: (reason?: unknown) => void, signal: AbortSignal): void {
|
|
36
|
+
try {
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
reject(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
45
|
+
if (ms <= 0) return Promise.resolve();
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
48
|
+
let timer: NodeJS.Timeout | undefined;
|
|
49
|
+
const cleanup = (): void => {
|
|
50
|
+
if (timer) {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
timer = undefined;
|
|
53
|
+
}
|
|
54
|
+
signal?.removeEventListener("abort", onAbort);
|
|
55
|
+
};
|
|
56
|
+
const onAbort = (): void => {
|
|
57
|
+
cleanup();
|
|
58
|
+
if (signal) rejectWithAbortReason(reject, signal);
|
|
59
|
+
};
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
cleanup();
|
|
62
|
+
resolve();
|
|
63
|
+
}, ms);
|
|
64
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
65
|
+
if (signal?.aborted) onAbort();
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function waitUntilDoneOrAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
70
|
+
if (!signal) return promise;
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
const { promise: aborted, reject } = Promise.withResolvers<never>();
|
|
73
|
+
const onAbort = (): void => rejectWithAbortReason(reject, signal);
|
|
74
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
75
|
+
return Promise.race([promise, aborted]).finally(() => {
|
|
76
|
+
signal.removeEventListener("abort", onAbort);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise<void> {
|
|
81
|
+
const delayMs = configuredExaSearchDelayMs();
|
|
82
|
+
if (delayMs <= 0) return;
|
|
83
|
+
|
|
84
|
+
const prior = exaSearchThrottle.catch(() => {});
|
|
85
|
+
const queued = prior.then(async () => {
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
const waitMs = Math.max(0, nextExaSearchRequestAt - Date.now());
|
|
88
|
+
if (waitMs > 0) {
|
|
89
|
+
await abortableSleep(waitMs, signal);
|
|
90
|
+
}
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
nextExaSearchRequestAt = Date.now() + delayMs;
|
|
93
|
+
});
|
|
94
|
+
exaSearchThrottle = queued.catch(() => {});
|
|
95
|
+
await waitUntilDoneOrAborted(queued, signal);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Reset Exa request pacing state for isolated provider tests. */
|
|
99
|
+
export function resetExaSearchThrottleForTest(): void {
|
|
100
|
+
nextExaSearchRequestAt = 0;
|
|
101
|
+
exaSearchThrottle = Promise.resolve();
|
|
102
|
+
}
|
|
21
103
|
|
|
22
104
|
type ExaSearchType = "neural" | "fast" | "auto" | "deep";
|
|
23
105
|
|
|
@@ -224,6 +306,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
|
|
|
224
306
|
const body = buildExaRequestBody(params);
|
|
225
307
|
|
|
226
308
|
const fetchImpl = params.fetch ?? fetch;
|
|
309
|
+
await waitForExaSearchSlot(params.signal);
|
|
227
310
|
const response = await fetchImpl(EXA_API_URL, {
|
|
228
311
|
method: "POST",
|
|
229
312
|
headers: {
|
|
@@ -260,6 +343,7 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
|
|
|
260
343
|
if (apiKey) query.set("exaApiKey", apiKey);
|
|
261
344
|
query.set("tools", "web_search_exa");
|
|
262
345
|
const fetchImpl = params.fetch ?? fetch;
|
|
346
|
+
await waitForExaSearchSlot(params.signal);
|
|
263
347
|
const response = await fetchImpl(`https://mcp.exa.ai/mcp?${query.toString()}`, {
|
|
264
348
|
method: "POST",
|
|
265
349
|
headers: {
|