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
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: neutral
|
|
3
|
-
name: 中性模板
|
|
4
|
-
description: 起步用的最小 persona:只约定语言、风格和协作方式。正文就是会被写进系统提示词的那一段,改这里就是改 agent 的行为。
|
|
5
|
-
harness: ["*"]
|
|
6
|
-
model: ["*"]
|
|
7
|
-
order: 1
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
# 系统提示词 · 起步模板
|
|
11
|
-
|
|
12
|
-
## 语言与格式
|
|
13
|
-
|
|
14
|
-
简体中文;先给结论,再给依据。命令、路径、标识符保持原样不翻译。
|
|
15
|
-
|
|
16
|
-
## 工作方式
|
|
17
|
-
|
|
18
|
-
- 先读再改:动文件前先看一眼现状,别凭印象改。
|
|
19
|
-
- 最小改动:只动与目标相关的地方,不顺手重构无关代码。
|
|
20
|
-
- 有证据再下结论:跑过的命令、读到的行号、真实报错,都可以引;没验证过的判断标 `[INFERENCE]`。
|
|
21
|
-
- 卡住时给下一步需要的具体信息,不要只说“无法完成”。
|
|
22
|
-
|
|
23
|
-
## 边界
|
|
24
|
-
|
|
25
|
-
技术细节、代码、配置该给就给;不确定的事实说不确定,不编造。
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
|
-
> 这份文件是模板:复制成 `<你的 id>.md` 再改,`agent-purge use <id>` 切过去。
|
|
30
|
-
> 正文之后的任何内容都会原样进入系统提示词(`install` / 裸命令接管 / `/purge` 都读它)。
|
|
1
|
+
---
|
|
2
|
+
id: neutral
|
|
3
|
+
name: 中性模板
|
|
4
|
+
description: 起步用的最小 persona:只约定语言、风格和协作方式。正文就是会被写进系统提示词的那一段,改这里就是改 agent 的行为。
|
|
5
|
+
harness: ["*"]
|
|
6
|
+
model: ["*"]
|
|
7
|
+
order: 1
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# 系统提示词 · 起步模板
|
|
11
|
+
|
|
12
|
+
## 语言与格式
|
|
13
|
+
|
|
14
|
+
简体中文;先给结论,再给依据。命令、路径、标识符保持原样不翻译。
|
|
15
|
+
|
|
16
|
+
## 工作方式
|
|
17
|
+
|
|
18
|
+
- 先读再改:动文件前先看一眼现状,别凭印象改。
|
|
19
|
+
- 最小改动:只动与目标相关的地方,不顺手重构无关代码。
|
|
20
|
+
- 有证据再下结论:跑过的命令、读到的行号、真实报错,都可以引;没验证过的判断标 `[INFERENCE]`。
|
|
21
|
+
- 卡住时给下一步需要的具体信息,不要只说“无法完成”。
|
|
22
|
+
|
|
23
|
+
## 边界
|
|
24
|
+
|
|
25
|
+
技术细节、代码、配置该给就给;不确定的事实说不确定,不编造。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
> 这份文件是模板:复制成 `<你的 id>.md` 再改,`agent-purge use <id>` 切过去。
|
|
30
|
+
> 正文之后的任何内容都会原样进入系统提示词(`install` / 裸命令接管 / `/purge` 都读它)。
|
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Release 检查与受限流式下载;只有确证的编译产品可以自替换,共享 node.exe 始终保持原样。
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, copyFileSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, readSync, writeSync, constants } from "node:fs";
|
|
4
|
+
import { basename, dirname } from "node:path";
|
|
5
|
+
import { readResource, isCompiled } from "./root.mjs";
|
|
6
|
+
import { atomicControlJSON } from "./control.mjs";
|
|
7
|
+
|
|
8
|
+
export const GITHUB_REPO = "ZiChuanLan/agent-purge";
|
|
9
|
+
export const RELEASES_API = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
|
|
10
|
+
export const RELEASES_PAGE = `https://github.com/${GITHUB_REPO}/releases/latest`;
|
|
11
|
+
export function currentVersion() {
|
|
12
|
+
try { return JSON.parse(readResource("package.json").toString("utf8")).version || "0.0.0"; }
|
|
13
|
+
catch { return "0.0.0"; }
|
|
14
|
+
}
|
|
15
|
+
// 当前 Release 仅有 Windows EXE,不能用它覆盖 Linux/macOS 的编译产物。
|
|
16
|
+
export const canReplaceSelf = () => process.platform === 'win32' && isCompiled() && !/^(?:node|bun)(?:\.exe)?$/i.test(basename(process.execPath));
|
|
17
|
+
|
|
18
|
+
export function compareSemver(a, b) {
|
|
19
|
+
const parse = value => String(value).replace(/^v/, "").split("+")[0].split(/-(.*)/s);
|
|
20
|
+
const [am, ap] = parse(a), [bm, bp] = parse(b);
|
|
21
|
+
const aa = am.split(".").map(Number), bb = bm.split(".").map(Number);
|
|
22
|
+
for (let i = 0; i < 3; i++) { const delta = (bb[i] || 0) - (aa[i] || 0); if (delta) return delta; }
|
|
23
|
+
if (ap === bp) return 0;
|
|
24
|
+
if (ap === undefined) return -1;
|
|
25
|
+
if (bp === undefined) return 1;
|
|
26
|
+
const ax = ap.split("."), bx = bp.split(".");
|
|
27
|
+
for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
|
|
28
|
+
if (ax[i] === bx[i]) continue;
|
|
29
|
+
if (ax[i] === undefined) return 1;
|
|
30
|
+
if (bx[i] === undefined) return -1;
|
|
31
|
+
const an = /^\d+$/.test(ax[i]), bn = /^\d+$/.test(bx[i]);
|
|
32
|
+
if (an && bn) return Number(bx[i]) - Number(ax[i]);
|
|
33
|
+
if (an !== bn) return an ? 1 : -1;
|
|
34
|
+
return bx[i] > ax[i] ? 1 : -1;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
export async function checkForUpdate({ timeout = 5000, cacheFile, force = false } = {}) {
|
|
39
|
+
const current = currentVersion();
|
|
40
|
+
if (!force && cacheFile) {
|
|
41
|
+
try {
|
|
42
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
43
|
+
if (cached.ok === true && typeof cached.latest === "string" && Date.now() - cached.at >= 0 && Date.now() - cached.at < 86400000)
|
|
44
|
+
return { ...cached, current, hasUpdate: compareSemver(current, cached.latest) > 0, fromCache: true };
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), timeout);
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(RELEASES_API, { signal: controller.signal, headers: { Accept: "application/vnd.github.v3+json", "User-Agent": `agent-purge/${current}` } });
|
|
50
|
+
if (response.status === 404) return { current, ok: true, published: false, latest: current, hasUpdate: false, body: "尚无公开 Release。", htmlUrl: RELEASES_PAGE, assets: [], at: Date.now() };
|
|
51
|
+
if (!response.ok) throw new Error(`GitHub API 响应 ${response.status}: ${response.statusText}`);
|
|
52
|
+
const data = await abortable(response.json(), controller.signal);
|
|
53
|
+
const latest = String(data.tag_name ?? "").replace(/^v/, "");
|
|
54
|
+
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(latest)) throw new Error("Release 版本字段无效");
|
|
55
|
+
const assets = (Array.isArray(data.assets) ? data.assets : []).filter(a => typeof a.name === "string" && /^https:\/\//.test(a.browser_download_url ?? ""))
|
|
56
|
+
.map(a => ({ name: a.name, size: a.size, downloadUrl: a.browser_download_url, digest: a.digest ?? null }));
|
|
57
|
+
const result = { ok: true, current, latest, hasUpdate: compareSemver(current, latest) > 0, tagName: data.tag_name,
|
|
58
|
+
name: data.name || data.tag_name, body: String(data.body ?? ""), publishedAt: data.published_at, htmlUrl: data.html_url || RELEASES_PAGE, assets, at: Date.now() };
|
|
59
|
+
if (cacheFile) { try { mkdirSync(dirname(cacheFile), { recursive: true }); atomicControlJSON(cacheFile, result); } catch {} }
|
|
60
|
+
return result;
|
|
61
|
+
} catch (error) { return { ok: false, current, latest: current, hasUpdate: false, error: controller.signal.aborted ? "请求超时" : error.message }; }
|
|
62
|
+
finally { clearTimeout(timer); }
|
|
63
|
+
}
|
|
64
|
+
function abortable(promise, signal) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const abort = () => reject(new DOMException("请求超时", "AbortError"));
|
|
67
|
+
if (signal.aborted) { abort(); return; }
|
|
68
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
69
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 下载到同目录临时文件,长度/摘要通过后才发布;中断时保留原 destPath。 */
|
|
74
|
+
export async function downloadFile(url, destPath, onProgress, { timeout = 60000, maxBytes = 512 * 1024 * 1024, expectedSize, digest } = {}) {
|
|
75
|
+
if (!/^https?:\/\//i.test(url)) throw new Error("下载 URL 必须是 HTTP(S)");
|
|
76
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || (expectedSize !== undefined && (!Number.isSafeInteger(expectedSize) || expectedSize < 1))) throw new Error("下载长度参数无效");
|
|
77
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), timeout);
|
|
78
|
+
const temporary = `${destPath}.part-${randomUUID()}`;
|
|
79
|
+
let fd, reader;
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetch(url, { signal: controller.signal, redirect: "follow", headers: { "User-Agent": `agent-purge/${currentVersion()}` } });
|
|
82
|
+
if (!response.ok || !response.body) throw new Error(`下载失败 HTTP ${response.status}`);
|
|
83
|
+
const total = Number(response.headers.get("content-length") || 0);
|
|
84
|
+
if (!Number.isSafeInteger(total) || total < 0 || total > maxBytes || (expectedSize !== undefined && expectedSize > maxBytes)) throw new Error("下载长度超出限制");
|
|
85
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
86
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
87
|
+
reader = response.body.getReader();
|
|
88
|
+
let received = 0;
|
|
89
|
+
const hash = createHash("sha256");
|
|
90
|
+
while (true) {
|
|
91
|
+
const { done, value } = await abortable(reader.read(), controller.signal);
|
|
92
|
+
if (done) break;
|
|
93
|
+
received += value.byteLength;
|
|
94
|
+
if (received > maxBytes) throw new Error("下载长度超出限制");
|
|
95
|
+
hash.update(value);
|
|
96
|
+
let offset = 0;
|
|
97
|
+
while (offset < value.byteLength) offset += writeSync(fd, value, offset, value.byteLength - offset);
|
|
98
|
+
onProgress?.(received, total);
|
|
99
|
+
}
|
|
100
|
+
if (!received || (total && received !== total) || (expectedSize !== undefined && received !== expectedSize)) throw new Error("下载不完整:长度不符");
|
|
101
|
+
const sha256 = hash.digest("hex");
|
|
102
|
+
if (digest && digest !== `sha256:${sha256}`) throw new Error("下载摘要不符");
|
|
103
|
+
fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
104
|
+
renameSync(temporary, destPath);
|
|
105
|
+
return { path: destPath, size: received, sha256 };
|
|
106
|
+
} finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
if (fd !== undefined) closeSync(fd);
|
|
109
|
+
controller.abort();
|
|
110
|
+
if (reader) reader.cancel().catch(() => {});
|
|
111
|
+
rmSync(temporary, { force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 独立文件事务供夹具测试;先 copy 到目标同卷,旧二进制保留为可回滚副本。 */
|
|
116
|
+
export function replaceExecutable(currentExe, downloadedNewExePath) {
|
|
117
|
+
if (/^(?:node|bun)(?:\.exe)?$/i.test(basename(currentExe))) throw new Error("共享运行时保持原样,请使用包管理器更新");
|
|
118
|
+
const fd = openSync(downloadedNewExePath, "r"), header = Buffer.alloc(2);
|
|
119
|
+
try {
|
|
120
|
+
if (statSync(downloadedNewExePath).size < 64 || readSync(fd, header, 0, 2, 0) !== 2 || header.toString() !== "MZ") throw new Error("更新文件不是 Windows 可执行文件");
|
|
121
|
+
} finally { closeSync(fd); }
|
|
122
|
+
const staged = `${currentExe}.new-${randomUUID()}`, oldExePath = `${currentExe}.old-${randomUUID()}`;
|
|
123
|
+
copyFileSync(downloadedNewExePath, staged, constants.COPYFILE_EXCL);
|
|
124
|
+
let moved = false;
|
|
125
|
+
try {
|
|
126
|
+
renameSync(currentExe, oldExePath); moved = true;
|
|
127
|
+
renameSync(staged, currentExe);
|
|
128
|
+
return { ok: true, currentExe, oldExePath };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (moved) { try { renameSync(oldExePath, currentExe); } catch (rollback) { throw new Error(`更新失败:${error.message};还原失败:${rollback.message};旧版保留于 ${oldExePath}`, { cause: error }); } }
|
|
131
|
+
throw error;
|
|
132
|
+
} finally { rmSync(staged, { force: true }); }
|
|
133
|
+
}
|
|
134
|
+
export function replaceSelfExecutable(downloadedNewExePath) {
|
|
135
|
+
if (!canReplaceSelf()) throw new Error(`当前为 Node/脚本安装,请使用 npm 或安装器更新:${process.execPath}`);
|
|
136
|
+
return replaceExecutable(process.execPath, downloadedNewExePath);
|
|
137
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 读回「实测证据」:谁真的被证明把人设发进了特权角色层。
|
|
2
|
+
// 只认本机抓包写下的 verification/latest.json,不认 matrix.json 的常量表,也不认配置写入成功。
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { verificationEvidence } from "./evidence.mjs";
|
|
6
|
+
export { sysHit } from "./evidence.mjs";
|
|
7
|
+
|
|
8
|
+
export function verificationFile(root) {
|
|
9
|
+
return join(root, "verification", "latest.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** harness 名 → true(有实测且命中 sys 层)。没有报告 = 空集合,调用方按「未实测」处理。 */
|
|
13
|
+
export function verifiedHarnesses(root) {
|
|
14
|
+
const ok = new Set();
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = JSON.parse(readFileSync(verificationFile(root), "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return ok;
|
|
20
|
+
}
|
|
21
|
+
for (const r of Array.isArray(raw?.results) ? raw.results : []) if (verificationEvidence(r).state === "verified") ok.add(r.harness);
|
|
22
|
+
return ok;
|
|
23
|
+
}
|
package/lib/window.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 桌面原生独立窗口启动器:优先使用 Edge/Chrome 的 App 模式(无地址栏、无标签页的原生软件窗口体验)
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const BROWSER_PATHS = [
|
|
6
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
7
|
+
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
8
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
9
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 寻找本机安装的 Chromium 内核浏览器(Edge / Chrome)
|
|
14
|
+
*/
|
|
15
|
+
export function findChromiumAppBrowser() {
|
|
16
|
+
if (process.platform !== "win32") return null;
|
|
17
|
+
for (const p of BROWSER_PATHS) {
|
|
18
|
+
if (existsSync(p)) return p;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 以原生应用窗口(独立窗口、无地址栏、无标签页)打开指定 URL
|
|
25
|
+
* @param {string} url
|
|
26
|
+
* @param {{ width?: number, height?: number }} [opts]
|
|
27
|
+
*/
|
|
28
|
+
export function openAppWindow(url, { width = 1260, height = 860, spawnProcess = spawn,
|
|
29
|
+
onError = error => console.error(`[web] 无法自动打开浏览器:${error.message};服务仍在运行,请手动访问 ${url}`) } = {}) {
|
|
30
|
+
const launch = (command, args) => {
|
|
31
|
+
try {
|
|
32
|
+
const child = spawnProcess(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
33
|
+
child.once("error", onError);
|
|
34
|
+
child.unref();
|
|
35
|
+
return child;
|
|
36
|
+
} catch (error) { onError(error); return null; }
|
|
37
|
+
};
|
|
38
|
+
const browser = findChromiumAppBrowser();
|
|
39
|
+
if (browser) {
|
|
40
|
+
// --app 模式:启动独立应用窗口,体验与原生桌面客户端完全一致
|
|
41
|
+
const child = launch(
|
|
42
|
+
browser,
|
|
43
|
+
[`--app=${url}`, `--window-size=${width},${height}`],
|
|
44
|
+
);
|
|
45
|
+
return { mode: child ? "app" : "manual", browser, pid: child?.pid };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 兜底回退:系统默认浏览器打开
|
|
49
|
+
let child;
|
|
50
|
+
if (process.platform === "win32") {
|
|
51
|
+
child = launch("cmd.exe", ["/d", "/c", "start", "", url]);
|
|
52
|
+
} else if (process.platform === "darwin") {
|
|
53
|
+
child = launch("open", [url]);
|
|
54
|
+
} else {
|
|
55
|
+
child = launch("xdg-open", [url]);
|
|
56
|
+
}
|
|
57
|
+
return { mode: child ? "browser" : "manual", browser: "default", pid: child?.pid };
|
|
58
|
+
}
|
package/lib/wrappers.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 已核对的完整模板;只归一换行,未知 wrapper 不授予临时宿主身份。
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
export const MANAGED_PI_BATCH = "@ECHO off\nSETLOCAL\nSET \"node=node\"\n\"%node%\" \"%~dp0pi-launcher.js\" %*\nSET \"pi_exit_code=%ERRORLEVEL%\"\nIF \"%pi_exit_code%\"==\"9009\" (\n IF \"%node%\"==\"node\" (\n \"%SystemRoot%\\System32\\where.exe\" /Q node\n IF ERRORLEVEL 1 GOTO node_missing\n ) ELSE IF NOT EXIST \"%node%\" GOTO node_missing\n)\nEXIT /B %pi_exit_code%\n:node_missing\nECHO Pi could not find Node.js. Install Node.js 22.19.0 or newer, or rerun the installer at https://pi.dev. 1>&2\nEXIT /B %pi_exit_code%\r\n";
|
|
5
|
+
export const MANAGED_PI_JS = "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst { spawnSync } = require(\"node:child_process\");\n\nconst packageName = \"@earendil-works/pi-coding-agent\";\nconst commandName = \"pi\";\nconst agentDir = path.dirname(__dirname);\nconst installRoot = path.join(agentDir, \"install\");\nconst currentFile = path.join(installRoot, \"current-version\");\nconst currentVersion = fs.readFileSync(currentFile, \"utf8\").trim();\nif (!currentVersion || currentVersion === \".\" || currentVersion === \"..\" || !/^[0-9A-Za-z._+-]+$/.test(currentVersion)) {\n\tthrow new Error(`Managed Pi version file is invalid: ${currentFile}`);\n}\n\nconst releaseDir = path.join(installRoot, \"releases\", currentVersion);\nconst packageDir = path.join(releaseDir, \"node_modules\", ...packageName.split(\"/\"));\nconst packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, \"package.json\"), \"utf8\"));\nconst binPath = typeof packageJson.bin === \"string\" ? packageJson.bin : packageJson.bin?.[commandName];\nif (!binPath) {\n\tthrow new Error(`${packageName} does not declare a ${commandName} bin`);\n}\nconst cliPath = path.resolve(packageDir, binPath);\nconst cliRelative = path.relative(packageDir, cliPath);\nif (cliRelative.startsWith(\"..\") || path.isAbsolute(cliRelative) || !fs.statSync(cliPath).isFile()) {\n\tthrow new Error(`Managed Pi executable is invalid: ${cliPath}`);\n}\n\nconst result = spawnSync(process.execPath, [cliPath, ...process.argv.slice(2)], {\n\tstdio: \"inherit\",\n\tenv: { ...process.env, PI_MANAGED_INSTALL_ROOT: installRoot },\n});\nif (result.error) throw result.error;\nprocess.exit(result.status ?? 1);\r\n";
|
|
6
|
+
export const MANAGED_PI_POWERSHELL = `#!/usr/bin/env pwsh
|
|
7
|
+
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
8
|
+
$launcher=Join-Path $basedir "pi-launcher.js"
|
|
9
|
+
$node="node"
|
|
10
|
+
try {
|
|
11
|
+
if ($MyInvocation.ExpectingInput) {
|
|
12
|
+
$input | & $node $launcher $args
|
|
13
|
+
} else {
|
|
14
|
+
& $node $launcher $args
|
|
15
|
+
}
|
|
16
|
+
} catch [System.Management.Automation.CommandNotFoundException] {
|
|
17
|
+
[Console]::Error.WriteLine("Pi could not find Node.js. Install Node.js 22.19.0 or newer, or rerun the installer at https://pi.dev.")
|
|
18
|
+
exit 127
|
|
19
|
+
}
|
|
20
|
+
exit $LASTEXITCODE
|
|
21
|
+
`;
|
|
22
|
+
const normalize = source => source.replace(/\r\n?/g, "\n").replace(/\n$/, "");
|
|
23
|
+
const same = (source, expected) => normalize(source) === normalize(expected);
|
|
24
|
+
export function knownWrapper(file, env, findNode) {
|
|
25
|
+
let source;
|
|
26
|
+
try { source = readFileSync(file, "utf8"); } catch { return null; }
|
|
27
|
+
const dir = dirname(file);
|
|
28
|
+
if ((basename(file).toLowerCase() === "pi.cmd" && same(source, MANAGED_PI_BATCH)) || (basename(file).toLowerCase() === "pi.ps1" && same(source, MANAGED_PI_POWERSHELL))) {
|
|
29
|
+
try {
|
|
30
|
+
if (!same(readFileSync(join(dir, "pi-launcher.js"), "utf8"), MANAGED_PI_JS)) return null;
|
|
31
|
+
const installRoot = join(dirname(dir), "install"), version = readFileSync(join(installRoot, "current-version"), "utf8").trim();
|
|
32
|
+
if (!/^[0-9A-Za-z._+-]+$/.test(version) || [".", ".."].includes(version)) return null;
|
|
33
|
+
const pkg = realpathSync(join(installRoot, "releases", version, "node_modules", "@earendil-works", "pi-coding-agent"));
|
|
34
|
+
const manifest = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8"));
|
|
35
|
+
const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
|
|
36
|
+
if (manifest.name !== "@earendil-works/pi-coding-agent" || typeof bin !== "string") return null;
|
|
37
|
+
const entry = realpathSync(resolve(pkg, bin)), inside = relative(pkg, entry);
|
|
38
|
+
if (inside.startsWith("..") || isAbsolute(inside) || !statSync(entry).isFile()) return null;
|
|
39
|
+
const node = findNode(env); if (!node) return null;
|
|
40
|
+
return { cmd: node, args: [entry], env: { PI_MANAGED_INSTALL_ROOT: installRoot }, hostDirect: true };
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
const v1 = join(dir, "node_modules", "opencode-ai", "bin", "opencode.exe");
|
|
44
|
+
const template = '@ECHO off\nGOTO start\n:find_dp0\nSET dp0=%~dp0\nEXIT /b\n:start\nSETLOCAL\nCALL :find_dp0\n"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*';
|
|
45
|
+
if (same(source, template) && existsSync(v1)) return { cmd: v1, args: [], hostDirect: true };
|
|
46
|
+
const v2 = join(dir, "node_modules", "@opencode", "cli", "bin", "opencode.exe");
|
|
47
|
+
if (same(source, '@echo off\n"' + v2 + '" %*') && existsSync(v2)) return { cmd: v2, args: [], hostDirect: true };
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# magpie 网关插件
|
|
2
|
+
|
|
3
|
+
可选集成:改写经过 magpie 网关的请求提示词,读取与 CLI 相同的人设和模式。
|
|
4
|
+
|
|
5
|
+
**插件入口是本目录,不是仓库根目录。** 根 `package.json` 是 CLI;插件不在 agent-purge 的 npm 包里。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/ZiChuanLan/agent-purge
|
|
11
|
+
agent-purge ls
|
|
12
|
+
node /path/to/agent-purge/magpie-plugin/gen-personas.mjs
|
|
13
|
+
magpie plugin add /path/to/agent-purge/magpie-plugin
|
|
14
|
+
magpie plugin list
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
先运行一次 agent-purge 以初始化人设。增删人设后重新运行 `gen-personas.mjs`;生成的 `page/personas.js` 只属于本地,不提交。
|
|
18
|
+
|
|
19
|
+
插件读取 `~/.agent-purge/`,也支持 `AGENT_PURGE_HOME`。切换 CLI 人设后,下一个经过网关的请求读取新设置。插件页设置会写入 magpie options。
|
|
20
|
+
|
|
21
|
+
## 配置
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
magpie plugin options magpie-plugin '{"mode":"append"}'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
上述插件名按安装目录名 `magpie-plugin` 使用。
|
|
28
|
+
|
|
29
|
+
| 字段 | 用途 |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `mode` | `off` / `strict` / `replace` / `append` |
|
|
32
|
+
| `persona` | 人设标识 |
|
|
33
|
+
| `agents` | 限定执行器,例如 `["claude","codex"]` |
|
|
34
|
+
|
|
35
|
+
`off` 不改请求;`strict` 替换可控 system 文本;`replace` 近似删除身份行后放入人设;`append` 将人设置于宿主文本**之前**,与 CLI 的后置位置不同。
|
|
36
|
+
|
|
37
|
+
## 边界与测试
|
|
38
|
+
|
|
39
|
+
- 只处理经过网关的请求,不能修改客户端已经固定的行为或未经过网关的流量。
|
|
40
|
+
- strict 会移除原 system 中的工具说明文本;不等于清空独立的结构化工具定义。
|
|
41
|
+
- 插件页不能直接读取本地文件,人设列表由生成脚本提供。
|
|
42
|
+
- `npm test` 包含钩子与页面逻辑回归,不依赖 magpie;这些测试不等于所有 magpie 版本的真实安装验收。
|
|
43
|
+
|
|
44
|
+
[主项目](../README.md) · [使用指南](../docs/usage.md)
|