agent-purge 0.2.9 → 0.2.11
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/CONTRIBUTING.md +64 -0
- package/LICENSE +21 -21
- package/README.md +176 -118
- package/backends/index.mjs +204 -244
- package/bench/bench-armor.mjs +844 -832
- package/bench/capture-hot.mjs +58 -0
- package/bench/capture-provider.mjs +66 -0
- package/bench/capture-report.mjs +15 -0
- package/bench/capture-server-hot.mjs +55 -0
- package/bench/capture-system.mjs +36 -0
- package/bench/judge-armor.mjs +119 -119
- package/bin/agent-purge.mjs +906 -772
- package/docs/LEGAL.md +27 -0
- package/docs/architecture.md +39 -0
- package/docs/compatibility.md +78 -0
- package/docs/images/console-desktop.png +0 -0
- package/docs/images/console-mobile.png +0 -0
- package/docs/releasing.md +87 -0
- package/docs/usage.md +158 -0
- package/lib/activation.mjs +38 -0
- package/lib/apply.mjs +227 -0
- package/lib/armed.mjs +38 -38
- package/lib/armor.mjs +225 -222
- package/lib/caps.mjs +182 -173
- package/lib/capture.mjs +324 -0
- package/lib/clean.mjs +124 -0
- package/lib/control.mjs +60 -0
- package/lib/detect.mjs +74 -70
- package/lib/doctor.mjs +7 -7
- package/lib/dsh.mjs +495 -453
- package/lib/environment.mjs +36 -0
- package/lib/evidence.mjs +41 -0
- package/lib/extension.mjs +140 -0
- package/lib/footprints.mjs +59 -0
- package/lib/home.mjs +47 -47
- package/lib/launch-conflicts.mjs +24 -0
- package/lib/launch.mjs +57 -0
- package/lib/opencode.mjs +62 -0
- package/lib/paths.mjs +25 -0
- package/lib/persist.mjs +439 -280
- package/lib/personas.mjs +17 -7
- package/lib/private-server.mjs +54 -0
- package/lib/project.mjs +134 -0
- package/lib/restore.mjs +131 -114
- package/lib/rewrite.mjs +124 -0
- package/lib/root.mjs +37 -0
- package/lib/shim.mjs +203 -160
- package/lib/slashes.mjs +227 -628
- package/lib/spawn.mjs +182 -126
- package/lib/state.mjs +85 -137
- package/lib/templates/personas/neutral.md +30 -30
- package/lib/update.mjs +137 -0
- package/lib/verification.mjs +23 -0
- package/lib/window.mjs +58 -0
- package/lib/wrappers.mjs +49 -0
- package/magpie-plugin/README.md +44 -0
- package/matrix.json +325 -341
- package/package.json +30 -4
- package/web/app.js +525 -0
- package/web/assets/agent-purge.ico +0 -0
- package/web/assets/claude.ico +0 -0
- package/web/assets/codebuddy.png +0 -0
- package/web/assets/codex.webp +0 -0
- package/web/assets/dsh.ico +0 -0
- package/web/assets/grok.ico +0 -0
- package/web/assets/hermes.ico +0 -0
- package/web/assets/omp.png +0 -0
- package/web/assets/opencode.ico +0 -0
- package/web/assets/opencode2.ico +0 -0
- package/web/assets/pi.png +0 -0
- package/web/assets/zcode.png +0 -0
- package/web/model.mjs +49 -0
- package/web/server.mjs +309 -194
- package/web/styles.css +917 -0
- package/web/ui.html +64 -1163
package/lib/personas.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// persona 管理:一个 markdown 文件 = 元数据(frontmatter) + 系统提示词正文。
|
|
2
2
|
// 正文是唯一喂给 harness 的东西;元数据只用来做选择。
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join } from "node:path";
|
|
6
5
|
import { loadState, saveState } from "./state.mjs";
|
|
6
|
+
import { listResources, readResource } from "./root.mjs";
|
|
7
|
+
|
|
8
|
+
const TEMPLATES = "lib/templates/personas/";
|
|
7
9
|
|
|
8
10
|
const WILD = "*";
|
|
9
11
|
// active.md 是派生物,不是状态:install 指向它、opencode 插件运行时读它 → 真热切。
|
|
@@ -19,9 +21,12 @@ export function createStore(root) {
|
|
|
19
21
|
// 首次运行(或 npm 安装后的空目录):从 lib/templates/personas 拷一份中性的起步 persona。
|
|
20
22
|
{
|
|
21
23
|
const has = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(".md") && f !== "active.md");
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
if (!has) {
|
|
25
|
+
try {
|
|
26
|
+
for (const f of listResources(TEMPLATES)) writeFileSync(join(dir, f), readResource(TEMPLATES + f));
|
|
27
|
+
} catch {
|
|
28
|
+
// 模板目录不在(裁剪过/自定义安装)就跳过,不挡人用
|
|
29
|
+
}
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -54,15 +59,20 @@ export function createStore(root) {
|
|
|
54
59
|
|
|
55
60
|
function remove(id) {
|
|
56
61
|
if (!isValidId(id)) return false;
|
|
62
|
+
const wasActive = activeId() === id;
|
|
57
63
|
rmSync(join(dir, `${id}.md`), { force: true });
|
|
58
64
|
rmSync(join(build, `${id}.md`), { force: true });
|
|
59
65
|
// 删的就是 active 时必须改道:否则下次无参启动直接 “没有 persona”。
|
|
60
66
|
// 落点不抓 list()[0](那是顺序序,等于静默换人),优先落到 neutral。
|
|
61
|
-
if (
|
|
67
|
+
if (wasActive) {
|
|
62
68
|
const all = list();
|
|
63
69
|
const next = all.find((p) => p.id === "neutral") ?? all[0] ?? null;
|
|
64
70
|
if (next) setActive(next.id);
|
|
65
|
-
else
|
|
71
|
+
else {
|
|
72
|
+
const { state } = loadState(root);
|
|
73
|
+
saveState(root, { ...state, persona: null });
|
|
74
|
+
writeFileSync(join(dir, ACTIVE_BODY), "");
|
|
75
|
+
}
|
|
66
76
|
}
|
|
67
77
|
return true;
|
|
68
78
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// OpenCode v2 的 prompt 在独立 server 进程执行:父进程直接持有该 PID,不把许可交给 CLI/后代。
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { prepareCommand, spawnPrepared } from "./spawn.mjs";
|
|
4
|
+
|
|
5
|
+
export function privateClientArgs(args, url) {
|
|
6
|
+
if (args.some(value => value === "--server" || value.startsWith("--server="))) throw new Error("本次启动使用独立服务,请移除 --server");
|
|
7
|
+
return ["--server", url, ...args.filter(value => value !== "--standalone")];
|
|
8
|
+
}
|
|
9
|
+
export async function startPrivateServer(command, { env, cwd, lease, timeout = 20000 }) {
|
|
10
|
+
const password = randomBytes(32).toString("base64url");
|
|
11
|
+
// 客户端和服务端使用同一次随机认证;不读取用户已有的服务器密码。
|
|
12
|
+
env.OPENCODE_PASSWORD = password;
|
|
13
|
+
delete env.OPENCODE_SERVER_PASSWORD;
|
|
14
|
+
const descriptor = prepareCommand(command, ["serve", "--stdio", "--hostname", "127.0.0.1", "--port", "0"], { env, cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
15
|
+
if (!descriptor.hostDirect) throw new Error("独立服务需要可核验的原生入口");
|
|
16
|
+
const startedAt = Date.now(), child = spawnPrepared(descriptor);
|
|
17
|
+
child.on("error", () => {});
|
|
18
|
+
child.stdin.on("error", () => {});
|
|
19
|
+
let closed = false, errors = "";
|
|
20
|
+
const close = () => {
|
|
21
|
+
if (closed) return;
|
|
22
|
+
closed = true;
|
|
23
|
+
child.stdin.end();
|
|
24
|
+
if (child.exitCode === null) child.kill();
|
|
25
|
+
};
|
|
26
|
+
const exited = new Promise(resolve => child.once("close", code => resolve(code)));
|
|
27
|
+
child.stderr.on("data", chunk => { errors = (errors + chunk.toString()).slice(-2000); });
|
|
28
|
+
try {
|
|
29
|
+
if (child.pid) lease?.publish(child.pid, startedAt);
|
|
30
|
+
const url = await new Promise((resolve, reject) => {
|
|
31
|
+
let pending = "", settled = false;
|
|
32
|
+
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); if (error) reject(error); else resolve(value); };
|
|
33
|
+
const timer = setTimeout(() => finish(new Error("独立服务启动超时")), timeout);
|
|
34
|
+
child.once("error", error => finish(error));
|
|
35
|
+
child.once("exit", code => finish(new Error(`独立服务提前退出 (${code})${errors ? ": " + errors.replaceAll(password, "[redacted]") : ""}`)));
|
|
36
|
+
child.stdout.on("data", chunk => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
pending += chunk.toString();
|
|
39
|
+
if (pending.length > 32768) { finish(new Error("独立服务 readiness 超出限制")); return; }
|
|
40
|
+
let end;
|
|
41
|
+
while ((end = pending.indexOf("\n")) !== -1) {
|
|
42
|
+
const line = pending.slice(0, end).trim(); pending = pending.slice(end + 1);
|
|
43
|
+
if (!line) continue;
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(line), ready = new URL(data.url);
|
|
46
|
+
if (ready.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(ready.hostname) || !ready.port || ready.username || ready.password || ready.pathname !== "/" || ready.search || ready.hash) throw new Error("readiness URL 不是本机独立服务");
|
|
47
|
+
finish(null, ready.origin); return;
|
|
48
|
+
} catch (error) { finish(new Error(`独立服务 readiness 无效:${error.message}`)); return; }
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
return { pid: child.pid, url, exited, close, clientArgs: args => privateClientArgs(args, url) };
|
|
53
|
+
} catch (error) { lease?.close(); close(); await exited; throw error; }
|
|
54
|
+
}
|
package/lib/project.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// 项目级人设:写进当前项目根的 AGENTS.md / CLAUDE.md 托管块。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这条通道:全局配置一改就是全机器生效,改坏了也只能回滚配置;项目块只影响
|
|
4
|
+
// 当前目录,删块即还原,比全局配置更局部也更好解释。撤销走 persist.revertFile(stash 字节还原)。
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import { cancel, listLedger, record } from "./footprints.mjs";
|
|
9
|
+
import { readStash, revertFile, stripBlock, upsertBlock, writeStash } from "./persist.mjs";
|
|
10
|
+
import { loadState, patchState } from "./state.mjs";
|
|
11
|
+
import { dataHome } from "./home.mjs";
|
|
12
|
+
|
|
13
|
+
/** 各家的项目规则文件。没列的家(codebuddy/dsh/antigravity)没有这个落点,不摆开关。 */
|
|
14
|
+
const PROJECT_FILES = {
|
|
15
|
+
pi: "AGENTS.md",
|
|
16
|
+
omp: "AGENTS.md",
|
|
17
|
+
grok: "AGENTS.md",
|
|
18
|
+
zcode: "AGENTS.md",
|
|
19
|
+
codex: "AGENTS.md",
|
|
20
|
+
opencode: "AGENTS.md",
|
|
21
|
+
opencode2: "AGENTS.md",
|
|
22
|
+
claude: "CLAUDE.md",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const projectTargets = PROJECT_FILES;
|
|
26
|
+
export const projectFilePath = (id, dir = process.cwd()) => (PROJECT_FILES[id] ? resolve(dir, PROJECT_FILES[id]) : null);
|
|
27
|
+
const pathKey = path => process.platform === "win32" ? path.toLowerCase() : path;
|
|
28
|
+
|
|
29
|
+
/** 暂停会撤销磁盘块,因此目录选择单独保存在 state;旧安装只从账本恢复,不猜 cwd。 */
|
|
30
|
+
export function projectDirectories(root, id) {
|
|
31
|
+
const { state } = loadState(root);
|
|
32
|
+
const saved = Array.isArray(state.projectDirs?.[id]) ? state.projectDirs[id] : [];
|
|
33
|
+
const recorded = listLedger(root)
|
|
34
|
+
.filter(e => e.harness === id && e.channel === "project" && typeof e.path === "string" && isAbsolute(e.path))
|
|
35
|
+
.map(e => dirname(e.path));
|
|
36
|
+
const dirs = new Map();
|
|
37
|
+
for (const path of [...saved, ...recorded]) {
|
|
38
|
+
if (typeof path !== "string" || !isAbsolute(path)) continue;
|
|
39
|
+
const dir = resolve(path);
|
|
40
|
+
dirs.set(pathKey(dir), dir);
|
|
41
|
+
}
|
|
42
|
+
return [...dirs.values()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveDirectories(root, id, dirs) {
|
|
46
|
+
const { state } = loadState(root);
|
|
47
|
+
const projectDirs = { ...state.projectDirs };
|
|
48
|
+
if (dirs.length) projectDirs[id] = dirs;
|
|
49
|
+
else delete projectDirs[id];
|
|
50
|
+
patchState(root, { projectDirs });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rememberDirectory(root, id, dir) {
|
|
54
|
+
const dirs = projectDirectories(root, id);
|
|
55
|
+
if (!dirs.some(path => pathKey(path) === pathKey(dir))) dirs.push(dir);
|
|
56
|
+
saveDirectories(root, id, dirs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 项目块优先于全局块(harness 把项目规则放前面),所以两块同时开会两份都进。 */
|
|
60
|
+
export const PROJECT_NOTE = "原生 harness 会共同读取同名规则文件;单文件只登记一个管理归属,同时开全局会有两份";
|
|
61
|
+
|
|
62
|
+
function ownersOf(root, file) {
|
|
63
|
+
return Object.keys(PROJECT_FILES).filter(id => projectDirectories(root, id).some(dir => pathKey(projectFilePath(id, dir)) === pathKey(file)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function projectInstall(id, { root, active, dir = process.cwd(), paused = false }) {
|
|
67
|
+
const file = projectFilePath(id, dir);
|
|
68
|
+
if (!file) return { error: `${id} 没有项目级规则文件(可用: ${Object.keys(PROJECT_FILES).join(", ")})` };
|
|
69
|
+
const owners = ownersOf(root, file).filter(owner => owner !== id);
|
|
70
|
+
if (owners.length) return { error: `项目文件归属冲突:${file} 已登记给 ${owners.join(", ")},请先撤销原选择`, file, owners };
|
|
71
|
+
if (paused) {
|
|
72
|
+
rememberDirectory(root, id, dirname(file));
|
|
73
|
+
return { file, action: "selected-while-paused" };
|
|
74
|
+
}
|
|
75
|
+
const body = String(active ?? "").trim();
|
|
76
|
+
if (!body) return { error: "没有可用人设" };
|
|
77
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
78
|
+
const old = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
79
|
+
if (/^(?:\s*)# (?:>>>|<<<) agent-purge/m.test(body)) return { error: `${id}: persona 正文包含 agent-purge 托管标记,拒绝写入` };
|
|
80
|
+
const next = upsertBlock(old ?? "", body);
|
|
81
|
+
// 多次同步沿用最初的还原基线;块外被用户编辑时,只移除旧托管块再保存。
|
|
82
|
+
const previous = readStash(file, root);
|
|
83
|
+
let before = previous && previous.after === old ? previous.before : old;
|
|
84
|
+
if (before !== null) before = stripBlock(before);
|
|
85
|
+
writeStash(file, { file, before, after: next }, root);
|
|
86
|
+
writeFileSync(file, next);
|
|
87
|
+
record(root, { kind: "block", harness: id, channel: "project", path: file, undo: { type: "block-revert" } });
|
|
88
|
+
rememberDirectory(root, id, dirname(file));
|
|
89
|
+
return { file, action: old === null ? "created" : stripBlock(old) !== old ? "replaced-block" : "appended-block", note: PROJECT_NOTE };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function projectUninstall(id, { dir = process.cwd(), root = dataHome(), keepSelection = false } = {}) {
|
|
93
|
+
const file = projectFilePath(id, dir);
|
|
94
|
+
if (!file) return { error: `${id} 没有项目级规则文件` };
|
|
95
|
+
const owners = ownersOf(root, file);
|
|
96
|
+
if (owners.length && !owners.includes(id)) return { file, action: "not-owner", owners };
|
|
97
|
+
if (owners.length > 1) return { error: `旧项目归属冲突:${file} (${owners.join(", ")}),请先核对账本`, file };
|
|
98
|
+
// 在撤销账本前保存旧安装的目录,暂停后仍可恢复到同一位置。
|
|
99
|
+
if (root && keepSelection) rememberDirectory(root, id, dirname(file));
|
|
100
|
+
const r = revertFile(file, { root });
|
|
101
|
+
if (root && !r.error) {
|
|
102
|
+
cancel(root, { kind: "block", path: file });
|
|
103
|
+
if (!keepSelection) saveDirectories(root, id, projectDirectories(root, id).filter(path => pathKey(path) !== pathKey(dirname(file))));
|
|
104
|
+
}
|
|
105
|
+
return r;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 现场状态。同一个 AGENTS.md 可能被好几家共用,所以「开着」必须靠账本归属到具体 harness:
|
|
110
|
+
* 块在文件里、账本又有这条记录才算开着(账本丢了就说不知道,不自作主张)。
|
|
111
|
+
*/
|
|
112
|
+
export function projectStatus(dir = process.cwd(), root) {
|
|
113
|
+
const entries = root ? listLedger(root).filter((e) => e.channel === "project") : null;
|
|
114
|
+
return Object.entries(PROJECT_FILES).map(([id, name]) => {
|
|
115
|
+
const file = resolve(dir, name);
|
|
116
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
117
|
+
const hasBlock = text !== null && stripBlock(text) !== text;
|
|
118
|
+
const mine = entries ? entries.some((e) => e.harness === id && typeof e.path === "string" && pathKey(e.path) === pathKey(file)) : hasBlock;
|
|
119
|
+
const selected = root ? projectDirectories(root, id).some(path => pathKey(path) === pathKey(dirname(file))) : hasBlock;
|
|
120
|
+
return { id, file, hasBlock, on: hasBlock && mine, selected };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* git 仓库里没被跟踪的规则文件,改完容易意外进提交。只提示,不替用户改 .gitignore。
|
|
126
|
+
* 返回 null = 不需要提示(不是 git 仓库 / 文件已被跟踪 / 没有 git)。
|
|
127
|
+
*/
|
|
128
|
+
export function gitHint(id, dir = process.cwd()) {
|
|
129
|
+
const name = PROJECT_FILES[id];
|
|
130
|
+
if (!name || !existsSync(join(dir, ".git"))) return null;
|
|
131
|
+
const run = spawnSync("git", ["-C", dir, "ls-files", "--error-unmatch", "--", name], { encoding: "utf8" });
|
|
132
|
+
if (run.error || run.status === 0) return null;
|
|
133
|
+
return `${name} 还没被 git 跟踪:不想提交这份人设就 git update-index --skip-worktree ${name} 或写进 .gitignore(不自动改)`;
|
|
134
|
+
}
|
package/lib/restore.mjs
CHANGED
|
@@ -1,114 +1,131 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export
|
|
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;
|
package/lib/rewrite.mjs
ADDED
|
@@ -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
|
+
}
|