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.
- package/CONTRIBUTING.md +64 -0
- package/LICENSE +21 -21
- package/README.md +169 -138
- 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 +956 -780
- 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/persona-import.mjs +351 -0
- 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 +590 -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 +330 -191
- package/web/styles.css +1072 -0
- package/web/ui.html +76 -1163
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// 从外部来源(GitHub 仓库或本地目录)拉取/导入人设并存入本地人设库
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
4
|
+
import { createStore, isReservedId } from "./personas.mjs";
|
|
5
|
+
|
|
6
|
+
const IGNORED_NAMES = new Set([
|
|
7
|
+
"readme.md",
|
|
8
|
+
"readme_zh.md",
|
|
9
|
+
"readme.zh.md",
|
|
10
|
+
"readme.zh-cn.md",
|
|
11
|
+
"license.md",
|
|
12
|
+
"licence.md",
|
|
13
|
+
"changelog.md",
|
|
14
|
+
"contributing.md",
|
|
15
|
+
"security.md",
|
|
16
|
+
"active.md",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** 解析源字符串:判断是本地目录还是 GitHub 仓库 */
|
|
20
|
+
export function parseSource(source) {
|
|
21
|
+
const raw = String(source ?? "").trim();
|
|
22
|
+
if (!raw) throw new Error("导入源不能为空");
|
|
23
|
+
|
|
24
|
+
// 1. 本地目录判断:绝对路径、相对路径前缀、或本地已存在该目录
|
|
25
|
+
if (
|
|
26
|
+
raw.startsWith("file://") ||
|
|
27
|
+
raw.startsWith(".") ||
|
|
28
|
+
raw.startsWith("/") ||
|
|
29
|
+
/^[A-Za-z]:[\\/]/.test(raw) ||
|
|
30
|
+
existsSync(raw)
|
|
31
|
+
) {
|
|
32
|
+
let cleanPath = raw;
|
|
33
|
+
if (raw.startsWith("file://")) {
|
|
34
|
+
try {
|
|
35
|
+
cleanPath = new URL(raw).pathname;
|
|
36
|
+
} catch {
|
|
37
|
+
cleanPath = raw.slice(7);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const localDir = resolve(process.cwd(), cleanPath);
|
|
41
|
+
if (!existsSync(localDir) || !statSync(localDir).isDirectory()) {
|
|
42
|
+
throw new Error(`本地目录不存在: ${localDir}`);
|
|
43
|
+
}
|
|
44
|
+
return { kind: "local", path: localDir };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. GitHub URL 或 owner/repo 格式
|
|
48
|
+
let owner = "";
|
|
49
|
+
let repo = "";
|
|
50
|
+
let ref = "main";
|
|
51
|
+
let subpath = "";
|
|
52
|
+
|
|
53
|
+
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
54
|
+
try {
|
|
55
|
+
const u = new URL(raw);
|
|
56
|
+
if (!u.hostname.includes("github.com")) {
|
|
57
|
+
throw new Error("目前外部拉取仅支持 GitHub 仓库或本地目录");
|
|
58
|
+
}
|
|
59
|
+
const parts = u.pathname.replace(/^\/+|\/+$/g, "").split("/");
|
|
60
|
+
if (parts.length < 2) throw new Error("无效的 GitHub 仓库 URL");
|
|
61
|
+
owner = parts[0];
|
|
62
|
+
repo = parts[1].replace(/\.git$/i, "");
|
|
63
|
+
if (parts[2] === "tree" && parts[3]) {
|
|
64
|
+
ref = parts[3];
|
|
65
|
+
subpath = parts.slice(4).join("/");
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
throw new Error(`无法解析 GitHub URL: ${e.message}`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// 可能是 "owner/repo" 或 "owner/repo@ref" 或 "owner/repo/subpath"
|
|
72
|
+
const clean = raw.replace(/\.git$/i, "");
|
|
73
|
+
const [repoPart, refPart] = clean.split("@");
|
|
74
|
+
if (refPart) ref = refPart;
|
|
75
|
+
|
|
76
|
+
const parts = repoPart.split("/");
|
|
77
|
+
if (parts.length >= 2) {
|
|
78
|
+
owner = parts[0];
|
|
79
|
+
repo = parts[1];
|
|
80
|
+
if (parts.length > 2) subpath = parts.slice(2).join("/");
|
|
81
|
+
} else {
|
|
82
|
+
throw new Error(`无法识别导入源: ${raw}(请指定 owner/repo 或本地目录路径)`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { kind: "github", owner, repo, ref, subpath };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 规范化从文件名或 frontmatter 得出的 persona id */
|
|
90
|
+
export function sanitizeId(rawId, fallbackName = "persona") {
|
|
91
|
+
let s = String(rawId || "").trim().toLowerCase();
|
|
92
|
+
// 移除非法字符,替换为空格或连字符
|
|
93
|
+
s = s.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
94
|
+
if (!s || isReservedId(s)) {
|
|
95
|
+
let fb = String(fallbackName || "persona").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
96
|
+
s = fb && !isReservedId(fb) ? fb : `p-${Date.now().toString(36)}`;
|
|
97
|
+
}
|
|
98
|
+
return s.slice(0, 64);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 解析 markdown 中的 frontmatter 与提示词正文 */
|
|
102
|
+
export function parsePersonaDoc(raw, fallbackId = "") {
|
|
103
|
+
let meta = {};
|
|
104
|
+
let body = raw;
|
|
105
|
+
|
|
106
|
+
if (raw.startsWith("---")) {
|
|
107
|
+
const end = raw.indexOf("\n---", 3);
|
|
108
|
+
if (end !== -1) {
|
|
109
|
+
const head = raw.slice(3, end).trim();
|
|
110
|
+
body = raw.slice(end + 4).replace(/^\r?\n/, "");
|
|
111
|
+
meta = parseFrontmatter(head);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const id = sanitizeId(meta.id || fallbackId, fallbackId);
|
|
116
|
+
const name = meta.name || meta.title || fallbackId || id;
|
|
117
|
+
const description = meta.description || meta.desc || "";
|
|
118
|
+
const harness = Array.isArray(meta.harness) ? meta.harness : ["*"];
|
|
119
|
+
const model = Array.isArray(meta.model) ? meta.model : ["*"];
|
|
120
|
+
const order = typeof meta.order === "number" ? meta.order : 50;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
meta: {
|
|
125
|
+
...meta,
|
|
126
|
+
id,
|
|
127
|
+
name,
|
|
128
|
+
description,
|
|
129
|
+
harness,
|
|
130
|
+
model,
|
|
131
|
+
order,
|
|
132
|
+
},
|
|
133
|
+
body: body.trim(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseFrontmatter(head) {
|
|
138
|
+
const meta = {};
|
|
139
|
+
let key = null;
|
|
140
|
+
for (const line of head.split(/\r?\n/)) {
|
|
141
|
+
if (!line.trim()) continue;
|
|
142
|
+
const arr = line.match(/^\s*-\s+(.*)$/);
|
|
143
|
+
if (arr && key) {
|
|
144
|
+
if (!Array.isArray(meta[key])) meta[key] = [];
|
|
145
|
+
meta[key].push(unquote(arr[1]));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
149
|
+
if (!kv) continue;
|
|
150
|
+
key = kv[1];
|
|
151
|
+
const v = kv[2].trim();
|
|
152
|
+
if (!v) {
|
|
153
|
+
meta[key] = [];
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (v.startsWith("[")) {
|
|
157
|
+
meta[key] = v
|
|
158
|
+
.replace(/^\[|\]$/g, "")
|
|
159
|
+
.split(",")
|
|
160
|
+
.map((s) => unquote(s.trim()))
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
} else {
|
|
163
|
+
meta[key] = coerce(v);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return meta;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const unquote = (s) => s.replace(/^["']|["']$/g, "");
|
|
170
|
+
const coerce = (v) => {
|
|
171
|
+
if (v === "true") return true;
|
|
172
|
+
if (v === "false") return false;
|
|
173
|
+
const n = Number(v);
|
|
174
|
+
return Number.isFinite(n) && !Number.isNaN(n) && String(n) === v ? n : unquote(v);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** 从本地目录导入人设 */
|
|
178
|
+
export function importFromDirectory(dirPath, root, { overwrite = true } = {}) {
|
|
179
|
+
const resolvedDir = resolve(dirPath);
|
|
180
|
+
if (!existsSync(resolvedDir)) throw new Error(`目录不存在: ${resolvedDir}`);
|
|
181
|
+
|
|
182
|
+
const entries = readdirSync(resolvedDir, { withFileTypes: true });
|
|
183
|
+
const store = createStore(root);
|
|
184
|
+
const imported = [];
|
|
185
|
+
const skipped = [];
|
|
186
|
+
const errors = [];
|
|
187
|
+
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
if (entry.isDirectory()) continue;
|
|
190
|
+
const name = entry.name;
|
|
191
|
+
const lower = name.toLowerCase();
|
|
192
|
+
if (!lower.endsWith(".md") && !lower.endsWith(".markdown")) continue;
|
|
193
|
+
if (IGNORED_NAMES.has(lower) || lower.startsWith(".")) {
|
|
194
|
+
skipped.push({ file: name, reason: "忽略的文件名(文档或隐藏文件)" });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const filePath = join(resolvedDir, name);
|
|
200
|
+
const raw = readFileSync(filePath, "utf8");
|
|
201
|
+
const fallbackId = basename(name, extname(name));
|
|
202
|
+
const parsed = parsePersonaDoc(raw, fallbackId);
|
|
203
|
+
|
|
204
|
+
if (!parsed.body) {
|
|
205
|
+
skipped.push({ file: name, reason: "提示词正文为空" });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const existing = store.read(parsed.id);
|
|
210
|
+
if (existing && !overwrite) {
|
|
211
|
+
skipped.push({ id: parsed.id, name: parsed.meta.name, file: name, reason: "已存在且未开启覆盖" });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const saved = store.save(parsed.id, { meta: parsed.meta, body: parsed.body });
|
|
216
|
+
imported.push({
|
|
217
|
+
id: saved.id,
|
|
218
|
+
name: saved.meta.name,
|
|
219
|
+
chars: saved.body.length,
|
|
220
|
+
action: existing ? "updated" : "created",
|
|
221
|
+
});
|
|
222
|
+
} catch (e) {
|
|
223
|
+
errors.push({ file: name, error: e.message });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
success: errors.length === 0,
|
|
229
|
+
source: { kind: "local", path: resolvedDir },
|
|
230
|
+
imported,
|
|
231
|
+
skipped,
|
|
232
|
+
errors,
|
|
233
|
+
total: imported.length,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 从 GitHub 仓库拉取人设 */
|
|
238
|
+
export async function pullFromGitHub(
|
|
239
|
+
{ owner, repo, ref = "main", subpath = "" },
|
|
240
|
+
root,
|
|
241
|
+
{ overwrite = true, token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN } = {}
|
|
242
|
+
) {
|
|
243
|
+
const store = createStore(root);
|
|
244
|
+
const headers = {
|
|
245
|
+
"User-Agent": "agent-purge-client",
|
|
246
|
+
Accept: "application/vnd.github.v3+json",
|
|
247
|
+
};
|
|
248
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
249
|
+
|
|
250
|
+
const apiPath = subpath ? `contents/${encodeURIComponent(subpath).replace(/%2F/g, "/")}` : "contents";
|
|
251
|
+
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${apiPath}?ref=${encodeURIComponent(ref)}`;
|
|
252
|
+
|
|
253
|
+
let res;
|
|
254
|
+
try {
|
|
255
|
+
res = await fetch(url, { headers });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
throw new Error(`连接 GitHub 失败: ${e.message}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
const text = await res.text();
|
|
262
|
+
throw new Error(`GitHub API 响应 ${res.status}: ${text.slice(0, 200)}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const list = await res.json();
|
|
266
|
+
if (!Array.isArray(list)) {
|
|
267
|
+
throw new Error("GitHub 仓库指定路径不是文件列表");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const imported = [];
|
|
271
|
+
const skipped = [];
|
|
272
|
+
const errors = [];
|
|
273
|
+
|
|
274
|
+
for (const item of list) {
|
|
275
|
+
if (item.type !== "file") continue;
|
|
276
|
+
const name = item.name;
|
|
277
|
+
const lower = name.toLowerCase();
|
|
278
|
+
if (!lower.endsWith(".md") && !lower.endsWith(".markdown")) continue;
|
|
279
|
+
if (IGNORED_NAMES.has(lower) || lower.startsWith(".")) {
|
|
280
|
+
skipped.push({ file: name, reason: "忽略的文件名(文档或隐藏文件)" });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
let content = "";
|
|
286
|
+
// 优先使用 download_url(raw),失败时回退到 git_url 获取 base64 blob
|
|
287
|
+
if (item.download_url) {
|
|
288
|
+
try {
|
|
289
|
+
const r = await fetch(item.download_url, { headers: { "User-Agent": "agent-purge-client" } });
|
|
290
|
+
if (r.ok) content = await r.text();
|
|
291
|
+
} catch {
|
|
292
|
+
// 忽略 raw 失败,走 blob 降级
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!content && item.git_url) {
|
|
297
|
+
const blobRes = await fetch(item.git_url, { headers });
|
|
298
|
+
if (blobRes.ok) {
|
|
299
|
+
const blobData = await blobRes.json();
|
|
300
|
+
if (blobData.content && blobData.encoding === "base64") {
|
|
301
|
+
content = Buffer.from(blobData.content, "base64").toString("utf8");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!content) throw new Error("无法读取文件内容");
|
|
307
|
+
|
|
308
|
+
const fallbackId = basename(name, extname(name));
|
|
309
|
+
const parsed = parsePersonaDoc(content, fallbackId);
|
|
310
|
+
|
|
311
|
+
if (!parsed.body) {
|
|
312
|
+
skipped.push({ file: name, reason: "提示词正文为空" });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const existing = store.read(parsed.id);
|
|
317
|
+
if (existing && !overwrite) {
|
|
318
|
+
skipped.push({ id: parsed.id, name: parsed.meta.name, file: name, reason: "已存在且未开启覆盖" });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const saved = store.save(parsed.id, { meta: parsed.meta, body: parsed.body });
|
|
323
|
+
imported.push({
|
|
324
|
+
id: saved.id,
|
|
325
|
+
name: saved.meta.name,
|
|
326
|
+
chars: saved.body.length,
|
|
327
|
+
action: existing ? "updated" : "created",
|
|
328
|
+
});
|
|
329
|
+
} catch (e) {
|
|
330
|
+
errors.push({ file: name, error: e.message });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
success: errors.length === 0,
|
|
336
|
+
source: { kind: "github", owner, repo, ref, subpath },
|
|
337
|
+
imported,
|
|
338
|
+
skipped,
|
|
339
|
+
errors,
|
|
340
|
+
total: imported.length,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 统一入口:根据来源自动分流导入 */
|
|
345
|
+
export async function importPersonas(sourceStr, root, options = {}) {
|
|
346
|
+
const parsed = parseSource(sourceStr);
|
|
347
|
+
if (parsed.kind === "local") {
|
|
348
|
+
return importFromDirectory(parsed.path, root, options);
|
|
349
|
+
}
|
|
350
|
+
return pullFromGitHub(parsed, root, options);
|
|
351
|
+
}
|
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
|
+
}
|