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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Read-tool implementation and read-specific helpers.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { existsSync, readFileSync, statSync } from "node:fs";
8
+ import { isAbsolute, join } from "node:path";
9
+ import type { Static, Tool } from "@mariozechner/pi-ai";
10
+ import { Type } from "@mariozechner/pi-ai";
11
+ import type { ToolHandler, ToolUpdateCallback } from "./agent.ts";
12
+ import {
13
+ type ToolExecResult,
14
+ textResult,
15
+ validateBuiltinToolArgs,
16
+ } from "./tool-common.ts";
17
+
18
+ const readToolParameters = Type.Object({
19
+ path: Type.String({
20
+ description: "File path (absolute or relative to cwd)",
21
+ }),
22
+ offset: Type.Optional(
23
+ Type.Integer({
24
+ minimum: 0,
25
+ description: "Zero-based line offset to start reading from",
26
+ }),
27
+ ),
28
+ limit: Type.Optional(
29
+ Type.Integer({
30
+ minimum: 1,
31
+ description: "Maximum number of lines to read in this call",
32
+ }),
33
+ ),
34
+ });
35
+
36
+ /** Default line window returned when `limit` is omitted. */
37
+ export const DEFAULT_READ_LIMIT = 200;
38
+
39
+ /** Number of logical lines to append between progressive UI updates. */
40
+ const READ_STREAM_CHUNK_LINES = 40;
41
+
42
+ /** Arguments for the `read` tool. */
43
+ export type ReadArgs = Static<typeof readToolParameters>;
44
+
45
+ /** Continuation metadata parsed from a successful read result. */
46
+ export interface ReadContinuationHint {
47
+ /** Next zero-based line offset to request. */
48
+ offset: number;
49
+ /** Suggested line limit for the follow-up read. */
50
+ limit: number;
51
+ }
52
+
53
+ /** Options for read execution. */
54
+ export interface ReadOpts {
55
+ /** Abort signal for interruption. */
56
+ signal?: AbortSignal;
57
+ /** Callback for progressive content updates. */
58
+ onUpdate?: ToolUpdateCallback;
59
+ }
60
+
61
+ /** pi-ai tool definition for `read`. */
62
+ export const readTool: Tool<typeof readToolParameters> = {
63
+ name: "read",
64
+ description:
65
+ "Read a UTF-8 text file from disk. " +
66
+ "Takes a file path plus optional line-based `offset` and `limit`. " +
67
+ "Prefer this tool over shelling out to `cat`, `sed`, `head`, or `tail` when you need file contents.",
68
+ parameters: readToolParameters,
69
+ };
70
+
71
+ function resolveReadPath(path: string, cwd: string): string {
72
+ return isAbsolute(path) ? path : join(cwd, path);
73
+ }
74
+
75
+ function splitLinesPreservingEndings(content: string): string[] {
76
+ if (content === "") {
77
+ return [];
78
+ }
79
+
80
+ const lines: string[] = [];
81
+ let start = 0;
82
+
83
+ for (let index = 0; index < content.length; index++) {
84
+ const char = content[index];
85
+ if (char === "\n") {
86
+ lines.push(content.slice(start, index + 1));
87
+ start = index + 1;
88
+ continue;
89
+ }
90
+ if (char === "\r" && content[index + 1] === "\n") {
91
+ lines.push(content.slice(start, index + 2));
92
+ start = index + 2;
93
+ index += 1;
94
+ }
95
+ }
96
+
97
+ if (start < content.length) {
98
+ lines.push(content.slice(start));
99
+ }
100
+
101
+ return lines;
102
+ }
103
+
104
+ function waitForNextTick(): Promise<void> {
105
+ return new Promise((resolve) => setImmediate(resolve));
106
+ }
107
+
108
+ /** Format the trailing continuation hint appended to truncated read results. */
109
+ export function formatReadContinuationHint(
110
+ offset: number,
111
+ limit: number,
112
+ ): string {
113
+ return `[use offset=${offset} limit=${limit} to continue]`;
114
+ }
115
+
116
+ /** Parse a standalone read continuation hint block. */
117
+ export function parseReadContinuationHint(
118
+ text: string,
119
+ ): ReadContinuationHint | null {
120
+ const match = /^\[use offset=(\d+) limit=(\d+) to continue\]$/.exec(text);
121
+ if (!match) {
122
+ return null;
123
+ }
124
+
125
+ const offsetText = match[1];
126
+ const limitText = match[2];
127
+ if (!offsetText || !limitText) {
128
+ return null;
129
+ }
130
+
131
+ return {
132
+ offset: Number.parseInt(offsetText, 10),
133
+ limit: Number.parseInt(limitText, 10),
134
+ };
135
+ }
136
+
137
+ /** Parse a legacy flattened read result into file content plus any continuation hint. */
138
+ export function parseReadResult(text: string): {
139
+ body: string;
140
+ continuation: ReadContinuationHint | null;
141
+ } {
142
+ const trailerMatch =
143
+ /(?:\r?\n){2}(\[use offset=\d+ limit=\d+ to continue\])$/.exec(text);
144
+ if (!trailerMatch) {
145
+ return { body: text, continuation: null };
146
+ }
147
+
148
+ const continuation = parseReadContinuationHint(trailerMatch[1] ?? "");
149
+ if (!continuation) {
150
+ return { body: text, continuation: null };
151
+ }
152
+
153
+ return {
154
+ body: text.slice(0, trailerMatch.index),
155
+ continuation,
156
+ };
157
+ }
158
+
159
+ async function streamReadPreview(
160
+ lines: readonly string[],
161
+ opts: ReadOpts | undefined,
162
+ ): Promise<void> {
163
+ if (!opts?.onUpdate || lines.length === 0) {
164
+ return;
165
+ }
166
+
167
+ const chunkSize = Math.max(1, READ_STREAM_CHUNK_LINES);
168
+ for (let index = chunkSize; index < lines.length; index += chunkSize) {
169
+ if (opts.signal?.aborted) {
170
+ return;
171
+ }
172
+
173
+ opts.onUpdate(textResult(lines.slice(0, index).join(""), false));
174
+ await waitForNextTick();
175
+ }
176
+ }
177
+
178
+ function loadReadContent(
179
+ args: ReadArgs,
180
+ cwd: string,
181
+ ): ToolExecResult | { content: string } {
182
+ const filePath = resolveReadPath(args.path, cwd);
183
+ if (!existsSync(filePath)) {
184
+ return textResult(`File not found: ${args.path}`, true);
185
+ }
186
+
187
+ try {
188
+ if (!statSync(filePath).isFile()) {
189
+ return textResult(`Not a file: ${args.path}`, true);
190
+ }
191
+ } catch (error) {
192
+ const message = error instanceof Error ? error.message : String(error);
193
+ return textResult(`Failed to stat ${args.path}: ${message}`, true);
194
+ }
195
+
196
+ try {
197
+ return {
198
+ content: readFileSync(filePath, "utf-8"),
199
+ };
200
+ } catch (error) {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ return textResult(`Failed to read ${args.path}: ${message}`, true);
203
+ }
204
+ }
205
+
206
+ function getReadSlice(
207
+ args: ReadArgs,
208
+ content: string,
209
+ ):
210
+ | ToolExecResult
211
+ | {
212
+ lines: string[];
213
+ offset: number;
214
+ limit: number;
215
+ } {
216
+ const lines = splitLinesPreservingEndings(content);
217
+ const offset = args.offset ?? 0;
218
+ const limit = args.limit ?? DEFAULT_READ_LIMIT;
219
+
220
+ if (offset > lines.length || (lines.length > 0 && offset === lines.length)) {
221
+ return textResult(
222
+ `Offset ${offset} is out of range for ${args.path} (${lines.length} lines)`,
223
+ true,
224
+ );
225
+ }
226
+
227
+ return {
228
+ lines: lines.slice(offset, offset + limit),
229
+ offset,
230
+ limit,
231
+ };
232
+ }
233
+
234
+ function buildReadResult(
235
+ body: string,
236
+ continuation: ReadContinuationHint | null,
237
+ ): ToolExecResult {
238
+ if (!continuation) {
239
+ return textResult(body, false);
240
+ }
241
+
242
+ return {
243
+ content: [
244
+ { type: "text", text: body },
245
+ {
246
+ type: "text",
247
+ text: formatReadContinuationHint(
248
+ continuation.offset,
249
+ continuation.limit,
250
+ ),
251
+ },
252
+ ],
253
+ isError: false,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Read a UTF-8 text file, optionally by line window.
259
+ *
260
+ * When `limit` is omitted, the tool returns a bounded initial slice and adds a
261
+ * continuation hint when more content remains.
262
+ *
263
+ * @param args - Read arguments.
264
+ * @param cwd - Working directory for resolving relative paths.
265
+ * @param opts - Optional abort signal and progressive update callback.
266
+ * @returns A text tool result containing file content or an error.
267
+ */
268
+ export async function executeRead(
269
+ args: ReadArgs,
270
+ cwd: string,
271
+ opts?: ReadOpts,
272
+ ): Promise<ToolExecResult> {
273
+ const loaded = loadReadContent(args, cwd);
274
+ if ("isError" in loaded) {
275
+ return loaded;
276
+ }
277
+
278
+ const slice = getReadSlice(args, loaded.content);
279
+ if ("isError" in slice) {
280
+ return slice;
281
+ }
282
+
283
+ await streamReadPreview(slice.lines, opts);
284
+ if (opts?.signal?.aborted) {
285
+ return textResult("Read aborted", true);
286
+ }
287
+
288
+ const body = slice.lines.join("");
289
+ const totalLines = splitLinesPreservingEndings(loaded.content).length;
290
+ if (slice.offset + slice.lines.length >= totalLines) {
291
+ return buildReadResult(body, null);
292
+ }
293
+
294
+ return buildReadResult(body, {
295
+ offset: slice.offset + slice.lines.length,
296
+ limit: slice.limit,
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Tool handler that validates read arguments before execution.
302
+ *
303
+ * @param args - Raw parsed tool-call arguments.
304
+ * @param cwd - Working directory for path resolution.
305
+ * @param signal - Optional abort signal.
306
+ * @param onUpdate - Optional progressive output callback.
307
+ * @returns The read tool result.
308
+ */
309
+ export const readToolHandler: ToolHandler = (args, cwd, signal, onUpdate) =>
310
+ executeRead(validateBuiltinToolArgs(readTool, args), cwd, {
311
+ ...(signal ? { signal } : {}),
312
+ ...(onUpdate ? { onUpdate } : {}),
313
+ });