mini-coder 0.5.14 → 0.6.1

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 (70) hide show
  1. package/README.md +26 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/nono-mini-coder.json +42 -0
  5. package/package.json +17 -22
  6. package/src/agent.ts +243 -1403
  7. package/src/args.ts +289 -0
  8. package/src/headless.ts +41 -359
  9. package/src/index.ts +29 -1016
  10. package/src/oauth.ts +117 -0
  11. package/src/prompt.ts +219 -284
  12. package/src/session.ts +55 -1306
  13. package/src/shared.ts +117 -38
  14. package/src/tool-bash.ts +110 -0
  15. package/src/tool-edit.ts +133 -0
  16. package/src/tool-read.ts +80 -293
  17. package/src/tui-components.ts +150 -0
  18. package/src/tui-conversation.ts +271 -0
  19. package/src/tui-editor.ts +29 -0
  20. package/src/tui-overlay.ts +403 -0
  21. package/src/tui.ts +228 -0
  22. package/src/types.ts +164 -0
  23. package/tsconfig.json +17 -0
  24. package/BENCHMARK.md +0 -107
  25. package/LICENSE +0 -9
  26. package/PROGRESS.md +0 -5
  27. package/assets/icon-1-minimal.svg +0 -31
  28. package/assets/icon-2-dark-terminal.svg +0 -48
  29. package/assets/icon-3-gradient-modern.svg +0 -45
  30. package/assets/icon-4-filled-bold.svg +0 -54
  31. package/assets/icon-5-community-badge.svg +0 -63
  32. package/assets/mc-claude-smart.png +0 -0
  33. package/assets/mc-gpt-smart.png +0 -0
  34. package/assets/preview-0-5-0.png +0 -0
  35. package/assets/preview.gif +0 -0
  36. package/benchmark-baseline.sh +0 -15
  37. package/benchmark-loop.sh +0 -19
  38. package/skills-lock.json +0 -15
  39. package/src/assistant-output.ts +0 -73
  40. package/src/cli.ts +0 -134
  41. package/src/delegation.ts +0 -238
  42. package/src/errors.ts +0 -15
  43. package/src/git.ts +0 -247
  44. package/src/input.ts +0 -168
  45. package/src/mcp.ts +0 -609
  46. package/src/paths.ts +0 -37
  47. package/src/session-message.ts +0 -385
  48. package/src/settings.ts +0 -449
  49. package/src/skills.ts +0 -271
  50. package/src/submit.ts +0 -376
  51. package/src/text.ts +0 -71
  52. package/src/theme.ts +0 -330
  53. package/src/tool-common.ts +0 -93
  54. package/src/tool-delegate.ts +0 -125
  55. package/src/tool-grep.ts +0 -606
  56. package/src/tool-shell.ts +0 -1051
  57. package/src/tools.ts +0 -1179
  58. package/src/ui/agent.ts +0 -320
  59. package/src/ui/commands.test.ts +0 -957
  60. package/src/ui/commands.ts +0 -848
  61. package/src/ui/conversation.test.ts +0 -585
  62. package/src/ui/conversation.ts +0 -1836
  63. package/src/ui/help.ts +0 -158
  64. package/src/ui/input.test.ts +0 -64
  65. package/src/ui/input.ts +0 -138
  66. package/src/ui/overlay.ts +0 -59
  67. package/src/ui/runtime.ts +0 -69
  68. package/src/ui/status.ts +0 -220
  69. package/src/ui.ts +0 -1190
  70. package/src/version.ts +0 -48
package/src/tools.ts DELETED
@@ -1,1179 +0,0 @@
1
- /**
2
- * Built-in tool implementations: `shell`, `delegate`, `read`, `grep`, `edit`,
3
- * `todoWrite`, `todoRead`, and `readImage`.
4
- *
5
- * Shell, delegate, read, and grep live in dedicated modules and are re-exported
6
- * here so the rest of the codebase can keep a single built-in-tools import
7
- * surface.
8
- *
9
- * Each tool is exposed as a pure-ish execute function that takes typed
10
- * arguments and a working directory, returning a result object. The pi-ai
11
- * {@link Tool} definitions (TypeBox schemas) are exported separately for
12
- * registration with the agent context.
13
- *
14
- * @module
15
- */
16
-
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
- import { dirname, extname, isAbsolute, join } from "node:path";
19
- import { crc32, inflateSync } from "node:zlib";
20
- import type {
21
- Message,
22
- Static,
23
- TextContent,
24
- Tool,
25
- ToolResultMessage,
26
- } from "@mariozechner/pi-ai";
27
- import { Type } from "@mariozechner/pi-ai";
28
- import type { ToolHandler } from "./agent.ts";
29
- import {
30
- detectLineEnding,
31
- normalizeLineEndings,
32
- type ToolExecResult,
33
- textResult,
34
- validateBuiltinToolArgs,
35
- } from "./tool-common.ts";
36
-
37
- export type { ToolExecResult } from "./tool-common.ts";
38
- export {
39
- type CreateDelegateToolHandlerOpts,
40
- createDelegateToolHandler,
41
- type DelegateArgs,
42
- type DelegateResultDetails,
43
- type DelegateRunResult,
44
- delegateTool,
45
- formatDelegateResultText,
46
- } from "./tool-delegate.ts";
47
- export {
48
- DEFAULT_GREP_LIMIT,
49
- executeGrep,
50
- type GrepArgs,
51
- type GrepOpts,
52
- type GrepResult,
53
- type GrepResultFile,
54
- type GrepResultLine,
55
- grepTool,
56
- grepToolHandler,
57
- parseGrepResult,
58
- } from "./tool-grep.ts";
59
- export {
60
- DEFAULT_READ_LIMIT,
61
- executeRead,
62
- formatReadContinuationHint,
63
- parseReadContinuationHint,
64
- parseReadResult,
65
- type ReadArgs,
66
- type ReadContinuationHint,
67
- type ReadOpts,
68
- readTool,
69
- readToolHandler,
70
- } from "./tool-read.ts";
71
- export {
72
- createDelegationAwareShellToolHandler,
73
- type DelegationAwareShellToolHandlerOpts,
74
- executeShell,
75
- formatShellResultText,
76
- parseLegacyShellResult,
77
- parseShellResultDetails,
78
- type ShellArgs,
79
- type ShellOpts,
80
- type ShellResultDetails,
81
- shellTool,
82
- shellToolHandler,
83
- truncateOutput,
84
- } from "./tool-shell.ts";
85
-
86
- /** Persisted todo status values shown to the user and stored in snapshots. */
87
- export type TodoStatus = "pending" | "in_progress" | "completed";
88
-
89
- /** Todo status values accepted by `todoWrite`. */
90
- export type TodoWriteStatus = TodoStatus | "cancelled";
91
-
92
- /** A single persisted todo item. */
93
- export interface TodoItem {
94
- /** Task description shown in the checklist. */
95
- content: string;
96
- /** Current persisted task status. */
97
- status: TodoStatus;
98
- }
99
-
100
- const todoWriteToolParameters = Type.Object({
101
- todos: Type.Array(
102
- Type.Object({
103
- content: Type.String({
104
- description: "Task description used as the matching key",
105
- }),
106
- status: Type.Union(
107
- [
108
- Type.Literal("pending"),
109
- Type.Literal("in_progress"),
110
- Type.Literal("completed"),
111
- Type.Literal("cancelled"),
112
- ],
113
- {
114
- description:
115
- "Task status. Use `cancelled` to remove the item entirely.",
116
- },
117
- ),
118
- }),
119
- {
120
- description:
121
- "List of todo items to create, update, or remove. Only send the items that changed.",
122
- },
123
- ),
124
- });
125
-
126
- /** Arguments for the `todoWrite` tool. */
127
- export type TodoWriteArgs = Static<typeof todoWriteToolParameters>;
128
-
129
- const todoReadToolParameters = Type.Object({});
130
-
131
- const MAX_TODO_CONTENT_LENGTH = 1_000;
132
-
133
- type TodoHistoryMessage = Message | { role: "ui" };
134
-
135
- function isTodoStatus(value: unknown): value is TodoStatus {
136
- return (
137
- value === "pending" || value === "in_progress" || value === "completed"
138
- );
139
- }
140
-
141
- function isTodoWriteStatus(value: unknown): value is TodoWriteStatus {
142
- return value === "cancelled" || isTodoStatus(value);
143
- }
144
-
145
- function cloneTodoItems(todos: readonly TodoItem[]): TodoItem[] {
146
- return todos.map((todo) => ({ ...todo }));
147
- }
148
-
149
- function getToolResultText(content: ToolResultMessage["content"]): string {
150
- return content
151
- .filter((entry): entry is TextContent => entry.type === "text")
152
- .map((entry) => entry.text)
153
- .join("\n");
154
- }
155
-
156
- /** Serialize a full todo snapshot for storage in a tool result. */
157
- export function formatTodoSnapshot(todos: readonly TodoItem[]): string {
158
- return JSON.stringify({ todos: cloneTodoItems(todos) }, null, 2);
159
- }
160
-
161
- /** Parse a serialized todo snapshot from tool-result text. */
162
- export function parseTodoSnapshot(text: string): TodoItem[] | null {
163
- let parsed: unknown;
164
- try {
165
- parsed = JSON.parse(text) as unknown;
166
- } catch {
167
- return null;
168
- }
169
-
170
- if (
171
- typeof parsed !== "object" ||
172
- parsed === null ||
173
- !Array.isArray((parsed as { todos?: unknown }).todos)
174
- ) {
175
- return null;
176
- }
177
-
178
- const todos = (parsed as { todos: unknown[] }).todos;
179
- if (
180
- !todos.every((todo) => {
181
- return (
182
- typeof todo === "object" &&
183
- todo !== null &&
184
- typeof (todo as { content?: unknown }).content === "string" &&
185
- isTodoStatus((todo as { status?: unknown }).status)
186
- );
187
- })
188
- ) {
189
- return null;
190
- }
191
-
192
- return todos.map((todo) => ({
193
- content: (todo as { content: string }).content,
194
- status: (todo as { status: TodoStatus }).status,
195
- }));
196
- }
197
-
198
- function getTodoSnapshotFromToolResult(
199
- message: ToolResultMessage,
200
- ): TodoItem[] | null {
201
- if (message.isError) {
202
- return null;
203
- }
204
- if (message.toolName !== "todoWrite" && message.toolName !== "todoRead") {
205
- return null;
206
- }
207
- return parseTodoSnapshot(getToolResultText(message.content));
208
- }
209
-
210
- /** Return the current todo list derived from persisted message history. */
211
- export function getTodoItems(
212
- messages: readonly TodoHistoryMessage[],
213
- ): TodoItem[] {
214
- for (let index = messages.length - 1; index >= 0; index -= 1) {
215
- const message = messages[index];
216
- if (!message || message.role !== "toolResult") {
217
- continue;
218
- }
219
-
220
- const snapshot = getTodoSnapshotFromToolResult(message);
221
- if (snapshot) {
222
- return snapshot;
223
- }
224
- }
225
-
226
- return [];
227
- }
228
-
229
- function validateTodoContent(content: string): string | null {
230
- if (content.trim().length === 0) {
231
- return "Todo content cannot be empty";
232
- }
233
- if (content.length > MAX_TODO_CONTENT_LENGTH) {
234
- return `Todo content exceeds maximum length of ${MAX_TODO_CONTENT_LENGTH} characters`;
235
- }
236
- return null;
237
- }
238
-
239
- /** Apply incremental todo changes and return the new full snapshot. */
240
- export function executeTodoWrite(
241
- args: TodoWriteArgs,
242
- messages: readonly TodoHistoryMessage[],
243
- ): ToolExecResult {
244
- const nextTodos = cloneTodoItems(getTodoItems(messages));
245
-
246
- for (const todo of args.todos) {
247
- const validationError = validateTodoContent(todo.content);
248
- if (validationError) {
249
- return textResult(validationError, true);
250
- }
251
- if (!isTodoWriteStatus(todo.status)) {
252
- return textResult(`Invalid todo status: ${String(todo.status)}`, true);
253
- }
254
-
255
- if (todo.status === "cancelled") {
256
- const index = nextTodos.findIndex(
257
- (existingTodo) => existingTodo.content === todo.content,
258
- );
259
- if (index !== -1) {
260
- nextTodos.splice(index, 1);
261
- }
262
- continue;
263
- }
264
-
265
- const existingTodo = nextTodos.find(
266
- (candidate) => candidate.content === todo.content,
267
- );
268
- if (existingTodo) {
269
- existingTodo.status = todo.status;
270
- continue;
271
- }
272
-
273
- nextTodos.push({
274
- content: todo.content,
275
- status: todo.status,
276
- });
277
- }
278
-
279
- return textResult(formatTodoSnapshot(nextTodos), false);
280
- }
281
-
282
- /** Return the current full todo snapshot without mutating it. */
283
- export function executeTodoRead(
284
- messages: readonly TodoHistoryMessage[],
285
- ): ToolExecResult {
286
- return textResult(formatTodoSnapshot(getTodoItems(messages)), false);
287
- }
288
-
289
- const MAX_EDIT_ERROR_MATCHES = 3;
290
- const MAX_EDIT_ERROR_SNIPPET_LINES = 8;
291
- const MAX_EDIT_ERROR_SNIPPET_LINE_CHARS = 160;
292
- const MIN_EDIT_SIMILARITY_SCORE = 0.45;
293
-
294
- interface EditSnippet {
295
- startLine: number;
296
- endLine: number;
297
- lines: string[];
298
- }
299
-
300
- function splitDisplayLines(content: string): string[] {
301
- const lines = content.replace(/\r\n/g, "\n").split("\n");
302
- if (lines.at(-1) === "") {
303
- lines.pop();
304
- }
305
- return lines;
306
- }
307
-
308
- function countDisplayLines(content: string): number {
309
- return Math.max(splitDisplayLines(content).length, 1);
310
- }
311
-
312
- function formatLineRange(startLine: number, endLine: number): string {
313
- return startLine === endLine
314
- ? `line ${startLine}`
315
- : `lines ${startLine}-${endLine}`;
316
- }
317
-
318
- function truncateSnippetLine(line: string): string {
319
- if (line.length <= MAX_EDIT_ERROR_SNIPPET_LINE_CHARS) {
320
- return line;
321
- }
322
- return `${line.slice(0, MAX_EDIT_ERROR_SNIPPET_LINE_CHARS - 1)}…`;
323
- }
324
-
325
- function formatSnippetLines(lines: readonly string[]): string {
326
- const visibleLines = lines.slice(0, MAX_EDIT_ERROR_SNIPPET_LINES);
327
- const formatted = visibleLines
328
- .map((line) => ` ${truncateSnippetLine(line)}`)
329
- .join("\n");
330
- const hiddenLineCount = lines.length - visibleLines.length;
331
- if (hiddenLineCount <= 0) {
332
- return formatted;
333
- }
334
- return `${formatted}\n … ${hiddenLineCount} more lines`;
335
- }
336
-
337
- function commonPrefixLength(a: string, b: string): number {
338
- let index = 0;
339
- const maxLength = Math.min(a.length, b.length);
340
- while (index < maxLength && a[index] === b[index]) {
341
- index++;
342
- }
343
- return index;
344
- }
345
-
346
- function commonSuffixLength(
347
- a: string,
348
- b: string,
349
- prefixLength: number,
350
- ): number {
351
- let index = 0;
352
- const maxLength = Math.min(a.length, b.length) - prefixLength;
353
- while (
354
- index < maxLength &&
355
- a[a.length - 1 - index] === b[b.length - 1 - index]
356
- ) {
357
- index++;
358
- }
359
- return index;
360
- }
361
-
362
- function scoreSimilarLine(oldLine: string, candidateLine: string): number {
363
- if (oldLine === candidateLine) {
364
- return 1;
365
- }
366
-
367
- const normalizedOldLine = oldLine.trim();
368
- const normalizedCandidateLine = candidateLine.trim();
369
- if (normalizedOldLine === normalizedCandidateLine) {
370
- return normalizedOldLine === "" ? 1 : 0.98;
371
- }
372
- if (normalizedOldLine === "" || normalizedCandidateLine === "") {
373
- return 0;
374
- }
375
-
376
- const prefixLength = commonPrefixLength(
377
- normalizedOldLine,
378
- normalizedCandidateLine,
379
- );
380
- const suffixLength = commonSuffixLength(
381
- normalizedOldLine,
382
- normalizedCandidateLine,
383
- prefixLength,
384
- );
385
- const overlapLength = Math.min(
386
- normalizedOldLine.length,
387
- prefixLength + suffixLength,
388
- );
389
- const maxLength = Math.max(
390
- normalizedOldLine.length,
391
- normalizedCandidateLine.length,
392
- );
393
- const structuralScore = overlapLength / maxLength;
394
-
395
- if (
396
- normalizedOldLine.includes(normalizedCandidateLine) ||
397
- normalizedCandidateLine.includes(normalizedOldLine)
398
- ) {
399
- const sharedLength = Math.min(
400
- normalizedOldLine.length,
401
- normalizedCandidateLine.length,
402
- );
403
- return Math.max(structuralScore, sharedLength / maxLength);
404
- }
405
-
406
- return structuralScore;
407
- }
408
-
409
- function scoreLineWindow(
410
- oldLines: readonly string[],
411
- candidateLines: readonly string[],
412
- ): number {
413
- const maxLineCount = Math.max(oldLines.length, candidateLines.length);
414
- let weightedScore = 0;
415
- let totalWeight = 0;
416
-
417
- for (let index = 0; index < maxLineCount; index++) {
418
- const oldLine = oldLines[index] ?? "";
419
- const candidateLine = candidateLines[index] ?? "";
420
- const weight = Math.max(
421
- oldLine.trim().length,
422
- candidateLine.trim().length,
423
- 1,
424
- );
425
- weightedScore += scoreSimilarLine(oldLine, candidateLine) * weight;
426
- totalWeight += weight;
427
- }
428
-
429
- return totalWeight === 0 ? 0 : weightedScore / totalWeight;
430
- }
431
-
432
- function findClosestEditSnippets(
433
- oldText: string,
434
- content: string,
435
- ): EditSnippet[] {
436
- const oldLines = splitDisplayLines(oldText);
437
- const fileLines = splitDisplayLines(content);
438
- if (fileLines.length === 0) {
439
- return [];
440
- }
441
-
442
- const windowSizes = Array.from(
443
- new Set([
444
- Math.max(1, oldLines.length - 1),
445
- Math.max(1, oldLines.length),
446
- Math.min(fileLines.length, oldLines.length + 1),
447
- ]),
448
- );
449
- const candidates: (EditSnippet & { score: number })[] = [];
450
-
451
- for (const windowSize of windowSizes) {
452
- if (windowSize > fileLines.length) {
453
- continue;
454
- }
455
-
456
- for (
457
- let startLineIndex = 0;
458
- startLineIndex <= fileLines.length - windowSize;
459
- startLineIndex++
460
- ) {
461
- const lines = fileLines.slice(
462
- startLineIndex,
463
- startLineIndex + windowSize,
464
- );
465
- candidates.push({
466
- startLine: startLineIndex + 1,
467
- endLine: startLineIndex + windowSize,
468
- lines,
469
- score: scoreLineWindow(oldLines, lines),
470
- });
471
- }
472
- }
473
-
474
- candidates.sort((a, b) => {
475
- const scoreDelta = b.score - a.score;
476
- if (scoreDelta !== 0) {
477
- return scoreDelta;
478
- }
479
-
480
- const lineSpanDelta =
481
- Math.abs(a.lines.length - oldLines.length) -
482
- Math.abs(b.lines.length - oldLines.length);
483
- if (lineSpanDelta !== 0) {
484
- return lineSpanDelta;
485
- }
486
-
487
- return a.startLine - b.startLine;
488
- });
489
-
490
- const snippets: EditSnippet[] = [];
491
- const seen = new Set<string>();
492
- for (const candidate of candidates) {
493
- if (candidate.score < MIN_EDIT_SIMILARITY_SCORE) {
494
- break;
495
- }
496
-
497
- const key = `${candidate.startLine}:${candidate.endLine}`;
498
- if (seen.has(key)) {
499
- continue;
500
- }
501
-
502
- snippets.push({
503
- startLine: candidate.startLine,
504
- endLine: candidate.endLine,
505
- lines: candidate.lines,
506
- });
507
- seen.add(key);
508
-
509
- if (snippets.length === MAX_EDIT_ERROR_MATCHES) {
510
- break;
511
- }
512
- }
513
-
514
- return snippets;
515
- }
516
-
517
- function buildLineStarts(content: string): number[] {
518
- const lineStarts = [0];
519
- for (let index = 0; index < content.length; index++) {
520
- if (content[index] === "\n") {
521
- lineStarts.push(index + 1);
522
- }
523
- }
524
- return lineStarts;
525
- }
526
-
527
- function findLineNumber(lineStarts: readonly number[], index: number): number {
528
- let low = 0;
529
- let high = lineStarts.length - 1;
530
-
531
- while (low <= high) {
532
- const mid = Math.floor((low + high) / 2);
533
- const lineStart = lineStarts[mid];
534
- if (lineStart === undefined) {
535
- break;
536
- }
537
- if (lineStart <= index) {
538
- low = mid + 1;
539
- } else {
540
- high = mid - 1;
541
- }
542
- }
543
-
544
- return high + 1;
545
- }
546
-
547
- function formatEditNotFoundError(
548
- path: string,
549
- oldText: string,
550
- content: string,
551
- ): string {
552
- const snippets = findClosestEditSnippets(oldText, content);
553
- if (snippets.length === 0) {
554
- return `Old text not found in ${path}`;
555
- }
556
-
557
- return [
558
- `Old text not found in ${path}`,
559
- "Closest matches:",
560
- ...snippets.map(
561
- (snippet) =>
562
- `- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
563
- ),
564
- ].join("\n");
565
- }
566
-
567
- function formatEditMultipleMatchesError(
568
- path: string,
569
- oldText: string,
570
- content: string,
571
- matchIndices: readonly number[],
572
- totalMatches: number,
573
- ): string {
574
- const lineStarts = buildLineStarts(content);
575
- const fileLines = splitDisplayLines(content);
576
- const matchLineCount = countDisplayLines(oldText);
577
- const snippets = matchIndices.map((matchIndex) => {
578
- const startLine = findLineNumber(lineStarts, matchIndex);
579
- const endLine = startLine + matchLineCount - 1;
580
- return {
581
- startLine,
582
- endLine,
583
- lines: fileLines.slice(startLine - 1, endLine),
584
- };
585
- });
586
-
587
- const lines = [
588
- `Old text matches multiple locations (${totalMatches}) in ${path}`,
589
- "Matches:",
590
- ...snippets.map(
591
- (snippet) =>
592
- `- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
593
- ),
594
- ];
595
- const hiddenMatchCount = totalMatches - matchIndices.length;
596
- if (hiddenMatchCount > 0) {
597
- lines.push(`- … ${hiddenMatchCount} more matches`);
598
- }
599
-
600
- return lines.join("\n");
601
- }
602
-
603
- // ---------------------------------------------------------------------------
604
- // edit
605
- // ---------------------------------------------------------------------------
606
-
607
- const editToolParameters = Type.Object({
608
- path: Type.String({
609
- description: "File path (absolute or relative to cwd)",
610
- }),
611
- oldText: Type.String({
612
- description:
613
- 'Exact text to find and replace. Empty string means "create new file".',
614
- }),
615
- newText: Type.String({
616
- description: "Replacement text (or full content for new files)",
617
- }),
618
- });
619
-
620
- /** Arguments for the `edit` tool. */
621
- export type EditArgs = Static<typeof editToolParameters>;
622
-
623
- /**
624
- * Execute an exact-text replacement in a single file.
625
- *
626
- * - If `oldText` is empty, creates a new file (with parent directories).
627
- * Fails if the file already exists.
628
- * - Otherwise, reads the file, finds exactly one occurrence of `oldText`,
629
- * and replaces it with `newText`. Fails if the text is not found or
630
- * matches multiple locations.
631
- *
632
- * @param args - Edit arguments (path, oldText, newText).
633
- * @param cwd - Working directory for resolving relative paths.
634
- * @returns A {@link ToolExecResult} with confirmation or error message.
635
- */
636
- export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
637
- const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
638
-
639
- // Create new file
640
- if (args.oldText === "") {
641
- if (existsSync(filePath)) {
642
- return textResult(`File already exists: ${args.path}`, true);
643
- }
644
- mkdirSync(dirname(filePath), { recursive: true });
645
- writeFileSync(filePath, args.newText, "utf-8");
646
- return textResult(`Created ${args.path}`, false);
647
- }
648
-
649
- // Replace in existing file
650
- if (!existsSync(filePath)) {
651
- return textResult(`File not found: ${args.path}`, true);
652
- }
653
-
654
- const content = readFileSync(filePath, "utf-8");
655
-
656
- // Count occurrences
657
- let count = 0;
658
- const matchIndices: number[] = [];
659
- let idx = 0;
660
- while (true) {
661
- idx = content.indexOf(args.oldText, idx);
662
- if (idx === -1) break;
663
- count++;
664
- if (matchIndices.length < MAX_EDIT_ERROR_MATCHES) {
665
- matchIndices.push(idx);
666
- }
667
- idx += args.oldText.length;
668
- }
669
-
670
- if (count === 0) {
671
- return textResult(
672
- formatEditNotFoundError(args.path, args.oldText, content),
673
- true,
674
- );
675
- }
676
- if (count > 1) {
677
- return textResult(
678
- formatEditMultipleMatchesError(
679
- args.path,
680
- args.oldText,
681
- content,
682
- matchIndices,
683
- count,
684
- ),
685
- true,
686
- );
687
- }
688
-
689
- // Exactly one match — replace
690
- const lineEnding = detectLineEnding(content);
691
- const newText = lineEnding
692
- ? normalizeLineEndings(args.newText, lineEnding)
693
- : args.newText;
694
- const matchIndex = matchIndices[0];
695
- if (matchIndex === undefined) {
696
- return textResult(`Old text not found in ${args.path}`, true);
697
- }
698
- const updated =
699
- content.slice(0, matchIndex) +
700
- newText +
701
- content.slice(matchIndex + args.oldText.length);
702
- writeFileSync(filePath, updated, "utf-8");
703
- return textResult(`Edited ${args.path}`, false);
704
- }
705
-
706
- // ---------------------------------------------------------------------------
707
- // readImage
708
- // ---------------------------------------------------------------------------
709
-
710
- /** Supported image extensions and their MIME types. */
711
- const IMAGE_MIME_TYPES: Record<string, string> = {
712
- ".png": "image/png",
713
- ".jpg": "image/jpeg",
714
- ".jpeg": "image/jpeg",
715
- ".gif": "image/gif",
716
- ".webp": "image/webp",
717
- };
718
-
719
- const PNG_SIGNATURE = Buffer.from([
720
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
721
- ]);
722
- const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8]);
723
- const GIF_SIGNATURES = ["GIF87a", "GIF89a"];
724
- const WEBP_RIFF_SIGNATURE = "RIFF";
725
- const WEBP_FILE_SIGNATURE = "WEBP";
726
-
727
- interface PngChunk {
728
- typeBytes: Buffer;
729
- type: string;
730
- data: Buffer;
731
- }
732
-
733
- interface PngValidationState {
734
- sawIHDR: boolean;
735
- sawIDAT: boolean;
736
- sawIEND: boolean;
737
- width: number;
738
- height: number;
739
- bitDepth: number;
740
- colorType: number;
741
- interlaceMethod: number;
742
- compressedParts: Buffer[];
743
- }
744
-
745
- function createPngValidationState(): PngValidationState {
746
- return {
747
- sawIHDR: false,
748
- sawIDAT: false,
749
- sawIEND: false,
750
- width: 0,
751
- height: 0,
752
- bitDepth: 0,
753
- colorType: 0,
754
- interlaceMethod: 0,
755
- compressedParts: [],
756
- };
757
- }
758
-
759
- function isValidPngColorFormat(bitDepth: number, colorType: number): boolean {
760
- switch (colorType) {
761
- case 0:
762
- return [1, 2, 4, 8, 16].includes(bitDepth);
763
- case 2:
764
- case 4:
765
- case 6:
766
- return bitDepth === 8 || bitDepth === 16;
767
- case 3:
768
- return [1, 2, 4, 8].includes(bitDepth);
769
- default:
770
- return false;
771
- }
772
- }
773
-
774
- function getPngScanlineByteLength(
775
- width: number,
776
- bitDepth: number,
777
- colorType: number,
778
- ): number {
779
- const samplesPerPixel =
780
- colorType === 0 || colorType === 3
781
- ? 1
782
- : colorType === 4
783
- ? 2
784
- : colorType === 2
785
- ? 3
786
- : 4;
787
- return Math.ceil((width * bitDepth * samplesPerPixel) / 8);
788
- }
789
-
790
- function readPngChunk(
791
- data: Buffer,
792
- offset: number,
793
- ): { chunk: PngChunk; nextOffset: number } | string {
794
- if (offset + 12 > data.length) {
795
- return "truncated PNG chunk header";
796
- }
797
-
798
- const length = data.readUInt32BE(offset);
799
- const typeBytes = data.subarray(offset + 4, offset + 8);
800
- const type = typeBytes.toString("ascii");
801
- const dataOffset = offset + 8;
802
- const nextOffset = dataOffset + length + 4;
803
-
804
- if (nextOffset > data.length) {
805
- return `truncated PNG chunk ${type}`;
806
- }
807
-
808
- const chunkData = data.subarray(dataOffset, dataOffset + length);
809
- const storedCrc = data.readUInt32BE(dataOffset + length);
810
- const computedCrc = crc32(Buffer.concat([typeBytes, chunkData]));
811
-
812
- if (storedCrc !== computedCrc) {
813
- return `invalid PNG CRC for chunk ${type}`;
814
- }
815
-
816
- return {
817
- chunk: { typeBytes, type, data: chunkData },
818
- nextOffset,
819
- };
820
- }
821
-
822
- function validatePngHeaderChunk(
823
- state: PngValidationState,
824
- chunk: PngChunk,
825
- ): string | null {
826
- if (state.sawIHDR || state.sawIDAT || chunk.data.length !== 13) {
827
- return "invalid IHDR chunk";
828
- }
829
-
830
- const compressionMethod = chunk.data[10] ?? 0;
831
- const filterMethod = chunk.data[11] ?? 0;
832
- const interlaceMethod = chunk.data[12] ?? 0;
833
-
834
- state.sawIHDR = true;
835
- state.width = chunk.data.readUInt32BE(0);
836
- state.height = chunk.data.readUInt32BE(4);
837
- state.bitDepth = chunk.data[8] ?? 0;
838
- state.colorType = chunk.data[9] ?? 0;
839
- state.interlaceMethod = interlaceMethod;
840
-
841
- if (state.width === 0 || state.height === 0) {
842
- return "invalid PNG image size";
843
- }
844
- if (!isValidPngColorFormat(state.bitDepth, state.colorType)) {
845
- return "unsupported PNG color format";
846
- }
847
- if (compressionMethod !== 0 || filterMethod !== 0) {
848
- return "unsupported PNG header values";
849
- }
850
- if (interlaceMethod !== 0 && interlaceMethod !== 1) {
851
- return "unsupported PNG interlace method";
852
- }
853
-
854
- return null;
855
- }
856
-
857
- function applyPngChunk(
858
- state: PngValidationState,
859
- chunk: PngChunk,
860
- nextOffset: number,
861
- totalLength: number,
862
- ): string | null {
863
- if (chunk.type === "IHDR") {
864
- return validatePngHeaderChunk(state, chunk);
865
- }
866
-
867
- if (!state.sawIHDR) {
868
- return "missing IHDR chunk";
869
- }
870
-
871
- if (chunk.type === "IDAT") {
872
- if (state.sawIEND) {
873
- return "invalid PNG chunk order";
874
- }
875
- state.sawIDAT = true;
876
- state.compressedParts.push(chunk.data);
877
- return null;
878
- }
879
-
880
- if (chunk.type !== "IEND") {
881
- return null;
882
- }
883
-
884
- if (!state.sawIDAT) {
885
- return "missing IDAT chunk";
886
- }
887
- if (chunk.data.length !== 0) {
888
- return "invalid IEND chunk length";
889
- }
890
-
891
- state.sawIEND = true;
892
- if (nextOffset !== totalLength) {
893
- return "unexpected trailing data after IEND chunk";
894
- }
895
-
896
- return null;
897
- }
898
-
899
- function validateInflatedPngData(state: PngValidationState): string | null {
900
- try {
901
- const inflated = inflateSync(Buffer.concat(state.compressedParts));
902
- if (state.interlaceMethod !== 0) {
903
- return null;
904
- }
905
-
906
- const expectedLength =
907
- state.height *
908
- (1 +
909
- getPngScanlineByteLength(state.width, state.bitDepth, state.colorType));
910
- if (inflated.length !== expectedLength) {
911
- return "decoded PNG payload does not match image dimensions";
912
- }
913
- return null;
914
- } catch (error) {
915
- const message = error instanceof Error ? error.message : String(error);
916
- return `corrupt PNG image data: ${message}`;
917
- }
918
- }
919
-
920
- function validatePngImageData(data: Buffer): string | null {
921
- if (
922
- data.length < PNG_SIGNATURE.length ||
923
- !data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
924
- ) {
925
- return "invalid PNG signature";
926
- }
927
-
928
- const state = createPngValidationState();
929
- let offset = PNG_SIGNATURE.length;
930
-
931
- while (offset < data.length) {
932
- const parsedChunk = readPngChunk(data, offset);
933
- if (typeof parsedChunk === "string") {
934
- return parsedChunk;
935
- }
936
-
937
- const chunkError = applyPngChunk(
938
- state,
939
- parsedChunk.chunk,
940
- parsedChunk.nextOffset,
941
- data.length,
942
- );
943
- if (chunkError) {
944
- return chunkError;
945
- }
946
-
947
- offset = parsedChunk.nextOffset;
948
- }
949
-
950
- if (!state.sawIHDR) {
951
- return "missing IHDR chunk";
952
- }
953
- if (!state.sawIDAT) {
954
- return "missing IDAT chunk";
955
- }
956
- if (!state.sawIEND) {
957
- return "missing IEND chunk";
958
- }
959
-
960
- return validateInflatedPngData(state);
961
- }
962
-
963
- function validateJpegImageData(data: Buffer): string | null {
964
- if (
965
- data.length < 4 ||
966
- !data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE) ||
967
- data.at(-2) !== 0xff ||
968
- data.at(-1) !== 0xd9
969
- ) {
970
- return "invalid JPEG markers";
971
- }
972
- return null;
973
- }
974
-
975
- function validateGifImageData(data: Buffer): string | null {
976
- if (data.length < 14) {
977
- return "truncated GIF file";
978
- }
979
-
980
- const header = data.subarray(0, 6).toString("ascii");
981
- if (!GIF_SIGNATURES.includes(header)) {
982
- return "invalid GIF signature";
983
- }
984
- if (data.at(-1) !== 0x3b) {
985
- return "missing GIF trailer";
986
- }
987
- return null;
988
- }
989
-
990
- function validateWebpImageData(data: Buffer): string | null {
991
- if (data.length < 16) {
992
- return "truncated WebP file";
993
- }
994
-
995
- if (
996
- data.subarray(0, 4).toString("ascii") !== WEBP_RIFF_SIGNATURE ||
997
- data.subarray(8, 12).toString("ascii") !== WEBP_FILE_SIGNATURE
998
- ) {
999
- return "invalid WebP signature";
1000
- }
1001
-
1002
- const riffSize = data.readUInt32LE(4);
1003
- if (riffSize + 8 > data.length) {
1004
- return "truncated WebP file";
1005
- }
1006
-
1007
- return null;
1008
- }
1009
-
1010
- function validateImageData(mimeType: string, data: Buffer): string | null {
1011
- switch (mimeType) {
1012
- case "image/png":
1013
- return validatePngImageData(data);
1014
- case "image/jpeg":
1015
- return validateJpegImageData(data);
1016
- case "image/gif":
1017
- return validateGifImageData(data);
1018
- case "image/webp":
1019
- return validateWebpImageData(data);
1020
- default:
1021
- return `unsupported image MIME type ${mimeType}`;
1022
- }
1023
- }
1024
-
1025
- const readImageToolParameters = Type.Object({
1026
- path: Type.String({
1027
- description: "File path (absolute or relative to cwd)",
1028
- }),
1029
- });
1030
-
1031
- /** Arguments for the `readImage` tool. */
1032
- export type ReadImageArgs = Static<typeof readImageToolParameters>;
1033
-
1034
- /**
1035
- * Read an image file and return it as base64-encoded content.
1036
- *
1037
- * Supports PNG, JPEG, GIF, and WebP. Returns {@link ImageContent} on
1038
- * success or a text error message on failure. The MIME type is detected
1039
- * from the file extension.
1040
- *
1041
- * @param args - ReadImage arguments (path).
1042
- * @param cwd - Working directory for resolving relative paths.
1043
- * @returns A {@link ToolExecResult} with image content or error message.
1044
- */
1045
- export function executeReadImage(
1046
- args: ReadImageArgs,
1047
- cwd: string,
1048
- ): ToolExecResult {
1049
- const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
1050
- const ext = extname(filePath).toLowerCase();
1051
-
1052
- const mimeType = IMAGE_MIME_TYPES[ext];
1053
- if (!mimeType) {
1054
- return textResult(
1055
- `Unsupported image format: ${ext || "(no extension)"}`,
1056
- true,
1057
- );
1058
- }
1059
-
1060
- if (!existsSync(filePath)) {
1061
- return textResult(`File not found: ${args.path}`, true);
1062
- }
1063
-
1064
- try {
1065
- const data = readFileSync(filePath);
1066
- const validationError = validateImageData(mimeType, data);
1067
- if (validationError) {
1068
- return textResult(
1069
- `Invalid image file ${args.path}: ${validationError}`,
1070
- true,
1071
- );
1072
- }
1073
-
1074
- return {
1075
- content: [{ type: "image", data: data.toString("base64"), mimeType }],
1076
- isError: false,
1077
- };
1078
- } catch (error) {
1079
- const message = error instanceof Error ? error.message : String(error);
1080
- return textResult(`Failed to read image ${args.path}: ${message}`, true);
1081
- }
1082
- }
1083
-
1084
- // ---------------------------------------------------------------------------
1085
- // Tool definitions (pi-ai Tool schemas)
1086
- // ---------------------------------------------------------------------------
1087
-
1088
- /** pi-ai tool definition for `edit`. */
1089
- export const editTool: Tool<typeof editToolParameters> = {
1090
- name: "edit",
1091
- description:
1092
- "Make an exact-text replacement in a single file. " +
1093
- "Provide the file path, the exact text to find, and the replacement text. " +
1094
- "The old text must match exactly one location in the file. " +
1095
- "To create a new file, use an empty old text and the full file content as new text. " +
1096
- "Use this to write the exact final file content the task requires.",
1097
- parameters: editToolParameters,
1098
- };
1099
-
1100
- /**
1101
- * Tool handler that validates edit arguments before execution.
1102
- *
1103
- * @param args - Raw parsed tool-call arguments.
1104
- * @param cwd - Working directory for path resolution.
1105
- * @returns The edit tool result.
1106
- */
1107
- export const editToolHandler: ToolHandler = (args, cwd) =>
1108
- executeEdit(validateBuiltinToolArgs(editTool, args), cwd);
1109
-
1110
- /** pi-ai tool definition for `todoWrite`. */
1111
- export const todoWriteTool: Tool<typeof todoWriteToolParameters> = {
1112
- name: "todoWrite",
1113
- description:
1114
- "Use this tool to create and manage a structured task list for your current coding session. " +
1115
- "This helps you track progress, organize complex tasks, and keep the user informed. " +
1116
- "Only send the items that changed; unchanged items stay as they are. " +
1117
- "Each item must include `content` and `status`, where `status` is one of `pending`, `in_progress`, `completed`, or `cancelled`. " +
1118
- "Use `cancelled` to remove an item from the list. " +
1119
- "Mark tasks `in_progress` before starting them and `completed` immediately after verification succeeds.",
1120
- parameters: todoWriteToolParameters,
1121
- };
1122
-
1123
- /**
1124
- * Create a `todoWrite` handler bound to the current persisted message history.
1125
- *
1126
- * @param messages - Current persisted message history.
1127
- * @returns Tool handler for todo writes.
1128
- */
1129
- export function createTodoWriteToolHandler(
1130
- messages: readonly TodoHistoryMessage[],
1131
- ): ToolHandler {
1132
- return (args) =>
1133
- executeTodoWrite(validateBuiltinToolArgs(todoWriteTool, args), messages);
1134
- }
1135
-
1136
- /** pi-ai tool definition for `todoRead`. */
1137
- export const todoReadTool: Tool<typeof todoReadToolParameters> = {
1138
- name: "todoRead",
1139
- description:
1140
- "Retrieves the current todo list for this coding session. " +
1141
- "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. " +
1142
- "If no todos exist yet, it returns an empty list.",
1143
- parameters: todoReadToolParameters,
1144
- };
1145
-
1146
- /**
1147
- * Create a `todoRead` handler bound to the current persisted message history.
1148
- *
1149
- * @param messages - Current persisted message history.
1150
- * @returns Tool handler for todo reads.
1151
- */
1152
- export function createTodoReadToolHandler(
1153
- messages: readonly TodoHistoryMessage[],
1154
- ): ToolHandler {
1155
- return (args) => {
1156
- validateBuiltinToolArgs(todoReadTool, args);
1157
- return executeTodoRead(messages);
1158
- };
1159
- }
1160
-
1161
- /** pi-ai tool definition for `readImage`. */
1162
- export const readImageTool: Tool<typeof readImageToolParameters> = {
1163
- name: "readImage",
1164
- description:
1165
- "Read an image file and return its contents. " +
1166
- "Supports PNG, JPEG, GIF, and WebP formats. " +
1167
- "Use this to inspect screenshots, diagrams, or any image in the repo.",
1168
- parameters: readImageToolParameters,
1169
- };
1170
-
1171
- /**
1172
- * Tool handler that validates image-read arguments before execution.
1173
- *
1174
- * @param args - Raw parsed tool-call arguments.
1175
- * @param cwd - Working directory for path resolution.
1176
- * @returns The readImage tool result.
1177
- */
1178
- export const readImageToolHandler: ToolHandler = (args, cwd) =>
1179
- executeReadImage(validateBuiltinToolArgs(readImageTool, args), cwd);