agent-purge 0.2.7 → 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 +17 -24
- package/backends/index.mjs +3 -3
- package/bin/agent-purge.mjs +58 -25
- 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 |
|
|
@@ -80,32 +90,14 @@ agent-purge pi --persona <id> # 只这一轮用别的
|
|
|
80
90
|
|
|
81
91
|
闭源桌面端(提示词在服务端或二进制里)只做原样启动,不做任何注入——`agent-purge <h>` 会明确打印原因,不会假装生效。
|
|
82
92
|
|
|
83
|
-
###
|
|
84
|
-
|
|
85
|
-
desktop / web / headless 三个形态共用一套插件:往每个 profile 的 `node_modules/agent-purge/` 放一个 cordis 插件,在 `cordis.patch.yml` 里用 `- insert` 挂上。**不改 `app.asar`,不碰安装目录,不依赖任何第三方插件。**
|
|
93
|
+
### 常驻安装(install)
|
|
86
94
|
|
|
87
95
|
```bash
|
|
88
|
-
agent-purge install
|
|
89
|
-
agent-purge
|
|
90
|
-
agent-purge uninstall dsh # 删插件 + 剥离 patch 段,字节还原
|
|
96
|
+
agent-purge install <h> # 写进该 harness 自己的托管位置,别人启动也生效
|
|
97
|
+
agent-purge uninstall <h> # 摘掉,按字节还原
|
|
91
98
|
```
|
|
92
99
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
三个形态可以**分别开关**:Web 面板的 dsh 卡片下方有 desktop / web / headless 三个小勾,对应 `state.json` 的 `dshProfiles`。比如只在桌面版用,就把 web 和 headless 关掉;勾选只写状态文件,插件每轮现读,**不用重启**。
|
|
96
|
-
|
|
97
|
-
只想临时试一下、不碰常驻 profile:`agent-purge dsh --ask "…"`(一次性会话,进程退出即清理)。
|
|
98
|
-
|
|
99
|
-
#### 为什么不做「文件夹级人设」
|
|
100
|
-
|
|
101
|
-
很容易想到的下一步是:同一个 dsh 里,A 项目一套人设、B 项目另一套。技术上做得到——插件在 `assemble` 时能拿到 cwd,按目录前缀匹配 persona 即可。但这是**破坏性**的设计,所以刻意不做:
|
|
102
|
-
|
|
103
|
-
- **唯一真相被打破**。现在所有人设共用一个 `state.json` 指针,换一次、所有入口跟着变。按目录分叉等于把「配置」升级成「规则引擎」,之后每次改人设都要先回答「改的是哪一层」。
|
|
104
|
-
- **规则会自我繁殖**。前缀匹配必然要处理嵌套目录、glob、优先级、冲突兜底。今天一个前缀,明天就要 `.agent-purge/` 文件,后天要继承与覆盖——最后没人说得清当前生效的是哪一条。
|
|
105
|
-
- **跨入口语义分裂**。codex / claude / opencode 这些入口没有「项目」概念(有的是 PATH shim,有的是配置文件)。dsh 里按目录切、别处全局,用户无法解释「为什么这里是人设 A,那里是 B」。
|
|
106
|
-
- **卸载不再是字节还原**。一旦引入目录规则,卸载就要判断哪些规则是用户写的、哪些是工具写的,回滚成本陡增。
|
|
107
|
-
|
|
108
|
-
一句话:**agent-purge 卖的是「一份人设,所有入口,一起切换」**。做文件夹级隔离,赚的是灵活性,赔的是这句话还成不成立。真有这个需求,给每个项目放不同的人设文件、用 `agent-purge use` 手动切,比在插件里塞一套匹配引擎干净得多。
|
|
100
|
+
dsh 是唯一有多个形态的:desktop / web / headless 共用一套 cordis 插件,装在各自 profile 的 `node_modules/agent-purge/`,并在 `cordis.patch.yml` 里 `- insert` 挂上,不改 `app.asar`。装完重启一次,之后热生效;三个形态还能在 Web 面板分别勾选。
|
|
109
101
|
|
|
110
102
|
## magpie 网关插件(可选)
|
|
111
103
|
|
|
@@ -133,7 +125,7 @@ magpie plugin list # 应看到 async gatewa
|
|
|
133
125
|
|
|
134
126
|
options 三键:`mode`、`persona`(人设 id)、`agents`(只对这些 magpie agent 生效,如 `["claude","codex"]`)。
|
|
135
127
|
|
|
136
|
-
**边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs
|
|
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 合并、失败回滚)。
|
|
137
129
|
|
|
138
130
|
## 目录
|
|
139
131
|
|
|
@@ -151,6 +143,7 @@ matrix.json 能力矩阵
|
|
|
151
143
|
|
|
152
144
|
- 只改提示词相关的位置,不碰二进制、不破解、不绕过任何授权。
|
|
153
145
|
- 所有改动可逆:`agent-purge uninstall <h>` / `disarm` / `--mode off`。
|
|
146
|
+
- 不做文件夹级人设隔离:技术上做得到,但会打破「一份人设、所有入口、一起切换」,卸载也不再是字节还原。
|
|
154
147
|
- 状态只存本机 `state.json`,不上传任何数据。
|
|
155
148
|
|
|
156
149
|
## 免责与合规声明
|
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) => {
|
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
|
}
|
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,
|
|
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: () =>
|
|
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:
|
|
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:
|
|
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}
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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) {
|