agent-purge 0.2.10 → 0.2.12

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.
Files changed (76) hide show
  1. package/CONTRIBUTING.md +64 -0
  2. package/LICENSE +21 -21
  3. package/README.md +169 -138
  4. package/backends/index.mjs +204 -244
  5. package/bench/bench-armor.mjs +844 -832
  6. package/bench/capture-hot.mjs +58 -0
  7. package/bench/capture-provider.mjs +66 -0
  8. package/bench/capture-report.mjs +15 -0
  9. package/bench/capture-server-hot.mjs +55 -0
  10. package/bench/capture-system.mjs +36 -0
  11. package/bench/judge-armor.mjs +119 -119
  12. package/bin/agent-purge.mjs +956 -780
  13. package/docs/LEGAL.md +27 -0
  14. package/docs/architecture.md +39 -0
  15. package/docs/compatibility.md +78 -0
  16. package/docs/images/console-desktop.png +0 -0
  17. package/docs/images/console-mobile.png +0 -0
  18. package/docs/releasing.md +87 -0
  19. package/docs/usage.md +158 -0
  20. package/lib/activation.mjs +38 -0
  21. package/lib/apply.mjs +227 -0
  22. package/lib/armed.mjs +38 -38
  23. package/lib/armor.mjs +225 -222
  24. package/lib/caps.mjs +182 -173
  25. package/lib/capture.mjs +324 -0
  26. package/lib/clean.mjs +124 -0
  27. package/lib/control.mjs +60 -0
  28. package/lib/detect.mjs +74 -70
  29. package/lib/doctor.mjs +7 -7
  30. package/lib/dsh.mjs +495 -453
  31. package/lib/environment.mjs +36 -0
  32. package/lib/evidence.mjs +41 -0
  33. package/lib/extension.mjs +140 -0
  34. package/lib/footprints.mjs +59 -0
  35. package/lib/home.mjs +47 -47
  36. package/lib/launch-conflicts.mjs +24 -0
  37. package/lib/launch.mjs +57 -0
  38. package/lib/opencode.mjs +62 -0
  39. package/lib/paths.mjs +25 -0
  40. package/lib/persist.mjs +439 -280
  41. package/lib/persona-import.mjs +351 -0
  42. package/lib/personas.mjs +17 -7
  43. package/lib/private-server.mjs +54 -0
  44. package/lib/project.mjs +134 -0
  45. package/lib/restore.mjs +131 -114
  46. package/lib/rewrite.mjs +124 -0
  47. package/lib/root.mjs +37 -0
  48. package/lib/shim.mjs +203 -160
  49. package/lib/slashes.mjs +227 -628
  50. package/lib/spawn.mjs +182 -126
  51. package/lib/state.mjs +85 -137
  52. package/lib/templates/personas/neutral.md +30 -30
  53. package/lib/update.mjs +137 -0
  54. package/lib/verification.mjs +23 -0
  55. package/lib/window.mjs +58 -0
  56. package/lib/wrappers.mjs +49 -0
  57. package/magpie-plugin/README.md +44 -0
  58. package/matrix.json +325 -341
  59. package/package.json +30 -4
  60. package/web/app.js +590 -0
  61. package/web/assets/agent-purge.ico +0 -0
  62. package/web/assets/claude.ico +0 -0
  63. package/web/assets/codebuddy.png +0 -0
  64. package/web/assets/codex.webp +0 -0
  65. package/web/assets/dsh.ico +0 -0
  66. package/web/assets/grok.ico +0 -0
  67. package/web/assets/hermes.ico +0 -0
  68. package/web/assets/omp.png +0 -0
  69. package/web/assets/opencode.ico +0 -0
  70. package/web/assets/opencode2.ico +0 -0
  71. package/web/assets/pi.png +0 -0
  72. package/web/assets/zcode.png +0 -0
  73. package/web/model.mjs +49 -0
  74. package/web/server.mjs +330 -191
  75. package/web/styles.css +1072 -0
  76. package/web/ui.html +76 -1163
package/lib/restore.mjs CHANGED
@@ -1,114 +1,131 @@
1
- // 崩溃安全的文件交换。
2
- //
3
- // 为什么需要它:bench / launch 会临时改写 harness 自己的配置(hermes 的 config.yaml、
4
- // opencode 插件、codex 的 config.toml)。如果进程被 Ctrl-C、OOM 或超时杀掉,
5
- // 只在内存里保存"原内容"是回不去的 —— 实测中 hermes 的 config.yaml 就被留在了半改状态,
6
- // 下一次启动直接 duplicate key 报错。
7
- //
8
- // 做法:每次改写前把原文落到 journal 目录(os.tmpdir()/agent-purge-restore),
9
- // 进程正常退出 / 收到信号 / 下次启动时统一回滚。undo() 幂等。
10
- import fs from "node:fs";
11
- import os from "node:os";
12
- import path from "node:path";
13
-
14
- const JOURNAL_DIR = path.join(os.tmpdir(), "agent-purge-restore");
15
- const ENTRY = "restore.json";
16
-
17
- let installed = false;
18
- const live = new Set();
19
-
20
- function ensureDir() {
21
- try {
22
- fs.mkdirSync(JOURNAL_DIR, { recursive: true });
23
- } catch {}
24
- }
25
-
26
- function entryPath(file) {
27
- // 同一文件在 journal 里只应有一条;用稳定 hash 命名,避免路径分隔符
28
- const h = Buffer.from(file.toLowerCase()).toString("base64url").slice(0, 40);
29
- return path.join(JOURNAL_DIR, h + ".json");
30
- }
31
-
32
- /**
33
- * 原子写 + 记录 journal。返回幂等的 undo()。
34
- * @param {string} file 绝对路径
35
- * @param {string} content 新内容
36
- */
37
- export function swapFile(file, content) {
38
- ensureDir();
39
- const exists = fs.existsSync(file);
40
- const backup = exists ? fs.readFileSync(file, "utf8") : null;
41
- const record = { file, exists, backup, at: new Date().toISOString() };
42
- fs.writeFileSync(entryPath(file), JSON.stringify(record), "utf8");
43
-
44
- const tmp = file + ".purge-tmp";
45
- fs.writeFileSync(tmp, content, "utf8");
46
- fs.renameSync(tmp, file);
47
-
48
- let done = false;
49
- const undo = () => {
50
- if (done) return;
51
- done = true;
52
- live.delete(undo);
53
- try {
54
- if (record.exists) fs.writeFileSync(file, record.backup, "utf8");
55
- else if (fs.existsSync(file)) fs.unlinkSync(file);
56
- } catch {}
57
- try {
58
- fs.rmSync(entryPath(file), { force: true });
59
- } catch {}
60
- };
61
- live.add(undo);
62
- return undo;
63
- }
64
-
65
- /** 回滚所有 journal 里残留的条目(上次进程没来得及 undo)。返回恢复的文件数。 */
66
- export function recover() {
67
- ensureDir();
68
- let n = 0;
69
- for (const name of fs.readdirSync(JOURNAL_DIR)) {
70
- if (!name.endsWith(".json")) continue;
71
- const p = path.join(JOURNAL_DIR, name);
72
- try {
73
- const r = JSON.parse(fs.readFileSync(p, "utf8"));
74
- if (r.exists) fs.writeFileSync(r.file, r.backup, "utf8");
75
- else if (fs.existsSync(r.file)) fs.unlinkSync(r.file);
76
- fs.rmSync(p, { force: true });
77
- n++;
78
- } catch {}
79
- }
80
- return n;
81
- }
82
-
83
- /** 安装信号钩子:SIGINT/SIGTERM/SIGHUP/uncaughtException/exit 都回滚。幂等。 */
84
- export function installCrashHandlers() {
85
- if (installed) return;
86
- installed = true;
87
- const flush = (code) => {
88
- for (const u of [...live]) {
89
- try {
90
- u();
91
- } catch {}
92
- }
93
- recover();
94
- process.exit(code);
95
- };
96
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
97
- try {
98
- process.on(sig, () => flush(sig === "SIGINT" ? 130 : 143));
99
- } catch {}
100
- }
101
- process.on("uncaughtException", (e) => {
102
- console.error("[restore] 未捕获异常,回滚配置:", e?.message ?? e);
103
- flush(1);
104
- });
105
- process.on("exit", () => {
106
- for (const u of [...live]) {
107
- try {
108
- u();
109
- } catch {}
110
- }
111
- });
112
- }
113
-
114
- export const journalDir = JOURNAL_DIR;
1
+ // 临时文件交换:独占 journal + 所有者存活检查 + 写后指纹。
2
+ // 冲突或恢复失败保留 journal,不覆盖用户在测试期间的修改。
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+
8
+ const JOURNAL_DIR = process.env.AGENT_PURGE_JOURNAL_DIR || path.join(os.tmpdir(), "agent-purge-restore");
9
+ let installed = false;
10
+ const live = new Set();
11
+ const hash = (bytes) => bytes === null ? null : createHash("sha256").update(bytes).digest("hex");
12
+ const read = (file) => { try { return fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
13
+ function canonical(file) {
14
+ const full = path.resolve(file);
15
+ // 同一目录的符号链接也应指向同一把锁;不跟随不存在的目标。
16
+ try { return fs.realpathSync(full); } catch (e) {
17
+ if (e.code !== "ENOENT") throw e;
18
+ return path.join(fs.realpathSync(path.dirname(full)), path.basename(full));
19
+ }
20
+ }
21
+ function entryPath(file) {
22
+ const key = process.platform === "win32" ? file.toLowerCase() : file;
23
+ return path.join(JOURNAL_DIR, createHash("sha256").update(key).digest("hex") + ".json");
24
+ }
25
+ function alive(pid) {
26
+ if (!Number.isInteger(pid) || pid <= 0) return true;
27
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code !== "ESRCH"; }
28
+ }
29
+ function atomicWrite(file, bytes, mode) {
30
+ const tmp = file + ".purge-" + randomUUID() + ".tmp";
31
+ try {
32
+ fs.writeFileSync(tmp, bytes, { flag: "wx", mode: mode ?? 0o600 });
33
+ fs.renameSync(tmp, file);
34
+ } finally { fs.rmSync(tmp, { force: true }); }
35
+ }
36
+ function restore(record, journal) {
37
+ let currentRecord;
38
+ try {
39
+ const source = fs.readFileSync(journal, "utf8");
40
+ currentRecord = JSON.parse(source);
41
+ } catch (error) {
42
+ throw new Error(`无法读取恢复凭据,文件未改动:${journal}`, { cause: error });
43
+ }
44
+ if (currentRecord.operation !== record.operation) throw new Error(`恢复所有权已改变:${record.file}`);
45
+ const current = hash(read(record.file));
46
+ if (current !== record.beforeHash && current !== record.afterHash) {
47
+ throw new Error(`文件在交换后被修改,保留 journal 等待处理:${record.file}`);
48
+ }
49
+ if (current !== record.beforeHash) {
50
+ if (record.backup === null) fs.rmSync(record.file, { force: true });
51
+ else atomicWrite(record.file, Buffer.from(record.backup, "base64"), record.mode);
52
+ }
53
+ fs.rmSync(journal);
54
+ }
55
+
56
+ /** 独占交换;返回可重试、幂等的 undo。恢复失败会抛错且保留原始备份。 */
57
+ export function swapFile(file, content) {
58
+ file = canonical(file);
59
+ fs.mkdirSync(JOURNAL_DIR, { recursive: true });
60
+ const journal = entryPath(file);
61
+ let fd;
62
+ try { fd = fs.openSync(journal, "wx", 0o600); }
63
+ catch (e) {
64
+ if (e.code === "EEXIST") throw new Error(`文件存在未结束的交换,请先处理 journal:${file}`);
65
+ throw e;
66
+ }
67
+ let record;
68
+ try {
69
+ const backup = read(file);
70
+ const next = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
71
+ record = { version: 2, file, pid: process.pid, operation: randomUUID(), at: new Date().toISOString(),
72
+ backup: backup === null ? null : backup.toString("base64"), beforeHash: hash(backup), afterHash: hash(next),
73
+ mode: backup === null ? undefined : fs.statSync(file).mode & 0o777 };
74
+ fs.writeFileSync(fd, JSON.stringify(record));
75
+ fs.fsyncSync(fd);
76
+ fs.closeSync(fd); fd = undefined;
77
+ atomicWrite(file, next, record.mode);
78
+ } catch (e) {
79
+ if (fd !== undefined) fs.closeSync(fd);
80
+ if (record) { try { restore(record, journal); } catch {} }
81
+ else fs.rmSync(journal, { force: true });
82
+ throw e;
83
+ }
84
+ let done = false;
85
+ const undo = () => {
86
+ if (done) return;
87
+ restore(record, journal);
88
+ done = true;
89
+ live.delete(undo);
90
+ };
91
+ live.add(undo);
92
+ return undo;
93
+ }
94
+
95
+ /** 只恢复已死亡的 v2 所有者;旧格式无法证明所有权,保留并报告。 */
96
+ export function recover({ onWarning = (message) => console.error(`[restore] ${message}`) } = {}) {
97
+ fs.mkdirSync(JOURNAL_DIR, { recursive: true });
98
+ let n = 0;
99
+ for (const name of fs.readdirSync(JOURNAL_DIR)) {
100
+ if (!name.endsWith(".json")) continue;
101
+ const journal = path.join(JOURNAL_DIR, name);
102
+ try {
103
+ const record = JSON.parse(fs.readFileSync(journal, "utf8"));
104
+ if (record.version !== 2 || !record.operation || !path.isAbsolute(record.file || "")) {
105
+ onWarning(`旧版或不完整 journal,未自动覆盖文件:${journal}`); continue;
106
+ }
107
+ if (alive(record.pid)) continue;
108
+ restore(record, journal); n++;
109
+ } catch (e) { onWarning(`${journal}: ${e.message}`); }
110
+ }
111
+ return n;
112
+ }
113
+
114
+ export function installCrashHandlers() {
115
+ if (installed) return;
116
+ installed = true;
117
+ const flush = () => {
118
+ for (const undo of [...live]) {
119
+ try { undo(); } catch (e) { console.error(`[restore] ${e.message}`); }
120
+ }
121
+ };
122
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
123
+ try { process.on(sig, () => { flush(); process.exit(sig === "SIGINT" ? 130 : 143); }); } catch {}
124
+ }
125
+ process.on("uncaughtException", (e) => {
126
+ console.error("[restore] 未捕获异常:", e?.stack ?? e);
127
+ flush(); process.exit(1);
128
+ });
129
+ process.on("exit", flush);
130
+ }
131
+ export const journalDir = JOURNAL_DIR;
@@ -0,0 +1,124 @@
1
+ // 模式语义与生成插件共享实现;运行时只还原有来源记录的完整变换。
2
+ import { activationRuntime } from "./activation.mjs";
3
+ export function stripIdentity(text) {
4
+ const self = /^\s*(?:You are|You're|你是|您是|我(?:是|现在扮演|就是))/i;
5
+ const named = /(Claude|ChatGPT|GPT-?[45]|Gemini|Codex|Copilot|DeepSeek|Qwen|Kimi|GLM|Cursor|Windsurf|OpenCode|ZCode|CodeBuddy|Hermes|Grok|Antigravity|AI (?:assistant|model)|coding (?:assistant|agent)|语言模型|助手)/i;
6
+ return String(text ?? "").split(/\r?\n/).filter(line => !(self.test(line) && named.test(line))).join("\n").trim();
7
+ }
8
+
9
+ export function cleanManaged(text, bodies = []) {
10
+ let out = String(text ?? "").replace(/^# >>> agent-purge[^\S\r\n]*\r?\n[\s\S]*?^# <<< agent-purge[^\S\r\n]*(?:\r?\n|$)/gm, "");
11
+ for (const body of bodies) {
12
+ if (!body) continue;
13
+ // 只删除独立完整正文;共享前40字符的另一份人设/用户规则不匹配。
14
+ const literal = String(body).trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+ out = out.replace(new RegExp("(^|\\n)" + literal + "(?=\\n|$)", "g"), "$1");
16
+ }
17
+ return out.trim();
18
+ }
19
+
20
+ export function rewriteTexts(hostTexts, persona, mode) {
21
+ if (mode === "off" || !persona) return hostTexts;
22
+ const host = (Array.isArray(hostTexts) ? hostTexts : []).map(t => String(t ?? "")).filter(t => t.trim());
23
+ if (mode === "strict") return [persona];
24
+ if (mode === "append") return [...host, persona];
25
+ return [persona, ...host.map(stripIdentity).filter(Boolean)];
26
+ }
27
+
28
+ export function rewriteSections(sections, persona, mode, name = "agent-purge") {
29
+ const list = Array.isArray(sections) ? sections : [];
30
+ if (mode === "off" || !persona) return list;
31
+ if (mode === "strict") return [{ name, text: persona, interpolate: false }];
32
+ if (mode === "append") return [...list, { name, text: persona, interpolate: false }];
33
+ const kept = list.map(s => typeof s?.text === "string" ? { ...s, text: stripIdentity(s.text) } : s)
34
+ .filter(s => typeof s?.text !== "string" || s.text.trim());
35
+ return [{ name, text: persona, interpolate: false }, ...kept];
36
+ }
37
+
38
+ /** 有界完整变换记录:无凭据或歧义时原样保留,不按标签/人设文本猜测删除。 */
39
+ export function createProvenance(limit = 64) {
40
+ const values = new Map();
41
+ const objects = new WeakMap();
42
+ const key = value => JSON.stringify(value);
43
+ function remember(before, after, aliases = []) {
44
+ const beforeKey = key(before), afterKey = key(after);
45
+ if (beforeKey === afterKey) return after;
46
+ const entry = { before: structuredClone(before), beforeKey, afterKey };
47
+ if (after && typeof after === "object") objects.set(after, entry);
48
+ for (const candidate of [after, ...aliases]) {
49
+ const id = key(candidate), previous = values.get(id);
50
+ values.set(id, previous && previous.beforeKey !== beforeKey ? { ambiguous: true } : previous?.ambiguous ? previous : entry);
51
+ }
52
+ while (values.size > limit) values.delete(values.keys().next().value);
53
+ return after;
54
+ }
55
+ function restore(value) {
56
+ const id = key(value);
57
+ const direct = value && typeof value === "object" ? objects.get(value) : null;
58
+ const entry = direct?.afterKey === id ? direct : values.get(id);
59
+ return entry && !entry.ambiguous ? structuredClone(entry.before) : value;
60
+ }
61
+ return { remember, restore };
62
+ }
63
+
64
+ export function createTextRewriter() {
65
+ const provenance = createProvenance();
66
+ return (texts, persona, mode) => {
67
+ const host = provenance.restore(texts);
68
+ if (mode === "off" || !persona) return host;
69
+ const next = rewriteTexts(host, persona, mode);
70
+ return provenance.remember(host, next, [[next.join("\n\n")]]);
71
+ };
72
+ }
73
+
74
+ export function createSectionRewriter() {
75
+ const provenance = createProvenance();
76
+ return (sections, persona, mode, name = "agent-purge") => {
77
+ const host = provenance.restore(sections);
78
+ if (mode === "off" || !persona) return host;
79
+ return provenance.remember(host, rewriteSections(host, persona, mode, name));
80
+ };
81
+ }
82
+
83
+ /** Anthropic-style content blocks: retain non-text blocks and cache metadata. */
84
+ export function createContentRewriter() {
85
+ const provenance = createProvenance();
86
+ return (blocks, persona, mode) => {
87
+ const host = provenance.restore(blocks);
88
+ if (mode === "off" || !persona || !Array.isArray(host)) return host;
89
+ let next;
90
+ if (mode === "strict") {
91
+ const text = host.find(b => b?.type === "text");
92
+ const own = { ...text, type: "text", text: persona };
93
+ next = [own, ...host.filter(b => b?.type !== "text")];
94
+ } else {
95
+ const kept = mode === "replace" ? host.map(b => typeof b?.text === "string" ? { ...b, text: stripIdentity(b.text) } : b) : host;
96
+ const own = { type: "text", text: persona };
97
+ next = mode === "append" ? [...kept, own] : [own, ...kept];
98
+ }
99
+ return provenance.remember(host, next);
100
+ };
101
+ }
102
+
103
+ export const rewriteRuntime = [stripIdentity, cleanManaged, rewriteTexts, rewriteSections, createProvenance, createTextRewriter, createSectionRewriter, createContentRewriter]
104
+ .map(fn => fn.toString()).join("\n") + `
105
+ const apStrip = stripIdentity;
106
+ const apRewrite = createTextRewriter();
107
+ const apRewriteSections = createSectionRewriter();
108
+ const apRewriteContent = createContentRewriter();
109
+ `;
110
+
111
+ export const resolveRuntime = activationRuntime;
112
+
113
+ /** 覆盖指定的人设直接读 personas/<id>.md(剥掉 frontmatter);读不到再回退嵌入的 active.md。 */
114
+ export const personaBodyRuntime = `
115
+ function resolvePersonaBody(personas, id, fallback) {
116
+ if (id && id !== "active" && /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
117
+ try {
118
+ const raw = readFileSync(personas + "/" + id + ".md", "utf8").replace(/^---\\r?\\n[\\s\\S]*?\\r?\\n---(?:\\r?\\n|$)/, "");
119
+ if (raw.trim()) return raw.trim();
120
+ } catch { return ""; }
121
+ }
122
+ return readFileSync(fallback, "utf8").trim();
123
+ }
124
+ `;
package/lib/root.mjs ADDED
@@ -0,0 +1,37 @@
1
+ // 随包资源访问:源码态读磁盘,bun --compile 产物读内嵌表。
2
+ //
3
+ // 为什么不是「算一个 ROOT 再 join」:实测(bun 1.3.14, Windows)
4
+ // - `[dir]/[name].[ext]` 命名下根目录文件变成 `B:/~BUN/root/./matrix.json`,readFileSync 直接 ENOENT;
5
+ // - 拼接出来的 `B:/~BUN/root/matrix.json` 同样打不开;
6
+ // - bunfs 目录不支持 readdir。
7
+ // 只有「原样用 import 拿到的路径」能读,所以编译态的每条路径都由 build 入口生成的表给出,
8
+ // 键 = 仓库相对路径(posix 分隔)。源码态保持原来的 join(包根, 相对路径) 行为。
9
+ import { readFileSync, readdirSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const FILES = globalThis.__AGENT_PURGE_FILES ?? null;
14
+
15
+ export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
16
+
17
+ /** 编译态:资源在 bunfs 里只读,任何「往包目录写」的逻辑都要绕开 */
18
+ export const isCompiled = () => FILES !== null;
19
+
20
+ /** 读一个随包资源(仓库相对路径,posix 分隔):matrix.json / web/ui.html / lib/templates/... */
21
+ export function readResource(rel) {
22
+ const hit = FILES?.[rel];
23
+ if (hit) return readFileSync(hit);
24
+ if (FILES) throw new Error(`exe 里没内嵌 ${rel}(scripts/build-exe.mjs 的 assets() 漏了它)`);
25
+ return readFileSync(join(ROOT, rel));
26
+ }
27
+
28
+ /** 列一个资源目录下的文件名(不含子目录):编译态筛内嵌表,源码态真读目录 */
29
+ export function listResources(prefix) {
30
+ const dir = prefix.endsWith("/") ? prefix : `${prefix}/`;
31
+ if (FILES) {
32
+ return Object.keys(FILES)
33
+ .filter((k) => k.startsWith(dir) && !k.slice(dir.length).includes("/"))
34
+ .map((k) => k.slice(dir.length));
35
+ }
36
+ return readdirSync(join(ROOT, dir));
37
+ }