@bolloon/bolloon-agent 0.3.1 → 0.3.3

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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * judgeness · store.ts
3
+ *
4
+ * 落盘布局 (与 plan §5.1 一致):
5
+ * ~/.bolloon/judgeness/
6
+ * descriptions/<jd-id>.md # 单 description 可读版 (frontmatter v2 + body)
7
+ * tags.yaml # 全局 tags 聚合
8
+ * visibility.yaml # 隐私策略
9
+ * allowlist.yaml # 白名单
10
+ * hearth-cache/<remote-pk>/ # 远端用户缓存
11
+ * manifest.json
12
+ * descriptions/<jd-id>.md
13
+ * last-seen.txt
14
+ */
15
+ import * as fs from 'fs/promises';
16
+ import * as path from 'path';
17
+ import * as os from 'os';
18
+ import * as crypto from 'crypto';
19
+ // ---------------------------------------------------------------------------
20
+ // 路径常量 (每次重读 env, 支持测试隔离)
21
+ // ---------------------------------------------------------------------------
22
+ export function homeDir() {
23
+ return process.env.BOLLOON_HOME || path.join(os.homedir(), '.bolloon');
24
+ }
25
+ export function JUDGENESS_ROOT() {
26
+ return path.join(homeDir(), 'judgeness');
27
+ }
28
+ function DESCRIPTIONS_DIR() { return path.join(JUDGENESS_ROOT(), 'descriptions'); }
29
+ function TAGS_FILE() { return path.join(JUDGENESS_ROOT(), 'tags.yaml'); }
30
+ function VISIBILITY_FILE() { return path.join(JUDGENESS_ROOT(), 'visibility.yaml'); }
31
+ function ALLOWLIST_FILE() { return path.join(JUDGENESS_ROOT(), 'allowlist.yaml'); }
32
+ function HEARTH_CACHE_ROOT() { return path.join(JUDGENESS_ROOT(), 'hearth-cache'); }
33
+ // ---------------------------------------------------------------------------
34
+ // id 生成
35
+ // ---------------------------------------------------------------------------
36
+ export function newDescriptionId() {
37
+ const ts = Date.now();
38
+ const rand = crypto.randomBytes(3).toString('hex');
39
+ return `jd-${ts}-${rand}`;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // ensureDirs
43
+ // ---------------------------------------------------------------------------
44
+ export async function ensureJudgenessDirs() {
45
+ await fs.mkdir(JUDGENESS_ROOT(), { recursive: true });
46
+ await fs.mkdir(DESCRIPTIONS_DIR(), { recursive: true });
47
+ await fs.mkdir(HEARTH_CACHE_ROOT(), { recursive: true });
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // description 落盘 / 读回 (md with frontmatter v2)
51
+ // ---------------------------------------------------------------------------
52
+ function descriptionMdPath(id) {
53
+ return path.join(DESCRIPTIONS_DIR(), `${id}.md`);
54
+ }
55
+ function escapeYamlString(s) {
56
+ // 单行安全: 包裹单引号 + 转义单引号. 避免依赖 js-yaml.
57
+ return `'${s.replace(/'/g, "''")}'`;
58
+ }
59
+ function safeVer(v) {
60
+ return v === 1 ? 1 : 0;
61
+ }
62
+ function descriptionToMarkdown(desc) {
63
+ const fm = [
64
+ '---',
65
+ `descriptionId: ${desc.descriptionId}`,
66
+ `judgmentRef: ${desc.judgmentRef}`,
67
+ `description_version: ${safeVer(desc.description_version)}`,
68
+ 'facets:',
69
+ desc.facets.judgment !== undefined ? ` judgment: ${desc.facets.judgment}` : ' judgment: null',
70
+ desc.facets.taste_aesthetic !== undefined ? ` taste_aesthetic: ${desc.facets.taste_aesthetic}` : ' taste_aesthetic: null',
71
+ desc.facets.novelty_score !== undefined ? ` novelty_score: ${desc.facets.novelty_score}` : ' novelty_score: null',
72
+ desc.facets.imaginative_score !== undefined ? ` imaginative_score: ${desc.facets.imaginative_score}` : ' imaginative_score: null',
73
+ desc.facets.curiosity_vector !== undefined ? ` curiosity_vector: ${desc.facets.curiosity_vector}` : ' curiosity_vector: null',
74
+ 'basis:',
75
+ desc.basis.taste_basis ? ` taste_basis: ${escapeYamlString(desc.basis.taste_basis)}` : ' taste_basis: null',
76
+ desc.basis.novelty_basis ? ` novelty_basis: ${escapeYamlString(desc.basis.novelty_basis)}` : ' novelty_basis: null',
77
+ desc.basis.imagination_basis ? ` imagination_basis: ${escapeYamlString(desc.basis.imagination_basis)}` : ' imagination_basis: null',
78
+ 'scope:',
79
+ desc.scope.domains && desc.scope.domains.length > 0
80
+ ? ` domains: [${desc.scope.domains.map((d) => escapeYamlString(d)).join(', ')}]`
81
+ : ' domains: []',
82
+ desc.scope.topics && desc.scope.topics.length > 0
83
+ ? ` topics: [${desc.scope.topics.map((d) => escapeYamlString(d)).join(', ')}]`
84
+ : ' topics: []',
85
+ `visibility: ${desc.visibility}`,
86
+ `openState: ${desc.openState}`,
87
+ `by: ${desc.by}`,
88
+ desc.byAgentId ? `byAgentId: ${escapeYamlString(desc.byAgentId)}` : 'byAgentId: null',
89
+ `createdAt: ${escapeYamlString(desc.createdAt)}`,
90
+ `updatedAt: ${escapeYamlString(desc.updatedAt)}`,
91
+ desc.lastTransitionAt ? `lastTransitionAt: ${escapeYamlString(desc.lastTransitionAt)}` : 'lastTransitionAt: null',
92
+ 'schema_version: 2',
93
+ 'audience: self',
94
+ 'stage: current',
95
+ 'status: current',
96
+ `entity_type: concept`,
97
+ `tags: [judgeness, judgment-ref=${desc.judgmentRef}, visibility=${desc.visibility}, state=${desc.openState}]`,
98
+ '---',
99
+ '',
100
+ `# Judgeness Description ${desc.descriptionId}`,
101
+ '',
102
+ `## Judgment Reference`,
103
+ `- judgmentRef: ${desc.judgmentRef}`,
104
+ '',
105
+ `## Facets`,
106
+ `- judgment: ${desc.facets.judgment ?? 'n/a'}`,
107
+ `- taste_aesthetic: ${desc.facets.taste_aesthetic ?? 'n/a'}${desc.basis.taste_basis ? ' — ' + desc.basis.taste_basis : ''}`,
108
+ `- novelty_score: ${desc.facets.novelty_score ?? 'n/a'}${desc.basis.novelty_basis ? ' — ' + desc.basis.novelty_basis : ''}`,
109
+ `- imaginative_score: ${desc.facets.imaginative_score ?? 'n/a'}${desc.basis.imagination_basis ? ' — ' + desc.basis.imagination_basis : ''}`,
110
+ `- curiosity_vector: ${desc.facets.curiosity_vector ?? 'n/a'}`,
111
+ '',
112
+ `## Scope`,
113
+ `- domains: ${(desc.scope.domains ?? []).join(', ') || '(any)'}`,
114
+ `- topics: ${(desc.scope.topics ?? []).join(', ') || '(any)'}`,
115
+ '',
116
+ `## Privacy`,
117
+ `- visibility: ${desc.visibility}`,
118
+ `- openState: ${desc.openState}`,
119
+ `- by: ${desc.by}${desc.byAgentId ? ' (' + desc.byAgentId + ')' : ''}`,
120
+ '',
121
+ ];
122
+ return fm.join('\n');
123
+ }
124
+ // (旧 declare-module hack 已移除 — types.ts 已允许 description_version: 1 | 0)
125
+ function parseMarkdownToDescription(raw) {
126
+ // 极简 frontmatter 解析 (匹配 key: value; facets/scope 走单行)
127
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
128
+ if (!m)
129
+ return null;
130
+ const fm = m[1];
131
+ const get = (key) => {
132
+ // ^[ ]* 让缩进子键 (如 ` judgment: 0.7`) 也能匹配
133
+ const re = new RegExp(`^[ ]*${key}:\\s*(.*)$`, 'm');
134
+ const x = fm.match(re);
135
+ if (!x)
136
+ return undefined;
137
+ let v = x[1].trim();
138
+ if (v === 'null' || v === '~')
139
+ return undefined;
140
+ if (v.startsWith("'") && v.endsWith("'"))
141
+ v = v.slice(1, -1).replace(/''/g, "'");
142
+ return v;
143
+ };
144
+ const getNum = (key) => {
145
+ const v = get(key);
146
+ if (v === undefined)
147
+ return undefined;
148
+ const n = Number(v);
149
+ return Number.isFinite(n) ? n : undefined;
150
+ };
151
+ const getList = (key) => {
152
+ const v = get(key);
153
+ if (v === undefined)
154
+ return undefined;
155
+ if (!v.startsWith('['))
156
+ return undefined;
157
+ const inner = v.slice(1, -1).trim();
158
+ if (!inner)
159
+ return [];
160
+ return inner.split(',').map((s) => s.trim().replace(/^'|'$/g, '').replace(/''/g, "'"));
161
+ };
162
+ const descriptionId = get('descriptionId');
163
+ const judgmentRef = get('judgmentRef');
164
+ if (!descriptionId || !judgmentRef)
165
+ return null;
166
+ return {
167
+ descriptionId,
168
+ judgmentRef,
169
+ description_version: (getNum('description_version') ?? 1),
170
+ facets: {
171
+ judgment: getNum('judgment'),
172
+ taste_aesthetic: getNum('taste_aesthetic'),
173
+ novelty_score: getNum('novelty_score'),
174
+ imaginative_score: getNum('imaginative_score'),
175
+ curiosity_vector: getNum('curiosity_vector'),
176
+ },
177
+ basis: {
178
+ taste_basis: get('taste_basis'),
179
+ novelty_basis: get('novelty_basis'),
180
+ imagination_basis: get('imagination_basis'),
181
+ },
182
+ scope: {
183
+ domains: getList('domains') ?? [],
184
+ topics: getList('topics') ?? [],
185
+ },
186
+ visibility: (get('visibility') ?? 'private'),
187
+ openState: (get('openState') ?? 'locked'),
188
+ by: (get('by') ?? 'human'),
189
+ byAgentId: get('byAgentId'),
190
+ createdAt: get('createdAt') ?? new Date().toISOString(),
191
+ updatedAt: get('updatedAt') ?? new Date().toISOString(),
192
+ lastTransitionAt: get('lastTransitionAt'),
193
+ };
194
+ }
195
+ export async function saveDescription(desc) {
196
+ await ensureJudgenessDirs();
197
+ const md = descriptionToMarkdown(desc);
198
+ const tmp = descriptionMdPath(desc.descriptionId) + '.tmp';
199
+ await fs.writeFile(tmp, md, 'utf-8');
200
+ await fs.rename(tmp, descriptionMdPath(desc.descriptionId));
201
+ }
202
+ export async function loadDescription(id) {
203
+ try {
204
+ const raw = await fs.readFile(descriptionMdPath(id), 'utf-8');
205
+ return parseMarkdownToDescription(raw);
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ export async function listDescriptions() {
212
+ await ensureJudgenessDirs();
213
+ const files = await fs.readdir(DESCRIPTIONS_DIR()).catch(() => []);
214
+ const out = [];
215
+ for (const f of files) {
216
+ if (!f.endsWith('.md'))
217
+ continue;
218
+ const id = f.slice(0, -3);
219
+ const d = await loadDescription(id);
220
+ if (d)
221
+ out.push(d);
222
+ }
223
+ return out;
224
+ }
225
+ export async function findDescriptionByJudgmentRef(ref) {
226
+ const all = await listDescriptions();
227
+ return all.find((d) => d.judgmentRef === ref) ?? null;
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // visibility.yaml
231
+ // ---------------------------------------------------------------------------
232
+ function visFileToYaml(f) {
233
+ const lines = ['# judgeness/visibility.yaml', `version: ${f.version}`, 'defaults:'];
234
+ lines.push(` visibility: ${f.defaults.visibility}`);
235
+ lines.push(` openState: ${f.defaults.openState}`);
236
+ lines.push('channels:');
237
+ for (const c of f.channels) {
238
+ lines.push(` - channelId: ${c.channelId}`);
239
+ lines.push(` visibility: ${c.visibility}`);
240
+ lines.push(` openState: ${c.openState}`);
241
+ lines.push(` humanOverride: ${c.humanOverride}`);
242
+ }
243
+ lines.push('cards:');
244
+ if (f.cards.length === 0)
245
+ lines.push(' []');
246
+ else
247
+ for (const c of f.cards) {
248
+ lines.push(` - descriptionId: ${c.descriptionId}`);
249
+ lines.push(` visibility: ${c.visibility}`);
250
+ lines.push(` openState: ${c.openState}`);
251
+ lines.push(` humanOverride: ${c.humanOverride}`);
252
+ }
253
+ return lines.join('\n') + '\n';
254
+ }
255
+ function parseVisYaml(raw) {
256
+ // 极简行解析 — 不引依赖
257
+ const lines = raw.split('\n');
258
+ const f = {
259
+ version: 1,
260
+ defaults: { visibility: 'private', openState: 'locked' },
261
+ channels: [],
262
+ cards: [],
263
+ };
264
+ let section = 'root';
265
+ let curChannel = null;
266
+ let curCard = null;
267
+ for (const line of lines) {
268
+ const t = line.trim();
269
+ if (!t || t.startsWith('#'))
270
+ continue;
271
+ if (t === 'defaults:') {
272
+ section = 'defaults';
273
+ continue;
274
+ }
275
+ if (t === 'channels:') {
276
+ section = 'channels';
277
+ continue;
278
+ }
279
+ if (t === 'cards:') {
280
+ section = 'cards';
281
+ continue;
282
+ }
283
+ if (t.startsWith('- ')) {
284
+ const kv = t.slice(2).split(':').map((s) => s.trim());
285
+ const k = kv[0];
286
+ const v = kv.slice(1).join(':');
287
+ if (section === 'channels') {
288
+ curChannel = { [k]: v };
289
+ f.channels.push(curChannel);
290
+ section = 'channelItem';
291
+ continue;
292
+ }
293
+ if (section === 'cards') {
294
+ curCard = { [k]: v };
295
+ f.cards.push(curCard);
296
+ section = 'cardItem';
297
+ continue;
298
+ }
299
+ }
300
+ if (section === 'defaults' || section === 'channelItem' || section === 'cardItem') {
301
+ const kv = t.split(':').map((s) => s.trim());
302
+ const k = kv[0];
303
+ const v = kv.slice(1).join(':');
304
+ if (section === 'defaults') {
305
+ if (k === 'visibility')
306
+ f.defaults.visibility = v;
307
+ if (k === 'openState')
308
+ f.defaults.openState = v;
309
+ }
310
+ else if (section === 'channelItem' && curChannel) {
311
+ if (k === 'visibility')
312
+ curChannel.visibility = v;
313
+ if (k === 'openState')
314
+ curChannel.openState = v;
315
+ if (k === 'humanOverride')
316
+ curChannel.humanOverride = v === 'true';
317
+ }
318
+ else if (section === 'cardItem' && curCard) {
319
+ if (k === 'visibility')
320
+ curCard.visibility = v;
321
+ if (k === 'openState')
322
+ curCard.openState = v;
323
+ if (k === 'humanOverride')
324
+ curCard.humanOverride = v === 'true';
325
+ }
326
+ }
327
+ }
328
+ return f;
329
+ }
330
+ export async function loadVisibility() {
331
+ try {
332
+ const raw = await fs.readFile(VISIBILITY_FILE(), 'utf-8');
333
+ return parseVisYaml(raw);
334
+ }
335
+ catch {
336
+ return {
337
+ version: 1,
338
+ defaults: { visibility: 'private', openState: 'locked' },
339
+ channels: [],
340
+ cards: [],
341
+ };
342
+ }
343
+ }
344
+ export async function saveVisibility(f) {
345
+ await ensureJudgenessDirs();
346
+ const tmp = VISIBILITY_FILE() + '.tmp';
347
+ await fs.writeFile(tmp, visFileToYaml(f), 'utf-8');
348
+ await fs.rename(tmp, VISIBILITY_FILE());
349
+ }
350
+ // ---------------------------------------------------------------------------
351
+ // allowlist.yaml
352
+ // ---------------------------------------------------------------------------
353
+ function allowlistToYaml(f) {
354
+ const lines = ['# judgeness/allowlist.yaml', `version: ${f.version}`, 'peers:'];
355
+ if (f.peers.length === 0) {
356
+ lines.push(' []');
357
+ }
358
+ else {
359
+ for (const p of f.peers) {
360
+ lines.push(` - pubkey: ${p.pubkey}`);
361
+ if (p.alias)
362
+ lines.push(` alias: ${escapeYamlString(p.alias)}`);
363
+ if (p.note)
364
+ lines.push(` note: ${escapeYamlString(p.note)}`);
365
+ lines.push(` addedAt: ${escapeYamlString(p.addedAt)}`);
366
+ }
367
+ }
368
+ return lines.join('\n') + '\n';
369
+ }
370
+ function parseAllowlistYaml(raw) {
371
+ const f = { version: 1, peers: [] };
372
+ const lines = raw.split('\n');
373
+ let cur = null;
374
+ for (const line of lines) {
375
+ const t = line.trim();
376
+ if (!t || t.startsWith('#'))
377
+ continue;
378
+ if (t.startsWith('- ')) {
379
+ if (cur && cur.pubkey)
380
+ f.peers.push(cur);
381
+ const kv = t.slice(2).split(':').map((s) => s.trim());
382
+ cur = { pubkey: kv.slice(1).join(':').trim() };
383
+ continue;
384
+ }
385
+ if (cur) {
386
+ const idx = t.indexOf(':');
387
+ if (idx === -1)
388
+ continue;
389
+ const k = t.slice(0, idx).trim();
390
+ let v = t.slice(idx + 1).trim();
391
+ if (v.startsWith("'") && v.endsWith("'"))
392
+ v = v.slice(1, -1).replace(/''/g, "'");
393
+ cur[k] = v;
394
+ }
395
+ }
396
+ if (cur && cur.pubkey)
397
+ f.peers.push(cur);
398
+ return f;
399
+ }
400
+ export async function loadAllowlist() {
401
+ try {
402
+ const raw = await fs.readFile(ALLOWLIST_FILE(), 'utf-8');
403
+ return parseAllowlistYaml(raw);
404
+ }
405
+ catch {
406
+ return { version: 1, peers: [] };
407
+ }
408
+ }
409
+ export async function saveAllowlist(f) {
410
+ await ensureJudgenessDirs();
411
+ const tmp = ALLOWLIST_FILE() + '.tmp';
412
+ await fs.writeFile(tmp, allowlistToYaml(f), 'utf-8');
413
+ await fs.rename(tmp, ALLOWLIST_FILE());
414
+ }
415
+ export async function isPubkeyAllowed(targetPubkey) {
416
+ const f = await loadAllowlist();
417
+ return f.peers.some((p) => p.pubkey === targetPubkey);
418
+ }
419
+ export async function addAllowlistPeer(entry) {
420
+ const f = await loadAllowlist();
421
+ const existing = f.peers.findIndex((p) => p.pubkey === entry.pubkey);
422
+ if (existing >= 0)
423
+ f.peers[existing] = entry;
424
+ else
425
+ f.peers.push(entry);
426
+ await saveAllowlist(f);
427
+ }
428
+ export async function removeAllowlistPeer(pubkey) {
429
+ const f = await loadAllowlist();
430
+ f.peers = f.peers.filter((p) => p.pubkey !== pubkey);
431
+ await saveAllowlist(f);
432
+ }
433
+ // ---------------------------------------------------------------------------
434
+ // hearth-cache/<remote-pk>/
435
+ // ---------------------------------------------------------------------------
436
+ function cacheDir(remotePubkey) {
437
+ const sub = remotePubkey.slice(0, 16) + '__' + remotePubkey.slice(0, 8);
438
+ return path.join(HEARTH_CACHE_ROOT(), sub);
439
+ }
440
+ export async function writeHearthCache(remotePubkey, manifest, descriptions) {
441
+ await ensureJudgenessDirs();
442
+ const dir = cacheDir(remotePubkey);
443
+ await fs.mkdir(dir, { recursive: true });
444
+ await fs.mkdir(path.join(dir, 'descriptions'), { recursive: true });
445
+ await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
446
+ await fs.writeFile(path.join(dir, 'last-seen.txt'), new Date().toISOString(), 'utf-8');
447
+ for (const d of descriptions) {
448
+ await fs.writeFile(path.join(dir, 'descriptions', `${d.descriptionId}.md`), descriptionToMarkdown(d), 'utf-8');
449
+ }
450
+ }
451
+ export async function readHearthCache(remotePubkey) {
452
+ try {
453
+ const raw = await fs.readFile(path.join(cacheDir(remotePubkey), 'manifest.json'), 'utf-8');
454
+ return JSON.parse(raw);
455
+ }
456
+ catch {
457
+ return null;
458
+ }
459
+ }
460
+ // ---------------------------------------------------------------------------
461
+ // tags.yaml (聚合器, 反攻期主用; 防御期写空壳)
462
+ // ---------------------------------------------------------------------------
463
+ export async function aggregateTags() {
464
+ const descs = await listDescriptions();
465
+ const set = new Set();
466
+ for (const d of descs) {
467
+ (d.scope.topics ?? []).forEach((t) => set.add(t));
468
+ (d.scope.domains ?? []).forEach((t) => set.add(t));
469
+ }
470
+ return Array.from(set).sort();
471
+ }
472
+ export async function writeTagsAggregate() {
473
+ await ensureJudgenessDirs();
474
+ const tags = await aggregateTags();
475
+ const yaml = ['# judgeness/tags.yaml', `version: 1`, `count: ${tags.length}`, 'tags:'];
476
+ for (const t of tags)
477
+ yaml.push(` - ${escapeYamlString(t)}`);
478
+ const tmp = TAGS_FILE() + '.tmp';
479
+ await fs.writeFile(tmp, yaml.join('\n') + '\n', 'utf-8');
480
+ await fs.rename(tmp, TAGS_FILE());
481
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * judgeness · types.ts
3
+ *
4
+ * 重要设计决策 (2026-07-15 user rev):
5
+ * judgeness 是对 judgement 的"描述统称", 不组块.
6
+ * 即: 不扩展 HumanJudgment 的 schema, 而是用一套独立 description vocabulary,
7
+ * 通过 judgmentRef 外键引用 HumanJudgment.id, 0 迁移成本.
8
+ *
9
+ * 核心 5 维 (与传播裂变 / 乔布斯 A 方向对应):
10
+ * judgment / taste_aesthetic / novelty_score / imaginative_score / curiosity_vector
11
+ * basis_* 是品味 / 创新 / 想象的依据描述 (D1 第三反转).
12
+ *
13
+ * 4 层隐私:
14
+ * public / allowlist / peers / private
15
+ *
16
+ * 状态机:
17
+ * open (agent 可 publish) | locked (默认, agent 不可) | human-only (仅人类写入)
18
+ */
19
+ export {};
@@ -0,0 +1,118 @@
1
+ /**
2
+ * judgeness · visibility.ts
3
+ *
4
+ * 三道授权闸的核心实现:
5
+ * 闸 1: id-visibility scrubber 出站前按 audience 字段过滤
6
+ * 闸 2: channel-allowlist gate joinPeer / joinTopic 前的白名单
7
+ * 闸 3: human-override handler 任何写入由人类 override 优先
8
+ *
9
+ * 复用了现有 sanitizeChannelForPeer 模式 (src/web/server-v3-p2p.ts:54) 的思路:
10
+ * 不引额外依赖; 默认 fail-closed (闸 1/2); 闸 3 fail-人类优先.
11
+ */
12
+ import { loadVisibility, isPubkeyAllowed } from './store.js';
13
+ /** 三态映射:
14
+ * - locked + agent 写入 → 拒
15
+ * - locked + human 写入 → 允许
16
+ * - open + 任意 → 允许 (但要过闸 2 allowlist)
17
+ * - human-only + agent 写入 → 拒
18
+ * - visibility.yaml.humanOverride=true → 完全优先于 agent openState
19
+ */
20
+ export function resolveGate3(desc, ctx, visFile) {
21
+ // 闸 3 先看 visibility.yaml.humanOverride (强制)
22
+ const visCard = visFile.cards.find((c) => c.descriptionId === desc.descriptionId);
23
+ const visChan = ctx.channelTopic
24
+ ? visFile.channels.find((c) => c.channelId === ctx.channelTopic)
25
+ : undefined;
26
+ const humanOverride = visCard?.humanOverride ?? visChan?.humanOverride ?? false;
27
+ const effectiveOpenState = visCard?.openState ?? visChan?.openState ?? desc.openState;
28
+ // humanOverride=true 时, agent 永不能写, 即使 openState=open
29
+ if (humanOverride && ctx.role !== 'human') {
30
+ return { allow: false, reason: 'humanOverride=true and writer is not human' };
31
+ }
32
+ // human-only 状态: 仅 human
33
+ if (effectiveOpenState === 'human-only' && ctx.role !== 'human') {
34
+ return { allow: false, reason: 'openState=human-only rejects agent' };
35
+ }
36
+ // locked 状态: agent 不能自动 share / publish (但可写入本地 draft)
37
+ if (effectiveOpenState === 'locked' && ctx.role === 'agent') {
38
+ return { allow: false, reason: 'openState=locked rejects agent auto-write' };
39
+ }
40
+ return { allow: true, reason: 'ok' };
41
+ }
42
+ /** 给 audience 一份 description 的可见版本. */
43
+ export async function scrubForAudience(desc, audience) {
44
+ const vis = await loadVisibility();
45
+ const visCard = vis.cards.find((c) => c.descriptionId === desc.descriptionId);
46
+ const visChan = audience.channelTopic
47
+ ? vis.channels.find((c) => c.channelId === audience.channelTopic)
48
+ : undefined;
49
+ const effectiveVis = visCard?.visibility ?? visChan?.visibility ?? desc.visibility;
50
+ const base = {
51
+ descriptionId: desc.descriptionId,
52
+ judgmentRef: desc.judgmentRef,
53
+ visibility: effectiveVis,
54
+ openState: visCard?.openState ?? visChan?.openState ?? desc.openState,
55
+ };
56
+ // private 仅 self 可见
57
+ if (effectiveVis === 'private' && audience.pubkey !== '__self__') {
58
+ return base;
59
+ }
60
+ // peers 仅已 join 的 peer (这里简化为: 任何非 self 都视为 peer-by-default)
61
+ if (effectiveVis === 'peers' && audience.pubkey === '__self__') {
62
+ return { ...base, facets: desc.facets, basis: desc.basis, scope: desc.scope, by: desc.by, createdAt: desc.createdAt };
63
+ }
64
+ // allowlist
65
+ if (effectiveVis === 'allowlist') {
66
+ if (audience.pubkey === '__self__') {
67
+ return { ...base, facets: desc.facets, basis: desc.basis, scope: desc.scope, by: desc.by, createdAt: desc.createdAt };
68
+ }
69
+ const allowed = await isPubkeyAllowed(audience.pubkey);
70
+ if (!allowed)
71
+ return base;
72
+ }
73
+ // public
74
+ return {
75
+ ...base,
76
+ facets: desc.facets,
77
+ basis: desc.basis,
78
+ scope: desc.scope,
79
+ by: desc.by,
80
+ createdAt: desc.createdAt,
81
+ };
82
+ }
83
+ /** 批量. 顺序: scrub → 过滤 private (self-only). */
84
+ export async function scrubListForAudience(descs, audience) {
85
+ const out = [];
86
+ for (const d of descs) {
87
+ const s = await scrubForAudience(d, audience);
88
+ out.push(s);
89
+ }
90
+ return out;
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // 闸 2 — allowlist gate (joinPeer / joinTopic 前)
94
+ // ---------------------------------------------------------------------------
95
+ /** resolveGate2: true = 允许 join. */
96
+ export async function resolveGate2(audiencePubkey, targetChannel, visFile) {
97
+ // 自我永远放行
98
+ if (audiencePubkey === '__self__')
99
+ return { allow: true, reason: 'self' };
100
+ const f = visFile ?? (await loadVisibility());
101
+ const chan = f.channels.find((c) => c.channelId === targetChannel);
102
+ if (!chan) {
103
+ // channel 没登记 = 默认 allowlist 模式 (闸 2 fail-closed)
104
+ const allowed = await isPubkeyAllowed(audiencePubkey);
105
+ return allowed
106
+ ? { allow: true, reason: 'default allowlist: pk in list' }
107
+ : { allow: false, reason: 'default allowlist: pk not in list' };
108
+ }
109
+ if (chan.visibility === 'public')
110
+ return { allow: true, reason: 'channel=public' };
111
+ if (chan.visibility === 'private')
112
+ return { allow: false, reason: 'channel=private' };
113
+ // allowlist / peers 都要求在白名单
114
+ const allowed = await isPubkeyAllowed(audiencePubkey);
115
+ return allowed
116
+ ? { allow: true, reason: 'allowlist match' }
117
+ : { allow: false, reason: 'allowlist miss' };
118
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 历史 session.messages 清理脚本 (2026-07-15 Bug 4 修复配套)
3
+ * 一次性扫描 ~/.bolloon/sessions/cache/ 下所有 session 文件, 相邻去重 (同 type+content),
4
+ * 写回原文件. 老数据有此 bug (client PATCH + server /message 各 push 一份 user msg)
5
+ * 会导致重启后"每个 user 气泡显示 2 个".
6
+ *
7
+ * 跑法:
8
+ * npx tsx src/scripts/dedup-session-messages.ts [--dry-run] [--only channelId]
9
+ */
10
+ import { promises as fs } from 'fs';
11
+ import * as path from 'path';
12
+ const SESSIONS_DIR = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions', 'cache');
13
+ async function main() {
14
+ const args = process.argv.slice(2);
15
+ const dryRun = args.includes('--dry-run');
16
+ const onlyId = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
17
+ let files;
18
+ try {
19
+ files = await fs.readdir(SESSIONS_DIR);
20
+ }
21
+ catch (e) {
22
+ console.log(`无法读 ${SESSIONS_DIR}: ${e.message}`);
23
+ return;
24
+ }
25
+ console.log(`扫描 ${SESSIONS_DIR}/ (${files.length} 文件), dry-run=${dryRun}${onlyId ? `, only=${onlyId}` : ''}`);
26
+ let totalScanned = 0;
27
+ let totalFixed = 0;
28
+ let totalDupes = 0;
29
+ for (const f of files) {
30
+ if (!f.endsWith('.json'))
31
+ continue;
32
+ if (onlyId && !f.startsWith(onlyId))
33
+ continue;
34
+ const fp = path.join(SESSIONS_DIR, f);
35
+ totalScanned++;
36
+ try {
37
+ const raw = await fs.readFile(fp, 'utf8');
38
+ const session = JSON.parse(raw);
39
+ const msgs = Array.isArray(session.messages) ? session.messages : [];
40
+ if (msgs.length === 0)
41
+ continue;
42
+ let lastType = null;
43
+ let lastContent = null;
44
+ const deduped = msgs.filter((m) => {
45
+ const same = lastType === m.type && lastContent === m.content;
46
+ lastType = m.type;
47
+ lastContent = m.content;
48
+ return !same;
49
+ });
50
+ const dupes = msgs.length - deduped.length;
51
+ if (dupes === 0)
52
+ continue;
53
+ totalFixed++;
54
+ totalDupes += dupes;
55
+ console.log(` ${f}: ${msgs.length} → ${deduped.length} (去重 ${dupes} 条)`);
56
+ if (!dryRun) {
57
+ session.messages = deduped;
58
+ session.lastUpdated = new Date().toISOString();
59
+ await fs.writeFile(fp, JSON.stringify(session, null, 2));
60
+ }
61
+ }
62
+ catch (e) {
63
+ console.warn(` ${f} 解析失败: ${e.message?.slice(0, 100)}`);
64
+ }
65
+ }
66
+ console.log(`\n扫描 ${totalScanned} 文件, 修复 ${totalFixed} 个, 共去重 ${totalDupes} 条${dryRun ? ' (dry-run, 未写入)' : ''}`);
67
+ }
68
+ main().catch(e => { console.error('ERR:', e); process.exit(1); });