agent-purge 0.2.8 → 0.2.10
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 +39 -2
- package/backends/index.mjs +3 -3
- package/bin/agent-purge.mjs +75 -34
- package/lib/armed.mjs +3 -2
- package/lib/caps.mjs +4 -4
- package/lib/dsh.mjs +123 -31
- package/lib/persist.mjs +33 -12
- package/lib/personas.mjs +10 -0
- package/lib/slashes.mjs +241 -25
- package/lib/spawn.mjs +3 -1
- package/lib/state.mjs +22 -4
- package/matrix.json +2 -2
- package/package.json +2 -3
- package/web/server.mjs +59 -7
- package/web/ui.html +17 -8
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 |
|
|
@@ -89,6 +99,33 @@ agent-purge uninstall <h> # 摘掉,按字节还原
|
|
|
89
99
|
|
|
90
100
|
dsh 是唯一有多个形态的:desktop / web / headless 共用一套 cordis 插件,装在各自 profile 的 `node_modules/agent-purge/`,并在 `cordis.patch.yml` 里 `- insert` 挂上,不改 `app.asar`。装完重启一次,之后热生效;三个形态还能在 Web 面板分别勾选。
|
|
91
101
|
|
|
102
|
+
### 三个通道与总电源(arm / disarm)
|
|
103
|
+
|
|
104
|
+
一家 harness 有三条能注入的入口,`arm` 默认三条全开:
|
|
105
|
+
|
|
106
|
+
| 通道 | 管什么 | 只想做这一项 |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `shim` | PATH 最前放同名壳,**裸敲** `codex` / `dsh` 也经 agent-purge 启动 | `arm <h> --shim` |
|
|
109
|
+
| `install` | 写 harness 自己的配置/插件,**别人启动的、GUI 里点开的**也生效 | `arm <h> --install` |
|
|
110
|
+
| `slash` | 会话内 `/purge` 热切换(只有 pi / omp / dsh 有 sys 层热切) | `arm <h> --slash` |
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
agent-purge arm <h> # 三条全开(= 注册)
|
|
114
|
+
agent-purge disarm <h> # 撤掉这一家的三条通道
|
|
115
|
+
agent-purge arm --all # 所有支持的 harness 全开 + 总电源打开
|
|
116
|
+
agent-purge disarm --all # 所有 harness 全撤 + 总电源关掉("别管我")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**`--all` 是总电源,不是"全部通道"。** 它改的是 `state.json` 里的 `default.mode`:
|
|
120
|
+
|
|
121
|
+
- `default.mode = off` 时,**所有**入口都不注入——包括裸敲 `agent-purge`、`agent-purge <h>`、PATH 壳;
|
|
122
|
+
- `disarm <h>` 只撤那家的三个通道,**不动总电源**,所以撤完 `agent-purge dsh` 仍然注入——
|
|
123
|
+
这就是"明明 disarm 了却还在改提示词"的来源;
|
|
124
|
+
- 只想彻底关掉某一家:`agent-purge disarm dsh --all`(撤 dsh 三条通道 + 关总电源,别的 harness 不动);
|
|
125
|
+
`arm` 会把上次的 mode 还回去。
|
|
126
|
+
|
|
127
|
+
改完用 `agent-purge status` 复核:每家的 `shim / slash / install` 三个格子 + 当前 mode 一张表。
|
|
128
|
+
|
|
92
129
|
## magpie 网关插件(可选)
|
|
93
130
|
|
|
94
131
|
[magpie](https://github.com/yetone/magpie) 把各家 agent 的请求收进本地网关。挂上这个插件后,**凡是经过网关的请求**(Claude Code、Codex、OpenCode、pi…)的 system 段都按 agent-purge 的当前人设改写——不用逐个 harness 装,换人设也不用重启 magpie。
|
|
@@ -115,7 +152,7 @@ magpie plugin list # 应看到 async gatewa
|
|
|
115
152
|
|
|
116
153
|
options 三键:`mode`、`persona`(人设 id)、`agents`(只对这些 magpie agent 生效,如 `["claude","codex"]`)。
|
|
117
154
|
|
|
118
|
-
**边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs
|
|
155
|
+
**边界**:改的是请求体里的文本,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
156
|
|
|
120
157
|
## 目录
|
|
121
158
|
|
package/backends/index.mjs
CHANGED
|
@@ -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:
|
|
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 }) => {
|
package/bin/agent-purge.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
|
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 (
|
|
191
|
-
const raw =
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
281
|
+
cleanup();
|
|
249
282
|
process.exit(code ?? 1);
|
|
250
283
|
};
|
|
251
284
|
child.on("error", (e) => {
|
|
@@ -346,21 +379,25 @@ async function armCmd(verb, rest) {
|
|
|
346
379
|
|
|
347
380
|
const supported = Object.keys(matrix.harnesses).filter((h) => matrix.harnesses[h].class !== "unsupported");
|
|
348
381
|
const namedTarget = rest.filter((x) => !x.startsWith("-"));
|
|
349
|
-
|
|
382
|
+
// 点了名的只动那家:`--all` 只管总电源,不再悄悄把别的 harness 一起撤(issue #5)
|
|
383
|
+
const targets = namedTarget.length ? namedTarget : supported;
|
|
350
384
|
|
|
351
385
|
// 全局 --all 就是总电源:撤的时候必须把 default.mode 置 off,
|
|
352
386
|
// 否则 `agent-purge <h>` 这条入口照样注入(实测:关了总开关还能接管,就是这里漏的)。
|
|
353
387
|
// 开的时候把上次的 mode 还回去。单家操作不动 mode。
|
|
388
|
+
// 没设过默认 harness 时也要能关:launch() 在 default 为空时会回落成 replace。
|
|
354
389
|
if (rest.includes("--all")) {
|
|
355
390
|
const { state: cur } = loadState(DATA);
|
|
356
|
-
const m = cur.default?.mode ??
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
patchState(DATA, { prevMode: m });
|
|
391
|
+
const m = cur.default?.mode ?? null; // null = 从没设过,launch() 会回落成 replace
|
|
392
|
+
const h = cur.default?.harness ?? null;
|
|
393
|
+
if (verb === "disarm" && m !== "off") {
|
|
394
|
+
patchState(DATA, { default: { harness: h, mode: "off" }, prevMode: m ?? "replace" });
|
|
395
|
+
console.log(`总电源:mode ${m ?? "(未设,实际按 replace)"} → off(裸敲 agent-purge / agent-purge <h> 都不再注入;arm --all 还回去)`);
|
|
360
396
|
}
|
|
361
|
-
if (verb === "arm" && m === "off"
|
|
397
|
+
if (verb === "arm" && cur.default && m === "off") {
|
|
362
398
|
const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
|
|
363
|
-
|
|
399
|
+
patchState(DATA, { default: { harness: h, mode: back } });
|
|
400
|
+
console.log(`总电源:mode off → ${back}`);
|
|
364
401
|
}
|
|
365
402
|
}
|
|
366
403
|
|
|
@@ -507,7 +544,7 @@ async function statusCmd() {
|
|
|
507
544
|
const slashes = slashMod.slashStatus(DATA);
|
|
508
545
|
|
|
509
546
|
console.log(`persona ${state.persona ?? "(未设置)"}`);
|
|
510
|
-
console.log(`default ${state.default ? `${state.default.harness} · ${state.default.mode}` : "(未设置)"} 裸敲 agent-purge 用它`);
|
|
547
|
+
console.log(`default ${state.default ? `${state.default.harness ?? "(未选默认)"} · ${state.default.mode}` : "(未设置)"} 裸敲 agent-purge 用它`);
|
|
511
548
|
console.log(`state ${join(DATA, "state.json")}`);
|
|
512
549
|
console.log("");
|
|
513
550
|
console.log("harness shim slash install /purge层 常驻落点");
|
|
@@ -711,7 +748,11 @@ function help() {
|
|
|
711
748
|
agent-purge arm <h> --shim 只做裸命令接管(写 PATH 最前)
|
|
712
749
|
agent-purge arm <h> --install 只做配置文件常驻
|
|
713
750
|
agent-purge arm <h> --slash 只做会话内 /purge
|
|
714
|
-
agent-purge disarm <h>|--all
|
|
751
|
+
agent-purge disarm <h>|--all 以上全部撤掉;不带 <h> 就是所有 harness
|
|
752
|
+
|
|
753
|
+
注:--all 是总电源(state.json 的 default.mode)。带 <h> 时只撤那家,
|
|
754
|
+
但同样把总电源关掉——不然裸敲 agent-purge、agent-purge <h> 照样注入。
|
|
755
|
+
彻底别管我:agent-purge disarm --all
|
|
715
756
|
|
|
716
757
|
④ 看
|
|
717
758
|
agent-purge status 一张表:每家的 shim / slash / install / 能做什么
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
451
|
+
return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && hasBlock(readFileSync(patch, "utf8"), name);
|
|
360
452
|
});
|
|
361
453
|
}
|