sage-mem 1.9.1 → 1.9.2

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/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  **Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
14
14
 
15
- **Current release: `1.9.1`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
15
+ **Current release: `1.9.2`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
16
16
  [frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
17
17
 
18
18
  If it is useful to you, a Star is the most direct feedback.
@@ -293,7 +293,7 @@ The guard has two paths, with **different** coverage, spelled out:
293
293
  | **The plugin itself** writing (autodream consolidation) | The caller passes the version it read at write time | A pending record `<state directory>/conflicts/<id>.json` is written, keeping **the version on disk** and **the version it wanted to write** — with only one side there is nothing to judge |
294
294
 
295
295
  - **Conflicts are not merged automatically**; which side to keep is your decision (the record is JSON, open it directly when you need to). Silent retry and silent abandonment both decide on your behalf.
296
- - **The injection side does not read this queue yet**: a memory with an unresolved conflict is still injected as body text. The `conflict_pending` suppression rule arrives with **1.10.0 conflict detection**, together with the panel entry that resolves conflicts — wiring the suppression without that entry would make a memory **permanently non-injectable**, which is worse than the current state. For now, open `conflicts/<id>.json` and set `status` to `resolved` or `dismissed` (or delete the file).
296
+ - **The injection side does not read this queue yet**: a memory with an unresolved conflict is still injected as body text. The `conflict_pending` suppression rule arrives with **2.0.0 conflict detection**, together with the panel entry that resolves conflicts — wiring the suppression without that entry would make a memory **permanently non-injectable**, which is worse than the current state. For now, open `conflicts/<id>.json` and set `status` to `resolved` or `dismissed` (or delete the file).
297
297
  - **The same conflict is not duplicated**: if a failure matches an unresolved record field for field (same file, both bodies, both versions), no extra copy is appended.
298
298
  - **The host file sandbox**: autodream runs **outside any session**, so it cannot inherit a session workspace. On a `workspace-write` host the plugin passes a per-call sandbox policy **narrowed to the memory directory** (narrower than the fallback root); on a `read-only` host it passes nothing, the write is still refused, and the run is recorded as a **failure** (not "success, nothing changed").
299
299
  - Known boundary: **editing memories in a shell does not go through this path**. The write gate guards "memory writes that go through the tools", not "all modifications to files"; redirection and the like inside `pwsh` are not recognized. If it cannot be sure, it does not claim it — so that "cannot see it" is not mistakenly recorded as fact.
@@ -344,6 +344,14 @@ Consolidation is a background batch job and need not be the same model as the on
344
344
  - The panel always shows **which one is actually in effect**; if the route named in the configuration happens to disappear from the catalog, the dropdown still shows it rather than quietly jumping back to the default.
345
345
  - When the model catalog cannot be obtained (host differences), it degrades to typing `provider` and `model` by hand.
346
346
 
347
+ ### When the Report Does Not Fit
348
+
349
+ The consolidation report is written by the model in one go, and a single output has a length cap. **The cap, the retry and the truncated label** all have defined behaviour:
350
+
351
+ - **The single-output cap is configurable**: Settings → Autodream → Settings → Advanced → "single output cap" (default 32768 tokens, range 1024–131072). For reasoning models the reasoning and the body **share** that budget; when the closing turn spends it all on reasoning, no body is left — raise the cap and run again.
352
+ - **Retry when the body is empty**: if an output hits the cap with **no body at all** (reasoning only), one retry asks for "the body directly, do not re-analyse"; up to 2 times, and only then is the report marked truncated. When there is a partial body, the loop asks it to continue from where it stopped (up to 3 rounds).
353
+ - **Truncation is stated**: the report's first screen, the consolidation manifest, the machine-readable record and the Settings → Autodream "last run" line all say "report truncated (fragment)" — a half report is never reported as "success". If the closing step produced no text, the report says "the model gave no textual summary" instead of reusing narration from an earlier step.
354
+
347
355
  ### Rollback Points
348
356
 
349
357
  **Whenever this run is a "direct rewrite", a rollback point is created before work starts** (a full snapshot of the top-level `.md` files in the memory directory, each with a sha256), and after the run you can see it in the settings page and roll back to it in one click.
package/README.zh.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  > File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
16
16
 
17
- **当前发布版本:`1.9.1`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`1.9.2`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
18
18
  [契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
19
19
 
20
20
  如果它对你有用,给个 Star 是最直接的反馈。
@@ -293,7 +293,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
293
293
  | **插件自己**写(自动做梦的整理) | 调用方在写入时带上读到的版本 | 落一条待处理记录 `<状态目录>/conflicts/<id>.json`,里面留着**盘上那一版**和**想要写的那一版** —— 只有一边等于没得判 |
294
294
 
295
295
  - **冲突不会自动合并**,保留哪边由你决定(记录是 JSON,需要时直接打开看)。静默重试和静默放弃都是在替你决定。
296
- - **注入侧目前不读这个队列**:有未裁决冲突的记忆照常作为正文注入;`conflict_pending` 这条抑制规则要等 **1.10.0 的矛盾检测**(连面板里的裁决入口一起)才生效 —— 只接抑制、不做入口,会让一条记忆**永久不再被注入**,比现在更糟。现阶段处理:打开 `conflicts/<id>.json`,把 `status` 改成 `resolved` 或 `dismissed`(或直接删掉文件)。
296
+ - **注入侧目前不读这个队列**:有未裁决冲突的记忆照常作为正文注入;`conflict_pending` 这条抑制规则要等 **2.0.0 的矛盾检测**(连面板里的裁决入口一起)才生效 —— 只接抑制、不做入口,会让一条记忆**永久不再被注入**,比现在更糟。现阶段处理:打开 `conflicts/<id>.json`,把 `status` 改成 `resolved` 或 `dismissed`(或直接删掉文件)。
297
297
  - **同一件事不会越积越多**:一次失败如果与队列里某条未裁决记录逐字段相同(同一文件、两份正文、两个版本),不再追加副本。
298
298
  - **宿主的文件沙箱**:自动做梦在**会话外**运行,拿不到会话工作区。宿主用 `workspace-write` 时,插件给每次写入带上**收窄到记忆目录**的逐次沙箱策略(比回退根更窄);宿主是 `read-only` 时不传策略、写入照旧被拒,且这一趟会被记成**失败**(不是「成功,没改什么」)。
299
299
  - 已知边界:**在 shell 里改记忆不走这条路**。写门守的是「经过工具的记忆写入」,不是「所有对文件的修改」;`pwsh` 里的重定向之类认不出来。认不准就不认,免得把「看不到」误记成事实。
@@ -344,6 +344,14 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
344
344
  - 面板下方始终显示**实际生效的是哪条**;配置里那条路线万一从目录里消失了,下拉框也会把它显示出来而不是悄悄跳回默认。
345
345
  - 拿不到模型目录时(宿主差异),退化成手填 `provider` 与 `model`。
346
346
 
347
+ ### 报告写不完的时候
348
+
349
+ 整理报告由模型一口气写完,而单次输出有长度上限。**上限、重试、残篇标注**三件事都有明确行为:
350
+
351
+ - **单次输出上限可配**:设置页「自动做梦 → 设置 → 高级设置 → 单次输出上限」(默认 32768 token,范围 1024–131072)。思考型模型的思考与正文**共用**这份额度;收尾那轮思考吃满时正文会一个字不剩 —— 这类报告先把上限调大再跑。
352
+ - **零正文先重试**:一轮额度用尽却**没有任何正文**(只剩思考)时,先要求它「直接输出正文、不要重新分析」重试;最多 2 次,仍为零才标残篇。撞上限但**有正文**的,按「从中断处继续写」续写(最多 3 轮)。
353
+ - **残篇会被指出来**:报告首屏、整理声明、机读记录与设置页「上次运行」那一行都会标「报告被截断(残篇)」—— 不会再把半篇报成「成功」。收尾那一步没写文字时,报告写「模型没有给出文字说明」,**不拿中途旁白顶替**。
354
+
347
355
  ### 回滚点
348
356
 
349
357
  **只要这次是「直接改写」,开工前就先建一个回滚点**(记忆目录顶层 `.md` 全量快照,每条带 sha256),跑完之后能在设置页看到它、也能一键退回去。
@@ -19,6 +19,16 @@ export const DEFAULT_CONFIG = {
19
19
  provider: '',
20
20
  model: '',
21
21
  maxSteps: 24,
22
+ /**
23
+ * 单次模型调用的输出上限(token)。
24
+ *
25
+ * ⚠️ 2026-10-26(1.9.2)从写死的 4096 提到 32768 并做成设置项。4096 对**思考型模型**
26
+ * 不够:思考与正文共用同一份额度,收尾那一轮里思考吃光额度、正文一个字都没剩下,
27
+ * 结果报告是残篇而面板报「成功」(2026-10-25 两趟手动整理连续复现)。
28
+ * 调大它不是治本(报告长度没有上界),治本是「零正文先重试、写不完才标残篇」;
29
+ * 做成设置项是为了让用户按自己模型的输出上限调,而不是每次为它改代码。
30
+ */
31
+ maxOutputTokens: 32768,
22
32
  maxSnapshotKeep: 5,
23
33
  /** 回滚按钮的默认范围:files = 只恢复该次运行动过的文件;all = 整目录恢复到该时点。 */
24
34
  rollbackScope: 'files',
@@ -316,6 +316,7 @@ export class AutodreamEngine {
316
316
  auditAfter,
317
317
  finalText: result.finalText,
318
318
  continuations: result.continuations,
319
+ retries: result.retries,
319
320
  truncated: result.truncated,
320
321
  archiveMode: policy.mode,
321
322
  archiveRequested: policy.requested,
@@ -344,6 +345,7 @@ export class AutodreamEngine {
344
345
  auditAfter,
345
346
  finalText: result.finalText,
346
347
  continuations: result.continuations,
348
+ retries: result.retries,
347
349
  truncated: result.truncated,
348
350
  transcript: result.transcript,
349
351
  tokensIn: result.tokensIn,
@@ -388,6 +390,7 @@ export class AutodreamEngine {
388
390
  tokensOut: result.tokensOut,
389
391
  // 残篇要能在界面上被认出来(面板读 lastResult):只报「成功」是不够的。
390
392
  continuations: result.continuations,
393
+ retries: result.retries,
391
394
  truncated: result.truncated,
392
395
  report: report.name,
393
396
  snapshotId: snapshot?.id ?? null,
@@ -403,6 +406,7 @@ export class AutodreamEngine {
403
406
  changeCount: result.changes.length,
404
407
  snapshotId: snapshot?.id ?? null,
405
408
  continuations: result.continuations,
409
+ retries: result.retries,
406
410
  truncated: result.truncated,
407
411
  auditBefore,
408
412
  auditAfter,
@@ -151,18 +151,42 @@ export async function streamOnce(llm, req) {
151
151
  }
152
152
 
153
153
  /**
154
- * 一次模型调用的输出上限。
154
+ * 一次模型调用的输出上限(配置缺字段 / 坏值时的回落值)。
155
155
  *
156
156
  * ⚠️ **调大它解决不了「长报告被截断」**:报告长度没有上界,调大只是把墙往后挪一格
157
157
  * (2026-10-17 实测:10-01 的报告 5532 字符、10-13 是 5957,都完整收尾;这次 8553 字符
158
158
  * 就断在半个句子里)。真正的处理是撞上它时**接着写** —— 见下面的 MAX_CONTINUATIONS。
159
+ *
160
+ * 但 4096 对**思考型模型**连「把收尾那一轮写完」都不够:思考与正文共用额度,
161
+ * 2026-10-25 两趟手动整理都在最后一步思考吃光额度、正文零字(1.9.2 改成可配置,默认 32768)。
159
162
  */
160
- const MAX_TOKENS_PER_CALL = 4096
163
+ const FALLBACK_MAX_TOKENS = 32768
164
+
165
+ /** 配置里的单次输出上限。缺字段(老配置 / 测试桩)或坏值都回落到常量。 */
166
+ export function resolveMaxTokens(cfg) {
167
+ const n = Number(cfg?.maxOutputTokens)
168
+ return Number.isFinite(n) && n >= 1024 ? Math.round(n) : FALLBACK_MAX_TOKENS
169
+ }
170
+
161
171
  /** 撞上单次上限后最多续写几轮:够把最后一段写完,又不至于让一次跑飞掉。 */
162
172
  const MAX_CONTINUATIONS = 3
173
+ /**
174
+ * 「零正文截断」最多重试几次(1.9.2)。
175
+ *
176
+ * 零正文不是「这段写完了」,而是**思考把额度吃光了**:直接标残篇会让一次本来能写完的报告
177
+ * 白丢。先重试一次「直接输出正文、不要重新分析」;仍为零才标残篇。
178
+ * 上限 2:思考型模型可能连续两轮都只顾着思考,再多就是拿 token 赌运气。
179
+ */
180
+ const MAX_EMPTY_RETRIES = 2
163
181
  /** 续写指令。三条禁令缺一不可 —— 重写整篇 / 重新开头 / 道歉寒暄,模型都干得出来。 */
164
182
  const CONTINUE_PROMPT =
165
183
  '(上一条回答被输出长度上限截断了。请**从中断处继续写**,不要重复已经写过的内容,不要重新开头,不要解释或道歉,直接接着写。)'
184
+ /**
185
+ * 零正文重试指令。与 CONTINUE_PROMPT 的区别:**没有「已写内容」可续**,
186
+ * 上一轮只有思考。所以这里要压住「再分析一遍」的冲动,只要正文。
187
+ */
188
+ const EMPTY_RETRY_PROMPT =
189
+ '(上一条回答把输出额度全用在思考上了,最终没有任何正文。请**直接输出正文**:不要再重新分析、不要重复思考过程、不要解释,直接给出这一轮要交付的内容。)'
166
190
 
167
191
  /** 取这一次调用里的全部文字块,拼成一段。 */
168
192
  function textOf(asm) {
@@ -206,8 +230,12 @@ export async function agentLoop(cfg, runOpts) {
206
230
  // 截断与续写的账:`continuations` 是「确实接着写过几轮」,`truncated` 是「续写用尽还是没写完」。
207
231
  // 两个都要进声明与报告 —— 「报告是残篇却报成功」正是这一版要修的那处静默。
208
232
  let continuations = 0
233
+ /** 零正文重试的账(1.9.2):与「续写」分开记 —— 一个是接着写,一个是根本没写出来。 */
234
+ let retries = 0
209
235
  let truncated = false
210
236
  let lastStep = 0
237
+ // 每次调用都从这里取上限:配置改了不用重启(下一趟就用新值),老配置缺字段也能跑。
238
+ const maxTokensPerCall = resolveMaxTokens(cfg)
211
239
  const allToolsUsed = []
212
240
  const addUsage = (u) => {
213
241
  if (!u) return
@@ -228,7 +256,7 @@ export async function agentLoop(cfg, runOpts) {
228
256
  system,
229
257
  messages,
230
258
  tools: toolSchemas,
231
- maxTokens: MAX_TOKENS_PER_CALL,
259
+ maxTokens: maxTokensPerCall,
232
260
  signal,
233
261
  })
234
262
  addUsage(asm.usage)
@@ -237,30 +265,64 @@ export async function agentLoop(cfg, runOpts) {
237
265
  let text = ''
238
266
  let calls = []
239
267
  let segment = 0
268
+ /** 下一段是不是「零正文重试」来的 —— 只影响过程清单上的标记。 */
269
+ let retryNext = false
240
270
  for (;;) {
271
+ const isRetry = retryNext
272
+ retryNext = false
241
273
  const segText = textOf(asm)
242
274
  const blocks = asm.blocks()
243
275
  const segCalls = blocks.filter((b) => b.type === 'tool-call')
244
276
  // 只有第一段可能带工具调用:被截断时 BlockAssembler 已经丢掉了参数残缺的 tool-call。
245
277
  if (segment === 0) calls = segCalls
246
278
  text += segText
279
+ const segMark = segment === 0 ? {} : isRetry ? { retry: true } : { continuation: true }
247
280
  transcript.push({
248
281
  step,
249
282
  text: segText,
250
283
  tools: segCalls.map((c) => c.name),
251
- ...(segment ? { continuation: true } : {}),
284
+ ...segMark,
252
285
  })
253
286
  this.stepLog.push({
254
287
  step,
255
288
  tools: segCalls.map((c) => c.name),
256
289
  chars: segText.length,
257
290
  at: Date.now(),
258
- ...(segment ? { continuation: true } : {}),
291
+ ...segMark,
259
292
  })
260
293
  segment += 1
261
294
  if (asm.finish.kind !== 'max-tokens') break
262
- // 空段没法接着写;续写名额用尽也一样 —— 两种都算「这篇没写完」,要如实记下来。
263
- if (!segText || continuations >= MAX_CONTINUATIONS) {
295
+ /**
296
+ * 零正文的截断:不是「这段写完了」,而是**思考把额度吃光了**(思考型模型常见)。
297
+ * 1.9.2 起先重试「直接输出正文」;重试用尽仍无正文才标残篇。
298
+ * 旧行为(1.0.1)是直接标残篇 —— 那会让一次本来能写完的报告白丢一段收尾。
299
+ */
300
+ if (!segText) {
301
+ if (retries >= MAX_EMPTY_RETRIES) {
302
+ truncated = true
303
+ break
304
+ }
305
+ retries += 1
306
+ // 把这一轮原样回灌(通常只有 reasoning 块):让模型看到它上一轮只思考、没交付。
307
+ // 完全空的 blocks 不回灌 —— 空的 assistant 消息不是合法输入,宁可不给。
308
+ if (blocks.length) messages.push(assistantMessage(blocks, provider, model))
309
+ messages.push(userMessage(EMPTY_RETRY_PROMPT))
310
+ retryNext = true
311
+ this.phase = `第 ${step} 轮:零正文被截断,重试直接输出正文 ${retries}/${MAX_EMPTY_RETRIES}`
312
+ asm = await this.streamOnce(llm, {
313
+ provider,
314
+ model,
315
+ system,
316
+ messages,
317
+ tools: toolSchemas,
318
+ maxTokens: maxTokensPerCall,
319
+ signal,
320
+ })
321
+ addUsage(asm.usage)
322
+ continue
323
+ }
324
+ // 有正文但被截断:接着写。名额用尽就如实标残篇。
325
+ if (continuations >= MAX_CONTINUATIONS) {
264
326
  truncated = true
265
327
  break
266
328
  }
@@ -274,13 +336,15 @@ export async function agentLoop(cfg, runOpts) {
274
336
  system,
275
337
  messages,
276
338
  tools: toolSchemas,
277
- maxTokens: MAX_TOKENS_PER_CALL,
339
+ maxTokens: maxTokensPerCall,
278
340
  signal,
279
341
  })
280
342
  addUsage(asm.usage)
281
343
  }
282
344
 
283
- if (text) finalText = text
345
+ // ④ **每一步都覆盖**(不是「有字才覆盖」):最后一步零正文时,收尾说明必须留空,
346
+ // 不能把中途某轮的旁白当成「模型的收尾说明」写进报告(2026-10-25 残篇报告的伴随症状)。
347
+ finalText = text
284
348
  if (asm.finish.kind === 'error') {
285
349
  throw new Error(`模型返回错误:${asm.finish.failure?.message ?? '未知'}`)
286
350
  }
@@ -308,9 +372,10 @@ export async function agentLoop(cfg, runOpts) {
308
372
 
309
373
  if (truncated) {
310
374
  // 过程清单要以「没写完」收尾:否则报告里那半句话看起来只是模型话说到一半。
375
+ const retryNote = retries ? `(其中零正文重试 ${retries} 次)` : ''
311
376
  transcript.push({
312
377
  step: lastStep,
313
- text: `(输出撞上单次长度上限,续写 ${continuations} 轮后仍未写完 —— 上面这段是残篇,后文缺失)`,
378
+ text: `(输出撞上单次长度上限,续写 ${continuations} 轮${retryNote}后仍未写完 —— 上面这段是残篇,后文缺失)`,
314
379
  tools: [],
315
380
  })
316
381
  }
@@ -321,6 +386,8 @@ export async function agentLoop(cfg, runOpts) {
321
386
  tokensIn,
322
387
  tokensOut,
323
388
  continuations,
389
+ /** 零正文重试次数(1.9.2):进报告与机读记录,让「重试过」和「一次写成」分得开。 */
390
+ retries,
324
391
  truncated,
325
392
  toolsUsed: allToolsUsed,
326
393
  touched: runner.touchedFiles(),
@@ -57,6 +57,8 @@ export async function writeDeclaration(payload) {
57
57
  finalText: payload.finalText ?? '',
58
58
  // 1.0.1:输出撞上单次上限这件事要进机读记录 —— 残篇在界面上和「话说一半」长得一模一样。
59
59
  continuations: Number(payload.continuations) || 0,
60
+ // 1.9.2:零正文重试是另一本账 —— 「接着写过」与「根本没写出来」不能混成一个数字。
61
+ retries: Number(payload.retries) || 0,
60
62
  truncated: payload.truncated === true,
61
63
  rolledBackAt: 0,
62
64
  rolledBackAtHuman: '',
@@ -79,10 +81,14 @@ export function renderDeclaration(m, headingLevel = 1) {
79
81
  : '- 回滚点:未做(只出报告模式不写盘,无需回滚)',
80
82
  `- 审计:结构问题 ${m.audit.before.problems} → ${m.audit.after.problems}`,
81
83
  ...(m.truncated
82
- ? [`- ⚠️ 输出被单次长度上限截断:续写 ${m.continuations ?? 0} 轮后仍未写完,收尾说明是**残篇**`]
83
- : m.continuations
84
- ? [`- 输出曾撞上单次长度上限,已自动续写 ${m.continuations} 轮补齐`]
85
- : []),
84
+ ? [
85
+ `- ⚠️ 输出被单次长度上限截断:续写 ${m.continuations ?? 0} 轮${m.retries ? `、零正文重试 ${m.retries} 次` : ''}后仍未写完,收尾说明是**残篇**`,
86
+ ]
87
+ : m.retries
88
+ ? [`- 输出曾出现零正文截断(思考吃满额度),重试 ${m.retries} 次后补齐`]
89
+ : m.continuations
90
+ ? [`- 输出曾撞上单次长度上限,已自动续写 ${m.continuations} 轮补齐`]
91
+ : []),
86
92
  '',
87
93
  `${H}# 改了什么、为什么`,
88
94
  '',
@@ -213,12 +219,15 @@ export async function writeReport(payload) {
213
219
  */
214
220
  const truncLines = payload.truncated
215
221
  ? [
216
- `- ⚠️ **本次输出被单次长度上限截断**:自动续写 ${payload.continuations ?? 0} 轮后仍未写完 —— ` +
222
+ `- ⚠️ **本次输出被单次长度上限截断**:自动续写 ${payload.continuations ?? 0} 轮` +
223
+ `${payload.retries ? `、零正文重试 ${payload.retries} 次` : ''}后仍未写完 —— ` +
217
224
  '下面「模型的收尾说明」是**残篇**,后文缺失',
218
225
  ]
219
- : payload.continuations
220
- ? [`- 输出曾撞上单次长度上限,已自动续写 ${payload.continuations} 轮补齐(报告完整)`]
221
- : []
226
+ : payload.retries
227
+ ? [`- 输出曾出现零正文截断(思考吃满额度),重试 ${payload.retries} 次后补齐(报告完整)`]
228
+ : payload.continuations
229
+ ? [`- 输出曾撞上单次长度上限,已自动续写 ${payload.continuations} 轮补齐(报告完整)`]
230
+ : []
222
231
 
223
232
  const lines = [
224
233
  `# Autodream 报告 · ${stampHuman(payload.startedAt)}`,
@@ -257,8 +266,9 @@ export async function writeReport(payload) {
257
266
  '## 过程',
258
267
  '',
259
268
  ...payload.transcript.map((t) => {
260
- // 「续写」必须标出来:否则同一轮出现好几条,读起来像模型重复劳动。
261
- const mark = t.continuation ? '(截断续写)' : ''
269
+ // 「续写」与「零正文重试」都必须标出来:否则同一轮出现好几条,读起来像模型重复劳动。
270
+ // 两者分开标 —— 一个是「接着写」,一个是「上一段根本没写出来」,读者要看得出区别。
271
+ const mark = t.retry ? '(零正文重试)' : t.continuation ? '(截断续写)' : ''
262
272
  const kind = t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'
263
273
  // ⚠️ **先压平空白再截断**。模型这一步的文字里常带换行与 `## 标题`,直接 slice(0,120)
264
274
  // 会把标题漏成真标题 —— 报告读起来像「内容重复了一遍」(2026-10-17 10:42 那次真踩到:
@@ -99,6 +99,12 @@ export async function setConfig(patch) {
99
99
  if (p.minHours !== undefined) c.minHours = clampNum(Number(p.minHours), 1, 720, DEFAULT_CONFIG.minHours)
100
100
  if (p.minSessions !== undefined) c.minSessions = Math.round(clampNum(Number(p.minSessions), 1, 200, DEFAULT_CONFIG.minSessions))
101
101
  if (p.maxSteps !== undefined) c.maxSteps = Math.round(clampNum(Number(p.maxSteps), 1, 80, DEFAULT_CONFIG.maxSteps))
102
+ /**
103
+ * 单次输出上限(1.9.2)。范围 1024–131072:下限够小、上限够到大多数模型的硬顶;
104
+ * 越界按统一口径**夹到合法范围**,不写一份坏配置。
105
+ */
106
+ if (p.maxOutputTokens !== undefined)
107
+ c.maxOutputTokens = Math.round(clampNum(Number(p.maxOutputTokens), 1024, 131072, DEFAULT_CONFIG.maxOutputTokens))
102
108
  if (p.maxSnapshotKeep !== undefined)
103
109
  c.maxSnapshotKeep = Math.round(clampNum(Number(p.maxSnapshotKeep), 1, 50, DEFAULT_CONFIG.maxSnapshotKeep))
104
110
  if (typeof p.provider === 'string') c.provider = p.provider.trim().slice(0, 120)
package/lib/client.js CHANGED
@@ -3954,6 +3954,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3954
3954
  " · 结构问题 " + last.problemsBefore + " → " + last.problemsAfter +
3955
3955
  " · token " + last.tokensIn + " / " + last.tokensOut)
3956
3956
  : ("失败:" + (last.error || "未知"))) +
3957
+ // 1.9.2:残篇必须在状态行上就能看见 —— 只写「成功」正是 10-25 那次误判的来源。
3958
+ (last.ok && last.truncated ? " · ⚠ 报告被截断(残篇)" : "") +
3959
+ (last.ok && !last.truncated && last.retries ? (" · 零正文重试 " + last.retries + " 次后补齐") : "") +
3960
+ (last.continuations ? (" · 续写 " + last.continuations + " 轮") : "") +
3957
3961
  (last.report ? " · 报告 " + last.report : ""))
3958
3962
  : null,
3959
3963
  ]),
@@ -4068,6 +4072,12 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4068
4072
  h("input", { className: "smem-num", key: "v", type: "number", min: "1", max: "80", value: c.maxSteps, onChange: function (e) { patch({ maxSteps: Number(e.target.value) }); } }),
4069
4073
  h("span", { className: "smem-status", key: "s" }, "一轮 = 一次模型调用 + 它要的工具调用"),
4070
4074
  ]),
4075
+ // 1.9.2:单次输出上限(原写死 4096)。思考型模型下 4096 会让收尾轮只剩思考、零正文。
4076
+ h("div", { className: "smem-autodream-row", key: "maxout" }, [
4077
+ h("span", { className: "smem-autodream-key", key: "k" }, "单次输出上限"),
4078
+ h("input", { className: "smem-num", key: "v", type: "number", min: "1024", max: "131072", value: c.maxOutputTokens, onChange: function (e) { patch({ maxOutputTokens: Number(e.target.value) }); } }),
4079
+ h("span", { className: "smem-status", key: "s" }, "token(思考与正文共用;报告残篇时先调它)"),
4080
+ ]),
4071
4081
  h("div", { className: "smem-autodream-row", key: "keep" }, [
4072
4082
  h("span", { className: "smem-autodream-key", key: "k" }, "保留快照"),
4073
4083
  h("input", { className: "smem-num", key: "v", type: "number", min: "1", max: "50", value: c.maxSnapshotKeep, onChange: function (e) { patch({ maxSnapshotKeep: Number(e.target.value) }); } }),
@@ -307,6 +307,8 @@ const autodreamConfigSchema = z.object({
307
307
  'provider': z.string().readonly(),
308
308
  'model': z.string().readonly(),
309
309
  'maxSteps': z.number().readonly(),
310
+ // 1.9.2:单次输出上限(token)。响应与 patch 两侧都必须声明 —— strict codec 会剥掉未声明字段。
311
+ 'maxOutputTokens': z.number().readonly(),
310
312
  'maxSnapshotKeep': z.number().readonly(),
311
313
  'rollbackScope': z.string().readonly(),
312
314
  /**
@@ -414,6 +416,7 @@ const autodreamPatchSchema = z.object({
414
416
  'provider': z.string().optional(),
415
417
  'model': z.string().optional(),
416
418
  'maxSteps': z.number().optional(),
419
+ 'maxOutputTokens': z.number().optional(),
417
420
  'maxSnapshotKeep': z.number().optional(),
418
421
  'rollbackScope': z.string().optional(),
419
422
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "1.9.1",
4
+ "version": "1.9.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",