agent-purge 0.2.2 → 0.2.3
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 +73 -1
- package/backends/index.mjs +63 -0
- package/bin/agent-purge.mjs +20 -14
- package/lib/caps.mjs +5 -1
- package/lib/detect.mjs +2 -0
- package/lib/dsh.mjs +177 -0
- package/lib/persist.mjs +80 -1
- package/matrix.json +24 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# agent-purge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/agent-purge)
|
|
4
|
+
[](https://www.npmjs.com/package/agent-purge)
|
|
5
|
+
[](https://github.com/ZiChuanLan/agent-purge/stargazers)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
一份系统提示词,喂给多个 coding agent。支持替换(replace)、只发人设(strict)、追加(append)、清空(off)四种模式,可热切换,全程可逆。
|
|
4
9
|
|
|
5
10
|
```
|
|
6
11
|
agent-purge web # 本地控制台 http://127.0.0.1:7788
|
|
@@ -52,10 +57,72 @@ agent-purge pi --persona <id> # 只这一轮用别的
|
|
|
52
57
|
|
|
53
58
|
## 支持情况
|
|
54
59
|
|
|
60
|
+
每个数字都是实测值:本地假 provider 抓真实请求体,不是估算。
|
|
61
|
+
|
|
62
|
+
| harness | 机制 | 实测版本 | 默认 system | 注入后 |
|
|
63
|
+
|---|---|---|---|---|
|
|
64
|
+
| `codex` | 启动参数 `-c model_instructions_file=` | 0.163.0 | 21,026 | 24 |
|
|
65
|
+
| `claude` | `--system-prompt-file` + 二进制补丁清残留身份句 | 2.1.304 | 6,379 | 156 |
|
|
66
|
+
| `grok` | `--system-prompt-override` | 1.0.62 | 5,146 | 21 |
|
|
67
|
+
| `omp` | `--system-prompt` | 17.4.2 | 34,511 | 10,106 |
|
|
68
|
+
| `hermes` | 临时 HOME 里替换 `SOUL.md` | 0.21.5 | 8,139 | 7,554 |
|
|
69
|
+
| `zcode` | UserPromptSubmit hook(user 角色)+ `customSystemPrompt` | 0.16.9 | 9,359 | 9,359 |
|
|
70
|
+
| `opencode` | cwd 下 `.opencode/plugin/*.ts` 原地改 `output.system` | 1.19.3 | 19,332 | 112 |
|
|
71
|
+
| `opencode2` | `plugins/agent-purge-v2.ts` 的 `ctx.session.hook("context")` | 0.0.0-beta-17823 | 8,279 | 3,189 |
|
|
72
|
+
| `dsh` | cordis 插件(三个 profile 各一份) | 0.2.1-rc.2 | 5,713 | 68 |
|
|
73
|
+
| `pi` | 原生扩展,会话内 `/purge` | — | — | — |
|
|
74
|
+
| `antigravity` | 等长替换二进制里的身份句 | 1.23.x | ≈20,498 | — |
|
|
75
|
+
| `codebuddy` | `--system-prompt-file` | 2.171.0 | — | — |
|
|
76
|
+
| `qoder` / `workbuddy` | 不支持(提示词在服务端) | — | — | — |
|
|
77
|
+
|
|
78
|
+
`opencode` 和 `opencode2` 是两个不同的 npm 包(`opencode-ai` 1.x 与 `@opencode/cli` 2.x),插件接口完全不同,各自独立支持。
|
|
79
|
+
|
|
55
80
|
`agent-purge ls` 和 Web 控制台显示的是**运行期真值**:每个 harness 有没有装、版本、注入层(SYS / USER)、三个通道各自可用性。`matrix.json` 里是静态能力表,两者冲突时以运行期为准。
|
|
56
81
|
|
|
57
82
|
闭源桌面端(提示词在服务端或二进制里)只做原样启动,不做任何注入——`agent-purge <h>` 会明确打印原因,不会假装生效。
|
|
58
83
|
|
|
84
|
+
### dsh(DeepSeek Harness)
|
|
85
|
+
|
|
86
|
+
desktop / web / headless 三个形态共用一套插件:往每个 profile 的 `node_modules/agent-purge/` 放一个 cordis 插件,在 `cordis.patch.yml` 里用 `- insert` 挂上。**不改 `app.asar`,不碰安装目录,不依赖任何第三方插件。**
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
agent-purge install dsh # 写插件 + 挂载(三个 profile 一起)
|
|
90
|
+
agent-purge dsh # 按当前 persona 启动桌面版
|
|
91
|
+
agent-purge uninstall dsh # 删插件 + 剥离 patch 段,字节还原
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
装完要**重启一次 dsh**(patch 层只在启动时读);之后换 persona、切模式、开关全是热生效,不用再重启。
|
|
95
|
+
|
|
96
|
+
只想临时试一下、不碰常驻 profile:`agent-purge dsh --ask "…"`(一次性会话,进程退出即清理)。
|
|
97
|
+
|
|
98
|
+
## magpie 网关插件(可选)
|
|
99
|
+
|
|
100
|
+
[magpie](https://github.com/yetone/magpie) 把各家 agent 的请求收进本地网关。挂上这个插件后,**凡是经过网关的请求**(Claude Code、Codex、OpenCode、pi…)的 system 段都按 agent-purge 的当前人设改写——不用逐个 harness 装,换人设也不用重启 magpie。
|
|
101
|
+
|
|
102
|
+
插件在仓库里,不进 npm 包(`files` 白名单),所以按路径装:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
git clone https://github.com/ZiChuanLan/agent-purge
|
|
106
|
+
magpie plugin add /path/to/agent-purge/magpie-plugin # 按路径装
|
|
107
|
+
magpie plugin options magpie-plugin '{"mode":"append"}' # 插件名 = 目录名
|
|
108
|
+
magpie plugin list # 应看到 async gateway hook + Persona 页
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
插件读的还是 `~/.agent-purge/`(同样认 `AGENT_PURGE_HOME`):`agent-purge use <id>` 之后下一个请求就换人设。magpie 里也会多出 **Persona** 页,点一下就把人设和模式写进插件的 options。
|
|
112
|
+
|
|
113
|
+
四种模式和 CLI 一致,差别在网关手里只有文本、没有各家启动参数:
|
|
114
|
+
|
|
115
|
+
| 模式 | 网关插件做什么 |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `off` | 不动请求 |
|
|
118
|
+
| `strict` | system 整段换成人设(工具说明也一起没了,harness 可能变笨) |
|
|
119
|
+
| `append` | 人设挂在最前,宿主 system 原样 |
|
|
120
|
+
| `replace` | 人设在前,再删掉宿主 system 里报名字的那句(近似;要干净用 `strict`) |
|
|
121
|
+
|
|
122
|
+
options 三键:`mode`、`persona`(人设 id)、`agents`(只对这些 magpie agent 生效,如 `["claude","codex"]`)。
|
|
123
|
+
|
|
124
|
+
**边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs`。自测不依赖 magpie:`npm test`(钩子 26 项断言:四种协议 × 四种模式;页面逻辑 18 项:搜索过滤、options 合并、失败回滚)。
|
|
125
|
+
|
|
59
126
|
## 目录
|
|
60
127
|
|
|
61
128
|
```
|
|
@@ -64,6 +131,7 @@ lib/ 状态、persona、注入、通道、检测
|
|
|
64
131
|
backends/ 每个 harness 的启动参数与身份句处理
|
|
65
132
|
web/ 控制台(server.mjs + ui.html)
|
|
66
133
|
bench/ 注入成功率实测与裁判脚本(开发用,不参与运行)
|
|
134
|
+
magpie-plugin/ magpie 网关插件(可选,不进 npm 包)
|
|
67
135
|
matrix.json 能力矩阵
|
|
68
136
|
```
|
|
69
137
|
|
|
@@ -112,6 +180,10 @@ matrix.json 能力矩阵
|
|
|
112
180
|
**第三方独立性声明**:本项目属于完全独立的开源安全评测研究项目,与 pi 官方或其关联主体无任何隶属、商业合作、授权或官方背书关系。文中「官方」仅指评测对象为使用者本机安装的官方 harness 软件包,不代表其官方开发、认可或担保本插件。
|
|
113
181
|
|
|
114
182
|
**必须署名**:借用本项目的名称、思路、代码或提示词,必须署名并标明本仓库。不署名将依法追究责任。
|
|
183
|
+
## Star History
|
|
184
|
+
|
|
185
|
+
[](https://star-history.com/#ZiChuanLan/agent-purge&Date)
|
|
186
|
+
|
|
115
187
|
## License
|
|
116
188
|
|
|
117
189
|
MIT
|
package/backends/index.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// setup/cleanup 由入口统一调用,保证能按哈希回滚。
|
|
3
3
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { SESSION as DSH_SESSION, addPlugin, removePlugin, sessionSource } from "../lib/dsh.mjs";
|
|
6
|
+
import { opencodePlugins } from "../lib/persist.mjs";
|
|
5
7
|
|
|
6
8
|
const inline = (text) => text.replace(/\s+$/, "");
|
|
7
9
|
|
|
@@ -86,6 +88,57 @@ export const backends = {
|
|
|
86
88
|
};
|
|
87
89
|
},
|
|
88
90
|
|
|
91
|
+
// dsh(DeepSeek Harness):CLI 只能起 headless(desktop 被 Electron 独占)。
|
|
92
|
+
// 一次性注入用 agent-purge-session 这个独立插件名,不会覆盖常驻的 agent-purge。
|
|
93
|
+
dsh: ({ personaText, mode, args }) => {
|
|
94
|
+
if (mode === "off") return { cmd: "dsh", args: ["--profile", "headless", ...args] };
|
|
95
|
+
return {
|
|
96
|
+
cmd: "dsh",
|
|
97
|
+
args: ["--profile", "headless", ...args],
|
|
98
|
+
note: `[plugin] ${DSH_SESSION}(三个 profile;patch: - insert)`,
|
|
99
|
+
residueHint: "desktop profile 由 Electron 独占,一次性注入只对本次 CLI 进程有效",
|
|
100
|
+
setup: () => addPlugin(DSH_SESSION, sessionSource(personaText, mode)),
|
|
101
|
+
cleanup: () => removePlugin(DSH_SESSION),
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// opencode v2(@opencode/cli,bin 名 opencode2):新插件 API,V1 的
|
|
106
|
+
// experimental.chat.system.transform 在 2.x 已废。插件放全局 plugins 目录。
|
|
107
|
+
opencode2: ({ personaText, mode, args }) => {
|
|
108
|
+
if (mode === "off") return { cmd: "opencode2", args: [...args] };
|
|
109
|
+
const dir = opencodePlugins();
|
|
110
|
+
// 与 V1 常驻插件(agent-purge.ts)分开,否则一次性注入会顶掉常驻接管
|
|
111
|
+
const file = join(dir, "agent-purge-v2-session.ts");
|
|
112
|
+
const persona = inline(personaText);
|
|
113
|
+
const mutate =
|
|
114
|
+
mode === "append"
|
|
115
|
+
? `event.system.push({ type: "text", text: PERSONA });`
|
|
116
|
+
: `event.system.splice(0, event.system.length, { type: "text", text: PERSONA });`;
|
|
117
|
+
const src = `// 由 agent-purge 生成;删除本文件即恢复宿主人设。
|
|
118
|
+
const PERSONA = ${JSON.stringify(persona)};
|
|
119
|
+
|
|
120
|
+
export default {
|
|
121
|
+
id: "agent-purge-v2-session",
|
|
122
|
+
async setup(ctx) {
|
|
123
|
+
await ctx.session.hook("context", (event) => {
|
|
124
|
+
${mutate}
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
`;
|
|
129
|
+
const write = () => {
|
|
130
|
+
mkdirSync(dir, { recursive: true });
|
|
131
|
+
writeFileSync(file, src);
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
cmd: "opencode2",
|
|
135
|
+
args,
|
|
136
|
+
note: `[plugin] ${file.replace(/\\/g, "/")}(ctx.session.hook("context") 改 event.system)`,
|
|
137
|
+
setup: write,
|
|
138
|
+
cleanup: () => rmSync(file, { force: true }),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
|
|
89
142
|
// 需要落盘或调用方配合,CLI 不代劳,只给可复制的配方
|
|
90
143
|
zcode: () => ({
|
|
91
144
|
manual: [
|
|
@@ -153,6 +206,16 @@ export const oneshots = {
|
|
|
153
206
|
const b = backends.opencode({ personaText, cwd, args: ["run", prompt] });
|
|
154
207
|
return b;
|
|
155
208
|
},
|
|
209
|
+
opencode2: ({ prompt, personaText, mode }) => {
|
|
210
|
+
if (mode === "off") return { cmd: "opencode2", args: ["run", prompt] };
|
|
211
|
+
const b = backends.opencode2({ personaText, mode, args: ["run", prompt] });
|
|
212
|
+
return b;
|
|
213
|
+
},
|
|
214
|
+
dsh: ({ prompt, personaText, mode }) => {
|
|
215
|
+
if (mode === "off") return { cmd: "dsh", args: ["--profile", "headless", prompt] };
|
|
216
|
+
// backends.dsh 会自己补 --profile headless,这里只给 prompt(否则重复)
|
|
217
|
+
return backends.dsh({ personaText, mode, args: [prompt] });
|
|
218
|
+
},
|
|
156
219
|
zcode: ({ prompt }) => ({
|
|
157
220
|
cmd: "zcode",
|
|
158
221
|
args: ["--prompt", prompt, "--mode", "yolo", "--output-format", "stream-json", "--no-color"],
|
package/bin/agent-purge.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
// agent-purge — 一个入口,把各家 harness 的系统提示词替换收进来。
|
|
3
3
|
// 能力与实测数字见 matrix.json。
|
|
4
4
|
//
|
|
@@ -309,22 +309,27 @@ function modeCmd() {
|
|
|
309
309
|
async function installCmd(verb, rest) {
|
|
310
310
|
const id = rest[0];
|
|
311
311
|
const ids = id === "--all" || !id ? Object.keys(matrix.harnesses).filter((h) => matrix.harnesses[h].class !== "unsupported") : [id];
|
|
312
|
-
|
|
312
|
+
// 常驻只走 lib/persist.mjs 这一个控制面(caps / Web 开关 / status 都读它的 targets)
|
|
313
|
+
const { install, uninstall, targets } = await import("../lib/persist.mjs");
|
|
314
|
+
const { createStore } = await import("../lib/personas.mjs");
|
|
315
|
+
const store = createStore(DATA);
|
|
313
316
|
for (const h of ids) {
|
|
314
|
-
|
|
315
|
-
if (!
|
|
316
|
-
const fn = verb === "install" ? b.install : b.uninstall;
|
|
317
|
-
if (!fn) {
|
|
317
|
+
if (!matrix.harnesses[h]) die(`未知 harness: ${h}`);
|
|
318
|
+
if (!targets[h]) {
|
|
318
319
|
console.log(`${h}: 没有常驻模式(用 arm 走裸命令接管 / slash 走会话内)`);
|
|
319
320
|
continue;
|
|
320
321
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
322
|
+
let r;
|
|
323
|
+
if (verb === "install") {
|
|
324
|
+
const pid = store.activeId();
|
|
325
|
+
if (!pid) die("还没有 persona:先 agent-purge use <id>");
|
|
326
|
+
r = install(h, { root: DATA, active: store.refreshActive() }) ?? {};
|
|
327
|
+
} else {
|
|
328
|
+
// 卸载不能依赖 persona 还在:人设删了也必须能关
|
|
329
|
+
r = uninstall(h) ?? {};
|
|
330
|
+
}
|
|
331
|
+
const ok = !r.error;
|
|
332
|
+
console.log(`${ok ? "✓" : "✗"} ${verb} ${h}${r.file ? ` → ${r.file}` : ""}${r.note ? ` (${r.note})` : ""}${r.error ? ` ${r.error}` : ""}`);
|
|
328
333
|
if (ok) setArmed(DATA, h, "install", verb === "install");
|
|
329
334
|
}
|
|
330
335
|
}
|
|
@@ -720,7 +725,8 @@ function help() {
|
|
|
720
725
|
其他家没有这个能力:/purge 展开成用户消息,bench 实测 0/2 无效,所以不提供
|
|
721
726
|
注入强度:只有 sys 层算。user 层(上下文块/用户消息)在 codex/claude/opencode/grok/hermes 实测都是 0/2,一律不提供。
|
|
722
727
|
|
|
723
|
-
harness: pi, opencode, codex, claude, grok, omp, hermes, zcode, antigravity, qoder, workbuddy, codebuddy
|
|
728
|
+
harness: pi, opencode, opencode2, dsh, codex, claude, grok, omp, hermes, zcode, antigravity, qoder, workbuddy, codebuddy
|
|
729
|
+
opencode2 = @opencode/cli 2.x(v2 插件 API),dsh = DeepSeek Harness(cordis 插件,desktop/web/headless 三形态)。
|
|
724
730
|
qoder / workbuddy 无 prompt 组装入口(提示词在服务端),只能改上下文规则文件。
|
|
725
731
|
`);
|
|
726
732
|
}
|
package/lib/caps.mjs
CHANGED
|
@@ -46,6 +46,8 @@ const DESKTOP_FIRST = new Set(["zcode", "antigravity", "qoder", "workbuddy"]);
|
|
|
46
46
|
},
|
|
47
47
|
qoder: { shim: "提示词在服务端,本地拦不到" },
|
|
48
48
|
workbuddy: { shim: "提示词在服务端,本地拦不到" },
|
|
49
|
+
// dsh 的 CLI 只能起 headless:接管裸命令会把桌面/网页入口静默改道
|
|
50
|
+
dsh: { shim: "dsh 命令行只能起 headless:接管裸命令会让双击/网页入口变成终端会话;要三形态就装常驻插件(install)" },
|
|
49
51
|
codebuddy: {
|
|
50
52
|
install: "只有 settings.json(插件开关/模型),没有能把文本塞进提示的词条;实测只能走启动参数或壳",
|
|
51
53
|
},
|
|
@@ -62,6 +64,8 @@ const BLOCKED = { antigravity: "二进制补丁才能改它的系统提示,配
|
|
|
62
64
|
const HOT = {
|
|
63
65
|
pi: { kind: "command", needs: "slash", how: "扩展钩子 before_agent_start:/purge 或改设置,下一轮生效" },
|
|
64
66
|
opencode: { kind: "state", needs: "install", how: "插件钩子 chat.system.transform 每轮读 state.json + active.md:改设置下一轮生效" },
|
|
67
|
+
dsh: { kind: "state", needs: "install", how: "cordis 插件每轮 assemble 读 state.json + active.md:改设置下一轮生效(desktop 装完要重启一次)" },
|
|
68
|
+
opencode2: { kind: "state", needs: "install", how: "v2 插件 ctx.session.hook('context') 每轮读 state.json + active.md:改设置下一轮生效" },
|
|
65
69
|
};
|
|
66
70
|
|
|
67
71
|
/** 没有每轮拼装点的:说明为什么只能重启 */
|
|
@@ -105,7 +109,7 @@ export function capsFor(id, ctx = {}) {
|
|
|
105
109
|
: `本机 PATH 里找不到 ${id}(先把它装到 PATH 上)`
|
|
106
110
|
: desktop
|
|
107
111
|
? `只对终端里敲 \`${id}\` 生效;桌面图标启动绕开 PATH`
|
|
108
|
-
:
|
|
112
|
+
: reason(id, "shim");
|
|
109
113
|
|
|
110
114
|
const t = persistTargets[id];
|
|
111
115
|
// user 层不算能力:写下去也压不住 harness 自己的身份,装了等于白装
|
package/lib/detect.mjs
CHANGED
|
@@ -14,6 +14,8 @@ export const specs = {
|
|
|
14
14
|
codebuddy: { bins: ["codebuddy"], homes: [["CODEBUDDY_HOME", ".codebuddy"]], version: ["--version"], marker: "history.jsonl" },
|
|
15
15
|
claude: { bins: ["claude"], homes: [["CLAUDE_CONFIG_DIR", ".claude"]], version: ["--version"], marker: "settings.json" },
|
|
16
16
|
opencode: { bins: ["opencode"], homes: [["XDG_CONFIG_HOME", ".config/opencode"]], version: ["--version"], marker: "opencode.json" },
|
|
17
|
+
opencode2: { bins: ["opencode2"], homes: [["XDG_CONFIG_HOME", ".config/opencode"]], version: ["--version"], marker: "opencode.json" },
|
|
18
|
+
dsh: { bins: ["dsh"], homes: [["DSH_HOME", ".dsh"]], version: ["--version"], marker: "profiles" },
|
|
17
19
|
grok: { bins: ["grok"], homes: [["GROK_HOME", ".grok"]], version: ["--version"], marker: "config.toml" },
|
|
18
20
|
omp: { bins: ["omp"], homes: [["OMP_HOME", ".omp"]], version: ["--version"], marker: "agent/config.yml" },
|
|
19
21
|
hermes: { bins: ["hermes"], homes: [["HERMES_HOME", "AppData/Local/hermes"]], version: ["--version"], marker: "hermes-agent" },
|
package/lib/dsh.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// dsh(DeepSeek Harness)的插件通道,三形态(desktop / web / headless)共用一套写法。
|
|
2
|
+
//
|
|
3
|
+
// 两个插件名,互不覆盖:
|
|
4
|
+
// agent-purge 常驻:每次 assemble 读 state.json + active.md,off 即时生效
|
|
5
|
+
// agent-purge-session 一次性:persona 快照编译进源码,进程退出即删
|
|
6
|
+
//
|
|
7
|
+
// 三个 profile 都要装:paths 推导自 profile 的 dirname(src/index.js → package root),
|
|
8
|
+
// 所以包必须放在 <profile>/node_modules/<name> 下。
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const DSH_PROFILES = ["desktop", "web", "headless"];
|
|
14
|
+
export const RESIDENT = "agent-purge";
|
|
15
|
+
export const SESSION = "agent-purge-session";
|
|
16
|
+
|
|
17
|
+
export const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
18
|
+
export const profileDir = (p) => join(dshHome(), "profiles", p);
|
|
19
|
+
export const pluginDir = (p, name = RESIDENT) => join(profileDir(p), "node_modules", name);
|
|
20
|
+
const patchFile = (p) => join(profileDir(p), "cordis.patch.yml");
|
|
21
|
+
|
|
22
|
+
const mark = (name) => [`# >>> agent-purge:${name}`, `# <<< agent-purge:${name}`];
|
|
23
|
+
|
|
24
|
+
/** 这个 package.json 是 agent-purge 写的吗(别碰同名外来目录)。 */
|
|
25
|
+
const isOurs = (meta) => existsSync(meta) && /"?agentPurge"?\s*:\s*true/.test(readFileSync(meta, "utf8"));
|
|
26
|
+
|
|
27
|
+
/** 常驻插件:不编译人设,每次请求现读 state;off 就原样放过宿主的结果。 */
|
|
28
|
+
export function residentSource(root, activePath) {
|
|
29
|
+
const state = join(root, "state.json").replace(/\\/g, "/");
|
|
30
|
+
// active.md 是派生物,实际位置是 <root>/personas/active.md(由 createStore().materialize 给出)
|
|
31
|
+
const active = String(activePath).replace(/\\/g, "/");
|
|
32
|
+
return `// 由 agent-purge 生成;删掉本目录 + patch 段即取消。
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
const STATE = ${JSON.stringify(state)};
|
|
35
|
+
const ACTIVE = ${JSON.stringify(active)};
|
|
36
|
+
function cfg() {
|
|
37
|
+
try {
|
|
38
|
+
const st = JSON.parse(readFileSync(STATE, "utf8"));
|
|
39
|
+
// 磁盘卸载后内存里的回调还在:armed 里没有 install 就当成已关闭
|
|
40
|
+
const armed = st && st.armed && st.armed.dsh;
|
|
41
|
+
if (!Array.isArray(armed) || !armed.includes("install")) return null;
|
|
42
|
+
const mode = (st && st.default && st.default.mode) || "replace";
|
|
43
|
+
if (mode === "off") return null;
|
|
44
|
+
const body = readFileSync(ACTIVE, "utf8").trim();
|
|
45
|
+
return body ? { mode, body } : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export default {
|
|
51
|
+
name: ${JSON.stringify(RESIDENT)},
|
|
52
|
+
inject: ["systemPrompt"],
|
|
53
|
+
apply(ctx) {
|
|
54
|
+
const prompt = ctx.systemPrompt;
|
|
55
|
+
const orig = prompt.assemble.bind(prompt);
|
|
56
|
+
prompt.assemble = async (...rest) => {
|
|
57
|
+
const asm = await orig(...rest);
|
|
58
|
+
const c = cfg();
|
|
59
|
+
if (!c) return asm;
|
|
60
|
+
if (c.mode === "append") return { ...asm, sections: [...(asm.sections || []), { name: ${JSON.stringify(RESIDENT)}, text: c.body }] };
|
|
61
|
+
return { ...asm, sections: [{ name: ${JSON.stringify(RESIDENT)}, text: c.body }] };
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 一次性插件:人设快照内嵌,进程结束就没。 */
|
|
69
|
+
export function sessionSource(personaText, mode) {
|
|
70
|
+
const body =
|
|
71
|
+
mode === "append"
|
|
72
|
+
? `\t\tctx.systemPrompt.section({ name: ${JSON.stringify(SESSION)}, order: 5100, interpolate: false, text: PERSONA });`
|
|
73
|
+
: `\t\tconst prompt = ctx.systemPrompt;\n\t\tconst orig = prompt.assemble.bind(prompt);\n\t\tprompt.assemble = async (...rest) => {\n\t\t\tconst asm = await orig(...rest);\n\t\t\treturn { ...asm, sections: [{ name: ${JSON.stringify(SESSION)}, text: PERSONA }] };\n\t\t};`;
|
|
74
|
+
return `// 由 agent-purge 生成(一次性,进程退出即删)。
|
|
75
|
+
const PERSONA = ${JSON.stringify(personaText.trim())};
|
|
76
|
+
|
|
77
|
+
export default {
|
|
78
|
+
name: ${JSON.stringify(SESSION)},
|
|
79
|
+
inject: ["systemPrompt"],
|
|
80
|
+
apply(ctx) {
|
|
81
|
+
${body}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 装一个插件名到三个 profile,并在 patch 里挂上(同一段 marker 幂等)。 */
|
|
88
|
+
export function addPlugin(name, source) {
|
|
89
|
+
const pkg = JSON.stringify({ name, version: "0.0.0", type: "module", main: "index.js", agentPurge: true }, null, 1);
|
|
90
|
+
const [begin, end] = mark(name);
|
|
91
|
+
const block = `\n${begin}\n- insert:\n - id: ${name}\n name: ${name}\n${end}\n`;
|
|
92
|
+
const files = [];
|
|
93
|
+
const touched = []; // { dir, patch, before }:中途抛错时按相反顺序回滚
|
|
94
|
+
// 预检:同名目录要么不存在,要么是我们的(package.json 带 agentPurge 标记),否则不碰
|
|
95
|
+
for (const p of DSH_PROFILES) {
|
|
96
|
+
const dir = pluginDir(p, name);
|
|
97
|
+
const meta = join(dir, "package.json");
|
|
98
|
+
if (existsSync(dir) && !isOurs(meta)) throw new Error(`${dir} 已存在且不是 agent-purge 装的,拒绝覆盖`);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
for (const p of DSH_PROFILES) {
|
|
102
|
+
const dir = pluginDir(p, name);
|
|
103
|
+
const patch = patchFile(p);
|
|
104
|
+
const before = existsSync(patch) ? readFileSync(patch, "utf8") : null;
|
|
105
|
+
// 先登记再动手:下面任何一步抛错,这一轮的产物都能被回滚
|
|
106
|
+
touched.push({ dir, patch, before });
|
|
107
|
+
mkdirSync(dir, { recursive: true });
|
|
108
|
+
writeFileSync(join(dir, "package.json"), pkg);
|
|
109
|
+
writeFileSync(join(dir, "index.js"), source);
|
|
110
|
+
files.push(dir);
|
|
111
|
+
const old = before ?? "# agent-purge 新建的 patch 层\n";
|
|
112
|
+
if (old.includes(begin)) continue;
|
|
113
|
+
// `[]` 是合法空数组,后面接 `- insert:` 就是非法 YAML:整份换成我们的段
|
|
114
|
+
// 不存在也算“空”:走 stash 分支才能把“原本没有这个文件”记下来,卸载后才不会留个空壳
|
|
115
|
+
const empty = before === null || /^\s*(\[\s*\]\s*)?$/.test(old);
|
|
116
|
+
const next = empty ? block.slice(1) : old + block;
|
|
117
|
+
if (empty) {
|
|
118
|
+
// 整份替换会把原文端掉:原文 + 我们写出的成品都存下,卸载时对得上才整份还原
|
|
119
|
+
// (对不上 = 期间被人改过,那就只剔自己那段)
|
|
120
|
+
writeFileSync(join(dir, ".patch.before"), JSON.stringify({ before, after: next }));
|
|
121
|
+
}
|
|
122
|
+
writeFileSync(patch, next);
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// 回滚:撤销本次已写的文件,宁可完全没装也不留半套
|
|
126
|
+
for (const { dir, patch, before } of touched.reverse()) {
|
|
127
|
+
rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
if (before === null) rmSync(patch, { force: true });
|
|
129
|
+
else writeFileSync(patch, before);
|
|
130
|
+
}
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
return { dirs: files, profiles: DSH_PROFILES };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 只撤自己那一段:目录 + patch marker,别的配置原样。 */
|
|
137
|
+
export function removePlugin(name) {
|
|
138
|
+
const [begin, end] = mark(name);
|
|
139
|
+
// 删 install 写入的那一段:前导 \n 可有可无(整份替换的形态没有),尾随 \n 一并吃掉
|
|
140
|
+
const re = new RegExp(`(?:^|\\n)${begin}[\\s\\S]*?${end}\\n?`, "g");
|
|
141
|
+
for (const p of DSH_PROFILES) {
|
|
142
|
+
const dir = pluginDir(p, name);
|
|
143
|
+
const meta = join(dir, "package.json");
|
|
144
|
+
// 只删自己装的;同名但不是我们的目录留着(可能被用户或其他包占了)
|
|
145
|
+
const ours = isOurs(meta);
|
|
146
|
+
const stashFile = join(dir, ".patch.before");
|
|
147
|
+
// 当初整份替换过的:记录躲在插件目录里,先读出来,不然删目录就丢了
|
|
148
|
+
let stash = null;
|
|
149
|
+
if (ours && existsSync(stashFile)) {
|
|
150
|
+
try {
|
|
151
|
+
stash = JSON.parse(readFileSync(stashFile, "utf8"));
|
|
152
|
+
} catch {
|
|
153
|
+
stash = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (ours) rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
const patch = patchFile(p);
|
|
158
|
+
if (!existsSync(patch)) continue;
|
|
159
|
+
const text = readFileSync(patch, "utf8");
|
|
160
|
+
// 我们整份写的、且此后没人动过 → 整份还原(原本没这个文件就删掉它)
|
|
161
|
+
if (stash && text === stash.after) {
|
|
162
|
+
if (stash.before === null) rmSync(patch, { force: true });
|
|
163
|
+
else writeFileSync(patch, stash.before);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (text.includes(begin)) writeFileSync(patch, text.replace(re, ""));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** patch 里还挂着这个名字吗(status 用;不许在有其他 insert 时谎报干净)。 */
|
|
171
|
+
export function hasPlugin(name) {
|
|
172
|
+
const [begin] = mark(name);
|
|
173
|
+
return DSH_PROFILES.every((p) => {
|
|
174
|
+
const patch = patchFile(p);
|
|
175
|
+
return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && readFileSync(patch, "utf8").includes(begin);
|
|
176
|
+
});
|
|
177
|
+
}
|
package/lib/persist.mjs
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
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
7
|
|
|
7
8
|
const BEGIN = "# >>> agent-purge";
|
|
8
9
|
const END = "# <<< agent-purge";
|
|
9
10
|
const home = process.env.USERPROFILE || process.env.HOME;
|
|
11
|
+
// opencode 认 XDG_CONFIG_HOME(lib/detect.mjs 也是这么探测的):两处必须同一个落点,否则装了白装
|
|
12
|
+
export const opencodePlugins = () => join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "opencode", "plugins");
|
|
10
13
|
|
|
11
14
|
export const targets = {
|
|
12
15
|
pi: {
|
|
@@ -67,8 +70,10 @@ export const targets = {
|
|
|
67
70
|
},
|
|
68
71
|
opencode: {
|
|
69
72
|
// 写入用全局单一位置;读取/删除要扫全部历史位置,否则老版本留下的项目级插件会继续注入
|
|
70
|
-
file: () => join(
|
|
73
|
+
file: () => join(opencodePlugins(), "agent-purge.ts"),
|
|
71
74
|
files: () => [
|
|
75
|
+
join(opencodePlugins(), "agent-purge.ts"),
|
|
76
|
+
// 旧落点:设了 XDG 的机器上,老版本写在 home/.config 的那份也得能被扫到并清掉
|
|
72
77
|
join(home, ".config", "opencode", "plugins", "agent-purge.ts"),
|
|
73
78
|
join(home, ".config", "opencode", "plugin", "agent-purge.ts"),
|
|
74
79
|
join(process.cwd(), ".opencode", "plugin", "agent-purge.ts"),
|
|
@@ -99,6 +104,62 @@ export const AgentPurge = async () => ({
|
|
|
99
104
|
else output.system.splice(0, output.system.length, c.body);
|
|
100
105
|
},
|
|
101
106
|
});
|
|
107
|
+
`,
|
|
108
|
+
},
|
|
109
|
+
dsh: {
|
|
110
|
+
// cordis 插件挂 systemPrompt 层;三个 profile 各一份(desktop 由 Electron 独占,装完要重启一次)
|
|
111
|
+
file: () => join(pluginDir("desktop"), "index.js"),
|
|
112
|
+
kind: "file",
|
|
113
|
+
layer: "sys",
|
|
114
|
+
note: "cordis 插件包;运行时读 state.json,off 即时生效;desktop 需重启一次",
|
|
115
|
+
probe: () => hasPlugin(DSH_RESIDENT),
|
|
116
|
+
install: ({ root, active }) => {
|
|
117
|
+
const r = addPlugin(DSH_RESIDENT, residentSource(root, active));
|
|
118
|
+
return { file: join(pluginDir("desktop"), "index.js"), note: `三个 profile:${r.profiles.join("/")};desktop 重启 DeepSeek Harness 生效` };
|
|
119
|
+
},
|
|
120
|
+
uninstall: () => {
|
|
121
|
+
removePlugin(DSH_RESIDENT);
|
|
122
|
+
// 一次性插件被 SIGKILL 时不会自己清,一并扫掉
|
|
123
|
+
removePlugin(DSH_SESSION);
|
|
124
|
+
return { file: join(pluginDir("desktop"), "index.js") };
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
opencode2: {
|
|
128
|
+
// v2 新版插件 API(V1 的 experimental.chat.system.transform 在 2.x 已废),与 V1 插件文件物理分开
|
|
129
|
+
file: () => join(opencodePlugins(), "agent-purge-v2.ts"),
|
|
130
|
+
files: () => [join(opencodePlugins(), "agent-purge-v2.ts"), join(opencodePlugins(), "agent-purge-v2-session.ts")],
|
|
131
|
+
kind: "file",
|
|
132
|
+
layer: "sys",
|
|
133
|
+
note: "v2 插件 ctx.session.hook(\"context\");运行时读 state.json,热切",
|
|
134
|
+
body: (active, root) => `import { readFileSync } from "node:fs";
|
|
135
|
+
// agent-purge 托管文件:删除即可取消
|
|
136
|
+
const STATE = ${JSON.stringify(join(root, "state.json").replace(/\\/g, "/"))};
|
|
137
|
+
const ACTIVE = ${JSON.stringify(String(active).replace(/\\/g, "/"))};
|
|
138
|
+
function cfg() {
|
|
139
|
+
try {
|
|
140
|
+
const st = JSON.parse(readFileSync(STATE, "utf8"));
|
|
141
|
+
// 磁盘卸载后服务里已加载的回调还在:armed 没了就当成已关闭
|
|
142
|
+
const armed = st && st.armed && st.armed.opencode2;
|
|
143
|
+
if (!Array.isArray(armed) || !armed.includes("install")) return null;
|
|
144
|
+
const mode = (st && st.default && st.default.mode) || "replace";
|
|
145
|
+
if (mode === "off") return null;
|
|
146
|
+
const body = readFileSync(ACTIVE, "utf8").trim();
|
|
147
|
+
return body ? { mode, body } : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export default {
|
|
153
|
+
id: "agent-purge",
|
|
154
|
+
async setup(ctx) {
|
|
155
|
+
await ctx.session.hook("context", (event) => {
|
|
156
|
+
const c = cfg();
|
|
157
|
+
if (!c) return;
|
|
158
|
+
if (c.mode === "append") event.system.push({ type: "text", text: c.body });
|
|
159
|
+
else event.system.splice(0, event.system.length, { type: "text", text: c.body });
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
};
|
|
102
163
|
`,
|
|
103
164
|
},
|
|
104
165
|
};
|
|
@@ -106,6 +167,8 @@ export const AgentPurge = async () => ({
|
|
|
106
167
|
export function status(_root) {
|
|
107
168
|
return Object.entries(targets).map(([name, t]) => {
|
|
108
169
|
const file = t.file();
|
|
170
|
+
// 自定义探针(多文件目标用),优先于默认的存在性判断
|
|
171
|
+
if (t.probe) return { name, installed: t.probe(), file };
|
|
109
172
|
const candidates = t.files ? t.files() : [file];
|
|
110
173
|
const present = candidates.filter((f) => existsSync(f));
|
|
111
174
|
if (!present.length) return { name, installed: false, file };
|
|
@@ -121,6 +184,15 @@ export function status(_root) {
|
|
|
121
184
|
export function install(name, { root, active }) {
|
|
122
185
|
const t = targets[name];
|
|
123
186
|
if (!t) return { error: `${name} 没有持久化后端(见 notes:grok/omp 只有 CLI 开关,claude/zcode/antigravity 要打补丁)` };
|
|
187
|
+
// 多文件/自定义布局的 harness 自带安装函数(dsh 三个 profile、opencode2 v2 插件)
|
|
188
|
+
// 它们的失败信号是异常(同名外来目录拒绝覆盖等),这里转成调用方约好的 {error}
|
|
189
|
+
if (t.install) {
|
|
190
|
+
try {
|
|
191
|
+
return { ...t.install({ root, active }), action: "custom" };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return { error: e.message };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
124
196
|
const file = t.file();
|
|
125
197
|
mkdirSync(dirname(file), { recursive: true });
|
|
126
198
|
if (t.kind === "file") {
|
|
@@ -136,6 +208,13 @@ export function install(name, { root, active }) {
|
|
|
136
208
|
export function uninstall(name) {
|
|
137
209
|
const t = targets[name];
|
|
138
210
|
if (!t) return { error: `${name} 没有持久化后端` };
|
|
211
|
+
if (t.uninstall) {
|
|
212
|
+
try {
|
|
213
|
+
return { ...t.uninstall(), action: "custom" };
|
|
214
|
+
} catch (e) {
|
|
215
|
+
return { error: e.message };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
139
218
|
if (t.kind === "file") {
|
|
140
219
|
// 清掉所有历史位置,保证“关掉”之后没有任何残留插件还在注入
|
|
141
220
|
const candidates = t.files ? t.files() : [t.file()];
|
package/matrix.json
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
"note": "每个数字都来自 agent-purge 的方法:本地假 provider 抓请求,或 harness 自带日志。更新时改这里,不要改 README 表格。",
|
|
3
3
|
"personasDir": "personas",
|
|
4
4
|
"harnesses": {
|
|
5
|
+
"dsh": {
|
|
6
|
+
"class": "plugin",
|
|
7
|
+
"override": true,
|
|
8
|
+
"inject": true,
|
|
9
|
+
"hotSwitch": true,
|
|
10
|
+
"patch": false,
|
|
11
|
+
"launcher": "dsh(三个 profile 各放 node_modules/agent-purge 插件,cordis.patch.yml 里 - insert 挂载;不碰 app.asar)",
|
|
12
|
+
"defaultSystemChars": 5713,
|
|
13
|
+
"purgedSystemChars": 68,
|
|
14
|
+
"measuredVersion": "0.2.1-rc.2",
|
|
15
|
+
"verify": "本地假 provider 抓请求体:headless / desktop / web 三形态各验一遍,插件 canary 进 system(本机走 developer 角色)"
|
|
16
|
+
},
|
|
17
|
+
"opencode2": {
|
|
18
|
+
"class": "plugin",
|
|
19
|
+
"override": true,
|
|
20
|
+
"inject": true,
|
|
21
|
+
"hotSwitch": true,
|
|
22
|
+
"patch": false,
|
|
23
|
+
"launcher": "opencode2(~/.config/opencode/plugins/agent-purge-v2.ts,ctx.session.hook(\"context\") 改 event.system)",
|
|
24
|
+
"defaultSystemChars": 8279,
|
|
25
|
+
"purgedSystemChars": 3189,
|
|
26
|
+
"measuredVersion": "0.0.0-beta-17823",
|
|
27
|
+
"verify": "本地假 provider 抓请求体:canary 进 role=system 消息(--print-logs 可看到 v2 插件加载)"
|
|
28
|
+
},
|
|
5
29
|
"pi": {
|
|
6
30
|
"class": "native-extension",
|
|
7
31
|
"override": true,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-purge",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode, grok, omp, hermes, pi, zcode.",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode (v1/v2), grok, omp, hermes, pi, zcode, dsh.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agent-purge": "bin/agent-purge.mjs"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"web": "node bin/agent-purge.mjs web",
|
|
24
24
|
"matrix": "node bin/agent-purge.mjs matrix",
|
|
25
25
|
"detect": "node bin/agent-purge.mjs detect",
|
|
26
|
+
"test": "node magpie-plugin/selftest.mjs && node magpie-plugin/test-page.mjs && node test/harness-adapters.test.mjs",
|
|
26
27
|
"build:exe": "node scripts/build-exe.mjs",
|
|
27
28
|
"prepack": "node -e \"require('node:fs').rmSync('personas/.build',{recursive:true,force:true})\""
|
|
28
29
|
},
|
|
@@ -31,6 +32,8 @@
|
|
|
31
32
|
"codex",
|
|
32
33
|
"claude",
|
|
33
34
|
"opencode",
|
|
35
|
+
"dsh",
|
|
36
|
+
"deepseek-harness",
|
|
34
37
|
"coding-agent",
|
|
35
38
|
"pi",
|
|
36
39
|
"persona",
|