sage-mem 1.8.2 → 1.9.1

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.8.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
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
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.
@@ -164,6 +164,8 @@ The state directory (configuration, ledger, rollback points, conflict queue) def
164
164
 
165
165
  Every image below is **rendered from the UI code in this repository** (the same CSS, the same component tree; the data is demo data for illustration, not a real memory directory).
166
166
 
167
+ Since 1.9.0 the memory-management surface follows the harness language (**Settings → General → Language**); memory content itself stays in whatever language it was written in, and the remaining panels (autodream, archive, starmap, audit) are still Chinese until the next i18n batch.
168
+
167
169
  ![File list: one Markdown file per memory, greyed rows are archived](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/files-list.png)
168
170
 
169
171
  *File list — the header always shows 「体检 N 个问题」(audit: N issues) and 「待重启生效」(restart to apply), so you know whether you should deal with it without opening anything; greyed rows are archived and can be restored at any time, or deleted for good with 「彻底删除」(irreversible, asks once more).*
@@ -291,6 +293,9 @@ The guard has two paths, with **different** coverage, spelled out:
291
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 |
292
294
 
293
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).
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").
294
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.
295
300
 
296
301
  ### Access Ledger: Three Kinds of "Used" Recorded Separately
@@ -362,7 +367,7 @@ It records item by item: **file / operation (create · rewrite · archive) / rea
362
367
 
363
368
  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.
364
369
  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.
365
- 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**.
370
+ 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).
366
371
 
367
372
  ### Rename Note
368
373
 
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.8.2`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`1.9.1`** —— 顶部 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 是最直接的反馈。
@@ -164,6 +164,8 @@ SAGE_MEM_DIR=/path/to/your/memory
164
164
 
165
165
  下面每张图都是**从仓库里的界面代码渲染出来的**(同一份 CSS、同一棵组件树;数据是演示用的假数据,不是真实记忆目录)。
166
166
 
167
+ 1.9.0 起,记忆管理这一块跟随宿主语言(**设置 → 通用 → 语言**);记忆内容本身保持它被写下时的语言,其余面板(自动做梦、自动归档、记忆星图、体检)仍是中文,等下一批 i18n。
168
+
167
169
  ![文件列表:每条记忆一个 Markdown 文件,灰底的是已归档](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/files-list.png)
168
170
 
169
171
  *文件列表 —— 头部常显「体检 N 个问题」与「待重启生效」,不用点开就知道该不该处理;灰底条目是已归档,随时可以恢复,也可以点「彻底删除」真的删掉(不可恢复,要再确认一次)。*
@@ -291,6 +293,9 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
291
293
  | **插件自己**写(自动做梦的整理) | 调用方在写入时带上读到的版本 | 落一条待处理记录 `<状态目录>/conflicts/<id>.json`,里面留着**盘上那一版**和**想要写的那一版** —— 只有一边等于没得判 |
292
294
 
293
295
  - **冲突不会自动合并**,保留哪边由你决定(记录是 JSON,需要时直接打开看)。静默重试和静默放弃都是在替你决定。
296
+ - **注入侧目前不读这个队列**:有未裁决冲突的记忆照常作为正文注入;`conflict_pending` 这条抑制规则要等 **1.10.0 的矛盾检测**(连面板里的裁决入口一起)才生效 —— 只接抑制、不做入口,会让一条记忆**永久不再被注入**,比现在更糟。现阶段处理:打开 `conflicts/<id>.json`,把 `status` 改成 `resolved` 或 `dismissed`(或直接删掉文件)。
297
+ - **同一件事不会越积越多**:一次失败如果与队列里某条未裁决记录逐字段相同(同一文件、两份正文、两个版本),不再追加副本。
298
+ - **宿主的文件沙箱**:自动做梦在**会话外**运行,拿不到会话工作区。宿主用 `workspace-write` 时,插件给每次写入带上**收窄到记忆目录**的逐次沙箱策略(比回退根更窄);宿主是 `read-only` 时不传策略、写入照旧被拒,且这一趟会被记成**失败**(不是「成功,没改什么」)。
294
299
  - 已知边界:**在 shell 里改记忆不走这条路**。写门守的是「经过工具的记忆写入」,不是「所有对文件的修改」;`pwsh` 里的重定向之类认不出来。认不准就不认,免得把「看不到」误记成事实。
295
300
 
296
301
  ### 访问台账:三种「用过」分开记
@@ -362,7 +367,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
362
367
 
363
368
  1. **安全边界在工具集上,不在提示词里** —— 自动做梦时宿主只把只读工具交给模型,改写模式才多给改写工具;而且**没有「删除」这个能力**,归档是把文件移进 `archive/`,可以人工捞回。
364
369
  2. **改写前自动建回滚点**,落在 `<记忆目录的上一层>/.sage-mem/autodream/snapshots/<runId>/`,保留最近 N 份。理由是记忆目录**不在任何版本控制下**、删除没有回收站,「可回滚」得自己造。
365
- 3. **跑完自动审计** —— 索引悬空、漏索引、`type` 与文件名前缀不一致、CRLF、双链断链、frontmatter 里被 YAML 当注释吞掉的「空格 + #」、无 frontmatter、归档中途残留的临时文件,外加「读不动」单列一类,结果与前后对比写进报告。它**只报不改**。
370
+ 3. **跑完自动审计** —— 索引悬空、漏索引、`type` 与文件名前缀不一致、CRLF、双链断链、frontmatter 里被 YAML 当注释吞掉的「空格 + #」、无 frontmatter、归档中途残留的临时文件,外加「读不动」单列一类,结果与前后对比写进报告。它**只报不改**。索引 `MEMORY.md` 是清单而不是一条记忆,因此**不参与**「type 与前缀」和「无 frontmatter」这两项(1.9.1 起)。
366
371
 
367
372
  ### 改名说明
368
373
 
@@ -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
- if (effective !== prefix) {
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/)) {
@@ -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)
@@ -331,12 +356,28 @@ export class AutodreamEngine {
331
356
  archiveDegraded: policy.degraded,
332
357
  })
333
358
 
359
+ /**
360
+ * 「有写入被沙箱拒绝」不等于「这一趟失败」,但**「所有写入都被拒绝、零改动」就是失败**。
361
+ *
362
+ * 原先这条路无条件写 `ok: true`:报告上写「成功,没改什么」,用户看到的是
363
+ * 一次空转被当成一次成功的整理 —— 外部报告 2026-10-25(issue #6)点的正是这里。
364
+ * 有部分改动时仍算成功:改动确实落地了,被拒的几次由整理声明的 warnings 交代。
365
+ */
366
+ const judged = judgeWriteOutcome({
367
+ apply: !!cfg.apply,
368
+ changeCount: result.changes.length,
369
+ denials: typeof runner?.denials === 'function' ? runner.denials() : [],
370
+ })
371
+ const allWritesDenied = judged.allDenied
372
+ const deniedError = judged.error
373
+
334
374
  this.state.lastRunAt = Date.now()
335
375
  this.state.retryAfter = 0
336
376
  this.state.lastResult = {
337
377
  at: this.state.lastRunAt,
338
378
  atHuman: stampHuman(this.state.lastRunAt),
339
- ok: true,
379
+ ok: !allWritesDenied,
380
+ ...(allWritesDenied ? { error: deniedError } : {}),
340
381
  runId,
341
382
  apply: !!cfg.apply,
342
383
  touched: result.touched,
@@ -354,7 +395,8 @@ export class AutodreamEngine {
354
395
  await this.persist()
355
396
  this.phase = ''
356
397
  return {
357
- ok: true,
398
+ ok: !allWritesDenied,
399
+ ...(allWritesDenied ? { error: deniedError } : {}),
358
400
  runId,
359
401
  report: report.name,
360
402
  touched: result.touched,
@@ -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('完整文件内容,必须以 --- 开头的 YAML frontmatter 起始'),
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
- return await fs.writeText(target, content, guard.expected)
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
- if (!/^---\r?\n/.test(a.content)) {
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
  }