@quekenao/dsh-auto-continue-community 0.0.0-stage → 0.3.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 quekenao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,593 @@
1
- # Temporary Holding Version
1
+ # dsh-auto-continue-community 插件
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![npm version](https://img.shields.io/npm/v/@quekenao/dsh-auto-continue-community)
4
+ ![npm downloads](https://img.shields.io/npm/dm/@quekenao/dsh-auto-continue-community)
5
+ ![license](https://img.shields.io/badge/license-MIT-blue)
6
+
7
+ > **状态:v0.3.2,已发布至 npm。**
8
+ > 全部验证已通过:95/95 单测、代码审查、性能基准、headless 端到端、
9
+ > Web 端到端(含 2026-10-30 真实 GUI 会话验证,见"部署验证"节)。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ dsh plugin add @quekenao/dsh-auto-continue-community
15
+ ```
16
+
17
+ 安装后插件通过自带的 `cordis.patch.yml` bundle 自动激活,无需手动编辑配置。
18
+
19
+ ## ⚠️ 适用边界(部署前必读)
20
+
21
+ 本插件**仅在以下链路下经过验证**,超出该范围不保证行为正确:
22
+
23
+ | 维度 | 适用范围 |
24
+ |---|---|
25
+ | 链路拓扑 | **DSH + llama.cpp + 本地模型** |
26
+ | DSH 版本 | **0.2.1-rc.5** |
27
+ | `@deepseek-ai/cordis` | **~4.0.5** |
28
+ | `@deepseek-ai/dsh-llm` / `dsh-web-app` | **0.2.1-rc.5** |
29
+ | Node.js | **>= 24**(开发/测试于 v24.18.0) |
30
+ | 运行端 | **仅 Web 端**(`dsh web` 的 waterfall) |
31
+ | 客户端 / CLI | **未适配,不在本版本支持范围内**(我们不交付客户端支持) |
32
+
33
+ 原因:插件依赖的 DSH 行为——`llm/stream` waterfall、`agent/assistant-stream` 事件、
34
+ `agent.steer()` inbox 通道、BlockAssembler 的 block-index 约定、max-tokens 时丢弃
35
+ tool-call 的 kept-filter——都是 **DSH 0.2.1-rc.5 这一条版本线**的特性。
36
+ 更换 DSH 大版本、换用非 llama.cpp 后端、或落到客户端 / CLI 端,均超出适用范围。
37
+
38
+ > 机器可读的同一份信息也写在 `package.json` 的 `dsh.scope` 字段和
39
+ > `peerDependencies` / `engines` 中,便于自动化工具校验。
40
+
41
+ ## 与内部版的区别
42
+
43
+ 本插件是 `dsh-auto-continue`(自用版)的社区版副本,针对"发社区/可分发性"做了多项整改:
44
+
45
+ | 整改 | 自用版 | 社区版(本插件) |
46
+ |---|---|---|
47
+ | 调试日志路径 | 硬编码绝对路径 | 可移植:`os.tmpdir()/dsh-auto-continue-community-debug.log` |
48
+ | provider 门控 | 硬编码 `local-vision` | 可配置(patch `config` 或环境变量,经 schema 校验),默认 `local-vision` |
49
+ | 续写参数 | 硬编码常量 | 可配置(`maxContinuations`、`timeoutMs`、`providers`、`mode`),zod schema 校验 + 加载期报错 |
50
+ | 续写架构 | 仅缓冲流模式 | **双模式**:loop(实时,默认)+ stream(缓冲,回退) |
51
+ | 单元测试 | 无 | `test/` 下 95 个单测(纯函数 + 集成 + 故障注入) |
52
+
53
+ ## 功能概述
54
+
55
+ 当配置 provider 的本地模型流式响应因 `max-tokens` 被截断时,插件透明地追加模型调用,
56
+ 将截断的输出补完,使 agent loop 看到完整的回复。
57
+
58
+ ### 双模式架构(v0.3.0 新增)
59
+
60
+ 插件提供两种互斥的续写模式,加载时二选一(`mode` 配置字段,默认 `loop`):
61
+
62
+ | 维度 | `loop`(默认,实时) | `stream`(回退,缓冲) |
63
+ |---|---|---|
64
+ | **token 实时显示** | ✅ 是——原始步与续写步各自独立流式推送,UI 实时显示 | ❌ 否——缓冲整段上游流,finish 只在流末到达 |
65
+ | **续写方式** | 通过 `agent.steer()` 注入一条**新的 agent 步** | 通过 `llm/stream` waterfall 拦截 + 重调 + 合并 |
66
+ | **UI 表现** | 两条独立的 assistant 消息(原始 + 续写),各自实时流式 | 一条合并的 assistant 消息,完成后一次性出现 |
67
+ | **依赖钩子** | `agent/assistant-stream` + `agent.steer()` + `agent/dispose` | `llm/stream` |
68
+ | **适用场景** | 默认推荐;实时体验 | loop 模式在你的部署中异常时的保证回退 |
69
+
70
+ **设计取舍**:loop 模式牺牲了"合并为一条消息"的美观,换来了实时 token 显示——
71
+ 这是用户的核心诉求。续写是一个**独立的 agent 步**(不是流拼接),因此 UI 显示两条
72
+ 消息,但每条都实时流式。若某次部署中 loop 模式行为异常(如 `steer()` 不可用),
73
+ 设置 `mode: stream` 即可回退到缓冲模式。
74
+
75
+ ### 可配置参数
76
+
77
+ 插件通过 cordis 的 `Config`(standard-schema,zod)声明配置契约:`apply()` 收到的是
78
+ **经过校验并填好默认值的对象**(cordis 在 `apply` 前对 patch `config` 块做校验)。
79
+
80
+ | 参数 | 默认值 | 约束 | patch `config` 键 | 环境变量* | 说明 |
81
+ |---|---|---|---|---|---|
82
+ | `providers` | `["local-vision"]` | 字符串数组,长度 ≥ 1 | `providers` | `DSH_AUTOCONTINUE_PROVIDERS`(逗号分隔) | 仅拦截这些 provider |
83
+ | `maxContinuations` | `5` | 整数,1–20 | `maxContinuations` | `DSH_AUTOCONTINUE_MAX` | 单条响应链最多续写次数 |
84
+ | `timeoutMs` | `120000` | 整数,1–3600000 | `timeoutMs` | `DSH_AUTOCONTINUE_TIMEOUT_MS` | 单次续写超时(毫秒),超时降级回原始截断流 |
85
+ | `mode` | `"loop"` | 枚举:`"loop"` \| `"stream"` | `mode` | `DSH_AUTOCONTINUE_MODE` | 续写模式:`loop`(实时)或 `stream`(缓冲回退) |
86
+
87
+ \* 环境变量是 `resolveConfig` 的**回退路径**:仅当某字段既不在 patch `config`、也不在
88
+ schema 默认值中时才生效。schema 默认值已覆盖四个字段,因此正常注册下环境变量通常不触发。
89
+
90
+ **校验行为**(这是与"硬编码常量"版的实质区别):
91
+
92
+ - **不写 `config` 块** → cordis 传 `undefined` 给 schema,object-level `.default()` 将其解析为默认值,等价于上面"默认值"列。
93
+ - **写了合法 `config` 块** → 校验通过,值生效。
94
+ - **写了非法 `config` 块**(如 `maxContinuations: 999`、`providers: []`、`timeoutMs: 0`、
95
+ `mode: "bogus"`)→ cordis 在**加载阶段抛 `ValidationError`**,启动即报错,而不是
96
+ "配置被静默丢弃、插件没生效"。配置拼写错误从此**大声失败**。
97
+
98
+ > **实现细节**:`Config` schema 使用 `z.object({...}).default({...})`(object-level default)。
99
+ > 字段级 `.default()` 只填充对象输入中缺失的字段,对 `undefined` 输入无效——而
100
+ > `cordis.patch.yml` 不写 `config` 块时 cordis 恰好传 `undefined`。object-level
101
+ > `.default()` 确保 `undefined` 输入被解析为默认值而非校验失败(v0.3.1 Bug #5 修复)。
102
+
103
+ ### 配置示例
104
+
105
+ 在 `cordis.patch.yml` 中,`config` 是 **insert 条目上的一个字段**(loader 把条目上的
106
+ `config` 透传给插件,与 `id`/`name` 同级):
107
+
108
+ ```yaml
109
+ - insert:
110
+ - id: auto-continue-community
111
+ name: "@quekenao/dsh-auto-continue-community"
112
+ config:
113
+ providers:
114
+ - local-vision
115
+ - llama-local
116
+ maxContinuations: 3
117
+ timeoutMs: 60000
118
+ mode: loop # 'loop'(默认)或 'stream'
119
+ ```
120
+
121
+ 这个 `config` 块会经过上面声明的 `Config` schema 校验:合法则生效,非法则加载时报
122
+ `ValidationError`。
123
+
124
+ 或纯环境变量(不改 yml,走 `resolveConfig` 回退路径):
125
+
126
+ ```bat
127
+ set "DSH_AUTOCONTINUE_PROVIDERS=local-vision,llama-local"
128
+ set "DSH_AUTOCONTINUE_MAX=3"
129
+ set "DSH_AUTOCONTINUE_TIMEOUT_MS=60000"
130
+ set "DSH_AUTOCONTINUE_MODE=stream"
131
+ ```
132
+
133
+ ## 工作流程
134
+
135
+ ### loop 模式(默认,实时)
136
+
137
+ ```
138
+ agent loop 运行一步(runtime.stream 内部,实时流式推送 UI)
139
+ │
140
+ ▼
141
+ agent/assistant-stream emit 事件(逐帧)
142
+ │
143
+ ├─ 首帧(type='start')→ markLoopPending(agent)(标记流在途)
144
+ │
145
+ ├─ finish 帧(type='chunk', chunk.type='finish')
146
+ │ → recordLoopFinish(agent, chunk.reason.kind)
147
+ │ ├─ kind === "max-tokens" → state.truncated = true
148
+ │ └─ 其他 → state.truncated = false, state.count = 0(链结束)
149
+ │
150
+ ├─ max-tokens 且 continuationDecision → continue:
151
+ │ │
152
+ │ ├─ providerOk(agent)?
153
+ │ │ (1) 插件 meta.provider ∈ providers → 放行
154
+ │ │ (2) agent.provider ∈ providers → 放行
155
+ │ │ (3) 无法确定 provider → fail-open 放行
156
+ │ │
157
+ │ ├─ 最后一条 user message 带 CONTINUATION_MARK → 跳过(重入守卫)
158
+ │ │
159
+ │ ├─ state.count ≥ maxContinuations → 跳过(预算耗尽)
160
+ │ │
161
+ │ └─ 构建续写消息 + agent.steer(continuationMsg)
162
+ │ │
163
+ │ ├─ 有已完成 tool call → 用 CONTINUE_PROMPT_TEXT_WITH_TOOLCALLS
164
+ │ │ (告知模型 tool call 未执行、不在上下文中、不要重新发出)
165
+ │ └─ 无 tool call → 用 CONTINUE_PROMPT_TEXT
166
+ │ │
167
+ │ ▼
168
+ │ steer() → inbox.nextStep + wakeDriver
169
+ │ │
170
+ │ ▼
171
+ │ agent loop 的 turn() 检测到 inbox.nextStep 非空
172
+ │ → 执行新一步(续写步),实时流式推送 UI
173
+ │
174
+ └─ 如果续写也 max-tokens → stream observer 再次检测到
175
+ → 再次 steer()(受预算约束)
176
+ 如果续写 stop / tool-calls → 链结束
177
+ ```
178
+
179
+ **关键**:
180
+
181
+ - 续写是**独立的 agent 步**,不是流拼接。UI 显示两条 assistant 消息,各自实时流式。
182
+ - **注入通道是 `agent.steer()`**(不是 `agent/pre-step`):`pre-step` 在 step 开始前触发,
183
+ 而 max-tokens 检测发生在流结束时刻,此时 turn 已关闭、`pre-step` 不再触发。
184
+ `steer()` 将消息追加到 `inbox.nextStep` 并唤醒 driver,`turn()` 的循环在
185
+ `inbox.nextStep` 非空时保持 turn 打开,续写步立即执行。
186
+ - `agent/dispose` 事件清理该 agent 的 loop 状态,防止泄漏。
187
+
188
+ ### stream 模式(回退,缓冲)
189
+
190
+ ```
191
+ agent loop 调用 runtime.stream(options)
192
+ │
193
+ ▼
194
+ llm/stream waterfall 触发
195
+ │
196
+ ├─ provider ∉ providers → 透传(next())
197
+ │
198
+ ├─ 缓冲上游流(collectChunks)
199
+ │ │
200
+ │ ▼
201
+ │ analyzeChunks: 检测 finish.kind === "max-tokens"
202
+ │ │
203
+ │ ├─ 未截断 → resetCounter,原样 yield
204
+ │ │
205
+ │ ├─ 截断 + 有未完成 tool call → 跳过续写,原样 yield
206
+ │ │ (JSON 参数被截断,无法修复;DSH 会丢弃所有 tool-call block)
207
+ │ │
208
+ │ └─ 截断(可续写)→ 检查预算
209
+ │ │
210
+ │ ├─ 预算耗尽 → 原样 yield
211
+ │ │
212
+ │ └─ 有预算 → 构建续写 prompt
213
+ │ │
214
+ │ ├─ 有已完成 tool call → CONTINUE_PROMPT_TEXT_WITH_TOOLCALLS
215
+ │ └─ 无 tool call → CONTINUE_PROMPT_TEXT
216
+ │ │
217
+ │ ▼
218
+ │ runtime.stream(continuationOptions)
219
+ │ │
220
+ │ ├─ 成功 → mergeChunks(合并两段流)
221
+ │ │ │
222
+ │ │ ├─ block index 偏移:continuation 的 block 加 offset,避免碰撞
223
+ │ │ ├─ usage 合并:completion_tokens 求和,prompt_tokens 取 continuation
224
+ │ │ ├─ replayState 剥离:避免 BlockAssembler 长度检查失败
225
+ │ │ └─ 终端 finish:优先 continuation 的 finish
226
+ │ │
227
+ │ ├─ 超时 → abort,yield 原始截断流
228
+ │ └─ 错误 → yield 原始截断流(降级)
229
+ │ │
230
+ │ ▼
231
+ │ 如果续写未截断 → resetCounter
232
+ │ 如果续写也截断 → 保留计数(下次调用检查预算)
233
+ ```
234
+
235
+ ### 合并流结构(stream 模式)
236
+
237
+ ```
238
+ [upstream body chunks (minus finish)]
239
+ [continuation body chunks (block indices + offset, minus finish)]
240
+ [merged usage chunk]
241
+ [terminal finish chunk (replayState stripped)]
242
+ ```
243
+
244
+ BlockAssembler 消费顺序:block-start / delta / block-end 按 index 组装,
245
+ usage 最后写入,finish 最后写入。合并结构与此一致。
246
+
247
+ ### 计数器生命周期
248
+
249
+ **loop 模式**(`loopState` Map,per agent):
250
+ - 每步流结束 → `recordLoopFinish`(设置 truncated 标志)
251
+ - 未截断步结束 → count 重置为 0(链结束)
252
+ - 截断 + 注入续写 → `consumeContinuation`(count+1)
253
+ - 预算耗尽(count ≥ maxContinuations)→ 不注入
254
+ - agent 销毁 → `clearLoopState`(清理)
255
+ - LRU 清理:Map 超 256 条且距上次清理 ≥60s → 淘汰最旧的,保留 128 条
256
+
257
+ **stream 模式**(`sessionCounters` Map,per session):
258
+ - 每次非截断响应 → `resetCounter`(重置为 0)
259
+ - 每次续写 → `incCounter`(+1)
260
+ - 续写也截断 → 不重置(保留计数,下次调用检查预算)
261
+ - 预算耗尽(≥ maxContinuations)→ 透传,不再续写
262
+ - LRU 清理:同上
263
+
264
+ ### 重入守卫
265
+
266
+ 续写请求的 messages 最后一条 user message 带有枚举字符串键属性 `CONTINUATION_MARK`
267
+ (`'__dsh_auto_continue_continuation__'`,值为 `true`)。两种模式都检测该标记:
268
+ - loop 模式:`continuationDecision` 检查 `lastMsg[CONTINUATION_MARK]`,有标记则不二次注入。
269
+ - stream 模式:waterfall 检测到标记时直接透传,不二次缓冲、不二次续写。
270
+
271
+ **为什么是 enumerable 字符串键?** DSH session 层的 `snapshotJsonValue` 使用
272
+ `Reflect.ownKeys` + `propertyIsEnumerable` 遍历对象——任何含非枚举 own property
273
+ 的对象都会被拒绝("non-JSON-serializable data")。续写消息会经 `agent.steer()`
274
+ 进入 inbox 并被 `session.append()` 序列化,因此标记**必须**是 enumerable 的。
275
+ 字符串键在 `JSON.stringify` 中可见(session 日志中可见 `__dsh_auto_continue_continuation__: true`),
276
+ 但不在消息的 `content` 数组内,对 LLM 不可见。
277
+
278
+ ### 降级策略
279
+
280
+ **loop 模式**:续写步本身失败(模型错误)时,agent loop 会显示错误,用户可手动重试。
281
+ 续写步的 max-tokens 由 stream observer 再次检测(受预算约束),通过 `steer()` 注入下一步。
282
+
283
+ **stream 模式**:续写流失败(超时、网络错误、模型错误)时,插件 yield 原始截断流。
284
+ agent loop 看到 `finish.kind === "max-tokens"`,UI 显示"已达到输出 token 上限"提示,
285
+ 用户可手动发送"继续"。
286
+
287
+ **模式回退**:若 loop 模式在你的部署中行为异常(如 `steer()` 不可用),
288
+ 设置 `mode: stream` 即可回退到缓冲模式。两种模式互斥,加载时二选一。
289
+
290
+ ## 已知限制
291
+
292
+ 1. **loop 模式的两条消息**:续写是独立的 agent 步,UI 显示两条 assistant 消息
293
+ (原始 + 续写),而不是一条合并消息。这是实时流式的代价。若需合并为一条,
294
+ 使用 `mode: stream`(但会失去实时显示)。
295
+
296
+ 2. **sticky max-tokens(cosmetic)**:agent-loop 的 sticky 逻辑(`turnEnds` 一旦设为
297
+ `max-tokens` 就不再覆盖)导致即使续写步以 `stop` 结束,turn 仍标记为 `max-tokens`,
298
+ UI 显示"已达到输出 token 上限"提示。这是 agent-loop 的行为,不是插件 bug。
299
+ 实际内容已完整,提示是多余的。
300
+
301
+ 3. **未完成 tool call 不续写**:如果截断发生在 tool call 的 JSON 参数中间,插件跳过续写。
302
+ 原因:DSH 在 max-tokens finish 时丢弃所有 tool-call block;模型没有部分 block 的上下文;
303
+ 重新发出同样的 tool call 仍会被同样的 max-tokens 上限截断。
304
+
305
+ 4. **replayState 被剥离(stream 模式)**:合并流的 finish chunk 中 `replayState` 设为
306
+ `undefined`。原因:continuation 的 replay envelope 只覆盖 continuation 自己的 block,
307
+ 合并后 block 总数不匹配,BlockAssembler 会静默丢弃。剥离后 agent loop 不附带 replay
308
+ 到 assistant message(功能降级,不影响正常对话和 tool call 执行)。loop 模式不受此影响
309
+ (续写是独立步,有自己完整的 replay envelope)。
310
+
311
+ 5. **模型"假性截断"(false stop)不会触发续写**:插件只识别协议层的
312
+ `finish.reason.kind === "max-tokens"`。如果本地模型在输出中途自愿停止
313
+ (finish reason 为 `stop`,但文本停在半句话、未闭合代码块),插件按设计
314
+ **不**注入续写——这是模型行为,不是插件 bug(2026-10-30 真实 GUI 会话
315
+ 实测复现:qwen3.8:64k 在 4827 token 处输出 EOS,文本停在半句,插件正确停止)。
316
+ **一句话:不输出的是模型本身(模型自愿停止),插件按设计不续写、行为正确。**
317
+ 识别"语义上没写完"需要启发式(如未闭合代码块检测),属于行为变更,
318
+ 不在本版本范围;v0.3.2 的 finish 日志(见"调试"节)使此类情况可被
319
+ 日志直接确认,无需挖事件日志。
320
+
321
+ ## 调试
322
+
323
+ **文件日志默认关闭。** 插件稳定后文件日志很少用到,且每轮对话的每次 LLM 调用都会追加几行,
324
+ 长会话会持续增大(单个会话可达数百 KB),因此改为按需开启。
325
+
326
+ - **开启方式**:启动 `dsh web` 前设置环境变量 `DSH_AUTOCONTINUE_DEBUG=1`。
327
+ 未设置时插件**完全不写文件**(连 `node:fs` / `node:os` 都不加载)。
328
+ - **日志路径(可移植)**:`os.tmpdir()/dsh-auto-continue-community-debug.log`
329
+ (追加写入,所有会话共用,无轮转)。Windows 下通常是 `%TEMP%`。
330
+ - 启动标记:开启后进程启动时写入 `process started (pid ...); plugin code with file logging is active`。
331
+ - **每个 step 的 finish 都有日志(v0.3.2 新增)**:
332
+ - `max-tokens` → `step ended max-tokens (continuation candidate)`(+ 注入/跳过日志);
333
+ - 其他 kind(`stop` / `tool-calls` / `error` / `aborted` / 空)→
334
+ `step ended <kind> (not max-tokens; chain ends, no continuation)`。
335
+ 这样"为什么这条响应链停在第 N 次续写、没有第 N+1 次"可以直接从 debug 日志回答
336
+ (典型场景:模型在 `stop` 处自愿结束,插件按设计不续写),无需再挖
337
+ `.zstd` 会话事件日志里的 finish chunk。
338
+ - **无论开关是否开启**,所有日志都写入 `ctx.logger.info`(cordis 内存日志);
339
+ 文件日志只是额外的一份落盘副本。
340
+ - 不写 `process.stdout`(避免 GUI 聊天视图被刷屏)。
341
+
342
+ ## 安全模型
343
+
344
+ **结论:插件本身安全,攻击面极小,不引入任何新的外部信任边界。**
345
+ 以下副作用面清单来自对 `index.js` 全量扫描(逐行核对所有可产生副作用的调用点)。
346
+
347
+ ### 副作用面清单
348
+
349
+ | 能力 | 是否存在 | 说明 |
350
+ |---|---|---|
351
+ | 子进程 / 命令执行 | ❌ 无 | 没有 `child_process` / `exec` / `spawn`,不碰 shell |
352
+ | 任意网络请求 | ❌ 无 | 没有 `http` / `fetch` / `net` / `dns`;续写走 DSH 内部 `runtime.stream()`,不新增网络出口 |
353
+ | 动态代码执行 | ❌ 无 | 没有 `eval` / `new Function` / `vm`,不可被注入执行代码 |
354
+ | 任意文件读写 | ❌ 无 | 唯一的文件写是调试日志,且默认关闭 |
355
+ | 进程控制 | ❌ 无 | 没有 `process.exit` / `abort` |
356
+ | 读环境变量 | ✅ 有(只读) | `DSH_AUTOCONTINUE_*` 五个开关,只读不写 |
357
+ | 写调试日志 | ✅ 有(默认关) | `appendFileSync` 到 `os.tmpdir()/dsh-auto-continue-community-debug.log` |
358
+
359
+ ### 逐条说明
360
+
361
+ 1. **唯一的"写"是调试日志,且默认关闭。** 不设 `DSH_AUTOCONTINUE_DEBUG=1` 时,
362
+ 连 `node:fs` 都不加载(动态 `await import` 在开关分支内),文件系统零接触。
363
+ 开启后也只往 `tmpdir()` 里追加一个固定文件名的日志:
364
+ 路径是 `path.join(tmpdir(), 常量)`,不拼接任何用户输入,无目录穿越风险。
365
+
366
+ 2. **续写不扩大信任边界(关键点)。** 插件没有"把数据发给新地方"——
367
+ 它只是让 DSH 把**原本就要调用的同一个本地 llama.cpp** 再调一次
368
+ (loop 模式:`agent.steer(continuationMsg)`;stream 模式:`runtime.stream(continuationOptions)`),
369
+ provider/model/signal 原样继承自原始请求。原始请求安全(本地模型),续写就安全;
370
+ 它无法把数据导向云端或任意第三方。
371
+
372
+ 3. **数据流是"本地 → 本地模型",没有外泄通道。** 插件处理的是 DSH 内部已有的对话内容,
373
+ 续写请求也回到同一个本地后端。不存在"把上下文发给外部 API"的路径。
374
+
375
+ 4. **失败时是降级,不是破坏。** loop 模式续写步失败时 agent loop 显示错误;
376
+ stream 模式续写超时/出错时 yield 原始截断流。都不会损坏数据、不会卡死进程。
377
+
378
+ ### 与解耦原则的一致性
379
+
380
+ - **未注册时**:零加载、零监听、零影响。
381
+ - **注册但开关关闭时**:不写文件;仅在检测到 max-tokens 截断时才续写(而那本就是它被注册的目的)。
382
+ - **自身失效时**:续写异常被 try/catch 捕获并降级;cordis 的 `_reload` 也只停用插件本身,宿主进程不受影响。
383
+
384
+ ### 两个"非安全但相关"的注意点
385
+
386
+ - **token 成本 / 资源**:每次截断会多调一次本地模型,长输出最多续写 `maxContinuations`(默认 5)次。
387
+ 是资源消耗,不是安全问题,但在受限环境要心里有数。
388
+ - **provider 门控是唯一的安全闸门**:插件靠"只拦截配置的 provider"来保证不误伤云端 API。
389
+ 该闸门现在可配置——**误把云端 provider 加进 `providers`,会让本应走云端优化的请求被本地续写逻辑接管**。
390
+ 这不是插件不安全,而是"配置错误会改变行为"。默认值为 `['local-vision']`,
391
+ 只要不改默认、且遵循上方"适用边界",就不会误伤。
392
+
393
+ ## 部署验证(2026-10-30)
394
+
395
+ ### Headless 验证
396
+
397
+ 使用 headless 独立进程验证(`dsh --profile headless --patch <overlay>`),
398
+ 不重启 web GUI 或 llama 后端。Overlay 将 headless 默认 agent 路由到
399
+ `local-vision`(本地 llama.cpp 后端),maxTokens 强制 512。
400
+
401
+ | 检查项 | 结果 |
402
+ |--------|------|
403
+ | 插件加载(`apply() completed; mode=loop`) | ✅ |
404
+ | provider 路由(`request/header` 显示 `local-vision`, maxTokens 512) | ✅ |
405
+ | 截断检测(3 次 `step ended max-tokens`) | ✅ |
406
+ | `steer()` 注入(`injecting continuation 1/3, 2/3, 3/3`) | ✅ |
407
+ | 续写次数 ≤ maxContinuations(恰好 3) | ✅ |
408
+ | 截断步 outputTokens(三次均 512) | ✅ |
409
+ | 最终步正常完成(87 tokens, `stop` finish) | ✅ |
410
+ | 短问题不续写 | ✅ |
411
+
412
+ ### Web 端到端验证
413
+
414
+ 在 web surface(port 3098 并行实例,`DSH_AUTOCONTINUE_DEBUG=1`)上通过 web API
415
+ 创建会话、发送长 prompt 验证,不重启 port 3097 主实例。`agent-default-model`
416
+ 由 `launch.patch.yml` 覆盖为 `local-vision/qwen3.8:64k-vision-Q5-KS`,maxTokens 8192。
417
+
418
+ | 检查项 | 结果 |
419
+ |--------|------|
420
+ | 插件加载(`apply() completed; mode=loop`,无 ValidationError) | ✅ |
421
+ | 截断检测(3 次 `step ended max-tokens`) | ✅ |
422
+ | `steer()` 注入(`injecting continuation 1/5, 2/5, 3/5 via steer()`) | ✅ |
423
+ | 续写次数 ≤ maxContinuations(3 次,默认上限 5) | ✅ |
424
+ | 截断步 outputTokens(三次均 8192) | ✅ |
425
+ | 最终步正常完成(3227 tokens, `stop` finish) | ✅ |
426
+ | Session 结构(4 条独立 assistant 消息 + 3 条 continuation 消息) | ✅ |
427
+ | 短问题不续写(1 条消息,26 tokens,`stop`,0 continuation) | ✅ |
428
+ | Provider 门控 fail-open(`providerOk branch=fail-open`) | ✅ |
429
+
430
+ > **Bug #5 发现**:port 3097 主实例上截断后无续写 → 诊断发现 `cordis.patch.yml`
431
+ > 无 `config:` 块时 cordis 传 `undefined` 给 zod schema → `z.object` 拒绝 `undefined`
432
+ > → fiber INACTIVE → `apply()` 未执行。修复:object-level `.default()`(v0.3.1)。
433
+ > 详见 `HANDOFF.md` §4 Bug #5 和 §6c。
434
+
435
+ ### 真实 GUI 会话验证(2026-10-30,port 3097 主实例)
436
+
437
+ 在主实例(pid 7344 / 12272,`DSH_AUTOCONTINUE_DEBUG=1`)上用 HANDOFF §2 的
438
+ Redis 10 主题 prompt 创建了真实 Web 会话(`session-56e48e06-...`),并解压
439
+ `session.v4.jsonl.zstd`(多帧 zstd,33 个事件)逐事件核对:
440
+
441
+ | 检查项 | 结果 |
442
+ |--------|------|
443
+ | step 1 finish = `max-tokens`(8192 tokens)→ 注入续写 1/5 | ✅ 日志 + 事件日志双确认 |
444
+ | step 2 finish = `max-tokens`(8192 tokens)→ 注入续写 2/5 | ✅ 日志 + 事件日志双确认 |
445
+ | step 3 finish = `stop`(模型自愿 EOS,4827 tokens)→ **不**注入续写(设计行为) | ✅ 事件日志 `stopReason: "stop"` |
446
+ | 续写次数 ≤ maxContinuations(2/5) | ✅ 预算未耗尽,链因模型 `stop` 正常结束 |
447
+ | debug 日志完整(appendFileSync 同步写,无缓冲丢失) | ✅ 9 行,与事件日志时间线一致 |
448
+
449
+ **结论**:插件逻辑健壮——两次 `max-tokens` 均正确续写,`stop` 步按设计不续写。
450
+ "2 轮后停止"的观感来自模型在 4827 token 处输出 EOS(文本停在半句,属模型
451
+ "假性截断",见"已知限制"第 5 条),不是插件故障。该验证暴露的可观测性缺口
452
+ (`stop` 步无日志)已在 v0.3.2 修复。详见 `HANDOFF.md` §6d。
453
+
454
+ ## 单元测试
455
+
456
+ 纯函数(`analyzeChunks`、`finishKind`、`sumUsage`、`mergeChunks`、
457
+ 计数器 store `getCounter`/`incCounter`/`resetCounter`/`pruneCounters`、
458
+ loop 状态 store `recordLoopFinish`/`markLoopPending`/`continuationDecision`/
459
+ `consumeContinuation`/`clearLoopState`/`pruneLoopState`、`resolveConfig`)
460
+ 从 `index.js` 以 named export 导出,可在隔离环境测试,无需启动 DSH 或 llama.cpp。
461
+
462
+ ```bash
463
+ cd plugins/dsh-auto-continue-community
464
+ npm test # = node --test test/*.test.js
465
+ ```
466
+
467
+ 覆盖点(95 个测试):
468
+ - `finishKind`:对象/字符串两种 finish reason 形态归一
469
+ - `analyzeChunks`:截断检测;tool-call 完整/不完整判定(含缺 index 的 delta 边界)
470
+ - `sumUsage`:null 处理、completion 求和、prompt 取 continuation
471
+ - `mergeChunks`:block index 偏移避免碰撞、usage 合并为单一 chunk
472
+ - 计数器:LRU 顺序、超阈值淘汰、默认值
473
+ - `resolveConfig`:默认值(含 `mode: 'loop'`)、patch config 数组/字符串、env 回退、优先级
474
+ - `Config` schema:默认值填充(含 `mode`)、合法值通过、越界/空值/非法 mode 产生 issues
475
+ - `apply()`:config 走第二参(而非不存在的 `ctx.options.config`);缺省 config 时降级不崩
476
+ - loop 状态:`recordLoopFinish`(max-tokens 保留 count / stop 重置 count)、
477
+ `markLoopPending`、`continuationDecision`(未截断/预算耗尽/重入守卫/tool-call prompt 选择)、
478
+ `consumeContinuation`(递增)、`clearLoopState`(删除)、`pruneLoopState`(LRU 淘汰)
479
+
480
+ ## 文件清单
481
+
482
+ | 文件 | 说明 |
483
+ |---|---|
484
+ | `index.js` | 插件主体(双模式;纯函数为 named export) |
485
+ | `package.json` | 包元数据(v0.3.2;`scripts.test` = `node --test test/*.test.js`) |
486
+ | `cordis.patch.yml` | 插件注册(含 `config` 块说明头注,含 `mode` 字段) |
487
+ | `test/analyze.test.js` | 流分析/合并纯函数单测(12 tests) |
488
+ | `test/counters.test.js` | 计数器 LRU + resolveConfig 单测(14 tests) |
489
+ | `test/config.test.js` | `Config` schema 校验(含 `mode` 字段)+ `apply()` 第二参配置传递回归(10 tests) |
490
+ | `test/loop-state.test.js` | loop 状态 store 纯函数单测(17 tests) |
491
+ | `test/integration.test.js` | 集成测试:mock DSH 框架 waterfall,两种模式全路径(21 tests) |
492
+ | `test/fault-injection.test.js` | 故障注入:null/undefined/畸形帧/序列化不变量/内存泄漏(21 tests) |
493
+ | `README.md` | 本文档 |
494
+ | `HANDOFF.md` | 交接文档(验证结果、Bug 修复记录、待办事项) |
495
+ | `PUBLISH-GUIDE.md` | 发布指南(npm / GitHub / DSH 社区发布流程和注意事项) |
496
+
497
+ **总计 95 个测试,全部通过。**
498
+
499
+ ## 修改历史
500
+
501
+ - 2026-07-12: 初版(自用版 `dsh-auto-continue`)+ 4 项审核修复
502
+ (usage 求和、block index 偏移、null-safe 日志、LRU 清理)
503
+ - 2026-07-12: 第二项审核修复(replayState 剥离、缺 index delta 判定改进)
504
+ - 2026-10-30: 自用版文件调试日志改为默认关闭
505
+ - 2026-10-30: **社区版**(本插件)从自用版复制并整改:
506
+ 可移植调试日志路径、provider/参数可配置、纯函数导出 + 23 个单测。
507
+ 核心算法未改动。
508
+ - 2026-10-30: **修复 config 传递 bug + 引入 schema 校验**。
509
+ 此前 `apply(ctx)` 从不存在的 `ctx.options.config` 取值,patch `config` 块被静默丢弃
510
+ ("可配置"只剩环境变量一条腿)。现改为 `apply(ctx, config)` 接收第二参,并为插件
511
+ 声明 zod `Config` schema:cordis 在加载阶段校验 `config` 块,合法填默认值、非法抛
512
+ `ValidationError`。新增 8 个单测(共 31 个),`package.json` 增 `zod` peerDependency。
513
+ - 2026-10-30: **v0.3.0 — 双模式架构**。新增 `mode` 配置字段(`loop` | `stream`,默认 `loop`):
514
+ - **loop 模式(实时,默认)**:通过 `agent.steer()` 注入续写步,
515
+ 通过 `agent/assistant-stream` 事件检测 max-tokens,token 实时流式显示。
516
+ 续写是独立的 agent 步(UI 显示两条消息),不是流拼接。
517
+ - **stream 模式(缓冲,回退)**:保留原有的 `llm/stream` 拦截 + 合并逻辑,
518
+ 作为 loop 模式异常时的保证回退。
519
+ - 新增 `DSH_AUTOCONTINUE_MODE` 环境变量。
520
+ - 新增 loop 状态 store(`loopState` Map)+ 6 个纯函数导出。
521
+ - 新增 19 个 loop 状态单测(共 50 个)。
522
+ - `apply()` 根据 `mode` 分派到 `registerLoopMode` 或 `registerStreamMode`。
523
+ - 2026-10-30: **鲁棒性加固**。
524
+ - `CONTINUATION_MARK` 从 `Symbol.for(...)` 改为字符串键 `__dsh_auto_continue_continuation__`。
525
+ - `registerLoopMode` 中所有 `agent.id` 访问添加 null/undefined 防护。
526
+ - `registerStreamMode` 添加 `runtime` / `runtime.stream` 存在性检查。
527
+ - `CONTINUATION_MARK` 加入 named exports。
528
+ - 新增故障注入 + 集成测试(共 95 个)。
529
+ - 2026-10-30: **P0 真实部署验证 + 4 个架构级 Bug 修复**(headless 独立进程,不重启 web GUI)。
530
+
531
+ 验证过程中发现并修复了 4 个导致 loop 模式完全不可用的 P0 Bug:
532
+
533
+ 1. **`providerOk` 始终 false**:`ReactLoopAgent` 无顶层 `.provider`/`.route` 字段,
534
+ cordis 不传 provider-bearing meta,导致 provider 门控永远拒绝。
535
+ 修复:重写 `providerOk` 为三级判定——(1) 插件 meta.provider (2) agent.provider
536
+ (3) **fail-open**(无法确定时放行)。
537
+
538
+ 2. **Stream observer 读错字段**:原代码从 frame envelope 读 finish reason,
539
+ 实际 finish 信息在 `frame.chunk.reason.kind` 内(`AssistantStreamFrame` 的
540
+ `chunk` 字段)。修复:改为 `frame.type==='chunk' && frame.chunk.type==='finish'`
541
+ 时读 `frame.chunk.reason.kind`。
542
+
543
+ 3. **`agent/pre-step` 是死代码**:`pre-step` waterfall 在 step 开始前触发,
544
+ 而 max-tokens 检测发生在流结束时刻——此时 turn 已关闭,`pre-step` 不再触发。
545
+ 修复:删除 `pre-step` 监听器,改为在 stream observer 中调用
546
+ **`agent.steer(continuationMsg)`**。`steer()` 将消息追加到 `inbox.nextStep`
547
+ 并唤醒 driver,`turn()` 的循环在 `inbox.nextStep` 非空时保持 turn 打开,
548
+ 续写步立即执行。
549
+
550
+ 4. **`steer()` 序列化失败**:DSH session 层 `walkJsonValue` 使用
551
+ `Reflect.ownKeys` + `propertyIsEnumerable`——任何含非枚举 own property
552
+ 的对象被拒绝("non-JSON-serializable data")。
553
+ 修复:`CONTINUATION_MARK` 改为 **enumerable: true**;续写消息加
554
+ `id: randomUUID()`(`inbox.mutate()` 用 `message.id` 做去重检测)。
555
+
556
+ 验证结果:headless 截断测试 3/3 续写成功(512-token 截断确认),
557
+ 短问题不续写,95/95 单测通过。详见 `HANDOFF.md` §6。
558
+
559
+ - 2026-10-30: **v0.3.1 — Bug #5 修复 + Web 端到端验证**。
560
+
561
+ Web 端到端测试(port 3098 并行实例)发现插件在 web surface 完全不激活:
562
+ `cordis.patch.yml` 无 `config:` 块时 cordis 传 `undefined` 给 zod `Config` schema,
563
+ `z.object({...})` 无 object-level `.default()` 时拒绝 `undefined` 输入,
564
+ fiber 进入 INACTIVE,`apply()` 从未执行。
565
+
566
+ 修复:给 `Config` 的 `z.object` 加 object-level
567
+ `.default({providers, maxContinuations, timeoutMs, mode})`,
568
+ 使 `undefined` 输入解析为默认值。字段级 `.default()` 对 `undefined` 无效
569
+ (只填充对象输入中缺失的字段),必须用 object-level default。
570
+
571
+ 验证结果:
572
+ - Web 长文续写:4 条独立 assistant 消息(1 原始 + 3 续写),
573
+ 3 次 max-tokens → 3 次 `injecting continuation N/5 via steer()`,
574
+ 最终步 3227 tokens `stop` finish。
575
+ - Web 短问题不续写:1 条消息,26 tokens,`stop`,0 continuation。
576
+ - 95/95 单测通过。详见 `HANDOFF.md` §4 Bug #5 和 §6c。
577
+
578
+ - 2026-10-30: **发布准备**。新增 `PUBLISH-GUIDE.md`,记录 npm / GitHub / DSH 社区
579
+ 发布的完整流程和注意事项。
580
+ - 2026-11-01: **npm 发布**。包名改为 `@quekenao/dsh-auto-continue-community`,
581
+ 补 LICENSE(MIT)、`files` 白名单、`publishConfig.access=public`、
582
+ `prepublishOnly` 测试门禁。`dsh plugin add @quekenao/dsh-auto-continue-community` 即可安装。
583
+ - 2026-10-30: **v0.3.2 — 真实 GUI 会话验证 + finish 日志可观测性**。
584
+ - **真实 GUI 验证通过**:在 port 3097 主实例上用 Redis 10 主题 prompt 创建
585
+ 真实会话(`session-56e48e06-...`),解压 `.zstd` 事件日志逐事件核对:
586
+ step 1/2 `max-tokens`(各 8192 tokens)→ 注入续写 1/5、2/5;step 3 模型
587
+ 自愿 `stop`(4827 tokens)→ 按设计不续写。插件逻辑健壮,"2 轮后停止"
588
+ 是模型 false stop 行为,非插件 bug。
589
+ - **新增 finish 日志**(纯日志,零行为变更):非 `max-tokens` 的 step finish
590
+ 也记一行 `step ended <kind> (not max-tokens; chain ends, no continuation)`。
591
+ 此前 `stop` 步无任何日志,"为什么没续写"只能靠挖 `.zstd` 事件日志定位;
592
+ 现在 debug 日志(`DSH_AUTOCONTINUE_DEBUG=1`)可直接回答。
593
+ - 95/95 单测通过,`node --check` 通过。
@@ -0,0 +1,30 @@
1
+ # dsh-auto-continue-community: activates the community auto-continue plugin in
2
+ # the profile layer stack. Detects max-tokens truncation and transparently
3
+ # continues the model's output.
4
+ #
5
+ # Two modes (config `mode` field):
6
+ # loop (default) — realtime: the continuation is injected as a NEW agent
7
+ # step via the agent/pre-step waterfall. Tokens stream live to the
8
+ # UI; the UI shows two distinct assistant messages (original +
9
+ # continuation). This is the recommended mode.
10
+ # stream (fallback) — buffered: intercepts the llm/stream waterfall, buffers
11
+ # the whole upstream stream, detects max-tokens at stream-end, then
12
+ # re-invokes and merges. Tokens do NOT appear in real time. Use
13
+ # this if the loop mode misbehaves in your deployment.
14
+ #
15
+ # The provider gate, continuation budget, timeout and mode are configurable.
16
+ # See README.md. To target a provider other than the default "local-vision",
17
+ # extend the insert with a `config` block, e.g.:
18
+ #
19
+ # - insert:
20
+ # - id: auto-continue-community
21
+ # name: '@quekenao/dsh-auto-continue-community'
22
+ # config:
23
+ # providers: ['local-vision', 'llama-local']
24
+ # maxContinuations: 5
25
+ # timeoutMs: 120000
26
+ # mode: loop # 'loop' (default) or 'stream'
27
+ #
28
+ - insert:
29
+ - id: auto-continue-community
30
+ name: '@quekenao/dsh-auto-continue-community'