@shgroup/dsh-serenity-hooks 1.40.1 → 1.42.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 +1 -1
- package/lib/deepseek-vision-patch.d.ts +168 -0
- package/lib/diag-ops.d.ts +3 -1
- package/lib/index.d.ts +10 -0
- package/lib/index.js +261 -12
- package/lib/wake-registry.d.ts +53 -0
- package/lib/{wake-scheduler-BRnPUnER.js → wake-scheduler-1AsvNQPW.js} +117 -12
- package/package.json +1 -1
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-serenity-hooks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
4
4
|
"main": "lib/index.js",
|
|
5
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-rc.2(deepseek-ai/deepseek-harness)。",
|
|
6
6
|
"engines": {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deepseek-vision-patch.ts — DeepSeek 模型的"支持图片"声明自动补齐(临时,S142 §26 §0x-9)
|
|
3
|
+
*
|
|
4
|
+
* ## 这东西用来干什么(D70:先用途、后机制)
|
|
5
|
+
*
|
|
6
|
+
* DSH 里"某个模型能不能收图"**不是自动识别的**,而是靠**声明**。
|
|
7
|
+
* 声明链(`dsh-llm-pi-ai/lib/index.js:682`):
|
|
8
|
+
*
|
|
9
|
+
* input: declaredInput(entry.input) ?? base?.input ?? [...request.defaultInput]
|
|
10
|
+
*
|
|
11
|
+
* 少数模型在**内置目录**里声明了 `["text","image"]`(如 `deepseek-v4-flash-vision-exp`);
|
|
12
|
+
* 而**不在目录里**的模型走最后一段兜底,`DEFAULT_INPUT = ["text"]`(`:906`)
|
|
13
|
+
* ⇒ **明明支持图片的模型被当成"只能收文字"**(图片被丢/报错)。
|
|
14
|
+
*
|
|
15
|
+
* 本模块:启动时检查模型配置,**凡 id 含 `deepseek` 的模型**,把 `input` 补上 `"image"`。
|
|
16
|
+
* ⇒ 效果:DeepSeek 系模型**开箱能收图**,无需用户手配。
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么叫"临时"(owner 2026-09-19 原话:「临时的我允许」「以后 dsh 做得更完善了我们这个功能再下掉」)
|
|
19
|
+
*
|
|
20
|
+
* 这是在**替上游补一个它还缺的声明**。⇒ 设计上必须**易于整体撤除**(见 §撤除)。
|
|
21
|
+
*
|
|
22
|
+
* ## 授权与边界(**不是后门**)
|
|
23
|
+
*
|
|
24
|
+
* owner 2026-09-19 14:5x 明确授权走**写入型**(§0x-9d 路线 c):
|
|
25
|
+
* 「临时的我允许,还是要 patch,帮我做个逻辑,如果发现任何 deepseek 模型,都直接 patch 支持多模态」。
|
|
26
|
+
* ⇒ 与 `opencode-provider.ts` **同款通道、同款授权模式**(`ctx.settings.update` 写 `llm-pi-ai` 命名空间)。
|
|
27
|
+
* 🔴 **只补缺失,绝不覆盖用户已有声明**(判据 J4);**只碰 deepseek 模型**(判据 J1/J2)。
|
|
28
|
+
*
|
|
29
|
+
* ## 注入通道(唯一可用且是官方的)—— 与 `opencode-provider.ts` 同源
|
|
30
|
+
*
|
|
31
|
+
* `ctx.settings.update('llm-pi-ai', patch)`:
|
|
32
|
+
* ① 深合并(`mergeLayers` 逐层递归)② **先校验后落盘** ③ `writable` ⇒ 写入用户 settings 文档(0600)。
|
|
33
|
+
*
|
|
34
|
+
* ## 🔴 核心实现约束:深合并对**数组是整体替换**(本模块最易错处)
|
|
35
|
+
*
|
|
36
|
+
* 宿主原文(`dsh-settings/lib/index.js:203-216`,逐字):
|
|
37
|
+
* "Layer `over` onto `under`: plain objects merge recursively,
|
|
38
|
+
* **every other value (arrays included) replaces the lower layer wholesale**."
|
|
39
|
+
*
|
|
40
|
+
* ⇒ 若只发 `{ providers: { r: { models: [ 一个元素 ] } } }`,宿主会把它当**整个 models 数组**
|
|
41
|
+
* ⇒ **静默冲掉该 route 上其它所有模型**。
|
|
42
|
+
* ⇒ 本模块**必须读出现有 `models` 全量 → 内存里改目标元素 → 把改过的整个数组回写**。
|
|
43
|
+
* (该行为已由 `tests/host/deepseek-vision-probe.test.ts` 的 W-1 **实测**钉住,非仅源码推断。)
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ 两条同族陷阱(已在实现中处理):
|
|
46
|
+
* ① 回写元素必须 **spread 原对象**(否则丢 `name`/`contextWindow`/`compat` 等字段 = 静默改配置);
|
|
47
|
+
* ② `modelOverrides` 与 `models` **互斥**(`dsh-llm-pi-ai/lib/index.js:643`)⇒
|
|
48
|
+
* 某 route 未设 `models` 时**绝不新建** `models` 数组(会让该 route 当场校验失败)。
|
|
49
|
+
*
|
|
50
|
+
* ## 已知边界(诚实,不粉饰)
|
|
51
|
+
*
|
|
52
|
+
* 🔴 **本模块只作用于 `llm-pi-ai` 面**。`dsh-llm-deepseek` 有自己的 `DEFAULT_MODELS` +
|
|
53
|
+
* `inputModalities` 字段(`dsh-llm-deepseek/lib/index.js:1841-1884`),**是另一条声明链**。
|
|
54
|
+
* ⇒ 若某 deepseek 模型仍收不了图,第一步应判定它走哪条链,而不是断定本 patch 失效。
|
|
55
|
+
*
|
|
56
|
+
* ## 撤除(三层,从便宜到彻底)
|
|
57
|
+
*
|
|
58
|
+
* ① **关开关**:CCC 配置 `visionPatch.enabled = false` ⇒ 停止再补(已写的值留着)
|
|
59
|
+
* ② **整体移除**:删本文件 + `index.ts` 一行装配 ⇒ 功能彻底消失
|
|
60
|
+
* ③ **清值**:手动编辑 `~/.dsh/settings.yaml` 删掉那些 `input`
|
|
61
|
+
*
|
|
62
|
+
* 🔴 **刻意不做"回滚已写入的值"**(R↓):我们写的是**事实**("该模型支持图片"),不是**偏好**;
|
|
63
|
+
* 停掉插件后它依然是事实。做回滚需要一份"哪些是我们写的"的**写入台账**——
|
|
64
|
+
* 那是第二真相源,属本仓已多次识别过的"只增不清"病族(§0z / §0A-5),**不值得为它引入**。
|
|
65
|
+
*/
|
|
66
|
+
import type { Context } from 'cordis';
|
|
67
|
+
/** 触发判据:模型 id 含此串即补(**大小写不敏感**;owner:「任何 deepseek 模型」) */
|
|
68
|
+
export declare const DEEPSEEK_ID_MARKER = "deepseek";
|
|
69
|
+
/** 要补上的模态(判据 J3) */
|
|
70
|
+
export declare const VISION_MODALITY = "image";
|
|
71
|
+
/**
|
|
72
|
+
* 该模型 id 是否属于"要 patch"的对象(判据 J1)。
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ **大小写不敏感**:目录里存在 `deepseek-*` 与 `DeepSeek-*` 两种写法
|
|
75
|
+
* (如 `dsh-llm-deepseek` 的 `name: "DeepSeek-V41-Flash"`)⇒ 只做小写比较。
|
|
76
|
+
*/
|
|
77
|
+
export declare function isDeepseekModelId(id: unknown): boolean;
|
|
78
|
+
/** 该 `input` 声明是否**已含** image(判据 J3/J4:已含即跳过 ⇒ 幂等) */
|
|
79
|
+
export declare function declaresImage(input: unknown): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* 计算目标 `input` 数组(**保序去重**;判据 J3)。
|
|
82
|
+
*
|
|
83
|
+
* 六种现状 → 结果(设计 §4.2 全表,测试逐条钉住):
|
|
84
|
+
* | 现状 | 结果 |
|
|
85
|
+
* |---|---|
|
|
86
|
+
* | 未设(undefined) | `["text","image"]` |
|
|
87
|
+
* | `["text"]` | `["text","image"]` |
|
|
88
|
+
* | `[]` | `["text","image"]` 🔴 `declaredInput([])` 返回 undefined(`:293`)⇒ 空数组语义上等于未声明 |
|
|
89
|
+
* | `["image"]` | **不动**(已含 ⇒ 由调用方跳过) |
|
|
90
|
+
* | `["text","image"]` | **不动**(幂等) |
|
|
91
|
+
* | 非数组(脏数据) | `["text","image"]`(按"未设"处理,不猜用户意图) |
|
|
92
|
+
*
|
|
93
|
+
* @returns 新的 input 数组(已含 image 时原样返回副本)
|
|
94
|
+
*/
|
|
95
|
+
export declare function desiredInput(input: unknown): string[];
|
|
96
|
+
interface DeepseekVisionPatchInput {
|
|
97
|
+
/** `llm-pi-ai` 命名空间**解析后**的值(`settings.get` 结果;undefined = 命名空间未注册) */
|
|
98
|
+
resolved: unknown;
|
|
99
|
+
/** 开关(CCC 配置 `visionPatch.enabled`);undefined ⇒ 按默认 true */
|
|
100
|
+
enabled?: boolean;
|
|
101
|
+
}
|
|
102
|
+
/** 一条动作记录(日志 + 测试断言共用;不返回"秘密"、只返回做了什么) */
|
|
103
|
+
export type DeepseekVisionAction = {
|
|
104
|
+
kind: 'patch-model';
|
|
105
|
+
route: string;
|
|
106
|
+
model: string;
|
|
107
|
+
} | {
|
|
108
|
+
kind: 'skip-model';
|
|
109
|
+
route: string;
|
|
110
|
+
model: string;
|
|
111
|
+
reason: 'already-image';
|
|
112
|
+
} | {
|
|
113
|
+
kind: 'idle';
|
|
114
|
+
reason: 'namespace-unregistered' | 'disabled' | 'no-deepseek-model';
|
|
115
|
+
};
|
|
116
|
+
export interface DeepseekVisionPatchPlan {
|
|
117
|
+
/** 传给 `settings.update` 的补丁;空对象 = 无事可做(**不写**,避免无谓触发 settings/updated) */
|
|
118
|
+
patch: Record<string, unknown>;
|
|
119
|
+
actions: readonly DeepseekVisionAction[];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 计算 patch 计划(**纯函数**:不读文件、不碰 ctx —— 便于穷举测试)。
|
|
123
|
+
*
|
|
124
|
+
* 判据顺序(R↓):
|
|
125
|
+
* ① 开关关闭 → idle `disabled`(**最先判**:关了就不该再看数据)
|
|
126
|
+
* ② 命名空间未注册(`resolved === undefined`)→ idle,调用方负责重试(llm-pi-ai 可能后装载)
|
|
127
|
+
* ③ 逐 route 遍历 `models` 数组:
|
|
128
|
+
* · 目标是 deepseek 且未声明 image ⇒ 该模型换新 input
|
|
129
|
+
* · 否则**原样保留**(非 deepseek 一律不动;已含 image 一律不动)
|
|
130
|
+
* ⇒ 仅当**整个数组确有变化**时才产出 patch(幂等 ⇒ 稳态下零写入)
|
|
131
|
+
* ④ 全无改动 → idle `no-deepseek-model`
|
|
132
|
+
*
|
|
133
|
+
* 🔴 **数组整写**:`patchProviders[route] = { models: 新数组 }` —— 新数组是**全量**
|
|
134
|
+
* (未改动的元素**原样带上**)。这是本模块最关键的实现约束,理由见文件头。
|
|
135
|
+
*
|
|
136
|
+
* 🔴 **绝不新建 `models`**:某 route 没有 `models`(用 `modelOverrides` 或目录默认)时**直接跳过**
|
|
137
|
+
* ——为它造一个 `models` 会让宿主拒掉整个 route(`:643` 二者互斥)。
|
|
138
|
+
*/
|
|
139
|
+
export declare function planDeepseekVisionPatch(input: DeepseekVisionPatchInput): DeepseekVisionPatchPlan;
|
|
140
|
+
/** 动作 → 单行人读日志(无秘密) */
|
|
141
|
+
export declare function describeDeepseekVisionAction(action: DeepseekVisionAction): string;
|
|
142
|
+
/** 执行结果(调用方据此决定是否停止重试 / 日志) */
|
|
143
|
+
export interface DeepseekVisionApplyResult {
|
|
144
|
+
/** 是否**真的写入了** */
|
|
145
|
+
wrote: boolean;
|
|
146
|
+
/** 命名空间是否已注册(false ⇒ 调用方应重试) */
|
|
147
|
+
registered: boolean;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 执行一次自动补齐。返回是否写入了(调用方据此决定是否停止重试)。
|
|
151
|
+
*
|
|
152
|
+
* 失败语义(F-08/F-07 纪律):**任何失败都不抛给宿主**——
|
|
153
|
+
* apply 抛错 = 整个 dsh 启动失败。一律 try/catch + 响亮告警,
|
|
154
|
+
* 最坏情况退回"用户手配 input"。
|
|
155
|
+
*/
|
|
156
|
+
export declare function applyDeepseekVisionPatchOnce(ctx: Context, enabled?: boolean): Promise<DeepseekVisionApplyResult>;
|
|
157
|
+
/**
|
|
158
|
+
* 装配 DeepSeek 多模态补丁。
|
|
159
|
+
*
|
|
160
|
+
* 触发面(三层,与 opencode-provider 同款):
|
|
161
|
+
* ① apply 时立即试一次
|
|
162
|
+
* ② 命名空间尚未注册 → 退避重试(1s/3s/8s/20s/40s)
|
|
163
|
+
* ③ `settings/updated` → 再试(用户热改 settings.yaml 时自愈;自身写入触发的复评是幂等空转)
|
|
164
|
+
*
|
|
165
|
+
* 🔴 不设"一次性完成"闸门:③ 必须能持续生效(用户随时可能新增 deepseek 模型)。
|
|
166
|
+
*/
|
|
167
|
+
export declare function registerDeepseekVisionPatch(ctx: Context, enabled?: boolean): void;
|
|
168
|
+
export {};
|
package/lib/diag-ops.d.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* ① live 运行态 —— 插件进程内 `diagLive(ctx)`(进程 cwd/CCC + live 会话清单)
|
|
12
12
|
* ——**独立进程看不到运行时**,这正是本工具存在的理由
|
|
13
13
|
* ①b 唤醒时钟 —— `containerClocks().wake`(唤醒调度器进程内快照:武装态/计数/上次跳过原因)
|
|
14
|
-
* ③ 唤醒注册表 —— `wake-registry.json`
|
|
14
|
+
* ③ 唤醒注册表 —— `wake-registry.json` 的**在办条目**(state / at / target / lastResult)+ 补跑窗口
|
|
15
|
+
* (🔴 2026-09-19 §0Q 甲案:终态条目**结案即清除** ⇒ 本段只列在办项,不再是"历史全量";
|
|
16
|
+
* 历史由 SESSION.md / 工具输出承担——该承诺原本就写在 `wake-registry.ts` 注释里,本次做实)
|
|
15
17
|
*
|
|
16
18
|
* ── 2026-09-15:段数由四降为三(S142「ACC 侧 autopilot 退场」§1 二阶裁定)─────────────
|
|
17
19
|
* ACC 侧 autopilot 整段退场后,报告里两段的**主语消失**,故删除(不是"暂时关掉"):
|
package/lib/index.d.ts
CHANGED
|
@@ -72,6 +72,16 @@ export interface Config {
|
|
|
72
72
|
opencodeProvider?: {
|
|
73
73
|
autoConfigure?: boolean;
|
|
74
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* v1.40.x DeepSeek 多模态临时补丁(plugin 全局;S142 owner 2026-09-19 令):
|
|
77
|
+
* 给 id 含 `deepseek` 的模型补 `input: [...,"image"]`——因为 DSH 里"模型能否收图"靠**声明**,
|
|
78
|
+
* 而**不在内置目录里**的模型走 `DEFAULT_INPUT = ["text"]` 兜底 ⇒ 明明支持图片却被当成只能收文字。
|
|
79
|
+
* 缺省开(`!== false` 判定)。🔴 **临时功能**:owner「以后 dsh 做得更完善了我们这个功能再下掉」
|
|
80
|
+
* ⇒ 关闭 = 停止再补;**不回滚已写入的值**(写的是"该模型支持图片"这个**事实**,不是偏好)。
|
|
81
|
+
*/
|
|
82
|
+
visionPatch?: {
|
|
83
|
+
enabled?: boolean;
|
|
84
|
+
};
|
|
75
85
|
}
|
|
76
86
|
export declare const Config: z<Config>;
|
|
77
87
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPE
|
|
|
7
7
|
import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-CEHL5cek.js";
|
|
8
8
|
import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-FTjoWQTQ.js";
|
|
9
9
|
import { r as sessionsRootDir, t as hasSessionLogById } from "./session-cleanup-BtkSmry8.js";
|
|
10
|
-
import { a as WAKE_CATCH_UP_MS, i as registerDisposer, n as sendToTrajectory, o as addWake, r as wakeSchedulerState, s as listWakes, t as registerWakeScheduler } from "./wake-scheduler-
|
|
10
|
+
import { a as WAKE_CATCH_UP_MS, i as registerDisposer, n as sendToTrajectory, o as addWake, r as wakeSchedulerState, s as listWakes, t as registerWakeScheduler } from "./wake-scheduler-1AsvNQPW.js";
|
|
11
11
|
import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-DMDUoQum.js";
|
|
12
12
|
import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-CtFYm53S.js";
|
|
13
13
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -13570,7 +13570,7 @@ function opencodeRouteHeaders() {
|
|
|
13570
13570
|
"x-opencode-project": "global"
|
|
13571
13571
|
};
|
|
13572
13572
|
}
|
|
13573
|
-
function isPlainObject(value) {
|
|
13573
|
+
function isPlainObject$1(value) {
|
|
13574
13574
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13575
13575
|
}
|
|
13576
13576
|
/** 该路由是否指向 opencode(按路由名,或按 baseURL 主机名——用户可能把路由改名) */
|
|
@@ -13622,14 +13622,14 @@ function planOpencodeAutoConfig(input) {
|
|
|
13622
13622
|
reason: "namespace-unregistered"
|
|
13623
13623
|
}]
|
|
13624
13624
|
};
|
|
13625
|
-
const providers = isPlainObject(input.resolved) && isPlainObject(input.resolved.providers) ? input.resolved.providers : {};
|
|
13625
|
+
const providers = isPlainObject$1(input.resolved) && isPlainObject$1(input.resolved.providers) ? input.resolved.providers : {};
|
|
13626
13626
|
const patchProviders = {};
|
|
13627
13627
|
let sawOpencodeRoute = false;
|
|
13628
13628
|
for (const [route, rawProfile] of Object.entries(providers)) {
|
|
13629
|
-
const profile = isPlainObject(rawProfile) ? rawProfile : {};
|
|
13629
|
+
const profile = isPlainObject$1(rawProfile) ? rawProfile : {};
|
|
13630
13630
|
if (!isOpencodeRoute(route, profile)) continue;
|
|
13631
13631
|
sawOpencodeRoute = true;
|
|
13632
|
-
const existing = isPlainObject(profile.headers) ? profile.headers : {};
|
|
13632
|
+
const existing = isPlainObject$1(profile.headers) ? profile.headers : {};
|
|
13633
13633
|
const sessionId = sessionIdentityValue(existing);
|
|
13634
13634
|
const wanted = {
|
|
13635
13635
|
...opencodeRouteHeaders(),
|
|
@@ -13740,7 +13740,7 @@ async function applyOpencodeAutoConfigOnce(ctx, env) {
|
|
|
13740
13740
|
* 与本插件 apply **无先后保证**(两者都依赖 settings 服务)。首次读取拿到 undefined 是常态而非错误
|
|
13741
13741
|
* (与 skiff 的 CCC root 重试同款问题、同款处置:v1.30.13 实证"只在 apply 时试一次 → 永不重试")。
|
|
13742
13742
|
*/
|
|
13743
|
-
const NAMESPACE_RETRY_DELAYS_MS = [
|
|
13743
|
+
const NAMESPACE_RETRY_DELAYS_MS$1 = [
|
|
13744
13744
|
1e3,
|
|
13745
13745
|
3e3,
|
|
13746
13746
|
8e3,
|
|
@@ -13769,12 +13769,12 @@ function registerOpencodeAutoConfig(ctx) {
|
|
|
13769
13769
|
};
|
|
13770
13770
|
const scheduleRetry = () => {
|
|
13771
13771
|
if (retryTimer !== null || settled) return;
|
|
13772
|
-
if (retries >= NAMESPACE_RETRY_DELAYS_MS.length) {
|
|
13772
|
+
if (retries >= NAMESPACE_RETRY_DELAYS_MS$1.length) {
|
|
13773
13773
|
console.warn(`[serenity-hooks] opencode 路由自动配置放弃:重试 ${retries} 次仍等不到 ${LLM_PI_AI_NAMESPACE} 命名空间(未使用 pi-ai 适配器时正常;若要手配见 README)`);
|
|
13774
13774
|
settled = true;
|
|
13775
13775
|
return;
|
|
13776
13776
|
}
|
|
13777
|
-
const delay = NAMESPACE_RETRY_DELAYS_MS[retries] ?? 0;
|
|
13777
|
+
const delay = NAMESPACE_RETRY_DELAYS_MS$1[retries] ?? 0;
|
|
13778
13778
|
retryTimer = setTimeout(() => {
|
|
13779
13779
|
retryTimer = null;
|
|
13780
13780
|
retries += 1;
|
|
@@ -13802,6 +13802,250 @@ function registerOpencodeAutoConfig(ctx) {
|
|
|
13802
13802
|
sync();
|
|
13803
13803
|
registerDisposer(ctx, "opencode provider auto-config retry timer", clearRetry);
|
|
13804
13804
|
}
|
|
13805
|
+
/** 要补上的模态(判据 J3) */
|
|
13806
|
+
const VISION_MODALITY = "image";
|
|
13807
|
+
/** 文本模态(补 `image` 时保证 `text` 在列——兜底现状就是 `["text"]`,故保持这个基线) */
|
|
13808
|
+
const TEXT_MODALITY = "text";
|
|
13809
|
+
/**
|
|
13810
|
+
* 该模型 id 是否属于"要 patch"的对象(判据 J1)。
|
|
13811
|
+
*
|
|
13812
|
+
* ⚠️ **大小写不敏感**:目录里存在 `deepseek-*` 与 `DeepSeek-*` 两种写法
|
|
13813
|
+
* (如 `dsh-llm-deepseek` 的 `name: "DeepSeek-V41-Flash"`)⇒ 只做小写比较。
|
|
13814
|
+
*/
|
|
13815
|
+
function isDeepseekModelId(id) {
|
|
13816
|
+
return typeof id === "string" && id.toLowerCase().includes("deepseek");
|
|
13817
|
+
}
|
|
13818
|
+
/** 该 `input` 声明是否**已含** image(判据 J3/J4:已含即跳过 ⇒ 幂等) */
|
|
13819
|
+
function declaresImage(input) {
|
|
13820
|
+
return Array.isArray(input) && input.includes("image");
|
|
13821
|
+
}
|
|
13822
|
+
/**
|
|
13823
|
+
* 计算目标 `input` 数组(**保序去重**;判据 J3)。
|
|
13824
|
+
*
|
|
13825
|
+
* 六种现状 → 结果(设计 §4.2 全表,测试逐条钉住):
|
|
13826
|
+
* | 现状 | 结果 |
|
|
13827
|
+
* |---|---|
|
|
13828
|
+
* | 未设(undefined) | `["text","image"]` |
|
|
13829
|
+
* | `["text"]` | `["text","image"]` |
|
|
13830
|
+
* | `[]` | `["text","image"]` 🔴 `declaredInput([])` 返回 undefined(`:293`)⇒ 空数组语义上等于未声明 |
|
|
13831
|
+
* | `["image"]` | **不动**(已含 ⇒ 由调用方跳过) |
|
|
13832
|
+
* | `["text","image"]` | **不动**(幂等) |
|
|
13833
|
+
* | 非数组(脏数据) | `["text","image"]`(按"未设"处理,不猜用户意图) |
|
|
13834
|
+
*
|
|
13835
|
+
* @returns 新的 input 数组(已含 image 时原样返回副本)
|
|
13836
|
+
*/
|
|
13837
|
+
function desiredInput(input) {
|
|
13838
|
+
if (!Array.isArray(input)) return [TEXT_MODALITY, VISION_MODALITY];
|
|
13839
|
+
if (input.length === 0) return [TEXT_MODALITY, VISION_MODALITY];
|
|
13840
|
+
const out = input.filter((m) => typeof m === "string");
|
|
13841
|
+
if (out.includes("image")) return out;
|
|
13842
|
+
if (!out.includes(TEXT_MODALITY)) out.unshift(TEXT_MODALITY);
|
|
13843
|
+
out.push(VISION_MODALITY);
|
|
13844
|
+
return out;
|
|
13845
|
+
}
|
|
13846
|
+
function isPlainObject(value) {
|
|
13847
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13848
|
+
}
|
|
13849
|
+
/**
|
|
13850
|
+
* 计算 patch 计划(**纯函数**:不读文件、不碰 ctx —— 便于穷举测试)。
|
|
13851
|
+
*
|
|
13852
|
+
* 判据顺序(R↓):
|
|
13853
|
+
* ① 开关关闭 → idle `disabled`(**最先判**:关了就不该再看数据)
|
|
13854
|
+
* ② 命名空间未注册(`resolved === undefined`)→ idle,调用方负责重试(llm-pi-ai 可能后装载)
|
|
13855
|
+
* ③ 逐 route 遍历 `models` 数组:
|
|
13856
|
+
* · 目标是 deepseek 且未声明 image ⇒ 该模型换新 input
|
|
13857
|
+
* · 否则**原样保留**(非 deepseek 一律不动;已含 image 一律不动)
|
|
13858
|
+
* ⇒ 仅当**整个数组确有变化**时才产出 patch(幂等 ⇒ 稳态下零写入)
|
|
13859
|
+
* ④ 全无改动 → idle `no-deepseek-model`
|
|
13860
|
+
*
|
|
13861
|
+
* 🔴 **数组整写**:`patchProviders[route] = { models: 新数组 }` —— 新数组是**全量**
|
|
13862
|
+
* (未改动的元素**原样带上**)。这是本模块最关键的实现约束,理由见文件头。
|
|
13863
|
+
*
|
|
13864
|
+
* 🔴 **绝不新建 `models`**:某 route 没有 `models`(用 `modelOverrides` 或目录默认)时**直接跳过**
|
|
13865
|
+
* ——为它造一个 `models` 会让宿主拒掉整个 route(`:643` 二者互斥)。
|
|
13866
|
+
*/
|
|
13867
|
+
function planDeepseekVisionPatch(input) {
|
|
13868
|
+
const actions = [];
|
|
13869
|
+
if (input.enabled === false) return {
|
|
13870
|
+
patch: {},
|
|
13871
|
+
actions: [{
|
|
13872
|
+
kind: "idle",
|
|
13873
|
+
reason: "disabled"
|
|
13874
|
+
}]
|
|
13875
|
+
};
|
|
13876
|
+
if (input.resolved === void 0) return {
|
|
13877
|
+
patch: {},
|
|
13878
|
+
actions: [{
|
|
13879
|
+
kind: "idle",
|
|
13880
|
+
reason: "namespace-unregistered"
|
|
13881
|
+
}]
|
|
13882
|
+
};
|
|
13883
|
+
const providers = isPlainObject(input.resolved) && isPlainObject(input.resolved.providers) ? input.resolved.providers : {};
|
|
13884
|
+
const patchProviders = {};
|
|
13885
|
+
for (const [route, rawProfile] of Object.entries(providers)) {
|
|
13886
|
+
const models = (isPlainObject(rawProfile) ? rawProfile : {}).models;
|
|
13887
|
+
if (!Array.isArray(models) || models.length === 0) continue;
|
|
13888
|
+
let changed = false;
|
|
13889
|
+
const nextModels = models.map((rawEntry) => {
|
|
13890
|
+
if (!isPlainObject(rawEntry)) return rawEntry;
|
|
13891
|
+
const id = rawEntry.id;
|
|
13892
|
+
if (!isDeepseekModelId(id)) return rawEntry;
|
|
13893
|
+
if (declaresImage(rawEntry.input)) {
|
|
13894
|
+
actions.push({
|
|
13895
|
+
kind: "skip-model",
|
|
13896
|
+
route,
|
|
13897
|
+
model: String(id),
|
|
13898
|
+
reason: "already-image"
|
|
13899
|
+
});
|
|
13900
|
+
return rawEntry;
|
|
13901
|
+
}
|
|
13902
|
+
changed = true;
|
|
13903
|
+
actions.push({
|
|
13904
|
+
kind: "patch-model",
|
|
13905
|
+
route,
|
|
13906
|
+
model: String(id)
|
|
13907
|
+
});
|
|
13908
|
+
return {
|
|
13909
|
+
...rawEntry,
|
|
13910
|
+
input: desiredInput(rawEntry.input)
|
|
13911
|
+
};
|
|
13912
|
+
});
|
|
13913
|
+
if (changed) patchProviders[route] = { models: nextModels };
|
|
13914
|
+
}
|
|
13915
|
+
if (Object.keys(patchProviders).length === 0) {
|
|
13916
|
+
if (actions.length === 0) actions.push({
|
|
13917
|
+
kind: "idle",
|
|
13918
|
+
reason: "no-deepseek-model"
|
|
13919
|
+
});
|
|
13920
|
+
return {
|
|
13921
|
+
patch: {},
|
|
13922
|
+
actions
|
|
13923
|
+
};
|
|
13924
|
+
}
|
|
13925
|
+
return {
|
|
13926
|
+
patch: { providers: patchProviders },
|
|
13927
|
+
actions
|
|
13928
|
+
};
|
|
13929
|
+
}
|
|
13930
|
+
/**
|
|
13931
|
+
* 执行一次自动补齐。返回是否写入了(调用方据此决定是否停止重试)。
|
|
13932
|
+
*
|
|
13933
|
+
* 失败语义(F-08/F-07 纪律):**任何失败都不抛给宿主**——
|
|
13934
|
+
* apply 抛错 = 整个 dsh 启动失败。一律 try/catch + 响亮告警,
|
|
13935
|
+
* 最坏情况退回"用户手配 input"。
|
|
13936
|
+
*/
|
|
13937
|
+
async function applyDeepseekVisionPatchOnce(ctx, enabled) {
|
|
13938
|
+
const settings = hostSettings(ctx);
|
|
13939
|
+
if (settings?.get === void 0 || settings.update === void 0) {
|
|
13940
|
+
console.warn("[serenity-hooks] DeepSeek 多模态补丁跳过:settings 服务不可用(未能读写 llm-pi-ai 命名空间)");
|
|
13941
|
+
return {
|
|
13942
|
+
wrote: false,
|
|
13943
|
+
registered: false
|
|
13944
|
+
};
|
|
13945
|
+
}
|
|
13946
|
+
let resolved;
|
|
13947
|
+
try {
|
|
13948
|
+
resolved = settings.get.call(settings, LLM_PI_AI_NAMESPACE);
|
|
13949
|
+
} catch (error) {
|
|
13950
|
+
console.warn(`[serenity-hooks] DeepSeek 多模态补丁跳过:读取 ${LLM_PI_AI_NAMESPACE} 失败: ${String(error?.message ?? error)}`);
|
|
13951
|
+
return {
|
|
13952
|
+
wrote: false,
|
|
13953
|
+
registered: false
|
|
13954
|
+
};
|
|
13955
|
+
}
|
|
13956
|
+
const plan = planDeepseekVisionPatch({
|
|
13957
|
+
resolved,
|
|
13958
|
+
...enabled === void 0 ? {} : { enabled }
|
|
13959
|
+
});
|
|
13960
|
+
if (Object.keys(plan.patch).length === 0) return {
|
|
13961
|
+
wrote: false,
|
|
13962
|
+
registered: resolved !== void 0
|
|
13963
|
+
};
|
|
13964
|
+
try {
|
|
13965
|
+
await settings.update.call(settings, LLM_PI_AI_NAMESPACE, plan.patch);
|
|
13966
|
+
} catch (error) {
|
|
13967
|
+
console.warn(`[serenity-hooks] ✗ DeepSeek 多模态补丁写入被拒: ${String(error?.message ?? error)}(可手动写入 llm-pi-ai.providers.<route>.models[].input)`);
|
|
13968
|
+
return {
|
|
13969
|
+
wrote: false,
|
|
13970
|
+
registered: true
|
|
13971
|
+
};
|
|
13972
|
+
}
|
|
13973
|
+
const changed = plan.actions.filter((a) => a.kind === "patch-model");
|
|
13974
|
+
if (changed.length > 0) console.log(`[serenity-hooks] ✓ DeepSeek 多模态:为 ${changed.length} 个模型补上 image 声明(${changed.map((a) => `${a.route}/${a.model}`).join(", ")})`);
|
|
13975
|
+
return {
|
|
13976
|
+
wrote: true,
|
|
13977
|
+
registered: true
|
|
13978
|
+
};
|
|
13979
|
+
}
|
|
13980
|
+
/**
|
|
13981
|
+
* 命名空间注册重试间隔(毫秒;累计 ~72s)。
|
|
13982
|
+
*
|
|
13983
|
+
* 为什么要重试:`llm-pi-ai` 只在**它自己的** `ctx.inject(['settings'])` 回调里注册命名空间,
|
|
13984
|
+
* 与本插件 apply **无先后保证**。首次读取拿到 undefined 是常态而非错误
|
|
13985
|
+
* (与 opencode-provider 同款问题、同款处置:v1.30.13 实证"只在 apply 时试一次 → 永不重试")。
|
|
13986
|
+
*/
|
|
13987
|
+
const NAMESPACE_RETRY_DELAYS_MS = [
|
|
13988
|
+
1e3,
|
|
13989
|
+
3e3,
|
|
13990
|
+
8e3,
|
|
13991
|
+
2e4,
|
|
13992
|
+
4e4
|
|
13993
|
+
];
|
|
13994
|
+
/**
|
|
13995
|
+
* 装配 DeepSeek 多模态补丁。
|
|
13996
|
+
*
|
|
13997
|
+
* 触发面(三层,与 opencode-provider 同款):
|
|
13998
|
+
* ① apply 时立即试一次
|
|
13999
|
+
* ② 命名空间尚未注册 → 退避重试(1s/3s/8s/20s/40s)
|
|
14000
|
+
* ③ `settings/updated` → 再试(用户热改 settings.yaml 时自愈;自身写入触发的复评是幂等空转)
|
|
14001
|
+
*
|
|
14002
|
+
* 🔴 不设"一次性完成"闸门:③ 必须能持续生效(用户随时可能新增 deepseek 模型)。
|
|
14003
|
+
*/
|
|
14004
|
+
function registerDeepseekVisionPatch(ctx, enabled) {
|
|
14005
|
+
let settled = false;
|
|
14006
|
+
let retryTimer = null;
|
|
14007
|
+
let retries = 0;
|
|
14008
|
+
const clearRetry = () => {
|
|
14009
|
+
if (retryTimer !== null) {
|
|
14010
|
+
clearTimeout(retryTimer);
|
|
14011
|
+
retryTimer = null;
|
|
14012
|
+
}
|
|
14013
|
+
};
|
|
14014
|
+
const scheduleRetry = () => {
|
|
14015
|
+
if (retryTimer !== null || settled) return;
|
|
14016
|
+
if (retries >= NAMESPACE_RETRY_DELAYS_MS.length) {
|
|
14017
|
+
console.warn(`[serenity-hooks] DeepSeek 多模态补丁放弃:重试 ${retries} 次仍等不到 ${LLM_PI_AI_NAMESPACE} 命名空间(未使用 pi-ai 适配器时正常)`);
|
|
14018
|
+
settled = true;
|
|
14019
|
+
return;
|
|
14020
|
+
}
|
|
14021
|
+
const delay = NAMESPACE_RETRY_DELAYS_MS[retries] ?? 0;
|
|
14022
|
+
retryTimer = setTimeout(() => {
|
|
14023
|
+
retryTimer = null;
|
|
14024
|
+
retries += 1;
|
|
14025
|
+
sync();
|
|
14026
|
+
}, delay);
|
|
14027
|
+
retryTimer.unref?.();
|
|
14028
|
+
};
|
|
14029
|
+
function sync() {
|
|
14030
|
+
applyDeepseekVisionPatchOnce(ctx, enabled).then((result) => {
|
|
14031
|
+
if (result.registered) {
|
|
14032
|
+
settled = true;
|
|
14033
|
+
clearRetry();
|
|
14034
|
+
} else if (!settled) scheduleRetry();
|
|
14035
|
+
}).catch((error) => {
|
|
14036
|
+
console.warn(`[serenity-hooks] DeepSeek 多模态补丁异常: ${String(error?.message ?? error)}`);
|
|
14037
|
+
settled = true;
|
|
14038
|
+
clearRetry();
|
|
14039
|
+
});
|
|
14040
|
+
}
|
|
14041
|
+
try {
|
|
14042
|
+
ctx.on?.call(ctx, "settings/updated", (ns) => {
|
|
14043
|
+
if (ns === "llm-pi-ai") sync();
|
|
14044
|
+
});
|
|
14045
|
+
} catch {}
|
|
14046
|
+
sync();
|
|
14047
|
+
registerDisposer(ctx, "deepseek vision patch retry timer", clearRetry);
|
|
14048
|
+
}
|
|
13805
14049
|
//#endregion
|
|
13806
14050
|
//#region src/im-weixin.ts
|
|
13807
14051
|
/**
|
|
@@ -14313,7 +14557,9 @@ function containerStatus(opts = {}) {
|
|
|
14313
14557
|
* ① live 运行态 —— 插件进程内 `diagLive(ctx)`(进程 cwd/CCC + live 会话清单)
|
|
14314
14558
|
* ——**独立进程看不到运行时**,这正是本工具存在的理由
|
|
14315
14559
|
* ①b 唤醒时钟 —— `containerClocks().wake`(唤醒调度器进程内快照:武装态/计数/上次跳过原因)
|
|
14316
|
-
* ③ 唤醒注册表 —— `wake-registry.json`
|
|
14560
|
+
* ③ 唤醒注册表 —— `wake-registry.json` 的**在办条目**(state / at / target / lastResult)+ 补跑窗口
|
|
14561
|
+
* (🔴 2026-09-19 §0Q 甲案:终态条目**结案即清除** ⇒ 本段只列在办项,不再是"历史全量";
|
|
14562
|
+
* 历史由 SESSION.md / 工具输出承担——该承诺原本就写在 `wake-registry.ts` 注释里,本次做实)
|
|
14317
14563
|
*
|
|
14318
14564
|
* ── 2026-09-15:段数由四降为三(S142「ACC 侧 autopilot 退场」§1 二阶裁定)─────────────
|
|
14319
14565
|
* ACC 侧 autopilot 整段退场后,报告里两段的**主语消失**,故删除(不是"暂时关掉"):
|
|
@@ -14388,10 +14634,11 @@ function renderAccDiag(r) {
|
|
|
14388
14634
|
lines.push("");
|
|
14389
14635
|
lines.push(renderClock("唤醒调度器(wake-registry tick)", r.clocks.wake));
|
|
14390
14636
|
lines.push("");
|
|
14391
|
-
lines.push(`── ③
|
|
14637
|
+
lines.push(`── ③ 唤醒注册表(在办项;已结案条目即时清除,历史见 SESSION.md)──`);
|
|
14638
|
+
lines.push(` (${r.ccc}/AGENT_SESSIONS/wake-registry.json)`);
|
|
14392
14639
|
if (r.wakes.error) lines.push(`⚠ 注册表读取问题: ${r.wakes.error}`);
|
|
14393
14640
|
lines.push(`条目: ${r.wakes.entries.length} 条(在办 ${r.wakes.pending})|补跑窗口 ${WAKE_CATCH_UP_MS / 36e5}h`);
|
|
14394
|
-
if (r.wakes.entries.length === 0) lines.push("
|
|
14641
|
+
if (r.wakes.entries.length === 0) lines.push("(无在办条目)");
|
|
14395
14642
|
for (const w of r.wakes.entries) {
|
|
14396
14643
|
lines.push(` · ${w.id} [${w.state}] at=${w.at} → ${w.target} by=${w.createdBy} attempts=${w.attempts}`);
|
|
14397
14644
|
if (w.lastResult) lines.push(` lastResult: ${w.lastResult}`);
|
|
@@ -14653,7 +14900,8 @@ const Config = z.object({
|
|
|
14653
14900
|
httpPort: z.number().min(1024).max(65535).default(ACP_HTTP_PORT)
|
|
14654
14901
|
}),
|
|
14655
14902
|
webFetch: z.object({ enabled: z.boolean().default(true) }),
|
|
14656
|
-
opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) })
|
|
14903
|
+
opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) }),
|
|
14904
|
+
visionPatch: z.object({ enabled: z.boolean().default(true) })
|
|
14657
14905
|
});
|
|
14658
14906
|
function apply(ctx, config) {
|
|
14659
14907
|
try {
|
|
@@ -14717,6 +14965,7 @@ function apply(ctx, config) {
|
|
|
14717
14965
|
registerLifecycle(ctx);
|
|
14718
14966
|
if (config.webFetch?.enabled !== false) registerWebFetchProvider(ctx);
|
|
14719
14967
|
if (config.opencodeProvider?.autoConfigure !== false) registerOpencodeAutoConfig(ctx);
|
|
14968
|
+
if (config.visionPatch?.enabled !== false) registerDeepseekVisionPatch(ctx, true);
|
|
14720
14969
|
}
|
|
14721
14970
|
/**
|
|
14722
14971
|
* F4 Skiff 调试服务装配:启停 = 人工(设置面板 Skiff 区块开关,settings 持久化)。
|
package/lib/wake-registry.d.ts
CHANGED
|
@@ -118,8 +118,61 @@ export declare function splitDueWakes(registry: WakeRegistry, nowMs: number, cat
|
|
|
118
118
|
due: WakeEntry[];
|
|
119
119
|
expired: WakeEntry[];
|
|
120
120
|
};
|
|
121
|
+
/**
|
|
122
|
+
* 🔴 结案并**移除**一条(owner 2026-09-19 裁定「**直接删**」,S142 §0Q 甲案)。
|
|
123
|
+
*
|
|
124
|
+
* ## 为什么删(R↓)
|
|
125
|
+
*
|
|
126
|
+
* 本模块原本是「`pending` → 终态」但**终态条目永不移除** ⇒ 文件**只增**(实测 308 KB / 143+ 条)。
|
|
127
|
+
* owner 明令「应当**唤醒后就清理**的」⇒ 终态即删。
|
|
128
|
+
*
|
|
129
|
+
* **审计不丢**:由 **SESSION.md 与工具输出**承担——这正是本文件 `removeWake` 的既有注释承诺
|
|
130
|
+
* (*"审计由 SESSION.md 与工具输出承担——注册表只保留在办项"*)。
|
|
131
|
+
* ⚠️ 此前该承诺**并未兑现**(`updateWake` 只改 state 从不删行)⇒ 本次是**把承诺做实**。
|
|
132
|
+
*
|
|
133
|
+
* ## 与 `updateWake` 的分工(**别混用**)
|
|
134
|
+
*
|
|
135
|
+
* - `updateWake`:**仍在办**的推进(失败重试 `state:'pending'` + `attempts+1`)⇒ **保留行**;
|
|
136
|
+
* - `finalizeWake`:**不再办**的归宿(投递成功 / 超窗 missed)⇒ 写终态后**同一笔事务里删掉**。
|
|
137
|
+
*
|
|
138
|
+
* 🔴 **为什么两个动作要同一次写盘**:若先 `updateWake(delivered)` 再 `removeWake`,
|
|
139
|
+
* 中间被打断会留下一条"已终结但没删"的条目 ⇒ 又回到只增。
|
|
140
|
+
* 故本函数**读一次、改一次、删一次、写一次**(单次原子落盘)。
|
|
141
|
+
*
|
|
142
|
+
* @param root CCC 根
|
|
143
|
+
* @param id 条目 id
|
|
144
|
+
* @param terminal 终态(`delivered` / `missed` / `cancelled`)——写入仅用于**日志文案**,
|
|
145
|
+
* 随即整行移除;保留该参数是为了让调用点语义自明("它归到哪一档了")。
|
|
146
|
+
* @param result 人读结果(进日志,不进表)
|
|
147
|
+
* @returns `removed` = 被移除的条目(供调用方打印"它是什么");不存在 → 稳定错误码
|
|
148
|
+
*/
|
|
149
|
+
export declare function finalizeWake(root: string, id: string, terminal: 'delivered' | 'missed' | 'cancelled', result: string): {
|
|
150
|
+
ok: boolean;
|
|
151
|
+
error: string | null;
|
|
152
|
+
removed: WakeEntry | null;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* 一次性清除**存量终态条目**(`delivered` / `missed` / `cancelled`)。
|
|
156
|
+
*
|
|
157
|
+
* ## 为什么需要(不是可选)
|
|
158
|
+
*
|
|
159
|
+
* 本函数上线前,表里已积压 143+ 条终态记录(308 KB)⇒ **只改行为不改存量 = 文件不再涨但仍很大**。
|
|
160
|
+
* 故首次运行须清一次。**幂等**(无终态条目时空转,不写盘)。
|
|
161
|
+
*
|
|
162
|
+
* 审计不丢:与 `finalizeWake` 同理(终态本就属性"已结案",其史由 SESSION.md 承担)。
|
|
163
|
+
* @param root CCC 根
|
|
164
|
+
* @returns 清掉几条(0 = 本来就没有)
|
|
165
|
+
*/
|
|
166
|
+
export declare function purgeFinalizedWakes(root: string): {
|
|
167
|
+
ok: boolean;
|
|
168
|
+
error: string | null;
|
|
169
|
+
purged: number;
|
|
170
|
+
};
|
|
121
171
|
/**
|
|
122
172
|
* 更新一条(状态推进;调用方给最终状态与结果文案)。
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ **只用于"仍在办"的推进**(主要是失败重试)。**终态请用 `finalizeWake`**——
|
|
175
|
+
* 否则该行会永远留下(这正是本表过去只增的根因,见 `finalizeWake` 注释)。
|
|
123
176
|
* @param root CCC 根
|
|
124
177
|
* @param id 条目 id
|
|
125
178
|
* @param patch 要写入的字段
|
|
@@ -230,7 +230,109 @@ function splitDueWakes(registry, nowMs, catchUpMs = WAKE_CATCH_UP_MS) {
|
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
232
|
/**
|
|
233
|
+
* 🔴 结案并**移除**一条(owner 2026-09-19 裁定「**直接删**」,S142 §0Q 甲案)。
|
|
234
|
+
*
|
|
235
|
+
* ## 为什么删(R↓)
|
|
236
|
+
*
|
|
237
|
+
* 本模块原本是「`pending` → 终态」但**终态条目永不移除** ⇒ 文件**只增**(实测 308 KB / 143+ 条)。
|
|
238
|
+
* owner 明令「应当**唤醒后就清理**的」⇒ 终态即删。
|
|
239
|
+
*
|
|
240
|
+
* **审计不丢**:由 **SESSION.md 与工具输出**承担——这正是本文件 `removeWake` 的既有注释承诺
|
|
241
|
+
* (*"审计由 SESSION.md 与工具输出承担——注册表只保留在办项"*)。
|
|
242
|
+
* ⚠️ 此前该承诺**并未兑现**(`updateWake` 只改 state 从不删行)⇒ 本次是**把承诺做实**。
|
|
243
|
+
*
|
|
244
|
+
* ## 与 `updateWake` 的分工(**别混用**)
|
|
245
|
+
*
|
|
246
|
+
* - `updateWake`:**仍在办**的推进(失败重试 `state:'pending'` + `attempts+1`)⇒ **保留行**;
|
|
247
|
+
* - `finalizeWake`:**不再办**的归宿(投递成功 / 超窗 missed)⇒ 写终态后**同一笔事务里删掉**。
|
|
248
|
+
*
|
|
249
|
+
* 🔴 **为什么两个动作要同一次写盘**:若先 `updateWake(delivered)` 再 `removeWake`,
|
|
250
|
+
* 中间被打断会留下一条"已终结但没删"的条目 ⇒ 又回到只增。
|
|
251
|
+
* 故本函数**读一次、改一次、删一次、写一次**(单次原子落盘)。
|
|
252
|
+
*
|
|
253
|
+
* @param root CCC 根
|
|
254
|
+
* @param id 条目 id
|
|
255
|
+
* @param terminal 终态(`delivered` / `missed` / `cancelled`)——写入仅用于**日志文案**,
|
|
256
|
+
* 随即整行移除;保留该参数是为了让调用点语义自明("它归到哪一档了")。
|
|
257
|
+
* @param result 人读结果(进日志,不进表)
|
|
258
|
+
* @returns `removed` = 被移除的条目(供调用方打印"它是什么");不存在 → 稳定错误码
|
|
259
|
+
*/
|
|
260
|
+
function finalizeWake(root, id, terminal, result) {
|
|
261
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
262
|
+
if (error) return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error,
|
|
265
|
+
removed: null
|
|
266
|
+
};
|
|
267
|
+
const idx = registry.entries.findIndex((e) => e.id === id);
|
|
268
|
+
if (idx < 0) return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: `wake_not_found: ${id}`,
|
|
271
|
+
removed: null
|
|
272
|
+
};
|
|
273
|
+
const [removed] = registry.entries.splice(idx, 1);
|
|
274
|
+
const saved = saveWakeRegistry(root, registry);
|
|
275
|
+
if (!saved.ok) return {
|
|
276
|
+
ok: false,
|
|
277
|
+
error: saved.error,
|
|
278
|
+
removed: null
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
error: null,
|
|
283
|
+
removed: {
|
|
284
|
+
...removed,
|
|
285
|
+
state: terminal,
|
|
286
|
+
lastResult: result
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* 一次性清除**存量终态条目**(`delivered` / `missed` / `cancelled`)。
|
|
292
|
+
*
|
|
293
|
+
* ## 为什么需要(不是可选)
|
|
294
|
+
*
|
|
295
|
+
* 本函数上线前,表里已积压 143+ 条终态记录(308 KB)⇒ **只改行为不改存量 = 文件不再涨但仍很大**。
|
|
296
|
+
* 故首次运行须清一次。**幂等**(无终态条目时空转,不写盘)。
|
|
297
|
+
*
|
|
298
|
+
* 审计不丢:与 `finalizeWake` 同理(终态本就属性"已结案",其史由 SESSION.md 承担)。
|
|
299
|
+
* @param root CCC 根
|
|
300
|
+
* @returns 清掉几条(0 = 本来就没有)
|
|
301
|
+
*/
|
|
302
|
+
function purgeFinalizedWakes(root) {
|
|
303
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
304
|
+
if (error) return {
|
|
305
|
+
ok: false,
|
|
306
|
+
error,
|
|
307
|
+
purged: 0
|
|
308
|
+
};
|
|
309
|
+
const kept = registry.entries.filter((e) => e.state === "pending");
|
|
310
|
+
const purged = registry.entries.length - kept.length;
|
|
311
|
+
if (purged === 0) return {
|
|
312
|
+
ok: true,
|
|
313
|
+
error: null,
|
|
314
|
+
purged: 0
|
|
315
|
+
};
|
|
316
|
+
const saved = saveWakeRegistry(root, {
|
|
317
|
+
...registry,
|
|
318
|
+
entries: kept
|
|
319
|
+
});
|
|
320
|
+
if (!saved.ok) return {
|
|
321
|
+
ok: false,
|
|
322
|
+
error: saved.error,
|
|
323
|
+
purged: 0
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
error: null,
|
|
328
|
+
purged
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
233
332
|
* 更新一条(状态推进;调用方给最终状态与结果文案)。
|
|
333
|
+
*
|
|
334
|
+
* ⚠️ **只用于"仍在办"的推进**(主要是失败重试)。**终态请用 `finalizeWake`**——
|
|
335
|
+
* 否则该行会永远留下(这正是本表过去只增的根因,见 `finalizeWake` 注释)。
|
|
234
336
|
* @param root CCC 根
|
|
235
337
|
* @param id 条目 id
|
|
236
338
|
* @param patch 要写入的字段
|
|
@@ -801,6 +903,8 @@ async function runWakeTick(ctx) {
|
|
|
801
903
|
}
|
|
802
904
|
clock.noteSkipReason(null);
|
|
803
905
|
for (const root of roots) {
|
|
906
|
+
const purged = purgeFinalizedWakes(root);
|
|
907
|
+
if (purged.ok && purged.purged > 0) log.push(`· ${root}: 清除 ${purged.purged} 条已结案记录(存量首清)`);
|
|
804
908
|
const { registry, error } = loadWakeRegistry(root);
|
|
805
909
|
if (error) {
|
|
806
910
|
log.push(`✗ ${root}: ${error}`);
|
|
@@ -808,11 +912,8 @@ async function runWakeTick(ctx) {
|
|
|
808
912
|
}
|
|
809
913
|
const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
|
|
810
914
|
for (const e of expired) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
lastResult: `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`
|
|
814
|
-
});
|
|
815
|
-
log.push(`· ${root}: ${e.id} → missed(超补跑窗口)`);
|
|
915
|
+
finalizeWake(root, e.id, "missed", `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`);
|
|
916
|
+
log.push(`· ${root}: ${e.id} → missed(超补跑窗口,已清除)`);
|
|
816
917
|
}
|
|
817
918
|
for (const e of due) {
|
|
818
919
|
const res = await deliverWake(ctx, root, e);
|
|
@@ -820,13 +921,17 @@ async function runWakeTick(ctx) {
|
|
|
820
921
|
log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
|
|
821
922
|
continue;
|
|
822
923
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
924
|
+
if (res.ok) {
|
|
925
|
+
finalizeWake(root, e.id, "delivered", res.detail);
|
|
926
|
+
log.push(`· ${root}: ${e.id} → delivered(已清除)`);
|
|
927
|
+
} else {
|
|
928
|
+
updateWake(root, e.id, {
|
|
929
|
+
state: "pending",
|
|
930
|
+
attempts: e.attempts + 1,
|
|
931
|
+
lastResult: res.detail
|
|
932
|
+
});
|
|
933
|
+
log.push(`· ${root}: ${e.id} → 重试(${res.detail})`);
|
|
934
|
+
}
|
|
830
935
|
}
|
|
831
936
|
}
|
|
832
937
|
return log;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shgroup/dsh-serenity-hooks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
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.5-rc.2。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|