mini-coder 0.5.13 → 0.6.0

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.
Files changed (67) hide show
  1. package/README.md +25 -108
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/package.json +17 -22
  5. package/src/agent.ts +242 -915
  6. package/src/args.ts +289 -0
  7. package/src/headless.ts +43 -385
  8. package/src/index.ts +29 -836
  9. package/src/oauth.ts +117 -0
  10. package/src/prompt.ts +227 -276
  11. package/src/session.ts +57 -961
  12. package/src/shared.ts +117 -38
  13. package/src/tool-bash.ts +110 -0
  14. package/src/tool-edit.ts +133 -0
  15. package/src/tool-task.ts +114 -0
  16. package/src/tui-components.ts +150 -0
  17. package/src/tui-conversation.ts +262 -0
  18. package/src/tui-editor.ts +29 -0
  19. package/src/tui-overlay.ts +403 -0
  20. package/src/tui.ts +236 -0
  21. package/src/types.ts +160 -0
  22. package/tsconfig.json +17 -0
  23. package/BENCHMARK.md +0 -107
  24. package/LICENSE +0 -9
  25. package/PROGRESS.md +0 -4
  26. package/assets/icon-1-minimal.svg +0 -31
  27. package/assets/icon-2-dark-terminal.svg +0 -48
  28. package/assets/icon-3-gradient-modern.svg +0 -45
  29. package/assets/icon-4-filled-bold.svg +0 -54
  30. package/assets/icon-5-community-badge.svg +0 -63
  31. package/assets/mc-claude-smart.png +0 -0
  32. package/assets/mc-gpt-smart.png +0 -0
  33. package/assets/preview-0-5-0.png +0 -0
  34. package/assets/preview.gif +0 -0
  35. package/benchmark-baseline.sh +0 -15
  36. package/benchmark-loop.sh +0 -19
  37. package/skills-lock.json +0 -15
  38. package/src/cli.ts +0 -134
  39. package/src/errors.ts +0 -15
  40. package/src/git.ts +0 -247
  41. package/src/input.ts +0 -168
  42. package/src/mcp.ts +0 -609
  43. package/src/paths.ts +0 -37
  44. package/src/session-message.ts +0 -393
  45. package/src/settings.ts +0 -449
  46. package/src/skills.ts +0 -271
  47. package/src/submit.ts +0 -371
  48. package/src/text.ts +0 -71
  49. package/src/theme.ts +0 -330
  50. package/src/tool-common.ts +0 -93
  51. package/src/tool-grep.ts +0 -606
  52. package/src/tool-read.ts +0 -313
  53. package/src/tool-shell.ts +0 -1001
  54. package/src/tools.ts +0 -854
  55. package/src/ui/agent.ts +0 -317
  56. package/src/ui/commands.test.ts +0 -913
  57. package/src/ui/commands.ts +0 -834
  58. package/src/ui/conversation.test.ts +0 -585
  59. package/src/ui/conversation.ts +0 -1836
  60. package/src/ui/help.ts +0 -158
  61. package/src/ui/input.test.ts +0 -64
  62. package/src/ui/input.ts +0 -138
  63. package/src/ui/overlay.ts +0 -59
  64. package/src/ui/runtime.ts +0 -69
  65. package/src/ui/status.ts +0 -220
  66. package/src/ui.ts +0 -1190
  67. package/src/version.ts +0 -48
package/src/tools.ts DELETED
@@ -1,854 +0,0 @@
1
- /**
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.
7
- *
8
- * Each tool is exposed as a pure-ish execute function that takes typed
9
- * arguments and a working directory, returning a result object. The pi-ai
10
- * {@link Tool} definitions (TypeBox schemas) are exported separately for
11
- * registration with the agent context.
12
- *
13
- * @module
14
- */
15
-
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
- import { dirname, extname, isAbsolute, join } from "node:path";
18
- import type {
19
- Message,
20
- Static,
21
- TextContent,
22
- Tool,
23
- ToolResultMessage,
24
- } from "@mariozechner/pi-ai";
25
- import { Type } from "@mariozechner/pi-ai";
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
- formatShellResultText,
63
- parseLegacyShellResult,
64
- parseShellResultDetails,
65
- type ShellArgs,
66
- type ShellOpts,
67
- type ShellResultDetails,
68
- shellTool,
69
- shellToolHandler,
70
- truncateOutput,
71
- } from "./tool-shell.ts";
72
-
73
- /** Persisted todo status values shown to the user and stored in snapshots. */
74
- export type TodoStatus = "pending" | "in_progress" | "completed";
75
-
76
- /** Todo status values accepted by `todoWrite`. */
77
- export type TodoWriteStatus = TodoStatus | "cancelled";
78
-
79
- /** A single persisted todo item. */
80
- export interface TodoItem {
81
- /** Task description shown in the checklist. */
82
- content: string;
83
- /** Current persisted task status. */
84
- status: TodoStatus;
85
- }
86
-
87
- const todoWriteToolParameters = Type.Object({
88
- todos: Type.Array(
89
- Type.Object({
90
- content: Type.String({
91
- description: "Task description used as the matching key",
92
- }),
93
- status: Type.Union(
94
- [
95
- Type.Literal("pending"),
96
- Type.Literal("in_progress"),
97
- Type.Literal("completed"),
98
- Type.Literal("cancelled"),
99
- ],
100
- {
101
- description:
102
- "Task status. Use `cancelled` to remove the item entirely.",
103
- },
104
- ),
105
- }),
106
- {
107
- description:
108
- "List of todo items to create, update, or remove. Only send the items that changed.",
109
- },
110
- ),
111
- });
112
-
113
- /** Arguments for the `todoWrite` tool. */
114
- export type TodoWriteArgs = Static<typeof todoWriteToolParameters>;
115
-
116
- const todoReadToolParameters = Type.Object({});
117
-
118
- const MAX_TODO_CONTENT_LENGTH = 1_000;
119
-
120
- type TodoHistoryMessage = Message | { role: "ui" };
121
-
122
- function isTodoStatus(value: unknown): value is TodoStatus {
123
- return (
124
- value === "pending" || value === "in_progress" || value === "completed"
125
- );
126
- }
127
-
128
- function isTodoWriteStatus(value: unknown): value is TodoWriteStatus {
129
- return value === "cancelled" || isTodoStatus(value);
130
- }
131
-
132
- function cloneTodoItems(todos: readonly TodoItem[]): TodoItem[] {
133
- return todos.map((todo) => ({ ...todo }));
134
- }
135
-
136
- function getToolResultText(content: ToolResultMessage["content"]): string {
137
- return content
138
- .filter((entry): entry is TextContent => entry.type === "text")
139
- .map((entry) => entry.text)
140
- .join("\n");
141
- }
142
-
143
- /** Serialize a full todo snapshot for storage in a tool result. */
144
- export function formatTodoSnapshot(todos: readonly TodoItem[]): string {
145
- return JSON.stringify({ todos: cloneTodoItems(todos) }, null, 2);
146
- }
147
-
148
- /** Parse a serialized todo snapshot from tool-result text. */
149
- export function parseTodoSnapshot(text: string): TodoItem[] | null {
150
- let parsed: unknown;
151
- try {
152
- parsed = JSON.parse(text) as unknown;
153
- } catch {
154
- return null;
155
- }
156
-
157
- if (
158
- typeof parsed !== "object" ||
159
- parsed === null ||
160
- !Array.isArray((parsed as { todos?: unknown }).todos)
161
- ) {
162
- return null;
163
- }
164
-
165
- const todos = (parsed as { todos: unknown[] }).todos;
166
- if (
167
- !todos.every((todo) => {
168
- return (
169
- typeof todo === "object" &&
170
- todo !== null &&
171
- typeof (todo as { content?: unknown }).content === "string" &&
172
- isTodoStatus((todo as { status?: unknown }).status)
173
- );
174
- })
175
- ) {
176
- return null;
177
- }
178
-
179
- return todos.map((todo) => ({
180
- content: (todo as { content: string }).content,
181
- status: (todo as { status: TodoStatus }).status,
182
- }));
183
- }
184
-
185
- function getTodoSnapshotFromToolResult(
186
- message: ToolResultMessage,
187
- ): TodoItem[] | null {
188
- if (message.isError) {
189
- return null;
190
- }
191
- if (message.toolName !== "todoWrite" && message.toolName !== "todoRead") {
192
- return null;
193
- }
194
- return parseTodoSnapshot(getToolResultText(message.content));
195
- }
196
-
197
- /** Return the current todo list derived from persisted message history. */
198
- export function getTodoItems(
199
- messages: readonly TodoHistoryMessage[],
200
- ): TodoItem[] {
201
- for (let index = messages.length - 1; index >= 0; index -= 1) {
202
- const message = messages[index];
203
- if (!message || message.role !== "toolResult") {
204
- continue;
205
- }
206
-
207
- const snapshot = getTodoSnapshotFromToolResult(message);
208
- if (snapshot) {
209
- return snapshot;
210
- }
211
- }
212
-
213
- return [];
214
- }
215
-
216
- function validateTodoContent(content: string): string | null {
217
- if (content.trim().length === 0) {
218
- return "Todo content cannot be empty";
219
- }
220
- if (content.length > MAX_TODO_CONTENT_LENGTH) {
221
- return `Todo content exceeds maximum length of ${MAX_TODO_CONTENT_LENGTH} characters`;
222
- }
223
- return null;
224
- }
225
-
226
- /** Apply incremental todo changes and return the new full snapshot. */
227
- export function executeTodoWrite(
228
- args: TodoWriteArgs,
229
- messages: readonly TodoHistoryMessage[],
230
- ): ToolExecResult {
231
- const nextTodos = cloneTodoItems(getTodoItems(messages));
232
-
233
- for (const todo of args.todos) {
234
- const validationError = validateTodoContent(todo.content);
235
- if (validationError) {
236
- return textResult(validationError, true);
237
- }
238
- if (!isTodoWriteStatus(todo.status)) {
239
- return textResult(`Invalid todo status: ${String(todo.status)}`, true);
240
- }
241
-
242
- if (todo.status === "cancelled") {
243
- const index = nextTodos.findIndex(
244
- (existingTodo) => existingTodo.content === todo.content,
245
- );
246
- if (index !== -1) {
247
- nextTodos.splice(index, 1);
248
- }
249
- continue;
250
- }
251
-
252
- const existingTodo = nextTodos.find(
253
- (candidate) => candidate.content === todo.content,
254
- );
255
- if (existingTodo) {
256
- existingTodo.status = todo.status;
257
- continue;
258
- }
259
-
260
- nextTodos.push({
261
- content: todo.content,
262
- status: todo.status,
263
- });
264
- }
265
-
266
- return textResult(formatTodoSnapshot(nextTodos), false);
267
- }
268
-
269
- /** Return the current full todo snapshot without mutating it. */
270
- export function executeTodoRead(
271
- messages: readonly TodoHistoryMessage[],
272
- ): ToolExecResult {
273
- return textResult(formatTodoSnapshot(getTodoItems(messages)), false);
274
- }
275
-
276
- const MAX_EDIT_ERROR_MATCHES = 3;
277
- const MAX_EDIT_ERROR_SNIPPET_LINES = 8;
278
- const MAX_EDIT_ERROR_SNIPPET_LINE_CHARS = 160;
279
- const MIN_EDIT_SIMILARITY_SCORE = 0.45;
280
-
281
- interface EditSnippet {
282
- startLine: number;
283
- endLine: number;
284
- lines: string[];
285
- }
286
-
287
- function splitDisplayLines(content: string): string[] {
288
- const lines = content.replace(/\r\n/g, "\n").split("\n");
289
- if (lines.at(-1) === "") {
290
- lines.pop();
291
- }
292
- return lines;
293
- }
294
-
295
- function countDisplayLines(content: string): number {
296
- return Math.max(splitDisplayLines(content).length, 1);
297
- }
298
-
299
- function formatLineRange(startLine: number, endLine: number): string {
300
- return startLine === endLine
301
- ? `line ${startLine}`
302
- : `lines ${startLine}-${endLine}`;
303
- }
304
-
305
- function truncateSnippetLine(line: string): string {
306
- if (line.length <= MAX_EDIT_ERROR_SNIPPET_LINE_CHARS) {
307
- return line;
308
- }
309
- return `${line.slice(0, MAX_EDIT_ERROR_SNIPPET_LINE_CHARS - 1)}…`;
310
- }
311
-
312
- function formatSnippetLines(lines: readonly string[]): string {
313
- const visibleLines = lines.slice(0, MAX_EDIT_ERROR_SNIPPET_LINES);
314
- const formatted = visibleLines
315
- .map((line) => ` ${truncateSnippetLine(line)}`)
316
- .join("\n");
317
- const hiddenLineCount = lines.length - visibleLines.length;
318
- if (hiddenLineCount <= 0) {
319
- return formatted;
320
- }
321
- return `${formatted}\n … ${hiddenLineCount} more lines`;
322
- }
323
-
324
- function commonPrefixLength(a: string, b: string): number {
325
- let index = 0;
326
- const maxLength = Math.min(a.length, b.length);
327
- while (index < maxLength && a[index] === b[index]) {
328
- index++;
329
- }
330
- return index;
331
- }
332
-
333
- function commonSuffixLength(
334
- a: string,
335
- b: string,
336
- prefixLength: number,
337
- ): number {
338
- let index = 0;
339
- const maxLength = Math.min(a.length, b.length) - prefixLength;
340
- while (
341
- index < maxLength &&
342
- a[a.length - 1 - index] === b[b.length - 1 - index]
343
- ) {
344
- index++;
345
- }
346
- return index;
347
- }
348
-
349
- function scoreSimilarLine(oldLine: string, candidateLine: string): number {
350
- if (oldLine === candidateLine) {
351
- return 1;
352
- }
353
-
354
- const normalizedOldLine = oldLine.trim();
355
- const normalizedCandidateLine = candidateLine.trim();
356
- if (normalizedOldLine === normalizedCandidateLine) {
357
- return normalizedOldLine === "" ? 1 : 0.98;
358
- }
359
- if (normalizedOldLine === "" || normalizedCandidateLine === "") {
360
- return 0;
361
- }
362
-
363
- const prefixLength = commonPrefixLength(
364
- normalizedOldLine,
365
- normalizedCandidateLine,
366
- );
367
- const suffixLength = commonSuffixLength(
368
- normalizedOldLine,
369
- normalizedCandidateLine,
370
- prefixLength,
371
- );
372
- const overlapLength = Math.min(
373
- normalizedOldLine.length,
374
- prefixLength + suffixLength,
375
- );
376
- const maxLength = Math.max(
377
- normalizedOldLine.length,
378
- normalizedCandidateLine.length,
379
- );
380
- const structuralScore = overlapLength / maxLength;
381
-
382
- if (
383
- normalizedOldLine.includes(normalizedCandidateLine) ||
384
- normalizedCandidateLine.includes(normalizedOldLine)
385
- ) {
386
- const sharedLength = Math.min(
387
- normalizedOldLine.length,
388
- normalizedCandidateLine.length,
389
- );
390
- return Math.max(structuralScore, sharedLength / maxLength);
391
- }
392
-
393
- return structuralScore;
394
- }
395
-
396
- function scoreLineWindow(
397
- oldLines: readonly string[],
398
- candidateLines: readonly string[],
399
- ): number {
400
- const maxLineCount = Math.max(oldLines.length, candidateLines.length);
401
- let weightedScore = 0;
402
- let totalWeight = 0;
403
-
404
- for (let index = 0; index < maxLineCount; index++) {
405
- const oldLine = oldLines[index] ?? "";
406
- const candidateLine = candidateLines[index] ?? "";
407
- const weight = Math.max(
408
- oldLine.trim().length,
409
- candidateLine.trim().length,
410
- 1,
411
- );
412
- weightedScore += scoreSimilarLine(oldLine, candidateLine) * weight;
413
- totalWeight += weight;
414
- }
415
-
416
- return totalWeight === 0 ? 0 : weightedScore / totalWeight;
417
- }
418
-
419
- function findClosestEditSnippets(
420
- oldText: string,
421
- content: string,
422
- ): EditSnippet[] {
423
- const oldLines = splitDisplayLines(oldText);
424
- const fileLines = splitDisplayLines(content);
425
- if (fileLines.length === 0) {
426
- return [];
427
- }
428
-
429
- const windowSizes = Array.from(
430
- new Set([
431
- Math.max(1, oldLines.length - 1),
432
- Math.max(1, oldLines.length),
433
- Math.min(fileLines.length, oldLines.length + 1),
434
- ]),
435
- );
436
- const candidates: (EditSnippet & { score: number })[] = [];
437
-
438
- for (const windowSize of windowSizes) {
439
- if (windowSize > fileLines.length) {
440
- continue;
441
- }
442
-
443
- for (
444
- let startLineIndex = 0;
445
- startLineIndex <= fileLines.length - windowSize;
446
- startLineIndex++
447
- ) {
448
- const lines = fileLines.slice(
449
- startLineIndex,
450
- startLineIndex + windowSize,
451
- );
452
- candidates.push({
453
- startLine: startLineIndex + 1,
454
- endLine: startLineIndex + windowSize,
455
- lines,
456
- score: scoreLineWindow(oldLines, lines),
457
- });
458
- }
459
- }
460
-
461
- candidates.sort((a, b) => {
462
- const scoreDelta = b.score - a.score;
463
- if (scoreDelta !== 0) {
464
- return scoreDelta;
465
- }
466
-
467
- const lineSpanDelta =
468
- Math.abs(a.lines.length - oldLines.length) -
469
- Math.abs(b.lines.length - oldLines.length);
470
- if (lineSpanDelta !== 0) {
471
- return lineSpanDelta;
472
- }
473
-
474
- return a.startLine - b.startLine;
475
- });
476
-
477
- const snippets: EditSnippet[] = [];
478
- const seen = new Set<string>();
479
- for (const candidate of candidates) {
480
- if (candidate.score < MIN_EDIT_SIMILARITY_SCORE) {
481
- break;
482
- }
483
-
484
- const key = `${candidate.startLine}:${candidate.endLine}`;
485
- if (seen.has(key)) {
486
- continue;
487
- }
488
-
489
- snippets.push({
490
- startLine: candidate.startLine,
491
- endLine: candidate.endLine,
492
- lines: candidate.lines,
493
- });
494
- seen.add(key);
495
-
496
- if (snippets.length === MAX_EDIT_ERROR_MATCHES) {
497
- break;
498
- }
499
- }
500
-
501
- return snippets;
502
- }
503
-
504
- function buildLineStarts(content: string): number[] {
505
- const lineStarts = [0];
506
- for (let index = 0; index < content.length; index++) {
507
- if (content[index] === "\n") {
508
- lineStarts.push(index + 1);
509
- }
510
- }
511
- return lineStarts;
512
- }
513
-
514
- function findLineNumber(lineStarts: readonly number[], index: number): number {
515
- let low = 0;
516
- let high = lineStarts.length - 1;
517
-
518
- while (low <= high) {
519
- const mid = Math.floor((low + high) / 2);
520
- const lineStart = lineStarts[mid];
521
- if (lineStart === undefined) {
522
- break;
523
- }
524
- if (lineStart <= index) {
525
- low = mid + 1;
526
- } else {
527
- high = mid - 1;
528
- }
529
- }
530
-
531
- return high + 1;
532
- }
533
-
534
- function formatEditNotFoundError(
535
- path: string,
536
- oldText: string,
537
- content: string,
538
- ): string {
539
- const snippets = findClosestEditSnippets(oldText, content);
540
- if (snippets.length === 0) {
541
- return `Old text not found in ${path}`;
542
- }
543
-
544
- return [
545
- `Old text not found in ${path}`,
546
- "Closest matches:",
547
- ...snippets.map(
548
- (snippet) =>
549
- `- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
550
- ),
551
- ].join("\n");
552
- }
553
-
554
- function formatEditMultipleMatchesError(
555
- path: string,
556
- oldText: string,
557
- content: string,
558
- matchIndices: readonly number[],
559
- totalMatches: number,
560
- ): string {
561
- const lineStarts = buildLineStarts(content);
562
- const fileLines = splitDisplayLines(content);
563
- const matchLineCount = countDisplayLines(oldText);
564
- const snippets = matchIndices.map((matchIndex) => {
565
- const startLine = findLineNumber(lineStarts, matchIndex);
566
- const endLine = startLine + matchLineCount - 1;
567
- return {
568
- startLine,
569
- endLine,
570
- lines: fileLines.slice(startLine - 1, endLine),
571
- };
572
- });
573
-
574
- const lines = [
575
- `Old text matches multiple locations (${totalMatches}) in ${path}`,
576
- "Matches:",
577
- ...snippets.map(
578
- (snippet) =>
579
- `- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
580
- ),
581
- ];
582
- const hiddenMatchCount = totalMatches - matchIndices.length;
583
- if (hiddenMatchCount > 0) {
584
- lines.push(`- … ${hiddenMatchCount} more matches`);
585
- }
586
-
587
- return lines.join("\n");
588
- }
589
-
590
- // ---------------------------------------------------------------------------
591
- // edit
592
- // ---------------------------------------------------------------------------
593
-
594
- const editToolParameters = Type.Object({
595
- path: Type.String({
596
- description: "File path (absolute or relative to cwd)",
597
- }),
598
- oldText: Type.String({
599
- description:
600
- 'Exact text to find and replace. Empty string means "create new file".',
601
- }),
602
- newText: Type.String({
603
- description: "Replacement text (or full content for new files)",
604
- }),
605
- });
606
-
607
- /** Arguments for the `edit` tool. */
608
- export type EditArgs = Static<typeof editToolParameters>;
609
-
610
- /**
611
- * Execute an exact-text replacement in a single file.
612
- *
613
- * - If `oldText` is empty, creates a new file (with parent directories).
614
- * Fails if the file already exists.
615
- * - Otherwise, reads the file, finds exactly one occurrence of `oldText`,
616
- * and replaces it with `newText`. Fails if the text is not found or
617
- * matches multiple locations.
618
- *
619
- * @param args - Edit arguments (path, oldText, newText).
620
- * @param cwd - Working directory for resolving relative paths.
621
- * @returns A {@link ToolExecResult} with confirmation or error message.
622
- */
623
- export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
624
- const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
625
-
626
- // Create new file
627
- if (args.oldText === "") {
628
- if (existsSync(filePath)) {
629
- return textResult(`File already exists: ${args.path}`, true);
630
- }
631
- mkdirSync(dirname(filePath), { recursive: true });
632
- writeFileSync(filePath, args.newText, "utf-8");
633
- return textResult(`Created ${args.path}`, false);
634
- }
635
-
636
- // Replace in existing file
637
- if (!existsSync(filePath)) {
638
- return textResult(`File not found: ${args.path}`, true);
639
- }
640
-
641
- const content = readFileSync(filePath, "utf-8");
642
-
643
- // Count occurrences
644
- let count = 0;
645
- const matchIndices: number[] = [];
646
- let idx = 0;
647
- while (true) {
648
- idx = content.indexOf(args.oldText, idx);
649
- if (idx === -1) break;
650
- count++;
651
- if (matchIndices.length < MAX_EDIT_ERROR_MATCHES) {
652
- matchIndices.push(idx);
653
- }
654
- idx += args.oldText.length;
655
- }
656
-
657
- if (count === 0) {
658
- return textResult(
659
- formatEditNotFoundError(args.path, args.oldText, content),
660
- true,
661
- );
662
- }
663
- if (count > 1) {
664
- return textResult(
665
- formatEditMultipleMatchesError(
666
- args.path,
667
- args.oldText,
668
- content,
669
- matchIndices,
670
- count,
671
- ),
672
- true,
673
- );
674
- }
675
-
676
- // Exactly one match — replace
677
- const lineEnding = detectLineEnding(content);
678
- const newText = lineEnding
679
- ? normalizeLineEndings(args.newText, lineEnding)
680
- : args.newText;
681
- const matchIndex = matchIndices[0];
682
- if (matchIndex === undefined) {
683
- return textResult(`Old text not found in ${args.path}`, true);
684
- }
685
- const updated =
686
- content.slice(0, matchIndex) +
687
- newText +
688
- content.slice(matchIndex + args.oldText.length);
689
- writeFileSync(filePath, updated, "utf-8");
690
- return textResult(`Edited ${args.path}`, false);
691
- }
692
-
693
- // ---------------------------------------------------------------------------
694
- // readImage
695
- // ---------------------------------------------------------------------------
696
-
697
- /** Supported image extensions and their MIME types. */
698
- const IMAGE_MIME_TYPES: Record<string, string> = {
699
- ".png": "image/png",
700
- ".jpg": "image/jpeg",
701
- ".jpeg": "image/jpeg",
702
- ".gif": "image/gif",
703
- ".webp": "image/webp",
704
- };
705
-
706
- const readImageToolParameters = Type.Object({
707
- path: Type.String({
708
- description: "File path (absolute or relative to cwd)",
709
- }),
710
- });
711
-
712
- /** Arguments for the `readImage` tool. */
713
- export type ReadImageArgs = Static<typeof readImageToolParameters>;
714
-
715
- /**
716
- * Read an image file and return it as base64-encoded content.
717
- *
718
- * Supports PNG, JPEG, GIF, and WebP. Returns {@link ImageContent} on
719
- * success or a text error message on failure. The MIME type is detected
720
- * from the file extension.
721
- *
722
- * @param args - ReadImage arguments (path).
723
- * @param cwd - Working directory for resolving relative paths.
724
- * @returns A {@link ToolExecResult} with image content or error message.
725
- */
726
- export function executeReadImage(
727
- args: ReadImageArgs,
728
- cwd: string,
729
- ): ToolExecResult {
730
- const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
731
- const ext = extname(filePath).toLowerCase();
732
-
733
- const mimeType = IMAGE_MIME_TYPES[ext];
734
- if (!mimeType) {
735
- return textResult(
736
- `Unsupported image format: ${ext || "(no extension)"}`,
737
- true,
738
- );
739
- }
740
-
741
- if (!existsSync(filePath)) {
742
- return textResult(`File not found: ${args.path}`, true);
743
- }
744
-
745
- try {
746
- const data = readFileSync(filePath);
747
- const base64 = Buffer.from(data).toString("base64");
748
-
749
- return {
750
- content: [{ type: "image", data: base64, mimeType }],
751
- isError: false,
752
- };
753
- } catch (error) {
754
- const message = error instanceof Error ? error.message : String(error);
755
- return textResult(`Failed to read image ${args.path}: ${message}`, true);
756
- }
757
- }
758
-
759
- // ---------------------------------------------------------------------------
760
- // Tool definitions (pi-ai Tool schemas)
761
- // ---------------------------------------------------------------------------
762
-
763
- /** pi-ai tool definition for `edit`. */
764
- export const editTool: Tool<typeof editToolParameters> = {
765
- name: "edit",
766
- description:
767
- "Make an exact-text replacement in a single file. " +
768
- "Provide the file path, the exact text to find, and the replacement text. " +
769
- "The old text must match exactly one location in the file. " +
770
- "To create a new file, use an empty old text and the full file content as new text. " +
771
- "Use this to write the exact final file content the task requires.",
772
- parameters: editToolParameters,
773
- };
774
-
775
- /**
776
- * Tool handler that validates edit arguments before execution.
777
- *
778
- * @param args - Raw parsed tool-call arguments.
779
- * @param cwd - Working directory for path resolution.
780
- * @returns The edit tool result.
781
- */
782
- export const editToolHandler: ToolHandler = (args, cwd) =>
783
- executeEdit(validateBuiltinToolArgs(editTool, args), cwd);
784
-
785
- /** pi-ai tool definition for `todoWrite`. */
786
- export const todoWriteTool: Tool<typeof todoWriteToolParameters> = {
787
- name: "todoWrite",
788
- description:
789
- "Use this tool to create and manage a structured task list for your current coding session. " +
790
- "This helps you track progress, organize complex tasks, and keep the user informed. " +
791
- "Only send the items that changed; unchanged items stay as they are. " +
792
- "Each item must include `content` and `status`, where `status` is one of `pending`, `in_progress`, `completed`, or `cancelled`. " +
793
- "Use `cancelled` to remove an item from the list. " +
794
- "Mark tasks `in_progress` before starting them and `completed` immediately after verification succeeds.",
795
- parameters: todoWriteToolParameters,
796
- };
797
-
798
- /**
799
- * Create a `todoWrite` handler bound to the current persisted message history.
800
- *
801
- * @param messages - Current persisted message history.
802
- * @returns Tool handler for todo writes.
803
- */
804
- export function createTodoWriteToolHandler(
805
- messages: readonly TodoHistoryMessage[],
806
- ): ToolHandler {
807
- return (args) =>
808
- executeTodoWrite(validateBuiltinToolArgs(todoWriteTool, args), messages);
809
- }
810
-
811
- /** pi-ai tool definition for `todoRead`. */
812
- export const todoReadTool: Tool<typeof todoReadToolParameters> = {
813
- name: "todoRead",
814
- description:
815
- "Retrieves the current todo list for this coding session. " +
816
- "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. " +
817
- "If no todos exist yet, it returns an empty list.",
818
- parameters: todoReadToolParameters,
819
- };
820
-
821
- /**
822
- * Create a `todoRead` handler bound to the current persisted message history.
823
- *
824
- * @param messages - Current persisted message history.
825
- * @returns Tool handler for todo reads.
826
- */
827
- export function createTodoReadToolHandler(
828
- messages: readonly TodoHistoryMessage[],
829
- ): ToolHandler {
830
- return (args) => {
831
- validateBuiltinToolArgs(todoReadTool, args);
832
- return executeTodoRead(messages);
833
- };
834
- }
835
-
836
- /** pi-ai tool definition for `readImage`. */
837
- export const readImageTool: Tool<typeof readImageToolParameters> = {
838
- name: "readImage",
839
- description:
840
- "Read an image file and return its contents. " +
841
- "Supports PNG, JPEG, GIF, and WebP formats. " +
842
- "Use this to inspect screenshots, diagrams, or any image in the repo.",
843
- parameters: readImageToolParameters,
844
- };
845
-
846
- /**
847
- * Tool handler that validates image-read arguments before execution.
848
- *
849
- * @param args - Raw parsed tool-call arguments.
850
- * @param cwd - Working directory for path resolution.
851
- * @returns The readImage tool result.
852
- */
853
- export const readImageToolHandler: ToolHandler = (args, cwd) =>
854
- executeReadImage(validateBuiltinToolArgs(readImageTool, args), cwd);