@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.
@@ -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
- const personaText = formatPersonaForSystemPrompt(docs);
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
- const sysPrompt = '你是 bolloon 记忆压缩助手. 输入是一段 session 消息历史 (用户问题 + AI 回答), 输出 200-400 字中文摘要, 包含 3-5 条关键发现和未完成事项. 不要寒暄, 不要复述已知. 格式: ## 关键发现 / ## 待办.';
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
- if (sections.length === 0)
75
- return '';
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
- const fixedOverhead = header.length + (sections.length - 1) * 2;
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
- * Key differences from Claude Code MCP:
8
- * - White-box design: every schema, parameter, and return value is visible
9
- * - Minimal primitives: only read, write, edit, bash under the hood
10
- * - No schema validation black boxes - interfaces are simple by design
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 (mcpJson.mcpServers) {
41
- for (const [name, config] of Object.entries(mcpJson.mcpServers)) {
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
- return configs;
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.set(config.name, { config, process: null, running: false });
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
- return {
160
- success: true,
161
- content: result.content,
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 (simplified - uses stdin/stdout JSON-RPC)
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
- // For now, return a placeholder - real implementation would use MCP protocol
181
- // The actual protocol typically uses stdio or HTTP+streamableHTTP
182
- console.log(`[McpAdapter] Would send ${method} to ${serverName}:`, params);
183
- // Simulate successful response for development
184
- return {
185
- content: [
186
- {
187
- type: 'text',
188
- text: `[Simulated] Tool ${method} executed with params: ${JSON.stringify(params)}`,
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
  /**