@jesonliu/lark-claudecode-bridge 1.3.7 → 1.4.0

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.
Files changed (49) hide show
  1. package/README.md +17 -0
  2. package/assets/web/js/main.js +2 -1
  3. package/assets/web/js/pages/memory.js +561 -0
  4. package/config.example.yaml +34 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +133 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/executor/claude-executor.d.ts +26 -3
  9. package/dist/executor/claude-executor.js +718 -293
  10. package/dist/executor/claude-executor.js.map +1 -1
  11. package/dist/executor/notify-server.d.ts +10 -0
  12. package/dist/executor/notify-server.js +11 -0
  13. package/dist/executor/notify-server.js.map +1 -1
  14. package/dist/executor/permission-gate.d.ts +8 -1
  15. package/dist/executor/permission-gate.js +7 -3
  16. package/dist/executor/permission-gate.js.map +1 -1
  17. package/dist/gateway/card-builder.d.ts +15 -3
  18. package/dist/gateway/card-builder.js +21 -10
  19. package/dist/gateway/card-builder.js.map +1 -1
  20. package/dist/gateway/feishu-gateway.d.ts +13 -0
  21. package/dist/gateway/feishu-gateway.js +34 -2
  22. package/dist/gateway/feishu-gateway.js.map +1 -1
  23. package/dist/gateway/progress-card.d.ts +4 -2
  24. package/dist/gateway/progress-card.js +52 -25
  25. package/dist/gateway/progress-card.js.map +1 -1
  26. package/dist/index.d.ts +7 -1
  27. package/dist/index.js +225 -32
  28. package/dist/index.js.map +1 -1
  29. package/dist/memory-installer.d.ts +174 -0
  30. package/dist/memory-installer.js +1035 -0
  31. package/dist/memory-installer.js.map +1 -0
  32. package/dist/memory.d.ts +88 -0
  33. package/dist/memory.js +195 -0
  34. package/dist/memory.js.map +1 -0
  35. package/dist/security/paths.d.ts +3 -1
  36. package/dist/security/paths.js +28 -4
  37. package/dist/security/paths.js.map +1 -1
  38. package/dist/session/session-store.d.ts +15 -0
  39. package/dist/session/session-store.js +16 -0
  40. package/dist/session/session-store.js.map +1 -1
  41. package/dist/types.d.ts +56 -1
  42. package/dist/web/config-api.js +32 -0
  43. package/dist/web/config-api.js.map +1 -1
  44. package/dist/web/memory-api.d.ts +25 -0
  45. package/dist/web/memory-api.js +197 -0
  46. package/dist/web/memory-api.js.map +1 -0
  47. package/dist/web/server.js +73 -2
  48. package/dist/web/server.js.map +1 -1
  49. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { basename, join, relative, resolve } from 'node:path';
5
- import { CONFIG_DIR, CONFIG_PATH, DEFAULT_CONTEXT_REMIND_TOKENS, loadConfig, sameApps } from './config.js';
5
+ import { CONFIG_DIR, CONFIG_PATH, DEFAULT_CONTEXT_REMIND_TOKENS, INBOX_DIR, loadConfig, sameApps } from './config.js';
6
6
  import { DEFAULT_CLAUDE_DIR, initManagedClaudeDir, resolveClaudeDir } from './claude-config.js';
7
7
  import { buildTaskEnv } from './task-env.js';
8
8
  import { warnIfNoClaudeAuth } from './auth-precheck.js';
@@ -12,17 +12,19 @@ import { clearPidFile, writePidFile, stopExistingBridgeAndWait } from './web/lif
12
12
  import { AccessControl } from './access/access-control.js';
13
13
  import { FeishuGateway } from './gateway/feishu-gateway.js';
14
14
  import { ProgressCard, feishuErrorBody } from './gateway/progress-card.js';
15
- import { DECISION_TEXT, buildResultTailCard, buildWorkspaceSwitchedCard, resultChunkHeader, } from './gateway/card-builder.js';
15
+ import { DECISION_TEXT, buildPlanTailCard, buildResultTailCard, buildWorkspaceSwitchedCard, planChunkHeader, resultChunkHeader, } from './gateway/card-builder.js';
16
16
  import { chunkText } from './util/chunk-text.js';
17
- import { runTask } from './executor/claude-executor.js';
17
+ import { runTask, killResidentSession, killAllResidentSessions } from './executor/claude-executor.js';
18
18
  import { buildFileDiffCards } from './gateway/diff-card.js';
19
19
  import { collectFileDiff } from './util/workspace-diff.js';
20
20
  import { PermissionGate, DEFAULT_ALLOW_TOOLS_LIST, DEFAULT_DANGEROUS_COMMANDS, DEFAULT_ALLOW_ALL_TOOLS, } from './executor/permission-gate.js';
21
21
  import { discoverPlugins, resolvePluginPaths } from './executor/plugin-discovery.js';
22
- import { createGatewaySender, createNotifyServer, NOTIFY_SERVER_NAME } from './executor/notify-server.js';
22
+ import { createGatewaySender, createNotifyServer, createNotifySenderSlot, NOTIFY_SERVER_NAME } from './executor/notify-server.js';
23
23
  import { BRIDGE_MCP_JSON, readMcpServersFromJsonFile } from './web/skills-mcp-api.js';
24
24
  import { FEISHU_NOTIFY_SOP_PROMPT, TOOL_HINT_PROMPT } from './notify-sop.js';
25
25
  import { ensureProfileSettingsFile, resolveAppProfile, resolveAppProfileModel } from './app-profile.js';
26
+ import { MEMORY_PLUGIN_NAME, createMemoryProvider, ensureMemoryUserKeys, memoryEnvFor, } from './memory.js';
27
+ import { cleanupClientCliConf, ensureClientCliConf, stopManagedServer } from './memory-installer.js';
26
28
  import { isImageFile } from './util/file-types.js';
27
29
  import { FileTracker } from './util/file-tracker.js';
28
30
  import { SessionStore, migrateLegacySessions } from './session/session-store.js';
@@ -61,7 +63,7 @@ const PLAN_DECISION_TEXT = {
61
63
  'plan-approve': '✅ 计划已批准,开始执行(后续写操作免确认)',
62
64
  'plan-revise': '📝 修改意见已提交',
63
65
  'plan-reject': '❌ 计划已放弃',
64
- // plan-view-file 不消费挂起项,仅 toast「正在发送方案文件…」——文案不在此出现,留口供编译期完整性
66
+ // plan-view-file 不消费挂起项,仅 toast「正在重新发送计划内容卡片…」——文案不在此出现,留口供编译期完整性
65
67
  'plan-view-file': '',
66
68
  };
67
69
  /**
@@ -133,6 +135,23 @@ deps, opts = {}) {
133
135
  // plan 确认挂起项:requestId → 等待中的计划决策(plan-approve/revise/reject)。
134
136
  // 嵌入主卡片后回调侧无 cardId(主卡 messageId 由 ProgressCard 内部管理),改带 channelKey 定位活跃进度卡
135
137
  const planPending = new Map();
138
+ // 计划正文两段式多卡直发(与下方「结果回传两段式」完全同构):~3000 字/块,非尾卡走文本
139
+ // 消息、尾卡走卡片,连发间隔 300ms 防飞书限流。planAsk 与 plan-view-file 回调(存量卡片
140
+ // 残留按钮,重发即「再看一遍」)共用
141
+ async function sendPlanContentCards(chatId, req) {
142
+ const chunks = chunkText(req.plan);
143
+ const total = chunks.length;
144
+ for (let i = 0; i < total; i++) {
145
+ if (i > 0)
146
+ await new Promise((r) => setTimeout(r, 300));
147
+ if (i === total - 1) {
148
+ await deps.gateway.sendCardTo(chatId, buildPlanTailCard(req, chunks[i], { index: total, total }, config.card?.width ?? 'default'));
149
+ }
150
+ else {
151
+ await deps.gateway.sendTextTo(chatId, `${planChunkHeader(req, i + 1, total)}\n\n${chunks[i]}`);
152
+ }
153
+ }
154
+ }
136
155
  // 提问挂起项:requestId → 等待中的问题答案(qa-pick 逐项收集,qa-submit 一次性 resolve)。
137
156
  // 嵌入主卡片后无 cardId,改带 channelKey 定位活跃进度卡
138
157
  const qaPending = new Map();
@@ -147,6 +166,10 @@ deps, opts = {}) {
147
166
  // 当前存活 query 的 MCP 状态拉取句柄(executor onQuery 注入、任务 finally 删除):
148
167
  // /mcp 命令在任务运行中经此实时拉取;句柄随 query 结束失效,调用侧须 catch
149
168
  const activeQueries = new Map();
169
+ // 通道级 notify sender 槽(生命周期方案 A2):常驻进程复用 spawn 时的 lcb-notify server
170
+ // 实例,发送目标(chatId 绑定 + sentPaths)经槽每任务换绑——同通道 FIFO 串行保证换绑
171
+ // 总在上一轮发送完成后发生
172
+ const notifySlots = new Map();
150
173
  // 触发词两段式(ask_detail)等待补充的挂起项:channelKey → 命中规则与原始消息。
151
174
  // 命中「需补充」规则时先追问不入队;同用户下一条非 / 消息视作补充内容,与 rewrite 合并后入队。
152
175
  // 超时(SUPPLEMENT_WAIT_MS)自动失效走原流程;任意本地命令(cmd.handled)即取消等待——
@@ -440,11 +463,14 @@ deps, opts = {}) {
440
463
  await deps.gateway.sendTextTo(msg.chatId, cmd.reply);
441
464
  }
442
465
  // 指针变更代际 bump:/new、/resume <n>、/ws use 都改写了 currentSessionId——
443
- // 运行中/收尾窗口内任务的迟到归档据 pointerGen 放弃回写指针(archive 闭包比对)
466
+ // 运行中/收尾窗口内任务的迟到归档据 pointerGen 放弃回写指针(archive 闭包比对)。
467
+ // 同时作废该通道的常驻进程:换了续接目标,旧进程的会话上下文不再适用(忙进程不动,
468
+ // 运行中任务的中止由 /new 的 abort 负责,abort 会连带杀进程)
444
469
  if (cmd.pointerTouched) {
445
470
  const rt = runtimes.get(key);
446
471
  if (rt)
447
472
  rt.pointerGen = (rt.pointerGen ?? 0) + 1;
473
+ killResidentSession(`${app.appId}:${key}`);
448
474
  }
449
475
  // /new 开启新会话:
450
476
  // 1) 清空旧代际排队消息(epoch 自增,旧任务入 executeTask 即 no-op)
@@ -542,6 +568,9 @@ deps, opts = {}) {
542
568
  const securityGuard = app.role === 'deputy'
543
569
  ? {
544
570
  cwd: workspacePath(wsName),
571
+ // 入站附件豁免根:与 gateway 下载目录共用单源常量(config.ts INBOX_DIR)——用户发给 bot 的
572
+ // 图片/文件必须可读可处理,属产品功能正确性;R1/Bash 探测豁免,R2 写边界不豁免
573
+ inboxRoot: INBOX_DIR,
545
574
  redzone: () => config.security?.redzone?.read ?? DEFAULT_REDZONE_READ_SOURCES,
546
575
  // 未配置工作区白名单 = 权限轴全给:写根 = 全部已配置工作区(R2 红线仍在:工作区外只读)
547
576
  writeRoots: () => (app.allowedWorkspaces?.length ? app.allowedWorkspaces : config.workspaces.map((w) => w.name))
@@ -603,19 +632,24 @@ deps, opts = {}) {
603
632
  });
604
633
  });
605
634
  },
606
- // plan 确认(#3+#6):不再单独发计划卡片组。计划正文先落盘到 ~/.lark-claudecode-bridge/claude/plans/
607
- // (slug + 时间戳,方便后续 review 与 SOP 要求的 plan 本地归档),然后嵌入当前任务的进度卡:
608
- // 卡片正文仅露 600 字预览 + 「查看完整方案」按钮(callback send_file 发送原 md 文件,不塞卡片正文
609
- // ——飞书单卡 30KB 上限与可读性都不允许长方案铺在卡片里);批准/放弃/按意见修改三按钮与意见输入框
610
- // 仍在主卡片的内嵌 form 内。超时由本闭包自管:到点 clearPlan + resolve reject
635
+ // plan 确认(#3+#6):计划正文与完整回复同款两段式多卡直发(sendPlanContentCards),
636
+ // 不搞「点按钮收文件」特例;原文另落盘 claude/plans/ 归档(slug + 时间戳,方便 review
637
+ // 与 SOP 要求的本地归档,不再有回调消费)。批准/放弃/按意见修改三按钮与意见输入框仍在
638
+ // 主卡片的内嵌 form 内。超时由本闭包自管:到点 clearPlan + resolve reject
611
639
  planAsk: async (req) => {
612
640
  const planReq = { requestId: randomUUID(), plan: req.plan, workspaceName: req.workspaceName };
613
- // 落盘计划原文:路径走 ensureRuntimeDirs 已建的 claude/plans 目录,文件名加时间戳避免并发冲突
614
- const planFilePath = writeRuntimeMarkdown(join('claude', 'plans'), req.workspaceName || 'plan', `# 执行计划 · ${req.workspaceName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${req.plan}\n`, `${tag}[计划落盘失败](不影响流程,仅「查看完整方案」可能不可用):`);
641
+ // 落盘归档:文件名加时间戳避免并发冲突;失败仅影响归档,不影响直发与审批
642
+ const planFilePath = writeRuntimeMarkdown(join('claude', 'plans'), req.workspaceName || 'plan', `# 执行计划 · ${req.workspaceName} · ${new Date().toLocaleString('zh-CN', { hour12: false })}\n\n${req.plan}\n`, `${tag}[计划归档落盘失败](不影响流程):`);
643
+ if (planFilePath)
644
+ console.log(tag, `[计划归档] ${planFilePath}`);
615
645
  const progress = activeProgress.get(key);
616
646
  if (!progress)
617
647
  return { action: 'reject' }; // 进度卡不在 = 任务已结束/被 abort,按放弃处理
618
- progress.setPlan({ requestId: planReq.requestId, plan: req.plan, workspaceName: req.workspaceName, planFilePath });
648
+ progress.setPlan({ requestId: planReq.requestId, plan: req.plan, workspaceName: req.workspaceName });
649
+ // 正文直发放挂起开始前:raceFallback 兜底 WS 偶发挂起(此刻超时定时器尚未 arm,裸 await
650
+ // 挂起即永不超时——同 ask 发卡约束);raceFallback 永不 reject,失败/超时仅内部落日志,
651
+ // 审批表单照常可用
652
+ await raceFallback(sendPlanContentCards(msg.chatId, planReq), CARD_SEND_TIMEOUT_MS, undefined);
619
653
  // 不再 sinkToBottom:plan 区嵌入主进度卡,flush 自然 PATCH,删旧卡重发只会让用户看到计划区跳一下
620
654
  return new Promise((resolve) => {
621
655
  const gc = setTimeout(() => {
@@ -634,7 +668,6 @@ deps, opts = {}) {
634
668
  ownerId: msg.userId,
635
669
  channelKey: key,
636
670
  chatId: msg.chatId,
637
- planFilePath,
638
671
  });
639
672
  });
640
673
  },
@@ -690,7 +723,14 @@ deps, opts = {}) {
690
723
  const toolHintEnabled = config.session?.toolHint !== false;
691
724
  const sentPaths = new Set();
692
725
  const sink = async () => { await activeProgress.get(key)?.sinkToBottom(); };
693
- const notifySender = createGatewaySender({
726
+ // 槽式换绑(A2):本任务的 sender(chatId 硬绑定 + sentPaths 去重集)挂进通道级槽——
727
+ // 常驻进程复用 spawn 时的 lcb-notify server 实例,经 delegate 路由到槽内当前 sender
728
+ let notifySlot = notifySlots.get(key);
729
+ if (!notifySlot) {
730
+ notifySlot = createNotifySenderSlot();
731
+ notifySlots.set(key, notifySlot);
732
+ }
733
+ notifySlot.setSender(createGatewaySender({
694
734
  chatId: msg.chatId,
695
735
  sentPaths,
696
736
  sendText: async (c, md) => {
@@ -709,7 +749,7 @@ deps, opts = {}) {
709
749
  await deps.gateway.uploadAndSendFile(c, p);
710
750
  await sink();
711
751
  },
712
- });
752
+ }));
713
753
  activeProgress.set(key, progress);
714
754
  // 指针代际快照:任务运行期间 /new、/resume <n>、/ws use 都会 bump pointerGen,本任务
715
755
  // 收尾归档据此放弃回写指针(只入历史不覆盖用户选择)。定义在 try 外:catch 路径同样调用
@@ -724,10 +764,40 @@ deps, opts = {}) {
724
764
  try {
725
765
  await progress.start();
726
766
  const state = deps.store.getChannelState(key);
767
+ // A3 群聊会话自动滚动(双阈值,先到先滚,业务无感):空闲超阈值(话题切换天然伴随
768
+ // 时间间隔)或轮次满阈值(兜底单话题超长)→ 指针置 null——即 /new 的现成一行语义,
769
+ // 本条消息就是新话题的首条。私聊不滚(手动 /new 管理最优)。滚动同样 bump epoch
770
+ // 对齐 /new 语义:旧代际排队消息按「新建会话」丢弃(指针写保护已有 pointerGen 兜底)。
771
+ // 有排队消息被丢弃时显式告知(静默吞用户消息不可接受);滚动本身不发独立提示,
772
+ // 仅在本条回复尾部带一次轻注记(见下方成功路径,可配置关闭)
773
+ let rolled = false;
774
+ if (msg.chatType === 'group') {
775
+ const idleHours = config.session?.groupIdleResetHours ?? 4;
776
+ const maxTurns = config.session?.groupMaxTurns ?? 30;
777
+ const lastActiveMs = state?.lastActiveAt ? Date.parse(state.lastActiveAt) : NaN;
778
+ const idleHit = idleHours > 0 && Number.isFinite(lastActiveMs) && Date.now() - lastActiveMs > idleHours * 3_600_000;
779
+ const turnsHit = maxTurns > 0 && (state?.turnCount ?? 0) >= maxTurns;
780
+ if (idleHit || turnsHit) {
781
+ deps.store.setCurrentSession(key, null, state?.workspaceName || wsName); // turnCount 随之清零
782
+ const rtRoll = runtimes.get(key);
783
+ const dropped = rtRoll?.queuedCount ?? 0;
784
+ if (rtRoll) {
785
+ rtRoll.epoch = (rtRoll.epoch ?? 0) + 1; // 本条已过代际检查,继续执行;后续旧代际排队消息入口 no-op
786
+ rtRoll.queuedCount = 0;
787
+ }
788
+ rolled = true;
789
+ console.log(tag, `[滚动] ${idleHit ? `空闲超 ${idleHours}h` : `轮次满 ${maxTurns}`},群聊自动开启新话题:${key}`);
790
+ if (dropped > 0) {
791
+ await deps.gateway.sendTextTo(msg.chatId, `🗑️ 已开启新话题,${dropped} 条排队消息按旧话题取消(/resume 可找回此前讨论)`)
792
+ .catch(() => { });
793
+ }
794
+ }
795
+ }
727
796
  // 续接指针三态:undefined=旧版存量数据 → 回退 sessions[0](旧语义 sessions[0] 即当前);
728
797
  // null=/new 或 /ws use 已清指针 → 新开会话;字符串 → resume 该会话。
729
- // 不能用 ??:null 会被 ?? 当成空值回退 sessions[0],/new 的意图就被吞掉了
730
- const pointer = state?.currentSessionId;
798
+ // 不能用 ??:null 会被 ?? 当成空值回退 sessions[0],/new 的意图就被吞掉了。
799
+ // 滚动刚把指针置 null:本条直接开新会话(rolled 分支不用再读盘)
800
+ const pointer = rolled ? null : state?.currentSessionId;
731
801
  const resumeId = pointer === undefined ? state?.sessions?.[0]?.sessionId : pointer || undefined;
732
802
  const now = () => nowBeijingISO();
733
803
  // 落盘 · user:任务起点(prompt 全文 + resume 链上一轮 sessionId)
@@ -737,6 +807,15 @@ deps, opts = {}) {
737
807
  });
738
808
  // 任务级 env 现读构造(热生效):apps[].env / claude.env 改动后下一条消息即用新值
739
809
  const taskEnv = buildTaskEnv(config, app, claudeDir);
810
+ // 长期记忆 ns env(§6.4/§6.6):memory.enabled 时每条消息拼装下发——OPENVIKING_USER 按
811
+ // 消息级 userId 隔离(群聊同事话语不混入主人记忆;deputy 的 appId 天然不同即分身永不读
812
+ // 主人记忆)。?? 保留既有值:apps[].env 手配 OPENVIKING_* = 专家覆盖口。关段 = 零注入,
813
+ // 行为与无此段完全一致(可插拔验收);常驻进程经配置指纹回收(env 变 → 指纹变 → 重建)
814
+ if (config.memory?.enabled) {
815
+ for (const [k, v] of Object.entries(memoryEnvFor(config.memory, app.appId, msg.userId, deps.memory?.userKeys()))) {
816
+ taskEnv[k] ??= v;
817
+ }
818
+ }
740
819
  // 打点只列键名不列值(env 常含凭证,防日志泄漏);值含 ${ 提示误配——env 值不做变量
741
820
  // 展开,${VAR} 会原样传给 CLI(变量展开只在 MCP 配置一侧,展开源正是这里注入的环境)
742
821
  const envKeys = [...new Set([...Object.keys(app.env ?? {}), ...Object.keys(config.claude?.env ?? {})])];
@@ -793,15 +872,28 @@ deps, opts = {}) {
793
872
  // 机器人档案认证 settings 文件(--settings):仅含认证键,命令行层压制生效目录 env
794
873
  ...(profileSettingsPath ? { extraArgs: { settings: profileSettingsPath } } : {}),
795
874
  // 进程内通知工具(send_text/send_image/send_file):中间产物实时推给当前聊天。
796
- // server 实例按任务构造,chatId 在 sender 闭包内硬绑定——模型无法选择接收者。
875
+ // server 实例按任务构造(挂通道槽 delegate,见上方槽式换绑),chatId 在 sender
876
+ // 闭包内硬绑定——模型无法选择接收者。
797
877
  // 配置页 MCP(#12)一并注入:bridge 自管 servers.json 每任务现读(热生效),notify 后展开保名
798
- mcpServers: { ...loadBridgeMcpServers(), [NOTIFY_SERVER_NAME]: createNotifyServer(notifySender) },
878
+ mcpServers: { ...loadBridgeMcpServers(), [NOTIFY_SERVER_NAME]: createNotifyServer(notifySlot.delegate) },
879
+ // A2 常驻进程池:同通道亲和复用 CLI 子进程(冷启动税摊薄为每会话一次)。
880
+ // 指纹 = 本任务现读的全部 spawn 输入(人设/模型/plan/env/MCP/插件/档案 settings/
881
+ // cwd/续接指针),任一变化 → executor 回收重建,保「每任务现读热生效」语义;
882
+ // 空闲回收与池上限策略同样每任务现读(热改配置下一条消息生效)
883
+ affinityKey: `${app.appId}:${key}`,
884
+ resident: config.session?.residentProcess !== false,
885
+ residentIdleMs: (config.session?.residentIdleMinutes ?? 60) * 60_000,
886
+ residentMax: config.session?.residentMaxProcesses ?? (app.concurrency ?? config.concurrency),
887
+ residentScope: app.appId,
799
888
  // 插件:config.yaml 显式配置(开发期指源码目录)+ Claude 配置目录已启用的 marketplace
800
889
  // 插件自动发现,同名显式优先;SDK 对无效路径静默跳过,实际加载以 init 清单(/plugins 命令)为准。
801
890
  // managed 模式合并发现两处目录(自管 + 本机 ~/.claude):按绝对 installPath 传 SDK,
802
891
  // 用户本机已装插件无须重装;resolvePluginPaths 按 name 去重,当前生效目录优先。
803
892
  // 分身角色按 allowed_plugins 白名单过滤(显式 app.plugins 始终保留——写在分身配置里
804
- // 的就是要给的);未配 allowed_plugins = 权限轴「全给」,自动发现插件全部加载
893
+ // 的就是要给的);未配 allowed_plugins = 权限轴「全给」,自动发现插件全部加载。
894
+ // 记忆插件自动注入(§6.9):memory.enabled 时 openviking-memory 以宿主基础设施身份
895
+ // 加入加载集,不受 deputy 白名单过滤——记忆是宿主给每个 bot 的恒定能力(同 notify
896
+ // server),不是主人分配给分身的业务插件。B0 实测教训:不注入则白名单分身静默金鱼记忆。
805
897
  ...(() => {
806
898
  const discovered = claudeDir === DEFAULT_CLAUDE_DIR
807
899
  ? discoverPlugins(claudeDir)
@@ -810,6 +902,27 @@ deps, opts = {}) {
810
902
  if (app.role === 'deputy' && app.allowedPlugins !== undefined) {
811
903
  merged = merged.filter((p) => app.plugins?.some((e) => e.name === p.name) || app.allowedPlugins.includes(p.name));
812
904
  }
905
+ // 装配校验告警 ①(§6.9 L2):deputy 白名单零命中——配了白名单但与发现集零交集,
906
+ // 「以为在收权,实际连一个插件都没给」几乎必为配置错误(B0 实测:二号 bot 配
907
+ // [library] 而该插件未启用 → 实际加载 0 插件,全程零提示)。在记忆注入前判定,
908
+ // 避免注入的记忆插件掩盖零命中事实
909
+ if (app.role === 'deputy' && app.allowedPlugins?.length && merged.length === 0) {
910
+ console.warn(tag, `[memory] 分身 "${app.name}" 配置了 allowed_plugins(${app.allowedPlugins.join('、')})`
911
+ + ' 但与已发现插件零命中:自动发现插件实际加载 0 个。请检查插件名拼写,或确认插件已安装且在 '
912
+ + 'settings.json enabledPlugins 中启用(/plugins 可查实际加载清单;方案 §6.9)');
913
+ }
914
+ // 记忆插件注入(§6.9 L1):幂等合流——已显式配置或已过白名单时同名去重不重复加载;
915
+ // 未发现(未安装/未启用)则注入无从谈起,落告警 ② 提示安装,不静默
916
+ if (config.memory?.enabled && !merged.some((p) => p.name === MEMORY_PLUGIN_NAME)) {
917
+ const found = discovered.find((p) => p.name === MEMORY_PLUGIN_NAME);
918
+ if (found)
919
+ merged.push({ name: found.name, path: found.path, source: 'discovered' });
920
+ else {
921
+ console.warn(tag, `[memory] memory.enabled=true 但插件 ${MEMORY_PLUGIN_NAME} 未被发现(未安装或 enabledPlugins 未启用):`
922
+ + '记忆召回/沉淀 hooks 不会生效。安装:claude plugin install openviking-memory@openviking'
923
+ + '(或经配置页插件安装;详见 docs 方案 §6.9)');
924
+ }
925
+ }
813
926
  return merged.length ? { plugins: merged.map((p) => ({ path: p.path })) } : {};
814
927
  })(),
815
928
  // 分身技能白名单:透传 SDK Options.skills(官方上下文过滤器——未列出技能对模型不可见
@@ -910,6 +1023,15 @@ deps, opts = {}) {
910
1023
  if (finalRedaction.canary)
911
1024
  onSecurityEvent({ kind: 'canary', source: 'final' });
912
1025
  const finalText = finalRedaction.text;
1026
+ // A3 滚动轻注记:新话题首条回复尾部带一次(仅此一条,可配置关闭)——不发独立提示
1027
+ // 消息(群聊里机器人自言自语就是噪音),失忆困惑由注记 + /resume 兜底;记忆层上线后
1028
+ // 此注记可弱化/去除。注记只进回传文本,不进 transcript(传输层装饰,非模型产出)
1029
+ const rollNote = rolled && config.session?.groupRollNotify !== false
1030
+ ? '🍃 已开启新话题(此前讨论 /resume 可找回)'
1031
+ : '';
1032
+ const replyText = finalText
1033
+ ? (rollNote ? `${finalText}\n\n---\n${rollNote}` : finalText)
1034
+ : rollNote;
913
1035
  await progress.finish('✅ 完成', finalText.length || undefined);
914
1036
  archive(outcome.sessionId);
915
1037
  deps.transcript?.result({
@@ -933,8 +1055,8 @@ deps, opts = {}) {
933
1055
  // 消息限流)——不论长短都不再进进度卡正文(主卡只留提示行),彻底消除「折叠看不到全文」
934
1056
  // 与「卡片/消息两边重复」(旧三段式 ≤400 终态自载的边界问题)。尾卡纯展示,想继续对话/
935
1057
  // 提意见直接回复消息即可
936
- if (finalText) {
937
- const chunks = chunkText(finalText);
1058
+ if (replyText) {
1059
+ const chunks = chunkText(replyText);
938
1060
  const total = chunks.length;
939
1061
  const resultReq = {
940
1062
  charCount: finalText.length, workspaceName: wsName,
@@ -1027,6 +1149,9 @@ deps, opts = {}) {
1027
1149
  activeQueries.delete(key);
1028
1150
  rt.abort = undefined;
1029
1151
  rt.busy = false;
1152
+ // A3 滚动计数:任务收尾即一轮活跃(成功/出错/停止都消耗了上下文,均向阈值累积);
1153
+ // 指针与历史归档已在上文完成,markActivity 只记时间戳与轮次
1154
+ deps.store.markActivity(key);
1030
1155
  release();
1031
1156
  }
1032
1157
  }
@@ -1046,12 +1171,12 @@ deps, opts = {}) {
1046
1171
  return { toast: { type: 'info', content: '仅任务发起人可操作计划' } };
1047
1172
  }
1048
1173
  const decision = action.value.decision;
1049
- // 「查看完整方案」(#6):callback 触发 send_file 直接发原 md 文件,不消耗挂起项
1174
+ // plan-view-file(存量卡片残留按钮):计划正文已改为卡片直发(planAsk),此处等价重发
1175
+ // 一遍内容卡片组——不消耗挂起项,可反复点击
1050
1176
  if (decision === 'plan-view-file') {
1051
- // 文件在 planAsk 时已落盘;这里直接 send_file(feishuGateway 走 file_type:stream,md 等任意后缀可发)
1052
- void deps.gateway.uploadAndSendFile(plan.chatId, plan.planFilePath)
1053
- .catch((e) => console.error('[查看方案发送失败]', action.value.requestId, e));
1054
- return { toast: { type: 'info', content: '正在发送方案文件…' } };
1177
+ void sendPlanContentCards(plan.chatId, plan.req)
1178
+ .catch((e) => console.error('[计划内容重发失败]', action.value.requestId, e));
1179
+ return { toast: { type: 'info', content: '正在重新发送计划内容卡片…' } };
1055
1180
  }
1056
1181
  if (decision !== 'plan-approve' && decision !== 'plan-revise' && decision !== 'plan-reject') {
1057
1182
  return { toast: { type: 'info', content: '该计划确认已被处理或已过期' } };
@@ -1172,10 +1297,12 @@ deps, opts = {}) {
1172
1297
  return { toast: { type: 'info', content: '该工作区已是当前工作区' } };
1173
1298
  }
1174
1299
  applyWorkspaceSwitch(deps.store, wsc.channelKey, target);
1175
- // 等价 cmd.pointerTouched:切换清了续接指针,运行中/收尾窗口内任务的迟到归档不得回写
1300
+ // 等价 cmd.pointerTouched:切换清了续接指针,运行中/收尾窗口内任务的迟到归档不得回写;
1301
+ // 常驻进程同步作废(cwd 已随工作区变化,旧进程上下文不再适用)
1176
1302
  const wsRt = runtimes.get(wsc.channelKey);
1177
1303
  if (wsRt)
1178
1304
  wsRt.pointerGen = (wsRt.pointerGen ?? 0) + 1;
1305
+ killResidentSession(`${app.appId}:${wsc.channelKey}`);
1179
1306
  // 同通道其余工作区卡置 stale:旧卡显示的当前项已过时,继续可点会诱导误切回旧工作区
1180
1307
  for (const [id, e] of wsPending) {
1181
1308
  if (e.channelKey === wsc.channelKey && id !== action.value.requestId)
@@ -1246,7 +1373,7 @@ deps, opts = {}) {
1246
1373
  * 读失败(文件被写坏的中间态等)沿用旧值不崩,并返回用户可读的错误消息(成功返回 null)——
1247
1374
  * 调用方(processMessage)据此在聊天里显式提示「本次沿用旧配置」,不再静默。
1248
1375
  */
1249
- export function createConfigReloader(config, configPath) {
1376
+ export function createConfigReloader(config, configPath, opts = {}) {
1250
1377
  return () => {
1251
1378
  let fresh;
1252
1379
  try {
@@ -1291,6 +1418,13 @@ export function createConfigReloader(config, configPath) {
1291
1418
  // 卡片宽度热应用:gateway 的 cardWidth getter 与各处发卡传参都现读 config.card,
1292
1419
  // 原地替换后下一条消息的所有卡片即用新宽度(0.x 遗漏导致改宽度须重启的修复)
1293
1420
  config.card = fresh.card;
1421
+ // 长期记忆段热应用(§6.6):OPENVIKING_* env 下发与记忆插件注入均每任务现读 config.memory,
1422
+ // 原地替换后下一条消息即生效;常驻进程经配置指纹回收(env/plugins 变化 → 指纹变 → 重建)。
1423
+ // 段变化时通知调用方(startBridge 据此后台重签 user key——account/root key 改动会使旧 key 失配)
1424
+ const memoryChanged = JSON.stringify(fresh.memory ?? null) !== JSON.stringify(config.memory ?? null);
1425
+ config.memory = fresh.memory;
1426
+ if (memoryChanged)
1427
+ opts.onMemoryChanged?.(fresh.memory);
1294
1428
  // managed 模式 claude.env 热生效:inherit 由每任务 buildTaskEnv 并入子进程 env 即可;
1295
1429
  // managed 走托管 settings.json 通道(CLI 侧 settings.json env 优先于进程 env),
1296
1430
  // 此处即时重写盘保证两通道同值——不写盘则手改 env 会被盘上旧值覆盖而静默失效
@@ -1338,8 +1472,62 @@ export async function startBridge(configPath = CONFIG_PATH) {
1338
1472
  if (removed > 0)
1339
1473
  console.log(`[transcripts] 已清理 ${removed} 个过期落盘文件`);
1340
1474
  }
1475
+ // 长期记忆(§6.6 B1):启动时 Admin API 签发 per-bot user key——api_key 模式才实际签发,
1476
+ // dev 模式服务端禁用 Admin API(身份经 OPENVIKING_USER → X-OpenViking-User header 隔离,
1477
+ // 跳过即正解);memory 段热重载变化时后台重签(account/root key 改动会使旧 key 失配)。
1478
+ // key 只进内存缓存与子进程 env,永不落日志/配置页
1479
+ const memoryUserKeys = new Map();
1480
+ const refreshMemoryKeys = async () => {
1481
+ if (!config.memory?.enabled) {
1482
+ memoryUserKeys.clear();
1483
+ return;
1484
+ }
1485
+ const outcomes = await ensureMemoryUserKeys(config.memory, config.apps.map((a) => a.appId));
1486
+ memoryUserKeys.clear();
1487
+ for (const o of outcomes.values()) {
1488
+ if (o.userKey) {
1489
+ memoryUserKeys.set(o.appId, o.userKey);
1490
+ console.log(`[memory] bot ${o.appId}:user key 已签发(api_key 模式)`);
1491
+ }
1492
+ else if (o.error)
1493
+ console.warn(`[memory] bot ${o.appId}:user key ${o.error}`);
1494
+ else
1495
+ console.log(`[memory] bot ${o.appId}:${o.skipped}`);
1496
+ }
1497
+ };
1498
+ if (config.memory?.enabled) {
1499
+ await refreshMemoryKeys();
1500
+ // 客户端接入配置兜底(§6.7 第 4 步):外部自管域自动 skipped-external;LCB 骨架已存在
1501
+ // kept 零写。失败只 warn——本机直连 claude 的插件启用受影响,LCB 会话有 env 通道不受影响
1502
+ try {
1503
+ const w = ensureClientCliConf(config.memory);
1504
+ if (w === 'written' || w === 'updated')
1505
+ console.log(`[memory] 客户端配置 ovcli.conf 已${w === 'written' ? '写入' : '刷新'}(${w})`);
1506
+ }
1507
+ catch (e) {
1508
+ console.warn('[memory] ovcli.conf 写入失败:', e instanceof Error ? e.message : e);
1509
+ }
1510
+ }
1511
+ const memoryProvider = createMemoryProvider(config, memoryUserKeys);
1341
1512
  // reloader 共享:所有 bridge 持有同一 config 对象引用,mutate 一处全员可见
1342
- const reloadConfig = createConfigReloader(config, configPath);
1513
+ const reloadConfig = createConfigReloader(config, configPath, {
1514
+ onMemoryChanged: (memory) => {
1515
+ if (memory?.enabled) {
1516
+ void refreshMemoryKeys();
1517
+ try {
1518
+ ensureClientCliConf(memory);
1519
+ }
1520
+ catch { /* 同启动口径,容错不阻断 */ } // 'updated' 自愈端点漂移
1521
+ }
1522
+ else {
1523
+ memoryUserKeys.clear();
1524
+ try {
1525
+ cleanupClientCliConf();
1526
+ }
1527
+ catch { /* 清理失败不影响停用:只删 LCB 骨架形状,手配/外部文件保留 */ }
1528
+ }
1529
+ },
1530
+ });
1343
1531
  // 旧版(≤0.3.x)缺省独立目录的一次性迁移提示:目录还在则其中历史会话不再可 /resume
1344
1532
  //(本版起所有机器人统一 ~/.claude,会话无法跨数据目录 resume)。不做自动迁移(双目录合并无安全做法)
1345
1533
  for (const app of config.apps) {
@@ -1388,6 +1576,7 @@ export async function startBridge(configPath = CONFIG_PATH) {
1388
1576
  executor: runTask,
1389
1577
  transcript: new TranscriptWriter(join(CONFIG_DIR, 'transcripts', app.appId)),
1390
1578
  audit: securityAudit,
1579
+ memory: memoryProvider,
1391
1580
  };
1392
1581
  return { app, gateway, bridge: createBridge(config, app, deps, { reloadConfig }) };
1393
1582
  });
@@ -1413,6 +1602,10 @@ export async function startBridge(configPath = CONFIG_PATH) {
1413
1602
  webHandle?.close();
1414
1603
  for (const r of runners)
1415
1604
  r.gateway.close();
1605
+ // 常驻 CLI 子进程一并回收(优雅退出,不赖 stdio 断连兜底)
1606
+ killAllResidentSessions();
1607
+ // B3 受管 OpenViking 服务一并关停(embedded 模式随 LCB 生命周期;独立手动服务不受影响)
1608
+ stopManagedServer();
1416
1609
  clearPidFile();
1417
1610
  process.exit(0);
1418
1611
  };