@trim21/personal-pi-extensions 0.1.636 → 0.1.641

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 CHANGED
@@ -137,7 +137,7 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
137
137
  写保护直接内置在各写工具(opencode 风格 `write`/`edit`、Claude Code 风格 `Write`/`Edit`)内部,通过 `src/lib/write-guard.ts` 的 `guardWriteAccess` 实现。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
138
138
 
139
139
  - workspace 内或 `/tmp` 下的路径自动放行
140
- - 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff)
140
+ - 外部路径需通过确认对话框由用户审批(**Approve once** / **Block** / **Block with reason**),对话框内以 ```diff 代码块展示将要发生的变更预览(定位由各写工具注入自己的匹配实现,能定位时显示带行号的真实 patch,否则退化为参数 diff)
141
141
  - headless(无 UI)会话直接拒绝外部写入;Windows 上不提供审批路径,工作区外写入一律拒绝
142
142
  - `/bwrap-deny-request` 生效期间外部写入按无理由拒绝处理(`user deny <tool>: blocked`)
143
143
  - 无需配置,随各写工具自动生效
@@ -253,7 +253,7 @@ opencode 风格工具集,随 `src/opencode/index.ts` 一次加载:`read` / `
253
253
 
254
254
  ### edit
255
255
 
256
- `edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts`,与写保护审批弹窗(`src/lib/write-guard.ts`)的 diff 预览共享。唯一的有意差异是去掉了原版的转义规范化(EscapeNormalizedReplacer):它会把源码里合法的 `\n`、`\t` 等转义序列当成模型多转义的产物做启发式反转义,可能改坏内容。
256
+ `edit` 使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数复制自 opencode,匹配引擎位于 `src/opencode/edit-engine.ts`;写保护审批弹窗(`src/lib/write-guard.ts`)的 diff 预览由调用方注入这套引擎,因此预览与落盘同语义。唯一的有意差异是去掉了原版的转义规范化(EscapeNormalizedReplacer):它会把源码里合法的 `\n`、`\t` 等转义序列当成模型多转义的产物做启发式反转义,可能改坏内容。
257
257
 
258
258
  支持的匹配策略:
259
259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.636",
3
+ "version": "0.1.641",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -90,7 +90,7 @@
90
90
  "prettier --write"
91
91
  ]
92
92
  },
93
- "packageManager": "pnpm@11.27.1",
93
+ "packageManager": "pnpm@11.28.0",
94
94
  "engines": {
95
95
  "node": ">=24.14"
96
96
  },
@@ -348,11 +348,25 @@ export function registerFileTools(
348
348
  await guardWriteAccess(ctx, {
349
349
  toolName: "Edit",
350
350
  absolutePath: filePath,
351
- change: {
352
- oldText: params.old_string,
353
- newText: params.new_string,
354
- replaceAll: params.replace_all,
355
- },
351
+ // 审批预览必须与落盘同语义:这里注入本工具自己的精确匹配(空 old_string
352
+ // 是创建/填充文件,按整文件写入预览)
353
+ change:
354
+ params.old_string === ""
355
+ ? { kind: "write", newText: params.new_string }
356
+ : {
357
+ kind: "edit",
358
+ oldText: params.old_string,
359
+ newText: params.new_string,
360
+ apply: (content) => ({
361
+ contentOld: content,
362
+ contentNew: applyExactEdit(
363
+ content,
364
+ params.old_string,
365
+ params.new_string,
366
+ params.replace_all ?? false,
367
+ ),
368
+ }),
369
+ },
356
370
  policy,
357
371
  signal,
358
372
  });
@@ -521,7 +535,7 @@ export function registerFileTools(
521
535
  await guardWriteAccess(ctx, {
522
536
  toolName: "Write",
523
537
  absolutePath: filePath,
524
- change: { oldText: "", newText: params.content },
538
+ change: { kind: "write", newText: params.content },
525
539
  policy,
526
540
  signal,
527
541
  });
@@ -198,7 +198,14 @@ export function registerLspRenameTool(
198
198
  await guardWriteAccess(ctx, {
199
199
  toolName: "lsp-rename",
200
200
  absolutePath: fileEdit.path,
201
- change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
201
+ change: {
202
+ kind: "edit",
203
+ oldText: fileEdit.oldText,
204
+ newText: fileEdit.newText,
205
+ // LSP 编辑已在内存里展开成整文件的 old/new,落盘是覆盖写:预览直接用
206
+ // 这一对文本,不必再把整份旧内容交给匹配引擎去找
207
+ apply: () => ({ contentOld: fileEdit.oldText, contentNew: fileEdit.newText }),
208
+ },
202
209
  policy: options.policy,
203
210
  signal,
204
211
  });
@@ -12,7 +12,9 @@
12
12
  * outright, with the same error as the user picking "Block".
13
13
  *
14
14
  * Callers have already parsed their tool arguments, so the guard only takes the
15
- * resolved pieces: the raw target path and the pending change (oldText/newText).
15
+ * resolved pieces: the raw target path and the pending change. An edit carries
16
+ * the caller's own matching implementation, so the previewed diff comes from the
17
+ * same semantics that will write the file.
16
18
  */
17
19
 
18
20
  import { readFile } from "node:fs/promises";
@@ -20,7 +22,6 @@ import { basename, isAbsolute, relative, sep } from "node:path";
20
22
 
21
23
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
22
24
 
23
- import { applyEdit, normalizeToLF } from "../opencode/edit-engine.js";
24
25
  import { fenceCodeBlock } from "./markdown.js";
25
26
  import type { RequestPolicy } from "./request-policy.js";
26
27
 
@@ -57,14 +58,46 @@ function wrapDiff(patch: string): string {
57
58
  return fenceCodeBlock(body, "diff");
58
59
  }
59
60
 
61
+ /**
62
+ * 审批预览用的应用函数:把这次改动套到文件内容上,返回 diff 用的前后文本。
63
+ * 必须是该工具落盘时使用的同一套匹配实现——否则对话框展示的 diff 与实际写入
64
+ * 可能不一致。
65
+ */
66
+ export type PendingChangeApply = (fileContent: string) => {
67
+ readonly contentOld: string;
68
+ readonly contentNew: string;
69
+ };
70
+
71
+ /** 整文件写入:预览是完整的增删 patch,不需要匹配实现。 */
72
+ export interface FileWriteChange {
73
+ readonly kind: "write";
74
+ readonly newText: string;
75
+ }
76
+
77
+ /**
78
+ * 替换:`apply` 是调用方自己的匹配实现(精确匹配、模糊匹配,或已展开好的整文件
79
+ * 编辑)。匹配不上时抛错即可,写保护会退化为参数 diff。
80
+ */
81
+ export interface FileEditChange {
82
+ readonly kind: "edit";
83
+ readonly oldText: string;
84
+ readonly newText: string;
85
+ readonly apply: PendingChangeApply;
86
+ }
87
+
60
88
  /** The pending file change, described by the caller from already-parsed args. */
61
- export interface PendingChange {
62
- /** Text to replace; empty for whole-file writes. */
63
- oldText: string;
64
- /** Replacement text. */
65
- newText: string;
66
- /** Replace all occurrences of oldText (edits only). */
67
- replaceAll?: boolean;
89
+ export type PendingChange = FileWriteChange | FileEditChange;
90
+
91
+ /** 预览统一按 LF 渲染:CRLF 文件的 diff 每行都带 \r,在对话框里显示成乱码。 */
92
+ function normalizeToLF(text: string): string {
93
+ return text.replaceAll("\r\n", "\n");
94
+ }
95
+
96
+ /** 参数 diff:调用方的匹配实现报告不出结果时的兜底形式。 */
97
+ function parameterDiff(oldText: string, newText: string): string {
98
+ const removed = oldText.split("\n").map((line) => `-${line}`);
99
+ const added = newText.split("\n").map((line) => `+${line}`);
100
+ return [...removed, ...added].join("\n");
68
101
  }
69
102
 
70
103
  /**
@@ -75,35 +108,31 @@ export async function buildDiffPreview(
75
108
  resolvedPath: string,
76
109
  change: PendingChange,
77
110
  ): Promise<string | undefined> {
78
- let oldContent = "";
111
+ let fileContent = "";
79
112
  try {
80
- oldContent = await readFile(resolvedPath, "utf8");
113
+ fileContent = await readFile(resolvedPath, "utf8");
81
114
  } catch {
82
115
  // Unreadable or missing file: treat as empty so writes show as full additions.
83
116
  }
84
117
 
85
- if (change.oldText === "") {
118
+ if (change.kind === "write") {
86
119
  // Whole-file write: show the full addition/replacement patch.
87
- return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, change.newText, 2));
120
+ return wrapDiff(generateUnifiedPatch(basename(resolvedPath), fileContent, change.newText, 2));
88
121
  }
89
122
 
90
- // Edit: reuse the real matching engine to locate oldText, giving a
91
- // line-numbered patch when it matches. Fall back to a parameter diff when the
92
- // edit cannot be applied (oldText not found, ambiguous, or no file).
123
+ // 定位由调用方的匹配实现负责(与其落盘同语义);匹配不上时退回参数 diff。
93
124
  try {
94
- const applied = applyEdit(oldContent, change.oldText, change.newText, change.replaceAll);
125
+ const { contentOld, contentNew } = change.apply(fileContent);
95
126
  return wrapDiff(
96
127
  generateUnifiedPatch(
97
128
  basename(resolvedPath),
98
- normalizeToLF(applied.contentOld),
99
- normalizeToLF(applied.contentNew),
129
+ normalizeToLF(contentOld),
130
+ normalizeToLF(contentNew),
100
131
  2,
101
132
  ),
102
133
  );
103
134
  } catch {
104
- const removed = change.oldText.split("\n").map((line) => `-${line}`);
105
- const added = change.newText.split("\n").map((line) => `+${line}`);
106
- return wrapDiff([...removed, ...added].join("\n"));
135
+ return wrapDiff(parameterDiff(change.oldText, change.newText));
107
136
  }
108
137
  }
109
138
 
@@ -635,7 +635,20 @@ function registerEditTool(
635
635
  await guardWriteAccess(ctx, {
636
636
  toolName: "edit",
637
637
  absolutePath,
638
- change: { oldText: oldString, newText: newString, replaceAll },
638
+ // 审批预览必须与落盘同语义:注入本工具自己的模糊匹配引擎(空 oldString
639
+ // 是创建新文件,按整文件写入预览)
640
+ change:
641
+ oldString === ""
642
+ ? { kind: "write", newText: newString }
643
+ : {
644
+ kind: "edit",
645
+ oldText: oldString,
646
+ newText: newString,
647
+ apply: (content) => {
648
+ const applied = applyEdit(content, oldString, newString, replaceAll);
649
+ return { contentOld: applied.contentOld, contentNew: applied.contentNew };
650
+ },
651
+ },
639
652
  policy,
640
653
  signal,
641
654
  });
@@ -800,7 +813,7 @@ function registerWriteTool(
800
813
  await guardWriteAccess(ctx, {
801
814
  toolName: "write",
802
815
  absolutePath,
803
- change: { oldText: "", newText: content },
816
+ change: { kind: "write", newText: content },
804
817
  policy,
805
818
  signal,
806
819
  });