agent-purge 0.2.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.
@@ -115,10 +115,22 @@ async function launch(name) {
115
115
  console.log(`[auto] 裸敲 pi ${autoFlag === "on" ? "会" : "不会"}自动注入(已写入 state.json)`);
116
116
  }
117
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
+
118
131
  // 实测策略:该 harness 的哪一层真的能注入(数据来自 matrix.json armor 段)
119
132
  const { plan } = await import("../lib/armor.mjs");
120
- const strategy = plan(matrix, name);
121
- if (!strategy.error) {
133
+ const strategy = plan(matrix, name); if (!strategy.error) {
122
134
  if (strategy.requiresSeed) console.log(`[策略] ${name}:裸注入 sys 层实测 ${strategy.bare.sys}%,带人设词种子 ${strategy.persona.sys}% —— 需要 seed 层`);
123
135
  const noSys = strategy.blocked.find((b) => b.layer === "sys");
124
136
  if (noSys && mode !== "off") console.log(`[策略] ${name}:sys 层不可用(${noSys.reason}),本次只能靠 user 层`);
package/lib/slashes.mjs CHANGED
@@ -186,7 +186,7 @@ function loadPersona(): { id: string | null; body: string } {
186
186
  }
187
187
  }
188
188
 
189
- function loadState(): { persona: string | null; mode: string; shim: boolean } {
189
+ function loadState(): { persona: string | null; mode: string; shim: boolean; piAuto: boolean; piUserLayer: boolean } {
190
190
  // 启动器会用环境变量告知本次运行用的 persona/mode(--persona / --mode 覆盖);
191
191
  // 否则读 state.json —— 注意 mode 存在 default.mode,不是顶层。
192
192
  const envPersona = process.env.AGENT_PURGE_PERSONA;
@@ -197,15 +197,17 @@ function loadState(): { persona: string | null; mode: string; shim: boolean } {
197
197
  default?: { harness?: string; mode?: string };
198
198
  armed?: Record<string, string[]>;
199
199
  piAuto?: boolean;
200
+ piUserLayer?: boolean;
200
201
  };
201
202
  return {
202
203
  persona: envPersona ?? st.persona ?? null,
203
204
  mode: envMode ?? st.default?.mode ?? "replace",
204
205
  shim: Array.isArray(st.armed?.pi) && st.armed.pi.includes("shim"),
205
206
  piAuto: st.piAuto === true,
207
+ piUserLayer: st.piUserLayer === true,
206
208
  };
207
209
  } catch {
208
- 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 };
209
211
  }
210
212
  }
211
213
 
@@ -248,6 +250,55 @@ export default function agentPurge(pi: ExtensionAPI) {
248
250
  return { systemPrompt: host.length > 0 ? cache.body + "\\n\\n" + host : cache.body };
249
251
  });
250
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
+
251
302
  pi.registerCommand("purge", {
252
303
  description: "会话级接管开关(人设跟随 agent-purge 的 state.json)",
253
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.2.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": {
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, 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";
@@ -54,6 +54,7 @@ async function handle(req, res) {
54
54
  if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
55
55
  if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
56
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) });
57
58
  if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
58
59
  if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
59
60
  if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
@@ -134,6 +135,7 @@ async function state(withVersions) {
134
135
  defaults: st.default,
135
136
  armed: st.armed ?? {},
136
137
  modes: MODES,
138
+ piAuto: st.piAuto === true,
137
139
  shimDir: shimStatus()[0]?.dir ?? null,
138
140
  slashes: slashes,
139
141
  };
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) => {