agent-purge 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,16 @@ npm i -g agent-purge # 或 npx agent-purge web
32
32
 
33
33
  要求 Node ≥ 20,零依赖。
34
34
 
35
+ **用 magpie 网关?** 插件不在 npm 包里,在仓库的 `magpie-plugin/` 子目录——不要指向仓库根(根 `package.json` 是 CLI,只有 `bin`,magpie 会拒绝):
36
+
37
+ ```bash
38
+ magpie plugin add /path/to/agent-purge/magpie-plugin
39
+ ```
40
+
41
+ 首次使用先跑一次 `agent-purge web`(打开 http://127.0.0.1:7788 的控制台,先看/选一个人设;不想开界面就用 `agent-purge ls`),它会生成 `~/.agent-purge/personas/`;再跑 `node magpie-plugin/gen-personas.mjs` 生成 Persona 页的人设清单。
42
+
43
+ 详见下文「magpie 网关插件(可选)」。
44
+
35
45
  **数据独立存放**,升级不会被覆盖:默认 `~/.agent-purge/`(Windows `C:\Users\<你>\.agent-purge\`),里面是 `state.json` 和 `personas/`。想换位置就设环境变量:
36
46
 
37
47
  ```bash
@@ -63,7 +73,7 @@ agent-purge pi --persona <id> # 只这一轮用别的
63
73
  | `codex` | 启动参数 `-c model_instructions_file=` | 0.163.0 | 21,026 | 24 |
64
74
  | `claude` | `--system-prompt-file` + 二进制补丁清残留身份句 | 2.1.304 | 6,379 | 156 |
65
75
  | `grok` | `--system-prompt-override` | 1.0.62 | 5,146 | 21 |
66
- | `omp` | `--system-prompt` | 17.4.2 | 34,511 | 10,106 |
76
+ | `omp` | 原生扩展:`before_agent_start` 改写 systemPrompt 数组,会话内 `/purge` 热切;`--ask` 仍走 `--system-prompt` | 17.4.2 | 34,511 | 10,106 |
67
77
  | `hermes` | 临时 HOME 里替换 `SOUL.md` | 0.21.5 | 8,139 | 7,554 |
68
78
  | `zcode` | UserPromptSubmit hook(user 角色)+ `customSystemPrompt` | 0.16.9 | 9,359 | 9,359 |
69
79
  | `opencode` | cwd 下 `.opencode/plugin/*.ts` 原地改 `output.system` | 1.19.3 | 19,332 | 112 |
@@ -115,7 +125,7 @@ magpie plugin list # 应看到 async gatewa
115
125
 
116
126
  options 三键:`mode`、`persona`(人设 id)、`agents`(只对这些 magpie agent 生效,如 `["claude","codex"]`)。
117
127
 
118
- **边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs`。自测不依赖 magpie:`npm test`(钩子 26 项断言:四种协议 × 四种模式;页面逻辑 18 项:搜索过滤、options 合并、失败回滚)。
128
+ **边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs`。前提是已经运行过 `agent-purge`(如 `agent-purge web` 或 `agent-purge ls`),`~/.agent-purge/personas/` 里有 persona;目录不存在时脚本会直接提示。自测不依赖 magpie:`npm test`(钩子 26 项断言:四种协议 × 四种模式;页面逻辑 18 项:搜索过滤、options 合并、失败回滚)。
119
129
 
120
130
  ## 目录
121
131
 
@@ -72,13 +72,13 @@ export const backends = {
72
72
  const file = join(dir, "agent-purge.ts");
73
73
  const src = `export const AgentPurge = async () => ({
74
74
  "experimental.chat.system.transform": async (_input, output) => {
75
- output.system.splice(0, output.system.length, ${JSON.stringify(inline(personaText))});
75
+ ${mode === "append" ? `output.system.push(${JSON.stringify(inline(personaText))});` : `output.system.splice(0, output.system.length, ${JSON.stringify(inline(personaText))});`}
76
76
  },
77
77
  });
78
78
  `;
79
79
  return {
80
80
  cmd: "opencode",
81
- note: "[plugin] .opencode/plugin/agent-purge.ts(原地 splice output.system)",
81
+ note: `[plugin] .opencode/plugin/agent-purge.ts(${mode === "append" ? "push" : "原地 splice"} output.system)`,
82
82
  args,
83
83
  setup: () => {
84
84
  mkdirSync(dir, { recursive: true });
@@ -203,7 +203,7 @@ export const oneshots = {
203
203
  }),
204
204
  opencode: ({ prompt, personaText, mode, cwd }) => {
205
205
  if (mode === "off") return { cmd: "opencode", args: ["run", prompt] };
206
- const b = backends.opencode({ personaText, cwd, args: ["run", prompt] });
206
+ const b = backends.opencode({ personaText, mode, cwd, args: ["run", prompt] });
207
207
  return b;
208
208
  },
209
209
  opencode2: ({ prompt, personaText, mode }) => {
@@ -28,6 +28,9 @@ const recovered = recover(); // 上次进程被杀时留下的半改配置,开
28
28
  if (recovered > 0) console.log(`[restore] 回滚了 ${recovered} 个上次残留的配置文件`);
29
29
  if (argv.includes("--help") || argv.includes("-h")) help(); // 永远优先于默认值
30
30
  const flag = (name, fallback) => {
31
+ // 支持 --mode=append 这种写法(以前只认空格分隔,等号形式会被静默忽略)
32
+ const inline = argv.find((a) => a.startsWith(name + "="));
33
+ if (inline !== undefined) return inline.slice(name.length + 1);
31
34
  const i = argv.indexOf(name);
32
35
  return i === -1 ? fallback : argv[i + 1];
33
36
  };
@@ -107,12 +110,13 @@ async function launch(name) {
107
110
  const mode = explicitMode ?? state.default?.mode ?? "replace";
108
111
 
109
112
  // 裸敲 pi 的持久位(写 state.json,扩展下一会话读它)
110
- const autoFlag = name === "pi" ? flag("--auto", null) : null;
113
+ const autoFlag = name === "pi" || name === "omp" ? flag("--auto", null) : null;
111
114
  if (autoFlag !== null) {
112
115
  if (autoFlag !== "on" && autoFlag !== "off") die("--auto 只能是 on / off");
113
- const { setPiAuto } = await import("../lib/state.mjs");
114
- setPiAuto(DATA, autoFlag === "on");
115
- console.log(`[auto] 裸敲 pi ${autoFlag === "on" ? "会" : "不会"}自动注入(已写入 state.json)`);
116
+ const { setPiAuto, setOmpAuto } = await import("../lib/state.mjs");
117
+ if (name === "pi") setPiAuto(DATA, autoFlag === "on");
118
+ else setOmpAuto(DATA, autoFlag === "on");
119
+ console.log(`[auto] 裸敲 ${name} ${autoFlag === "on" ? "会" : "不会"}自动注入(已写入 state.json)`);
116
120
  }
117
121
 
118
122
  // pi 的人设落在哪一层:sys(默认,真系统提示词)或 user(塞进用户消息,给忽略 system 的模型)
@@ -130,7 +134,8 @@ async function launch(name) {
130
134
 
131
135
  // 实测策略:该 harness 的哪一层真的能注入(数据来自 matrix.json armor 段)
132
136
  const { plan } = await import("../lib/armor.mjs");
133
- const strategy = plan(matrix, name); if (!strategy.error) {
137
+ const strategy = plan(matrix, name);
138
+ if (!strategy.error) {
134
139
  if (strategy.requiresSeed) console.log(`[策略] ${name}:裸注入 sys 层实测 ${strategy.bare.sys}%,带人设词种子 ${strategy.persona.sys}% —— 需要 seed 层`);
135
140
  const noSys = strategy.blocked.find((b) => b.layer === "sys");
136
141
  if (noSys && mode !== "off") console.log(`[策略] ${name}:sys 层不可用(${noSys.reason}),本次只能靠 user 层`);
@@ -172,7 +177,7 @@ async function launch(name) {
172
177
  if (dd !== -1) {
173
178
  passthrough = argv.slice(dd + 1);
174
179
  } else {
175
- const ownWithValue = new Set(["--ask", "--mode", "--persona", "--cwd", "--auto"]);
180
+ const ownWithValue = new Set(["--ask", "--mode", "--persona", "--cwd", "--auto", "--layer"]);
176
181
  const tail = argv.slice(1);
177
182
  for (let i = 0; i < tail.length; i++) {
178
183
  const t = tail[i];
@@ -185,10 +190,12 @@ async function launch(name) {
185
190
  }
186
191
  }
187
192
 
193
+ const askEq = argv.find((a) => a.startsWith("--ask="));
188
194
  const askIdx = argv.indexOf("--ask");
195
+ const askPrompt = askEq !== undefined ? askEq.slice(6) : askIdx !== -1 ? argv[askIdx + 1] : null;
189
196
  let built;
190
- if (askIdx !== -1) {
191
- const raw = argv[askIdx + 1];
197
+ if (askPrompt !== null) {
198
+ const raw = askPrompt;
192
199
  if (!raw || raw.startsWith("--")) die('--ask 需要一个 prompt,例如: agent-purge codex --ask "解释这段代码"');
193
200
  const prompt = raw.trim();
194
201
  const one = oneshots[name];
@@ -210,6 +217,35 @@ async function launch(name) {
210
217
 
211
218
  if (built.note) console.log(built.note);
212
219
  if (built.residueHint) console.log(`[residue] ${built.residueHint}`);
220
+ const dryRun = argv.includes("--dry-run");
221
+ // 启动时把人设塞进扩展(omp / pi):命令行走不通 10K 人设,扩展也没有长度上限。
222
+ // omp 交互式启动时把后端的 --system-prompt 摘掉,否则扩展和命令行会各注入一次。
223
+ // 但 --profile / --no-extensions 下默认扩展目录不会加载:这两个情况保留命令行注入。
224
+ const ompProfile = name === "omp" ? (flag("--profile", null) ?? process.env.OMP_PROFILE ?? null) : null;
225
+ const ompNoExtensions = name === "omp" && (argv.includes("--no-extensions") || argv.some((a) => a.startsWith("--no-extensions=")));
226
+ const useExtension = name !== "omp" || (askPrompt === null && !ompProfile && !ompNoExtensions);
227
+ let extFile = null;
228
+ if ((name === "pi" || name === "omp") && !built.unsupported && !built.manual) {
229
+ const isOmp = name === "omp";
230
+ if (mode === "off") {
231
+ // 不装、也不喊注入:扩展就算在盘上,也会因为 mode=off + 没 FORCE 而沉默
232
+ built.env = { ...built.env, AGENT_PURGE_MODE: "off" };
233
+ } else if (useExtension) {
234
+ if (isOmp && built.args[0] === "--system-prompt") built.args.splice(0, 2);
235
+ if (!dryRun) {
236
+ const { installSlash } = await import("../lib/slashes.mjs");
237
+ const r = installSlash(name, DATA, { personaId: picked.id, personaBody: personaText });
238
+ if (r.error) die(`${name}: ${r.error}`);
239
+ extFile = r.file;
240
+ }
241
+ built.env = { ...built.env, AGENT_PURGE_PERSONA: picked.id, AGENT_PURGE_MODE: mode, AGENT_PURGE_FORCE: "1" };
242
+ } else if (isOmp && askPrompt === null) {
243
+ console.log("[ext] --profile/--no-extensions:本次保留命令行注入,/purge 热切不可用");
244
+ } else if (isOmp) {
245
+ // --ask:命令行已经注入;扩展若在盘上且 ompAuto 开着会再注一遍,这里按 off 静音
246
+ built.env = { ...built.env, AGENT_PURGE_MODE: "off" };
247
+ }
248
+ }
213
249
  // off 有两种来源:全局总电源关了,或者这一轮手动指定。措辞不能混,否则看日志的人会以为总开关坏了
214
250
  if (mode === "off") {
215
251
  const globalOff = (state.default?.mode ?? "") === "off";
@@ -220,32 +256,29 @@ async function launch(name) {
220
256
  );
221
257
  }
222
258
  console.log(`[cmd] ${[built.cmd, ...built.args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a))].join(" ")}`);
223
- if (argv.includes("--dry-run")) process.exit(0);
224
-
225
- // pi:注入活在扩展里(命令行装不下 10K 人设)。
226
- // mode ≠ off:确保扩展在位(没有就装、有就重写成人设最新版),并用 AGENT_PURGE_FORCE=1 明确告诉扩展“这次是启动器要注入”。
227
- // mode = off:不装、也不喊注入。扩展就算挂在盘上,也会因为 mode=off + 没 FORCE 而保持沉默(裸敲 pi 原样跑)。
228
- if (name === "pi" && !built.unsupported && !built.manual) {
229
- const { installSlash } = await import("../lib/slashes.mjs");
230
- if (mode === "off") {
231
- // 不装、不喊注入:扩展就算在盘上,也会因为 mode=off + 没 FORCE 而沉默
232
- built.env = { ...built.env, AGENT_PURGE_MODE: "off" };
233
- } else {
234
- installSlash("pi", DATA, { personaId: picked.id, personaBody: personaText });
235
- built.env = { ...built.env, AGENT_PURGE_PERSONA: picked.id, AGENT_PURGE_MODE: mode, AGENT_PURGE_FORCE: "1" };
236
- }
237
- }
259
+ if (extFile) console.log(`[ext] ${extFile}(/purge 热切换)`);
260
+ if (dryRun) process.exit(0);
238
261
 
239
262
  if (!resolveBin(built.cmd)) {
240
263
  die(`${name}: 找不到可执行文件 ${built.cmd}(PATH 里没有它;agent-purge doctor 可看本机探测结果)`);
241
264
  }
242
265
  built.setup?.();
266
+ // Ctrl-C 时也要清掉一次性插件:process.exit 会触发 exit 监听器,不依赖 child close
267
+ let cleaned = false;
268
+ const cleanup = () => {
269
+ if (cleaned) return;
270
+ cleaned = true;
271
+ try {
272
+ built.cleanup?.();
273
+ } catch {}
274
+ };
275
+ process.once("exit", cleanup);
243
276
  // 一次性问答:stdin 直接给 NUL。否则 codex 这类会先“Reading additional input from stdin...”
244
277
  // 并阻塞到 EOF(非 TTY 环境必现);stdin 关了它就直接跑。
245
- const stdio = askIdx !== -1 ? ["ignore", "inherit", "inherit"] : "inherit";
278
+ const stdio = askPrompt !== null ? ["ignore", "inherit", "inherit"] : "inherit";
246
279
  const child = spawnBin(built.cmd, built.args, { cwd, stdio, env: { ...process.env, ...built.env } });
247
280
  const finish = (code) => {
248
- built.cleanup?.();
281
+ cleanup();
249
282
  process.exit(code ?? 1);
250
283
  };
251
284
  child.on("error", (e) => {
package/lib/armed.mjs CHANGED
@@ -20,12 +20,13 @@ export async function reconcileArmed(root) {
20
20
  let alive = true;
21
21
  if (ch === "shim") {
22
22
  const bin = join(home, ".agent-purge", "bin");
23
- alive = existsSync(join(bin, `${h}.cmd`)) || existsSync(join(bin, `${h}.ps1`));
23
+ alive = existsSync(join(bin, `${h}.cmd`)) || existsSync(join(bin, `${h}.ps1`)) || existsSync(join(bin, h));
24
24
  } else if (ch === "install") {
25
25
  alive = Boolean(installs.get(h));
26
26
  } else if (ch === "slash") {
27
27
  const s = resolveSlash(h, root);
28
- alive = Boolean(s && s.offered !== false && existsSync(s.file));
28
+ // dsh 给 probe(看挂载块,不看固定路径);其余家 slash 就是单个文件
29
+ alive = Boolean(s && s.offered !== false && (s.probe ? s.probe() : existsSync(s.file)));
29
30
  }
30
31
  if (!alive) {
31
32
  setArmed(root, h, ch, false);
package/lib/caps.mjs CHANGED
@@ -23,8 +23,8 @@ function readMatrix() {
23
23
  }
24
24
  }
25
25
 
26
- /** 有 /purge 机制的 harness —— **只算能把内容写进系统提示词的**(pi 的扩展钩子、dsh 的 cordis 插件) */
27
- export const SLASH_IDS = new Set(["pi", "dsh"]);
26
+ /** 有 /purge 机制的 harness —— **只算能把内容写进系统提示词的**(pi/omp 的扩展钩子、dsh 的 cordis 插件) */
27
+ export const SLASH_IDS = new Set(["pi", "dsh", "omp"]);
28
28
  /** 有可写配置文件/插件的 harness(其余家只能点名启动或打补丁) */
29
29
  export const PERSIST_IDS = new Set(Object.keys(persistTargets));
30
30
  /** 能把内容真的送进 system 数组的层;user 层(上下文块/用户消息)一律不对外提供 */
@@ -63,6 +63,7 @@ const BLOCKED = { antigravity: "二进制补丁才能改它的系统提示,配
63
63
  */
64
64
  const HOT = {
65
65
  pi: { kind: "command", needs: "slash", how: "扩展钩子 before_agent_start:/purge 或改设置,下一轮生效" },
66
+ omp: { kind: "command", needs: "slash", how: "扩展钩子 before_agent_start 改写 systemPrompt 数组:/purge 或改设置,下一轮生效" },
66
67
  opencode: { kind: "state", needs: "install", how: "插件钩子 chat.system.transform 每轮读 state.json + active.md:改设置下一轮生效" },
67
68
  dsh: { kind: "command", needs: "slash", how: "cordis 插件注册 /purge:改 state.json,下一句生效,不产生模型消息" },
68
69
  opencode2: { kind: "state", needs: "install", how: "v2 插件 ctx.session.hook('context') 每轮读 state.json + active.md:改设置下一轮生效" },
@@ -73,7 +74,6 @@ const NOT_HOT = {
73
74
  claude: "system 只在启动时拼一次;会话内只能往 user 消息里塞(实测 0/2)",
74
75
  codex: "config.toml 启动时读;会话内没有能重拼 system 的钩子",
75
76
  grok: "AGENTS.md 启动时读一次",
76
- omp: "rules 启动时读一次",
77
77
  hermes: "SOUL.md 虽然是每轮重读,但没有会话内命令面,只能改文件",
78
78
  zcode: "桌面版闭源,没有插件与命令钩子",
79
79
  codebuddy: "只有启动参数",
@@ -122,7 +122,7 @@ export function capsFor(id, ctx = {}) {
122
122
  const slashWhy = blocked
123
123
  ? blocked
124
124
  : !SLASH_IDS.has(id)
125
- ? `${id} 没有能改系统提示词的 /purge(/purge 展开成用户消息,实测无效;只有 pi 的扩展和 dsh 的 cordis 插件能做到)`
125
+ ? `${id} 没有能改系统提示词的 /purge(/purge 展开成用户消息,实测无效;只有 pi/omp 的扩展和 dsh 的 cordis 插件能做到)`
126
126
  : null;
127
127
 
128
128
  return {
package/lib/dsh.mjs CHANGED
@@ -15,11 +15,45 @@ export const DSH_PROFILES = ["desktop", "web", "headless"];
15
15
  export const RESIDENT = "agent-purge";
16
16
  export const SLASH_RESIDENT = "agent-purge-slash";
17
17
  export const SESSION = "agent-purge-session";
18
+ /** 我们写过的三个块名:stash 用完即弃的判断用 */
19
+ const ALL = [RESIDENT, SLASH_RESIDENT, SESSION];
18
20
 
19
21
  export const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
20
22
  export const profileDir = (p) => join(dshHome(), "profiles", p);
21
23
  export const pluginDir = (p, name = RESIDENT) => join(profileDir(p), "node_modules", name);
22
24
  const patchFile = (p) => join(profileDir(p), "cordis.patch.yml");
25
+ // 原始字节存 profile 级:插件目录会被删,多插件叠在一起时也要能一起还原(`[]` 空壳)
26
+ const stashFile = (p) => join(profileDir(p), ".agent-purge.patch.before");
27
+ const parseStash = (file) => {
28
+ try {
29
+ return JSON.parse(readFileSync(file, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+ const rmStash = (p) => rmSync(stashFile(p), { force: true });
35
+
36
+ /** 旧版前缀匹配会把 `-slash` 尾段留在 patch 里(issue #4)。只认第 0 列的裸行:块标量里的内容
37
+ * 必须缩进,子段更不可能出现在列 0,所以这个模式不会误删用户文本。 */
38
+ const healStraySlash = (text) => text.replace(/^-slash[ \t]*\r?(?:\n|$)/gm, "");
39
+
40
+ /** 剥孤儿行会改字节:换写前留一份原始字节,判断错了还有退路。
41
+ * 只在我们真棭行时写,且不覆盖已有备份(否则每次装/卸都会把真正的原文冲掉)。 */
42
+ const healBackup = (p, raw) => {
43
+ const f = join(profileDir(p), ".agent-purge.patch.healed");
44
+ if (raw === null || healStraySlash(raw) === raw || existsSync(f)) return;
45
+ writeFileSync(f, raw);
46
+ };
47
+
48
+ /** dsh 的 patch 模板就是一个 `[]`:空 flow 数组后面再接 `- insert:` 是非法 YAML
49
+ * (真 dsh 报 `end of the stream or a document separator is expected`),文档根的 `[]` 先摊平。
50
+ * 只认文档根:手写的 flow 数组、缩进里的 `[]` 原样留着。 */
51
+ const flattenEmptyArray = (text) => {
52
+ const lines = text.split("\n");
53
+ const i = lines.findIndex((l) => l.trim() && !/^\s*#/.test(l));
54
+ if (i < 0 || lines[i].trim() !== "[]") return text;
55
+ return [...lines.slice(0, i), ...lines.slice(i + 1)].join("\n");
56
+ };
23
57
 
24
58
  /**
25
59
  * dsh 自己建过的 profile(带 package.json)。
@@ -32,6 +66,16 @@ export function liveProfiles() {
32
66
  }
33
67
 
34
68
  const mark = (name) => [`# >>> agent-purge:${name}`, `# <<< agent-purge:${name}`];
69
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
+ // marker 必须**整行**匹配:`agent-purge` 是 `agent-purge-slash` 的前缀,
71
+ // 不锚行的话删常驻块会把 /purge 那段一起吃掉,留下 `-slash` 让 patch 变成非法 YAML(issue #4)。
72
+ const blockRe = (name) => {
73
+ const [begin, end] = mark(name);
74
+ return new RegExp(`(?:^|\\r?\\n)${esc(begin)}[ \\t]*\\r?\\n[\\s\\S]*?(?:^|\\r?\\n)${esc(end)}[ \\t]*\\r?(?:\\n|$)`, "g");
75
+ };
76
+ const hasBlock = (text, name) => blockRe(name).test(text);
77
+ /** 把我们的三段(以及旧版残留的孤儿行)都剥掉,得到“没有 agent-purge 的原样”。 */
78
+ const stripOurs = (text) => ALL.reduce((t, n) => t.replace(blockRe(n), ""), healStraySlash(text));
35
79
 
36
80
  /** 这个 package.json 是 agent-purge 写的吗(别碰同名外来目录)。 */
37
81
  const isOurs = (meta) => existsSync(meta) && /"?agentPurge"?\s*:\s*true/.test(readFileSync(meta, "utf8"));
@@ -284,31 +328,47 @@ export function addPlugin(name, source) {
284
328
  const dir = pluginDir(p, name);
285
329
  const patch = patchFile(p);
286
330
  const before = existsSync(patch) ? readFileSync(patch, "utf8") : null;
287
- // 先登记再动手:下面任何一步抛错,这一轮的产物都能被回滚
288
- touched.push({ dir, patch, before });
331
+ // 先登记再动手:下面任何一步抛错,这一轮的产物都能被回滚(stash 一并回滚)
332
+ touched.push({ dir, patch, before, existedDir: existsSync(dir), stashPath: stashFile(p), stash: existsSync(stashFile(p)) ? readFileSync(stashFile(p), "utf8") : null });
289
333
  mkdirSync(dir, { recursive: true });
290
334
  writeFileSync(join(dir, "package.json"), pkg);
291
335
  writeFileSync(join(dir, "index.js"), source);
292
336
  files.push(dir);
293
- const old = before ?? "# agent-purge 新建的 patch 层\n";
294
- if (old.includes(begin)) continue;
295
- // `[]` 是合法空数组,后面接 `- insert:` 就是非法 YAML:整份换成我们的段
296
- // 不存在也算“空”:走 stash 分支才能把“原本没有这个文件”记下来,卸载后才不会留个空壳
297
- const empty = before === null || /^\s*(\[\s*\]\s*)?$/.test(old);
298
- const next = empty ? block.slice(1) : old + block;
299
- if (empty) {
300
- // 整份替换会把原文端掉:原文 + 我们写出的成品都存下,卸载时对得上才整份还原
301
- // (对不上 = 期间被人改过,那就只剔自己那段)
302
- writeFileSync(join(dir, ".patch.before"), JSON.stringify({ before, after: next }));
337
+ // 旧版前缀匹配留下的孤儿 `-slash` 行会让整份 patch 不是合法 YAML:写入前剥掉
338
+ const stripped = healStraySlash(before ?? "");
339
+ const healed = flattenEmptyArray(stripped);
340
+ healBackup(p, before);
341
+ if (hasBlock(healed, name)) {
342
+ if (healed !== (before ?? "")) {
343
+ // 旧版装坏的 `[]` + 块:把自愈后的形态记成 stash.after,卸载才还原得回原文
344
+ const prev = parseStash(stashFile(p));
345
+ const keep = prev && prev.after === before ? prev.before : stripOurs(before ?? "");
346
+ writeFileSync(stashFile(p), JSON.stringify({ before: keep, after: healed }));
347
+ writeFileSync(patch, healed);
348
+ }
349
+ continue;
303
350
  }
351
+ // 空文档(模板那个 `[]`)直接换成我们的段;stash 记剥孤儿后的原样,卸载还回 `[]`
352
+ const next = healed.trim() ? healed + block : block.slice(1);
353
+ const prev = parseStash(stashFile(p));
354
+ // 还原点只在文件还是我们上次写的样子时有效:被改过就按当前字节重新记,
355
+ // 否则旧 before 会把用户后来写的内容整份覆盖掉(或把文件误删)
356
+ let keep = null;
357
+ if (prev && prev.after === before) keep = prev.before;
358
+ // 旧版写坏的 patch(`[]` + 块)里也含我们的块:还原点得先剥掉自己,否则卸载后会回放旧块
359
+ else if (before !== null) keep = stripOurs(before);
360
+ writeFileSync(stashFile(p), JSON.stringify({ before: keep, after: next }));
304
361
  writeFileSync(patch, next);
305
362
  }
306
363
  } catch (e) {
307
364
  // 回滚:撤销本次已写的文件,宁可完全没装也不留半套
308
- for (const { dir, patch, before } of touched.reverse()) {
309
- rmSync(dir, { recursive: true, force: true });
365
+ for (const { dir, patch, before, existedDir, stashPath, stash } of touched.reverse()) {
366
+ // 只删这一轮新建的目录:重装时目录本来就在,删掉会让还原后的 patch 指向一个不存在的包
367
+ if (!existedDir) rmSync(dir, { recursive: true, force: true });
310
368
  if (before === null) rmSync(patch, { force: true });
311
369
  else writeFileSync(patch, before);
370
+ if (stash === null) rmSync(stashPath, { force: true });
371
+ else writeFileSync(stashPath, stash);
312
372
  }
313
373
  throw e;
314
374
  }
@@ -317,45 +377,77 @@ export function addPlugin(name, source) {
317
377
 
318
378
  /** 只撤自己那一段:目录 + patch marker,别的配置原样。 */
319
379
  export function removePlugin(name) {
320
- const [begin, end] = mark(name);
321
380
  // 删 install 写入的那一段:前导 \n 可有可无(整份替换的形态没有),尾随 \n 一并吃掉
322
- const re = new RegExp(`(?:^|\\n)${begin}[\\s\\S]*?${end}\\n?`, "g");
381
+ const re = blockRe(name);
323
382
  for (const p of DSH_PROFILES) {
324
383
  const dir = pluginDir(p, name);
325
384
  const meta = join(dir, "package.json");
326
385
  // 只删自己装的;同名但不是我们的目录留着(可能被用户或其他包占了)
327
386
  const ours = isOurs(meta);
328
- const stashFile = join(dir, ".patch.before");
329
- // 当初整份替换过的:记录躲在插件目录里,先读出来,不然删目录就丢了
330
- let stash = null;
331
- if (ours && existsSync(stashFile)) {
332
- try {
333
- stash = JSON.parse(readFileSync(stashFile, "utf8"));
334
- } catch {
335
- stash = null;
336
- }
337
- }
387
+ // 当初整份替换过的:先读出来,不然删目录就丢了(新版在 profile 根,旧版在插件目录)
388
+ const stash = parseStash(stashFile(p)) ?? parseStash(join(dir, ".patch.before"));
338
389
  if (ours) rmSync(dir, { recursive: true, force: true });
339
390
  const patch = patchFile(p);
340
391
  if (!existsSync(patch)) continue;
341
392
  const text = readFileSync(patch, "utf8");
342
- // 我们整份写的、且此后没人动过 → 整份还原(原本没这个文件就删掉它)
343
- if (stash && text === stash.after) {
393
+ const healed = healStraySlash(text);
394
+ healBackup(p, text);
395
+ // 我们整份写的、且此后没人动过、且这是最后一个属于自己的块 → 整份还原(原本没这个文件就删掉它)
396
+ // 还有别的块(例如先装的 /purge)时不能整份还原,否则会连带把它抹掉
397
+ const siblings = ALL.filter((n) => n !== name && hasBlock(text, n));
398
+ if (stash && text === stash.after && siblings.length === 0) {
344
399
  if (stash.before === null) rmSync(patch, { force: true });
345
400
  else writeFileSync(patch, stash.before);
401
+ rmStash(p);
346
402
  continue;
347
403
  }
348
- if (text.includes(begin)) writeFileSync(patch, text.replace(re, ""));
404
+ let next = healed;
405
+ if (hasBlock(next, name)) next = next.replace(re, "");
406
+ // 卸到没有我们的块了:剩下的只要等于“原文摊平 `[]` 后的样子”,就把原文逐字节还回去
407
+ // (多块先后装时,第一个块删完 text 就不再等于 stash.after,但剩下的仍然是原文)
408
+ if (stash && !ALL.some((n) => hasBlock(next, n))) {
409
+ const base = stash.before === null ? "" : stash.before;
410
+ const baseFlat = flattenEmptyArray(healStraySlash(base));
411
+ // 只认两种“等于原文”:逐字节相等,或两边都只剩空白(原文是那个 `[]` 空壳)
412
+ if (next === baseFlat || (next.trim() === "" && baseFlat.trim() === "")) {
413
+ if (stash.before === null) rmSync(patch, { force: true });
414
+ else writeFileSync(patch, stash.before);
415
+ rmStash(p);
416
+ continue;
417
+ }
418
+ }
419
+ if (next !== text) writeFileSync(patch, next);
420
+ // 我们三个块都不在了 → 这份 stash 用完即弃
421
+ if (!ALL.some((n) => hasBlock(next, n))) rmStash(p);
422
+ }
423
+ }
424
+
425
+ /** 盘上真有那份插件文件的位置:status / armed 对账用。
426
+ * 不能拿没装过的 desktop 路径当真相:web/headless 装了、desktop 没建过时,
427
+ * 读那个路径就是 ENOENT(issue #3)。 */
428
+ export function pluginFileOnDisk(name) {
429
+ for (const p of liveProfiles()) {
430
+ const f = join(pluginDir(p, name), "index.js");
431
+ if (existsSync(f)) return f;
349
432
  }
433
+ return join(pluginDir("desktop", name), "index.js");
434
+ }
435
+
436
+ /** 任一份 profile 挂着就算能用:/purge 是会话内的,后来新建的 profile 不影响已有会话。 */
437
+ export function hasPluginAny(name) {
438
+ const targets = liveProfiles();
439
+ return targets.length > 0 && targets.some((p) => {
440
+ const patch = patchFile(p);
441
+ return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && hasBlock(readFileSync(patch, "utf8"), name);
442
+ });
350
443
  }
351
444
 
352
445
  /** patch 里还挂着这个名字吗(status 用;不许在有其他 insert 时谎报干净)。 */
353
446
  export function hasPlugin(name) {
354
- const [begin] = mark(name);
355
447
  const targets = liveProfiles();
356
448
  if (targets.length === 0) return false;
357
449
  return targets.every((p) => {
358
450
  const patch = patchFile(p);
359
- return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && readFileSync(patch, "utf8").includes(begin);
451
+ return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && hasBlock(readFileSync(patch, "utf8"), name);
360
452
  });
361
453
  }
package/lib/persist.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // 不动二进制——这里只做可逆的配置改动。
4
4
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
- import { RESIDENT as DSH_RESIDENT, SESSION as DSH_SESSION, addPlugin, hasPlugin, pluginDir, residentSource, removePlugin } from "./dsh.mjs";
6
+ import { RESIDENT as DSH_RESIDENT, SESSION as DSH_SESSION, addPlugin, hasPlugin, pluginFileOnDisk, residentSource, removePlugin } from "./dsh.mjs";
7
7
 
8
8
  const BEGIN = "# >>> agent-purge";
9
9
  const END = "# <<< agent-purge";
@@ -64,6 +64,8 @@ export const targets = {
64
64
  codex: {
65
65
  file: () => join(home, ".codex", "config.toml"),
66
66
  kind: "block",
67
+ // TOML 顶层键必须写在第一个 [table] 之前:追加到文件尾会被解析成上一个表里的键
68
+ position: "top",
67
69
  layer: "sys",
68
70
  note: "model_instructions_file 直接换掉它的指令文件(连 GUI/子进程启动都生效)",
69
71
  body: (active) => `model_instructions_file = "${active.replace(/\\/g, "/")}"`,
@@ -108,20 +110,20 @@ export const AgentPurge = async () => ({
108
110
  },
109
111
  dsh: {
110
112
  // cordis 插件挂 systemPrompt 层;三个 profile 各一份(desktop 由 Electron 独占,装完要重启一次)
111
- file: () => join(pluginDir("desktop"), "index.js"),
113
+ file: () => pluginFileOnDisk(DSH_RESIDENT),
112
114
  kind: "file",
113
115
  layer: "sys",
114
116
  note: "cordis 插件包;运行时每轮读 state.json,开关 / 切模式 / 换人设都即时生效;只有首次安装要重启一次",
115
117
  probe: () => hasPlugin(DSH_RESIDENT),
116
118
  install: ({ root, active }) => {
117
119
  const r = addPlugin(DSH_RESIDENT, residentSource(root, active));
118
- return { file: join(pluginDir("desktop"), "index.js"), note: `三个 profile:${r.profiles.join("/")};重启一次 DeepSeek Harness 后插件开始工作(之后热切换)` };
120
+ return { file: pluginFileOnDisk(DSH_RESIDENT), note: `三个 profile:${r.profiles.join("/")};重启一次 DeepSeek Harness 后插件开始工作(之后热切换)` };
119
121
  },
120
122
  uninstall: () => {
121
123
  removePlugin(DSH_RESIDENT);
122
124
  // 一次性插件被 SIGKILL 时不会自己清,一并扫掉
123
125
  removePlugin(DSH_SESSION);
124
- return { file: join(pluginDir("desktop"), "index.js") };
126
+ return { file: pluginFileOnDisk(DSH_RESIDENT) };
125
127
  },
126
128
  },
127
129
  opencode2: {
@@ -183,7 +185,7 @@ export function status(_root) {
183
185
 
184
186
  export function install(name, { root, active }) {
185
187
  const t = targets[name];
186
- if (!t) return { error: `${name} 没有持久化后端(见 notes:grok/omp 只有 CLI 开关,claude/zcode/antigravity 要打补丁)` };
188
+ if (!t) return { error: `${name} 没有持久化后端(可用: ${Object.keys(targets).join(", ")})` };
187
189
  // 多文件/自定义布局的 harness 自带安装函数(dsh 三个 profile、opencode2 v2 插件)
188
190
  // 它们的失败信号是异常(同名外来目录拒绝覆盖等),这里转成调用方约好的 {error}
189
191
  if (t.install) {
@@ -200,7 +202,7 @@ export function install(name, { root, active }) {
200
202
  return { file, action: "wrote-whole-file" };
201
203
  }
202
204
  const old = existsSync(file) ? readFileSync(file, "utf8") : "";
203
- const next = upsertBlock(old, t.body(active));
205
+ const next = upsertBlock(old, t.body(active), t.position);
204
206
  writeFileSync(file, next);
205
207
  return { file, action: old.includes(BEGIN) ? "replaced-block" : "appended-block" };
206
208
  }
@@ -237,15 +239,34 @@ export function uninstall(name) {
237
239
  return { file, action: "removed-block" };
238
240
  }
239
241
 
240
- // 按标记替换或追加,幂等
241
- function upsertBlock(text, body) {
242
+ // 按标记替换或追加,幂等。position="top" 用于 TOML:块必须落在第一个 [table] 之前。
243
+ export function upsertBlock(text, body, position = "bottom") {
244
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
242
245
  const block = `${BEGIN}\n${body}\n${END}`;
243
- if (text.includes(BEGIN)) return text.replace(new RegExp(`${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}`), block);
244
- return `${text.replace(/\s*$/, "")}\n\n${block}\n`;
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}`;
245
262
  }
246
263
 
247
- function stripBlock(text) {
248
- return text.replace(/\n*# >>> agent-purge[\s\S]*?# <<< agent-purge\n*/, "\n").replace(/\s*$/, "\n");
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);
249
270
  }
250
271
 
251
272
  function escapeRe(s) {
package/lib/personas.mjs CHANGED
@@ -29,6 +29,7 @@ export function createStore(root) {
29
29
  (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".") && f !== "active.md") : []);
30
30
 
31
31
  function read(id) {
32
+ if (!isValidId(id)) return null;
32
33
  const file = join(dir, `${id}.md`);
33
34
  if (!existsSync(file)) return null;
34
35
  const raw = readFileSync(file, "utf8");
@@ -45,12 +46,14 @@ export function createStore(root) {
45
46
 
46
47
  function save(id, { meta = {}, body }) {
47
48
  if (isReservedId(id)) throw new Error(`${id} 是保留名(active 指针专用),换个 id`);
49
+ if (!isValidId(id)) throw new Error(`非法 persona id: ${id}(不能含路径分隔符或 ..,最长 64 字符)`);
48
50
  const file = join(dir, `${id}.md`);
49
51
  writeFileSync(file, serialize({ id, meta, body }));
50
52
  return read(id);
51
53
  }
52
54
 
53
55
  function remove(id) {
56
+ if (!isValidId(id)) return false;
54
57
  rmSync(join(dir, `${id}.md`), { force: true });
55
58
  rmSync(join(build, `${id}.md`), { force: true });
56
59
  // 删的就是 active 时必须改道:否则下次无参启动直接 “没有 persona”。
@@ -61,6 +64,7 @@ export function createStore(root) {
61
64
  if (next) setActive(next.id);
62
65
  else writeFileSync(join(dir, ACTIVE_BODY), "You are a helpful coding assistant.\n");
63
66
  }
67
+ return true;
64
68
  }
65
69
 
66
70
  // 把正文落到 .build,给需要文件路径的 harness 用(frontmatter 绝不能进 prompt)
@@ -120,6 +124,12 @@ export function isReservedId(id) {
120
124
  return RESERVED_IDS.has(String(id || ""));
121
125
  }
122
126
 
127
+ /** persona id 会直接拼进文件名:挡掉路径分隔符与 ..,避免读/删到数据目录之外 */
128
+ export function isValidId(id) {
129
+ const s = String(id ?? "").trim();
130
+ return Boolean(s) && s.length <= 64 && !s.includes("..") && !/[\\/:*?"<>|\0]/.test(s) && !isReservedId(s);
131
+ }
132
+
123
133
  function parse(raw) {
124
134
  if (!raw.startsWith("---")) return { meta: {}, body: raw };
125
135
  const end = raw.indexOf("\n---", 3);