@shgroup/dsh-serenity-hooks 1.90.0 → 1.91.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
@@ -4,17 +4,17 @@ import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as
4
4
  import { A as namingTitleFor, B as summarize, C as createSession, E as getActiveSessionInfo, F as resolveSessionByTitle, G as readTrajectoryFile, H as useSession, I as sessionEvents, J as localHuman, K as isoLocal, L as sessionsRoot, M as parseTrajectoryCode, O as listActiveTrajectories, P as readTrajectoryUid, R as setActiveSessionInfo, S as clearActiveSessionInfo, T as findSession, U as TRAJECTORY_FILENAME, V as trajectoryDescOf, W as TRAJECTORY_LEGACY_FILENAMES, Y as localIdStamp, b as UID_FILENAME, c as replacesDefaultIdentity, i as listTrajectoryConstraints, j as parseSessionContextFromEvents, k as listSessions, l as trajectoryCodeForSession, n as findDirNameByCode, o as readTrajectoryConstraintAt, q as localFileStamp, r as isTrajectoryConstrained, s as readTrajectoryConstraintByCode, t as TRAJECTORY_CONSTRAINT_FILENAME, u as trajectoryConstraintForSession, v as COGNITIVE_TRAJECTORY_ACTIONS, w as extractSessionMdPathFromText, x as archiveSession, z as showSession } from "./profile-zxakCNXy.js";
5
5
  import { n as checkLocalstoreGitCompliance, o as readStore, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./service-DDmHSglV.js";
6
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-BAMwXu6i.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-Bb4GSSFJ.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-D5cEPEUY.js";
8
8
  import { i as sessionsRootDir, n as findSessionLog, r as hasSessionLogById } from "./cleanup-CPl1SSaK.js";
9
- import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-DAorSseV.js";
10
- import { a as readFeishuCredential, c as readWeixinSettings, d as weixinInboundDir, f as weixinSessionIdForTrajectory, i as matchWeixinRoute, n as extractWeixinText, o as readImSettings, r as hasVoiceItem, s as readWeixinCredential, t as extractWeixinMedia, u as sanitizeFileName$1 } from "./route-3OzOAjec.js";
9
+ import { i as runImBridge, n as isImWiredTrajectory, o as readSimpleSettings, r as registerImChannel, s as registerSettingsSection, t as imChannelIds } from "./channels-CuCsUfcY.js";
10
+ import { a as readFeishuCredential, c as readWeixinSettings, d as weixinInboundDir, f as weixinSessionIdForTrajectory, i as matchWeixinRoute, n as extractWeixinText, r as hasVoiceItem, s as readWeixinCredential, t as extractWeixinMedia, u as sanitizeFileName$1 } from "./route-DxIKWeN9.js";
11
11
  import { REGISTERED_TOOLS } from "./invariant.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-CsZ-f64q.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";
15
15
  import z from "@deepseek-ai/schemastery";
16
16
  import { defineTool } from "@deepseek-ai/dsh-tools";
17
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
17
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
18
18
  import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
19
19
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
20
20
  import { homedir, hostname, platform } from "node:os";
@@ -1373,36 +1373,30 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1373
1373
  历史(v1.30.9~v1.30.x):CCC 侧 MSM weixin-send(走 loopback HTTP 入口 3082)——
1374
1374
  v1.31.0 已退役(脚本删除 + 注册表注销);3082 入口保留给**非 agent 调用者**。
1375
1375
 
1376
- ▸ weixin.autoReplyWithLastMessage(v1.30.10,关闭桥的自动输出):
1377
- 缺省 true = 现状:桥在 agent 一轮结束后把**最终 assistant 文本**自动回发用户(source="reply")。
1378
- 显式 false = 关闭:桥**不再转发最终文本**,输出权交给 agent——每轮在用户消息**末尾**追加
1379
- **机制标记** [serenity:weixin-manual-output] + 一行已填好参数的 im-bridge 调用
1380
- (账号/用户 id),agent 自己决定发什么、发几条(全部记 source="proactive")。
1381
- **ACC 只给机制与数据,纪律措辞归 CCC**(v1.30.16):怎么写/必须怎么做/后果是什么写在
1382
- CCC 角色提示词文件里(如 .opencode/skiff/<role>.md),可热改(v1.30.15 起角色提示词热重载)。
1383
- 机械闸门(v1.30.16):本轮 turn 结束仍未成功调用 im-bridge(send / send-file;兼容旧
1384
- msm("weixin-send") 形态)→ agent 被打回(≤2 次,提示 = 标记 + 事实);达上限放弃并响亮告警。
1385
- 失败调用不算已送达。
1386
- 语义边界(用户拍板):只关最终文本;系统类消息(新对话通知 / 语音无法解析提示)保留;
1387
- typing 指示与 incoming hook 不受影响。
1388
-
1389
- ▸ weixin.fallbackOnNoSend(v1.30.17,手动模式的**兜底**;缺省 false):
1390
- 仅在 autoReplyWithLastMessage=false 时生效。true → 一轮结束时若 agent **一次都没成功**
1391
- 调用输出工具(闸门打回用尽),桥把该轮最终文本转发给用户(记录 source="reply-fallback",
1392
- 日志留响亮告警)——保证"不丢消息"。agent 自己发过 → 不兜底(输出权仍在 agent)。
1393
- 为什么需要(实证):v1.30.16 闸门会打回 ≤2 次,但实测某模型在**寒暄类消息**上连续 4 轮、
1394
- 跨 3 版 CCC 提示词仍不调工具 → 打回用尽后用户什么都收不到;提示词与闸门均已排除。
1395
- 代价(显式):角色失去"故意静默"能力(它总会产出一段最终文本)→ 需严格静默的角色保持缺省。
1396
- 三态:① autoReplyWithLastMessage=true(缺省)桥总是回发;② =false + fallbackOnNoSend=true
1397
- = agent 优先、桥兜底(推荐);③ 两者皆 false(缺省)= 严格静默(v1.30.10 语义)。
1398
- ⚠️ 兜底只在**闸门装配成功**时生效(未装配则无法判定是否发送过 → 不冒重复发送风险)。
1376
+ ▸ 🔴🔴 **出站只有一条路:agent 自己调 im-bridge**(2026-10-24 起,owner 令)。
1377
+ 桥**永不**替你回发最终文本 —— 你说什么、说几条、什么时候说、要不要不说,全由你决定。
1378
+ ⛔ 不要指望"我把话写进回答里就会自动发出去":**不会**。必须真的调工具。
1379
+ ⇒ 每轮用户消息末尾都会附一行**已填好参数**的 im-bridge 调用模板(机制标记
1380
+ [serenity:weixin-manual-output]),照它改 text 即可。
1381
+ ⇒ 本轮结束仍未成功调用(send / send-file)→ 你会被打回(≤2 次,提示 = 标记 + 事实);
1382
+ 达上限后**用户将收不到任何消息**(没有任何兜底转发)。
1383
+ ⇒ 成功调用才算送达;失败调用不算(不会被当成本轮已发送)。
1384
+ ⚠️ **前提**:本轨迹必须在 \`.opencode/serenity.json\` 的 \`im.routes\` 里被点名,否则 \`im-bridge\`
1385
+ 对你不**可见**——那种情况下闸门不会打回你(改成一条响亮告警,指明"该轨迹无发送权"),
1386
+ 因为打回一个你根本满足不了的要求只会空转。
1387
+
1388
+ ▸ **已退役(不要再配,配了也不生效)**:
1389
+ · \`weixin.autoReplyWithLastMessage\`(v1.30.10~2026-10-24)= 桥替 agent 回发最终文本。
1390
+ **被否理由**(owner 原话):「autoReply 并不合适,主动性的 imBridge 仍然是更好的」——
1391
+ 它让 agent 失去"说几条/何时说/要不要不说"的决定权,而那正是主动发送的价值。
1392
+ · \`weixin.fallbackOnNoSend\`(v1.30.17~2026-10-24)= 手动模式下 agent 没发成功时桥兜底转发。
1393
+ 它只在上面那个开关 =false 时才有意义 ⇒ 一起退役。
1394
+ ⚠️ 旧配置里残留这两个键**静默忽略**(不会报错,也不会生效)。
1399
1395
 
1400
1396
  Config:
1401
1397
  { "weixin": {
1402
1398
  "enabled": true,
1403
1399
  "hook": "scripts/weixin-message-hook.ts",
1404
- "autoReplyWithLastMessage": true,
1405
- "fallbackOnNoSend": false,
1406
1400
  "accounts": [{ "accountId": "wechat-1", "name": "家庭助手", "enabled": true }],
1407
1401
  "routes": [{ "user": "*", "role": "my-role" }]
1408
1402
  } }
@@ -1414,9 +1408,9 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1414
1408
  触发(双向):
1415
1409
  incoming = 用户 → bot:路由命中后、媒体落盘后触发(文本含语音转写;媒体带落盘 relPath)
1416
1410
  outgoing = bot → 用户:发送成功后触发(reply = 用户实际收到的纯文本,已剥离 think)
1417
- · source="reply"(缺省)= 回复用户消息;source="proactive"(v1.30.9)= bot 主动发起
1418
- (CCC 经 im-bridge 工具(v1.31.0;旧 weixin-send MSM 已退役)→ sendProactiveText);
1419
- source="reply-fallback"(v1.30.17)= 手动模式下 agent 本轮未发送 → 桥兜底转发最终文本
1411
+ · source="proactive" = agent 经 im-bridge 主动发起(**2026-10-24 起这是唯一值**)
1412
+ · 历史值 reply / reply-fallback(桥自动回发 / 桥兜底转发)已随两个开关一起退役;
1413
+ 存量日志里仍能看到它们,那是历史事实,不是当前行为
1420
1414
  · file(仅 send-file 补记):{ "name": "报告.pdf", "size": 12345, "caption": "可选" }
1421
1415
 
1422
1416
  脚本约定(事件 JSON 单行经 stdin 传入;bun 优先 node 兜底):
@@ -1431,7 +1425,7 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1431
1425
  "message": { "text": "你好", "media": [{ "kind": "image", "relPath": "_tmp/weixin-inbound/<hash>/img_x.jpg" }] } }
1432
1426
  { "event": "outgoing", "ts": ..., "cccRoot": ..., "accountId": ...,
1433
1427
  "userId": ..., "sessionId": ..., "role": ...,
1434
- "reply": "已记录(纯文本)", "source": "reply" | "proactive" | "reply-fallback" }
1428
+ "reply": "已记录(纯文本)", "source": "proactive" }
1435
1429
 
1436
1430
  示例脚本(追加到按日文件——持久化归 CCC 自选:文件/DB/远端均可):
1437
1431
  const fs = require('node:fs'); const p = '/path/ccc/AGENT_SESSIONS/_weixin-log.jsonl';
@@ -1541,6 +1535,51 @@ function hasCliEntry(absPath) {
1541
1535
  return /\b(?:async\s+)?function\s+main\s*\(/.test(text);
1542
1536
  }
1543
1537
  /**
1538
+ * **把 issue 列表压成「按检查码分组的一行摘要」**(D237:成功/失败类调用的返回精简)。
1539
+ *
1540
+ * ## 为什么需要它(owner 2026-10-24 令)
1541
+ *
1542
+ * > 「tool 的返回内容会影响 Agent 注意力……对于返回成功失败类的调用,如果成功要大幅精简;
1543
+ * > 对于其它的调用,简练其结构,在确保信息有效的情况下降低大小」
1544
+ *
1545
+ * 实测症状(本仓真读数):`container_admin msm check` 返回 **36 条 issue,每条一个三字段对象**,
1546
+ * 而其中 **M1 有 20 条、detail 逐字相同**(`no .test.ts / .spec.ts`)、**M3 有 16 条**(同样同 detail)。
1547
+ * ⇒ 同一句话重复 20 遍**不携带额外信息**,只吃掉注意力。
1548
+ *
1549
+ * ## 判据(照 D237 分档)
1550
+ *
1551
+ * - **有 issue(失败面)⇒ 不省略任何一条**:失败条目是唯一能改变下一步的东西(D186「出声必须指名哪一半」)。
1552
+ * 但可以**折叠结构**:同 `(check, detail)` 的条目合并成「一组 + 名字列表」,逐条信息一个字不丢。
1553
+ * - **零 issue(成功面)⇒ 只留一行结论 + 计数**:通过的条目对下一步零信息量。
1554
+ *
1555
+ * @param report 原始报告(逐条 issue)
1556
+ * @returns 压缩后的**面向 agent 的**结构(键名短、重复消除;entries 全保留只做分组)
1557
+ */
1558
+ function compactRegistryQuality(report) {
1559
+ const byKey = /* @__PURE__ */ new Map();
1560
+ for (const it of report.issues) {
1561
+ const key = `${it.check}\u0000${it.detail}`;
1562
+ const hit = byKey.get(key);
1563
+ if (hit) hit.names.push(it.name);
1564
+ else byKey.set(key, {
1565
+ check: it.check,
1566
+ detail: it.detail,
1567
+ names: [it.name]
1568
+ });
1569
+ }
1570
+ const groups = [...byKey.values()].sort((a, b) => a.check === b.check ? b.names.length - a.names.length : a.check < b.check ? -1 : 1).map((g) => ({
1571
+ check: g.check,
1572
+ detail: g.detail,
1573
+ count: g.names.length,
1574
+ names: g.names
1575
+ }));
1576
+ return {
1577
+ checked: report.checked,
1578
+ issueCount: report.issues.length,
1579
+ groups
1580
+ };
1581
+ }
1582
+ /**
1544
1583
  * **注册表「质量契约」判据(DC-M1~M4)** —— 具名出口(C5 观察面归一,2026-09-15)。
1545
1584
  *
1546
1585
  * 此前这段判据**只存在于 `runMsm` 的 `case 'check'` 分支体内**(没有名字、无法从别处取用),
@@ -1748,7 +1787,7 @@ function runMsm(root, args) {
1748
1787
  throw new Error(`MSM not registered: "${name}"`);
1749
1788
  }
1750
1789
  case "ccc-config": return docsPointer("config", "CCC 配置参考");
1751
- case "check": return checkRegistryQuality(root);
1790
+ case "check": return compactRegistryQuality(checkRegistryQuality(root));
1752
1791
  default: throw new Error(`Unknown action: ${args.action}`);
1753
1792
  }
1754
1793
  }
@@ -1938,20 +1977,68 @@ function buildMsmIndex(root) {
1938
1977
  bySkill.set(k, (bySkill.get(k) ?? 0) + 1);
1939
1978
  }
1940
1979
  const groups = [...bySkill.entries()].sort((a, b) => b[1] - a[1]).map(([skill, count]) => ` ${skill}: ${count} MSM`).join("\n");
1941
- return `${entries.length} MSMs registered (by skill):\n${groups}\n\nExecute: msm("<name>", ["<args>"]) — e.g. msm("ssh-connect", ["status"])\nInspect: msm("<name>", [], inspect=true) — view usage/flags\nPartial name returns matching candidates.\nFull listing: use container_admin (register/deregister/check).`;
1980
+ return `${entries.length} MSMs registered (by skill):\n${groups}\n\nExecute: msm("<name>", ["<args>"]) — e.g. msm("ssh-connect", ["status"])\nInspect: msm("<name>", [], inspect=true) — view usage/flags\nPartial name returns matching candidates.\nRegistry maintenance: container_admin (domain=msm, action=register|deregister|check).`;
1942
1981
  }
1943
1982
  /** 模糊候选:name/description 子串匹配(未精确命中时返回 top-K,供 LLM 下轮选对) */
1944
1983
  function suggestMsm(root, query, limit = 5) {
1945
1984
  const q = query.toLowerCase();
1946
- const hits = loadMsmEntries(root).filter((e) => e.name.toLowerCase().includes(q) || (e.description ?? "").toLowerCase().includes(q)).slice(0, limit);
1947
- if (hits.length === 0) return `No MSM matches "${query}". Registered: ${buildMsmIndex(root)}`;
1985
+ const entries = loadMsmEntries(root);
1986
+ const hits = entries.filter((e) => e.name.toLowerCase().includes(q) || (e.description ?? "").toLowerCase().includes(q)).slice(0, limit);
1987
+ if (hits.length === 0) {
1988
+ const skills = new Set(entries.map((e) => e.skill ?? "uncategorized"));
1989
+ return `No MSM matches "${query}" (${entries.length} registered across ${skills.size} skills).\nTry a shorter fragment, e.g. msm("${query.slice(0, 3)}"), or msm() for counts by skill.`;
1990
+ }
1948
1991
  const lines = hits.map((e) => {
1949
- const base = `${e.name} | ${e.skill ?? "-"} | ${e.category ?? "-"} | ${e.description ?? ""}`;
1992
+ const base = `${e.name} | ${e.skill ?? "-"} | ${e.category ?? "-"} | ${shortDesc(e.description)}`;
1950
1993
  const flags = (e.flags ?? []).map((f) => `--${f.name} <${f.type ?? "string"}>`).join(", ");
1951
1994
  return flags ? `${base} [flags: ${flags}]` : base;
1952
1995
  });
1953
1996
  return `No exact MSM "${query}" — did you mean one of these?\n` + lines.join("\n");
1954
1997
  }
1998
+ /**
1999
+ * 候选行的**描述截断**(D237:其它调用的"简练结构"那一档)。
2000
+ *
2001
+ * 判据 = **「帮不帮得了下决定」**:选哪个 MSM 靠前半句的分句就够,后面那些
2002
+ * 「子命令清单 / 设计取舍 / 历史沿革」属于**选中之后**才需要的细节(`inspect=true` 给全)。
2003
+ * 实测:一条候选描述可达 200+ 字,5 条候选就能吃掉大半屏。
2004
+ *
2005
+ * ⚠️ 截断点选**分号/句号/破折号**这类天然断句符,不做"每 N 字硬切"——硬切会把词切两半
2006
+ * (中文尤其明显),反而降低可读性。
2007
+ *
2008
+ * 🔴 **实现更正(自查抓到)**:初版先 `slice(0, max)` 再在里面找断句符 ⇒ 当第一个断句符
2009
+ * 出现在很靠前的位置时(如「短说明;后面全是细节」),它落在 `max/3` 之外就被放弃了,
2010
+ * 于是退化成硬切 —— **正好违反"在天然断句符处收尾"这条本意**。
2011
+ * ⇒ 正确做法:**先找 max 之后最近的那个断句符**,让它成为截断点(宁可略超 max,
2012
+ * 也要停在句读上);只有在整串都没有断句符时才硬切。
2013
+ */
2014
+ function shortDesc(desc, max = 64) {
2015
+ const s = (desc ?? "").trim();
2016
+ if (s === "") return "";
2017
+ if (s.length <= max) return s;
2018
+ const BREAKS = [
2019
+ ";",
2020
+ ";",
2021
+ "。",
2022
+ "——",
2023
+ "·",
2024
+ ",",
2025
+ ","
2026
+ ];
2027
+ const windowEnd = Math.min(s.length, max * 2);
2028
+ let best = -1;
2029
+ for (const brk of BREAKS) {
2030
+ const at = s.indexOf(brk, max);
2031
+ if (at !== -1 && at < windowEnd && (best === -1 || at < best)) best = at;
2032
+ }
2033
+ if (best !== -1) return s.slice(0, best).trimEnd() + "…";
2034
+ let back = -1;
2035
+ for (const brk of BREAKS) {
2036
+ const at = s.lastIndexOf(brk, max);
2037
+ if (at > back) back = at;
2038
+ }
2039
+ if (back >= Math.floor(max / 4)) return s.slice(0, back).trimEnd() + "…";
2040
+ return s.slice(0, max).trimEnd() + "…";
2041
+ }
1955
2042
  const msmTool = defineTool({
1956
2043
  name: "msm",
1957
2044
  description: "Execute a registered CCC MSM (Mech & Semi-Mech). msm(name, args) runs it directly; name not found returns matching candidates; inspect=true shows usage/flags without running; no args returns a summary index. The full CCC MSM list is discoverable via partial-name matching or the index.",
@@ -12255,460 +12342,6 @@ function createContainerAdminTool(ctx) {
12255
12342
  });
12256
12343
  }
12257
12344
  //#endregion
12258
- //#region src/im/log-scan.ts
12259
- /**
12260
- * im/log-scan.ts — 读聊天记录落盘,汇总出「与谁有过往来」(`contacts` 的数据源)
12261
- *
12262
- * ## 这个文件解决什么
12263
- *
12264
- * owner 2026-10-22:「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
12265
- * 需要有个能力让 CCC 查询用户配好的账户」。这里的"账户"= **通道用户**(对方是谁),
12266
- * 不是 IM 账号(我们自己登录的身份,`status` 早就能查)。
12267
- *
12268
- * 那个 id(微信 `xxx@im.wechat` / 飞书 `ou_…`)不在任何配置文件里 ——
12269
- * 它**只在聊天记录里出现过**。⇒ 本文件把记录读成联系人表。
12270
- *
12271
- * ## 🔴 两条硬边界
12272
- *
12273
- * 1. **只读**:本文件不写任何东西。真相源仍是那些 jsonl(append-only 事件流),
12274
- * 本文件只是它的一次投影 —— ⛔ 不产生第二真相源。
12275
- * 2. **不猜渠道**:事件的 `channel` 字段为准;缺它(存量事件)⇒ 按
12276
- * `accountId → im.accounts[].channel` 回填,仍取不到才落 `weixin`
12277
- * (那是该字段存在之前唯一的渠道)。⛔ **绝不按 userId 的形状猜**
12278
- * (`ou_` 前缀 / `@im.wechat` 后缀都是命名习惯,不是判据 —— D167 认族不认名)。
12279
- */
12280
- /** 日志根目录族(**新旧两代并存**:`_im-logs/<channel>/` 与存量 `_weixin-logs/`) */
12281
- const IM_LOG_DIR_NEW = "_im-logs";
12282
- const IM_LOG_DIR_LEGACY = "_weixin-logs";
12283
- /** 某 CCC 下**存在**的日志目录(相对根),新代在前 */
12284
- function imLogRoots(root) {
12285
- const out = [];
12286
- const newBase = join(root, "AGENT_SESSIONS", IM_LOG_DIR_NEW);
12287
- const legacy = join(root, "AGENT_SESSIONS", IM_LOG_DIR_LEGACY);
12288
- if (existsSync(newBase)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_NEW));
12289
- if (existsSync(legacy)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_LEGACY));
12290
- return out;
12291
- }
12292
- /** 收集某个日志根下所有 `*.jsonl`(含 `_im-logs/<channel>/` 一层;按文件名排序) */
12293
- function jsonlFilesIn(root, rel) {
12294
- const abs = join(root, rel);
12295
- const out = [];
12296
- let entries = [];
12297
- try {
12298
- entries = readdirSync(abs, { withFileTypes: true }).map((d) => d.name);
12299
- } catch {
12300
- return out;
12301
- }
12302
- for (const name of entries.sort()) {
12303
- if (name.startsWith(".")) continue;
12304
- if (name.endsWith(".jsonl")) {
12305
- out.push(join(rel, name));
12306
- continue;
12307
- }
12308
- if (rel === join("AGENT_SESSIONS", "_im-logs")) {
12309
- const sub = join(rel, name);
12310
- try {
12311
- for (const child of readdirSync(join(root, sub)).sort()) if (child.endsWith(".jsonl")) out.push(join(sub, child));
12312
- } catch {}
12313
- }
12314
- }
12315
- return out;
12316
- }
12317
- /** 从文件名 / 所在目录猜渠道(**只作为回填的最后一级**,不是判据) */
12318
- function channelFromPath(rel) {
12319
- const parts = rel.split("/");
12320
- const idx = parts.indexOf(IM_LOG_DIR_NEW);
12321
- const candidate = idx >= 0 && parts.length > idx + 2 ? parts[idx + 1] : void 0;
12322
- return candidate !== void 0 && candidate !== "" ? candidate : null;
12323
- }
12324
- /**
12325
- * 扫描本 CCC 的全部聊天记录,按 `(channel, userId)` 汇总。
12326
- *
12327
- * @param root CCC 根
12328
- * @param accountChannel 存量事件缺 `channel` 时的回填表(`accountId → channel`)
12329
- * @returns 汇总结果 + **扫描域** + 读取错误(三者都不可省)
12330
- */
12331
- function scanImContacts(root, accountChannel = {}) {
12332
- const byKey = /* @__PURE__ */ new Map();
12333
- const sources = [];
12334
- const errors = [];
12335
- for (const rel of imLogRoots(root)) for (const fileRel of jsonlFilesIn(root, rel)) {
12336
- sources.push(fileRel);
12337
- let text = "";
12338
- try {
12339
- text = readFileSync(join(root, fileRel), "utf8");
12340
- } catch (err) {
12341
- errors.push(`${fileRel}: ${err instanceof Error ? err.message : String(err)}`);
12342
- continue;
12343
- }
12344
- const pathChannel = channelFromPath(fileRel);
12345
- for (const line of text.split("\n")) {
12346
- const trimmed = line.trim();
12347
- if (trimmed === "") continue;
12348
- let ev;
12349
- try {
12350
- ev = JSON.parse(trimmed);
12351
- } catch {
12352
- errors.push(`${fileRel}: 有 1 行不是合法 JSON(已跳过)`);
12353
- continue;
12354
- }
12355
- const userId = typeof ev.userId === "string" ? ev.userId.trim() : "";
12356
- if (userId === "") continue;
12357
- const accountId = typeof ev.accountId === "string" ? ev.accountId.trim() : "";
12358
- const channel = (typeof ev.channel === "string" && ev.channel.trim() !== "" ? ev.channel.trim() : null) ?? pathChannel ?? (accountId !== "" ? accountChannel[accountId] ?? null : null) ?? "weixin";
12359
- const key = `${channel}\u0000${userId}`;
12360
- let entry = byKey.get(key);
12361
- if (!entry) {
12362
- entry = {
12363
- channel,
12364
- userId,
12365
- accountIds: [],
12366
- roleAtEvent: null,
12367
- firstSeen: null,
12368
- lastSeen: null,
12369
- messages: 0
12370
- };
12371
- byKey.set(key, entry);
12372
- }
12373
- if (accountId !== "" && !entry.accountIds.includes(accountId)) entry.accountIds.push(accountId);
12374
- if (typeof ev.role === "string" && ev.role.trim() !== "") entry.roleAtEvent = ev.role.trim();
12375
- const ts = typeof ev.ts === "number" && Number.isFinite(ev.ts) ? ev.ts : null;
12376
- if (ts !== null) {
12377
- if (entry.firstSeen === null || ts < entry.firstSeen) entry.firstSeen = ts;
12378
- if (entry.lastSeen === null || ts > entry.lastSeen) entry.lastSeen = ts;
12379
- }
12380
- entry.messages += 1;
12381
- }
12382
- }
12383
- return {
12384
- contacts: [...byKey.values()].sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0)),
12385
- sources,
12386
- errors
12387
- };
12388
- }
12389
- //#endregion
12390
- //#region src/im/channels.ts
12391
- /**
12392
- * im/channels.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
12393
- *
12394
- * 用户原话:「既然微信桥是我们 ACC 提供的,那么 weixin-send 应该是我们 ACC 提供的能力,
12395
- * 当用户配置了微信桥则可用,不配置则不可见」+「设定一个复杂的多层工具,叫 im-bridge,
12396
- * 里面有微信,然后保留后续可能兼容别的 im 的空间」。
12397
- *
12398
- * 分层(E↑):
12399
- * `tools/im/channels.ts`(工具面:参数 → 结果渲染)
12400
- * → **本模块**(通道注册表 + 可见性判据 + 动作分发 + 错误码翻译)
12401
- * → 通道实现(`im/weixin-channel.ts`:协议/凭据/账号/记录)
12402
- *
12403
- * 边界(R↓):
12404
- * - **本模块零宿主依赖**(不 import cordis/agent)——可见性判定与分发都是纯逻辑,可独立单测。
12405
- * - **通道只提供机制**:本模块不认识"微信";新增 IM 只需注册一个通道实现(R3 家族式扩展)。
12406
- * - **只能操作本会话 CCC**(R4):`root` 由调用方(工具面)从 agent cwd 解析后传入,
12407
- * 本模块不接受"目标 CCC"参数——跨 CCC 发送不在能力面。
12408
- * - **记录归通道**(R5):通道实现负责让每次发送都落既有 hook。
12409
- */
12410
- /** 文本长度上限(微信侧单条实践上限;超限拒绝而非静默截断——与 HTTP 入口同值) */
12411
- const IM_SEND_MAX_TEXT = 4e3;
12412
- /** 文件大小上限(20MB,与媒体接收上限一致) */
12413
- const IM_FILE_MAX_BYTES = 20971520;
12414
- const channels = /* @__PURE__ */ new Map();
12415
- /** 注册通道(index.ts apply 调用;重复 id 覆盖——测试与热重载友好) */
12416
- function registerImChannel(channel) {
12417
- channels.set(channel.id, channel);
12418
- }
12419
- /** 已注册通道 id(工具 `channel` 参数枚举) */
12420
- function imChannelIds() {
12421
- return [...channels.keys()];
12422
- }
12423
- /**
12424
- * 可见性判据:本 CCC 是否**有任何可用的 IM 账号**。
12425
- * 未启用 → 工具面对该 agent **不可见**(`tools.restrict` 移除,而非调用时报错)。
12426
- *
12427
- * ## 🔴 2026-10-22 判据修正(owner 令:「把开关升级为有任意账号即可,而不是盯着微信」)
12428
- *
12429
- * **旧判据** = 遍历已注册通道、任一 `channel.isEnabled(root)` 为真即可见。
12430
- * **旧判据的缺陷**(实测于一个只配飞书的 CCC):`feishuChannel.isEnabled()` 首行是
12431
- * `if (settings.enabled !== true) return false` —— 总开关一关,**后面的账号判断根本不执行**
12432
- * ⇒ 账号明明配了、`enabled:true`,却因为一个**渠道无关的总开关**被永久判成"未启用"
12433
- * ⇒ `im-bridge` 在模型清单里彻底消失,而配置面看上去是对的(`accounts[]` 里躺着一条 feishu)。
12434
- * 这正是 D179 的形态:**配置说它有,行为说它没有**,且零报错。
12435
- *
12436
- * **新判据** = **账号即事实**:`readImSettings(root).accounts` 里存在
12437
- * 「`enabled !== false` 的账号」即视为可见。理由三条:
12438
- * ① **可见性问的是"这个 CCC 有没有 IM 能力"**,而能力的载体是**账号**,不是总开关;
12439
- * ② 总开关的语义是"**要不要让桥跑起来**",把它当成可见性上限,会让"配好了但暂时不想让它跑"
12440
- * 这种合法状态变成"工具凭空消失";
12441
- * ③ 判**按账号判**后,微信与飞书不再各有一套"总开关 + 账号"的双重判据 ⇒ 消除同族漂移。
12442
- *
12443
- * ⚠️ **不放宽的部分**:没有账号 ⇒ 仍然不可见(`accounts` 为空即 false)。
12444
- * ⚠️ 单个通道自己的 `isEnabled` 仍是**发送/启停**判据(那是通道自己的事),
12445
- * 只是**不再兼任**工具可见性判据 —— 两件事被这次修正分开了。
12446
- *
12447
- * @param root 本会话 CCC 根(由调用方解析)
12448
- */
12449
- function hasEnabledImChannel(root) {
12450
- let settings;
12451
- try {
12452
- settings = readImSettings(root);
12453
- } catch {
12454
- return false;
12455
- }
12456
- return (settings.accounts ?? []).some((a) => a.enabled !== false);
12457
- }
12458
- /**
12459
- * 可见性判据(**轨迹级**,2026-10-23 owner 令):
12460
- * 这个 dsh 会话所属的认知轨迹,是否**被 IM 路由表点名**(= 它才是那个"接线人")。
12461
- *
12462
- * ## 它解决的问题(owner 原话的**去标识改写**,原文含私有角色名,按 D145 不入仓库)
12463
- *
12464
- * > 「很多轨迹发消息给我,但是我没空回,它们就会等待我,其实并不是,我希望他们继续工作;
12465
- * > 所以只有 IM 桥绑定了任意账户的认知轨迹才拥有 `im-bridge` 这个 tool,
12466
- * > 这样可以确保只有一个接线角色和我联系,其它轨迹和它联系即可,它会自行回复它们的」
12467
- *
12468
- * ⇒ 旧判据是 **CCC 级**({@link hasEnabledImChannel}:本 CCC 有任一启用账号 ⇒ 全体可见)。
12469
- * 它的后果不是"少一个工具",而是**一条错误的工作流**:每条轨迹都以为自己该直接对 owner 说话,
12470
- * 于是发完就**停下来等人回**——而 owner 根本没空逐条回。**工具在,就把等待也带上了。**
12471
- *
12472
- * ## 判据(两条,缺一不可)
12473
- *
12474
- * 1. 本 CCC 有可用账号({@link hasEnabledImChannel})—— 没有账号时路由表也没有意义;
12475
- * 2. 本会话**绑定的轨迹编码**出现在 `routes[].trajectory` 里。
12476
- *
12477
- * ⚠️ **为什么不看 `routes[].user`**:那是"对端是谁"(owner 的通道 id),
12478
- * 判"这条轨迹能不能说话"只跟**轨迹**有关,与哪个对端无关。
12479
- *
12480
- * ⚠️ **`*` 通配也算点名**:`{ user:"*", trajectory:"<某轨迹>" }` 这条路由的含义就是
12481
- * "**没人认领的消息都归那条轨迹**" ⇒ 它确实是一条被授权的接线轨迹,必须算可见。
12482
- * 这与 `matchWeixinRoute` 的通配兜底语义**同源**,不另立规则。
12483
- *
12484
- * ## 拿不到轨迹编码时怎么办(fail-closed,与 `exclusive` 同向)
12485
- *
12486
- * 会话 → 轨迹编码要读绑定。**取不到就判不可见**:
12487
- * - 与 `exclusive`(未声明 ⇒ 隐藏)同向;
12488
- * - 反向(取不到就放行)会让"绑定坏了"表现成"工具还在",
12489
- * 而那正是本判据要防的形态(D179:配置说它有、行为说它没有,且零报错)。
12490
- * ⚠️ 代价明示:绑定读取若短暂失败,该轮会少一个工具;下一轮绑定恢复即回。
12491
- * 这比"误给一条轨迹发消息的权限"便宜 —— 后者会让它**停下来等 owner**。
12492
- *
12493
- * @param root CCC 根
12494
- * @param trajectoryCode 本会话所属认知轨迹的编码(**由调用方解析**,见 `trajectory/policy/profile.ts`)
12495
- * @returns true = 该轨迹接线,`im-bridge` 可见
12496
- */
12497
- function isImWiredTrajectory(root, trajectoryCode) {
12498
- if (typeof trajectoryCode !== "string" || trajectoryCode.trim() === "") return false;
12499
- if (!hasEnabledImChannel(root)) return false;
12500
- let settings;
12501
- try {
12502
- settings = readImSettings(root);
12503
- } catch {
12504
- return false;
12505
- }
12506
- const wanted = trajectoryCode.trim();
12507
- for (const route of settings.routes ?? []) {
12508
- const target = typeof route.trajectory === "string" ? route.trajectory.trim() : "";
12509
- if (target !== "" && target === wanted) return true;
12510
- }
12511
- return false;
12512
- }
12513
- /**
12514
- * 错误码 → 可行动提示(模型视角:说清怎么改)。
12515
- *
12516
- * 🔴 `CHANNEL_NOT_CONFIGURED` 的提示**不能写死某一个渠道的字段名**(2026-10-20 Q4 真机验收踩到):
12517
- * 首版逐字写「enable it in … (weixin.enabled)」,而接第二个渠道后,**飞书能不能用不取决于
12518
- * 这个开关**(它取决于有没有一条启用中的飞书账号)⇒ 按那句提示去改 `weixin.enabled`
12519
- * **不会生效**,而错误码又指向"没配置" ⇒ 用户据此改完还是同一句报错。
12520
- * ⇒ 提示按**渠道是否带自己的启用判据**分岔:带 `channelHint` 的就用它自己的说法。
12521
- */
12522
- function remediationFor(code, channelHint) {
12523
- switch (code) {
12524
- case "CHANNEL_UNKNOWN": return "Use a registered channel id (see the tool description)";
12525
- case "CHANNEL_NOT_CONFIGURED": return channelHint ?? "This CCC has not configured that channel — check .opencode/serenity.json (the channel's own enabled flag / accounts) or the settings panel";
12526
- case "ACTION_UNKNOWN": return "Use send / send-file / users / status";
12527
- case "ACTION_UNSUPPORTED": return "This channel does not support that action";
12528
- case "ALIAS_UNKNOWN": return "Use a configured alias (see action=users) or a raw channel user id";
12529
- case "TEXT_REQUIRED": return "Provide text";
12530
- case "TEXT_TOO_LONG": return `Shorten the text to <= ${IM_SEND_MAX_TEXT} characters`;
12531
- case "USER_REQUIRED": return "Provide user";
12532
- case "FILE_REQUIRED": return "Provide file (a path inside this CCC)";
12533
- case "FILE_ESCAPE": return "Use a path inside this CCC";
12534
- case "FILE_NOT_FOUND": return "Check the path exists inside this CCC";
12535
- case "FILE_TOO_LARGE": return `Use a file <= ${Math.round(IM_FILE_MAX_BYTES / 1048576)} MB`;
12536
- default: return;
12537
- }
12538
- }
12539
- function fail(code, error, remediation) {
12540
- return {
12541
- ok: false,
12542
- code,
12543
- error,
12544
- ...remediation ?? remediationFor(code) ? { remediation: remediation ?? remediationFor(code) } : {}
12545
- };
12546
- }
12547
- /** CCC 内文件读取(路径逃逸/缺失/超限一律拒绝,返回稳定码) */
12548
- function readCccFile(root, relOrAbs) {
12549
- const abs = isAbsolute(relOrAbs) ? resolve(relOrAbs) : resolve(root, relOrAbs);
12550
- const rel = relative(resolve(root), abs);
12551
- if (rel.startsWith("..") || isAbsolute(rel)) return {
12552
- ok: false,
12553
- code: "FILE_ESCAPE",
12554
- error: `file escapes the CCC root: ${relOrAbs}`
12555
- };
12556
- if (!existsSync(abs)) return {
12557
- ok: false,
12558
- code: "FILE_NOT_FOUND",
12559
- error: `file not found: ${relOrAbs}`
12560
- };
12561
- let size = 0;
12562
- try {
12563
- size = statSync(abs).size;
12564
- } catch (err) {
12565
- return {
12566
- ok: false,
12567
- code: "FILE_NOT_FOUND",
12568
- error: `cannot stat file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
12569
- };
12570
- }
12571
- if (size > 20971520) return {
12572
- ok: false,
12573
- code: "FILE_TOO_LARGE",
12574
- error: `file is ${size} bytes (> ${IM_FILE_MAX_BYTES})`
12575
- };
12576
- try {
12577
- return {
12578
- ok: true,
12579
- data: readFileSync(abs),
12580
- fileName: abs.split("/").pop() ?? "file"
12581
- };
12582
- } catch (err) {
12583
- return {
12584
- ok: false,
12585
- code: "FILE_NOT_FOUND",
12586
- error: `cannot read file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
12587
- };
12588
- }
12589
- }
12590
- /**
12591
- * 执行一次 IM 动作(工具面唯一入口)。
12592
- *
12593
- * 顺序:通道解析 → 动作校验 → 用户解析(别名 → id)→ 输入校验 → 调用通道实现。
12594
- * 任何一步失败都返回**稳定错误码 + 可行动提示**(不抛错——工具面统一渲染)。
12595
- *
12596
- * @param root 本会话 CCC 根(调用方从 agent cwd 解析;本函数不接受目标 CCC)
12597
- * @param req 工具参数
12598
- */
12599
- async function runImBridge(root, req) {
12600
- const channelId = (req.channel ?? "").trim();
12601
- const action = (req.action ?? "").trim();
12602
- if (channelId === "") return fail("CHANNEL_REQUIRED", "channel is required", "Provide channel (e.g. \"weixin\")");
12603
- const channel = channels.get(channelId);
12604
- if (!channel) return fail("CHANNEL_UNKNOWN", `unknown channel "${channelId}" (registered: ${imChannelIds().join(", ") || "(none)"})`);
12605
- if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})`, channel.notEnabledReason?.(root));
12606
- if (action === "users") return {
12607
- ok: true,
12608
- channel: channelId,
12609
- action,
12610
- detail: { users: channel.listUsers(root) }
12611
- };
12612
- if (action === "contacts") {
12613
- if (!channel.listContacts) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" has no record source for contacts`);
12614
- const settings = readWeixinSettings(root);
12615
- const accountChannel = {};
12616
- 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";
12617
- const scanned = scanImContacts(root, accountChannel);
12618
- const mine = scanned.contacts.filter((c) => c.channel === channelId);
12619
- /** 当前路由表(`user → trajectory`);同一 user 多条时后者覆盖 */
12620
- const routeMap = /* @__PURE__ */ new Map();
12621
- for (const route of settings.routes ?? []) if (typeof route.user === "string" && route.user !== "") routeMap.set(route.user, typeof route.trajectory === "string" ? route.trajectory : "");
12622
- const contacts = mine.map((c) => ({
12623
- channel: c.channel,
12624
- userId: c.userId,
12625
- accountIds: c.accountIds,
12626
- route: routeMap.get(c.userId) ?? null,
12627
- firstSeen: c.firstSeen === null ? null : new Date(c.firstSeen).toISOString(),
12628
- lastSeen: c.lastSeen === null ? null : new Date(c.lastSeen).toISOString(),
12629
- messages: c.messages
12630
- }));
12631
- const seenUsers = new Set(mine.map((c) => c.userId));
12632
- return {
12633
- ok: true,
12634
- channel: channelId,
12635
- action,
12636
- detail: {
12637
- contacts,
12638
- unrouted: mine.filter((c) => !routeMap.has(c.userId)).map((c) => c.userId),
12639
- orphanRoutes: [...routeMap.keys()].filter((user) => !seenUsers.has(user)),
12640
- sources: scanned.sources,
12641
- ...scanned.errors.length > 0 ? { errors: scanned.errors } : {}
12642
- }
12643
- };
12644
- }
12645
- if (action === "status") return {
12646
- ok: true,
12647
- channel: channelId,
12648
- action,
12649
- detail: channel.status(root)
12650
- };
12651
- if (action === "send") {
12652
- const rawUser = (req.user ?? "").trim();
12653
- if (rawUser === "") return fail("USER_REQUIRED", "user is required");
12654
- const text = req.text ?? "";
12655
- if (text === "") return fail("TEXT_REQUIRED", "text is required");
12656
- if (text.length > 4e3) return fail("TEXT_TOO_LONG", `text is ${text.length} characters (> ${IM_SEND_MAX_TEXT})`);
12657
- const resolved = channel.resolveUser(root, rawUser);
12658
- if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
12659
- try {
12660
- return {
12661
- ok: true,
12662
- channel: channelId,
12663
- action,
12664
- detail: {
12665
- ...await channel.send({
12666
- root,
12667
- userId: resolved.id,
12668
- text,
12669
- accountId: req.account
12670
- }),
12671
- user: resolved.alias ?? resolved.id
12672
- }
12673
- };
12674
- } catch (err) {
12675
- return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
12676
- }
12677
- }
12678
- if (action === "send-file") {
12679
- if (!channel.sendFile) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" does not support send-file`);
12680
- const rawUser = (req.user ?? "").trim();
12681
- if (rawUser === "") return fail("USER_REQUIRED", "user is required");
12682
- const fileArg = (req.file ?? "").trim();
12683
- if (fileArg === "") return fail("FILE_REQUIRED", "file is required");
12684
- const resolved = channel.resolveUser(root, rawUser);
12685
- if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
12686
- const file = readCccFile(root, fileArg);
12687
- if (!file.ok) return fail(file.code, file.error);
12688
- try {
12689
- return {
12690
- ok: true,
12691
- channel: channelId,
12692
- action,
12693
- detail: {
12694
- ...await channel.sendFile({
12695
- root,
12696
- userId: resolved.id,
12697
- data: file.data,
12698
- fileName: file.fileName,
12699
- caption: req.caption,
12700
- accountId: req.account
12701
- }),
12702
- user: resolved.alias ?? resolved.id
12703
- }
12704
- };
12705
- } catch (err) {
12706
- return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
12707
- }
12708
- }
12709
- return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
12710
- }
12711
- //#endregion
12712
12345
  //#region src/seams/tool-visibility.ts
12713
12346
  /** ACC 自己注册的工具名集合(退化档只发这些 —— 它们一定在册) */
12714
12347
  const REGISTERED_TOOLS_SET = new Set(REGISTERED_TOOLS);
@@ -15144,11 +14777,11 @@ function registerStatusApi(ctx, opts = {}) {
15144
14777
  sendJson$2(res, 400, { error: chk.error });
15145
14778
  return;
15146
14779
  }
15147
- const { readWeixinSettings } = await import("./route-3OzOAjec.js").then((n) => n.l);
14780
+ const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15148
14781
  const { weixinBridgeStatus } = await Promise.resolve().then(() => bridge_exports);
15149
14782
  const settings = readWeixinSettings(chk.root);
15150
14783
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
15151
- const { imCredentialBound } = await import("./route-3OzOAjec.js").then((n) => n.l);
14784
+ const { imCredentialBound } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15152
14785
  const { normalizeAccountChannel } = await import("./accounts-BHQbakUh.js").then((n) => n.t);
15153
14786
  const accounts = (settings.accounts ?? []).map((a) => {
15154
14787
  const channel = normalizeAccountChannel(a.channel);
@@ -15185,7 +14818,7 @@ function registerStatusApi(ctx, opts = {}) {
15185
14818
  const { fetchQRCode } = await import("./ilink-CsZ-f64q.js").then((n) => n.a);
15186
14819
  const { randomUUID } = await import("node:crypto");
15187
14820
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
15188
- const { readWeixinSettings } = await import("./route-3OzOAjec.js").then((n) => n.l);
14821
+ const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15189
14822
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
15190
14823
  const loginKey = randomUUID();
15191
14824
  weixinLogins.set(loginKey, {
@@ -15206,7 +14839,7 @@ function registerStatusApi(ctx, opts = {}) {
15206
14839
  sendJson$2(res, 400, { error: "missing accountId" });
15207
14840
  return;
15208
14841
  }
15209
- const { removeWeixinAccount } = await import("./route-3OzOAjec.js").then((n) => n.l);
14842
+ const { removeWeixinAccount } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15210
14843
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
15211
14844
  removeWeixinAccount(root, body.accountId);
15212
14845
  syncCccBridge(ctx, root);
@@ -15232,7 +14865,7 @@ function registerStatusApi(ctx, opts = {}) {
15232
14865
  trajectory: code
15233
14866
  });
15234
14867
  }
15235
- const { saveWeixinRoutes } = await import("./route-3OzOAjec.js").then((n) => n.l);
14868
+ const { saveWeixinRoutes } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15236
14869
  const { findDirNameByCode } = await import("./profile-zxakCNXy.js").then((n) => n.a);
15237
14870
  for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
15238
14871
  sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
@@ -15244,7 +14877,7 @@ function registerStatusApi(ctx, opts = {}) {
15244
14877
  }
15245
14878
  if (body.action === "set-enabled") {
15246
14879
  const enabled = body.enabled === true;
15247
- const { readWeixinSettings, setWeixinEnabled } = await import("./route-3OzOAjec.js").then((n) => n.l);
14880
+ const { readWeixinSettings, setWeixinEnabled } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15248
14881
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
15249
14882
  const settings = readWeixinSettings(root);
15250
14883
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -15267,7 +14900,7 @@ function registerStatusApi(ctx, opts = {}) {
15267
14900
  sendJson$2(res, 400, { error: "appId 与 appSecret 都必须填(只填一个等于没绑)" });
15268
14901
  return;
15269
14902
  }
15270
- const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-3OzOAjec.js").then((n) => n.l);
14903
+ const { readWeixinSettings, upsertWeixinAccount, writeFeishuCredential, nextFeishuAccountId } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15271
14904
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
15272
14905
  const before = readWeixinSettings(root);
15273
14906
  const accountId = (body.accountId ?? "").trim() !== "" ? body.accountId.trim() : nextFeishuAccountId(before);
@@ -15317,7 +14950,7 @@ function registerStatusApi(ctx, opts = {}) {
15317
14950
  return;
15318
14951
  }
15319
14952
  const { pollQRStatus } = await import("./ilink-CsZ-f64q.js").then((n) => n.a);
15320
- const { readWeixinSettings } = await import("./route-3OzOAjec.js").then((n) => n.l);
14953
+ const { readWeixinSettings } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15321
14954
  const settings = readWeixinSettings(login.root);
15322
14955
  const status = await pollQRStatus({
15323
14956
  baseUrl: void 0,
@@ -15332,7 +14965,7 @@ function registerStatusApi(ctx, opts = {}) {
15332
14965
  });
15333
14966
  return;
15334
14967
  }
15335
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-3OzOAjec.js").then((n) => n.l);
14968
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-DxIKWeN9.js").then((n) => n.l);
15336
14969
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
15337
14970
  const accountId = nextWeixinAccountId(settings);
15338
14971
  upsertWeixinAccount(login.root, {
@@ -19899,7 +19532,7 @@ function buildOutgoingHookEvent(input) {
19899
19532
  sessionId: input.sessionId,
19900
19533
  role: input.role,
19901
19534
  reply: input.reply,
19902
- ...input.source && input.source !== "reply" ? { source: input.source } : {},
19535
+ ...input.source ? { source: input.source } : {},
19903
19536
  ...input.file ? { file: input.file } : {}
19904
19537
  };
19905
19538
  }
@@ -20105,8 +19738,6 @@ const manualSessions = /* @__PURE__ */ new Map();
20105
19738
  const sentTurn = /* @__PURE__ */ new Map();
20106
19739
  /** 连续打回计数(成功发送即清零) */
20107
19740
  const rebukeCounts = /* @__PURE__ */ new Map();
20108
- /** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
20109
- let guardActive = false;
20110
19741
  /** 桥:登记手动模式会话(每轮 incoming 都调用,幂等覆盖) */
20111
19742
  function noteManualOutputSession(sessionId, session) {
20112
19743
  if (typeof sessionId !== "string" || sessionId === "") return;
@@ -20135,10 +19766,6 @@ function hasSentThisTurn(sessionId, turn) {
20135
19766
  if (recorded === null || turn === null) return true;
20136
19767
  return recorded === turn;
20137
19768
  }
20138
- /** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
20139
- function isWeixinOutputGuardActive() {
20140
- return guardActive;
20141
- }
20142
19769
  /** 从 agent / 会话对象取会话 id(形状宽容) */
20143
19770
  function sessionIdOf$1(value) {
20144
19771
  const v = value;
@@ -20155,7 +19782,7 @@ function sessionIdOf$1(value) {
20155
19782
  * 🔴 **原名为 `isSuccessfulWeixinSend`,2026-10-20 改名**(Q4 出站飞书):
20156
19783
  * 它曾硬比 `channel !== 'weixin' ⇒ false`。接第二个渠道后,那条判据的含义变成
20157
19784
  * **"只有微信算发出去了"** —— 而实际后果是:飞书会话里 agent 真的发成功了,
20158
- * 闸门仍判"未发送" ⇒ 打回 ≤2 次,若 CCC 开了 `fallbackOnNoSend` 还会再兜底转发一遍
19785
+ * 闸门仍判"未发送" ⇒ 打回 ≤2 次,且当时的 `fallbackOnNoSend` 还会再兜底转发一遍
20159
19786
  * ⇒ **重复投递**。⇒ 判据从"是不是微信"改成"是不是**这个会话所在的那条渠道**",
20160
19787
  * 函数名随之更正(**名字要能对着行为读**,D151/D179 同族)。
20161
19788
  *
@@ -20237,10 +19864,32 @@ function registerWeixinOutputGuard(ctx) {
20237
19864
  const session = manualSessions.get(sessionId);
20238
19865
  if (!session) return;
20239
19866
  if (hasSentThisTurn(sessionId, payload?.turn ?? null)) return;
19867
+ /**
19868
+ * 🔴🔴 **D234 修法(2026-10-24,D238 强制同批):先确认"它有权发",再打回。**
19869
+ *
19870
+ * 缺陷(我 2026-10-23 查出的真缺陷):v1.88.0 起 `im-bridge` **只对 IM 路由表点名的
19871
+ * 轨迹可见**(取不到轨迹编码即 fail-closed 隐藏)。而本闸门**无条件打回**,打回文案
19872
+ * (`buildManualOutputRebuke`)逐字要求调用的正是那个工具 ⇒ 对一条"能收到消息但没被
19873
+ * 路由表点名"的轨迹,**agent 无论如何都满足不了** ⇒ 连打 2 次后只剩告警,
19874
+ * 用户侧零输出。**打回命中一个不可满足的前置条件**(D205 同族:前置不成立必须显式红,
19875
+ * 而不是继续走必然失败的重试)。
19876
+ *
19877
+ * 🔴 为什么砍掉自动回发后这条变成**必配件**:手动模式成了唯一路径 ⇒ 本闸门**恒生效**
19878
+ * (不再有"自动模式"这条安全退路),缺陷从边缘情形升级为主路径。
19879
+ *
19880
+ * 判据(与 `seams/guards.ts` 的可见性判据**同源**,D235 的要求):
19881
+ * 用同一个 `isImWiredTrajectory` 判——**可见性判据与打回判据必须同源**,
19882
+ * 否则两处各判一套,又会长出"能发但被判成不能发"这类漂移。
19883
+ */
19884
+ if (!isImWiredTrajectory(session.root, trajectoryCodeForSession(session.root, sessionId))) {
19885
+ console.warn(`[serenity-hooks] ✗ im 输出闸门:session=${sessionId} role=${session.role} **没有 im-bridge 发送权**(该轨迹不在本 CCC 的 IM 路由表 im.routes 里)⇒ **不打回**。本轮 agent 若想说话也无路可走 —— 请把该轨迹编码加进 .opencode/serenity.json 的 im.routes,或让它把话交给已被点名的接线轨迹。`);
19886
+ rebukeCounts.delete(sessionId);
19887
+ return;
19888
+ }
20240
19889
  const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
20241
19890
  if (count > 2) {
20242
19891
  rebukeCounts.delete(sessionId);
20243
- console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——本轮用户将收不到 agent 自己发的消息`);
19892
+ console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——**本轮用户将收不到任何消息**(兜底转发已于 2026-10-24 整条退役,出站只剩 im-bridge 一条路)`);
20244
19893
  return;
20245
19894
  }
20246
19895
  rebukeCounts.set(sessionId, count);
@@ -20261,7 +19910,6 @@ function registerWeixinOutputGuard(ctx) {
20261
19910
  console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:agent/turn-stopping 不可用");
20262
19911
  return;
20263
19912
  }
20264
- guardActive = true;
20265
19913
  }
20266
19914
  //#endregion
20267
19915
  //#region src/im/inbound-event.ts
@@ -20544,31 +20192,30 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
20544
20192
  if (workspaceMd) {
20545
20193
  if (!ensureWorkspacePromptSection(ref.agent, sessionId, root)) parts.push(workspaceCognitiveTrajectoryLine(workspaceMd));
20546
20194
  }
20547
- const manualOutput = settings.autoReplyWithLastMessage === false;
20548
20195
  if (text) parts.push(text);
20549
20196
  parts.push(...mediaNotes, ...degradedNotes);
20550
- if (manualOutput) {
20551
- /**
20552
- * 🔴 **不在这里清空"已发送"标记**(2026-10-21 修"同一条消息被处理两轮")。
20553
- *
20554
- * 旧写法 `clearSentThisTurn(sessionId)` 的语义是"来了新消息就当这个会话没发过"——
20555
- * 而它**连正在跑那一轮的证据一起抹掉**:真机时刻线
20556
- * `12:08:25 发送成功 → 12:08:56 新入站(抹) → 12:09:19 那一轮 turn-stopping 判"没发" ⇒ 打回`
20557
- * ⇒ 用户看到的就是"同一条消息被处理了两轮"。
20558
- *
20559
- * 现在**没有任何清空动作**:标记记的是**轮号**,上一轮发的记录在下一轮 `turn-stopping`
20560
- * 时天然不匹配(`hasSentThisTurn` 按轮比)⇒ 陈旧记录靠"轮号不等"淘汰,
20561
- * 不靠"谁来抹掉"。清空动作本身就是误伤面。
20562
- */
20563
- noteManualOutputSession(sessionId, {
20564
- root,
20565
- accountId,
20566
- userId: fromUserId,
20567
- role: trajectory,
20568
- channel
20569
- });
20570
- parts.push(manualOutputMarker(accountId, fromUserId, channel));
20571
- } else forgetManualOutputSession(sessionId);
20197
+ /**
20198
+ * 🔴🔴 **2026-10-24(owner 令,D238):出站只有一条路 —— agent 自己调 `im-bridge`。**
20199
+ *
20200
+ * 此前这里是 `if (manualOutput) {…}` 条件分支:`autoReplyWithLastMessage=false` 才走手动,
20201
+ * 否则桥替 agent 转发最终文本。**该开关已整条退役** ⇒ 本段成为**唯一路径**:
20202
+ * 每轮都登记闸门 + 注入机制标记,桥**永不**自动回发。
20203
+ *
20204
+ * 为什么要这样(owner 原话):「autoReply 并不合适,主动性的 imBridge 仍然是更好的」
20205
+ * —— 自动回发让 agent 失去"说几条 / 何时说 / 要不要不说"的决定权,而这正是主动发送的价值。
20206
+ *
20207
+ * ⚠️ **由此产生的唯一代价(必须同时修)**:agent 不发 ⇒ 用户确实收不到。
20208
+ * 所以 D234 的修法是本改动的**必配件**:guard 打回前必须确认 `im-bridge` 对该轨迹可见,
20209
+ * 否则"没权发"会被无解地打回成"忘了发"。见 `im/guard.ts`。
20210
+ */
20211
+ noteManualOutputSession(sessionId, {
20212
+ root,
20213
+ accountId,
20214
+ userId: fromUserId,
20215
+ role: trajectory,
20216
+ channel
20217
+ });
20218
+ parts.push(manualOutputMarker(accountId, fromUserId, channel));
20572
20219
  const question = parts.join("\n");
20573
20220
  const hookRel = settings.hook;
20574
20221
  if (hookRel) invokeWeixinHook(root, hookRel, buildIncomingHookEvent({
@@ -20583,53 +20230,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
20583
20230
  })).catch((err) => {
20584
20231
  console.log(`[serenity-hooks] im hook incoming error: ${err instanceof Error ? err.message : String(err)}`);
20585
20232
  });
20586
- const answer = (await askTrajectory(ctx, ref.agent, question)).answer ?? "";
20587
- if (manualOutput) {
20588
- if (!manualOutputFallbackNeeded({
20589
- fallbackOnNoSend: settings.fallbackOnNoSend,
20590
- guardActive: isWeixinOutputGuardActive(),
20591
- sentThisTurn: hasSentThisTurn(sessionId),
20592
- answer
20593
- })) return;
20594
- const reply = markdownToPlainText(stripThink(answer));
20595
- await io.sendText({
20596
- toUserId: fromUserId,
20597
- text: reply,
20598
- replyRef: event.replyRef
20599
- });
20600
- console.warn(`[serenity-hooks] ⚠ im 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, trajectory=${trajectory})`);
20601
- if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
20602
- cccRoot: root,
20603
- channel,
20604
- accountId,
20605
- userId: fromUserId,
20606
- sessionId,
20607
- role: trajectory,
20608
- reply,
20609
- source: "reply-fallback"
20610
- })).catch((err) => {
20611
- console.log(`[serenity-hooks] im hook outgoing(reply-fallback) error: ${err instanceof Error ? err.message : String(err)}`);
20612
- });
20613
- return;
20614
- }
20615
- if (answer === "") return;
20616
- const reply = markdownToPlainText(stripThink(answer));
20617
- await io.sendText({
20618
- toUserId: fromUserId,
20619
- text: reply,
20620
- replyRef: event.replyRef
20621
- });
20622
- if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
20623
- cccRoot: root,
20624
- channel,
20625
- accountId,
20626
- userId: fromUserId,
20627
- sessionId,
20628
- role: trajectory,
20629
- reply
20630
- })).catch((err) => {
20631
- console.log(`[serenity-hooks] im hook outgoing error: ${err instanceof Error ? err.message : String(err)}`);
20632
- });
20233
+ await askTrajectory(ctx, ref.agent, question);
20633
20234
  } finally {
20634
20235
  await io.typingStop?.({ toUserId: fromUserId });
20635
20236
  }
@@ -20643,7 +20244,7 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
20643
20244
  }
20644
20245
  }
20645
20246
  /**
20646
- * 手动输出模式的**机制标记**(v1.30.10 引入;v1.30.16 按「ACC 管机制、CCC 管措辞」重构)。
20247
+ * 出站**机制标记**(v1.30.10 引入;v1.30.16 按「ACC 管机制、CCC 管措辞」重构)。
20647
20248
  *
20648
20249
  * 边界(R↓):ACC 只提供**机制与数据**——"桥不转发最终文本"这个事实 + 三个已填好的参数值。
20649
20250
  * **纪律措辞归 CCC**:怎么写、必须怎么做、后果是什么,全部写在轨迹的 `PROMPT.md` 里。
@@ -20651,22 +20252,25 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
20651
20252
  * 🔴 **渠道不是常量**(2026-10-20 Q4):此前这里硬写 `channel:"weixin"`,
20652
20253
  * 而一条**飞书**会话收到的却是"发往微信"的命令 ⇒ 模型照着做必然失败(目标 id 是 open_id)。
20653
20254
  * ⇒ 渠道由调用方传入;缺省 `'weixin'` 保留既有调用点与测试的读数。
20255
+ *
20256
+ * 🔴 2026-10-24(D238):本标记从"手动模式才注入"变成**每轮必注入**(手动模式已成唯一路径)。
20257
+ * ⚠️ **标记字符串 `[serenity:weixin-manual-output]` 不改名**:CCC 侧角色提示词按它做判据,
20258
+ * 改名会把已写好的纪律文档全打成废纸;它是**协议标记**,不是渠道名(与 A85 同一处置)。
20654
20259
  */
20655
20260
  function manualOutputMarker(accountId, userId, channel = "weixin") {
20656
20261
  return ["[serenity:weixin-manual-output]", `im-bridge({channel:"${channel}", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
20657
20262
  }
20658
20263
  /**
20659
- * 手动输出模式的**兜底判定**(纯函数,v1.30.17——把决策矩阵从装配里抽出来,可独立测试)。
20264
+ * 🔴 **`manualOutputFallbackNeeded` 已于 2026-10-24 删除**(owner 令,D238)。
20265
+ *
20266
+ * 它实现的是「手动模式下 agent 一次都没发成功 ⇒ 桥兜底转发最终文本」。
20267
+ * **随 `fallbackOnNoSend` 一起退役** —— 那个开关只在 `autoReplyWithLastMessage=false` 时才有意义,
20268
+ * 而后者已整条删除(出站只剩 `im-bridge` 一条路)。
20660
20269
  *
20661
- * 四个条件同时成立才兜底:
20662
- * ① CCC 显式开启 `fallbackOnNoSend`(缺省 false = 严格静默)
20663
- * ② 机械闸门**已装配**(未装配时无法判定"是否发送过" → 宁可静默也不冒重复发送风险)
20664
- * ③ 本轮**没有**成功发送(agent 自己发过 → 输出权归 agent,桥不插话)
20665
- * ④ 有最终文本可转发(空文本无意义)
20270
+ * ⛔ **不要把它改写成"自动兜底"再放回来**:owner 否决的正是"桥替 agent 说最后一句"这件事本身
20271
+ * (「autoReply 并不合适,主动性的 imBridge 仍然是更好的」),而不是它的某个实现细节。
20272
+ * 兜底带来的"不丢消息"收益,改由 guard 打回(可见性前置检查 + 响亮告警)承担。
20666
20273
  */
20667
- function manualOutputFallbackNeeded(input) {
20668
- return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
20669
- }
20670
20274
  //#endregion
20671
20275
  //#region src/im/feishu-account.ts
20672
20276
  /** 去重集合上限(飞书重推窗口有限;限量防内存无界增长) */
@@ -22935,8 +22539,6 @@ const weixinChannel = {
22935
22539
  });
22936
22540
  return {
22937
22541
  enabled: settings.enabled === true,
22938
- autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
22939
- fallbackOnNoSend: settings.fallbackOnNoSend === true,
22940
22542
  routes,
22941
22543
  accounts
22942
22544
  };
@@ -23177,8 +22779,6 @@ const feishuChannel = {
23177
22779
  }));
23178
22780
  return {
23179
22781
  enabled: settings.enabled === true,
23180
- autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
23181
- fallbackOnNoSend: settings.fallbackOnNoSend === true,
23182
22782
  routes,
23183
22783
  accounts
23184
22784
  };