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 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`)+ 正文。正文就是会被写进系统提示词的那一段。
@@ -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(ROOT);
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(ROOT);
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(ROOT, autoFlag === "on");
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(ROOT);
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", ROOT, { personaId: picked.id, personaBody: personaText });
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(ROOT);
262
+ const store = createStore(DATA);
248
263
  const arg = argv[1];
249
- const { state, migrated } = loadState(ROOT);
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(ROOT, state.armed ? Object.entries(state.armed).filter(([, a]) => a.includes("slash")).map(([k]) => k) : [], {
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(ROOT);
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(ROOT, { harness: state.default?.harness ?? "codex", mode: want });
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(ROOT);
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: ROOT, reason: spec.reason }) ?? {};
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(ROOT, h, "install", verb === "install");
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(ROOT);
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(ROOT);
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(ROOT, { harness: cur.default.harness, mode: "off" });
339
- patchState(ROOT, { prevMode: m });
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(ROOT, { harness: cur.default.harness, mode: back });
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: ROOT, active: store.refreshActive() }) : uninstall(h);
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(ROOT, h, "install", verb === "arm");
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(ROOT, h) : uninstallShim(ROOT, h);
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(ROOT, h, "shim", verb === "arm");
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, ROOT, { personaId: pid, personaBody: store.read(pid).body.trim() })
386
- : uninstallSlash(h, ROOT);
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(ROOT, h, "slash", verb === "arm");
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(ROOT);
441
+ const store = createStore(DATA);
427
442
  const id = rest[0];
428
443
  if (!id || id === "status") {
429
- const rows = slashStatus(ROOT);
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(ROOT).filter((s) => s.installed).map((s) => s.id);
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, ROOT);
467
+ const r = uninstallSlash(h, DATA);
453
468
  if (!r.error) {
454
- setArmed(ROOT, h, "slash", false);
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(ROOT).filter((s) => s.offered).map((s) => s.id) : [id];
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, ROOT, { personaId: pid, personaBody: store.read(pid).body.trim() });
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(ROOT, h, "slash", true);
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(ROOT);
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(ROOT);
495
+ const fixed = await reconcileArmed(DATA);
481
496
  if (fixed.length) console.log(`[对账] armed 纠正:${fixed.join(", ")}`);
482
- const { state } = loadState(ROOT);
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(ROOT);
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(ROOT, "state.json")}`);
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(ROOT);
517
- const { state } = loadState(ROOT);
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
- const STATE = ${JSON.stringify(statePath)};
165
- const PERSONAS = ${JSON.stringify(personasDir)};
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.0",
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": { "type": "git", "url": "git+https://github.com/ZiChuanLan/agent-purge.git" },
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": { "url": "https://github.com/ZiChuanLan/agent-purge/issues" }
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(ROOT);
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(ROOT);
91
+ const reconciled = await reconcileArmed(DATA);
88
92
  const found = detect({ versions: withVersions });
89
- const persisted = persistStatus(ROOT);
90
- const { state: st } = loadState(ROOT);
93
+ const persisted = persistStatus(DATA);
94
+ const { state: st } = loadState(DATA);
91
95
  const shims = shimStatus();
92
- const slashes = slashStatus(ROOT);
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(ROOT, { harness, mode: mode ?? "replace" }) };
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(ROOT, payload.id);
177
- const { state: st } = loadState(ROOT);
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(ROOT, slashTargets, { personaId: payload.id, personaBody: store.read(payload.id).body.trim() });
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(ROOT);
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(ROOT, { harness: cur.default.harness, mode: "off" });
201
- patchState(ROOT, { prevMode: m });
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(ROOT, { harness: cur.default.harness, mode: back });
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(ROOT).state.default;
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(ROOT, harness, "install", false);
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: ROOT, active: store.refreshActive() });
240
+ const r = install(harness, { root: DATA, active: store.refreshActive() });
236
241
  if (r.error) return { error: r.error };
237
- setArmed(ROOT, harness, "install", true);
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(ROOT, harness);
282
+ const r = uninstallShim(DATA, harness);
278
283
  if (r.error) return { error: r.error };
279
- setArmed(ROOT, harness, "shim", false);
284
+ setArmed(DATA, harness, "shim", false);
280
285
  return { removed: r.removed };
281
286
  }
282
- const r = installShim(ROOT, harness);
287
+ const r = installShim(DATA, harness);
283
288
  if (r.error) return { error: r.error };
284
- setArmed(ROOT, harness, "shim", true);
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, ROOT);
294
+ const r = uninstallSlash(harness, DATA);
290
295
  if (r.error) return { error: r.error };
291
- setArmed(ROOT, harness, "slash", false);
296
+ setArmed(DATA, harness, "slash", false);
292
297
  return { removed: r.removed };
293
298
  }
294
- const r = installSlash(harness, ROOT, { personaId: id, personaBody: store.read(id).body.trim() });
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(ROOT, harness, "slash", true);
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
- </style>
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
- const hiddenChans = CHAN_ORDER.filter((c) => h.caps?.[c] && !h.caps[c].ok);
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) => {