@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.
- package/dsh.plugin.json +2 -2
- package/lib/{ccc-Dkoa2oW3.js → ccc-CZUOq3NY.js} +1 -1
- package/lib/{ccc-roots-jZYR2GVE.js → ccc-roots-BhoA7G-0.js} +3 -3
- package/lib/client.js +50 -58
- package/lib/{wake/cro.d.ts → cro/core.d.ts} +2 -2
- package/lib/{wake/cro-guide.d.ts → cro/guide.d.ts} +3 -3
- package/lib/{wake/cro-log.d.ts → cro/log.d.ts} +2 -2
- package/lib/{wake/cro-turns.d.ts → cro/turns.d.ts} +2 -2
- package/lib/guard/seam.d.ts +1 -1
- package/lib/{ilink-DEpmE3yi.js → ilink-BMk2w2KY.js} +1 -1
- package/lib/im/accounts.d.ts +105 -0
- package/lib/im/bridge.d.ts +33 -62
- package/lib/im/endpoint.d.ts +1 -1
- package/lib/im/feishu-account.d.ts +92 -0
- package/lib/im/inbound-event.d.ts +162 -0
- package/lib/im/route.d.ts +37 -2
- package/lib/im/weixin-account.d.ts +45 -0
- package/lib/index.d.ts +0 -22
- package/lib/index.js +1070 -996
- package/lib/kernel/ccc.d.ts +78 -2
- package/lib/kernel/ports.d.ts +2 -4
- package/lib/lib-TxscFKeo.js +119355 -0
- package/lib/{message-source-kVoNK0C5.js → message-source-ChyVfev9.js} +2 -2
- package/lib/{profile-DyMJ_bfr.js → profile-BIQ2widI.js} +14 -11
- package/lib/rolldown-runtime-B4iAMlE-.js +35 -0
- package/lib/{route-CPneN5iv.js → route-ItCgAsnz.js} +96 -35
- package/lib/seams/guards.d.ts +1 -1
- package/lib/seams/system-prompt.d.ts +3 -1
- package/lib/{settings-lt1i3OZv.js → settings-UcwIPJhM.js} +13 -62
- package/lib/skills/resource-index.d.ts +1 -1
- package/lib/surface/settings.d.ts +0 -6
- package/lib/surface/text.d.ts +72 -0
- package/lib/surface/usage.d.ts +2 -2
- package/lib/{role/worker/idle.d.ts → trajectory/carrier.d.ts} +12 -2
- package/lib/{role/worker → trajectory/delegate}/core.d.ts +7 -1
- package/lib/{role/worker → trajectory/delegate}/preset.d.ts +9 -2
- package/lib/{role/skiff → trajectory/policy}/profile.d.ts +9 -2
- package/lib/{role/skiff/config.d.ts → trajectory/policy/rules.d.ts} +9 -3
- package/lib/{role/skiff/registry.d.ts → trajectory/policy/window.d.ts} +7 -1
- package/lib/trajectory/prompt.d.ts +101 -0
- package/lib/{role/skiff/core.d.ts → trajectory/session.d.ts} +13 -4
- package/package.json +3 -2
- package/lib/role/skiff/debug.d.ts +0 -88
- package/lib/rolldown-runtime-8H4AJuhK.js +0 -14
package/lib/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
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-
|
|
4
|
-
import { A as
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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-
|
|
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-
|
|
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`(拆卸登记)、`
|
|
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/
|
|
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/
|
|
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
|
-
* ## 🔴 铁律(与 `
|
|
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
|
-
* `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
3813
|
+
//#region src/trajectory/delegate/core.ts
|
|
3617
3814
|
/**
|
|
3618
|
-
*
|
|
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/
|
|
3759
|
-
/** `provider/model` → agentOptions(由
|
|
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: () =>
|
|
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/
|
|
10090
|
+
//#region src/cro/core.ts
|
|
9889
10091
|
/**
|
|
9890
|
-
*
|
|
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/
|
|
10716
|
+
//#region src/cro/turns.ts
|
|
10515
10717
|
/**
|
|
10516
|
-
*
|
|
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
|
-
* ⇒ 该注解未提供任何类型保证(与 `
|
|
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/
|
|
10892
|
+
//#region src/cro/log.ts
|
|
10691
10893
|
/**
|
|
10692
|
-
*
|
|
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
|
-
* ## 🔴 铁律(与 `
|
|
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/
|
|
12053
|
+
//#region src/cro/guide.ts
|
|
11852
12054
|
/**
|
|
11853
|
-
*
|
|
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
|
-
|
|
12675
|
-
|
|
12676
|
-
|
|
12677
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
15468
|
+
sendJson$2(res, 404, { error: `no CCC found from workspace: ${workspace}` });
|
|
15264
15469
|
return;
|
|
15265
15470
|
}
|
|
15266
|
-
sendJson$
|
|
15471
|
+
sendJson$2(res, 200, { path: saveImageToTmp(root, body.mediaType ?? "", body.data ?? "") });
|
|
15267
15472
|
} catch (err) {
|
|
15268
|
-
sendJson$
|
|
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$
|
|
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$
|
|
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$
|
|
15495
|
+
sendJson$2(res, 404, { error: `no CCC found from workspace: ${workspace}` });
|
|
15291
15496
|
return;
|
|
15292
15497
|
}
|
|
15293
|
-
sendJson$
|
|
15498
|
+
sendJson$2(res, 200, { path: saveFileToTmp(root, body.name ?? "", body.data ?? "") });
|
|
15294
15499
|
} catch (err) {
|
|
15295
|
-
sendJson$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
15539
|
+
sendJson$2(res, 200, {
|
|
15335
15540
|
...getStatus(workspace, configPaths),
|
|
15336
15541
|
...result
|
|
15337
15542
|
});
|
|
15338
15543
|
return;
|
|
15339
15544
|
}
|
|
15340
|
-
sendJson$
|
|
15545
|
+
sendJson$2(res, 405, { error: "method not allowed" });
|
|
15341
15546
|
} catch (err) {
|
|
15342
|
-
sendJson$
|
|
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$
|
|
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$
|
|
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$
|
|
15575
|
+
sendJson$2(res, 200, { config: toWire(saved) });
|
|
15371
15576
|
return;
|
|
15372
15577
|
}
|
|
15373
|
-
sendJson$
|
|
15578
|
+
sendJson$2(res, 405, { error: "method not allowed" });
|
|
15374
15579
|
} catch (err) {
|
|
15375
|
-
sendJson$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
15618
|
+
sendJson$2(res, 400, { error: chk.error });
|
|
15414
15619
|
return;
|
|
15415
15620
|
}
|
|
15416
|
-
const { readWeixinSettings } = await import("./route-
|
|
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-
|
|
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$
|
|
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$
|
|
15642
|
+
sendJson$2(res, 405, { error: "method not allowed" });
|
|
15438
15643
|
return;
|
|
15439
15644
|
}
|
|
15440
|
-
const raw = await readBody$
|
|
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$
|
|
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-
|
|
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-
|
|
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$
|
|
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$
|
|
15675
|
+
sendJson$2(res, 400, { error: "missing accountId" });
|
|
15471
15676
|
return;
|
|
15472
15677
|
}
|
|
15473
|
-
const { removeWeixinAccount } = await import("./route-
|
|
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$
|
|
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$
|
|
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$
|
|
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-
|
|
15500
|
-
const { findDirNameByCode } = await import("./profile-
|
|
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$
|
|
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$
|
|
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-
|
|
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$
|
|
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$
|
|
15729
|
+
sendJson$2(res, 200, { enabled });
|
|
15525
15730
|
return;
|
|
15526
15731
|
}
|
|
15527
|
-
sendJson$
|
|
15732
|
+
sendJson$2(res, 400, { error: `unsupported action: ${body.action ?? ""}` });
|
|
15528
15733
|
} catch (err) {
|
|
15529
|
-
sendJson$
|
|
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$
|
|
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$
|
|
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$
|
|
15756
|
+
sendJson$2(res, 200, { status: "expired" });
|
|
15552
15757
|
return;
|
|
15553
15758
|
}
|
|
15554
|
-
const { pollQRStatus } = await import("./ilink-
|
|
15555
|
-
const { readWeixinSettings } = await import("./route-
|
|
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$
|
|
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-
|
|
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$
|
|
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$
|
|
15799
|
+
sendJson$2(res, 200, { status: "expired" });
|
|
15595
15800
|
return;
|
|
15596
15801
|
default:
|
|
15597
|
-
sendJson$
|
|
15802
|
+
sendJson$2(res, 200, { status: status.status ?? "wait" });
|
|
15598
15803
|
return;
|
|
15599
15804
|
}
|
|
15600
15805
|
} catch (err) {
|
|
15601
|
-
sendJson$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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/
|
|
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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
-
"&": "&",
|
|
19733
|
-
"<": "<",
|
|
19734
|
-
">": ">",
|
|
19735
|
-
"\"": """,
|
|
19736
|
-
"'": "'"
|
|
19737
|
-
})[c] ?? c);
|
|
19738
|
-
}
|
|
19730
|
+
//#region src/surface/text.ts
|
|
19739
19731
|
/**
|
|
19740
|
-
*
|
|
19741
|
-
*
|
|
19742
|
-
*
|
|
19743
|
-
*
|
|
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
|
|
19756
|
-
|
|
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
|
-
|
|
20288
|
+
//#endregion
|
|
20289
|
+
//#region src/im/hook.ts
|
|
20440
20290
|
/**
|
|
20441
|
-
*
|
|
20442
|
-
*
|
|
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
|
-
|
|
20445
|
-
|
|
20446
|
-
|
|
20447
|
-
|
|
20448
|
-
|
|
20449
|
-
|
|
20450
|
-
|
|
20451
|
-
|
|
20452
|
-
|
|
20453
|
-
|
|
20454
|
-
|
|
20455
|
-
|
|
20456
|
-
|
|
20457
|
-
|
|
20458
|
-
|
|
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
|
|
20462
|
-
return
|
|
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
|
-
*
|
|
20466
|
-
*
|
|
20467
|
-
|
|
20468
|
-
|
|
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/
|
|
20725
|
-
|
|
20726
|
-
|
|
20727
|
-
|
|
20728
|
-
|
|
20729
|
-
|
|
20730
|
-
|
|
20731
|
-
|
|
20732
|
-
|
|
20733
|
-
|
|
20734
|
-
|
|
20735
|
-
|
|
20736
|
-
|
|
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
|
-
*
|
|
20463
|
+
* 账号行的渠道归一(**单一处**)。
|
|
20741
20464
|
*
|
|
20742
|
-
*
|
|
20743
|
-
*
|
|
20744
|
-
* ② 归属表(`im/inbound.ts`)按 root 区分拥有者 ⇒ 机器级用这个哨兵值,
|
|
20745
|
-
* 与任何**真实** CCC 根都不会撞(真根一律是绝对路径)。
|
|
20465
|
+
* 缺省 `weixin`:老配置平移到 `im` 段时不必补这个字段——owner 的"统一配置"
|
|
20466
|
+
* 要能直接吃旧数据(与 `route.ts` 的 `normalizeImSection` 保持同一口径)。
|
|
20746
20467
|
*/
|
|
20747
|
-
|
|
20748
|
-
|
|
20749
|
-
|
|
20750
|
-
|
|
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
|
-
/**
|
|
20753
|
-
|
|
20754
|
-
|
|
20755
|
-
|
|
20756
|
-
function
|
|
20757
|
-
return
|
|
20476
|
+
/**
|
|
20477
|
+
* 微信的凭据就绪判据:**有 token** 才算绑定(baseUrl 留空 = 走官方默认)。
|
|
20478
|
+
* 与既有装配处逐字同义(原 `if (token === '') { …跳过… }`)。
|
|
20479
|
+
*/
|
|
20480
|
+
function weixinCredentialReady(cred) {
|
|
20481
|
+
return typeof cred.token === "string" && cred.token.trim() !== "";
|
|
20758
20482
|
}
|
|
20759
|
-
|
|
20760
|
-
|
|
20761
|
-
|
|
20762
|
-
|
|
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
|
-
/**
|
|
20787
|
-
async function
|
|
20788
|
-
|
|
20789
|
-
|
|
20790
|
-
|
|
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
|
-
|
|
20803
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
20841
|
-
*
|
|
20842
|
-
*
|
|
20843
|
-
*
|
|
20844
|
-
* ```
|
|
20537
|
+
* ⚠️ 必须在首次 `syncHumanChannel` / `syncCccBridge` **之前**发生。
|
|
20538
|
+
* `bridge.ts` 在**模块求值时**调用它一次(不是等到 `registerWeixinBridge`)——
|
|
20539
|
+
* 因为测试与面板会**直接**调 `syncCccBridge`,那时装配入口还没跑过;
|
|
20540
|
+
* 若依赖装配入口注册,那些路径会全部走进"未实现"分支(本轮实测:12 例红,根因就是它)。
|
|
20845
20541
|
*/
|
|
20846
|
-
function
|
|
20847
|
-
|
|
20542
|
+
function installWeixinAccountImpl(d) {
|
|
20543
|
+
setWeixinLoopDeps(d);
|
|
20544
|
+
registerImAccountImpl(weixinAccountImpl);
|
|
20848
20545
|
}
|
|
20546
|
+
const manualSessions = /* @__PURE__ */ new Map();
|
|
20849
20547
|
/**
|
|
20850
|
-
*
|
|
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
|
-
*
|
|
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
|
|
20859
|
-
|
|
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
|
-
*
|
|
20865
|
-
*
|
|
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-
|
|
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
|
-
* - **未命中** ⇒ 不投递 +
|
|
20889
|
-
* - **表残缺** ⇒
|
|
20890
|
-
* - **目标不存在**(CCC / **轨迹**)⇒
|
|
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-
|
|
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
|
-
*
|
|
20774
|
+
* **一条入站消息的主流程**(通道中立)。
|
|
20964
20775
|
*
|
|
20965
|
-
*
|
|
20966
|
-
*
|
|
20967
|
-
* 固定 id resume-or-create(v1.27.2):磁盘已有持久化 log → resume(历史延续,
|
|
20968
|
-
* 重启后记忆保留——真正的"同用户长期延续");无 log(首次)→ create。
|
|
20969
|
-
* "新的对话已开始"通知仅真正首次(create)时发送;resume/进程内延续不发。
|
|
20776
|
+
* 职责:路由 → 固定会话 id 创建/延续 → 提问 → 回复(或被闸门拦下后兜底)。
|
|
20777
|
+
* 语料(媒体)由事件自带 `fetch()`;出站走的 `io`。
|
|
20970
20778
|
*
|
|
20971
|
-
*
|
|
20972
|
-
*
|
|
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
|
-
*
|
|
20981
|
-
* **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
|
|
20982
|
-
* **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
|
|
20983
|
-
* **不是**"投给默认角色"。
|
|
20782
|
+
* @param preset 机器级模式:目标已解析好 ⇒ **不再自行路由**,且**不看本 CCC 的 `enabled`**
|
|
20984
20783
|
*/
|
|
20985
|
-
async function
|
|
20986
|
-
const fromUserId =
|
|
20784
|
+
async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
20785
|
+
const fromUserId = event.userId;
|
|
20987
20786
|
if (!fromUserId) return;
|
|
20988
|
-
const text =
|
|
20989
|
-
const mediaRefs =
|
|
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 (
|
|
21010
|
-
baseUrl: cred.baseUrl,
|
|
21011
|
-
token: cred.token,
|
|
20808
|
+
if (event.hasVoice) await io.sendText({
|
|
21012
20809
|
toUserId: fromUserId,
|
|
21013
20810
|
text: "(抱歉,暂时无法解析这条语音消息,请尝试发送文字)",
|
|
21014
|
-
|
|
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
|
|
21039
|
-
baseUrl: cred.baseUrl,
|
|
21040
|
-
token: cred.token,
|
|
20835
|
+
if (!ref.resumed) await io.sendText({
|
|
21041
20836
|
toUserId: fromUserId,
|
|
21042
20837
|
text: "(新的对话已开始)",
|
|
21043
|
-
|
|
20838
|
+
replyRef: event.replyRef
|
|
21044
20839
|
}).catch(() => {});
|
|
21045
|
-
await
|
|
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
|
|
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(
|
|
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]
|
|
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
|
|
21134
|
-
baseUrl: cred.baseUrl,
|
|
21135
|
-
token: cred.token,
|
|
20927
|
+
await io.sendText({
|
|
21136
20928
|
toUserId: fromUserId,
|
|
21137
20929
|
text: reply,
|
|
21138
|
-
|
|
20930
|
+
replyRef: event.replyRef
|
|
21139
20931
|
});
|
|
21140
|
-
console.warn(`[serenity-hooks] ⚠
|
|
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]
|
|
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
|
|
21157
|
-
baseUrl: cred.baseUrl,
|
|
21158
|
-
token: cred.token,
|
|
20948
|
+
await io.sendText({
|
|
21159
20949
|
toUserId: fromUserId,
|
|
21160
20950
|
text: reply,
|
|
21161
|
-
|
|
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]
|
|
20961
|
+
console.log(`[serenity-hooks] im hook outgoing error: ${err instanceof Error ? err.message : String(err)}`);
|
|
21172
20962
|
});
|
|
21173
20963
|
} finally {
|
|
21174
|
-
await
|
|
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]
|
|
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]
|
|
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
|
|
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
|
-
|
|
21248
|
-
|
|
21249
|
-
|
|
21250
|
-
|
|
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
|
-
|
|
21312
|
-
|
|
21313
|
-
|
|
21314
|
-
|
|
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
|
-
|
|
21320
|
-
|
|
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
|
-
|
|
21340
|
-
|
|
21341
|
-
|
|
21342
|
-
|
|
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-
|
|
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-
|
|
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 };
|