@trim21/personal-pi-extensions 0.0.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 +185 -0
- package/package.json +59 -0
- package/src/agents-md-user-message.ts +71 -0
- package/src/bash-default-timeout.ts +9 -0
- package/src/bwrap/index.ts +733 -0
- package/src/bwrap/seccomp-aarch64.bpf +0 -0
- package/src/bwrap/seccomp-x86_64.bpf +0 -0
- package/src/gh-readonly.ts +1264 -0
- package/src/opencode-edit.ts +556 -0
- package/src/opencode-read.ts +485 -0
- package/src/opencode-write.ts +63 -0
- package/src/todo-pendant.ts +82 -0
- package/src/workspace-guard.ts +112 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Read Tool Extension
|
|
3
|
+
*
|
|
4
|
+
* Overrides the built-in `read` tool with additional features inspired by
|
|
5
|
+
* opencode's read implementation:
|
|
6
|
+
*
|
|
7
|
+
* - Directory listing: When the path is a directory, lists its entries
|
|
8
|
+
* with "/" suffix for directories.
|
|
9
|
+
* - "Did you mean?" suggestions: When a file is not found, searches the
|
|
10
|
+
* parent directory for similarly-named files.
|
|
11
|
+
* - Binary file detection: Rejects binary files by extension and content
|
|
12
|
+
* sampling before handing them to the LLM.
|
|
13
|
+
* - Structured output: Uses <path>, <type>, <content>/<entries> XML tags
|
|
14
|
+
* to help the LLM parse output.
|
|
15
|
+
* - Image support: Detects and serves images as base64 attachments.
|
|
16
|
+
*
|
|
17
|
+
* Install:
|
|
18
|
+
* cp enhanced-read.ts ~/.pi/agent/extensions/
|
|
19
|
+
*
|
|
20
|
+
* Or for project-local:
|
|
21
|
+
* cp enhanced-read.ts .pi/extensions/
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { constants } from "node:fs";
|
|
25
|
+
import { access, open, readdir, readFile, stat } from "node:fs/promises";
|
|
26
|
+
import { basename, dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
|
|
27
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
28
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { Type } from "typebox";
|
|
30
|
+
|
|
31
|
+
const DEFAULT_MAX_LINES = 2000;
|
|
32
|
+
const DEFAULT_MAX_BYTES = 50 * 1024;
|
|
33
|
+
const SAMPLE_BYTES = 4096;
|
|
34
|
+
|
|
35
|
+
const SUPPORTED_IMAGE_MIMES = new Set([
|
|
36
|
+
"image/jpeg",
|
|
37
|
+
"image/png",
|
|
38
|
+
"image/gif",
|
|
39
|
+
"image/webp",
|
|
40
|
+
"image/bmp",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const BINARY_EXTENSIONS = new Set([
|
|
44
|
+
".zip",
|
|
45
|
+
".tar",
|
|
46
|
+
".gz",
|
|
47
|
+
".exe",
|
|
48
|
+
".dll",
|
|
49
|
+
".so",
|
|
50
|
+
".class",
|
|
51
|
+
".jar",
|
|
52
|
+
".war",
|
|
53
|
+
".7z",
|
|
54
|
+
".doc",
|
|
55
|
+
".docx",
|
|
56
|
+
".xls",
|
|
57
|
+
".xlsx",
|
|
58
|
+
".ppt",
|
|
59
|
+
".pptx",
|
|
60
|
+
".odt",
|
|
61
|
+
".ods",
|
|
62
|
+
".odp",
|
|
63
|
+
".bin",
|
|
64
|
+
".dat",
|
|
65
|
+
".obj",
|
|
66
|
+
".o",
|
|
67
|
+
".a",
|
|
68
|
+
".lib",
|
|
69
|
+
".wasm",
|
|
70
|
+
".pyc",
|
|
71
|
+
".pyo",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const IMAGE_SIGNATURES: Array<{
|
|
75
|
+
signature: Uint8Array | ((buf: Uint8Array) => boolean);
|
|
76
|
+
mimeType: string;
|
|
77
|
+
}> = [
|
|
78
|
+
{ signature: new Uint8Array([0xff, 0xd8, 0xff]), mimeType: "image/jpeg" },
|
|
79
|
+
{
|
|
80
|
+
signature: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
81
|
+
mimeType: "image/png",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
signature(buf) {
|
|
85
|
+
return startsWithAscii(buf, 0, "GIF");
|
|
86
|
+
},
|
|
87
|
+
mimeType: "image/gif",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
signature(buf) {
|
|
91
|
+
return startsWithAscii(buf, 0, "RIFF") && startsWithAscii(buf, 8, "WEBP");
|
|
92
|
+
},
|
|
93
|
+
mimeType: "image/webp",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
signature(buf) {
|
|
97
|
+
return (
|
|
98
|
+
startsWithAscii(buf, 0, "BM") &&
|
|
99
|
+
buf.length >= 30 &&
|
|
100
|
+
(buf[28] ?? 0) === 1 &&
|
|
101
|
+
[1, 4, 8, 16, 24, 32].includes(buf[28 + 1] ?? 0)
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
mimeType: "image/bmp",
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
function startsWithAscii(buf: Uint8Array, offset: number, text: string): boolean {
|
|
109
|
+
if (buf.length < offset + text.length) return false;
|
|
110
|
+
for (let i = 0; i < text.length; i++) {
|
|
111
|
+
if (buf[offset + i] !== text.charCodeAt(i)) return false;
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function detectImageMimeType(buffer: Uint8Array): string | null {
|
|
117
|
+
for (const { signature, mimeType } of IMAGE_SIGNATURES) {
|
|
118
|
+
if (typeof signature === "function" ? signature(buffer) : startsWith(buffer, signature)) {
|
|
119
|
+
return mimeType;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function startsWith(buffer: Uint8Array, bytes: Uint8Array): boolean {
|
|
126
|
+
if (buffer.length < bytes.length) return false;
|
|
127
|
+
return bytes.every((b, i) => buffer[i] === b);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function detectImageMimeTypeFromFile(filePath: string): Promise<string | null> {
|
|
131
|
+
try {
|
|
132
|
+
const fileHandle = await open(filePath, "r");
|
|
133
|
+
try {
|
|
134
|
+
const buf = Buffer.alloc(SAMPLE_BYTES);
|
|
135
|
+
const { bytesRead } = await fileHandle.read(buf, 0, SAMPLE_BYTES, 0);
|
|
136
|
+
return detectImageMimeType(buf.subarray(0, bytesRead));
|
|
137
|
+
} finally {
|
|
138
|
+
await fileHandle.close();
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isBinaryExtension(filePath: string): boolean {
|
|
146
|
+
const dotIndex = filePath.lastIndexOf(".");
|
|
147
|
+
if (dotIndex < 0) return false;
|
|
148
|
+
return BINARY_EXTENSIONS.has(filePath.slice(dotIndex).toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isBinaryFileBySample(sample: Uint8Array): boolean {
|
|
152
|
+
if (sample.length === 0) return false;
|
|
153
|
+
let nonPrintableCount = 0;
|
|
154
|
+
for (const byte of sample) {
|
|
155
|
+
if (byte === 0) return true;
|
|
156
|
+
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintableCount++;
|
|
157
|
+
}
|
|
158
|
+
return nonPrintableCount / sample.length > 0.3;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatSize(bytes: number): string {
|
|
162
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
163
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
164
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface TruncationResult {
|
|
168
|
+
content: string;
|
|
169
|
+
truncated: boolean;
|
|
170
|
+
truncatedBy: "lines" | "bytes" | null;
|
|
171
|
+
totalLines: number;
|
|
172
|
+
totalBytes: number;
|
|
173
|
+
outputLines: number;
|
|
174
|
+
outputBytes: number;
|
|
175
|
+
lastLinePartial: boolean;
|
|
176
|
+
firstLineExceedsLimit: boolean;
|
|
177
|
+
maxLines: number;
|
|
178
|
+
maxBytes: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function truncateHead(
|
|
182
|
+
content: string,
|
|
183
|
+
maxLines: number = DEFAULT_MAX_LINES,
|
|
184
|
+
maxBytes: number = DEFAULT_MAX_BYTES,
|
|
185
|
+
): TruncationResult {
|
|
186
|
+
const lines = content ? content.split("\n") : [];
|
|
187
|
+
if (content.endsWith("\n")) lines.pop();
|
|
188
|
+
const totalLines = lines.length;
|
|
189
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
190
|
+
|
|
191
|
+
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
|
192
|
+
return {
|
|
193
|
+
content,
|
|
194
|
+
truncated: false,
|
|
195
|
+
truncatedBy: null,
|
|
196
|
+
totalLines,
|
|
197
|
+
totalBytes,
|
|
198
|
+
outputLines: totalLines,
|
|
199
|
+
outputBytes: totalBytes,
|
|
200
|
+
lastLinePartial: false,
|
|
201
|
+
firstLineExceedsLimit: false,
|
|
202
|
+
maxLines,
|
|
203
|
+
maxBytes,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const firstLineBytes = lines.length > 0 ? Buffer.byteLength(lines[0], "utf-8") : 0;
|
|
208
|
+
if (firstLineBytes > maxBytes) {
|
|
209
|
+
return {
|
|
210
|
+
content: "",
|
|
211
|
+
truncated: true,
|
|
212
|
+
truncatedBy: "bytes",
|
|
213
|
+
totalLines,
|
|
214
|
+
totalBytes,
|
|
215
|
+
outputLines: 0,
|
|
216
|
+
outputBytes: 0,
|
|
217
|
+
lastLinePartial: false,
|
|
218
|
+
firstLineExceedsLimit: true,
|
|
219
|
+
maxLines,
|
|
220
|
+
maxBytes,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const outputLinesArr: string[] = [];
|
|
225
|
+
let outputBytesCount = 0;
|
|
226
|
+
let truncatedBy: "lines" | "bytes" = "lines";
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
|
229
|
+
const lineBytes = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0);
|
|
230
|
+
if (outputBytesCount + lineBytes > maxBytes) {
|
|
231
|
+
truncatedBy = "bytes";
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
outputLinesArr.push(lines[i]);
|
|
235
|
+
outputBytesCount += lineBytes;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
|
239
|
+
truncatedBy = "lines";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const outputContent = outputLinesArr.join("\n");
|
|
243
|
+
return {
|
|
244
|
+
content: outputContent,
|
|
245
|
+
truncated: true,
|
|
246
|
+
truncatedBy,
|
|
247
|
+
totalLines,
|
|
248
|
+
totalBytes,
|
|
249
|
+
outputLines: outputLinesArr.length,
|
|
250
|
+
outputBytes: Buffer.byteLength(outputContent, "utf-8"),
|
|
251
|
+
lastLinePartial: false,
|
|
252
|
+
firstLineExceedsLimit: false,
|
|
253
|
+
maxLines,
|
|
254
|
+
maxBytes,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function didYouMean(filePath: string): Promise<string> {
|
|
259
|
+
const dir = dirname(filePath);
|
|
260
|
+
const base = basename(filePath);
|
|
261
|
+
|
|
262
|
+
let items: string[];
|
|
263
|
+
try {
|
|
264
|
+
items = await readdir(dir);
|
|
265
|
+
} catch {
|
|
266
|
+
return "";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const candidates = items
|
|
270
|
+
.filter(
|
|
271
|
+
(item) =>
|
|
272
|
+
item.toLowerCase().includes(base.toLowerCase()) ||
|
|
273
|
+
base.toLowerCase().includes(item.toLowerCase()),
|
|
274
|
+
)
|
|
275
|
+
.slice(0, 3)
|
|
276
|
+
.map((item) => `${dir}${sep}${item}`);
|
|
277
|
+
|
|
278
|
+
if (candidates.length > 0) {
|
|
279
|
+
return `\n\nDid you mean one of these?\n${candidates.join("\n")}`;
|
|
280
|
+
}
|
|
281
|
+
return "";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
|
|
285
|
+
const items = await readdir(dirPath);
|
|
286
|
+
const results: string[] = [];
|
|
287
|
+
|
|
288
|
+
for (const item of items) {
|
|
289
|
+
let isDir = false;
|
|
290
|
+
try {
|
|
291
|
+
const s = await stat(`${dirPath}${sep}${item}`);
|
|
292
|
+
isDir = s.isDirectory();
|
|
293
|
+
} catch {
|
|
294
|
+
// Use name as-is if stat fails
|
|
295
|
+
}
|
|
296
|
+
results.push(item + (isDir ? "/" : ""));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
results.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
300
|
+
return results;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default function (pi: ExtensionAPI) {
|
|
304
|
+
pi.registerTool({
|
|
305
|
+
name: "read",
|
|
306
|
+
label: "read",
|
|
307
|
+
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
|
308
|
+
promptSnippet: "Read file contents",
|
|
309
|
+
promptGuidelines: ["Use read to examine files instead of cat or sed."],
|
|
310
|
+
parameters: Type.Object({
|
|
311
|
+
filePath: Type.String({ description: "The absolute path to the file or directory to read" }),
|
|
312
|
+
offset: Type.Optional(
|
|
313
|
+
Type.Number({ description: "The line number to start reading from (1-indexed)" }),
|
|
314
|
+
),
|
|
315
|
+
limit: Type.Optional(
|
|
316
|
+
Type.Number({ description: "The maximum number of lines to read (defaults to 2000)" }),
|
|
317
|
+
),
|
|
318
|
+
}),
|
|
319
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
320
|
+
const {
|
|
321
|
+
filePath: rawPath,
|
|
322
|
+
offset,
|
|
323
|
+
limit,
|
|
324
|
+
} = params as { filePath: string; offset?: number; limit?: number };
|
|
325
|
+
|
|
326
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolvePath(ctx.cwd, rawPath);
|
|
327
|
+
|
|
328
|
+
if (signal?.aborted) {
|
|
329
|
+
throw new Error("Operation aborted");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check if path exists
|
|
333
|
+
let fileStat: Awaited<ReturnType<typeof stat>>;
|
|
334
|
+
try {
|
|
335
|
+
fileStat = await stat(absolutePath);
|
|
336
|
+
} catch {
|
|
337
|
+
const suggestion = await didYouMean(absolutePath);
|
|
338
|
+
return {
|
|
339
|
+
content: [
|
|
340
|
+
{ type: "text", text: `File not found: ${absolutePath}${suggestion}` },
|
|
341
|
+
] as TextContent[],
|
|
342
|
+
details: undefined,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Directory listing ---
|
|
347
|
+
if (fileStat.isDirectory()) {
|
|
348
|
+
const entries = await formatDirectoryEntries(absolutePath);
|
|
349
|
+
const limitVal = limit ?? DEFAULT_MAX_LINES;
|
|
350
|
+
const offsetVal = offset ?? 1;
|
|
351
|
+
const start = offsetVal <= 0 ? 0 : offsetVal - 1;
|
|
352
|
+
const sliced = entries.slice(start, start + limitVal);
|
|
353
|
+
const totalEntries = entries.length;
|
|
354
|
+
const truncated = start + sliced.length < totalEntries;
|
|
355
|
+
|
|
356
|
+
let output = `<path>${absolutePath}</path>\n`;
|
|
357
|
+
output += `<type>directory</type>\n`;
|
|
358
|
+
output += `<entries>\n`;
|
|
359
|
+
output += sliced.join("\n");
|
|
360
|
+
if (truncated) {
|
|
361
|
+
const next = offsetVal + sliced.length;
|
|
362
|
+
output += `\n(Showing ${sliced.length} of ${totalEntries} entries. Use offset=${next} to continue.)`;
|
|
363
|
+
} else {
|
|
364
|
+
output += `\n(${totalEntries} entries)`;
|
|
365
|
+
}
|
|
366
|
+
output += `\n</entries>`;
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
content: [{ type: "text", text: output }] as TextContent[],
|
|
370
|
+
details: undefined,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- File read ---
|
|
375
|
+
let content: (TextContent | ImageContent)[];
|
|
376
|
+
let details: { truncation?: TruncationResult } | undefined;
|
|
377
|
+
|
|
378
|
+
// Check accessibility
|
|
379
|
+
try {
|
|
380
|
+
await access(absolutePath, constants.R_OK);
|
|
381
|
+
} catch {
|
|
382
|
+
return {
|
|
383
|
+
content: [{ type: "text", text: `File not readable: ${absolutePath}` }] as TextContent[],
|
|
384
|
+
details: undefined,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Check for images
|
|
389
|
+
const mimeType = await detectImageMimeTypeFromFile(absolutePath);
|
|
390
|
+
if (mimeType && SUPPORTED_IMAGE_MIMES.has(mimeType)) {
|
|
391
|
+
const buffer = await readFile(absolutePath);
|
|
392
|
+
const base64 = buffer.toString("base64");
|
|
393
|
+
content = [
|
|
394
|
+
{ type: "text", text: `[Image: ${mimeType}, ${formatSize(buffer.length)}]` },
|
|
395
|
+
{ type: "image", data: base64, mimeType } as ImageContent,
|
|
396
|
+
];
|
|
397
|
+
return { content, details: undefined };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Read text content
|
|
401
|
+
const buffer = await readFile(absolutePath);
|
|
402
|
+
const sample = buffer.subarray(0, SAMPLE_BYTES);
|
|
403
|
+
|
|
404
|
+
// Binary file detection
|
|
405
|
+
if (isBinaryExtension(absolutePath) || isBinaryFileBySample(sample)) {
|
|
406
|
+
return {
|
|
407
|
+
content: [
|
|
408
|
+
{ type: "text", text: `Cannot read binary file: ${absolutePath}` },
|
|
409
|
+
] as TextContent[],
|
|
410
|
+
details: undefined,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const textContent = buffer.toString("utf-8");
|
|
415
|
+
const allLines = textContent.split("\n");
|
|
416
|
+
const totalFileLines = allLines.length;
|
|
417
|
+
|
|
418
|
+
// Apply offset
|
|
419
|
+
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
|
420
|
+
const startLineDisplay = startLine + 1;
|
|
421
|
+
|
|
422
|
+
if (startLine >= allLines.length) {
|
|
423
|
+
return {
|
|
424
|
+
content: [
|
|
425
|
+
{
|
|
426
|
+
type: "text",
|
|
427
|
+
text: `Offset ${offset} is beyond end of file (${allLines.length} lines total)`,
|
|
428
|
+
},
|
|
429
|
+
] as TextContent[],
|
|
430
|
+
details: undefined,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Apply user-specified limit or default truncation
|
|
435
|
+
let selectedContent: string;
|
|
436
|
+
let userLimitedLines: number | undefined;
|
|
437
|
+
|
|
438
|
+
if (limit !== undefined) {
|
|
439
|
+
const endLine = Math.min(startLine + limit, allLines.length);
|
|
440
|
+
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
441
|
+
userLimitedLines = endLine - startLine;
|
|
442
|
+
} else {
|
|
443
|
+
selectedContent = allLines.slice(startLine).join("\n");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Apply byte/line truncation
|
|
447
|
+
const truncation = truncateHead(selectedContent);
|
|
448
|
+
let outputText: string;
|
|
449
|
+
|
|
450
|
+
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
451
|
+
|
|
452
|
+
if (truncation.firstLineExceedsLimit) {
|
|
453
|
+
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
|
454
|
+
outputText = `<path>${absolutePath}</path>\n<type>file</type>\n`;
|
|
455
|
+
outputText += `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash to read this line.]`;
|
|
456
|
+
details = { truncation };
|
|
457
|
+
} else {
|
|
458
|
+
const header = `<path>${absolutePath}</path>\n<type>file</type>\n<content>\n`;
|
|
459
|
+
const footer = "\n</content>";
|
|
460
|
+
if (truncation.truncated) {
|
|
461
|
+
const nextOffset = endLineDisplay + 1;
|
|
462
|
+
if (truncation.truncatedBy === "lines") {
|
|
463
|
+
outputText = `${header}${truncation.content}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.)${footer}`;
|
|
464
|
+
} else {
|
|
465
|
+
outputText = `${header}${truncation.content}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.)${footer}`;
|
|
466
|
+
}
|
|
467
|
+
details = { truncation };
|
|
468
|
+
} else if (
|
|
469
|
+
userLimitedLines !== undefined &&
|
|
470
|
+
startLine + userLimitedLines < allLines.length
|
|
471
|
+
) {
|
|
472
|
+
const remaining = allLines.length - (startLine + userLimitedLines);
|
|
473
|
+
const nextOffset = startLine + userLimitedLines + 1;
|
|
474
|
+
outputText = `${header}${truncation.content}\n\n(${remaining} more lines in file. Use offset=${nextOffset} to continue.)${footer}`;
|
|
475
|
+
} else {
|
|
476
|
+
outputText = `${header}${truncation.content}\n\n(End of file - total ${totalFileLines} lines)${footer}`;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
content = [{ type: "text", text: outputText }] as TextContent[];
|
|
481
|
+
|
|
482
|
+
return { content, details };
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Write Tool Extension
|
|
3
|
+
*
|
|
4
|
+
* Overrides the built-in `write` tool with opencode-compatible parameter names.
|
|
5
|
+
*
|
|
6
|
+
* - Uses `filePath` (opencode) instead of `path` (pi built-in)
|
|
7
|
+
* - Creates parent directories automatically
|
|
8
|
+
* - Serialises writes to the same file via mutation queue
|
|
9
|
+
*
|
|
10
|
+
* Install:
|
|
11
|
+
* cp enhanced-write.ts ~/.pi/agent/extensions/
|
|
12
|
+
*
|
|
13
|
+
* Or for project-local:
|
|
14
|
+
* cp enhanced-write.ts .pi/extensions/
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
18
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { Type } from "typebox";
|
|
22
|
+
|
|
23
|
+
export default function (pi: ExtensionAPI) {
|
|
24
|
+
pi.registerTool({
|
|
25
|
+
name: "write",
|
|
26
|
+
label: "write",
|
|
27
|
+
description:
|
|
28
|
+
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
|
29
|
+
promptSnippet: "Create or overwrite files",
|
|
30
|
+
promptGuidelines: ["Use write only for new files or complete rewrites."],
|
|
31
|
+
parameters: Type.Object({
|
|
32
|
+
filePath: Type.String({
|
|
33
|
+
description: "The absolute path to the file to write (must be absolute, not relative)",
|
|
34
|
+
}),
|
|
35
|
+
content: Type.String({ description: "The content to write to the file" }),
|
|
36
|
+
}),
|
|
37
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
38
|
+
const { filePath: rawPath, content } = params as { filePath: string; content: string };
|
|
39
|
+
const absolutePath = resolvePath(ctx.cwd, rawPath);
|
|
40
|
+
const dir = dirname(absolutePath);
|
|
41
|
+
|
|
42
|
+
return withFileMutationQueue(absolutePath, async () => {
|
|
43
|
+
const throwIfAborted = () => {
|
|
44
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
throwIfAborted();
|
|
48
|
+
await mkdir(dir, { recursive: true });
|
|
49
|
+
throwIfAborted();
|
|
50
|
+
await writeFile(absolutePath, content, "utf-8");
|
|
51
|
+
throwIfAborted();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: `Wrote file successfully: ${absolutePath}` }] as Array<{
|
|
55
|
+
type: "text";
|
|
56
|
+
text: string;
|
|
57
|
+
}>,
|
|
58
|
+
details: undefined,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Pendant Extension
|
|
3
|
+
*
|
|
4
|
+
* Intercepts `todo` tool results and renders the task list as a widget
|
|
5
|
+
* above the editor in Pendant's UI. Compatible with pi's built-in todo
|
|
6
|
+
* tool's four-status task model (pending / in_progress / completed / deleted).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* pi -e src/todo-pendant.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types matching pi's built-in todo tool TaskDetails
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
type TaskStatus = "pending" | "in_progress" | "completed" | "deleted";
|
|
19
|
+
|
|
20
|
+
interface Task {
|
|
21
|
+
id: number;
|
|
22
|
+
subject: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
activeForm?: string;
|
|
25
|
+
status: TaskStatus;
|
|
26
|
+
blockedBy?: number[];
|
|
27
|
+
owner?: string;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TaskDetails {
|
|
32
|
+
action: string;
|
|
33
|
+
params: Record<string, unknown>;
|
|
34
|
+
tasks: Task[];
|
|
35
|
+
nextId: number;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const STATUS_MARK = {
|
|
44
|
+
pending: " ",
|
|
45
|
+
in_progress: " ",
|
|
46
|
+
deleted: " ",
|
|
47
|
+
completed: "x",
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
function formatTaskLine(t: Task): string {
|
|
51
|
+
const mark = STATUS_MARK[t.status];
|
|
52
|
+
const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
|
|
53
|
+
let line = `- [${mark}] #${t.id} ${t.subject}${form}`;
|
|
54
|
+
if (t.blockedBy?.length) {
|
|
55
|
+
line += ` ⛓ ${t.blockedBy.map((id) => `#${id}`).join(", ")}`;
|
|
56
|
+
}
|
|
57
|
+
return line;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Extension
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export default function (pi: ExtensionAPI) {
|
|
65
|
+
pi.on("tool_result", (event, ctx) => {
|
|
66
|
+
if (event.toolName !== "todo") return;
|
|
67
|
+
|
|
68
|
+
const details = event.details as TaskDetails | undefined;
|
|
69
|
+
if (!details?.tasks?.length) {
|
|
70
|
+
ctx.ui.setWidget("todo-pendant", undefined);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const visible = details.tasks.filter((t) => t.status !== "deleted");
|
|
75
|
+
if (visible.length === 0) {
|
|
76
|
+
ctx.ui.setWidget("todo-pendant", undefined);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
ctx.ui.setWidget("todo-pendant", [...visible.map(formatTaskLine)]);
|
|
81
|
+
});
|
|
82
|
+
}
|