@trim21/personal-pi-extensions 0.1.559 → 0.1.560

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.559",
3
+ "version": "0.1.560",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -192,11 +192,17 @@ export interface LinePage {
192
192
  * - per-line truncation to MAX_LINE_LENGTH
193
193
  * - line cap via maxLines (more): keep scanning so count is the file total
194
194
  * - byte cap via maxBytes (cut): stop immediately
195
+ *
196
+ * offset 为负数时从文件末尾倒数(`offset=-2` 等价 `tail -n 2`):流式单遍
197
+ * 扫描全程只保留末尾窗口,返回前把 offset 换算为正的绝对起始行号
198
+ * (`totalLines + offset + 1`,超出文件长度时钳到 1),行号/续读提示与
199
+ * 正向语义一致。
195
200
  */
196
201
  export async function readLines(
197
202
  filePath: string,
198
203
  opts: { offset: number; limit: number; maxBytes?: number },
199
204
  ): Promise<LinePage> {
205
+ if (opts.offset < 0) return readTailLines(filePath, opts);
200
206
  const start = opts.offset - 1;
201
207
  const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
202
208
  const raw: string[] = [];
@@ -236,6 +242,59 @@ export async function readLines(
236
242
  return { raw, count, cut, more, offset: opts.offset };
237
243
  }
238
244
 
245
+ /**
246
+ * tail 语义的负 offset 实现:单遍流式扫描,环形缓冲只保留末尾 K 行
247
+ * (K = -offset),扫完后对窗口应用 limit 与字节上限,避免为取末尾几行
248
+ * 而把整个文件驻留内存。
249
+ */
250
+ async function readTailLines(
251
+ filePath: string,
252
+ opts: { offset: number; limit: number; maxBytes?: number },
253
+ ): Promise<LinePage> {
254
+ const windowSize = -opts.offset;
255
+ const window: string[] = [];
256
+ let head = 0;
257
+ let count = 0;
258
+
259
+ const stream = createReadStream(filePath, { encoding: "utf8" });
260
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
261
+ try {
262
+ for await (const text of rl) {
263
+ count += 1;
264
+ const line =
265
+ text.length > MAX_LINE_LENGTH ? text.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text;
266
+ window.push(line);
267
+ if (window.length > windowSize) head += 1;
268
+ }
269
+ } finally {
270
+ rl.close();
271
+ stream.destroy();
272
+ }
273
+
274
+ const offset = Math.max(count + opts.offset + 1, 1);
275
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
276
+ const raw: string[] = [];
277
+ let bytes = 0;
278
+ let cut = false;
279
+ let more = false;
280
+ for (const line of window.slice(head)) {
281
+ if (raw.length >= opts.limit) {
282
+ more = true;
283
+ break;
284
+ }
285
+ const size = Buffer.byteLength(line, "utf8") + (raw.length > 0 ? 1 : 0);
286
+ if (bytes + size > maxBytes) {
287
+ cut = true;
288
+ more = true;
289
+ break;
290
+ }
291
+ raw.push(line);
292
+ bytes += size;
293
+ }
294
+
295
+ return { raw, count, cut, more, offset };
296
+ }
297
+
239
298
  function truncationFromPage(page: LinePage): TruncationResult {
240
299
  const content = page.raw.join("\n");
241
300
  return {
@@ -299,15 +358,17 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
299
358
  pi.registerTool({
300
359
  name: "read",
301
360
  label: "read",
302
- 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.`,
361
+ 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. A negative offset counts from the end of the file (offset=-2 reads the last 2 lines). When you need the full file, continue with offset until complete.`,
303
362
  promptSnippet: "Read file contents",
304
- promptGuidelines: ["Use read to examine files instead of cat or sed."],
363
+ promptGuidelines: [
364
+ "Use read to examine files instead of cat, sed, or tail. A negative offset reads from the end of the file (offset=-2 reads the last 2 lines).",
365
+ ],
305
366
  parameters: Type.Object({
306
367
  filePath: Type.String({ description: "The absolute path to the file or directory to read" }),
307
368
  offset: Type.Optional(
308
369
  Type.Integer({
309
- minimum: 0,
310
- description: "The line number to start reading from (1-indexed)",
370
+ description:
371
+ "The line number to start reading from (1-indexed; 0 is treated as 1). A negative value counts from the end of the file (offset=-2 reads the last 2 lines).",
311
372
  }),
312
373
  ),
313
374
  limit: Type.Optional(
@@ -340,9 +401,9 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
340
401
  if (fileStat.isDirectory()) {
341
402
  const entries = await formatDirectoryEntries(absolutePath);
342
403
  const limitVal = limit ?? DEFAULT_MAX_LINES;
343
- // opencode: params.offset || 1(0 视为 1)
404
+ // opencode: params.offset || 1(0 视为 1);负数从末尾倒数
344
405
  const offsetVal = offset || 1;
345
- const start = offsetVal - 1;
406
+ const start = offsetVal < 0 ? Math.max(entries.length + offsetVal, 0) : offsetVal - 1;
346
407
  const sliced = entries.slice(start, start + limitVal);
347
408
  const totalEntries = entries.length;
348
409
  const truncated = start + sliced.length < totalEntries;