@shgroup/dsh-serenity-hooks 1.84.0 → 1.86.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.
package/lib/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { i as __toESM, n as __exportAll } from "./rolldown-runtime-B4iAMlE-.js";
2
- import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-4LwGLCXa.js";
2
+ import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-BI_4UVcj.js";
3
3
  import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readSafeModeHiddenTools, i as findGitRoot, l as pathInside, m as readExclusiveTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-CyYJL4F2.js";
4
- import { A as sessionEvents, B as isoLocal, C as listSessions, E as parseTrajectoryCode, F as trajectoryDescOf, H as localHuman, I as useSession, L as TRAJECTORY_FILENAME, M as setActiveSessionInfo, N as showSession, O as readTrajectoryUid, P as summarize, R as TRAJECTORY_LEGACY_FILENAMES, S as listActiveTrajectories, T as parseSessionContextFromEvents, U as localIdStamp, V as localFileStamp, _ as createSession, a as readTrajectoryConstraintAt, b as getActiveSessionInfo, c as trajectoryConstraintForSession, f as COGNITIVE_TRAJECTORY_ACTIONS, g as clearActiveSessionInfo, h as archiveSession, j as sessionsRoot, k as resolveSessionByTitle, m as UID_FILENAME, n as isTrajectoryConstrained, o as readTrajectoryConstraintByCode, r as listTrajectoryConstraints, s as replacesDefaultIdentity, t as TRAJECTORY_CONSTRAINT_FILENAME, v as extractSessionMdPathFromText, w as namingTitleFor, y as findSession, z as readTrajectoryFile } from "./profile-CiRWh2a2.js";
4
+ import { A as resolveSessionByTitle, B as readTrajectoryFile, C as listActiveTrajectories, D as parseTrajectoryCode, E as parseSessionContextFromEvents, F as summarize, H as localFileStamp, I as trajectoryDescOf, L as useSession, M as sessionsRoot, N as setActiveSessionInfo, P as showSession, R as TRAJECTORY_FILENAME, T as namingTitleFor, U as localHuman, V as isoLocal, W as localIdStamp, _ as clearActiveSessionInfo, b as findSession, c as replacesDefaultIdentity, g as archiveSession, h as UID_FILENAME, i as listTrajectoryConstraints, j as sessionEvents, k as readTrajectoryUid, l as trajectoryConstraintForSession, n as findDirNameByCode, o as readTrajectoryConstraintAt, p as COGNITIVE_TRAJECTORY_ACTIONS, r as isTrajectoryConstrained, s as readTrajectoryConstraintByCode, t as TRAJECTORY_CONSTRAINT_FILENAME, v as createSession, w as listSessions, x as getActiveSessionInfo, y as extractSessionMdPathFromText, z as TRAJECTORY_LEGACY_FILENAMES } from "./profile-vHheCJiU.js";
5
5
  import { n as checkLocalstoreGitCompliance, o as readStore, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./service-DDmHSglV.js";
6
- import { _ as resolveConstraintSystemPrompt, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as profileWriteWhitelist, h as profileToolWhitelist, i as carrierState, l as TRAJECTORY_CONSTRAINT_HINT, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND, v as ACC_VERSION, y as findPackageRoot } from "./message-source-CfqdGIi2.js";
7
- import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-BF4C3hJL.js";
6
+ import { _ as resolveConstraintSystemPrompt, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as profileWriteWhitelist, h as profileToolWhitelist, i as carrierState, l as TRAJECTORY_CONSTRAINT_HINT, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND, v as ACC_VERSION, y as findPackageRoot } from "./message-source-DRkQwfgh.js";
7
+ import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-DZXSLLEx.js";
8
8
  import { n as hasSessionLogById, r as sessionsRootDir } from "./cleanup-C-92qnDs.js";
9
- import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-Dj4_ppTC.js";
9
+ import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-H7TVDayZ.js";
10
+ import { a as readFeishuCredential, d as weixinSessionIdForTrajectory, i as matchWeixinRoute, l as sanitizeFileName$1, n as extractWeixinText, o as readWeixinCredential, r as hasVoiceItem, s as readWeixinSettings, t as extractWeixinMedia, u as weixinInboundDir } from "./route-GtYJdR06.js";
10
11
  import { REGISTERED_TOOLS } from "./invariant.js";
11
- import { a as readFeishuCredential, d as weixinSessionIdForTrajectory, i as matchWeixinRoute, l as sanitizeFileName$1, n as extractWeixinText, o as readWeixinCredential, r as hasVoiceItem, s as readWeixinSettings, t as extractWeixinMedia, u as weixinInboundDir } from "./route-Dr0SFD8A.js";
12
12
  import { c as sendTextMessage, i as getUpdates, l as sendTyping, n as downloadMedia, o as markdownToPlainText, r as getConfig, s as sendFileMessage, t as TypingStatus, u as sniffImageExt } from "./ilink-BMk2w2KY.js";
13
13
  import { a as registerImAccountImpl, i as normalizeAccountChannel, n as getImAccountImpl, r as imAccountChannels } from "./accounts-BHQbakUh.js";
14
14
  import { createRequire } from "node:module";
@@ -9346,7 +9346,8 @@ async function sendToCognitiveTrajectory(ctx, root, target, message, sender) {
9346
9346
  }],
9347
9347
  source: PLUGIN_SOURCE
9348
9348
  });
9349
- if (agent.status === "idle") {
9349
+ const freshCarrier = createdNote !== "";
9350
+ if (freshCarrier || agent.status === "idle") {
9350
9351
  try {
9351
9352
  agent.followup(make());
9352
9353
  } catch (err) {
@@ -9357,7 +9358,7 @@ async function sendToCognitiveTrajectory(ctx, root, target, message, sender) {
9357
9358
  }
9358
9359
  return {
9359
9360
  ok: true,
9360
- detail: `已即时投递 ${t.dirName}(${acquired.how};目标空闲 ⇒ followup 立即起轮)${createdNote}`
9361
+ detail: `已即时投递 ${t.dirName}(${acquired.how};${freshCarrier ? "刚建的载体 ⇒ followup 排队,锚定先于任务" : "目标空闲 ⇒ followup 立即起轮"})${createdNote}`
9361
9362
  };
9362
9363
  }
9363
9364
  try {
@@ -11047,6 +11048,152 @@ const TRAJECTORY_SELF_GUIDE = [
11047
11048
  ⚠️ 以上四项都是**读取当前真实状态**,不是"保存成功"的回执。保存成功 ≠ 生效(本仓反复踩过)。`
11048
11049
  ].join("\n");
11049
11050
  //#endregion
11051
+ //#region src/tools/im-docs.ts
11052
+ /**
11053
+ * tools/im-docs.ts — 「IM 桥怎么用、怎么配路由」的**手册正文**(2026-10-22)
11054
+ *
11055
+ * ## 为什么需要这一章(owner 原话)
11056
+ *
11057
+ * 「我在测试飞书,发现 ACC 没提供 IM 桥使用说明和账户查询方法,**导致 CCC 没办法自己配路由**,
11058
+ * 这个要完善下」+「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
11059
+ * 需要有个能力让 CCC 查询用户配好的账户」。
11060
+ *
11061
+ * ⇒ 两件事被同时指出来:**没有说明面**(只能猜配置形状)+ **没有查询面**(拿不到对方 id)。
11062
+ * 后者已由 `im-bridge action=contacts` 兑现;本章补前者。
11063
+ *
11064
+ * ## 🔴 写这一章时守的两条纪律
11065
+ *
11066
+ * 1. **只描述已经建成的东西**(D179):本章出现的每个字段、每个动作,都必须在实现里存在。
11067
+ * 写"打算做"的功能进手册 = 教用户按图施工然后把自己锁死。
11068
+ * 2. **指针从读者真正打开的那个工具发出**(D178):读这一章的人是 **CCC 的 agent**,
11069
+ * 它手边常用的入口是 `im-bridge`(工具描述已指向本章)。⛔ 不能只藏在 container_admin 里。
11070
+ */
11071
+ const IM_BRIDGE_GUIDE = `# IM 桥(im-bridge)—— 怎么用、怎么配、怎么查
11072
+
11073
+ ## 一、它是什么
11074
+
11075
+ 一个 CCC 通过 IM 渠道(微信 / 飞书)跟家人收发消息的能力。
11076
+ **只作用于本容器**——没有"目标容器"参数(跨 CCC 发送不在能力面)。
11077
+
11078
+ 工具在**未配置任何 IM 渠道时根本不可见**(不是"看得到但被拒")。
11079
+ ⇒ 你看到这个工具,说明本 CCC 已配了至少一个渠道。
11080
+
11081
+ ## 二、五个动作
11082
+
11083
+ | 动作 | 用途 | 关键参数 |
11084
+ |---|---|---|
11085
+ | \`send\` | 发一条文本 | \`user\`(对方)+ \`text\` |
11086
+ | \`send-file\` | 发本容器内的一个文件 | \`user\` + \`file\`(**CCC 根相对路径**)+ 可选 \`caption\` |
11087
+ | \`users\` | 已配的**别名**表 | — |
11088
+ | \`status\` | 渠道健康 + **账号** + **当前路由**(含每条路由的目标是否存在) | — |
11089
+ | \`contacts\` | **跟本容器有过往来的人**(含对方 id)+ 未配路由的 / 配了却没往来的 | — |
11090
+
11091
+ ⚠️ 渠道不一定支持全部动作(例如飞书目前不支持 \`send-file\`,会如实返回 \`ACTION_UNSUPPORTED\`)。
11092
+ **如实返回"不支持"比假装支持好**——后者会得到一条"发出去但对方收不到"的静默故障。
11093
+
11094
+ ## 三、🔴 配路由:先 \`contacts\`,再写,再 \`status\` 自检
11095
+
11096
+ 路由的形状(\`.opencode/serenity.json\` 的 \`im.routes\`):
11097
+
11098
+ \`\`\`json
11099
+ {
11100
+ "im": {
11101
+ "enabled": true,
11102
+ "accounts": [
11103
+ { "accountId": "wechat-1", "channel": "weixin", "enabled": true },
11104
+ { "accountId": "feishu-1", "channel": "feishu", "enabled": true }
11105
+ ],
11106
+ "routes": [
11107
+ { "user": "o9cq…@im.wechat", "trajectory": "zhaocai-xingzai" },
11108
+ { "user": "ou_xxxxxxxx", "trajectory": "llm-intel" }
11109
+ ]
11110
+ }
11111
+ }
11112
+ \`\`\`
11113
+
11114
+ - \`user\` = **对方在该渠道内的 id**(不是我们的账号,也不是别名)
11115
+ · 微信:形如 \`xxx@im.wechat\`
11116
+ · 飞书:形如 \`ou_…\`(open_id)
11117
+ - \`trajectory\` = **认知轨迹编码**(目录名里那一段,如 \`S142\` / \`llm-intel\`)
11118
+ - \`*\` 可作为 \`user\` 的**通配兜底**(exact 优先)
11119
+
11120
+ ### 那个 \`user\` id 从哪里来 —— 这是本节存在的原因
11121
+
11122
+ **你不知道对方 id,而它也不在任何配置文件里。** 它只出现在聊天记录中。
11123
+ ⇒ 用 \`im-bridge({channel:"weixin", action:"contacts"})\`,它会给出:
11124
+
11125
+ \`\`\`json
11126
+ {
11127
+ "contacts": [
11128
+ { "channel": "weixin", "userId": "o9cq…@im.wechat", "accountIds": ["wechat-1"],
11129
+ "route": null, "firstSeen": "2026-09-04T…", "lastSeen": "2026-10-22T…", "messages": 1284 }
11130
+ ],
11131
+ "unrouted": ["o9cq…@im.wechat"],
11132
+ "orphanRoutes": ["ou_typo_never_seen"],
11133
+ "sources": ["AGENT_SESSIONS/_im-logs/weixin/2026-10-22.jsonl", "…"]
11134
+ }
11135
+ \`\`\`
11136
+
11137
+ 三行输出**各有用途,缺一行这个动作就只剩"列名字"**:
11138
+
11139
+ | 字段 | 含义 | 你要做什么 |
11140
+ |---|---|---|
11141
+ | \`contacts[].userId\` | 对方 id | **复制它**填进 \`routes[].user\` |
11142
+ | \`unrouted\` | ⚠️ 有往来、但**没配路由** | 这些人的消息现在**无处可去** ⇒ 这就是该配的 |
11143
+ | \`orphanRoutes\` | ❓ 配了路由、但记录里**从没出现过** | 大概率 **id 写错**(或从未有人发过) |
11144
+ | \`sources\` | 实际读过的文件 | **否定性结论的可信度上限** = 这个列表;它空着就别下"没人"的结论 |
11145
+
11146
+ ### 写完怎么自检
11147
+
11148
+ \`im-bridge({channel:"weixin", action:"status"})\` 的 \`routes[]\` 每条都带 **\`targetExists\`**:
11149
+
11150
+ - \`true\` ⇒ 该轨迹目录真实存在,路由**有机会**生效
11151
+ - \`false\` ⇒ **轨迹编码写错了**(或那条轨迹已被归档/删除)
11152
+
11153
+ 🔴 这一格是"配了不生效"的**唯一机械信号**:写错一个编码**不会有任何报错**,
11154
+ 消息进来找不到目标就静默不投递,你只会看到"没回音"。
11155
+
11156
+ ## 四、多账号 / 多渠道
11157
+
11158
+ 一个 CCC 绑多个账号是常态(owner 2026-10-22:「一个 CCC 绑定多个账户很常见」)。
11159
+
11160
+ - **账号**在 \`im.accounts[]\`,每条自带 \`channel\`(缺省 \`weixin\`)
11161
+ - **路由是通道无关的**(\`user → trajectory\`);同一轨迹可以被多个渠道的对端指向
11162
+ - \`contacts\` 的每条带 \`accountIds\`(该对端被哪些账号收过)——**多账号靠它区分**
11163
+ - 发消息时可用 \`account\` 参数指定用哪个账号(缺省:第一个启用且已绑定的)
11164
+
11165
+ ## 五、聊天记录落在哪
11166
+
11167
+ \`\`\`
11168
+ AGENT_SESSIONS/_im-logs/<channel>/<YYYY-MM-DD>.jsonl
11169
+ \`\`\`
11170
+
11171
+ - 按**渠道**分目录(\`weixin\` / \`feishu\`),按**本地自然天**分文件,逐行 JSON 追加
11172
+ - 老路径 \`AGENT_SESSIONS/_weixin-logs/\` 是**同一族的前代**(2026-10-22 之前的微信记录);
11173
+ 读侧**两个都认**,⛔ 但**只往新路径写**
11174
+ - 每条事件带 \`channel\` 字段 ⇒ 飞书事件不会再混进微信目录(此前会,目录名会说谎)
11175
+ - 媒体的持久副本在 \`_im-logs/<channel>/media/<日期>/\`
11176
+
11177
+ ⚠️ **记录归 CCC**(\`im.hook\` 指向本容器的脚本),ACC 不认识存储格式 —— 这层分离是有意的。
11178
+
11179
+ ## 六、出错时
11180
+
11181
+ 工具返回**稳定错误码 + 可行动提示**,常见的:
11182
+
11183
+ | 码 | 意思 | 怎么改 |
11184
+ |---|---|---|
11185
+ | \`CHANNEL_NOT_CONFIGURED\` | 本 CCC 没启用该渠道 | 看提示里的**那一半**(总开关 vs 缺账号,两者改法不同) |
11186
+ | \`ALIAS_UNKNOWN\` | \`user\` 既不是已配别名、也不是合法的裸 id | 先 \`action=users\` 看别名,或用 \`contacts\` 拿裸 id |
11187
+ | \`ACTION_UNSUPPORTED\` | 该渠道没实现这个动作 | 换动作;⛔ 不要重试 |
11188
+ | \`FILE_ESCAPE\` | \`file\` 跑出了 CCC 根 | 用容器内相对路径 |
11189
+
11190
+ ## 七、边界(明确不做)
11191
+
11192
+ - ⛔ 跨 CCC 发送(只作用于本容器)
11193
+ - ⛔ 修改配置(\`contacts\` 只读;加路由要人/CCC 自己写 \`serenity.json\`)
11194
+ - ⛔ 判断"对方读没读"——记录只证明**已发出**
11195
+ `;
11196
+ //#endregion
11050
11197
  //#region src/tools/container-admin.ts
11051
11198
  /**
11052
11199
  * container-admin.ts — container_admin(v1.30:机务舱——容器管理面统一入口)
@@ -11197,6 +11344,11 @@ const DOCS_CHAPTERS = [
11197
11344
  id: "trajectory-constraint",
11198
11345
  summary: "轨迹约束(只讲限制面):trajectory.json 的双白名单 / 模型 / 纪律参与项 / IM 路由",
11199
11346
  render: () => TRAJECTORY_CONSTRAINT_GUIDE
11347
+ },
11348
+ {
11349
+ id: "im-bridge",
11350
+ summary: "IM 桥怎么用、怎么配路由、怎么查对方 id —— send/send-file/users/status/contacts 五个动作 + 路由三步法(contacts → 写 → status 自检)",
11351
+ render: () => IM_BRIDGE_GUIDE
11200
11352
  }
11201
11353
  ];
11202
11354
  /**
@@ -11207,7 +11359,7 @@ const DOCS_CHAPTERS = [
11207
11359
  function createContainerAdminTool(ctx) {
11208
11360
  return defineTool({
11209
11361
  name: "container_admin",
11210
- description: "Container administration (the ship's maintenance bay — one entry for managing the CCC): config — CCC configuration: docs (usage-manual index) / docs <chapter> (one chapter in full) / view (current values) + 轨迹约束 (guide/validate/apply/list); msm — MSM registry management (register/deregister/check quality DC-M1~M4) + manuals (guide dev manual / catalog ACC capability directory / ccc-config CCC configuration reference). TRAJECTORY SELF-DOCS: a cognitive trajectory describing ITSELF — charter (PROMPT.md) / resident declaration / self-restriction (whitelists) ⇒ config docs trajectory-self; the restriction side only ⇒ config docs trajectory-constraint. Execution of registered MSMs belongs to the msm tool; this tool manages the container.",
11362
+ description: "Container administration (the ship's maintenance bay — one entry for managing the CCC): config — CCC configuration: docs (usage-manual index) / docs <chapter> (one chapter in full) / view (current values) + 轨迹约束 (guide/validate/apply/list); msm — MSM registry management (register/deregister/check quality DC-M1~M4) + manuals (guide dev manual / catalog ACC capability directory / ccc-config CCC configuration reference). TRAJECTORY SELF-DOCS: a cognitive trajectory describing ITSELF — charter (PROMPT.md) / resident declaration / self-restriction (whitelists) ⇒ config docs trajectory-self; the restriction side only ⇒ config docs trajectory-constraint. IM BRIDGE: how to send / configure routes / look up the other party's channel user id — send / send-file / users / status / contacts + the three-step route recipe ⇒ config docs im-bridge. Execution of registered MSMs belongs to the msm tool; this tool manages the container.",
11211
11363
  parameters: {
11212
11364
  domain: {
11213
11365
  type: "string",
@@ -11320,6 +11472,138 @@ function createContainerAdminTool(ctx) {
11320
11472
  });
11321
11473
  }
11322
11474
  //#endregion
11475
+ //#region src/im/log-scan.ts
11476
+ /**
11477
+ * im/log-scan.ts — 读聊天记录落盘,汇总出「与谁有过往来」(`contacts` 的数据源)
11478
+ *
11479
+ * ## 这个文件解决什么
11480
+ *
11481
+ * owner 2026-10-22:「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
11482
+ * 需要有个能力让 CCC 查询用户配好的账户」。这里的"账户"= **通道用户**(对方是谁),
11483
+ * 不是 IM 账号(我们自己登录的身份,`status` 早就能查)。
11484
+ *
11485
+ * 那个 id(微信 `xxx@im.wechat` / 飞书 `ou_…`)不在任何配置文件里 ——
11486
+ * 它**只在聊天记录里出现过**。⇒ 本文件把记录读成联系人表。
11487
+ *
11488
+ * ## 🔴 两条硬边界
11489
+ *
11490
+ * 1. **只读**:本文件不写任何东西。真相源仍是那些 jsonl(append-only 事件流),
11491
+ * 本文件只是它的一次投影 —— ⛔ 不产生第二真相源。
11492
+ * 2. **不猜渠道**:事件的 `channel` 字段为准;缺它(存量事件)⇒ 按
11493
+ * `accountId → im.accounts[].channel` 回填,仍取不到才落 `weixin`
11494
+ * (那是该字段存在之前唯一的渠道)。⛔ **绝不按 userId 的形状猜**
11495
+ * (`ou_` 前缀 / `@im.wechat` 后缀都是命名习惯,不是判据 —— D167 认族不认名)。
11496
+ */
11497
+ /** 日志根目录族(**新旧两代并存**:`_im-logs/<channel>/` 与存量 `_weixin-logs/`) */
11498
+ const IM_LOG_DIR_NEW = "_im-logs";
11499
+ const IM_LOG_DIR_LEGACY = "_weixin-logs";
11500
+ /** 某 CCC 下**存在**的日志目录(相对根),新代在前 */
11501
+ function imLogRoots(root) {
11502
+ const out = [];
11503
+ const newBase = join(root, "AGENT_SESSIONS", IM_LOG_DIR_NEW);
11504
+ const legacy = join(root, "AGENT_SESSIONS", IM_LOG_DIR_LEGACY);
11505
+ if (existsSync(newBase)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_NEW));
11506
+ if (existsSync(legacy)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_LEGACY));
11507
+ return out;
11508
+ }
11509
+ /** 收集某个日志根下所有 `*.jsonl`(含 `_im-logs/<channel>/` 一层;按文件名排序) */
11510
+ function jsonlFilesIn(root, rel) {
11511
+ const abs = join(root, rel);
11512
+ const out = [];
11513
+ let entries = [];
11514
+ try {
11515
+ entries = readdirSync(abs, { withFileTypes: true }).map((d) => d.name);
11516
+ } catch {
11517
+ return out;
11518
+ }
11519
+ for (const name of entries.sort()) {
11520
+ if (name.startsWith(".")) continue;
11521
+ if (name.endsWith(".jsonl")) {
11522
+ out.push(join(rel, name));
11523
+ continue;
11524
+ }
11525
+ if (rel === join("AGENT_SESSIONS", "_im-logs")) {
11526
+ const sub = join(rel, name);
11527
+ try {
11528
+ for (const child of readdirSync(join(root, sub)).sort()) if (child.endsWith(".jsonl")) out.push(join(sub, child));
11529
+ } catch {}
11530
+ }
11531
+ }
11532
+ return out;
11533
+ }
11534
+ /** 从文件名 / 所在目录猜渠道(**只作为回填的最后一级**,不是判据) */
11535
+ function channelFromPath(rel) {
11536
+ const parts = rel.split("/");
11537
+ const idx = parts.indexOf(IM_LOG_DIR_NEW);
11538
+ const candidate = idx >= 0 && parts.length > idx + 2 ? parts[idx + 1] : void 0;
11539
+ return candidate !== void 0 && candidate !== "" ? candidate : null;
11540
+ }
11541
+ /**
11542
+ * 扫描本 CCC 的全部聊天记录,按 `(channel, userId)` 汇总。
11543
+ *
11544
+ * @param root CCC 根
11545
+ * @param accountChannel 存量事件缺 `channel` 时的回填表(`accountId → channel`)
11546
+ * @returns 汇总结果 + **扫描域** + 读取错误(三者都不可省)
11547
+ */
11548
+ function scanImContacts(root, accountChannel = {}) {
11549
+ const byKey = /* @__PURE__ */ new Map();
11550
+ const sources = [];
11551
+ const errors = [];
11552
+ for (const rel of imLogRoots(root)) for (const fileRel of jsonlFilesIn(root, rel)) {
11553
+ sources.push(fileRel);
11554
+ let text = "";
11555
+ try {
11556
+ text = readFileSync(join(root, fileRel), "utf8");
11557
+ } catch (err) {
11558
+ errors.push(`${fileRel}: ${err instanceof Error ? err.message : String(err)}`);
11559
+ continue;
11560
+ }
11561
+ const pathChannel = channelFromPath(fileRel);
11562
+ for (const line of text.split("\n")) {
11563
+ const trimmed = line.trim();
11564
+ if (trimmed === "") continue;
11565
+ let ev;
11566
+ try {
11567
+ ev = JSON.parse(trimmed);
11568
+ } catch {
11569
+ errors.push(`${fileRel}: 有 1 行不是合法 JSON(已跳过)`);
11570
+ continue;
11571
+ }
11572
+ const userId = typeof ev.userId === "string" ? ev.userId.trim() : "";
11573
+ if (userId === "") continue;
11574
+ const accountId = typeof ev.accountId === "string" ? ev.accountId.trim() : "";
11575
+ const channel = (typeof ev.channel === "string" && ev.channel.trim() !== "" ? ev.channel.trim() : null) ?? pathChannel ?? (accountId !== "" ? accountChannel[accountId] ?? null : null) ?? "weixin";
11576
+ const key = `${channel}\u0000${userId}`;
11577
+ let entry = byKey.get(key);
11578
+ if (!entry) {
11579
+ entry = {
11580
+ channel,
11581
+ userId,
11582
+ accountIds: [],
11583
+ roleAtEvent: null,
11584
+ firstSeen: null,
11585
+ lastSeen: null,
11586
+ messages: 0
11587
+ };
11588
+ byKey.set(key, entry);
11589
+ }
11590
+ if (accountId !== "" && !entry.accountIds.includes(accountId)) entry.accountIds.push(accountId);
11591
+ if (typeof ev.role === "string" && ev.role.trim() !== "") entry.roleAtEvent = ev.role.trim();
11592
+ const ts = typeof ev.ts === "number" && Number.isFinite(ev.ts) ? ev.ts : null;
11593
+ if (ts !== null) {
11594
+ if (entry.firstSeen === null || ts < entry.firstSeen) entry.firstSeen = ts;
11595
+ if (entry.lastSeen === null || ts > entry.lastSeen) entry.lastSeen = ts;
11596
+ }
11597
+ entry.messages += 1;
11598
+ }
11599
+ }
11600
+ return {
11601
+ contacts: [...byKey.values()].sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0)),
11602
+ sources,
11603
+ errors
11604
+ };
11605
+ }
11606
+ //#endregion
11323
11607
  //#region src/im/channels.ts
11324
11608
  /**
11325
11609
  * im/channels.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
@@ -11463,6 +11747,39 @@ async function runImBridge(root, req) {
11463
11747
  action,
11464
11748
  detail: { users: channel.listUsers(root) }
11465
11749
  };
11750
+ if (action === "contacts") {
11751
+ if (!channel.listContacts) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" has no record source for contacts`);
11752
+ const settings = readWeixinSettings(root);
11753
+ const accountChannel = {};
11754
+ for (const account of settings.accounts ?? []) if (typeof account.accountId === "string" && account.accountId !== "") accountChannel[account.accountId] = typeof account.channel === "string" && account.channel !== "" ? account.channel : "weixin";
11755
+ const scanned = scanImContacts(root, accountChannel);
11756
+ const mine = scanned.contacts.filter((c) => c.channel === channelId);
11757
+ /** 当前路由表(`user → trajectory`);同一 user 多条时后者覆盖 */
11758
+ const routeMap = /* @__PURE__ */ new Map();
11759
+ for (const route of settings.routes ?? []) if (typeof route.user === "string" && route.user !== "") routeMap.set(route.user, typeof route.trajectory === "string" ? route.trajectory : "");
11760
+ const contacts = mine.map((c) => ({
11761
+ channel: c.channel,
11762
+ userId: c.userId,
11763
+ accountIds: c.accountIds,
11764
+ route: routeMap.get(c.userId) ?? null,
11765
+ firstSeen: c.firstSeen === null ? null : new Date(c.firstSeen).toISOString(),
11766
+ lastSeen: c.lastSeen === null ? null : new Date(c.lastSeen).toISOString(),
11767
+ messages: c.messages
11768
+ }));
11769
+ const seenUsers = new Set(mine.map((c) => c.userId));
11770
+ return {
11771
+ ok: true,
11772
+ channel: channelId,
11773
+ action,
11774
+ detail: {
11775
+ contacts,
11776
+ unrouted: mine.filter((c) => !routeMap.has(c.userId)).map((c) => c.userId),
11777
+ orphanRoutes: [...routeMap.keys()].filter((user) => !seenUsers.has(user)),
11778
+ sources: scanned.sources,
11779
+ ...scanned.errors.length > 0 ? { errors: scanned.errors } : {}
11780
+ }
11781
+ };
11782
+ }
11466
11783
  if (action === "status") return {
11467
11784
  ok: true,
11468
11785
  channel: channelId,
@@ -13351,11 +13668,65 @@ function accMessage(root, configPaths, entrySkillMaxChars) {
13351
13668
  source: PLUGIN_SOURCE
13352
13669
  });
13353
13670
  }
13354
- /** 每 agent 是否已注入过(进程内存态) */
13671
+ /**
13672
+ * 每 agent 是否已注入过 —— **只作为本进程内的快路径**,⛔ 不是判据。
13673
+ *
13674
+ * 🔴🔴 2026-10-22(owner 报「send-now 创建的会话消息注入两次」的取证结论):
13675
+ * 此前**它就是判据**,于是 `pre-step` 那道门拿它回答"**这条会话**注入过没有"——
13676
+ * 而它实际回答的是"**本进程**注入过没有"。进程一换(`restart-web` / 看门狗拉起 /
13677
+ * 崩溃重启)它就清空 ⇒ 老会话被冷载入时 `pre-step` 判"没注入过" ⇒ **再注入一遍**。
13678
+ *
13679
+ * 实测铁证(同一会话两条 `[ACC] … active` 横幅,相隔 18.6 小时):
13680
+ * 只有**跨过重启**的会话有两条,没跨的只有一条 ⇒ 重复的量 = 跨了几个进程。
13681
+ *
13682
+ * ⇒ 判据改为 {@link hasAccBanner}(读**会话历史**)。本 Set 降级为"同进程免重复扫"的优化:
13683
+ * 命中它就直接返回(省一次扫),⛔ 它为空**不代表**没注入过。
13684
+ */
13355
13685
  const injected = /* @__PURE__ */ new Set();
13356
13686
  function agentKey(agent) {
13357
13687
  return agent.session.id ?? "global";
13358
13688
  }
13689
+ /**
13690
+ * ACC 身份横幅在**会话历史**里的识别特征(结构判据,不依赖措辞顺序)。
13691
+ *
13692
+ * 🔴 为什么认 `source.kind` + 正文前缀两件:
13693
+ * - 只认正文 ⇒ 将来文案一改就静默失效(D151 同族:判据不能长在措辞上);
13694
+ * - 只认 `source.kind` ⇒ 本插件注入的**所有**消息都算(`skill-catalog` 块、假首轮横幅…)
13695
+ * ⇒ 会把"别的注入出现过"误当成"身份横幅出现过",导致**永不再注入**。
13696
+ * ⇒ 两者与:由本插件发出 **且** 正文以 ACC 横幅首词开头。
13697
+ */
13698
+ const ACC_BANNER_PREFIX = "[ACC] Serenity cognitive container active";
13699
+ /**
13700
+ * 这条会话的历史里**是否已经有** ACC 身份横幅(= 判据的唯一权威)。
13701
+ *
13702
+ * 🔴 这是本条修复的核心:**"注入过没有"是会话的属性,不是进程的属性。**
13703
+ * 会话历史随会话走(落盘、重启后仍在),进程内状态随进程死。
13704
+ *
13705
+ * ⚠️ 读不到 events(受限宿主 / 投影未就绪)⇒ 返回 `false`(退回"注入一次")。
13706
+ * 这是**偏保守**的选择:多注入一条横幅(可见、可清理)远好于
13707
+ * **永远不再注入**(陈旧会话里 ACC 身份彻底缺失,且零信号)。
13708
+ *
13709
+ * @param session 目标会话(`sessionEvents` 能读的形状)
13710
+ */
13711
+ function hasAccBanner(session) {
13712
+ return sessionEvents(session).some((event) => {
13713
+ const e = event;
13714
+ if (e.type !== "user/message") return false;
13715
+ if (e.data?.source?.kind !== PLUGIN_SOURCE.kind) return false;
13716
+ return textOfUserMessage(e.data)?.trimStart().startsWith(ACC_BANNER_PREFIX) === true;
13717
+ });
13718
+ }
13719
+ /** 从 `user/message` 的 content 块数组里拼出纯文本(无 text 块 ⇒ undefined) */
13720
+ function textOfUserMessage(data) {
13721
+ const content = data?.content;
13722
+ if (!Array.isArray(content)) return void 0;
13723
+ const parts = [];
13724
+ for (const block of content) {
13725
+ const b = block;
13726
+ if (b?.type === "text" && typeof b.text === "string") parts.push(b.text);
13727
+ }
13728
+ return parts.length === 0 ? void 0 : parts.join("");
13729
+ }
13359
13730
  function agentScope(agent) {
13360
13731
  return agent.session.id ?? "default";
13361
13732
  }
@@ -13455,7 +13826,10 @@ function registerContext(ctx, opts = {}) {
13455
13826
  }
13456
13827
  }
13457
13828
  } catch {}
13458
- if (injected.has(key)) return;
13829
+ if (injected.has(key) || hasAccBanner(agent.session)) {
13830
+ injected.add(key);
13831
+ return;
13832
+ }
13459
13833
  injected.add(key);
13460
13834
  agent.inject(accMessage(root, configPaths, entrySkillMaxChars));
13461
13835
  syncSafeModeRestriction(agent, root);
@@ -13511,7 +13885,11 @@ function registerContext(ctx, opts = {}) {
13511
13885
  registerEntrySkillSection(agent, root);
13512
13886
  registerCognitiveTrajectorySkillSection(agent, root);
13513
13887
  }
13514
- if (!root || injected.has(key) || downstream.kind !== "enter") return downstream;
13888
+ if (!root || downstream.kind !== "enter") return downstream;
13889
+ if (injected.has(key) || hasAccBanner(agent.session)) {
13890
+ injected.add(key);
13891
+ return downstream;
13892
+ }
13515
13893
  injected.add(key);
13516
13894
  return {
13517
13895
  kind: "enter",
@@ -14100,11 +14478,11 @@ function registerStatusApi(ctx, opts = {}) {
14100
14478
  sendJson$2(res, 400, { error: chk.error });
14101
14479
  return;
14102
14480
  }
14103
- const { readWeixinSettings } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14481
+ const { readWeixinSettings } = await import("./route-GtYJdR06.js").then((n) => n.c);
14104
14482
  const { weixinBridgeStatus } = await Promise.resolve().then(() => bridge_exports);
14105
14483
  const settings = readWeixinSettings(chk.root);
14106
14484
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
14107
- const { imCredentialBound } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14485
+ const { imCredentialBound } = await import("./route-GtYJdR06.js").then((n) => n.c);
14108
14486
  const { normalizeAccountChannel } = await import("./accounts-BHQbakUh.js").then((n) => n.t);
14109
14487
  const accounts = (settings.accounts ?? []).map((a) => {
14110
14488
  const channel = normalizeAccountChannel(a.channel);
@@ -14141,7 +14519,7 @@ function registerStatusApi(ctx, opts = {}) {
14141
14519
  const { fetchQRCode } = await import("./ilink-BMk2w2KY.js").then((n) => n.a);
14142
14520
  const { randomUUID } = await import("node:crypto");
14143
14521
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
14144
- const { readWeixinSettings } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14522
+ const { readWeixinSettings } = await import("./route-GtYJdR06.js").then((n) => n.c);
14145
14523
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
14146
14524
  const loginKey = randomUUID();
14147
14525
  weixinLogins.set(loginKey, {
@@ -14162,7 +14540,7 @@ function registerStatusApi(ctx, opts = {}) {
14162
14540
  sendJson$2(res, 400, { error: "missing accountId" });
14163
14541
  return;
14164
14542
  }
14165
- const { removeWeixinAccount } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14543
+ const { removeWeixinAccount } = await import("./route-GtYJdR06.js").then((n) => n.c);
14166
14544
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14167
14545
  removeWeixinAccount(root, body.accountId);
14168
14546
  syncCccBridge(ctx, root);
@@ -14188,8 +14566,8 @@ function registerStatusApi(ctx, opts = {}) {
14188
14566
  trajectory: code
14189
14567
  });
14190
14568
  }
14191
- const { saveWeixinRoutes } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14192
- const { findDirNameByCode } = await import("./profile-CiRWh2a2.js").then((n) => n.i);
14569
+ const { saveWeixinRoutes } = await import("./route-GtYJdR06.js").then((n) => n.c);
14570
+ const { findDirNameByCode } = await import("./profile-vHheCJiU.js").then((n) => n.a);
14193
14571
  for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
14194
14572
  sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
14195
14573
  return;
@@ -14200,7 +14578,7 @@ function registerStatusApi(ctx, opts = {}) {
14200
14578
  }
14201
14579
  if (body.action === "set-enabled") {
14202
14580
  const enabled = body.enabled === true;
14203
- const { readWeixinSettings, setWeixinEnabled } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14581
+ const { readWeixinSettings, setWeixinEnabled } = await import("./route-GtYJdR06.js").then((n) => n.c);
14204
14582
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14205
14583
  const settings = readWeixinSettings(root);
14206
14584
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -14223,7 +14601,7 @@ function registerStatusApi(ctx, opts = {}) {
14223
14601
  sendJson$2(res, 400, { error: "appId 与 appSecret 都必须填(只填一个等于没绑)" });
14224
14602
  return;
14225
14603
  }
14226
- const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14604
+ const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-GtYJdR06.js").then((n) => n.c);
14227
14605
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14228
14606
  const before = readWeixinSettings(root);
14229
14607
  const accountId = (body.accountId ?? "").trim() !== "" ? body.accountId.trim() : nextFeishuAccountId(before);
@@ -14273,7 +14651,7 @@ function registerStatusApi(ctx, opts = {}) {
14273
14651
  return;
14274
14652
  }
14275
14653
  const { pollQRStatus } = await import("./ilink-BMk2w2KY.js").then((n) => n.a);
14276
- const { readWeixinSettings } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14654
+ const { readWeixinSettings } = await import("./route-GtYJdR06.js").then((n) => n.c);
14277
14655
  const settings = readWeixinSettings(login.root);
14278
14656
  const status = await pollQRStatus({
14279
14657
  baseUrl: void 0,
@@ -14288,7 +14666,7 @@ function registerStatusApi(ctx, opts = {}) {
14288
14666
  });
14289
14667
  return;
14290
14668
  }
14291
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14669
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-GtYJdR06.js").then((n) => n.c);
14292
14670
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
14293
14671
  const accountId = nextWeixinAccountId(settings);
14294
14672
  upsertWeixinAccount(login.root, {
@@ -18824,12 +19202,15 @@ function appendHumanChannelAudit(event) {
18824
19202
  const WEIXIN_HOOK_TIMEOUT_MS = 15e3;
18825
19203
  /** hook stderr/stdout 日志截断(防脚本刷屏) */
18826
19204
  const HOOK_LOG_MAX = 500;
19205
+ /** 存量调用点未传 `channel` 时的缺省值(本字段存在之前唯一的渠道) */
19206
+ const DEFAULT_HOOK_CHANNEL = "weixin";
18827
19207
  /** 构造 incoming 事件对象(纯函数,可测) */
18828
19208
  function buildIncomingHookEvent(input) {
18829
19209
  return {
18830
19210
  event: "incoming",
18831
19211
  ts: Date.now(),
18832
19212
  cccRoot: input.cccRoot,
19213
+ channel: input.channel ?? DEFAULT_HOOK_CHANNEL,
18833
19214
  accountId: input.accountId,
18834
19215
  userId: input.userId,
18835
19216
  sessionId: input.sessionId,
@@ -18846,6 +19227,7 @@ function buildOutgoingHookEvent(input) {
18846
19227
  event: "outgoing",
18847
19228
  ts: Date.now(),
18848
19229
  cccRoot: input.cccRoot,
19230
+ channel: input.channel ?? DEFAULT_HOOK_CHANNEL,
18849
19231
  accountId: input.accountId,
18850
19232
  userId: input.userId,
18851
19233
  sessionId: input.sessionId,
@@ -19228,7 +19610,7 @@ const MEDIA_MAX_BYTES = 20971520;
19228
19610
  */
19229
19611
  async function resolveCccRootByAlias(ctx, alias) {
19230
19612
  try {
19231
- const { listCccs } = await import("./ccc-roots-4LwGLCXa.js").then((n) => n.a);
19613
+ const { listCccs } = await import("./ccc-roots-BI_4UVcj.js").then((n) => n.a);
19232
19614
  const entries = await listCccs(ctx);
19233
19615
  const byName = entries.find((e) => e.name === alias);
19234
19616
  if (byName) return byName.root;
@@ -19296,7 +19678,7 @@ async function resolveInboundTarget(ctx, input) {
19296
19678
  });
19297
19679
  return null;
19298
19680
  }
19299
- const { findDirNameByCode } = await import("./profile-CiRWh2a2.js").then((n) => n.i);
19681
+ const { findDirNameByCode } = await import("./profile-vHheCJiU.js").then((n) => n.a);
19300
19682
  if (findDirNameByCode(root, target.role) === null) {
19301
19683
  const detail = `目标 CCC "${target.ccc}" 内没有编码为 "${target.role}" 的认知轨迹`;
19302
19684
  console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表里的目标编码`);
@@ -19525,6 +19907,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19525
19907
  const hookRel = settings.hook;
19526
19908
  if (hookRel) invokeWeixinHook(root, hookRel, buildIncomingHookEvent({
19527
19909
  cccRoot: root,
19910
+ channel,
19528
19911
  accountId,
19529
19912
  userId: fromUserId,
19530
19913
  sessionId,
@@ -19551,6 +19934,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19551
19934
  console.warn(`[serenity-hooks] ⚠ im 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, trajectory=${trajectory})`);
19552
19935
  if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
19553
19936
  cccRoot: root,
19937
+ channel,
19554
19938
  accountId,
19555
19939
  userId: fromUserId,
19556
19940
  sessionId,
@@ -19571,6 +19955,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19571
19955
  });
19572
19956
  if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
19573
19957
  cccRoot: root,
19958
+ channel,
19574
19959
  accountId,
19575
19960
  userId: fromUserId,
19576
19961
  sessionId,
@@ -20246,7 +20631,7 @@ function syncHumanChannel(ctx) {
20246
20631
  (async () => {
20247
20632
  try {
20248
20633
  stopMachineLoops();
20249
- const { listCccs } = await import("./ccc-roots-4LwGLCXa.js").then((n) => n.a);
20634
+ const { listCccs } = await import("./ccc-roots-BI_4UVcj.js").then((n) => n.a);
20250
20635
  for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
20251
20636
  } catch {}
20252
20637
  })();
@@ -20343,6 +20728,7 @@ async function sendProactiveText(input) {
20343
20728
  }
20344
20729
  if (settings.hook) invokeWeixinHook(root, settings.hook, buildOutgoingHookEvent({
20345
20730
  cccRoot: root,
20731
+ channel: "weixin",
20346
20732
  accountId,
20347
20733
  userId: toUserId,
20348
20734
  sessionId,
@@ -20537,7 +20923,7 @@ function matchCcc(input, candidates) {
20537
20923
  }
20538
20924
  /** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
20539
20925
  async function collectCandidates(ctx) {
20540
- const { listCccs } = await import("./ccc-roots-4LwGLCXa.js").then((n) => n.a);
20926
+ const { listCccs } = await import("./ccc-roots-BI_4UVcj.js").then((n) => n.a);
20541
20927
  const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
20542
20928
  const seen = /* @__PURE__ */ new Set();
20543
20929
  const out = [];
@@ -21752,6 +22138,24 @@ function credentialValue$1(root, key) {
21752
22138
  function channelError$1(code, error, remediation) {
21753
22139
  return /* @__PURE__ */ new Error(`${code}: ${error}${remediation ? ` — ${remediation}` : ""}`);
21754
22140
  }
22141
+ /**
22142
+ * 路由目标轨迹**是否存在**(`status` 的自检列)。
22143
+ *
22144
+ * 🔴 复用既有判据 `findDirNameByCode`(它扫活跃区并按编码解析)—— ⛔ 不在这里另写一套目录扫描,
22145
+ * 两份判据会漂(一处认归档、另一处不认,就会出现"这里说有、投递时说没有")。
22146
+ *
22147
+ * ⚠️ 解析失败(`findDirNameByCode` 在**重码**时会抛)⇒ 返回 `false` 但**不吞掉原因**:
22148
+ * 重码本身也是"这个路由不可信"的一种形态,`status` 里会表现为 `targetExists:false`,
22149
+ * 配合 `cognitive_trajectory list` 可定位。不额外造一个错误码(键名已经够直白)。
22150
+ */
22151
+ function trajectoryDirExists(root, code) {
22152
+ if (typeof code !== "string" || code.trim() === "") return false;
22153
+ try {
22154
+ return findDirNameByCode(root, code.trim()) !== null;
22155
+ } catch {
22156
+ return false;
22157
+ }
22158
+ }
21755
22159
  const weixinChannel = {
21756
22160
  id: "weixin",
21757
22161
  isEnabled(root) {
@@ -21830,6 +22234,7 @@ const weixinChannel = {
21830
22234
  const trajectory = matchWeixinRoute(settings.routes ?? [], input.userId) ?? "";
21831
22235
  invokeWeixinHook(input.root, settings.hook, buildOutgoingHookEvent({
21832
22236
  cccRoot: input.root,
22237
+ channel: "weixin",
21833
22238
  accountId: account.accountId,
21834
22239
  userId: input.userId,
21835
22240
  sessionId: weixinSessionIdForTrajectory(trajectory !== "" ? trajectory : sanitizeFileName$1(input.userId) || "unknown"),
@@ -21861,10 +22266,14 @@ const weixinChannel = {
21861
22266
  lastPollAt: account.lastPollAt,
21862
22267
  ...account.lastError ? { lastError: account.lastError } : {}
21863
22268
  })));
21864
- const routes = (settings.routes ?? []).map((route) => ({
21865
- user: route.user,
21866
- role: route.trajectory
21867
- }));
22269
+ const routes = (settings.routes ?? []).map((route) => {
22270
+ const code = route.trajectory;
22271
+ return {
22272
+ user: route.user,
22273
+ role: code,
22274
+ targetExists: trajectoryDirExists(root, code)
22275
+ };
22276
+ });
21868
22277
  return {
21869
22278
  enabled: settings.enabled === true,
21870
22279
  autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
@@ -21905,12 +22314,17 @@ const weixinChannel = {
21905
22314
  * ⇒ 校验**值的形状**(飞书 open_id 以 `ou_` 开头):错配在**解析处**就失败,
21906
22315
  * 错误码是 `ALIAS_UNKNOWN` + 可行动提示。
21907
22316
  *
21908
- * ## 🔴 为什么不 fire 记录 hook
22317
+ * ## 🔴 记录 hook:曾经不 fire,2026-10-22 起 fire(A83 已裁)
21909
22318
  *
21910
- * `settings.hook` 的契约与消费方都是**微信的**:CCC 的 `scripts/weixin-message-hook.ts`
21911
- * 把事件追加进 `AGENT_SESSIONS/_weixin-logs/<日期>.jsonl`。飞书事件若流进去,
21912
- * 那个目录就**同时装两种渠道的数据而名字只说一种**(D151/D179 同族:名字开始撒谎)。
21913
- * ⇒ 本期**不落记录**,并把它登记为待裁(A83)。工具描述里按渠道写清这件事。
22319
+ * **当时的理由**:`settings.hook` 的契约与消费方都是**微信的**,CCC 的
22320
+ * `scripts/weixin-message-hook.ts` 把事件追加进 `AGENT_SESSIONS/_weixin-logs/<日期>.jsonl`。
22321
+ * 飞书事件若流进去,那个目录就**同时装两种渠道的数据而名字只说一种**(D151/D179 同族)。
22322
+ *
22323
+ * **为什么现在可以 fire**:同批给 hook 事件加了 **`channel` 字段**(owner 2026-10-22:
22324
+ * 「一个 CCC 绑定多个账户很常见,聊天记录落盘要完善」)⇒ CCC 侧 hook 按渠道分流到
22325
+ * `_im-logs/<channel>/`,**目录名不再需要撒谎**。
22326
+ * ⇒ 所以这不是"决定记录",而是"**让记录不再有害**之后才记录"。
22327
+ * ⚠️ 顺序有意义:**先有渠道字段,再有记录**。反过来做就会先造出一批分不清渠道的数据。
21914
22328
  */
21915
22329
  /**
21916
22330
  * 飞书 open_id 的形状前缀(官方形态:`ou_` + 一串)。
@@ -22065,6 +22479,18 @@ const feishuChannel = {
22065
22479
  const settings = readWeixinSettings(input.root);
22066
22480
  const trajectory = matchWeixinRoute(settings.routes ?? [], input.userId) ?? "";
22067
22481
  const sessionId = weixinSessionIdForTrajectory(trajectory !== "" ? trajectory : sanitizeFileName$1(input.userId) || "unknown");
22482
+ if (settings.hook) invokeWeixinHook(input.root, settings.hook, buildOutgoingHookEvent({
22483
+ cccRoot: input.root,
22484
+ channel: "feishu",
22485
+ accountId: account.accountId,
22486
+ userId: input.userId,
22487
+ sessionId,
22488
+ role: trajectory,
22489
+ reply: markdownToPlainText(input.text),
22490
+ source: "proactive"
22491
+ })).catch((err) => {
22492
+ console.log(`[serenity-hooks] im hook outgoing(feishu) error: ${err instanceof Error ? err.message : String(err)}`);
22493
+ });
22068
22494
  return {
22069
22495
  accountId: account.accountId,
22070
22496
  userId: input.userId,
@@ -22118,7 +22544,8 @@ const ACTIONS = [
22118
22544
  "send",
22119
22545
  "send-file",
22120
22546
  "users",
22121
- "status"
22547
+ "status",
22548
+ "contacts"
22122
22549
  ];
22123
22550
  function renderText(value) {
22124
22551
  return [{
@@ -22133,7 +22560,7 @@ function renderText(value) {
22133
22560
  function createImBridgeTool() {
22134
22561
  return defineTool({
22135
22562
  name: "im-bridge",
22136
- description: `Send messages to family members through an IM channel configured for THIS container. 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). Not every channel supports every action (e.g. send-file may return ACTION_UNSUPPORTED). user accepts a configured alias (e.g. user-a, user-b) or a raw channel user id. Failures return a stable code plus a remediation hint.`,
22563
+ description: `Send messages to family members through an IM channel configured for THIS container. 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 (configured aliases) / status (channel health, accounts + current routes) / contacts (people this container has exchanged messages with — use this to find the \`user\` id when adding a route, since that id exists only in the chat logs). Not every channel supports every action (e.g. send-file may return ACTION_UNSUPPORTED). user accepts a configured alias or a raw channel user id. Docs: container_admin config docs im-bridge (how to add a route, where the other party's id comes from). Failures return a stable code plus a remediation hint.`,
22137
22564
  parameters: {
22138
22565
  channel: {
22139
22566
  type: "string",
@@ -22144,7 +22571,7 @@ function createImBridgeTool() {
22144
22571
  type: "string",
22145
22572
  enum: [...ACTIONS],
22146
22573
  required: true,
22147
- description: "send / send-file / users / status"
22574
+ description: "send / send-file / users / status / contacts"
22148
22575
  },
22149
22576
  user: {
22150
22577
  type: "string",