mini-coder 0.5.11 → 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/src/tools.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Built-in tool implementations: `edit`, `shell`, `todoWrite`, `todoRead`,
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 { ToolUpdateCallback } from "./agent.ts";
24
-
25
- // ---------------------------------------------------------------------------
26
- // Result type
27
- // ---------------------------------------------------------------------------
28
-
29
- /**
30
- * Result from executing a tool.
31
- *
32
- * Content blocks carry either text or image data. The agent loop
33
- * maps these directly into {@link ToolResultMessage.content}.
34
- */
35
- export interface ToolExecResult {
36
- /** Content blocks for the tool result (text and/or images). */
37
- content: (TextContent | ImageContent)[];
38
- /** Whether the execution encountered an error. */
39
- isError: boolean;
40
- }
41
-
42
- /** Convenience: build a text-only {@link ToolExecResult}. */
43
- function textResult(text: string, isError: boolean): ToolExecResult {
44
- return { content: [{ type: "text", text }], isError };
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
- interface TodoWriteInputItem {
62
- /** Task description used as the matching key. */
63
- content: string;
64
- /** Requested next status for the task. */
65
- status: TodoWriteStatus;
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 interface TodoWriteArgs {
70
- /** Todo items to create, update, or remove. */
71
- todos: TodoWriteInputItem[];
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
- interface EditArgs {
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
- interface ReadImageArgs {
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: Type.Object({
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);