agent-purge 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- 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/lib/slashes.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 会话内 `/purge`:把当前 persona 装进去,之后在这个会话里说一句 `/purge` 就切。
|
|
2
2
|
//
|
|
3
3
|
// 这里是整个项目里唯一"照实说实话"的地方:**斜杠命令不等于系统提示词注入**。
|
|
4
|
-
// pi
|
|
4
|
+
// pi / omp —— 扩展能在 before_agent_start 改写 systemPrompt → sys 层,热切换(实测 100%)
|
|
5
5
|
// claude —— ~/.claude/commands/purge.md 是**用户消息展开** → user 层
|
|
6
6
|
// codex —— ~/.codex/prompts/purge.md 同上 → user 层
|
|
7
7
|
// opencode —— ~/.config/opencode/command/purge.md 同上 → user 层(sys 层要靠 plugin 常驻)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { detect } from "./detect.mjs";
|
|
12
|
-
import { SLASH_RESIDENT, addPlugin,
|
|
12
|
+
import { SLASH_RESIDENT, addPlugin, hasPluginAny, pluginFileOnDisk, removePlugin, slashSource } from "./dsh.mjs";
|
|
13
13
|
|
|
14
14
|
const SIDECAR = ".disabled-by-agent-purge";
|
|
15
15
|
|
|
@@ -70,6 +70,19 @@ export function resolveSlash(id, root) {
|
|
|
70
70
|
note: "opencode 的 command 是用户消息 → user 层(bench 实测 0/2 无效);要 sys 层请用 install(chat.system.transform 插件)",
|
|
71
71
|
};
|
|
72
72
|
},
|
|
73
|
+
omp: () => {
|
|
74
|
+
const base = homeOf("omp");
|
|
75
|
+
if (!base) return null;
|
|
76
|
+
return {
|
|
77
|
+
layer: "sys",
|
|
78
|
+
offered: true,
|
|
79
|
+
hot: true,
|
|
80
|
+
trigger: "/purge on|off|status|mode",
|
|
81
|
+
file: join(base, "agent", "extensions", "agent-purge.ts"),
|
|
82
|
+
render: () => ompExtension(root),
|
|
83
|
+
note: "扩展在 before_agent_start 改写 systemPrompt 数组:真系统提示词层,/purge 热切,下一轮生效",
|
|
84
|
+
};
|
|
85
|
+
},
|
|
73
86
|
// dsh:不是写文件,而是往三个 profile 各装一个 cordis 小插件(agent-purge-slash)。
|
|
74
87
|
// 它只注册 /purge 命令、改 state.json,不碰系统提示词 —— 注入归 agent-purge 常驻插件。
|
|
75
88
|
dsh: () => ({
|
|
@@ -77,7 +90,9 @@ export function resolveSlash(id, root) {
|
|
|
77
90
|
offered: true,
|
|
78
91
|
hot: true,
|
|
79
92
|
trigger: "/purge on|off|status|mode",
|
|
80
|
-
file:
|
|
93
|
+
file: pluginFileOnDisk(SLASH_RESIDENT),
|
|
94
|
+
// 真值看挂载块 + 文件,不看某个 profile 的固定路径(没建过 desktop 就无意义);任一份在就算能用
|
|
95
|
+
probe: () => hasPluginAny(SLASH_RESIDENT),
|
|
81
96
|
render: () => slashSource(root),
|
|
82
97
|
note: "cordis 插件注册 /purge(/ap 为别名):改 state.json,下一句生效,不产生模型消息",
|
|
83
98
|
}),
|
|
@@ -96,7 +111,7 @@ export function installSlash(id, root, { personaId, personaBody }) {
|
|
|
96
111
|
// dsh:装 cordis 插件到三个 profile,没有单文件可写
|
|
97
112
|
if (id === "dsh") {
|
|
98
113
|
const r = addPlugin(SLASH_RESIDENT, slashSource(root));
|
|
99
|
-
return { file:
|
|
114
|
+
return { file: pluginFileOnDisk(SLASH_RESIDENT), layer: s.layer, note: s.note, sidecars: [], profiles: r.profiles };
|
|
100
115
|
}
|
|
101
116
|
mkdirSync(dirname(s.file), { recursive: true });
|
|
102
117
|
|
|
@@ -140,14 +155,14 @@ export function uninstallSlash(id, root) {
|
|
|
140
155
|
|
|
141
156
|
export function slashStatus(root) {
|
|
142
157
|
const out = [];
|
|
143
|
-
for (const id of Object.keys({ pi: 1, claude: 1, codex: 1, opencode: 1, dsh: 1 })) {
|
|
158
|
+
for (const id of Object.keys({ pi: 1, omp: 1, claude: 1, codex: 1, opencode: 1, dsh: 1 })) {
|
|
144
159
|
const s = resolveSlash(id, root);
|
|
145
160
|
if (!s) continue;
|
|
146
|
-
const installed =
|
|
161
|
+
const installed = s.probe ? s.probe() : existsSync(s.file);
|
|
147
162
|
let personaId = null;
|
|
148
|
-
if (installed) {
|
|
163
|
+
if (installed && existsSync(s.file)) {
|
|
149
164
|
const text = readFileSync(s.file, "utf8");
|
|
150
|
-
personaId = (text.match(/agent-purge: ([a-z0-9_-]+)/i) || [])[1] ?? (id === "pi" ? "(跟随 state.json)" : null);
|
|
165
|
+
personaId = (text.match(/agent-purge: ([a-z0-9_-]+)/i) || [])[1] ?? (id === "pi" || id === "omp" ? "(跟随 state.json)" : null);
|
|
151
166
|
}
|
|
152
167
|
out.push({ id, layer: s.layer, offered: s.offered !== false, hot: s.hot, file: s.file, installed, personaId, trigger: s.trigger, note: s.note });
|
|
153
168
|
}
|
|
@@ -222,13 +237,13 @@ function loadState(): { persona: string | null; mode: string; shim: boolean; piA
|
|
|
222
237
|
};
|
|
223
238
|
return {
|
|
224
239
|
persona: envPersona ?? st.persona ?? null,
|
|
225
|
-
mode:
|
|
240
|
+
mode: st.default?.mode ?? "replace",
|
|
226
241
|
shim: Array.isArray(st.armed?.pi) && st.armed.pi.includes("shim"),
|
|
227
242
|
piAuto: st.piAuto === true,
|
|
228
243
|
piUserLayer: st.piUserLayer === true,
|
|
229
244
|
};
|
|
230
245
|
} catch {
|
|
231
|
-
return { persona: envPersona ?? null, mode:
|
|
246
|
+
return { persona: envPersona ?? null, mode: "replace", shim: false, piAuto: process.env.AGENT_PURGE_FORCE === "1", piUserLayer: false };
|
|
232
247
|
}
|
|
233
248
|
}
|
|
234
249
|
|
|
@@ -245,6 +260,11 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
245
260
|
// 所以“只装会话内热切”的扩展不会自己开工;直呼真身二进制也不会被劫持。
|
|
246
261
|
let forced: "on" | "off" | null = null;
|
|
247
262
|
let mode: "strict" | "replace" | "append" = "strict";
|
|
263
|
+
// 启动器用 AGENT_PURGE_MODE 指定本次启动的初始模式;/purge mode 必须能覆盖它,
|
|
264
|
+
// 否则环境变量会在每轮把热切换改回去。
|
|
265
|
+
const ENV_MODE = process.env.AGENT_PURGE_MODE;
|
|
266
|
+
let modeOverride: "strict" | "replace" | "append" | "off" | null =
|
|
267
|
+
ENV_MODE === "off" ? "off" : ENV_MODE === "strict" || ENV_MODE === "append" ? ENV_MODE : ENV_MODE ? "replace" : null;
|
|
248
268
|
let cache = { id: null as string | null, body: "" };
|
|
249
269
|
|
|
250
270
|
// 两条来源:启动器点名(FORCE=1),或 state.json 的 piAuto(裸敲 pi 也注入)
|
|
@@ -258,8 +278,9 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
258
278
|
|
|
259
279
|
pi.on("before_agent_start", async (event) => {
|
|
260
280
|
const st = loadState();
|
|
261
|
-
|
|
262
|
-
|
|
281
|
+
const effective = modeOverride ?? st.mode;
|
|
282
|
+
mode = effective === "strict" || effective === "append" ? effective : "replace";
|
|
283
|
+
const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
|
|
263
284
|
if (on !== "on") return;
|
|
264
285
|
cache = loadPersona();
|
|
265
286
|
if (!cache.body) return;
|
|
@@ -277,8 +298,8 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
277
298
|
try {
|
|
278
299
|
pi.on("context_with_system", async (event: any) => {
|
|
279
300
|
const st = loadState();
|
|
280
|
-
const m =
|
|
281
|
-
const on =
|
|
301
|
+
const m = modeOverride ?? st.mode;
|
|
302
|
+
const on = m === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
|
|
282
303
|
if (on !== "on") return;
|
|
283
304
|
const p = loadPersona();
|
|
284
305
|
if (!p.body) return;
|
|
@@ -330,7 +351,8 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
330
351
|
const describe = () => {
|
|
331
352
|
const p = loadPersona();
|
|
332
353
|
const st = loadState();
|
|
333
|
-
const
|
|
354
|
+
const effective = modeOverride ?? st.mode;
|
|
355
|
+
const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
|
|
334
356
|
const why =
|
|
335
357
|
on === "on"
|
|
336
358
|
? forced
|
|
@@ -338,14 +360,14 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
338
360
|
: process.env.AGENT_PURGE_FORCE === "1"
|
|
339
361
|
? "启动器要求注入"
|
|
340
362
|
: "接管裸命令已开"
|
|
341
|
-
:
|
|
363
|
+
: effective === "off"
|
|
342
364
|
? "总电源关着"
|
|
343
365
|
: st.piAuto
|
|
344
366
|
? "裸启动已开,这次是你手动关的"
|
|
345
367
|
: "只装了会话内热切,裸启动不注入";
|
|
346
368
|
return [
|
|
347
369
|
"状态: " + (on === "on" ? "开启" : "关闭") + "(" + why + ")",
|
|
348
|
-
"模式: " +
|
|
370
|
+
"模式: " + effective,
|
|
349
371
|
"裸启动也注入: " + (st.piAuto ? "开" : "关") + "(/purge always on|off)",
|
|
350
372
|
"人设: " + (p.id ?? "(state.json 里没有 persona)") + " " + (p.body.length ? p.body.length + " 字符" : "未加载"),
|
|
351
373
|
"来源: " + STATE,
|
|
@@ -353,7 +375,7 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
353
375
|
};
|
|
354
376
|
const turnOn = () => {
|
|
355
377
|
const st = loadState();
|
|
356
|
-
if (st.mode === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
|
|
378
|
+
if ((modeOverride ?? st.mode) === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
|
|
357
379
|
const p = loadPersona();
|
|
358
380
|
if (!p.body) return notify("开启失败:state.json 里没有可用 persona(先 agent-purge use <id>)", "error");
|
|
359
381
|
forced = "on";
|
|
@@ -366,7 +388,7 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
366
388
|
// 直接裸启动 pi 的时候 forced 是 null、注入本来是关的,第一次 /purge 应该是「开」而不是「关」。
|
|
367
389
|
const st = loadState();
|
|
368
390
|
const injecting =
|
|
369
|
-
st.mode !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
|
|
391
|
+
(modeOverride ?? st.mode) !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
|
|
370
392
|
if (!injecting) return turnOn();
|
|
371
393
|
forced = "off";
|
|
372
394
|
return notify("已关闭本会话注入,恢复 pi 原始系统提示词。再输入 /purge 即重新开启。");
|
|
@@ -375,19 +397,28 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
375
397
|
return turnOn();
|
|
376
398
|
case "off":
|
|
377
399
|
forced = "off";
|
|
378
|
-
return notify("已关闭本会话注入,恢复 pi 原始系统提示词。");
|
|
400
|
+
return notify("已关闭本会话注入,恢复 pi 原始系统提示词。");
|
|
401
|
+
case "status":
|
|
379
402
|
return notify(describe());
|
|
380
403
|
case "reload":
|
|
381
|
-
|
|
404
|
+
// reload 只重读磁盘;本会话手工开的开关不能被重置
|
|
382
405
|
cache = loadPersona();
|
|
383
|
-
return notify("已重读 state.json
|
|
406
|
+
return notify("已重读 state.json(本会话开关不变)。\\n" + describe());
|
|
384
407
|
case "mode": {
|
|
385
408
|
const want = args.trim().split(/\\s+/)[1];
|
|
386
409
|
if (want !== "strict" && want !== "replace" && want !== "append") {
|
|
387
|
-
return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=清宿主身份句;append
|
|
410
|
+
return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=清宿主身份句;append=宿主后追加", "error");
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
// 以前只改内存局部变量,下一轮就被 state.json 覆盖 → 写进去才算真切换
|
|
414
|
+
const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, any>;
|
|
415
|
+
raw.default = { ...(raw.default ?? { harness: "pi" }), mode: want };
|
|
416
|
+
writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
|
|
417
|
+
modeOverride = want;
|
|
418
|
+
return notify("模式 → " + want + "(已写入 state.json,下一轮生效)\\n" + describe());
|
|
419
|
+
} catch (e) {
|
|
420
|
+
return notify("写 state.json 失败: " + String(e), "error");
|
|
388
421
|
}
|
|
389
|
-
mode = want;
|
|
390
|
-
return notify("模式 → " + want + "\\n" + describe());
|
|
391
422
|
}
|
|
392
423
|
case "always": {
|
|
393
424
|
const want = args.trim().split(/\\s+/)[1];
|
|
@@ -410,3 +441,188 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
410
441
|
}
|
|
411
442
|
`;
|
|
412
443
|
}
|
|
444
|
+
|
|
445
|
+
/** omp 扩展源码:每轮 before_agent_start 现读 state.json,/purge 热切 */
|
|
446
|
+
function ompExtension(root) {
|
|
447
|
+
const statePath = join(root, "state.json").replace(/\\/g, "/");
|
|
448
|
+
const personasDir = join(root, "personas").replace(/\\/g, "/");
|
|
449
|
+
return `/**
|
|
450
|
+
* agent-purge 生成的 /purge 扩展(omp 版;删除本文件即取消)。
|
|
451
|
+
* 人设不内嵌:每次 before_agent_start 现读 state.json → 换 persona / 改模式下一轮就跟上。
|
|
452
|
+
*/
|
|
453
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
454
|
+
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
455
|
+
|
|
456
|
+
const HOME = process.env.AGENT_PURGE_HOME ? String(process.env.AGENT_PURGE_HOME).replace(/\\\\/g, "/") : null;
|
|
457
|
+
const STATE = (HOME ? HOME + "/state.json" : ${JSON.stringify(statePath)});
|
|
458
|
+
const PERSONAS = (HOME ? HOME + "/personas" : ${JSON.stringify(personasDir)});
|
|
459
|
+
|
|
460
|
+
function stripFrontmatter(raw: string): string {
|
|
461
|
+
if (!raw.startsWith("---")) return raw;
|
|
462
|
+
const end = raw.indexOf("\\n---", 3);
|
|
463
|
+
return end === -1 ? raw : raw.slice(end + 4).replace(/^\\r?\\n/, "");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function loadPersona(): { id: string | null; body: string } {
|
|
467
|
+
try {
|
|
468
|
+
const st = JSON.parse(readFileSync(STATE, "utf8")) as { persona?: string };
|
|
469
|
+
const id = process.env.AGENT_PURGE_PERSONA ?? st.persona ?? null;
|
|
470
|
+
if (!id) return { id: null, body: "" };
|
|
471
|
+
return { id, body: stripFrontmatter(readFileSync(PERSONAS + "/" + id + ".md", "utf8")).trim() };
|
|
472
|
+
} catch {
|
|
473
|
+
return { id: process.env.AGENT_PURGE_PERSONA ?? null, body: "" };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function loadState(): { persona: string | null; mode: string; ompAuto: boolean } {
|
|
478
|
+
const envPersona = process.env.AGENT_PURGE_PERSONA;
|
|
479
|
+
const envMode = process.env.AGENT_PURGE_MODE;
|
|
480
|
+
try {
|
|
481
|
+
const st = JSON.parse(readFileSync(STATE, "utf8")) as {
|
|
482
|
+
persona?: string;
|
|
483
|
+
default?: { harness?: string; mode?: string };
|
|
484
|
+
ompAuto?: boolean;
|
|
485
|
+
};
|
|
486
|
+
return {
|
|
487
|
+
persona: envPersona ?? st.persona ?? null,
|
|
488
|
+
mode: envMode ?? st.default?.mode ?? "replace",
|
|
489
|
+
ompAuto: st.ompAuto === true,
|
|
490
|
+
};
|
|
491
|
+
} catch {
|
|
492
|
+
return { persona: envPersona ?? null, mode: envMode ?? "replace", ompAuto: process.env.AGENT_PURGE_FORCE === "1" };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function toLines(value: unknown): string[] {
|
|
497
|
+
if (Array.isArray(value)) return value.map((v) => String(v));
|
|
498
|
+
return value ? [String(value)] : [];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export default function agentPurge(pi: ExtensionAPI) {
|
|
502
|
+
let forced: "on" | "off" | null = null;
|
|
503
|
+
// 启动器的 AGENT_PURGE_MODE 只决定初始模式;/purge mode 必须能盖住它,
|
|
504
|
+
// 否则下一轮又被环境变量改回去。
|
|
505
|
+
const ENV_MODE = process.env.AGENT_PURGE_MODE;
|
|
506
|
+
let modeOverride: "strict" | "replace" | "append" | "off" | null =
|
|
507
|
+
ENV_MODE === "off" ? "off" : ENV_MODE === "strict" || ENV_MODE === "append" ? ENV_MODE : ENV_MODE ? "replace" : null;
|
|
508
|
+
let cache = { id: null as string | null, body: "" };
|
|
509
|
+
const autoOn = (st: { ompAuto?: boolean }) => process.env.AGENT_PURGE_FORCE === "1" || st.ompAuto === true;
|
|
510
|
+
|
|
511
|
+
pi.on("session_start", async () => {
|
|
512
|
+
forced = null;
|
|
513
|
+
cache = { id: null, body: "" };
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// omp 每轮重拼 system 提示词;这里直接返回新的 systemPrompt(string[])。
|
|
517
|
+
pi.on("before_agent_start", async (event) => {
|
|
518
|
+
const st = loadState();
|
|
519
|
+
const effective = modeOverride ?? st.mode;
|
|
520
|
+
const mode = effective === "strict" || effective === "append" ? effective : "replace";
|
|
521
|
+
const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
|
|
522
|
+
if (on !== "on") return;
|
|
523
|
+
cache = loadPersona();
|
|
524
|
+
if (!cache.body) return;
|
|
525
|
+
const hostBlocks = toLines((event as any).systemPrompt).filter((l: string) => l.length > 0);
|
|
526
|
+
// AGENTS.md 常驻通道可能已经把同一份人设塞进 systemPrompt:前缀相同就别再叠一遍
|
|
527
|
+
if (hostBlocks.some((b: string) => b.includes(cache.body.slice(0, 40)))) return;
|
|
528
|
+
// strict=只发人设;replace=用新人设换掉基础身份块、保留 PROJECT 块;append=原样保留再追加
|
|
529
|
+
if (mode === "strict") return { systemPrompt: [cache.body] };
|
|
530
|
+
if (mode === "append") return { systemPrompt: [...hostBlocks, cache.body] };
|
|
531
|
+
const projectBlocks = hostBlocks.filter((b: string, i: number) => i > 0 || b.trim().startsWith("PROJECT"));
|
|
532
|
+
return { systemPrompt: [cache.body, ...projectBlocks] };
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
pi.registerCommand("purge", {
|
|
536
|
+
description: "会话级接管开关(人设跟随 agent-purge 的 state.json)",
|
|
537
|
+
getArgumentCompletions: (prefix: string) =>
|
|
538
|
+
["on", "off", "always", "status", "reload", "mode"].filter((v) => v.startsWith(prefix)).map((value) => ({ value, label: value })),
|
|
539
|
+
handler: async (args: string, ctx: any) => {
|
|
540
|
+
const cmd = (args.trim().split(/\\s+/)[0] ?? "").toLowerCase();
|
|
541
|
+
const notify = (t: string, level: "info" | "error" = "info") => ctx.ui.notify(t, level);
|
|
542
|
+
const describe = () => {
|
|
543
|
+
const p = loadPersona();
|
|
544
|
+
const st = loadState();
|
|
545
|
+
const effective = modeOverride ?? st.mode;
|
|
546
|
+
const on = effective === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
|
|
547
|
+
const why =
|
|
548
|
+
on === "on"
|
|
549
|
+
? forced
|
|
550
|
+
? "本会话 /purge 开的"
|
|
551
|
+
: process.env.AGENT_PURGE_FORCE === "1"
|
|
552
|
+
? "启动器要求注入"
|
|
553
|
+
: "裸启动已开"
|
|
554
|
+
: effective === "off"
|
|
555
|
+
? "总电源关着"
|
|
556
|
+
: "只装了会话内热切,裸启动不注入";
|
|
557
|
+
return [
|
|
558
|
+
"状态: " + (on === "on" ? "开启" : "关闭") + "(" + why + ")",
|
|
559
|
+
"模式: " + effective,
|
|
560
|
+
"裸启动也注入: " + (st.ompAuto ? "开" : "关") + "(/purge always on|off)",
|
|
561
|
+
"人设: " + (p.id ?? "(state.json 里没有 persona)") + " " + (p.body.length ? p.body.length + " 字符" : "未加载"),
|
|
562
|
+
"来源: " + STATE,
|
|
563
|
+
].join("\\n");
|
|
564
|
+
};
|
|
565
|
+
const turnOn = () => {
|
|
566
|
+
const st = loadState();
|
|
567
|
+
if ((modeOverride ?? st.mode) === "off") return notify("总电源关着(state.default.mode=off):先开电源,/purge 才能注入", "error");
|
|
568
|
+
const p = loadPersona();
|
|
569
|
+
if (!p.body) return notify("开启失败:state.json 里没有可用 persona(先 agent-purge use <id>)", "error");
|
|
570
|
+
forced = "on";
|
|
571
|
+
cache = p;
|
|
572
|
+
notify("已开启(仅本会话),每轮注入 " + p.body.length + " 字符。\\n" + describe());
|
|
573
|
+
};
|
|
574
|
+
switch (cmd) {
|
|
575
|
+
case "": {
|
|
576
|
+
const st = loadState();
|
|
577
|
+
const injecting =
|
|
578
|
+
(modeOverride ?? st.mode) !== "off" && (forced === "on" || (forced === null && Boolean(st.persona) && autoOn(st)));
|
|
579
|
+
if (!injecting) return turnOn();
|
|
580
|
+
forced = "off";
|
|
581
|
+
return notify("已关闭本会话注入,恢复 omp 原始系统提示词。再输入 /purge 即重新开启。");
|
|
582
|
+
}
|
|
583
|
+
case "on":
|
|
584
|
+
return turnOn();
|
|
585
|
+
case "off":
|
|
586
|
+
forced = "off";
|
|
587
|
+
return notify("已关闭本会话注入,恢复 omp 原始系统提示词。");
|
|
588
|
+
case "status":
|
|
589
|
+
return notify(describe());
|
|
590
|
+
case "reload":
|
|
591
|
+
cache = loadPersona();
|
|
592
|
+
return notify("已重读 state.json(本会话开关不变)。\\n" + describe());
|
|
593
|
+
case "mode": {
|
|
594
|
+
const want = args.trim().split(/\\s+/)[1];
|
|
595
|
+
if (want !== "strict" && want !== "replace" && want !== "append") {
|
|
596
|
+
return notify("用法: /purge mode strict|replace|append\\nstrict=只发人设;replace=换基础提示、保留项目规则;append=宿主后追加", "error");
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, any>;
|
|
600
|
+
raw.default = { ...(raw.default ?? { harness: "omp" }), mode: want };
|
|
601
|
+
writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
|
|
602
|
+
modeOverride = want;
|
|
603
|
+
return notify("模式 → " + want + "(已写入 state.json,下一轮生效)\\n" + describe());
|
|
604
|
+
} catch (e) {
|
|
605
|
+
return notify("写 state.json 失败: " + String(e), "error");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
case "always": {
|
|
609
|
+
const want = args.trim().split(/\\s+/)[1];
|
|
610
|
+
if (want !== "on" && want !== "off")
|
|
611
|
+
return notify("用法: /purge always on|off —— 让裸敲 omp 也自动注入(写 state.json,新会话生效)", "error");
|
|
612
|
+
try {
|
|
613
|
+
const raw = JSON.parse(readFileSync(STATE, "utf8")) as Record<string, unknown>;
|
|
614
|
+
raw.ompAuto = want === "on";
|
|
615
|
+
writeFileSync(STATE, JSON.stringify(raw, null, 2) + "\\n");
|
|
616
|
+
return notify("已写入 ompAuto=" + want + "(新会话起生效)\\n" + describe());
|
|
617
|
+
} catch (e) {
|
|
618
|
+
return notify("写 state.json 失败: " + String(e), "error");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
default:
|
|
622
|
+
return notify("用法: /purge [on|off|always on|off|status|reload|mode strict|replace|append]", "error");
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
`;
|
|
628
|
+
}
|
package/lib/spawn.mjs
CHANGED
|
@@ -78,7 +78,9 @@ function cmdShimNodeTarget(file) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function dispatch(name, args, opts) {
|
|
81
|
-
|
|
81
|
+
// 非 Windows 也要解析成真实路径:否则 shim 目录里的同名壳转回 agent-purge 时,
|
|
82
|
+
// spawn("pi") 又会命中最前面的壳,无限套娃。
|
|
83
|
+
if (!WIN) return { cmd: resolveBin(name) ?? name, args, opts: { ...opts, shell: false } };
|
|
82
84
|
const full = resolveBin(name) ?? name;
|
|
83
85
|
const ext = extname(full).toLowerCase();
|
|
84
86
|
if (ext === ".ps1") {
|
package/lib/state.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// personas/active.md 退化为「派生物」:由 launch/arm 时按 persona 生成,
|
|
13
13
|
// 供落盘型 harness 与 opencode 插件读取,不再承载状态。
|
|
14
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
17
|
const FILE = "state.json";
|
|
@@ -33,7 +33,7 @@ export function statePath(root) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function emptyState() {
|
|
36
|
-
return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false, dshProfiles: null };
|
|
36
|
+
return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false, ompAuto: false, dshProfiles: null };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -81,13 +81,25 @@ export function loadState(root) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// 迁完立即落盘:否则下次调用看不到旧文件,迁移结果直接丢掉
|
|
84
|
-
if (migrated.length)
|
|
84
|
+
if (migrated.length) saveState(root, state);
|
|
85
85
|
|
|
86
86
|
return { state, migrated };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export function saveState(root, state) {
|
|
90
|
-
|
|
90
|
+
// 先写临时文件再改名:Web 与 CLI 同时写时,读的一方不会看到半截 JSON
|
|
91
|
+
const file = statePath(root);
|
|
92
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
|
|
95
|
+
renameSync(tmp, file);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// rename 失败时别把半截临时文件留在 ~/.agent-purge
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(tmp);
|
|
100
|
+
} catch {}
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
91
103
|
return state;
|
|
92
104
|
}
|
|
93
105
|
|
|
@@ -115,6 +127,12 @@ export function setPiAuto(root, on) {
|
|
|
115
127
|
return saveState(root, { ...state, piAuto: on === true }).piAuto;
|
|
116
128
|
}
|
|
117
129
|
|
|
130
|
+
/** 裸敲 omp 是否也自动注入(omp 扩展读这个位) */
|
|
131
|
+
export function setOmpAuto(root, on) {
|
|
132
|
+
const { state } = loadState(root);
|
|
133
|
+
return saveState(root, { ...state, ompAuto: on === true }).ompAuto;
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
/** pi 的人设走哪一层:false = system(默认),true = 额外塞进用户消息(给忽略 system 的模型用) */
|
|
119
137
|
export function setPiUserLayer(root, on) {
|
|
120
138
|
const { state } = loadState(root);
|
package/matrix.json
CHANGED
|
@@ -181,9 +181,9 @@
|
|
|
181
181
|
"class": "launch-arg",
|
|
182
182
|
"override": true,
|
|
183
183
|
"inject": true,
|
|
184
|
-
"hotSwitch":
|
|
184
|
+
"hotSwitch": true,
|
|
185
185
|
"patch": false,
|
|
186
|
-
"launcher": "omp --system-prompt
|
|
186
|
+
"launcher": "omp(扩展钩子 /purge 热切;--ask 用 --system-prompt)",
|
|
187
187
|
"defaultSystemChars": 34511,
|
|
188
188
|
"purgedSystemChars": 10106,
|
|
189
189
|
"measuredVersion": "17.4.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-purge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
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": {
|
|
@@ -23,8 +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",
|
|
27
|
-
"build:exe": "node scripts/build-exe.mjs",
|
|
26
|
+
"test": "node magpie-plugin/selftest.mjs && node magpie-plugin/test-page.mjs && node test/harness-adapters.test.mjs && node test/regressions.test.mjs",
|
|
28
27
|
"prepack": "node -e \"require('node:fs').rmSync('personas/.build',{recursive:true,force:true})\""
|
|
29
28
|
},
|
|
30
29
|
"keywords": [
|
package/web/server.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { detect } from "../lib/detect.mjs";
|
|
7
7
|
import { oneshots } from "../backends/index.mjs";
|
|
8
8
|
import { createStore } from "../lib/personas.mjs";
|
|
9
|
-
import { MODES, armedOf, dshProfilesOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto, setDshProfile } from "../lib/state.mjs";
|
|
9
|
+
import { MODES, armedOf, dshProfilesOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto, setOmpAuto, setDshProfile } from "../lib/state.mjs";
|
|
10
10
|
import { install, status as persistStatus, uninstall } from "../lib/persist.mjs";
|
|
11
11
|
import { plans, summary as armorSummary } from "../lib/armor.mjs";
|
|
12
12
|
import { installShim, shimStatus, uninstallShim } from "../lib/shim.mjs";
|
|
@@ -31,7 +31,14 @@ const matrix = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
|
|
|
31
31
|
const store = createStore(DATA);
|
|
32
32
|
|
|
33
33
|
export function serveWeb(port = 7788) {
|
|
34
|
-
|
|
34
|
+
// handle 是 async:任何异常都在这里收口,否则请求会挂着不响应
|
|
35
|
+
const server = createServer((req, res) => {
|
|
36
|
+
handle(req, res).catch((e) => {
|
|
37
|
+
try {
|
|
38
|
+
send(res, 500, { error: `服务端错误: ${e.message}` });
|
|
39
|
+
} catch {}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
35
42
|
server.on("error", (e) => {
|
|
36
43
|
// 端口被旧实例占着的时候,--detach 是静默的 → 这里必须说人话,否则用户看到的是旧 UI
|
|
37
44
|
if (e.code === "EADDRINUSE") {
|
|
@@ -51,11 +58,16 @@ async function handle(req, res) {
|
|
|
51
58
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
52
59
|
const p = url.pathname;
|
|
53
60
|
|
|
61
|
+
// 防 CSRF:本机 Web 页面能 POST 就能写配置/改人设;跨站页面用 text/plain 也能发“简单请求”。
|
|
62
|
+
// 只放同源:Origin 的 host 必须和本次请求的 Host 完全一致(端口不同也挡住)。
|
|
63
|
+
if (isMutating(req.method) && !sameOrigin(req)) return send(res, 403, { error: "只接受本机同源请求" });
|
|
64
|
+
|
|
54
65
|
if (req.method === "GET" && (p === "/" || p === "/index.html")) return html(res);
|
|
55
66
|
if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
|
|
56
67
|
if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
|
|
57
68
|
if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
|
|
58
69
|
if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)).on === true) });
|
|
70
|
+
if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, (await body(req)).on === true) });
|
|
59
71
|
if (req.method === "POST" && p === "/api/dsh-profile") return send(res, 200, saveDshProfile(await body(req)));
|
|
60
72
|
if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
|
|
61
73
|
if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
|
|
@@ -144,6 +156,7 @@ async function state(withVersions) {
|
|
|
144
156
|
dshAll: DSH_PROFILES,
|
|
145
157
|
modes: MODES,
|
|
146
158
|
piAuto: st.piAuto === true,
|
|
159
|
+
ompAuto: st.ompAuto === true,
|
|
147
160
|
shimDir: shimStatus()[0]?.dir ?? null,
|
|
148
161
|
slashes: slashes,
|
|
149
162
|
};
|
|
@@ -228,7 +241,8 @@ function savePersona(payload) {
|
|
|
228
241
|
function deletePersona(id) {
|
|
229
242
|
if (!id) return { error: "缺少 id" };
|
|
230
243
|
if (store.list().length <= 1) return { error: "至少保留一个 persona" };
|
|
231
|
-
store.
|
|
244
|
+
if (!store.read(id)) return { error: "找不到这个 persona" };
|
|
245
|
+
if (!store.remove(id)) return { error: "非法 persona id" };
|
|
232
246
|
return { ok: true, active: store.activeId() };
|
|
233
247
|
}
|
|
234
248
|
|
|
@@ -251,6 +265,7 @@ function usePersona(payload) {
|
|
|
251
265
|
|
|
252
266
|
/** 全局总开关:所有 harness × 选定通道一次折腾完(= `agent-purge 注册 --all` / `disarm --all`)*/
|
|
253
267
|
function applyArmAll(payload) {
|
|
268
|
+
if (!payload.action) return { error: "缺少 action" };
|
|
254
269
|
const { action } = payload;
|
|
255
270
|
const channel = payload.channel ?? "all";
|
|
256
271
|
const verb = verbOf(action);
|
|
@@ -310,6 +325,7 @@ function verbOf(action) {
|
|
|
310
325
|
|
|
311
326
|
/** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
|
|
312
327
|
function applyArm(payload) {
|
|
328
|
+
if (!payload.action) return { error: "缺少 action" };
|
|
313
329
|
const { harness, channel, action } = payload;
|
|
314
330
|
const id = payload.persona ?? store.activeId();
|
|
315
331
|
if (!store.read(id)) return { error: "没有可用 persona" };
|
|
@@ -363,21 +379,57 @@ function armChannel(harness, channel, verb, id) {
|
|
|
363
379
|
return { error: `未知通道: ${channel}` };
|
|
364
380
|
}
|
|
365
381
|
|
|
366
|
-
const send = (res, code, obj) =>
|
|
382
|
+
const send = (res, code, obj) =>
|
|
383
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }).end(JSON.stringify(obj));
|
|
367
384
|
const html = (res) =>
|
|
368
385
|
res
|
|
369
386
|
.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
|
|
370
387
|
.end(readFileSync(join(ROOT, "web", "ui.html"), "utf8"));
|
|
371
388
|
|
|
389
|
+
function isMutating(method) {
|
|
390
|
+
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 浏览器跨站请求会带 Origin;curl/脚本没有,放行。有 Origin 就必须和 Host 同源。 */
|
|
394
|
+
function sameOrigin(req) {
|
|
395
|
+
// 先看浏览器声明的跨站属性:没有 Origin 的跨站请求(部分 fetch 场景)也要挡住
|
|
396
|
+
const site = req.headers["sec-fetch-site"];
|
|
397
|
+
if (site && site !== "same-origin" && site !== "none") return false;
|
|
398
|
+
const origin = req.headers.origin;
|
|
399
|
+
if (!origin) return true;
|
|
400
|
+
try {
|
|
401
|
+
const u = new URL(origin);
|
|
402
|
+
const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1" || u.hostname === "[::1]";
|
|
403
|
+
return loopback && u.host.toLowerCase() === String(req.headers.host ?? "").toLowerCase();
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
372
409
|
function body(req) {
|
|
373
410
|
return new Promise((resolve) => {
|
|
374
411
|
let raw = "";
|
|
375
|
-
|
|
412
|
+
let size = 0;
|
|
413
|
+
let done = false;
|
|
414
|
+
const finish = (value) => {
|
|
415
|
+
if (done) return;
|
|
416
|
+
done = true;
|
|
417
|
+
resolve(value);
|
|
418
|
+
};
|
|
419
|
+
req.on("data", (c) => {
|
|
420
|
+
size += c.length;
|
|
421
|
+
// 本机也不该接无上限的 body:一个坏页面能靠大 POST 把内存吃光
|
|
422
|
+
if (size > 2 * 1024 * 1024) {
|
|
423
|
+
req.destroy();
|
|
424
|
+
return finish({});
|
|
425
|
+
}
|
|
426
|
+
raw += c;
|
|
427
|
+
});
|
|
376
428
|
req.on("end", () => {
|
|
377
429
|
try {
|
|
378
|
-
|
|
430
|
+
finish(JSON.parse(raw || "{}"));
|
|
379
431
|
} catch {
|
|
380
|
-
|
|
432
|
+
finish({});
|
|
381
433
|
}
|
|
382
434
|
});
|
|
383
435
|
});
|