@bolloon/bolloon-agent 0.3.26 → 0.3.28
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/dist/agents/decision-store.js +247 -0
- package/dist/agents/pi-sdk-tools.js +419 -0
- package/dist/bootstrap/context-os.js +213 -0
- package/dist/bootstrap/lifecycle-hooks.js +8 -2
- package/dist/bootstrap/memory-compressor.js +126 -1
- package/dist/bootstrap/persona-loader.js +88 -5
- package/dist/pi-ecosystem-mcp/index.js +153 -37
- package/dist/security/tool-gate.js +10 -0
- package/dist/web/client.js +119 -9
- package/dist/web/server-storage.js +9 -2
- package/dist/web/server.js +570 -36
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ import * as os from 'os';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { getCachedBolloonContext, clearBolloonContextCache } from './context-collector.js';
|
|
11
11
|
import { formatContextForSystemPrompt } from './project-context.js';
|
|
12
|
-
import { loadPersonaDocs, formatPersonaForSystemPrompt } from './persona-loader.js';
|
|
12
|
+
import { loadPersonaDocs, formatPersonaForSystemPrompt, loadPersonaJudgmentDeclaration, formatJudgmentDeclaration } from './persona-loader.js';
|
|
13
13
|
let lastSessionStartAt = 0;
|
|
14
14
|
const MIN_INTERVAL_MS = 5000; // 同一进程 5s 内最多触发一次, 防止循环
|
|
15
15
|
export async function onSessionStart(opts = {}) {
|
|
@@ -30,7 +30,13 @@ export async function onSessionStart(opts = {}) {
|
|
|
30
30
|
if (opts.agentId) {
|
|
31
31
|
try {
|
|
32
32
|
const docs = await loadPersonaDocs(opts.agentId);
|
|
33
|
-
|
|
33
|
+
let personaText = formatPersonaForSystemPrompt(docs);
|
|
34
|
+
// 2026-08-03 (Context OS P1): 追加 persona frontmatter 里的判断力声明
|
|
35
|
+
// (judgment_style / stakes_default / revisable) — 与 judgeness 5 维对应
|
|
36
|
+
const decl = await loadPersonaJudgmentDeclaration(opts.agentId);
|
|
37
|
+
const declText = formatJudgmentDeclaration(decl);
|
|
38
|
+
if (declText)
|
|
39
|
+
personaText = personaText ? `${personaText}\n\n${declText}` : declText;
|
|
34
40
|
if (personaText) {
|
|
35
41
|
systemAddition = personaText + '\n\n' + systemAddition;
|
|
36
42
|
}
|
|
@@ -144,7 +144,8 @@ export async function compressSessionToMemory(opts) {
|
|
|
144
144
|
const timestamp = new Date().toISOString();
|
|
145
145
|
let summaryBody;
|
|
146
146
|
try {
|
|
147
|
-
|
|
147
|
+
// 2026-08-03 (Context OS P4): 摘要 prompt 要求输出"价值点"段 — 供收尾路由入库 judgeness
|
|
148
|
+
const sysPrompt = '你是 bolloon 记忆压缩助手. 输入是一段 session 消息历史 (用户问题 + AI 回答), 输出 200-400 字中文摘要, 包含 3-5 条关键发现和未完成事项. 不要寒暄, 不要复述已知. 格式: ## 关键发现 / ## 待办. 最后单独输出 ## 价值点 段: 0-3 行, 每行 `- (类型) 一句话内容`, 类型 ∈ decision|lesson|knowledge|insight (decision=做出了什么决定; lesson=哪里出错下次怎么避免; knowledge=修正了什么认知; insight=改变了判断的洞察). 没有就写 `- (无)`';
|
|
148
149
|
const recentSnippet = newMessages.slice(-10).map(m => `[${m.type}] ${m.content}`).join('\n---\n').slice(0, 6000);
|
|
149
150
|
const userPrompt = `Channel: ${opts.channelId}\nSession: ${opts.sessionId}\n时间: ${timestamp}\n新增消息数: ${newMessages.length}\n\n最近消息:\n${recentSnippet}`;
|
|
150
151
|
summaryBody = await tryLlmSummary(sysPrompt, userPrompt);
|
|
@@ -161,6 +162,18 @@ export async function compressSessionToMemory(opts) {
|
|
|
161
162
|
await fs.mkdir(path.dirname(summaryPath), { recursive: true });
|
|
162
163
|
await fs.appendFile(summaryPath, block, 'utf-8');
|
|
163
164
|
await writeCursor(cursorPath, allMessages.length);
|
|
165
|
+
// 2026-08-03 (Context OS P4): 价值点分类路由 — 把摘要里的 decision/lesson/knowledge/insight
|
|
166
|
+
// 自动写入 human-values + judgeness (Context OS §6 对话收尾: 价值不流失).
|
|
167
|
+
// 失败静默, 不阻塞主对话. 幂等: 相同 decision 文本跳过.
|
|
168
|
+
try {
|
|
169
|
+
await routeValuePointsToJudgeness({
|
|
170
|
+
agentId,
|
|
171
|
+
channelId: opts.channelId,
|
|
172
|
+
summaryBody,
|
|
173
|
+
home,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch { /* 静默 */ }
|
|
164
177
|
// 2026-07-22 设计 C: 废气采样 — 压缩成功 = 上下文需要压缩的信号, 记入涡轮 (隐式)
|
|
165
178
|
// 废气不进 prompt, 只调参 (背压高 → judgment 注入收紧). 落 log/memory.
|
|
166
179
|
try {
|
|
@@ -179,3 +192,115 @@ export async function compressSessionToMemory(opts) {
|
|
|
179
192
|
bytesWritten: Buffer.byteLength(block, 'utf-8'),
|
|
180
193
|
};
|
|
181
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* 解析摘要里的 `## 价值点` 段.
|
|
197
|
+
* 容错 3 种行格式: `- (decision) 内容` / `- decision: 内容` / `- decision 内容`
|
|
198
|
+
* 无该段 / `- (无)` → 返回 [].
|
|
199
|
+
*/
|
|
200
|
+
export function extractValuePoints(summaryBody) {
|
|
201
|
+
if (!summaryBody)
|
|
202
|
+
return [];
|
|
203
|
+
const m = summaryBody.match(/##\s*价值点\s*\n([\s\S]*?)(?=\n##\s|\n---\s*$|$)/);
|
|
204
|
+
if (!m)
|
|
205
|
+
return [];
|
|
206
|
+
const lines = m[1].split('\n').map((l) => l.trim()).filter(Boolean);
|
|
207
|
+
const out = [];
|
|
208
|
+
const typeSet = ['decision', 'lesson', 'knowledge', 'insight'];
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
const stripped = line.replace(/^[-*•]\s*/, '');
|
|
211
|
+
if (stripped === '(无)' || stripped === '无' || stripped === '')
|
|
212
|
+
continue;
|
|
213
|
+
// - (type) content | - type: content | - type content
|
|
214
|
+
const m2 = stripped.match(/^\(?(\w+)\)?\s*[::]?\s+(.+)$/);
|
|
215
|
+
if (!m2)
|
|
216
|
+
continue;
|
|
217
|
+
const t = m2[1].toLowerCase();
|
|
218
|
+
if (!typeSet.includes(t))
|
|
219
|
+
continue;
|
|
220
|
+
const content = m2[2].trim();
|
|
221
|
+
if (content.length < 4)
|
|
222
|
+
continue;
|
|
223
|
+
out.push({ type: t, content });
|
|
224
|
+
}
|
|
225
|
+
return out.slice(0, 3);
|
|
226
|
+
}
|
|
227
|
+
/** 幂等检查: human-values 里已有相同 decision 文本 → 跳过 (防重复入库) */
|
|
228
|
+
async function alreadyRouted(decisionText, home) {
|
|
229
|
+
try {
|
|
230
|
+
const { loadAllJudgments } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
231
|
+
const all = await loadAllJudgments();
|
|
232
|
+
return all.some((j) => String(j.decision).trim() === decisionText.trim());
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false; // 读失败 → 不跳过 (重试语义)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 把摘要里的价值点路由到 human-values + judgeness + Context OS 资产层.
|
|
240
|
+
* 失败静默 (由调用方 try/catch), 单条失败不影响其余.
|
|
241
|
+
* 落点 (Context OS §6 Step2 唯一落点):
|
|
242
|
+
* decision → decisions/ (decision-store, 不重复写资产层)
|
|
243
|
+
* lesson → human-values + judgeness + 12-Analysis/ (复盘)
|
|
244
|
+
* knowledge→ human-values + judgeness + 07-Knowledge/
|
|
245
|
+
* insight → human-values + judgeness + 08-Insights/
|
|
246
|
+
* 返回写入条数 (测试/日志用).
|
|
247
|
+
*/
|
|
248
|
+
export async function routeValuePointsToJudgeness(opts) {
|
|
249
|
+
const points = extractValuePoints(opts.summaryBody);
|
|
250
|
+
if (points.length === 0)
|
|
251
|
+
return 0;
|
|
252
|
+
let written = 0;
|
|
253
|
+
for (const p of points) {
|
|
254
|
+
try {
|
|
255
|
+
const decisionText = p.content.slice(0, 300);
|
|
256
|
+
if (await alreadyRouted(decisionText, opts.home))
|
|
257
|
+
continue;
|
|
258
|
+
const { storeHumanJudgment } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
259
|
+
const { reflectAfterJudgment } = await import('../judgeness/reflect.js');
|
|
260
|
+
const isLesson = p.type === 'lesson';
|
|
261
|
+
const judgment = await storeHumanJudgment({
|
|
262
|
+
decision: decisionText,
|
|
263
|
+
decision_type: isLesson ? 'reject' : 'approve',
|
|
264
|
+
reasons: [`来源: session 摘要价值点 (${p.type})`],
|
|
265
|
+
values_derived: [],
|
|
266
|
+
context: {
|
|
267
|
+
domain: opts.channelId?.startsWith('ch_') ? '通用' : (opts.channelId || '通用'),
|
|
268
|
+
complexity: 'simple',
|
|
269
|
+
stakes: 'low',
|
|
270
|
+
time_pressure: 'low',
|
|
271
|
+
},
|
|
272
|
+
metadata: {
|
|
273
|
+
source: isLesson ? 'trajectory' : 'implicit',
|
|
274
|
+
confidence: 0.6,
|
|
275
|
+
revisable: true,
|
|
276
|
+
},
|
|
277
|
+
status: 'active',
|
|
278
|
+
appliesTo: [],
|
|
279
|
+
});
|
|
280
|
+
await reflectAfterJudgment(judgment, 'agent', sanitizeAgentId(opts.agentId)).catch(() => null);
|
|
281
|
+
written += 1;
|
|
282
|
+
// 2026-08-03 (Context OS P5): 唯一落点 — knowledge/insight/lesson 写入资产层
|
|
283
|
+
// (幂等: 同标题已存在则跳过; 失败静默不影响主流程)
|
|
284
|
+
const assetLayer = p.type === 'knowledge' ? '07-Knowledge' :
|
|
285
|
+
p.type === 'insight' ? '08-Insights' :
|
|
286
|
+
p.type === 'lesson' ? '12-Analysis' : null;
|
|
287
|
+
if (assetLayer) {
|
|
288
|
+
try {
|
|
289
|
+
const { writeContextAsset } = await import('./context-os.js');
|
|
290
|
+
await writeContextAsset({
|
|
291
|
+
layer: assetLayer,
|
|
292
|
+
title: p.content.slice(0, 40),
|
|
293
|
+
content: `> 来源: session 价值点自动路由 (${p.type})\n\n${p.content}\n\n## 价值判断自检\n未来哪个具体场景会用到它? (待确认后固化, 当前 stage0)`,
|
|
294
|
+
tags: [p.type, 'auto-routed'],
|
|
295
|
+
domain: opts.channelId,
|
|
296
|
+
}, opts.home);
|
|
297
|
+
}
|
|
298
|
+
catch { /* 资产层写入失败不影响 */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
/* 单条失败跳过 */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return written;
|
|
306
|
+
}
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* 失败静默: 文件不存在 → 字段 = '', 不抛错
|
|
5
5
|
* 安全: agentId sanitize (防路径穿越)
|
|
6
6
|
* 6 段输出顺序: identity → soul → project → user → agent → wiki
|
|
7
|
+
*
|
|
8
|
+
* 2026-08-03 (Context OS 融合 P1):
|
|
9
|
+
* - 支持 persona 文件 frontmatter 里的判断力声明 (judgment_style / stakes_default / revisable),
|
|
10
|
+
* 与 judgeness 5 维 facets 对应 — persona 提供"这个人怎么判断"的入口.
|
|
11
|
+
* - formatPersonaForSystemPrompt 固定追加 INJECT 工作纪律段 (Context OS 读取协议),
|
|
12
|
+
* 任何 channel 即使无 persona 文件也有纪律约束.
|
|
7
13
|
*/
|
|
8
14
|
import * as fs from 'fs/promises';
|
|
9
15
|
import * as os from 'os';
|
|
@@ -45,6 +51,71 @@ export async function loadPersonaDocs(agentId, home) {
|
|
|
45
51
|
}));
|
|
46
52
|
return docs;
|
|
47
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* 轻量 frontmatter 解析 (不依赖 js-yaml).
|
|
56
|
+
* 只认 `key: value` 单行字段; 无 frontmatter (不以 --- 开头) → 返回 {}.
|
|
57
|
+
*/
|
|
58
|
+
export function parseSimpleFrontmatter(content) {
|
|
59
|
+
const out = {};
|
|
60
|
+
const m = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
|
|
61
|
+
if (!m)
|
|
62
|
+
return out;
|
|
63
|
+
for (const line of m[1].split('\n')) {
|
|
64
|
+
const kv = line.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/);
|
|
65
|
+
if (kv)
|
|
66
|
+
out[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '').trim();
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 读 persona 6 文件 frontmatter 里的判断力声明 (Context OS 入口层 ↔ judgeness 5 维).
|
|
72
|
+
* 聚合所有文件中的 judgment_style / stakes_default / revisable 字段 (后者优先).
|
|
73
|
+
* 失败静默: 无 persona 文件 → 全部空值, 不抛错.
|
|
74
|
+
*/
|
|
75
|
+
export async function loadPersonaJudgmentDeclaration(agentId, home) {
|
|
76
|
+
const safeId = sanitizeAgentId(agentId);
|
|
77
|
+
const root = home || os.homedir();
|
|
78
|
+
const baseDir = path.join(root, '.bolloon', 'persona', safeId);
|
|
79
|
+
const decl = {
|
|
80
|
+
judgmentStyle: '',
|
|
81
|
+
stakesDefault: '',
|
|
82
|
+
revisable: true,
|
|
83
|
+
raw: {},
|
|
84
|
+
};
|
|
85
|
+
await Promise.all(FILE_KEYS.map(async (key) => {
|
|
86
|
+
try {
|
|
87
|
+
const content = await fs.readFile(path.join(baseDir, `${key}.md`), 'utf-8');
|
|
88
|
+
const fm = parseSimpleFrontmatter(content);
|
|
89
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
90
|
+
if (k.startsWith('judgment_') || k === 'stakes_default' || k === 'revisable') {
|
|
91
|
+
decl.raw[k] = v;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* 单文件缺失/损坏跳过 */
|
|
97
|
+
}
|
|
98
|
+
}));
|
|
99
|
+
decl.judgmentStyle = decl.raw['judgment_style'] || decl.raw['judgmentStyle'] || '';
|
|
100
|
+
decl.stakesDefault = decl.raw['stakes_default'] || decl.raw['stakesDefault'] || '';
|
|
101
|
+
decl.revisable = decl.raw['revisable'] !== 'false';
|
|
102
|
+
return decl;
|
|
103
|
+
}
|
|
104
|
+
/** 把判断力声明格式化成一行段 (server contextHint 注入用) */
|
|
105
|
+
export function formatJudgmentDeclaration(decl) {
|
|
106
|
+
if (!decl.judgmentStyle && !decl.stakesDefault && !decl.raw['revisable'])
|
|
107
|
+
return '';
|
|
108
|
+
const parts = [];
|
|
109
|
+
if (decl.judgmentStyle)
|
|
110
|
+
parts.push(`风格: ${decl.judgmentStyle}`);
|
|
111
|
+
if (decl.stakesDefault)
|
|
112
|
+
parts.push(`默认风险等级: ${decl.stakesDefault}`);
|
|
113
|
+
if (decl.raw['revisable'] === 'false')
|
|
114
|
+
parts.push('偏好不可回滚的决策 (谨慎)');
|
|
115
|
+
else if (decl.raw['revisable'] === 'true')
|
|
116
|
+
parts.push('偏好可回滚的决策');
|
|
117
|
+
return `[系统上下文] 判断风格声明 (来自 persona frontmatter, 与 judgeness 判断资产对应):\n ${parts.join(' / ')}\n\n`;
|
|
118
|
+
}
|
|
48
119
|
const DEFAULT_MAX_CHARS = 4000;
|
|
49
120
|
const SECTION_LABELS = {
|
|
50
121
|
identity: 'Identity',
|
|
@@ -60,6 +131,9 @@ const OUTPUT_ORDER = ['identity', 'soul', 'project', 'user', 'agent', 'wiki'];
|
|
|
60
131
|
*
|
|
61
132
|
* 超 maxChars 时按比例截断: 每个字段都保留头部,
|
|
62
133
|
* 保证 6 段标识都出现, 不砍段.
|
|
134
|
+
*
|
|
135
|
+
* 2026-08-03: 固定追加 INJECT 工作纪律段 (Context OS 读取协议 §4/§10).
|
|
136
|
+
* 纪律段不参与动态预算 — 即使没有 persona 文件也有纪律约束.
|
|
63
137
|
*/
|
|
64
138
|
export function formatPersonaForSystemPrompt(docs, maxChars) {
|
|
65
139
|
const cap = maxChars ?? DEFAULT_MAX_CHARS;
|
|
@@ -71,11 +145,20 @@ export function formatPersonaForSystemPrompt(docs, maxChars) {
|
|
|
71
145
|
sections.push({ key, text: `## ${SECTION_LABELS[key]}\n${v}` });
|
|
72
146
|
}
|
|
73
147
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
148
|
+
// INJECT 工作纪律 (Context OS §4 最小读取集 + §10 工作规则, 精简 4 条)
|
|
149
|
+
const discipline = '## 工作纪律 (INJECT)\n' +
|
|
150
|
+
'1. 先看动态状态 (历史记忆 / 进行中的计划), 再按任务路由读取对应文档; 不读到的内容不假装知道.\n' +
|
|
151
|
+
'2. 区分: 已知事实 / 你的判断 / 需要用户确认的内容.\n' +
|
|
152
|
+
'3. 重要决策前列出: 选项 (含不做)、成本、收益、风险、信息缺口、回滚条件 — 决策可追溯.\n' +
|
|
153
|
+
'4. 对话收尾提取可复用的决策/知识/教训, 归档到唯一位置, 不制造重复文件.';
|
|
77
154
|
const header = `# Persona (agentId=${docs.agentId})\n\n`;
|
|
78
|
-
|
|
155
|
+
// 无 persona 文件 → 只输出纪律段
|
|
156
|
+
if (sections.length === 0) {
|
|
157
|
+
const solo = `${header}${discipline}`;
|
|
158
|
+
return solo.length > cap ? solo.slice(0, cap) : solo;
|
|
159
|
+
}
|
|
160
|
+
// 预算每段 (去掉 ## 标识和换行的固定开销; 纪律段固定, 动态段让出预算)
|
|
161
|
+
const fixedOverhead = header.length + discipline.length + (sections.length - 1) * 2;
|
|
79
162
|
const perSectionBudget = Math.max(50, Math.floor((cap - fixedOverhead) / sections.length));
|
|
80
163
|
const parts = [header.trim()];
|
|
81
164
|
for (const sec of sections) {
|
|
@@ -85,7 +168,7 @@ export function formatPersonaForSystemPrompt(docs, maxChars) {
|
|
|
85
168
|
}
|
|
86
169
|
parts.push(body);
|
|
87
170
|
}
|
|
88
|
-
let result = parts.join('\n\n');
|
|
171
|
+
let result = parts.join('\n\n') + '\n\n' + discipline;
|
|
89
172
|
if (result.length > cap) {
|
|
90
173
|
const truncateMarker = '\n... (截断)';
|
|
91
174
|
result = result.substring(0, Math.max(0, cap - truncateMarker.length)) + truncateMarker;
|
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
* Bridges MCP (Model Context Protocol) servers with Bolloon's tool system.
|
|
5
5
|
* Based on the pi-mcp-adapter philosophy: on-demand tool loading with minimal token overhead.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 2026-08-03 (验证修复): sendMcpRequest 从 simulated 占位 → 真实 stdio JSON-RPC 通信.
|
|
8
|
+
* - 协议: initialize → notifications/initialized → tools/list → tools/call
|
|
9
|
+
* - 请求/响应按 id 配对, 30s 超时, server 崩溃时 pending 全部 reject
|
|
10
|
+
* - discoverMcpServers 修复重复读 mcpServers 键 (同一个键被读两遍)
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from 'fs/promises';
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import { spawn } from 'child_process';
|
|
15
15
|
import { EventEmitter } from 'events';
|
|
16
|
+
import * as readline from 'readline';
|
|
16
17
|
// MCP adapter state
|
|
17
18
|
let tools = new Map();
|
|
18
19
|
let servers = new Map();
|
|
@@ -22,6 +23,8 @@ let toolCallLog = [];
|
|
|
22
23
|
class McpEventEmitter extends EventEmitter {
|
|
23
24
|
}
|
|
24
25
|
const mcpEvents = new McpEventEmitter();
|
|
26
|
+
const MCP_REQUEST_TIMEOUT_MS = 30_000;
|
|
27
|
+
let mcpRequestSeq = 1;
|
|
25
28
|
/**
|
|
26
29
|
* Discover MCP servers from standard config locations
|
|
27
30
|
*/
|
|
@@ -37,24 +40,17 @@ export async function discoverMcpServers() {
|
|
|
37
40
|
try {
|
|
38
41
|
const content = await fs.readFile(loc, 'utf-8');
|
|
39
42
|
const mcpJson = JSON.parse(content);
|
|
40
|
-
if
|
|
41
|
-
|
|
43
|
+
// 2026-08-03 fix: mcpServers 只读一次 (之前 if + if['mcpServers'] 重复读同一键)
|
|
44
|
+
const serversConfig = (mcpJson.mcpServers ?? mcpJson['mcpServers']);
|
|
45
|
+
if (serversConfig && typeof serversConfig === 'object') {
|
|
46
|
+
for (const [name, config] of Object.entries(serversConfig)) {
|
|
42
47
|
const serverConfig = config;
|
|
48
|
+
if (!serverConfig || typeof serverConfig.command !== 'string')
|
|
49
|
+
continue;
|
|
43
50
|
configs.push({
|
|
44
51
|
name,
|
|
45
52
|
command: serverConfig.command,
|
|
46
|
-
args: serverConfig.args,
|
|
47
|
-
env: serverConfig.env,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (mcpJson['mcpServers']) {
|
|
52
|
-
for (const [name, config] of Object.entries(mcpJson['mcpServers'])) {
|
|
53
|
-
const serverConfig = config;
|
|
54
|
-
configs.push({
|
|
55
|
-
name,
|
|
56
|
-
command: serverConfig.command,
|
|
57
|
-
args: serverConfig.args,
|
|
53
|
+
args: Array.isArray(serverConfig.args) ? serverConfig.args : undefined,
|
|
58
54
|
env: serverConfig.env,
|
|
59
55
|
});
|
|
60
56
|
}
|
|
@@ -64,7 +60,15 @@ export async function discoverMcpServers() {
|
|
|
64
60
|
// File doesn't exist, skip
|
|
65
61
|
}
|
|
66
62
|
}
|
|
67
|
-
|
|
63
|
+
// 去重 (同 name 同 command)
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
return configs.filter((c) => {
|
|
66
|
+
const key = `${c.name}::${c.command}`;
|
|
67
|
+
if (seen.has(key))
|
|
68
|
+
return false;
|
|
69
|
+
seen.add(key);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
68
72
|
}
|
|
69
73
|
/**
|
|
70
74
|
* Initialize the MCP adapter
|
|
@@ -76,15 +80,60 @@ export async function initializeMcpAdapter() {
|
|
|
76
80
|
console.log(`[McpAdapter] Discovered ${discoveredServers.length} MCP servers`);
|
|
77
81
|
for (const server of discoveredServers) {
|
|
78
82
|
registerServer(server);
|
|
83
|
+
// 2026-08-03: 启动后立即握手 + 发现工具 (真实 stdio 协议)
|
|
84
|
+
await connectAndDiscover(server.name).catch((e) => {
|
|
85
|
+
console.warn(`[McpAdapter] connect ${server.name} 失败:`, e?.message?.slice(0, 120));
|
|
86
|
+
});
|
|
79
87
|
}
|
|
80
88
|
initialized = true;
|
|
81
89
|
mcpEvents.emit('initialized');
|
|
82
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* 连接 MCP server + 握手 + 发现并注册工具 (2026-08-03)
|
|
93
|
+
* 协议序: startServer → initialize → notifications/initialized → tools/list
|
|
94
|
+
*/
|
|
95
|
+
export async function connectAndDiscover(serverName) {
|
|
96
|
+
const server = servers.get(serverName);
|
|
97
|
+
if (!server)
|
|
98
|
+
return [];
|
|
99
|
+
if (!server.running || !server.process) {
|
|
100
|
+
const started = await startServer(serverName);
|
|
101
|
+
if (!started)
|
|
102
|
+
return [];
|
|
103
|
+
// 等 server 就绪
|
|
104
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
105
|
+
}
|
|
106
|
+
await sendMcpRequest(serverName, 'initialize', {
|
|
107
|
+
protocolVersion: '2024-11-05',
|
|
108
|
+
capabilities: {},
|
|
109
|
+
clientInfo: { name: 'bolloon', version: '0.3.27' },
|
|
110
|
+
});
|
|
111
|
+
await sendMcpRequest(serverName, 'notifications/initialized');
|
|
112
|
+
const result = await sendMcpRequest(serverName, 'tools/list');
|
|
113
|
+
const toolsList = Array.isArray(result?.tools) ? result.tools : [];
|
|
114
|
+
const discovered = [];
|
|
115
|
+
for (const t of toolsList) {
|
|
116
|
+
const tool = {
|
|
117
|
+
name: String(t.name || ''),
|
|
118
|
+
description: String(t.description || ''),
|
|
119
|
+
inputSchema: t.inputSchema || {},
|
|
120
|
+
serverName,
|
|
121
|
+
};
|
|
122
|
+
if (!tool.name)
|
|
123
|
+
continue;
|
|
124
|
+
discovered.push(tool);
|
|
125
|
+
registerTool(tool);
|
|
126
|
+
}
|
|
127
|
+
console.log(`[McpAdapter] ${serverName}: 发现 ${discovered.length} 个工具 (${discovered.map((t) => t.name).join(', ')})`);
|
|
128
|
+
return discovered;
|
|
129
|
+
}
|
|
83
130
|
/**
|
|
84
131
|
* Register an MCP server configuration
|
|
85
132
|
*/
|
|
86
133
|
export function registerServer(config) {
|
|
87
|
-
servers.
|
|
134
|
+
if (servers.has(config.name))
|
|
135
|
+
return;
|
|
136
|
+
servers.set(config.name, { config, process: null, running: false, pending: new Map() });
|
|
88
137
|
console.log(`[McpAdapter] Registered server: ${config.name}`);
|
|
89
138
|
}
|
|
90
139
|
/**
|
|
@@ -156,10 +205,12 @@ export async function executeTool(name, args) {
|
|
|
156
205
|
toolCallLog = toolCallLog.slice(-50);
|
|
157
206
|
}
|
|
158
207
|
if (result && typeof result === 'object' && 'content' in result) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
208
|
+
// 2026-08-03: 提取 content 数组里的文本 (agent 直接可用)
|
|
209
|
+
const content = result.content;
|
|
210
|
+
const text = Array.isArray(content)
|
|
211
|
+
? content.map((c) => c?.text ?? '').filter(Boolean).join('\n')
|
|
212
|
+
: JSON.stringify(result);
|
|
213
|
+
return { success: true, content: [{ type: 'text', text }] };
|
|
163
214
|
}
|
|
164
215
|
return { success: true, content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
165
216
|
}
|
|
@@ -170,25 +221,78 @@ export async function executeTool(name, args) {
|
|
|
170
221
|
}
|
|
171
222
|
}
|
|
172
223
|
/**
|
|
173
|
-
* Send MCP request to a server
|
|
224
|
+
* Send MCP request to a server via real stdio JSON-RPC (2026-08-03).
|
|
225
|
+
* - 写 JSON-RPC 行到 server stdin, 从 stdout 按 id 配对响应
|
|
226
|
+
* - 30s 超时; server 进程退出时挂起请求全部 reject
|
|
174
227
|
*/
|
|
175
228
|
async function sendMcpRequest(serverName, method, params) {
|
|
176
229
|
const server = servers.get(serverName);
|
|
177
230
|
if (!server) {
|
|
178
231
|
throw new Error(`Server not registered: ${serverName}`);
|
|
179
232
|
}
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
233
|
+
// 确保 server 进程在跑
|
|
234
|
+
if (!server.running || !server.process || !server.process.stdin?.writable) {
|
|
235
|
+
const started = await startServer(serverName);
|
|
236
|
+
if (!started)
|
|
237
|
+
throw new Error(`无法启动 MCP server: ${serverName}`);
|
|
238
|
+
// 等 500ms 让 server 就绪
|
|
239
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
240
|
+
}
|
|
241
|
+
const child = server.process;
|
|
242
|
+
if (!child.stdin?.writable)
|
|
243
|
+
throw new Error(`MCP server stdin 不可写: ${serverName}`);
|
|
244
|
+
const id = mcpRequestSeq++;
|
|
245
|
+
const isNotification = method.startsWith('notifications/');
|
|
246
|
+
// 通知 (notifications/*) 是 fire-and-forget: 无 id, server 不响应
|
|
247
|
+
const line = JSON.stringify(isNotification
|
|
248
|
+
? { jsonrpc: '2.0', method, params: params ?? {} }
|
|
249
|
+
: { jsonrpc: '2.0', id, method, params: params ?? {} });
|
|
250
|
+
if (isNotification) {
|
|
251
|
+
child.stdin.write(line + '\n');
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
server.pending.delete(id);
|
|
257
|
+
reject(new Error(`MCP request timeout (${MCP_REQUEST_TIMEOUT_MS}ms): ${method}`));
|
|
258
|
+
}, MCP_REQUEST_TIMEOUT_MS);
|
|
259
|
+
server.pending.set(id, { resolve, reject, timer });
|
|
260
|
+
child.stdin.write(line + '\n');
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** 挂上 stdout 行读取器 (按 id 分发响应) */
|
|
264
|
+
function attachStdoutReader(serverName, child) {
|
|
265
|
+
const server = servers.get(serverName);
|
|
266
|
+
if (!server)
|
|
267
|
+
return;
|
|
268
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
269
|
+
rl.on('line', (line) => {
|
|
270
|
+
const trimmed = line.trim();
|
|
271
|
+
if (!trimmed)
|
|
272
|
+
return;
|
|
273
|
+
let msg;
|
|
274
|
+
try {
|
|
275
|
+
msg = JSON.parse(trimmed);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
console.log(`[McpAdapter][${serverName}] non-JSON stdout:`, trimmed.slice(0, 200));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// 服务端主动推送 (无 id) → 忽略
|
|
282
|
+
if (msg.id === undefined || msg.id === null)
|
|
283
|
+
return;
|
|
284
|
+
const entry = server.pending.get(msg.id);
|
|
285
|
+
if (!entry)
|
|
286
|
+
return;
|
|
287
|
+
clearTimeout(entry.timer);
|
|
288
|
+
server.pending.delete(msg.id);
|
|
289
|
+
if (msg.error) {
|
|
290
|
+
entry.reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
entry.resolve(msg.result);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
192
296
|
}
|
|
193
297
|
/**
|
|
194
298
|
* Start an MCP server process
|
|
@@ -215,16 +319,20 @@ export async function startServer(serverName) {
|
|
|
215
319
|
});
|
|
216
320
|
child.on('error', (err) => {
|
|
217
321
|
console.error(`[McpAdapter][${serverName}] error:`, err);
|
|
322
|
+
rejectAllPending(server, `MCP server process error: ${err.message}`);
|
|
218
323
|
server.running = false;
|
|
219
324
|
server.process = null;
|
|
220
325
|
});
|
|
221
326
|
child.on('exit', (code) => {
|
|
222
327
|
console.log(`[McpAdapter][${serverName}] exited with code:`, code);
|
|
328
|
+
rejectAllPending(server, `MCP server exited with code ${code}`);
|
|
223
329
|
server.running = false;
|
|
224
330
|
server.process = null;
|
|
225
331
|
});
|
|
226
332
|
server.process = child;
|
|
227
333
|
server.running = true;
|
|
334
|
+
// 2026-08-03: 挂 stdout 行读取器, 按 id 分发 JSON-RPC 响应
|
|
335
|
+
attachStdoutReader(serverName, child);
|
|
228
336
|
console.log(`[McpAdapter] Started server: ${serverName}`);
|
|
229
337
|
return true;
|
|
230
338
|
}
|
|
@@ -233,6 +341,14 @@ export async function startServer(serverName) {
|
|
|
233
341
|
return false;
|
|
234
342
|
}
|
|
235
343
|
}
|
|
344
|
+
/** server 退出时把挂起请求全部 reject, 避免调用方永远等待 */
|
|
345
|
+
function rejectAllPending(server, reason) {
|
|
346
|
+
for (const [, entry] of server.pending) {
|
|
347
|
+
clearTimeout(entry.timer);
|
|
348
|
+
entry.reject(new Error(reason));
|
|
349
|
+
}
|
|
350
|
+
server.pending.clear();
|
|
351
|
+
}
|
|
236
352
|
/**
|
|
237
353
|
* Stop an MCP server
|
|
238
354
|
*/
|
|
@@ -46,6 +46,16 @@ const TOOL_WHITELIST = new Set([
|
|
|
46
46
|
'create_skill', 'update_skill', 'list_skill_candidates', 'promote_skill',
|
|
47
47
|
// 2026-08-02: plan/todo/review 工具 (plan-store.ts)
|
|
48
48
|
'create_plan', 'update_plan', 'review_plan', 'list_plans',
|
|
49
|
+
// 2026-08-03: 决策协议工具 (decision-store.ts, Context OS §7 九要素)
|
|
50
|
+
'create_decision', 'decide_decision', 'rollback_decision', 'list_decisions',
|
|
51
|
+
// 2026-08-03: Context OS 资产层工具 (context-os.ts, 12+3 层文件夹体系)
|
|
52
|
+
'list_context_layers', 'write_context_asset', 'read_context_assets',
|
|
53
|
+
// 2026-08-03: MCP 工具 (pi-ecosystem-mcp, 真实 stdio JSON-RPC)
|
|
54
|
+
'mcp_list_tools', 'mcp_tool',
|
|
55
|
+
// 2026-08-03: DID 发布到 IPFS+IPNS (自动安装 Kubo)
|
|
56
|
+
'publish_did',
|
|
57
|
+
// 2026-08-02: 远端 channel 工具 (本地智能体 @ 远程交流)
|
|
58
|
+
'list_remote_channels', 'send_to_remote_channel',
|
|
49
59
|
]);
|
|
50
60
|
export const gateWhitelist = { gate: 'whitelist', allowed: true };
|
|
51
61
|
/**
|