@shgroup/dsh-serenity-hooks 1.71.0 → 1.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dsh.plugin.json +2 -2
  2. package/lib/{ccc-Dkoa2oW3.js → ccc-CZUOq3NY.js} +1 -1
  3. package/lib/{ccc-roots-jZYR2GVE.js → ccc-roots-BhoA7G-0.js} +3 -3
  4. package/lib/client.js +50 -58
  5. package/lib/{wake/cro.d.ts → cro/core.d.ts} +2 -2
  6. package/lib/{wake/cro-guide.d.ts → cro/guide.d.ts} +3 -3
  7. package/lib/{wake/cro-log.d.ts → cro/log.d.ts} +2 -2
  8. package/lib/{wake/cro-turns.d.ts → cro/turns.d.ts} +2 -2
  9. package/lib/guard/seam.d.ts +1 -1
  10. package/lib/{ilink-DEpmE3yi.js → ilink-BMk2w2KY.js} +1 -1
  11. package/lib/im/accounts.d.ts +105 -0
  12. package/lib/im/bridge.d.ts +33 -62
  13. package/lib/im/endpoint.d.ts +1 -1
  14. package/lib/im/feishu-account.d.ts +92 -0
  15. package/lib/im/inbound-event.d.ts +162 -0
  16. package/lib/im/route.d.ts +37 -2
  17. package/lib/im/weixin-account.d.ts +45 -0
  18. package/lib/index.d.ts +0 -22
  19. package/lib/index.js +1070 -996
  20. package/lib/kernel/ccc.d.ts +78 -2
  21. package/lib/kernel/ports.d.ts +2 -4
  22. package/lib/lib-TxscFKeo.js +119355 -0
  23. package/lib/{message-source-kVoNK0C5.js → message-source-ChyVfev9.js} +2 -2
  24. package/lib/{profile-DyMJ_bfr.js → profile-BIQ2widI.js} +14 -11
  25. package/lib/rolldown-runtime-B4iAMlE-.js +35 -0
  26. package/lib/{route-CPneN5iv.js → route-ItCgAsnz.js} +96 -35
  27. package/lib/seams/guards.d.ts +1 -1
  28. package/lib/seams/system-prompt.d.ts +3 -1
  29. package/lib/{settings-lt1i3OZv.js → settings-UcwIPJhM.js} +13 -62
  30. package/lib/skills/resource-index.d.ts +1 -1
  31. package/lib/surface/settings.d.ts +0 -6
  32. package/lib/surface/text.d.ts +72 -0
  33. package/lib/surface/usage.d.ts +2 -2
  34. package/lib/{role/worker/idle.d.ts → trajectory/carrier.d.ts} +12 -2
  35. package/lib/{role/worker → trajectory/delegate}/core.d.ts +7 -1
  36. package/lib/{role/worker → trajectory/delegate}/preset.d.ts +9 -2
  37. package/lib/{role/skiff → trajectory/policy}/profile.d.ts +9 -2
  38. package/lib/{role/skiff/config.d.ts → trajectory/policy/rules.d.ts} +9 -3
  39. package/lib/{role/skiff/registry.d.ts → trajectory/policy/window.d.ts} +7 -1
  40. package/lib/trajectory/prompt.d.ts +101 -0
  41. package/lib/{role/skiff/core.d.ts → trajectory/session.d.ts} +13 -4
  42. package/package.json +3 -2
  43. package/lib/role/skiff/debug.d.ts +0 -88
  44. package/lib/rolldown-runtime-8H4AJuhK.js +0 -14
package/lib/index.js CHANGED
@@ -1,12 +1,12 @@
1
- import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.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-jZYR2GVE.js";
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, p as readCognitiveTrajectorySkills, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-Dkoa2oW3.js";
4
- import { A as sessionEvents$1, C as listSessions, D as readActiveSessionMd, E as parseTrajectoryCode, F as useSession, I as isoLocal, L as localFileStamp, M as setActiveSessionInfo, N as showSession, O as readTrajectoryUid, P as summarize, R as localHuman, S as listActiveTrajectories, T as parseSessionContextFromEvents, _ as createSession, a as readSkiffProfileAt, b as getActiveSessionInfo, c as registerSkiffSession$1, d as unregisterSkiffSession$1, f as COGNITIVE_TRAJECTORY_ACTIONS, g as clearActiveSessionInfo, h as archiveSession, j as sessionsRoot, k as resolveSessionByTitle, l as skiffSessionInfo$1, m as UID_FILENAME, n as isSkiffSession, o as readSkiffProfileByCode, p as DEFAULT_SESSION_SCOPE, r as listSkiffProfiles, s as skiffProfileForSession, t as SKIFF_PROFILE_FILENAME, u as skiffTrajectoryDirNameFor, v as extractSessionMdPathFromText, w as namingTitleFor, y as findSession, z as localIdStamp } from "./profile-DyMJ_bfr.js";
5
- import { a as readWeixinCredential, c as sanitizeFileName$1, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinInboundDir, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, t as extractWeixinMedia, u as weixinSessionIdForTrajectory } from "./route-CPneN5iv.js";
6
- import { _ as resolveProfileSystemPrompt, a as GATEWAY_PORT, c as SKIFF_DEBUG_PORT, d as buildSkiffBasePrompt, f as createProfilePromptReader, g as profileToolWhitelist, h as profileSystemPromptSource, i as FACE_PORTS, l as WEIXIN_SEND_PORT, m as profileMsmWhitelist, n as readSimpleSettings, o as MAIN_WEB_PORT, p as profileCognitiveTrajectory, r as registerSettingsSection, s as MECHANISM_PORTS, u as SKIFF_PROFILE_HINT, v as ACC_VERSION, y as findPackageRoot } from "./settings-lt1i3OZv.js";
7
- import { a as pruneMissingBindings, c as setBindingStore, i as listBoundSessionIds, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND } from "./message-source-kVoNK0C5.js";
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-BhoA7G-0.js";
3
+ import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, g as readUtf8, h as readSafeModeHiddenTools, i as findGitRoot, l as pathInside, m as readExclusiveTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readCognitiveTrajectorySkills, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-CZUOq3NY.js";
4
+ import { A as sessionsRoot, C as namingTitleFor, D as readTrajectoryUid, E as readActiveSessionMd, F as isoLocal, I as localFileStamp, L as localHuman, M as showSession, N as summarize, O as resolveSessionByTitle, P as useSession, R as localIdStamp, S as listSessions, T as parseTrajectoryCode, _ as extractSessionMdPathFromText, a as readSkiffProfileAt, c as registerSkiffSession$1, d as COGNITIVE_TRAJECTORY_ACTIONS, f as DEFAULT_SESSION_SCOPE, g as createSession, h as clearActiveSessionInfo, j as setActiveSessionInfo, k as sessionEvents$1, l as skiffTrajectoryDirNameFor, m as archiveSession, n as isSkiffSession, o as readSkiffProfileByCode, p as UID_FILENAME, r as listSkiffProfiles, s as skiffProfileForSession, t as SKIFF_PROFILE_FILENAME, u as unregisterSkiffSession$1, v as findSession, w as parseSessionContextFromEvents, x as listActiveTrajectories, y as getActiveSessionInfo } from "./profile-BIQ2widI.js";
5
+ import { _ as runLocalStore, a as readFeishuCredential, d as weixinSessionIdForTrajectory, f as LOCALSTORE_SCOPES, g as readStore, h as readGitTrack, i as matchWeixinRoute, l as sanitizeFileName$1, m as localstorePath, n as extractWeixinText, o as readWeixinCredential, p as checkLocalstoreGitCompliance, r as hasVoiceItem, s as readWeixinSettings, t as extractWeixinMedia, u as weixinInboundDir } from "./route-ItCgAsnz.js";
6
+ import { a as buildSkiffBasePrompt, c as profileMsmWhitelist, d as resolveProfileSystemPrompt, f as ACC_VERSION, i as SKIFF_PROFILE_HINT, l as profileSystemPromptSource, n as readSimpleSettings, o as createProfilePromptReader, p as findPackageRoot, r as registerSettingsSection, s as profileCognitiveTrajectory, u as profileToolWhitelist } from "./settings-UcwIPJhM.js";
7
+ import { a as pruneMissingBindings, c as setBindingStore, i as listBoundSessionIds, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND } from "./message-source-ChyVfev9.js";
8
8
  import { REGISTERED_TOOLS } from "./invariant.js";
9
- 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-DEpmE3yi.js";
9
+ 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";
10
10
  import { createRequire } from "node:module";
11
11
  import z from "@deepseek-ai/schemastery";
12
12
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -981,7 +981,7 @@ const gitTool = defineTool({
981
981
  * 后果实测:`acc-extras` / `ops` / `skiff-admin` 三个测试套件因解析不到宿主包而**整文件加载失败**
982
982
  * (它们只用到 MSM 注册表逻辑)。叶模块把「读」与「写」解耦:读者零依赖,写者自己承担重量。
983
983
  *
984
- * 同款先例:`host/effect.ts`(拆卸登记)、`role/worker/idle.ts`(等待结算)。
984
+ * 同款先例:`host/effect.ts`(拆卸登记)、`trajectory/carrier.ts`(等待结算)。
985
985
  */
986
986
  let endpoint = null;
987
987
  /** 设置当前入口地址(weixin-send-api 启停时调用;null = 未启动) */
@@ -1823,7 +1823,142 @@ async function runMsmAsync(root, args) {
1823
1823
  }
1824
1824
  }
1825
1825
  //#endregion
1826
- //#region src/role/worker/idle.ts
1826
+ //#region src/trajectory/prompt.ts
1827
+ /**
1828
+ * trajectory/prompt.ts — **轨迹提示词**(Trajectory Prompt):一条轨迹"是谁、按什么章程工作"
1829
+ *
1830
+ * ## 概念(owner 2026-10-19 定向,逐字)
1831
+ *
1832
+ * > 「参考会话的系统提示词,我们建立**轨迹提示词**概念来代替人格。架构上会好很多」
1833
+ *
1834
+ * **轨迹提示词** = 一条认知轨迹的**可编辑章程声明**,由 ACC 按固定顺序装配进系统提示词。
1835
+ * 三个关键词各是一条判据:
1836
+ * - **可编辑**:住在 CCC(盘上),改它**不发版**(D23 归属判据的直接推论)
1837
+ * - **声明**:它声明**身份/章程/方法论**,**不声明能力**(能力面归 `skiff.json` 白名单与工具 schema)
1838
+ * - **固定顺序**:装配顺序由 ACC 定死(见 `seams/system-prompt.ts` 的 ⑧ 位)
1839
+ *
1840
+ * ## 为什么取代「人格」
1841
+ *
1842
+ * 「人格」把身份绑在"像不像一个人"上 ⇒ 因为叫人格,它被设计成**可以整体替换**
1843
+ * (`persona` 置换 EAP 块,即 review F-05/R-14 那个悬置冲突)。而身份与章程**不该被替换,
1844
+ * 只该被特化**。⇒ 本模块把"替换"改成"叠加":容器层与质量层永远保留,轨迹提示词只往上加。
1845
+ *
1846
+ * ## 落点(为什么在轨迹目录里)
1847
+ *
1848
+ * `AGENT_SESSIONS/<轨迹目录>/PROMPT.md`
1849
+ *
1850
+ * - **跟着轨迹走**:`archive` 时整个目录搬走,章程不会掉队(旧形态 `.opencode/skiff/<name>.md`
1851
+ * 与轨迹**分居两处** —— 多条轨迹共用一份文件正是那个后果)。
1852
+ * - **每条轨迹都能有**:不只是 skiff。普通轨迹同样需要"我是谁、按什么章程"。
1853
+ * - 与 `SESSION.md`(身体:延续/日志)、`skiff.json`(约束面:能力)三件并列,
1854
+ * 各管一件事,不合并(合并会让"改能力"与"改身份"互相牵连)。
1855
+ *
1856
+ * ## 兼容(不静默坏)
1857
+ *
1858
+ * `PROMPT.md` 不存在时**不报错**(普通轨迹常态)。skiff 的旧字段 `systemPromptFile`
1859
+ * 在兼容期仍被读取——优先级见 {@link resolveTrajectoryPrompt}。
1860
+ */
1861
+ /** 轨迹提示词的固定文件名(**判据就是它存在**——不是目录名前缀,也不是配置里的一行) */
1862
+ const TRAJECTORY_PROMPT_FILENAME = "PROMPT.md";
1863
+ /** 轨迹目录的 PROMPT.md 绝对路径(不论存在与否——调用方自己判存在性) */
1864
+ function trajectoryPromptPath(root, dirName) {
1865
+ return join(sessionsRoot(root), dirName, TRAJECTORY_PROMPT_FILENAME);
1866
+ }
1867
+ /**
1868
+ * 该轨迹**有没有**轨迹提示词(单一判据)。
1869
+ *
1870
+ * 用途:兼容期的"让位"判定 —— 若已有 `PROMPT.md`,skiff 旧字段 `systemPromptFile`
1871
+ * 必须**让位**,否则同一份章程会在两处各注入一遍(第 ⑧ 段 + `serenity-skiff` 段)。
1872
+ * 🔴 判据只有这一处:别在 skiff 那边自己拼路径再判(两处会漂)。
1873
+ */
1874
+ function hasTrajectoryPrompt(root, dirName) {
1875
+ try {
1876
+ return existsSync(trajectoryPromptPath(root, dirName));
1877
+ } catch {
1878
+ return false;
1879
+ }
1880
+ }
1881
+ /**
1882
+ * 轨迹提示词的**读取器**(长期热更新;改文件即生效,无需重启)。
1883
+ *
1884
+ * ## 为什么返回函数而不是字符串(R↓,实证代价)
1885
+ *
1886
+ * 旧实现把提示词**快照**进系统提示词段的闭包 ⇒ CCC 改了文件必须重启 dsh web 才生效
1887
+ * (实测:改完 18KB 提示词,live agent 仍用旧文本)。现形态由系统提示词段的 `text()`
1888
+ * **每轮调用**本读取器:按 **mtime + size** 缓存重读(命中缓存零 IO)。
1889
+ *
1890
+ * ## 三条失败语义(各治一个失败模式)
1891
+ *
1892
+ * - **文件不存在** ⇒ 返回空串(**不是错误**:普通轨迹没有章程是常态,且删掉即失效
1893
+ * 必须是立即可见的——这就是"删掉 PROMPT.md 即恢复默认"的机制)
1894
+ * - **读取失败** ⇒ 返回**上次成功内容**(⛔ 绝不返回空:空提示词会让轨迹失去章程),
1895
+ * 并按去重标签告警一次
1896
+ * - **首次就读不到** ⇒ 空串 + 告警一次(区分"本来没有"与"有但读不了")
1897
+ *
1898
+ * @param root CCC 根
1899
+ * @param dirName 轨迹目录名(**不是编码**:目录名才是落盘位置)
1900
+ */
1901
+ function createTrajectoryPromptReader(root, dirName) {
1902
+ const absPath = trajectoryPromptPath(root, dirName);
1903
+ let cachedMtime = -1;
1904
+ let cachedSize = -1;
1905
+ let cachedText = "";
1906
+ let warned = false;
1907
+ return () => {
1908
+ let st;
1909
+ try {
1910
+ st = statSync(absPath);
1911
+ } catch (err) {
1912
+ const code = err?.code;
1913
+ cachedMtime = -1;
1914
+ cachedSize = -1;
1915
+ cachedText = "";
1916
+ if (code !== "ENOENT" && !warned) {
1917
+ warned = true;
1918
+ console.warn(`[serenity-hooks] ✗ 轨迹提示词不可读(${absPath})——按"没有章程"处理: ${String(err?.message ?? err)}`);
1919
+ }
1920
+ return "";
1921
+ }
1922
+ try {
1923
+ if (st.mtimeMs === cachedMtime && st.size === cachedSize) return cachedText;
1924
+ const text = readUtf8(absPath).trim();
1925
+ const isReload = cachedMtime !== -1;
1926
+ cachedMtime = st.mtimeMs;
1927
+ cachedSize = st.size;
1928
+ cachedText = text;
1929
+ warned = false;
1930
+ if (isReload) console.info(`[serenity-hooks] ↻ 轨迹提示词已热重载(${TRAJECTORY_PROMPT_FILENAME},${text.length} 字符)`);
1931
+ return text;
1932
+ } catch (err) {
1933
+ if (!warned) {
1934
+ warned = true;
1935
+ console.warn(`[serenity-hooks] ✗ 轨迹提示词读取失败(${absPath})——沿用上次成功内容: ${String(err?.message ?? err)}`);
1936
+ }
1937
+ return cachedText;
1938
+ }
1939
+ };
1940
+ }
1941
+ /**
1942
+ * 装配用的**提示词段**(幂等:无内容 ⇒ 空串,宿主会丢弃空 section/空块)。
1943
+ *
1944
+ * 标记头 `=== Serenity Trajectory Prompt ===`:
1945
+ * ① 让模型知道"下面是**这条轨迹自己的章程**",不是容器规则;
1946
+ * ② 让诊断可以按块名 grep。
1947
+ *
1948
+ * 🔴 这段**追加**在容器层与质量层之后,**永不替换它们**——那正是本概念相对「人格」的核心修正。
1949
+ */
1950
+ function trajectoryPromptBlock(text) {
1951
+ const body = text.trim();
1952
+ if (body === "") return "";
1953
+ return [
1954
+ "",
1955
+ "=== Serenity Trajectory Prompt ===",
1956
+ body,
1957
+ ""
1958
+ ].join("\n");
1959
+ }
1960
+ //#endregion
1961
+ //#region src/trajectory/carrier.ts
1827
1962
  /**
1828
1963
  * 等待 agent 空闲或销毁。
1829
1964
  * @param ctx 宿主插件上下文(事件通道来源;**结构化访问**,故类型为 `unknown`,见文件头 I2 段)
@@ -1970,7 +2105,7 @@ function truncateContent(content, maxChars) {
1970
2105
  * 六条硬性规则(逐条对应设计 §4.2,改这里先改那里):
1971
2106
  * ① **只枚举,不注入正文** —— 索引里**绝不**出现任何参考文件的正文;
1972
2107
  * ② **确定性排序** = 相对路径的 **UTF-8 字节序**(🔴 **不用 mtime**:mtime 随检出漂移,
1973
- * 会让每请求的注入文本抖动;对照 `src/wake/cro.ts:101` 那份**落盘快照**用 {name,bytes,mtime} ——
2108
+ * 会让每请求的注入文本抖动;对照 `src/cro/core.ts:101` 那份**落盘快照**用 {name,bytes,mtime} ——
1974
2109
  * **同一件事两处的取舍相反,那个差异是判据不是巧合**);
1975
2110
  * ③ **有界 + 响亮** —— 条数/字符各有上限,超出**必须显式写出还剩多少项**,**绝不静默截断**;
1976
2111
  * ④ **摘要可复现** —— 取该文件首个 `#` 标题(剥 markdown 记号);取不到 ⇒ 留空,**不编**;
@@ -2465,7 +2600,7 @@ function buildCognitiveTrajectorySkillsSection(root, mdPath, maxChars = COGNITIV
2465
2600
  * ⇒ 本模块让每个 CCC 留下一份**纯计数**账本:**用了哪个、多少次、第一次和最近一次是什么时候**。
2466
2601
  * 用途 = 清理/精简的**依据**(没有人用的东西与天天在用的东西,不再靠猜)。
2467
2602
  *
2468
- * ## 🔴 铁律(与 `wake/cro-log.ts` 同族)
2603
+ * ## 🔴 铁律(与 `cro/log.ts` 同族)
2469
2604
  *
2470
2605
  * **本模块每个导出函数都不抛错**:读盘 / 解析 / 写盘失败一律返回结构化失败,
2471
2606
  * 由调用方只记一行日志(或什么都不做)⇒ **统计的问题绝不影响工具执行与既有链路**。
@@ -2481,7 +2616,7 @@ function buildCognitiveTrajectorySkillsSection(root, mdPath, maxChars = COGNITIV
2481
2616
  *
2482
2617
  * 宿主会被重启(本容器实测 24h 内 3 次)⇒ 计数**不能只活在内存里**:
2483
2618
  * 每次写盘 = **读旧文件 → 在旧值上自增 → 原子写**(`tmp + rename`,与 `wake/registry.ts` /
2484
- * `wake/cro-log.ts` 同款形态)⇒ **进程重启后计数自然延续**,无需额外的 flush/落盘时机。
2619
+ * `cro/log.ts` 同款形态)⇒ **进程重启后计数自然延续**,无需额外的 flush/落盘时机。
2485
2620
  *
2486
2621
  * ## 口径(两个字段,勿合并)
2487
2622
  *
@@ -2764,6 +2899,48 @@ function verifyTotpCode(secretBase32, code, nowSeconds = nowEpochSeconds()) {
2764
2899
  return null;
2765
2900
  }
2766
2901
  //#endregion
2902
+ //#region src/kernel/ports.ts
2903
+ /**
2904
+ * 集中端口表。
2905
+ * A=宿主主面(插件不监听)/ B=网关(可配,默认关)/ C=微信发送(默认开,仅 loopback)
2906
+ * / D=Skiff 调试(默认关)。
2907
+ */
2908
+ const FACE_PORTS = {
2909
+ main: {
2910
+ port: 3080,
2911
+ host: null,
2912
+ defaultEnabled: true,
2913
+ meaning: "宿主 DSH 主 WebUI + 插件 /serenity/* 路由面(**插件不起此 listener**,端口自宿主读)"
2914
+ },
2915
+ gateway: {
2916
+ port: 3081,
2917
+ host: "0.0.0.0",
2918
+ defaultEnabled: false,
2919
+ meaning: "双端口网关(外部登录 + 全量反代宿主主面;host/port 走 plugin 全局配置)"
2920
+ },
2921
+ weixinSend: {
2922
+ port: 3082,
2923
+ host: "127.0.0.1",
2924
+ defaultEnabled: true,
2925
+ meaning: "微信主动发送面(容器外进程 → POST /send;与 3080 显式解耦,不经网关)"
2926
+ }
2927
+ };
2928
+ FACE_PORTS.main.port;
2929
+ /** 网关端口默认值(面 B) */
2930
+ const GATEWAY_PORT = FACE_PORTS.gateway.port;
2931
+ /** 微信主动发送端口默认值(面 C) */
2932
+ const WEIXIN_SEND_PORT = FACE_PORTS.weixinSend.port;
2933
+ /**
2934
+ * **机制端口**词表(output-guard 用:agent 在**用户可见输出**里提及内部端口即打回)。
2935
+ *
2936
+ * 由 {@link FACE_PORTS} 机械派生 ⇒ 与面清单**永不漂移**。
2937
+ * ⚠️ 修复记录(C4 块 B,2026-09-15):旧常量硬写 `['3080','3081','3099','3100']`
2938
+ * ——**漏 3082**(微信发送面,默认开)⇒ 守卫拦不住"3082"这类提及。现由表派生,四面齐全。
2939
+ * ⚠️ 面变更记录(2026-10-15):**面 E(3100 ACP/问答)已整体退役**(owner 裁「砍掉外部问答」)
2940
+ * ⇒ 本词表随之少一项,**无需另改**(这正是"派生而非手抄"的收益)。
2941
+ */
2942
+ const MECHANISM_PORTS = Object.keys(FACE_PORTS).map((id) => String(FACE_PORTS[id].port));
2943
+ //#endregion
2767
2944
  //#region src/kernel/config.ts
2768
2945
  /**
2769
2946
  * kernel/config.ts — plugin 全局配置读写(结构化,`~/.dsh/serenity-hooks.json`)
@@ -3441,7 +3618,7 @@ function sessionBlock(root, scope = DEFAULT_SESSION_SCOPE) {
3441
3618
  * Principles 剥离 MSM 原则段(指令遵循约束)——用户文本承接两处风格;本体论/关系段/
3442
3619
  * 操作边界(安全硬约束)永远保留。未配置 → 与 v1.23.0 逐字节一致(零影响)。
3443
3620
  */
3444
- function serenitySystemPrompt(root, scope = DEFAULT_SESSION_SCOPE) {
3621
+ function serenitySystemPrompt(root, scope = DEFAULT_SESSION_SCOPE, dirName) {
3445
3622
  const persona = readPersonaSettings();
3446
3623
  const personaOn = persona.mode !== "" && persona.overrideText.trim() !== "";
3447
3624
  const parts = [
@@ -3449,12 +3626,18 @@ function serenitySystemPrompt(root, scope = DEFAULT_SESSION_SCOPE) {
3449
3626
  metaphorBlock(),
3450
3627
  principlesBlock(root, personaOn),
3451
3628
  cceBlock(),
3452
- personaOn ? personaBlock(persona.mode, persona.overrideText) : eapBlock()
3629
+ eapBlock()
3453
3630
  ];
3631
+ if (personaOn) {
3632
+ const expr = personaBlock(persona.mode, persona.overrideText);
3633
+ if (expr !== "") parts.push(expr);
3634
+ }
3454
3635
  const state = [safeModeBlock(root), localstoreBlock(root)].filter((b) => b !== "").join("\n");
3455
3636
  if (state) parts.push(state);
3456
3637
  const skill = entrySkillSectionText(root);
3457
3638
  if (skill) parts.push(skill);
3639
+ const trajPrompt = dirName === void 0 ? "" : trajectoryPromptBlock(createTrajectoryPromptReader(root, dirName)());
3640
+ if (trajPrompt !== "") parts.push(trajPrompt);
3458
3641
  parts.push(toolsBlock());
3459
3642
  const session = sessionBlock(root, scope);
3460
3643
  if (session) parts.push(session);
@@ -3501,6 +3684,23 @@ function agentScope$2(context) {
3501
3684
  return (context.agent?.session)?.id ?? "default";
3502
3685
  }
3503
3686
  /**
3687
+ * 该会话**绑定的轨迹目录名**(有 ⇒ 注入第 ⑧ 段「轨迹提示词」;无 ⇒ `undefined`,不注入)。
3688
+ *
3689
+ * 🔴 为什么用**目录名**而不是编码或 label:`PROMPT.md` 的落盘位置是目录,
3690
+ * 而编码只是目录名里的一段。用 label 拼路径 = 把模糊匹配当精确匹配用(本轨已踩过)。
3691
+ *
3692
+ * ⚠️ 每次装配都重读绑定(**不缓存**):绑定切换后提示词必须立刻跟上,
3693
+ * 这与 `serenity-trajectory-skills` section 的口径一致(动态注入,owner 令 2026-09-20)。
3694
+ */
3695
+ function boundDirNameFor(session) {
3696
+ try {
3697
+ const name = readLastBound(session)?.dirName;
3698
+ return typeof name === "string" && name !== "" ? name : void 0;
3699
+ } catch {
3700
+ return;
3701
+ }
3702
+ }
3703
+ /**
3504
3704
  * 全局注册系统提示词 section(osp system.transform 的 DSH 等价)。
3505
3705
  * text 回调按 context.agent 的 cwd 上溯 .serenity → 返回该 CCC 的完整注入文本;
3506
3706
  * 非 CCC / 无 agent 返回空(不注入)。
@@ -3515,9 +3715,7 @@ function registerEntrySkillSectionGlobal(ctx) {
3515
3715
  if (!cwd) return "";
3516
3716
  const root = cccRootForCwd(cwd);
3517
3717
  if (!root) return "";
3518
- const scope = agentScope$2(context);
3519
- if (isSkiffSession(root, scope)) return "";
3520
- const base = serenitySystemPrompt(root, scope);
3718
+ const base = serenitySystemPrompt(root, agentScope$2(context), boundDirNameFor(context.agent?.session));
3521
3719
  const codeLine = codeModeAdaptationLine(ctx, context.scope);
3522
3720
  return codeLine ? `${base}\n${codeLine}` : base;
3523
3721
  }
@@ -3549,8 +3747,7 @@ function registerEntrySkillSection(agent, root) {
3549
3747
  name: "serenity-entry",
3550
3748
  order: -50,
3551
3749
  text: (context) => {
3552
- if (isSkiffSession(root, scope)) return "";
3553
- const base = serenitySystemPrompt(root, scope);
3750
+ const base = serenitySystemPrompt(root, scope, boundDirNameFor(agent.session));
3554
3751
  const codeLine = codeModeAdaptationLine(agent.ctx, context.scope);
3555
3752
  return codeLine ? `${base}\n${codeLine}` : base;
3556
3753
  }
@@ -3613,13 +3810,19 @@ function registerCognitiveTrajectorySkillSection(agent, root) {
3613
3810
  }
3614
3811
  }
3615
3812
  //#endregion
3616
- //#region src/role/worker/core.ts
3813
+ //#region src/trajectory/delegate/core.ts
3617
3814
  /**
3618
- * role/worker/core.ts — cheap-worker(杂工)纯逻辑层(零 DSH 依赖,可独立单测)
3815
+ * trajectory/delegate/core.ts — cheap-worker(杂工)纯逻辑层(零 DSH 依赖,可独立单测)
3619
3816
  *
3620
3817
  * 🆕 2026-10-14(owner 令「删除后台运行能力」):**进度文件 / stop token / 轮次循环**整族已退场 ——
3621
3818
  * 它们**只**服务于 background 模式。本模块现存 = 前台委派真正需要的三件:
3622
3819
  * 模型串解析 / 白名单校验 / provider 可用性判读(+ 使用指引文本)。
3820
+ *
3821
+ * ## S14(2026-10-19,轨迹中心重构):此前位于 `role/worker/` 下(该目录已消失)
3822
+ *
3823
+ * 落点 = `trajectory/delegate/`。判据(判读表 §3.1):它是**轨迹 × 轨迹**那种合法形态 ——
3824
+ * 主语是「主轨迹把活交给子 agent」,既不是某一条轨迹的切面,也不是容器宪法。
3825
+ * ⚠️ 它**不属于** `policy/`:policy 管"这条轨迹允许什么",本模块管"一条轨迹怎么把活派出去"。
3623
3826
  */
3624
3827
  /** 解析 model 字符串(provider/model)→ {provider, model};无 / 视为 model-only */
3625
3828
  function splitModel(model) {
@@ -3755,13 +3958,9 @@ Before calling cheap-worker, load eap (acc-eap skill) and design the "scale-up c
3755
3958
  (and "output" for whatever partial text was produced).
3756
3959
  `;
3757
3960
  //#endregion
3758
- //#region src/role/skiff/core.ts
3759
- /** `provider/model` → agentOptions(由 worker/core 提供,保持单一实现) */
3961
+ //#region src/trajectory/session.ts
3962
+ /** `provider/model` → agentOptions(由 delegate/core 提供,保持单一实现) */
3760
3963
  const skiffAgents = /* @__PURE__ */ new Map();
3761
- /** 查 sessionId 的会话绑定(轨迹 + ccc;无 → null)——追问校验 */
3762
- function skiffSessionInfo(sessionId) {
3763
- return skiffSessionInfo$1(sessionId);
3764
- }
3765
3964
  /** 查 sessionId 的活体 agent(进程内会话延续用;未注册/已清理 → undefined) */
3766
3965
  function getSkiffAgent(sessionId) {
3767
3966
  return skiffAgents.get(sessionId);
@@ -3903,7 +4102,10 @@ async function createSkiffAgent(ctx, root, profile, defaultModel, sessionId) {
3903
4102
  agent.ctx.systemPrompt.section({
3904
4103
  name: "serenity-skiff",
3905
4104
  order: -60,
3906
- text: () => [buildSkiffBasePrompt(profile.code), livePrompt()].filter(Boolean).join("\n")
4105
+ text: () => {
4106
+ const legacy = hasTrajectoryPrompt(root, profile.dirName) ? "" : livePrompt();
4107
+ return [buildSkiffBasePrompt(profile.code), legacy].filter(Boolean).join("\n");
4108
+ }
3907
4109
  });
3908
4110
  } catch (err) {
3909
4111
  console.warn(`[serenity-hooks] skiff 系统提示词注册失败: ${String(err?.message ?? err)}`);
@@ -9885,9 +10087,9 @@ function listWakes(root, opts = {}) {
9885
10087
  };
9886
10088
  }
9887
10089
  //#endregion
9888
- //#region src/wake/cro.ts
10090
+ //#region src/cro/core.ts
9889
10091
  /**
9890
- * wake/cro.ts — CRO(Continuous Re-Occurrence · 持续再发生)机制层(S142 §7.8,2026-09-19)
10092
+ * cro/core.ts — CRO(Continuous Re-Occurrence · 持续再发生)机制层(S142 §7.8,2026-09-19)
9891
10093
  *
9892
10094
  * ## 这东西用来干什么(白话)
9893
10095
  *
@@ -10511,9 +10713,9 @@ function registerDisposer(ctx, label, cleanup) {
10511
10713
  }
10512
10714
  }
10513
10715
  //#endregion
10514
- //#region src/wake/cro-turns.ts
10716
+ //#region src/cro/turns.ts
10515
10717
  /**
10516
- * wake/cro-turns.ts — 「这条轨迹是否正在跑轮次」的追踪(设计 §3.2,2026-09-19 S142)
10718
+ * cro/turns.ts — 「这条轨迹是否正在跑轮次」的追踪(设计 §3.2,2026-09-19 S142)
10517
10719
  *
10518
10720
  * ## 为什么需要这个模块(CRO 唯一需要 ACC 新增的状态)
10519
10721
  *
@@ -10639,7 +10841,7 @@ function runningCarriersOf(boundSessionIds, nowMs, ttlMs = TURN_TTL_MS) {
10639
10841
  * 🔴 **本处顺带更正 §2.7 的一处分档**:本文件原被列在"剩余 3 个属**真依赖**档"里,
10640
10842
  * 但**逐符号查证后与那个判断不符** ——
10641
10843
  * · `Context`:`ctx` 只被**结构化访问**(原码本就写着 `ctx as unknown as { on: … }`)
10642
- * ⇒ 该注解未提供任何类型保证(与 `role/worker/idle.ts` 同档)。
10844
+ * ⇒ 该注解未提供任何类型保证(与 `trajectory/carrier.ts` 同档)。
10643
10845
  * · `Agent`:**导入后从未使用** —— 事件 payload 一律按 `{ agent?: unknown }` 解构,
10644
10846
  * 再交给 `sessionIdOf(...)`(它收 `unknown`)。⇒ **纯粹是死 import**。
10645
10847
  * ⇒ **判据(本件再证一次,且比上一件更硬)**:分档**必须逐符号看"它有没有参与类型检查"**,
@@ -10687,9 +10889,9 @@ function registerCroTurnTracking(ctx) {
10687
10889
  });
10688
10890
  }
10689
10891
  //#endregion
10690
- //#region src/wake/cro-log.ts
10892
+ //#region src/cro/log.ts
10691
10893
  /**
10692
- * wake/cro-log.ts — CRO 唤起日志(**ACC 侧**,每轨迹一份固定名文件,2026-09-20 S142 §7.16)
10894
+ * cro/log.ts — CRO 唤起日志(**ACC 侧**,每轨迹一份固定名文件,2026-09-20 S142 §7.16)
10693
10895
  *
10694
10896
  * ## 所有者令(本模块的存在理由)
10695
10897
  *
@@ -10725,7 +10927,7 @@ function registerCroTurnTracking(ctx) {
10725
10927
  * ⇒ 清理与写入**是同一笔操作** ⇒ **结构上不存在"忘了清"的可能**(比"另设一个每 tick 的清理步"更硬:
10726
10928
  * 那多一个"可能忘了跑"的面)。窗宽是**常量**,**无配置键、无人工清理动作**。
10727
10929
  *
10728
- * ## 🔴 铁律(与 `wake/cro.ts` 同族)
10930
+ * ## 🔴 铁律(与 `cro/core.ts` 同族)
10729
10931
  *
10730
10932
  * **本模块的每个导出函数都不抛错**:写盘 / 裁剪 / 解析失败一律返回结构化失败,
10731
10933
  * 由调用方只记一行 tick 日志 ⇒ **CRO 流水的问题绝不影响投递与既有链路**(设计 §5)。
@@ -11848,9 +12050,9 @@ function registerWakeScheduler(ctx) {
11848
12050
  registerDisposer(ctx, "认知轨迹唤醒调度器", () => clock.dispose());
11849
12051
  }
11850
12052
  //#endregion
11851
- //#region src/wake/cro-guide.ts
12053
+ //#region src/cro/guide.ts
11852
12054
  /**
11853
- * wake/cro-guide.ts — CRO 编写指南(**ACC 内置**,S142 §7.8,2026-09-19)
12055
+ * cro/guide.ts — CRO 编写指南(**ACC 内置**,S142 §7.8,2026-09-19)
11854
12056
  *
11855
12057
  * ## 为什么指南在 ACC 而不在 CCC(所有者令)
11856
12058
  *
@@ -12671,11 +12873,14 @@ function validateSkiffConfig(root) {
12671
12873
  const hc = readCheapWorkerConfig(root);
12672
12874
  const allowedModels = hc ? new Set(hc.models) : null;
12673
12875
  for (const p of profiles) {
12674
- try {
12675
- if (!resolveProfileSystemPrompt(root, p).trim()) issues.push(`trajectory "${p.code}": system prompt is empty (define systemPromptFile (recommended) or systemPrompt)`);
12676
- } catch (err) {
12677
- issues.push(`trajectory "${p.code}": ${String(err?.message ?? err)}`);
12678
- }
12876
+ if (!(hasTrajectoryPrompt(root, p.dirName) || (() => {
12877
+ try {
12878
+ return resolveProfileSystemPrompt(root, p).trim() !== "";
12879
+ } catch (err) {
12880
+ issues.push(`trajectory "${p.code}": ${String(err?.message ?? err)}`);
12881
+ return true;
12882
+ }
12883
+ })())) issues.push(`trajectory "${p.code}": no trajectory prompt (add ${TRAJECTORY_PROMPT_FILENAME} in the trajectory dir — recommended — or the legacy systemPromptFile/systemPrompt)`);
12679
12884
  for (const m of p.msms) if (!registered.has(m)) issues.push(`trajectory "${p.code}": msms "${m}" is not registered in mech-registry.json`);
12680
12885
  if (p.model && allowedModels && !allowedModels.has(p.model)) issues.push(`trajectory "${p.code}": model "${p.model}" not in cheap-worker.models (${[...allowedModels].join(", ") || "(none)"})`);
12681
12886
  }
@@ -15151,7 +15356,7 @@ function saveFileToTmp(root, fileName, data) {
15151
15356
  writeFileSync(join(dir, filename), bytes);
15152
15357
  return `${FILE_UPLOAD_DIR}/${filename}`;
15153
15358
  }
15154
- function readBody$3(req, maxBytes) {
15359
+ function readBody$2(req, maxBytes) {
15155
15360
  return new Promise((resolve, reject) => {
15156
15361
  let data = "";
15157
15362
  req.on("data", (chunk) => {
@@ -15165,7 +15370,7 @@ function readBody$3(req, maxBytes) {
15165
15370
  req.on("error", reject);
15166
15371
  });
15167
15372
  }
15168
- function sendJson$3(res, code, body) {
15373
+ function sendJson$2(res, code, body) {
15169
15374
  const payload = JSON.stringify(body);
15170
15375
  res.writeHead(code, {
15171
15376
  "content-type": "application/json; charset=utf-8",
@@ -15190,7 +15395,7 @@ function senderIsWebUi(req) {
15190
15395
  */
15191
15396
  function requireWebUi(req, res, message) {
15192
15397
  if (senderIsWebUi(req)) return true;
15193
- sendJson$3(res, 403, { error: message });
15398
+ sendJson$2(res, 403, { error: message });
15194
15399
  return false;
15195
15400
  }
15196
15401
  /**
@@ -15248,11 +15453,11 @@ function registerStatusApi(ctx, opts = {}) {
15248
15453
  handler: async (req, res) => {
15249
15454
  try {
15250
15455
  if (req.method !== "POST") {
15251
- sendJson$3(res, 405, { error: "method not allowed" });
15456
+ sendJson$2(res, 405, { error: "method not allowed" });
15252
15457
  return;
15253
15458
  }
15254
15459
  if (!requireWebUi(req, res, "图片落盘仅限 WebUI(client 专用)")) return;
15255
- const raw = await readBody$3(req, 20971520);
15460
+ const raw = await readBody$2(req, 20971520);
15256
15461
  const body = JSON.parse(raw);
15257
15462
  const workspace = resolveWorkspace(ctx, {
15258
15463
  sessionId: body.sessionId,
@@ -15260,12 +15465,12 @@ function registerStatusApi(ctx, opts = {}) {
15260
15465
  });
15261
15466
  const root = cccRootForCwd(workspace);
15262
15467
  if (!root) {
15263
- sendJson$3(res, 404, { error: `no CCC found from workspace: ${workspace}` });
15468
+ sendJson$2(res, 404, { error: `no CCC found from workspace: ${workspace}` });
15264
15469
  return;
15265
15470
  }
15266
- sendJson$3(res, 200, { path: saveImageToTmp(root, body.mediaType ?? "", body.data ?? "") });
15471
+ sendJson$2(res, 200, { path: saveImageToTmp(root, body.mediaType ?? "", body.data ?? "") });
15267
15472
  } catch (err) {
15268
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15473
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15269
15474
  }
15270
15475
  }
15271
15476
  });
@@ -15275,11 +15480,11 @@ function registerStatusApi(ctx, opts = {}) {
15275
15480
  handler: async (req, res) => {
15276
15481
  try {
15277
15482
  if (req.method !== "POST") {
15278
- sendJson$3(res, 405, { error: "method not allowed" });
15483
+ sendJson$2(res, 405, { error: "method not allowed" });
15279
15484
  return;
15280
15485
  }
15281
15486
  if (!requireWebUi(req, res, "文件落盘仅限 WebUI(client 专用)")) return;
15282
- const raw = await readBody$3(req, 20971520);
15487
+ const raw = await readBody$2(req, 20971520);
15283
15488
  const body = JSON.parse(raw);
15284
15489
  const workspace = resolveWorkspace(ctx, {
15285
15490
  sessionId: body.sessionId,
@@ -15287,12 +15492,12 @@ function registerStatusApi(ctx, opts = {}) {
15287
15492
  });
15288
15493
  const root = cccRootForCwd(workspace);
15289
15494
  if (!root) {
15290
- sendJson$3(res, 404, { error: `no CCC found from workspace: ${workspace}` });
15495
+ sendJson$2(res, 404, { error: `no CCC found from workspace: ${workspace}` });
15291
15496
  return;
15292
15497
  }
15293
- sendJson$3(res, 200, { path: saveFileToTmp(root, body.name ?? "", body.data ?? "") });
15498
+ sendJson$2(res, 200, { path: saveFileToTmp(root, body.name ?? "", body.data ?? "") });
15294
15499
  } catch (err) {
15295
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15500
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15296
15501
  }
15297
15502
  }
15298
15503
  });
@@ -15308,7 +15513,7 @@ function registerStatusApi(ctx, opts = {}) {
15308
15513
  workspace: url.searchParams.get("workspace") ?? void 0
15309
15514
  }), configPaths);
15310
15515
  const runtime = ctx.get("codeRuntime");
15311
- sendJson$3(res, 200, {
15516
+ sendJson$2(res, 200, {
15312
15517
  ...status,
15313
15518
  ...runtime === void 0 ? { codeRuntime: null } : { codeRuntime: {
15314
15519
  language: runtime.language,
@@ -15319,7 +15524,7 @@ function registerStatusApi(ctx, opts = {}) {
15319
15524
  }
15320
15525
  if (req.method === "POST") {
15321
15526
  if (!requireWebUi(req, res, "safe-mode 切换仅限 WebUI(agent 不可自行开关)")) return;
15322
- const raw = await readBody$3(req, 65536);
15527
+ const raw = await readBody$2(req, 65536);
15323
15528
  const body = JSON.parse(raw);
15324
15529
  const workspace = resolveWorkspace(ctx, {
15325
15530
  sessionId: body.sessionId,
@@ -15327,19 +15532,19 @@ function registerStatusApi(ctx, opts = {}) {
15327
15532
  });
15328
15533
  const root = cccRootForCwd(workspace);
15329
15534
  if (!root) {
15330
- sendJson$3(res, 404, { error: `no CCC found from workspace: ${workspace}` });
15535
+ sendJson$2(res, 404, { error: `no CCC found from workspace: ${workspace}` });
15331
15536
  return;
15332
15537
  }
15333
15538
  const result = setSafeMode(root, body.on === true);
15334
- sendJson$3(res, 200, {
15539
+ sendJson$2(res, 200, {
15335
15540
  ...getStatus(workspace, configPaths),
15336
15541
  ...result
15337
15542
  });
15338
15543
  return;
15339
15544
  }
15340
- sendJson$3(res, 405, { error: "method not allowed" });
15545
+ sendJson$2(res, 405, { error: "method not allowed" });
15341
15546
  } catch (err) {
15342
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15547
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15343
15548
  }
15344
15549
  }
15345
15550
  });
@@ -15355,24 +15560,24 @@ function registerStatusApi(ctx, opts = {}) {
15355
15560
  try {
15356
15561
  known = projectKnownWorkspaces(hostService(ctx, "workspaceRegistry")?.list?.() ?? [], settings.gateway.workspaces);
15357
15562
  } catch {}
15358
- sendJson$3(res, 200, {
15563
+ sendJson$2(res, 200, {
15359
15564
  config: toWire(settings),
15360
15565
  knownWorkspaces: known
15361
15566
  });
15362
15567
  return;
15363
15568
  }
15364
15569
  if (req.method === "PUT") {
15365
- const raw = await readBody$3(req, 131072);
15570
+ const raw = await readBody$2(req, 131072);
15366
15571
  const saved = applyWirePatch(JSON.parse(raw).config ?? {});
15367
15572
  try {
15368
15573
  ctx.emit?.("serenity/config-updated");
15369
15574
  } catch {}
15370
- sendJson$3(res, 200, { config: toWire(saved) });
15575
+ sendJson$2(res, 200, { config: toWire(saved) });
15371
15576
  return;
15372
15577
  }
15373
- sendJson$3(res, 405, { error: "method not allowed" });
15578
+ sendJson$2(res, 405, { error: "method not allowed" });
15374
15579
  } catch (err) {
15375
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15580
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15376
15581
  }
15377
15582
  }
15378
15583
  });
@@ -15382,7 +15587,7 @@ function registerStatusApi(ctx, opts = {}) {
15382
15587
  handler: async (req, res) => {
15383
15588
  try {
15384
15589
  if (req.method !== "GET") {
15385
- sendJson$3(res, 405, { error: "method not allowed" });
15590
+ sendJson$2(res, 405, { error: "method not allowed" });
15386
15591
  return;
15387
15592
  }
15388
15593
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -15391,12 +15596,12 @@ function registerStatusApi(ctx, opts = {}) {
15391
15596
  workspace: url.searchParams.get("workspace") ?? void 0
15392
15597
  });
15393
15598
  const root = cccRootForCwd(workspace) ?? "";
15394
- sendJson$3(res, 200, { cccs: await listCccs(ctx, {
15599
+ sendJson$2(res, 200, { cccs: await listCccs(ctx, {
15395
15600
  defaultRoot: root,
15396
15601
  withRoles: true
15397
15602
  }) });
15398
15603
  } catch (err) {
15399
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15604
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15400
15605
  }
15401
15606
  }
15402
15607
  });
@@ -15410,21 +15615,21 @@ function registerStatusApi(ctx, opts = {}) {
15410
15615
  if (req.method === "GET") {
15411
15616
  const chk = requireCcc(url.searchParams.get("ccc") ?? void 0);
15412
15617
  if (!chk.ok) {
15413
- sendJson$3(res, 400, { error: chk.error });
15618
+ sendJson$2(res, 400, { error: chk.error });
15414
15619
  return;
15415
15620
  }
15416
- const { readWeixinSettings } = await import("./route-CPneN5iv.js").then((n) => n.s);
15621
+ const { readWeixinSettings } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15417
15622
  const { weixinBridgeStatus } = await Promise.resolve().then(() => bridge_exports);
15418
15623
  const settings = readWeixinSettings(chk.root);
15419
15624
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
15420
- const { readWeixinCredential } = await import("./route-CPneN5iv.js").then((n) => n.s);
15625
+ const { readWeixinCredential } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15421
15626
  const accounts = (settings.accounts ?? []).map((a) => ({
15422
15627
  accountId: a.accountId,
15423
15628
  name: a.name ?? void 0,
15424
15629
  enabled: a.enabled !== false,
15425
15630
  bound: readWeixinCredential(chk.root, a.accountId) !== null
15426
15631
  }));
15427
- sendJson$3(res, 200, {
15632
+ sendJson$2(res, 200, {
15428
15633
  enabled: settings.enabled === true,
15429
15634
  botType: settings.botType ?? void 0,
15430
15635
  accounts,
@@ -15434,22 +15639,22 @@ function registerStatusApi(ctx, opts = {}) {
15434
15639
  return;
15435
15640
  }
15436
15641
  if (req.method !== "POST") {
15437
- sendJson$3(res, 405, { error: "method not allowed" });
15642
+ sendJson$2(res, 405, { error: "method not allowed" });
15438
15643
  return;
15439
15644
  }
15440
- const raw = await readBody$3(req, 131072);
15645
+ const raw = await readBody$2(req, 131072);
15441
15646
  const body = JSON.parse(raw);
15442
15647
  const chk = requireCcc(body.ccc);
15443
15648
  if (!chk.ok) {
15444
- sendJson$3(res, 400, { error: chk.error });
15649
+ sendJson$2(res, 400, { error: chk.error });
15445
15650
  return;
15446
15651
  }
15447
15652
  const root = chk.root;
15448
15653
  if (body.action === "login-start") {
15449
- const { fetchQRCode } = await import("./ilink-DEpmE3yi.js").then((n) => n.a);
15654
+ const { fetchQRCode } = await import("./ilink-BMk2w2KY.js").then((n) => n.a);
15450
15655
  const { randomUUID } = await import("node:crypto");
15451
15656
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
15452
- const { readWeixinSettings } = await import("./route-CPneN5iv.js").then((n) => n.s);
15657
+ const { readWeixinSettings } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15453
15658
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
15454
15659
  const loginKey = randomUUID();
15455
15660
  weixinLogins.set(loginKey, {
@@ -15458,7 +15663,7 @@ function registerStatusApi(ctx, opts = {}) {
15458
15663
  startedAt: Date.now(),
15459
15664
  polling: false
15460
15665
  });
15461
- sendJson$3(res, 200, {
15666
+ sendJson$2(res, 200, {
15462
15667
  qrcode: qr.qrcode,
15463
15668
  qrcode_img_content: qr.qrcode_img_content,
15464
15669
  loginKey
@@ -15467,20 +15672,20 @@ function registerStatusApi(ctx, opts = {}) {
15467
15672
  }
15468
15673
  if (body.action === "remove-account") {
15469
15674
  if (!body.accountId) {
15470
- sendJson$3(res, 400, { error: "missing accountId" });
15675
+ sendJson$2(res, 400, { error: "missing accountId" });
15471
15676
  return;
15472
15677
  }
15473
- const { removeWeixinAccount } = await import("./route-CPneN5iv.js").then((n) => n.s);
15678
+ const { removeWeixinAccount } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15474
15679
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
15475
15680
  removeWeixinAccount(root, body.accountId);
15476
15681
  syncCccBridge(ctx, root);
15477
- sendJson$3(res, 200, { removed: body.accountId });
15682
+ sendJson$2(res, 200, { removed: body.accountId });
15478
15683
  return;
15479
15684
  }
15480
15685
  if (body.action === "save-routes") {
15481
15686
  const raw = body.routes ?? [];
15482
15687
  if (!Array.isArray(raw)) {
15483
- sendJson$3(res, 400, { error: "invalid routes (expected [{user, trajectory}, ...])" });
15688
+ sendJson$2(res, 400, { error: "invalid routes (expected [{user, trajectory}, ...])" });
15484
15689
  return;
15485
15690
  }
15486
15691
  const routes = [];
@@ -15488,7 +15693,7 @@ function registerStatusApi(ctx, opts = {}) {
15488
15693
  const user = typeof r?.user === "string" ? r.user : "";
15489
15694
  const code = typeof r?.trajectory === "string" && r.trajectory !== "" ? r.trajectory : typeof r?.role === "string" ? r.role : "";
15490
15695
  if (user === "" || code === "") {
15491
- sendJson$3(res, 400, { error: "invalid routes (each row needs non-empty user and trajectory)" });
15696
+ sendJson$2(res, 400, { error: "invalid routes (each row needs non-empty user and trajectory)" });
15492
15697
  return;
15493
15698
  }
15494
15699
  routes.push({
@@ -15496,23 +15701,23 @@ function registerStatusApi(ctx, opts = {}) {
15496
15701
  trajectory: code
15497
15702
  });
15498
15703
  }
15499
- const { saveWeixinRoutes } = await import("./route-CPneN5iv.js").then((n) => n.s);
15500
- const { findDirNameByCode } = await import("./profile-DyMJ_bfr.js").then((n) => n.i);
15704
+ const { saveWeixinRoutes } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15705
+ const { findDirNameByCode } = await import("./profile-BIQ2widI.js").then((n) => n.i);
15501
15706
  for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
15502
- sendJson$3(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
15707
+ sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
15503
15708
  return;
15504
15709
  }
15505
15710
  saveWeixinRoutes(root, routes);
15506
- sendJson$3(res, 200, { saved: routes.length });
15711
+ sendJson$2(res, 200, { saved: routes.length });
15507
15712
  return;
15508
15713
  }
15509
15714
  if (body.action === "set-enabled") {
15510
15715
  const enabled = body.enabled === true;
15511
- const { readWeixinSettings, setWeixinEnabled } = await import("./route-CPneN5iv.js").then((n) => n.s);
15716
+ const { readWeixinSettings, setWeixinEnabled } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15512
15717
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
15513
15718
  const settings = readWeixinSettings(root);
15514
15719
  if (enabled && (settings.accounts ?? []).length === 0) {
15515
- sendJson$3(res, 400, { error: "启用前请先扫码绑定至少一个账号" });
15720
+ sendJson$2(res, 400, { error: "启用前请先扫码绑定至少一个账号" });
15516
15721
  return;
15517
15722
  }
15518
15723
  setWeixinEnabled(root, enabled);
@@ -15521,12 +15726,12 @@ function registerStatusApi(ctx, opts = {}) {
15521
15726
  const { stopCccBridge } = await Promise.resolve().then(() => bridge_exports);
15522
15727
  stopCccBridge(root);
15523
15728
  }
15524
- sendJson$3(res, 200, { enabled });
15729
+ sendJson$2(res, 200, { enabled });
15525
15730
  return;
15526
15731
  }
15527
- sendJson$3(res, 400, { error: `unsupported action: ${body.action ?? ""}` });
15732
+ sendJson$2(res, 400, { error: `unsupported action: ${body.action ?? ""}` });
15528
15733
  } catch (err) {
15529
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15734
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15530
15735
  }
15531
15736
  }
15532
15737
  });
@@ -15537,22 +15742,22 @@ function registerStatusApi(ctx, opts = {}) {
15537
15742
  try {
15538
15743
  if (!requireWebUi(req, res, "微信桥配置仅限 WebUI(client 专用)")) return;
15539
15744
  if (req.method !== "GET") {
15540
- sendJson$3(res, 405, { error: "method not allowed" });
15745
+ sendJson$2(res, 405, { error: "method not allowed" });
15541
15746
  return;
15542
15747
  }
15543
15748
  const key = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("key") ?? "";
15544
15749
  const login = weixinLogins.get(key);
15545
15750
  if (!login) {
15546
- sendJson$3(res, 404, { error: "login not found or expired" });
15751
+ sendJson$2(res, 404, { error: "login not found or expired" });
15547
15752
  return;
15548
15753
  }
15549
15754
  if (Date.now() - login.startedAt > WEIXIN_LOGIN_TTL_MS) {
15550
15755
  weixinLogins.delete(key);
15551
- sendJson$3(res, 200, { status: "expired" });
15756
+ sendJson$2(res, 200, { status: "expired" });
15552
15757
  return;
15553
15758
  }
15554
- const { pollQRStatus } = await import("./ilink-DEpmE3yi.js").then((n) => n.a);
15555
- const { readWeixinSettings } = await import("./route-CPneN5iv.js").then((n) => n.s);
15759
+ const { pollQRStatus } = await import("./ilink-BMk2w2KY.js").then((n) => n.a);
15760
+ const { readWeixinSettings } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15556
15761
  const settings = readWeixinSettings(login.root);
15557
15762
  const status = await pollQRStatus({
15558
15763
  baseUrl: void 0,
@@ -15561,13 +15766,13 @@ function registerStatusApi(ctx, opts = {}) {
15561
15766
  switch (status.status) {
15562
15767
  case "confirmed": {
15563
15768
  if (!status.bot_token) {
15564
- sendJson$3(res, 200, {
15769
+ sendJson$2(res, 200, {
15565
15770
  status: "error",
15566
15771
  error: "confirmed but no bot_token"
15567
15772
  });
15568
15773
  return;
15569
15774
  }
15570
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-CPneN5iv.js").then((n) => n.s);
15775
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./route-ItCgAsnz.js").then((n) => n.c);
15571
15776
  const { syncCccBridge } = await Promise.resolve().then(() => bridge_exports);
15572
15777
  const accountId = nextWeixinAccountId(settings);
15573
15778
  upsertWeixinAccount(login.root, {
@@ -15582,7 +15787,7 @@ function registerStatusApi(ctx, opts = {}) {
15582
15787
  });
15583
15788
  syncCccBridge(ctx, login.root);
15584
15789
  weixinLogins.delete(key);
15585
- sendJson$3(res, 200, {
15790
+ sendJson$2(res, 200, {
15586
15791
  status: "confirmed",
15587
15792
  accountId,
15588
15793
  tokenSaved: true
@@ -15591,14 +15796,14 @@ function registerStatusApi(ctx, opts = {}) {
15591
15796
  }
15592
15797
  case "expired":
15593
15798
  weixinLogins.delete(key);
15594
- sendJson$3(res, 200, { status: "expired" });
15799
+ sendJson$2(res, 200, { status: "expired" });
15595
15800
  return;
15596
15801
  default:
15597
- sendJson$3(res, 200, { status: status.status ?? "wait" });
15802
+ sendJson$2(res, 200, { status: status.status ?? "wait" });
15598
15803
  return;
15599
15804
  }
15600
15805
  } catch (err) {
15601
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15806
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15602
15807
  }
15603
15808
  }
15604
15809
  });
@@ -15620,7 +15825,7 @@ function registerStatusApi(ctx, opts = {}) {
15620
15825
  const root = sessionsRootDir();
15621
15826
  if (req.method === "GET") {
15622
15827
  const { candidates } = performCleanup(root, cutoffDaysAgo(days), liveIds, { dryRun: true });
15623
- sendJson$3(res, 200, {
15828
+ sendJson$2(res, 200, {
15624
15829
  root,
15625
15830
  olderThanDays: days,
15626
15831
  dryRun: true,
@@ -15634,12 +15839,12 @@ function registerStatusApi(ctx, opts = {}) {
15634
15839
  return;
15635
15840
  }
15636
15841
  if (req.method !== "POST") {
15637
- sendJson$3(res, 405, { error: "method not allowed" });
15842
+ sendJson$2(res, 405, { error: "method not allowed" });
15638
15843
  return;
15639
15844
  }
15640
15845
  if (!requireWebUi(req, res, "会话清理仅限 WebUI(client 专用)")) return;
15641
15846
  const { result } = performCleanup(root, cutoffDaysAgo(days), liveIds);
15642
- sendJson$3(res, 200, {
15847
+ sendJson$2(res, 200, {
15643
15848
  root,
15644
15849
  olderThanDays: days,
15645
15850
  dryRun: false,
@@ -15647,7 +15852,7 @@ function registerStatusApi(ctx, opts = {}) {
15647
15852
  errors: result.errors
15648
15853
  });
15649
15854
  } catch (err) {
15650
- sendJson$3(res, 400, { error: err.message ?? String(err) });
15855
+ sendJson$2(res, 400, { error: err.message ?? String(err) });
15651
15856
  }
15652
15857
  }
15653
15858
  });
@@ -15826,7 +16031,7 @@ function loadVoyageHtml(assetPath = voyageAssetPath()) {
15826
16031
  return readFileSync(assetPath, "utf-8");
15827
16032
  }
15828
16033
  /** 发 JSON(错误分支用;与 surface/http.ts 的 sendJson 同形,但本模块不依赖它,保持零耦合) */
15829
- function sendJson$2(res, status, body) {
16034
+ function sendJson$1(res, status, body) {
15830
16035
  res.statusCode = status;
15831
16036
  res.setHeader("content-type", "application/json; charset=utf-8");
15832
16037
  res.end(JSON.stringify(body));
@@ -15844,14 +16049,14 @@ function registerVoyageApi(ctx, opts = {}) {
15844
16049
  path: VOYAGE_PATH,
15845
16050
  handler: (req, res) => {
15846
16051
  if (req.method !== "GET") {
15847
- sendJson$2(res, 405, { error: "method not allowed" });
16052
+ sendJson$1(res, 405, { error: "method not allowed" });
15848
16053
  return;
15849
16054
  }
15850
16055
  let html;
15851
16056
  try {
15852
16057
  html = loadVoyageHtml(opts.assetPath ?? voyageAssetPath());
15853
16058
  } catch (err) {
15854
- sendJson$2(res, 500, { error: `voyage asset unavailable: ${err?.message ?? String(err)}` });
16059
+ sendJson$1(res, 500, { error: `voyage asset unavailable: ${err?.message ?? String(err)}` });
15855
16060
  return;
15856
16061
  }
15857
16062
  let root = null;
@@ -16489,10 +16694,6 @@ var FaceStartCancelled = class extends Error {
16489
16694
  this.name = "FaceStartCancelled";
16490
16695
  }
16491
16696
  };
16492
- /** 面是否在监听 */
16493
- function faceActive(name) {
16494
- return faces.has(name);
16495
- }
16496
16697
  /** 面的实际监听端口(未在监听 → null) */
16497
16698
  function facePort(name) {
16498
16699
  return faces.get(name)?.handle.port ?? null;
@@ -17079,7 +17280,7 @@ function createDshCookieProvider(connection, authority) {
17079
17280
  /** 面名(C4 块 A:listener 生命周期归 `face-host`) */
17080
17281
  const FACE_GATEWAY = "gateway";
17081
17282
  /** 读取请求体(≤ maxBytes;超限 reject) */
17082
- function readBody$2(req, maxBytes) {
17283
+ function readBody$1(req, maxBytes) {
17083
17284
  return new Promise((resolve, reject) => {
17084
17285
  let data = "";
17085
17286
  req.on("data", (chunk) => {
@@ -17206,7 +17407,7 @@ async function startGateway(config, getAccounts, getDshCookie) {
17206
17407
  const session = authed(req);
17207
17408
  if (session !== void 0) {
17208
17409
  if (req.method === "POST" && isWorkspaceCreatePath(url.pathname)) {
17209
- readBody$2(req, 131072).then((body) => {
17410
+ readBody$1(req, 131072).then((body) => {
17210
17411
  const { rpcId, path } = parseWorkspaceCreateBody(body);
17211
17412
  if (!allowWorkspaceCreate) {
17212
17413
  res.writeHead(403, { "content-type": "application/json" });
@@ -19526,234 +19727,16 @@ f.parseInline;
19526
19727
  b.parse;
19527
19728
  x.lex;
19528
19729
  //#endregion
19529
- //#region src/role/skiff/debug.ts
19530
- /** 面名(C4 块 A) */
19531
- const FACE_SKIFF_DEBUG = "skiff-debug";
19532
- /** 监听地址:**只绑 loopback**(本面无认证——仅靠 loopback 挡住外部) */
19533
- const SKIFF_DEBUG_HOST = FACE_PORTS.skiffDebug.host ?? "127.0.0.1";
19534
- function skiffDebugActive() {
19535
- return faceActive(FACE_SKIFF_DEBUG);
19536
- }
19537
- function readBody$1(req) {
19538
- return new Promise((resolve, reject) => {
19539
- const chunks = [];
19540
- req.on("data", (c) => chunks.push(c));
19541
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
19542
- req.on("error", reject);
19543
- });
19544
- }
19545
- function sendJson$1(res, status, payload) {
19546
- const body = JSON.stringify(payload);
19547
- res.writeHead(status, {
19548
- "Content-Type": "application/json; charset=utf-8",
19549
- "Cache-Control": "no-store"
19550
- });
19551
- res.end(body);
19552
- }
19553
- function sendHtml(res, html) {
19554
- res.writeHead(200, {
19555
- "Content-Type": "text/html; charset=utf-8",
19556
- "Cache-Control": "no-store"
19557
- });
19558
- res.end(html);
19559
- }
19560
- /** 问答页 HTML:CCC 切换器 + 角色下拉 + 输入 + 答案区 + 轨迹区(JS 渲染)+ WebUI 链接 */
19561
- function skiffDebugPage(cccs, defaultRoot, webPort) {
19562
- const data = jscSafeJsonText(JSON.stringify(cccs).replace(/</g, "\\u003c"));
19563
- return `<!DOCTYPE html>
19564
- <html lang="zh">
19565
- <head>
19566
- <meta charset="utf-8">
19567
- <meta name="viewport" content="width=device-width, initial-scale=1">
19568
- <title>Skiff Debug — CCC cognitive subset roles</title>
19569
- <style>
19570
- :root { color-scheme: light dark; }
19571
- body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 24px; background: #f6f7f9; color: #1f2328; }
19572
- @media (prefers-color-scheme: dark) { body { background: #1a1b1e; color: #e6e6e6; } }
19573
- main { max-width: 720px; margin: 0 auto; }
19574
- h1 { font-size: 18px; margin: 0 0 4px; }
19575
- .sub { opacity: .65; font-size: 13px; margin-bottom: 16px; }
19576
- .ccc { display: inline-block; padding: 3px 10px; border-radius: 999px; background: rgba(11,168,117,.12); color: #0ba875; font-size: 12px; font-weight: 600; margin-bottom: 12px; word-break: break-all; }
19577
- @media (prefers-color-scheme: dark) { .ccc { color: #3ddc9a; } }
19578
- .sessionbar { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
19579
- .session-badge { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; padding: 3px 10px; border-radius: 999px; background: rgba(127,127,127,.12); word-break: break-all; }
19580
- .session-badge.continued { background: rgba(11,168,117,.12); color: #0ba875; }
19581
- @media (prefers-color-scheme: dark) { .session-badge.continued { color: #3ddc9a; } }
19582
- button.ghost { background: transparent; color: #57606a; border: 1px solid #d0d7de; padding: 4px 12px; font-size: 12px; border-radius: 8px; cursor: pointer; margin-top: 0; }
19583
- @media (prefers-color-scheme: dark) { button.ghost { color: #8b949e; border-color: #3a3d42; } }
19584
- button.ghost:hover { background: rgba(127,127,127,.1); }
19585
- label { font-size: 13px; font-weight: 600; display: block; margin: 12px 0 4px; }
19586
- select, textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 8px; border: 1px solid #d0d7de; background: #fff; color: inherit; font-size: 14px; }
19587
- @media (prefers-color-scheme: dark) { select, textarea { background: #26282c; border-color: #3a3d42; } }
19588
- textarea { min-height: 72px; resize: vertical; }
19589
- button { margin-top: 12px; padding: 9px 18px; border-radius: 8px; border: 0; background: #0ba875; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; }
19590
- button:disabled { opacity: .55; cursor: wait; }
19591
- #answer { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 12px; margin-top: 16px; font-size: 14px; line-height: 1.6; min-height: 48px; word-break: break-word; }
19592
- @media (prefers-color-scheme: dark) { #answer { background: #26282c; border-color: #3a3d42; } }
19593
- /* Markdown 渲染(v1.25.8):代码/标题/列表/引用/think 折叠 */
19594
- #answer p { margin: 6px 0; }
19595
- #answer h1, #answer h2, #answer h3 { margin: 12px 0 6px; font-size: 15px; line-height: 1.4; }
19596
- #answer h1 { font-size: 17px; }
19597
- #answer code { background: rgba(127,127,127,.14); border-radius: 4px; padding: 1px 5px; font-size: 13px; font-family: ui-monospace, SFMono-Regular, monospace; }
19598
- #answer pre { background: rgba(127,127,127,.1); border-radius: 8px; padding: 10px 12px; overflow-x: auto; margin: 8px 0; }
19599
- #answer pre code { background: none; padding: 0; font-size: 13px; line-height: 1.5; }
19600
- #answer ul, #answer ol { margin: 6px 0; padding-left: 22px; }
19601
- #answer li { margin: 2px 0; }
19602
- #answer blockquote { margin: 6px 0; padding: 2px 12px; border-left: 3px solid rgba(127,127,127,.35); opacity: .88; }
19603
- #answer a { color: #0ba875; text-decoration: underline; }
19604
- details.think { margin: 8px 0; border: 1px solid rgba(210,153,34,.4); border-radius: 8px; background: rgba(210,153,34,.06); }
19605
- details.think summary { cursor: pointer; padding: 6px 10px; font-size: 12px; color: #d29922; font-weight: 600; user-select: none; list-style-position: inside; }
19606
- details.think summary:hover { opacity: .8; }
19607
- details.think .think-body { padding: 2px 12px 10px; font-size: 13px; opacity: .78; white-space: normal; }
19608
- #cognitiveTrajectory { margin-top: 12px; font-size: 13px; }
19609
- .t-entry { border-left: 2px solid #d0d7de; padding: 6px 10px; margin: 6px 0; border-radius: 0 6px 6px 0; background: rgba(127,127,127,.06); white-space: pre-wrap; word-break: break-word; }
19610
- .t-user { border-left-color: #0ba875; }
19611
- .t-tool { border-left-color: #d29922; opacity: .85; font-family: ui-monospace, monospace; font-size: 12px; }
19612
- .t-role { font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; opacity: .6; margin-bottom: 2px; }
19613
- .muted { opacity: .6; font-size: 13px; }
19614
- a { color: #0ba875; }
19615
- .err { color: #cf222e; white-space: pre-wrap; }
19616
- </style>
19617
- </head>
19618
- <body>
19619
- <main>
19620
- <h1>Skiff Debug</h1>
19621
- <div class="sub">宁静号轨迹的子集角色问答页(实验性)。可切换 CCC;同一会话可追问,点「新对话」另起一条;回答在服务端渲染,think 块折叠。</div>
19622
- <div class="ccc" id="cccBadge"></div>
19623
- <div class="sessionbar">
19624
- <span id="sessionBadge" class="session-badge muted">(新会话)</span>
19625
- <button id="newChat" type="button" class="ghost">新对话</button>
19626
- </div>
19627
- <label for="ccc">认知容器</label>
19628
- <select id="ccc"></select>
19629
- <label for="role">角色</label>
19630
- <select id="role"></select>
19631
- <label for="q">问题</label>
19632
- <textarea id="q" placeholder="向该角色提问…(追问直接继续输入)"></textarea>
19633
- <button id="ask">提问</button>
19634
- <div id="answer" class="muted">等待提问…</div>
19635
- <div id="cognitiveTrajectory"></div>
19636
- <p class="muted"><a href="${`http://127.0.0.1:${webPort}`}" target="_blank" rel="noopener">在 dsh WebUI 查看完整会话</a>(会话列表搜索 sessionId;WebUI 有完整交互)</p>
19637
- </main>
19638
- <script id="skiff-data" type="application/json" data-default="${escapeHtml(defaultRoot)}">${data}<\/script>
19639
- <script>
19640
- const CCCS = JSON.parse(document.getElementById('skiff-data').textContent)
19641
- const defaultRoot = document.getElementById('skiff-data').dataset.default
19642
- const cccSel = document.getElementById('ccc')
19643
- const roleSel = document.getElementById('role')
19644
- const badge = document.getElementById('cccBadge')
19645
- const sessionBadge = document.getElementById('sessionBadge')
19646
- const newChatBtn = document.getElementById('newChat')
19647
- const btn = document.getElementById('ask')
19648
- const answer = document.getElementById('answer')
19649
- const traj = document.getElementById('cognitiveTrajectory')
19650
- const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
19651
- let sessionId = ''
19652
- function shortId(id) { return id.length > 24 ? id.slice(0, 18) + '…' + id.slice(-6) : id }
19653
- function renderSessionBadge() {
19654
- sessionBadge.textContent = sessionId ? '会话 ' + shortId(sessionId) + '(追问续接)' : '(新会话)'
19655
- sessionBadge.className = 'session-badge' + (sessionId ? ' continued' : '')
19656
- }
19657
- function newConversation() {
19658
- sessionId = ''
19659
- renderSessionBadge()
19660
- answer.className = 'muted'
19661
- answer.textContent = '已开启新对话'
19662
- traj.innerHTML = ''
19663
- document.getElementById('q').value = ''
19664
- }
19665
- function currentCcc() {
19666
- return CCCS.find((c) => c.root === cccSel.value) || (CCCS[0] || { root: '', name: '', roles: [] })
19667
- }
19668
- function fillCccs() {
19669
- cccSel.innerHTML = CCCS.map((c) => {
19670
- const n = c.roles.length
19671
- return '<option value="' + esc(c.root) + '">' + esc(c.name) + (n ? ' (' + n + ' 角色)' : ' (无角色)') + '</option>'
19672
- }).join('') || '<option value="">(未发现 CCC)</option>'
19673
- if (defaultRoot) cccSel.value = CCCS.some((c) => c.root === defaultRoot) ? defaultRoot : (CCCS[0] && CCCS[0].root)
19674
- fillRoles()
19675
- }
19676
- function fillRoles() {
19677
- const c = currentCcc()
19678
- badge.textContent = 'CCC: ' + c.root
19679
- roleSel.innerHTML = (c.roles && c.roles.length)
19680
- ? c.roles.map((r) => '<option value="' + esc(r) + '">' + esc(r) + '</option>').join('')
19681
- : '<option value="">(未配置角色)</option>'
19682
- }
19683
- cccSel.addEventListener('change', () => { fillRoles(); newConversation() })
19684
- roleSel.addEventListener('change', () => newConversation())
19685
- newChatBtn.addEventListener('click', newConversation)
19686
- fillCccs()
19687
- renderSessionBadge()
19688
- btn.addEventListener('click', async () => {
19689
- const c = currentCcc()
19690
- const role = roleSel.value
19691
- const q = document.getElementById('q').value.trim()
19692
- if (!c.root || !role || !q) { answer.className = 'err'; answer.textContent = '请选择认知容器与角色并输入问题'; return }
19693
- btn.disabled = true
19694
- answer.className = 'muted'
19695
- answer.textContent = '运行中…'
19696
- traj.innerHTML = ''
19697
- try {
19698
- const res = await fetch('/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ccc: c.root, role, question: q, sessionId: sessionId || undefined }) })
19699
- const data = await res.json()
19700
- if (!res.ok) {
19701
- // 会话不可恢复/绑定不匹配 → 前端清空 sessionId 重建(用户拍板:切换即新对话)
19702
- sessionId = ''
19703
- renderSessionBadge()
19704
- throw new Error(data.error || ('HTTP ' + res.status))
19705
- }
19706
- answer.className = ''
19707
- answer.innerHTML = data.answer_html || esc(data.answer || '') || '(空回答)'
19708
- sessionId = data.sessionId || sessionId
19709
- renderSessionBadge()
19710
- renderCognitiveTrajectory(data.cognitiveTrajectory || [], data.sessionId || '')
19711
- } catch (err) {
19712
- answer.className = 'err'
19713
- answer.textContent = String(err.message || err)
19714
- } finally {
19715
- btn.disabled = false
19716
- }
19717
- })
19718
- function renderCognitiveTrajectory(entries, sessionId) {
19719
- if (entries.length === 0) { traj.innerHTML = '<div class="muted">(本轮无轨迹)</div>'; return }
19720
- traj.innerHTML = '<div class="sub">本轮轨迹 · ' + esc(sessionId) + '</div>' + entries.map((e) => {
19721
- const cls = e.role === 'user' ? 't-user' : (e.role === 'tool' ? 't-tool' : '')
19722
- const role = e.role === 'tool' ? 'tool' + (e.tool ? ' · ' + esc(e.tool) : '') : e.role
19723
- return '<div class="t-entry ' + cls + '"><div class="t-role">' + role + '</div>' + esc(e.text) + '</div>'
19724
- }).join('')
19725
- }
19726
- <\/script>
19727
- </body>
19728
- </html>`;
19729
- }
19730
- function escapeHtml(s) {
19731
- return s.replace(/[&<>"']/g, (c) => ({
19732
- "&": "&amp;",
19733
- "<": "&lt;",
19734
- ">": "&gt;",
19735
- "\"": "&quot;",
19736
- "'": "&#39;"
19737
- })[c] ?? c);
19738
- }
19730
+ //#region src/surface/text.ts
19739
19731
  /**
19740
- * JSC (Safari/iOS) JSON.parse 快速路径正则兼容化(v1.26.9,S142 调研定稿)。
19741
- *
19742
- * 背景:WebKit bug 200190「JavaScriptCore's Regex can't match the content」——JSC 的
19743
- * JSON.parse 用**内部正则**预校验字符串;内容含**原始** `\u2028`(行分隔符)/`\u2029`
19744
- * (段分隔符)时正则无法匹配 → 对**合法 JSON** 也抛
19745
- * `SyntaxError: The string did not match the expected pattern`(sentry-javascript #2487 同源)。
19746
- * JSON.stringify **不转义** `\u2028`/`\u2029`/`\uFEFF`(它们都是合法 JSON 字符串字符)→
19747
- * 在 JSON **文本层**把它们替换为 `\uXXXX` 转义序列:JSON.parse 后语义完全一致(还原原字符),
19748
- * 且 JSC 正则看到的是常规 ASCII 转义(与 JSON.stringify 对控制字符的输出同形态,安全)。
19749
- *
19750
- * 3099 调试页客户端 `await res.json()` 与页面内嵌 JSON 均需此兼容层
19751
- * ——iOS Safari 用户实测"复杂回答"触发。(原同批还列了 3100 问答页,该面已退役,2026-10-15)
19752
- *
19753
- * @param jsonText JSON.stringify 的输出文本;原地等价替换,返回 JSC 安全文本
19732
+ * 剥离 `<think>…</think>` 块(v1.27.1,微信桥用户反馈:"过微信桥回复给用户的消息,
19733
+ * 要去掉 think 标签,用户不应看到")。复用 extractThinkBlocks 状态机(v1.26.8,
19734
+ * 弃正则)——提取后**只保留正文**(占位符替换为空,think 内容丢弃),
19735
+ * 未闭合 think 优雅截断。适用于纯文本输出面(微信等不支持 think 折叠的通道)。
19754
19736
  */
19755
- function jscSafeJsonText(jsonText) {
19756
- return jsonText.replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029").replace(/\uFEFF/g, "\\uFEFF");
19737
+ function stripThink(raw) {
19738
+ const { body } = extractThinkBlocks(raw);
19739
+ return body.replace(/\u0001T(\d+)\u0001/g, "");
19757
19740
  }
19758
19741
  /**
19759
19742
  * 提取 `<think>…</think>` 块(v1.26.8:**状态机扫描,弃正则**——用户批评"老用正则不是个办法")。
@@ -19831,140 +19814,6 @@ function matchCloseThink(s, contentStart) {
19831
19814
  }
19832
19815
  return -1;
19833
19816
  }
19834
- /**
19835
- * Markdown 渲染(v1.25.9,正经库 marked 服务端渲染——替代手写正则渲染器,S142 用户要求):
19836
- * ① 提取 `<think>…</think>` 块(v1.26.8 状态机扫描,占位符 \u0001T<idx>\u0001)
19837
- * ② 正文与 think 内容**先 escapeHtml 再 marked.parse**(GFM + breaks)——markdown 语法不受
19838
- * 转义影响,原始 HTML 注入被消除(安全);代码块内 `<` 显示为实体(可接受)
19839
- * ③ think 占位符:默认还原为 `<details class="think">` 折叠卡(🧠 思考过程,默认收起);
19840
- * **hideThink=true(v1.26.4,public 口)→ 直接移除**(思考过程对普通用户不展示)
19841
- * @param hideThink 为 true 时 `<think>` 内容完全不渲染(public 问答页体验)
19842
- */
19843
- function renderSkiffMarkdown(raw, hideThink = false) {
19844
- const { body, thinks } = extractThinkBlocks(raw);
19845
- const parsed = f.parse(escapeHtml(body), {
19846
- breaks: true,
19847
- gfm: true
19848
- });
19849
- return (typeof parsed === "string" ? parsed : "").replace(/\u0001T(\d+)\u0001/g, (_m, idx) => {
19850
- if (hideThink) return "";
19851
- const inner = thinks[Number(idx)] ?? "";
19852
- const innerHtml = inner === "" ? "" : f.parse(escapeHtml(inner), {
19853
- breaks: true,
19854
- gfm: true
19855
- });
19856
- return `<details class="think"><summary>🧠 思考过程</summary><div class="think-body">${typeof innerHtml === "string" ? innerHtml : ""}</div></details>`;
19857
- });
19858
- }
19859
- /**
19860
- * 剥离 `<think>…</think>` 块(v1.27.1,微信桥用户反馈:"过微信桥回复给用户的消息,
19861
- * 要去掉 think 标签,用户不应看到")。复用 extractThinkBlocks 状态机(v1.26.8,
19862
- * 弃正则)——提取后**只保留正文**(占位符替换为空,think 内容丢弃),
19863
- * 未闭合 think 优雅截断。适用于纯文本输出面(微信等不支持 think 折叠的通道)。
19864
- */
19865
- function stripThink(raw) {
19866
- const { body } = extractThinkBlocks(raw);
19867
- return body.replace(/\u0001T(\d+)\u0001/g, "");
19868
- }
19869
- /**
19870
- * 本面的规格(C4 块 A/④:**只提供** name/port/host/enabled/handler)。
19871
- * 路由/鉴权/HTML 全部仍归本模块的 {@link handle}(面宿主不介入面语义)。
19872
- */
19873
- function skiffDebugSpec(ctx, root, port, webPort) {
19874
- return {
19875
- name: FACE_SKIFF_DEBUG,
19876
- port,
19877
- host: SKIFF_DEBUG_HOST,
19878
- enabled: () => readSimpleSettings().skiffEnabled,
19879
- handler: (req, res) => handle(ctx, root, webPort, req, res)
19880
- };
19881
- }
19882
- /**
19883
- * 启动调试问答服务(单实例;重复启动幂等返回既有实例)。
19884
- *
19885
- * **CCC 绑定(v1.25.2 用户指出)**:服务绑定一个默认 CCC root(调用方 resolveSkiffRoot
19886
- * 解析:live 会话中**含 skiff.roles 的 CCC 优先**);v1.25.4 起页面可**手工切换**到
19887
- * 其它候选 CCC(live 会话发现的全部 CCC);角色配置**每次请求实时读取**(不缓存快照)。
19888
- *
19889
- * C4 块 A:listener 的创建/监听/错误/端口占用/关闭归 `face-host`(本函数只提供规格 + 记日志)。
19890
- *
19891
- * @param root 默认绑定的 CCC 根(首次加载选中;角色配置读取 + skiff agent cwd)
19892
- * @param port 调试端口(仅 127.0.0.1)
19893
- * @param webPort 主 WebUI 端口(WebUI 链接)
19894
- */
19895
- async function startSkiffDebugServer(ctx, root, port, webPort) {
19896
- if (skiffDebugActive()) return;
19897
- const handle = await startFace(skiffDebugSpec(ctx, root, port, webPort));
19898
- console.log(`[serenity-hooks] ✓ Skiff 调试问答页: http://127.0.0.1:${handle.port}(默认 CCC: ${root},WebUI: ${webPort})`);
19899
- }
19900
- function stopSkiffDebugServer() {
19901
- stopFace(FACE_SKIFF_DEBUG);
19902
- }
19903
- async function handle(ctx, defaultRoot, webPort, req, res) {
19904
- try {
19905
- const url = (req.url ?? "/").split("?")[0] ?? "/";
19906
- if (req.method === "GET" && url === "/") {
19907
- sendHtml(res, skiffDebugPage(await listCccs(ctx, {
19908
- defaultRoot,
19909
- withRoles: true
19910
- }), defaultRoot, webPort));
19911
- return;
19912
- }
19913
- if (req.method === "POST" && url === "/ask") {
19914
- let body;
19915
- try {
19916
- const parsed = JSON.parse(await readBody$1(req));
19917
- if (parsed === null || typeof parsed !== "object") throw new Error("not an object");
19918
- body = parsed;
19919
- } catch {
19920
- sendJson$1(res, 400, { error: "invalid JSON body" });
19921
- return;
19922
- }
19923
- const ccc = typeof body.ccc === "string" && body.ccc !== "" ? body.ccc : defaultRoot;
19924
- const code = typeof body.role === "string" ? body.role : "";
19925
- const question = typeof body.question === "string" ? body.question : "";
19926
- const sessionId = typeof body.sessionId === "string" && body.sessionId !== "" ? body.sessionId : void 0;
19927
- const profile = code !== "" ? readSkiffProfileByCode(ccc, code) : null;
19928
- if (!profile) {
19929
- sendJson$1(res, 400, { error: `unknown skiff trajectory: ${code} (no trajectory with code "${code}" has ${SKIFF_PROFILE_FILENAME} in ${ccc})` });
19930
- return;
19931
- }
19932
- if (!question.trim()) {
19933
- sendJson$1(res, 400, { error: "empty question" });
19934
- return;
19935
- }
19936
- let agent;
19937
- let continued = false;
19938
- if (sessionId) {
19939
- const info = skiffSessionInfo(sessionId);
19940
- const live = getSkiffAgent(sessionId);
19941
- if (!info || !live) {
19942
- sendJson$1(res, 400, { error: "session is not recoverable (process restarted or session unknown) — start a new conversation" });
19943
- return;
19944
- }
19945
- if (info.trajectoryDirName !== profile.dirName || info.ccc !== ccc) {
19946
- sendJson$1(res, 400, { error: `session "${sessionId}" belongs to trajectory "${info.trajectoryDirName}" in another CCC — start a new conversation` });
19947
- return;
19948
- }
19949
- agent = live;
19950
- continued = true;
19951
- }
19952
- const hc = readCheapWorkerConfig(ccc);
19953
- if (!agent) agent = (await createSkiffAgent(ctx, ccc, profile, hc?.defaultModel)).agent;
19954
- const result = await askSkiff(ctx, agent, question);
19955
- sendJson$1(res, 200, {
19956
- answer: result.answer,
19957
- answer_html: renderSkiffMarkdown(result.answer),
19958
- sessionId: result.sessionId,
19959
- continued
19960
- });
19961
- return;
19962
- }
19963
- sendJson$1(res, 404, { error: "not found" });
19964
- } catch (err) {
19965
- sendJson$1(res, 500, { error: err?.message ?? String(err) });
19966
- }
19967
- }
19968
19817
  //#endregion
19969
19818
  //#region src/guard/filter.ts
19970
19819
  /**
@@ -20436,188 +20285,63 @@ function appendHumanChannelAudit(event) {
20436
20285
  })}\n`, "utf-8");
20437
20286
  } catch {}
20438
20287
  }
20439
- const manualSessions = /* @__PURE__ */ new Map();
20288
+ //#endregion
20289
+ //#region src/im/hook.ts
20440
20290
  /**
20441
- * 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
20442
- * v1.30.17 起桥要在 `askSkiff` 返回后读它决定是否兜底,故 turn-stopping 不再结算清空)。
20291
+ * im/hook.ts — 微信桥消息记录 hook(F4c-3 扩展,v1.27.13 用户需求)
20292
+ *
20293
+ * 需求:微信桥发生的所有消息记录,支持 hook——允许 CCC 通过自行编写 hook
20294
+ * 脚本进行**持久性保存**(存哪/存成什么/是否入库,全归 CCC 决定——ACC 不绑定存储)。
20295
+ *
20296
+ * 设计(docs/weixin-message-hook-design.md v0.1,三项拍板 H1~H6):
20297
+ * - **双向触发**(H1):incoming(用户→bot,媒体落盘后)+ outgoing(bot→用户,发送成功后)
20298
+ * - **脚本 + stdin JSON**(H2):serenity.json weixin.hook = CCC 根相对脚本路径;
20299
+ * 每次消息事件 spawn 一次,事件 JSON 单行经 stdin 传入(bun 优先 / node 兜底,biasProvider 先例)
20300
+ * - **旁路容忍**(H3):异步 fire-and-forget + 超时 kill + 失败仅日志——微信桥可靠性不受影响
20301
+ * - **不含会话凭据**(H5):事件只有记录所需字段(无 context_token / bot_token——最小暴露面)
20302
+ *
20303
+ * 定位:Mech 纯逻辑(事件构造纯函数 + 执行器确定性),可独立单测。
20443
20304
  */
20444
- const sentThisTurn = /* @__PURE__ */ new Set();
20445
- /** 连续打回计数(成功发送即清零) */
20446
- const rebukeCounts = /* @__PURE__ */ new Map();
20447
- /** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
20448
- let guardActive = false;
20449
- /** 桥:登记手动模式会话(每轮 incoming 都调用,幂等覆盖) */
20450
- function noteManualOutputSession(sessionId, session) {
20451
- if (typeof sessionId !== "string" || sessionId === "") return;
20452
- manualSessions.set(sessionId, session);
20453
- }
20454
- /** 桥:非手动模式 / 会话失效 → 注销(闸门随之失效,零干预) */
20455
- function forgetManualOutputSession(sessionId) {
20456
- manualSessions.delete(sessionId);
20457
- sentThisTurn.delete(sessionId);
20458
- rebukeCounts.delete(sessionId);
20305
+ /** hook 执行超时(旁路记录足够;超时 kill 防挂死脚本) */
20306
+ const WEIXIN_HOOK_TIMEOUT_MS = 15e3;
20307
+ /** hook stderr/stdout 日志截断(防脚本刷屏) */
20308
+ const HOOK_LOG_MAX = 500;
20309
+ /** 构造 incoming 事件对象(纯函数,可测) */
20310
+ function buildIncomingHookEvent(input) {
20311
+ return {
20312
+ event: "incoming",
20313
+ ts: Date.now(),
20314
+ cccRoot: input.cccRoot,
20315
+ accountId: input.accountId,
20316
+ userId: input.userId,
20317
+ sessionId: input.sessionId,
20318
+ role: input.role,
20319
+ message: {
20320
+ text: input.text,
20321
+ media: input.media
20322
+ }
20323
+ };
20459
20324
  }
20460
- /** 本轮是否已成功发送(测试/诊断用;桥在每轮结束时读它决定是否兜底) */
20461
- function hasSentThisTurn(sessionId) {
20462
- return sentThisTurn.has(sessionId);
20325
+ /** 构造 outgoing 事件对象(纯函数,可测) */
20326
+ function buildOutgoingHookEvent(input) {
20327
+ return {
20328
+ event: "outgoing",
20329
+ ts: Date.now(),
20330
+ cccRoot: input.cccRoot,
20331
+ accountId: input.accountId,
20332
+ userId: input.userId,
20333
+ sessionId: input.sessionId,
20334
+ role: input.role,
20335
+ reply: input.reply,
20336
+ ...input.source && input.source !== "reply" ? { source: input.source } : {},
20337
+ ...input.file ? { file: input.file } : {}
20338
+ };
20463
20339
  }
20464
20340
  /**
20465
- * 桥:**每轮开始时**清空本会话的"已发送"标记(turn 边界由桥掌握——v1.30.17)。
20466
- * 闸门侧不再于 turn-stopping 结算清空,否则桥读不到本轮结果、无法兜底。
20467
- */
20468
- function clearSentThisTurn(sessionId) {
20469
- sentThisTurn.delete(sessionId);
20470
- }
20471
- /** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
20472
- function isWeixinOutputGuardActive() {
20473
- return guardActive;
20474
- }
20475
- /** 从 agent / 会话对象取会话 id(形状宽容) */
20476
- function sessionIdOf$1(value) {
20477
- const v = value;
20478
- const id = v?.session?.id ?? v?.id;
20479
- return typeof id === "string" && id !== "" ? id : null;
20480
- }
20481
- /**
20482
- * 工具调用是否为「成功的微信发送」。
20483
- *
20484
- * v1.31.0 起有两种合法形态(标记与闸门同时认,迁移期并存):
20485
- * - `im-bridge({channel:"weixin", action:"send"|"send-file", …})` —— ACC 工具(推荐)
20486
- * - `msm("weixin-send", ["send"|"send-file", …])` —— CCC MSM(v1.30.x 通道,逐步退役)
20487
- */
20488
- function isSuccessfulWeixinSend(exec, result) {
20489
- const e = exec;
20490
- if (result?.isError === true) return false;
20491
- const args = e?.arguments;
20492
- if (e?.name === "im-bridge") {
20493
- if ((typeof args?.channel === "string" ? args.channel : "") !== "weixin") return false;
20494
- const action = typeof args?.action === "string" ? args.action : "";
20495
- return action === "send" || action === "send-file";
20496
- }
20497
- if (e?.name === "msm") return args?.name === "weixin-send";
20498
- return false;
20499
- }
20500
- /** 打回消息(机制事实 + 标记;怎么做由 CCC 角色提示词定义) */
20501
- function buildManualOutputRebuke(session) {
20502
- return [
20503
- "[serenity:weixin-manual-output] no send detected in this turn — the user has received NOTHING.",
20504
- `ccc=${session.root} account=${session.accountId} user=${session.userId}`,
20505
- `im-bridge({channel:"weixin", action:"send", account:"${session.accountId}", user:"${session.userId}", text:"<回复>"})`
20506
- ].join("\n");
20507
- }
20508
- /**
20509
- * 装配闸门(index.ts apply 调用)。
20510
- * 只对**已登记的手动模式会话**生效——其他会话(主舱/其他 skiff 会话)零干预。
20511
- */
20512
- function registerWeixinOutputGuard(ctx) {
20513
- try {
20514
- ctx.on("tools/post-execute", async (exec, result, next) => {
20515
- const downstream = await next();
20516
- try {
20517
- const sessionId = sessionIdOf$1(exec?.agent);
20518
- if (sessionId && manualSessions.has(sessionId) && isSuccessfulWeixinSend(exec, result)) {
20519
- sentThisTurn.add(sessionId);
20520
- rebukeCounts.delete(sessionId);
20521
- }
20522
- } catch {}
20523
- return downstream;
20524
- });
20525
- } catch {
20526
- console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:tools/post-execute 不可用");
20527
- return;
20528
- }
20529
- try {
20530
- ctx.on("agent/turn-stopping", (payload) => {
20531
- const agent = payload?.agent;
20532
- if (!agent) return;
20533
- const sessionId = sessionIdOf$1(agent);
20534
- if (!sessionId) return;
20535
- const session = manualSessions.get(sessionId);
20536
- if (!session) return;
20537
- if (sentThisTurn.has(sessionId)) return;
20538
- const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
20539
- if (count > 2) {
20540
- rebukeCounts.delete(sessionId);
20541
- console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——本轮用户将收不到 agent 自己发的消息`);
20542
- return;
20543
- }
20544
- rebukeCounts.set(sessionId, count);
20545
- try {
20546
- agent.steer(createUserMessage({
20547
- content: [{
20548
- type: "text",
20549
- text: buildManualOutputRebuke(session)
20550
- }],
20551
- source: PLUGIN_SOURCE
20552
- }));
20553
- console.log(`[serenity-hooks] weixin 输出闸门:打回 ${count}/2(session=${sessionId}, turn=${payload.turn ?? "?"})`);
20554
- } catch (error) {
20555
- console.warn(`[serenity-hooks] ✗ weixin 输出闸门 steer 失败: ${String(error?.message ?? error)}`);
20556
- }
20557
- });
20558
- } catch {
20559
- console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:agent/turn-stopping 不可用");
20560
- return;
20561
- }
20562
- guardActive = true;
20563
- }
20564
- //#endregion
20565
- //#region src/im/hook.ts
20566
- /**
20567
- * im/hook.ts — 微信桥消息记录 hook(F4c-3 扩展,v1.27.13 用户需求)
20568
- *
20569
- * 需求:微信桥发生的所有消息记录,支持 hook——允许 CCC 通过自行编写 hook
20570
- * 脚本进行**持久性保存**(存哪/存成什么/是否入库,全归 CCC 决定——ACC 不绑定存储)。
20571
- *
20572
- * 设计(docs/weixin-message-hook-design.md v0.1,三项拍板 H1~H6):
20573
- * - **双向触发**(H1):incoming(用户→bot,媒体落盘后)+ outgoing(bot→用户,发送成功后)
20574
- * - **脚本 + stdin JSON**(H2):serenity.json weixin.hook = CCC 根相对脚本路径;
20575
- * 每次消息事件 spawn 一次,事件 JSON 单行经 stdin 传入(bun 优先 / node 兜底,biasProvider 先例)
20576
- * - **旁路容忍**(H3):异步 fire-and-forget + 超时 kill + 失败仅日志——微信桥可靠性不受影响
20577
- * - **不含会话凭据**(H5):事件只有记录所需字段(无 context_token / bot_token——最小暴露面)
20578
- *
20579
- * 定位:Mech 纯逻辑(事件构造纯函数 + 执行器确定性),可独立单测。
20580
- */
20581
- /** hook 执行超时(旁路记录足够;超时 kill 防挂死脚本) */
20582
- const WEIXIN_HOOK_TIMEOUT_MS = 15e3;
20583
- /** hook stderr/stdout 日志截断(防脚本刷屏) */
20584
- const HOOK_LOG_MAX = 500;
20585
- /** 构造 incoming 事件对象(纯函数,可测) */
20586
- function buildIncomingHookEvent(input) {
20587
- return {
20588
- event: "incoming",
20589
- ts: Date.now(),
20590
- cccRoot: input.cccRoot,
20591
- accountId: input.accountId,
20592
- userId: input.userId,
20593
- sessionId: input.sessionId,
20594
- role: input.role,
20595
- message: {
20596
- text: input.text,
20597
- media: input.media
20598
- }
20599
- };
20600
- }
20601
- /** 构造 outgoing 事件对象(纯函数,可测) */
20602
- function buildOutgoingHookEvent(input) {
20603
- return {
20604
- event: "outgoing",
20605
- ts: Date.now(),
20606
- cccRoot: input.cccRoot,
20607
- accountId: input.accountId,
20608
- userId: input.userId,
20609
- sessionId: input.sessionId,
20610
- role: input.role,
20611
- reply: input.reply,
20612
- ...input.source && input.source !== "reply" ? { source: input.source } : {},
20613
- ...input.file ? { file: input.file } : {}
20614
- };
20615
- }
20616
- /**
20617
- * 执行一次 hook:spawn CCC 根脚本,事件 JSON 单行经 stdin 传入。
20618
- * 旁路容忍(H3):超时 kill / 非 0 退出 / spawn 失败 → 仅日志返回 {ok:false},绝不抛(不阻断微信桥)。
20619
- * 路径逃逸校验:脚本必须解析在 CCC 根内(resolveInside——resolveInside 抛错也吞为 {ok:false})。
20620
- * 脚本缺失 → {ok:false}(配置了但未实现——日志提示;不视为异常)。
20341
+ * 执行一次 hook:spawn CCC 根脚本,事件 JSON 单行经 stdin 传入。
20342
+ * 旁路容忍(H3):超时 kill / 非 0 退出 / spawn 失败 → 仅日志返回 {ok:false},绝不抛(不阻断微信桥)。
20343
+ * 路径逃逸校验:脚本必须解析在 CCC 根内(resolveInside——resolveInside 抛错也吞为 {ok:false})。
20344
+ * 脚本缺失 → {ok:false}(配置了但未实现——日志提示;不视为异常)。
20621
20345
  */
20622
20346
  async function runWeixinHook(root, hookRel, event, timeoutMs = WEIXIN_HOOK_TIMEOUT_MS) {
20623
20347
  let scriptAbs;
@@ -20721,91 +20445,60 @@ function invokeWeixinHook(root, hookRel, event) {
20721
20445
  return activeRunner(root, hookRel, event);
20722
20446
  }
20723
20447
  //#endregion
20724
- //#region src/im/bridge.ts
20725
- var bridge_exports = /* @__PURE__ */ __exportAll({
20726
- handleIncoming: () => handleIncoming,
20727
- handleMachineIncoming: () => handleMachineIncoming,
20728
- manualOutputFallbackNeeded: () => manualOutputFallbackNeeded,
20729
- registerWeixinBridge: () => registerWeixinBridge,
20730
- resolveWeixinAccount: () => resolveWeixinAccount,
20731
- sendProactiveText: () => sendProactiveText,
20732
- stopAllBridges: () => stopAllBridges,
20733
- stopCccBridge: () => stopCccBridge,
20734
- syncCccBridge: () => syncCccBridge,
20735
- syncHumanChannel: () => syncHumanChannel,
20736
- weixinBridgeStatus: () => weixinBridgeStatus
20737
- });
20738
- const bridges = /* @__PURE__ */ new Map();
20448
+ //#region src/im/accounts.ts
20449
+ const impls = /* @__PURE__ */ new Map();
20450
+ /** 注册账号实现(`index.ts` 装配时调用;重复 channel 覆盖——测试与热重载友好) */
20451
+ function registerImAccountImpl(impl) {
20452
+ impls.set(impl.channel, impl);
20453
+ }
20454
+ /** 取实现(未注册 ⇒ `null`,由调用方**响亮出声**,不要静默跳过) */
20455
+ function getImAccountImpl(channel) {
20456
+ return impls.get(channel) ?? null;
20457
+ }
20458
+ /** 已注册的实现名(诊断与错误文案用;顺序 = 注册顺序) */
20459
+ function imAccountChannels() {
20460
+ return [...impls.keys()];
20461
+ }
20739
20462
  /**
20740
- * 🔴 **机器级 poller 的"根"哨兵**(P0-3 A)—— 它**不是一个目录**。
20463
+ * 账号行的渠道归一(**单一处**)。
20741
20464
  *
20742
- * 机器级 poller **不属于任何 CCC**(这正是"上移 ACC 层"的含义:谁轮询不再由 CCC 数量决定)。
20743
- * 用途有二:① `runAccountLoop` 的 `root` 形参在机器级模式下不被使用,传它以示"无根";
20744
- * ② 归属表(`im/inbound.ts`)按 root 区分拥有者 ⇒ 机器级用这个哨兵值,
20745
- * 与任何**真实** CCC 根都不会撞(真根一律是绝对路径)。
20465
+ * 缺省 `weixin`:老配置平移到 `im` 段时不必补这个字段——owner 的"统一配置"
20466
+ * 要能直接吃旧数据(与 `route.ts` 的 `normalizeImSection` 保持同一口径)。
20746
20467
  */
20747
- const MACHINE_SCOPE = "<machine>";
20748
- /** 机器级 poller(账号 id → 循环)。与 `bridges` **互斥**:同一时刻只有一种模式在跑。 */
20749
- const machineLoops = /* @__PURE__ */ new Map();
20750
- /** 轮询间隔(getupdates 失败后重试延迟;成功 = 立即续轮询) */
20468
+ function normalizeAccountChannel(channel) {
20469
+ const v = (channel ?? "").trim();
20470
+ return v === "" ? "weixin" : v;
20471
+ }
20472
+ //#endregion
20473
+ //#region src/im/weixin-account.ts
20474
+ /** 轮询间隔(getupdates 失败后重试;成功 = 立即续,长轮询本身 hold 35s) */
20751
20475
  const POLL_RETRY_MS = 3e3;
20752
- /** 媒体大小上限(M6:20MB,对齐 vlm-describe;超限降级告知不落盘) */
20753
- const MEDIA_MAX_BYTES = 20971520;
20754
- /** typing_ticket 缓存(每 (accountId, fromUserId);getconfig 一次后续复用——对齐参考实现 typingTicketCache) */
20755
- const typingTicketByUser = /* @__PURE__ */ new Map();
20756
- function typingCacheKey(accountId, fromUserId) {
20757
- return `${accountId}|${fromUserId}`;
20476
+ /**
20477
+ * 微信的凭据就绪判据:**有 token** 才算绑定(baseUrl 留空 = 走官方默认)。
20478
+ * 与既有装配处逐字同义(原 `if (token === '') { …跳过… }`)。
20479
+ */
20480
+ function weixinCredentialReady(cred) {
20481
+ return typeof cred.token === "string" && cred.token.trim() !== "";
20758
20482
  }
20759
- /** 处理开始:getconfig(无缓存时)→ sendtyping status=1(微信侧显示"正在输入...")。
20760
- * 任何失败静默(typing 不影响主流程——对齐参考实现 typingCallbacks try/catch 吞错)。 */
20761
- async function sendTypingStart(cred, accountId, fromUserId, contextToken) {
20762
- try {
20763
- const key = typingCacheKey(accountId, fromUserId);
20764
- let ticket = typingTicketByUser.get(key);
20765
- if (!ticket) {
20766
- ticket = (await getConfig({
20767
- baseUrl: cred.baseUrl,
20768
- token: cred.token,
20769
- ilinkUserId: fromUserId,
20770
- contextToken: contextToken ?? ""
20771
- })).typing_ticket;
20772
- if (ticket) typingTicketByUser.set(key, ticket);
20773
- }
20774
- if (!ticket) return;
20775
- await sendTyping({
20776
- baseUrl: cred.baseUrl,
20777
- token: cred.token,
20778
- ilinkUserId: fromUserId,
20779
- typingTicket: ticket,
20780
- status: TypingStatus.TYPING
20781
- });
20782
- } catch (err) {
20783
- console.log(`[serenity-hooks] weixin-bridge typing start skipped: ${err instanceof Error ? err.message : String(err)}`);
20784
- }
20483
+ let deps = null;
20484
+ /** 装配时注入依赖(`bridge.ts` 在模块初始化末尾调用一次;见 `installWeixinAccountImpl`) */
20485
+ function setWeixinLoopDeps(d) {
20486
+ deps = d;
20785
20487
  }
20786
- /** 处理结束(含异常路径 finally):sendtyping status=0;缓存保留(ticket 可复用)。 */
20787
- async function sendTypingStop(cred, accountId, fromUserId) {
20788
- try {
20789
- const ticket = typingTicketByUser.get(typingCacheKey(accountId, fromUserId));
20790
- if (!ticket) return;
20791
- await sendTyping({
20792
- baseUrl: cred.baseUrl,
20793
- token: cred.token,
20794
- ilinkUserId: fromUserId,
20795
- typingTicket: ticket,
20796
- status: TypingStatus.CANCEL
20797
- });
20798
- } catch (err) {
20799
- console.log(`[serenity-hooks] weixin-bridge typing stop skipped: ${err instanceof Error ? err.message : String(err)}`);
20488
+ /** 单账号轮询循环(**函数体逐字照搬自 bridge.ts 的 `runAccountLoop`,仅改类型与依赖来源**) */
20489
+ async function runWeixinAccountLoop(input, loop) {
20490
+ if (!deps) {
20491
+ loop.lastError = "weixin 实现未注入依赖(装配顺序错误)";
20492
+ return;
20800
20493
  }
20801
- }
20802
- /** 单账号轮询循环:getupdates 长轮询 → 逐消息分发 → 回复回写 */
20803
- async function runAccountLoop(ctx, root, accountId, cred, loop, machine = false) {
20494
+ const d = deps;
20495
+ const ctx = input.ctx;
20496
+ const cred = input.cred;
20804
20497
  let buf = "";
20805
20498
  while (!loop.stopped) {
20806
20499
  loop.lastPollAt = Date.now();
20807
20500
  try {
20808
- const resp = await getUpdates({
20501
+ const resp = await d.getUpdates({
20809
20502
  baseUrl: cred.baseUrl,
20810
20503
  token: cred.token,
20811
20504
  getUpdatesBuf: buf,
@@ -20815,8 +20508,8 @@ async function runAccountLoop(ctx, root, accountId, cred, loop, machine = false)
20815
20508
  for (const msg of resp.msgs ?? []) {
20816
20509
  if (loop.stopped) break;
20817
20510
  if (msg.message_type !== 1) continue;
20818
- if (machine) await handleMachineIncoming(ctx, accountId, cred, msg);
20819
- else await handleIncoming(ctx, root, accountId, cred, msg);
20511
+ if (input.machine) await d.handleMachineIncoming(ctx, input.accountId, cred, msg);
20512
+ else await d.handleIncoming(ctx, input.root, input.accountId, cred, msg);
20820
20513
  }
20821
20514
  } catch (err) {
20822
20515
  loop.lastError = err instanceof Error ? err.message : String(err);
@@ -20824,51 +20517,171 @@ async function runAccountLoop(ctx, root, accountId, cred, loop, machine = false)
20824
20517
  }
20825
20518
  }
20826
20519
  }
20520
+ /** 微信账号实现(登记的 channel 名 = `weixin`;与配置里的取值一致) */
20521
+ const weixinAccountImpl = {
20522
+ channel: "weixin",
20523
+ isReady: weixinCredentialReady,
20524
+ notReadyReason: () => "**无 token**(未绑定)",
20525
+ startLoop(input) {
20526
+ const loop = {
20527
+ stopped: false,
20528
+ lastPollAt: 0
20529
+ };
20530
+ runWeixinAccountLoop(input, loop);
20531
+ return loop;
20532
+ }
20533
+ };
20827
20534
  /**
20828
- * 手动输出模式的**机制标记**(v1.30.10 引入;v1.30.16 按 S142 用户"这个词不能让 ACC 定义,
20829
- * 要让 CCC 定义"重构)。
20830
- *
20831
- * 边界(R↓):ACC 只提供**机制与数据**——"桥不转发最终文本"这个事实 + 三个已填好的参数值
20832
- * (CCC 根 / 账号 / 用户 id)。**纪律措辞归 CCC**:怎么写、必须怎么做、后果是什么,全部写在
20833
- * CCC 的角色提示词文件里(如 `.opencode/skiff/<role>.md` 的「输出通道」节),CCC 可随时改,
20834
- * 且 v1.30.15 起角色提示词热重载——改完立即生效,无需 ACC 发版。
20835
- *
20836
- * 为什么不用 ACC 写措辞(历史教训):v1.30.10~v1.30.15 由 ACC 内嵌一段纪律文案,用户实测
20837
- * "约束不够,LLM 不听"——且措辞迭代要动插件代码 + 发版 + 重启;把措辞放 CCC 才能快速迭代,
20838
- * 也符合"ACC 管机制、CCC 管内容"的归属二分。
20535
+ * 注册微信实现**并注入依赖**(`bridge.ts` 调用;幂等——重复注册覆盖同名 channel)。
20839
20536
  *
20840
- * 标记格式(稳定,供 CCC 提示词引用;v1.31.0 起为 ACC 工具形态):
20841
- * ```
20842
- * [serenity:weixin-manual-output]
20843
- * im-bridge({channel:"weixin", action:"send", account:"<account>", user:"<user>", text:"<回复>"})
20844
- * ```
20537
+ * ⚠️ 必须在首次 `syncHumanChannel` / `syncCccBridge` **之前**发生。
20538
+ * `bridge.ts` 在**模块求值时**调用它一次(不是等到 `registerWeixinBridge`)——
20539
+ * 因为测试与面板会**直接**调 `syncCccBridge`,那时装配入口还没跑过;
20540
+ * 若依赖装配入口注册,那些路径会全部走进"未实现"分支(本轮实测:12 例红,根因就是它)。
20845
20541
  */
20846
- function weixinManualOutputMarker(root, accountId, userId) {
20847
- return ["[serenity:weixin-manual-output]", `im-bridge({channel:"weixin", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
20542
+ function installWeixinAccountImpl(d) {
20543
+ setWeixinLoopDeps(d);
20544
+ registerImAccountImpl(weixinAccountImpl);
20848
20545
  }
20546
+ const manualSessions = /* @__PURE__ */ new Map();
20849
20547
  /**
20850
- * 手动输出模式的**兜底判定**(纯函数,v1.30.17——把决策矩阵从装配里抽出来,可独立测试)。
20548
+ * 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
20549
+ * v1.30.17 起桥要在 `askSkiff` 返回后读它决定是否兜底,故 turn-stopping 不再结算清空)。
20550
+ */
20551
+ const sentThisTurn = /* @__PURE__ */ new Set();
20552
+ /** 连续打回计数(成功发送即清零) */
20553
+ const rebukeCounts = /* @__PURE__ */ new Map();
20554
+ /** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
20555
+ let guardActive = false;
20556
+ /** 桥:登记手动模式会话(每轮 incoming 都调用,幂等覆盖) */
20557
+ function noteManualOutputSession(sessionId, session) {
20558
+ if (typeof sessionId !== "string" || sessionId === "") return;
20559
+ manualSessions.set(sessionId, session);
20560
+ }
20561
+ /** 桥:非手动模式 / 会话失效 → 注销(闸门随之失效,零干预) */
20562
+ function forgetManualOutputSession(sessionId) {
20563
+ manualSessions.delete(sessionId);
20564
+ sentThisTurn.delete(sessionId);
20565
+ rebukeCounts.delete(sessionId);
20566
+ }
20567
+ /** 本轮是否已成功发送(测试/诊断用;桥在每轮结束时读它决定是否兜底) */
20568
+ function hasSentThisTurn(sessionId) {
20569
+ return sentThisTurn.has(sessionId);
20570
+ }
20571
+ /**
20572
+ * 桥:**每轮开始时**清空本会话的"已发送"标记(turn 边界由桥掌握——v1.30.17)。
20573
+ * 闸门侧不再于 turn-stopping 结算清空,否则桥读不到本轮结果、无法兜底。
20574
+ */
20575
+ function clearSentThisTurn(sessionId) {
20576
+ sentThisTurn.delete(sessionId);
20577
+ }
20578
+ /** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
20579
+ function isWeixinOutputGuardActive() {
20580
+ return guardActive;
20581
+ }
20582
+ /** 从 agent / 会话对象取会话 id(形状宽容) */
20583
+ function sessionIdOf$1(value) {
20584
+ const v = value;
20585
+ const id = v?.session?.id ?? v?.id;
20586
+ return typeof id === "string" && id !== "" ? id : null;
20587
+ }
20588
+ /**
20589
+ * 工具调用是否为「成功的微信发送」。
20851
20590
  *
20852
- * 四个条件同时成立才兜底:
20853
- * ① CCC 显式开启 `fallbackOnNoSend`(缺省 false = 严格静默,向后兼容)
20854
- * ② 机械闸门**已装配**(未装配时无法判定"是否发送过" → 宁可静默也不冒重复发送风险)
20855
- * ③ 本轮**没有**成功发送(agent 自己发过 → 输出权归 agent,桥不插话)
20856
- * ④ 有最终文本可转发(空文本无意义)
20591
+ * v1.31.0 起有两种合法形态(标记与闸门同时认,迁移期并存):
20592
+ * - `im-bridge({channel:"weixin", action:"send"|"send-file", …})` —— ACC 工具(推荐)
20593
+ * - `msm("weixin-send", ["send"|"send-file", …])` —— CCC MSM(v1.30.x 通道,逐步退役)
20857
20594
  */
20858
- function manualOutputFallbackNeeded(input) {
20859
- return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
20595
+ function isSuccessfulWeixinSend(exec, result) {
20596
+ const e = exec;
20597
+ if (result?.isError === true) return false;
20598
+ const args = e?.arguments;
20599
+ if (e?.name === "im-bridge") {
20600
+ if ((typeof args?.channel === "string" ? args.channel : "") !== "weixin") return false;
20601
+ const action = typeof args?.action === "string" ? args.action : "";
20602
+ return action === "send" || action === "send-file";
20603
+ }
20604
+ if (e?.name === "msm") return args?.name === "weixin-send";
20605
+ return false;
20606
+ }
20607
+ /** 打回消息(机制事实 + 标记;怎么做由 CCC 角色提示词定义) */
20608
+ function buildManualOutputRebuke(session) {
20609
+ return [
20610
+ "[serenity:weixin-manual-output] no send detected in this turn — the user has received NOTHING.",
20611
+ `ccc=${session.root} account=${session.accountId} user=${session.userId}`,
20612
+ `im-bridge({channel:"weixin", action:"send", account:"${session.accountId}", user:"${session.userId}", text:"<回复>"})`
20613
+ ].join("\n");
20860
20614
  }
20861
20615
  /**
20616
+ * 装配闸门(index.ts apply 调用)。
20617
+ * 只对**已登记的手动模式会话**生效——其他会话(主舱/其他 skiff 会话)零干预。
20618
+ */
20619
+ function registerWeixinOutputGuard(ctx) {
20620
+ try {
20621
+ ctx.on("tools/post-execute", async (exec, result, next) => {
20622
+ const downstream = await next();
20623
+ try {
20624
+ const sessionId = sessionIdOf$1(exec?.agent);
20625
+ if (sessionId && manualSessions.has(sessionId) && isSuccessfulWeixinSend(exec, result)) {
20626
+ sentThisTurn.add(sessionId);
20627
+ rebukeCounts.delete(sessionId);
20628
+ }
20629
+ } catch {}
20630
+ return downstream;
20631
+ });
20632
+ } catch {
20633
+ console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:tools/post-execute 不可用");
20634
+ return;
20635
+ }
20636
+ try {
20637
+ ctx.on("agent/turn-stopping", (payload) => {
20638
+ const agent = payload?.agent;
20639
+ if (!agent) return;
20640
+ const sessionId = sessionIdOf$1(agent);
20641
+ if (!sessionId) return;
20642
+ const session = manualSessions.get(sessionId);
20643
+ if (!session) return;
20644
+ if (sentThisTurn.has(sessionId)) return;
20645
+ const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
20646
+ if (count > 2) {
20647
+ rebukeCounts.delete(sessionId);
20648
+ console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——本轮用户将收不到 agent 自己发的消息`);
20649
+ return;
20650
+ }
20651
+ rebukeCounts.set(sessionId, count);
20652
+ try {
20653
+ agent.steer(createUserMessage({
20654
+ content: [{
20655
+ type: "text",
20656
+ text: buildManualOutputRebuke(session)
20657
+ }],
20658
+ source: PLUGIN_SOURCE
20659
+ }));
20660
+ console.log(`[serenity-hooks] weixin 输出闸门:打回 ${count}/2(session=${sessionId}, turn=${payload.turn ?? "?"})`);
20661
+ } catch (error) {
20662
+ console.warn(`[serenity-hooks] ✗ weixin 输出闸门 steer 失败: ${String(error?.message ?? error)}`);
20663
+ }
20664
+ });
20665
+ } catch {
20666
+ console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:agent/turn-stopping 不可用");
20667
+ return;
20668
+ }
20669
+ guardActive = true;
20670
+ }
20671
+ //#endregion
20672
+ //#region src/im/inbound-event.ts
20673
+ /** 媒体大小上限(M6:20MB,对齐 vlm-describe;超限降级告知不落盘) */
20674
+ const MEDIA_MAX_BYTES = 20971520;
20675
+ /**
20862
20676
  * 订阅表里的 `ccc` **别名 → CCC 根**(P0-3)。
20863
20677
  *
20864
- * 别名取值与面板下拉**同源**(方案 §2.3:`listCccs({withRoles:true})` 的下拉)⇒
20865
- * 先按 **`CccEntry.name`**(= CCC 根目录名,`basename(root)`)匹配;再退一步接受**绝对路径**
20866
- * (手写配置时人会直接粘路径)。两条都试 ⇒ 两种写法都可用,且**不引入第二张名字表**。
20867
- * @returns 命中的 CCC 根;枚举里没有 ⇒ `null`(调用方**响亮报错**,不静默丢弃)
20678
+ * 别名取值与面板下拉**同源** ⇒ 先按 `CccEntry.name`(= CCC 根目录名)匹配;
20679
+ * 再退一步接受**绝对路径**(手写配置时人会直接粘路径)。两条都试
20680
+ * ⇒ 两种写法都可用,且**不引入第二张名字表**。
20868
20681
  */
20869
20682
  async function resolveCccRootByAlias(ctx, alias) {
20870
20683
  try {
20871
- const { listCccs } = await import("./ccc-roots-jZYR2GVE.js").then((n) => n.a);
20684
+ const { listCccs } = await import("./ccc-roots-BhoA7G-0.js").then((n) => n.a);
20872
20685
  const entries = await listCccs(ctx);
20873
20686
  const byName = entries.find((e) => e.name === alias);
20874
20687
  if (byName) return byName.root;
@@ -20885,11 +20698,9 @@ async function resolveCccRootByAlias(ctx, alias) {
20885
20698
  * 判据**,差别只有一格 —— **有没有 CCC 级回退**(`cccFallback`)。写两遍必然分叉。
20886
20699
  *
20887
20700
  * 三档(方案 §3.1,**刻意分档**):
20888
- * - **未命中** ⇒ 不投递 + **留痕**(`console.warn`,静默档:这是"表里没有你",不是错误)
20889
- * - **表残缺** ⇒ **响亮出声**(`console.error`;且**独立于是否命中**)
20890
- * - **目标不存在**(CCC / **轨迹**)⇒ **响亮报错**(`console.error`;不静默丢弃)
20891
- *
20892
- * @param input.cccFallback 过渡期回退(方案 Q5);**机器级模式传 `null`** ⇒ 未命中即不投递
20701
+ * - **未命中** ⇒ 不投递 + **留痕**(静默档:这是"表里没有你",不是错误)
20702
+ * - **表残缺** ⇒ **响亮出声**(且**独立于是否命中**)
20703
+ * - **目标不存在**(CCC / **轨迹**)⇒ **响亮报错**(不静默丢弃)
20893
20704
  */
20894
20705
  async function resolveInboundTarget(ctx, input) {
20895
20706
  const route = planInboundRoute({
@@ -20938,7 +20749,7 @@ async function resolveInboundTarget(ctx, input) {
20938
20749
  });
20939
20750
  return null;
20940
20751
  }
20941
- const { findDirNameByCode } = await import("./profile-DyMJ_bfr.js").then((n) => n.i);
20752
+ const { findDirNameByCode } = await import("./profile-BIQ2widI.js").then((n) => n.i);
20942
20753
  if (findDirNameByCode(root, target.role) === null) {
20943
20754
  const detail = `目标 CCC "${target.ccc}" 内没有编码为 "${target.role}" 的认知轨迹`;
20944
20755
  console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表里的目标编码`);
@@ -20960,33 +20771,21 @@ async function resolveInboundTarget(ctx, input) {
20960
20771
  };
20961
20772
  }
20962
20773
  /**
20963
- * 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
20774
+ * **一条入站消息的主流程**(通道中立)。
20964
20775
  *
20965
- * 会话语义:`weixinSessionIdFor(fromUserId)` 固定可重建——同用户长期同一会话;
20966
- * 进程内已有(getSkiffAgent 命中)→ 延续;无(首次/进程重启)→ createSkiffAgent
20967
- * 固定 id resume-or-create(v1.27.2):磁盘已有持久化 log → resume(历史延续,
20968
- * 重启后记忆保留——真正的"同用户长期延续");无 log(首次)→ create。
20969
- * "新的对话已开始"通知仅真正首次(create)时发送;resume/进程内延续不发。
20776
+ * 职责:路由 → 固定会话 id 创建/延续 → 提问 → 回复(或被闸门拦下后兜底)。
20777
+ * 语料(媒体)由事件自带 `fetch()`;出站走的 `io`。
20970
20778
  *
20971
- * 完善(v1.27.3):
20972
- * - **语音支持**:`voice_item.text` = 微信服务端自带语音转写 → 与文本同路径进对话
20973
- * (无需下载/ASR);语音无转写 → 降级提示"暂时无法解析"
20974
- * - **正在输入**:处理前 sendtyping 1(微信显示"正在输入..."),处理后(含异常)0
20975
- * - **媒体接收(图片/文件)**:桥侧 CDN 下载 + AES 解密 → 落盘 CCC 根
20976
- * `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
20977
- * 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
20978
- * 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
20779
+ * 🔴 **本函数没有任何 iLink 概念** —— 这是 B1 的判据。任何 `context_token` /
20780
+ * `item_list` / `message_type` 出现在这里,就说明抽缝没抽干净。
20979
20781
  *
20980
- * 🔴 **P0-3(2026-09-27):路由改由机器级订阅表决定**(本函数的 `root` 入参退为
20981
- * **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
20982
- * **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
20983
- * **不是**"投给默认角色"。
20782
+ * @param preset 机器级模式:目标已解析好 ⇒ **不再自行路由**,且**不看本 CCC 的 `enabled`**
20984
20783
  */
20985
- async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
20986
- const fromUserId = msg.from_user_id;
20784
+ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
20785
+ const fromUserId = event.userId;
20987
20786
  if (!fromUserId) return;
20988
- const text = extractWeixinText(msg);
20989
- const mediaRefs = extractWeixinMedia(msg);
20787
+ const text = event.text;
20788
+ const mediaRefs = event.media;
20990
20789
  const settings = readWeixinSettings(root);
20991
20790
  let resolved;
20992
20791
  if (preset) resolved = preset.target;
@@ -21006,12 +20805,10 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21006
20805
  const { trajectory, profile } = resolved;
21007
20806
  root = resolved.root;
21008
20807
  if (!text && mediaRefs.length === 0) {
21009
- if (hasVoiceItem(msg)) await sendTextMessage({
21010
- baseUrl: cred.baseUrl,
21011
- token: cred.token,
20808
+ if (event.hasVoice) await io.sendText({
21012
20809
  toUserId: fromUserId,
21013
20810
  text: "(抱歉,暂时无法解析这条语音消息,请尝试发送文字)",
21014
- contextToken: msg.context_token
20811
+ replyRef: event.replyRef
21015
20812
  }).catch(() => {});
21016
20813
  return;
21017
20814
  }
@@ -21035,25 +20832,22 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21035
20832
  rebuild: true
21036
20833
  }
21037
20834
  }, hc?.defaultModel, sessionId);
21038
- if (!ref.resumed) await sendTextMessage({
21039
- baseUrl: cred.baseUrl,
21040
- token: cred.token,
20835
+ if (!ref.resumed) await io.sendText({
21041
20836
  toUserId: fromUserId,
21042
20837
  text: "(新的对话已开始)",
21043
- contextToken: msg.context_token
20838
+ replyRef: event.replyRef
21044
20839
  }).catch(() => {});
21045
- await sendTypingStart(cred, accountId, fromUserId, msg.context_token);
20840
+ await io.typingStart?.({
20841
+ toUserId: fromUserId,
20842
+ replyRef: event.replyRef
20843
+ });
21046
20844
  try {
21047
20845
  const mediaNotes = [];
21048
20846
  const degradedNotes = [];
21049
20847
  const hookMedia = [];
21050
20848
  for (const mediaRef of mediaRefs) {
21051
- const mediaType = mediaRef.kind === "image" ? "image_item" : "file_item";
21052
20849
  const label = mediaRef.kind === "image" ? "一张图片" : `文件 ${mediaRef.fileName ?? "(未命名)"}`;
21053
- const result = await downloadMedia({
21054
- item: mediaRef.item,
21055
- mediaType
21056
- });
20850
+ const result = await mediaRef.fetch();
21057
20851
  if (!result) {
21058
20852
  hookMedia.push({
21059
20853
  kind: mediaRef.kind,
@@ -21073,7 +20867,7 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21073
20867
  try {
21074
20868
  const inboundDir = weixinInboundDir(root, fromUserId);
21075
20869
  mkdirSync(inboundDir, { recursive: true });
21076
- const fname = mediaRef.kind === "image" ? `img_${Date.now()}_${randomBytes(4).toString("hex")}.${sniffImageExt(result.data)}` : sanitizeFileName$1(result.fileName ?? "") || `file_${Date.now()}_${randomBytes(4).toString("hex")}`;
20870
+ const fname = mediaRef.kind === "image" ? `img_${Date.now()}_${randomBytes(4).toString("hex")}.${sniffImageExt(result.data)}` : sanitizeFileName$1(result.fileName ?? mediaRef.fileName ?? "") || `file_${Date.now()}_${randomBytes(4).toString("hex")}`;
21077
20871
  const abs = join(inboundDir, fname);
21078
20872
  writeFileSync(abs, result.data);
21079
20873
  const rel = relative(root, abs);
@@ -21087,7 +20881,7 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21087
20881
  kind: mediaRef.kind,
21088
20882
  relPath: null
21089
20883
  });
21090
- degradedNotes.push(`(媒体保存失败,请重试)`);
20884
+ degradedNotes.push("(媒体保存失败,请重试)");
21091
20885
  }
21092
20886
  }
21093
20887
  const parts = [];
@@ -21106,7 +20900,7 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21106
20900
  userId: fromUserId,
21107
20901
  role: trajectory
21108
20902
  });
21109
- parts.push(weixinManualOutputMarker(root, accountId, fromUserId));
20903
+ parts.push(manualOutputMarker(accountId, fromUserId));
21110
20904
  } else forgetManualOutputSession(sessionId);
21111
20905
  const question = parts.join("\n");
21112
20906
  const hookRel = settings.hook;
@@ -21119,7 +20913,7 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21119
20913
  text,
21120
20914
  media: hookMedia
21121
20915
  })).catch((err) => {
21122
- console.log(`[serenity-hooks] weixin hook incoming error: ${err instanceof Error ? err.message : String(err)}`);
20916
+ console.log(`[serenity-hooks] im hook incoming error: ${err instanceof Error ? err.message : String(err)}`);
21123
20917
  });
21124
20918
  const answer = (await askSkiff(ctx, ref.agent, question)).answer ?? "";
21125
20919
  if (manualOutput) {
@@ -21130,14 +20924,12 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21130
20924
  answer
21131
20925
  })) return;
21132
20926
  const reply = markdownToPlainText(stripThink(answer));
21133
- await sendTextMessage({
21134
- baseUrl: cred.baseUrl,
21135
- token: cred.token,
20927
+ await io.sendText({
21136
20928
  toUserId: fromUserId,
21137
20929
  text: reply,
21138
- contextToken: msg.context_token
20930
+ replyRef: event.replyRef
21139
20931
  });
21140
- console.warn(`[serenity-hooks] ⚠ weixin 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, trajectory=${trajectory})`);
20932
+ console.warn(`[serenity-hooks] ⚠ im 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, trajectory=${trajectory})`);
21141
20933
  if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
21142
20934
  cccRoot: root,
21143
20935
  accountId,
@@ -21147,18 +20939,16 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21147
20939
  reply,
21148
20940
  source: "reply-fallback"
21149
20941
  })).catch((err) => {
21150
- console.log(`[serenity-hooks] weixin hook outgoing(reply-fallback) error: ${err instanceof Error ? err.message : String(err)}`);
20942
+ console.log(`[serenity-hooks] im hook outgoing(reply-fallback) error: ${err instanceof Error ? err.message : String(err)}`);
21151
20943
  });
21152
20944
  return;
21153
20945
  }
21154
20946
  if (answer === "") return;
21155
20947
  const reply = markdownToPlainText(stripThink(answer));
21156
- await sendTextMessage({
21157
- baseUrl: cred.baseUrl,
21158
- token: cred.token,
20948
+ await io.sendText({
21159
20949
  toUserId: fromUserId,
21160
20950
  text: reply,
21161
- contextToken: msg.context_token
20951
+ replyRef: event.replyRef
21162
20952
  });
21163
20953
  if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
21164
20954
  cccRoot: root,
@@ -21168,28 +20958,369 @@ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21168
20958
  role: trajectory,
21169
20959
  reply
21170
20960
  })).catch((err) => {
21171
- console.log(`[serenity-hooks] weixin hook outgoing error: ${err instanceof Error ? err.message : String(err)}`);
20961
+ console.log(`[serenity-hooks] im hook outgoing error: ${err instanceof Error ? err.message : String(err)}`);
21172
20962
  });
21173
20963
  } finally {
21174
- await sendTypingStop(cred, accountId, fromUserId);
20964
+ await io.typingStop?.({ toUserId: fromUserId });
21175
20965
  }
21176
20966
  } catch (err) {
21177
20967
  const msg = err instanceof Error ? err.message : String(err);
21178
- console.log(`[serenity-hooks] weixin-bridge error (ccc=${root}): ${msg}`);
20968
+ console.log(`[serenity-hooks] im-bridge error (ccc=${root}): ${msg}`);
21179
20969
  const stack = err instanceof Error ? err.stack : void 0;
21180
- if (stack) console.log(`[serenity-hooks] weixin-bridge stack:\n${stack.slice(0, 1500)}`);
20970
+ if (stack) console.log(`[serenity-hooks] im-bridge stack:\n${stack.slice(0, 1500)}`);
20971
+ }
20972
+ }
20973
+ /**
20974
+ * 手动输出模式的**机制标记**(v1.30.10 引入;v1.30.16 按「ACC 管机制、CCC 管措辞」重构)。
20975
+ *
20976
+ * 边界(R↓):ACC 只提供**机制与数据**——"桥不转发最终文本"这个事实 + 三个已填好的参数值。
20977
+ * **纪律措辞归 CCC**:怎么写、必须怎么做、后果是什么,全部写在轨迹的 `PROMPT.md` 里。
20978
+ */
20979
+ function manualOutputMarker(accountId, userId) {
20980
+ return ["[serenity:weixin-manual-output]", `im-bridge({channel:"weixin", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
20981
+ }
20982
+ /**
20983
+ * 手动输出模式的**兜底判定**(纯函数,v1.30.17——把决策矩阵从装配里抽出来,可独立测试)。
20984
+ *
20985
+ * 四个条件同时成立才兜底:
20986
+ * ① CCC 显式开启 `fallbackOnNoSend`(缺省 false = 严格静默)
20987
+ * ② 机械闸门**已装配**(未装配时无法判定"是否发送过" → 宁可静默也不冒重复发送风险)
20988
+ * ③ 本轮**没有**成功发送(agent 自己发过 → 输出权归 agent,桥不插话)
20989
+ * ④ 有最终文本可转发(空文本无意义)
20990
+ */
20991
+ function manualOutputFallbackNeeded(input) {
20992
+ return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
20993
+ }
20994
+ //#endregion
20995
+ //#region src/im/feishu-account.ts
20996
+ /** 去重集合上限(飞书重推窗口有限;限量防内存无界增长) */
20997
+ const SEEN_LIMIT = 2e3;
20998
+ /** 已处理过的 message_id(超时重推去重;按账号隔离) */
20999
+ const seenByAccount = /* @__PURE__ */ new Map();
21000
+ /** 取 SDK(未注入 ⇒ 动态 import 真包) */
21001
+ async function loadFeishuSdk() {
21002
+ return await import("./lib-TxscFKeo.js").then((m) => /* @__PURE__ */ __toESM(m.default, 1));
21003
+ }
21004
+ /**
21005
+ * 账号 → 长连接客户端(`ImAccountLoop` 的形状是面板既有契约,**不加字段** ——
21006
+ * 加字段会逼面板与 `im-bridge status` 认识一个通道特有的东西)。
21007
+ * 用途:`stopped` 置位后能真正 `close()` 掉连接(只置标志位而不关连接 = 假停止)。
21008
+ */
21009
+ const wsByAccount = /* @__PURE__ */ new Map();
21010
+ function seenSet(accountId) {
21011
+ let s = seenByAccount.get(accountId);
21012
+ if (!s) {
21013
+ s = /* @__PURE__ */ new Set();
21014
+ seenByAccount.set(accountId, s);
21015
+ }
21016
+ return s;
21017
+ }
21018
+ /**
21019
+ * 飞书凭据就绪判据:**appId 与 appSecret 都非空**。
21020
+ * (微信只需 token;飞书是双要素,缺任一起不了长连接。)
21021
+ */
21022
+ function feishuCredentialReady(cred) {
21023
+ const appId = typeof cred.appId === "string" ? cred.appId.trim() : "";
21024
+ const secret = typeof cred.token === "string" ? cred.token.trim() : "";
21025
+ return appId !== "" && secret !== "";
21026
+ }
21027
+ /**
21028
+ * 从飞书 `im.message.receive_v1` 事件里取出**中立入站事件**。
21029
+ *
21030
+ * 🔴 这是 P3 唯一的"形状翻译"处,与 `bridge.ts` 里 iLink 的那一处对称。
21031
+ * ⚠️ 用户标识用 **open_id**(不是 chat_id):路由表是"人 → 轨迹",
21032
+ * 而 chat_id 在群里会变、在单聊里也与人不是一一对应。
21033
+ * @returns 无可用发送者/消息体 ⇒ `null`(调用方丢弃并**出声**,不静默)
21034
+ */
21035
+ function feishuEventToInbound(data) {
21036
+ const sender = data.sender;
21037
+ const message = data.message;
21038
+ const userId = sender?.sender_id?.open_id;
21039
+ if (!userId || !message?.message_id) return null;
21040
+ let text = null;
21041
+ if (message.message_type === "text" && typeof message.content === "string") try {
21042
+ const parsed = JSON.parse(message.content);
21043
+ text = (typeof parsed.text === "string" ? parsed.text.trim() : "").replace(/@_user_\d+/g, "").trim();
21044
+ if (text === "") text = null;
21045
+ } catch {
21046
+ text = null;
21047
+ }
21048
+ return {
21049
+ userId,
21050
+ text,
21051
+ media: [],
21052
+ hasVoice: false,
21053
+ replyRef: void 0
21054
+ };
21055
+ }
21056
+ /**
21057
+ * 造出站动作面(飞书)。
21058
+ *
21059
+ * 🔴 `content` 必须是 **JSON 字符串**:`JSON.stringify({ text })` —— 这是官方形态,
21060
+ * 直接给裸文本会被服务端拒绝(且错误码不直观)。
21061
+ */
21062
+ function feishuIo(client) {
21063
+ const c = client;
21064
+ return { sendText: async ({ toUserId, text }) => {
21065
+ const create = c.im?.v1?.message?.create;
21066
+ if (typeof create !== "function") throw new Error("飞书 SDK 缺少 im.v1.message.create");
21067
+ await create({
21068
+ params: { receive_id_type: "open_id" },
21069
+ data: {
21070
+ receive_id: toUserId,
21071
+ msg_type: "text",
21072
+ content: JSON.stringify({ text: markdownToPlainText(text) })
21073
+ }
21074
+ });
21075
+ } };
21076
+ }
21077
+ /**
21078
+ * 单账号的飞书长连接循环。
21079
+ *
21080
+ * 与微信的**关键结构差别**:微信是 `while` 轮询,飞书是**事件回调**(长连接推)。
21081
+ * 所以这里的"循环"其实是"建连一次,然后等回调";`stopped` 靠 `close()` 兑现。
21082
+ */
21083
+ async function runFeishuAccountLoop(input, loop) {
21084
+ const cred = input.cred;
21085
+ const appId = cred.appId ?? "";
21086
+ const appSecret = cred.token ?? "";
21087
+ if (!appId || !appSecret) {
21088
+ loop.lastError = "飞书凭据不全(缺 appId 或 appSecret)";
21089
+ return;
21090
+ }
21091
+ const Lark = await loadFeishuSdk();
21092
+ const client = new Lark.Client({
21093
+ appId,
21094
+ appSecret
21095
+ });
21096
+ const dispatcher = new Lark.EventDispatcher({});
21097
+ const queue = [];
21098
+ let draining = false;
21099
+ const drain = async () => {
21100
+ if (draining) return;
21101
+ draining = true;
21102
+ try {
21103
+ while (queue.length > 0 && !loop.stopped) {
21104
+ const item = queue.shift();
21105
+ loop.lastPollAt = Date.now();
21106
+ try {
21107
+ const event = feishuEventToInbound(item.data);
21108
+ if (!event) {
21109
+ loop.lastError = "收到飞书事件但取不出发送者/消息体";
21110
+ continue;
21111
+ }
21112
+ const msgId = item.data.message?.message_id ?? "";
21113
+ if (msgId !== "") {
21114
+ const seen = seenSet(input.accountId);
21115
+ if (seen.has(msgId)) continue;
21116
+ seen.add(msgId);
21117
+ if (seen.size > SEEN_LIMIT) {
21118
+ const oldest = seen.values().next().value;
21119
+ if (oldest !== void 0) seen.delete(oldest);
21120
+ }
21121
+ }
21122
+ const ctx = input.ctx;
21123
+ const io = feishuIo(client);
21124
+ if (input.machine) {
21125
+ const target = await resolveInboundTarget(ctx, {
21126
+ account: input.accountId,
21127
+ user: event.userId,
21128
+ cccFallback: null
21129
+ });
21130
+ if (!target) continue;
21131
+ await handleInboundEvent(ctx, target.root, input.accountId, io, event, { target });
21132
+ } else await handleInboundEvent(ctx, input.root, input.accountId, io, event);
21133
+ } catch (err) {
21134
+ loop.lastError = err instanceof Error ? err.message : String(err);
21135
+ }
21136
+ }
21137
+ } finally {
21138
+ draining = false;
21139
+ }
21140
+ };
21141
+ dispatcher.register({ "im.message.receive_v1": (data) => {
21142
+ queue.push({ data });
21143
+ drain();
21144
+ } });
21145
+ const ws = new Lark.WSClient({
21146
+ appId,
21147
+ appSecret
21148
+ });
21149
+ wsByAccount.set(input.accountId, ws);
21150
+ try {
21151
+ await ws.start({ eventDispatcher: dispatcher });
21152
+ } catch (err) {
21153
+ loop.lastError = `飞书长连接建立失败: ${err instanceof Error ? err.message : String(err)}`;
21154
+ }
21155
+ }
21156
+ /** 飞书账号实现(登记名 = `feishu`;与配置里的 `channel` 取值一致) */
21157
+ const feishuAccountImpl = {
21158
+ channel: "feishu",
21159
+ isReady: feishuCredentialReady,
21160
+ notReadyReason: () => "**缺 appId 或 appSecret**(未绑定)",
21161
+ startLoop(input) {
21162
+ const loop = {
21163
+ stopped: false,
21164
+ lastPollAt: 0
21165
+ };
21166
+ runFeishuAccountLoop(input, loop);
21167
+ return loop;
21168
+ }
21169
+ };
21170
+ /**
21171
+ * 注册飞书实现(`bridge.ts` 在**模块求值时**调用)。
21172
+ *
21173
+ * ⚠️ 与微信同款教训(续 220 实测 12 例红):注册必须在**模块求值**时发生,
21174
+ * 不能等 `apply` —— 测试与面板会**直接**调 `syncCccBridge` / `syncHumanChannel`。
21175
+ */
21176
+ function installFeishuAccountImpl() {
21177
+ registerImAccountImpl(feishuAccountImpl);
21178
+ }
21179
+ //#endregion
21180
+ //#region src/im/bridge.ts
21181
+ var bridge_exports = /* @__PURE__ */ __exportAll({
21182
+ handleIncoming: () => handleIncoming,
21183
+ handleMachineIncoming: () => handleMachineIncoming,
21184
+ registerWeixinBridge: () => registerWeixinBridge,
21185
+ resolveInboundTarget: () => resolveInboundTarget,
21186
+ resolveWeixinAccount: () => resolveWeixinAccount,
21187
+ sendProactiveText: () => sendProactiveText,
21188
+ stopAllBridges: () => stopAllBridges,
21189
+ stopCccBridge: () => stopCccBridge,
21190
+ syncCccBridge: () => syncCccBridge,
21191
+ syncHumanChannel: () => syncHumanChannel,
21192
+ weixinBridgeStatus: () => weixinBridgeStatus
21193
+ });
21194
+ const bridges = /* @__PURE__ */ new Map();
21195
+ /**
21196
+ * 🔴 **机器级 poller 的"根"哨兵**(P0-3 A)—— 它**不是一个目录**。
21197
+ *
21198
+ * 机器级 poller **不属于任何 CCC**(这正是"上移 ACC 层"的含义:谁轮询不再由 CCC 数量决定)。
21199
+ * 用途有二:① `runAccountLoop` 的 `root` 形参在机器级模式下不被使用,传它以示"无根";
21200
+ * ② 归属表(`im/inbound.ts`)按 root 区分拥有者 ⇒ 机器级用这个哨兵值,
21201
+ * 与任何**真实** CCC 根都不会撞(真根一律是绝对路径)。
21202
+ */
21203
+ const MACHINE_SCOPE = "<machine>";
21204
+ /** 机器级 poller(账号 id → 循环)。与 `bridges` **互斥**:同一时刻只有一种模式在跑。 */
21205
+ const machineLoops = /* @__PURE__ */ new Map();
21206
+ /** typing_ticket 缓存(每 (accountId, fromUserId);getconfig 一次后续复用——对齐参考实现 typingTicketCache) */
21207
+ const typingTicketByUser = /* @__PURE__ */ new Map();
21208
+ function typingCacheKey(accountId, fromUserId) {
21209
+ return `${accountId}|${fromUserId}`;
21210
+ }
21211
+ /** 处理开始:getconfig(无缓存时)→ sendtyping status=1(微信侧显示"正在输入...")。
21212
+ * 任何失败静默(typing 不影响主流程——对齐参考实现 typingCallbacks try/catch 吞错)。 */
21213
+ async function sendTypingStart(cred, accountId, fromUserId, contextToken) {
21214
+ try {
21215
+ const key = typingCacheKey(accountId, fromUserId);
21216
+ let ticket = typingTicketByUser.get(key);
21217
+ if (!ticket) {
21218
+ ticket = (await getConfig({
21219
+ baseUrl: cred.baseUrl,
21220
+ token: cred.token,
21221
+ ilinkUserId: fromUserId,
21222
+ contextToken: contextToken ?? ""
21223
+ })).typing_ticket;
21224
+ if (ticket) typingTicketByUser.set(key, ticket);
21225
+ }
21226
+ if (!ticket) return;
21227
+ await sendTyping({
21228
+ baseUrl: cred.baseUrl,
21229
+ token: cred.token,
21230
+ ilinkUserId: fromUserId,
21231
+ typingTicket: ticket,
21232
+ status: TypingStatus.TYPING
21233
+ });
21234
+ } catch (err) {
21235
+ console.log(`[serenity-hooks] weixin-bridge typing start skipped: ${err instanceof Error ? err.message : String(err)}`);
21236
+ }
21237
+ }
21238
+ /** 处理结束(含异常路径 finally):sendtyping status=0;缓存保留(ticket 可复用)。 */
21239
+ async function sendTypingStop(cred, accountId, fromUserId) {
21240
+ try {
21241
+ const ticket = typingTicketByUser.get(typingCacheKey(accountId, fromUserId));
21242
+ if (!ticket) return;
21243
+ await sendTyping({
21244
+ baseUrl: cred.baseUrl,
21245
+ token: cred.token,
21246
+ ilinkUserId: fromUserId,
21247
+ typingTicket: ticket,
21248
+ status: TypingStatus.CANCEL
21249
+ });
21250
+ } catch (err) {
21251
+ console.log(`[serenity-hooks] weixin-bridge typing stop skipped: ${err instanceof Error ? err.message : String(err)}`);
21181
21252
  }
21182
21253
  }
21183
21254
  /**
21255
+ * 🔴 **2026-10-19(P2)`runAccountLoop` 已迁出本文件** —— 逐字搬到
21256
+ * `im/weixin-account.ts`,成为 `ImAccountImpl` 的一个实现(微信)。
21257
+ *
21258
+ * 为什么搬:装配处原先把渠道写死在这里(`channel !== 'weixin'` ⇒ 跳过)。
21259
+ * owner 定向「飞书和微信一样,只不过是 account 方实现不同」⇒ 起循环这个动作
21260
+ * 归**账号自己的实现**,装配处只按 `channel` 查派发表。
21261
+ * ⚠️ 依赖(`getUpdates` / `handleIncoming` / `handleMachineIncoming`)通过
21262
+ * `installWeixinAccountImpl` **注入**,避免 `bridge ⇄ weixin-account` 互相 import 成环。
21263
+ */
21264
+ /**
21265
+ * 处理单条**微信**消息:把 iLink 形状的 `msg` 归一成**中立入站事件**,
21266
+ * 再交给通道中立的 `handleInboundEvent`(`im/inbound-event.ts`)走主流程。
21267
+ *
21268
+ * ## 🔴 2026-10-19(B1):本函数已退化为**薄适配器**,主流程不在本文件
21269
+ *
21270
+ * 原因:`handleIncoming` 原先同时承担两件不同的事 —— ① "iLink 消息长什么样"
21271
+ * ② "一条已到达的消息该投到哪、怎么答、怎么记账"。接第二家 IM 时只有 ① 需要新写,
21272
+ * 而 ② 应该**被两家共用**。两者混在一个函数里 ⇒ 飞书接进来只能复制一整份主流程。
21273
+ *
21274
+ * ⇒ 主流程搬到 `im/inbound-event.ts`,本函数只做**形状翻译**:
21275
+ * `msg`(iLink)+ `cred`(iLink 凭据)→ `ImInboundEvent` + `ImAccountIo`。
21276
+ *
21277
+ * ## 为什么保留这个签名(而不是让调用方直接调新的主流程)
21278
+ *
21279
+ * 全仓约 50 处测试按 iLink 形状驱动本函数,而 B1 的验收判据是「**微信行为逐字不变**」
21280
+ * (用例数守恒 + 真机一条微信)。保留签名 ⇒ 那 50 处**逐字不动**,判据可复算。
21281
+ *
21282
+ * 会话语义(不变):会话 id 由**轨迹编码**派生 ⇒ 同一条轨迹恒同一条 dsh 会话;
21283
+ * 进程内已有 → 延续;无 → 固定 id resume-or-create。
21284
+ */
21285
+ async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
21286
+ const fromUserId = msg.from_user_id;
21287
+ if (!fromUserId) return;
21288
+ await handleInboundEvent(ctx, root, accountId, {
21289
+ sendText: async ({ toUserId, text, replyRef }) => {
21290
+ await sendTextMessage({
21291
+ baseUrl: cred.baseUrl,
21292
+ token: cred.token,
21293
+ toUserId,
21294
+ text,
21295
+ contextToken: replyRef
21296
+ });
21297
+ },
21298
+ typingStart: ({ toUserId, replyRef }) => sendTypingStart(cred, accountId, toUserId, replyRef),
21299
+ typingStop: ({ toUserId }) => sendTypingStop(cred, accountId, toUserId)
21300
+ }, {
21301
+ userId: fromUserId,
21302
+ text: extractWeixinText(msg),
21303
+ media: extractWeixinMedia(msg).map((ref) => ({
21304
+ kind: ref.kind,
21305
+ fileName: ref.fileName,
21306
+ fetch: () => downloadMedia({
21307
+ item: ref.item,
21308
+ mediaType: ref.kind === "image" ? "image_item" : "file_item"
21309
+ }).then((r) => r ? {
21310
+ data: r.data,
21311
+ fileName: r.fileName
21312
+ } : null)
21313
+ })),
21314
+ hasVoice: hasVoiceItem(msg),
21315
+ replyRef: msg.context_token
21316
+ }, preset);
21317
+ }
21318
+ /**
21184
21319
  * **机器级 poller 的单条消息入口**(P0-3 A)。
21185
21320
  *
21186
21321
  * 与 `handleIncoming` 的唯一差别:**目标由机器级订阅表逐条解析,且没有 CCC 级回退**
21187
21322
  * (方案 §3.1:机器级模式下**未命中 = 不投递**,不是"投给默认角色")。
21188
- * 解析出来后把**目标根**当 `root` 交给 `handleIncoming`(并带上 `preset` ⇒ 它不再自行路由、
21189
- * 也不看目标 CCC 的 `weixin.enabled`)。
21190
- *
21191
- * 🔴 为什么"起 poller"与"路由"必须同批(本件即 P0-2 的后半):机器级 poller 收到消息时
21192
- * **不知道它属于哪个 CCC** —— 那个答案是订阅表给的。没有订阅表 ⇒ **能收不能投**。
21323
+ * 解析出来后把**目标根**当 `root` 交给 `handleIncoming`(并带上 `preset`)。
21193
21324
  *
21194
21325
  * 🔵 **导出供测试直接驱动**(与 `handleIncoming` 同款:它俩都是"单条消息的入口",
21195
21326
  * 生产路径 `runAccountLoop` 只是循环壳)。
@@ -21206,6 +21337,35 @@ async function handleMachineIncoming(ctx, accountId, cred, msg) {
21206
21337
  await handleIncoming(ctx, target.root, accountId, cred, msg, { target });
21207
21338
  }
21208
21339
  /**
21340
+ * **按渠道读凭据**(P3 起的单一入口)。
21341
+ *
21342
+ * 为什么必须抽成一个函数:CCC 级桥(`syncCccBridge`)与机器级 poller(`syncMachinePollers`)
21343
+ * 是**两条装配路径**,原先各自写着"怎么从 localstore 取微信 token"。接第二家渠道时若两边各写一份,
21344
+ * 必然分叉(一家改了另一家忘改,且**没有任何机械信号**)。
21345
+ *
21346
+ * 两种取法的**真实差别**(不是风格差异):
21347
+ * - **CCC 级**:凭据在 localstore(`WEIXIN_<A>_TOKEN` / `FEISHU_<A>_APPID|SECRET`)。
21348
+ * - **机器级**:凭据在插件设置行(`account.token`)——那是宿主设置面,不经 localstore。
21349
+ *
21350
+ * ⇒ 本函数只负责**把"渠道"翻译成"该读哪套字段"**,返回统一形状 `ImAccountCredential`。
21351
+ * 微信的 appId 恒 undefined;飞书把 appSecret 装进 `token`(让两边共用同一个就绪判据位置)。
21352
+ */
21353
+ function readImCredentialByChannel(root, accountId, channel) {
21354
+ if (channel === "feishu") {
21355
+ const f = readFeishuCredential(root, accountId);
21356
+ return f ? {
21357
+ appId: f.appId,
21358
+ token: f.appSecret
21359
+ } : null;
21360
+ }
21361
+ const w = readWeixinCredential(root, accountId);
21362
+ return w ? {
21363
+ token: w.token,
21364
+ baseUrl: w.baseUrl,
21365
+ userId: w.userId
21366
+ } : null;
21367
+ }
21368
+ /**
21209
21369
  * 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
21210
21370
  * 已存在(配置变化热重建)→ 先停旧循环再启动新的。
21211
21371
  *
@@ -21225,15 +21385,28 @@ function syncCccBridge(ctx, root) {
21225
21385
  if (!settings.enabled) return;
21226
21386
  const requests = [];
21227
21387
  const creds = /* @__PURE__ */ new Map();
21388
+ /** 账号 id → 它的实现(P2:按 `channel` 查派发表) */
21389
+ const implByAccount = /* @__PURE__ */ new Map();
21228
21390
  for (const account of settings.accounts ?? []) {
21229
21391
  if (account.enabled === false) continue;
21230
- const cred = readWeixinCredential(root, account.accountId);
21392
+ const channel = normalizeAccountChannel(account.channel);
21393
+ const impl = getImAccountImpl(channel);
21394
+ if (!impl) {
21395
+ if (!warnedChannels.has(`${account.accountId}:${channel}`)) {
21396
+ warnedChannels.add(`${account.accountId}:${channel}`);
21397
+ console.warn(`[serenity-hooks] ⚠ weixin-bridge: 账号 ${account.accountId}(ccc=${root})的渠道 "${channel}" **本机未实现** ⇒ 不起循环(已实现:${imAccountChannels().join(", ") || "(无)"})`);
21398
+ }
21399
+ continue;
21400
+ }
21401
+ const cred = readImCredentialByChannel(root, account.accountId, channel);
21231
21402
  if (!cred) continue;
21403
+ if (!impl.isReady(cred)) continue;
21404
+ implByAccount.set(account.accountId, impl);
21232
21405
  creds.set(account.accountId, cred);
21233
21406
  requests.push({
21234
21407
  root,
21235
21408
  accountId: account.accountId,
21236
- identity: accountIdentity(root, account.accountId, cred.token)
21409
+ identity: accountIdentity(root, account.accountId, cred.token ?? "")
21237
21410
  });
21238
21411
  }
21239
21412
  const plan = planPollerClaims(requests, pollerOwners());
@@ -21244,13 +21417,16 @@ function syncCccBridge(ctx, root) {
21244
21417
  };
21245
21418
  for (const claim of plan.claims) {
21246
21419
  const cred = creds.get(claim.accountId);
21247
- if (!cred) continue;
21248
- const loop = {
21249
- stopped: false,
21250
- lastPollAt: 0
21251
- };
21420
+ const impl = implByAccount.get(claim.accountId);
21421
+ if (!cred || !impl) continue;
21422
+ const loop = impl.startLoop({
21423
+ ctx,
21424
+ root,
21425
+ accountId: claim.accountId,
21426
+ cred,
21427
+ machine: false
21428
+ });
21252
21429
  bridge.loops.set(claim.accountId, loop);
21253
- runAccountLoop(ctx, root, claim.accountId, cred, loop);
21254
21430
  }
21255
21431
  commitPollerClaims(plan);
21256
21432
  if (bridge.loops.size > 0) {
@@ -21301,6 +21477,8 @@ function syncMachinePollers(ctx, hc) {
21301
21477
  stopCccBridges();
21302
21478
  const requests = [];
21303
21479
  const creds = /* @__PURE__ */ new Map();
21480
+ /** 账号 id → 它的实现(P2:**按 `channel` 查派发表**,不再写死 weixin) */
21481
+ const implByAccount = /* @__PURE__ */ new Map();
21304
21482
  for (const account of hc.accounts) {
21305
21483
  const id = account.id;
21306
21484
  if (account.enabled === false) continue;
@@ -21308,23 +21486,29 @@ function syncMachinePollers(ctx, hc) {
21308
21486
  console.warn("[serenity-hooks] ⚠ human-channel: 机器级账号表有一行**缺 `id`** ⇒ 跳过(该行无法被订阅表引用)");
21309
21487
  continue;
21310
21488
  }
21311
- if (account.channel !== "weixin") {
21312
- if (!warnedChannels.has(`${id}:${account.channel ?? ""}`)) {
21313
- warnedChannels.add(`${id}:${account.channel ?? ""}`);
21314
- console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} 的渠道 "${account.channel ?? "(未填)"}" **本机未实现** ⇒ 不起 poller(当前只有 weixin)`);
21489
+ const channel = normalizeAccountChannel(account.channel);
21490
+ const impl = getImAccountImpl(channel);
21491
+ if (!impl) {
21492
+ if (!warnedChannels.has(`${id}:${channel}`)) {
21493
+ warnedChannels.add(`${id}:${channel}`);
21494
+ console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} 的渠道 "${account.channel ?? "(未填)"}" **本机未实现** ⇒ 不起 poller(已实现:${imAccountChannels().join(", ") || "(无)"})`);
21315
21495
  }
21316
21496
  continue;
21317
21497
  }
21318
21498
  const token = (account.token ?? "").trim();
21319
- if (token === "") {
21320
- console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} **无 token**(未绑定)⇒ 不起 poller`);
21321
- continue;
21322
- }
21323
- creds.set(id, {
21499
+ const appIdRaw = account.appId;
21500
+ const cred = {
21324
21501
  token,
21325
21502
  baseUrl: "",
21326
- userId: account.userId
21327
- });
21503
+ userId: account.userId,
21504
+ ...channel === "feishu" && typeof appIdRaw === "string" ? { appId: appIdRaw.trim() } : {}
21505
+ };
21506
+ if (!impl.isReady(cred)) {
21507
+ console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} ${impl.notReadyReason?.(cred) ?? "**凭据不全**"} ⇒ 不起 poller`);
21508
+ continue;
21509
+ }
21510
+ implByAccount.set(id, impl);
21511
+ creds.set(id, cred);
21328
21512
  requests.push({
21329
21513
  root: MACHINE_SCOPE,
21330
21514
  accountId: id,
@@ -21336,13 +21520,16 @@ function syncMachinePollers(ctx, hc) {
21336
21520
  stopMachineLoops();
21337
21521
  for (const claim of plan.claims) {
21338
21522
  const cred = creds.get(claim.accountId);
21339
- if (!cred) continue;
21340
- const loop = {
21341
- stopped: false,
21342
- lastPollAt: 0
21343
- };
21523
+ const impl = implByAccount.get(claim.accountId);
21524
+ if (!cred || !impl) continue;
21525
+ const loop = impl.startLoop({
21526
+ ctx,
21527
+ root: MACHINE_SCOPE,
21528
+ accountId: claim.accountId,
21529
+ cred,
21530
+ machine: true
21531
+ });
21344
21532
  machineLoops.set(claim.accountId, loop);
21345
- runAccountLoop(ctx, MACHINE_SCOPE, claim.accountId, cred, loop, true);
21346
21533
  }
21347
21534
  commitPollerClaims(plan);
21348
21535
  if (machineLoops.size > 0) console.log(`[serenity-hooks] ✓ human-channel: 机器级 poller accounts=${[...machineLoops.keys()].join(",")}(与 CCC 无关)`);
@@ -21374,7 +21561,7 @@ function syncHumanChannel(ctx) {
21374
21561
  (async () => {
21375
21562
  try {
21376
21563
  stopMachineLoops();
21377
- const { listCccs } = await import("./ccc-roots-jZYR2GVE.js").then((n) => n.a);
21564
+ const { listCccs } = await import("./ccc-roots-BhoA7G-0.js").then((n) => n.a);
21378
21565
  for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
21379
21566
  } catch {}
21380
21567
  })();
@@ -21494,6 +21681,34 @@ function registerWeixinBridge(ctx) {
21494
21681
  ctx.on("session/created", () => syncHumanChannel(ctx));
21495
21682
  } catch {}
21496
21683
  }
21684
+ /**
21685
+ * 🔴 **P2 装配:微信实现登记进"账号实现派发表"**(模块求值时执行,不是等 `apply`)。
21686
+ *
21687
+ * ## 为什么必须在模块求值时做(本轮实测教训)
21688
+ *
21689
+ * 首版把这句放在 `registerWeixinBridge` 里 ⇒ 全量测试 **12 例红**,报的全是
21690
+ * 「`weixinBridgeStatus()` 空数组」。根因:测试与设置面板会**直接**调 `syncCccBridge` /
21691
+ * `syncHumanChannel`,那时装配入口还没跑过 ⇒ 派发表是空的 ⇒ 每条账号都走进
21692
+ * 「渠道未实现」分支(那分支是给**真正**未实现的渠道用的)。
21693
+ *
21694
+ * ⇒ 判据:**"谁能被调"决定"注册要在哪儿发生"**,不能按"装配流程"想当然。
21695
+ *
21696
+ * 注入的是本模块自己的函数(`getUpdates` / `handleIncoming` / `handleMachineIncoming`)——
21697
+ * 不需要 `weixin-account.ts import 回 bridge.ts`,避免两个文件互相 import 成环。
21698
+ */
21699
+ installWeixinAccountImpl({
21700
+ getUpdates,
21701
+ handleIncoming,
21702
+ handleMachineIncoming
21703
+ });
21704
+ /**
21705
+ * 🆕 **P3 装配:飞书实现登记进同一张派发表**(同样是模块求值时)。
21706
+ *
21707
+ * 飞书不需要注入依赖 —— 它的收(`WSClient`)与发(`im.v1.message.create`)都在 SDK 里,
21708
+ * 主流程则复用 `im/inbound-event.ts`。⇒ 这一行就是"微信与飞书成为并列实现"的**最后一块**:
21709
+ * 派发表有了两个条目,装配处一行没改(它只按 `channel` 查表)。
21710
+ */
21711
+ installFeishuAccountImpl();
21497
21712
  //#endregion
21498
21713
  //#region src/im/send.ts
21499
21714
  /** 请求体上限(文本消息远小于此;防滥用) */
@@ -21610,7 +21825,7 @@ function matchCcc(input, candidates) {
21610
21825
  }
21611
21826
  /** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
21612
21827
  async function collectCandidates(ctx) {
21613
- const { listCccs } = await import("./ccc-roots-jZYR2GVE.js").then((n) => n.a);
21828
+ const { listCccs } = await import("./ccc-roots-BhoA7G-0.js").then((n) => n.a);
21614
21829
  const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
21615
21830
  const seen = /* @__PURE__ */ new Set();
21616
21831
  const out = [];
@@ -24453,10 +24668,6 @@ const Config = z.object({
24453
24668
  opencodeSkills: z.boolean().default(true),
24454
24669
  gateway: z.object({ enabled: z.boolean().default(false) }),
24455
24670
  rebuild: z.object({ thresholdK: z.number().min(50).max(4e3).default(400) }),
24456
- skiff: z.object({
24457
- enabled: z.boolean().default(false),
24458
- debugPort: z.number().min(1024).max(65535).default(SKIFF_DEBUG_PORT)
24459
- }),
24460
24671
  webFetch: z.object({ enabled: z.boolean().default(true) }),
24461
24672
  opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
24462
24673
  visionPatch: z.object({ enabled: z.boolean().default(true) }),
@@ -24469,8 +24680,6 @@ const Config = z.object({
24469
24680
  }),
24470
24681
  gatewayEnabled: z.boolean().volatile(),
24471
24682
  rebuildThresholdK: z.number().min(50).max(4e3).volatile(),
24472
- skiffEnabled: z.boolean().volatile(),
24473
- skiffDebugPort: z.number().min(1024).max(65535).volatile(),
24474
24683
  croEnabled: z.boolean().volatile(),
24475
24684
  diagramEnabled: z.boolean().volatile()
24476
24685
  });
@@ -24537,7 +24746,6 @@ function apply(ctx, config) {
24537
24746
  if (config.env) registerEnv(ctx);
24538
24747
  if (config.opencodeSkills) registerOpencodeSkills(ctx);
24539
24748
  registerBootstrap(ctx);
24540
- registerSkiff(ctx);
24541
24749
  registerCroTurnTracking(ctx);
24542
24750
  registerWakeScheduler(ctx);
24543
24751
  registerWeixinBridge(ctx);
@@ -24599,139 +24807,5 @@ function registerDiagramTool(ctx) {
24599
24807
  } catch {}
24600
24808
  sync();
24601
24809
  }
24602
- /**
24603
- * F4 Skiff 调试服务装配:启停 = 人工(设置面板 Skiff 区块开关,settings 持久化)。
24604
- * settings-changed 事件触发同步(skiffEnabled 开 → 启动调试服务;关 → 停止)。
24605
- * 角色配置(skiff.roles)从当前 CCC 根读取(含 skiff.roles 的 CCC 优先,进程 cwd 次之)。
24606
- *
24607
- * v1.30.13(S142 诊断 D1):**CCC root 解析失败不再"永不重试"**。旧实现只在 apply 时
24608
- * 同步一次——此刻通常还没有 live 会话,进程 cwd(服务启动目录)也不在 CCC 内 →
24609
- * `resolveSkiffRoot` 返回 null → 打印一行警告后**永不重试**(实证:重启日志
24610
- * `✗ Skiff 调试服务未启动:无法定位 CCC root`,`ss -ltn` 无 3099;同批 ACP 因容忍
24611
- * `root ?? undefined` 而正常启动 —— ⚠️ **该对照已随面 E 退役而失效**(2026-10-15),
24612
- * 但本条记录的**病因与修法不受影响**(Skiff 侧仍是"首次未定位 → 曾永不重试")。
24613
- *
24614
- * v1.30.14(重启日志实证后的收敛):**并发启动守卫** + 首次未定位降级为信息级日志。
24615
- * - 实证(v1.30.13 重启日志):定时触发与"会话就绪"事件几乎同时到达 → 两次 sync 都
24616
- * 看到 `started === false` → **两次 start**(第一次绑定成功、第二次 `EADDRINUSE` 刷错误日志)。
24617
- * → 加 `starting` 在飞标志(启动 Promise settle 前不再重复发起)。
24618
- * - 首次未定位**不是失败**(apply 时无 live 会话是常态)→ 用 `console.log` 说明"等待 live 会话"。
24619
- *
24620
- * v1.35(C2,S142 2026-09-15 Q5):**退避重试定时器退场**。原形态是 ① 启动同步
24621
- * ② 退避重试(1s/3s/8s/20s/40s) ③ 会话就绪事件再触发。C2 把 CCC 发现面归一后,
24622
- * ② 的病灶("apply 时刻解析不到根")改由 **`resolveSkiffRoot` 第 ④ 档(ccc-roots 的
24623
- * **并集**枚举:工作区注册表 ∪ 持久化会话 ∪ live 会话)** 从源头消掉——持久来源不依赖
24624
- * 任何 live 会话,故 apply 时刻就能解析到,不再需要"等一会儿再试"。
24625
- *
24626
- * 🔒 **必须保留**(它们不是"重试",删了会引入新缺陷):
24627
- * - **反应式再触发**(`agent/created` / `session/created` → sync):宿主重启后
24628
- * "恢复"旧会话不触发 `session/created`,但**开始对话**会触发 `agent/created`
24629
- * (v0.1.7 前名 `agent/session-start`)——这是唯一能把"进程起来时还解析不到、稍后才有会话"
24630
- * 这条路径接上的通道。
24631
- * - **`starting` 在飞标志**:防同拍双次 start → `EADDRINUSE`。
24632
- * (C4 块 A 备注:面宿主亦按面名合并在飞启动,是第二道保险;**本标志仍保留**——
24633
- * 它管的是"解析 root 这段装配流程"的在飞语义,且调试服务的启动替身在测试里被 mock,
24634
- * 面宿主的 active 表在那种场景下为空,不能当作 `started` 的替代。)
24635
- * - **`informedRoot` 信息级日志**(只报一次,不刷屏)、`serenity/settings-changed` 监听、
24636
- * 启动时那次 `sync()`。
24637
- */
24638
- function registerSkiff(ctx) {
24639
- let started = false;
24640
- let starting = false;
24641
- let informedRoot = false;
24642
- /** 已解析到的根(成功后缓存,避免每次 sync 重复解析) */
24643
- let resolvedRoot = null;
24644
- const sync = async () => {
24645
- const s = readSimpleSettings();
24646
- if (s.skiffEnabled) {
24647
- if (started || starting) return;
24648
- starting = true;
24649
- try {
24650
- const root = resolvedRoot ?? await resolveSkiffRoot(ctx);
24651
- if (!root) {
24652
- if (!informedRoot) {
24653
- informedRoot = true;
24654
- console.log("[serenity-hooks] Skiff 调试服务等待 CCC root(此刻无已知 CCC;会话就绪即试)");
24655
- }
24656
- return;
24657
- }
24658
- resolvedRoot = root;
24659
- const webPort = readWebPort(ctx);
24660
- await startSkiffDebugServer(ctx, root, s.skiffDebugPort, webPort);
24661
- started = true;
24662
- } catch (err) {
24663
- console.error(`[serenity-hooks] ✗ Skiff 调试服务启动失败: ${String(err?.message ?? err)}`);
24664
- } finally {
24665
- starting = false;
24666
- }
24667
- return;
24668
- }
24669
- if (started) {
24670
- stopSkiffDebugServer();
24671
- started = false;
24672
- }
24673
- resolvedRoot = null;
24674
- informedRoot = false;
24675
- };
24676
- try {
24677
- ctx.on("serenity/settings-changed", () => {
24678
- sync();
24679
- });
24680
- } catch {}
24681
- for (const eventName of ["agent/created", "session/created"]) try {
24682
- ctx.on(eventName, () => {
24683
- if (!started) sync();
24684
- });
24685
- } catch {}
24686
- sync();
24687
- }
24688
- /**
24689
- * 解析 Skiff 调试服务绑定的 CCC 根(v1.25.2 用户指出:skiff 必须绑定 CCC):
24690
- * ① live 会话中**配置了 skiff.roles 的 CCC 优先**(用户认知中的绑定目标)
24691
- * ② 回退进程 cwd 上溯 .serenity
24692
- * ③ 再回退任一 live 会话的 CCC
24693
- * ④ **(v1.35 / C2 新增)ccc-roots 枚举出的 CCC——仅在唯一候选时采用**
24694
- *
24695
- * 第 ④ 档为什么存在:①②③ 全部依赖 live 会话或进程 cwd;宿主重启、浏览器尚未重连时
24696
- * 三者可能同时落空,而 `ccc-roots.listCccs` 的**持久来源**(工作区注册表 / 持久化会话)
24697
- * 不依赖 live 会话 ⇒ 这正是"apply 时刻就能解析到根"的那条路,也是退避重试定时器
24698
- * 得以退场的原因。
24699
- *
24700
- * ④ 的收敛条件(**不猜**):只在候选**唯一**时采用;多个候选 → 返回 `null`
24701
- * (留着让 ①②③ 的语义与人类的显式选择决定,不代替人拍板)。
24702
- *
24703
- * ⚠️ ①②③ 的**逐条优先级不变**;④ 只追加在末尾。
24704
- */
24705
- async function resolveSkiffRoot(ctx) {
24706
- const liveRoots = [];
24707
- try {
24708
- const sessions = hostSessions(ctx);
24709
- for (const s of sessions?.list?.() ?? []) {
24710
- const cwd = s?.header?.cwd;
24711
- if (typeof cwd === "string") {
24712
- const r = cccRootForCwd(cwd);
24713
- if (r && !liveRoots.includes(r)) liveRoots.push(r);
24714
- }
24715
- }
24716
- } catch {}
24717
- for (const r of liveRoots) if (listSkiffProfiles(r).length > 0) return r;
24718
- const fromCwd = cccRootForCwd(process.cwd());
24719
- if (fromCwd) return fromCwd;
24720
- if (liveRoots[0]) return liveRoots[0];
24721
- try {
24722
- const entries = await listCccs(ctx);
24723
- if (entries.length === 1) return entries[0].root;
24724
- } catch {}
24725
- return null;
24726
- }
24727
- /** 主 WebUI 端口(WebUI 链接;webServer 未装配回退集中端口表的 main 面端口) */
24728
- function readWebPort(ctx) {
24729
- try {
24730
- const ws = hostWebServer(ctx);
24731
- return typeof ws?.port === "number" ? ws.port : MAIN_WEB_PORT;
24732
- } catch {
24733
- return MAIN_WEB_PORT;
24734
- }
24735
- }
24736
24810
  //#endregion
24737
24811
  export { Config, apply, humanChannelAccountSchema, humanChannelSubscriptionSchema, inject, name };