agent-purge 0.1.0 → 0.2.1
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 +8 -0
- package/bin/agent-purge.mjs +58 -42
- package/lib/home.mjs +47 -0
- package/lib/slashes.mjs +57 -4
- package/lib/state.mjs +7 -1
- package/package.json +8 -3
- package/web/server.mjs +31 -26
- package/web/ui.html +29 -6
package/README.md
CHANGED
|
@@ -28,6 +28,14 @@ npm i -g agent-purge # 或 npx agent-purge web
|
|
|
28
28
|
|
|
29
29
|
要求 Node ≥ 20,零依赖。
|
|
30
30
|
|
|
31
|
+
**数据独立存放**,升级不会被覆盖:默认 `~/.agent-purge/`(Windows `C:\Users\<你>\.agent-purge\`),里面是 `state.json` 和 `personas/`。想换位置就设环境变量:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export AGENT_PURGE_HOME=/path/to/your/data # Windows: setx AGENT_PURGE_HOME D:\ap-data
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
旧版本(0.1.x)把数据放在包目录里,首次运行会自动搬到新家,原文件不动。
|
|
38
|
+
|
|
31
39
|
## 人设
|
|
32
40
|
|
|
33
41
|
`personas/<id>.md` = frontmatter(`id` / `name` / `description` / `harness` / `model`)+ 正文。正文就是会被写进系统提示词的那一段。
|
package/bin/agent-purge.mjs
CHANGED
|
@@ -14,9 +14,12 @@ import { resolveBin, spawnBin } from "../lib/spawn.mjs";
|
|
|
14
14
|
import { installCrashHandlers, recover } from "../lib/restore.mjs";
|
|
15
15
|
import { capsFor } from "../lib/caps.mjs";
|
|
16
16
|
import { MODES, armedOf, loadState, patchState, setArmed, setDefault } from "../lib/state.mjs";
|
|
17
|
+
import { ensureDataHome } from "../lib/home.mjs";
|
|
17
18
|
|
|
18
19
|
// bun --compile 产物:资源目录 = exe 所在目录;普通 node:包根
|
|
19
20
|
const ROOT = process.versions.bun ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url)));
|
|
21
|
+
// 数据(state.json + personas/)放用户目录,npm 升级不会连人设一起清掉
|
|
22
|
+
const DATA = ensureDataHome(ROOT).home;
|
|
20
23
|
const matrix = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
|
|
21
24
|
|
|
22
25
|
const argv = process.argv.slice(2);
|
|
@@ -82,7 +85,7 @@ switch (cmd) {
|
|
|
82
85
|
|
|
83
86
|
/** 无参:state.default 说跑谁就跑谁;没有默认就跑到 Web 去选 */
|
|
84
87
|
async function defaultHarness() {
|
|
85
|
-
const { state } = loadState(
|
|
88
|
+
const { state } = loadState(DATA);
|
|
86
89
|
const harness = state.default?.harness;
|
|
87
90
|
if (!harness) {
|
|
88
91
|
console.log("还没有默认 harness。两种办法:\n agent-purge web 在浏览器里选,再点“设为默认执行器”\n agent-purge <harness> 直接点名跑(如 agent-purge codex)");
|
|
@@ -97,7 +100,7 @@ async function launch(name) {
|
|
|
97
100
|
if (!spec) die(`未知 harness: ${name}(可用: ${Object.keys(matrix.harnesses).join(", ")};子命令见 --help)`);
|
|
98
101
|
if (spec.class === "unsupported") die(`${name} 做不到:${spec.reason}`);
|
|
99
102
|
|
|
100
|
-
const { state } = loadState(
|
|
103
|
+
const { state } = loadState(DATA);
|
|
101
104
|
// 由 state.json 决定注入方式;显式 --mode 永远压过它
|
|
102
105
|
const explicitMode = flag("--mode", null);
|
|
103
106
|
if (explicitMode !== null && !MODES.includes(explicitMode)) die(`--mode 只能是 ${MODES.join(" / ")}`);
|
|
@@ -108,14 +111,26 @@ async function launch(name) {
|
|
|
108
111
|
if (autoFlag !== null) {
|
|
109
112
|
if (autoFlag !== "on" && autoFlag !== "off") die("--auto 只能是 on / off");
|
|
110
113
|
const { setPiAuto } = await import("../lib/state.mjs");
|
|
111
|
-
setPiAuto(
|
|
114
|
+
setPiAuto(DATA, autoFlag === "on");
|
|
112
115
|
console.log(`[auto] 裸敲 pi ${autoFlag === "on" ? "会" : "不会"}自动注入(已写入 state.json)`);
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
// pi 的人设落在哪一层:sys(默认,真系统提示词)或 user(塞进用户消息,给忽略 system 的模型)
|
|
119
|
+
const layerFlag = name === "pi" ? flag("--layer", null) : null;
|
|
120
|
+
if (layerFlag !== null) {
|
|
121
|
+
if (layerFlag !== "sys" && layerFlag !== "user") die("--layer 只能是 sys / user");
|
|
122
|
+
const { setPiUserLayer } = await import("../lib/state.mjs");
|
|
123
|
+
setPiUserLayer(DATA, layerFlag === "user");
|
|
124
|
+
console.log(
|
|
125
|
+
layerFlag === "user"
|
|
126
|
+
? "[layer] 人设额外塞进用户消息(给忽略 system 的模型用;代价是每条消息多一段)"
|
|
127
|
+
: "[layer] 人设走 system 层(默认)",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
// 实测策略:该 harness 的哪一层真的能注入(数据来自 matrix.json armor 段)
|
|
116
132
|
const { plan } = await import("../lib/armor.mjs");
|
|
117
|
-
const strategy = plan(matrix, name);
|
|
118
|
-
if (!strategy.error) {
|
|
133
|
+
const strategy = plan(matrix, name); if (!strategy.error) {
|
|
119
134
|
if (strategy.requiresSeed) console.log(`[策略] ${name}:裸注入 sys 层实测 ${strategy.bare.sys}%,带人设词种子 ${strategy.persona.sys}% —— 需要 seed 层`);
|
|
120
135
|
const noSys = strategy.blocked.find((b) => b.layer === "sys");
|
|
121
136
|
if (noSys && mode !== "off") console.log(`[策略] ${name}:sys 层不可用(${noSys.reason}),本次只能靠 user 层`);
|
|
@@ -131,7 +146,7 @@ async function launch(name) {
|
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
const { createStore } = await import("../lib/personas.mjs");
|
|
134
|
-
const store = createStore(
|
|
149
|
+
const store = createStore(DATA);
|
|
135
150
|
const personaName = flag("--persona", "active");
|
|
136
151
|
let pickedName = personaName === "active" ? store.activeId() : personaName;
|
|
137
152
|
let picked = store.read(pickedName);
|
|
@@ -216,7 +231,7 @@ async function launch(name) {
|
|
|
216
231
|
// 不装、不喊注入:扩展就算在盘上,也会因为 mode=off + 没 FORCE 而沉默
|
|
217
232
|
built.env = { ...built.env, AGENT_PURGE_MODE: "off" };
|
|
218
233
|
} else {
|
|
219
|
-
installSlash("pi",
|
|
234
|
+
installSlash("pi", DATA, { personaId: picked.id, personaBody: personaText });
|
|
220
235
|
built.env = { ...built.env, AGENT_PURGE_PERSONA: picked.id, AGENT_PURGE_MODE: mode, AGENT_PURGE_FORCE: "1" };
|
|
221
236
|
}
|
|
222
237
|
}
|
|
@@ -244,9 +259,9 @@ async function launch(name) {
|
|
|
244
259
|
|
|
245
260
|
async function useCmd() {
|
|
246
261
|
const { createStore } = await import("../lib/personas.mjs");
|
|
247
|
-
const store = createStore(
|
|
262
|
+
const store = createStore(DATA);
|
|
248
263
|
const arg = argv[1];
|
|
249
|
-
const { state, migrated } = loadState(
|
|
264
|
+
const { state, migrated } = loadState(DATA);
|
|
250
265
|
migrated.forEach((m) => console.log(`[migrate] ${m}`));
|
|
251
266
|
|
|
252
267
|
const id = arg && !arg.startsWith("-") ? arg : state.persona;
|
|
@@ -259,7 +274,7 @@ async function useCmd() {
|
|
|
259
274
|
|
|
260
275
|
// user 层的 /purge 命令文件是烘焙进去的,换人设必须重渲染
|
|
261
276
|
const { refreshSlashes } = await import("../lib/slashes.mjs");
|
|
262
|
-
const refreshed = refreshSlashes(
|
|
277
|
+
const refreshed = refreshSlashes(DATA, state.armed ? Object.entries(state.armed).filter(([, a]) => a.includes("slash")).map(([k]) => k) : [], {
|
|
263
278
|
personaId: id,
|
|
264
279
|
personaBody: store.read(id).body.trim(),
|
|
265
280
|
});
|
|
@@ -272,7 +287,7 @@ async function useCmd() {
|
|
|
272
287
|
|
|
273
288
|
function modeCmd() {
|
|
274
289
|
const want = argv[1];
|
|
275
|
-
const { state } = loadState(
|
|
290
|
+
const { state } = loadState(DATA);
|
|
276
291
|
const current = state.default?.mode ?? "replace";
|
|
277
292
|
if (!want) {
|
|
278
293
|
console.log(
|
|
@@ -287,7 +302,7 @@ function modeCmd() {
|
|
|
287
302
|
return;
|
|
288
303
|
}
|
|
289
304
|
if (!MODES.includes(want)) die(`mode 只能是 ${MODES.join(" / ")}`);
|
|
290
|
-
setDefault(
|
|
305
|
+
setDefault(DATA, { harness: state.default?.harness ?? "codex", mode: want });
|
|
291
306
|
console.log(`mode ← ${want}(state.json;下次启动生效,--mode 可临时覆盖)`);
|
|
292
307
|
}
|
|
293
308
|
|
|
@@ -305,12 +320,12 @@ async function installCmd(verb, rest) {
|
|
|
305
320
|
}
|
|
306
321
|
const spec = matrix.harnesses[h];
|
|
307
322
|
const { createStore } = await import("../lib/personas.mjs");
|
|
308
|
-
const store = createStore(
|
|
323
|
+
const store = createStore(DATA);
|
|
309
324
|
const pid = store.activeId();
|
|
310
|
-
const r = fn({ personaPath: store.materialize(pid), personaText: store.read(pid).body.trim(), root:
|
|
325
|
+
const r = fn({ personaPath: store.materialize(pid), personaText: store.read(pid).body.trim(), root: DATA, reason: spec.reason }) ?? {};
|
|
311
326
|
const ok = verb === "install" ? r.ok !== false : r.ok !== false;
|
|
312
327
|
console.log(`${ok ? "✓" : "✗"} ${verb} ${h}${r.file ? ` → ${r.file}` : ""}${r.note ? ` (${r.note})` : ""}`);
|
|
313
|
-
if (ok) setArmed(
|
|
328
|
+
if (ok) setArmed(DATA, h, "install", verb === "install");
|
|
314
329
|
}
|
|
315
330
|
}
|
|
316
331
|
|
|
@@ -320,7 +335,7 @@ async function armCmd(verb, rest) {
|
|
|
320
335
|
const { install, uninstall } = await import("../lib/persist.mjs");
|
|
321
336
|
const { capsFor } = await import("../lib/caps.mjs");
|
|
322
337
|
const { createStore } = await import("../lib/personas.mjs");
|
|
323
|
-
const store = createStore(
|
|
338
|
+
const store = createStore(DATA);
|
|
324
339
|
const pid = store.activeId();
|
|
325
340
|
if (!pid) die("还没有 persona:先 agent-purge use <id>");
|
|
326
341
|
|
|
@@ -332,15 +347,15 @@ async function armCmd(verb, rest) {
|
|
|
332
347
|
// 否则 `agent-purge <h>` 这条入口照样注入(实测:关了总开关还能接管,就是这里漏的)。
|
|
333
348
|
// 开的时候把上次的 mode 还回去。单家操作不动 mode。
|
|
334
349
|
if (rest.includes("--all")) {
|
|
335
|
-
const { state: cur } = loadState(
|
|
350
|
+
const { state: cur } = loadState(DATA);
|
|
336
351
|
const m = cur.default?.mode ?? "off";
|
|
337
352
|
if (verb === "disarm" && m !== "off" && cur.default?.harness) {
|
|
338
|
-
setDefault(
|
|
339
|
-
patchState(
|
|
353
|
+
setDefault(DATA, { harness: cur.default.harness, mode: "off" });
|
|
354
|
+
patchState(DATA, { prevMode: m });
|
|
340
355
|
}
|
|
341
356
|
if (verb === "arm" && m === "off" && cur.default?.harness) {
|
|
342
357
|
const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
|
|
343
|
-
setDefault(
|
|
358
|
+
setDefault(DATA, { harness: cur.default.harness, mode: back });
|
|
344
359
|
}
|
|
345
360
|
}
|
|
346
361
|
|
|
@@ -364,29 +379,29 @@ async function armCmd(verb, rest) {
|
|
|
364
379
|
}
|
|
365
380
|
}
|
|
366
381
|
if (ch === "install") {
|
|
367
|
-
const r = verb === "arm" ? install(h, { root:
|
|
382
|
+
const r = verb === "arm" ? install(h, { root: DATA, active: store.refreshActive() }) : uninstall(h);
|
|
368
383
|
if (r.error) skip.push(`常驻: ${r.error}`);
|
|
369
384
|
else {
|
|
370
|
-
setArmed(
|
|
385
|
+
setArmed(DATA, h, "install", verb === "arm");
|
|
371
386
|
done.push(verb === "arm" ? `常驻 ${r.action}` : `撤常驻 ${r.action}`);
|
|
372
387
|
}
|
|
373
388
|
}
|
|
374
389
|
if (ch === "shim") {
|
|
375
|
-
const r = verb === "arm" ? installShim(
|
|
390
|
+
const r = verb === "arm" ? installShim(DATA, h) : uninstallShim(DATA, h);
|
|
376
391
|
if (r.error) skip.push(`shim: ${r.error}`);
|
|
377
392
|
else {
|
|
378
|
-
setArmed(
|
|
393
|
+
setArmed(DATA, h, "shim", verb === "arm");
|
|
379
394
|
done.push(verb === "arm" ? "裸命令接管" : "撤裸命令接管");
|
|
380
395
|
}
|
|
381
396
|
}
|
|
382
397
|
if (ch === "slash") {
|
|
383
398
|
const r =
|
|
384
399
|
verb === "arm"
|
|
385
|
-
? installSlash(h,
|
|
386
|
-
: uninstallSlash(h,
|
|
400
|
+
? installSlash(h, DATA, { personaId: pid, personaBody: store.read(pid).body.trim() })
|
|
401
|
+
: uninstallSlash(h, DATA);
|
|
387
402
|
if (r.error) skip.push(`/purge: ${r.error}`);
|
|
388
403
|
else {
|
|
389
|
-
setArmed(
|
|
404
|
+
setArmed(DATA, h, "slash", verb === "arm");
|
|
390
405
|
done.push(verb === "arm" ? `/purge ${r.layer} 层` : "撤 /purge");
|
|
391
406
|
}
|
|
392
407
|
}
|
|
@@ -423,10 +438,10 @@ async function armCmd(verb, rest) {
|
|
|
423
438
|
async function slashCmd(rest) {
|
|
424
439
|
const { installSlash, uninstallSlash, slashStatus } = await import("../lib/slashes.mjs");
|
|
425
440
|
const { createStore } = await import("../lib/personas.mjs");
|
|
426
|
-
const store = createStore(
|
|
441
|
+
const store = createStore(DATA);
|
|
427
442
|
const id = rest[0];
|
|
428
443
|
if (!id || id === "status") {
|
|
429
|
-
const rows = slashStatus(
|
|
444
|
+
const rows = slashStatus(DATA);
|
|
430
445
|
const live = rows.filter((s) => s.offered);
|
|
431
446
|
live.forEach((s) => {
|
|
432
447
|
console.log(`${s.installed ? "✓" : " "} ${s.id.padEnd(9)} ${s.layer.padEnd(4)} ${s.trigger.padEnd(22)} persona=${s.personaId ?? "-"}`);
|
|
@@ -446,47 +461,47 @@ async function slashCmd(rest) {
|
|
|
446
461
|
const ids =
|
|
447
462
|
target && target !== "--all"
|
|
448
463
|
? rest.slice(1).filter((x) => !x.startsWith("-"))
|
|
449
|
-
: slashStatus(
|
|
464
|
+
: slashStatus(DATA).filter((s) => s.installed).map((s) => s.id);
|
|
450
465
|
if (!ids.length) console.log("没有装着的 /purge");
|
|
451
466
|
ids.forEach((h) => {
|
|
452
|
-
const r = uninstallSlash(h,
|
|
467
|
+
const r = uninstallSlash(h, DATA);
|
|
453
468
|
if (!r.error) {
|
|
454
|
-
setArmed(
|
|
469
|
+
setArmed(DATA, h, "slash", false);
|
|
455
470
|
console.log(`✓ 已移除 /purge ${h}${r.removed?.length ? " → " + r.removed.join(", ") : "(本来就没有)"}`);
|
|
456
471
|
}
|
|
457
472
|
});
|
|
458
473
|
return;
|
|
459
474
|
}
|
|
460
|
-
const targets = id === "--all" ? slashStatus(
|
|
475
|
+
const targets = id === "--all" ? slashStatus(DATA).filter((s) => s.offered).map((s) => s.id) : [id];
|
|
461
476
|
const pid = store.activeId();
|
|
462
477
|
if (!pid) die("还没有 persona:先 agent-purge use <id>");
|
|
463
478
|
for (const h of targets) {
|
|
464
|
-
const r = installSlash(h,
|
|
479
|
+
const r = installSlash(h, DATA, { personaId: pid, personaBody: store.read(pid).body.trim() });
|
|
465
480
|
if (r.error) {
|
|
466
481
|
console.log(`- ${h}: ${r.error}`);
|
|
467
482
|
continue;
|
|
468
483
|
}
|
|
469
|
-
setArmed(
|
|
484
|
+
setArmed(DATA, h, "slash", true);
|
|
470
485
|
console.log(`✓ /purge → ${h}(${r.layer} 层)${r.file}`);
|
|
471
486
|
console.log(` ${r.note}`);
|
|
472
487
|
}
|
|
473
488
|
}
|
|
474
489
|
|
|
475
490
|
async function statusCmd() {
|
|
476
|
-
const { migrated } = loadState(
|
|
491
|
+
const { migrated } = loadState(DATA);
|
|
477
492
|
migrated.forEach((m) => console.log(`[migrate] ${m}`));
|
|
478
493
|
// armed 是缓存,磁盘才是真相:先把假记录摘掉,再报数
|
|
479
494
|
const { reconcileArmed } = await import("../lib/armed.mjs");
|
|
480
|
-
const fixed = await reconcileArmed(
|
|
495
|
+
const fixed = await reconcileArmed(DATA);
|
|
481
496
|
if (fixed.length) console.log(`[对账] armed 纠正:${fixed.join(", ")}`);
|
|
482
|
-
const { state } = loadState(
|
|
497
|
+
const { state } = loadState(DATA);
|
|
483
498
|
return Promise.all([import("../lib/shim.mjs"), import("../lib/slashes.mjs")]).then(([shimMod, slashMod]) => {
|
|
484
499
|
const shims = shimMod.shimStatus();
|
|
485
|
-
const slashes = slashMod.slashStatus(
|
|
500
|
+
const slashes = slashMod.slashStatus(DATA);
|
|
486
501
|
|
|
487
502
|
console.log(`persona ${state.persona ?? "(未设置)"}`);
|
|
488
503
|
console.log(`default ${state.default ? `${state.default.harness} · ${state.default.mode}` : "(未设置)"} 裸敲 agent-purge 用它`);
|
|
489
|
-
console.log(`state ${join(
|
|
504
|
+
console.log(`state ${join(DATA, "state.json")}`);
|
|
490
505
|
console.log("");
|
|
491
506
|
console.log("harness shim slash install /purge层 常驻落点");
|
|
492
507
|
for (const [h] of Object.entries(matrix.harnesses)) {
|
|
@@ -513,8 +528,8 @@ async function statusCmd() {
|
|
|
513
528
|
|
|
514
529
|
async function lsCmd() {
|
|
515
530
|
const { createStore } = await import("../lib/personas.mjs");
|
|
516
|
-
const store = createStore(
|
|
517
|
-
const { state } = loadState(
|
|
531
|
+
const store = createStore(DATA);
|
|
532
|
+
const { state } = loadState(DATA);
|
|
518
533
|
const fit = store.resolve({ harness: state.default?.harness, model: null });
|
|
519
534
|
for (const p of store.list()) {
|
|
520
535
|
const mark = p.id === store.activeId() ? "*" : " ";
|
|
@@ -532,6 +547,7 @@ function webCmd() {
|
|
|
532
547
|
detached: true,
|
|
533
548
|
stdio: "ignore",
|
|
534
549
|
shell: false,
|
|
550
|
+
env: { ...process.env, AGENT_PURGE_HOME: DATA },
|
|
535
551
|
});
|
|
536
552
|
child.unref();
|
|
537
553
|
console.log(`agent-purge web 已在后台启动 → http://127.0.0.1:${port}(pid ${child.pid})`);
|
package/lib/home.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// 用户数据目录:state.json 与 personas/ 不跟代码走。
|
|
2
|
+
//
|
|
3
|
+
// 原因:npm 升级是「整个包目录替换」,数据若留在包目录里会连人设一起被清掉。
|
|
4
|
+
// 所以代码认 ROOT(装在哪都行),数据认 HOME:
|
|
5
|
+
// AGENT_PURGE_HOME 显式指定 > ~/.agent-purge
|
|
6
|
+
//
|
|
7
|
+
// 老布局(数据躺在包目录里)在首次运行时整份拷过来,原件留着当备份,不删。
|
|
8
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
export function dataHome() {
|
|
13
|
+
const env = process.env.AGENT_PURGE_HOME?.trim();
|
|
14
|
+
if (!env) return join(homedir(), ".agent-purge");
|
|
15
|
+
return isAbsolute(env) ? env : resolve(env);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 确保数据目录存在,并把旧布局搬过来。
|
|
20
|
+
* @returns {{ home: string, migrated: string[] }}
|
|
21
|
+
*/
|
|
22
|
+
export function ensureDataHome(pkgRoot = null) {
|
|
23
|
+
const home = dataHome();
|
|
24
|
+
mkdirSync(home, { recursive: true });
|
|
25
|
+
const migrated = [];
|
|
26
|
+
// 显式指定 AGENT_PURGE_HOME = 用户自己挑的新家,不往里搬旧数据
|
|
27
|
+
if (!pkgRoot || process.env.AGENT_PURGE_HOME?.trim() || resolve(pkgRoot) === resolve(home)) {
|
|
28
|
+
return { home, migrated };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const state = [join(pkgRoot, "state.json"), join(home, "state.json")];
|
|
32
|
+
if (existsSync(state[0]) && !existsSync(state[1])) {
|
|
33
|
+
copyFileSync(state[0], state[1]);
|
|
34
|
+
migrated.push(`state: ${state[0]} → ${state[1]}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const from = join(pkgRoot, "personas");
|
|
38
|
+
const to = join(home, "personas");
|
|
39
|
+
if (existsSync(from) && !existsSync(to)) {
|
|
40
|
+
const md = readdirSync(from).filter((f) => f.endsWith(".md"));
|
|
41
|
+
if (md.length) {
|
|
42
|
+
cpSync(from, to, { recursive: true });
|
|
43
|
+
migrated.push(`personas: ${from} → ${to}(${md.length} 个)`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { home, migrated };
|
|
47
|
+
}
|
package/lib/slashes.mjs
CHANGED
|
@@ -161,8 +161,10 @@ function piExtension(root) {
|
|
|
161
161
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
162
162
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
const
|
|
164
|
+
// 数据目录跟安装目录解耦:AGENT_PURGE_HOME 优先,没有才用安装时记住的路径
|
|
165
|
+
const HOME = process.env.AGENT_PURGE_HOME ? String(process.env.AGENT_PURGE_HOME).replace(/\\\\/g, "/") : null;
|
|
166
|
+
const STATE = (HOME ? HOME + "/state.json" : ${JSON.stringify(statePath)});
|
|
167
|
+
const PERSONAS = (HOME ? HOME + "/personas" : ${JSON.stringify(personasDir)});
|
|
166
168
|
const HOST_IDENTITY = "You are an expert coding assistant operating inside pi";
|
|
167
169
|
|
|
168
170
|
function stripFrontmatter(raw: string): string {
|
|
@@ -184,7 +186,7 @@ function loadPersona(): { id: string | null; body: string } {
|
|
|
184
186
|
}
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
function loadState(): { persona: string | null; mode: string; shim: boolean } {
|
|
189
|
+
function loadState(): { persona: string | null; mode: string; shim: boolean; piAuto: boolean; piUserLayer: boolean } {
|
|
188
190
|
// 启动器会用环境变量告知本次运行用的 persona/mode(--persona / --mode 覆盖);
|
|
189
191
|
// 否则读 state.json —— 注意 mode 存在 default.mode,不是顶层。
|
|
190
192
|
const envPersona = process.env.AGENT_PURGE_PERSONA;
|
|
@@ -195,15 +197,17 @@ function loadState(): { persona: string | null; mode: string; shim: boolean } {
|
|
|
195
197
|
default?: { harness?: string; mode?: string };
|
|
196
198
|
armed?: Record<string, string[]>;
|
|
197
199
|
piAuto?: boolean;
|
|
200
|
+
piUserLayer?: boolean;
|
|
198
201
|
};
|
|
199
202
|
return {
|
|
200
203
|
persona: envPersona ?? st.persona ?? null,
|
|
201
204
|
mode: envMode ?? st.default?.mode ?? "replace",
|
|
202
205
|
shim: Array.isArray(st.armed?.pi) && st.armed.pi.includes("shim"),
|
|
203
206
|
piAuto: st.piAuto === true,
|
|
207
|
+
piUserLayer: st.piUserLayer === true,
|
|
204
208
|
};
|
|
205
209
|
} catch {
|
|
206
|
-
return { persona: envPersona ?? null, mode: envMode ?? "replace", shim: false, piAuto: process.env.AGENT_PURGE_FORCE === "1" };
|
|
210
|
+
return { persona: envPersona ?? null, mode: envMode ?? "replace", shim: false, piAuto: process.env.AGENT_PURGE_FORCE === "1", piUserLayer: false };
|
|
207
211
|
}
|
|
208
212
|
}
|
|
209
213
|
|
|
@@ -246,6 +250,55 @@ export default function agentPurge(pi: ExtensionAPI) {
|
|
|
246
250
|
return { systemPrompt: host.length > 0 ? cache.body + "\\n\\n" + host : cache.body };
|
|
247
251
|
});
|
|
248
252
|
|
|
253
|
+
// 真正保证“会话中途热切也生效”的是这个拦截面:每个模型请求前重写 transcript 里的
|
|
254
|
+
// system 消恵。before_agent_start 的返回值在部分 pi 版本上只对首轮生效(会话已有上下文时
|
|
255
|
+
// 会被 transcript 覆盖),所以这里再兜一层;两层同时生效时靠 body 前缀去重。
|
|
256
|
+
try {
|
|
257
|
+
pi.on("context_with_system", async (event: any) => {
|
|
258
|
+
const st = loadState();
|
|
259
|
+
const m = st.mode === "strict" || st.mode === "append" ? st.mode : "replace";
|
|
260
|
+
const on = st.mode === "off" ? "off" : (forced ?? (st.persona && autoOn(st) ? "on" : "off"));
|
|
261
|
+
if (on !== "on") return;
|
|
262
|
+
const p = loadPersona();
|
|
263
|
+
if (!p.body) return;
|
|
264
|
+
const messages: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
|
265
|
+
if (messages.length === 0) return;
|
|
266
|
+
const textOf = (msg: any): string => {
|
|
267
|
+
const c = msg?.content;
|
|
268
|
+
if (typeof c === "string") return c;
|
|
269
|
+
if (Array.isArray(c)) return c.map((b: any) => (b && typeof b.text === "string" ? b.text : "")).join("");
|
|
270
|
+
return "";
|
|
271
|
+
};
|
|
272
|
+
// pi 1.4 约定:这个钩子的返回值会被忽略,必须原地改 event.messages
|
|
273
|
+
const out = messages;
|
|
274
|
+
const at = out.findIndex((msg: any) => msg?.role === "system");
|
|
275
|
+
const host = at >= 0 ? textOf(out[at]) : "";
|
|
276
|
+
if (!host.includes(p.body.slice(0, 40))) {
|
|
277
|
+
let next: string;
|
|
278
|
+
if (m === "strict") next = p.body;
|
|
279
|
+
else if (m === "append") next = (host ? host + "\\n\\n" : "") + p.body;
|
|
280
|
+
else next = host ? p.body + "\\n\\n" + cutHostIdentity(host) : p.body;
|
|
281
|
+
if (at >= 0) out[at].content = next;
|
|
282
|
+
else out.unshift({ role: "system", content: next });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 有些模型(实测 deepseek-v4.1-flash 走 mg 网关)直接无视 system 角色:
|
|
286
|
+
// 再往最后一条用户消息前面贴一份,让指令一定被看到。
|
|
287
|
+
if (st.piUserLayer) {
|
|
288
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
289
|
+
if (out[i]?.role !== "user") continue;
|
|
290
|
+
const shown = textOf(out[i]);
|
|
291
|
+
if (shown.includes(p.body.slice(0, 40))) break;
|
|
292
|
+
out[i].content = "<agent-purge>\\n" + p.body + "\\n</agent-purge>\\n\\n" + shown;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
// 老版本 pi 没有 context_with_system:退回 before_agent_start 的返回值
|
|
300
|
+
}
|
|
301
|
+
|
|
249
302
|
pi.registerCommand("purge", {
|
|
250
303
|
description: "会话级接管开关(人设跟随 agent-purge 的 state.json)",
|
|
251
304
|
getArgumentCompletions: (prefix: string) =>
|
package/lib/state.mjs
CHANGED
|
@@ -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 };
|
|
36
|
+
return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -115,6 +115,12 @@ export function setPiAuto(root, on) {
|
|
|
115
115
|
return saveState(root, { ...state, piAuto: on === true }).piAuto;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** pi 的人设走哪一层:false = system(默认),true = 额外塞进用户消息(给忽略 system 的模型用) */
|
|
119
|
+
export function setPiUserLayer(root, on) {
|
|
120
|
+
const { state } = loadState(root);
|
|
121
|
+
return saveState(root, { ...state, piUserLayer: on === true }).piUserLayer;
|
|
122
|
+
}
|
|
123
|
+
|
|
118
124
|
/** armed 变成 {codex: ["shim","install"]};空数组即删键 */
|
|
119
125
|
export function setArmed(root, id, arm, on) {
|
|
120
126
|
const { state } = loadState(root);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-purge",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode, grok, omp, hermes, pi, zcode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,12 @@
|
|
|
37
37
|
"prompt-injection"
|
|
38
38
|
],
|
|
39
39
|
"license": "MIT",
|
|
40
|
-
"repository": {
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/ZiChuanLan/agent-purge.git"
|
|
43
|
+
},
|
|
41
44
|
"homepage": "https://github.com/ZiChuanLan/agent-purge#readme",
|
|
42
|
-
"bugs": {
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/ZiChuanLan/agent-purge/issues"
|
|
47
|
+
}
|
|
43
48
|
}
|
package/web/server.mjs
CHANGED
|
@@ -6,15 +6,18 @@ 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, loadState, patchState, setArmed, setDefault, setPersona } from "../lib/state.mjs";
|
|
9
|
+
import { MODES, armedOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto } 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";
|
|
13
13
|
import { installSlash, refreshSlashes, slashStatus, uninstallSlash } from "../lib/slashes.mjs";
|
|
14
14
|
import { reconcileArmed } from "../lib/armed.mjs";
|
|
15
15
|
import { capsFor } from "../lib/caps.mjs";
|
|
16
|
+
import { ensureDataHome } from "../lib/home.mjs";
|
|
16
17
|
|
|
17
18
|
const ROOT = process.versions.bun ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
|
+
// 数据(state.json + personas/)在用户目录,不在安装目录
|
|
20
|
+
const DATA = ensureDataHome(ROOT).home;
|
|
18
21
|
// 运行期真正被改写的层(≠ bench 能测到的层):每家只有它自己暴露的那一个写入点。
|
|
19
22
|
// sys = 系统提示/配置文件/插件 splice;user = 只落在用户消息里(zcode 的 hook)。
|
|
20
23
|
const LAYER = {
|
|
@@ -24,7 +27,7 @@ const LAYER = {
|
|
|
24
27
|
zcode: "",
|
|
25
28
|
};
|
|
26
29
|
const matrix = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
|
|
27
|
-
const store = createStore(
|
|
30
|
+
const store = createStore(DATA);
|
|
28
31
|
|
|
29
32
|
export function serveWeb(port = 7788) {
|
|
30
33
|
const server = createServer(handle);
|
|
@@ -51,6 +54,7 @@ async function handle(req, res) {
|
|
|
51
54
|
if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
|
|
52
55
|
if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
|
|
53
56
|
if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
|
|
57
|
+
if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)).on === true) });
|
|
54
58
|
if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
|
|
55
59
|
if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
|
|
56
60
|
if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
|
|
@@ -84,12 +88,12 @@ function armorState() {
|
|
|
84
88
|
|
|
85
89
|
async function state(withVersions) {
|
|
86
90
|
// 先跟磁盘对账,再报数:armed 是缓存,磁盘才是真相
|
|
87
|
-
const reconciled = await reconcileArmed(
|
|
91
|
+
const reconciled = await reconcileArmed(DATA);
|
|
88
92
|
const found = detect({ versions: withVersions });
|
|
89
|
-
const persisted = persistStatus(
|
|
90
|
-
const { state: st } = loadState(
|
|
93
|
+
const persisted = persistStatus(DATA);
|
|
94
|
+
const { state: st } = loadState(DATA);
|
|
91
95
|
const shims = shimStatus();
|
|
92
|
-
const slashes = slashStatus(
|
|
96
|
+
const slashes = slashStatus(DATA);
|
|
93
97
|
const harnesses = Object.entries(matrix.harnesses).map(([id, m]) => {
|
|
94
98
|
const d = found[id] ?? {};
|
|
95
99
|
const inst = persisted.find((x) => x.name === id);
|
|
@@ -131,6 +135,7 @@ async function state(withVersions) {
|
|
|
131
135
|
defaults: st.default,
|
|
132
136
|
armed: st.armed ?? {},
|
|
133
137
|
modes: MODES,
|
|
138
|
+
piAuto: st.piAuto === true,
|
|
134
139
|
shimDir: shimStatus()[0]?.dir ?? null,
|
|
135
140
|
slashes: slashes,
|
|
136
141
|
};
|
|
@@ -143,7 +148,7 @@ function saveDefaults(payload) {
|
|
|
143
148
|
if (spec.class === "unsupported") return { error: `${harness} 做不到:${spec.reason}` };
|
|
144
149
|
if (mode && !MODES.includes(mode)) return { error: `mode 只能是 ${MODES.join(" / ")}` };
|
|
145
150
|
try {
|
|
146
|
-
return { ok: true, defaults: setDefault(
|
|
151
|
+
return { ok: true, defaults: setDefault(DATA, { harness, mode: mode ?? "replace" }) };
|
|
147
152
|
} catch (e) {
|
|
148
153
|
return { error: e.message };
|
|
149
154
|
}
|
|
@@ -173,12 +178,12 @@ function usePersona(payload) {
|
|
|
173
178
|
try {
|
|
174
179
|
store.setActive(payload.id);
|
|
175
180
|
// 两个指针必须一起写,否则 UI 选的“当前人设”和 `agent-purge` 实际用的不是同一个
|
|
176
|
-
setPersona(
|
|
177
|
-
const { state: st } = loadState(
|
|
181
|
+
setPersona(DATA, payload.id);
|
|
182
|
+
const { state: st } = loadState(DATA);
|
|
178
183
|
const slashTargets = Object.entries(st.armed ?? {})
|
|
179
184
|
.filter(([, a]) => a.includes("slash"))
|
|
180
185
|
.map(([k]) => k);
|
|
181
|
-
const refreshed = refreshSlashes(
|
|
186
|
+
const refreshed = refreshSlashes(DATA, slashTargets, { personaId: payload.id, personaBody: store.read(payload.id).body.trim() });
|
|
182
187
|
return { ok: true, active: payload.id, refreshed };
|
|
183
188
|
} catch (e) {
|
|
184
189
|
return { error: e.message };
|
|
@@ -194,15 +199,15 @@ function applyArmAll(payload) {
|
|
|
194
199
|
// 全局总闸 = 电源,不只是注册表:撤的时候把 default.mode 置 off(记下 prevMode),
|
|
195
200
|
// 否则 `agent-purge <h>` 这条入口永远注入 —— 用户实测“关了也能直接接管”就出在这。
|
|
196
201
|
// 开的时候把上次的 mode 还回去。
|
|
197
|
-
const { state: cur } = loadState(
|
|
202
|
+
const { state: cur } = loadState(DATA);
|
|
198
203
|
const m = cur.default?.mode ?? "off";
|
|
199
204
|
if (verb === "off" && m !== "off" && cur.default?.harness) {
|
|
200
|
-
setDefault(
|
|
201
|
-
patchState(
|
|
205
|
+
setDefault(DATA, { harness: cur.default.harness, mode: "off" });
|
|
206
|
+
patchState(DATA, { prevMode: m });
|
|
202
207
|
}
|
|
203
208
|
if (verb === "on" && m === "off" && cur.default?.harness) {
|
|
204
209
|
const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
|
|
205
|
-
setDefault(
|
|
210
|
+
setDefault(DATA, { harness: cur.default.harness, mode: back });
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
const perHarness = [];
|
|
@@ -217,7 +222,7 @@ function applyArmAll(payload) {
|
|
|
217
222
|
else perHarness.push({ harness: h, done: (r.done ?? []).map((d) => d.channel), skipped: r.skipped ?? [] });
|
|
218
223
|
}
|
|
219
224
|
if (!perHarness.length) return { error: failed.join(";") || "没有可注册的 harness" };
|
|
220
|
-
const after = loadState(
|
|
225
|
+
const after = loadState(DATA).state.default;
|
|
221
226
|
return { ok: true, action: verb, channel, mode: after?.mode ?? null, harnesses: perHarness, failed };
|
|
222
227
|
}
|
|
223
228
|
|
|
@@ -225,16 +230,16 @@ function applyInstall(payload) {
|
|
|
225
230
|
const { harness, action } = payload;
|
|
226
231
|
if (action === "uninstall") {
|
|
227
232
|
const r = uninstall(harness);
|
|
228
|
-
setArmed(
|
|
233
|
+
setArmed(DATA, harness, "install", false);
|
|
229
234
|
return { ok: true, result: r };
|
|
230
235
|
}
|
|
231
236
|
// 装谁就把谁设为 active:active.md 与 install 指向始终是同一份正文
|
|
232
237
|
const wanted = payload.persona ?? store.activeId();
|
|
233
238
|
if (!store.read(wanted)) return { error: "没有可用 persona" };
|
|
234
239
|
store.setActive(wanted);
|
|
235
|
-
const r = install(harness, { root:
|
|
240
|
+
const r = install(harness, { root: DATA, active: store.refreshActive() });
|
|
236
241
|
if (r.error) return { error: r.error };
|
|
237
|
-
setArmed(
|
|
242
|
+
setArmed(DATA, harness, "install", true);
|
|
238
243
|
return { ok: true, result: r };
|
|
239
244
|
}
|
|
240
245
|
|
|
@@ -274,26 +279,26 @@ function armChannel(harness, channel, verb, id) {
|
|
|
274
279
|
}
|
|
275
280
|
if (channel === "shim") {
|
|
276
281
|
if (verb === "off") {
|
|
277
|
-
const r = uninstallShim(
|
|
282
|
+
const r = uninstallShim(DATA, harness);
|
|
278
283
|
if (r.error) return { error: r.error };
|
|
279
|
-
setArmed(
|
|
284
|
+
setArmed(DATA, harness, "shim", false);
|
|
280
285
|
return { removed: r.removed };
|
|
281
286
|
}
|
|
282
|
-
const r = installShim(
|
|
287
|
+
const r = installShim(DATA, harness);
|
|
283
288
|
if (r.error) return { error: r.error };
|
|
284
|
-
setArmed(
|
|
289
|
+
setArmed(DATA, harness, "shim", true);
|
|
285
290
|
return { file: r.file, real: r.real };
|
|
286
291
|
}
|
|
287
292
|
if (channel === "slash") {
|
|
288
293
|
if (verb === "off") {
|
|
289
|
-
const r = uninstallSlash(harness,
|
|
294
|
+
const r = uninstallSlash(harness, DATA);
|
|
290
295
|
if (r.error) return { error: r.error };
|
|
291
|
-
setArmed(
|
|
296
|
+
setArmed(DATA, harness, "slash", false);
|
|
292
297
|
return { removed: r.removed };
|
|
293
298
|
}
|
|
294
|
-
const r = installSlash(harness,
|
|
299
|
+
const r = installSlash(harness, DATA, { personaId: id, personaBody: store.read(id).body.trim() });
|
|
295
300
|
if (r.error) return { error: r.error };
|
|
296
|
-
setArmed(
|
|
301
|
+
setArmed(DATA, harness, "slash", true);
|
|
297
302
|
return { file: r.file, layer: r.layer };
|
|
298
303
|
}
|
|
299
304
|
return { error: `未知通道: ${channel}` };
|
package/web/ui.html
CHANGED
|
@@ -399,7 +399,12 @@ textarea{resize:vertical;min-height:340px;font-family:var(--font-mono);font-size
|
|
|
399
399
|
*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important;
|
|
400
400
|
transform:none!important}
|
|
401
401
|
}
|
|
402
|
-
|
|
402
|
+
.pirow{display:flex;align-items:center;justify-content:space-between;gap:18px;border:1px solid var(--line);border-radius:12px;padding:11px 14px;margin:12px 0 4px;background:var(--field)}
|
|
403
|
+
.pirow b{display:block;font-size:13px;font-weight:600}
|
|
404
|
+
.pirow .tiny{display:block;margin-top:3px;line-height:1.5}
|
|
405
|
+
.pirow .switch{background:var(--line);border:1px solid var(--line)}
|
|
406
|
+
.pirow.on .switch{background:var(--accent);border-color:var(--accent)}
|
|
407
|
+
.pirow.on .switch::after{left:23px;background:#fff}</style>
|
|
403
408
|
</head>
|
|
404
409
|
<body>
|
|
405
410
|
|
|
@@ -655,7 +660,7 @@ function renderOverview() {
|
|
|
655
660
|
const hid = S.defaults.harness;
|
|
656
661
|
const h = S.harnesses.find((x) => x.id === hid) || S.harnesses[0];
|
|
657
662
|
const persona = S.personas.find((p) => p.id === (selPersona || S.persona)) || S.personas[0];
|
|
658
|
-
const hot = S.harnesses.filter((h) => (h.armed || []).length).map((h) => h.id + "(" + h.armed.join("/") + ")");
|
|
663
|
+
const hot = S.harnesses.filter((h) => (h.armed || []).length).map((h) => h.id + "(" + h.armed.join("/") + (h.id === "pi" && S.piAuto ? "+裸启动" : "") + ")");
|
|
659
664
|
|
|
660
665
|
const why = mode === "off"
|
|
661
666
|
? "总电源关着 —— 下面写的都只是配置,实际一条都不会注入。"
|
|
@@ -671,7 +676,7 @@ function renderOverview() {
|
|
|
671
676
|
const say = (c) => c === "shim"
|
|
672
677
|
? (h.id === "pi" ? "敲 pi 会注入(PATH 刚加的前置壳)" : "敲 " + h.id + " 会注入(PATH 前置壳)")
|
|
673
678
|
: c === "slash"
|
|
674
|
-
? (h.id === "pi" ? "裸敲 pi 不注入,只有会话里 /purge 才改写" : "会话里 /purge 展开成用户消息")
|
|
679
|
+
? (h.id === "pi" ? (S.piAuto ? "裸敲 pi 也注入(扩展常驻)" : "裸敲 pi 不注入,只有会话里 /purge 才改写") : "会话里 /purge 展开成用户消息")
|
|
675
680
|
: "改了它的配置文件,连别人启动的也生效";
|
|
676
681
|
return chans.map(say).join(";") + "。";
|
|
677
682
|
})()
|
|
@@ -736,8 +741,10 @@ function harnessCard(h) {
|
|
|
736
741
|
? h.armed.map((a) => `<span class="badge ok">${ARM_INFO[a]?.name || a}</span>`).join("")
|
|
737
742
|
: `<span class="badge">未武装</span>`;
|
|
738
743
|
const CHAN_ORDER = ["shim", "slash", "install"];
|
|
744
|
+
const isPi = h.id === "pi";
|
|
739
745
|
// 只摆真能用的:能点但没反应的按钮 = 误导,理由改用一行字写在卡片下方
|
|
740
|
-
|
|
746
|
+
// pi 例外:它的“裸命令接管”是扩展常驻(piAuto),不是 PATH 壳,所以照样给按钮
|
|
747
|
+
const hiddenChans = CHAN_ORDER.filter((c) => !(isPi && c === "shim") && h.caps?.[c] && !h.caps[c].ok);
|
|
741
748
|
const hiddenText = hiddenChans.map((c) => {
|
|
742
749
|
const cap = h.caps[c];
|
|
743
750
|
if (cap.hiddenLabel) return `${cap.hiddenLabel}(压不住系统提示词)`;
|
|
@@ -745,8 +752,14 @@ function harnessCard(h) {
|
|
|
745
752
|
if (c === "slash") return null;
|
|
746
753
|
return `${ARM_INFO[c].name}:${String(cap.why || "").replace(/\s+/g, " ").split(/[;。]/)[0]}`;
|
|
747
754
|
}).filter(Boolean);
|
|
748
|
-
const chans = CHAN_ORDER.filter((c) => !h.caps?.[c] || h.caps[c].ok).map((c) => {
|
|
749
|
-
const on = h.armed?.includes(c);
|
|
755
|
+
const chans = CHAN_ORDER.filter((c) => (isPi && c === "shim") || !h.caps?.[c] || h.caps[c].ok).map((c) => {
|
|
756
|
+
const on = isPi && c === "shim" ? Boolean(S.piAuto) : h.armed?.includes(c);
|
|
757
|
+
// pi 的裸命令接管=扩展常驻:点它直接切 piAuto,而不是装 PATH 壳
|
|
758
|
+
if (isPi && c === "shim") {
|
|
759
|
+
return `<button class="chan ${on ? "on" : ""}" data-piauto="${on ? "off" : "on"}" type="button" title="扩展常驻,不改 PATH:开关写在 state.json 的 piAuto">
|
|
760
|
+
<span class="dot"></span><span><b>裸命令接管${on ? " · 开" : ""}</b>
|
|
761
|
+
<small>${on ? "扩展常驻:敲 pi 就注入(每次启动)" : "关着:只有 /purge 或 agent-purge pi 才注入"}</small></span></button>`;
|
|
762
|
+
}
|
|
750
763
|
// 能力由后端(lib/caps.mjs)给;UI 不再自己猜一遍,否则两处判断迟早分叉
|
|
751
764
|
const cap = h.caps?.[c] ?? null;
|
|
752
765
|
const avail = cap ? cap.ok : c === "install" ? !!h.persistable : c === "slash" ? !!h.slash : true;
|
|
@@ -987,6 +1000,16 @@ $("#hpick").addEventListener("click", (e) => {
|
|
|
987
1000
|
const b = e.target.closest("[data-hpick]"); if (!b) return;
|
|
988
1001
|
selHarness = b.dataset.hpick; renderHarness();
|
|
989
1002
|
});
|
|
1003
|
+
$("#hsel").addEventListener("click", async (e) => {
|
|
1004
|
+
const b = e.target.closest("[data-piauto]"); if (!b) return;
|
|
1005
|
+
await withBusy(b, async () => {
|
|
1006
|
+
try {
|
|
1007
|
+
await api("/api/pi-auto", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ on: b.dataset.piauto === "on" }) });
|
|
1008
|
+
toast("裸启动自动注入 → " + (b.dataset.piauto === "on" ? "开" : "关"));
|
|
1009
|
+
await load();
|
|
1010
|
+
} catch (err) { toast("失败:" + err.message, true); }
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
990
1013
|
$("#hsel").addEventListener("click", onChan);
|
|
991
1014
|
$("#hdeadBox").addEventListener("click", onChan);
|
|
992
1015
|
$("#selbar").addEventListener("click", async (e) => {
|