pi-error-retry 0.1.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # 更新日志
2
+
3
+ ## 0.2.0
4
+
5
+ - 新增 `/error-retry import <文件路径>` 和 `/error-retry export <文件路径>`。
6
+ - 兼容 DSH v1 规则文件、原生配置对象和裸规则数组;导出文件可直接导入 DSH。
7
+ - 导入先校验、确认后替换,并保留配置冲突和会话生命周期保护。
8
+ - 导出原子创建新文件,不覆盖已有目标;支持默认规则导出、1 MiB 大小限制和失败清理。
9
+ - 源码改由 `librola/pi-extensions-shuyuan` 统一管理,npm 包名保持 `pi-error-retry`。
10
+
11
+ ## 0.1.0
12
+
13
+ - 按规则无限重试匹配的模型错误,并在输出长度截断后自动续写。
14
+ - 提供规则编辑、热更新、配置冲突校验和标准停止行为。
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-error-retry
2
2
 
3
- 独立 Pi 扩展,移植 `@local/dsh-error-retry` 的规则重试行为,并增加输出长度截断后的自动续写。当前在本机 Pi **1.4.0** 上验证。
3
+ 独立 Pi 扩展,移植 `@local/dsh-error-retry` 的规则重试行为,并增加输出长度截断后的自动续写及规则导入/导出命令。当前在本机 Pi **1.5.2** 上验证。
4
4
 
5
5
  ## 必须区分的两种行为
6
6
 
@@ -29,10 +29,10 @@ Pi 自带的有限错误重试先行;它耗尽后,本扩展接管匹配错
29
29
  pi install npm:pi-error-retry
30
30
  ```
31
31
 
32
- 也可直接从 GitHub 安装:
32
+ 本扩展现与 `pi-background-bash` 在[统一仓库](../README.md)管理。直接从 GitHub 安装两个扩展:
33
33
 
34
34
  ```sh
35
- pi install git:github.com/librola/pi-error-retry
35
+ pi install git:github.com/librola/pi-extensions-shuyuan
36
36
  ```
37
37
 
38
38
  在现有 Pi 会话中执行 `/reload`;新会话自动加载。克隆后的本地源码无需编译,也可临时加载:
@@ -41,6 +41,8 @@ pi install git:github.com/librola/pi-error-retry
41
41
  pi -e ./pi-error-retry/index.ts
42
42
  ```
43
43
 
44
+ 仅安装本扩展时,使用 `pi install ~/pi-dev/pi-extensions-shuyuan/pi-error-retry`。不要与原来的独立包重复加载。
45
+
44
46
  安装声明位于 `~/.pi/agent/settings.json` 的 `packages` 数组。本扩展只注册事件和用户命令,没有新增模型可调用的配置工具。
45
47
 
46
48
  不要同时启用其他错误恢复/长度续写扩展,避免竞争触发。
@@ -56,9 +58,11 @@ pi -e ./pi-error-retry/index.ts
56
58
  设置了 `PI_CODING_AGENT_DIR` 时使用该目录。规则只从用户目录读取,不读取项目里的规则覆盖。
57
59
 
58
60
  ```text
59
- /error-retry 查看规则数量、配置位置和两种恢复行为
60
- /error-retry edit 用 Pi 原生 JSON 编辑器修改规则
61
- /error-retry stop 停止当前请求
61
+ /error-retry 查看规则数量、配置位置和两种恢复行为
62
+ /error-retry edit 用 Pi 原生 JSON 编辑器修改规则
63
+ /error-retry stop 停止当前请求
64
+ /error-retry import <路径> 校验规则文件,确认后替换当前规则
65
+ /error-retry export <路径> 将当前有效规则导出为 DSH v1 JSON 文件
62
66
  ```
63
67
 
64
68
  Pi 原生编辑器适用于 TUI 和支持该对话协议的 RPC 客户端;不提供 DSH 的插件详情页。JSON/print 模式请直接编辑文件。
@@ -69,6 +73,21 @@ Pi 原生编辑器适用于 TUI 和支持该对话协议的 RPC 客户端;不
69
73
  - 保存不取消已经排定的等待;已排定的重试可能完成一次,下一次错误判定读取新规则。立即停止请使用标准停止按钮或 `/error-retry stop`。
70
74
  - 非法配置、读取失败或正则匹配超时不被当成命中,也不静默回退为宽泛规则。
71
75
 
76
+ ### 导入与导出
77
+
78
+ ```text
79
+ /error-retry import ~/Downloads/dsh-error-retry-rules.json
80
+ /error-retry export ~/Downloads/pi-error-retry-rules.json
81
+ /error-retry import "./rules backup.json"
82
+ ```
83
+
84
+ - 导入兼容 DSH 的 `{ "version": 1, "rules": [...] }`、本插件的 `{ "rules": [...] }`,也接受裸规则数组;空数组合法,缺少 `rules`、未知版本、额外字段及非法规则会被拒绝。用户配置文件仍只保存 `{ "rules": [...] }`,格式不变。
85
+ - 导入先解析并复用保存校验,再显示来源、规则数量和风险确认。确认后**替换整组规则,不追加**;取消、校验失败或确认期间发生配置冲突都不会覆盖原配置。TUI 和支持确认对话的 RPC 客户端可用;JSON/print 模式不跳过确认,因而拒绝导入。
86
+ - 导出当前已保存的有效规则,不包含编辑器未保存草稿、会话状态或凭据。没有用户配置文件时导出默认规则,不顺便创建用户配置。输出格式为 `{ "version": 1, "rules": [...] }`,可直接导入 DSH。
87
+ - 导出**只创建新文件,不覆盖已有文件或符号链接**,也不能导出到正在使用的配置路径。目标目录需已存在;文件权限为 0600,原子发布完整内容,失败时清理临时文件。导入和导出文件均限制为 1 MiB。
88
+ - 路径相对于当前会话的工作目录,支持绝对路径、`~/`、空格及成对的单/双引号;不展开环境变量或通配符,不执行 shell。路径是 **Pi 进程所在机器**的文件路径;Pi Web 不会把浏览器本机文件自动上传或下载。
89
+ - 命令不调用模型、不写模型上下文;导入不自动 reload、不取消已排定等待,新规则从下一次错误判定生效。reload 或会话切换会取消尚未完成的导入确认。
90
+
72
91
  ### 规则格式
73
92
 
74
93
  ```json
@@ -110,7 +129,9 @@ Pi 原生编辑器适用于 TUI 和支持该对话协议的 RPC 客户端;不
110
129
 
111
130
  ## 验证
112
131
 
113
- 开发依赖安装不会安装另一份 Pi:
132
+ 统一开发优先在仓库根目录执行 `npm ci --include=dev --ignore-scripts --legacy-peer-deps` 和 `npm run check`,参见[根目录说明](../README.md#开发与验证)。
133
+
134
+ 也可在本扩展目录独立开发;以下安装方式不会自动安装 peer Pi:
114
135
 
115
136
  ```sh
116
137
  npm install --ignore-scripts --legacy-peer-deps
@@ -121,9 +142,9 @@ npm test
121
142
 
122
143
  `PI_TEST_PACKAGE` 指向已安装 Pi 的包清单。不设置时从项目依赖中寻找宿主 Pi。测试使用真实 SDK、扩展 TypeScript 加载器、会话上下文投影及模拟 provider;不调用真实模型,也不读取用户模型凭据。临时文件在系统临时目录中创建和清理。
123
144
 
124
- 覆盖:101 次连续错误、内置重试串联、请求输入不变、截断前文保留、截断后错误重试、已执行工具不重放、截断工具不执行、取消长等待、规则热更新/冲突/校验、正则超时、退避、服务端等待及日志计数隔离。
145
+ 覆盖:101 次连续错误、内置重试串联、请求输入不变、截断前文保留、截断后错误重试、已执行工具不重放、截断工具不执行、取消长等待、规则热更新/冲突/校验、正则超时、退避、服务端等待及日志计数隔离;另覆盖 DSH/原生/裸数组导入、确认与取消、并发配置冲突、生命周期失效、默认规则导出、文件大小边界、路径解析、禁止覆盖、原子发布和磁盘写入失败清理,以及真实 SDK 的命令派发与导入后规则生效。
125
146
 
126
- 当前 **32 项测试通过**,TypeScript 严格类型检查通过。
147
+ 当前 **51 项测试通过**,TypeScript 严格类型检查通过。
127
148
 
128
149
  另有一个已独立对照复现的宿主限制:SDK 直接对活动会话调用 `session.dispose()` 时,Pi 1.4.0 会先解绑持久化订阅,随后可能报告 `turn_end could not resolve the persisted assistant entry ID`;仅注册空 `turn_end` 处理器、不加载本扩展也能复现。本扩展不会因此继续请求,也不会访问已失效的上下文。该宿主问题未修改;正常标准停止已验证,SDK 集成建议先 `await session.abort()` 再 `session.dispose()`。
129
150
 
package/index.ts CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { ExtensionAPI, SessionEntry, TurnEndEvent } from "@earendil-works/pi-coding-agent";
5
5
  import { readConfig, saveConfig } from "./src/config.ts";
6
+ import { exportRulesFile, readRulesFile, resolveRulesFilePath } from "./src/transfer.ts";
6
7
  import { matchesRules, policyKey, providerRetryAfter, retryDelay, waitForRetry } from "./src/rules.ts";
7
8
 
8
9
  export const RECOVERY_ENTRY = "pi-error-retry.recovery";
@@ -148,19 +149,50 @@ export function registerErrorRetry(pi: ExtensionAPI, configPath: string): void {
148
149
  });
149
150
 
150
151
  pi.registerCommand("error-retry", {
151
- description: "查看错误重试规则;/error-retry edit 编辑规则,/error-retry stop 停止当前请求",
152
+ description: "查看规则;edit 编辑,stop 停止,import <路径> 导入,export <路径> 导出",
152
153
  handler: async (args, ctx) => {
153
- const action = args.trim();
154
- if (action === "stop") {
154
+ const commandLifetime = lifetime;
155
+ if (commandLifetime.signal.aborted) return;
156
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
157
+ const action = match?.[1] ?? "";
158
+ const argument = match?.[2]?.trim() ?? "";
159
+ if (action === "stop" && !argument) {
155
160
  pendingRetry = undefined;
156
161
  ctx.abort();
157
162
  return;
158
163
  }
159
- if (action && action !== "edit") {
160
- ctx.ui.notify("用法:/error-retry [edit|stop]", "warning");
164
+ if ((action && !["edit", "import", "export"].includes(action)) ||
165
+ (action === "edit" && argument) || (["import", "export"].includes(action) && !argument)) {
166
+ ctx.ui.notify("用法:/error-retry [edit|stop|import <文件路径>|export <文件路径>]", "warning");
161
167
  return;
162
168
  }
163
169
  try {
170
+ if (action === "import" || action === "export") {
171
+ const path = resolveRulesFilePath(argument, ctx.cwd);
172
+ if (action === "export") {
173
+ const config = exportRulesFile(configPath, path);
174
+ ctx.ui.notify(`已导出 ${config.rules.length} 条规则:${path}(DSH v1 格式)`, "info");
175
+ return;
176
+ }
177
+ if (!ctx.hasUI) {
178
+ ctx.ui.notify("当前模式无法确认替换规则,请在 TUI 或支持确认对话的 RPC 客户端导入", "warning");
179
+ return;
180
+ }
181
+ const snapshot = readConfig(configPath);
182
+ const incoming = readRulesFile(path);
183
+ const confirmed = await ctx.ui.confirm("导入错误重试规则",
184
+ `从 ${path} 导入 ${incoming.rules.length} 条规则,将替换当前 ${snapshot.config.rules.length} 条规则。保存不取消已排定的等待;无限重试可能产生持续请求和费用。是否继续?`,
185
+ { signal: commandLifetime.signal });
186
+ // 对话可能跨越 reload 或会话切换;旧命令不能继续写文件或通知新会话。
187
+ if (commandLifetime.signal.aborted) return;
188
+ if (!confirmed) {
189
+ ctx.ui.notify("已取消导入,规则未修改", "info");
190
+ return;
191
+ }
192
+ const config = saveConfig(configPath, incoming, snapshot.revision);
193
+ ctx.ui.notify(`已导入 ${config.rules.length} 条规则,替换原规则;下次错误判定自动生效`, "info");
194
+ return;
195
+ }
164
196
  const snapshot = readConfig(configPath);
165
197
  if (action !== "edit") {
166
198
  ctx.ui.notify(`错误规则:${snapshot.config.rules.length} 条(命中后无限重试);长度截断:自动继续。配置:${configPath}`, "info");
@@ -191,7 +223,9 @@ export function registerErrorRetry(pi: ExtensionAPI, configPath: string): void {
191
223
  }
192
224
  }
193
225
  } catch (error) {
194
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
226
+ if (!commandLifetime.signal.aborted) {
227
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
228
+ }
195
229
  }
196
230
  },
197
231
  });
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "pi-error-retry",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "按规则无限重试 Pi 模型错误,并在长度截断时自动继续",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/librola/pi-error-retry.git"
8
+ "url": "git+https://github.com/librola/pi-extensions-shuyuan.git",
9
+ "directory": "pi-error-retry"
9
10
  },
10
- "homepage": "https://github.com/librola/pi-error-retry#readme",
11
+ "homepage": "https://github.com/librola/pi-extensions-shuyuan/tree/main/pi-error-retry#readme",
11
12
  "bugs": {
12
- "url": "https://github.com/librola/pi-error-retry/issues"
13
+ "url": "https://github.com/librola/pi-extensions-shuyuan/issues"
13
14
  },
14
15
  "files": [
16
+ "CHANGELOG.md",
15
17
  "index.ts",
16
18
  "src/",
17
19
  "examples/"
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, linkSync, openSync, readSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { readConfig } from "./config.ts";
6
+ import { type RetryConfig, validateConfig } from "./rules.ts";
7
+
8
+ const MAX_RULES_FILE_BYTES = 1024 * 1024;
9
+
10
+ /** 将命令剩余参数作为一个路径,不执行 shell 或展开环境变量。 */
11
+ export function resolveRulesFilePath(argument: string, cwd: string): string {
12
+ let path = argument.trim();
13
+ if (path.startsWith('"') || path.startsWith("'")) {
14
+ if (path.length < 2 || path.at(-1) !== path[0]) throw new Error("文件路径的引号必须成对");
15
+ path = path.slice(1, -1);
16
+ }
17
+ if (!path.trim() || path.includes("\0")) throw new Error("请提供有效的 JSON 文件路径");
18
+ if (path === "~") path = homedir();
19
+ else if (path.startsWith("~/")) path = join(homedir(), path.slice(2));
20
+ return resolve(cwd, path);
21
+ }
22
+
23
+ /** 读取 DSH v1、原生配置对象或裸规则数组,统一使用现有规则校验。 */
24
+ export function readRulesFile(path: string): RetryConfig {
25
+ // 非阻塞打开后检查同一个描述符,拒绝 FIFO、设备等可能让命令一直等待的输入。
26
+ const file = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
27
+ let text: string;
28
+ try {
29
+ const stat = fstatSync(file);
30
+ if (!stat.isFile()) throw new Error("导入路径必须是普通 JSON 文件");
31
+ if (stat.size > MAX_RULES_FILE_BYTES) throw new Error("规则文件过大(超过 1 MiB)");
32
+ // 读取上限不依赖最初的 stat,文件并发增长也不会无限分配内存。
33
+ const buffer = Buffer.alloc(MAX_RULES_FILE_BYTES + 1);
34
+ let size = 0;
35
+ while (size < buffer.length) {
36
+ const count = readSync(file, buffer, size, buffer.length - size, null);
37
+ if (count === 0) break;
38
+ size += count;
39
+ }
40
+ if (size > MAX_RULES_FILE_BYTES) throw new Error("规则文件过大(超过 1 MiB)");
41
+ text = buffer.subarray(0, size).toString("utf8").replace(/^\uFEFF/, "");
42
+ } finally {
43
+ closeSync(file);
44
+ }
45
+ let value: unknown;
46
+ try {
47
+ value = JSON.parse(text);
48
+ } catch (error) {
49
+ throw new Error(`JSON 解析失败:${error instanceof Error ? error.message : String(error)}`);
50
+ }
51
+ if (Array.isArray(value)) return validateConfig({ rules: value });
52
+ if (!value || typeof value !== "object") throw new Error("规则文件必须包含 rules 数组或直接提供规则数组");
53
+ const object = value as Record<string, unknown>;
54
+ if (Object.keys(object).some((key) => key !== "version" && key !== "rules")) {
55
+ throw new Error("规则文件仅接受 version、rules 字段");
56
+ }
57
+ if (object.version !== undefined && object.version !== 1) throw new Error("不支持的规则文件版本,仅支持 version: 1");
58
+ // 导入缺少 rules 的文件不能意外启用默认规则;用户配置读取的旧行为不变。
59
+ if (!Array.isArray(object.rules)) throw new Error("规则文件必须包含 rules 数组");
60
+ return validateConfig({ rules: object.rules });
61
+ }
62
+
63
+ /** 导出当前有效规则为 DSH v1 文件,原子创建新文件,绝不覆盖已有目标。 */
64
+ export function exportRulesFile(configPath: string, destination: string): RetryConfig {
65
+ const config = readConfig(configPath).config;
66
+ const target = join(realpathSync(dirname(destination)), basename(destination));
67
+ let active = resolve(configPath);
68
+ try {
69
+ active = join(realpathSync(dirname(active)), basename(active));
70
+ } catch (error) {
71
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
72
+ }
73
+ if (target === active) throw new Error("不能导出到正在使用的配置文件,请选择其他路径");
74
+ const rules = config.rules.map((rule) => rule.mode === "regex" ? { ...rule, flags: rule.flags ?? "" } : { ...rule });
75
+ const text = `${JSON.stringify({ version: 1, rules }, null, 2)}\n`;
76
+ if (Buffer.byteLength(text) > MAX_RULES_FILE_BYTES) throw new Error("导出规则文件过大(超过 1 MiB)");
77
+ const temporary = `${target}.${randomUUID()}.tmp`;
78
+ const file = openSync(temporary, "wx", 0o600);
79
+ try {
80
+ try {
81
+ writeFileSync(file, text);
82
+ } finally {
83
+ closeSync(file);
84
+ }
85
+ // 同目录硬链接一次发布完整内容;目标在检查后被创建也不会被覆盖。
86
+ try {
87
+ linkSync(temporary, target);
88
+ } catch (error) {
89
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`导出目标已存在,未覆盖:${destination}`);
90
+ throw error;
91
+ }
92
+ } finally {
93
+ unlinkSync(temporary);
94
+ }
95
+ return config;
96
+ }