@shgroup/dsh-serenity-hooks 1.50.0 → 1.50.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dsh.plugin.json +3 -3
- package/lib/host/contract.d.ts +40 -7
- package/lib/human-channel.d.ts +131 -2
- package/lib/index.js +550 -56
- package/lib/status.d.ts +29 -2
- package/lib/weixin-bridge.d.ts +60 -5
- package/package.json +16 -16
package/dsh.plugin.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-serenity-hooks",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.1",
|
|
4
4
|
"main": "lib/index.js",
|
|
5
|
-
"description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.
|
|
5
|
+
"description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1(deepseek-ai/deepseek-harness)。",
|
|
6
6
|
"engines": {
|
|
7
|
-
"dsh": ">=0.1.
|
|
7
|
+
"dsh": ">=0.1.7-rc.1"
|
|
8
8
|
},
|
|
9
9
|
"contributes": {
|
|
10
10
|
"tools": [
|
package/lib/host/contract.d.ts
CHANGED
|
@@ -61,8 +61,40 @@ type HostEventName = (typeof HOST_EVENT_NAMES)[number];
|
|
|
61
61
|
* dsp 订阅的宿主事件清单(与 HOST_EVENT_NAMES 同源;impact/site 供 health 报告与文档)。
|
|
62
62
|
*/
|
|
63
63
|
export declare const HOST_EVENTS: readonly HostEventContract[];
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* dsp **被验证过的宿主版本档**(单一真相源)—— 每档 = 一个半开区间 `[floor, ceiling)`。
|
|
66
|
+
*
|
|
67
|
+
* 🔴 为什么是"档"而不是一条 caret 范围(R↓:2026-09-28 0.2.0-rc.1 适配轮实测得出):
|
|
68
|
+
* - **宿主门的判据是真 semver**(`@deepseek-ai/dsh-app-boot:evaluatePluginCompatibility`
|
|
69
|
+
* = `semver.satisfies(runtime, range, { includePrerelease: true })`)⇒ **它支持 `||` 联合范围**;
|
|
70
|
+
* - 而 **dsp 这边是手写比较器**({@link compareSemver}),**不实现 semver 的 range 语义**:
|
|
71
|
+
* 旧写法"用正则从 `REQUIRED_HOST_RANGE` 剥前缀取 floor"在**联合范围上会取到垃圾**
|
|
72
|
+
* (`^0.1.7-rc.1 || ^0.2.0-rc.1` 的预发布捕获组 `(.*)` 会**吞掉 `||` 之后整段**)
|
|
73
|
+
* ⇒ 判定**变瞎**(既非"通过"也非"低于下限",而是不可解释的值)。
|
|
74
|
+
* ⇒ 故**档表是唯一被验证的真相**:`REQUIRED_HOST_RANGE`(给宿主门看的串)与
|
|
75
|
+
* `checkHostVersion`(给自己的手写比较器)**都从它派生**,两者不可能互相漂移。
|
|
76
|
+
*
|
|
77
|
+
* 🔴 两档的由来(**实测,非推断**;判据表见插件仓 `docs/dsh-0.2.0-rc.1-adaptation-plan.md` §7.4):
|
|
78
|
+
* - `^0.1.7-rc.1` 在 `0.2.0-rc.1` 上被宿主**整包拒绝 + 回滚**
|
|
79
|
+
* (逐字 `installation rejected: … incompatible with dsh 0.2.0-rc.1` + `restored package.json, …`);
|
|
80
|
+
* - 单档 `^0.2.0-rc.1` 会**同样硬拒本机正在跑的 `0.1.7-rc.2`**
|
|
81
|
+
* ⇒ 发布后 deploy 必须先升宿主,**而升级会打断本容器**;
|
|
82
|
+
* - ⇒ 双档是**唯一不把本机宿主堵死的写法**(两档在真 semver 下均 PASS)。
|
|
83
|
+
* 档上界取 `<0.2.0` / `<0.3.0`(即 `^0.2.0-rc.1` 的 caret 上界):第二档同时覆盖
|
|
84
|
+
* `0.2.0` 正式版与后续 `0.2.0-rc.N`。
|
|
85
|
+
*/
|
|
86
|
+
export declare const VERIFIED_HOST_BANDS: readonly {
|
|
87
|
+
floor: string;
|
|
88
|
+
ceiling: string;
|
|
89
|
+
}[];
|
|
90
|
+
/**
|
|
91
|
+
* 与 `package.json` 的 `peerDependencies` **逐字同串**的宿主版本范围(由 {@link VERIFIED_HOST_BANDS} 派生)。
|
|
92
|
+
*
|
|
93
|
+
* 🔴 这个字符串**就是**宿主门(`evaluatePluginCompatibility`)拿去跑 `semver.satisfies` 的那个串
|
|
94
|
+
* ⇒ 它与档表**必须一致**("声明 union、判定单档"是一种自相矛盾)。
|
|
95
|
+
* 一致性由 `tests/host-contract.test.ts` 的**跨文件对账用例**钉住(读 package.json 逐字比对)。
|
|
96
|
+
*/
|
|
97
|
+
export declare const REQUIRED_HOST_RANGE: string;
|
|
66
98
|
interface HostContractIssue {
|
|
67
99
|
id: string;
|
|
68
100
|
kind: 'service' | 'member' | 'version';
|
|
@@ -80,13 +112,14 @@ export interface HostContractReport {
|
|
|
80
112
|
/** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
|
|
81
113
|
export declare function compareSemver(a: string, b: string): number | null;
|
|
82
114
|
/**
|
|
83
|
-
* 宿主版本是否落在 dsp
|
|
115
|
+
* 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
|
|
84
116
|
* 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
|
|
85
117
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
118
|
+
* 🔴 **逐档判**(v1.50.x,0.2.0-rc.1 适配轮):判据 = "∃ 档:`version >= floor ∧ version < ceiling`"。
|
|
119
|
+
* 曾经的做法是"从 `REQUIRED_HOST_RANGE` 剥前缀取一个 floor + 一个显式 ceiling"——
|
|
120
|
+
* **单档假设一旦不成立(改联合范围),那个正则就会取到垃圾**(详见 {@link VERIFIED_HOST_BANDS}
|
|
121
|
+
* 的注释)⇒ 故本函数**只读档表**,不再从范围串反推。
|
|
122
|
+
* ⚠️ 手写比较器**不实现 semver 的 range 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
|
|
90
123
|
*/
|
|
91
124
|
export declare function checkHostVersion(version: string | null): {
|
|
92
125
|
ok: boolean;
|
package/lib/human-channel.d.ts
CHANGED
|
@@ -18,9 +18,15 @@
|
|
|
18
18
|
* `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
|
|
19
19
|
*
|
|
20
20
|
* ## 边界
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* 本模块做三件事(**都属机制**,与"哪个渠道"无关):
|
|
22
|
+
* ① **归属判定**(P0-2:谁能起 poller、谁被谁占着)—— 见下 §归属判定
|
|
23
|
+
* ② **订阅表查询 + 入站路由决策**(P0-3:`(账号, 用户) → (CCC, 角色)`)—— 见 §订阅表
|
|
24
|
+
* ③ **机器级留痕**(P0-3:未投递 / 目标不存在 / 表残缺)—— 见 §留痕。
|
|
25
|
+
* 🔴 为什么这些必须机器级:它们**没有目标 CCC** ⇒ 落进任何 CCC 都是"编一个"目标。
|
|
26
|
+
* **不碰**:轮询循环、凭据解析、会话投递 —— 那些是**渠道实现**,留在 `weixin-bridge.ts`。
|
|
27
|
+
* ⇒ 本模块**零 DSH 依赖**(只有 `node:crypto` / `node:fs`),可独立单测。
|
|
23
28
|
*/
|
|
29
|
+
import type { HumanChannelSubscriptionSettings } from './settings-section.js';
|
|
24
30
|
/** 一个"想为该账号起 poller"的请求(来自某 CCC) */
|
|
25
31
|
export interface PollerRequest {
|
|
26
32
|
/** 发起方 CCC 根(绝对路径) */
|
|
@@ -87,3 +93,126 @@ export declare function releasePollersOf(root: string): number;
|
|
|
87
93
|
export declare function releaseAllPollers(): void;
|
|
88
94
|
/** 测试专用:复位进程级归属表(生产零调用) */
|
|
89
95
|
export declare function __resetPollersForTest(): void;
|
|
96
|
+
/** 订阅表里**完好**的一行(四个字段全部非空) */
|
|
97
|
+
export interface SubscriptionHit {
|
|
98
|
+
account: string;
|
|
99
|
+
user: string;
|
|
100
|
+
ccc: string;
|
|
101
|
+
role: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 订阅表里的**残缺行**(缺字段)。
|
|
105
|
+
* 🔴 它**不是**"未命中"(见上);且**与是否命中无关** —— 表里只要有残缺行就要出声,
|
|
106
|
+
* 否则"命中了一条完好的"会让人以为整张表是干净的。
|
|
107
|
+
*/
|
|
108
|
+
export interface SubscriptionDefect {
|
|
109
|
+
/** 行序(0 基)—— 报错要指得出是**哪一行** */
|
|
110
|
+
index: number;
|
|
111
|
+
/** 缺哪些字段(`account` / `user` / `ccc` / `role`) */
|
|
112
|
+
missing: string[];
|
|
113
|
+
/** 行里**已有**的字段(残缺行的可见部分:让人认得出是哪一行) */
|
|
114
|
+
present: Record<string, string>;
|
|
115
|
+
}
|
|
116
|
+
export interface SubscriptionLookup {
|
|
117
|
+
/** 命中的行(未命中 ⇒ `null`) */
|
|
118
|
+
hit: SubscriptionHit | null;
|
|
119
|
+
/** 表里残缺的行(**与是否命中无关**,一律要出声) */
|
|
120
|
+
defects: SubscriptionDefect[];
|
|
121
|
+
/** 表里**完好**的行数(供调用方区分"表是空的"与"表里没有你") */
|
|
122
|
+
usable: number;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 订阅表查询(**纯函数**)。
|
|
126
|
+
*
|
|
127
|
+
* 匹配两段式(**刻意对齐**旧 `matchWeixinRoute` 的 exact → `*` 兜底):
|
|
128
|
+
* ① `account` **精确** ∧ `user` **精确**
|
|
129
|
+
* ② `account` **精确** ∧ `user === '*'`(通配兜底)
|
|
130
|
+
* 🔴 `account` **不设通配**:它是机器级账号的**显式身份**(表里写的就是账号 id);
|
|
131
|
+
* 给账号也开 `*` 会让"这条订阅到底覆盖哪个 bot"变得不可读。
|
|
132
|
+
* 🔴 `user` 保留 `*`:这是**有意的**——存量 CCC 级 `routes` 用的就是 `{user:'*'}`,
|
|
133
|
+
* 迁移(P0-4)要能**逐字平移**它,否则"迁移"会悄悄改变行为。
|
|
134
|
+
*
|
|
135
|
+
* @param subscriptions 机器级订阅表(`readSimpleSettings().humanChannel.subscriptions`)
|
|
136
|
+
* @param account 收到的**账号 id**(poller 身份;不是 token)
|
|
137
|
+
* @param user 发信人 id
|
|
138
|
+
*/
|
|
139
|
+
export declare function lookupSubscription(subscriptions: readonly HumanChannelSubscriptionSettings[], account: string, user: string): SubscriptionLookup;
|
|
140
|
+
/** 入站投递目标**从哪来**(判据用;也是"这条消息为什么被投到这里"的可重建依据) */
|
|
141
|
+
export type InboundRouteSource = 'machine' | 'ccc-fallback';
|
|
142
|
+
/** 未投递的原因(两档,措辞刻意不同 —— 见 `planInboundRoute`) */
|
|
143
|
+
export type InboundSkipReason = 'no-subscription' | 'no-match';
|
|
144
|
+
export interface InboundRoutePlan {
|
|
145
|
+
/** 投递目标(`null` ⇒ **不投递**) */
|
|
146
|
+
target: {
|
|
147
|
+
ccc: string;
|
|
148
|
+
role: string;
|
|
149
|
+
} | null;
|
|
150
|
+
/** 目标出处(`null` ⇔ `target === null`) */
|
|
151
|
+
source: InboundRouteSource | null;
|
|
152
|
+
/** 未投递的原因(`target !== null` ⇒ `null`) */
|
|
153
|
+
skipReason: InboundSkipReason | null;
|
|
154
|
+
/** 订阅表里残缺的行(**与是否命中无关**,一律要出声) */
|
|
155
|
+
defects: SubscriptionDefect[];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 入站路由决策(**纯函数**)。
|
|
159
|
+
*
|
|
160
|
+
* 顺序:**机器级订阅表优先** → 未命中时用 CCC 级回退。
|
|
161
|
+
*
|
|
162
|
+
* 🔴 **回退是过渡期的"不打断在用的微信"**(方案 §11.3 **Q5**):机器级账号表为空时,
|
|
163
|
+
* 桥仍按 CCC 起、路由也仍认 CCC 级 `routes`。机器级一旦配起来,回退传 `null`
|
|
164
|
+
* ⇒ **未命中 = 不投递**(不是"投给默认角色"——那正是方案 §3.1 明令禁止的)。
|
|
165
|
+
* @param input.subscriptions 机器级订阅表
|
|
166
|
+
* @param input.account 收到的账号 id
|
|
167
|
+
* @param input.user 发信人 id
|
|
168
|
+
* @param input.fallback CCC 级回退目标(`{ccc, role}`);无 ⇒ `null` / 省略
|
|
169
|
+
*/
|
|
170
|
+
export declare function planInboundRoute(input: {
|
|
171
|
+
subscriptions: readonly HumanChannelSubscriptionSettings[];
|
|
172
|
+
account: string;
|
|
173
|
+
user: string;
|
|
174
|
+
fallback?: {
|
|
175
|
+
ccc: string;
|
|
176
|
+
role: string;
|
|
177
|
+
} | null;
|
|
178
|
+
}): InboundRoutePlan;
|
|
179
|
+
/**
|
|
180
|
+
* 留痕落点:`$DSH_HOME/human-channel-audit.jsonl`(缺省 `~/.dsh/human-channel-audit.jsonl`)。
|
|
181
|
+
*
|
|
182
|
+
* 🔴 **为什么是机器级而不是某个 CCC 的 `_weixin-logs/`**:本文件记的事件(未投递 /
|
|
183
|
+
* 目标不存在 / 表残缺)**恰恰是"没有目标 CCC"的那些** —— 落进任何一个 CCC 都是**编一个**目标。
|
|
184
|
+
* (投递成功的入站事件仍落**被路由到的那个 CCC** 的 `_weixin-logs/`,方案 §3.6 不变。)
|
|
185
|
+
* env `SERENITY_HUMAN_CHANNEL_AUDIT` 覆盖(测试注入;与 `SERENITY_HOOKS_CONFIG` 同款推导)。
|
|
186
|
+
*/
|
|
187
|
+
export declare function humanChannelAuditPath(): string;
|
|
188
|
+
/**
|
|
189
|
+
* 留痕事件。
|
|
190
|
+
* 🔴 **刻意不记消息正文**:方案 §3.1 要的是"**谁**发的、**为什么**没投" —— 正文既不必要,
|
|
191
|
+
* 又会把人说的话抄进一个机器级文件(本该只落在他自己 CCC 的日志里)。
|
|
192
|
+
*/
|
|
193
|
+
export type HumanChannelAuditEvent = {
|
|
194
|
+
kind: 'inbound-unrouted';
|
|
195
|
+
account: string;
|
|
196
|
+
user: string;
|
|
197
|
+
reason: InboundSkipReason;
|
|
198
|
+
detail: string;
|
|
199
|
+
} | {
|
|
200
|
+
kind: 'inbound-target-missing';
|
|
201
|
+
account: string;
|
|
202
|
+
user: string;
|
|
203
|
+
ccc: string;
|
|
204
|
+
role: string;
|
|
205
|
+
detail: string;
|
|
206
|
+
} | {
|
|
207
|
+
kind: 'subscription-defect';
|
|
208
|
+
account: string;
|
|
209
|
+
index: number;
|
|
210
|
+
missing: string[];
|
|
211
|
+
present: Record<string, string>;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* 追加一条留痕。**永不抛** ——
|
|
215
|
+
* 留痕失败绝不能反过来打断投递(它是**旁路**,与 `invokeWeixinHook` 的"旁路容忍"同款)。
|
|
216
|
+
* 🔴 **不静默的是它记的那件事**,不是它自己:所以调用方仍要**另行 `console` 出声**。
|
|
217
|
+
*/
|
|
218
|
+
export declare function appendHumanChannelAudit(event: HumanChannelAuditEvent): void;
|
package/lib/index.js
CHANGED
|
@@ -9889,12 +9889,64 @@ function registerCompactRetention(ctx, opts = {}) {
|
|
|
9889
9889
|
* WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值 / handyman 模型。
|
|
9890
9890
|
* setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
|
|
9891
9891
|
*/
|
|
9892
|
+
/** 宿主包名 —— 从运行入口反推包根时的**身份核对**用(见 {@link dshVersionFromEntry}) */
|
|
9893
|
+
const HOST_PACKAGE_NAME = "@deepseek-ai/dsh";
|
|
9894
|
+
/** 上溯层数上限:入口脚本在包内的深度是 `lib/`(1 层)或 `lib/x/y/`(≤3 层)⇒ 6 层足够,且**保证有界** */
|
|
9895
|
+
const MAX_ENTRY_ASCENT = 6;
|
|
9896
|
+
/**
|
|
9897
|
+
* 🔴 **布局无关档**:从**正在跑的那个入口脚本**反推宿主包根,读它的 `version`。
|
|
9898
|
+
*
|
|
9899
|
+
* 为什么必须补这一档(R↓:一处**静默失效**的实际缺陷;2026-09-28 docker bench 实测):
|
|
9900
|
+
* 下面那条静态候选链({@link readDshVersion} 的 ②)是**猜安装位置**——`npm_config_prefix` /
|
|
9901
|
+
* `APPDATA\npm` / `~/.npm-global`。一旦宿主装在**别处**(典型 = 容器镜像里的
|
|
9902
|
+
* `/usr/local/lib/node_modules/@deepseek-ai/dsh`),**三个候选全不中** ⇒ 返回 `null`
|
|
9903
|
+
* ⇒ `checkHostVersion(null)` 只给一条 **`required: false`** 的 issue ⇒ 报告 `ok` 仍为 `true`
|
|
9904
|
+
* ⇒ **版本门完全静默**(bench 逐字:`host contract degraded (1 optional): host version unknown`)。
|
|
9905
|
+
* ⚠️ 这类坏法**比"偶尔报警"更危险**:一条**永远为假**的判据看起来像"没报警 = 没问题"。
|
|
9906
|
+
*
|
|
9907
|
+
* 而"我们正跑在哪个宿主里"**本来就有直接证据**:`process.argv[1]`(= 启动本进程的那个脚本)。
|
|
9908
|
+
* 三条判据要点(每条都对应一个实测过的坑):
|
|
9909
|
+
* ① **先 `realpath`** —— 全局安装的 `dsh` 是**符号链接**(`/usr/local/bin/dsh` → `…/@deepseek-ai/dsh/lib/bin.js`),
|
|
9910
|
+
* 而 Node 的 `argv[1]` **保留调用路径、不解析符号链接** ⇒ 不 realpath 就永远上溯不到包根。
|
|
9911
|
+
* ② **核对包名**(`name === '@deepseek-ai/dsh'`)—— 否则在 vitest / npx 等入口下会误取**别人的**
|
|
9912
|
+
* `package.json`(这是本档**唯一**的假阳性来源)。
|
|
9913
|
+
* ③ **上溯有界**({@link MAX_ENTRY_ASCENT})—— 不设界的话,一个不在任何 dsh 包内的入口会一路爬到文件系统根。
|
|
9914
|
+
*
|
|
9915
|
+
* @param entry `process.argv[1]`(单测直接注入,无需替身)
|
|
9916
|
+
*/
|
|
9917
|
+
function dshVersionFromEntry(entry) {
|
|
9918
|
+
if (typeof entry !== "string" || entry === "") return null;
|
|
9919
|
+
let dir;
|
|
9920
|
+
try {
|
|
9921
|
+
const real = realpathSync(entry);
|
|
9922
|
+
dir = statSync(real).isDirectory() ? real : dirname(real);
|
|
9923
|
+
} catch {
|
|
9924
|
+
return null;
|
|
9925
|
+
}
|
|
9926
|
+
for (let i = 0; i < MAX_ENTRY_ASCENT; i++) {
|
|
9927
|
+
try {
|
|
9928
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
9929
|
+
if (pkg.name === HOST_PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
|
|
9930
|
+
} catch {}
|
|
9931
|
+
const parent = dirname(dir);
|
|
9932
|
+
if (parent === dir) break;
|
|
9933
|
+
dir = parent;
|
|
9934
|
+
}
|
|
9935
|
+
return null;
|
|
9936
|
+
}
|
|
9892
9937
|
/**
|
|
9893
9938
|
* 读取已安装 DSH CLI 版本;读不到返回 null。
|
|
9894
|
-
*
|
|
9895
|
-
*
|
|
9939
|
+
*
|
|
9940
|
+
* **两档,顺序有理由**:
|
|
9941
|
+
* ① 🔴 **布局无关档**({@link dshVersionFromEntry})= **正在跑的就是它** —— 直接证据优先于"猜安装位置"。
|
|
9942
|
+
* ② **静态候选链**(跨平台历史档,保留):`npm_config_prefix` / `APPDATA\npm` / `~/.npm-global`
|
|
9943
|
+
* (Windows npm 全局装在 `%APPDATA%\npm`,见审计问题 13)。
|
|
9944
|
+
* ⚠️ 保留它是因为**本进程不一定是 dsh CLI**(单测、嵌入式、将来别的宿主进程)——
|
|
9945
|
+
* 那时"本机装了哪个版本"仍需一个答案,只是证据等级更低。
|
|
9896
9946
|
*/
|
|
9897
9947
|
function readDshVersion() {
|
|
9948
|
+
const fromEntry = dshVersionFromEntry(process.argv[1]);
|
|
9949
|
+
if (fromEntry !== null) return fromEntry;
|
|
9898
9950
|
const candidates = [];
|
|
9899
9951
|
if (process.env.npm_config_prefix) candidates.push(join(process.env.npm_config_prefix, "lib", "node_modules", "@deepseek-ai", "dsh"));
|
|
9900
9952
|
if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", "@deepseek-ai", "dsh"));
|
|
@@ -11017,12 +11069,43 @@ const HOST_SERVICES = [
|
|
|
11017
11069
|
required: false
|
|
11018
11070
|
}
|
|
11019
11071
|
];
|
|
11020
|
-
/**
|
|
11021
|
-
|
|
11022
|
-
|
|
11023
|
-
|
|
11024
|
-
|
|
11025
|
-
|
|
11072
|
+
/**
|
|
11073
|
+
* dsp **被验证过的宿主版本档**(单一真相源)—— 每档 = 一个半开区间 `[floor, ceiling)`。
|
|
11074
|
+
*
|
|
11075
|
+
* 🔴 为什么是"档"而不是一条 caret 范围(R↓:2026-09-28 0.2.0-rc.1 适配轮实测得出):
|
|
11076
|
+
* - **宿主门的判据是真 semver**(`@deepseek-ai/dsh-app-boot:evaluatePluginCompatibility`
|
|
11077
|
+
* = `semver.satisfies(runtime, range, { includePrerelease: true })`)⇒ **它支持 `||` 联合范围**;
|
|
11078
|
+
* - 而 **dsp 这边是手写比较器**({@link compareSemver}),**不实现 semver 的 range 语义**:
|
|
11079
|
+
* 旧写法"用正则从 `REQUIRED_HOST_RANGE` 剥前缀取 floor"在**联合范围上会取到垃圾**
|
|
11080
|
+
* (`^0.1.7-rc.1 || ^0.2.0-rc.1` 的预发布捕获组 `(.*)` 会**吞掉 `||` 之后整段**)
|
|
11081
|
+
* ⇒ 判定**变瞎**(既非"通过"也非"低于下限",而是不可解释的值)。
|
|
11082
|
+
* ⇒ 故**档表是唯一被验证的真相**:`REQUIRED_HOST_RANGE`(给宿主门看的串)与
|
|
11083
|
+
* `checkHostVersion`(给自己的手写比较器)**都从它派生**,两者不可能互相漂移。
|
|
11084
|
+
*
|
|
11085
|
+
* 🔴 两档的由来(**实测,非推断**;判据表见插件仓 `docs/dsh-0.2.0-rc.1-adaptation-plan.md` §7.4):
|
|
11086
|
+
* - `^0.1.7-rc.1` 在 `0.2.0-rc.1` 上被宿主**整包拒绝 + 回滚**
|
|
11087
|
+
* (逐字 `installation rejected: … incompatible with dsh 0.2.0-rc.1` + `restored package.json, …`);
|
|
11088
|
+
* - 单档 `^0.2.0-rc.1` 会**同样硬拒本机正在跑的 `0.1.7-rc.2`**
|
|
11089
|
+
* ⇒ 发布后 deploy 必须先升宿主,**而升级会打断本容器**;
|
|
11090
|
+
* - ⇒ 双档是**唯一不把本机宿主堵死的写法**(两档在真 semver 下均 PASS)。
|
|
11091
|
+
* 档上界取 `<0.2.0` / `<0.3.0`(即 `^0.2.0-rc.1` 的 caret 上界):第二档同时覆盖
|
|
11092
|
+
* `0.2.0` 正式版与后续 `0.2.0-rc.N`。
|
|
11093
|
+
*/
|
|
11094
|
+
const VERIFIED_HOST_BANDS = [{
|
|
11095
|
+
floor: "0.1.7-rc.1",
|
|
11096
|
+
ceiling: "0.2.0"
|
|
11097
|
+
}, {
|
|
11098
|
+
floor: "0.2.0-rc.1",
|
|
11099
|
+
ceiling: "0.3.0"
|
|
11100
|
+
}];
|
|
11101
|
+
/**
|
|
11102
|
+
* 与 `package.json` 的 `peerDependencies` **逐字同串**的宿主版本范围(由 {@link VERIFIED_HOST_BANDS} 派生)。
|
|
11103
|
+
*
|
|
11104
|
+
* 🔴 这个字符串**就是**宿主门(`evaluatePluginCompatibility`)拿去跑 `semver.satisfies` 的那个串
|
|
11105
|
+
* ⇒ 它与档表**必须一致**("声明 union、判定单档"是一种自相矛盾)。
|
|
11106
|
+
* 一致性由 `tests/host-contract.test.ts` 的**跨文件对账用例**钉住(读 package.json 逐字比对)。
|
|
11107
|
+
*/
|
|
11108
|
+
const REQUIRED_HOST_RANGE = VERIFIED_HOST_BANDS.map((b) => `^${b.floor}`).join(" || ");
|
|
11026
11109
|
/** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
|
|
11027
11110
|
function compareSemver(a, b) {
|
|
11028
11111
|
const parse = (v) => {
|
|
@@ -11047,37 +11130,54 @@ function compareSemver(a, b) {
|
|
|
11047
11130
|
return ra < rb ? -1 : 1;
|
|
11048
11131
|
}
|
|
11049
11132
|
/**
|
|
11050
|
-
* 宿主版本是否落在 dsp
|
|
11133
|
+
* 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
|
|
11051
11134
|
* 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
|
|
11052
11135
|
*
|
|
11053
|
-
*
|
|
11054
|
-
*
|
|
11055
|
-
*
|
|
11056
|
-
*
|
|
11136
|
+
* 🔴 **逐档判**(v1.50.x,0.2.0-rc.1 适配轮):判据 = "∃ 档:`version >= floor ∧ version < ceiling`"。
|
|
11137
|
+
* 曾经的做法是"从 `REQUIRED_HOST_RANGE` 剥前缀取一个 floor + 一个显式 ceiling"——
|
|
11138
|
+
* **单档假设一旦不成立(改联合范围),那个正则就会取到垃圾**(详见 {@link VERIFIED_HOST_BANDS}
|
|
11139
|
+
* 的注释)⇒ 故本函数**只读档表**,不再从范围串反推。
|
|
11140
|
+
* ⚠️ 手写比较器**不实现 semver 的 range 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
|
|
11057
11141
|
*/
|
|
11058
11142
|
function checkHostVersion(version) {
|
|
11059
11143
|
if (version === null) return {
|
|
11060
11144
|
ok: false,
|
|
11061
11145
|
detail: `host version unknown (required ${REQUIRED_HOST_RANGE})`
|
|
11062
11146
|
};
|
|
11063
|
-
|
|
11064
|
-
|
|
11065
|
-
|
|
11147
|
+
let matched = false;
|
|
11148
|
+
let belowEveryFloor = true;
|
|
11149
|
+
let unparseable = false;
|
|
11150
|
+
for (const band of VERIFIED_HOST_BANDS) {
|
|
11151
|
+
const floor = compareSemver(version, band.floor);
|
|
11152
|
+
const ceiling = compareSemver(version, band.ceiling);
|
|
11153
|
+
if (floor === null || ceiling === null) {
|
|
11154
|
+
unparseable = true;
|
|
11155
|
+
break;
|
|
11156
|
+
}
|
|
11157
|
+
if (floor >= 0) {
|
|
11158
|
+
belowEveryFloor = false;
|
|
11159
|
+
if (ceiling < 0) {
|
|
11160
|
+
matched = true;
|
|
11161
|
+
break;
|
|
11162
|
+
}
|
|
11163
|
+
}
|
|
11164
|
+
}
|
|
11165
|
+
if (unparseable) return {
|
|
11066
11166
|
ok: false,
|
|
11067
11167
|
detail: `host version "${version}" not parseable (required ${REQUIRED_HOST_RANGE})`
|
|
11068
11168
|
};
|
|
11069
|
-
if (
|
|
11169
|
+
if (matched) return {
|
|
11170
|
+
ok: true,
|
|
11171
|
+
detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
|
|
11172
|
+
};
|
|
11173
|
+
if (belowEveryFloor) return {
|
|
11070
11174
|
ok: false,
|
|
11071
11175
|
detail: `host ${version} is below the verified floor (required ${REQUIRED_HOST_RANGE})`
|
|
11072
11176
|
};
|
|
11073
|
-
|
|
11177
|
+
return {
|
|
11074
11178
|
ok: false,
|
|
11075
11179
|
detail: `host ${version} is outside the verified range (required ${REQUIRED_HOST_RANGE})`
|
|
11076
11180
|
};
|
|
11077
|
-
return {
|
|
11078
|
-
ok: true,
|
|
11079
|
-
detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
|
|
11080
|
-
};
|
|
11081
11181
|
}
|
|
11082
11182
|
/** 结构化读取宿主服务(lazy → ctx.get;injected → 直接属性,属性缺失再试 ctx.get) */
|
|
11083
11183
|
function readService(ctx, name, access) {
|
|
@@ -15923,8 +16023,13 @@ qInput.focus()
|
|
|
15923
16023
|
* `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
|
|
15924
16024
|
*
|
|
15925
16025
|
* ## 边界
|
|
15926
|
-
*
|
|
15927
|
-
*
|
|
16026
|
+
* 本模块做三件事(**都属机制**,与"哪个渠道"无关):
|
|
16027
|
+
* ① **归属判定**(P0-2:谁能起 poller、谁被谁占着)—— 见下 §归属判定
|
|
16028
|
+
* ② **订阅表查询 + 入站路由决策**(P0-3:`(账号, 用户) → (CCC, 角色)`)—— 见 §订阅表
|
|
16029
|
+
* ③ **机器级留痕**(P0-3:未投递 / 目标不存在 / 表残缺)—— 见 §留痕。
|
|
16030
|
+
* 🔴 为什么这些必须机器级:它们**没有目标 CCC** ⇒ 落进任何 CCC 都是"编一个"目标。
|
|
16031
|
+
* **不碰**:轮询循环、凭据解析、会话投递 —— 那些是**渠道实现**,留在 `weixin-bridge.ts`。
|
|
16032
|
+
* ⇒ 本模块**零 DSH 依赖**(只有 `node:crypto` / `node:fs`),可独立单测。
|
|
15928
16033
|
*/
|
|
15929
16034
|
/**
|
|
15930
16035
|
* 算账号的**机器级身份**。
|
|
@@ -16018,6 +16123,135 @@ function releasePollersOf(root) {
|
|
|
16018
16123
|
function releaseAllPollers() {
|
|
16019
16124
|
owners.clear();
|
|
16020
16125
|
}
|
|
16126
|
+
/** 订阅行的四个字段(顺序即报错里的字段顺序) */
|
|
16127
|
+
const SUBSCRIPTION_FIELDS = [
|
|
16128
|
+
"account",
|
|
16129
|
+
"user",
|
|
16130
|
+
"ccc",
|
|
16131
|
+
"role"
|
|
16132
|
+
];
|
|
16133
|
+
/**
|
|
16134
|
+
* 订阅表查询(**纯函数**)。
|
|
16135
|
+
*
|
|
16136
|
+
* 匹配两段式(**刻意对齐**旧 `matchWeixinRoute` 的 exact → `*` 兜底):
|
|
16137
|
+
* ① `account` **精确** ∧ `user` **精确**
|
|
16138
|
+
* ② `account` **精确** ∧ `user === '*'`(通配兜底)
|
|
16139
|
+
* 🔴 `account` **不设通配**:它是机器级账号的**显式身份**(表里写的就是账号 id);
|
|
16140
|
+
* 给账号也开 `*` 会让"这条订阅到底覆盖哪个 bot"变得不可读。
|
|
16141
|
+
* 🔴 `user` 保留 `*`:这是**有意的**——存量 CCC 级 `routes` 用的就是 `{user:'*'}`,
|
|
16142
|
+
* 迁移(P0-4)要能**逐字平移**它,否则"迁移"会悄悄改变行为。
|
|
16143
|
+
*
|
|
16144
|
+
* @param subscriptions 机器级订阅表(`readSimpleSettings().humanChannel.subscriptions`)
|
|
16145
|
+
* @param account 收到的**账号 id**(poller 身份;不是 token)
|
|
16146
|
+
* @param user 发信人 id
|
|
16147
|
+
*/
|
|
16148
|
+
function lookupSubscription(subscriptions, account, user) {
|
|
16149
|
+
const defects = [];
|
|
16150
|
+
const usable = [];
|
|
16151
|
+
subscriptions.forEach((row, index) => {
|
|
16152
|
+
const raw = {
|
|
16153
|
+
account: row?.account,
|
|
16154
|
+
user: row?.user,
|
|
16155
|
+
ccc: row?.ccc,
|
|
16156
|
+
role: row?.role
|
|
16157
|
+
};
|
|
16158
|
+
const present = {};
|
|
16159
|
+
const missing = [];
|
|
16160
|
+
for (const field of SUBSCRIPTION_FIELDS) {
|
|
16161
|
+
const v = raw[field];
|
|
16162
|
+
if (typeof v === "string" && v.trim() !== "") present[field] = v.trim();
|
|
16163
|
+
else missing.push(field);
|
|
16164
|
+
}
|
|
16165
|
+
if (missing.length > 0) {
|
|
16166
|
+
defects.push({
|
|
16167
|
+
index,
|
|
16168
|
+
missing,
|
|
16169
|
+
present
|
|
16170
|
+
});
|
|
16171
|
+
return;
|
|
16172
|
+
}
|
|
16173
|
+
usable.push({
|
|
16174
|
+
account: present.account,
|
|
16175
|
+
user: present.user,
|
|
16176
|
+
ccc: present.ccc,
|
|
16177
|
+
role: present.role
|
|
16178
|
+
});
|
|
16179
|
+
});
|
|
16180
|
+
return {
|
|
16181
|
+
hit: usable.find((r) => r.account === account && r.user === user) ?? usable.find((r) => r.account === account && r.user === "*") ?? null,
|
|
16182
|
+
defects,
|
|
16183
|
+
usable: usable.length
|
|
16184
|
+
};
|
|
16185
|
+
}
|
|
16186
|
+
/**
|
|
16187
|
+
* 入站路由决策(**纯函数**)。
|
|
16188
|
+
*
|
|
16189
|
+
* 顺序:**机器级订阅表优先** → 未命中时用 CCC 级回退。
|
|
16190
|
+
*
|
|
16191
|
+
* 🔴 **回退是过渡期的"不打断在用的微信"**(方案 §11.3 **Q5**):机器级账号表为空时,
|
|
16192
|
+
* 桥仍按 CCC 起、路由也仍认 CCC 级 `routes`。机器级一旦配起来,回退传 `null`
|
|
16193
|
+
* ⇒ **未命中 = 不投递**(不是"投给默认角色"——那正是方案 §3.1 明令禁止的)。
|
|
16194
|
+
* @param input.subscriptions 机器级订阅表
|
|
16195
|
+
* @param input.account 收到的账号 id
|
|
16196
|
+
* @param input.user 发信人 id
|
|
16197
|
+
* @param input.fallback CCC 级回退目标(`{ccc, role}`);无 ⇒ `null` / 省略
|
|
16198
|
+
*/
|
|
16199
|
+
function planInboundRoute(input) {
|
|
16200
|
+
const { hit, defects } = lookupSubscription(input.subscriptions, input.account, input.user);
|
|
16201
|
+
if (hit) return {
|
|
16202
|
+
target: {
|
|
16203
|
+
ccc: hit.ccc,
|
|
16204
|
+
role: hit.role
|
|
16205
|
+
},
|
|
16206
|
+
source: "machine",
|
|
16207
|
+
skipReason: null,
|
|
16208
|
+
defects
|
|
16209
|
+
};
|
|
16210
|
+
if (input.fallback) return {
|
|
16211
|
+
target: {
|
|
16212
|
+
ccc: input.fallback.ccc,
|
|
16213
|
+
role: input.fallback.role
|
|
16214
|
+
},
|
|
16215
|
+
source: "ccc-fallback",
|
|
16216
|
+
skipReason: null,
|
|
16217
|
+
defects
|
|
16218
|
+
};
|
|
16219
|
+
return {
|
|
16220
|
+
target: null,
|
|
16221
|
+
source: null,
|
|
16222
|
+
skipReason: input.subscriptions.length === 0 ? "no-subscription" : "no-match",
|
|
16223
|
+
defects
|
|
16224
|
+
};
|
|
16225
|
+
}
|
|
16226
|
+
/**
|
|
16227
|
+
* 留痕落点:`$DSH_HOME/human-channel-audit.jsonl`(缺省 `~/.dsh/human-channel-audit.jsonl`)。
|
|
16228
|
+
*
|
|
16229
|
+
* 🔴 **为什么是机器级而不是某个 CCC 的 `_weixin-logs/`**:本文件记的事件(未投递 /
|
|
16230
|
+
* 目标不存在 / 表残缺)**恰恰是"没有目标 CCC"的那些** —— 落进任何一个 CCC 都是**编一个**目标。
|
|
16231
|
+
* (投递成功的入站事件仍落**被路由到的那个 CCC** 的 `_weixin-logs/`,方案 §3.6 不变。)
|
|
16232
|
+
* env `SERENITY_HUMAN_CHANNEL_AUDIT` 覆盖(测试注入;与 `SERENITY_HOOKS_CONFIG` 同款推导)。
|
|
16233
|
+
*/
|
|
16234
|
+
function humanChannelAuditPath() {
|
|
16235
|
+
const override = process.env.SERENITY_HUMAN_CHANNEL_AUDIT;
|
|
16236
|
+
if (override && override !== "") return override;
|
|
16237
|
+
const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
|
|
16238
|
+
return join(dshHome, "human-channel-audit.jsonl");
|
|
16239
|
+
}
|
|
16240
|
+
/**
|
|
16241
|
+
* 追加一条留痕。**永不抛** ——
|
|
16242
|
+
* 留痕失败绝不能反过来打断投递(它是**旁路**,与 `invokeWeixinHook` 的"旁路容忍"同款)。
|
|
16243
|
+
* 🔴 **不静默的是它记的那件事**,不是它自己:所以调用方仍要**另行 `console` 出声**。
|
|
16244
|
+
*/
|
|
16245
|
+
function appendHumanChannelAudit(event) {
|
|
16246
|
+
try {
|
|
16247
|
+
const path = humanChannelAuditPath();
|
|
16248
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
16249
|
+
appendFileSync(path, `${JSON.stringify({
|
|
16250
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16251
|
+
...event
|
|
16252
|
+
})}\n`, "utf-8");
|
|
16253
|
+
} catch {}
|
|
16254
|
+
}
|
|
16021
16255
|
const manualSessions = /* @__PURE__ */ new Map();
|
|
16022
16256
|
/**
|
|
16023
16257
|
* 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
|
|
@@ -16306,6 +16540,7 @@ function invokeWeixinHook(root, hookRel, event) {
|
|
|
16306
16540
|
//#region src/weixin-bridge.ts
|
|
16307
16541
|
var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
|
|
16308
16542
|
handleIncoming: () => handleIncoming,
|
|
16543
|
+
handleMachineIncoming: () => handleMachineIncoming,
|
|
16309
16544
|
manualOutputFallbackNeeded: () => manualOutputFallbackNeeded,
|
|
16310
16545
|
registerWeixinBridge: () => registerWeixinBridge,
|
|
16311
16546
|
resolveWeixinAccount: () => resolveWeixinAccount,
|
|
@@ -16313,9 +16548,21 @@ var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
|
|
|
16313
16548
|
stopAllBridges: () => stopAllBridges,
|
|
16314
16549
|
stopCccBridge: () => stopCccBridge,
|
|
16315
16550
|
syncCccBridge: () => syncCccBridge,
|
|
16551
|
+
syncHumanChannel: () => syncHumanChannel,
|
|
16316
16552
|
weixinBridgeStatus: () => weixinBridgeStatus
|
|
16317
16553
|
});
|
|
16318
16554
|
const bridges = /* @__PURE__ */ new Map();
|
|
16555
|
+
/**
|
|
16556
|
+
* 🔴 **机器级 poller 的"根"哨兵**(P0-3 A)—— 它**不是一个目录**。
|
|
16557
|
+
*
|
|
16558
|
+
* 机器级 poller **不属于任何 CCC**(这正是"上移 ACC 层"的含义:谁轮询不再由 CCC 数量决定)。
|
|
16559
|
+
* 用途有二:① `runAccountLoop` 的 `root` 形参在机器级模式下不被使用,传它以示"无根";
|
|
16560
|
+
* ② 归属表(`human-channel.ts`)按 root 区分拥有者 ⇒ 机器级用这个哨兵值,
|
|
16561
|
+
* 与任何**真实** CCC 根都不会撞(真根一律是绝对路径)。
|
|
16562
|
+
*/
|
|
16563
|
+
const MACHINE_SCOPE = "<machine>";
|
|
16564
|
+
/** 机器级 poller(账号 id → 循环)。与 `bridges` **互斥**:同一时刻只有一种模式在跑。 */
|
|
16565
|
+
const machineLoops = /* @__PURE__ */ new Map();
|
|
16319
16566
|
/** 轮询间隔(getupdates 失败后重试延迟;成功 = 立即续轮询) */
|
|
16320
16567
|
const POLL_RETRY_MS = 3e3;
|
|
16321
16568
|
/** 媒体大小上限(M6:20MB,对齐 vlm-describe;超限降级告知不落盘) */
|
|
@@ -16369,7 +16616,7 @@ async function sendTypingStop(cred, accountId, fromUserId) {
|
|
|
16369
16616
|
}
|
|
16370
16617
|
}
|
|
16371
16618
|
/** 单账号轮询循环:getupdates 长轮询 → 逐消息分发 → 回复回写 */
|
|
16372
|
-
async function runAccountLoop(ctx, root, accountId, cred, loop) {
|
|
16619
|
+
async function runAccountLoop(ctx, root, accountId, cred, loop, machine = false) {
|
|
16373
16620
|
let buf = "";
|
|
16374
16621
|
while (!loop.stopped) {
|
|
16375
16622
|
loop.lastPollAt = Date.now();
|
|
@@ -16384,7 +16631,8 @@ async function runAccountLoop(ctx, root, accountId, cred, loop) {
|
|
|
16384
16631
|
for (const msg of resp.msgs ?? []) {
|
|
16385
16632
|
if (loop.stopped) break;
|
|
16386
16633
|
if (msg.message_type !== 1) continue;
|
|
16387
|
-
await
|
|
16634
|
+
if (machine) await handleMachineIncoming(ctx, accountId, cred, msg);
|
|
16635
|
+
else await handleIncoming(ctx, root, accountId, cred, msg);
|
|
16388
16636
|
}
|
|
16389
16637
|
} catch (err) {
|
|
16390
16638
|
loop.lastError = err instanceof Error ? err.message : String(err);
|
|
@@ -16427,6 +16675,106 @@ function manualOutputFallbackNeeded(input) {
|
|
|
16427
16675
|
return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
|
|
16428
16676
|
}
|
|
16429
16677
|
/**
|
|
16678
|
+
* 订阅表里的 `ccc` **别名 → CCC 根**(P0-3)。
|
|
16679
|
+
*
|
|
16680
|
+
* 别名取值与面板下拉**同源**(方案 §2.3:`listCccs({withRoles:true})` 的下拉)⇒
|
|
16681
|
+
* 先按 **`CccEntry.name`**(= CCC 根目录名,`basename(root)`)匹配;再退一步接受**绝对路径**
|
|
16682
|
+
* (手写配置时人会直接粘路径)。两条都试 ⇒ 两种写法都可用,且**不引入第二张名字表**。
|
|
16683
|
+
* @returns 命中的 CCC 根;枚举里没有 ⇒ `null`(调用方**响亮报错**,不静默丢弃)
|
|
16684
|
+
*/
|
|
16685
|
+
async function resolveCccRootByAlias(ctx, alias) {
|
|
16686
|
+
try {
|
|
16687
|
+
const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
|
|
16688
|
+
const entries = await listCccs(ctx);
|
|
16689
|
+
const byName = entries.find((e) => e.name === alias);
|
|
16690
|
+
if (byName) return byName.root;
|
|
16691
|
+
const byRoot = entries.find((e) => e.root === alias);
|
|
16692
|
+
return byRoot ? byRoot.root : null;
|
|
16693
|
+
} catch {
|
|
16694
|
+
return null;
|
|
16695
|
+
}
|
|
16696
|
+
}
|
|
16697
|
+
/**
|
|
16698
|
+
* **入站路由的单一解析处**(P0-3):机器级订阅表优先 → CCC 级回退;三档语义都在这里落地。
|
|
16699
|
+
*
|
|
16700
|
+
* 🔴 为什么抽成一个函数而不是写两遍:**两种模式(CCC 起桥 / 机器级起 poller)走的是同一套
|
|
16701
|
+
* 判据**,差别只有一格 —— **有没有 CCC 级回退**(`cccFallback`)。写两遍必然分叉。
|
|
16702
|
+
*
|
|
16703
|
+
* 三档(方案 §3.1,**刻意分档**):
|
|
16704
|
+
* - **未命中** ⇒ 不投递 + **留痕**(`console.warn`,静默档:这是"表里没有你",不是错误)
|
|
16705
|
+
* - **表残缺** ⇒ **响亮出声**(`console.error`;且**独立于是否命中**)
|
|
16706
|
+
* - **目标不存在**(CCC / 角色)⇒ **响亮报错**(`console.error`;不静默丢弃)
|
|
16707
|
+
*
|
|
16708
|
+
* @param input.cccFallback 过渡期回退(方案 Q5);**机器级模式传 `null`** ⇒ 未命中即不投递
|
|
16709
|
+
*/
|
|
16710
|
+
async function resolveInboundTarget(ctx, input) {
|
|
16711
|
+
const route = planInboundRoute({
|
|
16712
|
+
subscriptions: readSimpleSettings().humanChannel.subscriptions,
|
|
16713
|
+
account: input.account,
|
|
16714
|
+
user: input.user,
|
|
16715
|
+
fallback: input.cccFallback ? {
|
|
16716
|
+
ccc: input.cccFallback.root,
|
|
16717
|
+
role: input.cccFallback.role
|
|
16718
|
+
} : null
|
|
16719
|
+
});
|
|
16720
|
+
for (const defect of route.defects) {
|
|
16721
|
+
console.error(`[serenity-hooks] ✗ human-channel: 订阅表第 ${defect.index + 1} 行**残缺**(缺 ${defect.missing.join(" / ")})|已有字段 ${JSON.stringify(defect.present)}|该行**不生效** —— 请补全或删掉(机器级 humanChannel.subscriptions)`);
|
|
16722
|
+
appendHumanChannelAudit({
|
|
16723
|
+
kind: "subscription-defect",
|
|
16724
|
+
account: input.account,
|
|
16725
|
+
index: defect.index,
|
|
16726
|
+
missing: defect.missing,
|
|
16727
|
+
present: defect.present
|
|
16728
|
+
});
|
|
16729
|
+
}
|
|
16730
|
+
const target = route.target;
|
|
16731
|
+
if (!target) {
|
|
16732
|
+
const detail = route.skipReason === "no-subscription" ? "机器级订阅表为空(且无 CCC 级回退)" : "订阅表里没有匹配 (账号, 用户) 的行";
|
|
16733
|
+
console.warn(`[serenity-hooks] ⚠ human-channel: 入站未命中订阅 ⇒ **不投递**(account=${input.account} user=${input.user}):${detail}`);
|
|
16734
|
+
appendHumanChannelAudit({
|
|
16735
|
+
kind: "inbound-unrouted",
|
|
16736
|
+
account: input.account,
|
|
16737
|
+
user: input.user,
|
|
16738
|
+
reason: route.skipReason ?? "no-match",
|
|
16739
|
+
detail
|
|
16740
|
+
});
|
|
16741
|
+
return null;
|
|
16742
|
+
}
|
|
16743
|
+
const root = route.source === "ccc-fallback" ? target.ccc : await resolveCccRootByAlias(ctx, target.ccc);
|
|
16744
|
+
if (root === null) {
|
|
16745
|
+
const detail = `订阅表指向的 CCC "${target.ccc}" 在本机 CCC 枚举里不存在`;
|
|
16746
|
+
console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表`);
|
|
16747
|
+
appendHumanChannelAudit({
|
|
16748
|
+
kind: "inbound-target-missing",
|
|
16749
|
+
account: input.account,
|
|
16750
|
+
user: input.user,
|
|
16751
|
+
ccc: target.ccc,
|
|
16752
|
+
role: target.role,
|
|
16753
|
+
detail
|
|
16754
|
+
});
|
|
16755
|
+
return null;
|
|
16756
|
+
}
|
|
16757
|
+
const role = readSkiffRoles(root).get(target.role);
|
|
16758
|
+
if (!role) {
|
|
16759
|
+
const detail = `目标 CCC "${target.ccc}" 未定义 skiff 角色 "${target.role}"`;
|
|
16760
|
+
console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表或该 CCC 的 skiff.roles`);
|
|
16761
|
+
appendHumanChannelAudit({
|
|
16762
|
+
kind: "inbound-target-missing",
|
|
16763
|
+
account: input.account,
|
|
16764
|
+
user: input.user,
|
|
16765
|
+
ccc: target.ccc,
|
|
16766
|
+
role: target.role,
|
|
16767
|
+
detail
|
|
16768
|
+
});
|
|
16769
|
+
return null;
|
|
16770
|
+
}
|
|
16771
|
+
return {
|
|
16772
|
+
root,
|
|
16773
|
+
roleName: target.role,
|
|
16774
|
+
role
|
|
16775
|
+
};
|
|
16776
|
+
}
|
|
16777
|
+
/**
|
|
16430
16778
|
* 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
|
|
16431
16779
|
*
|
|
16432
16780
|
* 会话语义:`weixinSessionIdFor(fromUserId)` 固定可重建——同用户长期同一会话;
|
|
@@ -16443,16 +16791,35 @@ function manualOutputFallbackNeeded(input) {
|
|
|
16443
16791
|
* `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
|
|
16444
16792
|
* 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
|
|
16445
16793
|
* 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
|
|
16794
|
+
*
|
|
16795
|
+
* 🔴 **P0-3(2026-09-27):路由改由机器级订阅表决定**(本函数的 `root` 入参退为
|
|
16796
|
+
* **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
|
|
16797
|
+
* **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
|
|
16798
|
+
* **不是**"投给默认角色"。
|
|
16446
16799
|
*/
|
|
16447
|
-
async function handleIncoming(ctx, root, accountId, cred, msg) {
|
|
16800
|
+
async function handleIncoming(ctx, root, accountId, cred, msg, preset) {
|
|
16448
16801
|
const fromUserId = msg.from_user_id;
|
|
16449
16802
|
if (!fromUserId) return;
|
|
16450
16803
|
const text = extractWeixinText(msg);
|
|
16451
16804
|
const mediaRefs = extractWeixinMedia(msg);
|
|
16452
16805
|
const settings = readWeixinSettings(root);
|
|
16453
|
-
|
|
16454
|
-
|
|
16455
|
-
|
|
16806
|
+
let resolved;
|
|
16807
|
+
if (preset) resolved = preset.target;
|
|
16808
|
+
else {
|
|
16809
|
+
if (!settings.enabled) return;
|
|
16810
|
+
const cccRole = matchWeixinRoute(settings.routes ?? [], fromUserId);
|
|
16811
|
+
resolved = await resolveInboundTarget(ctx, {
|
|
16812
|
+
account: accountId,
|
|
16813
|
+
user: fromUserId,
|
|
16814
|
+
cccFallback: cccRole ? {
|
|
16815
|
+
root,
|
|
16816
|
+
role: cccRole
|
|
16817
|
+
} : null
|
|
16818
|
+
});
|
|
16819
|
+
}
|
|
16820
|
+
if (!resolved) return;
|
|
16821
|
+
const { roleName, role } = resolved;
|
|
16822
|
+
root = resolved.root;
|
|
16456
16823
|
if (!text && mediaRefs.length === 0) {
|
|
16457
16824
|
if (hasVoiceItem(msg)) await sendTextMessage({
|
|
16458
16825
|
baseUrl: cred.baseUrl,
|
|
@@ -16464,11 +16831,6 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
|
|
|
16464
16831
|
return;
|
|
16465
16832
|
}
|
|
16466
16833
|
try {
|
|
16467
|
-
const role = readSkiffRoles(root).get(roleName);
|
|
16468
|
-
if (!role) {
|
|
16469
|
-
console.log(`[serenity-hooks] weixin-bridge: 路由命中角色 "${roleName}" 但该 CCC 未定义(检查 skiff.roles)`);
|
|
16470
|
-
return;
|
|
16471
|
-
}
|
|
16472
16834
|
const sessionId = weixinSessionIdFor(fromUserId);
|
|
16473
16835
|
const existing = getSkiffAgent(sessionId);
|
|
16474
16836
|
const hc = readHandymanConfig(root);
|
|
@@ -16628,6 +16990,31 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
|
|
|
16628
16990
|
}
|
|
16629
16991
|
}
|
|
16630
16992
|
/**
|
|
16993
|
+
* **机器级 poller 的单条消息入口**(P0-3 A)。
|
|
16994
|
+
*
|
|
16995
|
+
* 与 `handleIncoming` 的唯一差别:**目标由机器级订阅表逐条解析,且没有 CCC 级回退**
|
|
16996
|
+
* (方案 §3.1:机器级模式下**未命中 = 不投递**,不是"投给默认角色")。
|
|
16997
|
+
* 解析出来后把**目标根**当 `root` 交给 `handleIncoming`(并带上 `preset` ⇒ 它不再自行路由、
|
|
16998
|
+
* 也不看目标 CCC 的 `weixin.enabled`)。
|
|
16999
|
+
*
|
|
17000
|
+
* 🔴 为什么"起 poller"与"路由"必须同批(本件即 P0-2 的后半):机器级 poller 收到消息时
|
|
17001
|
+
* **不知道它属于哪个 CCC** —— 那个答案是订阅表给的。没有订阅表 ⇒ **能收不能投**。
|
|
17002
|
+
*
|
|
17003
|
+
* 🔵 **导出供测试直接驱动**(与 `handleIncoming` 同款:它俩都是"单条消息的入口",
|
|
17004
|
+
* 生产路径 `runAccountLoop` 只是循环壳)。
|
|
17005
|
+
*/
|
|
17006
|
+
async function handleMachineIncoming(ctx, accountId, cred, msg) {
|
|
17007
|
+
const fromUserId = msg.from_user_id;
|
|
17008
|
+
if (!fromUserId) return;
|
|
17009
|
+
const target = await resolveInboundTarget(ctx, {
|
|
17010
|
+
account: accountId,
|
|
17011
|
+
user: fromUserId,
|
|
17012
|
+
cccFallback: null
|
|
17013
|
+
});
|
|
17014
|
+
if (!target) return;
|
|
17015
|
+
await handleIncoming(ctx, target.root, accountId, cred, msg, { target });
|
|
17016
|
+
}
|
|
17017
|
+
/**
|
|
16631
17018
|
* 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
|
|
16632
17019
|
* 已存在(配置变化热重建)→ 先停旧循环再启动新的。
|
|
16633
17020
|
*
|
|
@@ -16689,13 +17076,118 @@ function stopCccBridge(root) {
|
|
|
16689
17076
|
bridges.delete(root);
|
|
16690
17077
|
}
|
|
16691
17078
|
/**
|
|
16692
|
-
*
|
|
17079
|
+
* 停掉**全部 CCC 级桥**(不碰机器级 poller)+ 释放它们各自的账号归属。
|
|
17080
|
+
* 🔴 模式切换时必须先调它:否则同一个 bot 会被**两处**轮询(方案 §2.6 要消灭的正是这个)。
|
|
17081
|
+
*/
|
|
17082
|
+
function stopCccBridges() {
|
|
17083
|
+
for (const root of [...bridges.keys()]) stopCccBridge(root);
|
|
17084
|
+
}
|
|
17085
|
+
/** 停掉**机器级 poller**(不碰 CCC 级桥)+ 释放机器级归属 */
|
|
17086
|
+
function stopMachineLoops() {
|
|
17087
|
+
for (const loop of machineLoops.values()) loop.stopped = true;
|
|
17088
|
+
machineLoops.clear();
|
|
17089
|
+
releasePollersOf(MACHINE_SCOPE);
|
|
17090
|
+
}
|
|
17091
|
+
/**
|
|
17092
|
+
* 停止全部桥(插件 dispose):CCC 级 + 机器级
|
|
16693
17093
|
*/
|
|
16694
17094
|
function stopAllBridges() {
|
|
16695
|
-
|
|
16696
|
-
|
|
17095
|
+
stopCccBridges();
|
|
17096
|
+
stopMachineLoops();
|
|
16697
17097
|
releaseAllPollers();
|
|
16698
17098
|
}
|
|
17099
|
+
/** 已告警过的"未实现渠道"(避免每次 session/created 都刷一行) */
|
|
17100
|
+
const warnedChannels = /* @__PURE__ */ new Set();
|
|
17101
|
+
/**
|
|
17102
|
+
* **机器级模式**:按**账号**起 poller(方案 §2.6「一账号一 poller」的正面形态)。
|
|
17103
|
+
*
|
|
17104
|
+
* 与 CCC 模式的差别:poller **不属于任何 CCC** —— 消息投到哪由**订阅表逐条决定**
|
|
17105
|
+
* (见 `handleMachineIncoming`)。凭据来自机器级配置本身(方案 Q3:凭据落插件 Config)。
|
|
17106
|
+
*
|
|
17107
|
+
* 🔴 进入本函数即**先停掉全部 CCC 级桥**:两种模式**互斥**(同时跑 = 同一 bot 两处轮询)。
|
|
17108
|
+
*/
|
|
17109
|
+
function syncMachinePollers(ctx, hc) {
|
|
17110
|
+
stopCccBridges();
|
|
17111
|
+
const requests = [];
|
|
17112
|
+
const creds = /* @__PURE__ */ new Map();
|
|
17113
|
+
for (const account of hc.accounts) {
|
|
17114
|
+
const id = account.id;
|
|
17115
|
+
if (account.enabled === false) continue;
|
|
17116
|
+
if (!id) {
|
|
17117
|
+
console.warn("[serenity-hooks] ⚠ human-channel: 机器级账号表有一行**缺 `id`** ⇒ 跳过(该行无法被订阅表引用)");
|
|
17118
|
+
continue;
|
|
17119
|
+
}
|
|
17120
|
+
if (account.channel !== "weixin") {
|
|
17121
|
+
if (!warnedChannels.has(`${id}:${account.channel ?? ""}`)) {
|
|
17122
|
+
warnedChannels.add(`${id}:${account.channel ?? ""}`);
|
|
17123
|
+
console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} 的渠道 "${account.channel ?? "(未填)"}" **本机未实现** ⇒ 不起 poller(当前只有 weixin)`);
|
|
17124
|
+
}
|
|
17125
|
+
continue;
|
|
17126
|
+
}
|
|
17127
|
+
const token = (account.token ?? "").trim();
|
|
17128
|
+
if (token === "") {
|
|
17129
|
+
console.warn(`[serenity-hooks] ⚠ human-channel: 账号 ${id} **无 token**(未绑定)⇒ 不起 poller`);
|
|
17130
|
+
continue;
|
|
17131
|
+
}
|
|
17132
|
+
creds.set(id, {
|
|
17133
|
+
token,
|
|
17134
|
+
baseUrl: "",
|
|
17135
|
+
userId: account.userId
|
|
17136
|
+
});
|
|
17137
|
+
requests.push({
|
|
17138
|
+
root: MACHINE_SCOPE,
|
|
17139
|
+
accountId: id,
|
|
17140
|
+
identity: accountIdentity(MACHINE_SCOPE, id, token)
|
|
17141
|
+
});
|
|
17142
|
+
}
|
|
17143
|
+
const plan = planPollerClaims(requests, pollerOwners());
|
|
17144
|
+
for (const { request, ownedBy } of plan.skipped) console.warn(`[serenity-hooks] ⚠ human-channel: 机器级账号 ${request.accountId} 与 ${ownedBy.accountId} **绑的是同一个 bot** ⇒ 本处不重复起循环(一账号一 poller;方案 §2.6)`);
|
|
17145
|
+
stopMachineLoops();
|
|
17146
|
+
for (const claim of plan.claims) {
|
|
17147
|
+
const cred = creds.get(claim.accountId);
|
|
17148
|
+
if (!cred) continue;
|
|
17149
|
+
const loop = {
|
|
17150
|
+
stopped: false,
|
|
17151
|
+
lastPollAt: 0
|
|
17152
|
+
};
|
|
17153
|
+
machineLoops.set(claim.accountId, loop);
|
|
17154
|
+
runAccountLoop(ctx, MACHINE_SCOPE, claim.accountId, cred, loop, true);
|
|
17155
|
+
}
|
|
17156
|
+
commitPollerClaims(plan);
|
|
17157
|
+
if (machineLoops.size > 0) console.log(`[serenity-hooks] ✓ human-channel: 机器级 poller accounts=${[...machineLoops.keys()].join(",")}(与 CCC 无关)`);
|
|
17158
|
+
}
|
|
17159
|
+
/**
|
|
17160
|
+
* 人机信道的**装配入口**(P0-3 A)—— 决定用哪种模式起 poller。
|
|
17161
|
+
*
|
|
17162
|
+
* 🔴 **模式由机器级账号表决定**(方案 §11.3 **Q5** 的过渡期语义):
|
|
17163
|
+
* - 账号表**非空** ⇒ **机器级模式**:按账号起 poller;路由**只认订阅表**
|
|
17164
|
+
* - 账号表**为空** ⇒ **回退模式**(今天的形态):按 CCC 起桥;路由**先查订阅表,未命中回退
|
|
17165
|
+
* 到 CCC 级 routes** ⇒ **不打断在用的微信**
|
|
17166
|
+
* ⚠️ 两种模式**互斥**(各自进入时先停掉另一种),否则同一个 bot 会被两处轮询。
|
|
17167
|
+
*/
|
|
17168
|
+
function syncHumanChannel(ctx) {
|
|
17169
|
+
let hc;
|
|
17170
|
+
try {
|
|
17171
|
+
hc = readSimpleSettings().humanChannel;
|
|
17172
|
+
} catch (err) {
|
|
17173
|
+
console.warn(`[serenity-hooks] ✗ human-channel: 机器级配置读取失败 ⇒ 按"未配置"处理(回退按 CCC 起桥): ${String(err?.message ?? err)}`);
|
|
17174
|
+
}
|
|
17175
|
+
if (hc && hc.accounts.length > 0) {
|
|
17176
|
+
try {
|
|
17177
|
+
syncMachinePollers(ctx, hc);
|
|
17178
|
+
} catch (err) {
|
|
17179
|
+
console.warn(`[serenity-hooks] ✗ human-channel: 机器级 poller 装配失败: ${String(err?.message ?? err)}`);
|
|
17180
|
+
}
|
|
17181
|
+
return;
|
|
17182
|
+
}
|
|
17183
|
+
(async () => {
|
|
17184
|
+
try {
|
|
17185
|
+
stopMachineLoops();
|
|
17186
|
+
const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
|
|
17187
|
+
for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
|
|
17188
|
+
} catch {}
|
|
17189
|
+
})();
|
|
17190
|
+
}
|
|
16699
17191
|
/**
|
|
16700
17192
|
* 解析该 CCC 的发送账号 + 凭据(v1.31.0:文本与文件发送的**单一账号选择真相源**)。
|
|
16701
17193
|
* 顺序:桥启用 → 有启用账号 → 指定账号存在 → 凭据已绑定(扫码)。
|
|
@@ -16778,35 +17270,37 @@ async function sendProactiveText(input) {
|
|
|
16778
17270
|
role
|
|
16779
17271
|
};
|
|
16780
17272
|
}
|
|
16781
|
-
/**
|
|
17273
|
+
/**
|
|
17274
|
+
* 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康。
|
|
17275
|
+
* 🔴 **P0-3 A 起多一行**:机器级 poller 以 `ccc: '<machine>'`(`MACHINE_SCOPE`)出现 ——
|
|
17276
|
+
* 它**不属于任何 CCC**,故**不伪装成某个 CCC 的行**(面板按原样显示这个哨兵值即可)。
|
|
17277
|
+
*/
|
|
16782
17278
|
function weixinBridgeStatus() {
|
|
16783
17279
|
const out = [];
|
|
17280
|
+
const toAccounts = (loops) => [...loops.entries()].map(([accountId, loop]) => ({
|
|
17281
|
+
accountId,
|
|
17282
|
+
lastPollAt: loop.lastPollAt,
|
|
17283
|
+
...loop.lastError ? { lastError: loop.lastError } : {}
|
|
17284
|
+
}));
|
|
16784
17285
|
for (const [root, bridge] of bridges) out.push({
|
|
16785
17286
|
ccc: root,
|
|
16786
|
-
accounts:
|
|
16787
|
-
|
|
16788
|
-
|
|
16789
|
-
|
|
16790
|
-
|
|
17287
|
+
accounts: toAccounts(bridge.loops)
|
|
17288
|
+
});
|
|
17289
|
+
if (machineLoops.size > 0) out.push({
|
|
17290
|
+
ccc: MACHINE_SCOPE,
|
|
17291
|
+
accounts: toAccounts(machineLoops)
|
|
16791
17292
|
});
|
|
16792
17293
|
return out;
|
|
16793
17294
|
}
|
|
16794
17295
|
/**
|
|
16795
|
-
* 装配(index.ts
|
|
16796
|
-
*
|
|
17296
|
+
* 装配(index.ts 调用):**按机器级配置决定模式**(P0-3 A:`syncHumanChannel`),
|
|
17297
|
+
* 并监听会话变化以重扫(新 CCC 出现即启动其桥;配置变化也可经此路径热重建)。
|
|
17298
|
+
* 事件驱动(对齐 autotrajectory v1.26.15)。
|
|
16797
17299
|
*/
|
|
16798
17300
|
function registerWeixinBridge(ctx) {
|
|
16799
|
-
|
|
16800
|
-
(async () => {
|
|
16801
|
-
try {
|
|
16802
|
-
const { listCccs } = await import("./ccc-roots-Duk89a-h.js").then((n) => n.a);
|
|
16803
|
-
for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
|
|
16804
|
-
} catch {}
|
|
16805
|
-
})();
|
|
16806
|
-
};
|
|
16807
|
-
syncFromLive();
|
|
17301
|
+
syncHumanChannel(ctx);
|
|
16808
17302
|
try {
|
|
16809
|
-
ctx.on("session/created", () =>
|
|
17303
|
+
ctx.on("session/created", () => syncHumanChannel(ctx));
|
|
16810
17304
|
} catch {}
|
|
16811
17305
|
}
|
|
16812
17306
|
//#endregion
|
package/lib/status.d.ts
CHANGED
|
@@ -4,10 +4,37 @@
|
|
|
4
4
|
* WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值 / handyman 模型。
|
|
5
5
|
* setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* 🔴 **布局无关档**:从**正在跑的那个入口脚本**反推宿主包根,读它的 `version`。
|
|
9
|
+
*
|
|
10
|
+
* 为什么必须补这一档(R↓:一处**静默失效**的实际缺陷;2026-09-28 docker bench 实测):
|
|
11
|
+
* 下面那条静态候选链({@link readDshVersion} 的 ②)是**猜安装位置**——`npm_config_prefix` /
|
|
12
|
+
* `APPDATA\npm` / `~/.npm-global`。一旦宿主装在**别处**(典型 = 容器镜像里的
|
|
13
|
+
* `/usr/local/lib/node_modules/@deepseek-ai/dsh`),**三个候选全不中** ⇒ 返回 `null`
|
|
14
|
+
* ⇒ `checkHostVersion(null)` 只给一条 **`required: false`** 的 issue ⇒ 报告 `ok` 仍为 `true`
|
|
15
|
+
* ⇒ **版本门完全静默**(bench 逐字:`host contract degraded (1 optional): host version unknown`)。
|
|
16
|
+
* ⚠️ 这类坏法**比"偶尔报警"更危险**:一条**永远为假**的判据看起来像"没报警 = 没问题"。
|
|
17
|
+
*
|
|
18
|
+
* 而"我们正跑在哪个宿主里"**本来就有直接证据**:`process.argv[1]`(= 启动本进程的那个脚本)。
|
|
19
|
+
* 三条判据要点(每条都对应一个实测过的坑):
|
|
20
|
+
* ① **先 `realpath`** —— 全局安装的 `dsh` 是**符号链接**(`/usr/local/bin/dsh` → `…/@deepseek-ai/dsh/lib/bin.js`),
|
|
21
|
+
* 而 Node 的 `argv[1]` **保留调用路径、不解析符号链接** ⇒ 不 realpath 就永远上溯不到包根。
|
|
22
|
+
* ② **核对包名**(`name === '@deepseek-ai/dsh'`)—— 否则在 vitest / npx 等入口下会误取**别人的**
|
|
23
|
+
* `package.json`(这是本档**唯一**的假阳性来源)。
|
|
24
|
+
* ③ **上溯有界**({@link MAX_ENTRY_ASCENT})—— 不设界的话,一个不在任何 dsh 包内的入口会一路爬到文件系统根。
|
|
25
|
+
*
|
|
26
|
+
* @param entry `process.argv[1]`(单测直接注入,无需替身)
|
|
27
|
+
*/
|
|
28
|
+
export declare function dshVersionFromEntry(entry: string | undefined): string | null;
|
|
7
29
|
/**
|
|
8
30
|
* 读取已安装 DSH CLI 版本;读不到返回 null。
|
|
9
|
-
*
|
|
10
|
-
*
|
|
31
|
+
*
|
|
32
|
+
* **两档,顺序有理由**:
|
|
33
|
+
* ① 🔴 **布局无关档**({@link dshVersionFromEntry})= **正在跑的就是它** —— 直接证据优先于"猜安装位置"。
|
|
34
|
+
* ② **静态候选链**(跨平台历史档,保留):`npm_config_prefix` / `APPDATA\npm` / `~/.npm-global`
|
|
35
|
+
* (Windows npm 全局装在 `%APPDATA%\npm`,见审计问题 13)。
|
|
36
|
+
* ⚠️ 保留它是因为**本进程不一定是 dsh CLI**(单测、嵌入式、将来别的宿主进程)——
|
|
37
|
+
* 那时"本机装了哪个版本"仍需一个答案,只是证据等级更低。
|
|
11
38
|
*/
|
|
12
39
|
export declare function readDshVersion(): string | null;
|
|
13
40
|
interface SerenityStatus {
|
package/lib/weixin-bridge.d.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { Context } from 'cordis';
|
|
20
20
|
import { type WeixinAccountCredential } from './weixin-route.js';
|
|
21
21
|
import { type WeixinMessage } from './weixin-api.js';
|
|
22
|
+
import { type SkiffRoleConfig } from './skiff-role.js';
|
|
22
23
|
/** 测试辅助:清空 typing_ticket 缓存(生产零调用) */
|
|
23
24
|
export declare function resetWeixinTypingCache(): void;
|
|
24
25
|
/**
|
|
@@ -36,6 +37,15 @@ export declare function manualOutputFallbackNeeded(input: {
|
|
|
36
37
|
sentThisTurn: boolean;
|
|
37
38
|
answer: string;
|
|
38
39
|
}): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* 路由解析结果:目标根 + 角色名 + **角色配置**(三者一起给,避免调用方再读一次 skiff.roles)。
|
|
42
|
+
* `null` 从本函数出去 ⇒ **不投递**(未命中已留痕 / 目标不存在已响亮出声)。
|
|
43
|
+
*/
|
|
44
|
+
interface ResolvedInboundTarget {
|
|
45
|
+
root: string;
|
|
46
|
+
roleName: string;
|
|
47
|
+
role: SkiffRoleConfig;
|
|
48
|
+
}
|
|
39
49
|
/**
|
|
40
50
|
* 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
|
|
41
51
|
*
|
|
@@ -53,8 +63,38 @@ export declare function manualOutputFallbackNeeded(input: {
|
|
|
53
63
|
* `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
|
|
54
64
|
* 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
|
|
55
65
|
* 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
|
|
66
|
+
*
|
|
67
|
+
* 🔴 **P0-3(2026-09-27):路由改由机器级订阅表决定**(本函数的 `root` 入参退为
|
|
68
|
+
* **回退目标**)。两档语义刻意分开(方案 §3.1):**未命中 ⇒ 不投递 + 留痕**(静默档);
|
|
69
|
+
* **目标不存在(CCC / 角色)⇒ 响亮报错**(`console.error` + 机器级留痕)。两者都
|
|
70
|
+
* **不是**"投给默认角色"。
|
|
56
71
|
*/
|
|
57
|
-
export declare function handleIncoming(ctx: Context, root: string, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'
|
|
72
|
+
export declare function handleIncoming(ctx: Context, root: string, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'>,
|
|
73
|
+
/**
|
|
74
|
+
* **机器级模式**(P0-3 A):目标已由 `handleMachineIncoming` 解析好(`root` 入参即该目标根)。
|
|
75
|
+
* 给了它 ⇒ 本函数**不再自行路由**,且**不看本 CCC 的 `weixin.enabled`** ——
|
|
76
|
+
* 起 poller 与路由都已上移机器级(目标 CCC 没配 weixin 段也照样收得到)。
|
|
77
|
+
* ⚠️ **行为开关仍归目标 CCC**(`hook` / `autoReplyWithLastMessage` / `fallbackOnNoSend`)
|
|
78
|
+
* —— 机器级只管"**谁收**"与"**投到哪**",不管"**怎么答**"。
|
|
79
|
+
*/
|
|
80
|
+
preset?: {
|
|
81
|
+
target: ResolvedInboundTarget;
|
|
82
|
+
}): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* **机器级 poller 的单条消息入口**(P0-3 A)。
|
|
85
|
+
*
|
|
86
|
+
* 与 `handleIncoming` 的唯一差别:**目标由机器级订阅表逐条解析,且没有 CCC 级回退**
|
|
87
|
+
* (方案 §3.1:机器级模式下**未命中 = 不投递**,不是"投给默认角色")。
|
|
88
|
+
* 解析出来后把**目标根**当 `root` 交给 `handleIncoming`(并带上 `preset` ⇒ 它不再自行路由、
|
|
89
|
+
* 也不看目标 CCC 的 `weixin.enabled`)。
|
|
90
|
+
*
|
|
91
|
+
* 🔴 为什么"起 poller"与"路由"必须同批(本件即 P0-2 的后半):机器级 poller 收到消息时
|
|
92
|
+
* **不知道它属于哪个 CCC** —— 那个答案是订阅表给的。没有订阅表 ⇒ **能收不能投**。
|
|
93
|
+
*
|
|
94
|
+
* 🔵 **导出供测试直接驱动**(与 `handleIncoming` 同款:它俩都是"单条消息的入口",
|
|
95
|
+
* 生产路径 `runAccountLoop` 只是循环壳)。
|
|
96
|
+
*/
|
|
97
|
+
export declare function handleMachineIncoming(ctx: Context, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'>): Promise<void>;
|
|
58
98
|
/**
|
|
59
99
|
* 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
|
|
60
100
|
* 已存在(配置变化热重建)→ 先停旧循环再启动新的。
|
|
@@ -68,9 +108,19 @@ export declare function syncCccBridge(ctx: Context, root: string): void;
|
|
|
68
108
|
/** 停止某 CCC 的桥(移除账号/禁用时);同时释放它的账号归属 */
|
|
69
109
|
export declare function stopCccBridge(root: string): void;
|
|
70
110
|
/**
|
|
71
|
-
* 停止全部桥(插件 dispose
|
|
111
|
+
* 停止全部桥(插件 dispose):CCC 级 + 机器级
|
|
72
112
|
*/
|
|
73
113
|
export declare function stopAllBridges(): void;
|
|
114
|
+
/**
|
|
115
|
+
* 人机信道的**装配入口**(P0-3 A)—— 决定用哪种模式起 poller。
|
|
116
|
+
*
|
|
117
|
+
* 🔴 **模式由机器级账号表决定**(方案 §11.3 **Q5** 的过渡期语义):
|
|
118
|
+
* - 账号表**非空** ⇒ **机器级模式**:按账号起 poller;路由**只认订阅表**
|
|
119
|
+
* - 账号表**为空** ⇒ **回退模式**(今天的形态):按 CCC 起桥;路由**先查订阅表,未命中回退
|
|
120
|
+
* 到 CCC 级 routes** ⇒ **不打断在用的微信**
|
|
121
|
+
* ⚠️ 两种模式**互斥**(各自进入时先停掉另一种),否则同一个 bot 会被两处轮询。
|
|
122
|
+
*/
|
|
123
|
+
export declare function syncHumanChannel(ctx: Context): void;
|
|
74
124
|
/** 主动发送失败原因(稳定 code——入口层翻译成 HTTP 状态 + 可行动提示) */
|
|
75
125
|
type ProactiveSendErrorCode = 'BRIDGE_DISABLED' | 'NO_ACCOUNT' | 'ACCOUNT_NOT_FOUND' | 'ACCOUNT_NOT_BOUND' | 'SEND_FAILED';
|
|
76
126
|
interface ProactiveSendInput {
|
|
@@ -129,7 +179,11 @@ type WeixinAccountResolution = {
|
|
|
129
179
|
*/
|
|
130
180
|
export declare function resolveWeixinAccount(root: string, accountId?: string): WeixinAccountResolution;
|
|
131
181
|
export declare function sendProactiveText(input: ProactiveSendInput): Promise<ProactiveSendResult>;
|
|
132
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* 桥状态快照(面板数据源):每 CCC → 每账号 → 轮询健康。
|
|
184
|
+
* 🔴 **P0-3 A 起多一行**:机器级 poller 以 `ccc: '<machine>'`(`MACHINE_SCOPE`)出现 ——
|
|
185
|
+
* 它**不属于任何 CCC**,故**不伪装成某个 CCC 的行**(面板按原样显示这个哨兵值即可)。
|
|
186
|
+
*/
|
|
133
187
|
export declare function weixinBridgeStatus(): Array<{
|
|
134
188
|
ccc: string;
|
|
135
189
|
accounts: Array<{
|
|
@@ -139,8 +193,9 @@ export declare function weixinBridgeStatus(): Array<{
|
|
|
139
193
|
}>;
|
|
140
194
|
}>;
|
|
141
195
|
/**
|
|
142
|
-
* 装配(index.ts
|
|
143
|
-
*
|
|
196
|
+
* 装配(index.ts 调用):**按机器级配置决定模式**(P0-3 A:`syncHumanChannel`),
|
|
197
|
+
* 并监听会话变化以重扫(新 CCC 出现即启动其桥;配置变化也可经此路径热重建)。
|
|
198
|
+
* 事件驱动(对齐 autotrajectory v1.26.15)。
|
|
144
199
|
*/
|
|
145
200
|
export declare function registerWeixinBridge(ctx: Context): void;
|
|
146
201
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shgroup/dsh-serenity-hooks",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.1",
|
|
4
4
|
"description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,21 +54,21 @@
|
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
57
|
-
"@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
|
|
58
|
-
"@deepseek-ai/dsh-agent-loop": "^0.1.7-rc.1",
|
|
59
|
-
"@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.1",
|
|
60
|
-
"@deepseek-ai/dsh-compaction": "^0.1.7-rc.1",
|
|
61
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
|
|
62
|
-
"@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
|
|
63
|
-
"@deepseek-ai/dsh-session": "^0.1.7-rc.1",
|
|
64
|
-
"@deepseek-ai/dsh-session-title": "^0.1.7-rc.1",
|
|
65
|
-
"@deepseek-ai/dsh-shell": "^0.1.7-rc.1",
|
|
66
|
-
"@deepseek-ai/dsh-shell-env": "^0.1.7-rc.1",
|
|
67
|
-
"@deepseek-ai/dsh-skill": "^0.1.7-rc.1",
|
|
68
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1",
|
|
69
|
-
"@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
|
|
70
|
-
"@deepseek-ai/dsh-web": "^0.1.7-rc.1",
|
|
71
|
-
"@deepseek-ai/dsh-web-fetch-http": "^0.1.7-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-agent": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-agent-loop": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-compaction": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-session": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-session-title": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-shell": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-shell-env": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-skill": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-web": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-web-fetch-http": "^0.1.7-rc.1 || ^0.2.0-rc.1",
|
|
72
72
|
"@deepseek-ai/schemastery": "^3.18.4",
|
|
73
73
|
"cordis": "^4.0.0-rc.7"
|
|
74
74
|
},
|