dsh-turn-recovery 0.2.4 → 0.2.5
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/CHANGELOG.md +53 -0
- package/README.md +10 -5
- package/lib/index.js +247 -44
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@
|
|
|
7
7
|
> `@nlflora/dsh-empty-turn-guard`)冲突,自 0.2.0 起更名为 `dsh-turn-recovery`。0.1.x 及更早的条目保留旧名原文,
|
|
8
8
|
> 其提到的 `plugin:empty-turn-guard`、`empty-turn-guard.log` 等标识在 0.2.0 起分别改为 `plugin:turn-recovery`、`turn-recovery.log`。
|
|
9
9
|
|
|
10
|
+
## [0.2.5] - 2026-11-12
|
|
11
|
+
|
|
12
|
+
**宿主契约修正版**。三专家评审(架构 / 安全 / 验收)对着宿主 0.2.2-rc.1 的
|
|
13
|
+
`app.asar` 源码与 8 天生产日志复核 0.2.4,查出一个「处理器在真实宿主上永远空转」
|
|
14
|
+
的契约漂移、一条会把模型输出发给第三方的越权路径,以及若干状态文件与路径问题。
|
|
15
|
+
|
|
16
|
+
### 修复(缺陷)
|
|
17
|
+
|
|
18
|
+
- **`agent/inbox/discarded` 载荷漂移(P1)**:宿主 0.2.2-rc.1 只发
|
|
19
|
+
`{ message }`(`dsh-agent-loop/lib/index.js:199`,已从 asar 逐行核实),
|
|
20
|
+
而 0.2.4 读的是 `payload.agent` → 处理器永远在第一行 `return`:用户点「停止」
|
|
21
|
+
后待发补写不会被撤销,仍会多跑一个模型回合,且 `DISCARDED by host` 日志永不出现。
|
|
22
|
+
改为按 `message.id` 反查 `stateById` 中的在途补写,不再依赖载荷里的 agent/session。
|
|
23
|
+
单测夹具同步改成宿主真实形状(旧夹具自造 `{agent, message}`,所以 65 例全绿也测不到)。
|
|
24
|
+
- **来源闸门(P1)**:新增 `nudgeSources`。`turn/end` 会检查本回合最后一条
|
|
25
|
+
`user/message` 的 `source.kind`,只有落在白名单(默认 = 前台白名单)才补写。
|
|
26
|
+
社交桥(`wechat-social-bridge` / `form: relay`)默认被排除:桥接器会把模型输出
|
|
27
|
+
发回给真人联系人,补写等于**替你对外发言**。
|
|
28
|
+
- **伪造认领不得释放在途标记(P2)**:`clearNudgeInFlight` 原来在「id 不匹配即忽略」
|
|
29
|
+
之前无条件执行,同进程任一插件 append 一条同 kind、随意 id 的消息就能让守卫
|
|
30
|
+
多唤醒一次模型。现在只在真的对上 id 时清;过期由 120s 认领超时兜底。
|
|
31
|
+
- **header 缺失时 fail-closed(P2)**:新增 `headerlessScope`,默认 `'out'`(不判定)。
|
|
32
|
+
旧版按 id 形态把 `session-*` 当根会话,可能给「无 header 的子代理」补写,
|
|
33
|
+
破坏委派语义。老宿主兼容可显式设 `'root'`。
|
|
34
|
+
- **`sawTurnStart` 不再被误清(P2)**:`turn/start` 缺数值 `turn` 时,
|
|
35
|
+
后续 `assistant/message` 触发的重置会把 `sawTurnStart` 一并清成 false,
|
|
36
|
+
整个回合被判成「中途加载」而跳过判定。现在只补记 turn 号、保留 `sawTurnStart`。
|
|
37
|
+
- **`logFile` / `budgetFile` 路径校验(P2)**:自定义路径必须 `.log` 结尾,否则回落默认并记
|
|
38
|
+
config issue;轮转前检查文件头是否为本插件日志(首字节 `[`),不是就拒绝 rename/truncate ——
|
|
39
|
+
避免一次配置手滑不可逆地毁掉无关文件。
|
|
40
|
+
- **状态文件压缩(P2)**:`guard-ids.log` 此前只增不减,超 1 MiB 后读侧按字节截断会把
|
|
41
|
+
TTL 内的旧记录丢掉(晚到认领认不出 → 连环补写)。现在与预算文件同法按 TTL 原子压缩。
|
|
42
|
+
预算/guard 压缩时若文件里有内容却一条都解析不出来,判定为「不是我们的文件」并拒绝重写。
|
|
43
|
+
|
|
44
|
+
### 加固
|
|
45
|
+
|
|
46
|
+
- `stateById` 改为 LRU(命中刷新插入序),避免长跑 >500 会话时把活跃会话按插入序淘汰。
|
|
47
|
+
- 日志压平扩展为 `\r \n U+2028 U+2029 ESC`(部分查看器把前两者当换行、把 ESC 当终端着色)。
|
|
48
|
+
- 状态文件压缩、路径拒绝、来源拦截都会留一行日志,便于现场判定「为什么没补写」。
|
|
49
|
+
|
|
50
|
+
### 测试
|
|
51
|
+
|
|
52
|
+
- 65 → 76 例。新增:真实形状的丢弃事件(含陌生 id)、来源闸门(默认拦 / 显式放开)、
|
|
53
|
+
headerlessScope 两种取值、`sawTurnStart` 补记、guard-ids 压缩、`logFile` 回落、控制字符压平。
|
|
54
|
+
- 测试夹具默认带根会话 header(0.2.5 起无 header 视为范围外),需要测无 header 时传 `__noHeader`。
|
|
55
|
+
|
|
56
|
+
### 已知遗留
|
|
57
|
+
|
|
58
|
+
- 「`agent/inbox/discarded` 是否真的广播到插件根 `ctx`」仍**未验证**;
|
|
59
|
+
收不到时 900ms 停止窗口依然存在(README 已如实标注)。
|
|
60
|
+
- 补写投递 → 认领 → 跨重启去重整条链在真实宿主上仍**零生产证据**
|
|
61
|
+
(`delivered=0 claimed=0`),需按 README 的 dryRun 流程取证。
|
|
62
|
+
|
|
10
63
|
## [0.2.4] - 2026-11-09
|
|
11
64
|
|
|
12
65
|
**运行期复核版**。0.2.3 之后按 8 天生产日志(645 个 `turn/end`)回头复查代码,
|
package/README.md
CHANGED
|
@@ -64,8 +64,12 @@
|
|
|
64
64
|
maxNudgesPerHour: 3 # 每会话每小时的补写上限(0 = 关闭补写)
|
|
65
65
|
sessionScope: root # root = 只处理根会话(按 SessionHeader 判定);all = 连子代理会话一起
|
|
66
66
|
foregroundKinds: [] # 追加「会取消待发补写」的来源 kind(默认 user / agent-message)
|
|
67
|
+
nudgeSources: [] # 允许被补写的「回合发起来源」;空 = 沿用 foregroundKinds
|
|
68
|
+
# 社交桥(wechat-social-bridge 等)默认**不在**其中:模型输出
|
|
69
|
+
# 可能被转发给第三方联系人,补写等于替你对外发言
|
|
70
|
+
headerlessScope: out # 会话无 SessionHeader 时的判定:out = 不碰(默认);root = 沿用旧版 id 形态猜
|
|
67
71
|
budgetFile: '' # 空 = <DSH_HOME>/state/turn-recovery/nudges.log(跨重启限流)
|
|
68
|
-
#
|
|
72
|
+
# 必须 .log 结尾;同目录还会写 guard-ids.log(跨重启认出晚到认领的补写 id)
|
|
69
73
|
logFile: '' # 空 = <DSH_HOME>/logs/turn-recovery.log
|
|
70
74
|
logTurns: empty # empty = 只记空回合/被跳过判定的回合;all = 连正常回合也记
|
|
71
75
|
dryRun: false # true = 判定/限流/日志全部照走,但不真的投递(验证用)
|
|
@@ -121,7 +125,7 @@
|
|
|
121
125
|
|
|
122
126
|
`version=` 是进程里真正加载的版本号(从包自身 `package.json` 读)——**升级后先看这一行**,不要靠「某个日志字段有没有」推断跑的是哪一版(0.1.4 与 0.1.5 都打过 `sawStart`,这个推断会错)。
|
|
123
127
|
|
|
124
|
-
排查常用短语:`config issue`(配置写错,插件仍启用)、`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `
|
|
128
|
+
排查常用短语:`config issue`(配置写错,插件仍启用)、`skip scope`(被 `sessionScope` 过滤掉的会话,括号里写明是 `header says subagent` 还是 `no header (headerlessScope=out)`)、`scope fallback`(`headerlessScope: 'root'` 时靠 id 形态放行的会话,首次记一行)、`guard NOT armed (fail-closed`(同名 prompt 段落已被占用,插件整体不启用,不再谎报 armed)、`inbox already holds a pending turn-recovery nudge`(队列里已有一笔,未重复投递)、`turn/start not observed`(热加载中途,不判定)、`NOT ours`(同来源但 id 对不上,已忽略;0.2.5 起不再因此清在途标记)、`LATE, from guardIds`(认领超时后才到,仍按补写回合处理)、`DISCARDED by host`(用户停止,补写被丢弃)、`is not an in-flight nudge`(丢弃事件里的 id 不是我们的在途补写)、`origin=… not in nudge sources`(来源闸门拦下的空回合,例如社交桥)、`a nudge is already awaiting claim`(上一笔还没被认领)、`skipped:in-flight`(本回合没排上,因为上一笔仍在途 —— 不算「已补过」)、`turn N already handled`(该回合确实排过或确实被限流)、`DROPPED: turn moved on`(定时器到点时会话已进入下一回合)、`stop chaining`(补写回合又空,按设计不再连环)、`budget exhausted`(本小时额度用完)、`guard ids compacted` / `budget compact skipped`(状态文件压缩结果)、`log rotate skipped: file does not look like`(`logFile` 配到了别的文件,拒绝轮转它)。
|
|
125
129
|
|
|
126
130
|
## 实现约束
|
|
127
131
|
|
|
@@ -139,11 +143,12 @@
|
|
|
139
143
|
## 已知限制
|
|
140
144
|
|
|
141
145
|
- **会话状态是双索引**:`state` 是 WeakMap(按 session 对象身份,快路径),另有有界(500 条)的 `stateById`(按 `session.id`,兜底)。宿主在某些代码路径上会给出不同的 session 实例;只按对象身份索引时,`turn/start` 与 `turn/end` 会落在两份状态上,`sawTurnStart` 恒为 false、判定链整体静默早退(只留一行 `turn/start not observed`)。现在两个索引同时写,`agent/disposed` 时同时清。
|
|
146
|
+
- **补写只在「人类发起」的回合里发生(0.2.5 来源闸门)**:`turn/end` 会检查本回合最后一条 `user/message` 的 `source.kind`,只有落在 `nudgeSources`(默认等于前台白名单 `user` / `agent-message`)里才补写。原因是社交桥(`source.kind='wechat-social-bridge'`, `form='relay'`)这类会话里,桥接器会把模型输出发回给真实的微信/QQ 联系人 —— 插件往这种会话塞一句"请直接输出纯文本",等于**替你对外发言**。需要给桥接对话也保留保护时,把该 kind 写进 `nudgeSources` 显式开启。本回合没有 `user/message`(拿不到来源)时按"无法判定"放行,日志里能看到判定过程。
|
|
142
147
|
- **补写消息以 `role: 'user'` 落盘**(`source.kind = 'plugin:turn-recovery'`):宿主 session 层对 `source.kind` 不做枚举校验(只要求非空字符串),插件侧也改不了 role。桌面前端按 `source.kind !== 'user'` 渲染成上下文卡片(不是用户气泡),mnemon 只收录 `source.kind === 'user'`,所以界面与记忆都不受污染;但**会话转录文件里它确实是一条 user 消息**,审计时要看 `source.kind` 区分。另外 `plugin:*` 这个前缀不在宿主 `MessageSourceMap` 的类型联合里 —— 运行时不受影响(前端对未知 kind 走 `default` 分支,显示为「请求」卡片),但下游按类型联合写的消费者会漏掉它。
|
|
143
|
-
- **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session
|
|
144
|
-
- **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19
|
|
148
|
+
- **用户点停止的窗口**:宿主 `cancel()` 只清 inbox + abort,不 append 任何 session 事件。插件订阅宿主的 `agent/inbox/discarded` 来撤掉自己那条补写。0.2.4 及更早读的是 `payload.agent`,而宿主 0.2.2-rc.1 实际只发 `{ message }`(`dsh-agent-loop/lib/index.js:199`),于是处理器永远在第一行 return —— 0.2.5 改为按 `message.id` 反查在途补写,真实形状下才会真的撤掉定时器。**仍未验证**的是:该事件是否向插件的根 `ctx` 广播(宿主在自己的 `ownerCtx` 上监听 `agent/disposed`,说明 agent 作用域事件存在)。收不到时,「用户已点停止、插件仍在 `delayMs` 后补写」这个窗口依然存在(默认 900ms,可通过 `delayMs` 调小)。真正发出去的那一回合仍受宿主 abort 语义约束。
|
|
149
|
+
- **`sessionScope: 'root'` 的判定依据是宿主的 `Session.header`**:`origin === 'subagent'` 或 `delegationDepth > 0` 才算子代理会话,`orchestrator-*` 这类顶层会话会被正常处理(0.2.0 用 id 前缀判断,把它们全部判成子代理,实测日志里有 19 次静默跳过)。**0.2.5 起**:拿不到 header 时默认**不判定**(`headerlessScope: 'out'`,fail-closed)—— 旧版按 id 形态把 `session-*` / `orchestrator-*` 当根会话,于是「无 header 的子代理」也会被补写,往子代理里塞"不要调用工具"会破坏委派语义。确有老宿主需要兼容时显式设 `headerlessScope: 'root'`(首次放行会记一行 `scope fallback`)。被过滤的会话每个 id 只记一条 `skip scope` 日志,不会静默消失。
|
|
145
150
|
- **可见字符是「按回合累计」而不是「末条消息」**:一个回合里先写了结论、后来又被工具调用打断的,累计口径下不算空(这是刻意的:用户此前已经看到了文字)。反过来,`aborted` / `interrupted` 等非 `completed` 回合一律不补写,即使它确实没留下正文 —— 那些回合多半是用户自己喊停的,再补一轮会变成「已取消还继续跑」。
|
|
146
|
-
- **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds
|
|
151
|
+
- **崩溃遗留**:补写已入队但宿主在认领前崩溃时,该消息可能在下次启动被重放。插件只认 id 匹配的认领(`NOT ours`),因此不会被这种消息骗成「补写回合」,但它仍可能作为一条插件消息出现在转录里。为覆盖「120s 认领超时后才被认领」与「重启后才被认领」两种情形,最近 8 条投递 id 会同时留在内存与 `<budgetFile 同目录>/guard-ids.log` 里(`LATE, from guardIds`),重启后仍能认出它们,不会把补写回合再当成真空回合补第二笔。**0.2.5 起**该文件超过 1 MiB 会按 TTL 原子压缩(`guard ids compacted: lines=N`)—— 旧版只增不减,而且读侧按字节截断会把 TTL 内的旧记录丢掉,反而让「晚到认领」认不出。
|
|
147
152
|
- **预算文件是不可信输入**:解析时会丢弃「晚于当前时钟 +5 分钟」的时间戳(否则 `now - t` 为负、该会话被永久判成预算耗尽),并且整份文件只在 1 MiB 以内整读、超过就只读尾部(避免配置写错指向几百 MB 的文件时同步阻塞宿主)。写入用 JSON 行(会话 id 含空格也不会被截断),压缩是「重读磁盘 + 临时文件原子替换」,不会覆盖另一个进程刚追加的行。
|
|
148
153
|
- **多进程(桌面宿主 + 独立的 web/headless 宿主)**:各自一套内存态,`agent.inbox.nextTurn` 只对本进程可见。预算文件与补写 id 文件是共享的,因此限流大致成立,但「同一会话同时被两个宿主打开」仍可能各补一笔。同一会话请只用一个宿主。
|
|
149
154
|
- **`dryRun` 不写预算文件**:判定链、日志、限流检查照走,但不投递、不记账,所以它只能证明「判定会发生」,不能用来验证限流是否生效(日志里的 `budgetUsed=n/max` 是当时的已用额度)。
|
package/lib/index.js
CHANGED
|
@@ -191,7 +191,7 @@ function enabledOr(value, fallback) {
|
|
|
191
191
|
const CONFIG_KEYS = new Set([
|
|
192
192
|
'enabled', 'injectInstruction', 'dryRun', 'instructionText',
|
|
193
193
|
'minVisibleChars', 'delayMs', 'maxNudgesPerHour', 'logFile', 'logTurns',
|
|
194
|
-
'sessionScope', 'foregroundKinds', 'budgetFile',
|
|
194
|
+
'sessionScope', 'foregroundKinds', 'nudgeSources', 'headerlessScope', 'budgetFile',
|
|
195
195
|
]);
|
|
196
196
|
const NUMERIC_KEYS = ['minVisibleChars', 'delayMs', 'maxNudgesPerHour'];
|
|
197
197
|
const STRING_KEYS = ['instructionText', 'logFile', 'budgetFile'];
|
|
@@ -260,6 +260,15 @@ function describeConfigIssues(raw) {
|
|
|
260
260
|
const bad = raw.foregroundKinds.filter((k) => typeof k !== 'string' || k.trim() === '').length;
|
|
261
261
|
if (bad > 0) issues.push(bad + ' non-string/empty entry in "foregroundKinds" -> dropped');
|
|
262
262
|
}
|
|
263
|
+
if (raw.nudgeSources !== undefined && !Array.isArray(raw.nudgeSources)) {
|
|
264
|
+
issues.push('"nudgeSources" expects an array, got ' + brief(raw.nudgeSources) + ' -> ignored');
|
|
265
|
+
} else if (Array.isArray(raw.nudgeSources)) {
|
|
266
|
+
const bad = raw.nudgeSources.filter((k) => typeof k !== 'string' || k.trim() === '').length;
|
|
267
|
+
if (bad > 0) issues.push(bad + ' non-string/empty entry in "nudgeSources" -> dropped');
|
|
268
|
+
}
|
|
269
|
+
if (raw.headerlessScope !== undefined && raw.headerlessScope !== 'out' && raw.headerlessScope !== 'root') {
|
|
270
|
+
issues.push('"headerlessScope" expects "out" | "root", got ' + brief(raw.headerlessScope) + ' -> "out"');
|
|
271
|
+
}
|
|
263
272
|
if (raw.logTurns !== undefined && raw.logTurns !== 'empty' && raw.logTurns !== 'all') {
|
|
264
273
|
issues.push('"logTurns" expects "empty" | "all", got ' + brief(raw.logTurns) + ' -> "empty"');
|
|
265
274
|
}
|
|
@@ -332,6 +341,31 @@ export function apply(ctx, config) {
|
|
|
332
341
|
const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
|
|
333
342
|
return new Set([...DEFAULT_FOREGROUND_KINDS, ...extra]);
|
|
334
343
|
})(),
|
|
344
|
+
/**
|
|
345
|
+
* 允许被补写的「回合发起来源」(0.2.5)。
|
|
346
|
+
*
|
|
347
|
+
* 决定的是「这个会话里该不该由插件插一句话」,与 foregroundKinds 的
|
|
348
|
+
* 「谁的消息能取消待发补写」是两件事:
|
|
349
|
+
* - 社交桥(source.kind='wechat-social-bridge', form='relay')把真人微信
|
|
350
|
+
* 消息送进会话,桥接器又会把模型输出发回给联系人。它不在前台白名单里,
|
|
351
|
+
* 于是 0.2.4 既不会被它取消补写、也不会因此排除该会话 —— 一旦那里出现
|
|
352
|
+
* completed 空回合,插件就会往一段真人对话里塞"请直接输出纯文本",
|
|
353
|
+
* 模型输出经桥接成为对外发文。这是未授权的外部副作用,必须默认关掉。
|
|
354
|
+
* - 显式列在这个白名单里的来源才允许补写;空数组 = 沿用 foregroundKinds。
|
|
355
|
+
* 想给桥接对话也保留保护的用户,把 kind 显式写进来即可(自担对外发言的语义)。
|
|
356
|
+
*/
|
|
357
|
+
nudgeSources: (() => {
|
|
358
|
+
const list = Array.isArray(raw.nudgeSources) ? raw.nudgeSources : [];
|
|
359
|
+
const extra = list.filter((k) => typeof k === 'string' && k.trim() !== '').map((k) => k.trim());
|
|
360
|
+
return extra.length > 0 ? new Set(extra) : null;
|
|
361
|
+
})(),
|
|
362
|
+
/**
|
|
363
|
+
* 会话 header 缺失时怎么判定(0.2.5)。宿主的 SessionHeader 是根/子代理的
|
|
364
|
+
* 唯一可信判据;拿不到时旧版按 id 形态猜(session-* 当根会话),于是
|
|
365
|
+
* 「无 header 且 id 形如 session-* 的子代理」会被补写,破坏委派语义。
|
|
366
|
+
* 默认 'out':无法判定就不碰;确有老宿主需要时显式设成 'root'。
|
|
367
|
+
*/
|
|
368
|
+
headerlessScope: raw.headerlessScope === 'root' ? 'root' : 'out',
|
|
335
369
|
// 补写预算的持久化文件(跨重启限流);默认 $DSH_HOME/state/turn-recovery/nudges.log。
|
|
336
370
|
budgetFile: typeof raw.budgetFile === 'string' ? raw.budgetFile.trim() : '',
|
|
337
371
|
// 干跑:判定、限流、日志全部照常走,唯独不真的把补写投进队列。
|
|
@@ -348,7 +382,12 @@ export function apply(ctx, config) {
|
|
|
348
382
|
const dshHome = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
349
383
|
? process.env.DSH_HOME.trim()
|
|
350
384
|
: path.join(os.homedir(), '.dsh');
|
|
351
|
-
|
|
385
|
+
// 0.2.5:自定义路径必须像日志文件(.log 结尾)。否则一次配置手滑就可能让
|
|
386
|
+
// 轮转/压缩逻辑去 rename 或 truncate 一个无关文件 —— 那是不可逆的破坏。
|
|
387
|
+
const customLogRejected = cfg.logFile !== '' && !/\.log$/i.test(cfg.logFile);
|
|
388
|
+
const logPath = (cfg.logFile !== '' && !customLogRejected)
|
|
389
|
+
? cfg.logFile
|
|
390
|
+
: path.join(dshHome, 'logs', 'turn-recovery.log');
|
|
352
391
|
const fallbackLogPath = path.join(os.tmpdir(), 'turn-recovery.log');
|
|
353
392
|
const startedAt = Date.now();
|
|
354
393
|
let lastRotateCheck = 0;
|
|
@@ -359,9 +398,33 @@ export function apply(ctx, config) {
|
|
|
359
398
|
* 先试 rename,失败就退回 truncate(0) —— 宁可丢历史,也不能让日志无限长。
|
|
360
399
|
*/
|
|
361
400
|
let rotateWarned = false;
|
|
401
|
+
let foreignLogWarned = false;
|
|
402
|
+
/**
|
|
403
|
+
* 文件头是否像本插件写的日志(首字节 '[',即 ISO 时间戳的开头)。
|
|
404
|
+
* 轮转/截断前必须过这一关:`logFile` 配错时,旧版会直接 rename 或 truncate
|
|
405
|
+
* 用户的任意文件;现在宁可让日志继续长,也不动一个不认识的文件。
|
|
406
|
+
*/
|
|
407
|
+
const looksLikeOurLog = (file) => {
|
|
408
|
+
try {
|
|
409
|
+
const fd = fs.openSync(file, 'r');
|
|
410
|
+
try {
|
|
411
|
+
const buf = Buffer.alloc(1);
|
|
412
|
+
const read = fs.readSync(fd, buf, 0, 1, 0);
|
|
413
|
+
if (read === 0) return true; // 空文件:轮转/截断都无害
|
|
414
|
+
return buf[0] === 0x5B; // '['
|
|
415
|
+
} finally { fs.closeSync(fd); }
|
|
416
|
+
} catch { return false; }
|
|
417
|
+
};
|
|
362
418
|
const rotateIfNeeded = (file) => {
|
|
363
419
|
try {
|
|
364
420
|
if (fs.statSync(file).size <= LOG_MAX_BYTES) return;
|
|
421
|
+
if (!looksLikeOurLog(file)) {
|
|
422
|
+
if (!foreignLogWarned) {
|
|
423
|
+
foreignLogWarned = true;
|
|
424
|
+
log('log rotate skipped: file does not look like a turn-recovery log (' + file + ')');
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
365
428
|
const backup = file + '.1';
|
|
366
429
|
try {
|
|
367
430
|
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
|
@@ -397,7 +460,9 @@ export function apply(ctx, config) {
|
|
|
397
460
|
const log = (line) => {
|
|
398
461
|
// 日志行内不允许出现换行:会话 id、错误文本都可能带 \n,拼进去就能在日志里
|
|
399
462
|
// 伪造出一行看似插件自己写的记录(伪造 turn/end 或 guard armed 会误导排障)。
|
|
400
|
-
|
|
463
|
+
// 0.2.5 起连 U+2028/U+2029(部分查看器当换行)与 ESC(终端着色)一起压平:
|
|
464
|
+
// 会话 id 由宿主/桥接插件产生,现网已出现 social-*、bili-whale 这类非 UUID 形态。
|
|
465
|
+
const safe = String(line).replace(/[\r\n\u2028\u2029\x1b]+/g, ' ');
|
|
401
466
|
const record = '[' + new Date().toISOString() + '] ' + safe + '\n';
|
|
402
467
|
maybeRotate();
|
|
403
468
|
try {
|
|
@@ -431,9 +496,12 @@ export function apply(ctx, config) {
|
|
|
431
496
|
* 也不能让限流文件的 IO 问题拖垮宿主回合)。多进程并发追加时可能丢几行,
|
|
432
497
|
* 这是可接受的——它只是预算,不是账本。
|
|
433
498
|
*/
|
|
434
|
-
const
|
|
499
|
+
const customBudgetRejected = cfg.budgetFile !== '' && !/\.log$/i.test(cfg.budgetFile);
|
|
500
|
+
const budgetPath = (cfg.budgetFile !== '' && !customBudgetRejected)
|
|
435
501
|
? cfg.budgetFile
|
|
436
502
|
: path.join(dshHome, 'state', 'turn-recovery', 'nudges.log');
|
|
503
|
+
if (customLogRejected) configIssues.push('"logFile" must end with .log -> default used');
|
|
504
|
+
if (customBudgetRejected) configIssues.push('"budgetFile" must end with .log -> default used');
|
|
437
505
|
const BUDGET_MAX_BYTES = 1024 * 1024;
|
|
438
506
|
/** 已投递补写 id 的持久化文件(跨重启识别晚到认领用),与预算文件同目录。 */
|
|
439
507
|
const guardPath = path.join(path.dirname(budgetPath), 'guard-ids.log');
|
|
@@ -548,6 +616,64 @@ export function apply(ctx, config) {
|
|
|
548
616
|
return list.slice(-GUARD_IDS_MAX);
|
|
549
617
|
};
|
|
550
618
|
|
|
619
|
+
/**
|
|
620
|
+
* guard-ids 文件压缩(0.2.5)。这个文件此前是唯一「只增不减」的持久文件:
|
|
621
|
+
* 每投递一笔写一行,永不轮转;读侧只取尾部 1 MiB,于是超过 1 MiB 后 TTL 内
|
|
622
|
+
* 的旧记录会被按字节截断丢掉 —— 重启后的晚到认领认不出,反而触发连环补写
|
|
623
|
+
* (正是这个文件想解决的场景)。现在与预算文件同法:重读磁盘 → 只留 TTL 内
|
|
624
|
+
* 且每会话至多 GUARD_IDS_MAX 条 → 原子替换。
|
|
625
|
+
* 若文件内容一条都解析不出来(被别的程序写成了别的东西),拒绝重写。
|
|
626
|
+
*/
|
|
627
|
+
let guardCompactWarned = false;
|
|
628
|
+
const compactGuardIds = (now) => {
|
|
629
|
+
try {
|
|
630
|
+
const rawText = readTail(guardPath, BUDGET_MAX_BYTES);
|
|
631
|
+
const floor = now - GUARD_ID_TTL_MS;
|
|
632
|
+
const keep = new Map(); // sid -> [{ id, at }]
|
|
633
|
+
let parsed = 0;
|
|
634
|
+
for (const line of rawText.split('\n')) {
|
|
635
|
+
const text = line.trim();
|
|
636
|
+
if (text === '') continue;
|
|
637
|
+
let rec;
|
|
638
|
+
try { rec = JSON.parse(text); } catch { continue; }
|
|
639
|
+
if (!rec || typeof rec.id !== 'string' || typeof rec.sid !== 'string') continue;
|
|
640
|
+
const at = Number(rec.at);
|
|
641
|
+
if (!Number.isFinite(at) || at < floor || at > now + BUDGET_FUTURE_SKEW_MS) continue;
|
|
642
|
+
parsed += 1;
|
|
643
|
+
const list = keep.get(rec.sid) || [];
|
|
644
|
+
const seen = list.find((x) => x.id === rec.id);
|
|
645
|
+
if (seen) seen.at = Math.max(seen.at, at);
|
|
646
|
+
else list.push({ id: rec.id, at });
|
|
647
|
+
keep.set(rec.sid, list);
|
|
648
|
+
}
|
|
649
|
+
if (parsed === 0 && rawText.trim() !== '') {
|
|
650
|
+
if (!guardCompactWarned) {
|
|
651
|
+
guardCompactWarned = true;
|
|
652
|
+
log('guard ids compact skipped: file does not look like turn-recovery state ('
|
|
653
|
+
+ guardPath + ')');
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const lines = [];
|
|
658
|
+
for (const [sid, entries] of keep) {
|
|
659
|
+
for (const entry of entries.slice(-GUARD_IDS_MAX)) {
|
|
660
|
+
lines.push(JSON.stringify({ sid, id: entry.id, at: entry.at }));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const tmp = guardPath + '.tmp-' + process.pid;
|
|
664
|
+
fs.writeFileSync(tmp, lines.length > 0 ? lines.join('\n') + '\n' : '');
|
|
665
|
+
try {
|
|
666
|
+
fs.renameSync(tmp, guardPath);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
log('guard ids compacted: lines=' + lines.length);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
log('guard ids compact FAILED: ' + errText(err));
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
551
677
|
const recordGuardId = (session, id, at) => {
|
|
552
678
|
const sid = session && typeof session.id === 'string' ? session.id : '';
|
|
553
679
|
if (sid === '' || typeof id !== 'string' || id === '') return;
|
|
@@ -558,6 +684,9 @@ export function apply(ctx, config) {
|
|
|
558
684
|
while (list.length > GUARD_IDS_MAX) list.shift();
|
|
559
685
|
fs.mkdirSync(path.dirname(guardPath), { recursive: true });
|
|
560
686
|
fs.appendFileSync(guardPath, JSON.stringify({ sid, id, at }) + '\n');
|
|
687
|
+
let size = 0;
|
|
688
|
+
try { size = fs.statSync(guardPath).size; } catch { /* 首次写入 */ }
|
|
689
|
+
if (size > BUDGET_MAX_BYTES) compactGuardIds(Date.now());
|
|
561
690
|
} catch { /* 只服务于去重,写失败不影响补写本身 */ }
|
|
562
691
|
};
|
|
563
692
|
|
|
@@ -590,17 +719,31 @@ export function apply(ctx, config) {
|
|
|
590
719
|
* 旧版直接用内存 map 覆盖写整份文件 —— 多进程(桌面宿主 + 独立 web/headless)
|
|
591
720
|
* 同时运行时,会把对方刚追加的行整段抹掉,预算就少算、于是多补。
|
|
592
721
|
*/
|
|
722
|
+
let budgetCompactWarned = false;
|
|
593
723
|
const compactBudget = (now) => {
|
|
724
|
+
let parsed = 0;
|
|
725
|
+
let rawText = '';
|
|
594
726
|
try {
|
|
595
|
-
|
|
727
|
+
rawText = readTail(budgetPath, BUDGET_MAX_BYTES);
|
|
596
728
|
for (const line of rawText.split('\n')) {
|
|
597
729
|
const rec = parseBudgetLine(line, now);
|
|
598
730
|
if (!rec) continue;
|
|
731
|
+
parsed += 1;
|
|
599
732
|
const list = budgetHistory.get(rec.id);
|
|
600
733
|
if (!list) budgetHistory.set(rec.id, [rec.at]);
|
|
601
734
|
else if (list.indexOf(rec.at) < 0) list.push(rec.at);
|
|
602
735
|
}
|
|
603
736
|
} catch { /* 读不到就只用内存态 */ }
|
|
737
|
+
// 文件里有内容却一条都解析不出来:这不是我们的预算文件(配置指错了,或被
|
|
738
|
+
// 别的程序写成了别的东西)。重写它会毁掉别人的数据,宁可不压缩、只留一行。
|
|
739
|
+
if (parsed === 0 && rawText.trim() !== '') {
|
|
740
|
+
if (!budgetCompactWarned) {
|
|
741
|
+
budgetCompactWarned = true;
|
|
742
|
+
log('budget compact skipped: file does not look like turn-recovery state ('
|
|
743
|
+
+ budgetPath + ')');
|
|
744
|
+
}
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
604
747
|
pruneBudgetHistory(now);
|
|
605
748
|
const lines = [];
|
|
606
749
|
for (const [sid, times] of budgetHistory) {
|
|
@@ -639,14 +782,18 @@ export function apply(ctx, config) {
|
|
|
639
782
|
* 日志里的会话短标签。旧版直接 id.slice(0,8),而根会话 id 形如
|
|
640
783
|
* session-<uuid>,前 8 位恒为 "session-",多会话根本分不开。
|
|
641
784
|
* 现在先剥掉 session- / orchestrator- / worker- 前缀再取前 8 位;剥完为空则退回原 id 前 8 位。
|
|
785
|
+
* 只拿到 id(没有 session 对象)的路径——例如宿主只发 { message } 的
|
|
786
|
+
* agent/inbox/discarded——直接复用 shortId。
|
|
642
787
|
*/
|
|
788
|
+
const shortId = (rawId) => {
|
|
789
|
+
const id = typeof rawId === 'string' ? rawId : '';
|
|
790
|
+
if (id === '') return 'no-session';
|
|
791
|
+
const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
|
|
792
|
+
return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
|
|
793
|
+
};
|
|
794
|
+
|
|
643
795
|
const tag = (session) => {
|
|
644
|
-
try {
|
|
645
|
-
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
646
|
-
if (!id) return 'no-session';
|
|
647
|
-
const stripped = id.replace(/^(session-|orchestrator-|worker-)/, '');
|
|
648
|
-
return stripped ? stripped.slice(0, 8) : id.slice(0, 8);
|
|
649
|
-
} catch { return 'no-session'; }
|
|
796
|
+
try { return shortId(session && session.id); } catch { return 'no-session'; }
|
|
650
797
|
};
|
|
651
798
|
|
|
652
799
|
/**
|
|
@@ -668,6 +815,8 @@ export function apply(ctx, config) {
|
|
|
668
815
|
*/
|
|
669
816
|
const scopeSeen = new Set();
|
|
670
817
|
let scopeSeenCapped = false;
|
|
818
|
+
/** headerlessScope='root' 时,每个「靠 id 形态放行」的会话只记一条日志。 */
|
|
819
|
+
const headerlessSeen = new Set();
|
|
671
820
|
|
|
672
821
|
/** header 判定根会话;返回 null 表示无法判定(没有 header)。 */
|
|
673
822
|
const rootByHeader = (session) => {
|
|
@@ -702,9 +851,19 @@ export function apply(ctx, config) {
|
|
|
702
851
|
return false;
|
|
703
852
|
}
|
|
704
853
|
const id = session && typeof session.id === 'string' ? session.id : '';
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
854
|
+
// 0.2.5:拿不到 header 时默认**不碰**(fail-closed)。旧版按 id 形态把
|
|
855
|
+
// session-* / orchestrator-* 当根会话,于是一个「无 header 且 id 形如
|
|
856
|
+
// session-*」的子代理会话也会被补写 —— 往子代理里塞"不要调用工具"会破坏
|
|
857
|
+
// 委派语义。确有老宿主需要兼容时,显式设 headerlessScope: 'root'。
|
|
858
|
+
if (cfg.headerlessScope === 'root' && /^(session-|orchestrator-)/.test(id)) {
|
|
859
|
+
if (!headerlessSeen.has(id) && headerlessSeen.size < SCOPE_SEEN_MAX) {
|
|
860
|
+
headerlessSeen.add(id);
|
|
861
|
+
log('scope fallback: no header, id shape=root -> in scope (headerlessScope=root) ['
|
|
862
|
+
+ shortId(id) + ']');
|
|
863
|
+
}
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
logScopeSkip(session, id === '' ? 'no header and no id' : 'no header (headerlessScope=out)');
|
|
708
867
|
return false;
|
|
709
868
|
};
|
|
710
869
|
|
|
@@ -815,6 +974,12 @@ export function apply(ctx, config) {
|
|
|
815
974
|
* 拿去补写会对着「本来有正文的回合」发假提醒。
|
|
816
975
|
*/
|
|
817
976
|
sawTurnStart: false,
|
|
977
|
+
/**
|
|
978
|
+
* 本回合最近一条非补写 user/message 的来源 kind('' = 本回合没见到)。
|
|
979
|
+
* 0.2.5 的来源闸门用它:只有来源在 nudgeSources(默认前台白名单)里
|
|
980
|
+
* 才允许补写,避免往社交桥这类「模型输出会被转发给第三方」的会话里插话。
|
|
981
|
+
*/
|
|
982
|
+
turnOrigin: '',
|
|
818
983
|
/**
|
|
819
984
|
* 本回合已经「处理过」:要么排上了补写,要么在排补写前就被限流/去重挡下。
|
|
820
985
|
* 只在 turn/end 的判定走完之后置位 —— 旧版在调用 scheduleNudge 之前就置位,
|
|
@@ -854,15 +1019,21 @@ export function apply(ctx, config) {
|
|
|
854
1019
|
// 两个索引都写:对象身份是最快路径,id 是宿主换 session 实例时的兜底。
|
|
855
1020
|
if (session && typeof session === 'object') state.set(session, s);
|
|
856
1021
|
const sid = session && typeof session.id === 'string' ? session.id : '';
|
|
857
|
-
if (sid !== ''
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
1022
|
+
if (sid !== '') {
|
|
1023
|
+
const existing = stateById.get(sid);
|
|
1024
|
+
if (existing === s) {
|
|
1025
|
+
// LRU:命中即刷新插入顺序(Map 保序)。旧版按纯插入序淘汰,长跑 >500 个
|
|
1026
|
+
// 会话时会把仍在活跃的会话踢掉,它的 sawTurnStart 随即复位 → 静默漏补。
|
|
1027
|
+
stateById.delete(sid);
|
|
1028
|
+
stateById.set(sid, s);
|
|
1029
|
+
} else {
|
|
1030
|
+
if (stateById.size >= STATE_BY_ID_MAX && !stateById.has(sid)) {
|
|
1031
|
+
// Map 保序,第一个就是最久未使用的(见上面的命中刷新)。
|
|
1032
|
+
const oldest = stateById.keys().next();
|
|
1033
|
+
if (!oldest.done) stateById.delete(oldest.value);
|
|
1034
|
+
}
|
|
1035
|
+
stateById.set(sid, s);
|
|
864
1036
|
}
|
|
865
|
-
stateById.set(sid, s);
|
|
866
1037
|
}
|
|
867
1038
|
return s;
|
|
868
1039
|
};
|
|
@@ -1003,26 +1174,33 @@ export function apply(ctx, config) {
|
|
|
1003
1174
|
}
|
|
1004
1175
|
|
|
1005
1176
|
/**
|
|
1006
|
-
* P2-C
|
|
1007
|
-
* session 事件,插件单靠 session/event 无从得知那条补写已被丢弃,
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1177
|
+
* P2-C 缓解(0.2.5 修):用户点「停止」时宿主 cancel() 只清 inbox + abort,
|
|
1178
|
+
* 不 append 任何 session 事件,插件单靠 session/event 无从得知那条补写已被丢弃,
|
|
1179
|
+
* delayMs 后照发,于是「用户已经喊停」还会多跑一轮模型。
|
|
1180
|
+
*
|
|
1181
|
+
* 宿主 0.2.2-rc.1 的真实载荷(app.asar 内 dsh-agent-loop/lib/index.js:199):
|
|
1182
|
+
* this.dispatch.emit("agent/inbox/discarded", { message })
|
|
1183
|
+
* —— 只有 message,没有 agent、也没有 session。0.2.4 及更早读的是 payload.agent,
|
|
1184
|
+
* 于是 handler 在真实宿主上永远在第一行 return:定时器不撤、日志不写。
|
|
1185
|
+
* 单测又用自造的 { agent, message } 形状喂它,所以 65 例全绿也覆盖不到这个失效。
|
|
1186
|
+
* 现在按 message.id 反查持有该在途 id 的会话状态,不再依赖载荷里的 agent/session。
|
|
1012
1187
|
*/
|
|
1013
1188
|
const onInboxDiscarded = (payload) => {
|
|
1014
1189
|
try {
|
|
1015
|
-
const
|
|
1016
|
-
const session = agent && agent.session;
|
|
1017
|
-
if (!session) return;
|
|
1018
|
-
const s = stateLookup(session);
|
|
1019
|
-
if (!s) return;
|
|
1020
|
-
const message = payload && payload.message;
|
|
1190
|
+
const message = payload && payload.message ? payload.message : payload;
|
|
1021
1191
|
const id = message && typeof message.id === 'string' ? message.id : '';
|
|
1022
|
-
if (id === ''
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1192
|
+
if (id === '') return;
|
|
1193
|
+
let hit = 0;
|
|
1194
|
+
for (const [sid, s] of stateById) {
|
|
1195
|
+
if (s.nudgeMessageId !== id) continue;
|
|
1196
|
+
clearTimer(s, 'nudge discarded by host');
|
|
1197
|
+
clearNudgeInFlight(s, 'discarded by host');
|
|
1198
|
+
log('nudge DISCARDED by host before claim [' + shortId(sid) + ']');
|
|
1199
|
+
hit += 1;
|
|
1200
|
+
}
|
|
1201
|
+
if (hit === 0) {
|
|
1202
|
+
log('inbox discarded: message ' + id.slice(0, 8) + ' is not an in-flight nudge -> ignored');
|
|
1203
|
+
}
|
|
1026
1204
|
} catch (err) {
|
|
1027
1205
|
log('agent/inbox/discarded handler error: ' + errText(err));
|
|
1028
1206
|
}
|
|
@@ -1277,19 +1455,21 @@ export function apply(ctx, config) {
|
|
|
1277
1455
|
// 仍可能认领那笔「已超时」的消息 —— 它进转录后开出的照样是补写回合。
|
|
1278
1456
|
// 不认它,下一个空回合就会被当成真空回合再补一笔(连环补写)。
|
|
1279
1457
|
const late = !matched && typeof data.id === 'string' && s.guardIds.indexOf(data.id) >= 0;
|
|
1280
|
-
//
|
|
1281
|
-
// 对不上说明队列已经往前走了(上一进程遗留/重放),这笔补写也不会再被认领。
|
|
1282
|
-
// 只有晚到认领例外 —— 它可能是旧 id,不该顺手抹掉新一笔的在途状态。
|
|
1283
|
-
if (!late) clearNudgeInFlight(s, null);
|
|
1284
|
-
// 关键:只有真的对上 id 才算「补写回合」。
|
|
1458
|
+
// 关键:只有真的对上 id(或晚到认领)才算「补写回合」。
|
|
1285
1459
|
// 旧版把 matched 只写进日志、然后无条件置 awaitingGuardTurn,于是任何
|
|
1286
1460
|
// 同来源但 id 不符的消息(上次进程遗留、重放、另一实例写入)都会被误认,
|
|
1287
1461
|
// 导致下一个真正的空回合被判成「连环补写」而静默跳过。
|
|
1288
1462
|
if (!matched && !late) {
|
|
1463
|
+
// 0.2.5 修:这里**不再清在途标记**。旧版在早退之前无条件 clear,
|
|
1464
|
+
// 于是同进程任一插件只要 append 一条 source.kind=plugin:turn-recovery
|
|
1465
|
+
// 且 id 随意的消息,就能把在途标记抹掉 → 下一个空回合多补一笔
|
|
1466
|
+
// (多一轮模型唤醒与费用)。真正「队列已前进」由 120s 认领超时兜底。
|
|
1289
1467
|
log('guard-kind message seen but NOT ours (id mismatch/expired) -> ignored ['
|
|
1290
1468
|
+ tag(session) + ']');
|
|
1291
1469
|
return;
|
|
1292
1470
|
}
|
|
1471
|
+
// 只有对上 id 时它才算被消费;晚到认领是旧 id,不该抹掉新一笔的在途状态。
|
|
1472
|
+
if (matched) clearNudgeInFlight(s, null);
|
|
1293
1473
|
s.awaitingGuardTurn = true;
|
|
1294
1474
|
stats.claimed += 1;
|
|
1295
1475
|
log('guard message claimed' + (late ? ' (LATE, from guardIds)' : '')
|
|
@@ -1305,6 +1485,10 @@ export function apply(ctx, config) {
|
|
|
1305
1485
|
// 0.2.0 用「后台黑名单 + 未知即前台」,宿主每加一个通知来源就可能让补写
|
|
1306
1486
|
// 被静默取消;白名单把默认值改成「不认识就当背景」,漏取消由 turn/start 兜底。
|
|
1307
1487
|
const form = source && typeof source.form === 'string' ? source.form : '';
|
|
1488
|
+
// 记下本回合是谁发起的:turn/end 的来源闸门读它(见 nudgeSources)。
|
|
1489
|
+
// form:'notice' 是宿主的通知形态,不算「有人说话」,但它的 kind 仍可能
|
|
1490
|
+
// 恰好是 user —— 那种情况不覆盖 turnOrigin,避免把通知当成人类回合。
|
|
1491
|
+
if (form !== 'notice') s.turnOrigin = kind;
|
|
1308
1492
|
// form:'notice' 是宿主的通知形态(tool-jobs / action-cards / plan-mode /
|
|
1309
1493
|
// tool-goal 都用它):无论 kind 叫什么,它都不是人在说话。
|
|
1310
1494
|
if (form !== 'notice' && cfg.foregroundKinds.has(kind)) {
|
|
@@ -1316,7 +1500,14 @@ export function apply(ctx, config) {
|
|
|
1316
1500
|
if (event.type === 'assistant/message') {
|
|
1317
1501
|
const message = data.message;
|
|
1318
1502
|
if (!message || message.role !== 'assistant') return;
|
|
1319
|
-
if (typeof data.turn === 'number' && s.turn !== data.turn)
|
|
1503
|
+
if (typeof data.turn === 'number' && s.turn !== data.turn) {
|
|
1504
|
+
// 0.2.5:turn/start 见到过、但它的 turn 不是数值时,s.turn 仍是 null;
|
|
1505
|
+
// 这里若走 resetTurnCounters 会把 sawTurnStart 一并清成 false,于是整个
|
|
1506
|
+
// 回合被误判成「中途加载」而跳过判定(日志与热加载同形,排障会跑偏)。
|
|
1507
|
+
// 只补记 turn 号、保留 sawTurnStart。
|
|
1508
|
+
if (s.sawTurnStart && s.turn === null) s.turn = data.turn;
|
|
1509
|
+
else resetTurnCounters(s, data.turn);
|
|
1510
|
+
}
|
|
1320
1511
|
const seen = inspectMessage(message);
|
|
1321
1512
|
s.assistantMessages += 1;
|
|
1322
1513
|
s.visibleChars += seen.chars;
|
|
@@ -1350,6 +1541,9 @@ export function apply(ctx, config) {
|
|
|
1350
1541
|
|
|
1351
1542
|
const sawStart = s.sawTurnStart === true;
|
|
1352
1543
|
const empty = !s.visibleNonText && s.visibleChars < cfg.minVisibleChars;
|
|
1544
|
+
// 回合已结束:来源标记取一次就清,避免跨回合残留把上一回合的来源当成这一次的。
|
|
1545
|
+
const turnOrigin = s.turnOrigin;
|
|
1546
|
+
s.turnOrigin = '';
|
|
1353
1547
|
stats.turns += 1;
|
|
1354
1548
|
if (empty) {
|
|
1355
1549
|
if (kind === 'completed') stats.emptyCompleted += 1;
|
|
@@ -1375,6 +1569,15 @@ export function apply(ctx, config) {
|
|
|
1375
1569
|
log('empty turn but kind=' + kind + ' -> no nudge');
|
|
1376
1570
|
return;
|
|
1377
1571
|
}
|
|
1572
|
+
// 来源闸门(0.2.5):只给「人类发起」的回合补写。社交桥等来源的会话
|
|
1573
|
+
// 一旦被补写,模型输出可能被桥接器转发给第三方联系人 —— 那是用户没有
|
|
1574
|
+
// 授权的对外发言,必须默认拒绝;需要时用 nudgeSources 显式放开。
|
|
1575
|
+
const allowedOrigins = cfg.nudgeSources || cfg.foregroundKinds;
|
|
1576
|
+
if (turnOrigin !== '' && !allowedOrigins.has(turnOrigin)) {
|
|
1577
|
+
log('empty turn but origin=' + turnOrigin + ' not in nudge sources -> no nudge ['
|
|
1578
|
+
+ tag(session) + ']');
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1378
1581
|
if (wasGuard) {
|
|
1379
1582
|
log('previous turn was already a guard turn and still empty -> stop chaining');
|
|
1380
1583
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-turn-recovery",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "DSH plugin: detect assistant turns that end with no visible text (zero-width placeholder) and re-prompt the model to write the answer; also injects a visible-closing-answer system prompt rule.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "HERO476",
|