@shgroup/dsh-serenity-hooks 1.30.17 → 1.31.1

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/lib/index.js CHANGED
@@ -3,14 +3,14 @@ import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$2, f as re
3
3
  import { a as readSkiffRoles, d as systemPromptSource, i as isSkiffSessionId, l as roleToolWhitelist, o as resolveRoleSystemPrompt } from "./skiff-role-BYvNnwv1.js";
4
4
  import { A as skiffRoleFor, C as listActiveHandymen, D as splitModel, E as requireWhitelistedModel, O as writeFailedStatus, S as handymanProgressPaths, T as readProgress, _ as unregisterSkiffSession, a as startSkiffDebugServer, b as HANDYMAN_GUIDE, c as askSkiff, d as ensureWorkspacePromptSection, f as getSkiffAgent, g as skiffTrajectoryEnabled, h as skiffSessionSnapshot, k as writeProgress, l as createSkiffAgent, m as skiffSessionInfo, n as jscSafeJsonText, o as stopSkiffDebugServer, p as skiffMsmGate, r as renderSkiffMarkdown, s as stripThink, t as discoverCccs, u as ensureSkiffSession, v as workspaceTrajectoryLine, w as newStopToken, x as buildRoundPrompt, y as waitAgentIdle } from "./skiff-debug-C5ulZKCa.js";
5
5
  import { i as hostSessions, n as hostInjected, o as hostWeb, r as hostService, s as hostWebServer, t as hostAgents } from "./access-dU_vG1q8.js";
6
- import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, h as runLocalStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-DIaUYipW.js";
6
+ import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-wWk4AIwV.js";
7
7
  import { C as useSession, S as summarize, _ as resolveSessionByTitle, a as archiveSessions, b as setActiveSessionInfo, c as createSession, d as getActiveSessionInfo, f as healthCheck, g as readActiveSessionMd, h as qaCheck, i as SESSION_ACTIONS, l as extractSessionMdPathFromText, m as parseSessionContextFromEvents, n as readLastBound, o as clearActiveSessionInfo, p as listSessions, r as DEFAULT_SESSION_SCOPE, s as closeSession, t as appendBound, u as findSession, v as sessionEvents, x as showSession, y as sessionsRoot } from "./session-bound-BpTayaIS.js";
8
8
  import { n as registerSettingsSection, t as readSimpleSettings } from "./settings-section-CP4uMJf_.js";
9
- import { a as markdownToPlainText, c as sniffImageExt, i as getUpdates, n as downloadMedia, o as sendTextMessage, r as getConfig, s as sendTyping, t as TypingStatus } from "./weixin-api-BMpljodH.js";
9
+ import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-PjEhmjlZ.js";
10
10
  import z from "@deepseek-ai/schemastery";
11
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
12
12
  import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
13
- import { basename, dirname, extname, join, relative, resolve } from "node:path";
13
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
14
14
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
15
15
  import { homedir, platform } from "node:os";
16
16
  import { fileURLToPath } from "node:url";
@@ -421,10 +421,10 @@ function runCcFs(root, args) {
421
421
  * 进程内注册(取代 v0.1 的 bash spawn runner):zod/schemastery 参数校验、
422
422
  * 规范 JSON 输出、纯 render 投影。逻辑在 fs-ops.ts(可单测)。
423
423
  */
424
- function agentCwd$9(exec) {
424
+ function agentCwd$10(exec) {
425
425
  return exec.agent?.session?.header?.cwd ?? process.cwd();
426
426
  }
427
- function renderText$12(value) {
427
+ function renderText$13(value) {
428
428
  return [{
429
429
  type: "text",
430
430
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -496,10 +496,10 @@ const ccFsTool = defineTool({
496
496
  },
497
497
  output: {
498
498
  schema: { type: "json" },
499
- render: (args, value) => renderText$12(value)
499
+ render: (args, value) => renderText$13(value)
500
500
  },
501
501
  async execute(args, exec) {
502
- const root = findSerenityRoot(agentCwd$9(exec));
502
+ const root = findSerenityRoot(agentCwd$10(exec));
503
503
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
504
504
  return runCcFs(root, args);
505
505
  }
@@ -520,6 +520,208 @@ const ACC_VERSION = (() => {
520
520
  }
521
521
  })();
522
522
  //#endregion
523
+ //#region src/im-bridge.ts
524
+ /**
525
+ * im-bridge.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
526
+ *
527
+ * 用户原话:「既然微信桥是我们 ACC 提供的,那么 weixin-send 应该是我们 ACC 提供的能力,
528
+ * 当用户配置了微信桥则可用,不配置则不可见」+「设定一个复杂的多层工具,叫 im-bridge,
529
+ * 里面有微信,然后保留后续可能兼容别的 im 的空间」。
530
+ *
531
+ * 分层(E↑):
532
+ * `tools/im-bridge.ts`(工具面:参数 → 结果渲染)
533
+ * → **本模块**(通道注册表 + 可见性判据 + 动作分发 + 错误码翻译)
534
+ * → 通道实现(`im-weixin.ts`:协议/凭据/账号/记录)
535
+ *
536
+ * 边界(R↓):
537
+ * - **本模块零宿主依赖**(不 import cordis/agent)——可见性判定与分发都是纯逻辑,可独立单测。
538
+ * - **通道只提供机制**:本模块不认识"微信";新增 IM 只需注册一个通道实现(R3 家族式扩展)。
539
+ * - **只能操作本会话 CCC**(R4):`root` 由调用方(工具面)从 agent cwd 解析后传入,
540
+ * 本模块不接受"目标 CCC"参数——跨 CCC 发送不在能力面。
541
+ * - **记录归通道**(R5):通道实现负责让每次发送都落既有 hook。
542
+ */
543
+ /** 文本长度上限(微信侧单条实践上限;超限拒绝而非静默截断——与 HTTP 入口同值) */
544
+ const IM_SEND_MAX_TEXT = 4e3;
545
+ /** 文件大小上限(20MB,与媒体接收上限一致) */
546
+ const IM_FILE_MAX_BYTES = 20971520;
547
+ const channels = /* @__PURE__ */ new Map();
548
+ /** 注册通道(index.ts apply 调用;重复 id 覆盖——测试与热重载友好) */
549
+ function registerImChannel(channel) {
550
+ channels.set(channel.id, channel);
551
+ }
552
+ /** 已注册通道 id(工具 `channel` 参数枚举) */
553
+ function imChannelIds() {
554
+ return [...channels.keys()];
555
+ }
556
+ /**
557
+ * 可见性判据:本 CCC 是否启用了**任一** IM 通道。
558
+ * 未启用 → 工具面对该 agent **不可见**(`tools.restrict` 移除,而非调用时报错)。
559
+ * @param root 本会话 CCC 根(由调用方解析)
560
+ */
561
+ function hasEnabledImChannel(root) {
562
+ for (const channel of channels.values()) try {
563
+ if (channel.isEnabled(root)) return true;
564
+ } catch {}
565
+ return false;
566
+ }
567
+ /** 错误码 → 可行动提示(模型视角:说清怎么改) */
568
+ function remediationFor(code) {
569
+ switch (code) {
570
+ case "CHANNEL_UNKNOWN": return "Use a registered channel id (see the tool description)";
571
+ case "CHANNEL_NOT_CONFIGURED": return "This CCC has not configured that channel — enable it in .opencode/serenity.json (weixin.enabled) or the settings panel";
572
+ case "ACTION_UNKNOWN": return "Use send / send-file / users / status";
573
+ case "ACTION_UNSUPPORTED": return "This channel does not support that action";
574
+ case "ALIAS_UNKNOWN": return "Use a configured alias (see action=users) or a raw channel user id";
575
+ case "TEXT_REQUIRED": return "Provide text";
576
+ case "TEXT_TOO_LONG": return `Shorten the text to <= ${IM_SEND_MAX_TEXT} characters`;
577
+ case "USER_REQUIRED": return "Provide user";
578
+ case "FILE_REQUIRED": return "Provide file (a path inside this CCC)";
579
+ case "FILE_ESCAPE": return "Use a path inside this CCC";
580
+ case "FILE_NOT_FOUND": return "Check the path exists inside this CCC";
581
+ case "FILE_TOO_LARGE": return `Use a file <= ${Math.round(IM_FILE_MAX_BYTES / 1048576)} MB`;
582
+ default: return;
583
+ }
584
+ }
585
+ function fail(code, error, remediation) {
586
+ return {
587
+ ok: false,
588
+ code,
589
+ error,
590
+ ...remediation ?? remediationFor(code) ? { remediation: remediation ?? remediationFor(code) } : {}
591
+ };
592
+ }
593
+ /** CCC 内文件读取(路径逃逸/缺失/超限一律拒绝,返回稳定码) */
594
+ function readCccFile(root, relOrAbs) {
595
+ const abs = isAbsolute(relOrAbs) ? resolve(relOrAbs) : resolve(root, relOrAbs);
596
+ const rel = relative(resolve(root), abs);
597
+ if (rel.startsWith("..") || isAbsolute(rel)) return {
598
+ ok: false,
599
+ code: "FILE_ESCAPE",
600
+ error: `file escapes the CCC root: ${relOrAbs}`
601
+ };
602
+ if (!existsSync(abs)) return {
603
+ ok: false,
604
+ code: "FILE_NOT_FOUND",
605
+ error: `file not found: ${relOrAbs}`
606
+ };
607
+ let size = 0;
608
+ try {
609
+ size = statSync(abs).size;
610
+ } catch (err) {
611
+ return {
612
+ ok: false,
613
+ code: "FILE_NOT_FOUND",
614
+ error: `cannot stat file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
615
+ };
616
+ }
617
+ if (size > 20971520) return {
618
+ ok: false,
619
+ code: "FILE_TOO_LARGE",
620
+ error: `file is ${size} bytes (> ${IM_FILE_MAX_BYTES})`
621
+ };
622
+ try {
623
+ return {
624
+ ok: true,
625
+ data: readFileSync(abs),
626
+ fileName: abs.split("/").pop() ?? "file"
627
+ };
628
+ } catch (err) {
629
+ return {
630
+ ok: false,
631
+ code: "FILE_NOT_FOUND",
632
+ error: `cannot read file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
633
+ };
634
+ }
635
+ }
636
+ /**
637
+ * 执行一次 IM 动作(工具面唯一入口)。
638
+ *
639
+ * 顺序:通道解析 → 动作校验 → 用户解析(别名 → id)→ 输入校验 → 调用通道实现。
640
+ * 任何一步失败都返回**稳定错误码 + 可行动提示**(不抛错——工具面统一渲染)。
641
+ *
642
+ * @param root 本会话 CCC 根(调用方从 agent cwd 解析;本函数不接受目标 CCC)
643
+ * @param req 工具参数
644
+ */
645
+ async function runImBridge(root, req) {
646
+ const channelId = (req.channel ?? "").trim();
647
+ const action = (req.action ?? "").trim();
648
+ if (channelId === "") return fail("CHANNEL_REQUIRED", "channel is required", "Provide channel (e.g. \"weixin\")");
649
+ const channel = channels.get(channelId);
650
+ if (!channel) return fail("CHANNEL_UNKNOWN", `unknown channel "${channelId}" (registered: ${imChannelIds().join(", ") || "(none)"})`);
651
+ if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})`);
652
+ if (action === "users") return {
653
+ ok: true,
654
+ channel: channelId,
655
+ action,
656
+ detail: { users: channel.listUsers(root) }
657
+ };
658
+ if (action === "status") return {
659
+ ok: true,
660
+ channel: channelId,
661
+ action,
662
+ detail: channel.status(root)
663
+ };
664
+ if (action === "send") {
665
+ const rawUser = (req.user ?? "").trim();
666
+ if (rawUser === "") return fail("USER_REQUIRED", "user is required");
667
+ const text = req.text ?? "";
668
+ if (text === "") return fail("TEXT_REQUIRED", "text is required");
669
+ if (text.length > 4e3) return fail("TEXT_TOO_LONG", `text is ${text.length} characters (> ${IM_SEND_MAX_TEXT})`);
670
+ const resolved = channel.resolveUser(root, rawUser);
671
+ if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
672
+ try {
673
+ return {
674
+ ok: true,
675
+ channel: channelId,
676
+ action,
677
+ detail: {
678
+ ...await channel.send({
679
+ root,
680
+ userId: resolved.id,
681
+ text,
682
+ accountId: req.account
683
+ }),
684
+ user: resolved.alias ?? resolved.id
685
+ }
686
+ };
687
+ } catch (err) {
688
+ return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
689
+ }
690
+ }
691
+ if (action === "send-file") {
692
+ if (!channel.sendFile) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" does not support send-file`);
693
+ const rawUser = (req.user ?? "").trim();
694
+ if (rawUser === "") return fail("USER_REQUIRED", "user is required");
695
+ const fileArg = (req.file ?? "").trim();
696
+ if (fileArg === "") return fail("FILE_REQUIRED", "file is required");
697
+ const resolved = channel.resolveUser(root, rawUser);
698
+ if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
699
+ const file = readCccFile(root, fileArg);
700
+ if (!file.ok) return fail(file.code, file.error);
701
+ try {
702
+ return {
703
+ ok: true,
704
+ channel: channelId,
705
+ action,
706
+ detail: {
707
+ ...await channel.sendFile({
708
+ root,
709
+ userId: resolved.id,
710
+ data: file.data,
711
+ fileName: file.fileName,
712
+ caption: req.caption,
713
+ accountId: req.account
714
+ }),
715
+ user: resolved.alias ?? resolved.id
716
+ }
717
+ };
718
+ } catch (err) {
719
+ return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
720
+ }
721
+ }
722
+ return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
723
+ }
724
+ //#endregion
523
725
  //#region src/seams/guards.ts
524
726
  /**
525
727
  * guards.ts — 拦截缝:安全模式 + 路径守卫(P3 语义的机械层)
@@ -712,6 +914,55 @@ function syncSafeModeRestriction(agent, root) {
712
914
  }
713
915
  writeRestrictDiag(root);
714
916
  }
917
+ /** 条件可见的 IM 工具名(与 tools/im-bridge.ts 的注册名一致) */
918
+ const IM_BRIDGE_TOOL_NAME = "im-bridge";
919
+ /** agent key → restrict disposer(im-bridge 隐藏状态;key 同安全模式 = 会话 id) */
920
+ const imBridgeRestrictions = /* @__PURE__ */ new Map();
921
+ /**
922
+ * 同步 im-bridge 可见性(v1.31.0,S142 用户拍板 Q3「未配置则不可见」)。
923
+ *
924
+ * 判据 = 本会话 CCC 是否启用了**任一** IM 通道(`hasEnabledImChannel`,当前只有 weixin):
925
+ * - 未启用 → `agent.ctx.tools.restrict({ deny: ['im-bridge'] })`:**从 schema 移除**
926
+ * (模型看不到,而不是看得到但调用被拒——用户原话"不配置则不可见")
927
+ * - 启用 → 解除隐藏(CCC 配置热更新即时生效)
928
+ *
929
+ * 与 `syncSafeModeRestriction` 同点调用(pre-step 每步 + 会话就绪),失败不阻断
930
+ * (守卫仍兜底;restrict 不可用时最坏是"看得到但通道未配置 → 返回 CHANNEL_NOT_CONFIGURED")。
931
+ */
932
+ function syncImBridgeVisibility(agent, root) {
933
+ const key = agent.session.id ?? "global";
934
+ let enabled = false;
935
+ try {
936
+ enabled = hasEnabledImChannel(root);
937
+ } catch (e) {
938
+ console.error(`[serenity-hooks] im-bridge 可见性判据失败 (key=${key}):`, e.message);
939
+ return;
940
+ }
941
+ const existing = imBridgeRestrictions.get(key);
942
+ if (!enabled && !existing) try {
943
+ const dispose = agent.ctx.tools.restrict({ deny: [IM_BRIDGE_TOOL_NAME] });
944
+ imBridgeRestrictions.set(key, dispose);
945
+ } catch (e) {
946
+ console.error(`[serenity-hooks] im-bridge 隐藏失败 (key=${key}):`, e.message);
947
+ }
948
+ else if (enabled && existing) {
949
+ try {
950
+ existing();
951
+ } catch (e) {
952
+ console.error(`[serenity-hooks] im-bridge 解除隐藏失败 (key=${key}):`, e.message);
953
+ }
954
+ imBridgeRestrictions.delete(key);
955
+ }
956
+ }
957
+ /** 会话销毁清理(lifecycle 调用;防 per-会话 Map 无界增长——review F-08 同族) */
958
+ function forgetImBridgeVisibility(sessionId) {
959
+ const existing = imBridgeRestrictions.get(sessionId);
960
+ if (!existing) return;
961
+ try {
962
+ existing();
963
+ } catch {}
964
+ imBridgeRestrictions.delete(sessionId);
965
+ }
715
966
  /**
716
967
  * 从 exec 参数中提取常见路径字段(write/edit 工具);宽松读取。
717
968
  * container_fs 复合工具:path(单路径)/ paths(数组)/ src / dst——取第一个命中(越界检查
@@ -1316,10 +1567,10 @@ async function runKit(root, args, hostCtx) {
1316
1567
  /**
1317
1568
  * kit.ts — dashboard 真实 DSH 工具定义(defineTool)(v1.30:acc_kit → dashboard)
1318
1569
  */
1319
- function agentCwd$8(exec) {
1570
+ function agentCwd$9(exec) {
1320
1571
  return exec.agent?.session?.header?.cwd ?? process.cwd();
1321
1572
  }
1322
- function renderText$11(value) {
1573
+ function renderText$12(value) {
1323
1574
  return [{
1324
1575
  type: "text",
1325
1576
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -1347,10 +1598,10 @@ function createKitTool(ctx) {
1347
1598
  },
1348
1599
  output: {
1349
1600
  schema: { type: "json" },
1350
- render: (args, value) => renderText$11(value)
1601
+ render: (args, value) => renderText$12(value)
1351
1602
  },
1352
1603
  async execute(args, exec) {
1353
- return await runKit(findSerenityRoot(agentCwd$8(exec)), args, ctx);
1604
+ return await runKit(findSerenityRoot(agentCwd$9(exec)), args, ctx);
1354
1605
  }
1355
1606
  });
1356
1607
  }
@@ -1539,10 +1790,10 @@ function runGit(root, args) {
1539
1790
  /**
1540
1791
  * git.ts — container_git 真实 DSH 工具定义(defineTool)(v1.30:cc_git → container_git)
1541
1792
  */
1542
- function agentCwd$7(exec) {
1793
+ function agentCwd$8(exec) {
1543
1794
  return exec.agent?.session?.header?.cwd ?? process.cwd();
1544
1795
  }
1545
- function renderText$10(value) {
1796
+ function renderText$11(value) {
1546
1797
  return [{
1547
1798
  type: "text",
1548
1799
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -1581,10 +1832,10 @@ const gitTool = defineTool({
1581
1832
  },
1582
1833
  output: {
1583
1834
  schema: { type: "json" },
1584
- render: (args, value) => renderText$10(value)
1835
+ render: (args, value) => renderText$11(value)
1585
1836
  },
1586
1837
  async execute(args, exec) {
1587
- const root = findSerenityRoot(agentCwd$7(exec));
1838
+ const root = findSerenityRoot(agentCwd$8(exec));
1588
1839
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
1589
1840
  return runGit(root, args);
1590
1841
  }
@@ -1776,16 +2027,25 @@ handyman(杂工)工具可用模型白名单(provider/model 列表);未
1776
2027
  Example:
1777
2028
  { "handyman": { "models": ["minimax-cn-coding-plan/MiniMax-M3"], "defaultModel": "minimax-cn-coding-plan/MiniMax-M3", "maxRounds": 100, "maxParallel": 10 } }
1778
2029
 
1779
- ── 2. sessionKeeper.threshold ──
2030
+ ── 2. sessionKeeper.threshold / sessionKeeper.sessionMdMaxKB ──
1780
2031
  trajectory-assistant(TRAJECTORY-ASSISTANT · CHECKPOINT)提醒机制的积分阈值(非 headless 主 agent)。
1781
2032
  按工具调用加权 + 耗时计分;达到阈值注入提醒,要求模型回复 ACK 码。
1782
2033
 
1783
2034
  Config:
1784
- { "sessionKeeper": { "threshold": 150 } }
2035
+ { "sessionKeeper": { "threshold": 150, "sessionMdMaxKB": 100 } }
1785
2036
 
1786
2037
  计分:write/edit = 3,task = 10,read/grep/glob/msm 等 = 1,时间 = 1/分钟
1787
2038
  默认:150
1788
2039
 
2040
+ ▸ sessionMdMaxKB(v1.31.1):**活跃 SESSION.md 体积上限(KB,缺省 100;0 = 关闭)**。
2041
+ 超过 → 注入 [TRAJECTORY-ASSISTANT · LOGBOOK COMPACTION] 提醒:暂停当前工作 →
2042
+ 加载 eap(praxis eap)→ 按 EAP 分层骨架重写 SESSION.md。四条原则(ACC 内嵌,随提醒给出):
2043
+ ① 保留 EAP 分层骨架(不压成时间流水账)② 内容可外移到 references 文件
2044
+ (AGENT_SESSIONS/<会话>/references/*.md,SESSION.md 留链接)③ 允许整合/删除不重要事项
2045
+ (被取代的决策、已解决问题、中间态)④ 自主裁量权充分允许(唯一硬要求 = 骨架 +
2046
+ 未决项/决策理由/下一步仍可重建)。超限每轮提醒;连续 3 轮未重写 → 升级强制语气;
2047
+ 文件回到限内 → 自动停止(自愈)。轨迹提醒**不机械阻断工作**(与 rebuild 提醒同族)。
2048
+
1789
2049
  ── 3. localstore.gitTrack ──
1790
2050
  localstore.json 的 git 策略:allow 可提交 / deny 禁提交(默认 deny)。
1791
2051
  deny 时写入自动确保 .gitignore 含该文件(物理保证),container_git commit 会检查拒绝。
@@ -1844,26 +2104,33 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1844
2104
  (扫码绑定后自动写入,永不进 git 明文面)。
1845
2105
  路由 user → role:exact 优先,* 通配兜底;role 必须 ∈ 该 CCC skiff.roles。
1846
2106
  面板(WebUI 设置 → 微信桥)可扫码绑定/移除账号/编辑路由;msm("weixin-doctor", ["status"|"diag"|"verify"|"guide"]) 排查 + 大而全指南。凭据主动查看:localstore get WEIXIN_<ACCOUNT>_TOKEN。
1847
- ▸ 主动发送(v1.30.9):CCC 用 msm("weixin-send", ["send", "--ccc", "<CCC>", "--user", "<别名|id>", "<文本>"])
1848
- 给用户发消息——经 dsp 主动发送入口(**只绑 127.0.0.1**,plugin 全局配置 ~/.dsh/serenity-hooks.json
1849
- 的 weixinApi.port,默认 3082;公网不可达故无密钥)→ weixin-bridge.sendProactiveText →
1850
- 成功后触发 outgoing hook(source="proactive")。send-file 仍由 MSM 直连 iLink 上传并自行补记。
2107
+ ▸ 主动发送(v1.31.0:**ACC 工具 im-bridge**):调
2108
+ im-bridge({channel:"weixin", action:"send", user:"<别名|id>", text:"<文本>"}) 给用户发消息——
2109
+ **工具只能操作本会话 CCC**(无 ccc 参数,防误发他容器)→ weixin-bridge.sendProactiveText →
2110
+ 成功后触发 outgoing hook(source="proactive")。文件用 action:"send-file" + file:"<CCC 内路径>"
2111
+ (逃逸/缺失/超 20MB 拒绝)+ 可选 caption。**可见性**:本 CCC 配置了 IM 通道
2112
+ (weixin.enabled=true)才出现在模型工具清单里;未配置 → 由 guards 逐 agent
2113
+ tools.restrict({deny:['im-bridge']}) 移除(不是"看得到但被拒")。skiff 角色另需在
2114
+ roles.<role>.tools 白名单列出 im-bridge。
2115
+ 历史(v1.30.9~v1.30.x):CCC 侧 MSM weixin-send(走 loopback HTTP 入口 3082)——
2116
+ v1.31.0 已退役(脚本删除 + 注册表注销);3082 入口保留给**非 agent 调用者**。
1851
2117
 
1852
2118
  ▸ weixin.autoReplyWithLastMessage(v1.30.10,关闭桥的自动输出):
1853
2119
  缺省 true = 现状:桥在 agent 一轮结束后把**最终 assistant 文本**自动回发用户(source="reply")。
1854
2120
  显式 false = 关闭:桥**不再转发最终文本**,输出权交给 agent——每轮在用户消息**末尾**追加
1855
- **机制标记** [serenity:weixin-manual-output] + 一行已填好参数的 weixin-send 命令
1856
- (CCC 根/账号/用户 id),agent 自己决定发什么、发几条(全部记 source="proactive")。
2121
+ **机制标记** [serenity:weixin-manual-output] + 一行已填好参数的 im-bridge 调用
2122
+ (账号/用户 id),agent 自己决定发什么、发几条(全部记 source="proactive")。
1857
2123
  **ACC 只给机制与数据,纪律措辞归 CCC**(v1.30.16):怎么写/必须怎么做/后果是什么写在
1858
2124
  CCC 角色提示词文件里(如 .opencode/skiff/<role>.md),可热改(v1.30.15 起角色提示词热重载)。
1859
- 机械闸门(v1.30.16):本轮 turn 结束仍未成功调用 weixin-send → agent 被打回(≤2 次,
1860
- 提示 = 标记 + 事实);达上限放弃并响亮告警。失败调用不算已送达。
2125
+ 机械闸门(v1.30.16):本轮 turn 结束仍未成功调用 im-bridge(send / send-file;兼容旧
2126
+ msm("weixin-send") 形态)→ agent 被打回(≤2 次,提示 = 标记 + 事实);达上限放弃并响亮告警。
2127
+ 失败调用不算已送达。
1861
2128
  语义边界(用户拍板):只关最终文本;系统类消息(新对话通知 / 语音无法解析提示)保留;
1862
2129
  typing 指示与 incoming hook 不受影响。
1863
2130
 
1864
2131
  ▸ weixin.fallbackOnNoSend(v1.30.17,手动模式的**兜底**;缺省 false):
1865
2132
  仅在 autoReplyWithLastMessage=false 时生效。true → 一轮结束时若 agent **一次都没成功**
1866
- 调用 weixin-send(闸门打回用尽),桥把该轮最终文本转发给用户(记录 source="reply-fallback",
2133
+ 调用输出工具(闸门打回用尽),桥把该轮最终文本转发给用户(记录 source="reply-fallback",
1867
2134
  日志留响亮告警)——保证"不丢消息"。agent 自己发过 → 不兜底(输出权仍在 agent)。
1868
2135
  为什么需要(实证):v1.30.16 闸门会打回 ≤2 次,但实测某模型在**寒暄类消息**上连续 4 轮、
1869
2136
  跨 3 版 CCC 提示词仍不调工具 → 打回用尽后用户什么都收不到;提示词与闸门均已排除。
@@ -1890,7 +2157,7 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1890
2157
  incoming = 用户 → bot:路由命中后、媒体落盘后触发(文本含语音转写;媒体带落盘 relPath)
1891
2158
  outgoing = bot → 用户:发送成功后触发(reply = 用户实际收到的纯文本,已剥离 think)
1892
2159
  · source="reply"(缺省)= 回复用户消息;source="proactive"(v1.30.9)= bot 主动发起
1893
- (CCC 经 weixin-send MSM → dsp 主动发送入口 → sendProactiveText);
2160
+ (CCC 经 im-bridge 工具(v1.31.0;旧 weixin-send MSM 已退役)→ sendProactiveText);
1894
2161
  source="reply-fallback"(v1.30.17)= 手动模式下 agent 本轮未发送 → 桥兜底转发最终文本
1895
2162
  · file(仅 send-file 补记):{ "name": "报告.pdf", "size": 12345, "caption": "可选" }
1896
2163
 
@@ -2327,13 +2594,13 @@ async function runMsmAsync(root, args) {
2327
2594
  * 管理面(register/deregister/check/guide/catalog/ccc-config)已移入 container_admin。
2328
2595
  * 底层 runMsmAsync/prepareExec 全复用;未命中候选为新增 findSuggestions。
2329
2596
  */
2330
- function agentCwd$6(exec) {
2597
+ function agentCwd$7(exec) {
2331
2598
  return exec.agent?.session?.header?.cwd ?? process.cwd();
2332
2599
  }
2333
2600
  function agentSessionId$1(exec) {
2334
2601
  return exec.agent?.session?.id ?? "";
2335
2602
  }
2336
- function renderText$9(value) {
2603
+ function renderText$10(value) {
2337
2604
  return [{
2338
2605
  type: "text",
2339
2606
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2383,10 +2650,10 @@ const msmTool = defineTool({
2383
2650
  },
2384
2651
  output: {
2385
2652
  schema: { type: "json" },
2386
- render: (args, value) => renderText$9(value)
2653
+ render: (args, value) => renderText$10(value)
2387
2654
  },
2388
2655
  async execute(args, exec) {
2389
- const root = findSerenityRoot(agentCwd$6(exec));
2656
+ const root = findSerenityRoot(agentCwd$7(exec));
2390
2657
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
2391
2658
  const sessionId = agentSessionId$1(exec);
2392
2659
  const name = args.name;
@@ -2444,7 +2711,7 @@ CCC (home-serenity etc.) encodes cognitive content as skills/SESSIONs/design doc
2444
2711
 
2445
2712
  ## Reference
2446
2713
  https://github.com/tellmewhattodo/theory-eap`;
2447
- function renderText$8(value) {
2714
+ function renderText$9(value) {
2448
2715
  return [{
2449
2716
  type: "text",
2450
2717
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2464,7 +2731,7 @@ defineTool({
2464
2731
  } },
2465
2732
  output: {
2466
2733
  schema: { type: "string" },
2467
- render: (_args, value) => renderText$8(value)
2734
+ render: (_args, value) => renderText$9(value)
2468
2735
  },
2469
2736
  async execute(args) {
2470
2737
  if (args.section === "variables") return EAP_CONTENT.split("## Pre-Output Self-Check Checklist")[0];
@@ -2505,7 +2772,7 @@ Requirements → Scope → Solution → Interface → Implementation
2505
2772
  2. Wait for confirmation or correction (small step)
2506
2773
  3. After confirmation, record the decision (into session SESSION.md or a design doc)
2507
2774
  4. Move to the next decision point`;
2508
- function renderText$7(value) {
2775
+ function renderText$8(value) {
2509
2776
  return [{
2510
2777
  type: "text",
2511
2778
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2521,7 +2788,7 @@ defineTool({
2521
2788
  } },
2522
2789
  output: {
2523
2790
  schema: { type: "string" },
2524
- render: (_args, value) => renderText$7(value)
2791
+ render: (_args, value) => renderText$8(value)
2525
2792
  },
2526
2793
  async execute(args) {
2527
2794
  if (args.section === "rules") return NEAT_CONTENT.match(/## Four Iron Rules[\s\S]*?(?=## )/)?.[0] ?? NEAT_CONTENT;
@@ -2584,7 +2851,7 @@ CCE answers "how structured knowledge should keep evolving across time without l
2584
2851
  ## Relationship with Serenity
2585
2852
  Serenity's session system, session tracking, and entropy management mechanisms (SQC quality loop) are all engineering implementations of CCE;
2586
2853
  the behavioral constraints embedded in CCC system prompts come from CCE.`;
2587
- function renderText$6(value) {
2854
+ function renderText$7(value) {
2588
2855
  return [{
2589
2856
  type: "text",
2590
2857
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2605,7 +2872,7 @@ defineTool({
2605
2872
  } },
2606
2873
  output: {
2607
2874
  schema: { type: "string" },
2608
- render: (_args, value) => renderText$6(value)
2875
+ render: (_args, value) => renderText$7(value)
2609
2876
  },
2610
2877
  async execute(args) {
2611
2878
  const section = args.section;
@@ -2651,7 +2918,7 @@ const PRAXIS_INDEX = `praxis — 可实践理论注入(认知框架,按需
2651
2918
  praxis cce(认知连续性工程——容器 5 行为约束 + H_op 操作熵)
2652
2919
  无参返回本目录;详情以对应框架原文为准(单一真相源)。
2653
2920
  `;
2654
- function renderText$5(value) {
2921
+ function renderText$6(value) {
2655
2922
  return [{
2656
2923
  type: "text",
2657
2924
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2671,7 +2938,7 @@ const praxisTool = defineTool({
2671
2938
  } },
2672
2939
  output: {
2673
2940
  schema: { type: "string" },
2674
- render: (_args, value) => renderText$5(value)
2941
+ render: (_args, value) => renderText$6(value)
2675
2942
  },
2676
2943
  async execute(args) {
2677
2944
  if (args.section === "eap") return EAP_CONTENT;
@@ -2725,10 +2992,10 @@ function handymanPresetInheritance(parentCtx) {
2725
2992
  * preset 继承 + 工具收窄:setup 钩子里对子 agent 执行 agentPresets.composeFrom(对齐
2726
2993
  * subagent 先例)+ tools.restrict deny handyman(worker 内部看不到 handyman 工具)。
2727
2994
  */
2728
- function agentCwd$5(exec) {
2995
+ function agentCwd$6(exec) {
2729
2996
  return (exec.agent?.session)?.header?.cwd ?? process.cwd();
2730
2997
  }
2731
- function renderText$4(value) {
2998
+ function renderText$5(value) {
2732
2999
  return [{
2733
3000
  type: "text",
2734
3001
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2931,11 +3198,11 @@ function createHandymanTool(ctx) {
2931
3198
  },
2932
3199
  output: {
2933
3200
  schema: { type: "json" },
2934
- render: (_args, value) => renderText$4(value)
3201
+ render: (_args, value) => renderText$5(value)
2935
3202
  },
2936
3203
  async execute(args, exec) {
2937
3204
  if (args.guide) return { guide: HANDYMAN_GUIDE };
2938
- const root = findSerenityRoot(agentCwd$5(exec));
3205
+ const root = findSerenityRoot(agentCwd$6(exec));
2939
3206
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
2940
3207
  const hc = readHandymanConfig(root, DEFAULT_SERENITY_CONFIG_PATHS);
2941
3208
  if (hc === null) throw new Error("handyman requires a model whitelist: configure .opencode/serenity.json \"handyman.models\" (e.g. {\"handyman\": {\"models\": [\"minimax-cn-coding-plan/MiniMax-M3\"], \"defaultModel\": \"minimax-cn-coding-plan/MiniMax-M3\"}})");
@@ -2996,7 +3263,7 @@ function createHandymanTool(ctx) {
2996
3263
  * 同步把当前 dsh 会话重命名为该 SESSION 目录名**(sessionTitle.rename,user source
2997
3264
  * pin 住标题)。非创建时预命名。
2998
3265
  */
2999
- function agentCwd$4(exec) {
3266
+ function agentCwd$5(exec) {
3000
3267
  return exec.agent?.session?.header?.cwd ?? process.cwd();
3001
3268
  }
3002
3269
  /** 当前 dsh 会话 id(use/close 按会话隔离的 scope) */
@@ -3112,7 +3379,7 @@ function renameDshSessionForActive(ctx, exec, info, summary) {
3112
3379
  console.warn(`[serenity-hooks] dsh 会话重命名异常: ${String(err?.message ?? err)}`);
3113
3380
  }
3114
3381
  }
3115
- function renderText$3(value) {
3382
+ function renderText$4(value) {
3116
3383
  return [{
3117
3384
  type: "text",
3118
3385
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -3258,10 +3525,10 @@ function createSessionTool(ctx) {
3258
3525
  },
3259
3526
  output: {
3260
3527
  schema: { type: "json" },
3261
- render: (args, value) => renderText$3(value)
3528
+ render: (args, value) => renderText$4(value)
3262
3529
  },
3263
3530
  async execute(args, exec) {
3264
- const root = findSerenityRoot(agentCwd$4(exec));
3531
+ const root = findSerenityRoot(agentCwd$5(exec));
3265
3532
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
3266
3533
  const entries = loadMsmEntries(root);
3267
3534
  const hasSessionTool = entries.some((e) => e.name === "session-tool");
@@ -3367,7 +3634,7 @@ function createSessionTool(ctx) {
3367
3634
  root,
3368
3635
  note: args.note,
3369
3636
  summary: args.summary,
3370
- agentCwd: agentCwd$4(exec),
3637
+ agentCwd: agentCwd$5(exec),
3371
3638
  dshSessionId
3372
3639
  });
3373
3640
  return {
@@ -3392,7 +3659,7 @@ function createSessionTool(ctx) {
3392
3659
  * 进程内注册,零 DSH 依赖逻辑在 localstore-ops.ts(可单测)。
3393
3660
  * doc 子命令输出存储规范,agent 可直接用 fs 工具(read/write)自己读写。
3394
3661
  */
3395
- const ACTIONS = [
3662
+ const ACTIONS$1 = [
3396
3663
  "list",
3397
3664
  "get",
3398
3665
  "set",
@@ -3400,10 +3667,10 @@ const ACTIONS = [
3400
3667
  "show",
3401
3668
  "doc"
3402
3669
  ];
3403
- function agentCwd$3(exec) {
3670
+ function agentCwd$4(exec) {
3404
3671
  return exec.agent?.session?.header?.cwd ?? process.cwd();
3405
3672
  }
3406
- function renderText$2(value) {
3673
+ function renderText$3(value) {
3407
3674
  return [{
3408
3675
  type: "text",
3409
3676
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -3415,7 +3682,7 @@ const localstoreTool = defineTool({
3415
3682
  parameters: {
3416
3683
  action: {
3417
3684
  type: "string",
3418
- enum: [...ACTIONS],
3685
+ enum: [...ACTIONS$1],
3419
3686
  required: true,
3420
3687
  description: "Subcommand: list/get/set/unset/show/doc"
3421
3688
  },
@@ -3435,10 +3702,10 @@ const localstoreTool = defineTool({
3435
3702
  },
3436
3703
  output: {
3437
3704
  schema: { type: "json" },
3438
- render: (args, value) => renderText$2(value)
3705
+ render: (args, value) => renderText$3(value)
3439
3706
  },
3440
3707
  async execute(args, exec) {
3441
- const root = findSerenityRoot(agentCwd$3(exec));
3708
+ const root = findSerenityRoot(agentCwd$4(exec));
3442
3709
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
3443
3710
  return runLocalStore(root, args);
3444
3711
  }
@@ -3594,13 +3861,13 @@ function applySkiffConfig(root) {
3594
3861
  *
3595
3862
  * 参数极简(LLM 直觉):domain(管理域)+ action(该域子命令)+ 少量业务参数。
3596
3863
  */
3597
- function agentCwd$2(exec) {
3864
+ function agentCwd$3(exec) {
3598
3865
  return exec.agent?.session?.header?.cwd ?? process.cwd();
3599
3866
  }
3600
3867
  function agentSessionId(exec) {
3601
3868
  return exec.agent?.session?.id ?? "";
3602
3869
  }
3603
- function renderText$1(value) {
3870
+ function renderText$2(value) {
3604
3871
  return [{
3605
3872
  type: "text",
3606
3873
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -3655,10 +3922,10 @@ const containerAdminTool = defineTool({
3655
3922
  },
3656
3923
  output: {
3657
3924
  schema: { type: "json" },
3658
- render: (args, value) => renderText$1(value)
3925
+ render: (args, value) => renderText$2(value)
3659
3926
  },
3660
3927
  async execute(args, exec) {
3661
- const root = findSerenityRoot(agentCwd$2(exec));
3928
+ const root = findSerenityRoot(agentCwd$3(exec));
3662
3929
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
3663
3930
  const sessionId = agentSessionId(exec);
3664
3931
  const domain = args.domain;
@@ -4363,10 +4630,10 @@ const AUTOPILOT_ACTIONS = [
4363
4630
  "guide",
4364
4631
  "diag-live"
4365
4632
  ];
4366
- function agentCwd$1(exec) {
4633
+ function agentCwd$2(exec) {
4367
4634
  return exec.agent?.session?.header?.cwd ?? process.cwd();
4368
4635
  }
4369
- function renderText(value) {
4636
+ function renderText$1(value) {
4370
4637
  return [{
4371
4638
  type: "text",
4372
4639
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -4405,11 +4672,11 @@ function createAutopilotTool(ctx) {
4405
4672
  } },
4406
4673
  output: {
4407
4674
  schema: { type: "json" },
4408
- render: (_args, value) => renderText(value)
4675
+ render: (_args, value) => renderText$1(value)
4409
4676
  },
4410
4677
  async execute(args, exec) {
4411
4678
  if (args.action === "diag-live") return { output: renderDiagLive(diagLive(ctx)) };
4412
- const root = findSerenityRoot(agentCwd$1(exec));
4679
+ const root = findSerenityRoot(agentCwd$2(exec));
4413
4680
  const result = {};
4414
4681
  if (!EXP_SCRIPT) {
4415
4682
  result.error = "autopilot-trajectory 脚本未随安装分发(npm 包缺 experiments/autopilot-trajectory/)——请检查包完整性";
@@ -4787,7 +5054,9 @@ const EVENT_LABEL = {
4787
5054
  /** 重建锚点头部(原 TRAJECTORY-REBUILD) */
4788
5055
  rebuild: "REBUILD",
4789
5056
  /** 敏感输出边界守卫(原 SERENITY OUTPUT GUARD) */
4790
- guard: "BOUNDARY GUARD"
5057
+ guard: "BOUNDARY GUARD",
5058
+ /** SESSION.md 体积超限重写提醒(v1.31.1,S142 用户需求) */
5059
+ compaction: "LOGBOOK COMPACTION"
4791
5060
  };
4792
5061
  /** 家族标识(所有动态注入统一前缀) */
4793
5062
  const ASSISTANT_PREFIX = "TRAJECTORY-ASSISTANT";
@@ -4797,6 +5066,17 @@ function eventToken(event) {
4797
5066
  }
4798
5067
  /** ACK 确认码前缀(recorded/skipped 语义不变,仅家族名更新) */
4799
5068
  const ACK_PREFIX = `${ASSISTANT_PREFIX}-recorded`;
5069
+ /**
5070
+ * rebuild 交接区块标题(v1.31.1,S142 用户需求)。
5071
+ *
5072
+ * 用途:rebuild 提醒要求模型把**当前手头事项**写到 SESSION.md **末尾**的这个小标题之下;
5073
+ * rebuild 锚点随后要求重建后的自己去读这一段并处理。两侧引用**同一个常量**——
5074
+ * 标题一旦漂移,读侧就找不到写侧写的东西(单一真相源)。
5075
+ *
5076
+ * 形态(用户拍板 Q2):**固定英文标题行**(不是 HTML 注释锚)——人读友好;ACC 写死英文,
5077
+ * 因为 SESSION.md 的正文语言归 CCC,但两侧的**机械锚点**必须逐字一致。
5078
+ */
5079
+ const IN_FLIGHT_HEADING = "## In-flight (rebuild handover)";
4800
5080
  //#endregion
4801
5081
  //#region src/seams/keeper.ts
4802
5082
  const SCORES = {
@@ -4861,10 +5141,16 @@ function reminderText(code, score) {
4861
5141
  *
4862
5142
  * 需求①(S142 用户拍板):百分比比例 → K 数值——tokensK = 实际占用(千 token),
4863
5143
  * thresholdK = 配置阈值(千 token);文案 `Context usage at NNNK (threshold NNNK)`。
5144
+ *
5145
+ * v1.31.1 交接协议(S142 用户需求"要求 LLM 将当前手头事项写在 SESSION.md 尾部,并要求
5146
+ * rebuild 后去读并处理"):**写侧** = 这里(两条文案都要求把 in-flight 事项写在
5147
+ * SESSION.md 末尾的 `IN_FLIGHT_HEADING` 之下);**读侧** = rebuild 锚点(rebuild.ts)。
5148
+ * 两侧共用 trajectory-assistant 的标题常量(单一真相源)。
4864
5149
  */
4865
5150
  function rebuildReminderText(tokensK, thresholdK, escalated = false) {
4866
- if (escalated) return `${eventToken("limitMandatory")} Context usage at ${Math.round(tokensK)}K (threshold ${Math.round(thresholdK)}K) — you have been reminded repeatedly and have NOT called the logbook rebuild action. This is now mandatory: STOP at the current task step, preserve valuable cognition into the CCC skills (or write a new-skill proposal into SESSION.md), then call logbook rebuild immediately, passing --summary "<content summary ≤20 chars>" (required; the dsh session title is renamed to S###-YYYY-MM-DD-<summary> after rebuild). The conversation will be cleared and rebuilt in place; SESSION.md is the persistent trajectory and stays in place — identity continues from it. Do not continue working without rebuilding; this reminder persists until you call logbook rebuild.`;
4867
- return `${eventToken("limit")} Context usage at ${Math.round(tokensK)}K (threshold ${Math.round(thresholdK)}K). This session is the rebuildable carrier of the trajectory: SESSION.md is the persistent body, this conversation is only a temporary work copy. Before rebuilding: if this conversation produced valuable cognition, revise the relevant existing skill of this CCC (structure it with eap); if a new skill is warranted, write a short proposal into SESSION.md for the user to review — do not create it yourself. ACT NOW: at the next natural pause (end of the current task step), call the logbook rebuild action — passing --summary "<content summary ≤20 chars>" describing the next work phase (required; the dsh session title is renamed to S###-YYYY-MM-DD-<summary> after rebuild) — to clear and rebuild this conversation: the current copy is discarded, identity continues from SESSION.md. If you are in the middle of an unbreakable step, continue it, then rebuild at its end. Do not ignore this; rebuild is the expected action, not an option.`;
5151
+ const handover = ` Before rebuilding, write your current in-flight items (the exact step you are in the middle of, what is not finished yet, and what the next action is) at the very end of SESSION.md under the heading "${IN_FLIGHT_HEADING}" — the rebuilt conversation reads that section first and continues from it.`;
5152
+ if (escalated) return `${eventToken("limitMandatory")} Context usage at ${Math.round(tokensK)}K (threshold ${Math.round(thresholdK)}K) — you have been reminded repeatedly and have NOT called the logbook rebuild action. This is now mandatory: STOP at the current task step, preserve valuable cognition into the CCC skills (or write a new-skill proposal into SESSION.md), then call logbook rebuild immediately, passing --summary "<content summary ≤20 chars>" (required; the dsh session title is renamed to S###-YYYY-MM-DD-<summary> after rebuild).${handover} The conversation will be cleared and rebuilt in place; SESSION.md is the persistent trajectory and stays in place — identity continues from it. Do not continue working without rebuilding; this reminder persists until you call logbook rebuild.`;
5153
+ return `${eventToken("limit")} Context usage at ${Math.round(tokensK)}K (threshold ${Math.round(thresholdK)}K). This session is the rebuildable carrier of the trajectory: SESSION.md is the persistent body, this conversation is only a temporary work copy. Before rebuilding: if this conversation produced valuable cognition, revise the relevant existing skill of this CCC (structure it with eap); if a new skill is warranted, write a short proposal into SESSION.md for the user to review — do not create it yourself.${handover} ACT NOW: at the next natural pause (end of the current task step), call the logbook rebuild action — passing --summary "<content summary ≤20 chars>" describing the next work phase (required; the dsh session title is renamed to S###-YYYY-MM-DD-<summary> after rebuild) — to clear and rebuild this conversation: the current copy is discarded, identity continues from SESSION.md. If you are in the middle of an unbreakable step, continue it, then rebuild at its end. Do not ignore this; rebuild is the expected action, not an option.`;
4868
5154
  }
4869
5155
  /** 读取会话 contextPressure 投影(sessionProjections 可选服务;未装配返回 null) */
4870
5156
  function readContextPressure(ctx, session) {
@@ -4881,6 +5167,96 @@ function readContextPressure(ctx, session) {
4881
5167
  return null;
4882
5168
  }
4883
5169
  }
5170
+ /** 连续超限轮数达此值 → 升级强制语气(对齐 rebuild 提醒的 REBUILD_ESCALATE_AFTER) */
5171
+ const COMPACTION_ESCALATE_AFTER = 3;
5172
+ /** 路径/配置缓存 TTL(避免每次工具调用都解析路径与读配置;stat 每次都做,很便宜) */
5173
+ const MD_CACHE_TTL_MS = 6e4;
5174
+ const mdPathCache = /* @__PURE__ */ new Map();
5175
+ const mdLimitCache = /* @__PURE__ */ new Map();
5176
+ const compactionStates = /* @__PURE__ */ new Map();
5177
+ /** 会话销毁/测试重置:清理路径缓存与超限计数(防 per-会话 Map 无界增长,review F-08 同族) */
5178
+ function forgetLogbookCompactionState(scope) {
5179
+ mdPathCache.delete(scope);
5180
+ compactionStates.delete(scope);
5181
+ }
5182
+ /**
5183
+ * 解析**活跃** SESSION.md 绝对路径(缓存 60s)。
5184
+ *
5185
+ * 候选链(刻意比 rebuild 的完整链短——这里每个工具调用都要跑,贵候选不划算):
5186
+ * ① `getActiveSessionInfo(scope).mdPath`(`logbook use` / skiff 绑定 / 重启恢复都会写)
5187
+ * ② `readLastBound(session).mdPath`(`.bindings.json` 权威绑定——重启后内存空时兜底)
5188
+ * 两者都拿不到 → null(**不猜**:宁可不提醒,也不去猜别的轨迹——对齐 v1.30.13 D4 的教训)。
5189
+ */
5190
+ function resolveActiveSessionMdPath(root, scope, session, now = Date.now) {
5191
+ const t = now();
5192
+ const cached = mdPathCache.get(scope);
5193
+ if (cached && t - cached.at < MD_CACHE_TTL_MS) return cached.mdPath;
5194
+ let candidate = null;
5195
+ try {
5196
+ candidate = getActiveSessionInfo(scope)?.mdPath ?? null;
5197
+ } catch {
5198
+ candidate = null;
5199
+ }
5200
+ if (!candidate) try {
5201
+ candidate = readLastBound(session)?.mdPath ?? null;
5202
+ } catch {
5203
+ candidate = null;
5204
+ }
5205
+ let abs = null;
5206
+ if (candidate) {
5207
+ const p = candidate.startsWith(root) ? candidate : resolve(root, candidate);
5208
+ abs = existsSync(p) ? p : null;
5209
+ }
5210
+ mdPathCache.set(scope, {
5211
+ mdPath: abs,
5212
+ at: t
5213
+ });
5214
+ return abs;
5215
+ }
5216
+ /** 读文件字节数(读不到 → null,不抛错) */
5217
+ function readFileSize(absPath) {
5218
+ try {
5219
+ return statSync(absPath).size;
5220
+ } catch {
5221
+ return null;
5222
+ }
5223
+ }
5224
+ /** 读该 CCC 的 SESSION.md 体积上限(KB;缓存 60s;0 = 关闭提醒) */
5225
+ function readSessionMdMaxKB(root, configPaths, now = Date.now) {
5226
+ const t = now();
5227
+ const cached = mdLimitCache.get(root);
5228
+ if (cached && t - cached.at < MD_CACHE_TTL_MS) return cached.limitKB;
5229
+ let limitKB = 100;
5230
+ try {
5231
+ const configured = loadSerenityConfig(root, configPaths).sessionKeeper?.sessionMdMaxKB;
5232
+ if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) limitKB = configured;
5233
+ } catch {}
5234
+ mdLimitCache.set(root, {
5235
+ limitKB,
5236
+ at: t
5237
+ });
5238
+ return limitKB;
5239
+ }
5240
+ /**
5241
+ * LOGBOOK COMPACTION 提醒文案(v1.31.1)。
5242
+ *
5243
+ * 归属(D23 + 用户 2026-09-09 拍板):**ACC 内嵌机制事实 + 4 条重写原则**——理由:这 4 条是
5244
+ * 认知质量规范(与 EAP 同层,不是会随角色迭代的措辞),且未配置任何 CCC 文档的容器也要立刻可用。
5245
+ *
5246
+ * 四条原则(用户原话逐条落成可执行判据):
5247
+ * ① 保留 EAP 分层骨架(不许压成时间流水账)
5248
+ * ② 内容可外移到 references 文件(证据链/日志/参考资料 → `AGENT_SESSIONS/<会话>/references/*.md`,
5249
+ * 在 SESSION.md 里留链接)
5250
+ * ③ 允许整合不重要事项(被取代的决策 / 已解决问题 / 中间态)
5251
+ * ④ 自主裁量权充分允许(唯一硬要求 = 骨架 + 未决项/决策理由/下一步仍可重建)
5252
+ */
5253
+ function logbookCompactionReminderText(input) {
5254
+ const { sizeKB, limitKB, mdPath, escalated = false } = input;
5255
+ const facts = `SESSION.md is ${sizeKB} KB (limit ${limitKB} KB): ${mdPath}`;
5256
+ const principles = "Load eap (praxis eap) first, then rewrite SESSION.md under these principles: (1) keep the EAP layered skeleton — a stable section structure, not a chronological dump; (2) move detail out — long evidence chains, logs and reference material belong in separate files (e.g. AGENT_SESSIONS/<session>/references/*.md) linked from SESSION.md; (3) merge or drop what no longer matters — superseded decisions, resolved issues, intermediate states; (4) you have full discretion on how far to go: the only hard requirements are the skeleton and that every still-open item, decision rationale and next step stays recoverable (reconstruction over preservation).";
5257
+ if (escalated) return `${eventToken("compaction")} ${facts} — you have been reminded repeatedly and have NOT compacted the logbook. This is now mandatory: STOP the current task step, load eap (praxis eap), rewrite SESSION.md, then resume. ${principles} This reminder persists until the file is under the limit.`;
5258
+ return `${eventToken("compaction")} ${facts}. Pause the current work and compact the trajectory logbook before continuing — the logbook is the persistent body of this trajectory, and its size is operational entropy (H_op) the container pays on every read. ${principles} After rewriting, resume the paused work; this reminder stops once the file is under the limit.`;
5259
+ }
4884
5260
  const PLUGIN_SOURCE$4 = {
4885
5261
  kind: "plugin",
4886
5262
  plugin: "dsh-serenity-hooks"
@@ -4942,6 +5318,29 @@ function registerKeeper(ctx, opts = {}) {
4942
5318
  }
4943
5319
  }
4944
5320
  }
5321
+ if (skiffTrajectoryEnabled(root, sessionId, "session")) {
5322
+ const limitKB = readSessionMdMaxKB(root, opts.configPaths);
5323
+ if (limitKB > 0) {
5324
+ const scope = sessionId ?? "global";
5325
+ const session = exec.agent?.session;
5326
+ const mdPath = resolveActiveSessionMdPath(root, scope, session);
5327
+ const size = mdPath ? readFileSize(mdPath) : null;
5328
+ if (mdPath && size !== null && size > limitKB * 1024) {
5329
+ const st = compactionStates.get(scope) ?? { consecutive: 0 };
5330
+ st.consecutive += 1;
5331
+ blocks.push({
5332
+ type: "text",
5333
+ text: logbookCompactionReminderText({
5334
+ sizeKB: Math.round(size / 1024),
5335
+ limitKB,
5336
+ mdPath,
5337
+ escalated: st.consecutive >= COMPACTION_ESCALATE_AFTER
5338
+ })
5339
+ });
5340
+ compactionStates.set(scope, st);
5341
+ } else compactionStates.delete(scope);
5342
+ }
5343
+ }
4945
5344
  if (blocks.length === 0) return downstream;
4946
5345
  const reminderAll = createUserMessage({
4947
5346
  content: blocks,
@@ -5889,7 +6288,7 @@ function codeModeAdaptationLine(ctx, scope) {
5889
6288
  * 无 agent → undefined(不注入);有 agent 但无 header.cwd(workflow subagent 等)
5890
6289
  * 回退 process.cwd()——对齐 context.ts,否则这些 agent 系统提示词注入为空
5891
6290
  * (v1.18.6:workflow subagent 无宁静号上下文 bug) */
5892
- function agentCwd(context) {
6291
+ function agentCwd$1(context) {
5893
6292
  const agent = context.agent;
5894
6293
  if (!agent) return void 0;
5895
6294
  return agent.session?.header?.cwd ?? process.cwd();
@@ -5909,7 +6308,7 @@ function registerEntrySkillSectionGlobal(ctx) {
5909
6308
  name: "serenity-entry",
5910
6309
  order: -50,
5911
6310
  text: (context) => {
5912
- const cwd = agentCwd(context);
6311
+ const cwd = agentCwd$1(context);
5913
6312
  if (!cwd) return "";
5914
6313
  const root = findSerenityRoot(cwd);
5915
6314
  if (!root) return "";
@@ -6116,6 +6515,9 @@ function registerContext(ctx, opts = {}) {
6116
6515
  injected.add(key);
6117
6516
  agent.inject(accMessage(root, configPaths, entrySkillMaxChars));
6118
6517
  syncSafeModeRestriction(agent, root);
6518
+ try {
6519
+ syncImBridgeVisibility(agent, root);
6520
+ } catch {}
6119
6521
  };
6120
6522
  if (opts.seedOnStart ?? true) ctx.on("agent/session-start", (payload) => {
6121
6523
  try {
@@ -6133,6 +6535,9 @@ function registerContext(ctx, opts = {}) {
6133
6535
  try {
6134
6536
  syncSafeModeRestriction(agent, root);
6135
6537
  } catch {}
6538
+ try {
6539
+ syncImBridgeVisibility(agent, root);
6540
+ } catch {}
6136
6541
  registerEntrySkillSection(agent, root);
6137
6542
  }
6138
6543
  if (!root || injected.has(key) || downstream.kind !== "enter") return downstream;
@@ -6621,11 +7026,11 @@ function registerStatusApi(ctx, opts = {}) {
6621
7026
  sendJson$2(res, 400, { error: chk.error });
6622
7027
  return;
6623
7028
  }
6624
- const { readWeixinSettings } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7029
+ const { readWeixinSettings } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6625
7030
  const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
6626
7031
  const settings = readWeixinSettings(chk.root);
6627
7032
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
6628
- const { readWeixinCredential } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7033
+ const { readWeixinCredential } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6629
7034
  const accounts = (settings.accounts ?? []).map((a) => ({
6630
7035
  accountId: a.accountId,
6631
7036
  name: a.name ?? void 0,
@@ -6654,10 +7059,10 @@ function registerStatusApi(ctx, opts = {}) {
6654
7059
  }
6655
7060
  const root = chk.root;
6656
7061
  if (body.action === "login-start") {
6657
- const { fetchQRCode } = await import("./weixin-api-BMpljodH.js").then((n) => n.l);
7062
+ const { fetchQRCode } = await import("./weixin-api-PjEhmjlZ.js").then((n) => n.u);
6658
7063
  const { randomUUID } = await import("node:crypto");
6659
7064
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
6660
- const { readWeixinSettings } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7065
+ const { readWeixinSettings } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6661
7066
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
6662
7067
  const loginKey = randomUUID();
6663
7068
  weixinLogins.set(loginKey, {
@@ -6678,7 +7083,7 @@ function registerStatusApi(ctx, opts = {}) {
6678
7083
  sendJson$2(res, 400, { error: "missing accountId" });
6679
7084
  return;
6680
7085
  }
6681
- const { removeWeixinAccount } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7086
+ const { removeWeixinAccount } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6682
7087
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
6683
7088
  removeWeixinAccount(root, body.accountId);
6684
7089
  syncCccBridge(ctx, root);
@@ -6691,7 +7096,7 @@ function registerStatusApi(ctx, opts = {}) {
6691
7096
  sendJson$2(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
6692
7097
  return;
6693
7098
  }
6694
- const { saveWeixinRoutes } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7099
+ const { saveWeixinRoutes } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6695
7100
  const { readSkiffRoles } = await import("./skiff-role-BYvNnwv1.js").then((n) => n.u);
6696
7101
  const roles = readSkiffRoles(root);
6697
7102
  for (const r of routes) if (!roles.has(r.role)) {
@@ -6704,7 +7109,7 @@ function registerStatusApi(ctx, opts = {}) {
6704
7109
  }
6705
7110
  if (body.action === "set-enabled") {
6706
7111
  const enabled = body.enabled === true;
6707
- const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7112
+ const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6708
7113
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
6709
7114
  const settings = readWeixinSettings(root);
6710
7115
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -6750,8 +7155,8 @@ function registerStatusApi(ctx, opts = {}) {
6750
7155
  sendJson$2(res, 200, { status: "expired" });
6751
7156
  return;
6752
7157
  }
6753
- const { pollQRStatus } = await import("./weixin-api-BMpljodH.js").then((n) => n.l);
6754
- const { readWeixinSettings } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7158
+ const { pollQRStatus } = await import("./weixin-api-PjEhmjlZ.js").then((n) => n.u);
7159
+ const { readWeixinSettings } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6755
7160
  const settings = readWeixinSettings(login.root);
6756
7161
  const status = await pollQRStatus({
6757
7162
  baseUrl: void 0,
@@ -6766,7 +7171,7 @@ function registerStatusApi(ctx, opts = {}) {
6766
7171
  });
6767
7172
  return;
6768
7173
  }
6769
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-DIaUYipW.js").then((n) => n.u);
7174
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6770
7175
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
6771
7176
  const accountId = nextWeixinAccountId(settings);
6772
7177
  upsertWeixinAccount(login.root, {
@@ -7938,6 +8343,7 @@ function buildRebuildAnchor(root, sessionName, activeMdPath, anchorMessages = DE
7938
8343
  `- Persistent trajectory — SESSION.md path: ${rel}`,
7939
8344
  ` (the trajectory's persistent body — stays in place through rebuilds)`,
7940
8345
  `- Read that SESSION.md first (goal/decisions/progress/unresolved), then continue from the last checkpoint.`,
8346
+ `- Then read the "${IN_FLIGHT_HEADING}" section at the very end of SESSION.md and work through those in-flight items (the previous self paused mid-task and handed them over). If that section is missing, infer the in-flight items from the latest progress entries.`,
7941
8347
  ...focusLine ? [`- Task focus: ${focusLine}`] : []
7942
8348
  ].join("\n");
7943
8349
  }
@@ -9239,19 +9645,31 @@ function sessionIdOf$1(value) {
9239
9645
  const id = v?.session?.id ?? v?.id;
9240
9646
  return typeof id === "string" && id !== "" ? id : null;
9241
9647
  }
9242
- /** 工具调用是否为「成功的 weixin-send」 */
9648
+ /**
9649
+ * 工具调用是否为「成功的微信发送」。
9650
+ *
9651
+ * v1.31.0 起有两种合法形态(标记与闸门同时认,迁移期并存):
9652
+ * - `im-bridge({channel:"weixin", action:"send"|"send-file", …})` —— ACC 工具(推荐)
9653
+ * - `msm("weixin-send", ["send"|"send-file", …])` —— CCC MSM(v1.30.x 通道,逐步退役)
9654
+ */
9243
9655
  function isSuccessfulWeixinSend(exec, result) {
9244
9656
  const e = exec;
9245
- if (e?.name !== "msm") return false;
9246
- if (e.arguments?.name !== "weixin-send") return false;
9247
- return result?.isError !== true;
9657
+ if (result?.isError === true) return false;
9658
+ const args = e?.arguments;
9659
+ if (e?.name === "im-bridge") {
9660
+ if ((typeof args?.channel === "string" ? args.channel : "") !== "weixin") return false;
9661
+ const action = typeof args?.action === "string" ? args.action : "";
9662
+ return action === "send" || action === "send-file";
9663
+ }
9664
+ if (e?.name === "msm") return args?.name === "weixin-send";
9665
+ return false;
9248
9666
  }
9249
9667
  /** 打回消息(机制事实 + 标记;怎么做由 CCC 角色提示词定义) */
9250
9668
  function buildManualOutputRebuke(session) {
9251
9669
  return [
9252
9670
  "[serenity:weixin-manual-output] no send detected in this turn — the user has received NOTHING.",
9253
9671
  `ccc=${session.root} account=${session.accountId} user=${session.userId}`,
9254
- `msm("weixin-send", ["send", "--ccc", "${session.root}", "--account", "${session.accountId}", "--user", "${session.userId}", "<回复>"])`
9672
+ `im-bridge({channel:"weixin", action:"send", account:"${session.accountId}", user:"${session.userId}", text:"<回复>"})`
9255
9673
  ].join("\n");
9256
9674
  }
9257
9675
  /**
@@ -9358,7 +9776,8 @@ function buildOutgoingHookEvent(input) {
9358
9776
  sessionId: input.sessionId,
9359
9777
  role: input.role,
9360
9778
  reply: input.reply,
9361
- ...input.source && input.source !== "reply" ? { source: input.source } : {}
9779
+ ...input.source && input.source !== "reply" ? { source: input.source } : {},
9780
+ ...input.file ? { file: input.file } : {}
9362
9781
  };
9363
9782
  }
9364
9783
  /**
@@ -9474,6 +9893,7 @@ var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
9474
9893
  handleIncoming: () => handleIncoming,
9475
9894
  manualOutputFallbackNeeded: () => manualOutputFallbackNeeded,
9476
9895
  registerWeixinBridge: () => registerWeixinBridge,
9896
+ resolveWeixinAccount: () => resolveWeixinAccount,
9477
9897
  sendProactiveText: () => sendProactiveText,
9478
9898
  stopAllBridges: () => stopAllBridges,
9479
9899
  stopCccBridge: () => stopCccBridge,
@@ -9571,14 +9991,14 @@ async function runAccountLoop(ctx, root, accountId, cred, loop) {
9571
9991
  * "约束不够,LLM 不听"——且措辞迭代要动插件代码 + 发版 + 重启;把措辞放 CCC 才能快速迭代,
9572
9992
  * 也符合"ACC 管机制、CCC 管内容"的归属二分。
9573
9993
  *
9574
- * 标记格式(稳定,供 CCC 提示词引用):
9994
+ * 标记格式(稳定,供 CCC 提示词引用;v1.31.0 起为 ACC 工具形态):
9575
9995
  * ```
9576
9996
  * [serenity:weixin-manual-output]
9577
- * msm("weixin-send", ["send", "--ccc", "<root>", "--account", "<account>", "--user", "<user>", "<回复>"])
9997
+ * im-bridge({channel:"weixin", action:"send", account:"<account>", user:"<user>", text:"<回复>"})
9578
9998
  * ```
9579
9999
  */
9580
10000
  function weixinManualOutputMarker(root, accountId, userId) {
9581
- return ["[serenity:weixin-manual-output]", `msm("weixin-send", ["send", "--ccc", "${root}", "--account", "${accountId}", "--user", "${userId}", "<回复>"])`].join("\n");
10001
+ return ["[serenity:weixin-manual-output]", `im-bridge({channel:"weixin", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
9582
10002
  }
9583
10003
  /**
9584
10004
  * 手动输出模式的**兜底判定**(纯函数,v1.30.17——把决策矩阵从装配里抽出来,可独立测试)。
@@ -9840,22 +10260,12 @@ function stopAllBridges() {
9840
10260
  bridges.clear();
9841
10261
  }
9842
10262
  /**
9843
- * 以 bot 身份主动给指定用户发文本(**不经用户消息触发**)。
9844
- *
9845
- * 为什么放在桥里而不是让 CCC 自己直连 iLink(用户拍板 A 方案):
9846
- * ① **记录归桥**——发送成功后触发既有 outgoing hook,`_weixin-logs/*.jsonl` 与对话回复
9847
- * 同源同格式(事件多一个 `source: 'proactive'` 标记);② 协议/账号/凭据解析单一真相源,
9848
- * CCC 侧不必重复实现 sendmessage;③ 多账号可选。
9849
- *
9850
- * 记录一致性:`sessionId` 取 `weixinSessionIdFor(toUserId)`——与该用户平时对话**同一条轨迹**,
9851
- * 记录里能直接按会话串联;`role` 取该 CCC 路由命中角色(未命中 → 空串)。
9852
- *
9853
- * 主动消息不带 context_token(iLink 接受 bot 主动发起;带 token 的路径见 handleIncoming 回复)。
9854
- * @param input CCC 根 + 目标用户 + 文本 +(可选)账号
9855
- * @returns 成功含 accountId/userId/sessionId/role;失败含稳定 code 与可行动提示(不抛错)
10263
+ * 解析该 CCC 的发送账号 + 凭据(v1.31.0:文本与文件发送的**单一账号选择真相源**)。
10264
+ * 顺序:桥启用 → 有启用账号 → 指定账号存在 → 凭据已绑定(扫码)。
10265
+ * @param root CCC 根
10266
+ * @param accountId 指定账号(缺省 = 第一个启用账号)
9856
10267
  */
9857
- async function sendProactiveText(input) {
9858
- const { root, toUserId } = input;
10268
+ function resolveWeixinAccount(root, accountId) {
9859
10269
  const settings = readWeixinSettings(root);
9860
10270
  if (!settings.enabled) return {
9861
10271
  ok: false,
@@ -9870,20 +10280,32 @@ async function sendProactiveText(input) {
9870
10280
  error: "微信桥未配置任何启用账号",
9871
10281
  remediation: "在设置面板「微信桥」扫码绑定账号"
9872
10282
  };
9873
- const accountId = input.accountId ?? accounts[0].accountId;
9874
- if (!accounts.some((a) => a.accountId === accountId)) return {
10283
+ const id = accountId ?? accounts[0].accountId;
10284
+ if (!accounts.some((a) => a.accountId === id)) return {
9875
10285
  ok: false,
9876
10286
  code: "ACCOUNT_NOT_FOUND",
9877
- error: `账号不存在或未启用: ${accountId}`,
10287
+ error: `账号不存在或未启用: ${id}`,
9878
10288
  remediation: `可用账号: ${accounts.map((a) => a.accountId).join(", ")}`
9879
10289
  };
9880
- const cred = readWeixinCredential(root, accountId);
10290
+ const cred = readWeixinCredential(root, id);
9881
10291
  if (!cred) return {
9882
10292
  ok: false,
9883
10293
  code: "ACCOUNT_NOT_BOUND",
9884
- error: `账号未绑定(无凭据): ${accountId}`,
10294
+ error: `账号未绑定(无凭据): ${id}`,
9885
10295
  remediation: "在设置面板重新扫码绑定"
9886
10296
  };
10297
+ return {
10298
+ ok: true,
10299
+ accountId: id,
10300
+ cred
10301
+ };
10302
+ }
10303
+ async function sendProactiveText(input) {
10304
+ const { root, toUserId } = input;
10305
+ const settings = readWeixinSettings(root);
10306
+ const account = resolveWeixinAccount(root, input.accountId);
10307
+ if (!account.ok) return account;
10308
+ const { accountId, cred } = account;
9887
10309
  const sessionId = weixinSessionIdFor(toUserId);
9888
10310
  const role = matchWeixinRoute(settings.routes ?? [], toUserId) ?? "";
9889
10311
  try {
@@ -9959,7 +10381,9 @@ function registerWeixinBridge(ctx) {
9959
10381
  * 而 3081 的登录态属于"家庭账号"——一旦挂上去,任何已登录的外部用户都能冒充 bot 发消息。
9960
10382
  * 独立 loopback 监听器则**不经网关、公网不可达**,因此不做共享密钥;仍校验来源地址。
9961
10383
  *
9962
- * 调用者 = CCC 自己的 MSM(`msm weixin-send ...`):ACC **不新增工具**,只多一个本机 HTTP 面。
10384
+ * 调用者 = **非 agent 的调用者**(外部脚本/集成/运维)。**agent 侧已不走这里**:v1.31.0 起
10385
+ * agent 统一调 `im-bridge` 工具(进程内直调 `sendProactiveText`,无 HTTP 一跳),旧 CCC MSM
10386
+ * `weixin-send` 已退役。本入口保留 = 给容器外的进程一个稳定的本机发送面。
9963
10387
  * 发送与记录都经 `weixin-bridge.sendProactiveText` → 复用既有 outgoing hook(`source: 'proactive'`)。
9964
10388
  *
9965
10389
  * 契约:
@@ -10325,6 +10749,12 @@ function cleanupSessionState(sessionId) {
10325
10749
  try {
10326
10750
  forgetManualOutputSession(sessionId);
10327
10751
  } catch {}
10752
+ try {
10753
+ forgetImBridgeVisibility(sessionId);
10754
+ } catch {}
10755
+ try {
10756
+ forgetLogbookCompactionState(sessionId);
10757
+ } catch {}
10328
10758
  }
10329
10759
  /**
10330
10760
  * 装配生命周期订阅与资源拆卸。
@@ -10548,6 +10978,243 @@ async function registerWebFetchProvider(ctx) {
10548
10978
  }
10549
10979
  }
10550
10980
  //#endregion
10981
+ //#region src/im-weixin.ts
10982
+ /**
10983
+ * im-weixin.ts — 微信通道实现(`im-bridge` 的通道之一,v1.31.0)
10984
+ *
10985
+ * 能力归 ACC(S142 用户洞察):本文件吸收原 CCC MSM `.opencode/skills/home-serenity/scripts/
10986
+ * weixin-send.ts` 的 `send` / `send-file` / `users` 语义——协议、凭据、账号选择、别名解析、
10987
+ * 记录全部落在 ACC 侧;CCC 只保留「何时发 / 发什么 / 怎么写」。
10988
+ *
10989
+ * 通道内部契约:
10990
+ * - `isEnabled(root)` = 该 CCC 的 `weixin.enabled`(可见性判据,im-bridge 用它决定工具是否可见)
10991
+ * - `send` 复用 `sendProactiveText`(唯一文本发送实现 + 唯一记录点)
10992
+ * - `sendFile` 走 iLink 上传(`weixin-api.sendFileMessage`)+ 同一 outgoing hook(带 `file` 元数据)
10993
+ */
10994
+ /**
10995
+ * 别名 → CCC localstore 凭据键(与 v1.30.9 CCC MSM 逐字一致,迁移零认知成本)。
10996
+ * 别名解析属"数据填值",归 ACC(D23)。
10997
+ */
10998
+ const WEIXIN_ALIAS_MAP = {
10999
+ yh: "WEIXIN_USER_YH",
11000
+ danica: "WEIXIN_USER_DANICA",
11001
+ xiaowang: "WEIXIN_USER_DANICA"
11002
+ };
11003
+ /** 读该 CCC 的一个凭据值(空/缺失 → null) */
11004
+ function credentialValue(root, key) {
11005
+ try {
11006
+ const value = readStore(root, "credential")[key];
11007
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
11008
+ } catch {
11009
+ return null;
11010
+ }
11011
+ }
11012
+ /** 把通道层的失败码转成一句可读错误(工具面统一映射为 SEND_FAILED) */
11013
+ function channelError(code, error, remediation) {
11014
+ return /* @__PURE__ */ new Error(`${code}: ${error}${remediation ? ` — ${remediation}` : ""}`);
11015
+ }
11016
+ const weixinChannel = {
11017
+ id: "weixin",
11018
+ isEnabled(root) {
11019
+ return readWeixinSettings(root).enabled === true;
11020
+ },
11021
+ resolveUser(root, input) {
11022
+ const raw = input.trim();
11023
+ if (raw === "") return null;
11024
+ const alias = raw.toLowerCase();
11025
+ const key = WEIXIN_ALIAS_MAP[alias];
11026
+ if (key) {
11027
+ const id = credentialValue(root, key);
11028
+ return id ? {
11029
+ alias,
11030
+ id
11031
+ } : null;
11032
+ }
11033
+ if (raw.includes("@")) return {
11034
+ alias: raw,
11035
+ id: raw
11036
+ };
11037
+ return null;
11038
+ },
11039
+ listUsers(root) {
11040
+ const out = [];
11041
+ for (const [alias, key] of Object.entries(WEIXIN_ALIAS_MAP)) {
11042
+ const id = credentialValue(root, key);
11043
+ if (id) out.push({
11044
+ alias,
11045
+ id
11046
+ });
11047
+ }
11048
+ return out;
11049
+ },
11050
+ async send(input) {
11051
+ const result = await sendProactiveText({
11052
+ root: input.root,
11053
+ toUserId: input.userId,
11054
+ text: input.text,
11055
+ ...input.accountId ? { accountId: input.accountId } : {}
11056
+ });
11057
+ if (!result.ok) throw channelError(result.code, result.error, result.remediation);
11058
+ return {
11059
+ accountId: result.accountId,
11060
+ userId: result.userId,
11061
+ sessionId: result.sessionId,
11062
+ role: result.role
11063
+ };
11064
+ },
11065
+ async sendFile(input) {
11066
+ const settings = readWeixinSettings(input.root);
11067
+ if (!settings.enabled) throw channelError("BRIDGE_DISABLED", `微信桥未启用(ccc=${input.root})`);
11068
+ const account = resolveWeixinAccount(input.root, input.accountId);
11069
+ if (!account.ok) throw channelError(account.code, account.error, account.remediation);
11070
+ const sent = await sendFileMessage({
11071
+ baseUrl: account.cred.baseUrl,
11072
+ token: account.cred.token,
11073
+ toUserId: input.userId,
11074
+ data: input.data,
11075
+ fileName: input.fileName
11076
+ });
11077
+ if (input.caption) await sendTextMessage({
11078
+ baseUrl: account.cred.baseUrl,
11079
+ token: account.cred.token,
11080
+ toUserId: input.userId,
11081
+ text: input.caption
11082
+ });
11083
+ if (settings.hook) {
11084
+ const role = matchWeixinRoute(settings.routes ?? [], input.userId) ?? "";
11085
+ invokeWeixinHook(input.root, settings.hook, buildOutgoingHookEvent({
11086
+ cccRoot: input.root,
11087
+ accountId: account.accountId,
11088
+ userId: input.userId,
11089
+ sessionId: weixinSessionIdFor(input.userId),
11090
+ role,
11091
+ reply: input.caption ? `[文件] ${sent.fileName} — ${input.caption}` : `[文件] ${sent.fileName}`,
11092
+ source: "proactive",
11093
+ file: {
11094
+ name: sent.fileName,
11095
+ size: sent.size,
11096
+ ...input.caption ? { caption: input.caption } : {}
11097
+ }
11098
+ })).catch((err) => {
11099
+ console.log(`[serenity-hooks] weixin hook outgoing(file) error: ${err instanceof Error ? err.message : String(err)}`);
11100
+ });
11101
+ }
11102
+ return sent;
11103
+ },
11104
+ /**
11105
+ * 通道健康快照(`status` 动作)。
11106
+ *
11107
+ * 显式映射为 JSON 安全形状(而非把内部结构直接返回):内部类型含 `undefined`
11108
+ * 可选字段与 interface(无隐式索引签名)→ 直接返回既过不了 `ImJson` 类型门,
11109
+ * 也会让工具返回值出现不可序列化字段。缺省用条件展开去掉 undefined。
11110
+ */
11111
+ status(root) {
11112
+ const settings = readWeixinSettings(root);
11113
+ const accounts = weixinBridgeStatus().filter((entry) => entry.ccc === root).flatMap((entry) => entry.accounts.map((account) => ({
11114
+ accountId: account.accountId,
11115
+ lastPollAt: account.lastPollAt,
11116
+ ...account.lastError ? { lastError: account.lastError } : {}
11117
+ })));
11118
+ const routes = (settings.routes ?? []).map((route) => ({
11119
+ user: route.user,
11120
+ role: route.role
11121
+ }));
11122
+ return {
11123
+ enabled: settings.enabled === true,
11124
+ autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
11125
+ fallbackOnNoSend: settings.fallbackOnNoSend === true,
11126
+ routes,
11127
+ accounts
11128
+ };
11129
+ }
11130
+ };
11131
+ //#endregion
11132
+ //#region src/tools/im-bridge.ts
11133
+ /**
11134
+ * im-bridge.ts(工具面)— IM 消息发送工具(v1.31.0)
11135
+ *
11136
+ * 用户洞察(S142):微信桥是 ACC 提供的 → **发送能力也应是 ACC 的工具**;
11137
+ * 「当用户配置了微信桥则可用,不配置则不可见」。
11138
+ *
11139
+ * 本文件只做工具面三件事:① 从 agent 会话解析**本会话 CCC**(R4:不接受目标 CCC)
11140
+ * ② 把参数交给 `im-bridge.ts`(能力层)分发 ③ 渲染结果/错误。
11141
+ *
11142
+ * 可见性不在这里判定:`seams/context.ts` 在会话就绪时按 CCC 配置
11143
+ * `agent.ctx.tools.restrict({ deny: ['im-bridge'] })` 把本工具从**未配置通道**的
11144
+ * agent 工具清单中移除(未配置 = 模型看不到,而非"看得到但被拒")。
11145
+ */
11146
+ const ACTIONS = [
11147
+ "send",
11148
+ "send-file",
11149
+ "users",
11150
+ "status"
11151
+ ];
11152
+ function agentCwd(exec) {
11153
+ return exec.agent?.session?.header?.cwd ?? process.cwd();
11154
+ }
11155
+ function renderText(value) {
11156
+ return [{
11157
+ type: "text",
11158
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
11159
+ }];
11160
+ }
11161
+ /**
11162
+ * IM 发送工具(`im-bridge`)。通道维度为将来其他 IM 预留(R3)——
11163
+ * 新增通道只注册实现,不改工具名与参数形状。
11164
+ */
11165
+ function createImBridgeTool() {
11166
+ return defineTool({
11167
+ name: "im-bridge",
11168
+ description: `Send messages to family members through an IM channel configured for THIS container (currently: weixin / 微信). Every successful send is recorded in the container log automatically; this tool only acts on the current container (no target container parameter). It is hidden entirely when this container has no IM channel configured. Channels: ${imChannelIds().join(", ") || "(none registered)"}. Actions: send (text) / send-file (a file inside this container) / users (list configured recipients) / status (channel health). user accepts a configured alias (e.g. yh, danica) or a raw channel user id. Failures return a stable code plus a remediation hint.`,
11169
+ parameters: {
11170
+ channel: {
11171
+ type: "string",
11172
+ description: `IM channel id (e.g. "weixin"); available: ${imChannelIds().join(", ") || "(none)"}`,
11173
+ required: true
11174
+ },
11175
+ action: {
11176
+ type: "string",
11177
+ enum: [...ACTIONS],
11178
+ required: true,
11179
+ description: "send / send-file / users / status"
11180
+ },
11181
+ user: {
11182
+ type: "string",
11183
+ description: "Recipient: configured alias (yh, danica) or raw channel user id (send / send-file)"
11184
+ },
11185
+ text: {
11186
+ type: "string",
11187
+ description: "Message text, plain text only (send)"
11188
+ },
11189
+ file: {
11190
+ type: "string",
11191
+ description: "File path inside this container, relative to the container root (send-file)"
11192
+ },
11193
+ caption: {
11194
+ type: "string",
11195
+ description: "Optional caption sent as a separate message (send-file)"
11196
+ },
11197
+ account: {
11198
+ type: "string",
11199
+ description: "Channel account id (default: the first enabled, bound account)"
11200
+ }
11201
+ },
11202
+ output: {
11203
+ schema: { type: "json" },
11204
+ render: (_args, value) => renderText(value)
11205
+ },
11206
+ async execute(args, exec) {
11207
+ const root = findSerenityRoot(agentCwd(exec));
11208
+ if (!root) return {
11209
+ ok: false,
11210
+ code: "CCC_UNRESOLVED",
11211
+ error: "this session is not inside a Serenity container (no .serenity marker found from the session cwd)"
11212
+ };
11213
+ return runImBridge(root, args);
11214
+ }
11215
+ });
11216
+ }
11217
+ //#endregion
10551
11218
  //#region src/index.ts
10552
11219
  const name = "dsh-serenity-hooks";
10553
11220
  /** 主动调用的服务;其余(agent 事件)随 harness 装配必然存在
@@ -10597,6 +11264,7 @@ function apply(ctx, config) {
10597
11264
  const report = probeHostContract(ctx, readDshVersion());
10598
11265
  if (report.issues.length > 0) console.warn(`[serenity-hooks] ${summarizeHostContract(report)}`);
10599
11266
  } catch {}
11267
+ registerImChannel(weixinChannel);
10600
11268
  if (config.tools) {
10601
11269
  ctx.tools.register(ccFsTool);
10602
11270
  ctx.tools.register(createSessionTool(ctx));
@@ -10608,6 +11276,7 @@ function apply(ctx, config) {
10608
11276
  ctx.tools.register(localstoreTool);
10609
11277
  ctx.tools.register(containerAdminTool);
10610
11278
  ctx.tools.register(createAutopilotTool(ctx));
11279
+ ctx.tools.register(createImBridgeTool());
10611
11280
  }
10612
11281
  if (config.guards) registerGuards(ctx, { configPaths: config.serenityConfigPaths });
10613
11282
  if (config.keeper) registerKeeper(ctx, {