agent-purge 0.2.9 → 0.2.11

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.
Files changed (75) hide show
  1. package/CONTRIBUTING.md +64 -0
  2. package/LICENSE +21 -21
  3. package/README.md +176 -118
  4. package/backends/index.mjs +204 -244
  5. package/bench/bench-armor.mjs +844 -832
  6. package/bench/capture-hot.mjs +58 -0
  7. package/bench/capture-provider.mjs +66 -0
  8. package/bench/capture-report.mjs +15 -0
  9. package/bench/capture-server-hot.mjs +55 -0
  10. package/bench/capture-system.mjs +36 -0
  11. package/bench/judge-armor.mjs +119 -119
  12. package/bin/agent-purge.mjs +906 -772
  13. package/docs/LEGAL.md +27 -0
  14. package/docs/architecture.md +39 -0
  15. package/docs/compatibility.md +78 -0
  16. package/docs/images/console-desktop.png +0 -0
  17. package/docs/images/console-mobile.png +0 -0
  18. package/docs/releasing.md +87 -0
  19. package/docs/usage.md +158 -0
  20. package/lib/activation.mjs +38 -0
  21. package/lib/apply.mjs +227 -0
  22. package/lib/armed.mjs +38 -38
  23. package/lib/armor.mjs +225 -222
  24. package/lib/caps.mjs +182 -173
  25. package/lib/capture.mjs +324 -0
  26. package/lib/clean.mjs +124 -0
  27. package/lib/control.mjs +60 -0
  28. package/lib/detect.mjs +74 -70
  29. package/lib/doctor.mjs +7 -7
  30. package/lib/dsh.mjs +495 -453
  31. package/lib/environment.mjs +36 -0
  32. package/lib/evidence.mjs +41 -0
  33. package/lib/extension.mjs +140 -0
  34. package/lib/footprints.mjs +59 -0
  35. package/lib/home.mjs +47 -47
  36. package/lib/launch-conflicts.mjs +24 -0
  37. package/lib/launch.mjs +57 -0
  38. package/lib/opencode.mjs +62 -0
  39. package/lib/paths.mjs +25 -0
  40. package/lib/persist.mjs +439 -280
  41. package/lib/personas.mjs +17 -7
  42. package/lib/private-server.mjs +54 -0
  43. package/lib/project.mjs +134 -0
  44. package/lib/restore.mjs +131 -114
  45. package/lib/rewrite.mjs +124 -0
  46. package/lib/root.mjs +37 -0
  47. package/lib/shim.mjs +203 -160
  48. package/lib/slashes.mjs +227 -628
  49. package/lib/spawn.mjs +182 -126
  50. package/lib/state.mjs +85 -137
  51. package/lib/templates/personas/neutral.md +30 -30
  52. package/lib/update.mjs +137 -0
  53. package/lib/verification.mjs +23 -0
  54. package/lib/window.mjs +58 -0
  55. package/lib/wrappers.mjs +49 -0
  56. package/magpie-plugin/README.md +44 -0
  57. package/matrix.json +325 -341
  58. package/package.json +30 -4
  59. package/web/app.js +525 -0
  60. package/web/assets/agent-purge.ico +0 -0
  61. package/web/assets/claude.ico +0 -0
  62. package/web/assets/codebuddy.png +0 -0
  63. package/web/assets/codex.webp +0 -0
  64. package/web/assets/dsh.ico +0 -0
  65. package/web/assets/grok.ico +0 -0
  66. package/web/assets/hermes.ico +0 -0
  67. package/web/assets/omp.png +0 -0
  68. package/web/assets/opencode.ico +0 -0
  69. package/web/assets/opencode2.ico +0 -0
  70. package/web/assets/pi.png +0 -0
  71. package/web/assets/zcode.png +0 -0
  72. package/web/model.mjs +49 -0
  73. package/web/server.mjs +309 -194
  74. package/web/styles.css +917 -0
  75. package/web/ui.html +64 -1163
package/lib/persist.mjs CHANGED
@@ -1,280 +1,439 @@
1
- // 持久化层:把 persona 写进 harness 自己的配置,这样用户在自己终端敲 codex 也生效。
2
- // 分两类:托管块(文本文件,带标记,可精确删除)与插件文件(可删)。
3
- // 不动二进制——这里只做可逆的配置改动。
4
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
- import { dirname, join } from "node:path";
6
- import { RESIDENT as DSH_RESIDENT, SESSION as DSH_SESSION, addPlugin, hasPlugin, pluginFileOnDisk, residentSource, removePlugin } from "./dsh.mjs";
7
-
8
- const BEGIN = "# >>> agent-purge";
9
- const END = "# <<< agent-purge";
10
- const home = process.env.USERPROFILE || process.env.HOME;
11
- // opencode 认 XDG_CONFIG_HOME(lib/detect.mjs 也是这么探测的):两处必须同一个落点,否则装了白装
12
- export const opencodePlugins = () => join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "opencode", "plugins");
13
-
14
- export const targets = {
15
- pi: {
16
- // pi 每次启动都读全局 AGENTS.md(“Global Agent Rules”),它就是 pi 唯一的 sys 层常驻口
17
- // 代价:正文是快照,换 persona 要重新 install
18
- file: () => join(home, ".pi", "agent", "AGENTS.md"),
19
- kind: "block",
20
- layer: "sys",
21
- note: "全局 AGENTS.md,每次启动重读;正文是快照,换人设要重装",
22
- body: (active) => readFileSync(active, "utf8").trim(),
23
- },
24
- claude: {
25
- // 用户级 CLAUDE.md:每次都读,但进入的是 <system-reminder> 的上下文块(USER 层),不是 system 数组
26
- file: () => join(home, ".claude", "CLAUDE.md"),
27
- kind: "block",
28
- layer: "user",
29
- note: "CLAUDE.md 进的是 <system-reminder> 上下文块(用户消息),不是 system 数组",
30
- body: (active) => readFileSync(active, "utf8").trim(),
31
- },
32
- grok: {
33
- // grok 把全局 AGENTS.md 的内容 append 到系统提示;单文件上限 10000 字符,超了会被截
34
- file: () => join(home, ".grok", "AGENTS.md"),
35
- kind: "block",
36
- layer: "sys",
37
- note: "内容 append 进系统提示;单文件上限 10000 字符,超了会自动截断",
38
- body: (active) => clamp(readFileSync(active, "utf8").trim(), 9600),
39
- },
40
- zcode: {
41
- // 官方文档:~/.zcode/AGENTS.md 是“every workspace 的默认 instructions”(桌面闭源,无法抓包复核)
42
- file: () => join(home, ".zcode", "AGENTS.md"),
43
- kind: "block",
44
- layer: "sys",
45
- note: "官方文档写的默认 instructions 文件;桌面版闭源,无法抓包复核",
46
- body: (active) => readFileSync(active, "utf8").trim(),
47
- },
48
- omp: {
49
- // Oh My Pi 血统:~/.omp/agent/AGENTS.md 就是它的 rules 文件(--no-rules 关的就是它)
50
- file: () => join(home, ".omp", "agent", "AGENTS.md"),
51
- kind: "block",
52
- layer: "sys",
53
- note: "就是它的 rules 文件(--no-rules 关的就是这个)",
54
- body: (active) => readFileSync(active, "utf8").trim(),
55
- },
56
- hermes: {
57
- // SOUL.md 每次构建提示都重读(prompt_builder.load_soul_md)
58
- file: () => join(process.env.HERMES_HOME || join(home, "AppData", "Local", "hermes"), "SOUL.md"),
59
- kind: "block",
60
- layer: "sys",
61
- note: "SOUL.md 每次构建提示都重读(prompt_builder.load_soul_md)",
62
- body: (active) => readFileSync(active, "utf8").trim(),
63
- },
64
- codex: {
65
- file: () => join(home, ".codex", "config.toml"),
66
- kind: "block",
67
- // TOML 顶层键必须写在第一个 [table] 之前:追加到文件尾会被解析成上一个表里的键
68
- position: "top",
69
- layer: "sys",
70
- note: "model_instructions_file 直接换掉它的指令文件(连 GUI/子进程启动都生效)",
71
- body: (active) => `model_instructions_file = "${active.replace(/\\/g, "/")}"`,
72
- },
73
- opencode: {
74
- // 写入用全局单一位置;读取/删除要扫全部历史位置,否则老版本留下的项目级插件会继续注入
75
- file: () => join(opencodePlugins(), "agent-purge.ts"),
76
- files: () => [
77
- join(opencodePlugins(), "agent-purge.ts"),
78
- // 旧落点:设了 XDG 的机器上,老版本写在 home/.config 的那份也得能被扫到并清掉
79
- join(home, ".config", "opencode", "plugins", "agent-purge.ts"),
80
- join(home, ".config", "opencode", "plugin", "agent-purge.ts"),
81
- join(process.cwd(), ".opencode", "plugin", "agent-purge.ts"),
82
- ],
83
- kind: "file",
84
- layer: "sys",
85
- note: "插件改 chat.system(SYS 层真改);每轮读 state.json + active.md,人设与模式(off/replace/append)全部热切",
86
- // 每轮请求都重拼一次 system:人设、模式都从 state.json 现读 → 真热切,不用重启
87
- body: (active, root) => `import { readFileSync } from "node:fs";
88
- // agent-purge 托管文件:删除即可取消
89
- const PERSONA = ${JSON.stringify(active.replace(/\\/g, "/"))};
90
- const STATE = ${JSON.stringify(join(root, "state.json").replace(/\\/g, "/"))};
91
- function cfg() {
92
- try {
93
- const st = JSON.parse(readFileSync(STATE, "utf8"));
94
- const mode = st && st.default ? st.default.mode : "replace";
95
- const body = readFileSync(PERSONA, "utf8").trim();
96
- return body ? { mode: mode || "replace", body } : null;
97
- } catch {
98
- return null;
99
- }
100
- }
101
- export const AgentPurge = async () => ({
102
- "experimental.chat.system.transform": async (_input, output) => {
103
- const c = cfg();
104
- if (!c || c.mode === "off") return;
105
- if (c.mode === "append") output.system.push(c.body);
106
- else output.system.splice(0, output.system.length, c.body);
107
- },
108
- });
109
- `,
110
- },
111
- dsh: {
112
- // cordis 插件挂 systemPrompt 层;三个 profile 各一份(desktop 由 Electron 独占,装完要重启一次)
113
- file: () => pluginFileOnDisk(DSH_RESIDENT),
114
- kind: "file",
115
- layer: "sys",
116
- note: "cordis 插件包;运行时每轮读 state.json,开关 / 切模式 / 换人设都即时生效;只有首次安装要重启一次",
117
- probe: () => hasPlugin(DSH_RESIDENT),
118
- install: ({ root, active }) => {
119
- const r = addPlugin(DSH_RESIDENT, residentSource(root, active));
120
- return { file: pluginFileOnDisk(DSH_RESIDENT), note: `三个 profile:${r.profiles.join("/")};重启一次 DeepSeek Harness 后插件开始工作(之后热切换)` };
121
- },
122
- uninstall: () => {
123
- removePlugin(DSH_RESIDENT);
124
- // 一次性插件被 SIGKILL 时不会自己清,一并扫掉
125
- removePlugin(DSH_SESSION);
126
- return { file: pluginFileOnDisk(DSH_RESIDENT) };
127
- },
128
- },
129
- opencode2: {
130
- // v2 新版插件 API(V1 的 experimental.chat.system.transform 在 2.x 已废),与 V1 插件文件物理分开
131
- file: () => join(opencodePlugins(), "agent-purge-v2.ts"),
132
- files: () => [join(opencodePlugins(), "agent-purge-v2.ts"), join(opencodePlugins(), "agent-purge-v2-session.ts")],
133
- kind: "file",
134
- layer: "sys",
135
- note: "v2 插件 ctx.session.hook(\"context\");运行时读 state.json,热切",
136
- body: (active, root) => `import { readFileSync } from "node:fs";
137
- // agent-purge 托管文件:删除即可取消
138
- const STATE = ${JSON.stringify(join(root, "state.json").replace(/\\/g, "/"))};
139
- const ACTIVE = ${JSON.stringify(String(active).replace(/\\/g, "/"))};
140
- function cfg() {
141
- try {
142
- const st = JSON.parse(readFileSync(STATE, "utf8"));
143
- // 磁盘卸载后服务里已加载的回调还在:armed 没了就当成已关闭
144
- const armed = st && st.armed && st.armed.opencode2;
145
- if (!Array.isArray(armed) || !armed.includes("install")) return null;
146
- const mode = (st && st.default && st.default.mode) || "replace";
147
- if (mode === "off") return null;
148
- const body = readFileSync(ACTIVE, "utf8").trim();
149
- return body ? { mode, body } : null;
150
- } catch {
151
- return null;
152
- }
153
- }
154
- export default {
155
- id: "agent-purge",
156
- async setup(ctx) {
157
- await ctx.session.hook("context", (event) => {
158
- const c = cfg();
159
- if (!c) return;
160
- if (c.mode === "append") event.system.push({ type: "text", text: c.body });
161
- else event.system.splice(0, event.system.length, { type: "text", text: c.body });
162
- });
163
- },
164
- };
165
- `,
166
- },
167
- };
168
-
169
- export function status(_root) {
170
- return Object.entries(targets).map(([name, t]) => {
171
- const file = t.file();
172
- // 自定义探针(多文件目标用),优先于默认的存在性判断
173
- if (t.probe) return { name, installed: t.probe(), file };
174
- const candidates = t.files ? t.files() : [file];
175
- const present = candidates.filter((f) => existsSync(f));
176
- if (!present.length) return { name, installed: false, file };
177
- // 文件类目标:只要还存在任意一份托管文件就算“已安装”(内容可能是旧版本写的)
178
- const installed =
179
- t.kind === "block"
180
- ? present.some((f) => readFileSync(f, "utf8").includes(BEGIN))
181
- : present.length > 0;
182
- return { name, installed, file: present[0], files: present };
183
- });
184
- }
185
-
186
- export function install(name, { root, active }) {
187
- const t = targets[name];
188
- if (!t) return { error: `${name} 没有持久化后端(可用: ${Object.keys(targets).join(", ")})` };
189
- // 多文件/自定义布局的 harness 自带安装函数(dsh 三个 profile、opencode2 v2 插件)
190
- // 它们的失败信号是异常(同名外来目录拒绝覆盖等),这里转成调用方约好的 {error}
191
- if (t.install) {
192
- try {
193
- return { ...t.install({ root, active }), action: "custom" };
194
- } catch (e) {
195
- return { error: e.message };
196
- }
197
- }
198
- const file = t.file();
199
- mkdirSync(dirname(file), { recursive: true });
200
- if (t.kind === "file") {
201
- writeFileSync(file, t.body(active, root));
202
- return { file, action: "wrote-whole-file" };
203
- }
204
- const old = existsSync(file) ? readFileSync(file, "utf8") : "";
205
- const next = upsertBlock(old, t.body(active), t.position);
206
- writeFileSync(file, next);
207
- return { file, action: old.includes(BEGIN) ? "replaced-block" : "appended-block" };
208
- }
209
-
210
- export function uninstall(name) {
211
- const t = targets[name];
212
- if (!t) return { error: `${name} 没有持久化后端` };
213
- if (t.uninstall) {
214
- try {
215
- return { ...t.uninstall(), action: "custom" };
216
- } catch (e) {
217
- return { error: e.message };
218
- }
219
- }
220
- if (t.kind === "file") {
221
- // 清掉所有历史位置,保证“关掉”之后没有任何残留插件还在注入
222
- const candidates = t.files ? t.files() : [t.file()];
223
- const gone = candidates.filter((f) => existsSync(f));
224
- if (!gone.length) return { file: t.file(), action: "already-gone" };
225
- for (const f of gone) rmSync(f, { force: true });
226
- return { file: gone[0], files: gone, action: "deleted-file" };
227
- }
228
- const file = t.file();
229
- if (!existsSync(file)) return { file, action: "already-gone" };
230
- const old = readFileSync(file, "utf8");
231
- if (!old.includes(BEGIN)) return { file, action: "no-block" };
232
- const stripped = stripBlock(old);
233
- // 只剩空白说明这文件本来就是给我们用的,直接删掉别留空壳
234
- if (!stripped.trim()) {
235
- rmSync(file, { force: true });
236
- return { file, action: "deleted-empty-file" };
237
- }
238
- writeFileSync(file, stripped);
239
- return { file, action: "removed-block" };
240
- }
241
-
242
- // 按标记替换或追加,幂等。position="top" 用于 TOML:块必须落在第一个 [table] 之前。
243
- export function upsertBlock(text, body, position = "bottom") {
244
- const eol = text.includes("\r\n") ? "\r\n" : "\n";
245
- const block = `${BEGIN}\n${body}\n${END}`;
246
- const stripped = text.includes(BEGIN) ? stripBlock(text) : text;
247
- if (position === "top") {
248
- let lines = stripped.split(/\r?\n/);
249
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
250
- const end = firstTable === -1 ? lines.length : firstTable;
251
- // 已有的顶层同名键先删掉,否则 TOML 会报 duplicate key
252
- const key = body.split("=")[0].trim();
253
- const keyRe = key ? new RegExp(`^\\s*${escapeRe(key)}\\s*=`) : null;
254
- if (keyRe) lines = lines.filter((l, i) => !(i < end && keyRe.test(l)));
255
- // 插在开头连续的注释/空行之后:顶层键必须早于第一个 [table],但不该淹没 #:schema 这类头注释
256
- let at = 0;
257
- while (at < lines.length && (/^\s*#/.test(lines[at]) || lines[at].trim() === "")) at++;
258
- lines.splice(at, 0, ...block.split("\n"));
259
- return lines.join(eol).replace(/\s*$/, eol);
260
- }
261
- return `${stripped.replace(/\s*$/, "")}${eol}${eol}${block.split("\n").join(eol)}${eol}`;
262
- }
263
-
264
- export function stripBlock(text) {
265
- const eol = text.includes("\r\n") ? "\r\n" : "\n";
266
- return text
267
- .replace(/\r?\n*# >>> agent-purge[\s\S]*?# <<< agent-purge\r?\n*/, eol + eol)
268
- .replace(/^(?:\r?\n)+/, "")
269
- .replace(/\s*$/, eol);
270
- }
271
-
272
- function escapeRe(s) {
273
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
274
- }
275
-
276
- /** 有些 harness 对单文件有硬上限(grok:10000 字符),超了直接截断并留一行明说 */
277
- function clamp(text, max) {
278
- if (text.length <= max) return text;
279
- return text.slice(0, max) + "\n\n(agent-purge:超出该 harness 的文件上限,此处被截断)";
280
- }
1
+ // 持久化层:把 persona 写进 harness 自己的配置,这样用户在自己终端敲 codex 也生效。
2
+ // 分两类:托管块(文本文件,带标记,可精确删除)与插件文件(可删)。
3
+ // 不动二进制——这里只做可逆的配置改动。
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { RESIDENT as DSH_RESIDENT, SESSION as DSH_SESSION, addPlugin, hasPlugin, pluginFileOnDisk, residentSource, removePlugin } from "./dsh.mjs";
8
+ import { dataHome } from "./home.mjs";
9
+ import { cancel, forgetHarness, record } from "./footprints.mjs";
10
+ import { opencodeSource } from "./opencode.mjs";
11
+ import { configHome } from "./paths.mjs";
12
+
13
+
14
+ const BEGIN = "# >>> agent-purge";
15
+ const END = "# <<< agent-purge";
16
+ const home = process.env.USERPROFILE || process.env.HOME;
17
+ // opencode 认 XDG_CONFIG_HOME(lib/detect.mjs 也是这么探测的):两处必须同一个落点,否则装了白装
18
+ export const opencodePlugins = () => join(configHome("opencode"), "plugins");
19
+
20
+ // 托管块的原字节备份。只靠 stripBlock 无法还原 install 前文件前后的空行,
21
+ // 所以把 before/after 记到数据目录;卸载时若文件仍是上次写入的样子就按原字节还原。
22
+ const stashDir = (root = dataHome()) => join(root, "stash");
23
+ const stashPath = (file, root = dataHome()) => {
24
+ const full = resolve(file);
25
+ return join(stashDir(root), createHash("sha256").update(process.platform === "win32" ? full.toLowerCase() : full).digest("hex").slice(0, 24) + ".json");
26
+ };
27
+ function readStash(file, root = dataHome()) {
28
+ try {
29
+ return JSON.parse(readFileSync(stashPath(file, root), "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ export function writeStash(file, record, root = dataHome()) {
35
+ mkdirSync(stashDir(root), { recursive: true });
36
+ writeFileSync(stashPath(file, root), JSON.stringify(record, null, 2) + "\n");
37
+ }
38
+ function clearStash(file, root = dataHome()) {
39
+ rmSync(stashPath(file, root), { force: true });
40
+ }
41
+ /** 撤销链路对外暴露的只读接口(slashes 刷新命令文件时要接着上一份 before) */
42
+ export { readStash };
43
+ export function unusedBackup(file) {
44
+ let path = `${file}.disabled-by-agent-purge`, n = 0;
45
+ while (existsSync(path)) path = `${file}.disabled-by-agent-purge-${++n}`;
46
+ return path;
47
+ }
48
+ export function preserveEdited(file, content) { const path = unusedBackup(file); writeFileSync(path, content, { flag: "wx" }); return path; }
49
+ export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
50
+
51
+ /**
52
+ * 这个文件是不是我们写的?—— 旧账本没有 stash/指纹时的唯一依据。
53
+ * 三个生成器都会留下固定标记:托管注释、托管块 marker、shim 头。
54
+ */
55
+ export function looksOwned(text) {
56
+ const s = String(text ?? "");
57
+ return /agent-purge 托管|agent-purge shim|# >>> agent-purge|<!-- agent-purge:/.test(s);
58
+ }
59
+
60
+ /** 不是我们的文件就一个字节都别动——这是卸载路径的总闸门 */
61
+ const FOREIGN = "skipped-foreign";
62
+
63
+ /**
64
+ * 撤掉一个「整份文件」类的落点(插件文件、命令文件、shim)。
65
+ * 顺序:stash 原字节还原 > 账本指纹 > 内容标记;三条都对不上就当别人的文件,不碰。
66
+ *
67
+ * 为什么不能直接 rm:install 会把用户原有的同名文件整份覆盖(比如手改过的扩展),
68
+ * 只记一个「文件路径」根本还原不回去。这里靠 stash.before 把原字节写回去。
69
+ */
70
+ export function removeOwnedFile(file, { root = dataHome(), expectedSha = null } = {}) {
71
+ if (!existsSync(file)) {
72
+ clearStash(file, root);
73
+ cancel(root, { kind: "file", path: file });
74
+ return { file, action: "already-gone" };
75
+ }
76
+ const now = readFileSync(file, "utf8");
77
+ const stash = readStash(file, root);
78
+
79
+ // 1) 还是我们上次写进去的原字节:按 stash 还原(当初没有这个文件就删掉)
80
+ if (stash && typeof stash.after === "string" && stash.after === now) {
81
+ if (stash.before === null || stash.before === undefined) rmSync(file, { force: true });
82
+ else writeFileSync(file, stash.before);
83
+ clearStash(file, root);
84
+ cancel(root, { kind: "file", path: file });
85
+ return {
86
+ file,
87
+ action: stash.before == null ? "deleted-file" : "restored-file",
88
+ exact: true,
89
+ };
90
+ }
91
+
92
+ // 2) 没有 stash 的老安装:只认托管标记;有账本指纹的再确认一次是我们写的
93
+ const ours = Boolean(expectedSha) && expectedSha === sha256(now);
94
+ if (!stash && (ours || looksOwned(now))) {
95
+ rmSync(file, { force: true });
96
+ clearStash(file, root);
97
+ cancel(root, { kind: "file", path: file });
98
+ return { file, action: "deleted-file", exact: true };
99
+ }
100
+
101
+ // 3) 我们写的、但已被改过:删了会吃掉用户的字节 → 改名停用(宿主不再加载它,字节全留着)
102
+ if (looksOwned(now)) {
103
+ const off = unusedBackup(file);
104
+ renameSync(file, off);
105
+ if (stash?.before != null) writeFileSync(file, stash.before);
106
+ clearStash(file, root);
107
+ cancel(root, { kind: "file", path: file });
108
+ return { file, action: "disabled-edited-file", moved: off };
109
+ }
110
+
111
+ // 4) 不是我们的文件:不删、不销账,交给 leftovers 报出来
112
+ return { file, action: FOREIGN };
113
+ }
114
+ /** persona 正文里出现托管 marker 会让卸载定位错乱:install 前直接拒绝,而不是事后猜。 */
115
+ const hasMarker = (text) => /^(?:\s*)# (?:>>>|<<<) agent-purge/m.test(String(text ?? ""));
116
+
117
+ export const targets = {
118
+ pi: {
119
+ // pi 每次启动都读全局 AGENTS.md(“Global Agent Rules”),它就是 pi 唯一的 sys 层常驻口
120
+ // 代价:正文是快照,换 persona 要重新 install
121
+ file: () => join(configHome("pi"), "AGENTS.md"),
122
+ kind: "block",
123
+ layer: "sys",
124
+ note: "全局 AGENTS.md,每次启动重读;正文是快照,换人设要重装",
125
+ body: (active) => readFileSync(active, "utf8").trim(),
126
+ },
127
+ claude: {
128
+ // 用户级 CLAUDE.md:每次都读,但进入的是 <system-reminder> 的上下文块(USER 层),不是 system 数组
129
+ file: () => join(configHome("claude"), "CLAUDE.md"),
130
+ kind: "block",
131
+ layer: "user",
132
+ note: "CLAUDE.md 进的是 <system-reminder> 上下文块(用户消息),不是 system 数组",
133
+ body: (active) => readFileSync(active, "utf8").trim(),
134
+ },
135
+ grok: {
136
+ // grok 把全局 AGENTS.md 的内容 append 到系统提示;单文件上限 10000 字符,超了会被截
137
+ file: () => join(configHome("grok"), "AGENTS.md"),
138
+ kind: "block",
139
+ layer: "sys",
140
+ note: "内容 append 进系统提示;单文件上限 10000 字符,超了会自动截断",
141
+ body: (active) => clamp(readFileSync(active, "utf8").trim(), 9600),
142
+ },
143
+ zcode: {
144
+ // 官方文档:~/.zcode/AGENTS.md 是“every workspace 的默认 instructions”(桌面闭源,无法抓包复核)
145
+ file: () => join(configHome("zcode"), "AGENTS.md"),
146
+ kind: "block",
147
+ layer: "sys",
148
+ note: "官方文档写的默认 instructions 文件;桌面版闭源,无法抓包复核",
149
+ body: (active) => readFileSync(active, "utf8").trim(),
150
+ },
151
+ omp: {
152
+ // Oh My Pi 血统:~/.omp/agent/AGENTS.md 就是它的 rules 文件(--no-rules 关的就是它)
153
+ file: () => join(configHome("omp"), "agent", "AGENTS.md"),
154
+ kind: "block",
155
+ layer: "sys",
156
+ note: "就是它的 rules 文件(--no-rules 关的就是这个)",
157
+ body: (active) => readFileSync(active, "utf8").trim(),
158
+ },
159
+ hermes: {
160
+ // SOUL.md 是身份槽位 #1;只在会话首次/重建时读,续会话用 session DB 里的 system 提示词
161
+ file: () => join(configHome("hermes"), "SOUL.md"),
162
+ kind: "block",
163
+ layer: "sys",
164
+ note: "SOUL.md 是身份槽位 #1,新会话才读(续会话沿用 session DB 的提示词)",
165
+ body: (active) => readFileSync(active, "utf8").trim(),
166
+ },
167
+ codex: {
168
+ file: () => join(configHome("codex"), "config.toml"),
169
+ kind: "block",
170
+ // TOML 顶层键必须写在第一个 [table] 之前:追加到文件尾会被解析成上一个表里的键
171
+ position: "top",
172
+ layer: "sys",
173
+ note: "model_instructions_file 直接换掉它的指令文件(连 GUI/子进程启动都生效)",
174
+ body: (active) => `model_instructions_file = "${active.replace(/\\/g, "/")}"`,
175
+ },
176
+ opencode: {
177
+ // 写入用全局单一位置;读取/删除要扫全部历史位置,否则老版本留下的项目级插件会继续注入
178
+ file: () => join(opencodePlugins(), "agent-purge.ts"),
179
+ files: () => [
180
+ join(opencodePlugins(), "agent-purge.ts"),
181
+ // 旧落点:设了 XDG 的机器上,老版本写在 home/.config 的那份也得能被扫到并清掉
182
+ join(home, ".config", "opencode", "plugins", "agent-purge.ts"),
183
+ join(home, ".config", "opencode", "plugin", "agent-purge.ts"),
184
+ join(process.cwd(), ".opencode", "plugin", "agent-purge.ts"),
185
+ ],
186
+ kind: "file",
187
+ layer: "sys",
188
+ note: "插件改 chat.system(SYS 层真改);每轮读 state.json + active.md,人设与模式(off/replace/append)全部热切",
189
+ // 每轮请求都重拼一次 system:人设、模式都从 state.json 现读 → 真热切,不用重启
190
+ body: (active, root) => opencodeSource(root, "opencode", { active }),
191
+ },
192
+ dsh: {
193
+ // cordis 插件挂 systemPrompt 层;三个 profile 各一份(desktop 由 Electron 独占,装完要重启一次)
194
+ file: () => pluginFileOnDisk(DSH_RESIDENT),
195
+ kind: "file",
196
+ layer: "sys",
197
+ note: "cordis 插件包;运行时每轮读 state.json,开关 / 切模式 / 换人设都即时生效;只有首次安装要重启一次",
198
+ probe: () => hasPlugin(DSH_RESIDENT),
199
+ install: ({ root, active }) => {
200
+ const r = addPlugin(DSH_RESIDENT, residentSource(root, active));
201
+ return { file: pluginFileOnDisk(DSH_RESIDENT), profiles: r.profiles, note: `实际 profile:${r.profiles.join("/")};重启一次 DeepSeek Harness 后插件开始工作(之后热切换)` };
202
+ },
203
+ uninstall: () => {
204
+ const file = pluginFileOnDisk(DSH_RESIDENT);
205
+ const resident = removePlugin(DSH_RESIDENT);
206
+ // 一次性插件被 SIGKILL 时不会自己清,一并扫掉
207
+ const session = removePlugin(DSH_SESSION);
208
+ return { file, profiles: [...new Set([...resident.profiles, ...session.profiles])], preserved: [...resident.preserved, ...session.preserved] };
209
+ },
210
+ },
211
+ opencode2: {
212
+ // v2 新版插件 API(V1 的 experimental.chat.system.transform 在 2.x 已废),与 V1 插件文件物理分开
213
+ file: () => join(opencodePlugins(), "agent-purge-v2.ts"),
214
+ files: () => [join(opencodePlugins(), "agent-purge-v2.ts"), join(opencodePlugins(), "agent-purge-v2-session.ts")],
215
+ kind: "file",
216
+ layer: "sys",
217
+ note: "v2 插件 ctx.session.hook(\"context\");运行时读 state.json,热切",
218
+ body: (active, root) => opencodeSource(root, "opencode2", { active }),
219
+ },
220
+ };
221
+
222
+ export function status(_root) {
223
+ return Object.entries(targets).map(([name, t]) => {
224
+ const file = t.file();
225
+ // 自定义探针(多文件目标用),优先于默认的存在性判断
226
+ if (t.probe) return { name, installed: t.probe(), file };
227
+ const candidates = t.files ? t.files() : [file];
228
+ const present = candidates.filter((f) => existsSync(f));
229
+ if (!present.length) return { name, installed: false, file };
230
+ // 文件类目标:只要还存在任意一份托管文件就算“已安装”(内容可能是旧版本写的)
231
+ const installed =
232
+ t.kind === "block"
233
+ ? present.some((f) => {
234
+ const text = readFileSync(f, "utf8");
235
+ return stripBlock(text) !== text;
236
+ })
237
+ : present.some(f => looksOwned(readFileSync(f, "utf8")));
238
+ return { name, installed, file: present[0], files: present };
239
+ });
240
+ }
241
+
242
+ export function install(name, { root, active }) {
243
+ const t = targets[name];
244
+ if (!t) return { error: `${name} 没有持久化后端(可用: ${Object.keys(targets).join(", ")})` };
245
+ // 多文件/自定义布局的 harness 自带安装函数(dsh 三个 profile、opencode2 v2 插件)
246
+ // 它们的失败信号是异常(同名外来目录拒绝覆盖等),这里转成调用方约好的 {error}
247
+ if (t.install) {
248
+ try {
249
+ const r = t.install({ root, active });
250
+ // 账本按「实际落点」登记:撤销时不再重新推导 profile 目录
251
+ record(root, { kind: "custom", harness: name, channel: "install", path: r.file, undo: { type: "custom", harness: name, op: "install" } });
252
+ return { ...r, action: "custom" };
253
+ } catch (e) {
254
+ return { error: e.message };
255
+ }
256
+ }
257
+ const file = t.file();
258
+ mkdirSync(dirname(file), { recursive: true });
259
+ if (t.kind === "file") {
260
+ // 和托管块同等安全:写之前把原字节存进 stash,撤销时才知道该还原成什么
261
+ const old = existsSync(file) ? readFileSync(file, "utf8") : null;
262
+ const content = t.body(active, root);
263
+ const previous = readStash(file, root);
264
+ const before = previous ? previous.before : looksOwned(old) ? null : old;
265
+ if (previous && old !== previous.after && looksOwned(old)) preserveEdited(file, old);
266
+ writeStash(file, { file, before, after: content }, root);
267
+ writeFileSync(file, content);
268
+ record(root, {
269
+ kind: "file",
270
+ harness: name,
271
+ channel: "install",
272
+ path: file,
273
+ undo: { type: "file-delete" },
274
+ createdByUs: old === null,
275
+ });
276
+ return { file, action: "wrote-whole-file", replaced: old !== null };
277
+ }
278
+ const old = existsSync(file) ? readFileSync(file, "utf8") : null;
279
+ const body = t.body(active);
280
+ if (hasMarker(body)) return { error: `${name}: persona 正文包含 agent-purge 托管标记,拒绝写入` };
281
+ const next = upsertBlock(old ?? "", body, t.position);
282
+ const prev = readStash(file, root);
283
+ // 无 stash 的旧安装、或块外被用户编辑后,基线必须先去掉旧托管块。
284
+ // 否则下一次卸载会把上一版人设重新写回来。
285
+ let before = prev && prev.after === old ? prev.before : old;
286
+ if (before !== null) before = stripBlock(before);
287
+ // 顶层覆盖键的原值不应因为块外编辑而丢失;用户已自行写了新值则优先保留。
288
+ if (t.position === "top" && prev && prev.after !== old && before !== null) {
289
+ const key = body.split("=")[0].trim();
290
+ const keyRe = new RegExp(`^\\s*${escapeRe(key)}\\s*=`);
291
+ const topLines = (text) => {
292
+ const lines = String(text ?? "").split(/\r?\n/);
293
+ const at = lines.findIndex((line) => /^\s*\[/.test(line));
294
+ return at < 0 ? lines : lines.slice(0, at);
295
+ };
296
+ const originalKey = topLines(stripBlock(prev.before ?? "")).find((l) => keyRe.test(l));
297
+ if (originalKey && !topLines(before).some((l) => keyRe.test(l))) before = originalKey + (before.includes("\r\n") ? "\r\n" : "\n") + before;
298
+ }
299
+ writeStash(file, { file, before, after: next }, root);
300
+ writeFileSync(file, next);
301
+ record(root, {
302
+ kind: "block",
303
+ harness: name,
304
+ channel: "install",
305
+ path: file,
306
+ undo: { type: "block-revert" },
307
+ createdByUs: before === null,
308
+ });
309
+ return { file, action: old && stripBlock(old) !== old ? "replaced-block" : "appended-block" };
310
+ }
311
+
312
+ export function uninstall(name, { root = dataHome() } = {}) {
313
+ const t = targets[name];
314
+ if (!t) return { error: `${name} 没有持久化后端` };
315
+ if (t.uninstall) {
316
+ try {
317
+ const r = t.uninstall();
318
+ forgetHarness(root, name, "install");
319
+ return { ...r, action: "custom" };
320
+ } catch (e) {
321
+ return { error: e.message };
322
+ }
323
+ }
324
+ if (t.kind === "file") {
325
+ // 清掉所有历史位置,保证“关掉”之后没有任何残留插件还在注入。
326
+ // 但每份都要先确认是自己写的——历史位置里可能有用户自己的同名文件。
327
+ const candidates = t.files ? t.files() : [t.file()];
328
+ const present = candidates.filter((f) => existsSync(f));
329
+ if (!present.length) return { file: t.file(), action: "already-gone" };
330
+ const results = present.map((f) => removeOwnedFile(f, { root }));
331
+ const removed = results.filter((r) => r.action === "deleted-file" || r.action === "restored-file" || r.action === "disabled-edited-file");
332
+ const skipped = results.filter((r) => r.action === FOREIGN).map((r) => r.file);
333
+ return {
334
+ file: (removed[0] ?? results[0]).file,
335
+ files: present,
336
+ results,
337
+ action: removed.length ? (removed[0].action === "deleted-file" ? "deleted-file" : removed[0].action) : FOREIGN,
338
+ ...(skipped.length ? { skipped } : {}),
339
+ };
340
+ }
341
+ return revertFile(t.file(), { root });
342
+ }
343
+
344
+ /**
345
+ * 按绝对路径撤销一个托管块(账本执行器用:路径来自账本,不来自 targets 推导)。
346
+ * 有 stash 且文件仍是上次写入的样子 → 按原字节还原;否则按 marker 剥块。
347
+ */
348
+ export function revertFile(file, { root = dataHome() } = {}) {
349
+ if (!existsSync(file)) {
350
+ clearStash(file, root);
351
+ cancel(root, { kind: "block", path: file });
352
+ return { file, action: "already-gone" };
353
+ }
354
+ const old = readFileSync(file, "utf8");
355
+ const stash = readStash(file, root);
356
+ // 原字节还原:只有文件仍是上次 install 写入的 after 才敢整份写回。
357
+ if (stash && stash.after === old) {
358
+ if (stash.before === null) rmSync(file, { force: true });
359
+ else writeFileSync(file, stripBlock(stash.before));
360
+ clearStash(file, root);
361
+ cancel(root, { kind: "block", path: file });
362
+ return {
363
+ file,
364
+ action: stash.before === null ? "deleted-empty-file" : "removed-block",
365
+ exact: true,
366
+ };
367
+ }
368
+ if (stripBlock(old) === old) {
369
+ clearStash(file, root);
370
+ cancel(root, { kind: "block", path: file });
371
+ return { file, action: "no-block" };
372
+ }
373
+ const stripped = stripBlock(old);
374
+ // 只剩空白说明这文件本来就是给我们用的,直接删掉别留空壳
375
+ if (!stripped.trim()) {
376
+ rmSync(file, { force: true });
377
+ clearStash(file, root);
378
+ cancel(root, { kind: "block", path: file });
379
+ return { file, action: "deleted-empty-file" };
380
+ }
381
+ writeFileSync(file, stripped);
382
+ clearStash(file, root);
383
+ cancel(root, { kind: "block", path: file });
384
+ return { file, action: "removed-block" };
385
+ }
386
+
387
+ // 按标记替换或追加,幂等。position="top" 用于 TOML:块必须落在第一个 [table] 之前。
388
+ export function upsertBlock(text, body, position = "bottom") {
389
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
390
+ const block = `${BEGIN}\n${body}\n${END}`;
391
+ const stripped = stripBlock(text) !== text ? stripBlock(text) : text;
392
+ if (position === "top") {
393
+ let lines = stripped.split(/\r?\n/);
394
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
395
+ const end = firstTable === -1 ? lines.length : firstTable;
396
+ // 已有的顶层同名键先删掉,否则 TOML 会报 duplicate key
397
+ const key = body.split("=")[0].trim();
398
+ const keyRe = key ? new RegExp(`^\\s*${escapeRe(key)}\\s*=`) : null;
399
+ if (keyRe) lines = lines.filter((l, i) => !(i < end && keyRe.test(l)));
400
+ // 插在开头连续的注释/空行之后:顶层键必须早于第一个 [table],但不该淹没 #:schema 这类头注释
401
+ let at = 0;
402
+ while (at < lines.length && (/^\s*#/.test(lines[at]) || lines[at].trim() === "")) at++;
403
+ lines.splice(at, 0, ...block.split("\n"));
404
+ return lines.join(eol).replace(/\s*$/, eol);
405
+ }
406
+ return `${stripped.replace(/\s*$/, "")}${eol}${eol}${block.split("\n").join(eol)}${eol}`;
407
+ }
408
+
409
+ const markerLine = (line, marker) => String(line).replace(/\r?\n$/, "").trim() === marker;
410
+
411
+ /**
412
+ * 按整行 marker 删块。旧实现用不锚行的正则,persona 里出现 `# >>> agent-purge`
413
+ * 就会从行中间开始吞内容;这里只认独立成行的 BEGIN/END,且不改动块外一个字节。
414
+ * 精确字节还原由 install 的 stash 负责,这个函数只做兜底清理。
415
+ */
416
+ export function stripBlock(text) {
417
+ const src = String(text ?? "");
418
+ const lines = src.split(/(?<=\n)/);
419
+ const out = [];
420
+ for (let i = 0; i < lines.length; i++) {
421
+ if (markerLine(lines[i], BEGIN)) {
422
+ let end = i + 1;
423
+ while (end < lines.length && !markerLine(lines[end], END)) end++;
424
+ if (end < lines.length) { i = end; continue; }
425
+ }
426
+ out.push(lines[i]);
427
+ }
428
+ return out.join("");
429
+ }
430
+
431
+ function escapeRe(s) {
432
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
433
+ }
434
+
435
+ /** 有些 harness 对单文件有硬上限(grok:10000 字符),超了直接截断并留一行明说 */
436
+ function clamp(text, max) {
437
+ if (text.length <= max) return text;
438
+ return text.slice(0, max) + "\n\n(agent-purge:超出该 harness 的文件上限,此处被截断)";
439
+ }