aims-workstation 0.1.6 → 0.1.8

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/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Agent 层基于 @earendil-works/pi-ai(统一 LLM API),前端为 web/ 预构建的 shadcn/ui 应用(web/dist)
4
4
  'use strict';
5
5
  // TLS 证书校验关闭(用户决策:内网私有部署自签证书常见,能访问优先)
6
- // 覆盖所有出站 fetch(ARM REST/MCP、LLM、面板代理)。本工具面向内网/本机使用。
6
+ // 覆盖所有出站 fetch(ARM REST、LLM、面板代理)。本工具面向内网/本机使用。
7
7
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
8
8
  const http = require('http');
9
9
  const fs = require('fs');
@@ -76,7 +76,7 @@ const PORT = +(process.env.PORT || env.PORT || 5600);
76
76
  const HOST = process.env.AIMS_HOST || '0.0.0.0'; // 默认局域网可达(团队共用);仅本机使用可设 AIMS_HOST=127.0.0.1
77
77
  // ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
78
78
  const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
79
- const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '', access_token: '' };
79
+ const cfg = { arm_api_base: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '', access_token: '' };
80
80
  // 守护栏默认策略(可在「系统设置·守护栏」维护,存 platform.json,保存即热生效):deny=正则源串(命中即永久拒绝,任何级别不豁免);
81
81
  // ro=只读白名单命令名(免确认直行);sudo_u_l2=true 时 `sudo -u <用户>` 降权形式不再永久拒绝、按写命令走 L2 人工确认(裸 sudo 仍拒绝)
82
82
  const GUARD_DEFAULT = {
@@ -92,14 +92,14 @@ function normalizeGuard(g) {
92
92
  sudo_u_l2: !!g.sudo_u_l2,
93
93
  };
94
94
  }
95
- function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } cfg.guard = normalizeGuard(cfg.guard); }
95
+ function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } delete cfg.arm_mcp_url; /* MCP 通道已退役:剥离旧配置残留 */ cfg.guard = normalizeGuard(cfg.guard); }
96
96
  loadCfg();
97
97
  const guardNow = () => cfg.guard;
98
98
  function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
99
- // ARM 站点地址:运行时配置 > ARM_API_BASE(env) > 从 MCP URL 推导 > 公司内网默认(内网可达即开箱可用);MCP 端点:显式配置 > 跟随站点地址
99
+ // ARM 站点地址:运行时配置 > ARM_API_BASE(env) > 公司内网默认(内网可达即开箱可用)
100
+ //(2026-10-21 全面切换新版 ARM:仅 REST 通道,MCP 通道及旧版兼容回落已退役)
100
101
  const ARM_DEFAULT_BASE = 'http://arm.agent-platform.dev.aimstek.cn';
101
- const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || (env.ARM_MCP_URL ? String(env.ARM_MCP_URL).replace(/\/api\/mcp$/, '') : ARM_DEFAULT_BASE);
102
- const armMcpUrl = () => cfg.arm_mcp_url || `${armApiBase()}/api/mcp`;
102
+ const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || ARM_DEFAULT_BASE;
103
103
  const armApiToken = () => cfg.arm_token || env.ARM_API_TOKEN || ARM_PAT;
104
104
  // LLM:OpenAI 兼容端点(baseUrl/模型/密钥),缺省公司内网 bifrost 网关(内网可达即可用,无需配置)
105
105
  const LLM_DEFAULT_BASE = 'http://bifrost.infra.dev.aimstek.cn/v1';
@@ -186,26 +186,7 @@ const world = { devices: [], alarms: [], kpi: {}, work_orders: [], inspections:
186
186
  function audit(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(AUDIT_LOG, JSON.stringify(entry) + '\n'); }
187
187
  function metric(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(METRICS_LOG, JSON.stringify(entry) + '\n'); }
188
188
 
189
- // ── ARM MCP 客户端(调用链 + 回流链)─────────────────────
190
- async function mcpCall(name, args, { pat = false, timeout_ms = 20000 } = {}) {
191
- const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' };
192
- if (pat && ARM_PAT) headers['Authorization'] = `Bearer ${ARM_PAT}`;
193
- const ctrl = new AbortController();
194
- const timer = setTimeout(() => ctrl.abort(), timeout_ms); // 离线快速失败(降级本地库)
195
- let data;
196
- try {
197
- const res = await fetch(armMcpUrl(), {
198
- method: 'POST', headers, signal: ctrl.signal,
199
- body: JSON.stringify({ jsonrpc: '2.0', id: crypto.randomUUID(), method: 'tools/call', params: { name, arguments: args } }),
200
- });
201
- data = await res.json();
202
- } finally { clearTimeout(timer); }
203
- if (data.error) throw new Error(`ARM MCP ${name}: ${data.error.message}`);
204
- if ((data.result || {}).isError) throw new Error(`ARM MCP ${name} 拒绝: ${((data.result).content || []).map(c => c.text).join('').slice(0, 150)}`);
205
- return ((data.result || {}).content || []).map(c => c.text || '').join('\n');
206
- }
207
-
208
- // ARM REST 客户端(v2 开发版结构化通道;{ ok, data, msg } 包络,ok=false 抛错)
189
+ // ARM REST 客户端(v2 开发版结构化通道;{ ok, data, msg } 包络,ok=false 抛错)——目录/检索/下载/回流全链路唯一通道
209
190
  async function armRest(pathname, init = {}, timeout_ms = 20000) {
210
191
  const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) };
211
192
  const token = armApiToken();
@@ -232,6 +213,11 @@ async function armRest(pathname, init = {}, timeout_ms = 20000) {
232
213
  throw e;
233
214
  } finally { clearTimeout(timer); }
234
215
  }
216
+ // 知识写入(经验回流链):REST v2,令牌走 armApiToken() 三级回落(设置页 > env > ~/.arm PAT)
217
+ // 返回 data: { id, name, shareUrl, … }——id 为 UUID,供指标留痕与回流记录定位
218
+ async function armCreateKnowledge({ name, description, content, tags }) {
219
+ return armRest('/api/v1/knowledges', { method: 'POST', body: JSON.stringify({ name, description, content, tags }) }, 25000);
220
+ }
235
221
  const vVer = (v) => (String(v || '').startsWith('v') ? String(v) : `v${v || '0.0.0'}`); // REST 无 v 前缀,统一内部表示
236
222
 
237
223
  const agentCache = new Map();
@@ -240,30 +226,14 @@ async function getArmAgent(agentId) {
240
226
  if (lm) return { name: lm.name, version: lm.version, prompt: lm.prompt, knowledges: (lm.knowledges || []).map(k => k.ref) };
241
227
  const hit = agentCache.get(agentId);
242
228
  if (hit && Date.now() - hit.at < 5 * 60_000) return hit.data;
243
- const text = await mcpCall('get_agent', { id: agentId });
244
- const name = (text.match(/^#\s+(.+?)\s+\(/m) || [])[1] || agentId;
245
- const version = (text.match(/^#\s+.+?\s+\((v[\d.]+)\)/m) || [])[1] || '';
246
- const prompt = (text.match(/## Prompt\n([\s\S]*?)(\n## |\n*$)/) || [])[1]?.trim() || '';
247
- const knowledges = [...text.matchAll(/^- (.+?) v[\d.]+((\w+))/gm)].map(m => m[1]);
248
- const data = { name, version, prompt, knowledges };
229
+ const d = await armRest(`/api/v1/agents/${agentId}`);
230
+ const knowledges = (d.knowledges || []).map(kb => (kb.knowledge && kb.knowledge.name) || kb.knowledgeId || '').filter(Boolean);
231
+ const data = { name: d.name || agentId, version: vVer(d.version), prompt: d.prompt || '', knowledges };
249
232
  agentCache.set(agentId, { data, at: Date.now() });
250
233
  return data;
251
234
  }
252
235
 
253
- // search_agents 目录解析(大厅/同步共用)
254
- function parseAgentCards(raw) {
255
- const cards = [];
256
- for (const block of raw.split(/\n(?=## )/)) {
257
- const m = block.match(/^## (.+?) \((v[\d.]+)\)\nID: ([0-9a-f-]{36})/);
258
- if (!m) continue;
259
- const bind = block.match(/绑定:(\d+) Skill \/ (\d+) Knowledge/);
260
- const desc = (block.split('\n').filter(l => l && !l.startsWith('#') && !l.startsWith('ID:') && !l.startsWith('绑定'))[0] || '').trim();
261
- cards.push({ name: m[1], version: m[2], agent_id: m[3], skills: +(bind?.[1] || 0), knowledges: +(bind?.[2] || 0), description: desc.slice(0, 120) });
262
- }
263
- return cards;
264
- }
265
-
266
- // 助手目录:REST 分页结构化优先(可全量),旧版 ARM 回落 MCP markdown 解析(limit 上限 20)
236
+ // 助手目录:REST 分页结构化(可全量)
267
237
  async function armListAgents(keyword = '') {
268
238
  const out = [];
269
239
  for (let page = 1; page <= 5; page++) {
@@ -274,26 +244,16 @@ async function armListAgents(keyword = '') {
274
244
  return out.map(a => ({ agent_id: a.id, name: a.name, version: vVer(a.version), skills: a.skillsCount || 0, knowledges: a.knowledgesCount || 0, description: String(a.description || '').slice(0, 120), status: a.status || 'active', created_by: a.creatorName || a.createdBy || '' }));
275
245
  }
276
246
 
277
- // 知识检索(全局经验库):REST 混合检索(RAG 语义 ⊕ 关键词)优先,回落 MCP RAG
247
+ // 知识检索(全局经验库):REST 混合检索(RAG 语义 ⊕ 关键词);不可达由调用方降级(本地知识关键词命中)
278
248
  async function searchKnowledgeItems(query, topK = 5) {
279
- try {
280
- const d = await armRest('/api/v1/knowledges/search', { method: 'POST', body: JSON.stringify({ query, topK }) });
281
- return {
282
- via: `rest:${d.mode || 'hybrid'}${d.degraded ? ':degraded' : ''}`,
283
- items: (d.items || []).map(it => ({ title: it.name, similarity: it.score != null ? Math.round(it.score * 100) : null, snippet: String(it.snippet || '').slice(0, 320), id: it.knowledgeId })),
284
- };
285
- } catch {
286
- const text = await mcpCall('search_knowledges', { query, limit: topK });
287
- const items = text.split(/\n(?=## )/).filter(Boolean).map(b => {
288
- const title = (b.match(/^## (.+?)(?:(相似度 (\d+)%))?/) || [])[1] || b.slice(0, 40);
289
- return { title: title.trim(), similarity: +((b.match(/相似度 (\d+)%/) || [])[1] || 0) || null, snippet: b.replace(/^## .*\n?/, '').split('[完整内容')[0].slice(0, 320) };
290
- });
291
- return { via: 'mcp', items };
292
- }
249
+ const d = await armRest('/api/v1/knowledges/search', { method: 'POST', body: JSON.stringify({ query, topK }) });
250
+ return {
251
+ via: `rest:${d.mode || 'hybrid'}${d.degraded ? ':degraded' : ''}`,
252
+ items: (d.items || []).map(it => ({ title: it.name, similarity: it.score != null ? Math.round(it.score * 100) : null, snippet: String(it.snippet || '').slice(0, 320), id: it.knowledgeId })),
253
+ };
293
254
  }
294
255
 
295
256
  // ── 本地助手库(07-助手本地化:下载物化 → 离线可用;按版本目录存档,升级原子切指针)──
296
- const armSourceTag = (base) => { try { return `arm://${new URL(base).host}`; } catch { return 'arm://unknown'; } };
297
257
  const regFile = () => path.join(ARM_AGENTS_DIR, 'registry.json');
298
258
  function loadRegistry() {
299
259
  try { return JSON.parse(fs.readFileSync(regFile(), 'utf8')); } catch { return { agents: [], last_sync_at: null }; }
@@ -319,13 +279,7 @@ function cmpVersion(a, b) { // 'v1.10.0' vs 'v1.9.3' 语义化比较
319
279
  }
320
280
  const slug = (s) => String(s).trim().replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 60) || 'knowledge';
321
281
 
322
- // 下载物化:REST 主路径(结构化详情 + knowledgeId 直取全文),旧版 ARM 回落 MCP 解析
323
- async function downloadAgent(agentId) {
324
- try { return await downloadAgentRest(agentId); }
325
- catch { return await downloadAgentMcp(agentId); }
326
- }
327
-
328
- // 注册表提交(两路下载共用):写版本目录后原子切指针
282
+ // 注册表提交:写版本目录后原子切指针
329
283
  function commitDownload(manifest) {
330
284
  const reg = loadRegistry();
331
285
  let e = reg.agents.find(a => a.agent_id === manifest.agent_id);
@@ -349,7 +303,8 @@ function uninstallLocalAgent(agentId, purge_workspaces) {
349
303
  return true;
350
304
  }
351
305
 
352
- async function downloadAgentRest(agentId) {
306
+ // 下载物化:REST 结构化详情 + knowledgeId 直取全文
307
+ async function downloadAgent(agentId) {
353
308
  const d = await armRest(`/api/v1/agents/${agentId}`);
354
309
  const version = vVer(d.version);
355
310
  const verDir = path.join(ARM_AGENTS_DIR, agentId, 'versions', version);
@@ -366,62 +321,39 @@ async function downloadAgentRest(agentId) {
366
321
  } catch (e) { note = `知识拉取失败: ${String(e.message).slice(0, 80)}`; }
367
322
  knowledges.push({ ref: name, version: vVer(kb.version), kind: kb.kind || '', file, note });
368
323
  }
324
+ // UI 绑定物化(三资产模型:助手上岗自带工位)——面板过校验器入库、workbench 直接线上 v1(可回滚)
325
+ const ui_bindings = [];
326
+ for (const ub of d.uiBindings || []) {
327
+ const an = ub.asset && ub.asset.name;
328
+ const ver = ub.assetVersion || (ub.asset && ub.asset.latestVersion) || 'v1';
329
+ if (!an) continue;
330
+ let note = '';
331
+ try {
332
+ const { buf } = await armDownload(`/api/v1/ui-assets/${encodeURIComponent(an)}/versions/${encodeURIComponent(ver)}/download`);
333
+ fs.mkdirSync(path.join(verDir, 'ui'), { recursive: true });
334
+ fs.writeFileSync(path.join(verDir, 'ui', `${slug(an)}-${ver}.zip`), buf);
335
+ const r = installAssetFiles(unzipFiles(buf), { agentId, mode: 'live' });
336
+ note = `panels×${r.panels.length}${r.workbench ? ' + workbench' : ''}`;
337
+ } catch (e) { note = `UI 资产物化失败: ${String(e.message).slice(0, 80)}`; }
338
+ ui_bindings.push({ ref: an, version: ver, note });
339
+ }
369
340
  const manifest = {
370
341
  agent_id: agentId, name: d.name, version, description: d.description || '',
371
342
  downloaded_at: new Date().toISOString(), source: `arm-rest://${(armApiBase() || '').replace(/^https?:\/\//, '').split('/')[0]}`,
372
343
  prompt: d.prompt || '',
373
344
  skills: (d.skills || []).map(s => ({ ref: (s.skill && s.skill.name) || s.skillId || s.id, version: vVer(s.version), kind: 'skill', description: String((s.skill && s.skill.description) || '').slice(0, 200) })),
374
- knowledges,
345
+ knowledges, ui_bindings,
375
346
  };
376
347
  fs.writeFileSync(path.join(verDir, 'prompt.md'), manifest.prompt);
377
348
  fs.writeFileSync(path.join(verDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
378
349
  commitDownload(manifest);
379
- audit({ actor: 'arm-library', agentId, action: 'download', via: 'rest', version, knowledges_local: knowledges.filter(k => k.file).length });
380
- return manifest;
381
- }
382
-
383
- // 旧版 ARM 兼容:get_agent markdown 解析 + 知识 ID 经检索定位
384
- async function downloadAgentMcp(agentId) {
385
- const text = await mcpCall('get_agent', { id: agentId }, { timeout_ms: 25000 });
386
- const section = (h) => (text.match(new RegExp(`## ${h}\\n([\\s\\S]*?)(\\n## |\\n*$)`)) || [])[1] || '';
387
- const parseList = (s) => [...s.matchAll(/^- (.+?) (v[\d.]+)((\w+))/gm)].map(m => ({ ref: m[1].trim(), version: m[2], kind: m[3] }));
388
- const name = (text.match(/^#\s+(.+?)\s+\(/m) || [])[1] || agentId;
389
- const version = (text.match(/^#\s+.+?\s+\((v[\d.]+)\)/m) || [])[1] || 'v0.0.0';
390
- const prompt = (text.match(/## Prompt\n([\s\S]*?)(\n## |\n*$)/) || [])[1]?.trim() || '';
391
- const description = section('描述').trim();
392
- const skills = parseList(section('Skills'));
393
- const verDir = path.join(ARM_AGENTS_DIR, agentId, 'versions', version);
394
- fs.mkdirSync(path.join(verDir, 'knowledges'), { recursive: true });
395
- const knowledges = [];
396
- for (const k of parseList(section('Knowledges'))) {
397
- let file = null, note = '';
398
- try { // 知识 ID 经检索定位(ARM 无按名直取接口),再拉全文。注意:知识正文自身含 "## " 内部标题,不可按标题切块——须按「标题→首个 [完整内容] 标记」成对匹配
399
- const sres = await mcpCall('search_knowledges', { query: k.ref, limit: 5 }, { timeout_ms: 20000 });
400
- const pairs = [...sres.matchAll(/^## (.+?)(?:(相似度 \d+%))?\n[\s\S]*?\[完整内容: get_knowledge id=([0-9a-f-]{36})\]/gm)];
401
- const kid = pairs.find(m => m[1].trim() === k.ref)?.[2];
402
- if (kid) {
403
- const full = await mcpCall('get_knowledge', { id: kid }, { timeout_ms: 20000 });
404
- file = `knowledges/${slug(k.ref)}.md`;
405
- fs.writeFileSync(path.join(verDir, file), full);
406
- } else note = 'ARM 检索未命中该知识,离线不可用';
407
- } catch (e) { note = `知识拉取失败: ${String(e.message).slice(0, 80)}`; }
408
- knowledges.push({ ...k, file, note });
409
- }
410
- const manifest = {
411
- agent_id: agentId, name, version, description,
412
- downloaded_at: new Date().toISOString(), source: armSourceTag(armMcpUrl()), prompt,
413
- skills, knowledges,
414
- };
415
- fs.writeFileSync(path.join(verDir, 'prompt.md'), prompt);
416
- fs.writeFileSync(path.join(verDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
417
- commitDownload(manifest);
418
- audit({ actor: 'arm-library', agentId, action: 'download', via: 'mcp', version, knowledges_local: knowledges.filter(k => k.file).length });
350
+ audit({ actor: 'arm-library', agentId, action: 'download', via: 'rest', version, knowledges_local: knowledges.filter(k => k.file).length, ui_bindings: ui_bindings.length });
419
351
  return manifest;
420
352
  }
421
353
 
422
354
  // 同步:比对远端目录与本地库,产出 new/update/delisted 事件;顺带冲账离线经验 outbox
423
355
  async function armSync() {
424
- const remote = await armListAgents(); // REST 全量优先,旧版 ARM 内部回落 MCP(≤20)
356
+ const remote = await armListAgents();
425
357
  const reg = loadRegistry();
426
358
  const remoteIds = new Set(remote.map(r => r.agent_id));
427
359
  const events = { new: [], update: [], delisted: [] };
@@ -449,7 +381,7 @@ async function flushExperienceOutbox() {
449
381
  const lines = fs.readFileSync(OUTBOX_FILE, 'utf8').split('\n').filter(Boolean);
450
382
  const remain = []; let ok = 0;
451
383
  for (const line of lines) {
452
- try { const it = JSON.parse(line); await mcpCall('create_knowledge', it.args, { pat: true }); ok++; }
384
+ try { const it = JSON.parse(line); await armCreateKnowledge(it.args); ok++; }
453
385
  catch { remain.push(line); }
454
386
  }
455
387
  fs.writeFileSync(OUTBOX_FILE, remain.length ? remain.join('\n') + '\n' : '');
@@ -671,16 +603,15 @@ const TOOLS = {
671
603
  const content = ['---', 'kind: experience', 'source: aims-platform-mvp', `agent: ${arm.name || agentId}@${arm.version || ''}`, 'scenario: 立库A区智能运维@mvp', '---', '', markdown].join('\n');
672
604
  const args = { name: title, description: `MVP回流:${title}`, content, tags: ['MVP回流'] };
673
605
  let r;
674
- try { r = await mcpCall('create_knowledge', args, { pat: true }); }
606
+ try { r = await armCreateKnowledge(args); }
675
607
  catch (e) { // 离线:入 outbox 缓写,恢复连接后经「检查更新」自动回流
676
608
  fs.appendFileSync(OUTBOX_FILE, JSON.stringify({ at: new Date().toISOString(), agentId, args }) + '\n');
677
609
  audit({ actor: 'experience-reflow', target: 'outbox', title });
678
610
  return `ARM 不可达(${String(e.message).slice(0, 60)}),经验已存本地待回流队列;恢复连接后在助手大厅点「检查更新」将自动回流。`;
679
611
  }
680
- const kid = (r.match(/id:\s*([0-9a-f-]{36})/) || [])[1] || '';
681
- metric({ kind: 'experience', agentId, title, knowledge_id: kid });
612
+ metric({ kind: 'experience', agentId, title, knowledge_id: r.id });
682
613
  audit({ actor: 'experience-reflow', target: 'ARM', title });
683
- return `经验已沉淀到 ARM 知识库:\n${r}`;
614
+ return `经验已沉淀到 ARM 知识库:${r.name}(id: ${r.id}${r.shareUrl ? `,${r.shareUrl}` : ''})`;
684
615
  },
685
616
  },
686
617
  };
@@ -747,10 +678,15 @@ function openDb(file) {
747
678
  const db = new DatabaseSync(path.join(DATA_DIR, file));
748
679
  db.exec('CREATE TABLE IF NOT EXISTS docs (collection TEXT NOT NULL, id TEXT PRIMARY KEY, data TEXT NOT NULL, created TEXT, updated TEXT)');
749
680
  db.exec('CREATE INDEX IF NOT EXISTS idx_docs_collection ON docs(collection)');
681
+ db.exec('CREATE TABLE IF NOT EXISTS _meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)'); // 变更版本计数(面板订阅用,08 契约 §4)
750
682
  _dbs.set(file, db);
751
683
  }
752
684
  return _dbs.get(file);
753
685
  }
686
+ // 记录写入后自增 ver:<collection>(持久化,重启不归零)——REST 与助手 data_write 同源,面板 watch 据此联动
687
+ function bumpVer(db, coll) {
688
+ db.prepare("INSERT INTO _meta (k, v) VALUES (?, '1') ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1").run(`ver:${coll}`);
689
+ }
754
690
  const DB = openDb('store.db'); // 兼容旧引用(迁移后即空)
755
691
  // 作用域语义:agent=助手私有(默认,存归属助手×工作区的独立库);shared=跨助手共享(存共享库)
756
692
  const scopeOf = (m) => (m.scope === 'shared' || m.scope === 'global') ? 'shared' : 'agent'; // legacy global→shared, workbench→agent
@@ -800,6 +736,7 @@ function insertDoc(m, doc, wsId = 'default') {
800
736
  const now = new Date().toISOString();
801
737
  dbFor(m, wsId).prepare('INSERT OR REPLACE INTO docs (collection, id, data, created, updated) VALUES (?,?,?,?,?)')
802
738
  .run(collKey(m), id, JSON.stringify(doc), now, now);
739
+ bumpVer(dbFor(m, wsId), collKey(m));
803
740
  return { ...doc, _id: id, _created: now, _updated: now };
804
741
  }
805
742
  function updateDoc(m, id, patch, wsId = 'default') {
@@ -808,11 +745,13 @@ function updateDoc(m, id, patch, wsId = 'default') {
808
745
  const merged = { ...JSON.parse(row.data), ...patch };
809
746
  validateDoc(m, merged);
810
747
  dbFor(m, wsId).prepare('UPDATE docs SET data = ?, updated = ? WHERE collection = ? AND id = ?').run(JSON.stringify(merged), new Date().toISOString(), collKey(m), id);
748
+ bumpVer(dbFor(m, wsId), collKey(m));
811
749
  return merged;
812
750
  }
813
751
  function deleteDoc(m, id, wsId = 'default') {
814
752
  const r = dbFor(m, wsId).prepare('DELETE FROM docs WHERE collection = ? AND id = ?').run(collKey(m), id);
815
753
  if (!r.changes) throw new Error(`记录 ${id} 不存在`);
754
+ bumpVer(dbFor(m, wsId), collKey(m));
816
755
  }
817
756
 
818
757
  // ── 一次性迁移:旧单库 → 按助手分库(按归属切分记录)──
@@ -880,8 +819,8 @@ function deleteDoc(m, id, wsId = 'default') {
880
819
  // 占位清理:旧类型残留保护
881
820
  (function migrateBuiltinPanels() {
882
821
  const MAP = {
883
- realtime_list: { plugin: 'system-alarm-stream', slot: 'left', keep: ['refresh', 'device_type'] },
884
- status_grid: { plugin: 'system-device-grid', slot: 'center', keep: ['refresh', 'device_type', 'context_inject'] },
822
+ realtime_list: { plugin: 'system-alarm-stream', slot: 'left', keep: ['device_type'] },
823
+ status_grid: { plugin: 'system-device-grid', slot: 'center', keep: ['device_type', 'context_inject'] },
885
824
  kpi_strip: { plugin: 'system-kpi-strip', slot: 'center', keep: [] },
886
825
  inspection_report: { plugin: 'system-inspection-report', slot: 'left', keep: [] },
887
826
  action_group: { plugin: 'system-action-group', slot: 'right', keep: ['actions'] },
@@ -1036,22 +975,233 @@ function pluginCenterData() {
1036
975
  system: systemPluginStatus().map((s) => ({ ...s, usage: usage[`system:${s.id}`] || [] })),
1037
976
  library: listPanelPlugins().map((pl) => {
1038
977
  const full = loadPanelPlugin(pl.id);
1039
- return { ...pl, status: full?.status || 'active', usage: usage[`plugin:${pl.id}`] || [], lastCheck: full?.lastCheck || null };
978
+ return { ...pl, status: full?.status || 'active', usage: usage[`plugin:${pl.id}`] || [], lastCheck: full?.lastCheck || null, arm_ref: full?.arm_ref || null };
1040
979
  }),
1041
980
  };
1042
981
  }
1043
982
  // 代理域名白名单:从在库插件代码中提取其声明访问的主机(含 deprecated,保守起见全量)
983
+ // 存主机名(剥离端口)——检查侧用 URL.hostname 比对;带端口声明的 host 此前永远匹配不上(45.36 实例排查)
1044
984
  function proxyAllowedHosts() {
1045
985
  const hosts = new Set(['localhost', '127.0.0.1']);
1046
986
  for (const f of fs.readdirSync(PANELS_DIR).filter((f) => f.endsWith('.json'))) {
1047
987
  try {
1048
988
  const code = JSON.parse(fs.readFileSync(path.join(PANELS_DIR, f), 'utf8')).code || '';
1049
- for (const m of code.matchAll(/https?:\/\/([a-z0-9.-]+(?::\d+)?)/gi)) hosts.add(m[1].toLowerCase());
989
+ for (const m of code.matchAll(/https?:\/\/([a-z0-9.-]+)(?::\d+)?/gi)) hosts.add(m[1].toLowerCase());
1050
990
  } catch { }
1051
991
  }
1052
992
  return hosts;
1053
993
  }
1054
994
 
995
+ // ── UI 资产分发(ARM 三资产模型:UIAsset = 面板/工作台的版本化分发,docs/08 §7、docs/09)──
996
+ const zlib = require('node:zlib');
997
+ // ZIP 读写器(零依赖:CRC32 查表 + local header + central directory + EOCD,deflate 走 zlib 内置)
998
+ const CRC_TABLE = (() => { const t = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : c >>> 1; t[n] = c; } return t; })();
999
+ function crc32(buf) { let c = -1; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); return (c ^ -1) >>> 0; }
1000
+ const dosTime = (d) => ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
1001
+ const dosDate = (d) => (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
1002
+ function zipFiles(files) { // files: [{path, data: Buffer|string}] → Buffer(兼容系统 unzip:deflate/stored 自适应 + UTF-8 名)
1003
+ const parts = [], central = []; let offset = 0; const now = new Date();
1004
+ for (const f of files) {
1005
+ const name = Buffer.from(f.path, 'utf8');
1006
+ const raw = Buffer.isBuffer(f.data) ? f.data : Buffer.from(String(f.data), 'utf8');
1007
+ const comp = zlib.deflateRawSync(raw, { level: 9 });
1008
+ const useDeflate = comp.length < raw.length;
1009
+ const data = useDeflate ? comp : raw;
1010
+ const crc = crc32(raw);
1011
+ const lh = Buffer.alloc(30);
1012
+ lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
1013
+ lh.writeUInt16LE(useDeflate ? 8 : 0, 8); lh.writeUInt16LE(dosTime(now), 10); lh.writeUInt16LE(dosDate(now), 12);
1014
+ lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(raw.length, 22);
1015
+ lh.writeUInt16LE(name.length, 26); lh.writeUInt16LE(0, 28);
1016
+ parts.push(lh, name, data);
1017
+ const ch = Buffer.alloc(46);
1018
+ ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6); ch.writeUInt16LE(0x0800, 8);
1019
+ ch.writeUInt16LE(useDeflate ? 8 : 0, 10); ch.writeUInt16LE(dosTime(now), 12); ch.writeUInt16LE(dosDate(now), 14);
1020
+ ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(raw.length, 24);
1021
+ ch.writeUInt16LE(name.length, 28);
1022
+ ch.writeUInt32LE(offset, 42);
1023
+ central.push(Buffer.concat([ch, name]));
1024
+ offset += 30 + name.length + data.length;
1025
+ }
1026
+ const cd = Buffer.concat(central);
1027
+ const eocd = Buffer.alloc(22);
1028
+ eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(files.length, 8); eocd.writeUInt16LE(files.length, 10);
1029
+ eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
1030
+ return Buffer.concat([...parts, cd, eocd]);
1031
+ }
1032
+ function unzipFiles(buf) { // → [{path, data: Buffer}](支持 stored/deflate)
1033
+ let eocd = -1;
1034
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65536); i--) {
1035
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
1036
+ }
1037
+ if (eocd < 0) throw new Error('ZIP 解析失败:未找到目录尾');
1038
+ const count = buf.readUInt16LE(eocd + 10);
1039
+ let p = buf.readUInt32LE(eocd + 16);
1040
+ const out = [];
1041
+ for (let n = 0; n < count; n++) {
1042
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('ZIP 解析失败:目录损坏');
1043
+ const method = buf.readUInt16LE(p + 10);
1044
+ const compSize = buf.readUInt32LE(p + 20);
1045
+ const nameLen = buf.readUInt16LE(p + 28), extraLen = buf.readUInt16LE(p + 30), commentLen = buf.readUInt16LE(p + 32);
1046
+ const lho = buf.readUInt32LE(p + 42);
1047
+ const zipPath = buf.slice(p + 46, p + 46 + nameLen).toString('utf8');
1048
+ const lnameLen = buf.readUInt16LE(lho + 26), lextraLen = buf.readUInt16LE(lho + 28);
1049
+ const dStart = lho + 30 + lnameLen + lextraLen;
1050
+ const comp = buf.slice(dStart, dStart + compSize);
1051
+ if (!zipPath.endsWith('/')) out.push({ path: zipPath, data: method === 8 ? zlib.inflateRawSync(comp) : Buffer.from(comp) });
1052
+ p += 46 + nameLen + extraLen + commentLen;
1053
+ }
1054
+ return out;
1055
+ }
1056
+ // ARM multipart 上传(FormData+Blob,零依赖 fetch 原生支持;复用 armRest 的 token 体系与错误提示)
1057
+ async function armUpload(pathname, formData, timeout_ms = 30000) {
1058
+ const token = armApiToken();
1059
+ const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeout_ms);
1060
+ try {
1061
+ const res = await fetch(armApiBase() + pathname, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: formData, signal: ctrl.signal });
1062
+ const data = await res.json().catch(() => ({}));
1063
+ if (!res.ok || data.ok === false) {
1064
+ const noAuth = res.status === 401 || res.status === 403;
1065
+ throw new Error(`ARM 上传 ${pathname}: ${data.msg || res.status}${noAuth ? '——令牌未配置或无权限:到「系统设置 · ARM 源」配置访问令牌(arm_pat_…)' : ''}`);
1066
+ }
1067
+ return data.data;
1068
+ } finally { clearTimeout(t); }
1069
+ }
1070
+ // ARM 二进制下载(资产包 ZIP):返回 Buffer + X-File-Hash(完整性校验)
1071
+ async function armDownload(pathname, timeout_ms = 30000) {
1072
+ const token = armApiToken();
1073
+ const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeout_ms);
1074
+ try {
1075
+ const res = await fetch(armApiBase() + pathname, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: ctrl.signal });
1076
+ if (!res.ok) {
1077
+ const j = await res.json().catch(() => ({}));
1078
+ const noAuth = res.status === 401 || res.status === 403;
1079
+ throw new Error(`ARM 下载 ${pathname.split('?')[0]}: ${j.msg || res.status}${noAuth ? '——令牌未配置或无权限:到「系统设置 · ARM 源」配置访问令牌(arm_pat_…)' : ''}`);
1080
+ }
1081
+ return { buf: Buffer.from(await res.arrayBuffer()), hash: res.headers.get('x-file-hash') || '' };
1082
+ } finally { clearTimeout(t); }
1083
+ }
1084
+ const assetSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'asset';
1085
+ // 组包:panel=单插件三件套;workbench=线上 DSL(无则草稿)+依赖插件+数据集 schema+requires 声明(08 契约 §7)
1086
+ function buildAssetBundle(type, key, nameOverride) {
1087
+ const files = [];
1088
+ const requires = { apis: [], actions: [], collections: [] };
1089
+ const plugins = []; const collections = [];
1090
+ const addPlugin = (id) => {
1091
+ if (plugins.some((p) => p.id === id)) return;
1092
+ const plugin = loadPanelPlugin(id);
1093
+ if (!plugin) return;
1094
+ plugins.push(plugin);
1095
+ files.push({ path: `panels/${plugin.id}.json`, data: JSON.stringify({ id: plugin.id, name: plugin.name, description: plugin.description, code: plugin.code }, null, 2) });
1096
+ for (const m of (plugin.code || '').matchAll(/https?:\/\/([a-z0-9.-]+)(?::\d+)?/gi)) { // 外部依赖声明:代码即事实(与 proxyAllowedHosts 同源正则),分享时提取
1097
+ const host = m[1].toLowerCase();
1098
+ if (host !== 'localhost' && host !== '127.0.0.1' && !requires.apis.some((x) => x.host === host)) requires.apis.push({ host, usage: `插件 ${plugin.id} 跨域取数`, via: 'proxy' });
1099
+ }
1100
+ for (const m of (plugin.code || '').matchAll(/store\.(?:list|create|update|remove|watch)\(\s*['"`]([a-z0-9_-]+)['"`]/g)) {
1101
+ const manifest = loadCollection(m[1]);
1102
+ if (manifest && !collections.some((c) => c.name === manifest.name)) collections.push(manifest);
1103
+ }
1104
+ };
1105
+ let wbName = '';
1106
+ if (type === 'panel') {
1107
+ addPlugin(key);
1108
+ if (!plugins.length) throw new Error(`插件 ${key} 不存在`);
1109
+ } else {
1110
+ const dsl = loadWorkbench(key) || loadDraft(key);
1111
+ if (!dsl) throw new Error(`助手 ${key} 无工作台(线上与草稿均为空)`);
1112
+ wbName = dsl.workbench || '';
1113
+ for (const p of dsl.panels || []) {
1114
+ if (p.type === 'custom_panel' && p.panel) addPlugin(p.panel);
1115
+ for (const a of (p.params && p.params.actions) || []) { // 交互脚本全文明示(凭据风险人审,红线③)
1116
+ requires.actions.push({ panel: p.id, id: a.id, label: a.label, exec: String(a.exec), has_credentials: /pass|-p\s|pwd|token|secret|apikey|key=/i.test(String(a.exec)) });
1117
+ }
1118
+ }
1119
+ for (const m of loadCollections()) { // 归属助手的数据集 schema(数据永不入包,红线②)
1120
+ if (m.owner_agent === key && !collections.some((c) => c.name === m.name)) collections.push(m);
1121
+ }
1122
+ const wb = JSON.parse(JSON.stringify(dsl));
1123
+ delete wb.owner_agent; wb.version = 'v1';
1124
+ files.push({ path: 'workbench.json', data: JSON.stringify(wb, null, 2) });
1125
+ }
1126
+ for (const m of collections) {
1127
+ files.push({ path: `collections/${m.name}.json`, data: JSON.stringify({ name: m.name, title: m.title, description: m.description, schema: m.schema, required: m.required || [], scope: m.scope === 'shared' ? 'shared' : 'agent' }, null, 2) });
1128
+ }
1129
+ const first = type === 'panel' ? plugins[0] : null;
1130
+ const dep = type === 'workbench' ? loadDeployment(key) : null;
1131
+ const wbSlug = assetSlug(dep?.name || wbName || '');
1132
+ // 中文名 slug 化后为空 → 以 agentId 前 8 位兜底(全局唯一锚)
1133
+ const name = nameOverride || (type === 'panel' ? `aims-panel-${first.id}` : `aims-workbench-${wbSlug !== 'asset' ? wbSlug : String(key).slice(0, 8)}`);
1134
+ const manifest = {
1135
+ name, description: (first ? first.description : `${dep?.name || wbName}的工作台模板`).slice(0, 500),
1136
+ asset_type: type, version: type === 'panel' ? first.version : 'v1', source: 'aims-workstation', shared_at: new Date().toISOString(),
1137
+ provides: { panels: plugins.map((p) => p.id), collections: collections.map((c) => c.name), workbench: type === 'workbench' },
1138
+ requires, compatibility: { 'aims-workstation': '>=0.1.5' },
1139
+ };
1140
+ files.push({ path: 'asset.json', data: JSON.stringify(manifest, null, 2) });
1141
+ files.push({
1142
+ path: 'SKILL.md',
1143
+ data: `---\nname: ${name}\ndescription: ${manifest.description.replace(/\n/g, ' ').slice(0, 200)}\n---\n\n# ${name}\n\nAIMS 工作台资产(${type === 'panel' ? '面板插件' : '工作台模板'})。\n\n## 包含\n\n- 面板插件:${plugins.map((p) => `${p.name}(${p.id}@${p.version})`).join('、') || '无'}\n- 数据集 schema:${collections.map((c) => c.name).join('、') || '无'}${type === 'workbench' ? '\n- 工作台 DSL:workbench.json' : ''}\n${requires.apis.length ? `\n## 外部 API 依赖(requires.apis)\n\n${requires.apis.map((a) => `- ${a.host}:${a.usage}`).join('\n')}` : ''}${requires.actions.length ? `\n## 交互脚本(requires.actions,含真实 shell 命令)\n\n${requires.actions.map((a) => `- [${a.panel}/${a.id}] ${a.label}:\`${a.exec.slice(0, 160)}\`${a.has_credentials ? ' ⚠️疑似含凭据' : ''}`).join('\n')}` : ''}\n\n## 安装\n\nAIMS 平台「插件中心 → 从 ARM 安装」按名搜索安装;面板代码过平台校验器后入库。\n`,
1144
+ });
1145
+ return { name, manifest, files };
1146
+ }
1147
+ // 解包安装:panels 过校验器入库;collections 同名保留本地仅新增缺失;workbench 按模式落草稿(人审)或直接线上(下载物化)
1148
+ function installAssetFiles(files, { agentId, mode = 'draft' } = {}) {
1149
+ const manifest = JSON.parse(String(files.find((f) => f.path === 'asset.json' || f.path.endsWith('/asset.json'))?.data || 'null'));
1150
+ if (!manifest || !manifest.name) throw new Error('资产包缺少 asset.json');
1151
+ const armRefOf = (hash) => ({ skill_name: manifest.name, version: manifest.version || null, file_hash: hash, shared_at: manifest.shared_at || null });
1152
+ const result = { name: manifest.name, asset_type: manifest.asset_type, panels: [], collections: [], workbench: null, skipped_collections: [] };
1153
+ for (const f of files.filter((x) => /^panels\/[a-z0-9-]+\.json$/.test(x.path))) {
1154
+ const p = JSON.parse(String(f.data));
1155
+ const saved = savePanelPlugin({ id: p.id, name: p.name, description: p.description, code: p.code, author: 'arm' });
1156
+ // savePanelPlugin 已写盘,补 arm_ref 后再写一次(不重复升版本)
1157
+ const full = loadPanelPlugin(p.id);
1158
+ Object.assign(full, { arm_ref: armRefOf(null) });
1159
+ fs.writeFileSync(path.join(PANELS_DIR, `${p.id}.json`), JSON.stringify(full, null, 2));
1160
+ result.panels.push({ id: saved.id, version: saved.version });
1161
+ }
1162
+ if (agentId) {
1163
+ for (const f of files.filter((x) => /^collections\/[a-z0-9_-]+\.json$/.test(x.path))) {
1164
+ const m = JSON.parse(String(f.data));
1165
+ if (loadCollection(m.name)) { result.skipped_collections.push(m.name); continue; } // 冲突保留本地(数据在现场,不静默换表)
1166
+ saveCollection({ name: m.name, title: m.title, description: m.description, schema: m.schema, required: m.required || [], scope: m.scope === 'shared' ? 'shared' : 'agent', owner_agent: m.scope === 'shared' ? undefined : agentId });
1167
+ result.collections.push(m.name);
1168
+ }
1169
+ const wbFile = files.find((x) => x.path === 'workbench.json');
1170
+ if (wbFile) {
1171
+ const dsl = JSON.parse(String(wbFile.data));
1172
+ dsl.owner_agent = agentId;
1173
+ if (mode === 'live') {
1174
+ dsl.version = `v${Math.max(0, ...workbenchVersions(agentId).map((x) => +String(x.version || '').replace(/^v/, '') || 0)) + 1}`;
1175
+ saveWorkbench(agentId, dsl); // 下载物化:直接线上(三道信任关后开箱即用,版本前进可回滚)
1176
+ } else {
1177
+ saveDraft(agentId, dsl); // 手动安装:进草稿,人审发布
1178
+ }
1179
+ const target = mode === 'live' ? path.join(WB_DIR, `${agentId}.json`) : draftPath(agentId);
1180
+ const cur = JSON.parse(fs.readFileSync(target, 'utf8'));
1181
+ cur.arm_ref = armRefOf(null);
1182
+ fs.writeFileSync(target, JSON.stringify(cur, null, 2));
1183
+ result.workbench = { mode, version: cur.version };
1184
+ }
1185
+ }
1186
+ audit({ actor: 'asset-install', target: 'ARM', name: manifest.name, ...result });
1187
+ return result;
1188
+ }
1189
+ // 本地 arm_ref 索引:插件库 + 各工作台 DSL(列表页 installed/upgrade_available 判定)
1190
+ function localArmRefs() {
1191
+ const refs = {};
1192
+ for (const pl of listPanelPlugins()) {
1193
+ const full = loadPanelPlugin(pl.id);
1194
+ if (full?.arm_ref?.skill_name) (refs[full.arm_ref.skill_name] = refs[full.arm_ref.skill_name] || []).push({ kind: 'plugin', id: pl.id, file_hash: full.arm_ref.file_hash });
1195
+ }
1196
+ for (const f of fs.readdirSync(WB_DIR).filter((f) => f.endsWith('.json'))) {
1197
+ try {
1198
+ const dsl = JSON.parse(fs.readFileSync(path.join(WB_DIR, f), 'utf8'));
1199
+ if (dsl.arm_ref?.skill_name) (refs[dsl.arm_ref.skill_name] = refs[dsl.arm_ref.skill_name] || []).push({ kind: 'workbench', id: dsl.owner_agent || f.replace(/\.(draft\.)?json$/, ''), file_hash: dsl.arm_ref.file_hash });
1200
+ } catch { }
1201
+ }
1202
+ return refs;
1203
+ }
1204
+
1055
1205
  // ── 工作台工程师 Agent(对话式开发工作台的 skill:工具即扩展点)──
1056
1206
  const DEV_TOOLS = {
1057
1207
  list_workbench: {
@@ -1154,6 +1304,25 @@ const DEV_TOOLS = {
1154
1304
  const devCtx = { agentId: null };
1155
1305
  const devSessions = new Map();
1156
1306
 
1307
+ // 定制会话上下文裁剪:前端 sessionId 固定为 dev-<agentId前缀>,会话跨请求长存——不裁剪会无限增长,
1308
+ // 最终超过模型上下文窗口被网关整单拒绝(2026-10-21 海立实例"发消息没反应"根因)。
1309
+ // 超预算从最旧轮次整轮丢弃;下刀点只选 user 消息边界,剩余前缀不会出现孤儿 toolResult(转换层要求其紧跟 assistant 的 toolCall);当前轮永不裁。
1310
+ const DEV_CTX_CHAR_BUDGET = 150_000; // 字符预算:混合中英/代码转录按 ~0.4 token/char 估 ≈ 60K token,对 128K 窗口留足余量
1311
+ function trimDevContext(ctx) {
1312
+ const msgs = ctx.messages;
1313
+ const size = () => msgs.reduce((n, m) => n + JSON.stringify(m.content ?? '').length, 0);
1314
+ if (size() <= DEV_CTX_CHAR_BUDGET) return 0;
1315
+ const lastUser = msgs.length - 1 - [...msgs].reverse().findIndex((m) => m.role === 'user');
1316
+ let dropped = 0;
1317
+ while (size() > DEV_CTX_CHAR_BUDGET) {
1318
+ const cut = msgs.slice(1, lastUser - dropped).findIndex((m) => m.role === 'user');
1319
+ if (cut < 0) break; // 当前轮之前再无完整轮次可丢(单轮就超预算只能放行,交由模型侧报错)
1320
+ dropped += cut + 1;
1321
+ msgs.splice(0, cut + 1);
1322
+ }
1323
+ return dropped;
1324
+ }
1325
+
1157
1326
  function devSystemPrompt() {
1158
1327
  const live = loadWorkbench(devCtx.agentId);
1159
1328
  const plugins = listPanelPlugins();
@@ -1235,12 +1404,24 @@ async function devChatLoop(session, emit, signal = null) {
1235
1404
  if (interrupted()) { emit('interrupted', { at: `第 ${round + 1} 轮` }); return { reply: '(已被用户中断)', events }; }
1236
1405
  emit('status', { text: `第 ${round + 1} 轮` });
1237
1406
  const stream = models.stream(model, session.context, signal ? { signal } : undefined);
1238
- for await (const ev of stream) {
1239
- if (interrupted()) break;
1240
- if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1241
- else if (ev.type === 'toolcall_end') emit('status', { text: `调用工具 ${ev.toolCall?.name || ''}` });
1407
+ let final = null;
1408
+ try {
1409
+ for await (const ev of stream) {
1410
+ if (interrupted()) break;
1411
+ if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1412
+ else if (ev.type === 'toolcall_end') emit('status', { text: `调用工具 ${ev.toolCall?.name || ''}` });
1413
+ else if (ev.type === 'error') {
1414
+ if (interrupted()) break; // 中断引发的流错误按中断处理,不报错
1415
+ throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`);
1416
+ }
1417
+ }
1418
+ final = await stream.result();
1419
+ // pi-ai 把认证/请求失败表现为 stopReason=error 而非异常(同 llmProbe 判定)——不检查会被吞成静默空回复
1420
+ if (final?.stopReason === 'error') throw new Error(`LLM 请求失败: ${final.error?.errorMessage || JSON.stringify(final.error || {}).slice(0, 200) || final.stopReason}`);
1421
+ } catch (e) {
1422
+ if (!interrupted()) throw e; // 真实错误上抛给路由 catch → SSE error → 前端 toast;中断路径用占位消息兜底
1242
1423
  }
1243
- const final = await stream.result();
1424
+ if (!final) final = { role: 'assistant', content: [], timestamp: Date.now() };
1244
1425
  session.context.messages.push(final);
1245
1426
  const calls = (final.content || []).filter(b => b.type === 'toolCall');
1246
1427
  const text = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
@@ -1445,7 +1626,7 @@ const server = http.createServer(async (req, res) => {
1445
1626
  if (pathname === '/api/health') return json(res, 200, { ok: true, auth_required: true }); // 前端据此弹登录门
1446
1627
  return json(res, 401, { error: '需要访问令牌', code: 'AUTH_REQUIRED' });
1447
1628
  }
1448
- if (req.method === 'GET' && pathname === '/api/health') return json(res, 200, { ok: true, model: model?.id, arm: armMcpUrl(), pat: !!ARM_PAT, local_agents: loadRegistry().agents.length });
1629
+ if (req.method === 'GET' && pathname === '/api/health') return json(res, 200, { ok: true, model: model?.id, arm: armApiBase(), pat: !!ARM_PAT, local_agents: loadRegistry().agents.length });
1449
1630
 
1450
1631
  // ── ARM 连接与同步(07:大厅双视图 = 本地助手库 ∪ 远端目录)──
1451
1632
  if (req.method === 'GET' && pathname === '/api/settings') {
@@ -1454,7 +1635,6 @@ const server = http.createServer(async (req, res) => {
1454
1635
  return json(res, 200, {
1455
1636
  arm: {
1456
1637
  api_base: armApiBase(), api_base_runtime: !!cfg.arm_api_base, default_base: ARM_DEFAULT_BASE,
1457
- mcp_url: armMcpUrl(), mcp_explicit: !!cfg.arm_mcp_url,
1458
1638
  token_set: !!t, token_masked: t ? `${t.slice(0, 10)}…${t.slice(-4)}` : '',
1459
1639
  token_source: cfg.arm_token ? 'runtime' : (env.ARM_API_TOKEN ? 'env' : (ARM_PAT ? 'pat' : 'none')),
1460
1640
  },
@@ -1470,7 +1650,7 @@ const server = http.createServer(async (req, res) => {
1470
1650
  });
1471
1651
  }
1472
1652
  if (req.method === 'PUT' && pathname === '/api/settings') {
1473
- const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token, guard } = await body(req);
1653
+ const { arm_api_base, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token, guard } = await body(req);
1474
1654
  // 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
1475
1655
  let guardAudit = null;
1476
1656
  if (guard !== undefined) {
@@ -1501,11 +1681,6 @@ const server = http.createServer(async (req, res) => {
1501
1681
  if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `站点地址须为 http(s) URL:${u}` });
1502
1682
  cfg.arm_api_base = u;
1503
1683
  }
1504
- if (arm_mcp_url !== undefined) {
1505
- const u = normUrl(arm_mcp_url);
1506
- if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `MCP 端点须为 http(s) URL:${u}` });
1507
- cfg.arm_mcp_url = u;
1508
- }
1509
1684
  if (arm_token !== undefined) cfg.arm_token = String(arm_token || '').trim(); // 置空 = 回落 env/PAT
1510
1685
  if (llm_base_url !== undefined) {
1511
1686
  const u = normUrl(llm_base_url);
@@ -1531,10 +1706,10 @@ const server = http.createServer(async (req, res) => {
1531
1706
  try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
1532
1707
  }
1533
1708
  saveCfg();
1534
- audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, mcp_explicit: !!cfg.arm_mcp_url, llm_model: cfg.llm_model, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
1709
+ audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, llm_model: cfg.llm_model, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
1535
1710
  // ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
1536
1711
  let probe = { online: false, error: '' };
1537
- if ([arm_api_base, arm_mcp_url, arm_token].some((v) => v !== undefined)) {
1712
+ if ([arm_api_base, arm_token].some((v) => v !== undefined)) {
1538
1713
  try {
1539
1714
  const t0 = Date.now();
1540
1715
  await armRest('/api/v1/agents?page=1&pageSize=1', {}, 6000);
@@ -1542,7 +1717,7 @@ const server = http.createServer(async (req, res) => {
1542
1717
  } catch (e) { probe = { online: false, error: String(e.message).slice(0, 120) }; }
1543
1718
  }
1544
1719
  const notes = [];
1545
- if (probe.error) notes.push('ARM 当前源不可达(目录/检索将回落 MCP 或本地库,源恢复后自动可用)');
1720
+ if (probe.error) notes.push('ARM 当前源不可达(目录/检索将回落本地库,源恢复后自动可用)');
1546
1721
  if (llm_probe && !llm_probe.online) notes.push('LLM 已保存但探针未通过(密钥/端点/模型可用性待确认,对话可能失败,可再试或改回)');
1547
1722
  return json(res, 200, { saved: true, probe, llm_probe, note: notes.join(';') });
1548
1723
  }
@@ -1552,14 +1727,9 @@ const server = http.createServer(async (req, res) => {
1552
1727
  try {
1553
1728
  await armRest('/api/v1/agents?page=1&pageSize=1', {}, 6000);
1554
1729
  online = true; latency_ms = Date.now() - t0;
1555
- } catch {
1556
- try { // 旧版 ARM 无 REST:回落 MCP 探测
1557
- await mcpCall('search_agents', { keyword: '', limit: 1 }, { timeout_ms: 6000 });
1558
- online = true; latency_ms = Date.now() - t0;
1559
- } catch { }
1560
- }
1730
+ } catch { }
1561
1731
  const reg = loadRegistry();
1562
- return json(res, 200, { online, latency_ms, url: armMcpUrl(), api: armApiBase(), pat: !!ARM_PAT, last_sync_at: reg.last_sync_at || null, local_count: reg.agents.length, outbox_pending: outboxCount() });
1732
+ return json(res, 200, { online, latency_ms, api: armApiBase(), pat: !!ARM_PAT, last_sync_at: reg.last_sync_at || null, local_count: reg.agents.length, outbox_pending: outboxCount() });
1563
1733
  }
1564
1734
  if (req.method === 'POST' && pathname === '/api/arm/sync') {
1565
1735
  try { return json(res, 200, await armSync()); }
@@ -1571,7 +1741,7 @@ const server = http.createServer(async (req, res) => {
1571
1741
  const kw = (url.searchParams.get('keyword') || '').trim().toLowerCase();
1572
1742
  const reg = loadRegistry();
1573
1743
  let online = true, remoteCards = [], authError = false;
1574
- try { remoteCards = await armListAgents(url.searchParams.get('keyword') || ''); } // REST 全量优先,旧版回落 MCP
1744
+ try { remoteCards = await armListAgents(url.searchParams.get('keyword') || ''); } // REST 全量
1575
1745
  catch (e) { online = false; authError = /令牌未配置或无权限/.test(String(e.message)); } // 区分"未授权"与"不可达",前端引导配置令牌
1576
1746
  const byId = new Map();
1577
1747
  for (const e of reg.agents) {
@@ -1845,7 +2015,7 @@ const server = http.createServer(async (req, res) => {
1845
2015
  if (!dep) return json(res, 400, { error: '助手未上岗' });
1846
2016
  const arm = await getArmAgent(agent_id);
1847
2017
  const toolsAvail = dep.toolset.map(t => t.tool).join(', ');
1848
- const sys = '你是工业智能体平台的工作台设计师。根据助手档案生成工作台 DSL(JSON)。面板类型只允许: realtime_list(实时告警流)/status_grid(设备状态矩阵)/agent_dialog(诊断对话)/action_group(操作区)/kpi_strip(指标条)/inspection_report(自检报告)。结构: {"workbench":"名称","owner_agent":"agentId","version":"v0.1-draft","layout":[...],"panels":[{"id","type","title","refresh"}...]}。action_group 的 panels 含 "actions":[{"id","label","orchestration","autonomy","confirm":{"text","double_check"}}],orchestration 只能引用这些: logic://设备复位流程@mock / logic://告警派工@mock / logic://服务滚动重启@mock / logic://环境自检@mock。只输出 JSON,不要 markdown 代码围栏。';
2018
+ const sys = '你是工业智能体平台的工作台设计师。根据助手档案生成工作台 DSL(JSON)。面板类型只允许: realtime_list(实时告警流)/status_grid(设备状态矩阵)/agent_dialog(诊断对话)/action_group(操作区)/kpi_strip(指标条)/inspection_report(自检报告)。结构: {"workbench":"名称","owner_agent":"agentId","version":"v0.1-draft","layout":[...],"panels":[{"id","type","title"}...]}。action_group 的 panels 含 "actions":[{"id","label","orchestration","autonomy","confirm":{"text","double_check"}}],orchestration 只能引用这些: logic://设备复位流程@mock / logic://告警派工@mock / logic://服务滚动重启@mock / logic://环境自检@mock。只输出 JSON,不要 markdown 代码围栏。';
1849
2019
  const user = `助手: ${arm.name} ${arm.version}\n职责: ${arm.prompt}\n可用工具: ${toolsAvail}\n默认自主级别: ${dep.default_autonomy}\n请生成 4-6 个面板,含一个 agent_dialog 和一个 action_group。`;
1850
2020
  const out = await llmComplete(sys, user);
1851
2021
  const cleaned = out.replace(/```json|```/g, '').trim();
@@ -1950,6 +2120,17 @@ const server = http.createServer(async (req, res) => {
1950
2120
  try { const m = saveCollection(await body(req)); return json(res, 200, { collection: m }); }
1951
2121
  catch (e) { return json(res, 400, { error: e.message }); }
1952
2122
  }
2123
+ if (req.method === 'GET' && pathname === '/api/store/version') { // 变更版本轻端点:面板 watch 轮询(08 契约 §4)
2124
+ const manifest = loadCollection(String(url.searchParams.get('name') || ''));
2125
+ if (!manifest) return json(res, 404, { error: '数据集不存在' });
2126
+ const wb = url.searchParams.get('wb') || undefined;
2127
+ const ws = url.searchParams.get('ws') || 'default';
2128
+ if (scopeOf(manifest) === 'agent' && wb && wb !== manifest.owner_agent) {
2129
+ return json(res, 403, { error: '数据集为助手私有' });
2130
+ }
2131
+ const row = dbFor(manifest, ws).prepare('SELECT v FROM _meta WHERE k = ?').get(`ver:${manifest.name}`);
2132
+ return json(res, 200, { version: +(row ? row.v : 0) });
2133
+ }
1953
2134
  let mStore = pathname.match(/^\/api\/store\/([a-z0-9_-]+)\/records$/);
1954
2135
  if (mStore) {
1955
2136
  const manifest = loadCollection(mStore[1]);
@@ -2051,6 +2232,94 @@ const server = http.createServer(async (req, res) => {
2051
2232
  return json(res, 400, { error: 'kind 须为 system|plugin' });
2052
2233
  }
2053
2234
 
2235
+ // ── UI 资产分发(ARM 三资产模型):预览 / 发布 / 市场列表 / 安装 ──
2236
+ if (req.method === 'GET' && pathname === '/api/assets/preview') { // 分享确认框数据:组包预览(requires 全文明示,红线③)
2237
+ const type = url.searchParams.get('type');
2238
+ const key = type === 'panel' ? url.searchParams.get('id') : url.searchParams.get('agentId');
2239
+ if (!['panel', 'workbench'].includes(type) || !key) return json(res, 400, { error: 'type 须为 panel|workbench,且提供 id 或 agentId' });
2240
+ try {
2241
+ const bundle = buildAssetBundle(type, key);
2242
+ return json(res, 200, { name: bundle.name, manifest: bundle.manifest, files: bundle.files.map((f) => f.path) });
2243
+ } catch (e) { return json(res, 400, { error: String(e.message).slice(0, 300) }); }
2244
+ }
2245
+ if (req.method === 'POST' && pathname === '/api/assets/share') {
2246
+ const { type, id, agentId, name, bind_agent } = await body(req); // type: panel|workbench;panel 用 id=插件id,workbench 用 agentId
2247
+ const key = type === 'panel' ? id : agentId;
2248
+ if (!['panel', 'workbench'].includes(type) || !key) return json(res, 400, { error: 'type 须为 panel|workbench,且提供 id(插件)或 agentId(工作台)' });
2249
+ try {
2250
+ const bundle = buildAssetBundle(type, key, name);
2251
+ // 分享预检(红字提示用):requires 全文回传前端人审确认
2252
+ const fd = new FormData();
2253
+ fd.append('file', new Blob([zipFiles(bundle.files)], { type: 'application/zip' }), `${bundle.name}.zip`);
2254
+ const uploaded = await armUpload('/api/v1/ui-assets', fd);
2255
+ const armRef = { skill_name: bundle.name, version: uploaded.version, file_hash: uploaded.fileHash, shared_at: new Date().toISOString() };
2256
+ if (type === 'panel') {
2257
+ const full = loadPanelPlugin(id);
2258
+ full.arm_ref = armRef;
2259
+ fs.writeFileSync(path.join(PANELS_DIR, `${id}.json`), JSON.stringify(full, null, 2));
2260
+ } else {
2261
+ const target = loadDraft(agentId) ? draftPath(agentId) : path.join(WB_DIR, `${agentId}.json`);
2262
+ const dsl = JSON.parse(fs.readFileSync(target, 'utf8'));
2263
+ dsl.arm_ref = armRef;
2264
+ fs.writeFileSync(target, JSON.stringify(dsl, null, 2));
2265
+ }
2266
+ let bound = null;
2267
+ if (bind_agent) { // 发布后顺手绑定到当前助手(三资产组合:员工自带工位)
2268
+ const r = await armRest(`/api/v1/agents/${bind_agent}/ui-assets`, { method: 'POST', body: JSON.stringify({ name: bundle.name }) });
2269
+ bound = r && r.version;
2270
+ }
2271
+ metric({ kind: 'asset_share', type, name: bundle.name, version: uploaded.version });
2272
+ audit({ actor: 'asset-share', target: 'ARM', type, name: bundle.name, version: uploaded.version, bound });
2273
+ return json(res, 200, { name: bundle.name, version: uploaded.version, requires: bundle.manifest.requires, bound });
2274
+ } catch (e) { return json(res, 400, { error: String(e.message).slice(0, 400) }); }
2275
+ }
2276
+ if (req.method === 'GET' && pathname === '/api/assets/arm-library') {
2277
+ const keyword = url.searchParams.get('keyword') || '';
2278
+ const assetType = url.searchParams.get('asset_type') || '';
2279
+ try {
2280
+ const q = new URLSearchParams({ pageSize: '50', ...(keyword ? { keyword } : {}), ...(assetType ? { assetType } : {}) });
2281
+ const d = await armRest(`/api/v1/ui-assets?${q}`);
2282
+ const refs = localArmRefs();
2283
+ const items = (d.uiAssets || []).map((a) => {
2284
+ const local = refs[a.name] || [];
2285
+ return {
2286
+ name: a.name, description: a.description, asset_type: a.assetType, updated_at: a.updatedAt,
2287
+ download_count: a.downloadCount, latest_version: a.latestVersion ? a.latestVersion.version : null,
2288
+ publisher: a.creatorName,
2289
+ installed: local.length > 0,
2290
+ upgrade_available: !!(a.latestVersion && local.some((l) => l.file_hash && l.file_hash !== a.latestVersion.fileHash)),
2291
+ local,
2292
+ };
2293
+ });
2294
+ return json(res, 200, { items });
2295
+ } catch (e) { return json(res, 400, { error: String(e.message).slice(0, 300) }); }
2296
+ }
2297
+ if (req.method === 'POST' && pathname === '/api/assets/install') {
2298
+ const { name, version, agentId } = await body(req); // agentId:workbench 资产的目标助手(必填);panel 资产可省
2299
+ if (!name) return json(res, 400, { error: 'name 不能为空' });
2300
+ try {
2301
+ const detail = await armRest(`/api/v1/ui-assets/${encodeURIComponent(name)}`);
2302
+ if (detail.assetType === 'workbench' && !agentId) return json(res, 400, { error: '工作台资产须指定目标助手 agentId' });
2303
+ const ver = version || (detail.versions && detail.versions[0] && detail.versions[0].version) || 'v1';
2304
+ const { buf, hash } = await armDownload(`/api/v1/ui-assets/${encodeURIComponent(name)}/versions/${encodeURIComponent(ver)}/download`);
2305
+ const result = installAssetFiles(unzipFiles(buf), { agentId, mode: 'draft' }); // 手动安装进草稿,人审发布
2306
+ // 回填真实 file_hash(升级判断用)
2307
+ if (result.panels.length) {
2308
+ for (const p of result.panels) {
2309
+ const full = loadPanelPlugin(p.id);
2310
+ if (full?.arm_ref?.skill_name === name) { full.arm_ref.file_hash = hash; fs.writeFileSync(path.join(PANELS_DIR, `${p.id}.json`), JSON.stringify(full, null, 2)); }
2311
+ }
2312
+ }
2313
+ if (agentId && result.workbench) {
2314
+ const target = draftPath(agentId);
2315
+ const cur = JSON.parse(fs.readFileSync(target, 'utf8'));
2316
+ if (cur.arm_ref?.skill_name === name) { cur.arm_ref.file_hash = hash; fs.writeFileSync(target, JSON.stringify(cur, null, 2)); }
2317
+ }
2318
+ metric({ kind: 'asset_install', name, version: ver });
2319
+ return json(res, 200, result);
2320
+ } catch (e) { return json(res, 400, { error: String(e.message).slice(0, 400) }); }
2321
+ }
2322
+
2054
2323
  // ── 草稿管理 ──
2055
2324
  if (req.method === 'DELETE' && pathname === '/api/workbench/draft') {
2056
2325
  const { agent_id } = await body(req);
@@ -2075,6 +2344,8 @@ const server = http.createServer(async (req, res) => {
2075
2344
  s.context.systemPrompt = devSystemPrompt();
2076
2345
  }
2077
2346
  s.context.messages.push({ role: 'user', content: message, timestamp: Date.now() });
2347
+ const dropped = trimDevContext(s.context);
2348
+ if (dropped > 0) emit('status', { text: `会话历史过长,已自动裁剪最早 ${dropped} 条消息` });
2078
2349
  await devChatLoop(s, emit, ctl.signal);
2079
2350
  emit('meta', { hasDraft: !!loadDraft(agentId) });
2080
2351
  } catch (e) { emit('error', { message: e.message }); }
@@ -2206,11 +2477,10 @@ const server = http.createServer(async (req, res) => {
2206
2477
  const { agentId, name, description, content } = await body(req);
2207
2478
  const arm = agentProfileNow(agentId);
2208
2479
  const full = ['---', 'kind: experience', 'source: aims-platform-mvp', `agent: ${arm.name || agentId}@${arm.version || ''}`, 'scenario: 立库A区智能运维@mvp', '---', '', content].join('\n');
2209
- const r = await mcpCall('create_knowledge', { name, description: description || `工作台人工审阅沉淀`, content: full, tags: ['MVP回流'] }, { pat: true });
2210
- const kid = (r.match(/id:\s*([0-9a-f-]{36})/) || [])[1] || '';
2211
- metric({ kind: 'experience', agentId, title: name, knowledge_id: kid });
2480
+ const r = await armCreateKnowledge({ name, description: description || `工作台人工审阅沉淀`, content: full, tags: ['MVP回流'] });
2481
+ metric({ kind: 'experience', agentId, title: name, knowledge_id: r.id });
2212
2482
  audit({ actor: 'experience-reflow', target: 'ARM', name });
2213
- return json(res, 200, { result: r });
2483
+ return json(res, 200, { result: `已创建 ${r.name}(id: ${r.id})${r.shareUrl ? `\n${r.shareUrl}` : ''}`, knowledge_id: r.id, share_url: r.shareUrl || '' });
2214
2484
  }
2215
2485
  // ── 自进化看板 ──
2216
2486
  if (req.method === 'GET' && pathname === '/api/evolution') {
@@ -2289,7 +2559,7 @@ applyLLMConfig().then(() => {
2289
2559
  server.listen(PORT, HOST, () => {
2290
2560
  console.log(`[aims-workstation] http://localhost:${PORT}(绑定 ${HOST}) | 模型: ${model.id} @ ${model.baseUrl}`);
2291
2561
  console.log(`[aims-workstation] 数据目录 AIMS_HOME: ${AIMS_HOME}`);
2292
- console.log(`[aims-workstation] ARM: ${armMcpUrl()} | REST: ${armApiBase()} | PAT: ${ARM_PAT ? '已加载' : '未配置'} | 已上岗助手: ${fs.readdirSync(DEPLOY_DIR).length} | 本地助手库: ${loadRegistry().agents.length}`);
2562
+ console.log(`[aims-workstation] ARM(REST): ${armApiBase()} | PAT: ${ARM_PAT ? '已加载' : '未配置'} | 已上岗助手: ${fs.readdirSync(DEPLOY_DIR).length} | 本地助手库: ${loadRegistry().agents.length}`);
2293
2563
  });
2294
2564
  // 已上岗助手后台物化为本地档案(best-effort;ARM 不可达时静默跳过,待手动同步)
2295
2565
  (async () => {