agent-purge 0.2.1 → 0.2.2

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/lib/personas.mjs CHANGED
@@ -1,180 +1,207 @@
1
- // persona 管理:一个 markdown 文件 = 元数据(frontmatter) + 系统提示词正文。
2
- // 正文是唯一喂给 harness 的东西;元数据只用来做选择。
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { loadState, saveState } from "./state.mjs";
7
-
8
- const WILD = "*";
9
- // active.md 是派生物,不是状态:install 指向它、opencode 插件运行时读它 → 真热切。
10
- // 真正的指针在 state.json 的 persona 字段(旧布局的 personas/.active 在 loadState 里已迁移)。
11
- const ACTIVE_BODY = "active.md";
12
- const RESERVED_IDS = new Set([ACTIVE_BODY.slice(0, -3), ".active"]); // "active" / ".active"
13
-
14
- export function createStore(root) {
15
- const dir = join(root, "personas");
16
- const build = join(dir, ".build");
17
- mkdirSync(build, { recursive: true });
18
-
19
- // 首次运行(或 npm 安装后的空目录):从 lib/templates/personas 拷一份中性的起步 persona。
20
- {
21
- const has = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(".md") && f !== "active.md");
22
- const tpl = join(dirname(fileURLToPath(import.meta.url)), "templates", "personas");
23
- if (!has && existsSync(tpl)) {
24
- for (const f of readdirSync(tpl)) writeFileSync(join(dir, f), readFileSync(join(tpl, f), "utf8"));
25
- }
26
- }
27
-
28
- const listFiles = () =>
29
- (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".") && f !== "active.md") : []);
30
-
31
- function read(id) {
32
- const file = join(dir, `${id}.md`);
33
- if (!existsSync(file)) return null;
34
- const raw = readFileSync(file, "utf8");
35
- const { meta, body } = parse(raw);
36
- return { id, file, meta, body, raw };
37
- }
38
-
39
- function list() {
40
- return listFiles()
41
- .map((f) => read(f.slice(0, -3)))
42
- .filter(Boolean)
43
- .sort((a, b) => (a.meta.order ?? 99) - (b.meta.order ?? 99) || a.id.localeCompare(b.id));
44
- }
45
-
46
- function save(id, { meta = {}, body }) {
47
- if (isReservedId(id)) throw new Error(`${id} 是保留名(active 指针专用),换个 id`);
48
- const file = join(dir, `${id}.md`);
49
- writeFileSync(file, serialize({ id, meta, body }));
50
- return read(id);
51
- }
52
-
53
- function remove(id) {
54
- rmSync(join(dir, `${id}.md`), { force: true });
55
- rmSync(join(build, `${id}.md`), { force: true });
56
- // 删的就是 active 时必须改道:否则下次无参启动直接 “没有 persona”。
57
- // 落点不抓 list()[0](那是顺序序,等于静默换人),优先落到 neutral。
58
- if (activeId() === id) {
59
- const all = list();
60
- const next = all.find((p) => p.id === "neutral") ?? all[0] ?? null;
61
- if (next) setActive(next.id);
62
- else writeFileSync(join(dir, ACTIVE_BODY), "You are a helpful coding assistant.\n");
63
- }
64
- }
65
-
66
- // 把正文落到 .build,给需要文件路径的 harness 用(frontmatter 绝不能进 prompt)
67
- function materialize(id) {
68
- const p = read(id);
69
- if (!p) return null;
70
- const out = join(build, `${id}.md`);
71
- writeFileSync(out, p.body.trim() + "\n");
72
- return out;
73
- }
74
-
75
- function activeId() {
76
- const { state } = loadState(root);
77
- if (state.persona && read(state.persona)) return state.persona;
78
- return list()[0]?.id ?? null;
79
- }
80
-
81
- function setActive(id) {
82
- const p = read(id);
83
- if (!p) throw new Error(`no such persona: ${id}`);
84
- // 先写正文快照,再写指针:中途被杀也只会多一份一致的快照
85
- writeFileSync(join(dir, ACTIVE_BODY), p.body.trim() + "\n");
86
- const { state } = loadState(root);
87
- saveState(root, { ...state, persona: id });
88
- }
89
-
90
- // install 前调用:保证 active.md 是最新正文,返回它的路径(落盘型后端指向它)
91
- function refreshActive() {
92
- const id = activeId();
93
- if (id) setActive(id);
94
- return join(dir, ACTIVE_BODY);
95
- }
96
-
97
- // 选中的 persona:harness+model 精确度优先,其次 active,其次第一个
98
- function resolve({ harness, model } = {}) {
99
- const all = list();
100
- const scored = all.map((p) => {
101
- const h = asArray(p.meta.harness);
102
- const m = asArray(p.meta.model);
103
- const hHit = h.includes(WILD) ? 1 : h.some((g) => globHit(g, harness));
104
- const mHit = m.includes(WILD) ? 1 : m.some((g) => globHit(g, model));
105
- const explicitH = hHit && !h.includes(WILD) ? 1 : 0;
106
- const explicitM = mHit && !m.includes(WILD) ? 1 : 0;
107
- return { p, hit: hHit && mHit, score: explicitH * 2 + explicitM * 4 };
108
- });
109
- const eligible = scored.filter((s) => s.hit).sort((a, b) => b.score - a.score || a.p.id.localeCompare(b.p.id));
110
- const active = activeId();
111
- const fallback = all.find((p) => p.id === active) ?? all[0] ?? null;
112
- return { persona: eligible[0]?.p ?? fallback, matched: Boolean(eligible[0]), score: eligible[0]?.score ?? 0 };
113
- }
114
-
115
- return { dir, build, list, read, save, remove, materialize, activeId, setActive, refreshActive, resolve };
116
- }
117
-
118
- /** 保留 id:会被当成 persona 文件名,与 active 双文件冲突 */
119
- export function isReservedId(id) {
120
- return RESERVED_IDS.has(String(id || ""));
121
- }
122
-
123
- function parse(raw) {
124
- if (!raw.startsWith("---")) return { meta: {}, body: raw };
125
- const end = raw.indexOf("\n---", 3);
126
- if (end === -1) return { meta: {}, body: raw };
127
- const head = raw.slice(3, end).trim();
128
- const body = raw.slice(end + 4).replace(/^\r?\n/, "");
129
- return { meta: parseMeta(head), body };
130
- }
131
-
132
- function parseMeta(head) {
133
- const meta = {};
134
- let key = null;
135
- for (const line of head.split(/\r?\n/)) {
136
- if (!line.trim()) continue;
137
- const arr = line.match(/^\s*-\s+(.*)$/);
138
- if (arr && key) {
139
- if (!Array.isArray(meta[key])) meta[key] = [];
140
- meta[key].push(unquote(arr[1]));
141
- continue;
142
- }
143
- const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
144
- if (!kv) continue;
145
- key = kv[1];
146
- const v = kv[2].trim();
147
- if (!v) {
148
- meta[key] = [];
149
- continue;
150
- }
151
- if (v.startsWith("[")) {
152
- meta[key] = v
153
- .replace(/^\[|\]$/g, "")
154
- .split(",")
155
- .map((s) => unquote(s.trim()))
156
- .filter(Boolean);
157
- } else meta[key] = unquote(v);
158
- }
159
- return meta;
160
- }
161
-
162
- function serialize({ id, meta, body }) {
163
- const lines = ["---", `id: ${meta.id ?? id}`];
164
- for (const [k, v] of Object.entries(meta)) {
165
- if (k === "id") continue;
166
- if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => JSON.stringify(String(x))).join(", ")}]`);
167
- else if (v !== undefined && v !== null && v !== "") lines.push(`${k}: ${JSON.stringify(String(v))}`);
168
- }
169
- lines.push("---", "", body.trim(), "");
170
- return lines.join("\n");
171
- }
172
-
173
- const unquote = (s) => s.replace(/^["']|["']$/g, "");
174
- const asArray = (v) => (Array.isArray(v) ? v : v ? [String(v)] : [WILD]);
175
-
176
- function globHit(pattern, value) {
177
- if (!value) return pattern === WILD;
178
- const re = new RegExp("^" + String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i");
179
- return re.test(value);
180
- }
1
+ // persona 管理:一个 markdown 文件 = 元数据(frontmatter) + 系统提示词正文。
2
+ // 正文是唯一喂给 harness 的东西;元数据只用来做选择。
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadState, saveState } from "./state.mjs";
7
+
8
+ const WILD = "*";
9
+ // active.md 是派生物,不是状态:install 指向它、opencode 插件运行时读它 → 真热切。
10
+ // 真正的指针在 state.json 的 persona 字段(旧布局的 personas/.active 在 loadState 里已迁移)。
11
+ const ACTIVE_BODY = "active.md";
12
+ const RESERVED_IDS = new Set([ACTIVE_BODY.slice(0, -3), ".active"]); // "active" / ".active"
13
+
14
+ export function createStore(root) {
15
+ const dir = join(root, "personas");
16
+ const build = join(dir, ".build");
17
+ mkdirSync(build, { recursive: true });
18
+
19
+ // 首次运行(或 npm 安装后的空目录):从 lib/templates/personas 拷一份中性的起步 persona。
20
+ {
21
+ const has = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(".md") && f !== "active.md");
22
+ const tpl = join(dirname(fileURLToPath(import.meta.url)), "templates", "personas");
23
+ if (!has && existsSync(tpl)) {
24
+ for (const f of readdirSync(tpl)) writeFileSync(join(dir, f), readFileSync(join(tpl, f), "utf8"));
25
+ }
26
+ }
27
+
28
+ const listFiles = () =>
29
+ (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".") && f !== "active.md") : []);
30
+
31
+ function read(id) {
32
+ const file = join(dir, `${id}.md`);
33
+ if (!existsSync(file)) return null;
34
+ const raw = readFileSync(file, "utf8");
35
+ const { meta, body } = parse(raw);
36
+ return { id, file, meta: normalizeMeta(meta), body, raw };
37
+ }
38
+
39
+ function list() {
40
+ return listFiles()
41
+ .map((f) => read(f.slice(0, -3)))
42
+ .filter(Boolean)
43
+ .sort((a, b) => (a.meta.order ?? 99) - (b.meta.order ?? 99) || a.id.localeCompare(b.id));
44
+ }
45
+
46
+ function save(id, { meta = {}, body }) {
47
+ if (isReservedId(id)) throw new Error(`${id} 是保留名(active 指针专用),换个 id`);
48
+ const file = join(dir, `${id}.md`);
49
+ writeFileSync(file, serialize({ id, meta, body }));
50
+ return read(id);
51
+ }
52
+
53
+ function remove(id) {
54
+ rmSync(join(dir, `${id}.md`), { force: true });
55
+ rmSync(join(build, `${id}.md`), { force: true });
56
+ // 删的就是 active 时必须改道:否则下次无参启动直接 “没有 persona”。
57
+ // 落点不抓 list()[0](那是顺序序,等于静默换人),优先落到 neutral。
58
+ if (activeId() === id) {
59
+ const all = list();
60
+ const next = all.find((p) => p.id === "neutral") ?? all[0] ?? null;
61
+ if (next) setActive(next.id);
62
+ else writeFileSync(join(dir, ACTIVE_BODY), "You are a helpful coding assistant.\n");
63
+ }
64
+ }
65
+
66
+ // 把正文落到 .build,给需要文件路径的 harness 用(frontmatter 绝不能进 prompt)
67
+ function materialize(id) {
68
+ const p = read(id);
69
+ if (!p) return null;
70
+ const out = join(build, `${id}.md`);
71
+ writeFileSync(out, p.body.trim() + "\n");
72
+ return out;
73
+ }
74
+
75
+ function activeId() {
76
+ const { state } = loadState(root);
77
+ if (state.persona && read(state.persona)) return state.persona;
78
+ return list()[0]?.id ?? null;
79
+ }
80
+
81
+ function setActive(id) {
82
+ const p = read(id);
83
+ if (!p) throw new Error(`no such persona: ${id}`);
84
+ // 先写正文快照,再写指针:中途被杀也只会多一份一致的快照
85
+ writeFileSync(join(dir, ACTIVE_BODY), p.body.trim() + "\n");
86
+ const { state } = loadState(root);
87
+ saveState(root, { ...state, persona: id });
88
+ }
89
+
90
+ // install 前调用:保证 active.md 是最新正文,返回它的路径(落盘型后端指向它)
91
+ function refreshActive() {
92
+ const id = activeId();
93
+ if (id) setActive(id);
94
+ return join(dir, ACTIVE_BODY);
95
+ }
96
+
97
+ // 选中的 persona:harness+model 精确度优先,其次 active,其次第一个
98
+ function resolve({ harness, model } = {}) {
99
+ const all = list();
100
+ const scored = all.map((p) => {
101
+ const h = asArray(p.meta.harness);
102
+ const m = asArray(p.meta.model);
103
+ const hHit = h.includes(WILD) ? 1 : h.some((g) => globHit(g, harness));
104
+ const mHit = m.includes(WILD) ? 1 : m.some((g) => globHit(g, model));
105
+ const explicitH = hHit && !h.includes(WILD) ? 1 : 0;
106
+ const explicitM = mHit && !m.includes(WILD) ? 1 : 0;
107
+ return { p, hit: hHit && mHit, score: explicitH * 2 + explicitM * 4 };
108
+ });
109
+ const eligible = scored.filter((s) => s.hit).sort((a, b) => b.score - a.score || a.p.id.localeCompare(b.p.id));
110
+ const active = activeId();
111
+ const fallback = all.find((p) => p.id === active) ?? all[0] ?? null;
112
+ return { persona: eligible[0]?.p ?? fallback, matched: Boolean(eligible[0]), score: eligible[0]?.score ?? 0 };
113
+ }
114
+
115
+ return { dir, build, list, read, save, remove, materialize, activeId, setActive, refreshActive, resolve };
116
+ }
117
+
118
+ /** 保留 id:会被当成 persona 文件名,与 active 双文件冲突 */
119
+ export function isReservedId(id) {
120
+ return RESERVED_IDS.has(String(id || ""));
121
+ }
122
+
123
+ function parse(raw) {
124
+ if (!raw.startsWith("---")) return { meta: {}, body: raw };
125
+ const end = raw.indexOf("\n---", 3);
126
+ if (end === -1) return { meta: {}, body: raw };
127
+ const head = raw.slice(3, end).trim();
128
+ const body = raw.slice(end + 4).replace(/^\r?\n/, "");
129
+ return { meta: parseMeta(head), body };
130
+ }
131
+
132
+ function parseMeta(head) {
133
+ const meta = {};
134
+ let key = null;
135
+ for (const line of head.split(/\r?\n/)) {
136
+ if (!line.trim()) continue;
137
+ const arr = line.match(/^\s*-\s+(.*)$/);
138
+ if (arr && key) {
139
+ if (!Array.isArray(meta[key])) meta[key] = [];
140
+ meta[key].push(unquote(arr[1]));
141
+ continue;
142
+ }
143
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
144
+ if (!kv) continue;
145
+ key = kv[1];
146
+ const v = kv[2].trim();
147
+ if (!v) {
148
+ meta[key] = [];
149
+ continue;
150
+ }
151
+ if (v.startsWith("[")) {
152
+ meta[key] = v
153
+ .replace(/^\[|\]$/g, "")
154
+ .split(",")
155
+ .map((s) => unquote(s.trim()))
156
+ .filter(Boolean);
157
+ } else meta[key] = coerce(v);
158
+ }
159
+ return meta;
160
+ }
161
+
162
+ function serialize({ id, meta, body }) {
163
+ const lines = ["---", `id: ${meta.id ?? id}`];
164
+ const seen = new Set(["id"]);
165
+ for (const [k, v] of Object.entries(meta)) {
166
+ // desc 是早期 UI 的键名,README/模板写的是 description:写回时只留 description,避免同名两行
167
+ const key = k === "desc" ? "description" : k;
168
+ if (seen.has(key)) continue;
169
+ if (Array.isArray(v)) {
170
+ seen.add(key);
171
+ lines.push(`${key}: [${v.map((x) => JSON.stringify(String(x))).join(", ")}]`);
172
+ } else if (v !== undefined && v !== null && v !== "") {
173
+ seen.add(key);
174
+ // 数字/布尔裸写(`order: 1`、`enabled: true`),字符串才引号包——否则一次保存就把类型改成字符串
175
+ lines.push(`${key}: ${typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(String(v))}`);
176
+ }
177
+ }
178
+ lines.push("---", "", body.trim(), "");
179
+ return lines.join("\n");
180
+ }
181
+
182
+ /**
183
+ * 键名归一:磁盘上有两套写法(README/模板 `description`,0.2.x UI `desc`)。
184
+ * 统一按 description 存,读的时候把 desc 当别名补上 —— CLI 列表、Web 表单都能直接读到。
185
+ */
186
+ function normalizeMeta(meta) {
187
+ if (meta.description !== undefined && meta.desc === undefined) meta.desc = meta.description;
188
+ if (meta.desc !== undefined && meta.description === undefined) meta.description = meta.desc;
189
+ return meta;
190
+ }
191
+
192
+ const unquote = (s) => s.replace(/^["']|["']$/g, "");
193
+ /** 标量取值:数字/布尔还原成真类型,其余保持字符串(只收干净的写法,`01` 这种不动) */
194
+ function coerce(v) {
195
+ const s = unquote(v);
196
+ if (s === "true") return true;
197
+ if (s === "false") return false;
198
+ if (/^-?\d+(\.\d+)?$/.test(s) && String(Number(s)) === s) return Number(s);
199
+ return s;
200
+ }
201
+ const asArray = (v) => (Array.isArray(v) ? v : v ? [String(v)] : [WILD]);
202
+
203
+ function globHit(pattern, value) {
204
+ if (!value) return pattern === WILD;
205
+ const re = new RegExp("^" + String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i");
206
+ return re.test(value);
207
+ }
package/package.json CHANGED
@@ -1,48 +1,48 @@
1
- {
2
- "name": "agent-purge",
3
- "version": "0.2.1",
4
- "description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode, grok, omp, hermes, pi, zcode.",
5
- "type": "module",
6
- "bin": {
7
- "agent-purge": "bin/agent-purge.mjs"
8
- },
9
- "files": [
10
- "bin",
11
- "lib",
12
- "backends",
13
- "web",
14
- "bench",
15
- "matrix.json",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "engines": {
20
- "node": ">=20"
21
- },
22
- "scripts": {
23
- "web": "node bin/agent-purge.mjs web",
24
- "matrix": "node bin/agent-purge.mjs matrix",
25
- "detect": "node bin/agent-purge.mjs detect",
26
- "build:exe": "node scripts/build-exe.mjs",
27
- "prepack": "node -e \"require('node:fs').rmSync('personas/.build',{recursive:true,force:true})\""
28
- },
29
- "keywords": [
30
- "system-prompt",
31
- "codex",
32
- "claude",
33
- "opencode",
34
- "coding-agent",
35
- "pi",
36
- "persona",
37
- "prompt-injection"
38
- ],
39
- "license": "MIT",
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/ZiChuanLan/agent-purge.git"
43
- },
44
- "homepage": "https://github.com/ZiChuanLan/agent-purge#readme",
45
- "bugs": {
46
- "url": "https://github.com/ZiChuanLan/agent-purge/issues"
47
- }
48
- }
1
+ {
2
+ "name": "agent-purge",
3
+ "version": "0.2.2",
4
+ "description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode, grok, omp, hermes, pi, zcode.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-purge": "bin/agent-purge.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "backends",
13
+ "web",
14
+ "bench",
15
+ "matrix.json",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "web": "node bin/agent-purge.mjs web",
24
+ "matrix": "node bin/agent-purge.mjs matrix",
25
+ "detect": "node bin/agent-purge.mjs detect",
26
+ "build:exe": "node scripts/build-exe.mjs",
27
+ "prepack": "node -e \"require('node:fs').rmSync('personas/.build',{recursive:true,force:true})\""
28
+ },
29
+ "keywords": [
30
+ "system-prompt",
31
+ "codex",
32
+ "claude",
33
+ "opencode",
34
+ "coding-agent",
35
+ "pi",
36
+ "persona",
37
+ "prompt-injection"
38
+ ],
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/ZiChuanLan/agent-purge.git"
43
+ },
44
+ "homepage": "https://github.com/ZiChuanLan/agent-purge#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/ZiChuanLan/agent-purge/issues"
47
+ }
48
+ }