agent-purge 0.2.10 → 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 +169 -138
  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 -780
  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/slashes.mjs CHANGED
@@ -1,628 +1,227 @@
1
- // 会话内 `/purge`:把当前 persona 装进去,之后在这个会话里说一句 `/purge` 就切。
2
- //
3
- // 这里是整个项目里唯一"照实说实话"的地方:**斜杠命令不等于系统提示词注入**。
4
- // pi / omp —— 扩展能在 before_agent_start 改写 systemPrompt → sys 层,热切换(实测 100%)
5
- // claude —— ~/.claude/commands/purge.md 是**用户消息展开** → user 层
6
- // codex —— ~/.codex/prompts/purge.md 同上 → user 层
7
- // opencode —— ~/.config/opencode/command/purge.md 同上 → user 层(sys 层要靠 plugin 常驻)
8
- // user 层的注入成功率由 matrix.json 的 armor.probe.*.user 记录,`agent-purge ls` 会标出来。
9
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
- import { dirname, join } from "node:path";
11
- import { detect } from "./detect.mjs";
12
- import { SLASH_RESIDENT, addPlugin, hasPluginAny, pluginFileOnDisk, removePlugin, slashSource } from "./dsh.mjs";
13
-
14
- const SIDECAR = ".disabled-by-agent-purge";
15
-
16
- /** 谁有 /purge、走哪层、文件放哪 */
17
- export function resolveSlash(id, root) {
18
- const homes = detect();
19
- const homeOf = (name) => homes?.[name]?.home ?? null;
20
- const specs = {
21
- pi: () => {
22
- const base = homeOf("pi");
23
- if (!base) return null;
24
- return {
25
- layer: "sys",
26
- offered: true,
27
- hot: true,
28
- trigger: "/purge on|off|status|mode",
29
- file: join(base, "extensions", "agent-purge.ts"),
30
- render: () => piExtension(root),
31
- note: "扩展在 before_agent_start 改写 systemPrompt:真系统提示词层,且跟随 state.json 换人设,无需重装",
32
- };
33
- },
34
- claude: () => {
35
- const base = homeOf("claude");
36
- if (!base) return null;
37
- return {
38
- layer: "user",
39
- offered: false,
40
- hot: false,
41
- trigger: "/purge",
42
- file: join(base, "commands", "purge.md"),
43
- render: ({ personaBody, personaId }) => commandMd("把本次会话的规则切换为 agent-purge 当前人设", personaBody, personaId),
44
- note: "claude 的 slash command 展开成用户消息 → user 层(bench 实测 0/2 无效);人设要生效请走 agent-purge claude(--system-prompt-file)",
45
- };
46
- },
47
- codex: () => {
48
- const base = homeOf("codex");
49
- if (!base) return null;
50
- return {
51
- layer: "user",
52
- offered: false,
53
- hot: false,
54
- trigger: "/purge",
55
- file: join(base, "prompts", "purge.md"),
56
- render: ({ personaBody, personaId }) => `${personaBody.trim()}\n\n<!-- agent-purge: ${personaId} -->\n`,
57
- note: "codex 的自定义 prompt 是用户消息 → user 层(bench 实测 0/2 无效);codex 只认启动时读的 model_instructions_file",
58
- };
59
- },
60
- opencode: () => {
61
- const base = homeOf("opencode");
62
- if (!base) return null;
63
- return {
64
- layer: "user",
65
- offered: false,
66
- hot: false,
67
- trigger: "/purge",
68
- file: join(base, "command", "purge.md"),
69
- render: ({ personaBody, personaId }) => commandMd("装入 agent-purge 当前人设", personaBody, personaId),
70
- note: "opencode 的 command 是用户消息 → user 层(bench 实测 0/2 无效);要 sys 层请用 install(chat.system.transform 插件)",
71
- };
72
- },
73
- omp: () => {
74
- const base = homeOf("omp");
75
- if (!base) return null;
76
- return {
77
- layer: "sys",
78
- offered: true,
79
- hot: true,
80
- trigger: "/purge on|off|status|mode",
81
- file: join(base, "agent", "extensions", "agent-purge.ts"),
82
- render: () => ompExtension(root),
83
- note: "扩展在 before_agent_start 改写 systemPrompt 数组:真系统提示词层,/purge 热切,下一轮生效",
84
- };
85
- },
86
- // dsh:不是写文件,而是往三个 profile 各装一个 cordis 小插件(agent-purge-slash)。
87
- // 它只注册 /purge 命令、改 state.json,不碰系统提示词 —— 注入归 agent-purge 常驻插件。
88
- dsh: () => ({
89
- layer: "sys",
90
- offered: true,
91
- hot: true,
92
- trigger: "/purge on|off|status|mode",
93
- file: pluginFileOnDisk(SLASH_RESIDENT),
94
- // 真值看挂载块 + 文件,不看某个 profile 的固定路径(没建过 desktop 就无意义);任一份在就算能用
95
- probe: () => hasPluginAny(SLASH_RESIDENT),
96
- render: () => slashSource(root),
97
- note: "cordis 插件注册 /purge(/ap 为别名):改 state.json,下一句生效,不产生模型消息",
98
- }),
99
- };
100
- const spec = specs[id];
101
- if (!spec) return null;
102
- const base = spec();
103
- if (!base) return null;
104
- return { id, ...base };
105
- }
106
-
107
- export function installSlash(id, root, { personaId, personaBody }) {
108
- const s = resolveSlash(id, root);
109
- if (!s) return { error: `${id} 没有 /purge 通道` };
110
- if (s.offered === false) return { error: `${s.note}(已下线,不再提供)` };
111
- // dsh:装 cordis 插件到三个 profile,没有单文件可写
112
- if (id === "dsh") {
113
- const r = addPlugin(SLASH_RESIDENT, slashSource(root));
114
- return { file: pluginFileOnDisk(SLASH_RESIDENT), layer: s.layer, note: s.note, sidecars: [], profiles: r.profiles };
115
- }
116
- mkdirSync(dirname(s.file), { recursive: true });
117
-
118
- // pi:旧的 pi-purge.ts 也注册 /purge,会撞名 → 先移开,卸载时还原
119
- const sidecars = [];
120
- if (id === "pi") {
121
- const legacy = join(dirname(s.file), "pi-purge.ts");
122
- if (existsSync(legacy)) {
123
- const off = legacy + SIDECAR;
124
- rmSync(off, { force: true });
125
- renameSync(legacy, off);
126
- sidecars.push({ from: off, to: legacy });
127
- }
128
- }
129
-
130
- writeFileSync(s.file, s.render({ personaId, personaBody }), "utf8");
131
- return { file: s.file, layer: s.layer, note: s.note, sidecars };
132
- }
133
-
134
- export function uninstallSlash(id, root) {
135
- const s = resolveSlash(id, root);
136
- if (!s) return { error: `${id} 没有 /purge 通道` };
137
- if (id === "dsh") {
138
- removePlugin(SLASH_RESIDENT);
139
- return { removed: [s.file] };
140
- }
141
- const removed = [];
142
- if (existsSync(s.file)) {
143
- rmSync(s.file, { force: true });
144
- removed.push(s.file);
145
- }
146
- if (id === "pi") {
147
- const legacy = join(dirname(s.file), "pi-purge.ts");
148
- if (existsSync(legacy + SIDECAR)) {
149
- renameSync(legacy + SIDECAR, legacy);
150
- removed.push(`${legacy}(已还原原 pi-purge 扩展)`);
151
- }
152
- }
153
- return { removed };
154
- }
155
-
156
- export function slashStatus(root) {
157
- const out = [];
158
- for (const id of Object.keys({ pi: 1, omp: 1, claude: 1, codex: 1, opencode: 1, dsh: 1 })) {
159
- const s = resolveSlash(id, root);
160
- if (!s) continue;
161
- const installed = s.probe ? s.probe() : existsSync(s.file);
162
- let personaId = null;
163
- if (installed && existsSync(s.file)) {
164
- const text = readFileSync(s.file, "utf8");
165
- personaId = (text.match(/agent-purge: ([a-z0-9_-]+)/i) || [])[1] ?? (id === "pi" || id === "omp" ? "(跟随 state.json)" : null);
166
- }
167
- out.push({ id, layer: s.layer, offered: s.offered !== false, hot: s.hot, file: s.file, installed, personaId, trigger: s.trigger, note: s.note });
168
- }
169
- return out;
170
- }
171
-
172
- /** 换人设后重渲染已安装的 user 层命令文件(它们是烘焙进去的,必须刷新) */
173
- export function refreshSlashes(root, armedIds, { personaId, personaBody }) {
174
- const updated = [];
175
- for (const id of armedIds) {
176
- const s = resolveSlash(id, root);
177
- if (!s?.hot && s && s.offered !== false && existsSync(s.file)) {
178
- writeFileSync(s.file, s.render({ personaId, personaBody }), "utf8");
179
- updated.push(s.file);
180
- }
181
- }
182
- return updated;
183
- }
184
-
185
- function commandMd(description, body, personaId) {
186
- return `---\ndescription: ${description}\n---\n\n${body.trim()}\n\n<!-- agent-purge: ${personaId} -->\n`;
187
- }
188
-
189
- /** pi 扩展源码:每次 before_agent_start 现读 state.json → 换 persona 立即跟着变 */
190
- function piExtension(root) {
191
- const statePath = join(root, "state.json").replace(/\\/g, "/");
192
- const personasDir = join(root, "personas").replace(/\\/g, "/");
193
- return `/**
194
- * agent-purge 生成的 /purge 扩展(删除本文件即取消)。
195
- * 与手写版 pi-purge 的区别:人设不内嵌,每次现读 state.json → 换 persona 下一轮就跟上。
196
- */
197
- import { readFileSync, writeFileSync } from "node:fs";
198
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
199
-
200
- // 数据目录跟安装目录解耦:AGENT_PURGE_HOME 优先,没有才用安装时记住的路径
201
- const HOME = process.env.AGENT_PURGE_HOME ? String(process.env.AGENT_PURGE_HOME).replace(/\\\\/g, "/") : null;
202
- const STATE = (HOME ? HOME + "/state.json" : ${JSON.stringify(statePath)});
203
- const PERSONAS = (HOME ? HOME + "/personas" : ${JSON.stringify(personasDir)});
204
- const HOST_IDENTITY = "You are an expert coding assistant operating inside pi";
205
-
206
- function stripFrontmatter(raw: string): string {
207
- if (!raw.startsWith("---")) return raw;
208
- const end = raw.indexOf("\\n---", 3);
209
- return end === -1 ? raw : raw.slice(end + 4).replace(/^\\r?\\n/, "");
210
- }
211
-
212
- function loadPersona(): { id: string | null; body: string } {
213
- try {
214
- const state = JSON.parse(readFileSync(STATE, "utf8")) as { persona?: string };
215
- // 启动器的 --persona 优先于 state.json:否则 --persona X 只有 env 变了、人设还是旧的那份
216
- const id = process.env.AGENT_PURGE_PERSONA ?? state.persona ?? null;
217
- if (!id) return { id: null, body: "" };
218
- const body = stripFrontmatter(readFileSync(PERSONAS + "/" + id + ".md", "utf8")).trim();
219
- return { id, body };
220
- } catch {
221
- return { id: process.env.AGENT_PURGE_PERSONA ?? null, body: "" };
222
- }
223
- }
224
-
225
- function loadState(): { persona: string | null; mode: string; shim: boolean; piAuto: boolean; piUserLayer: boolean } {
226
- // 启动器会用环境变量告知本次运行用的 persona/mode(--persona / --mode 覆盖);
227
- // 否则读 state.json —— 注意 mode 存在 default.mode,不是顶层。
228
- const envPersona = process.env.AGENT_PURGE_PERSONA;
229
- const envMode = process.env.AGENT_PURGE_MODE;
230
- try {
231
- const st = JSON.parse(readFileSync(STATE, "utf8")) as {
232
- persona?: string;
233
- default?: { harness?: string; mode?: string };
234
- armed?: Record<string, string[]>;
235
- piAuto?: boolean;
236
- piUserLayer?: boolean;
237
- };
238
- return {
239
- persona: envPersona ?? st.persona ?? null,
240
- mode: st.default?.mode ?? "replace",
241
- shim: Array.isArray(st.armed?.pi) && st.armed.pi.includes("shim"),
242
- piAuto: st.piAuto === true,
243
- piUserLayer: st.piUserLayer === true,
244
- };
245
- } catch {
246
- return { persona: envPersona ?? null, mode: "replace", shim: false, piAuto: process.env.AGENT_PURGE_FORCE === "1", piUserLayer: false };
247
- }
248
- }
249
-
250
- function cutHostIdentity(prompt: string): string {
251
- const at = prompt.indexOf(HOST_IDENTITY);
252
- if (at === -1) return prompt;
253
- const lineEnd = prompt.indexOf("\\n", at);
254
- return prompt.slice(0, at) + prompt.slice(lineEnd === -1 ? prompt.length : lineEnd + 1);
255
- }
256
-
257
- export default function agentPurge(pi: ExtensionAPI) {
258
- // 自动注入的唯一开关:启动器明确要求(AGENT_PURGE_FORCE=1)。
259
- // 谁会设它:agent-purge pi,以及 PATH 上的接管壳(它就是把命令转给 agent-purge pi)。
260
- // 所以“只装会话内热切”的扩展不会自己开工;直呼真身二进制也不会被劫持。
261
- let forced: "on" | "off" | null = null;
262
- let mode: "strict" | "replace" | "append" = "strict";
263
- // 启动器用 AGENT_PURGE_MODE 指定本次启动的初始模式;/purge mode 必须能覆盖它,
264
- // 否则环境变量会在每轮把热切换改回去。
265
- const ENV_MODE = process.env.AGENT_PURGE_MODE;
266
- let modeOverride: "strict" | "replace" | "append" | "off" | null =
267
- ENV_MODE === "off" ? "off" : ENV_MODE === "strict" || ENV_MODE === "append" ? ENV_MODE : ENV_MODE ? "replace" : null;
268
- let cache = { id: null as string | null, body: "" };
269
-
270
- // 两条来源:启动器点名(FORCE=1),或 state.json 的 piAuto(裸敲 pi 也注入)
271
- const autoOn = (st: { piAuto?: boolean }) =>
272
- process.env.AGENT_PURGE_FORCE === "1" || st.piAuto === true;
273
-
274
- pi.on("session_start", async () => {
275
- forced = null;
276
- cache = { id: null, body: "" };
277
- });
278
-
279
- pi.on("before_agent_start", async (event) => {
280
- const st = loadState();
281
- const effective = modeOverride ?? st.mode;
282
- mode = effective === "strict" || effective === "append" ? effective : "replace";
283
- const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
284
- if (on !== "on") return;
285
- cache = loadPersona();
286
- if (!cache.body) return;
287
- if (mode === "strict") {
288
- const cwd = event.systemPromptOptions?.cwd;
289
- return { systemPrompt: cwd ? cache.body + "\\n\\nCurrent working directory: " + String(cwd).replace(/\\\\/g, "/") : cache.body };
290
- }
291
- const host = mode === "replace" ? cutHostIdentity(event.systemPrompt) : event.systemPrompt;
292
- return { systemPrompt: host.length > 0 ? cache.body + "\\n\\n" + host : cache.body };
293
- });
294
-
295
- // 真正保证“会话中途热切也生效”的是这个拦截面:每个模型请求前重写 transcript 里的
296
- // system 消恵。before_agent_start 的返回值在部分 pi 版本上只对首轮生效(会话已有上下文时
297
- // 会被 transcript 覆盖),所以这里再兜一层;两层同时生效时靠 body 前缀去重。
298
- try {
299
- pi.on("context_with_system", async (event: any) => {
300
- const st = loadState();
301
- const m = modeOverride ?? st.mode;
302
- const on = m === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
303
- if (on !== "on") return;
304
- const p = loadPersona();
305
- if (!p.body) return;
306
- const messages: any[] = Array.isArray(event?.messages) ? event.messages : [];
307
- if (messages.length === 0) return;
308
- const textOf = (msg: any): string => {
309
- const c = msg?.content;
310
- if (typeof c === "string") return c;
311
- if (Array.isArray(c)) return c.map((b: any) => (b && typeof b.text === "string" ? b.text : "")).join("");
312
- return "";
313
- };
314
- // pi 1.4 约定:这个钩子的返回值会被忽略,必须原地改 event.messages
315
- const out = messages;
316
- const at = out.findIndex((msg: any) => msg?.role === "system");
317
- const host = at >= 0 ? textOf(out[at]) : "";
318
- if (!host.includes(p.body.slice(0, 40))) {
319
- let next: string;
320
- if (m === "strict") next = p.body;
321
- else if (m === "append") next = (host ? host + "\\n\\n" : "") + p.body;
322
- else next = host ? p.body + "\\n\\n" + cutHostIdentity(host) : p.body;
323
- if (at >= 0) out[at].content = next;
324
- else out.unshift({ role: "system", content: next });
325
- }
326
-
327
- // 有些模型(实测 deepseek-v4.1-flash 走 mg 网关)直接无视 system 角色:
328
- // 再往最后一条用户消息前面贴一份,让指令一定被看到。
329
- if (st.piUserLayer) {
330
- for (let i = out.length - 1; i >= 0; i--) {
331
- if (out[i]?.role !== "user") continue;
332
- const shown = textOf(out[i]);
333
- if (shown.includes(p.body.slice(0, 40))) break;
334
- out[i].content = "<agent-purge>\\n" + p.body + "\\n</agent-purge>\\n\\n" + shown;
335
- break;
336
- }
337
- }
338
- return;
339
- });
340
- } catch {
341
- // 老版本 pi 没有 context_with_system:退回 before_agent_start 的返回值
342
- }
343
-
344
- pi.registerCommand("purge", {
345
- description: "会话级接管开关(人设跟随 agent-purge 的 state.json)",
346
- getArgumentCompletions: (prefix: string) =>
347
- ["on", "off", "always", "status", "reload", "mode"].filter((v) => v.startsWith(prefix)).map((value) => ({ value, label: value })),
348
- handler: async (args: string, ctx) => {
349
- const cmd = (args.trim().split(/\\s+/)[0] ?? "").toLowerCase();
350
- const notify = (t: string, level: "info" | "error" = "info") => ctx.ui.notify(t, level);
351
- const describe = () => {
352
- const p = loadPersona();
353
- const st = loadState();
354
- const effective = modeOverride ?? st.mode;
355
- const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
356
- const why =
357
- on === "on"
358
- ? forced
359
- ? "本会话 /purge 开的"
360
- : process.env.AGENT_PURGE_FORCE === "1"
361
- ? "启动器要求注入"
362
- : "接管裸命令已开"
363
- : effective === "off"
364
- ? "总电源关着"
365
- : st.piAuto
366
- ? "裸启动已开,这次是你手动关的"
367
- : "只装了会话内热切,裸启动不注入";
368
- return [
369
- "状态: " + (on === "on" ? "开启" : "关闭") + "(" + why + ")",
370
- "模式: " + effective,
371
- "裸启动也注入: " + (st.piAuto ? "开" : "关") + "(/purge always on|off)",
372
- "人设: " + (p.id ?? "(state.json 里没有 persona)") + " " + (p.body.length ? p.body.length + " 字符" : "未加载"),
373
- "来源: " + STATE,
374
- ].join("\\n");
375
- };
376
- const turnOn = () => {
377
- const st = loadState();
378
- if ((modeOverride ?? st.mode) === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
379
- const p = loadPersona();
380
- if (!p.body) return notify("开启失败:state.json 里没有可用 persona(先 agent-purge use <id>)", "error");
381
- forced = "on";
382
- cache = p;
383
- notify("已开启(仅本会话),每轮注入 " + p.body.length + " 字符。\\n" + describe());
384
- };
385
- switch (cmd) {
386
- case "": {
387
- // 空参 = 切换。判断依据是“现在到底注不注入”,不是只看本会话手工按过什么:
388
- // 直接裸启动 pi 的时候 forced 是 null、注入本来是关的,第一次 /purge 应该是「开」而不是「关」。
389
- const st = loadState();
390
- const injecting =
391
- (modeOverride ?? st.mode) !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
392
- if (!injecting) return turnOn();
393
- forced = "off";
394
- return notify("已关闭本会话注入,恢复 pi 原始系统提示词。再输入 /purge 即重新开启。");
395
- }
396
- case "on":
397
- return turnOn();
398
- case "off":
399
- forced = "off";
400
- return notify("已关闭本会话注入,恢复 pi 原始系统提示词。");
401
- case "status":
402
- return notify(describe());
403
- case "reload":
404
- // reload 只重读磁盘;本会话手工开的开关不能被重置
405
- cache = loadPersona();
406
- return notify("已重读 state.json(本会话开关不变)。\\n" + describe());
407
- case "mode": {
408
- const want = args.trim().split(/\\s+/)[1];
409
- if (want !== "strict" && want !== "replace" && want !== "append") {
410
- return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=清宿主身份句;append=宿主后追加", "error");
411
- }
412
- try {
413
- // 以前只改内存局部变量,下一轮就被 state.json 覆盖 → 写进去才算真切换
414
- const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, any>;
415
- raw.default = { ...(raw.default ?? { harness: "pi" }), mode: want };
416
- writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
417
- modeOverride = want;
418
- return notify("模式 → " + want + "(已写入 state.json,下一轮生效)\\n" + describe());
419
- } catch (e) {
420
- return notify("写 state.json 失败: " + String(e), "error");
421
- }
422
- }
423
- case "always": {
424
- const want = args.trim().split(/\\s+/)[1];
425
- if (want !== "on" && want !== "off")
426
- return notify("用法: /purge always on|off —— 让裸敲 pi 也自动注入(写 state.json,新会话生效)", "error");
427
- try {
428
- const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, unknown>;
429
- raw.piAuto = want === "on";
430
- writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
431
- return notify("已写入 piAuto=" + want + "(新会话起生效)\\n" + describe());
432
- } catch (e) {
433
- return notify("写 state.json 失败: " + String(e), "error");
434
- }
435
- }
436
- default:
437
- return notify("用法: /purge [on|off|always on|off|status|reload|mode strict|replace|append]", "error");
438
- }
439
- },
440
- });
441
- }
442
- `;
443
- }
444
-
445
- /** omp 扩展源码:每轮 before_agent_start 现读 state.json,/purge 热切 */
446
- function ompExtension(root) {
447
- const statePath = join(root, "state.json").replace(/\\/g, "/");
448
- const personasDir = join(root, "personas").replace(/\\/g, "/");
449
- return `/**
450
- * agent-purge 生成的 /purge 扩展(omp 版;删除本文件即取消)。
451
- * 人设不内嵌:每次 before_agent_start 现读 state.json → 换 persona / 改模式下一轮就跟上。
452
- */
453
- import { readFileSync, writeFileSync } from "node:fs";
454
- import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
455
-
456
- const HOME = process.env.AGENT_PURGE_HOME ? String(process.env.AGENT_PURGE_HOME).replace(/\\\\/g, "/") : null;
457
- const STATE = (HOME ? HOME + "/state.json" : ${JSON.stringify(statePath)});
458
- const PERSONAS = (HOME ? HOME + "/personas" : ${JSON.stringify(personasDir)});
459
-
460
- function stripFrontmatter(raw: string): string {
461
- if (!raw.startsWith("---")) return raw;
462
- const end = raw.indexOf("\\n---", 3);
463
- return end === -1 ? raw : raw.slice(end + 4).replace(/^\\r?\\n/, "");
464
- }
465
-
466
- function loadPersona(): { id: string | null; body: string } {
467
- try {
468
- const st = JSON.parse(readFileSync(STATE, "utf8")) as { persona?: string };
469
- const id = process.env.AGENT_PURGE_PERSONA ?? st.persona ?? null;
470
- if (!id) return { id: null, body: "" };
471
- return { id, body: stripFrontmatter(readFileSync(PERSONAS + "/" + id + ".md", "utf8")).trim() };
472
- } catch {
473
- return { id: process.env.AGENT_PURGE_PERSONA ?? null, body: "" };
474
- }
475
- }
476
-
477
- function loadState(): { persona: string | null; mode: string; ompAuto: boolean } {
478
- const envPersona = process.env.AGENT_PURGE_PERSONA;
479
- const envMode = process.env.AGENT_PURGE_MODE;
480
- try {
481
- const st = JSON.parse(readFileSync(STATE, "utf8")) as {
482
- persona?: string;
483
- default?: { harness?: string; mode?: string };
484
- ompAuto?: boolean;
485
- };
486
- return {
487
- persona: envPersona ?? st.persona ?? null,
488
- mode: envMode ?? st.default?.mode ?? "replace",
489
- ompAuto: st.ompAuto === true,
490
- };
491
- } catch {
492
- return { persona: envPersona ?? null, mode: envMode ?? "replace", ompAuto: process.env.AGENT_PURGE_FORCE === "1" };
493
- }
494
- }
495
-
496
- function toLines(value: unknown): string[] {
497
- if (Array.isArray(value)) return value.map((v) => String(v));
498
- return value ? [String(value)] : [];
499
- }
500
-
501
- export default function agentPurge(pi: ExtensionAPI) {
502
- let forced: "on" | "off" | null = null;
503
- // 启动器的 AGENT_PURGE_MODE 只决定初始模式;/purge mode 必须能盖住它,
504
- // 否则下一轮又被环境变量改回去。
505
- const ENV_MODE = process.env.AGENT_PURGE_MODE;
506
- let modeOverride: "strict" | "replace" | "append" | "off" | null =
507
- ENV_MODE === "off" ? "off" : ENV_MODE === "strict" || ENV_MODE === "append" ? ENV_MODE : ENV_MODE ? "replace" : null;
508
- let cache = { id: null as string | null, body: "" };
509
- const autoOn = (st: { ompAuto?: boolean }) => process.env.AGENT_PURGE_FORCE === "1" || st.ompAuto === true;
510
-
511
- pi.on("session_start", async () => {
512
- forced = null;
513
- cache = { id: null, body: "" };
514
- });
515
-
516
- // omp 每轮重拼 system 提示词;这里直接返回新的 systemPrompt(string[])。
517
- pi.on("before_agent_start", async (event) => {
518
- const st = loadState();
519
- const effective = modeOverride ?? st.mode;
520
- const mode = effective === "strict" || effective === "append" ? effective : "replace";
521
- const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
522
- if (on !== "on") return;
523
- cache = loadPersona();
524
- if (!cache.body) return;
525
- const hostBlocks = toLines((event as any).systemPrompt).filter((l: string) => l.length > 0);
526
- // AGENTS.md 常驻通道可能已经把同一份人设塞进 systemPrompt:前缀相同就别再叠一遍
527
- if (hostBlocks.some((b: string) => b.includes(cache.body.slice(0, 40)))) return;
528
- // strict=只发人设;replace=用新人设换掉基础身份块、保留 PROJECT 块;append=原样保留再追加
529
- if (mode === "strict") return { systemPrompt: [cache.body] };
530
- if (mode === "append") return { systemPrompt: [...hostBlocks, cache.body] };
531
- const projectBlocks = hostBlocks.filter((b: string, i: number) => i > 0 || b.trim().startsWith("PROJECT"));
532
- return { systemPrompt: [cache.body, ...projectBlocks] };
533
- });
534
-
535
- pi.registerCommand("purge", {
536
- description: "会话级接管开关(人设跟随 agent-purge 的 state.json)",
537
- getArgumentCompletions: (prefix: string) =>
538
- ["on", "off", "always", "status", "reload", "mode"].filter((v) => v.startsWith(prefix)).map((value) => ({ value, label: value })),
539
- handler: async (args: string, ctx: any) => {
540
- const cmd = (args.trim().split(/\\s+/)[0] ?? "").toLowerCase();
541
- const notify = (t: string, level: "info" | "error" = "info") => ctx.ui.notify(t, level);
542
- const describe = () => {
543
- const p = loadPersona();
544
- const st = loadState();
545
- const effective = modeOverride ?? st.mode;
546
- const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
547
- const why =
548
- on === "on"
549
- ? forced
550
- ? "本会话 /purge 开的"
551
- : process.env.AGENT_PURGE_FORCE === "1"
552
- ? "启动器要求注入"
553
- : "裸启动已开"
554
- : effective === "off"
555
- ? "总电源关着"
556
- : "只装了会话内热切,裸启动不注入";
557
- return [
558
- "状态: " + (on === "on" ? "开启" : "关闭") + "(" + why + ")",
559
- "模式: " + effective,
560
- "裸启动也注入: " + (st.ompAuto ? "开" : "关") + "(/purge always on|off)",
561
- "人设: " + (p.id ?? "(state.json 里没有 persona)") + " " + (p.body.length ? p.body.length + " 字符" : "未加载"),
562
- "来源: " + STATE,
563
- ].join("\\n");
564
- };
565
- const turnOn = () => {
566
- const st = loadState();
567
- if ((modeOverride ?? st.mode) === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
568
- const p = loadPersona();
569
- if (!p.body) return notify("开启失败:state.json 里没有可用 persona(先 agent-purge use <id>)", "error");
570
- forced = "on";
571
- cache = p;
572
- notify("已开启(仅本会话),每轮注入 " + p.body.length + " 字符。\\n" + describe());
573
- };
574
- switch (cmd) {
575
- case "": {
576
- const st = loadState();
577
- const injecting =
578
- (modeOverride ?? st.mode) !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
579
- if (!injecting) return turnOn();
580
- forced = "off";
581
- return notify("已关闭本会话注入,恢复 omp 原始系统提示词。再输入 /purge 即重新开启。");
582
- }
583
- case "on":
584
- return turnOn();
585
- case "off":
586
- forced = "off";
587
- return notify("已关闭本会话注入,恢复 omp 原始系统提示词。");
588
- case "status":
589
- return notify(describe());
590
- case "reload":
591
- cache = loadPersona();
592
- return notify("已重读 state.json(本会话开关不变)。\\n" + describe());
593
- case "mode": {
594
- const want = args.trim().split(/\\s+/)[1];
595
- if (want !== "strict" && want !== "replace" && want !== "append") {
596
- return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=换基础提示、保留项目规则;append=宿主后追加", "error");
597
- }
598
- try {
599
- const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, any>;
600
- raw.default = { ...(raw.default ?? { harness: "omp" }), mode: want };
601
- writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
602
- modeOverride = want;
603
- return notify("模式 → " + want + "(已写入 state.json,下一轮生效)\\n" + describe());
604
- } catch (e) {
605
- return notify("写 state.json 失败: " + String(e), "error");
606
- }
607
- }
608
- case "always": {
609
- const want = args.trim().split(/\\s+/)[1];
610
- if (want !== "on" && want !== "off")
611
- return notify("用法: /purge always on|off —— 让裸敲 omp 也自动注入(写 state.json,新会话生效)", "error");
612
- try {
613
- const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, unknown>;
614
- raw.ompAuto = want === "on";
615
- writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
616
- return notify("已写入 ompAuto=" + want + "(新会话起生效)\\n" + describe());
617
- } catch (e) {
618
- return notify("写 state.json 失败: " + String(e), "error");
619
- }
620
- }
621
- default:
622
- return notify("用法: /purge [on|off|always on|off|status|reload|mode strict|replace|append]", "error");
623
- }
624
- },
625
- });
626
- }
627
- `;
628
- }
1
+ // 会话内 `/purge`:把当前 persona 装进去,之后在这个会话里说一句 `/purge` 就切。
2
+ //
3
+ // 这里是整个项目里唯一"照实说实话"的地方:**斜杠命令不等于系统提示词注入**。
4
+ // pi / omp —— 扩展能在 before_agent_start 改写 systemPrompt → sys 层,热切换(实测 100%)
5
+ // claude —— ~/.claude/commands/purge.md 是**用户消息展开** → user 层
6
+ // codex —— ~/.codex/prompts/purge.md 同上 → user 层
7
+ // opencode —— ~/.config/opencode/command/purge.md 同上 → user 层(sys 层要靠 plugin 常驻)
8
+ // user 层的注入成功率由 matrix.json 的 armor.probe.*.user 记录,`agent-purge ls` 会标出来。
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { configHome } from "./paths.mjs";
12
+ import { extensionSource } from "./extension.mjs";
13
+ import { SLASH_RESIDENT, addPlugin, hasPluginAny, pluginFileOnDisk, removePlugin, slashSource } from "./dsh.mjs";
14
+ import { record } from "./footprints.mjs";
15
+ import { readStash, removeOwnedFile, writeStash, looksOwned, preserveEdited, unusedBackup } from "./persist.mjs";
16
+ import { cancel, forgetHarness } from "./footprints.mjs";
17
+
18
+ const SIDECAR = ".disabled-by-agent-purge";
19
+
20
+ /** 谁有 /purge、走哪层、文件放哪 */
21
+ export function resolveSlash(id, root) {
22
+ const homeOf = configHome;
23
+ const specs = {
24
+ pi: () => {
25
+ const base = homeOf("pi");
26
+ if (!base) return null;
27
+ return {
28
+ layer: "sys",
29
+ offered: true,
30
+ hot: true,
31
+ trigger: "/purge on|off|status|mode",
32
+ file: join(base, "extensions", "agent-purge.ts"),
33
+ render: () => extensionSource(root, "pi"),
34
+ note: "扩展在 before_agent_start 改写 systemPrompt:真系统提示词层,且跟随 state.json 换人设,无需重装",
35
+ };
36
+ },
37
+ claude: () => {
38
+ const base = homeOf("claude");
39
+ if (!base) return null;
40
+ return {
41
+ layer: "user",
42
+ offered: false,
43
+ hot: false,
44
+ trigger: "/purge",
45
+ file: join(base, "commands", "purge.md"),
46
+ render: ({ personaBody, personaId }) => commandMd("把本次会话的规则切换为 agent-purge 当前人设", personaBody, personaId),
47
+ note: "claude 的 slash command 展开成用户消息 → user 层(bench 实测 0/2 无效);人设要生效请走 agent-purge claude(--system-prompt-file)",
48
+ };
49
+ },
50
+ codex: () => {
51
+ const base = homeOf("codex");
52
+ if (!base) return null;
53
+ return {
54
+ layer: "user",
55
+ offered: false,
56
+ hot: false,
57
+ trigger: "/purge",
58
+ file: join(base, "prompts", "purge.md"),
59
+ render: ({ personaBody, personaId }) => `${personaBody.trim()}\n\n<!-- agent-purge: ${personaId} -->\n`,
60
+ note: "codex 的自定义 prompt 是用户消息 → user 层(bench 实测 0/2 无效);codex 只认启动时读的 model_instructions_file",
61
+ };
62
+ },
63
+ opencode: () => {
64
+ const base = homeOf("opencode");
65
+ if (!base) return null;
66
+ return {
67
+ layer: "user",
68
+ offered: false,
69
+ hot: false,
70
+ trigger: "/purge",
71
+ file: join(base, "command", "purge.md"),
72
+ render: ({ personaBody, personaId }) => commandMd("装入 agent-purge 当前人设", personaBody, personaId),
73
+ note: "opencode 的 command 是用户消息 → user 层(bench 实测 0/2 无效);要 sys 层请用 install(chat.system.transform 插件)",
74
+ };
75
+ },
76
+ omp: () => {
77
+ const base = homeOf("omp");
78
+ if (!base) return null;
79
+ return {
80
+ layer: "sys",
81
+ offered: true,
82
+ hot: true,
83
+ trigger: "/purge on|off|status|mode",
84
+ file: join(base, "agent", "extensions", "agent-purge.ts"),
85
+ render: () => extensionSource(root, "omp"),
86
+ note: "扩展在 before_agent_start 改写 systemPrompt 数组:真系统提示词层,/purge 热切,下一轮生效",
87
+ };
88
+ },
89
+ // dsh:不是写文件,而是往三个 profile 各装一个 cordis 小插件(agent-purge-slash)。
90
+ // 它只注册 /purge 命令、改 state.json,不碰系统提示词 —— 注入归 agent-purge 常驻插件。
91
+ dsh: () => ({
92
+ layer: "sys",
93
+ offered: true,
94
+ hot: true,
95
+ trigger: "/purge on|off|status|mode",
96
+ file: pluginFileOnDisk(SLASH_RESIDENT),
97
+ // 真值看挂载块 + 文件,不看某个 profile 的固定路径(没建过 desktop 就无意义);任一份在就算能用
98
+ probe: () => hasPluginAny(SLASH_RESIDENT),
99
+ render: () => slashSource(root),
100
+ note: "cordis 插件注册 /purge(/ap 为别名):改 state.json,下一句生效,不产生模型消息;注入靠常驻插件,两样都要启用",
101
+ }),
102
+ };
103
+ const spec = specs[id];
104
+ if (!spec) return null;
105
+ const base = spec();
106
+ if (!base) return null;
107
+ return { id, ...base };
108
+ }
109
+
110
+ export function installSlash(id, root, { personaId, personaBody } = {}) {
111
+ const s = resolveSlash(id, root);
112
+ if (!s) return { error: `${id} 没有 /purge 通道` };
113
+ if (s.offered === false) return { error: `${s.note}(已下线,不再提供)` };
114
+ // dsh:装 cordis 插件到三个 profile,没有单文件可写。
115
+ // 没跑过 dsh 时 addPlugin 会抛;这里必须转成 {error},否则 arm --all 会半途崩掉。
116
+ if (id === "dsh") {
117
+ try {
118
+ const r = addPlugin(SLASH_RESIDENT, slashSource(root));
119
+ return { file: pluginFileOnDisk(SLASH_RESIDENT), layer: s.layer, note: s.note, sidecars: [], profiles: r.profiles };
120
+ } catch (e) {
121
+ return { error: e.message };
122
+ }
123
+ }
124
+ mkdirSync(dirname(s.file), { recursive: true });
125
+
126
+ // pi:旧的 pi-purge.ts 也注册 /purge,会撞名 → 先移开,卸载时还原
127
+ const sidecars = [];
128
+ if (id === "pi") {
129
+ const legacy = join(dirname(s.file), "pi-purge.ts");
130
+ if (existsSync(legacy)) {
131
+ const off = unusedBackup(legacy);
132
+ renameSync(legacy, off);
133
+ sidecars.push({ from: off, to: legacy });
134
+ }
135
+ }
136
+
137
+ // 扩展文件会整份覆盖用户的同名文件:写之前存原字节,卸载时才能还回去
138
+ const old = existsSync(s.file) ? readFileSync(s.file, "utf8") : null;
139
+ const content = s.render({ personaId, personaBody });
140
+ const previous = readStash(s.file, root);
141
+ if (previous && old !== previous.after && looksOwned(old)) preserveEdited(s.file, old);
142
+ writeStash(s.file, { file: s.file, before: previous ? previous.before : looksOwned(old) ? null : old, after: content }, root);
143
+ writeFileSync(s.file, content, "utf8");
144
+ record(root, {
145
+ kind: "file",
146
+ harness: id,
147
+ channel: "slash",
148
+ path: s.file,
149
+ undo: { type: "file-delete" },
150
+ createdByUs: old === null,
151
+ });
152
+ return { file: s.file, layer: s.layer, note: s.note, sidecars, replaced: old !== null };
153
+ }
154
+
155
+ export function uninstallSlash(id, root) {
156
+ const s = resolveSlash(id, root);
157
+ if (!s) return { error: `${id} 没有 /purge 通道` };
158
+ if (id === "dsh") {
159
+ const result = removePlugin(SLASH_RESIDENT);
160
+ forgetHarness(root, id, "slash");
161
+ return { removed: [s.file], ...result };
162
+ }
163
+ const removed = [];
164
+ const notes = [];
165
+ if (existsSync(s.file)) {
166
+ const r = removeOwnedFile(s.file, { root });
167
+ if (r.action === "skipped-foreign") {
168
+ // 不是我们写的:不删。账本留着不动,clean 的 leftovers 会把这条报出来
169
+ notes.push(`${s.file} 不是 agent-purge 写的,未删除(要 /purge 消失请手工处理)`);
170
+ } else if (r.action === "restored-file") {
171
+ removed.push(s.file);
172
+ notes.push(`${s.file} 已还原为安装前的内容`);
173
+ } else if (r.action === "disabled-edited-file") {
174
+ removed.push(s.file);
175
+ notes.push(`${s.file} 已被改过,改名为 ${r.moved} 停用(原字节保留)`);
176
+ } else if (r.action !== "already-gone") {
177
+ removed.push(s.file);
178
+ }
179
+ }
180
+ if (id === "pi") {
181
+ const legacy = join(dirname(s.file), "pi-purge.ts");
182
+ if (!existsSync(legacy) && existsSync(legacy + SIDECAR)) {
183
+ renameSync(legacy + SIDECAR, legacy);
184
+ removed.push(`${legacy}(已还原原 pi-purge 扩展)`);
185
+ }
186
+ }
187
+ if (!existsSync(s.file)) cancel(root, { kind: "file", path: s.file });
188
+ return { removed, ...(notes.length ? { notes } : {}) };
189
+ }
190
+
191
+ export function slashStatus(root) {
192
+ const out = [];
193
+ for (const id of Object.keys({ pi: 1, omp: 1, claude: 1, codex: 1, opencode: 1, dsh: 1 })) {
194
+ const s = resolveSlash(id, root);
195
+ if (!s) continue;
196
+ const installed = s.probe ? s.probe() : existsSync(s.file);
197
+ let personaId = null;
198
+ if (installed && existsSync(s.file)) {
199
+ const text = readFileSync(s.file, "utf8");
200
+ personaId = (text.match(/agent-purge: ([a-z0-9_-]+)/i) || [])[1] ?? (id === "pi" || id === "omp" ? "(跟随 state.json)" : null);
201
+ }
202
+ out.push({ id, layer: s.layer, offered: s.offered !== false, hot: s.hot, file: s.file, installed, personaId, trigger: s.trigger, note: s.note });
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** 换人设后重渲染已安装的 user 层命令文件(它们是烘焙进去的,必须刷新) */
208
+ export function refreshSlashes(root, armedIds, { personaId, personaBody }) {
209
+ const updated = [];
210
+ for (const id of armedIds) {
211
+ const s = resolveSlash(id, root);
212
+ if (!s?.hot && s && s.offered !== false && existsSync(s.file)) {
213
+ const old = readFileSync(s.file, "utf8");
214
+ const content = s.render({ personaId, personaBody });
215
+ // 刷新只改 after,before 沿用上一份:否则卸载时还原成的是上一次的人设,不是用户原文
216
+ const prev = readStash(s.file, root);
217
+ writeStash(s.file, { file: s.file, before: prev ? prev.before : old, after: content }, root);
218
+ writeFileSync(s.file, content, "utf8");
219
+ updated.push(s.file);
220
+ }
221
+ }
222
+ return updated;
223
+ }
224
+
225
+ function commandMd(description, body, personaId) {
226
+ return `---\ndescription: ${description}\n---\n\n${body.trim()}\n\n<!-- agent-purge: ${personaId} -->\n`;
227
+ }