mini-coder 0.5.10 → 0.5.12
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/BENCHMARK.md +408 -0
- package/PROGRESS.md +5 -0
- package/README.md +14 -1
- package/assets/mc-claude-smart.png +0 -0
- package/assets/mc-gpt-smart.png +0 -0
- package/benchmark-loop.sh +19 -0
- package/package.json +1 -1
- package/src/agent.ts +5 -1
- package/src/headless.ts +62 -21
- package/src/index.ts +35 -56
- package/src/prompt.ts +8 -0
- package/src/session-message.ts +393 -0
- package/src/session.ts +102 -396
- package/src/settings.ts +19 -15
- package/src/shared.ts +39 -0
- package/src/submit.ts +3 -25
- package/src/text.ts +71 -0
- package/src/tool-common.ts +91 -0
- package/src/tool-grep.ts +606 -0
- package/src/tool-read.ts +313 -0
- package/src/tool-shell.ts +869 -0
- package/src/tools.ts +186 -995
- package/src/ui/agent.ts +199 -110
- package/src/ui/commands.test.ts +16 -302
- package/src/ui/commands.ts +21 -47
- package/src/ui/conversation.test.ts +263 -1389
- package/src/ui/conversation.ts +496 -151
- package/src/ui/input.test.ts +1 -43
- package/src/ui/runtime.ts +69 -0
- package/src/ui.ts +196 -114
- package/src/ui/agent.test.ts +0 -49
- package/src/ui/help.test.ts +0 -65
- package/src/ui/overlay.test.ts +0 -42
- package/src/ui/render-performance.test.ts +0 -444
- package/src/ui/status.test.ts +0 -489
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Built-in tool implementations: `
|
|
3
|
-
* and `readImage`.
|
|
2
|
+
* Built-in tool implementations: `shell`, `read`, `grep`, `edit`, `todoWrite`,
|
|
3
|
+
* `todoRead`, and `readImage`.
|
|
4
|
+
*
|
|
5
|
+
* Shell, read, and grep live in dedicated modules and are re-exported here so
|
|
6
|
+
* the rest of the codebase can keep a single built-in-tools import surface.
|
|
4
7
|
*
|
|
5
8
|
* Each tool is exposed as a pure-ish execute function that takes typed
|
|
6
9
|
* arguments and a working directory, returning a result object. The pi-ai
|
|
@@ -13,36 +16,55 @@
|
|
|
13
16
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
17
|
import { dirname, extname, isAbsolute, join } from "node:path";
|
|
15
18
|
import type {
|
|
16
|
-
ImageContent,
|
|
17
19
|
Message,
|
|
20
|
+
Static,
|
|
18
21
|
TextContent,
|
|
19
22
|
Tool,
|
|
20
23
|
ToolResultMessage,
|
|
21
24
|
} from "@mariozechner/pi-ai";
|
|
22
25
|
import { Type } from "@mariozechner/pi-ai";
|
|
23
|
-
import type {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
26
|
+
import type { ToolHandler } from "./agent.ts";
|
|
27
|
+
import {
|
|
28
|
+
detectLineEnding,
|
|
29
|
+
normalizeLineEndings,
|
|
30
|
+
type ToolExecResult,
|
|
31
|
+
textResult,
|
|
32
|
+
validateBuiltinToolArgs,
|
|
33
|
+
} from "./tool-common.ts";
|
|
34
|
+
|
|
35
|
+
export type { ToolExecResult } from "./tool-common.ts";
|
|
36
|
+
export {
|
|
37
|
+
DEFAULT_GREP_LIMIT,
|
|
38
|
+
executeGrep,
|
|
39
|
+
type GrepArgs,
|
|
40
|
+
type GrepOpts,
|
|
41
|
+
type GrepResult,
|
|
42
|
+
type GrepResultFile,
|
|
43
|
+
type GrepResultLine,
|
|
44
|
+
grepTool,
|
|
45
|
+
grepToolHandler,
|
|
46
|
+
parseGrepResult,
|
|
47
|
+
} from "./tool-grep.ts";
|
|
48
|
+
export {
|
|
49
|
+
DEFAULT_READ_LIMIT,
|
|
50
|
+
executeRead,
|
|
51
|
+
formatReadContinuationHint,
|
|
52
|
+
parseReadContinuationHint,
|
|
53
|
+
parseReadResult,
|
|
54
|
+
type ReadArgs,
|
|
55
|
+
type ReadContinuationHint,
|
|
56
|
+
type ReadOpts,
|
|
57
|
+
readTool,
|
|
58
|
+
readToolHandler,
|
|
59
|
+
} from "./tool-read.ts";
|
|
60
|
+
export {
|
|
61
|
+
executeShell,
|
|
62
|
+
type ShellArgs,
|
|
63
|
+
type ShellOpts,
|
|
64
|
+
shellTool,
|
|
65
|
+
shellToolHandler,
|
|
66
|
+
truncateOutput,
|
|
67
|
+
} from "./tool-shell.ts";
|
|
46
68
|
|
|
47
69
|
/** Persisted todo status values shown to the user and stored in snapshots. */
|
|
48
70
|
export type TodoStatus = "pending" | "in_progress" | "completed";
|
|
@@ -58,18 +80,36 @@ export interface TodoItem {
|
|
|
58
80
|
status: TodoStatus;
|
|
59
81
|
}
|
|
60
82
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
83
|
+
const todoWriteToolParameters = Type.Object({
|
|
84
|
+
todos: Type.Array(
|
|
85
|
+
Type.Object({
|
|
86
|
+
content: Type.String({
|
|
87
|
+
description: "Task description used as the matching key",
|
|
88
|
+
}),
|
|
89
|
+
status: Type.Union(
|
|
90
|
+
[
|
|
91
|
+
Type.Literal("pending"),
|
|
92
|
+
Type.Literal("in_progress"),
|
|
93
|
+
Type.Literal("completed"),
|
|
94
|
+
Type.Literal("cancelled"),
|
|
95
|
+
],
|
|
96
|
+
{
|
|
97
|
+
description:
|
|
98
|
+
"Task status. Use `cancelled` to remove the item entirely.",
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
}),
|
|
102
|
+
{
|
|
103
|
+
description:
|
|
104
|
+
"List of todo items to create, update, or remove. Only send the items that changed.",
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
});
|
|
67
108
|
|
|
68
109
|
/** Arguments for the `todoWrite` tool. */
|
|
69
|
-
export
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
110
|
+
export type TodoWriteArgs = Static<typeof todoWriteToolParameters>;
|
|
111
|
+
|
|
112
|
+
const todoReadToolParameters = Type.Object({});
|
|
73
113
|
|
|
74
114
|
const MAX_TODO_CONTENT_LENGTH = 1_000;
|
|
75
115
|
|
|
@@ -229,26 +269,6 @@ export function executeTodoRead(
|
|
|
229
269
|
return textResult(formatTodoSnapshot(getTodoItems(messages)), false);
|
|
230
270
|
}
|
|
231
271
|
|
|
232
|
-
function detectLineEnding(content: string): "\n" | "\r\n" | null {
|
|
233
|
-
if (content.includes("\r\n")) {
|
|
234
|
-
return "\r\n";
|
|
235
|
-
}
|
|
236
|
-
if (content.includes("\n")) {
|
|
237
|
-
return "\n";
|
|
238
|
-
}
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function normalizeLineEndings(
|
|
243
|
-
content: string,
|
|
244
|
-
lineEnding: "\n" | "\r\n",
|
|
245
|
-
): string {
|
|
246
|
-
if (lineEnding === "\r\n") {
|
|
247
|
-
return content.replace(/\r?\n/g, "\r\n");
|
|
248
|
-
}
|
|
249
|
-
return content.replace(/\r\n/g, "\n");
|
|
250
|
-
}
|
|
251
|
-
|
|
252
272
|
const MAX_EDIT_ERROR_MATCHES = 3;
|
|
253
273
|
const MAX_EDIT_ERROR_SNIPPET_LINES = 8;
|
|
254
274
|
const MAX_EDIT_ERROR_SNIPPET_LINE_CHARS = 160;
|
|
@@ -567,15 +587,21 @@ function formatEditMultipleMatchesError(
|
|
|
567
587
|
// edit
|
|
568
588
|
// ---------------------------------------------------------------------------
|
|
569
589
|
|
|
590
|
+
const editToolParameters = Type.Object({
|
|
591
|
+
path: Type.String({
|
|
592
|
+
description: "File path (absolute or relative to cwd)",
|
|
593
|
+
}),
|
|
594
|
+
oldText: Type.String({
|
|
595
|
+
description:
|
|
596
|
+
'Exact text to find and replace. Empty string means "create new file".',
|
|
597
|
+
}),
|
|
598
|
+
newText: Type.String({
|
|
599
|
+
description: "Replacement text (or full content for new files)",
|
|
600
|
+
}),
|
|
601
|
+
});
|
|
602
|
+
|
|
570
603
|
/** Arguments for the `edit` tool. */
|
|
571
|
-
|
|
572
|
-
/** File path (absolute or relative to cwd). */
|
|
573
|
-
path: string;
|
|
574
|
-
/** Exact text to find. Empty string means "create new file". */
|
|
575
|
-
oldText: string;
|
|
576
|
-
/** Replacement text (or full content for new files). */
|
|
577
|
-
newText: string;
|
|
578
|
-
}
|
|
604
|
+
export type EditArgs = Static<typeof editToolParameters>;
|
|
579
605
|
|
|
580
606
|
/**
|
|
581
607
|
* Execute an exact-text replacement in a single file.
|
|
@@ -660,927 +686,6 @@ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
|
|
|
660
686
|
return textResult(`Edited ${args.path}`, false);
|
|
661
687
|
}
|
|
662
688
|
|
|
663
|
-
// ---------------------------------------------------------------------------
|
|
664
|
-
// shell
|
|
665
|
-
// ---------------------------------------------------------------------------
|
|
666
|
-
|
|
667
|
-
/** Arguments for the `shell` tool. */
|
|
668
|
-
interface ShellArgs {
|
|
669
|
-
/** The command to run. */
|
|
670
|
-
command: string;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/** Options for shell execution. */
|
|
674
|
-
interface ShellOpts {
|
|
675
|
-
/** Maximum output lines before truncation. Default: 1000. */
|
|
676
|
-
maxLines?: number;
|
|
677
|
-
/** Maximum UTF-8 bytes before truncation. Default: 50_000. */
|
|
678
|
-
maxBytes?: number;
|
|
679
|
-
/** Abort signal to cancel the command. */
|
|
680
|
-
signal?: AbortSignal;
|
|
681
|
-
/** Callback for progressive output updates while the command is running. */
|
|
682
|
-
onUpdate?: ToolUpdateCallback;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
type ShellProcess = ReturnType<typeof Bun.spawn>;
|
|
686
|
-
|
|
687
|
-
const DEFAULT_MAX_LINES = 1000;
|
|
688
|
-
const DEFAULT_MAX_BYTES = 50_000;
|
|
689
|
-
const SHELL_UPDATE_INTERVAL_MS = 75;
|
|
690
|
-
const SHELL_STREAM_DRAIN_TIMEOUT_MS = 25;
|
|
691
|
-
|
|
692
|
-
/** Format combined stdout/stderr for display in tool results. */
|
|
693
|
-
function formatShellOutput(stdout: string, stderr: string): string {
|
|
694
|
-
if (stdout && stderr) {
|
|
695
|
-
return `${stdout}\n\n[stderr]\n${stderr}`;
|
|
696
|
-
}
|
|
697
|
-
if (stdout) {
|
|
698
|
-
return stdout;
|
|
699
|
-
}
|
|
700
|
-
if (stderr) {
|
|
701
|
-
return `[stderr]\n${stderr}`;
|
|
702
|
-
}
|
|
703
|
-
return "";
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
interface ShellCommandLines {
|
|
707
|
-
lines: string[];
|
|
708
|
-
lineEnding: "\n" | "\r\n";
|
|
709
|
-
hasTrailingLineEnding: boolean;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
interface PendingHeredoc {
|
|
713
|
-
startLineIndex: number;
|
|
714
|
-
delimiter: string;
|
|
715
|
-
stripLeadingTabs: boolean;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
interface ShellQuoteState {
|
|
719
|
-
quote: "'" | '"' | null;
|
|
720
|
-
escaped: boolean;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
function splitShellCommandLines(command: string): ShellCommandLines {
|
|
724
|
-
const lineEnding = detectLineEnding(command) ?? "\n";
|
|
725
|
-
const normalized = normalizeLineEndings(command, "\n");
|
|
726
|
-
const hasTrailingLineEnding = normalized.endsWith("\n");
|
|
727
|
-
const lines = normalized.split("\n");
|
|
728
|
-
if (hasTrailingLineEnding) {
|
|
729
|
-
lines.pop();
|
|
730
|
-
}
|
|
731
|
-
return { lines, lineEnding, hasTrailingLineEnding };
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
function joinShellCommandLines(parts: ShellCommandLines): string {
|
|
735
|
-
const joined = parts.lines.join(parts.lineEnding);
|
|
736
|
-
if (parts.hasTrailingLineEnding) {
|
|
737
|
-
return joined + parts.lineEnding;
|
|
738
|
-
}
|
|
739
|
-
return joined;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
function advanceShellQuoteState(char: string, state: ShellQuoteState): boolean {
|
|
743
|
-
if (state.quote === "'") {
|
|
744
|
-
if (char === "'") {
|
|
745
|
-
state.quote = null;
|
|
746
|
-
}
|
|
747
|
-
return true;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (state.quote === '"') {
|
|
751
|
-
if (state.escaped) {
|
|
752
|
-
state.escaped = false;
|
|
753
|
-
return true;
|
|
754
|
-
}
|
|
755
|
-
if (char === "\\") {
|
|
756
|
-
state.escaped = true;
|
|
757
|
-
return true;
|
|
758
|
-
}
|
|
759
|
-
if (char === '"') {
|
|
760
|
-
state.quote = null;
|
|
761
|
-
}
|
|
762
|
-
return true;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
if (char === "'") {
|
|
766
|
-
state.quote = "'";
|
|
767
|
-
return true;
|
|
768
|
-
}
|
|
769
|
-
if (char === '"') {
|
|
770
|
-
state.quote = '"';
|
|
771
|
-
return true;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
return false;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
function isHeredocPrefixCharacter(char: string): boolean {
|
|
778
|
-
return (
|
|
779
|
-
char === "" ||
|
|
780
|
-
char === " " ||
|
|
781
|
-
char === "\t" ||
|
|
782
|
-
char === ";" ||
|
|
783
|
-
char === "(" ||
|
|
784
|
-
char === "&" ||
|
|
785
|
-
char === "|"
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function getHeredocStartAt(
|
|
790
|
-
line: string,
|
|
791
|
-
index: number,
|
|
792
|
-
): { index: number; stripLeadingTabs: boolean } | null {
|
|
793
|
-
if (line[index] !== "<" || line[index + 1] !== "<") {
|
|
794
|
-
return null;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
const previousChar = index === 0 ? "" : (line[index - 1] ?? "");
|
|
798
|
-
if (!isHeredocPrefixCharacter(previousChar)) {
|
|
799
|
-
return null;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
return {
|
|
803
|
-
index,
|
|
804
|
-
stripLeadingTabs: line[index + 2] === "-",
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
function findUnquotedHeredocStart(
|
|
809
|
-
line: string,
|
|
810
|
-
): { index: number; stripLeadingTabs: boolean } | null {
|
|
811
|
-
const quoteState: ShellQuoteState = { quote: null, escaped: false };
|
|
812
|
-
let heredocStart: { index: number; stripLeadingTabs: boolean } | null = null;
|
|
813
|
-
|
|
814
|
-
for (let index = 0; index < line.length - 1; index++) {
|
|
815
|
-
const char = line[index];
|
|
816
|
-
if (char === undefined || advanceShellQuoteState(char, quoteState)) {
|
|
817
|
-
continue;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
const nextHeredocStart = getHeredocStartAt(line, index);
|
|
821
|
-
if (!nextHeredocStart) {
|
|
822
|
-
continue;
|
|
823
|
-
}
|
|
824
|
-
if (heredocStart) {
|
|
825
|
-
return null;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
heredocStart = nextHeredocStart;
|
|
829
|
-
index += heredocStart.stripLeadingTabs ? 2 : 1;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
return heredocStart;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
function skipHeredocDelimiterWhitespace(line: string, cursor: number): number {
|
|
836
|
-
let nextCursor = cursor;
|
|
837
|
-
while (line[nextCursor] === " " || line[nextCursor] === "\t") {
|
|
838
|
-
nextCursor++;
|
|
839
|
-
}
|
|
840
|
-
return nextCursor;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function readQuotedHeredocDelimiter(
|
|
844
|
-
line: string,
|
|
845
|
-
cursor: number,
|
|
846
|
-
): string | null {
|
|
847
|
-
const quote = line[cursor];
|
|
848
|
-
if (quote !== "'" && quote !== '"') {
|
|
849
|
-
return null;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const endQuoteIndex = line.indexOf(quote, cursor + 1);
|
|
853
|
-
if (endQuoteIndex === -1) {
|
|
854
|
-
return null;
|
|
855
|
-
}
|
|
856
|
-
return line.slice(cursor + 1, endQuoteIndex);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
function isHeredocDelimiterStopCharacter(char: string): boolean {
|
|
860
|
-
return (
|
|
861
|
-
char === " " ||
|
|
862
|
-
char === "\t" ||
|
|
863
|
-
char === "<" ||
|
|
864
|
-
char === ">" ||
|
|
865
|
-
char === "&" ||
|
|
866
|
-
char === "|" ||
|
|
867
|
-
char === ";" ||
|
|
868
|
-
char === "(" ||
|
|
869
|
-
char === ")"
|
|
870
|
-
);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function readBareHeredocDelimiter(line: string, cursor: number): string | null {
|
|
874
|
-
const startChar = line[cursor];
|
|
875
|
-
if (startChar === undefined || !/[A-Za-z_]/.test(startChar)) {
|
|
876
|
-
return null;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
let endIndex = cursor;
|
|
880
|
-
while (endIndex < line.length) {
|
|
881
|
-
const currentChar = line[endIndex];
|
|
882
|
-
if (
|
|
883
|
-
currentChar === undefined ||
|
|
884
|
-
isHeredocDelimiterStopCharacter(currentChar)
|
|
885
|
-
) {
|
|
886
|
-
break;
|
|
887
|
-
}
|
|
888
|
-
endIndex++;
|
|
889
|
-
}
|
|
890
|
-
return line.slice(cursor, endIndex);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
function findUnquotedHeredoc(
|
|
894
|
-
line: string,
|
|
895
|
-
startLineIndex: number,
|
|
896
|
-
): PendingHeredoc | null {
|
|
897
|
-
const heredocStart = findUnquotedHeredocStart(line);
|
|
898
|
-
if (!heredocStart) {
|
|
899
|
-
return null;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
const cursor = skipHeredocDelimiterWhitespace(
|
|
903
|
-
line,
|
|
904
|
-
heredocStart.index + 2 + (heredocStart.stripLeadingTabs ? 1 : 0),
|
|
905
|
-
);
|
|
906
|
-
const delimiter =
|
|
907
|
-
readQuotedHeredocDelimiter(line, cursor) ??
|
|
908
|
-
readBareHeredocDelimiter(line, cursor);
|
|
909
|
-
if (!delimiter) {
|
|
910
|
-
return null;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
return {
|
|
914
|
-
startLineIndex,
|
|
915
|
-
delimiter,
|
|
916
|
-
stripLeadingTabs: heredocStart.stripLeadingTabs,
|
|
917
|
-
};
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
function getHeredocLineBody(line: string, stripLeadingTabs: boolean): string {
|
|
921
|
-
if (!stripLeadingTabs) {
|
|
922
|
-
return line;
|
|
923
|
-
}
|
|
924
|
-
return line.replace(/^\t+/, "");
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
function getSupportedHeredocTrailer(rest: string): string | null {
|
|
928
|
-
const trimmedRest = rest.trimStart();
|
|
929
|
-
if (!trimmedRest) {
|
|
930
|
-
return null;
|
|
931
|
-
}
|
|
932
|
-
if (trimmedRest.startsWith("&&")) {
|
|
933
|
-
return trimmedRest.slice(2).trim() ? rest : null;
|
|
934
|
-
}
|
|
935
|
-
if (trimmedRest.startsWith("||")) {
|
|
936
|
-
return null;
|
|
937
|
-
}
|
|
938
|
-
if (trimmedRest.startsWith("|")) {
|
|
939
|
-
return trimmedRest.slice(1).trim() ? rest : null;
|
|
940
|
-
}
|
|
941
|
-
if (trimmedRest.startsWith(">")) {
|
|
942
|
-
return trimmedRest.slice(1).trim() ? rest : null;
|
|
943
|
-
}
|
|
944
|
-
return null;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function rewritePendingHeredocTrailer(
|
|
948
|
-
parts: ShellCommandLines,
|
|
949
|
-
line: string,
|
|
950
|
-
lineIndex: number,
|
|
951
|
-
pendingHeredoc: PendingHeredoc,
|
|
952
|
-
): PendingHeredoc | null {
|
|
953
|
-
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
954
|
-
if (body === pendingHeredoc.delimiter) {
|
|
955
|
-
return null;
|
|
956
|
-
}
|
|
957
|
-
if (!body.startsWith(pendingHeredoc.delimiter)) {
|
|
958
|
-
return pendingHeredoc;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
const trailer = getSupportedHeredocTrailer(
|
|
962
|
-
body.slice(pendingHeredoc.delimiter.length),
|
|
963
|
-
);
|
|
964
|
-
if (!trailer) {
|
|
965
|
-
return pendingHeredoc;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
const startLine = parts.lines[pendingHeredoc.startLineIndex];
|
|
969
|
-
if (startLine === undefined) {
|
|
970
|
-
return pendingHeredoc;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
parts.lines[pendingHeredoc.startLineIndex] = startLine + trailer;
|
|
974
|
-
const leadingTabs = pendingHeredoc.stripLeadingTabs
|
|
975
|
-
? (line.match(/^\t*/) ?? [""])[0]
|
|
976
|
-
: "";
|
|
977
|
-
parts.lines[lineIndex] = `${leadingTabs}${pendingHeredoc.delimiter}`;
|
|
978
|
-
return null;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
function normalizeHeredocTrailingContinuations(command: string): string {
|
|
982
|
-
const parts = splitShellCommandLines(command);
|
|
983
|
-
let pendingHeredoc: PendingHeredoc | null = null;
|
|
984
|
-
|
|
985
|
-
for (const [index, line] of parts.lines.entries()) {
|
|
986
|
-
if (pendingHeredoc) {
|
|
987
|
-
pendingHeredoc = rewritePendingHeredocTrailer(
|
|
988
|
-
parts,
|
|
989
|
-
line,
|
|
990
|
-
index,
|
|
991
|
-
pendingHeredoc,
|
|
992
|
-
);
|
|
993
|
-
continue;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
pendingHeredoc = findUnquotedHeredoc(line, index);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
return joinShellCommandLines(parts);
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function normalizeLeadingDashPrintf(command: string): string {
|
|
1003
|
-
const parts = splitShellCommandLines(command);
|
|
1004
|
-
let pendingHeredoc: PendingHeredoc | null = null;
|
|
1005
|
-
|
|
1006
|
-
for (const [index, line] of parts.lines.entries()) {
|
|
1007
|
-
if (pendingHeredoc) {
|
|
1008
|
-
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
1009
|
-
if (body === pendingHeredoc.delimiter) {
|
|
1010
|
-
pendingHeredoc = null;
|
|
1011
|
-
}
|
|
1012
|
-
continue;
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
parts.lines[index] = line.replace(
|
|
1016
|
-
/^(\s*)printf(\s+)(['"])-/,
|
|
1017
|
-
"$1printf$2-- $3-",
|
|
1018
|
-
);
|
|
1019
|
-
pendingHeredoc = findUnquotedHeredoc(parts.lines[index] || "", index);
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
return joinShellCommandLines(parts);
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
function normalizeShellCommand(command: string): string {
|
|
1026
|
-
try {
|
|
1027
|
-
return normalizeLeadingDashPrintf(
|
|
1028
|
-
normalizeHeredocTrailingContinuations(command),
|
|
1029
|
-
);
|
|
1030
|
-
} catch {
|
|
1031
|
-
return command;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
interface ShellStreamCapture {
|
|
1036
|
-
done: Promise<void>;
|
|
1037
|
-
getOutput: () => string;
|
|
1038
|
-
isFinished: () => boolean;
|
|
1039
|
-
close: () => Promise<void>;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
function startShellStreamCapture(
|
|
1043
|
-
stream: ReadableStream<Uint8Array>,
|
|
1044
|
-
onChunk: (chunk: string) => void,
|
|
1045
|
-
): ShellStreamCapture {
|
|
1046
|
-
const reader = stream.getReader();
|
|
1047
|
-
const decoder = new TextDecoder();
|
|
1048
|
-
let output = "";
|
|
1049
|
-
let closed = false;
|
|
1050
|
-
let finished = false;
|
|
1051
|
-
|
|
1052
|
-
const done = (async (): Promise<void> => {
|
|
1053
|
-
try {
|
|
1054
|
-
while (true) {
|
|
1055
|
-
const { done, value } = await reader.read();
|
|
1056
|
-
if (done) {
|
|
1057
|
-
break;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
1061
|
-
output += chunk;
|
|
1062
|
-
onChunk(chunk);
|
|
1063
|
-
}
|
|
1064
|
-
} catch (error) {
|
|
1065
|
-
if (!closed) {
|
|
1066
|
-
throw error;
|
|
1067
|
-
}
|
|
1068
|
-
} finally {
|
|
1069
|
-
const trailing = decoder.decode();
|
|
1070
|
-
output += trailing;
|
|
1071
|
-
onChunk(trailing);
|
|
1072
|
-
finished = true;
|
|
1073
|
-
}
|
|
1074
|
-
})();
|
|
1075
|
-
|
|
1076
|
-
return {
|
|
1077
|
-
done,
|
|
1078
|
-
getOutput: () => output,
|
|
1079
|
-
isFinished: () => finished,
|
|
1080
|
-
close: async (): Promise<void> => {
|
|
1081
|
-
if (!finished) {
|
|
1082
|
-
closed = true;
|
|
1083
|
-
try {
|
|
1084
|
-
await reader.cancel();
|
|
1085
|
-
} catch {
|
|
1086
|
-
// Ignore cancellation errors while closing the pipe after exit/abort.
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
await done;
|
|
1090
|
-
},
|
|
1091
|
-
};
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
async function finalizeShellStreamCaptures(
|
|
1095
|
-
captures: readonly ShellStreamCapture[],
|
|
1096
|
-
): Promise<void> {
|
|
1097
|
-
const pending = captures
|
|
1098
|
-
.filter((capture) => !capture.isFinished())
|
|
1099
|
-
.map((capture) => capture.done);
|
|
1100
|
-
|
|
1101
|
-
if (pending.length > 0) {
|
|
1102
|
-
await new Promise<void>((resolve) => {
|
|
1103
|
-
const timer = setTimeout(resolve, SHELL_STREAM_DRAIN_TIMEOUT_MS);
|
|
1104
|
-
void Promise.allSettled(pending).then(() => {
|
|
1105
|
-
clearTimeout(timer);
|
|
1106
|
-
resolve();
|
|
1107
|
-
});
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
await Promise.all(captures.map((capture) => capture.close()));
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
function buildShellSpawnOptions(cwd: string): Parameters<typeof Bun.spawn>[1] {
|
|
1115
|
-
return {
|
|
1116
|
-
cwd,
|
|
1117
|
-
stdout: "pipe",
|
|
1118
|
-
stderr: "pipe",
|
|
1119
|
-
...(process.platform === "win32" ? {} : { detached: true }),
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
function abortShellProcess(proc: ShellProcess): void {
|
|
1124
|
-
if (proc.killed || proc.exitCode !== null) {
|
|
1125
|
-
return;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
if (process.platform !== "win32") {
|
|
1129
|
-
try {
|
|
1130
|
-
process.kill(-proc.pid, "SIGTERM");
|
|
1131
|
-
return;
|
|
1132
|
-
} catch {
|
|
1133
|
-
// Fall through to a direct kill when the process group is unavailable.
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
proc.kill("SIGTERM");
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function registerShellAbort(
|
|
1141
|
-
signal: AbortSignal | undefined,
|
|
1142
|
-
proc: ShellProcess,
|
|
1143
|
-
): (() => void) | null {
|
|
1144
|
-
if (!signal) {
|
|
1145
|
-
return null;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
const abortListener = (): void => {
|
|
1149
|
-
abortShellProcess(proc);
|
|
1150
|
-
};
|
|
1151
|
-
|
|
1152
|
-
if (signal.aborted) {
|
|
1153
|
-
abortShellProcess(proc);
|
|
1154
|
-
return null;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
signal.addEventListener("abort", abortListener, { once: true });
|
|
1158
|
-
return () => {
|
|
1159
|
-
signal.removeEventListener("abort", abortListener);
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
/**
|
|
1164
|
-
* Run a command in the user's shell.
|
|
1165
|
-
*
|
|
1166
|
-
* Executes via `$SHELL -c` (falling back to `/bin/sh`). Returns combined
|
|
1167
|
-
* stdout/stderr and the exit code. Large output is truncated to keep
|
|
1168
|
-
* head + tail lines with a middle marker.
|
|
1169
|
-
*
|
|
1170
|
-
* @param args - Shell arguments (command).
|
|
1171
|
-
* @param cwd - Working directory to run the command in.
|
|
1172
|
-
* @param opts - Optional execution options (maxLines, signal, onUpdate).
|
|
1173
|
-
* @returns A {@link ToolExecResult} with the command output.
|
|
1174
|
-
*/
|
|
1175
|
-
export async function executeShell(
|
|
1176
|
-
args: ShellArgs,
|
|
1177
|
-
cwd: string,
|
|
1178
|
-
opts?: ShellOpts,
|
|
1179
|
-
): Promise<ToolExecResult> {
|
|
1180
|
-
const shell = process.env.SHELL || "/bin/sh";
|
|
1181
|
-
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
|
|
1182
|
-
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
1183
|
-
let updateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1184
|
-
let cleanupAbort: (() => void) | null = null;
|
|
1185
|
-
let lastReportedOutput = "";
|
|
1186
|
-
let lastReportAt = 0;
|
|
1187
|
-
let stdoutCapture: ShellStreamCapture | null = null;
|
|
1188
|
-
let stderrCapture: ShellStreamCapture | null = null;
|
|
1189
|
-
|
|
1190
|
-
try {
|
|
1191
|
-
const clearPendingUpdate = (): void => {
|
|
1192
|
-
if (updateTimer) {
|
|
1193
|
-
clearTimeout(updateTimer);
|
|
1194
|
-
updateTimer = null;
|
|
1195
|
-
}
|
|
1196
|
-
};
|
|
1197
|
-
|
|
1198
|
-
const buildOutput = (trimEnd: boolean): string => {
|
|
1199
|
-
const stdout = stdoutCapture?.getOutput() ?? "";
|
|
1200
|
-
const stderr = stderrCapture?.getOutput() ?? "";
|
|
1201
|
-
return truncateOutput(
|
|
1202
|
-
formatShellOutput(
|
|
1203
|
-
trimEnd ? stdout.trimEnd() : stdout,
|
|
1204
|
-
trimEnd ? stderr.trimEnd() : stderr,
|
|
1205
|
-
),
|
|
1206
|
-
maxLines,
|
|
1207
|
-
maxBytes,
|
|
1208
|
-
);
|
|
1209
|
-
};
|
|
1210
|
-
|
|
1211
|
-
const emitUpdate = (): void => {
|
|
1212
|
-
clearPendingUpdate();
|
|
1213
|
-
if (!opts?.onUpdate) {
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
const output = buildOutput(false);
|
|
1218
|
-
if (!output || output === lastReportedOutput) {
|
|
1219
|
-
return;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
lastReportedOutput = output;
|
|
1223
|
-
lastReportAt = Date.now();
|
|
1224
|
-
opts.onUpdate(textResult(output, false));
|
|
1225
|
-
};
|
|
1226
|
-
|
|
1227
|
-
const scheduleUpdate = (): void => {
|
|
1228
|
-
if (!opts?.onUpdate) {
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
const elapsed = Date.now() - lastReportAt;
|
|
1233
|
-
if (elapsed >= SHELL_UPDATE_INTERVAL_MS) {
|
|
1234
|
-
emitUpdate();
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
if (updateTimer) {
|
|
1238
|
-
return;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
updateTimer = setTimeout(() => {
|
|
1242
|
-
emitUpdate();
|
|
1243
|
-
}, SHELL_UPDATE_INTERVAL_MS - elapsed);
|
|
1244
|
-
};
|
|
1245
|
-
|
|
1246
|
-
const command = normalizeShellCommand(args.command);
|
|
1247
|
-
const proc = Bun.spawn([shell, "-c", command], buildShellSpawnOptions(cwd));
|
|
1248
|
-
cleanupAbort = registerShellAbort(opts?.signal, proc);
|
|
1249
|
-
stdoutCapture = startShellStreamCapture(
|
|
1250
|
-
proc.stdout as ReadableStream<Uint8Array>,
|
|
1251
|
-
() => {
|
|
1252
|
-
scheduleUpdate();
|
|
1253
|
-
},
|
|
1254
|
-
);
|
|
1255
|
-
stderrCapture = startShellStreamCapture(
|
|
1256
|
-
proc.stderr as ReadableStream<Uint8Array>,
|
|
1257
|
-
() => {
|
|
1258
|
-
scheduleUpdate();
|
|
1259
|
-
},
|
|
1260
|
-
);
|
|
1261
|
-
|
|
1262
|
-
const exitCode = await proc.exited;
|
|
1263
|
-
cleanupAbort?.();
|
|
1264
|
-
cleanupAbort = null;
|
|
1265
|
-
|
|
1266
|
-
await finalizeShellStreamCaptures([stdoutCapture, stderrCapture]);
|
|
1267
|
-
clearPendingUpdate();
|
|
1268
|
-
const output = buildOutput(true);
|
|
1269
|
-
if (opts?.onUpdate && output && output !== lastReportedOutput) {
|
|
1270
|
-
lastReportedOutput = output;
|
|
1271
|
-
opts.onUpdate(textResult(output, false));
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
const isError = exitCode !== 0;
|
|
1275
|
-
const body = output || "(no output)";
|
|
1276
|
-
return textResult(`Exit code: ${exitCode}\n${body}`, isError);
|
|
1277
|
-
} catch (err) {
|
|
1278
|
-
cleanupAbort?.();
|
|
1279
|
-
cleanupAbort = null;
|
|
1280
|
-
const captures = [stdoutCapture, stderrCapture].filter(
|
|
1281
|
-
(capture): capture is ShellStreamCapture => capture !== null,
|
|
1282
|
-
);
|
|
1283
|
-
if (captures.length > 0) {
|
|
1284
|
-
await Promise.allSettled(captures.map((capture) => capture.close()));
|
|
1285
|
-
}
|
|
1286
|
-
if (updateTimer) {
|
|
1287
|
-
clearTimeout(updateTimer);
|
|
1288
|
-
updateTimer = null;
|
|
1289
|
-
}
|
|
1290
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1291
|
-
return textResult(`Shell error: ${message}`, true);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
// ---------------------------------------------------------------------------
|
|
1296
|
-
// Output truncation
|
|
1297
|
-
// ---------------------------------------------------------------------------
|
|
1298
|
-
|
|
1299
|
-
/** Build line-limited head/tail segments and their truncation marker. */
|
|
1300
|
-
function buildLineTruncation(
|
|
1301
|
-
output: string,
|
|
1302
|
-
maxLines: number,
|
|
1303
|
-
): {
|
|
1304
|
-
head: string;
|
|
1305
|
-
tail: string;
|
|
1306
|
-
marker: string;
|
|
1307
|
-
} | null {
|
|
1308
|
-
const lines = output.split("\n");
|
|
1309
|
-
if (lines.length <= maxLines) {
|
|
1310
|
-
return null;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
const headCount = Math.ceil(maxLines / 2);
|
|
1314
|
-
const tailCount = Math.floor(maxLines / 2);
|
|
1315
|
-
const omitted = lines.length - headCount - tailCount;
|
|
1316
|
-
|
|
1317
|
-
return {
|
|
1318
|
-
head: lines.slice(0, headCount).join("\n"),
|
|
1319
|
-
tail: lines.slice(lines.length - tailCount).join("\n"),
|
|
1320
|
-
marker: `\n… truncated ${omitted} lines …\n`,
|
|
1321
|
-
};
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
function isHighSurrogate(codeUnit: number): boolean {
|
|
1325
|
-
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
function isLowSurrogate(codeUnit: number): boolean {
|
|
1329
|
-
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
function findUtf8SliceLength(
|
|
1333
|
-
input: string,
|
|
1334
|
-
maxBytes: number,
|
|
1335
|
-
getCandidate: (length: number) => string,
|
|
1336
|
-
): number {
|
|
1337
|
-
if (maxBytes <= 0 || input === "") {
|
|
1338
|
-
return 0;
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
let low = 0;
|
|
1342
|
-
let high = input.length;
|
|
1343
|
-
while (low < high) {
|
|
1344
|
-
const mid = Math.ceil((low + high) / 2);
|
|
1345
|
-
const candidate = getCandidate(mid);
|
|
1346
|
-
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
|
|
1347
|
-
low = mid;
|
|
1348
|
-
} else {
|
|
1349
|
-
high = mid - 1;
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
return low;
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
function normalizeUtf8PrefixEnd(input: string, end: number): number {
|
|
1357
|
-
if (end <= 0 || end >= input.length) {
|
|
1358
|
-
return end;
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
const previousCodeUnit = input.charCodeAt(end - 1);
|
|
1362
|
-
const nextCodeUnit = input.charCodeAt(end);
|
|
1363
|
-
if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
|
|
1364
|
-
return end - 1;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
return end;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
function normalizeUtf8SuffixStart(input: string, start: number): number {
|
|
1371
|
-
if (start <= 0 || start >= input.length) {
|
|
1372
|
-
return start;
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
const previousCodeUnit = input.charCodeAt(start - 1);
|
|
1376
|
-
const nextCodeUnit = input.charCodeAt(start);
|
|
1377
|
-
if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
|
|
1378
|
-
return start + 1;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
return start;
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
/** Slice the largest UTF-8 prefix that fits within `maxBytes`. */
|
|
1385
|
-
function sliceUtf8Prefix(input: string, maxBytes: number): string {
|
|
1386
|
-
const end = normalizeUtf8PrefixEnd(
|
|
1387
|
-
input,
|
|
1388
|
-
findUtf8SliceLength(input, maxBytes, (length) => input.slice(0, length)),
|
|
1389
|
-
);
|
|
1390
|
-
return input.slice(0, end);
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
/** Slice the largest UTF-8 suffix that fits within `maxBytes`. */
|
|
1394
|
-
function sliceUtf8Suffix(input: string, maxBytes: number): string {
|
|
1395
|
-
const start = normalizeUtf8SuffixStart(
|
|
1396
|
-
input,
|
|
1397
|
-
input.length -
|
|
1398
|
-
findUtf8SliceLength(input, maxBytes, (length) =>
|
|
1399
|
-
input.slice(input.length - length),
|
|
1400
|
-
),
|
|
1401
|
-
);
|
|
1402
|
-
return input.slice(start);
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
/** Fit disjoint head/tail segments plus a marker within a UTF-8 byte budget. */
|
|
1406
|
-
function fitSegmentsWithinBytes(
|
|
1407
|
-
headSource: string,
|
|
1408
|
-
tailSource: string,
|
|
1409
|
-
marker: string,
|
|
1410
|
-
maxBytes: number,
|
|
1411
|
-
): string {
|
|
1412
|
-
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
1413
|
-
if (markerBytes >= maxBytes) {
|
|
1414
|
-
return sliceUtf8Prefix(headSource, maxBytes);
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
const availableBytes = maxBytes - markerBytes;
|
|
1418
|
-
const headBudget = Math.ceil(availableBytes / 2);
|
|
1419
|
-
const tailBudget = Math.floor(availableBytes / 2);
|
|
1420
|
-
|
|
1421
|
-
let head = sliceUtf8Prefix(headSource, headBudget);
|
|
1422
|
-
let tail = sliceUtf8Suffix(tailSource, tailBudget);
|
|
1423
|
-
|
|
1424
|
-
const usedBytes =
|
|
1425
|
-
Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
|
|
1426
|
-
let remainingBytes = availableBytes - usedBytes;
|
|
1427
|
-
|
|
1428
|
-
if (remainingBytes > 0) {
|
|
1429
|
-
const headBytes = Buffer.byteLength(head, "utf8");
|
|
1430
|
-
const expandedHead = sliceUtf8Prefix(
|
|
1431
|
-
headSource,
|
|
1432
|
-
headBytes + remainingBytes,
|
|
1433
|
-
);
|
|
1434
|
-
remainingBytes -= Buffer.byteLength(expandedHead, "utf8") - headBytes;
|
|
1435
|
-
head = expandedHead;
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
if (remainingBytes > 0) {
|
|
1439
|
-
const tailBytes = Buffer.byteLength(tail, "utf8");
|
|
1440
|
-
tail = sliceUtf8Suffix(tailSource, tailBytes + remainingBytes);
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
return head + marker + tail;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
/** Truncate output by UTF-8 byte size, preserving head and tail text. */
|
|
1447
|
-
function truncateOutputByBytes(output: string, maxBytes: number): string {
|
|
1448
|
-
if (Buffer.byteLength(output, "utf8") <= maxBytes) {
|
|
1449
|
-
return output;
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
return fitSegmentsWithinBytes(
|
|
1453
|
-
output,
|
|
1454
|
-
output,
|
|
1455
|
-
"\n… truncated for size …\n",
|
|
1456
|
-
maxBytes,
|
|
1457
|
-
);
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
/**
|
|
1461
|
-
* Truncate output to keep useful head and tail content within line and byte budgets.
|
|
1462
|
-
*
|
|
1463
|
-
* The line budget avoids flooding the model with very tall outputs, while the
|
|
1464
|
-
* byte budget prevents context explosions caused by a small number of very long
|
|
1465
|
-
* lines.
|
|
1466
|
-
*
|
|
1467
|
-
* @param output - The full output string.
|
|
1468
|
-
* @param maxLines - Maximum number of content lines to keep.
|
|
1469
|
-
* @param maxBytes - Maximum UTF-8 bytes to keep.
|
|
1470
|
-
* @returns The (possibly truncated) output string.
|
|
1471
|
-
*/
|
|
1472
|
-
export function truncateOutput(
|
|
1473
|
-
output: string,
|
|
1474
|
-
maxLines: number,
|
|
1475
|
-
maxBytes: number,
|
|
1476
|
-
): string {
|
|
1477
|
-
if (!output) return output;
|
|
1478
|
-
|
|
1479
|
-
const lineTruncation = buildLineTruncation(output, maxLines);
|
|
1480
|
-
if (!lineTruncation) {
|
|
1481
|
-
return truncateOutputByBytes(output, maxBytes);
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
const lineLimited =
|
|
1485
|
-
lineTruncation.head + lineTruncation.marker + lineTruncation.tail;
|
|
1486
|
-
if (Buffer.byteLength(lineLimited, "utf8") <= maxBytes) {
|
|
1487
|
-
return lineLimited;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
return fitSegmentsWithinBytes(
|
|
1491
|
-
lineTruncation.head,
|
|
1492
|
-
lineTruncation.tail,
|
|
1493
|
-
lineTruncation.marker,
|
|
1494
|
-
maxBytes,
|
|
1495
|
-
);
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
// ---------------------------------------------------------------------------
|
|
1499
|
-
// Tool definitions (pi-ai Tool schemas)
|
|
1500
|
-
// ---------------------------------------------------------------------------
|
|
1501
|
-
|
|
1502
|
-
/** pi-ai tool definition for `edit`. */
|
|
1503
|
-
export const editTool: Tool = {
|
|
1504
|
-
name: "edit",
|
|
1505
|
-
description:
|
|
1506
|
-
"Make an exact-text replacement in a single file. " +
|
|
1507
|
-
"Provide the file path, the exact text to find, and the replacement text. " +
|
|
1508
|
-
"The old text must match exactly one location in the file. " +
|
|
1509
|
-
"To create a new file, use an empty old text and the full file content as new text. " +
|
|
1510
|
-
"Use this to write the exact final file content the task requires.",
|
|
1511
|
-
parameters: Type.Object({
|
|
1512
|
-
path: Type.String({
|
|
1513
|
-
description: "File path (absolute or relative to cwd)",
|
|
1514
|
-
}),
|
|
1515
|
-
oldText: Type.String({
|
|
1516
|
-
description:
|
|
1517
|
-
'Exact text to find and replace. Empty string means "create new file".',
|
|
1518
|
-
}),
|
|
1519
|
-
newText: Type.String({
|
|
1520
|
-
description: "Replacement text (or full content for new files)",
|
|
1521
|
-
}),
|
|
1522
|
-
}),
|
|
1523
|
-
};
|
|
1524
|
-
|
|
1525
|
-
/** pi-ai tool definition for `todoWrite`. */
|
|
1526
|
-
export const todoWriteTool: Tool = {
|
|
1527
|
-
name: "todoWrite",
|
|
1528
|
-
description:
|
|
1529
|
-
"Use this tool to create and manage a structured task list for your current coding session. " +
|
|
1530
|
-
"This helps you track progress, organize complex tasks, and keep the user informed. " +
|
|
1531
|
-
"Only send the items that changed; unchanged items stay as they are. " +
|
|
1532
|
-
"Each item must include `content` and `status`, where `status` is one of `pending`, `in_progress`, `completed`, or `cancelled`. " +
|
|
1533
|
-
"Use `cancelled` to remove an item from the list. " +
|
|
1534
|
-
"Mark tasks `in_progress` before starting them and `completed` immediately after verification succeeds.",
|
|
1535
|
-
parameters: Type.Object({
|
|
1536
|
-
todos: Type.Array(
|
|
1537
|
-
Type.Object({
|
|
1538
|
-
content: Type.String({
|
|
1539
|
-
description: "Task description used as the matching key",
|
|
1540
|
-
}),
|
|
1541
|
-
status: Type.Union(
|
|
1542
|
-
[
|
|
1543
|
-
Type.Literal("pending"),
|
|
1544
|
-
Type.Literal("in_progress"),
|
|
1545
|
-
Type.Literal("completed"),
|
|
1546
|
-
Type.Literal("cancelled"),
|
|
1547
|
-
],
|
|
1548
|
-
{
|
|
1549
|
-
description:
|
|
1550
|
-
"Task status. Use `cancelled` to remove the item entirely.",
|
|
1551
|
-
},
|
|
1552
|
-
),
|
|
1553
|
-
}),
|
|
1554
|
-
{
|
|
1555
|
-
description:
|
|
1556
|
-
"List of todo items to create, update, or remove. Only send the items that changed.",
|
|
1557
|
-
},
|
|
1558
|
-
),
|
|
1559
|
-
}),
|
|
1560
|
-
};
|
|
1561
|
-
|
|
1562
|
-
/** pi-ai tool definition for `todoRead`. */
|
|
1563
|
-
export const todoReadTool: Tool = {
|
|
1564
|
-
name: "todoRead",
|
|
1565
|
-
description:
|
|
1566
|
-
"Retrieves the current todo list for this coding session. " +
|
|
1567
|
-
"Use this tool before updating todos when you need to inspect the current list, or when the user asks for the current plan or progress. " +
|
|
1568
|
-
"If no todos exist yet, it returns an empty list.",
|
|
1569
|
-
parameters: Type.Object({}),
|
|
1570
|
-
};
|
|
1571
|
-
|
|
1572
|
-
/** pi-ai tool definition for `shell`. */
|
|
1573
|
-
export const shellTool: Tool = {
|
|
1574
|
-
name: "shell",
|
|
1575
|
-
description:
|
|
1576
|
-
"Run a command in the user's shell. Returns stdout, stderr, and exit code. " +
|
|
1577
|
-
"Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands. " +
|
|
1578
|
-
"Commands mutate the real working directory, so direct verification outputs to temporary paths or clean them up before finishing.",
|
|
1579
|
-
parameters: Type.Object({
|
|
1580
|
-
command: Type.String({ description: "The shell command to execute" }),
|
|
1581
|
-
}),
|
|
1582
|
-
};
|
|
1583
|
-
|
|
1584
689
|
// ---------------------------------------------------------------------------
|
|
1585
690
|
// readImage
|
|
1586
691
|
// ---------------------------------------------------------------------------
|
|
@@ -1594,11 +699,14 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
|
|
|
1594
699
|
".webp": "image/webp",
|
|
1595
700
|
};
|
|
1596
701
|
|
|
702
|
+
const readImageToolParameters = Type.Object({
|
|
703
|
+
path: Type.String({
|
|
704
|
+
description: "File path (absolute or relative to cwd)",
|
|
705
|
+
}),
|
|
706
|
+
});
|
|
707
|
+
|
|
1597
708
|
/** Arguments for the `readImage` tool. */
|
|
1598
|
-
|
|
1599
|
-
/** File path (absolute or relative to cwd). */
|
|
1600
|
-
path: string;
|
|
1601
|
-
}
|
|
709
|
+
export type ReadImageArgs = Static<typeof readImageToolParameters>;
|
|
1602
710
|
|
|
1603
711
|
/**
|
|
1604
712
|
* Read an image file and return it as base64-encoded content.
|
|
@@ -1644,16 +752,99 @@ export function executeReadImage(
|
|
|
1644
752
|
}
|
|
1645
753
|
}
|
|
1646
754
|
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
// Tool definitions (pi-ai Tool schemas)
|
|
757
|
+
// ---------------------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
/** pi-ai tool definition for `edit`. */
|
|
760
|
+
export const editTool: Tool<typeof editToolParameters> = {
|
|
761
|
+
name: "edit",
|
|
762
|
+
description:
|
|
763
|
+
"Make an exact-text replacement in a single file. " +
|
|
764
|
+
"Provide the file path, the exact text to find, and the replacement text. " +
|
|
765
|
+
"The old text must match exactly one location in the file. " +
|
|
766
|
+
"To create a new file, use an empty old text and the full file content as new text. " +
|
|
767
|
+
"Use this to write the exact final file content the task requires.",
|
|
768
|
+
parameters: editToolParameters,
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Tool handler that validates edit arguments before execution.
|
|
773
|
+
*
|
|
774
|
+
* @param args - Raw parsed tool-call arguments.
|
|
775
|
+
* @param cwd - Working directory for path resolution.
|
|
776
|
+
* @returns The edit tool result.
|
|
777
|
+
*/
|
|
778
|
+
export const editToolHandler: ToolHandler = (args, cwd) =>
|
|
779
|
+
executeEdit(validateBuiltinToolArgs(editTool, args), cwd);
|
|
780
|
+
|
|
781
|
+
/** pi-ai tool definition for `todoWrite`. */
|
|
782
|
+
export const todoWriteTool: Tool<typeof todoWriteToolParameters> = {
|
|
783
|
+
name: "todoWrite",
|
|
784
|
+
description:
|
|
785
|
+
"Use this tool to create and manage a structured task list for your current coding session. " +
|
|
786
|
+
"This helps you track progress, organize complex tasks, and keep the user informed. " +
|
|
787
|
+
"Only send the items that changed; unchanged items stay as they are. " +
|
|
788
|
+
"Each item must include `content` and `status`, where `status` is one of `pending`, `in_progress`, `completed`, or `cancelled`. " +
|
|
789
|
+
"Use `cancelled` to remove an item from the list. " +
|
|
790
|
+
"Mark tasks `in_progress` before starting them and `completed` immediately after verification succeeds.",
|
|
791
|
+
parameters: todoWriteToolParameters,
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Create a `todoWrite` handler bound to the current persisted message history.
|
|
796
|
+
*
|
|
797
|
+
* @param messages - Current persisted message history.
|
|
798
|
+
* @returns Tool handler for todo writes.
|
|
799
|
+
*/
|
|
800
|
+
export function createTodoWriteToolHandler(
|
|
801
|
+
messages: readonly TodoHistoryMessage[],
|
|
802
|
+
): ToolHandler {
|
|
803
|
+
return (args) =>
|
|
804
|
+
executeTodoWrite(validateBuiltinToolArgs(todoWriteTool, args), messages);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** pi-ai tool definition for `todoRead`. */
|
|
808
|
+
export const todoReadTool: Tool<typeof todoReadToolParameters> = {
|
|
809
|
+
name: "todoRead",
|
|
810
|
+
description:
|
|
811
|
+
"Retrieves the current todo list for this coding session. " +
|
|
812
|
+
"Use this tool before updating todos when you need to inspect the current list, or when the user asks for the current plan or progress. " +
|
|
813
|
+
"If no todos exist yet, it returns an empty list.",
|
|
814
|
+
parameters: todoReadToolParameters,
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Create a `todoRead` handler bound to the current persisted message history.
|
|
819
|
+
*
|
|
820
|
+
* @param messages - Current persisted message history.
|
|
821
|
+
* @returns Tool handler for todo reads.
|
|
822
|
+
*/
|
|
823
|
+
export function createTodoReadToolHandler(
|
|
824
|
+
messages: readonly TodoHistoryMessage[],
|
|
825
|
+
): ToolHandler {
|
|
826
|
+
return (args) => {
|
|
827
|
+
validateBuiltinToolArgs(todoReadTool, args);
|
|
828
|
+
return executeTodoRead(messages);
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
1647
832
|
/** pi-ai tool definition for `readImage`. */
|
|
1648
|
-
export const readImageTool: Tool = {
|
|
833
|
+
export const readImageTool: Tool<typeof readImageToolParameters> = {
|
|
1649
834
|
name: "readImage",
|
|
1650
835
|
description:
|
|
1651
836
|
"Read an image file and return its contents. " +
|
|
1652
837
|
"Supports PNG, JPEG, GIF, and WebP formats. " +
|
|
1653
838
|
"Use this to inspect screenshots, diagrams, or any image in the repo.",
|
|
1654
|
-
parameters:
|
|
1655
|
-
path: Type.String({
|
|
1656
|
-
description: "File path (absolute or relative to cwd)",
|
|
1657
|
-
}),
|
|
1658
|
-
}),
|
|
839
|
+
parameters: readImageToolParameters,
|
|
1659
840
|
};
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Tool handler that validates image-read arguments before execution.
|
|
844
|
+
*
|
|
845
|
+
* @param args - Raw parsed tool-call arguments.
|
|
846
|
+
* @param cwd - Working directory for path resolution.
|
|
847
|
+
* @returns The readImage tool result.
|
|
848
|
+
*/
|
|
849
|
+
export const readImageToolHandler: ToolHandler = (args, cwd) =>
|
|
850
|
+
executeReadImage(validateBuiltinToolArgs(readImageTool, args), cwd);
|