@bolloon/bolloon-agent 0.4.22 → 0.4.24

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/llm/pi-ai.js CHANGED
@@ -303,6 +303,28 @@ export class PiAIModel {
303
303
  };
304
304
  return modelMap[this.provider];
305
305
  }
306
+ /**
307
+ * 2026-09-15: 出网前把 messages 规整成 wire 形状.
308
+ *
309
+ * 背景 (真跑复现 + 逐项对照, 见 wiki log 2026-09-15): DeepSeek 思考模式 (deepseek-v4-*)
310
+ * 在**请求带 tools** 时, 任何 assistant 消息缺 `reasoning_content` 字段 →
311
+ * HTTP 400 "The `reasoning_content` in the thinking mode must be passed back to the API".
312
+ * 复现: 同一个 17 条消息的真实请求体, 不带 tools → 200; 带上 tools → 400;
313
+ * 给每条 assistant 补 `reasoning_content:""` → 带 tools 也 200。
314
+ * 表现: 多轮工具循环 (第 2 轮起) 直接断, 用户看到 "AI 服务调用失败"。
315
+ *
316
+ * 处置: 只对 deepseek 生效 (唯一实测过的 provider); 有原文用原文, 没有就补空串 —
317
+ * 空串已被官方接受, 且不改变语义 (思维链不是给模型看的历史内容)。
318
+ * 其他 provider 原样透传 (OpenAI 系对未知字段更敏感, 不做无证据的改动)。
319
+ */
320
+ prepareWireMessages(messages) {
321
+ const echoReasoning = this.provider === 'deepseek';
322
+ if (!echoReasoning)
323
+ return messages;
324
+ return messages.map((m) => m.role === 'assistant'
325
+ ? { role: 'assistant', content: m.content ?? '', reasoning_content: m.reasoningContent ?? '' }
326
+ : m);
327
+ }
306
328
  async callOpenAI(messages, temperature, maxTokens, signal, tools) {
307
329
  const apiKey = this.getApiKey();
308
330
  if (!apiKey) {
@@ -310,7 +332,7 @@ export class PiAIModel {
310
332
  }
311
333
  const requestBody = {
312
334
  model: this.mapModel(),
313
- messages,
335
+ messages: this.prepareWireMessages(messages),
314
336
  temperature,
315
337
  max_tokens: maxTokens
316
338
  };
@@ -370,6 +392,15 @@ export class PiAIModel {
370
392
  const errBody = await body.text().catch(() => '(no body)');
371
393
  console.log(`[pi-ai DEBUG] OpenAI 错误 ${statusCode}: ${String(errBody).slice(0, 500)}`);
372
394
  console.log(`[pi-ai DEBUG] 请求体: model=${requestBody.model}, messages=${requestBody.messages?.length}, max_tokens=${requestBody.max_tokens}, baseUrl=${this.getBaseUrl()}`);
395
+ if (process.env.BOLLOON_DUMP_BODY === '1') {
396
+ try {
397
+ const fsx = await import('fs');
398
+ const p = `/tmp/bolloon-req-${Date.now()}.json`;
399
+ fsx.writeFileSync(p, JSON.stringify(requestBody, null, 2));
400
+ console.log(`[pi-ai DEBUG] 失败请求体已落盘: ${p}`);
401
+ }
402
+ catch { /* 调试用, 失败忽略 */ }
403
+ }
373
404
  retryAgent?.destroy().catch(() => { });
374
405
  throw new Error(`OpenAI API error: ${statusCode} ${String(errBody).slice(0, 300)}`);
375
406
  }
@@ -378,6 +409,8 @@ export class PiAIModel {
378
409
  const choice = data.choices?.[0];
379
410
  const content = choice?.message?.content || '';
380
411
  const toolCalls = choice?.message?.tool_calls;
412
+ // 2026-09-15: 思考模式思维链 — 原样存回 history, 下一轮必须回带 (见 prepareWireMessages)
413
+ const reasoningContent = choice?.message?.reasoning_content || undefined;
381
414
  lastFinishReason = choice?.finish_reason || '';
382
415
  // Bug 7: tool_calls 存在时不走重试 — LLM 选工具时 content 空是合法的
383
416
  if (content || (toolCalls && toolCalls.length > 0)) {
@@ -388,7 +421,7 @@ export class PiAIModel {
388
421
  const promptBytes = JSON.stringify(messages).length;
389
422
  console.log(`[pi-ai timing] total=${_tAfter - _t0}ms attempt=${attempt + 1} fetch=${_tResp - _tFetch}ms parse=${_tParse - _tResp}ms reply=${content.length}B toolCalls=${toolCalls?.length ?? 0} model=${this.mapModel()} prompt=${promptBytes}B`);
390
423
  retryAgent?.destroy().catch(() => { });
391
- return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined };
424
+ return { reply: content, toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined, reasoningContent };
392
425
  }
393
426
  console.warn(`[pi-ai] attempt ${attempt + 1}/3: 空 content (finish_reason=${lastFinishReason}), 退避 1.5s 重试`);
394
427
  const _tSleep = Date.now();
@@ -10,6 +10,33 @@ const RELAY_RETRY_INTERVAL = 30000;
10
10
  const MAX_RELAY_HOPS = 3;
11
11
  const MESSAGE_TIMESTAMP_TOLERANCE = 24 * 60 * 60 * 1000;
12
12
  const SIGNED_MESSAGE_TYPES = ['task', 'response', 'discovery', 'address_broadcast'];
13
+ /**
14
+ * 2026-09-15: did:key:z6Mk… 与 Ed25519 公钥字节是否一致。
15
+ *
16
+ * DID 由公钥派生 (did:key 规范: multibase base58btc of 0xed01 || rawKey), 所以首次接触时
17
+ * 自携公钥**无法伪造**: 冒充者换了公钥就解码不出同一个 DID。
18
+ * 返回: true 一致 / false 不一致 (疑似冒充) / null 非 did:key 形态 (无法判定, 不阻断, 交给签名验证)。
19
+ */
20
+ export async function didKeyMatchesPublicKey(did, publicKeyHex) {
21
+ if (!/^did:key:z/.test(did))
22
+ return null;
23
+ try {
24
+ const { base58btc } = await import('multiformats/bases/base58');
25
+ const decoded = base58btc.decode(did.slice('did:key:'.length));
26
+ // 34 字节 = 2 字节 multicodec 前缀 (0xed01 = ed25519-pub) + 32 字节公钥
27
+ if (decoded.length !== 34)
28
+ return false;
29
+ if (decoded[0] !== 0xed || decoded[1] !== 0x01)
30
+ return false;
31
+ const claimed = Buffer.from(publicKeyHex, 'hex');
32
+ if (claimed.length !== 32)
33
+ return false;
34
+ return Buffer.compare(Buffer.from(decoded.slice(2)), claimed) === 0;
35
+ }
36
+ catch {
37
+ return null; // 解码失败 → 不判定 (不当成冒充, 也不放行: 签名仍要过)
38
+ }
39
+ }
13
40
  export class AgentRegistry {
14
41
  agents = new Map();
15
42
  registryPath;
@@ -192,6 +219,7 @@ export class AgentRegistry {
192
219
  return null;
193
220
  }
194
221
  const relayAddr = p2pNetwork.getRelayAddress?.() || null;
222
+ const ownPublicKeyHex = Buffer.from(this.keyPair.publicKey).toString('hex');
195
223
  const broadcastData = JSON.stringify({
196
224
  type: 'address_broadcast',
197
225
  from: this.keyPair.did,
@@ -200,6 +228,7 @@ export class AgentRegistry {
200
228
  multiaddrs: this.ownEndpoint.multiaddrs,
201
229
  relayAddr: relayAddr || undefined,
202
230
  canRelay: relayAddr ? true : false,
231
+ publicKey: ownPublicKeyHex,
203
232
  timestamp: now
204
233
  });
205
234
  const signature = await this.signMessage(broadcastData);
@@ -213,6 +242,7 @@ export class AgentRegistry {
213
242
  multiaddrs: this.ownEndpoint.multiaddrs,
214
243
  relayAddr: relayAddr || undefined,
215
244
  canRelay: relayAddr ? true : false,
245
+ publicKey: ownPublicKeyHex,
216
246
  timestamp: now,
217
247
  signature: Buffer.from(signature).toString('hex')
218
248
  };
@@ -237,10 +267,35 @@ export class AgentRegistry {
237
267
  multiaddrs: broadcast.multiaddrs,
238
268
  relayAddr: broadcast.relayAddr,
239
269
  canRelay: broadcast.canRelay,
270
+ publicKey: broadcast.publicKey,
240
271
  timestamp: broadcast.timestamp
241
272
  });
242
273
  const signature = Buffer.from(broadcast.signature, 'hex');
243
- const isValid = await this.verifySignature(broadcast.from, broadcastData, signature);
274
+ const known = this.agents.get(broadcast.from);
275
+ // 2026-09-15: 首次接触 (全球化去中心网络里全是陌生人) —— 原来只认「registry 里已有的公钥」,
276
+ // 陌生人第一次广播必然验签失败 → 广播被丢弃 → 陌生人永远发现不了彼此 (入网文档 §7 的链路实际不通).
277
+ // 现在: 未知 DID 时用广播自带 publicKey 自证 (TOFU: 首次接触信任自携公钥);
278
+ // did:key 额外做 DID↔公钥 派生一致性检查 (DID 本身就是公钥, 自携公钥无法伪造).
279
+ let verifyKeyHex = known?.publicKey || undefined;
280
+ if (!verifyKeyHex) {
281
+ const claimed = String(broadcast.publicKey || '');
282
+ if (!claimed) {
283
+ console.warn(`[Registry] Unknown agent ${broadcast.from.substring(0, 20)}… 且广播未携带 publicKey → 无法验证, 拒收`);
284
+ return false;
285
+ }
286
+ const binding = await didKeyMatchesPublicKey(broadcast.from, claimed);
287
+ if (binding === false) {
288
+ console.warn(`[Registry] did:key 与自带公钥不匹配 (疑似冒充) from=${broadcast.from.substring(0, 24)}… → 拒收`);
289
+ return false;
290
+ }
291
+ verifyKeyHex = claimed;
292
+ }
293
+ else if (broadcast.publicKey && broadcast.publicKey !== verifyKeyHex) {
294
+ // 已认识这个 DID, 却报出另一把公钥 → 身份接管尝试, 拒收 (不覆盖已存公钥)
295
+ console.warn(`[Registry] ${broadcast.from.substring(0, 20)}… 广播的公钥与已知公钥不一致 → 拒收 (不覆盖)`);
296
+ return false;
297
+ }
298
+ const isValid = await this.verifySignatureWithKey(broadcast.from, verifyKeyHex, broadcastData, signature);
244
299
  if (!isValid) {
245
300
  console.warn(`[Registry] Invalid broadcast signature from ${broadcast.from.substring(0, 20)}`);
246
301
  return false;
@@ -253,7 +308,9 @@ export class AgentRegistry {
253
308
  registeredAt: Date.now(),
254
309
  lastSeen: broadcast.timestamp,
255
310
  lastBroadcast: 0,
256
- publicKey: Buffer.from(this.keyPair.publicKey).toString('hex'),
311
+ // 2026-09-15 修复: 原来写的是**自己**的公钥 (this.keyPair.publicKey)
312
+ // 对端后续任何签名消息都验不过 (拿我方公钥去验对方签名), 属于把对端身份写坏.
313
+ publicKey: verifyKeyHex,
257
314
  relayAddr: broadcast.relayAddr,
258
315
  canRelay: broadcast.canRelay
259
316
  };
@@ -261,15 +318,30 @@ export class AgentRegistry {
261
318
  if (existing) {
262
319
  entry.registeredAt = existing.registeredAt;
263
320
  entry.lastBroadcast = existing.lastBroadcast;
264
- if (!existing.publicKey) {
265
- existing.publicKey = entry.publicKey;
266
- }
321
+ if (existing.publicKey)
322
+ entry.publicKey = existing.publicKey; // 已有公钥永不覆盖
267
323
  }
268
324
  this.agents.set(broadcast.from, entry);
269
325
  this.saveRegistry();
270
- console.log(`[Registry] Verified broadcast from: ${broadcast.name} (${broadcast.from.substring(0, 20)}...) ${broadcast.canRelay ? '[Relay Capable]' : ''}`);
326
+ console.log(`[Registry] Verified broadcast from: ${broadcast.name} (${broadcast.from.substring(0, 20)}...) ${broadcast.canRelay ? '[Relay Capable]' : ''}${known ? '' : ' [首次接触 TOFU]'}`);
271
327
  return true;
272
328
  }
329
+ /** 用给定公钥 (hex) 验签 — 首次接触路径用 (未知 DID 时 registry 里没公钥) */
330
+ async verifySignatureWithKey(did, publicKeyHex, data, signature) {
331
+ try {
332
+ const publicKey = Buffer.from(publicKeyHex, 'hex');
333
+ if (publicKey.length !== 32) {
334
+ console.warn(`[Registry] publicKey 长度非法 (${publicKey.length} 字节, 期望 32): ${did.substring(0, 20)}…`);
335
+ return false;
336
+ }
337
+ const keyPair = { privateKey: new Uint8Array(32), publicKey, did };
338
+ return await KeyManager.verify(keyPair, new TextEncoder().encode(data), signature);
339
+ }
340
+ catch (e) {
341
+ console.warn(`[Registry] Verification with provided key failed:`, e);
342
+ return false;
343
+ }
344
+ }
273
345
  async createSignedMessage(type, payload) {
274
346
  if (!this.keyPair || !this.ownEndpoint)
275
347
  return null;
@@ -17,9 +17,10 @@
17
17
  */
18
18
  import express from 'express';
19
19
  import { buildAgentDelegateRequest, buildAgentResponse, buildManifestPayload, parseFrame, setLocalManifest, getLocalManifest, getRemoteManifests, cacheRemoteManifest, pickAgent, } from '../agents/agent-manifest-protocol.js';
20
- export function createAgentDelegateApp(transport) {
20
+ export function createAgentDelegateApp(transport, options = {}) {
21
21
  const app = express();
22
22
  app.use(express.json({ limit: '2mb' }));
23
+ const executeTimeoutMs = options.executeTimeoutMs ?? 60_000;
23
24
  // ---- 本地 manifest ----
24
25
  app.get('/api/agent/local-manifest', (_req, res) => {
25
26
  res.json(getLocalManifest());
@@ -81,7 +82,10 @@ export function createAgentDelegateApp(transport) {
81
82
  }
82
83
  res.json({
83
84
  ok: true,
84
- targetAgent: targetAgent || { id: f.payload.delegatedTo, capabilities: [capability], status: 'active', name: f.payload.delegatedTo },
85
+ // 2026-09-15: 没缓存到对端 manifest 就如实给 null —— 旧版会编一个
86
+ // 「capabilities:[capability], name:<id>」的假目标, 让人以为已经知道对端是谁。
87
+ targetAgent: targetAgent || null,
88
+ targetAgentKnown: !!targetAgent,
85
89
  response: f.payload,
86
90
  });
87
91
  }
@@ -104,18 +108,60 @@ export function createAgentDelegateApp(transport) {
104
108
  return null; // 不需要回包
105
109
  }
106
110
  if (f.type === 'agent_delegate') {
107
- // 路由到本地匹配 agent
108
111
  const req = f.payload;
112
+ const capability = String(req?.capability || '');
113
+ // 2026-09-15: 严格按文档 §6 / §9 —— 只认 capabilities 含该能力且 active 的 agent。
114
+ // 旧实现 `|| local.agents[0]` 会把不匹配的指令塞给任意一个本地 agent,
115
+ // 与「pick 404 = 没有匹配能力」的语义自相矛盾。
109
116
  const local = getLocalManifest();
110
- const target = local.agents.find((a) => a.capabilities.includes(req.capability) && a.status === 'active') || local.agents[0];
111
- if (!target)
112
- return buildAgentResponse({ ok: false, delegatedTo: 'none', summary: 'no local agent available' });
113
- return buildAgentResponse({
114
- ok: true,
115
- delegatedTo: target.id,
116
- resultCid: `mock-${Date.now()}`,
117
- summary: `[${target.name}] 已处理任务: ${req.instruction?.substring(0, 30)}`,
118
- });
117
+ const target = local.agents.find((a) => a.capabilities.includes(capability) && a.status === 'active');
118
+ if (!target) {
119
+ return buildAgentResponse({
120
+ ok: false,
121
+ delegatedTo: 'none',
122
+ summary: `no local agent available for capability '${capability}'`,
123
+ error: 'no-capability-match',
124
+ });
125
+ }
126
+ // 匹配到了, 但本节点没接执行器 → 如实说"干不了", 不假签收
127
+ if (!options.execute) {
128
+ return buildAgentResponse({
129
+ ok: false,
130
+ delegatedTo: target.id,
131
+ summary: `matched agent '${target.name}' but this node has no executor wired`,
132
+ error: 'no-executor',
133
+ });
134
+ }
135
+ try {
136
+ const raced = await Promise.race([
137
+ options.execute({
138
+ capability,
139
+ instruction: String(req?.instruction || ''),
140
+ docPath: req?.docPath ? String(req.docPath) : undefined,
141
+ docContent: req?.docContent ? String(req.docContent) : undefined,
142
+ fromAgentId: req?.fromAgentId ? String(req.fromAgentId) : undefined,
143
+ fromPublicKey,
144
+ targetAgentId: target.id,
145
+ targetAgentName: target.name,
146
+ }),
147
+ new Promise((resolve) => setTimeout(() => resolve({ ok: false, summary: `executor timed out after ${executeTimeoutMs}ms`, error: 'executor-timeout' }), executeTimeoutMs)),
148
+ ]);
149
+ return buildAgentResponse({
150
+ ok: !!raced.ok,
151
+ delegatedTo: target.id,
152
+ resultCid: raced.resultCid,
153
+ summary: String(raced.summary || '').slice(0, 4000),
154
+ error: raced.error,
155
+ });
156
+ }
157
+ catch (e) {
158
+ return buildAgentResponse({
159
+ ok: false,
160
+ delegatedTo: target.id,
161
+ summary: `executor threw: ${String(e?.message || e).slice(0, 300)}`,
162
+ error: 'executor-error',
163
+ });
164
+ }
119
165
  }
120
166
  return null;
121
167
  });
@@ -160,11 +160,205 @@ async function applyLlmConfigToBridge() {
160
160
  }
161
161
  catch { /* 注入失败不阻塞本地执行 */ }
162
162
  }
163
- // ============ 本地执行 (Kotlin AgentRuntime / 内置规则) ============
163
+ // ============ 手机端「读入网说明 → 入网」(2026-09-15) ============
164
+ //
165
+ // 背景: 手机端「一键入网」发出的口令是 `read https://bolloon.cn/bolloon-gateway-join.md`
166
+ // (mobile.js DEFAULT_JOIN_PROMPT), 但手机侧此前只会走到「已收到: "…"」的兜底回复 ——
167
+ // 也就是说这句话在手机上是**空转**的: 既不读文档, 也不入网。
168
+ // 现在手机自己就能走完: 读说明(校验 frontmatter) → 本机 DID → 服务登记
169
+ // (桌面可达则登记进桌面的网络 registry, 否则本机登记并如实说明) → P2P 公告(尽力) → 落盘入网态。
170
+ // 桌面不可达不是失败: 手机是自治节点; 但每一步都如实报 ok/note, 不假装入网。
171
+ /** 入网口令识别 (与 mobile.js 的默认 prompt 同源) */
172
+ export const MOBILE_JOIN_DOC_RE = /read\s+(https?:\/\/\S*bolloon-gateway-join\.md)/i;
173
+ const MOBILE_JOIN_STATE_KEY = 'bolloon_gateway_join';
174
+ export function detectJoinDocUrl(text) {
175
+ const m = MOBILE_JOIN_DOC_RE.exec(String(text || ''));
176
+ return m ? m[1] : null;
177
+ }
178
+ /** 极简 SKILL.md frontmatter 解析 (只认 name/version) */
179
+ function parseFm(text) {
180
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
181
+ if (!m)
182
+ return {};
183
+ const out = {};
184
+ for (const line of m[1].split(/\r?\n/)) {
185
+ const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
186
+ if (!kv)
187
+ continue;
188
+ const k = kv[1].toLowerCase();
189
+ if (k === 'name')
190
+ out.name = kv[2].trim().replace(/^["']|["']$/g, '');
191
+ if (k === 'version')
192
+ out.version = kv[2].trim().replace(/^["']|["']$/g, '');
193
+ }
194
+ return out;
195
+ }
196
+ export async function getMobileJoinState() {
197
+ try {
198
+ const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(MOBILE_JOIN_STATE_KEY) : null;
199
+ return raw ? JSON.parse(raw) : null;
200
+ }
201
+ catch {
202
+ return null;
203
+ }
204
+ }
205
+ function saveMobileJoinState(s) {
206
+ try {
207
+ if (typeof localStorage !== 'undefined')
208
+ localStorage.setItem(MOBILE_JOIN_STATE_KEY, JSON.stringify(s));
209
+ }
210
+ catch { /* 忽略 */ }
211
+ }
212
+ /**
213
+ * 按入网说明文档入网 (手机端自足执行)。
214
+ * opts.desktopBaseUrl 可注入 (测试用); 默认读 mobile-gateway 持久化的桌面基址。
215
+ */
216
+ export async function joinGatewayFromDoc(docUrl, opts = {}) {
217
+ const f = opts.fetchImpl || fetch;
218
+ const steps = [];
219
+ const url = String(docUrl || '').trim();
220
+ if (!/^https?:\/\//i.test(url)) {
221
+ return { ok: false, docUrl: url, steps, error: `入网说明地址必须是 http(s) URL (收到: ${url.slice(0, 60)})` };
222
+ }
223
+ // ① 读入网说明 + 校验
224
+ let docVersion;
225
+ try {
226
+ const r = await f(url, { signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) });
227
+ if (!r.ok) {
228
+ steps.push({ step: '读入网说明', ok: false, note: `文档不可达 (HTTP ${r.status})` });
229
+ return { ok: false, docUrl: url, steps, error: `入网说明不可达 (HTTP ${r.status})` };
230
+ }
231
+ const text = await r.text();
232
+ const fm = parseFm(text);
233
+ if (fm.name !== 'bolloon-gateway-join' && !/加入网关|bolloon-gateway-join/.test(text)) {
234
+ steps.push({ step: '读入网说明', ok: false, note: `不是 Bolloon 入网说明 (name=${fm.name || '无'})` });
235
+ return { ok: false, docUrl: url, steps, error: '该文档不是 Bolloon 网关入网说明, 拒绝据此入网' };
236
+ }
237
+ docVersion = fm.version;
238
+ steps.push({ step: '读入网说明', ok: true, note: `${fm.name || 'bolloon-gateway-join'} v${fm.version || '?'} (${text.length} 字符)` });
239
+ }
240
+ catch (e) {
241
+ steps.push({ step: '读入网说明', ok: false, note: `读取失败: ${String(e?.message || e).slice(0, 120)}` });
242
+ return { ok: false, docUrl: url, steps, error: `入网说明读取失败: ${String(e?.message || e).slice(0, 120)}` };
243
+ }
244
+ // ② 本机 DID (手机端身份层; 可注入, 便于测试/无 IndexedDB 环境)
245
+ let did = String(opts.did || '');
246
+ if (did) {
247
+ steps.push({ step: 'DID 身份', ok: true, note: `${did} (注入身份)` });
248
+ }
249
+ else {
250
+ try {
251
+ const id = await ensureIdentity();
252
+ did = id.did;
253
+ steps.push({ step: 'DID 身份', ok: true, note: `${did} (手机端本机生成)` });
254
+ }
255
+ catch (e) {
256
+ steps.push({ step: 'DID 身份', ok: false, note: String(e?.message || e).slice(0, 120) });
257
+ return { ok: false, docUrl: url, docVersion, steps, error: '手机端 DID 生成失败' };
258
+ }
259
+ }
260
+ const name = String(opts.name || 'phone-agent');
261
+ const capabilities = ['chat', 'gateway-join'];
262
+ // ③ 服务登记: 桌面可达 → 登记进桌面(网络) registry; 否则本机登记并如实说明
263
+ let desktopBase = String(opts.desktopBaseUrl ?? '');
264
+ if (opts.desktopBaseUrl === undefined) {
265
+ try {
266
+ const g = await import('./mobile-gateway.js');
267
+ desktopBase = String(g.getDesktopBaseUrl() || '');
268
+ }
269
+ catch {
270
+ desktopBase = '';
271
+ }
272
+ }
273
+ desktopBase = desktopBase.replace(/\/+$/, '');
274
+ let registeredOn = 'local';
275
+ if (desktopBase) {
276
+ try {
277
+ const r = await f(`${desktopBase}/api/registry/register`, {
278
+ method: 'POST', headers: { 'content-type': 'application/json' },
279
+ body: JSON.stringify({
280
+ agentId: did, name, wallet: '',
281
+ service: { name: 'chat', description: '手机端智能体 (自足节点)', price: { amount: '0', currency: 'USDC', per: 'task' }, endpoint: '' },
282
+ capabilities,
283
+ }),
284
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15000),
285
+ });
286
+ if (r.ok) {
287
+ registeredOn = 'desktop';
288
+ steps.push({ step: '服务登记', ok: true, note: `已登记进电脑端网络 registry (${desktopBase}) —— 网络内其他智能体可按能力发现我` });
289
+ }
290
+ else {
291
+ steps.push({ step: '服务登记', ok: false, note: `电脑端 registry 拒绝 (HTTP ${r.status}); 已改为本机登记` });
292
+ }
293
+ }
294
+ catch (e) {
295
+ steps.push({ step: '服务登记', ok: false, note: `电脑端不可达 (${String(e?.message || e).slice(0, 80)}); 已改为本机登记` });
296
+ }
297
+ }
298
+ else {
299
+ steps.push({ step: '服务登记', ok: true, note: '未配置电脑端基址 → 只在本机登记 (手机是自治节点; 设置里填电脑端地址可登记进网络 registry)' });
300
+ }
301
+ // ④ P2P 公告 (尽力): 让已连接的对端知道本机服务; 没连上不是入网失败
302
+ try {
303
+ const p2p = await import('./mobile-p2p.js');
304
+ let peers = 0;
305
+ try {
306
+ peers = (p2p.getConnectedPeers?.() || []).length;
307
+ }
308
+ catch {
309
+ peers = 0;
310
+ }
311
+ if (peers > 0 && typeof p2p.sendMobileP2PMessage === 'function') {
312
+ const okAnnounce = await p2p.sendMobileP2PMessage('*', 'registry.register', JSON.stringify({ agent_id: did, name, capabilities }), did);
313
+ steps.push({ step: 'P2P 公告', ok: !!okAnnounce, note: okAnnounce ? `已向 ${peers} 个对端广播本机声明` : `广播失败 (对端 ${peers} 个)` });
314
+ }
315
+ else {
316
+ steps.push({ step: 'P2P 公告', ok: false, note: '当前无已连接对端 (浏览器/未连电脑端时正常) — 本机声明已就绪, 连上即生效' });
317
+ }
318
+ }
319
+ catch (e) {
320
+ steps.push({ step: 'P2P 公告', ok: false, note: `P2P 层不可用: ${String(e?.message || e).slice(0, 80)}` });
321
+ }
322
+ // ⑤ 落盘入网态 (幂等: 同 url 再次入网覆盖时间戳)
323
+ saveMobileJoinState({ url, did, name, capabilities, docVersion, registeredOn, desktopBaseUrl: desktopBase || undefined, joinedAt: new Date().toISOString() });
324
+ const state = await getMobileJoinState();
325
+ steps.push({ step: '落盘入网态', ok: !!state?.did, note: `localStorage:${MOBILE_JOIN_STATE_KEY}` });
326
+ return { ok: true, docUrl: url, docVersion, did, steps };
327
+ }
328
+ /** 把入网结果渲染成给用户看的回复 (与桌面工具的 steps 汇报风格一致) */
329
+ export function formatMobileJoinResult(r) {
330
+ if (!r.ok) {
331
+ return `❌ 入网失败: ${r.error || '未知原因'}\n\n${r.steps.map((s) => `${s.ok ? '✓' : '✗'} ${s.step}: ${s.note}`).join('\n')}`;
332
+ }
333
+ const s = r.steps.find((x) => x.step === '服务登记');
334
+ return [
335
+ '✅ 已加入全球智能体网络 (手机端自足执行)',
336
+ '',
337
+ `DID: ${r.did}`,
338
+ `入网说明: v${r.docVersion || '?'} (${r.docUrl})`,
339
+ s ? `登记: ${s.note}` : '',
340
+ '',
341
+ ...r.steps.map((x) => `${x.ok ? '✓' : '✗'} ${x.step}: ${x.note}`),
342
+ '',
343
+ '用「网络 → Agent 网络」可查看成员; 设置里填电脑端地址可把本机登记进网络 registry。',
344
+ ].filter((l) => l !== '').join('\n');
345
+ }
346
+ // ============ 本地执行 (Kotlin AgentRuntime / 离线兜底) ============
164
347
  /** 手机端本地 agent 执行 (优先 Kotlin, 离线内置规则) */
165
348
  export async function runLocalAgent(goal) {
166
349
  const win = typeof window !== 'undefined' ? window : null;
167
350
  const cap = win?.Capacitor;
351
+ // 2026-09-15: 「读入网说明 → 入网」在手机端本地自足执行 (先于 Kotlin 桥: 原生工具集里没有入网能力,
352
+ // 交给它只会得到空转回复)。这样浏览器 / WebView / 真机三种环境行为一致。
353
+ const joinDocUrl = detectJoinDocUrl(goal);
354
+ if (joinDocUrl) {
355
+ _lastWorklog = [`🧩 识别为入网口令: ${joinDocUrl}`];
356
+ const r = await joinGatewayFromDoc(joinDocUrl).catch((e) => ({
357
+ ok: false, docUrl: joinDocUrl, steps: [{ step: '入网', ok: false, note: String(e?.message || e).slice(0, 120) }], error: String(e?.message || e),
358
+ }));
359
+ _lastWorklog = [..._lastWorklog, ...r.steps.map((s) => `${s.ok ? '✓' : '✗'} ${s.step}: ${s.note}`)];
360
+ return formatMobileJoinResult(r);
361
+ }
168
362
  const bridge = cap && cap.Plugins && cap.Plugins.RokidBridge;
169
363
  if (bridge && cap.isNativePlatform?.()) {
170
364
  try {