pi-patch-keeper 1.0.4 → 1.0.6
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.en.md +31 -29
- package/README.md +37 -27
- package/index.ts +170 -529
- package/package.json +6 -3
package/README.en.md
CHANGED
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
# pi-patch-keeper — extension load-order guard (pi extension)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Prevents "force-typed" extensions from silently swallowing every section written after them.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Why it exists
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Statically scans npm extension sources for unconditional `return { systemPrompt }` / `forceSystemPrompt`.
|
|
9
|
-
- Moves known force-type packages to the end of `packages` (settings backed up first).
|
|
10
|
-
- Speaks only when something is wrong; `/patch-keeper` reports, `/patch-keeper fix` repairs.
|
|
11
|
-
- This release contains no machine-specific patch healing (source patches, PiDeck asar, etc.).
|
|
7
|
+
`before_agent_start` runs extensions in order. An extension that returns `{ systemPrompt }` (or sets `forceSystemPrompt`) performs a **full replacement**, so every later extension's `sections` write is discarded — with no error. The symptom looks like "this extension is installed but has no effect".
|
|
12
8
|
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
| Item | Default | Note |
|
|
16
|
-
| --- | --- | --- |
|
|
17
|
-
| Force-type packages | `npm:pi-rtk-optimizer`, `npm:pi-cache-optimizer` | override via `forceTail` |
|
|
18
|
-
| Auto repair | on | backup `settings.json.bak-force-order-*` |
|
|
19
|
-
| Startup notice | only on issue | silent when clean |
|
|
20
|
-
| Scope | `npm:` packages | local path packages are skipped |
|
|
9
|
+
## What it does
|
|
21
10
|
|
|
22
|
-
|
|
11
|
+
- On startup, checks the order of `packages` in `~/.pi/agent/settings.json`: known force-typed extensions must sit at the end.
|
|
12
|
+
- Statically scans installed npm extension entry files and separates:
|
|
13
|
+
- **unconditional force** (`return { systemPrompt }` / `forceSystemPrompt`) → must be last;
|
|
14
|
+
- **sections-first with legacy fallback** → report only, no move suggested.
|
|
15
|
+
- Records a fingerprint of key settings fields and warns when it changes (earlier conclusions may be stale).
|
|
16
|
+
- `codemode` single-point check: disabling `builtin:codemode` requires a replacement, and vice versa.
|
|
17
|
+
- Silent when everything is fine.
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
pi install npm:pi-patch-keeper@1.0.0
|
|
26
|
-
pi install ./pi-patch-keeper
|
|
27
|
-
```
|
|
19
|
+
## Commands
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
| Command | Effect |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `/patch-keeper` | Show order, suspected force packages, fallback packages, codemode status |
|
|
24
|
+
| `/patch-keeper fix` | Move known force-typed packages to the end (backup written first) |
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
{ "forceTail": ["npm:pi-rtk-optimizer", "npm:pi-cache-optimizer"] }
|
|
33
|
-
```
|
|
26
|
+
## Defaults
|
|
34
27
|
|
|
35
|
-
|
|
28
|
+
| Key | Default |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `forceTail` | `npm:pi-rtk-optimizer`, `npm:pi-cache-optimizer` |
|
|
31
|
+
| Auto-fix | on (order only, backup first) |
|
|
32
|
+
| Notify | only on problems |
|
|
33
|
+
| Scope | `npm:` packages |
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
- Reports when any package still follows a force-type package.
|
|
35
|
+
Optional config: `~/.pi/patch-keeper-state.json`
|
|
39
36
|
|
|
40
37
|
## Limits
|
|
41
38
|
|
|
42
|
-
-
|
|
43
|
-
-
|
|
39
|
+
- Detects static forms only; dynamic `systemPrompt` construction is not detected.
|
|
40
|
+
- Local-path packages are skipped.
|
|
41
|
+
- Unknown force-typed extensions must be added to `forceTail` manually.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT.
|
package/README.md
CHANGED
|
@@ -1,47 +1,57 @@
|
|
|
1
1
|
# pi-patch-keeper — 扩展加载顺序守护(pi 扩展)
|
|
2
2
|
|
|
3
|
-
防止「force
|
|
3
|
+
防止「force 型扩展」静默吞掉其后的 system 注入。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 为什么需要
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
pi 的 system prompt 由各扩展写入的 section 拼装。若某扩展在 `before_agent_start` 里
|
|
8
|
+
`return { systemPrompt }`(或设置 `forceSystemPrompt`),宿主会用返回值**整段替换**,
|
|
9
|
+
其后所有扩展的 section 写入被静默丢弃——没有报错,表现为「配了却不起效」。
|
|
8
10
|
|
|
9
11
|
## 功能
|
|
10
12
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
13
|
+
- **启动检查** `settings.json` 的 `packages` 顺序:已知 force 型必须整体位于末尾。
|
|
14
|
+
- **静态扫描**已装 npm 扩展的入口源码,区分两类:
|
|
15
|
+
- 「无条件 force」——从不写 section,只 return systemPrompt(会吞掉其后一切)
|
|
16
|
+
- 「兼容兜底」——先写 section,只在旧宿主才 force(安全)
|
|
17
|
+
- **自动修复**:把已知 force 型移到末尾(写前备份 `settings.json.bak-force-order-*`)。
|
|
18
|
+
- **指纹提示**:记录 `packages / defaultTools / enabledModels / extensions / codemode` 指纹,
|
|
19
|
+
变化时提示「环境已变,旧结论可能过期」。
|
|
20
|
+
- **codemode 单点检查**:禁用 `builtin:codemode` 必须有替代实现,反之亦然。
|
|
16
21
|
|
|
17
|
-
##
|
|
22
|
+
## 命令
|
|
18
23
|
|
|
19
|
-
|
|
|
20
|
-
| --- | --- |
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
| 启动提示 | 仅异常时 | 无问题不打扰 |
|
|
24
|
-
| 检测范围 | `npm:` 包 | 本地路径包跳过 |
|
|
24
|
+
| 命令 | 作用 |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `/patch-keeper` | 查看顺序 / 疑似 force / 兼容兜底 / codemode 单点 |
|
|
27
|
+
| `/patch-keeper fix` | 修 force 顺序(写前备份) |
|
|
25
28
|
|
|
26
|
-
##
|
|
29
|
+
## 配置
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
pi install npm:pi-patch-keeper
|
|
30
|
-
pi install ./pi-patch-keeper
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## 配置示例
|
|
31
|
+
`~/.pi/patch-keeper.json`(可选):
|
|
34
32
|
|
|
35
33
|
```json
|
|
36
34
|
{ "forceTail": ["npm:pi-rtk-optimizer", "npm:pi-cache-optimizer"] }
|
|
37
35
|
```
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
`forceTail` 覆盖默认的「已知 force 型」列表;数组顺序即期望顺序(最可能触发者最后)。
|
|
38
|
+
指纹状态写入同文件的 `fingerprint` 字段。
|
|
39
|
+
|
|
40
|
+
## 纪律
|
|
40
41
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
42
|
+
- **绝不** `return { systemPrompt }` —— 否则自己就是它要防的问题。
|
|
43
|
+
- 唯一的写操作是修 `settings.json` 的 `packages`,且写前备份。
|
|
44
|
+
- 任何异常只提示、不抛错;无问题不打扰。
|
|
45
|
+
|
|
46
|
+
## 安装
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pi install npm:pi-patch-keeper
|
|
50
|
+
pi install ./pi-patch-keeper
|
|
51
|
+
```
|
|
43
52
|
|
|
44
53
|
## 限制
|
|
45
54
|
|
|
46
|
-
-
|
|
47
|
-
-
|
|
55
|
+
- 静态扫描只看扩展入口前 4000 字符内 `before_agent_start` 附近的写法,动态构造的 force 检测不到。
|
|
56
|
+
- user 扩展(不在 `packages` 数组里)不在扫描范围。
|
|
57
|
+
- 只处理顺序问题;不修改任何第三方扩展的源码。
|
package/index.ts
CHANGED
|
@@ -1,429 +1,112 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* patch-keeper ——
|
|
2
|
+
* patch-keeper —— 扩展加载顺序守护(pi 扩展)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 上游一升级就覆盖,得重跑桌面上的补丁脚本 —— 容易忘。这个扩展把它变成自愈:
|
|
9
|
-
* 每次 pi 启动时检查一遍,能修的直接修,不能修的(PiDeck 正开着,asar 被锁)明确报出来。
|
|
4
|
+
* 问题:pi 的 system prompt 由各扩展的 section 写入拼装。若某扩展在
|
|
5
|
+
* `before_agent_start` 里 `return { systemPrompt }`(或设置 forceSystemPrompt),
|
|
6
|
+
* 它会**静默丢弃其后所有扩展的 section 写入**——没有任何报错,表现为
|
|
7
|
+
* 「某个扩展配了却没效果」。
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 所以在 factory 里同步改 dist/index.js,pi-lens 随后 import 到的就是打过补丁的内容 —— 当轮生效。
|
|
9
|
+
* 本扩展做三件事(全部只读,唯一的写是带备份修 settings):
|
|
10
|
+
* 1. 检查 `settings.json` 的 packages 顺序:已知 force 型必须排在末尾。
|
|
11
|
+
* 2. 静态扫描已装 npm 扩展入口源码,区分「无条件 force」与「section 优先 + 旧宿主兜底」。
|
|
12
|
+
* 3. 记录 settings 关键字段指纹;变化时提示「环境已变,旧结论可能过期」。
|
|
16
13
|
*
|
|
17
|
-
*
|
|
18
|
-
* - PiDeck asar:**只检测不修**。PiDeck 正开着,文件被锁;且插件无权碰 Electron 主进程。
|
|
19
|
-
* 检测到未打补丁 → 提示跑 `run-all.cmd` / `launch-pideck.cmd`(桌面 PI脚本补丁/ 不在时用快照副本 05-脚本补丁/)。
|
|
20
|
-
* - 任何失败都只 notify,不抛错、不影响会话(纪律:不合法就跳过 + 记日志,绝不崩溃)。
|
|
14
|
+
* 纪律:绝不 return systemPrompt(否则自己就是问题);任何失败只提示、不抛错。
|
|
21
15
|
*/
|
|
22
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import { existsSync, readFileSync, writeFileSync, copyFileSync
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
|
|
25
18
|
import * as os from "node:os";
|
|
26
19
|
import * as path from "node:path";
|
|
27
20
|
|
|
28
|
-
// 路径从 home / %LOCALAPPDATA% 推导(不写死用户名:换机 / 换用户名 / 复刻包到别的机器仍可用)
|
|
29
21
|
const HOME = os.homedir();
|
|
30
|
-
const LOCALAPPDATA = process.env.LOCALAPPDATA ?? path.join(HOME, "AppData", "Local");
|
|
31
|
-
const PI_LENS = path.join(HOME, ".pi", "agent", "npm", "node_modules", "pi-lens", "dist", "index.js");
|
|
32
|
-
const PI_MEMORY = path.join(HOME, ".pi", "agent", "npm", "node_modules", "pi-memory", "index.ts");
|
|
33
|
-
const ASAR = path.join(LOCALAPPDATA, "Programs", "PiDeck", "resources", "app.asar");
|
|
34
|
-
// 唯一处置入口:快照 05-脚本补丁/(桌面旧目录 PI脚本补丁/ 已不存在,2026-10-15 核实)
|
|
35
|
-
const SNAPSHOT_PATCH_DIR = path.join(HOME, "Desktop", "pi-env-snapshot", "05-脚本补丁");
|
|
36
|
-
// 环境指纹文件(settings 关键字段,用于漂移检测)
|
|
37
|
-
const FINGERPRINT = path.join(HOME, ".pi", "patch-keeper.json");
|
|
38
22
|
const SETTINGS = path.join(HOME, ".pi", "agent", "settings.json");
|
|
39
|
-
|
|
40
|
-
const
|
|
23
|
+
const STATE = path.join(HOME, ".pi", "patch-keeper-state.json");
|
|
24
|
+
const NPM_ROOT = path.join(HOME, ".pi", "agent", "npm", "node_modules");
|
|
41
25
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const PIDECK_INSTALL = path.join(process.env.LOCALAPPDATA ?? path.join(HOME, "AppData", "Local"), "Programs", "PiDeck", "resources", "extensions");
|
|
45
|
-
// needle = 本机修复特征串(不是 section 名):命中即视为已打;升级覆盖后从快照恢复。
|
|
46
|
-
const BUILTIN_PATCHES: Array<{ file: string; install: string; needle: string; name: string }> = [
|
|
47
|
-
{ file: path.join(PIDECK_BUILTIN, "pi-deck-security-gate.ts"), install: path.join(PIDECK_INSTALL, "pi-deck-security-gate.ts"), needle: "越界操作先说明理由", name: "security-gate" },
|
|
48
|
-
{ file: path.join(PIDECK_BUILTIN, "pi-deck-plan-mode.ts"), install: path.join(PIDECK_INSTALL, "pi-deck-plan-mode.ts"), needle: "keepThirdParty", name: "plan-mode" },
|
|
49
|
-
{ file: path.join(PIDECK_BUILTIN, "pi-deck-goal-mode.ts"), install: path.join(PIDECK_INSTALL, "pi-deck-goal-mode.ts"), needle: "roundLine", name: "goal-mode" },
|
|
50
|
-
{ file: path.join(PIDECK_BUILTIN, "pi-deck-trash-guard.ts"), install: path.join(PIDECK_INSTALL, "pi-deck-trash-guard.ts"), needle: "deleteFlagStyle", name: "trash-guard" },
|
|
51
|
-
{ file: path.join(PIDECK_BUILTIN, "pi-deck-session-title.ts"), install: path.join(PIDECK_INSTALL, "pi-deck-session-title.ts"), needle: "TITLE_SOURCE_MARKER_HEAD", name: "session-title" },
|
|
52
|
-
];
|
|
53
|
-
const SNAPSHOT_BUILTIN = path.join(HOME, "Desktop", "pi-env-snapshot", "03-PiDeck", "builtin-extensions");
|
|
54
|
-
const MANUAL_STATE = path.join(HOME, ".pi", "patch-keeper-manual.json");
|
|
55
|
-
/**
|
|
56
|
-
* 从手册文本里取版本号(标题「自主工作手册」后 200 字符内的第一个 vNN.NN)。
|
|
57
|
-
* 取不到返回 null(注入文本若不含标题即静默跳过,绝不猜)。
|
|
58
|
-
*/
|
|
59
|
-
function manualVer(text: string): string | null {
|
|
60
|
-
const i = text.indexOf("自主工作手册");
|
|
61
|
-
if (i < 0) return null;
|
|
62
|
-
const m = text.slice(i, i + 200).match(/v(\d+\.\d+)/);
|
|
63
|
-
return m ? m[1] : null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── pi-lens word index 补丁:两处替换(与 patches/pi-lens-wordindex-patch.js 同源)
|
|
67
|
-
const A1_FROM = " warmWordIndexes.set(key4, entry);\n }\n entry.leases++;";
|
|
68
|
-
const A1_MARK = "if (entry && !entry.index) {";
|
|
69
|
-
const A1_TO = " warmWordIndexes.set(key4, entry);\n }\n if (entry && !entry.index) {\n const late = deserializeWordIndex(loadProjectSnapshot(key4)?.wordIndex) ?? void 0;\n if (late?.forward) entry.index = late;\n }\n entry.leases++;";
|
|
70
|
-
const A2_FROM = "if (!snapshot.wordIndex && existing.wordIndex && existing.seq === snapshot.seq && (snapshot.incomplete || !existing.incomplete)) {";
|
|
71
|
-
const A2_MARK = "existing.wordIndex && (snapshot.incomplete";
|
|
72
|
-
const A2_TO = "if (!snapshot.wordIndex && existing.wordIndex && (snapshot.incomplete || !existing.incomplete)) {";
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// ── pi-memory exit summary 空行规范化补丁(幂等;与 pi-memory/index.ts 内已打状态一致)
|
|
76
|
-
const ME_MARK = "normalizeExitSummaryMarkdown";
|
|
77
|
-
const ME_FROM_FN = "function formatExitSummaryEntry(";
|
|
78
|
-
const ME_FROM_RET = 'header, "", summary.trim()].join("\\n");';
|
|
79
|
-
const ME_TO_RET = 'header, "", normalizeExitSummaryMarkdown(summary.trim())].join("\\n");';
|
|
80
|
-
const ME_FN = ['/**', ' * 规范 exit summary 的块级空行(MD022 / MD032:标题 / 列表前后须空行);代码围栏内不动。', ' */', 'function normalizeExitSummaryMarkdown(text: string): string {', '\tconst lines = text.split(/\\r?\\n/);', '\tconst out: string[] = [];', '\tconst isFence = (l: string) => /^\\s*(\\x60{3,}|~{3,})/.test(l);', '\tconst isHeading = (l: string) => /^#{1,6}\\s/.test(l);', '\tconst isList = (l: string) => /^\\s*(?:[-*+]|\\d+\\.)\\s/.test(l);', '\tlet inFence = false;', '\tfor (const line of lines) {', '\t\tconst fenceLine = isFence(line);', '\t\tif (fenceLine) inFence = !inFence;', '\t\tconst prev = out.length ? out[out.length - 1] : "";', '\t\tif (!fenceLine && !inFence && prev !== "") {', '\t\t\tconst startsBlock = (isHeading(line) || isList(line)) && !(isList(line) && isList(prev));', '\t\t\tconst afterBlock = isHeading(prev) || (isList(prev) && !isList(line));', '\t\t\tif (startsBlock || afterBlock) out.push("");', '\t\t}', '\t\tout.push(line);', '\t}', '\treturn out.join("\\n");', '}'].join("\n");
|
|
81
|
-
const MEM_SECTION_MARK = 'sections["pi_memory"]';
|
|
82
|
-
const MEM_SECTION_ANCHOR = [
|
|
83
|
-
"\t\treturn {",
|
|
84
|
-
"\t\t\tsystemPrompt: event.systemPrompt + headerLines.join(\"\\n\"),",
|
|
85
|
-
"\t\t};",
|
|
86
|
-
].join("\n");
|
|
87
|
-
const MEM_SECTION_BLOCK = [
|
|
88
|
-
"\t\t// R7:有 sections 的宿主优先写 section —— 返回 systemPrompt 是 force 覆盖,",
|
|
89
|
-
"\t\t// 会丢弃其后(builtin 组)goal / plan / security 的 section 写入",
|
|
90
|
-
"\t\t// (2026-10-17 实证:goal 的 Objective 与 Rules 从 system 消失,只剩 Round 消息)。",
|
|
91
|
-
"\t\tconst sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;",
|
|
92
|
-
"\t\tif (sections) {",
|
|
93
|
-
"\t\t\tsections[\"pi_memory\"] = headerLines.join(\"\\n\").replace(/^\\n+/, \"\");",
|
|
94
|
-
"\t\t\treturn;",
|
|
95
|
-
"\t\t}",
|
|
96
|
-
"\t\treturn {",
|
|
97
|
-
"\t\t\tsystemPrompt: event.systemPrompt + headerLines.join(\"\\n\"),",
|
|
98
|
-
"\t\t};",
|
|
99
|
-
].join("\n");
|
|
100
|
-
const TACT = path.join(HOME, ".pi", "agent", "npm", "node_modules", "@khanhicetea", "pi-tactician", "src", "index.ts");
|
|
101
|
-
const TACT_MARK = 'sections["tactician"]';
|
|
102
|
-
const TACT_ANCHOR = [
|
|
103
|
-
"\t\tif (event.systemPrompt.includes(GUIDANCE_MARKER)) return;",
|
|
104
|
-
"\t\treturn { systemPrompt: `${event.systemPrompt}\\n\\n${TACTICIAN_GUIDANCE}` };",
|
|
105
|
-
].join("\n");
|
|
106
|
-
const TACT_BLOCK = [
|
|
107
|
-
"\t\t// R7b:有 sections 的宿主写 section(不 force)——force 会让其后 builtin 组",
|
|
108
|
-
"\t\t// (goal / plan / security)的 section 写入被整组丢弃(2026-10-17 实证)。",
|
|
109
|
-
"\t\tconst sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;",
|
|
110
|
-
"\t\tif (sections) {",
|
|
111
|
-
"\t\t\tsections[\"tactician\"] = TACTICIAN_GUIDANCE;",
|
|
112
|
-
"\t\t\treturn;",
|
|
113
|
-
"\t\t}",
|
|
114
|
-
"\t\tif (event.systemPrompt.includes(GUIDANCE_MARKER)) return;",
|
|
115
|
-
"\t\treturn { systemPrompt: `${event.systemPrompt}\\n\\n${TACTICIAN_GUIDANCE}` };",
|
|
116
|
-
].join("\n");
|
|
117
|
-
|
|
118
|
-
/** pi-memory 状态:done / none。 */
|
|
119
|
-
function piMemoryState(src: string): "done" | "none" {
|
|
120
|
-
return src.includes(ME_MARK) ? "done" : "none";
|
|
121
|
-
}
|
|
26
|
+
/** 已知无条件 force 型包。触发概率升序:越靠后越应排在 packages 末尾。 */
|
|
27
|
+
const DEFAULT_FORCE_TAIL = ["npm:pi-rtk-optimizer", "npm:pi-cache-optimizer"];
|
|
122
28
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const src = safeRead(PI_MEMORY);
|
|
127
|
-
if (src === null) return { ok: false, detail: "读取失败" };
|
|
128
|
-
if (piMemoryState(src) === "done") return { ok: true, detail: "已是最新(无需动作)" };
|
|
129
|
-
if (!src.includes(ME_FROM_FN) || !src.includes(ME_FROM_RET)) return { ok: false, detail: "锚点未找到(版本变化?)—— 需人工确认" };
|
|
130
|
-
let next = src.replace(ME_FROM_RET, ME_TO_RET);
|
|
131
|
-
next = next.replace(ME_FROM_FN, ME_FN + "\n\n" + ME_FROM_FN);
|
|
132
|
-
if (!next.includes(ME_MARK) || next === src) return { ok: false, detail: "打补丁失败(结果校验不通过)" };
|
|
133
|
-
try {
|
|
134
|
-
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
135
|
-
const bak = `${PI_MEMORY}.bak-exitsummary-${stamp}`;
|
|
136
|
-
if (!existsSync(bak)) copyFileSync(PI_MEMORY, bak);
|
|
137
|
-
writeFileSync(PI_MEMORY, next, "utf8");
|
|
138
|
-
} catch (e) { return { ok: false, detail: `写入失败:${(e as Error).message}` }; }
|
|
139
|
-
return { ok: true, detail: "已自动修补(exit summary 空行规范化)—— 本轮即生效" };
|
|
140
|
-
}
|
|
141
|
-
/** R7:memory 段改 section(防 force 吞掉其后 builtin 组的 section)。 */
|
|
142
|
-
function healMemorySection(): Result {
|
|
143
|
-
if (!existsSync(PI_MEMORY)) return { ok: false, detail: "pi-memory/index.ts 不存在(未安装?)" };
|
|
144
|
-
const src = safeRead(PI_MEMORY);
|
|
145
|
-
if (src === null) return { ok: false, detail: "读取失败" };
|
|
146
|
-
if (src.includes(MEM_SECTION_MARK)) return { ok: true, detail: "已是最新(无需动作)" };
|
|
147
|
-
if (!src.includes(MEM_SECTION_ANCHOR)) return { ok: false, detail: "锚点未找到(版本变化?)—— 需人工确认" };
|
|
148
|
-
const next = src.replace(MEM_SECTION_ANCHOR, MEM_SECTION_BLOCK);
|
|
149
|
-
if (!next.includes(MEM_SECTION_MARK) || next === src) return { ok: false, detail: "打补丁失败(结果校验不通过)" };
|
|
150
|
-
try {
|
|
151
|
-
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
152
|
-
const bak = `${PI_MEMORY}.bak-memsec-${stamp}`;
|
|
153
|
-
if (!existsSync(bak)) copyFileSync(PI_MEMORY, bak);
|
|
154
|
-
writeFileSync(PI_MEMORY, next, "utf8");
|
|
155
|
-
} catch (e) { return { ok: false, detail: `写入失败:${(e as Error).message}` }; }
|
|
156
|
-
return { ok: true, detail: "已自动修补(memory 段改 section)—— 重启后生效" };
|
|
157
|
-
}
|
|
158
|
-
/** R7b:tactician 改 section(防 force 吞掉其后 builtin 组的 section)。 */
|
|
159
|
-
function healTactician(): Result {
|
|
160
|
-
if (!existsSync(TACT)) return { ok: false, detail: "pi-tactician/src/index.ts 不存在(未安装?)" };
|
|
161
|
-
const src = safeRead(TACT);
|
|
162
|
-
if (src === null) return { ok: false, detail: "读取失败" };
|
|
163
|
-
if (src.includes(TACT_MARK)) return { ok: true, detail: "已是最新(无需动作)" };
|
|
164
|
-
if (!src.includes(TACT_ANCHOR)) return { ok: false, detail: "锚点未找到(版本变化?)—— 需人工确认" };
|
|
165
|
-
const next = src.replace(TACT_ANCHOR, TACT_BLOCK);
|
|
166
|
-
if (!next.includes(TACT_MARK) || next === src) return { ok: false, detail: "打补丁失败(结果校验不通过)" };
|
|
167
|
-
try {
|
|
168
|
-
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
169
|
-
const bak = `${TACT}.bak-tactician-${stamp}`;
|
|
170
|
-
if (!existsSync(bak)) copyFileSync(TACT, bak);
|
|
171
|
-
writeFileSync(TACT, next, "utf8");
|
|
172
|
-
} catch (e) { return { ok: false, detail: `写入失败:${(e as Error).message}` }; }
|
|
173
|
-
return { ok: true, detail: "已自动修补(tactician 改 section)—— 重启后生效" };
|
|
174
|
-
}
|
|
175
|
-
// ── PiDeck asar 补丁:只读检测(needle 出现次数)
|
|
176
|
-
const ASAR_CHECKS: Array<{ name: string; needle: string; expected: number }> = [
|
|
177
|
-
{ name: "history-race", needle: "runtime.tab.runtimeGeneration !== runtimeGeneration", expected: 8 },
|
|
178
|
-
{ name: "katex", needle: "singleDollarTextMath: !0", expected: 0 },
|
|
179
|
-
{ name: "noise", needle: "appLogger.warn(\"app\", \"Main window renderer console error\"", expected: 0 },
|
|
180
|
-
];
|
|
29
|
+
/** 静态扫描:入口源码里出现这两个形态即判为 force。 */
|
|
30
|
+
const FORCE_RE = /return\s*\{\s*systemPrompt|forceSystemPrompt/;
|
|
31
|
+
const SECTION_RE = /sections\s*(\[|\.)/;
|
|
181
32
|
|
|
182
|
-
type Result = { ok: boolean; detail: string };
|
|
33
|
+
export type Result = { ok: boolean; detail: string };
|
|
183
34
|
|
|
184
|
-
/** 读文件;失败返回 null(永不抛)。 */
|
|
185
35
|
function safeRead(p: string): string | null {
|
|
186
|
-
try { return readFileSync(p, "utf8"); } catch { return null; }
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** 统计 needle 在原串里的出现次数。 */
|
|
190
|
-
function count(hay: string, needle: string): number {
|
|
191
|
-
let n = 0, i = 0;
|
|
192
|
-
while ((i = hay.indexOf(needle, i)) >= 0) { n += 1; i += needle.length; }
|
|
193
|
-
return n;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** pi-lens 状态:both / partial / none / missing。 */
|
|
197
|
-
function piLensState(src: string): { state: "both" | "partial" | "none"; a1: boolean; a2: boolean } {
|
|
198
|
-
const a1 = src.includes(A1_MARK);
|
|
199
|
-
const a2 = !src.includes(A2_FROM) && src.includes(A2_MARK);
|
|
200
|
-
let state: "both" | "partial" | "none" = "none";
|
|
201
|
-
if (a1 && a2) state = "both";
|
|
202
|
-
else if (a1 || a2) state = "partial";
|
|
203
|
-
return { state, a1, a2 };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** 只在未打或半打时修补 pi-lens(幂等)。 */
|
|
207
|
-
function healPiLens(): Result {
|
|
208
|
-
if (!existsSync(PI_LENS)) return { ok: false, detail: "pi-lens dist 不存在(未安装?)" };
|
|
209
|
-
const src = safeRead(PI_LENS);
|
|
210
|
-
if (src === null) return { ok: false, detail: "读取失败" };
|
|
211
|
-
const { state, a1, a2 } = piLensState(src);
|
|
212
|
-
if (state === "both") return { ok: true, detail: "已是最新(无需动作)" };
|
|
213
|
-
let next = src;
|
|
214
|
-
// 半打状态先还原,再整体重打(避免只打一半造成新的不一致)
|
|
215
|
-
if (a2 && !a1) next = next.replace(A2_TO, A2_FROM); // 半打:先把 B 还原,再由下面两步整体重打
|
|
216
|
-
if (!next.includes(A1_MARK)) next = next.replace(A1_FROM, A1_TO);
|
|
217
|
-
if (next.includes(A2_FROM)) next = next.replace(A2_FROM, A2_TO);
|
|
218
|
-
const after = piLensState(next);
|
|
219
|
-
if (after.state !== "both") return { ok: false, detail: `打补丁失败(结果 ${after.state},锚点可能已随版本变化)` };
|
|
220
36
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
writeFileSync(PI_LENS, next, "utf8");
|
|
225
|
-
} catch (e) { return { ok: false, detail: `写入失败:${(e as Error).message}` }; }
|
|
226
|
-
return { ok: true, detail: `已自动修补(原状态 ${state})—— 本轮即生效` };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
/** 记忆文件格式规范化(压连续空行 + 单尾换行;围栏内不动)。 */
|
|
231
|
-
function normMd(txt: string): string {
|
|
232
|
-
const lines = txt.split(/\r?\n/);
|
|
233
|
-
const out: string[] = [];
|
|
234
|
-
let inFence = false;
|
|
235
|
-
for (const line of lines) {
|
|
236
|
-
const isF = /^\s*(?:`{3,}|~{3,})/.test(line);
|
|
237
|
-
if (isF) inFence = !inFence;
|
|
238
|
-
if (!inFence && line.trim() === "" && out.length > 0 && out[out.length - 1].trim() === "") continue;
|
|
239
|
-
out.push(line);
|
|
240
|
-
}
|
|
241
|
-
while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
|
|
242
|
-
return out.join("\n") + "\n";
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** 第 3 处自愈:memory_write 每次写入都会引入 MD012/MD047(同日复发 3 次后机制化)——启动即规范。 */
|
|
246
|
-
function healMemoryFormat(): Result {
|
|
247
|
-
try {
|
|
248
|
-
const dir = path.join(HOME, ".pi", "agent", "memory");
|
|
249
|
-
if (!existsSync(dir)) return { ok: true, detail: "memory 目录不存在(跳过)" };
|
|
250
|
-
let fixed = 0;
|
|
251
|
-
const walk = (d: string): void => {
|
|
252
|
-
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
253
|
-
const p = path.join(d, e.name);
|
|
254
|
-
if (e.isDirectory()) { if (e.name !== ".git") walk(p); continue; }
|
|
255
|
-
if (!e.name.endsWith(".md")) continue;
|
|
256
|
-
const txt = safeRead(p);
|
|
257
|
-
if (txt === null) continue;
|
|
258
|
-
const norm = normMd(txt);
|
|
259
|
-
if (norm !== txt) { try { writeFileSync(p, norm, "utf8"); fixed += 1; } catch { /* 静默 */ } }
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
walk(dir);
|
|
263
|
-
return { ok: true, detail: fixed > 0 ? `已规范化 ${fixed} 个记忆文件(空行 / 尾换行)` : "记忆格式无需动作" };
|
|
264
|
-
} catch (e) { return { ok: false, detail: (e as Error).message }; }
|
|
265
|
-
}
|
|
266
|
-
/** PiDeck asar:只读检测,返回未打补丁的项名。 */
|
|
267
|
-
let asarOk = false;
|
|
268
|
-
/** 未打补丁时的处置提示(纯函数,便于单测)。 */
|
|
269
|
-
function patchTip(lines: string[]): string {
|
|
270
|
-
if (!lines.some((l) => l.includes("未打") || l.includes("半打"))) return "";
|
|
271
|
-
const dir = existsSync(SNAPSHOT_PATCH_DIR) ? SNAPSHOT_PATCH_DIR : "pi-env-snapshot/05-脚本补丁/(也未找到)";
|
|
272
|
-
return ` → PiDeck 补丁需退出 PiDeck 后重跑补丁脚本(快照副本:${dir};或用其中的 launch-pideck.cmd 启动 PiDeck 时自动重打)`;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
function checkAsar(): { ran: boolean; missing: string[]; detail: string } {
|
|
277
|
-
if (asarOk) return { ran: true, missing: [], detail: "三个补丁都在" };
|
|
278
|
-
if (!existsSync(ASAR)) return { ran: false, missing: [], detail: "app.asar 不存在" };
|
|
279
|
-
let buf: Buffer;
|
|
280
|
-
try { buf = readFileSync(ASAR); } catch (e) { return { ran: false, missing: [], detail: `读取失败:${(e as NodeJS.ErrnoException).code ?? (e as Error).message}` }; }
|
|
281
|
-
const txt = buf.toString("utf8");
|
|
282
|
-
const missing: string[] = [];
|
|
283
|
-
for (const c of ASAR_CHECKS) {
|
|
284
|
-
const n = count(txt, c.needle);
|
|
285
|
-
if (n !== c.expected) missing.push(`${c.name}(=${n}, 期望 ${c.expected})`);
|
|
37
|
+
return readFileSync(p, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
286
40
|
}
|
|
287
|
-
if (!missing.length) asarOk = true; // 只在通过时缓存:缺失时每次都实测,便于打完补丁后立刻复检
|
|
288
|
-
return { ran: true, missing, detail: missing.length ? `未打补丁:${missing.join(" ")}` : "三个补丁都在" };
|
|
289
41
|
}
|
|
290
42
|
|
|
291
|
-
|
|
292
|
-
// ── 环境指纹:settings 关键字段漂移 → 提示「手册 / 验证结论可能过期」
|
|
293
|
-
function fingerprint(): Record<string, string[]> | null {
|
|
43
|
+
function readSettings(): Record<string, unknown> | null {
|
|
294
44
|
const txt = safeRead(SETTINGS);
|
|
295
45
|
if (txt === null) return null;
|
|
296
46
|
try {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
defaultTools: (s.defaultTools as string[]) ?? [],
|
|
301
|
-
enabledModels: (s.enabledModels as string[]) ?? [],
|
|
302
|
-
extensions: (s.extensions as string[]) ?? [],
|
|
303
|
-
codemode: [(s.codemode as { mode?: string })?.mode ?? "?"],
|
|
304
|
-
};
|
|
305
|
-
} catch { return null; }
|
|
306
|
-
}
|
|
307
|
-
/** 与上次指纹对比;变化摘要数组(无变化为空)。对比后写回新指纹。 */
|
|
308
|
-
function fingerprintDrift(): string[] {
|
|
309
|
-
const cur = fingerprint();
|
|
310
|
-
if (cur === null) return [];
|
|
311
|
-
let prev: Record<string, string[]> | null = null;
|
|
312
|
-
const prevRaw = safeRead(FINGERPRINT);
|
|
313
|
-
if (prevRaw) { try { prev = JSON.parse(prevRaw) as Record<string, string[]>; } catch { prev = null; } }
|
|
314
|
-
try { writeFileSync(FINGERPRINT, JSON.stringify(cur, null, 1), "utf8"); } catch { /* 静默 */ }
|
|
315
|
-
if (prev === null) return [];
|
|
316
|
-
const out: string[] = [];
|
|
317
|
-
for (const k of ["packages", "defaultTools", "enabledModels", "extensions", "codemode"] as const) {
|
|
318
|
-
const a = prev[k] ?? [];
|
|
319
|
-
const b = cur[k];
|
|
320
|
-
const added = b.filter((x) => !a.includes(x)).length;
|
|
321
|
-
const removed = a.filter((x) => !b.includes(x)).length;
|
|
322
|
-
if (added || removed) out.push(`${k} ${a.length}→${b.length}${added ? ` +${added}` : ""}${removed ? ` -${removed}` : ""}`);
|
|
47
|
+
return JSON.parse(txt) as Record<string, unknown>;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
323
50
|
}
|
|
324
|
-
return out;
|
|
325
51
|
}
|
|
326
52
|
|
|
327
|
-
/**
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const out: string[] = [];
|
|
339
|
-
for (const bp of BUILTIN_PATCHES) {
|
|
340
|
-
for (const target of [bp.install, bp.file]) {
|
|
341
|
-
if (!existsSync(target)) continue; // 未装 PiDeck / 路径不同:跳过,不算缺失
|
|
342
|
-
const cur = safeRead(target);
|
|
343
|
-
if (cur !== null && cur.includes(bp.needle)) continue;
|
|
344
|
-
const snap = path.join(SNAPSHOT_BUILTIN, path.basename(target));
|
|
345
|
-
if (!existsSync(snap)) { out.push(`${bp.name}: 缺补丁且快照无副本`); continue; }
|
|
346
|
-
try {
|
|
347
|
-
if (cur !== null) copyFileSync(target, `${target}.bak-restore-${Date.now()}`);
|
|
348
|
-
copyFileSync(snap, target);
|
|
349
|
-
out.push(`${bp.name}(${target === bp.install ? "安装目录" : "用户层"}): 已从快照恢复`);
|
|
350
|
-
} catch (e) { out.push(`${bp.name}: 恢复失败(${(e as Error).message})`); }
|
|
351
|
-
}
|
|
53
|
+
/** 用户可在 ~/.pi/patch-keeper.json 用 forceTail 覆盖已知 force 型列表。 */
|
|
54
|
+
function forceTail(): string[] {
|
|
55
|
+
const txt = safeRead(STATE);
|
|
56
|
+
if (!txt) return DEFAULT_FORCE_TAIL;
|
|
57
|
+
try {
|
|
58
|
+
const cfg = JSON.parse(txt) as { forceTail?: unknown };
|
|
59
|
+
return Array.isArray(cfg.forceTail) && cfg.forceTail.every((x) => typeof x === "string")
|
|
60
|
+
? (cfg.forceTail as string[])
|
|
61
|
+
: DEFAULT_FORCE_TAIL;
|
|
62
|
+
} catch {
|
|
63
|
+
return DEFAULT_FORCE_TAIL;
|
|
352
64
|
}
|
|
353
|
-
return out.length ? out.join(";") : null;
|
|
354
65
|
}
|
|
355
66
|
|
|
356
|
-
/**
|
|
357
|
-
function
|
|
358
|
-
const disk = safeRead(MANUAL);
|
|
359
|
-
if (disk === null) return null;
|
|
360
|
-
const ver = manualVer(disk) ?? "";
|
|
361
|
-
const sig = createHash("sha256").update(disk).digest("hex").slice(0, 12);
|
|
362
|
-
let prev: { ver?: string; sig?: string } | null = null;
|
|
363
|
-
const raw = safeRead(MANUAL_STATE);
|
|
364
|
-
if (raw) { try { prev = JSON.parse(raw); } catch { prev = null; } }
|
|
365
|
-
try { writeFileSync(MANUAL_STATE, JSON.stringify({ ver, sig })); } catch { /* ignore */ }
|
|
366
|
-
if (prev && prev.ver === ver && prev.sig !== sig) return `手册内容已变但版本号未升(${ver})—— bump-manual 或撤销未登记改动`;
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
// 已知盲区:caveman-think-zh 是 user 扩展(不在 packages 数组),它在检测到原版英文段时
|
|
370
|
-
// 也会 force;FORCE_TAIL 覆盖不到,触发时本扩展看不到。
|
|
371
|
-
/** force/条件 force 型必须排在 packages 末尾(其后 section 写入会被静默丢弃)。触发概率升序:rtk → cache-optimizer。 */
|
|
372
|
-
const FORCE_TAIL = ["npm:pi-rtk-optimizer", "npm:pi-cache-optimizer"];
|
|
373
|
-
export function forceOrderIssues(packages: string[]): string[] {
|
|
67
|
+
/** force 型必须整体位于末尾,且相对顺序按 tail 数组。 */
|
|
68
|
+
export function forceOrderIssues(packages: string[], tail: string[] = DEFAULT_FORCE_TAIL): string[] {
|
|
374
69
|
const out: string[] = [];
|
|
375
70
|
const idx = (n: string) => packages.indexOf(n);
|
|
376
|
-
const present =
|
|
71
|
+
const present = tail.filter((n) => idx(n) >= 0);
|
|
377
72
|
if (!present.length) return out;
|
|
378
73
|
const minForce = Math.min(...present.map(idx));
|
|
379
|
-
const later = packages.slice(minForce + 1).filter((n) => !
|
|
380
|
-
if (later.length)
|
|
381
|
-
|
|
74
|
+
const later = packages.slice(minForce + 1).filter((n) => !tail.includes(n));
|
|
75
|
+
if (later.length) {
|
|
76
|
+
out.push(
|
|
77
|
+
`force 型之后仍有 ${later.length} 个包(${later.slice(0, 2).join(" / ")})—— 它们的 section 写入会被静默丢弃;把 ${tail.join(" / ")} 移到 packages 末尾`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const ordered = present.every((n, i) => i === 0 || idx(present[i - 1]) < idx(n));
|
|
81
|
+
if (!ordered) out.push(`force 型相对顺序应与 ${tail.join(" → ")} 一致(最可能触发者最后)`);
|
|
382
82
|
return out;
|
|
383
83
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
84
|
+
|
|
85
|
+
/** 把 force 型移到末尾(其余相对顺序不变);已正确返回 null。 */
|
|
86
|
+
export function normalizeForceOrder(packages: string[], tail: string[] = DEFAULT_FORCE_TAIL): string[] | null {
|
|
87
|
+
const present = tail.filter((n) => packages.includes(n));
|
|
387
88
|
if (!present.length) return null;
|
|
388
|
-
const rest = packages.filter((n) => !
|
|
89
|
+
const rest = packages.filter((n) => !tail.includes(n));
|
|
389
90
|
const next = [...rest, ...present];
|
|
390
91
|
return next.join("\n") === packages.join("\n") ? null : next;
|
|
391
92
|
}
|
|
392
93
|
|
|
393
|
-
/**
|
|
394
|
-
function
|
|
395
|
-
const txt = safeRead(SETTINGS);
|
|
396
|
-
if (txt === null) return null;
|
|
397
|
-
let s: Record<string, unknown>;
|
|
398
|
-
try { s = JSON.parse(txt) as Record<string, unknown>; } catch { return "force 顺序自动修复失败:settings.json 解析失败"; }
|
|
399
|
-
const packages = (s.packages as string[] | undefined) ?? [];
|
|
400
|
-
const next = normalizeForceOrder(packages);
|
|
401
|
-
if (!next) return null;
|
|
402
|
-
try {
|
|
403
|
-
copyFileSync(SETTINGS, `${SETTINGS}.bak-force-order-${Date.now()}`);
|
|
404
|
-
s.packages = next;
|
|
405
|
-
writeFileSync(SETTINGS, JSON.stringify(s, null, 2), "utf8");
|
|
406
|
-
} catch (e) { return `force 顺序自动修复失败:${(e as Error).message}`; }
|
|
407
|
-
return `force 顺序已自动修复:${FORCE_TAIL.filter((n) => next.includes(n)).join(" / ")} 移到 packages 末尾`;
|
|
408
|
-
}
|
|
409
|
-
/** 静态扫描:packages 中疑似 force 型扩展(入口源码含 return { systemPrompt } / forceSystemPrompt)。 */
|
|
410
|
-
const FORCE_RE = /return\s*\{\s*systemPrompt|forceSystemPrompt/;
|
|
411
|
-
const SECTION_RE = /sections\s*(\[|\.)/;
|
|
412
|
-
|
|
413
|
-
/** 分类:先写 section、只有旧宿主才 force 的算兼容兜底,不算疑似无条件 force。 */
|
|
414
|
-
function classifyPackages(packages: string[]): { force: string[]; fallback: string[] } {
|
|
94
|
+
/** 读扩展入口源码并分类:无条件 force / section 优先(旧宿主兜底)/ 无。 */
|
|
95
|
+
function classifyPackages(packages: string[], tail: string[]): { force: string[]; fallback: string[] } {
|
|
415
96
|
const force: string[] = [];
|
|
416
97
|
const fallback: string[] = [];
|
|
417
98
|
for (const name of packages) {
|
|
418
|
-
if (!name.startsWith("npm:") ||
|
|
419
|
-
const dir = path.join(
|
|
99
|
+
if (!name.startsWith("npm:") || tail.includes(name)) continue;
|
|
100
|
+
const dir = path.join(NPM_ROOT, name.slice(4));
|
|
420
101
|
if (!existsSync(dir)) continue;
|
|
421
102
|
let entries: string[] = [];
|
|
422
103
|
try {
|
|
423
104
|
const pj = JSON.parse(safeRead(path.join(dir, "package.json")) ?? "{}") as { pi?: { extensions?: unknown } };
|
|
424
105
|
const ex = pj.pi?.extensions;
|
|
425
106
|
entries = Array.isArray(ex) ? ex.filter((x): x is string => typeof x === "string") : [];
|
|
426
|
-
} catch {
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
427
110
|
for (const rel of entries.slice(0, 3)) {
|
|
428
111
|
const src = safeRead(path.join(dir, rel));
|
|
429
112
|
if (src === null) continue;
|
|
@@ -433,179 +116,137 @@ function classifyPackages(packages: string[]): { force: string[]; fallback: stri
|
|
|
433
116
|
const fi = body.search(FORCE_RE);
|
|
434
117
|
if (fi < 0) continue;
|
|
435
118
|
const si = body.search(SECTION_RE);
|
|
436
|
-
if (si >= 0 && si < fi) fallback.push(name);
|
|
119
|
+
if (si >= 0 && si < fi) fallback.push(name);
|
|
120
|
+
else force.push(name);
|
|
437
121
|
break;
|
|
438
122
|
}
|
|
439
123
|
}
|
|
440
124
|
return { force, fallback };
|
|
441
125
|
}
|
|
442
126
|
|
|
443
|
-
/** 疑似无条件 force
|
|
444
|
-
export function scanForcePackages(packages: string[]): string[] {
|
|
445
|
-
return classifyPackages(packages).force;
|
|
127
|
+
/** 疑似无条件 force(会吞掉其后 section)。 */
|
|
128
|
+
export function scanForcePackages(packages: string[], tail: string[] = DEFAULT_FORCE_TAIL): string[] {
|
|
129
|
+
return classifyPackages(packages, tail).force;
|
|
446
130
|
}
|
|
447
131
|
|
|
448
|
-
/**
|
|
449
|
-
export function scanForceFallbacks(packages: string[]): string[] {
|
|
450
|
-
return classifyPackages(packages).fallback;
|
|
132
|
+
/** 兼容兜底型(section 优先,仅旧宿主 force)。 */
|
|
133
|
+
export function scanForceFallbacks(packages: string[], tail: string[] = DEFAULT_FORCE_TAIL): string[] {
|
|
134
|
+
return classifyPackages(packages, tail).fallback;
|
|
451
135
|
}
|
|
452
136
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
137
|
+
/** codemode 单点检查:禁用 builtin:codemode 时必须有替代实现,反之亦然。 */
|
|
138
|
+
export function consistencyCheck(extensions: string[], packages: string[]): string[] {
|
|
139
|
+
const disabled = extensions.includes("-builtin:codemode");
|
|
140
|
+
const guard = packages.includes("npm:pi-codemode-guard");
|
|
141
|
+
if (disabled && !guard) return ["codemode 单点断裂:已禁 builtin:codemode 但 packages 无替代实现 —— codemode 工具将不可用"];
|
|
142
|
+
if (!disabled && guard) return ["codemode 单点异常:替代实现已在,但 builtin:codemode 未禁用 —— 双实现风险"];
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** settings 关键字段指纹(顺序敏感)。 */
|
|
147
|
+
function fingerprint(s: Record<string, unknown>): Record<string, string[]> {
|
|
148
|
+
return {
|
|
149
|
+
packages: (s.packages as string[]) ?? [],
|
|
150
|
+
defaultTools: (s.defaultTools as string[]) ?? [],
|
|
151
|
+
enabledModels: (s.enabledModels as string[]) ?? [],
|
|
152
|
+
extensions: (s.extensions as string[]) ?? [],
|
|
153
|
+
codemode: [(s.codemode as { mode?: string })?.mode ?? "?"],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 与上次指纹对比并写回;返回变化摘要(无变化为空)。 */
|
|
158
|
+
function fingerprintDrift(cur: Record<string, string[]>): string[] {
|
|
159
|
+
let prev: Record<string, string[]> | null = null;
|
|
160
|
+
const raw = safeRead(STATE);
|
|
161
|
+
if (raw) {
|
|
162
|
+
try {
|
|
163
|
+
const j = JSON.parse(raw) as { fingerprint?: Record<string, string[]> };
|
|
164
|
+
prev = j.fingerprint ?? null;
|
|
165
|
+
} catch {
|
|
166
|
+
prev = null;
|
|
167
|
+
}
|
|
463
168
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const asar = checkAsar();
|
|
470
|
-
lines.push(`PiDeck : ${asar.detail}`);
|
|
471
|
-
for (const bp of BUILTIN_PATCHES) {
|
|
472
|
-
const st = (p: string): string => { const src = safeRead(p); return src === null ? "无" : src.includes(bp.needle) ? "已打" : "未打"; };
|
|
473
|
-
lines.push(`builtin : ${bp.name} 生效版=${st(bp.install)} 用户层=${st(bp.file)}`);
|
|
169
|
+
try {
|
|
170
|
+
const cfg = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
171
|
+
writeFileSync(STATE, JSON.stringify({ ...cfg, fingerprint: cur }, null, 1), "utf8");
|
|
172
|
+
} catch {
|
|
173
|
+
/* 静默 */
|
|
474
174
|
}
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
lines.push(`forceExt: ${fp ? (fe.length ? "疑似 " + fe.join(" / ") : "无(静态扫描)") : "读不到 settings"}`);
|
|
484
|
-
const fb = fp ? scanForceFallbacks(fp.packages) : [];
|
|
485
|
-
lines.push(`force兜底: ${fp ? (fb.length ? fb.join(" / ") : "无") : "读不到 settings"}`);
|
|
486
|
-
return lines;
|
|
175
|
+
if (prev === null) return [];
|
|
176
|
+
const out: string[] = [];
|
|
177
|
+
for (const k of ["packages", "defaultTools", "enabledModels", "extensions", "codemode"] as const) {
|
|
178
|
+
const a = prev[k] ?? [];
|
|
179
|
+
const b = cur[k];
|
|
180
|
+
if (a.join("\n") !== b.join("\n")) out.push(k);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
487
183
|
}
|
|
488
184
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const r = healPiMemory();
|
|
498
|
-
if (!r.ok) healNote = (healNote ? healNote + "\n" : "") + `patch-keeper: pi-memory 自愈失败 —— ${r.detail}`;
|
|
499
|
-
} catch (e) { healNote = (healNote ? healNote + "\n" : "") + `patch-keeper: pi-memory 自愈异常 —— ${(e as Error).message}`; }
|
|
500
|
-
try {
|
|
501
|
-
const r = healMemorySection();
|
|
502
|
-
if (!r.ok) healNote = (healNote ? healNote + "\n" : "") + `patch-keeper: pi-memory section 自愈失败 —— ${r.detail}`;
|
|
503
|
-
} catch (e) { healNote = (healNote ? healNote + "\n" : "") + `patch-keeper: pi-memory section 自愈异常 —— ${(e as Error).message}`; }
|
|
504
|
-
try {
|
|
505
|
-
const rt = healTactician();
|
|
506
|
-
if (!rt.ok) healNote = (healNote ? healNote + "\n" : "") + `patch-keeper: tactician section 自愈失败 —— ${rt.detail}`;
|
|
507
|
-
} catch (e) { healNote = (healNote ? healNote + "\n" : "") + `patch-keeper: tactician section 自愈异常 —— ${(e as Error).message}`; }
|
|
185
|
+
/** 修 settings 的 force 顺序(写前备份);无变化返回 null。 */
|
|
186
|
+
function fixForceOrder(): string | null {
|
|
187
|
+
const s = readSettings();
|
|
188
|
+
if (!s) return null;
|
|
189
|
+
const tail = forceTail();
|
|
190
|
+
const packages = (s.packages as string[] | undefined) ?? [];
|
|
191
|
+
const next = normalizeForceOrder(packages, tail);
|
|
192
|
+
if (!next) return null;
|
|
508
193
|
try {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
194
|
+
copyFileSync(SETTINGS, `${SETTINGS}.bak-force-order-${Date.now()}`);
|
|
195
|
+
s.packages = next;
|
|
196
|
+
writeFileSync(SETTINGS, JSON.stringify(s, null, 2), "utf8");
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return `force 顺序自动修复失败:${(e as Error).message}`;
|
|
199
|
+
}
|
|
200
|
+
return `force 顺序已自动修复:${tail.filter((n) => next.includes(n)).join(" / ")} 移到 packages 末尾`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 状态报告(命令与启动提示共用)。 */
|
|
204
|
+
function report(): string[] {
|
|
205
|
+
const s = readSettings();
|
|
206
|
+
if (!s) return ["读不到 settings.json"];
|
|
207
|
+
const tail = forceTail();
|
|
208
|
+
const packages = (s.packages as string[]) ?? [];
|
|
209
|
+
const lines: string[] = [];
|
|
210
|
+
const fo = forceOrderIssues(packages, tail);
|
|
211
|
+
lines.push(`force顺序 : ${fo.length ? fo[0] : "OK"}`);
|
|
212
|
+
const fe = scanForcePackages(packages, tail);
|
|
213
|
+
lines.push(`疑似force: ${fe.length ? fe.join(" / ") : "无(静态扫描)"}`);
|
|
214
|
+
const fb = scanForceFallbacks(packages, tail);
|
|
215
|
+
lines.push(`兼容兜底 : ${fb.length ? fb.join(" / ") : "无"}`);
|
|
216
|
+
const cm = consistencyCheck((s.extensions as string[]) ?? [], packages);
|
|
217
|
+
lines.push(`codemode : ${cm.length ? cm[0] : "单点一致"}`);
|
|
218
|
+
return lines;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default function patchKeeper(pi: ExtensionAPI): void {
|
|
222
|
+
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
223
|
+
try {
|
|
224
|
+
const s = readSettings();
|
|
225
|
+
if (!s) return;
|
|
226
|
+
const msgs: string[] = [];
|
|
227
|
+
const fixed = fixForceOrder();
|
|
228
|
+
if (fixed) msgs.push(fixed);
|
|
229
|
+
else for (const x of forceOrderIssues((s.packages as string[]) ?? [], forceTail())) msgs.push(x);
|
|
230
|
+
const drift = fingerprintDrift(fingerprint(s));
|
|
231
|
+
if (drift.length) msgs.push(`环境指纹已变(${drift.join(" / ")})—— 此前关于工具/模型的结论可能过期`);
|
|
232
|
+
const cm = consistencyCheck((s.extensions as string[]) ?? [], (s.packages as string[]) ?? []);
|
|
233
|
+
for (const x of cm) msgs.push(x);
|
|
234
|
+
for (const m of msgs) ctx.ui.notify("patch-keeper: " + m, "warning");
|
|
235
|
+
} catch {
|
|
236
|
+
/* 静默:检测不该影响会话 */
|
|
237
|
+
}
|
|
238
|
+
});
|
|
512
239
|
|
|
513
240
|
pi.registerCommand("patch-keeper", {
|
|
514
|
-
description: "查看 /
|
|
241
|
+
description: "查看 / 修复扩展加载顺序(force 型必须在 packages 末尾)",
|
|
515
242
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
516
243
|
const arg = (args || "").trim().toLowerCase();
|
|
517
244
|
if (arg === "fix") {
|
|
518
|
-
const r =
|
|
519
|
-
ctx.ui.notify(`
|
|
520
|
-
|
|
521
|
-
const r2 = healPiMemory();
|
|
522
|
-
ctx.ui.notify(`pi-memory: ${r2.detail}`, r2.ok ? "info" : "error");
|
|
523
|
-
const r3 = healMemorySection();
|
|
524
|
-
ctx.ui.notify(`pi-memory section: ${r3.detail}`, r3.ok ? "info" : "error");
|
|
525
|
-
const r4 = healTactician();
|
|
526
|
-
ctx.ui.notify(`tactician section: ${r4.detail}`, r4.ok ? "info" : "error");
|
|
527
|
-
const rForce = fixForceOrder();
|
|
528
|
-
if (rForce) ctx.ui.notify(`patch-keeper: ${rForce}`, "info");
|
|
529
|
-
const rb = restoreBuiltinPatches();
|
|
530
|
-
if (rb) ctx.ui.notify(`patch-keeper: ${rb}`, "warning");
|
|
245
|
+
const r = fixForceOrder();
|
|
246
|
+
ctx.ui.notify(r ? `patch-keeper: ${r}` : "patch-keeper: force 顺序已正确,无需修复", r ? "info" : "info");
|
|
531
247
|
return;
|
|
532
248
|
}
|
|
533
|
-
|
|
534
|
-
const tip = patchTip(lines);
|
|
535
|
-
ctx.ui.notify(`补丁状态\n${lines.join("\n")}${tip}`, "info");
|
|
249
|
+
ctx.ui.notify(`扩展加载顺序\n${report().join("\n")}`, "info");
|
|
536
250
|
},
|
|
537
251
|
});
|
|
538
|
-
|
|
539
|
-
// 启动提示:只在「PiDeck 补丁缺失」时出声(pi-lens 已自动修好,不打扰)
|
|
540
|
-
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
541
|
-
try {
|
|
542
|
-
const asar = checkAsar();
|
|
543
|
-
const msgs: string[] = [];
|
|
544
|
-
const drift = fingerprintDrift();
|
|
545
|
-
const forceFix = fixForceOrder();
|
|
546
|
-
const fp = fingerprint();
|
|
547
|
-
const cm = fp ? consistencyCheck(fp.extensions, fp.packages) : [];
|
|
548
|
-
for (const x of cm) msgs.push("patch-keeper: " + x);
|
|
549
|
-
if (forceFix) msgs.push("patch-keeper: " + forceFix);
|
|
550
|
-
else if (fp) for (const x of forceOrderIssues(fp.packages)) msgs.push("patch-keeper: " + x);
|
|
551
|
-
if (fp) {
|
|
552
|
-
const fe = scanForcePackages(fp.packages);
|
|
553
|
-
const mid = fe.filter((n) => fp.packages.indexOf(n) < fp.packages.length - 1);
|
|
554
|
-
if (mid.length) msgs.push("patch-keeper: 疑似 force 型扩展不在末尾(其后的 section 可能被静默丢弃):" + mid.join(" / ") + " —— 核源码后移到 packages 末尾");
|
|
555
|
-
}
|
|
556
|
-
if (drift.length) msgs.push(`patch-keeper: 环境指纹已变(${drift.join(";")})—— 手册 / 上轮验证结论可能过期,按 08-台账核对纪律逐项重验`);
|
|
557
|
-
if (asar.ran && asar.missing.length) {
|
|
558
|
-
msgs.push(`patch-keeper: PiDeck 补丁缺失 —— ${asar.detail}`);
|
|
559
|
-
msgs.push(`关闭 PiDeck 后重跑补丁脚本(快照 ${SNAPSHOT_PATCH_DIR};或用其中的 launch-pideck.cmd 启动 PiDeck 时自动重打)`);
|
|
560
|
-
}
|
|
561
|
-
for (const bp of BUILTIN_PATCHES) {
|
|
562
|
-
const missing = [bp.install, bp.file].filter((p: string) => { const src = safeRead(p); return src !== null && !src.includes(bp.needle); });
|
|
563
|
-
if (missing.length) {
|
|
564
|
-
const where = missing.length === 2 ? "两处" : missing[0] === bp.install ? "安装目录(生效版)" : "用户层";
|
|
565
|
-
msgs.push(`patch-keeper: PiDeck 内置扩展补丁缺失(${bp.name},${where})—— 升级覆盖?从快照 03-PiDeck/builtin-extensions 恢复`);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
{
|
|
569
|
-
const memSrc2 = safeRead(PI_MEMORY);
|
|
570
|
-
if (memSrc2 !== null && !memSrc2.includes(MEM_SECTION_MARK)) msgs.push("patch-keeper: pi-memory 未打 section 优先补丁 —— goal / plan / security 的 section 会被 force 吞掉,建议 /patch-keeper fix");
|
|
571
|
-
}
|
|
572
|
-
{
|
|
573
|
-
const tactSrc2 = safeRead(TACT);
|
|
574
|
-
if (tactSrc2 !== null && !tactSrc2.includes(TACT_MARK)) msgs.push("patch-keeper: tactician 未打 section 优先补丁 —— 会 force 吞掉其后的 section(goal / plan / security),建议 /patch-keeper fix");
|
|
575
|
-
}
|
|
576
|
-
const md = manualDrift();
|
|
577
|
-
if (md) msgs.push(`patch-keeper: ${md}`);
|
|
578
|
-
if (healNote) msgs.push(healNote);
|
|
579
|
-
for (const m of msgs) ctx.ui.notify(m, "warning");
|
|
580
|
-
} catch { /* 静默:检测不该影响会话 */ }
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
// 注入对齐(手册 §2 纪律区):注入的是宿主上次读取的文本,可能与磁盘版不同步。每轮比对一次,不一致时提示一次。
|
|
584
|
-
// 纪律:**绝不返回 systemPrompt** —— 返回即 forceSystemPrompt 覆盖式替换,会静默丢弃其后所有 section 的修改。
|
|
585
|
-
let lastPair = "";
|
|
586
|
-
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
|
|
587
|
-
try {
|
|
588
|
-
const injected = String((event as { systemPrompt?: string } | undefined)?.systemPrompt ?? "");
|
|
589
|
-
const vi = manualVer(injected);
|
|
590
|
-
const disk = safeRead(MANUAL);
|
|
591
|
-
const vd = disk === null ? null : manualVer(disk);
|
|
592
|
-
if (!vi || !vd) return undefined;
|
|
593
|
-
const pair = vi + "|" + vd;
|
|
594
|
-
if (vi === vd || pair === lastPair) return undefined;
|
|
595
|
-
lastPair = pair;
|
|
596
|
-
ctx.ui.notify(`注入对齐:注入 v${vi} ≠ 磁盘 v${vd}(观察者 = 磁盘版)→ 判据:注入版不可作判断依据;动作:读回全文执行,重启后自愈`, "warning");
|
|
597
|
-
// 回灌四律②:用户已收到通知;模型也需要知道,否则「以磁盘版执行」停在用户侧。
|
|
598
|
-
try {
|
|
599
|
-
(pi as unknown as { sendMessage?: (m: unknown, o?: unknown) => void }).sendMessage?.(
|
|
600
|
-
{
|
|
601
|
-
customType: "patch-keeper",
|
|
602
|
-
content: `注入对齐:注入 v${vi} ≠ 磁盘 v${vd}(观察者 = 磁盘版)→ 注入版不可作判断依据;动作:读回 APPEND_SYSTEM.md 全文后执行,重启后自愈。`,
|
|
603
|
-
display: false,
|
|
604
|
-
},
|
|
605
|
-
{ deliverAs: "nextTurn" },
|
|
606
|
-
);
|
|
607
|
-
} catch { /* 静默:回灌失败不影响会话 */ }
|
|
608
|
-
} catch { /* 静默:检测不该影响会话 */ }
|
|
609
|
-
return undefined;
|
|
610
|
-
});
|
|
611
|
-
}
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-patch-keeper",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.6",
|
|
4
|
+
"description": "Detect and fix pi extension load-order hazards: force-typed extensions silently swallow later system-prompt sections.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
|
+
"load-order",
|
|
8
9
|
"pi",
|
|
10
|
+
"pi-coding-agent",
|
|
9
11
|
"pi-extension",
|
|
10
|
-
"pi-
|
|
12
|
+
"pi-package",
|
|
13
|
+
"system-prompt"
|
|
11
14
|
],
|
|
12
15
|
"pi": {
|
|
13
16
|
"extensions": [
|