@bolloon/bolloon-agent 0.3.24 → 0.3.25
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/README.md +83 -474
- package/dist/agents/pi-sdk-tools.js +337 -15
- package/dist/agents/plan-store.js +167 -0
- package/dist/agents/skill-writer.js +193 -0
- package/dist/agents/workflow-pivot-loop.js +86 -25
- package/dist/constraint-runtime/src/tools/PolymarketSDK/cancelOrder.js +1 -1
- package/dist/constraint-runtime/src/tools/PolymarketSDK/createOrder.js +1 -1
- package/dist/constraint-runtime/src/tools/PolymarketSDK/getOrders.js +1 -1
- package/dist/network/known-peers.js +52 -8
- package/dist/security/tool-gate.js +4 -0
- package/dist/web/client.js +520 -17
- package/dist/web/index.html +6 -2
- package/dist/web/server.js +481 -29
- package/dist/web/style.css +14 -10
- package/dist/web/ui/message-renderer.js +42 -3
- package/dist/web/ui/step-timeline.js +5 -0
- package/package.json +6 -2
- package/bin/ipfs +0 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-writer.ts — skill 的创建 / 更新 / 删除 (2026-08-02)
|
|
3
|
+
*
|
|
4
|
+
* 背景: skill-loader.ts 只读 SKILL.md, 没有写路径 → agent 无法从成功经验沉淀技能,
|
|
5
|
+
* "越用越聪明" 的闭环缺失. 本模块补齐写侧:
|
|
6
|
+
*
|
|
7
|
+
* - createSkill(name, description, body, opts) → 写 ~/.bolloon/skills/<name>/SKILL.md
|
|
8
|
+
* - updateSkill(name, patch) → 更新已有 SKILL.md (description / body 追加或替换)
|
|
9
|
+
* - listSkillCandidates(dir) → 扫描 run-end 候选沉淀目录
|
|
10
|
+
* - loadSkillsFromPaths (复用 skill-loader)
|
|
11
|
+
*
|
|
12
|
+
* 安全:
|
|
13
|
+
* - 只写 ~/.bolloon/skills/ (全局) 和 <cwd>/.bolloon/skills/ (项目), 不碰其他路径
|
|
14
|
+
* - 目录名 sanitize: 只允许 [a-z0-9_-], 防路径穿越
|
|
15
|
+
* - 大小上限 50KB, 防 LLM 写爆
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs/promises';
|
|
18
|
+
import * as os from 'os';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
/** 全局用户级 skills 目录 */
|
|
21
|
+
export function getUserSkillsDir(home = os.homedir()) {
|
|
22
|
+
return path.join(home, '.bolloon', 'skills');
|
|
23
|
+
}
|
|
24
|
+
/** 项目级 skills 目录 */
|
|
25
|
+
export function getProjectSkillsDir(cwd = process.cwd()) {
|
|
26
|
+
return path.join(cwd, '.bolloon', 'skills');
|
|
27
|
+
}
|
|
28
|
+
/** skill 名 sanitize: 只允许 [a-z0-9_-], 长度 ≤ 64, 去掉首尾连字符 */
|
|
29
|
+
export function sanitizeSkillName(name) {
|
|
30
|
+
return name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 创建或覆盖一个 skill
|
|
34
|
+
* @param name skill 名 (会 sanitize)
|
|
35
|
+
* @param description 一句话描述
|
|
36
|
+
* @param body Markdown 正文 (步骤 / 命令 / 注意事项)
|
|
37
|
+
*/
|
|
38
|
+
export async function createSkill(name, description, body, opts = {}) {
|
|
39
|
+
const safeName = sanitizeSkillName(name);
|
|
40
|
+
if (!safeName)
|
|
41
|
+
return { ok: false, path: '', error: 'skill 名非法 (sanitize 后为空)' };
|
|
42
|
+
if (body.length > 50_000)
|
|
43
|
+
return { ok: false, path: '', error: `正文过长 (${body.length} > 50000 字节)` };
|
|
44
|
+
const dir = opts.scope === 'project' ? getProjectSkillsDir() : getUserSkillsDir();
|
|
45
|
+
const skillDir = path.join(dir, safeName);
|
|
46
|
+
const file = path.join(skillDir, 'SKILL.md');
|
|
47
|
+
// 覆盖保护
|
|
48
|
+
if (opts.overwrite === false) {
|
|
49
|
+
try {
|
|
50
|
+
await fs.access(file);
|
|
51
|
+
return { ok: false, path: file, error: `skill '${safeName}' 已存在 (overwrite=false)` };
|
|
52
|
+
}
|
|
53
|
+
catch { /* 不存在, 可写 */ }
|
|
54
|
+
}
|
|
55
|
+
const status = opts.status || 'active';
|
|
56
|
+
const triggersBlock = opts.triggers && opts.triggers.length > 0
|
|
57
|
+
? `triggers:\n${opts.triggers.map(t => ` - "${t.replace(/"/g, "'")}"`).join('\n')}\n`
|
|
58
|
+
: '';
|
|
59
|
+
const frontmatter = `---\nname: ${safeName}\ndescription: ${String(description || '').replace(/\n/g, ' ').slice(0, 200)}\nstatus: ${status}\n${triggersBlock}---\n`;
|
|
60
|
+
const content = frontmatter + '\n' + body.trim() + '\n';
|
|
61
|
+
try {
|
|
62
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
63
|
+
await fs.writeFile(file, content, 'utf-8');
|
|
64
|
+
return { ok: true, path: file };
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
return { ok: false, path: file, error: `写入失败: ${e?.message || String(e)}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 更新已有 skill. 找不到则报错 (不自动创建).
|
|
72
|
+
*/
|
|
73
|
+
export async function updateSkill(name, opts) {
|
|
74
|
+
const safeName = sanitizeSkillName(name);
|
|
75
|
+
if (!safeName)
|
|
76
|
+
return { ok: false, path: '', error: 'skill 名非法' };
|
|
77
|
+
// 先找现有文件 (用户级 + 项目级)
|
|
78
|
+
const candidates = [
|
|
79
|
+
path.join(getUserSkillsDir(), safeName, 'SKILL.md'),
|
|
80
|
+
path.join(getProjectSkillsDir(), safeName, 'SKILL.md'),
|
|
81
|
+
];
|
|
82
|
+
let file = '';
|
|
83
|
+
for (const c of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
await fs.access(c);
|
|
86
|
+
file = c;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
catch { /* 不存在 */ }
|
|
90
|
+
}
|
|
91
|
+
if (!file)
|
|
92
|
+
return { ok: false, path: '', error: `skill '${safeName}' 不存在, 用 create_skill 创建` };
|
|
93
|
+
try {
|
|
94
|
+
let raw = await fs.readFile(file, 'utf-8');
|
|
95
|
+
let body = raw.replace(/^---[\s\S]*?---\n?/, '').trim(); // 剥 frontmatter
|
|
96
|
+
if (opts.body !== undefined) {
|
|
97
|
+
body = opts.body.trim();
|
|
98
|
+
}
|
|
99
|
+
else if (opts.appendBody) {
|
|
100
|
+
body = (body + '\n\n' + opts.appendBody.trim()).slice(0, 50_000);
|
|
101
|
+
}
|
|
102
|
+
// 重建 frontmatter
|
|
103
|
+
const { parseSkillFile } = await import('./skill-loader.js');
|
|
104
|
+
const meta = await parseSkillFile(file);
|
|
105
|
+
const fm = meta?.frontmatter || {};
|
|
106
|
+
const fmLines = [];
|
|
107
|
+
fmLines.push(`name: ${safeName}`);
|
|
108
|
+
fmLines.push(`description: ${String(opts.description ?? meta?.description ?? '').replace(/\n/g, ' ').slice(0, 200)}`);
|
|
109
|
+
fmLines.push(`status: ${opts.status ?? meta?.status ?? 'active'}`);
|
|
110
|
+
const triggers = opts.triggers ?? meta?.triggers ?? [];
|
|
111
|
+
if (triggers.length > 0) {
|
|
112
|
+
fmLines.push('triggers:');
|
|
113
|
+
for (const t of triggers)
|
|
114
|
+
fmLines.push(` - "${String(t).replace(/"/g, "'")}"`);
|
|
115
|
+
}
|
|
116
|
+
const content = '---\n' + fmLines.join('\n') + '\n---\n\n' + body + '\n';
|
|
117
|
+
await fs.writeFile(file, content, 'utf-8');
|
|
118
|
+
return { ok: true, path: file };
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
return { ok: false, path: file, error: `更新失败: ${e?.message || String(e)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** 删除 skill (返回删除的文件路径) */
|
|
125
|
+
export async function deleteSkill(name) {
|
|
126
|
+
const safeName = sanitizeSkillName(name);
|
|
127
|
+
if (!safeName)
|
|
128
|
+
return { ok: false, path: '', error: 'skill 名非法' };
|
|
129
|
+
const candidates = [
|
|
130
|
+
path.join(getUserSkillsDir(), safeName),
|
|
131
|
+
path.join(getProjectSkillsDir(), safeName),
|
|
132
|
+
];
|
|
133
|
+
for (const c of candidates) {
|
|
134
|
+
try {
|
|
135
|
+
await fs.rm(c, { recursive: true, force: true });
|
|
136
|
+
return { ok: true, path: c };
|
|
137
|
+
}
|
|
138
|
+
catch { /* 尝试下一个 */ }
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, path: '', error: `skill '${safeName}' 不存在` };
|
|
141
|
+
}
|
|
142
|
+
export function getCandidateDir(home = os.homedir()) {
|
|
143
|
+
return path.join(home, '.bolloon', 'skill-candidates');
|
|
144
|
+
}
|
|
145
|
+
export async function writeSkillCandidate(c) {
|
|
146
|
+
const dir = getCandidateDir();
|
|
147
|
+
await fs.mkdir(dir, { recursive: true });
|
|
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');
|
|
151
|
+
return file;
|
|
152
|
+
}
|
|
153
|
+
export async function listSkillCandidates(home = os.homedir()) {
|
|
154
|
+
const dir = getCandidateDir(home);
|
|
155
|
+
let entries;
|
|
156
|
+
try {
|
|
157
|
+
entries = await fs.readdir(dir);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const f of entries.filter(f => f.endsWith('.json')).sort().slice(-50)) {
|
|
164
|
+
try {
|
|
165
|
+
const raw = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
166
|
+
const c = JSON.parse(raw);
|
|
167
|
+
if (c.name && c.body)
|
|
168
|
+
out.push(c);
|
|
169
|
+
}
|
|
170
|
+
catch { /* 坏文件跳过 */ }
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/** 把候选转正为正式 skill (可选: 转正后删除候选文件) */
|
|
175
|
+
export async function promoteCandidate(name, opts = {}, home = os.homedir()) {
|
|
176
|
+
const candidates = await listSkillCandidates(home);
|
|
177
|
+
const c = candidates.find(x => x.name === name || sanitizeSkillName(x.name) === sanitizeSkillName(name));
|
|
178
|
+
if (!c)
|
|
179
|
+
return { ok: false, path: '', error: `候选 '${name}' 不存在` };
|
|
180
|
+
const r = await createSkill(c.name, c.description, c.body, opts);
|
|
181
|
+
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 { /* 清理失败不阻塞 */ }
|
|
191
|
+
}
|
|
192
|
+
return r;
|
|
193
|
+
}
|
|
@@ -128,6 +128,58 @@ export class WorkflowPivotLoop {
|
|
|
128
128
|
this.registerTool(tool);
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* 2026-08-02: 把 this.tools Map 转成 OpenAI 原生 tools 格式 (含参数 schema).
|
|
133
|
+
* pivot loop 之前只把工具描述塞 system prompt, LLM 靠文本 JSON 猜格式
|
|
134
|
+
* (deepseek 输出 {"name":"X","result":{...}} 编造结果), 从不真正执行工具.
|
|
135
|
+
* 传原生 tools + tool_choice auto → LLM 返回结构化 tool_calls.
|
|
136
|
+
*/
|
|
137
|
+
buildOpenAITools() {
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const [name, tool] of this.tools) {
|
|
140
|
+
const params = tool.parameters || {};
|
|
141
|
+
const properties = {};
|
|
142
|
+
const required = [];
|
|
143
|
+
for (const [pName, pDesc] of Object.entries(params)) {
|
|
144
|
+
properties[pName] = { type: 'string', description: String(pDesc) };
|
|
145
|
+
if (String(pDesc).includes('必填'))
|
|
146
|
+
required.push(pName);
|
|
147
|
+
}
|
|
148
|
+
out.push({
|
|
149
|
+
type: 'function',
|
|
150
|
+
function: {
|
|
151
|
+
name,
|
|
152
|
+
description: tool.description || name,
|
|
153
|
+
parameters: { type: 'object', properties, required },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 2026-08-02: 把 OpenAI 原生 tool_calls (结构化) 转成 ToolDefinition 数组.
|
|
161
|
+
* 优先于文本 JSON 解析 — LLM 返回 tool_calls 时直接执行, 不再猜格式.
|
|
162
|
+
*/
|
|
163
|
+
nativeToolCallsToDefinitions(toolCalls) {
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const tc of toolCalls || []) {
|
|
166
|
+
const fn = tc?.function;
|
|
167
|
+
if (!fn || !fn.name)
|
|
168
|
+
continue;
|
|
169
|
+
const name = fn.name;
|
|
170
|
+
if (!this.tools.has(name))
|
|
171
|
+
continue;
|
|
172
|
+
let args = {};
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(fn.arguments || '{}');
|
|
175
|
+
if (parsed && typeof parsed === 'object')
|
|
176
|
+
args = parsed;
|
|
177
|
+
}
|
|
178
|
+
catch { /* args 保持空 */ }
|
|
179
|
+
out.push({ name, args: this.normalizeArgs(args), description: '', parameters: {} });
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
131
183
|
/**
|
|
132
184
|
* Execute the pivot loop
|
|
133
185
|
*/
|
|
@@ -202,9 +254,12 @@ export class WorkflowPivotLoop {
|
|
|
202
254
|
try {
|
|
203
255
|
// Call LLM
|
|
204
256
|
const t0 = Date.now();
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
257
|
+
// 2026-08-02: 传原生 OpenAI tools — deepseek 返回结构化 tool_calls,
|
|
258
|
+
// UI 才能显示真实的工具执行 step (之前靠文本 JSON 猜格式, LLM 编造 result)
|
|
259
|
+
const openAITools = this.buildOpenAITools();
|
|
260
|
+
const llmResponse = await llm.chat(context, headerForThisIter, signal, openAITools);
|
|
261
|
+
const reply = (llmResponse.reply || '').trim();
|
|
262
|
+
this.vlog(`[pivot] iter=${this.state.iteration} LLM took=${Date.now() - t0}ms reply=${reply.length} nativeToolCalls=${llmResponse.toolCalls?.length ?? 0} head=${reply.substring(0, 80).replace(/\n/g, ' ')}`);
|
|
208
263
|
this.emit({ type: 'token', content: reply.substring(0, 100) });
|
|
209
264
|
// 2026-07-06: 把完整 reply 推给前端 — 前端按需更新临时气泡
|
|
210
265
|
// 之前只 emit token(100B 截断), 前端拿到 100B 看不清. 现在 emit preview 带完整 content.
|
|
@@ -248,7 +303,10 @@ export class WorkflowPivotLoop {
|
|
|
248
303
|
return this.createResult(false, response, 'token_budget_exceeded');
|
|
249
304
|
}
|
|
250
305
|
// Check if this is a final response (no tool calls)
|
|
251
|
-
|
|
306
|
+
// 2026-08-02: 优先用 OpenAI 原生 tool_calls (结构化, LLM 不会编造 result),
|
|
307
|
+
// 没有才 fallback 到文本 JSON 解析 (extractPendingToolUses)
|
|
308
|
+
const nativeTools = this.nativeToolCallsToDefinitions(llmResponse.toolCalls || []);
|
|
309
|
+
const pendingTools = nativeTools.length > 0 ? nativeTools : this.extractPendingToolUses(reply);
|
|
252
310
|
if (pendingTools.length === 0) {
|
|
253
311
|
// 2026-07-06: LLM 显式 <final gen> 标记 — pivot 立即退出, 不再走 quality/iter 流程
|
|
254
312
|
// 这个 marker 之前和 思考/ 同义被忽略, 害得 "你好" 类问题跑 11 iter 还不见停
|
|
@@ -558,29 +616,27 @@ export class WorkflowPivotLoop {
|
|
|
558
616
|
// JSON parsing failed, ignore
|
|
559
617
|
}
|
|
560
618
|
// Pattern 4: Single JSON tool call format {"name": "tool_name", "arguments": {...}}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
catch {
|
|
578
|
-
// JSON parsing failed, skip
|
|
619
|
+
// 2026-08-02 fix: 兼容 system prompt 教的 {"name":"X","input":{...}} 格式 —
|
|
620
|
+
// pi-sdk.ts 工具调用格式说明用 input 字段, 但解析器只认 arguments,
|
|
621
|
+
// 导致 LLM 输出 {"name":"read_document","input":{...}} 解析不到 → 工具永不执行。
|
|
622
|
+
const singleJsonRe = /\{\s*"name"\s*:\s*"(\w+)"\s*,\s*"(?:arguments|input)"\s*:\s*(\{[\s\S]*?\})\s*\}/g;
|
|
623
|
+
let singleMatch;
|
|
624
|
+
while ((singleMatch = singleJsonRe.exec(content)) !== null) {
|
|
625
|
+
const name = singleMatch[1];
|
|
626
|
+
if (pending.some(p => p.name === name))
|
|
627
|
+
continue;
|
|
628
|
+
if (!this.tools.has(name))
|
|
629
|
+
continue;
|
|
630
|
+
try {
|
|
631
|
+
const args = JSON.parse(singleMatch[2]);
|
|
632
|
+
if (args && typeof args === 'object') {
|
|
633
|
+
const normalizedArgs = this.normalizeArgs(args);
|
|
634
|
+
pending.push({ name, args: normalizedArgs, description: '', parameters: {} });
|
|
579
635
|
}
|
|
580
636
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
637
|
+
catch {
|
|
638
|
+
// JSON parsing failed, skip
|
|
639
|
+
}
|
|
584
640
|
}
|
|
585
641
|
return pending;
|
|
586
642
|
}
|
|
@@ -646,6 +702,11 @@ export class WorkflowPivotLoop {
|
|
|
646
702
|
return this.messageHistory.map(m => {
|
|
647
703
|
if (m.role === 'user')
|
|
648
704
|
return `用户: ${m.content}`;
|
|
705
|
+
if (m.role === 'assistant' && m.toolCall && m.toolResult) {
|
|
706
|
+
// 2026-08-02 fix: 工具调用后的 assistant 消息带 toolCall/toolResult,
|
|
707
|
+
// 之前只输出 content (原生 tool_calls 时 content 为空) → LLM 永远看不到结果 → 无限重试同一工具
|
|
708
|
+
return `工具调用: ${m.toolCall.name}(${JSON.stringify(m.toolCall.args)})\n工具结果: ${JSON.stringify(m.toolResult)}`;
|
|
709
|
+
}
|
|
649
710
|
if (m.role === 'assistant')
|
|
650
711
|
return `助手: ${m.content}`;
|
|
651
712
|
if (m.role === 'tool' && m.toolResult) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildClobClient, fetchMarketMeta, resolveTokenId } from './clobShared';
|
|
1
|
+
import { buildClobClient, fetchMarketMeta, resolveTokenId } from './clobShared.js';
|
|
2
2
|
export async function createOrder(params) {
|
|
3
3
|
if (!params.privateKey) {
|
|
4
4
|
return { success: false, message: '下单需要提供钱包私钥 privateKey (用于 EIP-712 订单签名)' };
|
|
@@ -31,20 +31,64 @@ async function writeFile(data) {
|
|
|
31
31
|
await ensureDir();
|
|
32
32
|
await fs.writeFile(KNOWN_PEERS_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
33
33
|
}
|
|
34
|
-
/** 添加或更新一个 known peer (key 用 name) */
|
|
34
|
+
/** 添加或更新一个 known peer (key 用 name, 但按 publicKey 去重) */
|
|
35
35
|
export async function addOrUpdatePeer(name, publicKey, notes) {
|
|
36
36
|
const safeName = (name && name.length > 0) ? name : `peer-${publicKey.substring(0, 8)}`;
|
|
37
37
|
const data = await readFile();
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// 2026-08-02 fix: 按 publicKey 去重 — 之前用 name 作 key, 同一 publicKey 被
|
|
39
|
+
// 自动发现 (discovered-xxx) / 手动添加 (备注名) / manifest 重命名 (ownerName)
|
|
40
|
+
// 等多条路径写入时, 会生成多条重复条目 (apple/mechrevo/node 指向同一节点)。
|
|
41
|
+
// 现在: 若 publicKey 已存在 (无论 name 是什么), 复用那条 entry 只更新名字/备注。
|
|
42
|
+
let existingEntry;
|
|
43
|
+
let existingName = null;
|
|
44
|
+
for (const [n, p] of Object.entries(data.peers)) {
|
|
45
|
+
if (p.publicKey === publicKey) {
|
|
46
|
+
existingEntry = p;
|
|
47
|
+
existingName = n;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const targetName = existingName || safeName;
|
|
52
|
+
if (existingEntry) {
|
|
53
|
+
// 同 publicKey 已存在 → 更新 (保留原 addedAt, 新名字非 discovered- 前缀时替换)
|
|
54
|
+
// 2026-08-02 二次修: 用户手动命名过的条目 (非 discovered- 前缀, 如 mechrevo)
|
|
55
|
+
// 不应被自动来源的名字 (senderName/node/ownerName) 覆盖 — 只有自动名
|
|
56
|
+
// (discovered-xxx / peer-xxx) 才允许被替换成更有意义的名称。
|
|
57
|
+
const userNamed = existingName && !existingName.startsWith('discovered-') && !existingName.startsWith('peer-');
|
|
58
|
+
const keepAutoName = existingName?.startsWith('discovered-') && !name;
|
|
59
|
+
let finalName;
|
|
60
|
+
if (userNamed) {
|
|
61
|
+
finalName = existingName; // 保留用户命名
|
|
62
|
+
}
|
|
63
|
+
else if (keepAutoName) {
|
|
64
|
+
finalName = existingName;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
finalName = safeName;
|
|
68
|
+
}
|
|
69
|
+
if (finalName !== existingName) {
|
|
70
|
+
delete data.peers[existingName];
|
|
71
|
+
}
|
|
72
|
+
data.peers[finalName] = {
|
|
73
|
+
publicKey,
|
|
74
|
+
name: finalName,
|
|
75
|
+
addedAt: existingEntry.addedAt || new Date().toISOString(),
|
|
76
|
+
lastConnectedAt: existingEntry.lastConnectedAt,
|
|
77
|
+
notes: notes || existingEntry.notes
|
|
78
|
+
};
|
|
79
|
+
await writeFile(data);
|
|
80
|
+
console.log(`[known-peers] 更新 (publicKey 去重): ${finalName} = ${publicKey.substring(0, 12)}...`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
data.peers[targetName] = {
|
|
40
84
|
publicKey,
|
|
41
|
-
name:
|
|
42
|
-
addedAt:
|
|
43
|
-
lastConnectedAt:
|
|
44
|
-
notes: notes ||
|
|
85
|
+
name: targetName,
|
|
86
|
+
addedAt: new Date().toISOString(),
|
|
87
|
+
lastConnectedAt: undefined,
|
|
88
|
+
notes: notes || undefined
|
|
45
89
|
};
|
|
46
90
|
await writeFile(data);
|
|
47
|
-
console.log(`[known-peers] 添加/更新: ${
|
|
91
|
+
console.log(`[known-peers] 添加/更新: ${targetName} = ${publicKey.substring(0, 12)}...`);
|
|
48
92
|
}
|
|
49
93
|
/** 删除 known peer */
|
|
50
94
|
export async function removePeer(name) {
|
|
@@ -42,6 +42,10 @@ const TOOL_WHITELIST = new Set([
|
|
|
42
42
|
'read_directory', 'add_friend_by_id', 'delegate_to_engine',
|
|
43
43
|
'set_persona', 'get_operation_logs', 'park_goal',
|
|
44
44
|
'list_channels', 'list_local_channels',
|
|
45
|
+
// 2026-08-02: skill 沉淀工具 (skill-writer.ts)
|
|
46
|
+
'create_skill', 'update_skill', 'list_skill_candidates', 'promote_skill',
|
|
47
|
+
// 2026-08-02: plan/todo/review 工具 (plan-store.ts)
|
|
48
|
+
'create_plan', 'update_plan', 'review_plan', 'list_plans',
|
|
45
49
|
]);
|
|
46
50
|
export const gateWhitelist = { gate: 'whitelist', allowed: true };
|
|
47
51
|
/**
|