@trim21/personal-pi-extensions 0.0.389 → 0.0.392

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.0.389",
3
+ "version": "0.0.392",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -82,7 +82,7 @@
82
82
  "prettier --write"
83
83
  ]
84
84
  },
85
- "packageManager": "pnpm@11.22.0",
85
+ "packageManager": "pnpm@11.23.0",
86
86
  "engines": {
87
87
  "node": ">=24"
88
88
  },
@@ -18,7 +18,7 @@ import { basename, isAbsolute, relative, sep } from "node:path";
18
18
 
19
19
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
20
20
 
21
- import { normalizeForEdit, replace } from "../opencode/edit-engine.js";
21
+ import { applyEdit, normalizeToLF } from "../opencode/edit-engine.js";
22
22
 
23
23
  const ALWAYS_ALLOW = ["/tmp"];
24
24
  const MAX_PREVIEW_LINES = 100;
@@ -82,11 +82,15 @@ export async function buildDiffPreview(
82
82
  // line-numbered patch when it matches. Fall back to a parameter diff when the
83
83
  // edit cannot be applied (oldText not found, ambiguous, or no file).
84
84
  try {
85
- const normalized = normalizeForEdit(oldContent);
86
- const newContent = replace(normalized, change.oldText, change.newText, change.replaceAll);
87
- // The full path is shown in the dialog title, so the patch header only
88
- // carries the file name.
89
- return wrapDiff(generateUnifiedPatch(basename(resolvedPath), normalized, newContent, 2));
85
+ const applied = applyEdit(oldContent, change.oldText, change.newText, change.replaceAll);
86
+ return wrapDiff(
87
+ generateUnifiedPatch(
88
+ basename(resolvedPath),
89
+ normalizeToLF(applied.contentOld),
90
+ normalizeToLF(applied.contentNew),
91
+ 2,
92
+ ),
93
+ );
90
94
  } catch {
91
95
  const removed = change.oldText.split("\n").map((line) => `-${line}`);
92
96
  const added = change.newText.split("\n").map((line) => `+${line}`);
@@ -1,14 +1,11 @@
1
1
  /**
2
- * Opencode edit matching engine.
2
+ * Opencode v1 edit matching engine.
3
3
  *
4
- * The core replacers and replace() function are copied directly from opencode
5
- * commit 999be62662 (v1.2.25-1672-g999be62662, 2026-08-12):
6
- * https://github.com/anomalyco/opencode/blob/999be62662/packages/opencode/src/tool/edit.ts
7
- * and wrapped in a pi extension so the behaviour is identical to opencode.
4
+ * Replacers and replace() follow packages/opencode/src/tool/edit.ts.
5
+ * applyEdit() matches the official preprocessing: convert oldString/newString
6
+ * to the file's line endings, then replace on the original file content.
8
7
  *
9
- * Shared by:
10
- * - opencode-edit.ts — the edit tool implementation
11
- * - lib/write-guard.ts — the diff preview shown in the approval dialog
8
+ * Shared by the edit tool and lib/write-guard.ts (approval-dialog preview).
12
9
  */
13
10
 
14
11
  // ── BOM & line ending helpers ─────────────────────────────────────────────────
@@ -27,16 +24,52 @@ export function normalizeToLF(text: string): string {
27
24
  return text.replaceAll("\r\n", "\n");
28
25
  }
29
26
 
30
- export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
31
- return ending === "\r\n" ? text.replaceAll("\n", "\r\n") : text;
27
+ /** Convert LF text to the file's line ending. Input must already be LF. */
28
+ export function convertToLineEnding(text: string, ending: "\r\n" | "\n"): string {
29
+ if (ending === "\n") return text;
30
+ return text.replaceAll("\n", "\r\n");
32
31
  }
33
32
 
34
- /** Strip BOM and normalize line endings to LF (what the replacers expect). */
33
+ export const restoreLineEndings = convertToLineEnding;
34
+
35
+ /** Strip BOM and normalize line endings to LF. */
35
36
  export function normalizeForEdit(content: string): string {
36
37
  const { text } = stripBom(content);
37
38
  return normalizeToLF(text);
38
39
  }
39
40
 
41
+ export interface AppliedEdit {
42
+ /** File text without BOM, original line endings. */
43
+ contentOld: string;
44
+ /** Replaced text without BOM, original line endings. */
45
+ contentNew: string;
46
+ /** Bytes to write: desiredBom + contentNew. */
47
+ finalContent: string;
48
+ }
49
+
50
+ /**
51
+ * Official v1 edit path: strip BOM, convert params to the file's line endings,
52
+ * replace on the original content, then desiredBom = source.bom || next.bom.
53
+ */
54
+ export function applyEdit(
55
+ rawContent: string,
56
+ oldString: string,
57
+ newString: string,
58
+ replaceAll = false,
59
+ ): AppliedEdit {
60
+ const { bom: sourceBom, text: contentOld } = stripBom(rawContent);
61
+ const ending = detectLineEnding(contentOld);
62
+ const old = convertToLineEnding(normalizeToLF(oldString), ending);
63
+ const replacement = convertToLineEnding(normalizeToLF(newString), ending);
64
+ const replaced = replace(contentOld, old, replacement, replaceAll);
65
+ const { bom: nextBom, text: contentNew } = stripBom(replaced);
66
+ return {
67
+ contentOld,
68
+ contentNew,
69
+ finalContent: (sourceBom || nextBom) + contentNew,
70
+ };
71
+ }
72
+
40
73
  // ── copied from opencode ──────────────────────────────────────────────────────
41
74
 
42
75
  function levenshtein(a: string, b: string): number {
@@ -417,9 +450,9 @@ function isDisproportionateMatch(search: string, oldString: string) {
417
450
  }
418
451
 
419
452
  /**
420
- * Replace `oldString` with `newString` in `content`, using opencode's matching
421
- * engine. Expects LF-normalized content (see `normalizeForEdit`).
422
- * Throws when oldString cannot be matched or the match is ambiguous.
453
+ * Replace `oldString` with `newString` in `content`.
454
+ * `oldString`/`newString` must already use the same line endings as `content`
455
+ * (see `applyEdit`). Throws when the match is missing or ambiguous.
423
456
  */
424
457
  export function replace(
425
458
  content: string,
@@ -459,7 +492,8 @@ export function replace(
459
492
  );
460
493
  }
461
494
  if (replaceAll) {
462
- return content.replaceAll(search, () => newString);
495
+ // eslint-disable-next-line unicorn/no-unsafe-string-replacement -- match opencode v1 $& / $$ interpolation
496
+ return content.replaceAll(search, newString);
463
497
  }
464
498
  const lastIndex = content.lastIndexOf(search);
465
499
  if (index !== lastIndex) continue;
@@ -5,21 +5,20 @@
5
5
  * service 实例(registerLsp 创建的闭包变量),与 claude-code/files.ts 共享
6
6
  * read-snapshot state 的方式一致;不再用模块级全局缓存。
7
7
  *
8
- * 各工具行为对齐 opencode commit 999be62662(v1.2.25-1672-g999be62662,
9
- * 2026-08-12):
10
- * - read:每行 `N: ` 行号前缀、单行 2000 字符截断、1 起始 offset、目录
11
- * 排序;读取后后台 LSP warm-up(fire-and-forget)。
12
- * - edit:opencode 匹配引擎(9 replacer、0.65 相似度阈值、0.25 行差);
13
- * 写后等待文档诊断,ERROR 级追加到输出。
8
+ * 对齐官方 v1(packages/opencode/src/tool/{read,edit,write}.ts):
9
+ * - read:流式分行(LF / CRLF / CR)、每行 `N: ` 行号前缀、单行 2000
10
+ * 字符截断、1 起始 offset、目录排序;读取后后台 LSP warm-up。
11
+ * 不接 PDF、不接 <system-reminder>;图片 magic 检测保留。
12
+ * - edit:匹配引擎 + old/new 转到文件换行后再替换;写后等待文档诊断。
14
13
  * - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。
15
14
  *
16
- * 匹配引擎(replacers + replace())在 edit-engine.ts,也被 lib/write-guard
17
- * 复用。spawn-agent 子代理按工具名加载本文件(`--tools` allowlist 过滤)。
15
+ * 匹配引擎在 edit-engine.ts,也被 lib/write-guard 复用。
18
16
  */
19
17
 
20
- import { constants } from "node:fs";
18
+ import { constants, createReadStream } from "node:fs";
21
19
  import { access, mkdir, open, readdir, readFile, stat, writeFile } from "node:fs/promises";
22
20
  import { basename, dirname, extname, isAbsolute, resolve as resolvePath, sep } from "node:path";
21
+ import { createInterface } from "node:readline";
23
22
 
24
23
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
25
24
  import {
@@ -32,13 +31,7 @@ import { Type } from "typebox";
32
31
 
33
32
  import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
34
33
  import { guardWriteAccess } from "../lib/write-guard.js";
35
- import {
36
- detectLineEnding,
37
- normalizeToLF,
38
- replace,
39
- restoreLineEndings,
40
- stripBom,
41
- } from "./edit-engine.js";
34
+ import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
42
35
 
43
36
  // ── read 工具 ────────────────────────────────────────────────────────────────
44
37
 
@@ -138,21 +131,25 @@ function startsWith(buffer: Uint8Array, bytes: Uint8Array): boolean {
138
131
  return bytes.every((b, i) => buffer[i] === b);
139
132
  }
140
133
 
141
- async function detectImageMimeTypeFromFile(filePath: string): Promise<string | null> {
134
+ async function readSample(filePath: string): Promise<Uint8Array> {
142
135
  try {
143
136
  const fileHandle = await open(filePath, "r");
144
137
  try {
145
138
  const buf = Buffer.alloc(SAMPLE_BYTES);
146
139
  const { bytesRead } = await fileHandle.read(buf, 0, SAMPLE_BYTES, 0);
147
- return detectImageMimeType(buf.subarray(0, bytesRead));
140
+ return buf.subarray(0, bytesRead);
148
141
  } finally {
149
142
  await fileHandle.close();
150
143
  }
151
144
  } catch {
152
- return null;
145
+ return new Uint8Array();
153
146
  }
154
147
  }
155
148
 
149
+ async function detectImageMimeTypeFromFile(filePath: string): Promise<string | null> {
150
+ return detectImageMimeType(await readSample(filePath));
151
+ }
152
+
156
153
  function isBinaryExtension(filePath: string): boolean {
157
154
  return BINARY_EXTENSIONS.has(extname(filePath).toLowerCase());
158
155
  }
@@ -172,68 +169,79 @@ export interface TruncationResult {
172
169
  truncated: boolean;
173
170
  truncatedBy: "lines" | "bytes" | null;
174
171
  totalLines: number;
175
- totalBytes: number;
176
172
  outputLines: number;
177
173
  outputBytes: number;
178
- maxLines: number;
179
- maxBytes: number;
174
+ offset: number;
175
+ }
176
+
177
+ export interface LinePage {
178
+ raw: string[];
179
+ count: number;
180
+ cut: boolean;
181
+ more: boolean;
182
+ offset: number;
180
183
  }
181
184
 
182
185
  /**
183
- * Truncate the head of `content` the way opencode's ReadTool.lines does:
184
- * - per-line truncation to MAX_LINE_LENGTH chars (with MAX_LINE_SUFFIX)
185
- * - line cap via maxLines (more)
186
- * - byte cap via maxBytes, computed on the truncated lines (cut)
186
+ * Stream a text file the way opencode v1 ReadTool.lines does:
187
+ * - readline with crlfDelay: Infinity (LF / CRLF / CR as one break)
188
+ * - per-line truncation to MAX_LINE_LENGTH
189
+ * - line cap via maxLines (more): keep scanning so count is the file total
190
+ * - byte cap via maxBytes (cut): stop immediately
187
191
  */
188
- export function truncateHead(
189
- content: string,
190
- maxLines: number = DEFAULT_MAX_LINES,
191
- maxBytes: number = DEFAULT_MAX_BYTES,
192
- ): TruncationResult {
193
- const lines = content ? content.split("\n") : [];
194
- if (content.endsWith("\n")) lines.pop();
195
- const totalLines = lines.length;
196
- const totalBytes = Buffer.byteLength(content, "utf8");
197
-
198
- const outputLinesArr: string[] = [];
199
- let outputBytesCount = 0;
200
- let truncated = false;
201
- let truncatedBy: "lines" | "bytes" | null = null;
202
-
203
- for (const rawLine of lines) {
204
- // opencode: 行数到达 limit 即截断(more)
205
- if (outputLinesArr.length >= maxLines) {
206
- truncated = true;
207
- truncatedBy = "lines";
208
- break;
209
- }
210
- // opencode: 单行超过 MAX_LINE_LENGTH 截断并追加提示
211
- const line =
212
- rawLine.length > MAX_LINE_LENGTH
213
- ? rawLine.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
214
- : rawLine;
215
- const lineBytes = Buffer.byteLength(line, "utf8") + (outputLinesArr.length > 0 ? 1 : 0);
216
- // opencode: 累计字节超 MAX_BYTES 即截断(cut,优先于 more)
217
- if (outputBytesCount + lineBytes > maxBytes) {
218
- truncated = true;
219
- truncatedBy = "bytes";
220
- break;
192
+ export async function readLines(
193
+ filePath: string,
194
+ opts: { offset: number; limit: number; maxBytes?: number },
195
+ ): Promise<LinePage> {
196
+ const start = opts.offset - 1;
197
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
198
+ const raw: string[] = [];
199
+ let count = 0;
200
+ let bytes = 0;
201
+ let cut = false;
202
+ let more = false;
203
+
204
+ const stream = createReadStream(filePath, { encoding: "utf8" });
205
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
206
+ try {
207
+ for await (const text of rl) {
208
+ count += 1;
209
+ if (count <= start) continue;
210
+
211
+ if (raw.length >= opts.limit) {
212
+ more = true;
213
+ continue;
214
+ }
215
+
216
+ const line =
217
+ text.length > MAX_LINE_LENGTH ? text.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text;
218
+ const size = Buffer.byteLength(line, "utf8") + (raw.length > 0 ? 1 : 0);
219
+ if (bytes + size > maxBytes) {
220
+ cut = true;
221
+ more = true;
222
+ break;
223
+ }
224
+ raw.push(line);
225
+ bytes += size;
221
226
  }
222
- outputLinesArr.push(line);
223
- outputBytesCount += lineBytes;
227
+ } finally {
228
+ rl.close();
229
+ stream.destroy();
224
230
  }
225
231
 
226
- const outputContent = outputLinesArr.join("\n");
232
+ return { raw, count, cut, more, offset: opts.offset };
233
+ }
234
+
235
+ function truncationFromPage(page: LinePage): TruncationResult {
236
+ const content = page.raw.join("\n");
227
237
  return {
228
- content: outputContent,
229
- truncated,
230
- truncatedBy,
231
- totalLines,
232
- totalBytes,
233
- outputLines: outputLinesArr.length,
234
- outputBytes: Buffer.byteLength(outputContent, "utf8"),
235
- maxLines,
236
- maxBytes,
238
+ content,
239
+ truncated: page.more || page.cut,
240
+ truncatedBy: page.cut ? "bytes" : page.more ? "lines" : null,
241
+ totalLines: page.count,
242
+ outputLines: page.raw.length,
243
+ outputBytes: Buffer.byteLength(content, "utf8"),
244
+ offset: page.offset,
237
245
  };
238
246
  }
239
247
 
@@ -379,65 +387,41 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
379
387
  return { content, details: undefined };
380
388
  }
381
389
 
382
- // Read text content
383
- const buffer = await readFile(absolutePath);
384
- const sample = buffer.subarray(0, SAMPLE_BYTES);
385
-
386
- // Binary file detection
387
- if (isBinaryExtension(absolutePath) || isBinaryFileBySample(sample)) {
390
+ if (isBinaryExtension(absolutePath) || isBinaryFileBySample(await readSample(absolutePath))) {
388
391
  return {
389
392
  content: [{ type: "text", text: `Cannot read binary file: ${absolutePath}` }],
390
393
  details: undefined,
391
394
  };
392
395
  }
393
396
 
394
- const textContent = buffer.toString("utf8");
395
- // opencode 用 Stream.splitLines,不含末尾换行产生的空行
396
- const allLines = textContent.split("\n");
397
- if (textContent.endsWith("\n")) allLines.pop();
398
- const totalFileLines = allLines.length;
399
-
400
- // opencode: offset 1 起始,offset=0 视为 1(params.offset || 1)
401
397
  const effectiveOffset = offset || 1;
402
- const startLine = Math.max(0, effectiveOffset - 1);
398
+ const page = await readLines(absolutePath, {
399
+ offset: effectiveOffset,
400
+ limit: limit ?? DEFAULT_MAX_LINES,
401
+ });
403
402
 
404
- // opencode: 越界报错(空文件 + offset=1 除外)
405
- if (totalFileLines < effectiveOffset && !(totalFileLines === 0 && effectiveOffset === 1)) {
403
+ if (page.count < page.offset && !(page.count === 0 && page.offset === 1)) {
406
404
  throw new Error(
407
- `Offset ${effectiveOffset} is out of range for this file (${totalFileLines} lines)`,
405
+ `Offset ${page.offset} is out of range for this file (${page.count} lines)`,
408
406
  );
409
407
  }
410
408
 
411
- const startLineDisplay = startLine + 1;
412
-
413
- // opencode: limit 即行数上限(默认 2000)
414
- const selectedContent = allLines.slice(startLine).join("\n");
415
- const truncation = truncateHead(selectedContent, limit ?? DEFAULT_MAX_LINES);
416
- let outputText: string;
417
-
418
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
409
+ const last = page.offset + page.raw.length - 1;
410
+ const next = last + 1;
411
+ const numbered = page.raw.map((line, i) => `${i + page.offset}: ${line}`).join("\n");
419
412
  const header = `<path>${absolutePath}</path>\n<type>file</type>\n<content>\n`;
420
413
  const footer = "\n</content>";
421
- // opencode: 每行 `${i + offset}: ${line}` 行号前缀
422
- const numbered =
423
- truncation.content === ""
424
- ? ""
425
- : truncation.content
426
- .split("\n")
427
- .map((line, i) => `${startLineDisplay + i}: ${line}`)
428
- .join("\n");
429
414
 
415
+ let outputText: string;
430
416
  let details: { truncation?: TruncationResult } | undefined;
431
- if (truncation.truncated) {
432
- const nextOffset = endLineDisplay + 1;
433
- if (truncation.truncatedBy === "bytes") {
434
- outputText = `${header}${numbered}\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${startLineDisplay}-${endLineDisplay}. Use offset=${nextOffset} to continue.)${footer}`;
435
- } else {
436
- outputText = `${header}${numbered}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.)${footer}`;
437
- }
438
- details = { truncation };
417
+ if (page.cut) {
418
+ outputText = `${header}${numbered}\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${page.offset}-${last}. Use offset=${next} to continue.)${footer}`;
419
+ details = { truncation: truncationFromPage(page) };
420
+ } else if (page.more) {
421
+ outputText = `${header}${numbered}\n\n(Showing lines ${page.offset}-${last} of ${page.count}. Use offset=${next} to continue.)${footer}`;
422
+ details = { truncation: truncationFromPage(page) };
439
423
  } else {
440
- outputText = `${header}${numbered}\n\n(End of file - total ${totalFileLines} lines)${footer}`;
424
+ outputText = `${header}${numbered}\n\n(End of file - total ${page.count} lines)${footer}`;
441
425
  }
442
426
 
443
427
  content = [{ type: "text", text: outputText }];
@@ -535,6 +519,15 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
535
519
  ] as const;
536
520
  }
537
521
 
522
+ let fileStat: Awaited<ReturnType<typeof stat>>;
523
+ try {
524
+ fileStat = await stat(absolutePath);
525
+ } catch {
526
+ throw new Error(`File ${absolutePath} not found`);
527
+ }
528
+ if (fileStat.isDirectory()) {
529
+ throw new Error(`Path is a directory, not a file: ${absolutePath}`);
530
+ }
538
531
  try {
539
532
  await access(absolutePath, constants.R_OK | constants.W_OK);
540
533
  } catch (error: unknown) {
@@ -545,23 +538,15 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
545
538
  throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
546
539
  }
547
540
 
548
- const buffer = await readFile(absolutePath);
549
- const rawContent = buffer.toString("utf8");
550
-
551
- // Strip BOM then normalize line endings to LF.
552
- // The opencode replacers split on \n and expect only LF.
553
- const { bom, text: content } = stripBom(rawContent);
554
- const originalEnding = detectLineEnding(content);
555
- const normalizedContent = normalizeToLF(content);
556
-
557
- const newContent = replace(normalizedContent, oldString, newString, replaceAll);
541
+ const rawContent = await readFile(absolutePath, "utf8");
542
+ const applied = applyEdit(rawContent, oldString, newString, replaceAll);
558
543
  signal?.throwIfAborted();
544
+ await writeFile(absolutePath, applied.finalContent, "utf8");
559
545
 
560
- const finalContent = bom + restoreLineEndings(newContent, originalEnding);
561
- await writeFile(absolutePath, finalContent, "utf8");
562
-
563
- const diffResult = generateDiffString(normalizedContent, newContent);
564
- const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
546
+ const diffOld = normalizeToLF(applied.contentOld);
547
+ const diffNew = normalizeToLF(applied.contentNew);
548
+ const diffResult = generateDiffString(diffOld, diffNew);
549
+ const patch = generateUnifiedPatch(filePath, diffOld, diffNew);
565
550
  const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
566
551
  signal,
567
552
  });
@@ -21,6 +21,8 @@ import opencodeTodo from "./todo.js";
21
21
 
22
22
  export { default as opencodeBash } from "./bash.js";
23
23
  export {
24
+ applyEdit,
25
+ convertToLineEnding,
24
26
  detectLineEnding,
25
27
  normalizeForEdit,
26
28
  normalizeToLF,
@@ -30,8 +32,8 @@ export {
30
32
  } from "./edit-engine.js";
31
33
  export {
32
34
  default as opencodeFileTools,
35
+ readLines,
33
36
  resolveBom,
34
- truncateHead,
35
37
  type TruncationResult,
36
38
  } from "./files.js";
37
39
  export { default as opencodeQuestion } from "./question.js";