sage-mem 0.9.18 → 0.9.20

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.
@@ -29,8 +29,11 @@ import { join, basename } from 'node:path'
29
29
  import { createHash } from 'node:crypto'
30
30
  import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
31
31
  import { archiveMemory } from './memory/archive.js'
32
- import { MAX_FILE_BYTES } from './memory/config.js'
32
+ import { MAX_FILE_BYTES, STATE_ROOT } from './memory/config.js'
33
33
  import { writeFileAtomic, warnLog } from './memory/util.js'
34
+ import { buildConflict, writeConflict } from './memory/conflicts.js'
35
+ import { memoryPathOf } from './memory/paths.js'
36
+ import { decideWriteIntent } from './memory/write-gate.js'
34
37
  import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
35
38
 
36
39
  /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
@@ -459,7 +462,112 @@ export function createToolRunner(opts) {
459
462
  * @param {'create'|'update'|'archive'} op
460
463
  * @param {'model'|'auto'} reasonSource — model = 模型自述;auto = 宿主追溯(模型没给)
461
464
  */
462
- function changeRecord(op, file, reason, before, after, reasonSource = 'model') {
465
+ /**
466
+ * 取宿主的 `fs` 服务,拿不到就返回 null。
467
+ *
468
+ * ⚠️ 必须用 `ctx.get('fs')` 而不是 `ctx.fs`:后者在插件没声明 `inject: ['fs']` 时会**抛**
469
+ * `cannot get property "fs" without inject`(实测,见 docs/host-facts.md)。
470
+ * 而 sage-mem 有意不声明这个硬依赖 —— 声明了它,没有 fs 服务的宿主上整个插件会一直待机。
471
+ */
472
+ function hostFs() {
473
+ try {
474
+ const fs = typeof ctx?.get === 'function' ? ctx.get('fs') : undefined
475
+ return fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function' ? fs : null
476
+ } catch {
477
+ return null
478
+ }
479
+ }
480
+
481
+ /**
482
+ * 把一次「版本过期」落成一条**待人工判定**的冲突记录。
483
+ *
484
+ * 为什么不是「重试」也不是「放弃」:两者都是在替用户决定保留哪一边。
485
+ * 我们要做的是把这次失败从**一个报错**变成**一条可查询的记录** —— 盘上那份是什么、
486
+ * 我们想写的是什么,都留下来,让用户在面板里看到再选。
487
+ *
488
+ * ⚠️ 这里**吞掉一切异常**:它发生在「写入已经失败」的路径上,再抛一次只会把真正的原因
489
+ * (`FS_STALE_VERSION`)盖掉。留不下记录就只留日志,绝不改变向外抛的那个错误。
490
+ */
491
+ async function recordStaleConflict(fullPath, incoming, err) {
492
+ try {
493
+ const fs = hostFs()
494
+ let onDisk = null
495
+ // 把盘上那一版也存下来 —— 冲突记录的价值就在「两份都在」,只有一边等于没得判。
496
+ try {
497
+ if (fs && typeof fs.readText === 'function') onDisk = await fs.readText(await fs.resolve(fullPath))
498
+ } catch { /* 读不到就只留我们这一版:宁缺一半,也不要为它把冲突记录整条丢掉 */ }
499
+ const record = buildConflict({
500
+ kind: 'stale_version',
501
+ file: basename(fullPath),
502
+ expectedVersion: 'unknown', // 守卫是我们自己按 stat 到的版本构造的,这里拿不到那个字符串
503
+ actualVersion: String(err?.code ?? ''),
504
+ incoming,
505
+ onDisk,
506
+ note: '自动做梦写入时版本已变化:有人在这份记忆被读入之后改过它',
507
+ tool: 'write_memory',
508
+ at: new Date().toISOString(),
509
+ })
510
+ await writeConflict(record, { stateRoot: STATE_ROOT })
511
+ } catch (conflictErr) {
512
+ warnLog(ctx, `冲突记录没写下来(写入失败的原因不受影响):${conflictErr?.message ?? conflictErr}`)
513
+ }
514
+ }
515
+
516
+ /**
517
+ * 把内容写进一条记忆文件。
518
+ *
519
+ * **优先走宿主的 `fs.writeText`**,理由是它同时吃到 `fs/write-intent` 那道**写入门**
520
+ * (见 `lib/memory/write-gate.js`):我们自己那套 `writeFileAtomic` **绕过它**。
521
+ * 绕过意味着 autodream 排在版本守卫之外 —— 而 autodream 恰恰是最可能与用户手动编辑
522
+ * 撞上的写入者(它在后台跑,用户看不见)。两套写入者之间还能静默互踩,就等于没修。
523
+ *
524
+ * ⚠️ **宿主写入失败时绝不回落。** 回落等于「版本守卫拒绝之后我们照样写进去」,
525
+ * 那把守卫整个作废了,还更糟:它会让失败看起来像成功。失败如实抛给调用方。
526
+ *
527
+ * 只有**宿主没有 fs 服务**时才回落(老宿主 / 测试里的假 ctx)——那条路径的行为与改动前
528
+ * 逐字一致,所以既有断言不受影响。
529
+ *
530
+ * @returns {Promise<object|null>} 宿主的 `FsWriteOutcome`;回落时返回 null
531
+ */
532
+ async function writeMemoryText(fullPath, content) {
533
+ const fs = hostFs()
534
+ if (!fs) {
535
+ await writeFileAtomic(fullPath, content)
536
+ return null
537
+ }
538
+ const target = await fs.resolve(fullPath)
539
+ // ⚠️ **守卫必须由我们自己传。** `fs/write-intent` 只为「工具发起」的写入派发,
540
+ // 插件自己调 `writeText` 时一次都不发(实测)—— 不自己传,这次写入就是**无守卫**的,
541
+ // 那正是 D4 要避免的漏洞,也是「两套写入者静默互踩」的入口。
542
+ // 判定表与写门用的是同一个(`write-gate.js`),所以两条路的语义不会漂。
543
+ let expected
544
+ if (typeof fs.stat === 'function') {
545
+ try {
546
+ const info = await fs.stat(target)
547
+ expected = decideWriteIntent({
548
+ memoryPath: memoryPathOf(fullPath, memoryDir),
549
+ exists: !!info,
550
+ version: info?.version ?? null,
551
+ caller: undefined,
552
+ }).intent
553
+ } catch {
554
+ // 探不到就不猜:不传守卫(宿主按默认语义走),也绝不退化成 createIfAbsent ——
555
+ // 那对**其实存在**的文件是一次误拦。
556
+ expected = undefined
557
+ }
558
+ }
559
+ try {
560
+ return await fs.writeText(target, content, expected)
561
+ } catch (err) {
562
+ // 版本过期不是「写失败」,是**一次待判定的冲突**:有人在我们读入之后改过这份记忆。
563
+ // 落一条记录(保留哪边由人决定),然后**照旧把错误抛出去** ——
564
+ // 静默重试或静默放弃都是在替用户做决定。
565
+ if (err?.code === 'FS_STALE_VERSION') await recordStaleConflict(fullPath, content, err)
566
+ throw err
567
+ }
568
+ }
569
+
570
+ function changeRecord(op, file, reason, before, after, reasonSource = 'model', extra = null) {
463
571
  seq += 1
464
572
  const at = Date.now()
465
573
  changes.push({
@@ -474,6 +582,9 @@ export function createToolRunner(opts) {
474
582
  before,
475
583
  after,
476
584
  deltaBytes: after.bytes - before.bytes,
585
+ // 这一笔是走宿主 fs 写的、还是回落到本地原子写;走宿主时带**版本**。
586
+ // 回落时两者都如实写 null —— 不假装知道一个我们其实拿不到的版本。
587
+ ...(extra && typeof extra === 'object' ? extra : {}),
477
588
  })
478
589
  }
479
590
 
@@ -572,10 +683,9 @@ export function createToolRunner(opts) {
572
683
  const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
573
684
  const selfReported = reason.length >= MIN_REASON_CHARS
574
685
  const before = await fileFingerprint(full)
575
- // 原子写:这是**唯一**会把模型生成的内容写进记忆目录的路径。
576
- // 直写若崩在中途,留下的是半截 frontmatter —— 文件还在、列表还列得出,
577
- // 但解析不出类型与标题,检索与星图同时哑掉。
578
- await writeFileAtomic(full, a.content)
686
+ // 走宿主写入(有 fs 服务时):原子性由宿主保证,**并且吃到写入门**(见 writeMemoryText)。
687
+ // 内容闸门上面已按字节判过,这里只关心「写没写进去、写成了什么版本」。
688
+ const outcome = await writeMemoryText(full, a.content)
579
689
  const after = await fileFingerprint(full)
580
690
  if (!selfReported) {
581
691
  // **不拒绝。** 一次改写缺了缘由,拒掉的代价是这条修正彻底丢失——而它可能
@@ -583,12 +693,17 @@ export function createToolRunner(opts) {
583
693
  // 既保住了改动,也让声明里一眼看得出哪些缘由是模型自己说的。
584
694
  notes.push(`${basename(full)} 的改动模型没有自述缘由,声明里已标「未自述」并附追溯信息`) }
585
695
  changeRecord(
586
- before.exist ? 'update' : 'create',
696
+ // 宿主给了 operation(create / update)就直接用 —— 它比「看写之前存不存在」更权威:
697
+ // 那个指纹是我们**自己**在写之前 stat 的,中间还有一道缝。回落时才由指纹推。
698
+ outcome?.operation === 'create' || outcome?.operation === 'update'
699
+ ? outcome.operation
700
+ : (before.exist ? 'update' : 'create'),
587
701
  basename(full),
588
702
  selfReported ? reason : '',
589
703
  before,
590
704
  after,
591
705
  selfReported ? 'model' : 'auto',
706
+ { via: outcome ? 'fs' : 'atomic', version: outcome?.version ?? null },
592
707
  )
593
708
  touched.push(basename(full))
594
709
  listCache = null
package/lib/index.js CHANGED
@@ -29,6 +29,8 @@ import { detectIntent } from './memory/intent.js'
29
29
  import { decideDelivery } from './memory/deliver.js'
30
30
  import { classifyToolExecution, accessEventKey, createDeduper } from './memory/observe.js'
31
31
  import { readCheckpoint } from './memory/checkpoint.js'
32
+ import { memoryPathOf } from './memory/paths.js'
33
+ import { decideWriteIntent, decideEditGuard } from './memory/write-gate.js'
32
34
  import { createMemoryTools } from './tools/memory-tools.js'
33
35
  import { archiveMemory, restoreMemory } from './memory/archive.js'
34
36
  import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess, recordRead } from './memory/access.js'
@@ -271,6 +273,87 @@ export class MemoryGateway extends TypertRemoteService {
271
273
  })
272
274
  } catch { /* 老宿主没有这个事件:不影响注入 */ }
273
275
 
276
+ // ── 写入门:给记忆目录的写入补上版本守卫 ──
277
+ //
278
+ // 实测(`scripts/events-probe.mjs`,本机 headless,2026-10-15):这两个事件**只对
279
+ // 「工具发起」的写入**触发 —— 模型调内置 `write` / `edit` 打到记忆文件时会走这里;
280
+ // 而**插件自己**调 `ctx.fs.writeText` 时一次都不发(实测 0 次),那条路由调用方自己传
281
+ // `expected`(见 `lib/autodream-tools.js` 的 `writeMemoryText`)。
282
+ // 监听器**不需要声明 inject** 也能收到,所以这里不挂子 fiber。
283
+ //
284
+ // 它买到的东西:**模型的 `write` 没法在一个记忆文件被改过之后静默覆盖它** ——
285
+ // 守卫过不了,宿主会以 `FS_STALE_VERSION` 明确拒绝(实测确实会被拒)。
286
+ // 这属于「把静默的坏结果变成显式的失败」,不是「阻止写入」:这个事件**没有拒绝动作**,
287
+ // 我们也不发明一个(见 write-gate.js 的头注释)。
288
+ //
289
+ // 单槽纪律:返回 `undefined` 才是「不管」。目录外**必须**返回 undefined,否则会占掉槽位、
290
+ // 影响整块盘的写入(write-gate.js 的 `outside_memory` 判在 `caller_wins` 之前正是为此)。
291
+ /**
292
+ * 探目标的当前版本。**区分「不存在」与「不知道」**。
293
+ *
294
+ * 这个区分不是洁癖:`!!info` 会把「拿不到 fs 服务」也写成 `exists:false`,判定表据此给
295
+ * `createIfAbsent` —— 而目标**其实是存在的**记忆文件时,那是一次**误拦**(写入必然失败,
296
+ * 模型收到一句看不懂的错误)。拿不准就不干涉,比猜一个安全。
297
+ */
298
+ // ⚠️⚠️ **这段注册必须在 `apply()` 里直接做,绝不能挪进 `ctx.inject(['fs'], …)`,也不能给
299
+ // 插件加 `static inject = ['fs']`。** 这不是风格偏好,是实测出来的硬约束:
300
+ // 这两个事件有一个**注册窗口**,窗口在 fs 服务就绪**之前**就关了。
301
+ // 不 inject、apply 里直接挂 → 收到 1 次 ✅
302
+ // 顶层 `inject:['fs']` → **0 次**
303
+ // 顶层 `inject:['tokenMeter']` → **0 次**(与 fs 无关,是时机问题)
304
+ // `ctx.inject(['fs'], sub)` 子 fiber → **0 次**
305
+ // 不 inject、但 apply 之后 2.5 秒再挂 → **0 次**(晚挂收不到)
306
+ // 证据:`scripts/host-facts-probe.mjs` 的七组对照 + `docs/host-facts.md` 3.3 节。
307
+ // 之所以特别标注:**按 D5 的规范直觉(可选服务要用 ctx.inject 挂子 fiber)改这里,
308
+ // 会让写门静默失效** —— 编译得过、测试可能也过,只是永远不触发。
309
+ // 而我们**不需要** inject:实测不声明 inject 时,处理器**内部** `ctx.get('fs')` 照样拿得到服务
310
+ // (`{hasFs:true, hasStat:true}`),取版本就靠它。
311
+ const probeTarget = async (target) => {
312
+ try {
313
+ const fs = typeof ctx.get === 'function' ? ctx.get('fs') : undefined
314
+ if (!fs || typeof fs.stat !== 'function' || !target) return { known: false }
315
+ const info = await fs.stat(target)
316
+ return { known: true, exists: !!info, version: info?.version ?? null }
317
+ } catch {
318
+ return { known: false }
319
+ }
320
+ }
321
+ try {
322
+ if (typeof ctx.on === 'function') {
323
+ // 公共骨架:**先判位置、再决定要不要碰链子**。
324
+ //
325
+ // ⚠️ 三条纪律(都是踩出来的):
326
+ // 1. **目录外连 `next()` 都不调**。宿主对**任何**工具写入都发这个事件(实测:写一个
327
+ // 临时文件也发),无条件调 next() + stat 等于给整个系统的每次写文件都加一次系统调用,
328
+ // 而我们本来就什么都不该做。直接 `return next()` 把决策权原样交出去。
329
+ // 2. **一旦调了 `next()`,就必须把它给的东西交回去**。`next()` 返回的是下游(或调用方
330
+ // 自己)已经做出的决定;此时再 `return undefined` 会把这个决定**丢掉** ——
331
+ // 我们本想「不干涉」,实际却比干涉还糟。
332
+ // 3. **探不到版本就不碰**(见 probeTarget):把「不知道」当「不存在」会误拦真实存在的文件。
333
+ const gateEvent = (decide) => async (target, actor, next) => {
334
+ try {
335
+ const memoryPath = memoryPathOf(target?.displayPath, MEMORY_DIR)
336
+ if (!memoryPath.ok) return typeof next === 'function' ? next() : undefined
337
+ const caller = typeof next === 'function' ? await next() : undefined
338
+ const probed = await probeTarget(target)
339
+ if (!probed.known) return caller
340
+ const chosen = decide({
341
+ memoryPath,
342
+ exists: probed.exists,
343
+ version: probed.version,
344
+ caller,
345
+ })
346
+ // 我们给出决定就用我们的;决定不了(undefined)就把 `caller` 原样交回去,不吞。
347
+ return chosen === undefined ? caller : chosen
348
+ } catch {
349
+ return undefined /* 写门在宿主的写入链路上:抛出去会把一次正常写入炸掉 */
350
+ }
351
+ }
352
+ ctx.on('fs/write-intent', gateEvent((facts) => decideWriteIntent(facts).intent))
353
+ ctx.on('fs/edit-intent', gateEvent((facts) => decideEditGuard(facts).guard))
354
+ }
355
+ } catch { /* 老宿主没有这两个事件:写入按宿主默认语义走 */ }
356
+
274
357
  // 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
275
358
  // 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
276
359
  const recallCache = new Map()
@@ -0,0 +1,520 @@
1
+ /**
2
+ * sage-mem 记忆层 —— 冲突队列(`<状态根>/conflicts/<id>.json`)。
3
+ *
4
+ * 为什么要有这一层:宿主的文件写入是**原子**的、而且带版本守卫 —— 版本过期会抛
5
+ * `FS_STALE_VERSION`。所以「冲突」不需要我们去比内容差异,宿主已经把「谁的版本旧了」
6
+ * 告诉调用方了。我们要做的只有一件事:**把这次失败从「一个报错」变成「一条待判定的记录」**,
7
+ * 这样用户能在面板上看到「有 N 条冲突等你处理」,而不是让一次静默覆盖把别人的改动吃掉。
8
+ *
9
+ * ⚠️ **本模块不做任何自动裁决**。保留磁盘那份 / 保留我的 / 手工合并 —— 由**人**决定,
10
+ * 插件不猜(自动合并冲突留到 1.0.0 之后)。所以这里只有五个动作:
11
+ * 落记录(buildConflict / writeConflict)、读一条(readConflict)、列全部(listConflicts)、
12
+ * 标记状态(resolveConflict)、删除(removeConflict)。**不要在这里加「聪明」的合并逻辑。**
13
+ *
14
+ * ⚠️ **冲突正文是用户的记忆内容,本模块永不把它写进日志/控制台**:进终端就等于进日志,
15
+ * 而这份记录里放的是两份正文。所以本模块**一处 console 都没有**,失败一律用返回值表达。
16
+ *
17
+ * 并发:同一个 id 的并发写由**调用方串行**(进程内用 `util.js` 的 `createWriteQueue`);
18
+ * 不同 id 互不影响(一个 id 一个文件)。本模块自己只保证每次写是原子的。
19
+ */
20
+
21
+ import { createHash } from 'node:crypto'
22
+ import { mkdir, readdir, readFile, unlink } from 'node:fs/promises'
23
+ import { dirname, join } from 'node:path'
24
+ import { STATE_ROOT } from './config.js'
25
+ import { writeFileAtomic } from './util.js'
26
+
27
+ /** 冲突记录都放在状态根的这个子目录下。 */
28
+ export const CONFLICTS_DIR_NAME = 'conflicts'
29
+
30
+ /** 一条冲突一个文件。 */
31
+ export const CONFLICT_FILE_SUFFIX = '.json'
32
+
33
+ /**
34
+ * 冲突的种类。`stale_version` 是宿主版本守卫直接报出来的那种(最常见);
35
+ * `external_change` 是文件在别处被改了;`unresolved_merge` 是人工合并到一半留下的。
36
+ */
37
+ export const CONFLICT_KINDS = Object.freeze(['stale_version', 'external_change', 'unresolved_merge'])
38
+
39
+ /**
40
+ * 记录的状态。`open` 是唯一「待处理」的状态;另外两种都是人的判断结果,
41
+ * 区别只在语义(`resolved` = 处理过了,`dismissed` = 看过、决定不管)。
42
+ */
43
+ export const CONFLICT_STATUSES = Object.freeze(['open', 'resolved', 'dismissed'])
44
+
45
+ /**
46
+ * 单份冲突记录的体积上限(字节)。
47
+ *
48
+ * 定 256 KB:一份记录里要放**两份**正文(我的版本 + 磁盘上的版本),所以它天生比记忆大;
49
+ * 但再大就不该整份塞进上下文了 —— 到量说明该人工合并,而不是把两份都留着。
50
+ */
51
+ export const CONFLICT_MAX_BYTES = 256 * 1024
52
+
53
+ /**
54
+ * 各字段的单字段字节上限(**数据表**而不是散落的魔法数:面板与文档要能展示凭什么是这个数)。
55
+ *
56
+ * 为什么要「上限之外另有一条」:整个记录的上限(256 KB)管不住**一个字段**把预算吃光 ——
57
+ * 一份 200 KB 的 `incoming` 能挤掉 `onDisk`,而后者恰恰是判定冲突时最需要对照的那份。
58
+ * 所以两个正文字段各自先卡 48 KB(两份共 96 KB,还有一半留给元信息与 JSON 转义),
59
+ * 小字段给到只够装下它们的量级。超了就**截断头部**并置 `truncated: true`。
60
+ */
61
+ export const CONFLICT_FIELD_LIMITS = Object.freeze({
62
+ incoming: 48 * 1024,
63
+ onDisk: 48 * 1024,
64
+ file: 1024,
65
+ note: 4096,
66
+ tool: 256,
67
+ version: 256,
68
+ })
69
+
70
+ /**
71
+ * 状态机(**数据**,不是 if 链:面板要能显示「这一步能不能点」,断言也要能逐条核对)。
72
+ *
73
+ * `open → resolved` / `open → dismissed` / `resolved ↔ dismissed` 允许;
74
+ * **不许从 `resolved`/`dismissed` 回到 `open`** —— 那等于把一条已经做过判断的记录
75
+ * 悄悄复活成「待处理」,而判断的理由不在记录里,复活后没人知道当初为什么关掉它。
76
+ * 要重开就新建一条(带上新的证据)。
77
+ */
78
+ export const CONFLICT_TRANSITIONS = Object.freeze({
79
+ open: Object.freeze(['resolved', 'dismissed']),
80
+ resolved: Object.freeze(['dismissed']),
81
+ dismissed: Object.freeze(['resolved']),
82
+ })
83
+
84
+ /**
85
+ * 冲突 id 允许的字符集:单段、`[A-Za-z0-9-]`、1..64 字符。
86
+ *
87
+ * 字符集里**根本没有** `.`、`/`、`\`、`:` —— 前三个是路径语义(`.` 的尾随形态还会被 Windows
88
+ * 静默吃掉),`:` 在 NTFS 上是交替数据流的分隔符。判定走纯字符类、**不走 `path.basename`**:
89
+ * `\` 在 Windows 是分隔符、在 Linux 只是普通字符,用平台 API 判会得出「同一份输入在两个平台上
90
+ * 合法性相反」的结论(本仓库刚被 Linux CI 抓过同类缺陷)。这里的判据两个平台逐字一致。
91
+ */
92
+ const CONFLICT_ID_RE = /^[A-Za-z0-9-]{1,64}$/
93
+
94
+ /**
95
+ * Windows 保留设备名:`conflicts/CON.json` 在这个平台上**建不出来**(设备名带扩展名同样保留),
96
+ * Linux 上完全正常 —— 同一份状态目录同步过去就会「在那边能写、在这边写不了」。
97
+ *
98
+ * 同一份判据 `checkpoint.js` 里也有一份:两个模块各守自己的 id 空间,为此去动 `lib/` 加一个
99
+ * 共享文件等于引入跨模块耦合,而表本身很小 —— 重复比耦合便宜。
100
+ */
101
+ const WINDOWS_RESERVED = new Set([
102
+ 'con',
103
+ 'prn',
104
+ 'aux',
105
+ 'nul',
106
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
107
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
108
+ ])
109
+
110
+ /**
111
+ * 冲突 id 归一:合法原样返回,非法返回 null。
112
+ *
113
+ * 只收字符串,也不 trim:路径两端的空格在 Windows 上会被静默裁掉、在 Linux 上不会,
114
+ * 「帮忙归一化」本身就会造出两个平台指向不同文件的输入。
115
+ */
116
+ export function normalizeConflictId(raw) {
117
+ if (typeof raw !== 'string') return null
118
+ if (!CONFLICT_ID_RE.test(raw)) return null
119
+ // 纯点号(`.` / `..`)与尾随点在这里已经被字符集挡掉了(字符集不含 `.`),
120
+ // 但下面这条**设备名**判据必须在:`con` / `com1` 全是合法字符,却是 Windows 的保留名。
121
+ if (WINDOWS_RESERVED.has(raw.toLowerCase())) return null
122
+ return raw
123
+ }
124
+
125
+ /** stateRoot 的归一:非空字符串才用,否则回落 config.js 的 STATE_ROOT。 */
126
+ function rootOf(stateRoot) {
127
+ return typeof stateRoot === 'string' && stateRoot !== '' ? stateRoot : STATE_ROOT
128
+ }
129
+
130
+ /** 冲突目录。 */
131
+ function conflictsDir(stateRoot) {
132
+ return join(rootOf(stateRoot), CONFLICTS_DIR_NAME)
133
+ }
134
+
135
+ /** 一条冲突的文件路径;调用方必须先过 `normalizeConflictId`。 */
136
+ function conflictPathFor(id, stateRoot) {
137
+ return join(conflictsDir(stateRoot), `${id}${CONFLICT_FILE_SUFFIX}`)
138
+ }
139
+
140
+ /**
141
+ * 按**码点**截断到不超过 maxBytes 字节。
142
+ *
143
+ * 不能按字符数截(`s.slice(0, N)`):一个汉字 3 字节,按字符截出来的东西是预算的三倍,
144
+ * 闸门等于没设。也不能按字节硬切:那会把一个码点切成半个,注入 / 面板上显示成 U+FFFD。
145
+ */
146
+ function truncateByBytes(s, maxBytes) {
147
+ const text = String(s ?? '')
148
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
149
+ let out = ''
150
+ let used = 0
151
+ for (const ch of text) {
152
+ const b = Buffer.byteLength(ch, 'utf8')
153
+ if (used + b > maxBytes) break
154
+ out += ch
155
+ used += b
156
+ }
157
+ return out
158
+ }
159
+
160
+ /** 单字段限长。非字符串按「缺」处理(不去猜一个对象 / Buffer 的内容)。 */
161
+ function clampText(value, limit) {
162
+ if (typeof value !== 'string') return { text: '', cut: false }
163
+ if (Buffer.byteLength(value, 'utf8') <= limit) return { text: value, cut: false }
164
+ return { text: truncateByBytes(value, limit), cut: true }
165
+ }
166
+
167
+ /** 版本号一律当字符串用(`undefined` / `null` = 没有这个版本,不是字符串 "null")。 */
168
+ function versionText(value) {
169
+ return value === undefined || value === null ? '' : String(value)
170
+ }
171
+
172
+ /** 时间戳归一:认得出才用,认不出就是空串(**不编一个时间**)。 */
173
+ function normalizeAt(raw) {
174
+ if (typeof raw !== 'string' || raw === '') return ''
175
+ return Number.isFinite(Date.parse(raw)) ? raw : ''
176
+ }
177
+
178
+ /** 把时间戳压成 id 里能用的紧凑形态:`2026-10-15T19:47:54.000Z` → `20261015T194754Z`。 */
179
+ function compactStamp(at) {
180
+ const ms = Date.parse(at)
181
+ if (!Number.isFinite(ms)) return ''
182
+ return new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
183
+ }
184
+
185
+ /**
186
+ * 内容指纹(8 位十六进制)。
187
+ *
188
+ * 这里**刻意不用随机数**,虽然接口注释写的是「时间戳 + 短随机」:
189
+ * 1. 随机数会让「同输入两次结果一致」不成立 —— 一个从「写失败」造记录的纯函数不该读随机源;
190
+ * 2. 指纹还有个更好的性质:**同一条冲突被重复上报时 id 相同**,队列里不会长出重复项。
191
+ * 指纹取的是「这次冲突是什么」的字段(种类 / 文件 / 两个版本 / 两份正文),不含 `note` / `tool`
192
+ * 这类关于「谁在什么时候碰上的」的元信息 —— 换一次说明不该变成另一条冲突。
193
+ */
194
+ function shortDigest(parts) {
195
+ const canonical = parts.map((p) => String(p ?? '')).join('\u0000')
196
+ return createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8)
197
+ }
198
+
199
+ /**
200
+ * 从「一次写失败」造一条记录(**纯函数**:不落盘、不读时钟、不掷随机数)。
201
+ *
202
+ * 时间与 id 从输入来:`at` 给定了就是逐字确定、可重放的结果;不给 `at` 就是空串
203
+ * (id 也就没有时间戳前缀)。**不自己读时钟**是刻意的 —— 时间是调用方的事实
204
+ * (只有它知道这次过期发生在何时),编一个「现在」会让重放与去重都失效。
205
+ * `id` 也可以直接传(面板要指定 id 时用),非法就按内容指纹现算。
206
+ *
207
+ * 截断:各字段按 `CONFLICT_FIELD_LIMITS` 卡字节,截过就置 `truncated: true`。
208
+ *
209
+ * @param {{kind?, file?, expectedVersion?, actualVersion?, incoming?, onDisk?, note?, tool?, at?, id?}} input
210
+ */
211
+ export function buildConflict(input) {
212
+ const src = input && typeof input === 'object' ? input : {}
213
+
214
+ const at = normalizeAt(src.at)
215
+ // 不认识的 kind **原样保留**:静默换成某个已知值等于把「未知」伪装成「已知」。
216
+ const kind = typeof src.kind === 'string' ? src.kind : ''
217
+
218
+ const file = clampText(src.file, CONFLICT_FIELD_LIMITS.file)
219
+ const incoming = clampText(src.incoming, CONFLICT_FIELD_LIMITS.incoming)
220
+ const onDisk = clampText(src.onDisk, CONFLICT_FIELD_LIMITS.onDisk)
221
+ const note = clampText(src.note, CONFLICT_FIELD_LIMITS.note)
222
+ const tool = clampText(src.tool, CONFLICT_FIELD_LIMITS.tool)
223
+ const expectedVersion = clampText(versionText(src.expectedVersion), CONFLICT_FIELD_LIMITS.version)
224
+ const actualVersion = clampText(versionText(src.actualVersion), CONFLICT_FIELD_LIMITS.version)
225
+
226
+ const truncated =
227
+ file.cut || incoming.cut || onDisk.cut || note.cut || tool.cut || expectedVersion.cut || actualVersion.cut
228
+
229
+ const stamp = compactStamp(at)
230
+ const digest = shortDigest([kind, file.text, expectedVersion.text, actualVersion.text, incoming.text, onDisk.text])
231
+ const id = normalizeConflictId(src.id) ?? `${stamp === '' ? '' : `${stamp}-`}${digest}`
232
+
233
+ return {
234
+ v: 1,
235
+ id,
236
+ kind,
237
+ status: 'open',
238
+ file: file.text,
239
+ expectedVersion: expectedVersion.text,
240
+ actualVersion: actualVersion.text,
241
+ at,
242
+ incoming: incoming.text,
243
+ onDisk: onDisk.text,
244
+ note: note.text,
245
+ tool: tool.text,
246
+ truncated,
247
+ }
248
+ }
249
+
250
+ /** 记录 → 文件正文。紧凑 JSON + 一个尾换行:状态目录是给程序读的,行数少一点更好 diff。 */
251
+ function serialize(record) {
252
+ try {
253
+ return `${JSON.stringify(record)}\n`
254
+ } catch {
255
+ // 循环引用 / BigInt 会让 JSON.stringify 抛 —— 契约要求「所有失败都返回对象」,所以在这里收住。
256
+ return null
257
+ }
258
+ }
259
+
260
+ /**
261
+ * 整份记录超上限时,按字节削两个正文字段(两份都保留**头部**,只削当前最大的那份)。
262
+ *
263
+ * 为什么要迭代而不是一次算准:JSON 转义会让同一段文本的落盘字节数变多(引号、反斜杠、
264
+ * 控制字符最多膨胀到 6 倍),所以「预算」只能边算边收。步数有界:每轮至少削 1 字节,
265
+ * 削到两个字段都空还放不下就放弃(返回 null,由调用方报 too_big,绝不静默写半份)。
266
+ */
267
+ function fitBodies(record, limit) {
268
+ const bodyOf = (k) => (typeof record[k] === 'string' ? record[k] : '')
269
+ const originals = [bodyOf('incoming'), bodyOf('onDisk')]
270
+ const keep = originals.map((s) => Buffer.byteLength(s, 'utf8'))
271
+ for (let round = 0; round < 128; round++) {
272
+ const trial = {
273
+ ...record,
274
+ incoming: truncateByBytes(originals[0], keep[0]),
275
+ onDisk: truncateByBytes(originals[1], keep[1]),
276
+ truncated: true,
277
+ }
278
+ const text = serialize(trial)
279
+ if (text === null) return null
280
+ const bytes = Buffer.byteLength(text, 'utf8')
281
+ if (bytes <= limit) return text
282
+ // 每轮只削**当前最大的**那个正文字段:撑爆预算的是大的那份;两份一起削会把短的那份
283
+ // 先削没,而短的那份往往正是用户想保留的 `incoming`。两份都空了还放不下 = 放弃。
284
+ let largest = -1
285
+ for (let i = 0; i < keep.length; i++) {
286
+ if (keep[i] > 0 && (largest === -1 || keep[i] > keep[largest])) largest = i
287
+ }
288
+ if (largest === -1) return null
289
+ keep[largest] = Math.max(0, keep[largest] - Math.max(1, Math.ceil((bytes - limit) / 2)))
290
+ }
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * 落盘一条冲突记录(原子写,复用 `util.js` 的 `writeFileAtomic`)。
296
+ *
297
+ * ⚠️ **并发:同一个 id 由调用方串行**(进程内 `createWriteQueue`)—— 本模块不做锁,
298
+ * 因为跨进程锁的成本与误伤面都不划算,而真实并发都发生在同一个插件进程里。不同 id
299
+ * 各写各的文件,互不影响。
300
+ *
301
+ * 超 `CONFLICT_MAX_BYTES` 时**按字节截断两个正文字段**并置 `truncated: true`(而不是拒收:
302
+ * 一条被截断的冲突记录仍然是人可以判定的,一份写不进去的记录才是真的丢了)。
303
+ * 只有「削完正文还放不下」(元信息本身畸形地大)才报 `too_big`。
304
+ *
305
+ * 本函数**不校验记录的内容**(除了 id —— 路径需要它):校验的落点在读的那一端,
306
+ * 写路径只守体积与原子性。
307
+ *
308
+ * @param {object} record
309
+ * @param {{stateRoot?: string}} [opts]
310
+ * @returns {Promise<{ok:true, bytes:number, path:string, truncated:boolean}
311
+ * | {ok:false, code:'invalid'|'too_big'|'io_error', ...}>}
312
+ */
313
+ export async function writeConflict(record, opts) {
314
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
315
+ return { ok: false, code: 'invalid', error: '冲突记录必须是一个对象' }
316
+ }
317
+ const id = normalizeConflictId(record.id)
318
+ if (id === null) return { ok: false, code: 'invalid', error: `冲突 id 非法:${JSON.stringify(record.id)}` }
319
+
320
+ const path = conflictPathFor(id, opts && typeof opts === 'object' ? opts.stateRoot : undefined)
321
+
322
+ let text = serialize(record)
323
+ if (text === null) return { ok: false, code: 'invalid', error: '冲突记录无法序列化(循环引用或非法值)' }
324
+ let truncated = record.truncated === true
325
+ if (Buffer.byteLength(text, 'utf8') > CONFLICT_MAX_BYTES) {
326
+ const fitted = fitBodies(record, CONFLICT_MAX_BYTES)
327
+ if (fitted === null) {
328
+ return {
329
+ ok: false,
330
+ code: 'too_big',
331
+ bytes: Buffer.byteLength(text, 'utf8'),
332
+ limit: CONFLICT_MAX_BYTES,
333
+ error: `冲突记录超过 ${CONFLICT_MAX_BYTES} 字节,且削完两个正文字段仍放不下`,
334
+ }
335
+ }
336
+ text = fitted
337
+ truncated = true
338
+ }
339
+
340
+ try {
341
+ await mkdir(dirname(path), { recursive: true })
342
+ await writeFileAtomic(path, text)
343
+ } catch (err) {
344
+ return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
345
+ }
346
+ return { ok: true, bytes: Buffer.byteLength(text, 'utf8'), path, truncated }
347
+ }
348
+
349
+ /**
350
+ * 解析一条记录的正文。形状不对就报原因 —— 判据是「读不读得出这是哪条冲突」:
351
+ * 必须有非空 `id`,`status` 必须是已知状态。`kind` 允许未知(老记录 / 别处写的记录照读)。
352
+ */
353
+ function parseConflictText(text) {
354
+ let obj
355
+ try {
356
+ obj = JSON.parse(text)
357
+ } catch (err) {
358
+ return { ok: false, reason: `JSON 解析失败:${err?.message ?? err}` }
359
+ }
360
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, reason: '不是一个 JSON 对象' }
361
+ if (typeof obj.id !== 'string' || obj.id === '') return { ok: false, reason: '缺少 id' }
362
+ if (!CONFLICT_STATUSES.includes(obj.status)) {
363
+ return { ok: false, reason: `status 不是已知状态:${JSON.stringify(obj.status)}` }
364
+ }
365
+ return { ok: true, record: obj }
366
+ }
367
+
368
+ /**
369
+ * 读一条冲突记录。**所有失败都返回对象,绝不抛**。
370
+ *
371
+ * 文件名才是身份:文件里声明的 id 与文件名不一致时,以文件名为准并给出警告
372
+ * (不静默采信标签,也不把整条记录判成坏 —— 内容还在,人还能判定)。
373
+ *
374
+ * @returns {Promise<{ok:true, path, bytes, record, warnings:string[]}
375
+ * | {ok:false, code:'invalid'|'missing'|'io_error'|'malformed', path?, error}>}
376
+ */
377
+ export async function readConflict(id, opts) {
378
+ const cid = normalizeConflictId(id)
379
+ if (cid === null) return { ok: false, code: 'invalid', error: `冲突 id 非法:${JSON.stringify(id)}` }
380
+
381
+ const path = conflictPathFor(cid, opts && typeof opts === 'object' ? opts.stateRoot : undefined)
382
+ let text
383
+ try {
384
+ text = await readFile(path, 'utf8')
385
+ } catch (err) {
386
+ if (err?.code === 'ENOENT') return { ok: false, code: 'missing', path, error: '冲突记录不存在' }
387
+ return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
388
+ }
389
+
390
+ const parsed = parseConflictText(text)
391
+ if (!parsed.ok) return { ok: false, code: 'malformed', path, error: parsed.reason }
392
+
393
+ const record = { ...parsed.record, id: cid }
394
+ const warnings = []
395
+ if (parsed.record.id !== cid) {
396
+ warnings.push(`文件内声明的冲突 id(${parsed.record.id})与文件名(${cid})不一致,已按文件名归一`)
397
+ }
398
+ return { ok: true, path, bytes: Buffer.byteLength(text, 'utf8'), record, warnings }
399
+ }
400
+
401
+ /** 稳定比较:不用 localeCompare(它跟 ICU 走,跨环境可能给出不同顺序)。 */
402
+ function cmp(a, b) {
403
+ const x = String(a ?? '')
404
+ const y = String(b ?? '')
405
+ return x < y ? -1 : x > y ? 1 : 0
406
+ }
407
+
408
+ /**
409
+ * 列出全部冲突(只读目录)。
410
+ *
411
+ * 一个坏文件**绝不能**让整张列表打不开 —— 那等于「有一条冲突坏了,面板上什么都看不见」。
412
+ * 所以坏文件只记名字进 `broken`,能读的照列;非 `.json` 的文件(编辑器临时文件、别的模块的垃圾)
413
+ * 进 `ignored`,一样报告出来但不当成冲突。
414
+ *
415
+ * 目录不存在**不是错误**(还没发生过冲突):返回空列表。
416
+ *
417
+ * @returns {Promise<{ok:true, items:Array, broken:string[], ignored:string[], dir:string}
418
+ * | {ok:false, code:'io_error', dir, error}>}
419
+ */
420
+ export async function listConflicts(opts) {
421
+ const dir = conflictsDir(opts && typeof opts === 'object' ? opts.stateRoot : undefined)
422
+ let names
423
+ try {
424
+ names = await readdir(dir)
425
+ } catch (err) {
426
+ if (err?.code === 'ENOENT') return { ok: true, items: [], broken: [], ignored: [], dir }
427
+ return { ok: false, code: 'io_error', dir, error: String(err?.message ?? err) }
428
+ }
429
+
430
+ const items = []
431
+ const broken = []
432
+ const ignored = []
433
+ // readdir 的顺序不保证:先排序,让 broken / ignored 这两个「报告用」的数组也逐次一致。
434
+ for (const name of [...names].sort(cmp)) {
435
+ if (!name.endsWith(CONFLICT_FILE_SUFFIX)) {
436
+ ignored.push(name)
437
+ continue
438
+ }
439
+ const id = name.slice(0, -CONFLICT_FILE_SUFFIX.length)
440
+ if (normalizeConflictId(id) === null) {
441
+ broken.push(name)
442
+ continue
443
+ }
444
+ let text
445
+ try {
446
+ text = await readFile(join(dir, name), 'utf8')
447
+ } catch {
448
+ broken.push(name)
449
+ continue
450
+ }
451
+ const parsed = parseConflictText(text)
452
+ if (!parsed.ok) {
453
+ broken.push(name)
454
+ continue
455
+ }
456
+ items.push({ ...parsed.record, id })
457
+ }
458
+
459
+ // 新的在前(队列的第一眼应该是最新那条);没有 at 的排在后面,同档按 id 排,保证顺序确定。
460
+ items.sort((a, b) => cmp(String(b.at ?? ''), String(a.at ?? '')) || cmp(a.id, b.id))
461
+ return { ok: true, items, broken, ignored, dir }
462
+ }
463
+
464
+ /**
465
+ * 改状态并落盘。
466
+ *
467
+ * 状态机见 `CONFLICT_TRANSITIONS`:`open` 只能去 `resolved` / `dismissed`,那两个之间可以互转,
468
+ * **都不许回到 `open`**(要重开就新建一条)。同状态重复点是**幂等**的 —— 界面上连点两下
469
+ * 不该报错,也不重写文件。
470
+ *
471
+ * @returns {Promise<{ok:true, record:object} | {ok:false, code:'invalid'|'invalid_transition'|'missing'|'malformed'|'io_error', error?}>}
472
+ */
473
+ export async function resolveConflict(id, status, opts) {
474
+ const cid = normalizeConflictId(id)
475
+ if (cid === null) return { ok: false, code: 'invalid', error: `冲突 id 非法:${JSON.stringify(id)}` }
476
+ if (!CONFLICT_STATUSES.includes(status)) {
477
+ return { ok: false, code: 'invalid', error: `未知状态:${JSON.stringify(status)}` }
478
+ }
479
+
480
+ const read = await readConflict(cid, opts)
481
+ if (!read.ok) return { ok: false, code: read.code, error: read.error }
482
+
483
+ const from = read.record.status
484
+ if (from === status) return { ok: true, record: read.record }
485
+ const allowed = CONFLICT_TRANSITIONS[from] ?? []
486
+ if (!allowed.includes(status)) {
487
+ return {
488
+ ok: false,
489
+ code: 'invalid_transition',
490
+ error: `${from} → ${status} 不被允许(已判定过的记录不许回到 open,要重开请新建一条)`,
491
+ }
492
+ }
493
+
494
+ const next = { ...read.record, status }
495
+ const written = await writeConflict(next, opts)
496
+ if (!written.ok) return { ok: false, code: written.code, error: written.error }
497
+ return { ok: true, record: next }
498
+ }
499
+
500
+ /**
501
+ * 删一条(清理)。
502
+ *
503
+ * 刻意**不看状态**:拦截「删掉一条还没处理的冲突」是面板的确认动作,不是数据层的事 ——
504
+ * 数据层再拦一道只会让「用户明确要删」变成删不掉。文件不存在 → `missing`。
505
+ *
506
+ * @returns {Promise<{ok:true, path:string} | {ok:false, code:'invalid'|'missing'|'io_error', path?, error}>}
507
+ */
508
+ export async function removeConflict(id, opts) {
509
+ const cid = normalizeConflictId(id)
510
+ if (cid === null) return { ok: false, code: 'invalid', error: `冲突 id 非法:${JSON.stringify(id)}` }
511
+
512
+ const path = conflictPathFor(cid, opts && typeof opts === 'object' ? opts.stateRoot : undefined)
513
+ try {
514
+ await unlink(path)
515
+ } catch (err) {
516
+ if (err?.code === 'ENOENT') return { ok: false, code: 'missing', path, error: '冲突记录不存在' }
517
+ return { ok: false, code: 'io_error', path, error: String(err?.message ?? err) }
518
+ }
519
+ return { ok: true, path }
520
+ }
@@ -49,6 +49,11 @@
49
49
  * - `dir` = **规范化后的绝对路径**,只在「值指向记忆目录里的一个目录(含记忆目录本身)」时给出。
50
50
  */
51
51
 
52
+ // 路径身份判据**抽到 paths.js 共用**:写入门(write-gate.js)要回答同一个问题
53
+ // 「这个路径在不在记忆目录里」。两处各写一份迟早漂成两套判据,而漂移的后果不是报错,
54
+ // 是「看起来很勤快、实际判错了对象」。注意 paths.js 同样不依赖 node:path。
55
+ import { canonicalPathOf, isSameOrInside, fileNameOf, isRootLike } from './paths.js'
56
+
52
57
  /** 认得的读工具 → 取路径的参数名。表是**数据**:调用方与测试都直接读它,不写第二份分支。 */
53
58
  export const READ_TOOLS = Object.freeze({ read: 'file_path', grep: 'path', glob: 'path' })
54
59
 
@@ -76,80 +81,6 @@ const WRITE_PATH_ARG = 'file_path'
76
81
  /** 只有这两种事件有「哪个文件」这个键,才进得了以文件名为键的台账。 */
77
82
  const RECORDABLE_KINDS = new Set(['active_read', 'write'])
78
83
 
79
- /**
80
- * 把路径归一成规范形式;**不是绝对路径就返回 null**(调用方一律按「不认」处理)。
81
- *
82
- * 为什么自己写而不用 `path.resolve`:`path.resolve` 在 Windows 上把 `\` 当分隔符、在 Linux 上
83
- * 不当,于是同一份输入两个平台给出不同结果 —— 而记忆目录会被同步 / 拷贝 / 备份还原,判据必须
84
- * 跨平台一致。这里的规则只依赖输入的**形状**,不依赖 `path.sep`。
85
- *
86
- * `..` 到根为止(绝对路径在根上的 `..` 是空操作,POSIX 与 Windows 同义);不作磁盘解析,
87
- * 所以符号链接意义上的「真实位置」不保证被还原 —— 见文件头的残留边界。
88
- */
89
- function canonicalPathOf(raw) {
90
- if (typeof raw !== 'string' || raw === '') return null
91
- const isDrive = /^[A-Za-z]:[\\/]/.test(raw)
92
- // UNC(`\\server\share…` / `//server/share…`)保留双斜杠前缀:它和「根下的同名目录」不是
93
- // 一回事,合并成一个前缀会把两个不同的位置判成同一个(那才是误记)。
94
- const isUnc = !isDrive && (raw.startsWith('//') || raw.startsWith('\\\\'))
95
- let prefix
96
- let rest
97
- if (isDrive) {
98
- // 盘符统一小写:Windows 上 `C:` 与 `c:` 必然同义;POSIX 上这种形状不是绝对路径,到不了这里。
99
- prefix = `${raw[0].toLowerCase()}:`
100
- rest = raw.slice(2)
101
- } else if (isUnc) {
102
- prefix = '//'
103
- rest = raw.slice(2)
104
- } else if (raw.startsWith('/')) {
105
- prefix = '/'
106
- rest = raw.slice(1)
107
- } else {
108
- // `\x`(Windows 当前盘的根)、`a/b`(相对 cwd)、`C:foo`(盘符相对)都在这里被拒:
109
- // 它们的真实落点取决于运行时状态,纯函数看不到,猜一个基准就是误记。
110
- return null
111
- }
112
- const segs = []
113
- for (const seg of rest.split(/[\\/]+/)) {
114
- if (seg === '' || seg === '.') continue
115
- if (seg === '..') {
116
- if (segs.length) segs.pop()
117
- continue
118
- }
119
- segs.push(seg)
120
- }
121
- if (prefix === '/') return segs.length ? `/${segs.join('/')}` : '/'
122
- if (prefix === '//') return segs.length ? `//${segs.join('/')}` : '//'
123
- return segs.length ? `${prefix}/${segs.join('/')}` : `${prefix}/`
124
- }
125
-
126
- /**
127
- * 段边界包含:`/mem` 含 `/mem/x.md` 与它自己,**不含** `/memories/x.md`。
128
- *
129
- * 判据是「父目录 + 分隔符」,不是裸字符串前缀 —— 裸前缀正是那个经典陷阱。
130
- */
131
- function isSameOrInside(parent, child) {
132
- if (parent === child) return true
133
- const boundary = parent.endsWith('/') ? parent : `${parent}/`
134
- return child.startsWith(boundary)
135
- }
136
-
137
- /** 规范路径的最后一段(`file` 字段要的是记忆文件名,不是路径)。 */
138
- function fileNameOf(canon) {
139
- const cut = canon.lastIndexOf('/')
140
- return cut < 0 ? canon : canon.slice(cut + 1)
141
- }
142
-
143
- /**
144
- * 根目录不算记忆目录。
145
- *
146
- * 拿根(`/`、`//`、`X:/`)做包含判定,等于把整块盘都算成记忆 —— 一条配置写错就能把台账
147
- * 灌满「读了别处」的记录。宁可不判。
148
- */
149
- function isRootLike(canon) {
150
- return canon === '/' || canon === '//' || /^[a-z]:\/$/.test(canon)
151
- }
152
-
153
84
  /** 结果工厂:保证四个字段恒在,调用方不必到处判 undefined。 */
154
85
  function verdict(kind, why) {
155
86
  return { kind, file: null, dir: null, why }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * sage-mem 记忆层 —— **路径身份**的唯一基准(归一 + 包含判定)。
3
+ *
4
+ * 为什么要有这个文件:观测(`observe.js`)与写入门(`write-gate.js`)都要回答同一个问题 ——
5
+ * 「这个路径在不在记忆目录里」。两处各写一份,迟早漂成两套判据;而那类漂移的后果不是报错,
6
+ * 是**看起来很勤快、实际判错了对象**(`access.js` 的「扫 A 目录写 B 台账」就是这一类)。
7
+ * 与 `naming.js`(文件名身份基准)同一个思路,只是这里管的是路径。
8
+ *
9
+ * ⚠️ **判据只依赖输入的形状,绝不依赖 `path.sep` / `path.resolve`。**
10
+ * `path.resolve` 在 Windows 上把 `\` 当分隔符、在 Linux 上不当,于是同一份输入在两个平台上
11
+ * 得到不同结论 —— 而记忆目录是会被同步 / 拷贝 / 备份还原的。本仓库已经因此被 Linux CI 抓过
12
+ * 一次(`safeName` 的 `basename` 判据),不要再犯同一类。
13
+ */
14
+
15
+ /**
16
+ * 把路径归一成规范形式;**不是绝对路径就返回 null**(调用方一律按「不认」处理)。
17
+ *
18
+ * 规则:只接受绝对路径(`/…`、`//server/share…`、`X:/…`)→ `\` 与 `/` **一律**当分隔符 →
19
+ * 折叠 `.`、连续分隔符与逐段回退 `..`(绝对路径在根上的 `..` 是空操作)→ 盘符小写 →
20
+ * 其余**大小写敏感**。不做磁盘解析,所以符号链接意义上的「真实位置」不保证被还原。
21
+ */
22
+ export function canonicalPathOf(raw) {
23
+ if (typeof raw !== 'string' || raw === '') return null
24
+ const isDrive = /^[A-Za-z]:[\\/]/.test(raw)
25
+ // UNC(`\\server\share…` / `//server/share…`)保留双斜杠前缀:它和「根下的同名目录」不是
26
+ // 一回事,合并成一个前缀会把两个不同的位置判成同一个(那才是误判)。
27
+ const isUnc = !isDrive && (raw.startsWith('//') || raw.startsWith('\\\\'))
28
+ let prefix
29
+ let rest
30
+ if (isDrive) {
31
+ // 盘符统一小写:Windows 上 `C:` 与 `c:` 必然同义;POSIX 上这种形状不是绝对路径,到不了这里。
32
+ prefix = `${raw[0].toLowerCase()}:`
33
+ rest = raw.slice(2)
34
+ } else if (isUnc) {
35
+ prefix = '//'
36
+ rest = raw.slice(2)
37
+ } else if (raw.startsWith('/')) {
38
+ prefix = '/'
39
+ rest = raw.slice(1)
40
+ } else {
41
+ // `\x`(Windows 当前盘的根)、`a/b`(相对 cwd)、`C:foo`(盘符相对)都在这里被拒:
42
+ // 它们的真实落点取决于运行时状态,纯函数看不到,猜一个基准就是误判。
43
+ return null
44
+ }
45
+ const segs = []
46
+ for (const seg of rest.split(/[\\/]+/)) {
47
+ if (seg === '' || seg === '.') continue
48
+ if (seg === '..') {
49
+ if (segs.length) segs.pop()
50
+ continue
51
+ }
52
+ segs.push(seg)
53
+ }
54
+ if (prefix === '/') return segs.length ? `/${segs.join('/')}` : '/'
55
+ if (prefix === '//') return segs.length ? `//${segs.join('/')}` : '//'
56
+ return segs.length ? `${prefix}/${segs.join('/')}` : `${prefix}/`
57
+ }
58
+
59
+ /**
60
+ * 段边界包含:`/mem` 含 `/mem/x.md` 与它自己,**不含** `/memories/x.md`。
61
+ *
62
+ * 判据是「父目录 + 分隔符」,不是裸字符串前缀 —— 裸前缀正是那个经典陷阱。
63
+ */
64
+ export function isSameOrInside(parent, child) {
65
+ if (parent === child) return true
66
+ const boundary = parent.endsWith('/') ? parent : `${parent}/`
67
+ return child.startsWith(boundary)
68
+ }
69
+
70
+ /** 规范路径的最后一段(要文件名而不是路径时用它)。 */
71
+ export function fileNameOf(canon) {
72
+ const cut = canon.lastIndexOf('/')
73
+ return cut < 0 ? canon : canon.slice(cut + 1)
74
+ }
75
+
76
+ /**
77
+ * 根目录不算记忆目录。
78
+ *
79
+ * 拿根(`/`、`//`、`X:/`)做包含判定,等于把整块盘都算成记忆 —— 一条配置写错就能把台账
80
+ * 灌满「读了别处」的记录,或者把整个盘的写入都套上守卫。宁可不判。
81
+ */
82
+ export function isRootLike(canon) {
83
+ return canon === '/' || canon === '//' || /^[a-z]:\/$/.test(canon)
84
+ }
85
+
86
+ /**
87
+ * 一步到位:这个路径是不是**记忆目录里的**条目(含目录本身时 `includeSelf` 决定)。
88
+ *
89
+ * 三条拒绝理由都返回 `{ok:false, why}` 而不是抛 —— 调用方在事件回调里,抛出去会打断
90
+ * 宿主的写入链路。
91
+ *
92
+ * @returns {{ok:true, canon:string, name:string} | {ok:false, why:string}}
93
+ */
94
+ export function memoryPathOf(raw, memoryDir, opts = {}) {
95
+ const canon = canonicalPathOf(raw)
96
+ if (canon === null) return { ok: false, why: '不是绝对路径(相对路径的真实落点取决于运行时状态,不猜)' }
97
+ const root = canonicalPathOf(memoryDir)
98
+ if (root === null) return { ok: false, why: '记忆目录本身不是绝对路径' }
99
+ if (isRootLike(root)) return { ok: false, why: '记忆目录是文件系统根:不做包含判定' }
100
+ if (!isSameOrInside(root, canon)) return { ok: false, why: '路径不在记忆目录内' }
101
+ if (canon === root) return opts.includeSelf ? { ok: true, canon, name: '' } : { ok: false, why: '目标是记忆目录本身' }
102
+ return { ok: true, canon, name: fileNameOf(canon) }
103
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * sage-mem 记忆层 —— 写入门:给记忆目录的写入补上**版本守卫**。
3
+ *
4
+ * ## 它为什么存在
5
+ *
6
+ * 宿主的 `fs/write-intent` / `fs/edit-intent` 会**在写入之前**问插件一次:「这次写入按什么
7
+ * 意图做」。没有插件回答时,一次「读-改-写」之间只要有人插进来写过,后写的那个就**静默**
8
+ * 把前一个覆盖掉 —— 谁也不报错,谁也不知情。
9
+ *
10
+ * 本模块的回答是:目标在记忆目录内、且我们已经知道它的当前版本时,把这次写入钉在
11
+ * `replaceIfVersion(当前版本)` 上。于是并发写入不再互踩,而是由宿主报出 `FS_STALE_VERSION`,
12
+ * 交给上层(冲突队列 / 人)决定怎么办。
13
+ *
14
+ * ## ⚠️ 两个事件都**没有「拒绝」这个动作**
15
+ *
16
+ * 它们是**单槽意图决策**:只能决定写入**意图**(`createIfAbsent` / `replaceIfVersion`)或编辑
17
+ * **守卫**(`{version}`),不能阻止写入。所以这里**没有 deny、没有 null、没有抛错**——
18
+ * 只有「给意图」「给守卫」和「不管」(返回 `undefined`)。唯一的失败信号是宿主自己报的
19
+ * `FS_STALE_VERSION`,我们只需保证它报得出来。
20
+ *
21
+ * ## ⚠️ 这个事件只覆盖「工具发起的写入」(真实 headless 宿主实测)
22
+ *
23
+ * 宿主对这两个事件的派发范围比直觉窄:
24
+ *
25
+ * - **模型调内置 `write` / `edit`** 时派发(实测 `fs/write-intent = 1`:模型用 `write` 写了一个
26
+ * 临时文件;监听器不需要声明 `inject` 也能收到,与 sage-mem 主插件的处境一致)。
27
+ * - **插件自己调 `ctx.fs.writeText` / `editText` 时一次都不派发**(实测 0)。所以 autodream
28
+ * 那类**插件内部写入不经过这里**,本模块也不假装能兜底它 —— 那条路由调用方自己传
29
+ * `expected`(`lib/autodream-tools.js` 就是这么做的)。
30
+ *
31
+ * 由此两条硬结论:
32
+ *
33
+ * 1. 这道门**确实拦得到**「模型直接改记忆文件」的写入 —— 这是它存在的意义,不是空想。
34
+ * 2. 宿主的派发范围是**整个盘**(实测那一次的目标就在记忆目录之外),所以 `facts.memoryPath`
35
+ * 的过滤是**唯一**一道屏障:没有它,我们就会去干涉整个盘的写入。这就是判定表把
36
+ * `outside_memory` 排在第一位、且必须返回 `undefined` 的原因。
37
+ *
38
+ * ⚠️ **集成侧的放行方式**:本模块给出 `undefined`(不管)时,监听器应当 `return next()` 把链
39
+ * 放下去,**不要只计数不返回** —— 实测 `ctx.on(waterfallEvent, () => count++)` 会让宿主拿到
40
+ * `undefined` 后去读它的 `.contexts`,整轮对话崩在 `Cannot read properties of undefined
41
+ * (reading 'contexts')`。本模块不做订阅,只算结论。
42
+ *
43
+ * ## 判定表(顺序即优先级)
44
+ *
45
+ * | 条件 | reason | 返回 |
46
+ * |---|---|---|
47
+ * | 不是记忆目录里的路径(含目录本身、相对路径、根目录) | `outside_memory` | `undefined` |
48
+ * | 调用方自己已经给了意图 / 守卫 | `caller_wins` | **原样透传**(同一个对象,不复制不改写) |
49
+ * | 明确知道目标不存在(`exists === false`) | `created` | 写入:`createIfAbsent`;编辑:无守卫 |
50
+ * | 不确知存在(`exists` 缺失 / 非布尔)且拿不到版本 | `no_version` | 写入:`createIfAbsent`;编辑:无守卫 |
51
+ * | 存在且有版本 | `guarded` | 写入:`replaceIfVersion(版本)`;编辑:`{version: 版本}` |
52
+ * | 其余(存在但没有可用的版本等) | `unknown` | `undefined`(**不猜**) |
53
+ *
54
+ * ## 三个容易做反的地方(都写进了断言)
55
+ *
56
+ * 1. **`outside_memory` 判在最前面,连「调用方给了什么」都不看。**
57
+ * 单槽的含义是「第一个返回值的监听器拥有决策权」——所以在目录外**返回任何值**(哪怕只是
58
+ * 原样回传调用方的意图)都会把这个槽占掉,后面的监听器再没有机会。只有返回 `undefined`
59
+ * 才是字面意义上的「整个盘的其它写入完全不受影响」。目录内才轮到「尊重调用方」。
60
+ * 2. **存在但没有版本 → `unknown`,绝不退化成 `createIfAbsent`。**
61
+ * 这两件事的行为完全不同:`replaceIfVersion` 是「覆盖这份内容,除非它已经变了」,
62
+ * `createIfAbsent` 是「只在它不存在时才写」。退化等于把一次覆盖悄悄变成一次「已存在就失败」,
63
+ * 而调用方拿到的理由会是错的。
64
+ * 3. **版本是不透明值**:不解析、不比较大小、不当数字用,只做「是不是非空字符串」的形状检查
65
+ * 然后**原样**放进意图里。宿主给什么就是什么。
66
+ *
67
+ * ## ⚠️ `exists` 必须是**观测到的**事实,不是回落值(接线实测踩到过)
68
+ *
69
+ * `exists !== true` 那条分支给的是 `createIfAbsent` —— 它是这里唯一不可能**静默覆盖**的意图,
70
+ * 但目标其实存在时它是一次**误拦**:写入必然失败,而调用方(模型)拿到的是一个它无从理解的
71
+ * 错误,它本来只是想改一份自己刚读过的记忆。
72
+ *
73
+ * 生产接线踩到的正是这里:原来写成 `exists: !!info`,于是「没有 `fs` 服务 / stat 抛错」这种
74
+ * **探不到**被压成了 `false` —— 一个看起来像事实的回落值,与 `access.js` 那次「扫 A 目录写 B
75
+ * 台账」属于同一类错误。
76
+ *
77
+ * 所以:`exists: false` 只允许表示「**明确探到不存在**」(stat 的 ENOENT)。**探不到**不是
78
+ * `false`,是「不知道」—— 那种情况**不要调用本模块**,接线应自行短路成「不碰」(生产路径
79
+ * `lib/index.js` 就是这么做的:探不到就把调用方的意图原样交回)。判定表对「调用方没给
80
+ * `exists`」仍然有确定的答案(`no_version`,见下),但那是**契约的确定性**,不是鼓励拿
81
+ * 「不知道」去换一个意图。
82
+ *
83
+ * `facts.toolWrite`(是否来自内置 `write` / `edit` 工具)**不参与判定**:判据只有四个
84
+ * 「在不在目录内 / 确不确知存在 / 有没有版本 / 调用方给没给」。把「谁在写」也变成分支,
85
+ * 就是同一件事的第二套判据。它留在 facts 里供调用方记录与排查。
86
+ *
87
+ * 路径判据一律走 `paths.js`(与 `observe.js` 同一个函数),**本模块不写第二份**。
88
+ */
89
+
90
+ import { memoryPathOf } from './paths.js'
91
+
92
+ export const GATE_REASONS = Object.freeze([
93
+ 'outside_memory', // 目标不在记忆目录内 → 不管
94
+ 'no_version', // 目标不存在且拿不到版本 → createIfAbsent
95
+ 'created', // 目标不存在 → createIfAbsent
96
+ 'guarded', // 目标存在 → replaceIfVersion(version)
97
+ 'caller_wins', // 调用方自己已经给了意图/守卫 → 原样透传(**尊重调用方**)
98
+ 'unknown', // 信息不足 → 返回 undefined(不猜)
99
+ ])
100
+
101
+ /** 目标位置的三种结论:在目录内 / 不在目录内 / 说不清。 */
102
+ function locationOf(facts) {
103
+ const mp = facts.memoryPath
104
+ // 主契约:调用方已经把 paths.js 的 memoryPathOf 结果算好了。只认带布尔 ok 的对象,
105
+ // 免得一个形状不对的字段被当成「不在目录内」(那是把一个错误悄悄读成一个结论)。
106
+ if (mp && typeof mp === 'object' && typeof mp.ok === 'boolean') {
107
+ return { inside: mp.ok === true }
108
+ }
109
+ // 便捷入口:只给了原始路径就现算 —— 仍然走 paths.js 的**同一个** memoryPathOf,
110
+ // 所以两条路不可能判出两个结果(这正是把判据抽出去的目的)。
111
+ if (typeof facts.path === 'string' && facts.path !== '') {
112
+ return { inside: memoryPathOf(facts.path, facts.memoryDir).ok === true }
113
+ }
114
+ return { unknown: true }
115
+ }
116
+
117
+ /**
118
+ * 核心判定:把 facts 压成「给什么 / 为什么」。
119
+ *
120
+ * 两个公开函数(写入意图、编辑守卫)共用它,是为了让 reason 的含义在两条路上完全一致 ——
121
+ * 分成两份实现,迟早出现「同一个事实在写入侧叫 created、在编辑侧叫 unknown」这种漂移。
122
+ *
123
+ * @returns {{reason:string, caller?:*, create?:boolean, version?:string}}
124
+ */
125
+ function gate(facts) {
126
+ if (!facts || typeof facts !== 'object') return { reason: 'unknown' }
127
+
128
+ const where = locationOf(facts)
129
+ if (where.unknown) return { reason: 'unknown' }
130
+ // 先判位置,理由见文件头第 1 条:目录外返回任何值都会占掉单槽。
131
+ if (!where.inside) return { reason: 'outside_memory' }
132
+
133
+ // 尊重调用方:他给了就原样透传,我们只是**补充**守卫的,不覆盖他的意图。
134
+ // `null` 与 `undefined` 一起当「没给」—— 宿主的契约里「没给」就是 undefined。
135
+ if (facts.caller !== undefined && facts.caller !== null) {
136
+ return { reason: 'caller_wins', caller: facts.caller }
137
+ }
138
+
139
+ // 只认严格的 `exists === true` 为「确知存在」;缺失 / 非布尔一律按「不确知」处理(不猜)。
140
+ if (facts.exists !== true) {
141
+ // 「明确不存在」与「不确知存在」给不同 reason:前者是确定的事实,后者是信息不足,
142
+ // 但两者都只能给 createIfAbsent —— 它是这里唯一**不可能静默覆盖**的意图。
143
+ // ⚠️ 正因为这个分支会 createIfAbsent,接线**不许**把「探不到」写成 `exists: false`
144
+ // (那是误拦,见文件头那节);`no_version` 是给「调用方确实没给这个字段」的确定答案。
145
+ return facts.exists === false ? { reason: 'created', create: true } : { reason: 'no_version', create: true }
146
+ }
147
+
148
+ // 版本是不透明的非空字符串。空串 / 非字符串一律当「拿不到版本」:拿一个形状不对的东西
149
+ // 去构造 replaceIfVersion,只会换来宿主一句看不懂的报错。
150
+ const version = typeof facts.version === 'string' && facts.version !== '' ? facts.version : null
151
+ if (version === null) return { reason: 'unknown' }
152
+ return { reason: 'guarded', version }
153
+ }
154
+
155
+ /**
156
+ * 决定一次**整份写入**的意图。纯函数:只吃已观测到的事实,不读盘、不看时钟、不抛。
157
+ *
158
+ * @param {object} facts `{memoryPath, exists, version, caller, toolWrite?, path?, memoryDir?}`
159
+ * @returns {{intent: {kind:'createIfAbsent'}|{kind:'replaceIfVersion',version:string}|undefined, reason:string}}
160
+ * `undefined` = 不管 / 不猜(**不是拒绝**:宿主照它自己的默认继续)。
161
+ */
162
+ export function decideWriteIntent(facts) {
163
+ try {
164
+ const d = gate(facts)
165
+ if (d.reason === 'caller_wins') return { intent: d.caller, reason: d.reason }
166
+ if (d.create) return { intent: { kind: 'createIfAbsent' }, reason: d.reason }
167
+ if (d.reason === 'guarded') return { intent: { kind: 'replaceIfVersion', version: d.version }, reason: d.reason }
168
+ return { intent: undefined, reason: d.reason }
169
+ } catch {
170
+ // 调用方的 facts 可能来自带抛错 getter 的对象。写入门在宿主的写入链路上,
171
+ // 抛出去会把一次正常写入炸掉 —— 任何意外都退化成「不管」,让写入按默认语义走。
172
+ return { intent: undefined, reason: 'unknown' }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 决定一次**编辑**的守卫。与 `decideWriteIntent` 同一张判定表,只是输出改成一个版本守卫。
178
+ *
179
+ * 编辑没有「创建」这个动作,所以 `created` / `no_version` 两条在这里都返回**无守卫**
180
+ * (`undefined`)—— 文件不存在时该不该创建由宿主与调用方决定,不是我们替它决定。
181
+ *
182
+ * @returns {{guard: {version:string}|undefined, reason:string}}
183
+ */
184
+ export function decideEditGuard(facts) {
185
+ try {
186
+ const d = gate(facts)
187
+ if (d.reason === 'caller_wins') return { guard: d.caller, reason: d.reason }
188
+ if (d.reason === 'guarded') return { guard: { version: d.version }, reason: d.reason }
189
+ return { guard: undefined, reason: d.reason }
190
+ } catch {
191
+ return { guard: undefined, reason: 'unknown' }
192
+ }
193
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
4
- "version": "0.9.18",
4
+ "version": "0.9.20",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {