@bolloon/bolloon-agent 0.3.42 → 0.3.44

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.
@@ -146,8 +146,26 @@ export async function writeSkillCandidate(c) {
146
146
  const dir = getCandidateDir();
147
147
  await fs.mkdir(dir, { recursive: true });
148
148
  const safeName = sanitizeSkillName(c.name);
149
- const file = path.join(dir, `${safeName}-${Date.now()}.json`);
150
- await fs.writeFile(file, JSON.stringify(c, null, 2), 'utf-8');
149
+ // 2026-08-08: signature 的候选用固定文件名 (合并更新同一个), 无 signature 才带时间戳
150
+ const file = c.signature
151
+ ? path.join(dir, `${safeName}.json`)
152
+ : path.join(dir, `${safeName}-${Date.now()}.json`);
153
+ // 追加式合并: 若同 signature 已存在, 累积 runs + 追加 body
154
+ let runs = c.runs ?? 1;
155
+ let body = c.body;
156
+ try {
157
+ const prev = JSON.parse(await fs.readFile(file, 'utf-8'));
158
+ if (prev && prev.runs)
159
+ runs = prev.runs + 1;
160
+ if (prev && prev.body && body !== prev.body && c.signature) {
161
+ // 同一 signature 重复运行 → 追加一条经验 (去重, 避免 body 膨胀)
162
+ const line = `- ${new Date().toISOString().slice(0, 16)} ${c.source}: ${c.description}`;
163
+ body = `${prev.body}\n${line}`;
164
+ }
165
+ }
166
+ catch { /* 新文件 */ }
167
+ const merged = { ...c, runs, body, timestamp: c.timestamp || new Date().toISOString() };
168
+ await fs.writeFile(file, JSON.stringify(merged, null, 2), 'utf-8');
151
169
  return file;
152
170
  }
153
171
  export async function listSkillCandidates(home = os.homedir()) {
@@ -165,12 +183,37 @@ export async function listSkillCandidates(home = os.homedir()) {
165
183
  const raw = await fs.readFile(path.join(dir, f), 'utf-8');
166
184
  const c = JSON.parse(raw);
167
185
  if (c.name && c.body)
168
- out.push(c);
186
+ out.push({ ...c, file: path.join(dir, f) });
169
187
  }
170
188
  catch { /* 坏文件跳过 */ }
171
189
  }
172
190
  return out;
173
191
  }
192
+ /** 按名字删除所有同名候选文件 (名可能与文件名前缀不完全一致) */
193
+ async function removeCandidateFiles(name, home) {
194
+ const safe = sanitizeSkillName(name);
195
+ const dir = getCandidateDir(home);
196
+ let files;
197
+ try {
198
+ files = await fs.readdir(dir);
199
+ }
200
+ catch {
201
+ return;
202
+ }
203
+ for (const f of files) {
204
+ if (!f.endsWith('.json'))
205
+ continue;
206
+ try {
207
+ const c = JSON.parse(await fs.readFile(path.join(dir, f), 'utf-8'));
208
+ if (sanitizeSkillName(c.name) === safe)
209
+ await fs.rm(path.join(dir, f), { force: true });
210
+ }
211
+ catch {
212
+ if (f.startsWith(safe))
213
+ await fs.rm(path.join(dir, f), { force: true });
214
+ }
215
+ }
216
+ }
174
217
  /** 把候选转正为正式 skill (可选: 转正后删除候选文件) */
175
218
  export async function promoteCandidate(name, opts = {}, home = os.homedir()) {
176
219
  const candidates = await listSkillCandidates(home);
@@ -179,18 +222,25 @@ export async function promoteCandidate(name, opts = {}, home = os.homedir()) {
179
222
  return { ok: false, path: '', error: `候选 '${name}' 不存在` };
180
223
  const r = await createSkill(c.name, c.description, c.body, opts);
181
224
  if (r.ok) {
182
- // 清理已转正的候选文件
183
- try {
184
- const dir = getCandidateDir(home);
185
- for (const f of (await fs.readdir(dir))) {
186
- if (f.startsWith(sanitizeSkillName(c.name) + '-'))
187
- await fs.rm(path.join(dir, f), { force: true });
188
- }
189
- }
190
- catch { /* 清理失败不阻塞 */ }
225
+ // 清理已转正的候选文件: 同 name 的所有候选
226
+ await removeCandidateFiles(c.name, home);
191
227
  }
192
228
  return r;
193
229
  }
230
+ /**
231
+ * 从一轮成功的工具调用生成稳定签名 — 同一套工具序列 (有序去重, 最多 4 个) 视为同一经验.
232
+ * 用于跨运行合并: 第二次跑同样的工具 → 更新同一个候选, 而不是新建一个.
233
+ */
234
+ export function toolSignature(okSteps) {
235
+ const seq = [];
236
+ for (const s of (okSteps || [])) {
237
+ if (s.name && !seq.includes(s.name))
238
+ seq.push(s.name);
239
+ if (seq.length >= 4)
240
+ break;
241
+ }
242
+ return seq.join('_');
243
+ }
194
244
  export async function writeRunEndSkillCandidates(steps, source, minOk = 2) {
195
245
  const okSteps = (steps || []).filter((s) => s.status === 'ok' && s.name && s.name !== 'system' && s.name !== '?');
196
246
  if (okSteps.length < minOk) {
@@ -200,13 +250,19 @@ export async function writeRunEndSkillCandidates(steps, source, minOk = 2) {
200
250
  const body = `## 背景\n本轮对话连续成功调用了 ${okSteps.length} 个工具: ${toolNames}.\n\n` +
201
251
  `## 流程\n${okSteps.map((s) => `1. 调用 ${s.name}${s.output ? ': ' + String(s.output).slice(0, 120) : ''}`).join('\n')}\n\n` +
202
252
  `## 注意事项\n- 工具名以 list_skills / get_operation_logs 的实际注册名为准\n- 沉淀为正式 skill 前请人工确认流程可复用\n`;
203
- const candName = `auto-${okSteps[0].name}-${Date.now().toString(36)}`;
253
+ // 2026-08-08: 稳定签名 + 固定文件名 → 同一套工具反复跑时合并更新到同一个候选 (runs++)
254
+ const signature = toolSignature(okSteps);
255
+ const candName = `auto-${signature}`;
256
+ const existing = (await listSkillCandidates()).find((x) => x.signature === signature || sanitizeSkillName(x.name) === sanitizeSkillName(candName));
204
257
  const file = await writeSkillCandidate({
205
258
  name: candName,
206
259
  description: `自动候选: ${okSteps.length} 个工具连续成功 (${toolNames})`,
207
260
  body,
208
261
  source,
209
262
  timestamp: new Date().toISOString(),
263
+ signature,
210
264
  });
211
- return { wrote: true, file, count: okSteps.length, names: toolNames };
265
+ const merged = !!existing;
266
+ const runs = (existing?.runs ?? 0) + 1;
267
+ return { wrote: true, file, count: okSteps.length, names: toolNames, merged, runs };
212
268
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * did-catalog.ts — 以用户 DID 为唯一标识的 Postgres 式关系目录 (2026-08-08)
3
+ *
4
+ * 借鉴 Postgres 标准设计:
5
+ * - 表 (table) 由列 (columns) 定义, 每个表有主键 (dsc_key, 默认 = row 的 id 字段)
6
+ * - 每张表 + 每行都以主键 did 分区: 同一用户的全部数据通过 userDid 绑定
7
+ * - 变更写入日志 (WAL: append-only event log), 与目录一起落盘
8
+ * - 多设备同步 = 拉取另一台设备的 WAL → 回放 (replay) → 按 updatedAt LWW 合并
9
+ *
10
+ * 数据模型 (Postgres 式 DDL):
11
+ * TABLE memory (did, dsc_key, content, meta, updatedAt, deviceId)
12
+ * TABLE vocal (did, agentId, persona/name, did, publicKey...)
13
+ * TABLE on_policy (did, policyVersion, policy(JSON), updatedAt)
14
+ * TABLE skills ...
15
+ * TABLE tools / plugins / mcp / context_os ...
16
+ *
17
+ * WAL 事件谱:
18
+ * { seq, did, table, op: upsert|delete, key, dscid, row, ts, deviceId }
19
+ * 回放/合并规则: 同 (did,table,dscid) → 取 updatedAt 较大者 (LWW); 更新更大的 `rev`
20
+ */
21
+ import * as fs from 'fs/promises';
22
+ import * as path from 'path';
23
+ import * as os from 'os';
24
+ export const ALL_TABLES = [
25
+ 'memory', 'persona', 'on_policy', 'skills', 'tools', 'plugins', 'mcp', 'context_os', 'channels',
26
+ ];
27
+ const homeDir = (h) => {
28
+ if (h)
29
+ return h;
30
+ return process.env.HOME || os.homedir() || '/tmp';
31
+ };
32
+ /** 安全 DID → 目录名片段 */
33
+ export function didDirName(did) {
34
+ return did.split(':').pop()?.substring(0, 24) || 'unknown';
35
+ }
36
+ export class DidCatalog {
37
+ did;
38
+ deviceId;
39
+ root;
40
+ tables = {};
41
+ wal = [];
42
+ nextSeq = 0;
43
+ dirty = false;
44
+ constructor(did, opts = {}) {
45
+ this.did = did;
46
+ this.deviceId = opts.deviceId || 'device1';
47
+ const base = path.join(homeDir(opts.home), '.bolloon', 'did-catalog');
48
+ // 以 did 分区: ~/.bolloon/did-catalog/<did>/table.json + wal.jsonl
49
+ this.root = path.join(base, didDirName(did));
50
+ for (const t of ALL_TABLES)
51
+ this.tables[t] = new Map();
52
+ }
53
+ get rootPath() { return this.root; }
54
+ tablePath(t) { return path.join(this.root, `${t}.json`); }
55
+ walPath() { return path.join(this.root, 'wal.jsonl'); }
56
+ /** 加载本地持久化 (表 + WAL)。幂等, 可重复调。 */
57
+ async load() {
58
+ await fs.mkdir(this.root, { recursive: true });
59
+ for (const t of ALL_TABLES) {
60
+ try {
61
+ const raw = JSON.parse(await fs.readFile(this.tablePath(t), 'utf-8'));
62
+ this.tables[t] = new Map(Object.entries(raw));
63
+ }
64
+ catch {
65
+ this.tables[t] = new Map();
66
+ }
67
+ }
68
+ // WAL 回放自身劫
69
+ this.wal = [];
70
+ try {
71
+ const text = await fs.readFile(this.walPath(), 'utf-8');
72
+ for (const line of text.split('\n')) {
73
+ if (!line.trim())
74
+ continue;
75
+ try {
76
+ const e = JSON.parse(line);
77
+ this.wal.push(e);
78
+ if (e.seq >= this.nextSeq)
79
+ this.nextSeq = e.seq + 1;
80
+ }
81
+ catch { /* 坏行跳过 */ }
82
+ }
83
+ }
84
+ catch { /* 无 WAL */ }
85
+ }
86
+ /** 无参 upsert / write-return. @param keys -> 主键自动填 */
87
+ async upsert(table, key, data) {
88
+ const now = Date.now();
89
+ const existing = this.tables[table]?.get(key);
90
+ const ev = {
91
+ seq: this.nextSeq++,
92
+ did: this.did,
93
+ table,
94
+ op: 'upsert',
95
+ key,
96
+ row: { ...existing?.data, ...data, dscKey: key, updatedAt: now, deviceId: this.deviceId },
97
+ ts: now,
98
+ deviceId: this.deviceId,
99
+ };
100
+ this.tables[table].set(key, {
101
+ dscKey: key,
102
+ data: ev.row,
103
+ updatedAt: now,
104
+ deviceId: this.deviceId,
105
+ });
106
+ this.wal.push(ev);
107
+ this.dirty = true;
108
+ return ev;
109
+ }
110
+ /** 删除一行 */
111
+ async remove(table, key) {
112
+ if (!this.tables[table]?.has(key))
113
+ return null;
114
+ const ev = {
115
+ seq: this.nextSeq++, did: this.did, table, op: 'delete', key,
116
+ row: null, ts: Date.now(), deviceId: this.deviceId,
117
+ };
118
+ this.tables[table].delete(key);
119
+ this.wal.push(ev);
120
+ this.dirty = true;
121
+ return ev;
122
+ }
123
+ /** 读一行 */
124
+ get(table, key) {
125
+ return this.tables[table]?.get(key);
126
+ }
127
+ /** 全表 */
128
+ all(table) {
129
+ return Array.from((this.tables[table] || new Map()).entries()).map(([k, r]) => ({ key: k, row: r }));
130
+ }
131
+ /** 基础查询: 按字段精确匹配 (Postgres WHERE col = v) */
132
+ where(table, col, value) {
133
+ return this.all(table).filter(({ row }) => {
134
+ const v = row.data[col];
135
+ return String(v) === String(value);
136
+ }).map(({ row }) => row);
137
+ }
138
+ get walEvents() { return Array.from(this.wal); }
139
+ /**
140
+ * 多设备同步入口: 接收一台设备的 WAL 事件流, 按 updatedAt LWW 合并.
141
+ * 返回 {applied, merged} — applied=本机新增, merged=LWW 冲突保留较大者.
142
+ */
143
+ syncRemote(events) {
144
+ const byKey = new Map();
145
+ for (const e of events) {
146
+ if (!e.table || !e.key)
147
+ continue;
148
+ if (e.did && e.did !== this.did)
149
+ continue; // 只收本用户 DID 的数据
150
+ const signature = `${e.table}::${e.key}`;
151
+ if (e.op === 'delete') {
152
+ byKey.set(signature, e);
153
+ continue;
154
+ }
155
+ const prior = byKey.get(signature);
156
+ if (!prior || (e.ts ?? 0) >= (prior.ts ?? 0))
157
+ byKey.set(signature, e);
158
+ }
159
+ let applied = 0, merged = 0;
160
+ for (const e of byKey.values()) {
161
+ if (e.op === 'delete') {
162
+ const had = this.tables[e.table]?.has(e.key);
163
+ if (had) {
164
+ this.tables[e.table]?.delete(e.key);
165
+ applied++;
166
+ }
167
+ continue;
168
+ }
169
+ const cur = this.tables[e.table]?.get(e.key);
170
+ if (cur && (cur.updatedAt ?? 0) > (e.ts ?? 0)) {
171
+ merged++;
172
+ continue;
173
+ } // 本地更新 → 保留本地
174
+ this.tables[e.table]?.set(e.key, {
175
+ dscKey: e.key,
176
+ data: e.row,
177
+ updatedAt: e.ts ?? Date.now(),
178
+ deviceId: e.deviceId || 'remote',
179
+ });
180
+ applied++;
181
+ }
182
+ this.dirty = true;
183
+ return { applied, merged };
184
+ }
185
+ /** 持久化表 + WAL 到磁盘 */
186
+ async persist() {
187
+ await fs.mkdir(this.root, { recursive: true });
188
+ for (const t of ALL_TABLES) {
189
+ const obj = {};
190
+ for (const [k, r] of this.tables[t])
191
+ obj[k] = r;
192
+ await fs.writeFile(this.tablePath(t), JSON.stringify(obj, null, 2), 'utf-8');
193
+ }
194
+ const lines = this.wal.map(e => JSON.stringify(e)).join('\n') + '\n';
195
+ await fs.writeFile(this.walPath(), lines, 'utf-8');
196
+ this.dirty = false;
197
+ }
198
+ get isDirty() { return this.dirty; }
199
+ }
200
+ /** 轻量单例注册: 同 DID 复用同一目录实例 (跨模块共享) */
201
+ export function createDidCatalogRegistry(opts = {}) {
202
+ const cache = new Map();
203
+ return {
204
+ async open(did) {
205
+ if (!did)
206
+ throw new Error('did 必填');
207
+ if (!cache.has(did)) {
208
+ cache.set(did, (async () => {
209
+ const c = new DidCatalog(did, opts);
210
+ await c.load();
211
+ return c;
212
+ })());
213
+ }
214
+ return cache.get(did);
215
+ },
216
+ async persistAll() {
217
+ for (const p of cache.values()) {
218
+ const c = await p;
219
+ if (c.isDirty)
220
+ await c.persist();
221
+ }
222
+ },
223
+ };
224
+ }
225
+ /** 进程级默认注册表 (server / CLI 共用, 同 DID 复用实例) */
226
+ const _defaultRegistry = createDidCatalogRegistry();
227
+ /** 按 did 打开的目录 (默认注册表单例) — 供各端点快速接入 */
228
+ export async function registryOpen(did, opts) {
229
+ const reg = opts ? createDidCatalogRegistry(opts) : _defaultRegistry;
230
+ return reg.open(did);
231
+ }
@@ -2759,6 +2759,65 @@ ${goalDesc}
2759
2759
  res.status(500).json({ error: err.message });
2760
2760
  }
2761
2761
  });
2762
+ // 2026-08-08: DID 目录 (Postgres 式) — 以用户 DID 为主键, 绑定 memory/persona/skills/on_policy/context_os 等表
2763
+ app.get('/api/did-catalog/:table', async (req, res) => {
2764
+ try {
2765
+ const table = String(req.params.table || '');
2766
+ const { registryOpen, ALL_TABLES } = await import('../storage/did-catalog.js');
2767
+ if (!ALL_TABLES.includes(table)) {
2768
+ return res.status(400).json({ error: `未知表: ${table}`, tables: ALL_TABLES });
2769
+ }
2770
+ const identity = await loadOrCreateUserIdentity();
2771
+ if (!identity.did)
2772
+ return res.status(404).json({ error: '未生成用户 DID' });
2773
+ const cat = await registryOpen(identity.did);
2774
+ const rows = cat.all(table).map(({ key, row }) => ({ key, data: row.data, updatedAt: row.updatedAt, deviceId: row.deviceId }));
2775
+ res.json({ did: identity.did, table, rows, deviceId: cat.deviceId });
2776
+ }
2777
+ catch (err) {
2778
+ res.status(500).json({ error: err.message });
2779
+ }
2780
+ });
2781
+ // 写入 DID 目录单行 (以用户 DID 为分区主键) — 供插件/工具把各类数据绑定到用户
2782
+ app.post('/api/did-catalog/:table', async (req, res) => {
2783
+ try {
2784
+ const table = String(req.params.table || '');
2785
+ const { key, data } = req.body || {};
2786
+ const { registryOpen, ALL_TABLES } = await import('../storage/did-catalog.js');
2787
+ if (!ALL_TABLES.includes(table)) {
2788
+ return res.status(400).json({ error: `未知表: ${table}`, tables: ALL_TABLES });
2789
+ }
2790
+ if (!key)
2791
+ return res.status(400).json({ error: 'key 必填' });
2792
+ const identity = await loadOrCreateUserIdentity();
2793
+ if (!identity.did)
2794
+ return res.status(404).json({ error: '未生成用户 DID' });
2795
+ const cat = await registryOpen(identity.did);
2796
+ const ev = await cat.upsert(table, String(key), (data && typeof data === 'object' ? data : {}));
2797
+ res.json({ ok: true, did: identity.did, table, key, seq: ev.seq, deviceId: cat.deviceId });
2798
+ }
2799
+ catch (err) {
2800
+ res.status(500).json({ error: err.message });
2801
+ }
2802
+ });
2803
+ // 拉取某台的 WAL → 多设备同步合并
2804
+ app.post('/api/did-catalog/sync', async (req, res) => {
2805
+ try {
2806
+ const { events } = req.body || {};
2807
+ if (!Array.isArray(events))
2808
+ return res.status(400).json({ error: 'events 必填数组' });
2809
+ const identity = await loadOrCreateUserIdentity();
2810
+ if (!identity.did)
2811
+ return res.status(404).json({ error: '未生成用户 DID' });
2812
+ const { registryOpen } = await import('../storage/did-catalog.js');
2813
+ const cat = await registryOpen(identity.did);
2814
+ const r = cat.syncRemote(events);
2815
+ res.json({ ok: true, did: identity.did, applied: r.applied, merged: r.merged });
2816
+ }
2817
+ catch (err) {
2818
+ res.status(500).json({ error: err.message });
2819
+ }
2820
+ });
2762
2821
  // 2026-07-01 (v0.2.6): 前后端分离核心 — 后端切 LLM 输出为结构化 segments
2763
2822
  // - POST /api/segment-reply { reply, knownTools }
2764
2823
  // - 返回 ChatSegment[] (think / text / env_details / tool_call / final)
@@ -6567,6 +6626,22 @@ ${goalDesc}
6567
6626
  const success = writePolicy(newPolicy);
6568
6627
  if (success) {
6569
6628
  auditShellCall('allowed', 'api:PUT:/api/self-improve/policy', [], `人类用户更新策略`);
6629
+ // 2026-08-08: 策略版本写入 DID 目录 (on-policy 以用户 did 绑定)
6630
+ try {
6631
+ const identity = await loadOrCreateUserIdentity();
6632
+ if (identity.did) {
6633
+ const { registryOpen } = await import('../storage/did-catalog.js');
6634
+ const cat = await registryOpen(identity.did);
6635
+ await cat.upsert('on_policy', `pol-${newPolicy.version || 0}`, {
6636
+ version: newPolicy.version || 0,
6637
+ policy: JSON.parse(JSON.stringify(newPolicy)),
6638
+ updatedAt: Date.now(),
6639
+ });
6640
+ }
6641
+ }
6642
+ catch (e) {
6643
+ console.warn('[did-catalog] 记录 on-policy 失败(非致命):', e?.message);
6644
+ }
6570
6645
  res.json({ ok: true, message: '策略已更新, 60 秒内生效' });
6571
6646
  }
6572
6647
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.42",
3
+ "version": "0.3.44",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",