mini-coder 0.4.1 → 0.5.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.
- package/README.md +87 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +592 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +164 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +645 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +294 -0
- package/src/session.ts +838 -0
- package/src/settings.ts +184 -0
- package/src/skills.ts +258 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +26 -0
- package/src/ui/help.ts +119 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +450 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +615 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
package/src/tools.ts
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in tool implementations: `edit`, `shell`, and `readImage`.
|
|
3
|
+
*
|
|
4
|
+
* Each tool is exposed as a pure-ish execute function that takes typed
|
|
5
|
+
* arguments and a working directory, returning a result object. The pi-ai
|
|
6
|
+
* {@link Tool} definitions (TypeBox schemas) are exported separately for
|
|
7
|
+
* registration with the agent context.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, extname, isAbsolute, join } from "node:path";
|
|
14
|
+
import type { ImageContent, TextContent, Tool } from "@mariozechner/pi-ai";
|
|
15
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
16
|
+
import type { ToolUpdateCallback } from "./agent.ts";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Result type
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Result from executing a tool.
|
|
24
|
+
*
|
|
25
|
+
* Content blocks carry either text or image data. The agent loop
|
|
26
|
+
* maps these directly into {@link ToolResultMessage.content}.
|
|
27
|
+
*/
|
|
28
|
+
export interface ToolExecResult {
|
|
29
|
+
/** Content blocks for the tool result (text and/or images). */
|
|
30
|
+
content: (TextContent | ImageContent)[];
|
|
31
|
+
/** Whether the execution encountered an error. */
|
|
32
|
+
isError: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Convenience: build a text-only {@link ToolExecResult}. */
|
|
36
|
+
function textResult(text: string, isError: boolean): ToolExecResult {
|
|
37
|
+
return { content: [{ type: "text", text }], isError };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function detectLineEnding(content: string): "\n" | "\r\n" | null {
|
|
41
|
+
if (content.includes("\r\n")) {
|
|
42
|
+
return "\r\n";
|
|
43
|
+
}
|
|
44
|
+
if (content.includes("\n")) {
|
|
45
|
+
return "\n";
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeLineEndings(
|
|
51
|
+
content: string,
|
|
52
|
+
lineEnding: "\n" | "\r\n",
|
|
53
|
+
): string {
|
|
54
|
+
if (lineEnding === "\r\n") {
|
|
55
|
+
return content.replace(/\r?\n/g, "\r\n");
|
|
56
|
+
}
|
|
57
|
+
return content.replace(/\r\n/g, "\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// edit
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Arguments for the `edit` tool. */
|
|
65
|
+
interface EditArgs {
|
|
66
|
+
/** File path (absolute or relative to cwd). */
|
|
67
|
+
path: string;
|
|
68
|
+
/** Exact text to find. Empty string means "create new file". */
|
|
69
|
+
oldText: string;
|
|
70
|
+
/** Replacement text (or full content for new files). */
|
|
71
|
+
newText: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Execute an exact-text replacement in a single file.
|
|
76
|
+
*
|
|
77
|
+
* - If `oldText` is empty, creates a new file (with parent directories).
|
|
78
|
+
* Fails if the file already exists.
|
|
79
|
+
* - Otherwise, reads the file, finds exactly one occurrence of `oldText`,
|
|
80
|
+
* and replaces it with `newText`. Fails if the text is not found or
|
|
81
|
+
* matches multiple locations.
|
|
82
|
+
*
|
|
83
|
+
* @param args - Edit arguments (path, oldText, newText).
|
|
84
|
+
* @param cwd - Working directory for resolving relative paths.
|
|
85
|
+
* @returns A {@link ToolExecResult} with confirmation or error message.
|
|
86
|
+
*/
|
|
87
|
+
export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
|
|
88
|
+
const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
|
|
89
|
+
|
|
90
|
+
// Create new file
|
|
91
|
+
if (args.oldText === "") {
|
|
92
|
+
if (existsSync(filePath)) {
|
|
93
|
+
return textResult(`File already exists: ${args.path}`, true);
|
|
94
|
+
}
|
|
95
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
96
|
+
writeFileSync(filePath, args.newText, "utf-8");
|
|
97
|
+
return textResult(`Created ${args.path}`, false);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Replace in existing file
|
|
101
|
+
if (!existsSync(filePath)) {
|
|
102
|
+
return textResult(`File not found: ${args.path}`, true);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const content = readFileSync(filePath, "utf-8");
|
|
106
|
+
|
|
107
|
+
// Count occurrences
|
|
108
|
+
let count = 0;
|
|
109
|
+
let idx = 0;
|
|
110
|
+
while (true) {
|
|
111
|
+
idx = content.indexOf(args.oldText, idx);
|
|
112
|
+
if (idx === -1) break;
|
|
113
|
+
count++;
|
|
114
|
+
idx += args.oldText.length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (count === 0) {
|
|
118
|
+
return textResult(`Old text not found in ${args.path}`, true);
|
|
119
|
+
}
|
|
120
|
+
if (count > 1) {
|
|
121
|
+
return textResult(
|
|
122
|
+
`Old text matches multiple locations (${count}) in ${args.path}`,
|
|
123
|
+
true,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Exactly one match — replace
|
|
128
|
+
const lineEnding = detectLineEnding(content);
|
|
129
|
+
const newText = lineEnding
|
|
130
|
+
? normalizeLineEndings(args.newText, lineEnding)
|
|
131
|
+
: args.newText;
|
|
132
|
+
const updated = content.replace(args.oldText, newText);
|
|
133
|
+
writeFileSync(filePath, updated, "utf-8");
|
|
134
|
+
return textResult(`Edited ${args.path}`, false);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// shell
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/** Arguments for the `shell` tool. */
|
|
142
|
+
interface ShellArgs {
|
|
143
|
+
/** The command to run. */
|
|
144
|
+
command: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Options for shell execution. */
|
|
148
|
+
interface ShellOpts {
|
|
149
|
+
/** Maximum output lines before truncation. Default: 1000. */
|
|
150
|
+
maxLines?: number;
|
|
151
|
+
/** Maximum UTF-8 bytes before truncation. Default: 50_000. */
|
|
152
|
+
maxBytes?: number;
|
|
153
|
+
/** Abort signal to cancel the command. */
|
|
154
|
+
signal?: AbortSignal;
|
|
155
|
+
/** Callback for progressive output updates while the command is running. */
|
|
156
|
+
onUpdate?: ToolUpdateCallback;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const DEFAULT_MAX_LINES = 1000;
|
|
160
|
+
const DEFAULT_MAX_BYTES = 50_000;
|
|
161
|
+
const SHELL_UPDATE_INTERVAL_MS = 75;
|
|
162
|
+
|
|
163
|
+
/** Format combined stdout/stderr for display in tool results. */
|
|
164
|
+
function formatShellOutput(stdout: string, stderr: string): string {
|
|
165
|
+
if (stdout && stderr) {
|
|
166
|
+
return `${stdout}\n\n[stderr]\n${stderr}`;
|
|
167
|
+
}
|
|
168
|
+
if (stdout) {
|
|
169
|
+
return stdout;
|
|
170
|
+
}
|
|
171
|
+
if (stderr) {
|
|
172
|
+
return `[stderr]\n${stderr}`;
|
|
173
|
+
}
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Read a spawned shell stream into a string, reporting progressive updates. */
|
|
178
|
+
async function consumeShellStream(
|
|
179
|
+
stream: ReadableStream<Uint8Array>,
|
|
180
|
+
onChunk: (chunk: string) => void,
|
|
181
|
+
): Promise<string> {
|
|
182
|
+
const reader = stream.getReader();
|
|
183
|
+
const decoder = new TextDecoder();
|
|
184
|
+
let output = "";
|
|
185
|
+
|
|
186
|
+
while (true) {
|
|
187
|
+
const { done, value } = await reader.read();
|
|
188
|
+
if (done) {
|
|
189
|
+
output += decoder.decode();
|
|
190
|
+
return output;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
194
|
+
output += chunk;
|
|
195
|
+
onChunk(chunk);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Run a command in the user's shell.
|
|
201
|
+
*
|
|
202
|
+
* Executes via `$SHELL -c` (falling back to `/bin/sh`). Returns combined
|
|
203
|
+
* stdout/stderr and the exit code. Large output is truncated to keep
|
|
204
|
+
* head + tail lines with a middle marker.
|
|
205
|
+
*
|
|
206
|
+
* @param args - Shell arguments (command).
|
|
207
|
+
* @param cwd - Working directory to run the command in.
|
|
208
|
+
* @param opts - Optional execution options (maxLines, signal, onUpdate).
|
|
209
|
+
* @returns A {@link ToolExecResult} with the command output.
|
|
210
|
+
*/
|
|
211
|
+
export async function executeShell(
|
|
212
|
+
args: ShellArgs,
|
|
213
|
+
cwd: string,
|
|
214
|
+
opts?: ShellOpts,
|
|
215
|
+
): Promise<ToolExecResult> {
|
|
216
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
217
|
+
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
|
|
218
|
+
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
219
|
+
let updateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
|
|
223
|
+
cwd,
|
|
224
|
+
stdout: "pipe",
|
|
225
|
+
stderr: "pipe",
|
|
226
|
+
};
|
|
227
|
+
if (opts?.signal) spawnOpts.signal = opts.signal;
|
|
228
|
+
const proc = Bun.spawn([shell, "-c", args.command], spawnOpts);
|
|
229
|
+
|
|
230
|
+
let stdoutBuf = "";
|
|
231
|
+
let stderrBuf = "";
|
|
232
|
+
let lastReportedOutput = "";
|
|
233
|
+
let lastReportAt = 0;
|
|
234
|
+
|
|
235
|
+
const clearPendingUpdate = (): void => {
|
|
236
|
+
if (updateTimer) {
|
|
237
|
+
clearTimeout(updateTimer);
|
|
238
|
+
updateTimer = null;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const buildProgressOutput = (): string => {
|
|
243
|
+
return truncateOutput(
|
|
244
|
+
formatShellOutput(stdoutBuf, stderrBuf),
|
|
245
|
+
maxLines,
|
|
246
|
+
maxBytes,
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const emitUpdate = (): void => {
|
|
251
|
+
clearPendingUpdate();
|
|
252
|
+
if (!opts?.onUpdate) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const output = buildProgressOutput();
|
|
257
|
+
if (!output || output === lastReportedOutput) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
lastReportedOutput = output;
|
|
262
|
+
lastReportAt = Date.now();
|
|
263
|
+
opts.onUpdate(textResult(output, false));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const scheduleUpdate = (): void => {
|
|
267
|
+
if (!opts?.onUpdate) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const elapsed = Date.now() - lastReportAt;
|
|
272
|
+
if (elapsed >= SHELL_UPDATE_INTERVAL_MS) {
|
|
273
|
+
emitUpdate();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (updateTimer) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
updateTimer = setTimeout(() => {
|
|
281
|
+
emitUpdate();
|
|
282
|
+
}, SHELL_UPDATE_INTERVAL_MS - elapsed);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
286
|
+
consumeShellStream(proc.stdout as ReadableStream<Uint8Array>, (chunk) => {
|
|
287
|
+
stdoutBuf += chunk;
|
|
288
|
+
scheduleUpdate();
|
|
289
|
+
}),
|
|
290
|
+
consumeShellStream(proc.stderr as ReadableStream<Uint8Array>, (chunk) => {
|
|
291
|
+
stderrBuf += chunk;
|
|
292
|
+
scheduleUpdate();
|
|
293
|
+
}),
|
|
294
|
+
proc.exited,
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
clearPendingUpdate();
|
|
298
|
+
const output = truncateOutput(
|
|
299
|
+
formatShellOutput(stdout.trimEnd(), stderr.trimEnd()),
|
|
300
|
+
maxLines,
|
|
301
|
+
maxBytes,
|
|
302
|
+
);
|
|
303
|
+
if (opts?.onUpdate && output && output !== lastReportedOutput) {
|
|
304
|
+
lastReportedOutput = output;
|
|
305
|
+
opts.onUpdate(textResult(output, false));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const isError = exitCode !== 0;
|
|
309
|
+
const body = output || "(no output)";
|
|
310
|
+
return textResult(`Exit code: ${exitCode}\n${body}`, isError);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (updateTimer) {
|
|
313
|
+
clearTimeout(updateTimer);
|
|
314
|
+
updateTimer = null;
|
|
315
|
+
}
|
|
316
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
317
|
+
return textResult(`Shell error: ${message}`, true);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Output truncation
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/** Build line-limited head/tail segments and their truncation marker. */
|
|
326
|
+
function buildLineTruncation(
|
|
327
|
+
output: string,
|
|
328
|
+
maxLines: number,
|
|
329
|
+
): {
|
|
330
|
+
head: string;
|
|
331
|
+
tail: string;
|
|
332
|
+
marker: string;
|
|
333
|
+
} | null {
|
|
334
|
+
const lines = output.split("\n");
|
|
335
|
+
if (lines.length <= maxLines) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const headCount = Math.ceil(maxLines / 2);
|
|
340
|
+
const tailCount = Math.floor(maxLines / 2);
|
|
341
|
+
const omitted = lines.length - headCount - tailCount;
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
head: lines.slice(0, headCount).join("\n"),
|
|
345
|
+
tail: lines.slice(lines.length - tailCount).join("\n"),
|
|
346
|
+
marker: `\n… truncated ${omitted} lines …\n`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isHighSurrogate(codeUnit: number): boolean {
|
|
351
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isLowSurrogate(codeUnit: number): boolean {
|
|
355
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function findUtf8SliceLength(
|
|
359
|
+
input: string,
|
|
360
|
+
maxBytes: number,
|
|
361
|
+
getCandidate: (length: number) => string,
|
|
362
|
+
): number {
|
|
363
|
+
if (maxBytes <= 0 || input === "") {
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let low = 0;
|
|
368
|
+
let high = input.length;
|
|
369
|
+
while (low < high) {
|
|
370
|
+
const mid = Math.ceil((low + high) / 2);
|
|
371
|
+
const candidate = getCandidate(mid);
|
|
372
|
+
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
|
|
373
|
+
low = mid;
|
|
374
|
+
} else {
|
|
375
|
+
high = mid - 1;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return low;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function normalizeUtf8PrefixEnd(input: string, end: number): number {
|
|
383
|
+
if (end <= 0 || end >= input.length) {
|
|
384
|
+
return end;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const previousCodeUnit = input.charCodeAt(end - 1);
|
|
388
|
+
const nextCodeUnit = input.charCodeAt(end);
|
|
389
|
+
if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
|
|
390
|
+
return end - 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return end;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeUtf8SuffixStart(input: string, start: number): number {
|
|
397
|
+
if (start <= 0 || start >= input.length) {
|
|
398
|
+
return start;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const previousCodeUnit = input.charCodeAt(start - 1);
|
|
402
|
+
const nextCodeUnit = input.charCodeAt(start);
|
|
403
|
+
if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
|
|
404
|
+
return start + 1;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return start;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Slice the largest UTF-8 prefix that fits within `maxBytes`. */
|
|
411
|
+
function sliceUtf8Prefix(input: string, maxBytes: number): string {
|
|
412
|
+
const end = normalizeUtf8PrefixEnd(
|
|
413
|
+
input,
|
|
414
|
+
findUtf8SliceLength(input, maxBytes, (length) => input.slice(0, length)),
|
|
415
|
+
);
|
|
416
|
+
return input.slice(0, end);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Slice the largest UTF-8 suffix that fits within `maxBytes`. */
|
|
420
|
+
function sliceUtf8Suffix(input: string, maxBytes: number): string {
|
|
421
|
+
const start = normalizeUtf8SuffixStart(
|
|
422
|
+
input,
|
|
423
|
+
input.length -
|
|
424
|
+
findUtf8SliceLength(input, maxBytes, (length) =>
|
|
425
|
+
input.slice(input.length - length),
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
return input.slice(start);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Fit disjoint head/tail segments plus a marker within a UTF-8 byte budget. */
|
|
432
|
+
function fitSegmentsWithinBytes(
|
|
433
|
+
headSource: string,
|
|
434
|
+
tailSource: string,
|
|
435
|
+
marker: string,
|
|
436
|
+
maxBytes: number,
|
|
437
|
+
): string {
|
|
438
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
439
|
+
if (markerBytes >= maxBytes) {
|
|
440
|
+
return sliceUtf8Prefix(headSource, maxBytes);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const availableBytes = maxBytes - markerBytes;
|
|
444
|
+
const headBudget = Math.ceil(availableBytes / 2);
|
|
445
|
+
const tailBudget = Math.floor(availableBytes / 2);
|
|
446
|
+
|
|
447
|
+
let head = sliceUtf8Prefix(headSource, headBudget);
|
|
448
|
+
let tail = sliceUtf8Suffix(tailSource, tailBudget);
|
|
449
|
+
|
|
450
|
+
const usedBytes =
|
|
451
|
+
Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
|
|
452
|
+
let remainingBytes = availableBytes - usedBytes;
|
|
453
|
+
|
|
454
|
+
if (remainingBytes > 0) {
|
|
455
|
+
const headBytes = Buffer.byteLength(head, "utf8");
|
|
456
|
+
const expandedHead = sliceUtf8Prefix(
|
|
457
|
+
headSource,
|
|
458
|
+
headBytes + remainingBytes,
|
|
459
|
+
);
|
|
460
|
+
remainingBytes -= Buffer.byteLength(expandedHead, "utf8") - headBytes;
|
|
461
|
+
head = expandedHead;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (remainingBytes > 0) {
|
|
465
|
+
const tailBytes = Buffer.byteLength(tail, "utf8");
|
|
466
|
+
tail = sliceUtf8Suffix(tailSource, tailBytes + remainingBytes);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return head + marker + tail;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Truncate output by UTF-8 byte size, preserving head and tail text. */
|
|
473
|
+
function truncateOutputByBytes(output: string, maxBytes: number): string {
|
|
474
|
+
if (Buffer.byteLength(output, "utf8") <= maxBytes) {
|
|
475
|
+
return output;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return fitSegmentsWithinBytes(
|
|
479
|
+
output,
|
|
480
|
+
output,
|
|
481
|
+
"\n… truncated for size …\n",
|
|
482
|
+
maxBytes,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Truncate output to keep useful head and tail content within line and byte budgets.
|
|
488
|
+
*
|
|
489
|
+
* The line budget avoids flooding the model with very tall outputs, while the
|
|
490
|
+
* byte budget prevents context explosions caused by a small number of very long
|
|
491
|
+
* lines.
|
|
492
|
+
*
|
|
493
|
+
* @param output - The full output string.
|
|
494
|
+
* @param maxLines - Maximum number of content lines to keep.
|
|
495
|
+
* @param maxBytes - Maximum UTF-8 bytes to keep.
|
|
496
|
+
* @returns The (possibly truncated) output string.
|
|
497
|
+
*/
|
|
498
|
+
export function truncateOutput(
|
|
499
|
+
output: string,
|
|
500
|
+
maxLines: number,
|
|
501
|
+
maxBytes: number,
|
|
502
|
+
): string {
|
|
503
|
+
if (!output) return output;
|
|
504
|
+
|
|
505
|
+
const lineTruncation = buildLineTruncation(output, maxLines);
|
|
506
|
+
if (!lineTruncation) {
|
|
507
|
+
return truncateOutputByBytes(output, maxBytes);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const lineLimited =
|
|
511
|
+
lineTruncation.head + lineTruncation.marker + lineTruncation.tail;
|
|
512
|
+
if (Buffer.byteLength(lineLimited, "utf8") <= maxBytes) {
|
|
513
|
+
return lineLimited;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return fitSegmentsWithinBytes(
|
|
517
|
+
lineTruncation.head,
|
|
518
|
+
lineTruncation.tail,
|
|
519
|
+
lineTruncation.marker,
|
|
520
|
+
maxBytes,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Tool definitions (pi-ai Tool schemas)
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
/** pi-ai tool definition for `edit`. */
|
|
529
|
+
export const editTool: Tool = {
|
|
530
|
+
name: "edit",
|
|
531
|
+
description:
|
|
532
|
+
"Make an exact-text replacement in a single file. " +
|
|
533
|
+
"Provide the file path, the exact text to find, and the replacement text. " +
|
|
534
|
+
"The old text must match exactly one location in the file. " +
|
|
535
|
+
"To create a new file, use an empty old text and the full file content as new text.",
|
|
536
|
+
parameters: Type.Object({
|
|
537
|
+
path: Type.String({
|
|
538
|
+
description: "File path (absolute or relative to cwd)",
|
|
539
|
+
}),
|
|
540
|
+
oldText: Type.String({
|
|
541
|
+
description:
|
|
542
|
+
'Exact text to find and replace. Empty string means "create new file".',
|
|
543
|
+
}),
|
|
544
|
+
newText: Type.String({
|
|
545
|
+
description: "Replacement text (or full content for new files)",
|
|
546
|
+
}),
|
|
547
|
+
}),
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
/** pi-ai tool definition for `shell`. */
|
|
551
|
+
export const shellTool: Tool = {
|
|
552
|
+
name: "shell",
|
|
553
|
+
description:
|
|
554
|
+
"Run a command in the user's shell. Returns stdout, stderr, and exit code. " +
|
|
555
|
+
"Use for exploring the codebase (rg, find, ls, cat), running tests, builds, git, etc.",
|
|
556
|
+
parameters: Type.Object({
|
|
557
|
+
command: Type.String({ description: "The shell command to execute" }),
|
|
558
|
+
}),
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// readImage
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
/** Supported image extensions and their MIME types. */
|
|
566
|
+
const IMAGE_MIME_TYPES: Record<string, string> = {
|
|
567
|
+
".png": "image/png",
|
|
568
|
+
".jpg": "image/jpeg",
|
|
569
|
+
".jpeg": "image/jpeg",
|
|
570
|
+
".gif": "image/gif",
|
|
571
|
+
".webp": "image/webp",
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/** Arguments for the `readImage` tool. */
|
|
575
|
+
interface ReadImageArgs {
|
|
576
|
+
/** File path (absolute or relative to cwd). */
|
|
577
|
+
path: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Read an image file and return it as base64-encoded content.
|
|
582
|
+
*
|
|
583
|
+
* Supports PNG, JPEG, GIF, and WebP. Returns {@link ImageContent} on
|
|
584
|
+
* success or a text error message on failure. The MIME type is detected
|
|
585
|
+
* from the file extension.
|
|
586
|
+
*
|
|
587
|
+
* @param args - ReadImage arguments (path).
|
|
588
|
+
* @param cwd - Working directory for resolving relative paths.
|
|
589
|
+
* @returns A {@link ToolExecResult} with image content or error message.
|
|
590
|
+
*/
|
|
591
|
+
export function executeReadImage(
|
|
592
|
+
args: ReadImageArgs,
|
|
593
|
+
cwd: string,
|
|
594
|
+
): ToolExecResult {
|
|
595
|
+
const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
|
|
596
|
+
const ext = extname(filePath).toLowerCase();
|
|
597
|
+
|
|
598
|
+
const mimeType = IMAGE_MIME_TYPES[ext];
|
|
599
|
+
if (!mimeType) {
|
|
600
|
+
return textResult(
|
|
601
|
+
`Unsupported image format: ${ext || "(no extension)"}`,
|
|
602
|
+
true,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!existsSync(filePath)) {
|
|
607
|
+
return textResult(`File not found: ${args.path}`, true);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const data = readFileSync(filePath);
|
|
612
|
+
const base64 = Buffer.from(data).toString("base64");
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
content: [{ type: "image", data: base64, mimeType }],
|
|
616
|
+
isError: false,
|
|
617
|
+
};
|
|
618
|
+
} catch (error) {
|
|
619
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
620
|
+
return textResult(`Failed to read image ${args.path}: ${message}`, true);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** pi-ai tool definition for `readImage`. */
|
|
625
|
+
export const readImageTool: Tool = {
|
|
626
|
+
name: "readImage",
|
|
627
|
+
description:
|
|
628
|
+
"Read an image file and return its contents. " +
|
|
629
|
+
"Supports PNG, JPEG, GIF, and WebP formats. " +
|
|
630
|
+
"Use this to inspect screenshots, diagrams, or any image in the repo.",
|
|
631
|
+
parameters: Type.Object({
|
|
632
|
+
path: Type.String({
|
|
633
|
+
description: "File path (absolute or relative to cwd)",
|
|
634
|
+
}),
|
|
635
|
+
}),
|
|
636
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isEmptyUserContent, stripSkillFrontmatter } from "./agent.ts";
|
|
3
|
+
|
|
4
|
+
describe("ui/agent", () => {
|
|
5
|
+
test("stripSkillFrontmatter removes frontmatter and keeps the skill body", () => {
|
|
6
|
+
const content = [
|
|
7
|
+
"---",
|
|
8
|
+
"name: code-review",
|
|
9
|
+
'description: "Review code for issues"',
|
|
10
|
+
"---",
|
|
11
|
+
"# Review Checklist",
|
|
12
|
+
"- Find bugs",
|
|
13
|
+
"",
|
|
14
|
+
].join("\n");
|
|
15
|
+
|
|
16
|
+
expect(stripSkillFrontmatter(content)).toBe(
|
|
17
|
+
"# Review Checklist\n- Find bugs\n",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("stripSkillFrontmatter leaves content without frontmatter unchanged", () => {
|
|
22
|
+
expect(stripSkillFrontmatter("# Skill\nUse this carefully\n")).toBe(
|
|
23
|
+
"# Skill\nUse this carefully\n",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("isEmptyUserContent returns true for empty text-only content", () => {
|
|
28
|
+
expect(isEmptyUserContent(" \n\t")).toBe(true);
|
|
29
|
+
expect(
|
|
30
|
+
isEmptyUserContent([
|
|
31
|
+
{ type: "text", text: " " },
|
|
32
|
+
{ type: "text", text: "\n" },
|
|
33
|
+
]),
|
|
34
|
+
).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("isEmptyUserContent returns false when multipart content includes an image", () => {
|
|
38
|
+
expect(
|
|
39
|
+
isEmptyUserContent([
|
|
40
|
+
{ type: "text", text: " " },
|
|
41
|
+
{
|
|
42
|
+
type: "image",
|
|
43
|
+
data: Buffer.from("fake-png-data").toString("base64"),
|
|
44
|
+
mimeType: "image/png",
|
|
45
|
+
},
|
|
46
|
+
]),
|
|
47
|
+
).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|