sage-mem 1.9.0 → 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 +13 -2
- package/README.zh.md +13 -2
- package/lib/autodream/audit.js +10 -1
- package/lib/autodream/config.js +10 -0
- package/lib/autodream/engine.js +48 -2
- package/lib/autodream/llm-loop.js +77 -10
- package/lib/autodream/report.js +20 -10
- package/lib/autodream/settings.js +6 -0
- package/lib/autodream/tools.js +129 -5
- package/lib/client.js +10 -0
- package/lib/typert.host.js +3 -0
- package/package.json +1 -1
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.
|
|
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,6 +293,9 @@ 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 **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
|
+
- **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
|
+
- **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").
|
|
296
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.
|
|
297
300
|
|
|
298
301
|
### Access Ledger: Three Kinds of "Used" Recorded Separately
|
|
@@ -341,6 +344,14 @@ Consolidation is a background batch job and need not be the same model as the on
|
|
|
341
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.
|
|
342
345
|
- When the model catalog cannot be obtained (host differences), it degrades to typing `provider` and `model` by hand.
|
|
343
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
|
+
|
|
344
355
|
### Rollback Points
|
|
345
356
|
|
|
346
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.
|
|
@@ -364,7 +375,7 @@ It records item by item: **file / operation (create · rewrite · archive) / rea
|
|
|
364
375
|
|
|
365
376
|
1. **The safety boundary is in the tool set, not in the prompt** — during autodream the host gives the model only read-only tools, and only rewrite mode adds the rewriting tools; and **there is no "delete" capability at all** — archiving moves a file into `archive/`, recoverable by hand.
|
|
366
377
|
2. **A rollback point is created automatically before rewriting**, under `<parent of the memory directory>/.sage-mem/autodream/snapshots/<runId>/`, keeping the most recent N. The reason: the memory directory is **not under any version control** and deletion has no recycle bin, so "rollback-ability" has to be built by hand.
|
|
367
|
-
3. **An audit runs automatically at the end** — dangling index entries, missing index entries, `type` disagreeing with the file name prefix, CRLF, broken wikilinks, "space + #" swallowed as a YAML comment inside frontmatter, missing frontmatter, temporary files left behind by an interrupted archive, plus "unreadable" as a category of its own, and the result and the before/after comparison are written into the report. It **only reports, it does not change**.
|
|
378
|
+
3. **An audit runs automatically at the end** — dangling index entries, missing index entries, `type` disagreeing with the file name prefix, CRLF, broken wikilinks, "space + #" swallowed as a YAML comment inside frontmatter, missing frontmatter, temporary files left behind by an interrupted archive, plus "unreadable" as a category of its own, and the result and the before/after comparison are written into the report. It **only reports, it does not change**. The index `MEMORY.md` is a list, not a memory, so it takes part in **neither** the "type vs prefix" check nor the "missing frontmatter" check (since 1.9.1).
|
|
368
379
|
|
|
369
380
|
### Rename Note
|
|
370
381
|
|
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.
|
|
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,6 +293,9 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
|
|
|
293
293
|
| **插件自己**写(自动做梦的整理) | 调用方在写入时带上读到的版本 | 落一条待处理记录 `<状态目录>/conflicts/<id>.json`,里面留着**盘上那一版**和**想要写的那一版** —— 只有一边等于没得判 |
|
|
294
294
|
|
|
295
295
|
- **冲突不会自动合并**,保留哪边由你决定(记录是 JSON,需要时直接打开看)。静默重试和静默放弃都是在替你决定。
|
|
296
|
+
- **注入侧目前不读这个队列**:有未裁决冲突的记忆照常作为正文注入;`conflict_pending` 这条抑制规则要等 **2.0.0 的矛盾检测**(连面板里的裁决入口一起)才生效 —— 只接抑制、不做入口,会让一条记忆**永久不再被注入**,比现在更糟。现阶段处理:打开 `conflicts/<id>.json`,把 `status` 改成 `resolved` 或 `dismissed`(或直接删掉文件)。
|
|
297
|
+
- **同一件事不会越积越多**:一次失败如果与队列里某条未裁决记录逐字段相同(同一文件、两份正文、两个版本),不再追加副本。
|
|
298
|
+
- **宿主的文件沙箱**:自动做梦在**会话外**运行,拿不到会话工作区。宿主用 `workspace-write` 时,插件给每次写入带上**收窄到记忆目录**的逐次沙箱策略(比回退根更窄);宿主是 `read-only` 时不传策略、写入照旧被拒,且这一趟会被记成**失败**(不是「成功,没改什么」)。
|
|
296
299
|
- 已知边界:**在 shell 里改记忆不走这条路**。写门守的是「经过工具的记忆写入」,不是「所有对文件的修改」;`pwsh` 里的重定向之类认不出来。认不准就不认,免得把「看不到」误记成事实。
|
|
297
300
|
|
|
298
301
|
### 访问台账:三种「用过」分开记
|
|
@@ -341,6 +344,14 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
|
|
|
341
344
|
- 面板下方始终显示**实际生效的是哪条**;配置里那条路线万一从目录里消失了,下拉框也会把它显示出来而不是悄悄跳回默认。
|
|
342
345
|
- 拿不到模型目录时(宿主差异),退化成手填 `provider` 与 `model`。
|
|
343
346
|
|
|
347
|
+
### 报告写不完的时候
|
|
348
|
+
|
|
349
|
+
整理报告由模型一口气写完,而单次输出有长度上限。**上限、重试、残篇标注**三件事都有明确行为:
|
|
350
|
+
|
|
351
|
+
- **单次输出上限可配**:设置页「自动做梦 → 设置 → 高级设置 → 单次输出上限」(默认 32768 token,范围 1024–131072)。思考型模型的思考与正文**共用**这份额度;收尾那轮思考吃满时正文会一个字不剩 —— 这类报告先把上限调大再跑。
|
|
352
|
+
- **零正文先重试**:一轮额度用尽却**没有任何正文**(只剩思考)时,先要求它「直接输出正文、不要重新分析」重试;最多 2 次,仍为零才标残篇。撞上限但**有正文**的,按「从中断处继续写」续写(最多 3 轮)。
|
|
353
|
+
- **残篇会被指出来**:报告首屏、整理声明、机读记录与设置页「上次运行」那一行都会标「报告被截断(残篇)」—— 不会再把半篇报成「成功」。收尾那一步没写文字时,报告写「模型没有给出文字说明」,**不拿中途旁白顶替**。
|
|
354
|
+
|
|
344
355
|
### 回滚点
|
|
345
356
|
|
|
346
357
|
**只要这次是「直接改写」,开工前就先建一个回滚点**(记忆目录顶层 `.md` 全量快照,每条带 sha256),跑完之后能在设置页看到它、也能一键退回去。
|
|
@@ -364,7 +375,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
|
|
|
364
375
|
|
|
365
376
|
1. **安全边界在工具集上,不在提示词里** —— 自动做梦时宿主只把只读工具交给模型,改写模式才多给改写工具;而且**没有「删除」这个能力**,归档是把文件移进 `archive/`,可以人工捞回。
|
|
366
377
|
2. **改写前自动建回滚点**,落在 `<记忆目录的上一层>/.sage-mem/autodream/snapshots/<runId>/`,保留最近 N 份。理由是记忆目录**不在任何版本控制下**、删除没有回收站,「可回滚」得自己造。
|
|
367
|
-
3. **跑完自动审计** —— 索引悬空、漏索引、`type` 与文件名前缀不一致、CRLF、双链断链、frontmatter 里被 YAML 当注释吞掉的「空格 + #」、无 frontmatter
|
|
378
|
+
3. **跑完自动审计** —— 索引悬空、漏索引、`type` 与文件名前缀不一致、CRLF、双链断链、frontmatter 里被 YAML 当注释吞掉的「空格 + #」、无 frontmatter、归档中途残留的临时文件,外加「读不动」单列一类,结果与前后对比写进报告。它**只报不改**。索引 `MEMORY.md` 是清单而不是一条记忆,因此**不参与**「type 与前缀」和「无 frontmatter」这两项(1.9.1 起)。
|
|
368
379
|
|
|
369
380
|
### 改名说明
|
|
370
381
|
|
package/lib/autodream/audit.js
CHANGED
|
@@ -255,7 +255,16 @@ export async function auditMemoryDir(dir) {
|
|
|
255
255
|
const meta = parseFrontmatterLite(raw)
|
|
256
256
|
const prefix = f.split('_')[0]
|
|
257
257
|
const effective = meta.type || 'reference'
|
|
258
|
-
|
|
258
|
+
/**
|
|
259
|
+
* ⚠️ **索引同样豁免「type 与前缀」这条**,与上面 `f !== INDEX_FILE` 是同一个理由。
|
|
260
|
+
*
|
|
261
|
+
* `MEMORY.md` 没有下划线,`f.split('_')[0]` 得到的就是整个 `MEMORY.md`;而历史版本的
|
|
262
|
+
* `write_memory` 硬要求内容以 frontmatter 开头,模型只好给索引补一个 `type: reference`
|
|
263
|
+
* —— 于是**每一个健康的记忆库都常驻一条**「type=reference 前缀=MEMORY.md」,把
|
|
264
|
+
* `problems` 这个唯一健康数字变成恒大于 0 的噪音(外部报告 2026-10-25 抓到)。
|
|
265
|
+
* 索引不是一条记忆,它没有「前缀」可言,这条检查对它不成立。
|
|
266
|
+
*/
|
|
267
|
+
if (f !== INDEX_FILE && effective !== prefix) {
|
|
259
268
|
typeMismatch.push({ file: f, type: meta.type || '(缺失)', prefix, effective })
|
|
260
269
|
}
|
|
261
270
|
for (const line of fmMatch[1].split(/\r?\n/)) {
|
package/lib/autodream/config.js
CHANGED
|
@@ -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',
|
package/lib/autodream/engine.js
CHANGED
|
@@ -49,6 +49,31 @@ import * as rollbackMod from './rollback.js'
|
|
|
49
49
|
import * as settingsMod from './settings.js'
|
|
50
50
|
import * as snapshotMod from './snapshot.js'
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 一趟整理的结果该怎么定性(纯函数,`run()` 与单测共用这一份判据)。
|
|
54
|
+
*
|
|
55
|
+
* 单纯「有写入被沙箱拒绝」不是失败 —— 其余文件可能照样改成功了,那时该算成功,
|
|
56
|
+
* 被拒的几次由整理声明里的 warnings 交代。但**一次改动都没有、却有拒绝**就是失败:
|
|
57
|
+
* 那是空转,报成成功会让用户以为「整理过了,没需要改的」。
|
|
58
|
+
*
|
|
59
|
+
* 为什么抽出来:`run()` 那条路径要 llm 与真实磁盘才跑得动,判据本身却只是三行逻辑;
|
|
60
|
+
* 不抽出来的话,这条最要紧的修复只能靠源码级断言看着。
|
|
61
|
+
*
|
|
62
|
+
* @param {{apply?:boolean, changeCount?:number, denials?:Array<{message?:string}>}} input
|
|
63
|
+
* @returns {{allDenied:boolean, error:string}}
|
|
64
|
+
*/
|
|
65
|
+
export function judgeWriteOutcome(input) {
|
|
66
|
+
const i = input && typeof input === 'object' ? input : {}
|
|
67
|
+
const denials = Array.isArray(i.denials) ? i.denials : []
|
|
68
|
+
const allDenied = i.apply === true && Number(i.changeCount) === 0 && denials.length > 0
|
|
69
|
+
return {
|
|
70
|
+
allDenied,
|
|
71
|
+
error: allDenied
|
|
72
|
+
? `本次 ${denials.length} 次写入全部被宿主文件沙箱拒绝,没有改动任何文件:${denials[0]?.message ?? ''}`
|
|
73
|
+
: '',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
export class AutodreamEngine {
|
|
53
78
|
/**
|
|
54
79
|
* @param {object} ctx — 插件上下文(Cordis)
|
|
@@ -291,6 +316,7 @@ export class AutodreamEngine {
|
|
|
291
316
|
auditAfter,
|
|
292
317
|
finalText: result.finalText,
|
|
293
318
|
continuations: result.continuations,
|
|
319
|
+
retries: result.retries,
|
|
294
320
|
truncated: result.truncated,
|
|
295
321
|
archiveMode: policy.mode,
|
|
296
322
|
archiveRequested: policy.requested,
|
|
@@ -319,6 +345,7 @@ export class AutodreamEngine {
|
|
|
319
345
|
auditAfter,
|
|
320
346
|
finalText: result.finalText,
|
|
321
347
|
continuations: result.continuations,
|
|
348
|
+
retries: result.retries,
|
|
322
349
|
truncated: result.truncated,
|
|
323
350
|
transcript: result.transcript,
|
|
324
351
|
tokensIn: result.tokensIn,
|
|
@@ -331,12 +358,28 @@ export class AutodreamEngine {
|
|
|
331
358
|
archiveDegraded: policy.degraded,
|
|
332
359
|
})
|
|
333
360
|
|
|
361
|
+
/**
|
|
362
|
+
* 「有写入被沙箱拒绝」不等于「这一趟失败」,但**「所有写入都被拒绝、零改动」就是失败**。
|
|
363
|
+
*
|
|
364
|
+
* 原先这条路无条件写 `ok: true`:报告上写「成功,没改什么」,用户看到的是
|
|
365
|
+
* 一次空转被当成一次成功的整理 —— 外部报告 2026-10-25(issue #6)点的正是这里。
|
|
366
|
+
* 有部分改动时仍算成功:改动确实落地了,被拒的几次由整理声明的 warnings 交代。
|
|
367
|
+
*/
|
|
368
|
+
const judged = judgeWriteOutcome({
|
|
369
|
+
apply: !!cfg.apply,
|
|
370
|
+
changeCount: result.changes.length,
|
|
371
|
+
denials: typeof runner?.denials === 'function' ? runner.denials() : [],
|
|
372
|
+
})
|
|
373
|
+
const allWritesDenied = judged.allDenied
|
|
374
|
+
const deniedError = judged.error
|
|
375
|
+
|
|
334
376
|
this.state.lastRunAt = Date.now()
|
|
335
377
|
this.state.retryAfter = 0
|
|
336
378
|
this.state.lastResult = {
|
|
337
379
|
at: this.state.lastRunAt,
|
|
338
380
|
atHuman: stampHuman(this.state.lastRunAt),
|
|
339
|
-
ok:
|
|
381
|
+
ok: !allWritesDenied,
|
|
382
|
+
...(allWritesDenied ? { error: deniedError } : {}),
|
|
340
383
|
runId,
|
|
341
384
|
apply: !!cfg.apply,
|
|
342
385
|
touched: result.touched,
|
|
@@ -347,6 +390,7 @@ export class AutodreamEngine {
|
|
|
347
390
|
tokensOut: result.tokensOut,
|
|
348
391
|
// 残篇要能在界面上被认出来(面板读 lastResult):只报「成功」是不够的。
|
|
349
392
|
continuations: result.continuations,
|
|
393
|
+
retries: result.retries,
|
|
350
394
|
truncated: result.truncated,
|
|
351
395
|
report: report.name,
|
|
352
396
|
snapshotId: snapshot?.id ?? null,
|
|
@@ -354,13 +398,15 @@ export class AutodreamEngine {
|
|
|
354
398
|
await this.persist()
|
|
355
399
|
this.phase = ''
|
|
356
400
|
return {
|
|
357
|
-
ok:
|
|
401
|
+
ok: !allWritesDenied,
|
|
402
|
+
...(allWritesDenied ? { error: deniedError } : {}),
|
|
358
403
|
runId,
|
|
359
404
|
report: report.name,
|
|
360
405
|
touched: result.touched,
|
|
361
406
|
changeCount: result.changes.length,
|
|
362
407
|
snapshotId: snapshot?.id ?? null,
|
|
363
408
|
continuations: result.continuations,
|
|
409
|
+
retries: result.retries,
|
|
364
410
|
truncated: result.truncated,
|
|
365
411
|
auditBefore,
|
|
366
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
|
|
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:
|
|
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
|
-
...
|
|
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
|
-
...
|
|
291
|
+
...segMark,
|
|
259
292
|
})
|
|
260
293
|
segment += 1
|
|
261
294
|
if (asm.finish.kind !== 'max-tokens') break
|
|
262
|
-
|
|
263
|
-
|
|
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:
|
|
339
|
+
maxTokens: maxTokensPerCall,
|
|
278
340
|
signal,
|
|
279
341
|
})
|
|
280
342
|
addUsage(asm.usage)
|
|
281
343
|
}
|
|
282
344
|
|
|
283
|
-
|
|
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(),
|
package/lib/autodream/report.js
CHANGED
|
@@ -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
|
-
? [
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
220
|
-
? [`-
|
|
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
|
-
|
|
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/autodream/tools.js
CHANGED
|
@@ -33,7 +33,7 @@ import { MAX_FILE_BYTES, STATE_ROOT } from '../memory/config.js'
|
|
|
33
33
|
import { INDEX_FILE_KEY, SESSION_LOG_KEY } from '../shared/md-name.js'
|
|
34
34
|
import { stampHuman as localStamp } from '../shared/stamp.js'
|
|
35
35
|
import { writeFileAtomic, warnLog } from '../memory/util.js'
|
|
36
|
-
import { buildConflict, writeConflict } from '../memory/conflicts.js'
|
|
36
|
+
import { buildConflict, listConflicts, writeConflict } from '../memory/conflicts.js'
|
|
37
37
|
import { memoryPathOf } from '../memory/paths.js'
|
|
38
38
|
import { decideWriteIntent } from '../memory/write-gate.js'
|
|
39
39
|
import { inspectMemoryEntry, reservedIdentitySet } from '../memory/scan.js'
|
|
@@ -368,7 +368,10 @@ export function autodreamToolSchemas(opts) {
|
|
|
368
368
|
type: 'object',
|
|
369
369
|
properties: {
|
|
370
370
|
file: str('记忆文件名,例如 project_example.md'),
|
|
371
|
-
content: str(
|
|
371
|
+
content: str(
|
|
372
|
+
'完整文件内容,必须以 --- 开头的 YAML frontmatter 起始。**唯一的例外是索引 `MEMORY.md`**:' +
|
|
373
|
+
'它是一份清单、不是一条记忆,没有 type / 前缀,直接写正文(`- [标题](文件.md) — 一句话`)即可,不要给它补 frontmatter。',
|
|
374
|
+
),
|
|
372
375
|
reason: str(
|
|
373
376
|
'强烈建议填。一句话说清为什么改这条、改了什么(例如「合并了重复的部署记录,删掉被推翻的旧路径」)。' +
|
|
374
377
|
'缺了**不会**被拒绝——改动照样生效——但整理声明会把这条标成「未自述」,用宿主能追溯到的信息' +
|
|
@@ -433,6 +436,14 @@ export function createToolRunner(opts) {
|
|
|
433
436
|
const changes = []
|
|
434
437
|
/** 被拒绝的写操作(归档没给 reason 之类):**改动没有发生**,声明里单列一节。 */
|
|
435
438
|
const warnings = []
|
|
439
|
+
/**
|
|
440
|
+
* 被**宿主文件沙箱**拒绝的写入(与「模型没给 reason」这类被拒分开记)。
|
|
441
|
+
*
|
|
442
|
+
* 为什么单列:这类失败不是模型写错了,是**环境不允许**——把它混进 warnings 只能让人
|
|
443
|
+
* 以为「某次写入没成功」,而真正要回答的问题是「这一趟到底有没有落地任何东西」。
|
|
444
|
+
* 上层(`engine.js`)靠它把「零改动的假成功」判成失败(外部报告 2026-10-25 抓到)。
|
|
445
|
+
*/
|
|
446
|
+
const denials = []
|
|
436
447
|
/**
|
|
437
448
|
* 提示(advisory):**改动发生了**,只是有话要说(例如模型没自述缘由)。
|
|
438
449
|
*
|
|
@@ -530,6 +541,67 @@ export function createToolRunner(opts) {
|
|
|
530
541
|
}
|
|
531
542
|
}
|
|
532
543
|
|
|
544
|
+
/**
|
|
545
|
+
* 这一次写入该带上的**逐次沙箱策略**(宿主 `fs.writeText` 的第 5 个参数)。
|
|
546
|
+
*
|
|
547
|
+
* 为什么必须有(外部报告 2026-10-25,issue #6):autodream **刻意不在会话里跑**
|
|
548
|
+
* (见 README「整理刻意不走会话」),所以永远没有 `session.cwd` 可以继承。宿主的
|
|
549
|
+
* `sandboxPolicy.resolve()` 在没有 session 时退到**部署回退根**(配置的 workspaceRoot,
|
|
550
|
+
* 缺省 `process.cwd()`);而 workspace-write 的可写根只有那个根 + 临时区。于是
|
|
551
|
+
* 默认记忆目录 `~/.sage-mem/memory` 必然落在根外 → **每一次写入都被围栏拒绝**,
|
|
552
|
+
* 和宿主怎么配无关,是这条路径的结构性缺口。
|
|
553
|
+
*
|
|
554
|
+
* 传 `{ mode: 'workspace-write', workspaceRoot: memoryDir }` 把可写根**收窄到记忆目录**
|
|
555
|
+
* (比回退根更窄,不是更宽),让「插件写自己的数据目录」这件事成立。
|
|
556
|
+
*
|
|
557
|
+
* 三条边界,每一条都是刻意的:
|
|
558
|
+
* - `read-only` **原样尊重**:只读是用户此刻的选择,不由后台整理替它翻案;
|
|
559
|
+
* 这时不传策略,宿主照旧拒绝,失败会如实上报(见下面 `denials`)。
|
|
560
|
+
* - `danger-full-access` 本来就不围栏,传了也没意义。
|
|
561
|
+
* - 拿不到 `sandboxPolicy` 服务(老宿主 / 测试假 ctx)就**不传**,退回宿主默认行为,
|
|
562
|
+
* 与改动前逐字一致。
|
|
563
|
+
*
|
|
564
|
+
* @returns {{mode:string, workspaceRoot:string}|undefined}
|
|
565
|
+
*/
|
|
566
|
+
function writeSandboxPolicy() {
|
|
567
|
+
try {
|
|
568
|
+
const sp = typeof ctx?.get === 'function' ? ctx.get('sandboxPolicy') : undefined
|
|
569
|
+
if (!sp || typeof sp.resolve !== 'function') return undefined
|
|
570
|
+
const policy = sp.resolve()
|
|
571
|
+
if (!policy || policy.mode !== 'workspace-write') return undefined
|
|
572
|
+
return { mode: 'workspace-write', workspaceRoot: memoryDir }
|
|
573
|
+
} catch {
|
|
574
|
+
return undefined
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** 记一次「宿主沙箱拒绝写入」。调用点只有 `writeMemoryText` 的 catch —— 别在别处重复记。 */
|
|
579
|
+
function recordSandboxDenial(fullPath, err) {
|
|
580
|
+
const file = basename(fullPath)
|
|
581
|
+
const message = String(err?.message ?? err)
|
|
582
|
+
denials.push({ file, code: String(err?.code ?? ''), message })
|
|
583
|
+
// 同时进 warnings:整理声明里原本就有「未能落地的改动」一节,别让这类失败只活在内存里。
|
|
584
|
+
warnings.push(`写入 ${file} 被宿主文件沙箱拒绝:${message}`)
|
|
585
|
+
warnLog(ctx, `写入被沙箱拒绝:${file}: ${message}`)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 沙箱拒绝时给模型的**一句话出路**。
|
|
590
|
+
*
|
|
591
|
+
* 原样回一句 `access denied` 的话,模型只会换个文件名反复重试;而人读 transcript 时
|
|
592
|
+
* 也看不出该去改什么配置。这句提示只说明「这是环境问题、该改哪里」,绝不暗示
|
|
593
|
+
* 「换个方式绕过围栏」—— 那会把一次功能故障变成一次安全边界失效。
|
|
594
|
+
*/
|
|
595
|
+
function sandboxDenialHint(err) {
|
|
596
|
+
if (err?.code !== 'FS_SANDBOX_DENIED') return ''
|
|
597
|
+
return (
|
|
598
|
+
'(这次写入被宿主文件沙箱拒绝,与文件名、内容都无关。自动做梦在会话外运行,' +
|
|
599
|
+
'拿不到会话的工作区:宿主是 read-only 时这是预期行为;是 workspace-write 时,' +
|
|
600
|
+
'请把记忆目录放进宿主的 workspaceRoot,或给 @deepseek-ai/dsh-sandbox-policy 配置 workspaceRoot。' +
|
|
601
|
+
'不要用其它方式绕过围栏。)'
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
|
|
533
605
|
/**
|
|
534
606
|
* 把一次「版本过期」落成一条**待人工判定**的冲突记录。
|
|
535
607
|
*
|
|
@@ -559,12 +631,44 @@ export function createToolRunner(opts) {
|
|
|
559
631
|
tool: 'write_memory',
|
|
560
632
|
at: new Date().toISOString(),
|
|
561
633
|
})
|
|
634
|
+
// 同一件事只留一条:否则每一趟重试都会给队列添一份副本(见 hasSameOpenConflict)。
|
|
635
|
+
if (await hasSameOpenConflict(record)) return
|
|
562
636
|
await writeConflict(record, { stateRoot: STATE_ROOT })
|
|
563
637
|
} catch (conflictErr) {
|
|
564
638
|
warnLog(ctx, `冲突记录没写下来(写入失败的原因不受影响):${conflictErr?.message ?? conflictErr}`)
|
|
565
639
|
}
|
|
566
640
|
}
|
|
567
641
|
|
|
642
|
+
/**
|
|
643
|
+
* 队列里是否已有一条**完全同一件事**的未裁决记录。
|
|
644
|
+
*
|
|
645
|
+
* 为什么需要(外部报告 2026-10-25,issue #7):记录的 id 带着 `at` 的时间戳前缀
|
|
646
|
+
* (见 `buildConflict`),而「模型每一趟都试图覆盖一份它没读过的记忆」是**结构性**的
|
|
647
|
+
* —— 每一趟都会生成新 id,于是 `conflicts/` 只增不减,而其中绝大多数是同一件事的副本。
|
|
648
|
+
*
|
|
649
|
+
* 判据是**逐字段相等**(kind / file / 两份正文 / 两个版本):任何一处不同都算另一件事,
|
|
650
|
+
* 仍然是两条独立记录 —— 去重只跳过重复,不做「合并」,更不做裁决。
|
|
651
|
+
* 读队列失败一律当作「没有重复」:去重是省磁盘的优化,不能因为读不动就连该留的记录也不留。
|
|
652
|
+
*/
|
|
653
|
+
async function hasSameOpenConflict(record) {
|
|
654
|
+
try {
|
|
655
|
+
const listed = await listConflicts({ stateRoot: STATE_ROOT })
|
|
656
|
+
if (!listed.ok) return false
|
|
657
|
+
return listed.items.some(
|
|
658
|
+
(c) =>
|
|
659
|
+
c.status === 'open' &&
|
|
660
|
+
c.kind === record.kind &&
|
|
661
|
+
c.file === record.file &&
|
|
662
|
+
c.expectedVersion === record.expectedVersion &&
|
|
663
|
+
c.actualVersion === record.actualVersion &&
|
|
664
|
+
c.incoming === record.incoming &&
|
|
665
|
+
c.onDisk === record.onDisk,
|
|
666
|
+
)
|
|
667
|
+
} catch {
|
|
668
|
+
return false
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
568
672
|
/**
|
|
569
673
|
* 把内容写进一条记忆文件。
|
|
570
674
|
*
|
|
@@ -603,12 +707,15 @@ export function createToolRunner(opts) {
|
|
|
603
707
|
throw Object.assign(new Error(guard.why), { code: 'SAGE_MEM_UNVERIFIED_OVERWRITE' })
|
|
604
708
|
}
|
|
605
709
|
try {
|
|
606
|
-
|
|
710
|
+
// 第 4 个参数是 abort signal(这里不用);第 5 个是逐次沙箱策略 —— 见 writeSandboxPolicy。
|
|
711
|
+
return await fs.writeText(target, content, guard.expected, undefined, writeSandboxPolicy())
|
|
607
712
|
} catch (err) {
|
|
608
713
|
// 版本过期不是「写失败」,是**一次待判定的冲突**:有人在我们读入之后改过这份记忆。
|
|
609
714
|
// 落一条记录(保留哪边由人决定),然后**照旧把错误抛出去** ——
|
|
610
715
|
// 静默重试或静默放弃都是在替用户做决定。
|
|
611
716
|
if (err?.code === 'FS_STALE_VERSION') await recordStaleConflict(fullPath, content, err)
|
|
717
|
+
// 沙箱拒绝是环境故障:记下来,让上层别把「零改动」报成成功。
|
|
718
|
+
if (err?.code === 'FS_SANDBOX_DENIED') recordSandboxDenial(fullPath, err)
|
|
612
719
|
throw err
|
|
613
720
|
}
|
|
614
721
|
}
|
|
@@ -647,6 +754,13 @@ export function createToolRunner(opts) {
|
|
|
647
754
|
warnings() {
|
|
648
755
|
return [...warnings]
|
|
649
756
|
},
|
|
757
|
+
/**
|
|
758
|
+
* 被**宿主文件沙箱**拒绝的写入(环境故障,不是模型写错)。
|
|
759
|
+
* 上层用「有拒绝 + 零改动」把空转判成失败,别只靠 warnings 里的一句话。
|
|
760
|
+
*/
|
|
761
|
+
denials() {
|
|
762
|
+
return denials.map((d) => ({ ...d }))
|
|
763
|
+
},
|
|
650
764
|
/** 提示(改动发生了,但有话要说)。 */
|
|
651
765
|
notes() {
|
|
652
766
|
return [...notes]
|
|
@@ -715,7 +829,17 @@ export function createToolRunner(opts) {
|
|
|
715
829
|
// 文件级回滚就找不到原版(RB-9)。
|
|
716
830
|
const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
|
|
717
831
|
if (typeof a.content !== 'string' || !a.content.trim()) return '错误:content 为空。'
|
|
718
|
-
|
|
832
|
+
/**
|
|
833
|
+
* 索引 `MEMORY.md` **不要求 frontmatter**(外部报告 2026-10-25,issue #8)。
|
|
834
|
+
*
|
|
835
|
+
* 索引是一份清单、不是一条记忆:它没有 `type`,也没有能对上的文件名前缀。
|
|
836
|
+
* 硬要求 frontmatter 只会让模型为了「能写进去」给它编一个 `type: reference`,
|
|
837
|
+
* 而体检随后按「type 与前缀不一致」报警 —— 一个纯粹的假阳性,而且是常驻的。
|
|
838
|
+
* 手动入口(面板「建立索引骨架」)写出来的骨架本来也没有 frontmatter,
|
|
839
|
+
* 两条写入路径不该对同一个文件给出两套规矩。
|
|
840
|
+
*/
|
|
841
|
+
const isIndex = basename(full).toLowerCase() === INDEX_FILE_KEY
|
|
842
|
+
if (!isIndex && !/^---\r?\n/.test(a.content)) {
|
|
719
843
|
return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
|
|
720
844
|
}
|
|
721
845
|
/**
|
|
@@ -798,7 +922,7 @@ export function createToolRunner(opts) {
|
|
|
798
922
|
return `错误:没有这个工具:${name}`
|
|
799
923
|
}
|
|
800
924
|
} catch (err) {
|
|
801
|
-
return `工具执行失败:${err?.message ?? String(err)}`
|
|
925
|
+
return `工具执行失败:${err?.message ?? String(err)}${sandboxDenialHint(err)}`
|
|
802
926
|
}
|
|
803
927
|
},
|
|
804
928
|
}
|
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) }); } }),
|
package/lib/typert.host.js
CHANGED
|
@@ -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