@bolloon/bolloon-agent 0.2.7 → 0.2.8

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,161 @@
1
+ /**
2
+ * p2p-outbox.ts — 离线消息队列管理 (基于 peer-fs.ts 落盘)
3
+ *
4
+ * 设计目的 (2026-07-05):
5
+ * v3 P2PDirect 通道 sendTo 找不到 conn 就返回 false (消息扔进虚空).
6
+ * 这个模块给所有"发往远端"的 RPC 提供 outbox 兜底:
7
+ * 1) 发送前先 try sendToWithWait (3s 超时)
8
+ * 2) 失败 → enqueueOutbox 到 ~/.bolloon/peers/<pk>/outbox.jsonl
9
+ * 3) flushAllOutboxes() 周期跑 + 启动后跑, 检测到 peer 上线就批量重发
10
+ *
11
+ * 用法:
12
+ * import { sendOrQueue } from '../network/p2p-outbox.js';
13
+ * const r = await sendOrQueue(peerPk, 'agent.chat.send', { channelId, text }, v3P2PRef);
14
+ * // r: 'SENT' | 'QUEUED' | 'FAILED'
15
+ */
16
+ import * as crypto from 'crypto';
17
+ import * as peerFs from './peer-fs.js';
18
+ /**
19
+ * 发送或入队: 优先 sendToWithWait (3s), 失败 → 写 outbox.jsonl
20
+ */
21
+ export async function sendOrQueue(publicKey, op, payload, p2p, opts = {}) {
22
+ if (!publicKey)
23
+ return 'FAILED';
24
+ const rpc = JSON.stringify({ v: 3, op, payload });
25
+ // 先尝试直发
26
+ if (!opts.forceQueue && p2p) {
27
+ try {
28
+ const r = await p2p.sendToWithWait(publicKey, rpc, 3000);
29
+ if (r === 'SENT')
30
+ return 'SENT';
31
+ }
32
+ catch (err) {
33
+ console.warn(`[outbox] sendToWithWait 抛错 (${publicKey.slice(0, 12)}...): ${err?.message?.slice(0, 100)}`);
34
+ }
35
+ }
36
+ // 直发失败 → 入队
37
+ try {
38
+ await peerFs.enqueueOutbox(publicKey, {
39
+ id: crypto.randomUUID(),
40
+ ts: new Date().toISOString(),
41
+ op,
42
+ payload,
43
+ attempts: 0,
44
+ });
45
+ console.log(`[outbox] ${op} → ${publicKey.slice(0, 12)}... 入队 (peer 当前不在线, 上线后自动重发)`);
46
+ return 'QUEUED';
47
+ }
48
+ catch (err) {
49
+ console.error(`[outbox] 入队失败 (${publicKey.slice(0, 12)}...): ${err?.message?.slice(0, 200)}`);
50
+ return 'FAILED';
51
+ }
52
+ }
53
+ /**
54
+ * 批量 flush 所有 peer 的 outbox — 对方上线后调用.
55
+ * 返回 { totalQueued, totalSent, totalFailed }.
56
+ */
57
+ export async function flushAllOutboxes(p2p) {
58
+ if (!p2p)
59
+ return { peers: 0, totalQueued: 0, totalSent: 0, totalFailed: 0 };
60
+ let peers = 0, totalQueued = 0, totalSent = 0, totalFailed = 0;
61
+ try {
62
+ const all = await peerFs.listPeersFromDisk();
63
+ const myPk = p2p.getPublicKey();
64
+ for (const peer of all) {
65
+ if (peer.publicKey === myPk)
66
+ continue;
67
+ const outbox = await peerFs.readOutbox(peer.publicKey);
68
+ if (outbox.length === 0)
69
+ continue;
70
+ peers++;
71
+ totalQueued += outbox.length;
72
+ let sent = 0;
73
+ let failedEntries = [];
74
+ for (const entry of outbox) {
75
+ const rpc = JSON.stringify({ v: 3, op: entry.op, payload: entry.payload });
76
+ try {
77
+ const r = await p2p.sendToWithWait(peer.publicKey, rpc, 3000);
78
+ if (r === 'SENT') {
79
+ sent++;
80
+ }
81
+ else {
82
+ failedEntries.push({ ...entry, attempts: entry.attempts + 1, lastError: r });
83
+ }
84
+ }
85
+ catch (err) {
86
+ failedEntries.push({ ...entry, attempts: entry.attempts + 1, lastError: err?.message?.slice(0, 200) });
87
+ }
88
+ }
89
+ totalSent += sent;
90
+ totalFailed += failedEntries.length;
91
+ // 写回未发送的 (覆盖原文件)
92
+ if (failedEntries.length === 0) {
93
+ await peerFs.clearOutbox(peer.publicKey);
94
+ }
95
+ else {
96
+ // 重新写一份
97
+ const { writeFile, mkdir } = await import('fs/promises');
98
+ const path = await import('path');
99
+ const file = peerFs.getPeerOutboxPath(peer.publicKey);
100
+ await mkdir(path.dirname(file), { recursive: true });
101
+ const lines = failedEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
102
+ await writeFile(file, lines, 'utf-8');
103
+ }
104
+ if (sent > 0) {
105
+ console.log(`[outbox] flush → ${peer.publicKey.slice(0, 12)}... 重发 ${sent}/${outbox.length} 条 ✓`);
106
+ }
107
+ }
108
+ if (totalQueued > 0) {
109
+ console.log(`[outbox] 累计处理: queued=${totalQueued}, sent=${totalSent}, failed=${totalFailed}`);
110
+ }
111
+ }
112
+ catch (err) {
113
+ console.warn('[outbox] flushAllOutboxes 失败:', err?.message?.slice(0, 200));
114
+ }
115
+ return { peers, totalQueued, totalSent, totalFailed };
116
+ }
117
+ /**
118
+ * 给单个 peer 入队一条 — 用于明确"对方一定不在线"的场景
119
+ */
120
+ export async function queueOnly(publicKey, op, payload) {
121
+ await peerFs.enqueueOutbox(publicKey, {
122
+ id: crypto.randomUUID(),
123
+ ts: new Date().toISOString(),
124
+ op,
125
+ payload,
126
+ attempts: 0,
127
+ });
128
+ }
129
+ /**
130
+ * 查 outbox 状态
131
+ * 直接扫 ~/.bolloon/peers/ 子目录, 看每个目录里 outbox.jsonl 是否有内容 (不依赖 peer.json)
132
+ */
133
+ export async function outboxStats() {
134
+ const fsPromises = await import('fs/promises');
135
+ const path = await import('path');
136
+ const byPeer = [];
137
+ let totalQueued = 0;
138
+ try {
139
+ const entries = await fsPromises.readdir(peerFs._debug.PEERS_ROOT);
140
+ for (const e of entries) {
141
+ const outboxFile = path.join(peerFs._debug.PEERS_ROOT, e, 'outbox.jsonl');
142
+ try {
143
+ const raw = await fsPromises.readFile(outboxFile, 'utf-8');
144
+ const cnt = raw.split('\n').filter(Boolean).length;
145
+ if (cnt > 0) {
146
+ // 从目录名反推 publicKey (peerDirName 是 hash__prefix)
147
+ const prefix = e.split('__').slice(1).join('__') || e;
148
+ byPeer.push({ publicKey: prefix, count: cnt });
149
+ totalQueued += cnt;
150
+ }
151
+ }
152
+ catch {
153
+ // 没 outbox.jsonl → 跳过
154
+ }
155
+ }
156
+ }
157
+ catch {
158
+ // PEERS_ROOT 不存在 → 空
159
+ }
160
+ return { totalPeers: byPeer.length, totalQueued, byPeer };
161
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * peer-fs.ts — peer 目录读写 + agents 索引 + manifest 落盘
3
+ *
4
+ * 设计目的 (2026-07-05):
5
+ * 把"远程 P2P 节点"在本地物化成一份文件夹, 让本地所有 agent 都能"知道对方有什么":
6
+ * ~/.bolloon/peers/<publicKey>/
7
+ * peer.json ← 对方基础信息 (name / publicKey / lastSeen / lastManifestTs)
8
+ * user.md ← 对方用户画像 (本地维护)
9
+ * _index.json ← 对方 agent 列表 (轻量, 懒加载时优先读)
10
+ * capability-index.md ← ≤500 字摘要, 拼进 prompt
11
+ * agents/<agentId>.md ← 每个 agent 详细描述
12
+ * groups/<groupId>.md ← 群组
13
+ * function/<cap>.md ← 视频/音乐/图片
14
+ * exportment/<game>.md ← 游戏
15
+ * science/<exp>.md ← 实验记录
16
+ * chat-<YYYY-MM>.md ← 月度聊天记录归档
17
+ * outbox.jsonl ← 离线发送队列 (对方不在线时缓存)
18
+ *
19
+ * 写盘策略:
20
+ * - 纯文本 md 文件, 人类可读, 便于调试
21
+ * - JSON 仅用于 peer.json / _index.json / outbox.jsonl
22
+ * - 全部操作 atomic (write tmp + rename), 避免半截文件
23
+ */
24
+ import * as fs from 'fs/promises';
25
+ import * as path from 'path';
26
+ import * as os from 'os';
27
+ import * as crypto from 'crypto';
28
+ // ============== 路径常量 ==============
29
+ const HOME = process.env.BOLLOON_HOME || path.join(os.homedir(), '.bolloon');
30
+ const PEERS_ROOT = path.join(HOME, 'peers');
31
+ /** publicKey hex → 短哈希, 用于目录名防超长 */
32
+ export function peerDirName(publicKey) {
33
+ // 用 sha256 前 16 位 hex, 防 publicKey 改名冲突 + 文件名长度可控
34
+ const h = crypto.createHash('sha256').update(publicKey).digest('hex').slice(0, 16);
35
+ return `${h}__${publicKey.slice(0, 8)}`;
36
+ }
37
+ export function getPeerDir(publicKey) {
38
+ return path.join(PEERS_ROOT, peerDirName(publicKey));
39
+ }
40
+ export function getPeerIndexPath(publicKey) {
41
+ return path.join(getPeerDir(publicKey), '_index.json');
42
+ }
43
+ export function getPeerJsonPath(publicKey) {
44
+ return path.join(getPeerDir(publicKey), 'peer.json');
45
+ }
46
+ export function getPeerCapabilityIndexPath(publicKey) {
47
+ return path.join(getPeerDir(publicKey), 'capability-index.md');
48
+ }
49
+ export function getPeerChatPath(publicKey, yearMonth) {
50
+ const ym = yearMonth || currentYearMonth();
51
+ return path.join(getPeerDir(publicKey), `chat-${ym}.md`);
52
+ }
53
+ export function getPeerOutboxPath(publicKey) {
54
+ return path.join(getPeerDir(publicKey), 'outbox.jsonl');
55
+ }
56
+ export function getPeerUserMdPath(publicKey) {
57
+ return path.join(getPeerDir(publicKey), 'user.md');
58
+ }
59
+ export function getPeerAgentMdPath(publicKey, agentId) {
60
+ return path.join(getPeerDir(publicKey), 'agents', `${safeName(agentId)}.md`);
61
+ }
62
+ export function getPeerGroupMdPath(publicKey, groupId) {
63
+ return path.join(getPeerDir(publicKey), 'groups', `${safeName(groupId)}.md`);
64
+ }
65
+ export function getPeerFunctionMdPath(publicKey, capability) {
66
+ return path.join(getPeerDir(publicKey), 'function', `${safeName(capability)}.md`);
67
+ }
68
+ export function getPeerExportmentMdPath(publicKey, game) {
69
+ return path.join(getPeerDir(publicKey), 'exportment', `${safeName(game)}.md`);
70
+ }
71
+ export function getPeerScienceMdPath(publicKey, expId) {
72
+ return path.join(getPeerDir(publicKey), 'science', `${safeName(expId)}.md`);
73
+ }
74
+ export function currentYearMonth() {
75
+ const d = new Date();
76
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
77
+ }
78
+ /** 文件名安全化: 去掉 / \ : * ? " < > | 控制符 */
79
+ function safeName(s) {
80
+ return s.replace(/[\/\\:\*\?"<>\|\x00-\x1f]/g, '_').slice(0, 64);
81
+ }
82
+ // ============== Atomic write helper ==============
83
+ async function atomicWrite(filePath, content) {
84
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
85
+ const tmp = filePath + '.tmp.' + Math.random().toString(36).slice(2, 8);
86
+ await fs.writeFile(tmp, content);
87
+ await fs.rename(tmp, filePath);
88
+ }
89
+ async function atomicWriteJson(filePath, obj) {
90
+ await atomicWrite(filePath, JSON.stringify(obj, null, 2) + '\n');
91
+ }
92
+ async function readJsonSafe(filePath) {
93
+ try {
94
+ const raw = await fs.readFile(filePath, 'utf-8');
95
+ return JSON.parse(raw);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ // ============== peer.json ==============
102
+ export async function upsertPeer(rec) {
103
+ const file = getPeerJsonPath(rec.publicKey);
104
+ const existing = await readJsonSafe(file);
105
+ const merged = {
106
+ publicKey: rec.publicKey,
107
+ name: rec.name ?? existing?.name,
108
+ addedAt: existing?.addedAt || rec.addedAt || new Date().toISOString(),
109
+ lastSeenAt: rec.lastSeenAt ?? existing?.lastSeenAt,
110
+ lastManifestTs: rec.lastManifestTs ?? existing?.lastManifestTs,
111
+ manifestCount: rec.manifestCount ?? existing?.manifestCount,
112
+ notes: rec.notes ?? existing?.notes,
113
+ };
114
+ await atomicWriteJson(file, merged);
115
+ return merged;
116
+ }
117
+ export async function getPeer(publicKey) {
118
+ return readJsonSafe(getPeerJsonPath(publicKey));
119
+ }
120
+ export async function listPeersFromDisk() {
121
+ try {
122
+ const entries = await fs.readdir(PEERS_ROOT);
123
+ const out = [];
124
+ for (const e of entries) {
125
+ const peerFile = path.join(PEERS_ROOT, e, 'peer.json');
126
+ const rec = await readJsonSafe(peerFile);
127
+ if (rec)
128
+ out.push(rec);
129
+ }
130
+ return out;
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ }
136
+ // ============== _index.json (agent 清单) ==============
137
+ export async function writePeerIndex(publicKey, idx) {
138
+ await atomicWriteJson(getPeerIndexPath(publicKey), idx);
139
+ }
140
+ export async function readPeerIndex(publicKey) {
141
+ return readJsonSafe(getPeerIndexPath(publicKey));
142
+ }
143
+ // ============== 单个 agent 详细描述 (markdown) ==============
144
+ export async function writeAgentDescription(publicKey, agent, ownerDescription) {
145
+ const lines = [];
146
+ lines.push(`# ${agent.name || agent.id}`);
147
+ lines.push('');
148
+ lines.push(`- **ID**: ${agent.id}`);
149
+ lines.push(`- **状态**: ${agent.status}`);
150
+ if (agent.capabilities.length) {
151
+ lines.push(`- **能力**: ${agent.capabilities.join(', ')}`);
152
+ }
153
+ if (agent.description) {
154
+ lines.push('');
155
+ lines.push('## 描述');
156
+ lines.push('');
157
+ lines.push(agent.description);
158
+ }
159
+ if (ownerDescription) {
160
+ lines.push('');
161
+ lines.push('## Owner 备注');
162
+ lines.push('');
163
+ lines.push(ownerDescription);
164
+ }
165
+ if (agent.peerId || agent.irohNodeId || agent.sessionId) {
166
+ lines.push('');
167
+ lines.push('## 寻址');
168
+ if (agent.peerId)
169
+ lines.push(`- peerId: ${agent.peerId}`);
170
+ if (agent.irohNodeId)
171
+ lines.push(`- irohNodeId: ${agent.irohNodeId}`);
172
+ if (agent.sessionId)
173
+ lines.push(`- sessionId: ${agent.sessionId}`);
174
+ }
175
+ lines.push('');
176
+ lines.push(`> 自动生成于 ${new Date().toISOString()} (peer=${publicKey.slice(0, 12)}...)`);
177
+ lines.push('');
178
+ await atomicWrite(getPeerAgentMdPath(publicKey, agent.id), lines.join('\n'));
179
+ }
180
+ // ============== capability-index.md (≤500 字摘要, 进 prompt) ==============
181
+ export function buildCapabilityIndex(idx) {
182
+ const lines = [];
183
+ lines.push(`# 远端节点能力索引 — ${idx.ownerName || idx.publicKey.slice(0, 12)}`);
184
+ lines.push('');
185
+ if (idx.ownerDescription) {
186
+ lines.push(idx.ownerDescription.slice(0, 200));
187
+ lines.push('');
188
+ }
189
+ lines.push(`## Agent (${idx.agents.length} 个)`);
190
+ for (const a of idx.agents.slice(0, 20)) {
191
+ const caps = a.capabilities.length ? ` [${a.capabilities.join('/')}]` : '';
192
+ const desc = a.description ? ` — ${a.description.slice(0, 60)}` : '';
193
+ lines.push(`- ${a.name}${caps}${desc}`);
194
+ }
195
+ if (idx.agents.length > 20)
196
+ lines.push(`- … 还有 ${idx.agents.length - 20} 个`);
197
+ if (idx.groups?.length) {
198
+ lines.push('');
199
+ lines.push(`## 群组 (${idx.groups.length})`);
200
+ for (const g of idx.groups.slice(0, 10))
201
+ lines.push(`- ${g.name}${g.description ? ' — ' + g.description.slice(0, 50) : ''}`);
202
+ }
203
+ if (idx.functions?.length) {
204
+ lines.push('');
205
+ lines.push(`## 可用功能 (${idx.functions.length})`);
206
+ for (const f of idx.functions.slice(0, 10))
207
+ lines.push(`- ${f.capability}${f.description ? ' — ' + f.description.slice(0, 50) : ''}`);
208
+ }
209
+ if (idx.exportments?.length) {
210
+ lines.push('');
211
+ lines.push(`## 娱乐 (${idx.exportments.length})`);
212
+ for (const e of idx.exportments.slice(0, 10))
213
+ lines.push(`- ${e.name}${e.description ? ' — ' + e.description.slice(0, 50) : ''}`);
214
+ }
215
+ const text = lines.join('\n');
216
+ // 截到 500 字
217
+ return text.length > 500 ? text.slice(0, 497) + '…' : text;
218
+ }
219
+ export async function writeCapabilityIndex(publicKey, idx) {
220
+ const text = buildCapabilityIndex(idx);
221
+ await atomicWrite(getPeerCapabilityIndexPath(publicKey), text);
222
+ }
223
+ export async function readCapabilityIndex(publicKey) {
224
+ try {
225
+ return await fs.readFile(getPeerCapabilityIndexPath(publicKey), 'utf-8');
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ }
231
+ // ============== chat-<YYYY-MM>.md 月度归档 ==============
232
+ function formatChatEntry(e) {
233
+ const ts = e.ts.replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
234
+ const src = e.source;
235
+ const chan = e.channelName ? ` [${e.channelName}]` : '';
236
+ const from = e.fromPublicKey ? ` (${e.fromPublicKey.slice(0, 12)}…)` : '';
237
+ return `### ${ts}${chan} — ${src}${from}\n\n${e.text}\n`;
238
+ }
239
+ export async function appendChat(publicKey, entry) {
240
+ const file = getPeerChatPath(publicKey);
241
+ // 如果文件不存在, 先写头
242
+ try {
243
+ await fs.access(file);
244
+ }
245
+ catch {
246
+ await atomicWrite(file, `# 与 ${publicKey.slice(0, 12)}… 的聊天记录\n\n> 自动归档; 详细消息存于 sessions/cache/, 这里按月滚动.\n\n`);
247
+ }
248
+ // append
249
+ await fs.appendFile(file, '\n---\n\n' + formatChatEntry(entry), 'utf-8');
250
+ }
251
+ export async function readChatArchive(publicKey, yearMonth) {
252
+ try {
253
+ return await fs.readFile(getPeerChatPath(publicKey, yearMonth), 'utf-8');
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ export async function listChatMonths(publicKey) {
260
+ try {
261
+ const dir = getPeerDir(publicKey);
262
+ const entries = await fs.readdir(dir);
263
+ return entries
264
+ .filter(e => /^chat-\d{4}-\d{2}\.md$/.test(e))
265
+ .map(e => e.replace(/^chat-/, '').replace(/\.md$/, ''))
266
+ .sort();
267
+ }
268
+ catch {
269
+ return [];
270
+ }
271
+ }
272
+ // ============== outbox.jsonl 离线队列 ==============
273
+ export async function enqueueOutbox(publicKey, entry) {
274
+ const file = getPeerOutboxPath(publicKey);
275
+ await fs.mkdir(path.dirname(file), { recursive: true });
276
+ await fs.appendFile(file, JSON.stringify(entry) + '\n', 'utf-8');
277
+ }
278
+ export async function readOutbox(publicKey) {
279
+ try {
280
+ const raw = await fs.readFile(getPeerOutboxPath(publicKey), 'utf-8');
281
+ return raw.split('\n').filter(Boolean).map(line => JSON.parse(line));
282
+ }
283
+ catch {
284
+ return [];
285
+ }
286
+ }
287
+ export async function clearOutbox(publicKey) {
288
+ try {
289
+ await fs.unlink(getPeerOutboxPath(publicKey));
290
+ }
291
+ catch { }
292
+ }
293
+ export async function countOutbox(publicKey) {
294
+ return (await readOutbox(publicKey)).length;
295
+ }
296
+ // ============== user.md 本地维护的对方用户画像 ==============
297
+ export async function readUserPortrait(publicKey) {
298
+ try {
299
+ return await fs.readFile(getPeerUserMdPath(publicKey), 'utf-8');
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ }
305
+ export async function writeUserPortrait(publicKey, markdown) {
306
+ await atomicWrite(getPeerUserMdPath(publicKey), markdown);
307
+ }
308
+ // ============== 单个 group/function/exportment/science writer ==============
309
+ // (peer-fs.ts 之前只有 reader — 现在补齐 4 类资源的 writer, 让 manifest.exchange.reply
310
+ // 能落盘对方的群组/功能/娱乐/实验清单, 跟 agents/*.md 一样被本地 agent 看见.)
311
+ function formatFrontmatter(fields) {
312
+ const lines = ['---'];
313
+ for (const [k, v] of Object.entries(fields)) {
314
+ if (v === undefined || v === null)
315
+ continue;
316
+ if (Array.isArray(v)) {
317
+ lines.push(`${k}: [${v.map((x) => JSON.stringify(x)).join(', ')}]`);
318
+ }
319
+ else if (typeof v === 'string') {
320
+ lines.push(`${k}: ${JSON.stringify(v)}`);
321
+ }
322
+ else {
323
+ lines.push(`${k}: ${JSON.stringify(v)}`);
324
+ }
325
+ }
326
+ lines.push('---');
327
+ return lines.join('\n');
328
+ }
329
+ function mdBody(title, fields, description, extra) {
330
+ const parts = [];
331
+ parts.push(`# ${title}`);
332
+ parts.push('');
333
+ parts.push(formatFrontmatter(fields));
334
+ parts.push('');
335
+ if (description) {
336
+ parts.push(description.trim());
337
+ parts.push('');
338
+ }
339
+ if (extra?.length) {
340
+ parts.push(...extra);
341
+ parts.push('');
342
+ }
343
+ parts.push(`> 自动写入于 ${new Date().toISOString()} (peer 来源)`);
344
+ parts.push('');
345
+ return parts.join('\n');
346
+ }
347
+ export async function writeGroup(publicKey, g) {
348
+ const body = mdBody(g.name, {
349
+ id: g.id,
350
+ name: g.name,
351
+ visibility: g.visibility || 'invite',
352
+ memberCount: g.memberCount,
353
+ }, g.description);
354
+ await atomicWrite(getPeerGroupMdPath(publicKey, g.id), body);
355
+ }
356
+ export async function writeFunction(publicKey, f) {
357
+ const body = mdBody(f.capability, {
358
+ capability: f.capability,
359
+ mediaType: f.mediaType || 'text',
360
+ endpoint: f.endpoint,
361
+ }, f.description, f.examples?.length ? ['## 示例', '', ...f.examples.map((e) => `- ${e}`)] : undefined);
362
+ await atomicWrite(getPeerFunctionMdPath(publicKey, f.capability), body);
363
+ }
364
+ export async function writeExportment(publicKey, e) {
365
+ const body = mdBody(e.name, {
366
+ name: e.name,
367
+ genre: e.genre,
368
+ minPlayers: e.minPlayers,
369
+ maxPlayers: e.maxPlayers,
370
+ }, e.description);
371
+ await atomicWrite(getPeerExportmentMdPath(publicKey, e.name), body);
372
+ }
373
+ export async function writeScience(publicKey, s) {
374
+ const recordsBlock = s.records?.length
375
+ ? ['## 记录', '', ...s.records.map((r) => `- **${r.ts}**: ${r.note}`)]
376
+ : undefined;
377
+ const body = mdBody(s.title, {
378
+ id: s.id,
379
+ title: s.title,
380
+ status: s.status || 'planned',
381
+ tags: s.tags,
382
+ }, s.description, recordsBlock);
383
+ await atomicWrite(getPeerScienceMdPath(publicKey, s.id), body);
384
+ }
385
+ export async function listPeerResources(publicKey) {
386
+ const dir = getPeerDir(publicKey);
387
+ const out = { groups: [], functions: [], exportments: [], sciences: [] };
388
+ for (const [subdir, key] of [
389
+ ['groups', 'groups'],
390
+ ['function', 'functions'],
391
+ ['exportment', 'exportments'],
392
+ ['science', 'sciences'],
393
+ ]) {
394
+ try {
395
+ const entries = await fs.readdir(path.join(dir, subdir));
396
+ for (const e of entries) {
397
+ if (!e.endsWith('.md'))
398
+ continue;
399
+ const content = await fs.readFile(path.join(dir, subdir, e), 'utf-8');
400
+ const id = e.replace(/\.md$/, '');
401
+ if (key === 'groups')
402
+ out.groups.push({ id, content });
403
+ else if (key === 'functions')
404
+ out.functions.push({ capability: id, content });
405
+ else if (key === 'exportments')
406
+ out.exportments.push({ name: id, content });
407
+ else
408
+ out.sciences.push({ id, content });
409
+ }
410
+ }
411
+ catch { }
412
+ }
413
+ return out;
414
+ }
415
+ // ============== 测试 / 调试 helper ==============
416
+ export const _debug = {
417
+ PEERS_ROOT,
418
+ peerDirName,
419
+ getPeerDir,
420
+ };