@yandy0725/memory-core 2.0.0 → 2.1.0

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.
Files changed (114) hide show
  1. package/dist/config.d.ts +66 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +69 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +28 -7
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +28 -5
  8. package/dist/index.js.map +1 -1
  9. package/dist/injection.d.ts.map +1 -1
  10. package/dist/injection.js +1 -1
  11. package/dist/injection.js.map +1 -1
  12. package/dist/orchestration/conversation.d.ts.map +1 -1
  13. package/dist/orchestration/conversation.js +1 -2
  14. package/dist/orchestration/conversation.js.map +1 -1
  15. package/dist/orchestration/ports.d.ts.map +1 -1
  16. package/dist/orchestration/task.d.ts.map +1 -1
  17. package/dist/orchestration/task.js +1 -1
  18. package/dist/orchestration/task.js.map +1 -1
  19. package/dist/store/filename.d.ts +43 -0
  20. package/dist/store/filename.d.ts.map +1 -1
  21. package/dist/store/filename.js +72 -0
  22. package/dist/store/filename.js.map +1 -1
  23. package/dist/store/fs-lock.d.ts.map +1 -1
  24. package/dist/store/fs-lock.js +1 -3
  25. package/dist/store/fs-lock.js.map +1 -1
  26. package/dist/store/memory-store.d.ts +37 -0
  27. package/dist/store/memory-store.d.ts.map +1 -1
  28. package/dist/store/memory-store.js +169 -17
  29. package/dist/store/memory-store.js.map +1 -1
  30. package/dist/store/paths.d.ts.map +1 -1
  31. package/dist/store/paths.js +8 -2
  32. package/dist/store/paths.js.map +1 -1
  33. package/dist/store/process-lock.d.ts.map +1 -1
  34. package/dist/store/process-lock.js.map +1 -1
  35. package/dist/sync/backend-git.d.ts +27 -0
  36. package/dist/sync/backend-git.d.ts.map +1 -0
  37. package/dist/sync/backend-git.js +815 -0
  38. package/dist/sync/backend-git.js.map +1 -0
  39. package/dist/sync/backend.d.ts +213 -0
  40. package/dist/sync/backend.d.ts.map +1 -0
  41. package/dist/sync/backend.js +31 -0
  42. package/dist/sync/backend.js.map +1 -0
  43. package/dist/sync/conflict.d.ts +74 -0
  44. package/dist/sync/conflict.d.ts.map +1 -0
  45. package/dist/sync/conflict.js +164 -0
  46. package/dist/sync/conflict.js.map +1 -0
  47. package/dist/sync/device.d.ts +45 -0
  48. package/dist/sync/device.d.ts.map +1 -0
  49. package/dist/sync/device.js +227 -0
  50. package/dist/sync/device.js.map +1 -0
  51. package/dist/sync/diff.d.ts +78 -0
  52. package/dist/sync/diff.d.ts.map +1 -0
  53. package/dist/sync/diff.js +115 -0
  54. package/dist/sync/diff.js.map +1 -0
  55. package/dist/sync/engine.d.ts +142 -0
  56. package/dist/sync/engine.d.ts.map +1 -0
  57. package/dist/sync/engine.js +960 -0
  58. package/dist/sync/engine.js.map +1 -0
  59. package/dist/sync/hash.d.ts +11 -0
  60. package/dist/sync/hash.d.ts.map +1 -0
  61. package/dist/sync/hash.js +14 -0
  62. package/dist/sync/hash.js.map +1 -0
  63. package/dist/sync/manifest.d.ts +63 -0
  64. package/dist/sync/manifest.d.ts.map +1 -0
  65. package/dist/sync/manifest.js +117 -0
  66. package/dist/sync/manifest.js.map +1 -0
  67. package/dist/sync/paths.d.ts +70 -0
  68. package/dist/sync/paths.d.ts.map +1 -0
  69. package/dist/sync/paths.js +111 -0
  70. package/dist/sync/paths.js.map +1 -0
  71. package/dist/sync/scan.d.ts +44 -0
  72. package/dist/sync/scan.d.ts.map +1 -0
  73. package/dist/sync/scan.js +102 -0
  74. package/dist/sync/scan.js.map +1 -0
  75. package/dist/sync/scheduler.d.ts +81 -0
  76. package/dist/sync/scheduler.d.ts.map +1 -0
  77. package/dist/sync/scheduler.js +183 -0
  78. package/dist/sync/scheduler.js.map +1 -0
  79. package/dist/sync/types.d.ts +21 -0
  80. package/dist/sync/types.d.ts.map +1 -0
  81. package/dist/sync/types.js +8 -0
  82. package/dist/sync/types.js.map +1 -0
  83. package/dist/tool/handler.d.ts.map +1 -1
  84. package/dist/tool/handler.js +1 -3
  85. package/dist/tool/handler.js.map +1 -1
  86. package/package.json +15 -3
  87. package/src/config.ts +121 -0
  88. package/src/index.ts +101 -5
  89. package/src/injection.ts +1 -3
  90. package/src/orchestration/conversation.ts +1 -2
  91. package/src/orchestration/dream.ts +0 -1
  92. package/src/orchestration/extract.ts +0 -1
  93. package/src/orchestration/ports.ts +1 -5
  94. package/src/orchestration/prompts.ts +0 -1
  95. package/src/orchestration/surfacing.ts +0 -1
  96. package/src/orchestration/task.ts +2 -10
  97. package/src/store/filename.ts +71 -0
  98. package/src/store/fs-lock.ts +2 -6
  99. package/src/store/memory-store.ts +311 -134
  100. package/src/store/paths.ts +12 -3
  101. package/src/store/process-lock.ts +4 -1
  102. package/src/sync/backend-git.ts +902 -0
  103. package/src/sync/backend.ts +220 -0
  104. package/src/sync/conflict.ts +247 -0
  105. package/src/sync/device.ts +266 -0
  106. package/src/sync/diff.ts +152 -0
  107. package/src/sync/engine.ts +1258 -0
  108. package/src/sync/hash.ts +14 -0
  109. package/src/sync/manifest.ts +148 -0
  110. package/src/sync/paths.ts +117 -0
  111. package/src/sync/scan.ts +115 -0
  112. package/src/sync/scheduler.ts +241 -0
  113. package/src/sync/types.ts +22 -0
  114. package/src/tool/handler.ts +1 -3
@@ -0,0 +1,220 @@
1
+ /**
2
+ * 同步的**传输端口**:引擎只认这个接口,不认任何具体存储。
3
+ *
4
+ * 端口形状由「以后还要有别的 backend(对象存储之类)」倒推:**不得**出现「本地工作树」
5
+ * 「clone」「commit」「ref」这类 git 专属概念。原因不是洁癖 —— 第三方 SDK 进不了 core
6
+ * (core 的源码只许 import 标准库与相对路径,这是一道测试门),所以将来那些 backend 只能是
7
+ * **独立的包**实现这里导出的端口,再由适配层装配。端口里漏进一个 git 概念,那个包就实现不出来。
8
+ *
9
+ * 两条最容易踩的口径:
10
+ * - `revision` 是**不透明字符串**。引擎把它写回基线、下次原样交回来,从不解析它。
11
+ * - `list()` 的 hash 恒为 **sha256(文件字节) 的十六进制小写**。这是端口契约的一部分,
12
+ * 不是实现细节:基线与三方差集都直接与它比较。backend 若原生只有别的标识(git 的 blob
13
+ * sha1 掺了 `blob <长度>\0` 头,对象存储的 ETag 掺了分片规则),换算责任在 backend 自己
14
+ * 身上 —— 换算错了不会报错,只会让「没变」被判成「变了」,每轮同步凭空生出冲突副本。
15
+ *
16
+ * 本文件只有类型与一个错误类,**没有运行时代码**:任何逻辑都会把整条同步链绑到某个具体存储上。
17
+ */
18
+
19
+ /**
20
+ * 每次 backend 调用的运行参数。`timeoutMs` 由调用方按「自动路径 / 用户显式在等」两档给。
21
+ *
22
+ * 注意它是**这次调用的总预算**,不是「单次远端往返的上限」:一次 `fetch()` 内部就是
23
+ * `git fetch` + 失败复核的 `ls-remote` + `ls-tree` + `cat-file --batch` 好几个往返,它们**共享**
24
+ * 这一份预算(实现见 `backend-git.ts` 的 `budgeted()`:每跑一个子进程时现算剩余量)。
25
+ * 引擎那边同理:一轮同步会在多次 backend 调用(fetch → publish,含一次整轮重跑)之间共享同一份预算,
26
+ * 交下来的是剩余量。所以谁也不要把它当成「每个子进程/每次请求都能各花这么久」。
27
+ */
28
+ export interface SyncRunOptions {
29
+ /** 本次调用的**总预算**:调用内部多次往返共享它。超时按失败处理,绝不无限等(自动路径是挂在会话启动上的)。 */
30
+ timeoutMs: number;
31
+ /** 可选的中止信号:宿主退出/切会话时让在途的远端操作尽快收尾。 */
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ /**
36
+ * 远端内的一个前缀。backend 只在该前缀内活动,因此引擎不必知道远端还放着别的项目。
37
+ * 前缀恒为 POSIX 形态(`/` 分隔),与宿主平台无关 —— 它是远端仓库里的数据布局,
38
+ * 跟着平台走会让同一个仓库在两台设备上算出两个目录、各同步各的。
39
+ */
40
+ export interface SyncScope {
41
+ readonly prefix: string;
42
+ }
43
+
44
+ /** 远端一次快照的只读视图。 */
45
+ export interface RemoteView {
46
+ /**
47
+ * 这次快照的**不透明**标识,会被写回基线的 `revision`。空串表示远端还没有任何已同步的
48
+ * 内容(第一次同步之前),即「没有基线可比」。
49
+ */
50
+ readonly revision: string;
51
+
52
+ /**
53
+ * 远端在 `scope.prefix` 下的全部路径 + 内容 hash。
54
+ *
55
+ * **路径相对于 `scope.prefix`**(因此就是单层文件名),与引擎差集用的 key 同域。
56
+ * **hash 恒为 sha256(字节) 的十六进制小写** —— 换算责任在 backend,见文件头。
57
+ */
58
+ list(): Promise<Array<{ path: string; hash: string }>>;
59
+
60
+ /**
61
+ * 取内容。只会被 `list()` 报出的路径调用。
62
+ *
63
+ * **可能在根锁释放之后才被调用**:引擎只在真要落到本地时才读内容,那时这一轮已经不在
64
+ * `fetch`/`publish` 的串行段里了(下面 `SyncBackend` 那条并发假设只盖住这两个调用)。
65
+ * 所以这里读的必须是 `fetch` 那一刻就已经固定下来的快照(内容已缓存,或按当时那个
66
+ * revision 取对象),**不能**对着共享的传输状态现取 —— 另一个项目的 `fetch`/`publish`
67
+ * 会同时跑:前者会移动引用(`git fetch` 改掉 `FETCH_HEAD` 与远程跟踪分支),后者会把
68
+ * 工作树 `reset --hard` + `clean -fd`。把 `read()` 写成对着共享暂存 clone 现跑一次
69
+ * `git show`、或直接读暂存工作树,读到的就可能是别的项目的内容、更旧的内容,或直接报错。
70
+ */
71
+ read(path: string): Promise<string>;
72
+
73
+ /**
74
+ * 远端存在、但**不该在那儿**的路径(**远端根相对**,原样保留)。纯报告用:引擎把它抄进
75
+ * 同步结果与状态报告,让人知道远端有内容没被同步 —— 有人手工推上来的索引文件、
76
+ * 同步范围之外的目录(例如那些永不参与同步的本地状态)、或本前缀底下的嵌套目录都算。
77
+ *
78
+ * **判据是「异常」,不是「不在本 scope 内」**:远端仓库按设计镜像整个记忆根,而一次会话只
79
+ * 同步当前那一个项目,所以**别的项目的记忆是常态**,不属于这里。把它们报出来,条数就随
80
+ * 项目数 × 记忆数线性增长,而异常报告一旦和正常数据同量级就等于没有报告 —— 真正那一条信号
81
+ * 会被永久埋掉。
82
+ *
83
+ * **不得静默丢弃异常路径**:丢了用户就永远无从得知远端有内容没进同步。
84
+ * 无法廉价判断的 backend 返回 `[]`。
85
+ */
86
+ readonly outOfScope: string[];
87
+ }
88
+
89
+ /**
90
+ * 一次初始化的结果。
91
+ *
92
+ * `revision` 是远端**真的存在**的那一个(空串 = 远端还没有任何已同步内容):适配层拿它写首份基线
93
+ * (`emptyManifest(syncTransport(actualOrigin, branch))` + 这个 revision,`files` 为空集)。不得让适配层自己再 `fetch()` 一遍拼:
94
+ * 那一来多一次远端往返,二来两次读之间远端可能已经前进,写进基线的就是一个对不上的版本。
95
+ *
96
+ * `warnings` 是「已经发生、但不足以拒绝初始化」的情形(例如用户自己把记忆根做成了指向别处的
97
+ * 仓库),逐条可读、不带凭据,由适配层排进非致命提示。
98
+ */
99
+ export interface SyncInitResult {
100
+ revision: string;
101
+ warnings: string[];
102
+ }
103
+
104
+ /**
105
+ * 要发布的**增量**:从 `base`(= 本轮取到的远端那一侧)到合并结果的差异。
106
+ *
107
+ * 用增量而不是完整文件集:对象存储这类 backend 只需 PUT/DELETE 变化的对象,不必重传全量。
108
+ * 增量由引擎算好再交下来 —— backend 不判断该写什么,只负责把这份差异变成远端的新状态。
109
+ *
110
+ * `path` 与 `list()` 报出的同域:相对于 `scope.prefix` 的**单层**文件名(不含目录)。
111
+ * 记忆目录是扁平的,嵌套路径写上去就再也读不回来(下一轮会被归成范围外)。
112
+ */
113
+ export interface SyncPublishChanges {
114
+ writes: Array<{ path: string; content: string }>;
115
+ deletions: string[];
116
+ }
117
+
118
+ /**
119
+ * 远端已经不在 `base` 上了(另一台设备在这轮之间推过)。
120
+ *
121
+ * 这是**预期内**的失败,不是故障:引擎拿到它就把整轮重跑一次(重新取远端、重算差集),
122
+ * 仍失败则本轮放弃、基线不前进,留到下次。所以它必须是一个可识别的类型,
123
+ * 而不是一条只能靠读文案区分的普通错误。
124
+ */
125
+ export class NonFastForwardError extends Error {
126
+ constructor(message: string) {
127
+ super(message);
128
+ this.name = "NonFastForwardError";
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 一个传输后端。实现方只需满足本接口的方法语义,不必也不得假定调用方认识具体存储。
134
+ *
135
+ * **并发假设**:backend 可以假定调用方已经把同一份本地状态(同一个 `syncRoot`)上的调用
136
+ * **串行化**了,但**不是**「一轮从头到尾持有一把锁」。真实形状是引擎把一轮切成三段,每段的锁不同(见设计文档):
137
+ *
138
+ * 1. `ready()`:在根级 sync 锁**之外**被调用(引擎每轮进门先问一次)。`init()` 由适配层触发,
139
+ * **引擎自己绝不会调它**;git 实现会在唯一 staging 中初始化,并经同一路径的有界 `withLock`
140
+ * 串行后原子发布。`setRemote()` 也使用这把管理锁,但两者都不得在引擎 fetch/publish 锁内嵌套调用。
141
+ * 自动轮遇到未初始化只报一句可读原因,初始化是适配层的事。
142
+ * 2. `fetch()`:**在根级 sync 锁内**跑(try 语义:拿不到就本轮直接跳过,绝不把传输放进锁外),
143
+ * 并与 `getRemote()` 同锁取得快照的 transport 身份。
144
+ * 这一段要读本地文件算基线,而「一次 save 期间没有别的 save」这条不变量正是那把根锁守的。
145
+ * 3. 落盘合并:**根锁已经放开**,只由本 scope 的逻辑锁(`sync:<scope>`)守着。
146
+ * 4. `publish()`:**重新抢一次根级 sync 锁**(同样抢不到就本轮报「忙」)。
147
+ *
148
+ * 于是 `fetch` 返回的远端视图与落盘之间,本地磁盘**可能被别的写入者改过**:backend 不得跨这道
149
+ * 边界缓存本地状态;引擎这边则会在发布前重新核对基线,远端已经前进了就抛
150
+ * {@link NonFastForwardError} 让整轮重跑,而不是硬推。
151
+ *
152
+ * 同一次调用内部(一次 `fetch` 或一次 `publish`)是顺序的、且共享那一份 `timeoutMs` 预算。
153
+ * 因此以独立包实现的 backend **不必**自己再加内部锁;反过来,端口也不承诺任何并发安全,
154
+ * 直接并行调用同一个实例属于误用。
155
+ */
156
+ export interface SyncBackend {
157
+ /**
158
+ * 是否已初始化(本地那点状态在不在)。false 时引擎的自动轮直接跳过并报一句可读原因 ——
159
+ * **引擎自己不初始化**(首次 clone 可能要几十秒,挂在会话启动上的它必须有独立预算)。
160
+ * 初始化由适配层在会话启动时按配置调用 {@link init};它抛出的失败由适配层降级成一条提示,
161
+ * 不影响其余记忆功能。`ready()` 的判据是本地那份传输状态在不在(git: `<syncRoot>/repo/.git`),
162
+ * **不得**改成“设备身份在不在”:两者不同时写成(clone 成功之后才写设备身份),
163
+ * 拿后者当判据会把“clone 好了、身份写失败”误报成“没初始化”,从而重做一次初始化。
164
+ */
165
+ ready(): Promise<boolean>;
166
+
167
+ /**
168
+ * 首次初始化:由适配层在启动时按配置触发(显式 pull/push 的恢复路径也可以再调),但不是一条
169
+ * 用户要手工输入的命令。失败要抛错让人看见,不静默降级成「没初始化」。幂等:已初始化时不得
170
+ * 重做一次会改变远端历史的初始化;已初始化时只复核当前 origin。适配层在 ready 且 manifest
171
+ * transport 匹配的快路径上不会再调它,因此这份复核不会成为每次启动的固定远端往返。
172
+ *
173
+ * 返回远端真的存在的那个 revision 与非致命警告(见 {@link SyncInitResult})。
174
+ * **拒绝初始化时不得留下任何可被认成「已初始化」的产物**:否则 `ready()` 随即变 true,
175
+ * 下一次启动会照着那个状态继续同步,拒绝分支就成了假话。
176
+ */
177
+ init(opts: SyncRunOptions): Promise<SyncInitResult>;
178
+
179
+ /**
180
+ * 本地那份传输状态记录的远端地址(git: 暂存 clone 的 origin)。未初始化或没有 origin 时为
181
+ * `null`;其他读取失败(权限、仓库损坏、子进程错误)一律抛错,绝不把「读不出来」伪装成「没配置」。
182
+ *
183
+ * 它是初始化之后「同步到底去哪儿」的**真相来源**:状态报告里那一行必须来自这里,而不是构造时的
184
+ * 配置值(配置里的 remote 只是未初始化时的输入,已初始化后留空也合法)。
185
+ */
186
+ getRemote(): Promise<string | null>;
187
+
188
+ /**
189
+ * 改本地那份传输状态指向的远端(git: `remote set-url`),**不对远端发任何写操作**。
190
+ *
191
+ * 已初始化时用(适配层的「换远端」入口走这里);未初始化时它什么都不改也不报错 ——
192
+ * 那时该改的是配置,而本功能不写用户的配置文件。
193
+ * 空地址 fail-closed(抛错,origin 原样不动);错误里的地址必须经凭据脱敏。
194
+ */
195
+ setRemote(remote: string, opts: SyncRunOptions): Promise<void>;
196
+
197
+ /** 取远端最新状态。 */
198
+ fetch(scope: SyncScope, opts: SyncRunOptions): Promise<RemoteView>;
199
+
200
+ /**
201
+ * 把「从 `base` 到合并结果」的增量发布到远端,返回新的 revision。
202
+ *
203
+ * 端口不承诺原子,但要求 backend **要么整体成功、要么抛错且不留下半推状态**:
204
+ * 抛错之后远端内容与本地状态都原样可解释,引擎才敢下一轮重来。
205
+ * `base.revision` 用于乐观并发:远端已经不在 base 上时抛 {@link NonFastForwardError}。
206
+ * `base.remote` 若存在,是 fetch 同一把根锁内读到的实际 origin;backend 必须在任何远端 mutation 前
207
+ * 复核它,不同则抛 {@link NonFastForwardError},防止旧进程的 delta 写进新 origin。
208
+ * 空串的 base 表示「没有基线」(首次发布),**不算**非快进。
209
+ *
210
+ * 返回的 revision **必须是远端真的有的那一个**:引擎会原样写进基线,下一轮拿它当 base 比对。
211
+ * 返回一个只存在于本地状态的标识(例如「本轮无变化,就把本地 head 交上去」而远端根本没这个分支),
212
+ * 会让下一轮的 base 永远对不上远端 —— 看起来只是多一次重跑,实际上是每轮都白跑。
213
+ */
214
+ publish(
215
+ scope: SyncScope,
216
+ changes: SyncPublishChanges,
217
+ base: { revision: string; remote?: string },
218
+ opts: SyncRunOptions & { message: string; author: { name: string; email: string } },
219
+ ): Promise<string>;
220
+ }
@@ -0,0 +1,247 @@
1
+ import { parseEntryFile, serializeEntryFile } from "../store/entry-file.js";
2
+ import { ENTRY_EXT, entryFileName } from "../store/filename.js";
3
+ import { sha256Hex } from "./hash.js";
4
+ import type { SyncFileInfo } from "./types.js";
5
+
6
+ /**
7
+ * 冲突收敛:同一个 entry 文件在两侧内容不同时,决定「谁留在原路径、谁另存为新 entry」。
8
+ *
9
+ * 策略是**两份都留**:`modified` 新的一方留在原路径(逐字写入),被挤出来的那一份成为一条新记忆,
10
+ * `name` 带上由它自己的内容派生的后缀,后续的合并交给整理流程(它的既有职责就是合并重复、解决矛盾)。
11
+ * 「删除 vs 修改」是同一套规则的另一半:删除照办(原路径要删),幸存的那份内容按新 entry 留下 ——
12
+ * 没有任何一方丢内容,删除的意图也被执行。
13
+ *
14
+ * **载重不变量:产物只能是冲突内容的函数。** 两台设备各自解同一次冲突(一台在拉、另一台也在拉),
15
+ * 必须算出逐字节相同的新文件名与字节。一旦后缀里掺进本地状态 —— hostname、设备 id、当天日期、
16
+ * 或者目录里恰好有什么 —— 两台设备就各造一份副本,下一轮互相把对方的副本拉过来,
17
+ * 用户的同一条记忆变成三份、四份,永不收敛。因此这里:
18
+ * - 不用时钟(`modified` 非法时退回**常量**,不退回今天);
19
+ * - 不用主机与设备信息;
20
+ * - 不用 `resolveUniqueFileName` —— 它的 `-2`/`-3` 取决于本地目录里恰好有什么,
21
+ * 撞名时改追加**这份内容自己的** sha256 指纹,仍然是内容的函数。
22
+ *
23
+ * 纯函数、零 IO:字节由调用方读、文件由调用方写,这里只决定「写什么、写到哪个名字」。
24
+ */
25
+
26
+ /** 冲突的一侧:字节 + 它的指纹。`null` = 这一侧已删除(来自 `SyncConflict` 的同一套表示)。两侧不会同时为 null。 */
27
+ export interface ConflictSide {
28
+ /** 这一侧此刻的完整字节(逐字:行尾与空白都算内容)。 */
29
+ raw: string;
30
+ /** 同一份内容的指纹(扫描与清单的口径)。 */
31
+ info: SyncFileInfo;
32
+ }
33
+
34
+ /** 一次要收敛的冲突。形状与差集给出的 `SyncConflict` 对齐,只是两侧各自带上了字节。 */
35
+ export interface ConflictInput {
36
+ /** 冲突所在的 entry 文件名(原路径)。派生名字**不**来自它 —— 只用于报错时指明是哪一条。 */
37
+ file: string;
38
+ /** `null` = 本地这一侧已删除。 */
39
+ ours: ConflictSide | null;
40
+ /** `null` = 远端这一侧已删除。 */
41
+ theirs: ConflictSide | null;
42
+ }
43
+
44
+ export interface ConflictOutcome {
45
+ /**
46
+ * 原路径要写的字节(逐字,不重新序列化);
47
+ * **`null` = 原路径要被删除** —— 删除 vs 修改时删除照办,幸存的那份内容改由 loserEntry 承接。
48
+ */
49
+ winnerRaw: string | null;
50
+ /**
51
+ * 需要另存为新 entry 的那一份(名字已加内容派生的后缀);
52
+ * `null` = 没有需要另存的一方(删除 vs 修改时,被删的那一侧没有内容要留)。
53
+ */
54
+ loserEntry: { name: string; file: string; raw: string } | null;
55
+ }
56
+
57
+ /** 另存为新 entry 的那一份(`ConflictOutcome.loserEntry` 的非 null 形态)。 */
58
+ type NewEntry = NonNullable<ConflictOutcome["loserEntry"]>;
59
+
60
+ /** `modified` 取不出日期时用的**常量**。绝不用今天:今天会让两台设备在不同日子算出不同后缀。 */
61
+ const UNKNOWN_DATE = "unknown-date";
62
+
63
+ /** 后缀与派生文件名里都用 sha256 的前 6 位当内容指纹。 */
64
+ const FINGERPRINT_LENGTH = 6;
65
+
66
+ /** `modified` 的前 10 字符要正好是 `YYYY-MM-DD` 才算日期。 */
67
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
68
+
69
+ /** 一份内容的「日期 + 指纹」。后缀与派生文件名都由它拼出来,两处同源、不会漂移。 */
70
+ interface ContentStamp {
71
+ /** `YYYY-MM-DD`,或常量 `unknown-date`。 */
72
+ date: string;
73
+ /** 这份内容 sha256 的前 6 位(小写十六进制)。 */
74
+ fingerprint: string;
75
+ /** ` (冲突 <date> <fingerprint>)` —— 逐字,是用户看得见的契约。 */
76
+ suffix: string;
77
+ }
78
+
79
+ function stampOf(raw: string): ContentStamp {
80
+ // 解析不出 frontmatter 也要给出确定答案(常量日期),不抛:后缀是内容的函数,坏内容也有坏内容的后缀。
81
+ const modified = parseEntryFile(raw)?.meta.modified ?? "";
82
+ const head = modified.slice(0, 10);
83
+ const date = DATE_RE.test(head) ? head : UNKNOWN_DATE;
84
+ const fingerprint = sha256Hex(raw).slice(0, FINGERPRINT_LENGTH);
85
+ return { date, fingerprint, suffix: ` (冲突 ${date} ${fingerprint})` };
86
+ }
87
+
88
+ /**
89
+ * 冲突副本 `name` 的后缀:`" (冲突 " + 该份内容 modified 的日期 + " " + 该份内容 sha256 前 6 位 + ")"`。
90
+ *
91
+ * 两个成分都只来自**这一份字节**,所以两台设备各自解同一次冲突会得到同一个后缀、同一个派生文件名,
92
+ * 下一轮就是「两边都新增了同一个路径、内容相同」→ 收敛成一份,而不是越同步越多。
93
+ */
94
+ export function conflictSuffix(raw: string): string {
95
+ return stampOf(raw).suffix;
96
+ }
97
+
98
+ /** 派生出的文件名是否仍以指纹结尾(= 指纹没被 `entryFileName` 的 stem 截断吃掉)。 */
99
+ function keepsFingerprint(file: string, fingerprint: string): boolean {
100
+ return file.endsWith(ENTRY_EXT) && file.slice(0, -ENTRY_EXT.length).endsWith(fingerprint);
101
+ }
102
+
103
+ /**
104
+ * 由「主干 + 指纹」派生 entry 文件名,并保证**指纹留在末尾**。
105
+ *
106
+ * `entryFileName` 的截断只保留前缀(100 字节上限),所以指纹若留在主干中间,长名字会把它整段切掉 ——
107
+ * 而指纹正是「同名不同内容」时唯一的区分位,切掉它等于让两份不同的记忆抢同一个文件名。
108
+ * 做法:指纹永远拼在主干之后,主干被截断时就从末尾逐个码点回退,直到派生结果重新以指纹结尾。
109
+ *
110
+ * 回退用二分找起点(超长 name 上逐码点回退会退化成平方级)。二分的前提是 `fits` 对 `keep` 单调
111
+ * (能放下的前缀越短越宽松),而它确实单调:**`build(keep)` 的字节长度对 `keep` 单调不减** ——
112
+ * 多保留一个码点只可能让它更长(空白连写归一会让长度不变,绝不会变短),而 `entryFileName` 截断时
113
+ * 保留的是**前缀**,所以「截断完还是不是以指纹结尾」只取决于 `build(keep)` 有没有超过上限。
114
+ * 二分只依赖入参,因此是确定性的。
115
+ */
116
+ function fileNameEndingWith(head: string, fingerprint: string): string {
117
+ const points = Array.from(head);
118
+ const build = (keep: number): string => entryFileName(`${points.slice(0, keep).join("")}-${fingerprint}`);
119
+ const fits = (keep: number): boolean => keepsFingerprint(build(keep), fingerprint);
120
+
121
+ if (fits(points.length)) return build(points.length); // 常见路径:没被截断,一次就够
122
+
123
+ let low = 0; // 空主干必然放得下 `-<指纹>`(7 字节)
124
+ let high = points.length;
125
+ while (low < high) {
126
+ const mid = Math.ceil((low + high) / 2);
127
+ if (fits(mid)) low = mid;
128
+ else high = mid - 1;
129
+ }
130
+ // `low` 就是「仍能放下指纹」的最长前缀:它只在 `fits(mid)` 为真时前移,起点 0 恒真
131
+ // (空主干派生成 `-<指纹>`,7 字节,不可能被截断)。
132
+ return build(low);
133
+ }
134
+
135
+ /** 已占用文件名的索引:名字 → 该文件内容的 hash(`undefined` = 调用方不知道 hash)。 */
136
+ function indexOfTaken(
137
+ takenFileNames: Iterable<string | { file: string; hash?: string }>,
138
+ ): Map<string, { hash?: string }> {
139
+ const index = new Map<string, { hash?: string }>();
140
+ for (const item of takenFileNames) {
141
+ const file = typeof item === "string" ? item : item.file;
142
+ const hash = typeof item === "string" ? undefined : item.hash;
143
+ const known = index.get(file);
144
+ // 同名重复时留下「知道 hash」的那条:hash 未知的条目给不出任何判断力。
145
+ if (known === undefined || (known.hash === undefined && hash !== undefined)) index.set(file, { hash });
146
+ }
147
+ return index;
148
+ }
149
+
150
+ /**
151
+ * 派生冲突副本的文件名。
152
+ *
153
+ * 与 `name` 同源,但把后缀里的指纹**挪到末尾**(右括号提前):`A (冲突 2026-11-09 abc123)` 派生出
154
+ * `A-(冲突-2026-11-09)-abc123.md`。这样既保住了「文件名以指纹结尾」(截断时指纹才活得下来),
155
+ * 也让「这个名字被别的字节占用了」有一个确定性的退路:再追加一次同一份内容的指纹。
156
+ *
157
+ * 占用判断只按**逐字文件名**,刻意不做大小写/规范化折叠:折叠要跟 `platform`(本地配置),
158
+ * 引进来就等于让派生名字依赖本地状态。折叠碰撞由写入口的碰撞跳过兜底。
159
+ */
160
+ function pickFileName(
161
+ name: string,
162
+ stamp: ContentStamp,
163
+ newRaw: string,
164
+ taken: Map<string, { hash?: string }>,
165
+ ): string {
166
+ const head = `${name} (冲突 ${stamp.date})`;
167
+ const base = fileNameEndingWith(head, stamp.fingerprint);
168
+ const occupant = taken.get(base);
169
+ // 没被占用 → 直接用。
170
+ if (occupant === undefined) return base;
171
+ // 被占用,且占用者的字节与「我们即将写下去的那份」逐字相同 → 复用(幂等)。
172
+ // 比的是新 entry 的字节而不是败者的原字节:副本的 name 已加后缀,两者 hash 必然不同,
173
+ // 拿原字节去比会让每一轮重解都再追加一次指纹 —— 副本越同步越多,正是不收敛的那个形状。
174
+ if (occupant.hash !== undefined && occupant.hash === sha256Hex(newRaw)) return base;
175
+ // 被不同内容占用,或 hash 未知(证明不了是同一份)→ 追加指纹,绝不写穿别人的文件。
176
+ // 这里不用 `resolveUniqueFileName`:`-2`/`-3` 取决于本地目录里恰好有什么,两台设备会算出不同名字。
177
+ return fileNameEndingWith(`${head}-${stamp.fingerprint}`, stamp.fingerprint);
178
+ }
179
+
180
+ /**
181
+ * 把一份内容另存为新 entry:`name` 加内容派生的后缀,其余 frontmatter 与正文原样保留。
182
+ *
183
+ * 新 entry 由我们**作者化**(重新序列化),所以字节是规范化的;胜者那一份仍然逐字过去 —— 它是别人
184
+ * 写的文件,重新序列化等于把行尾与空白按我们的口味改掉,会让对端下一轮又判成「变了」。
185
+ *
186
+ * 解析不出 frontmatter 时抛错而不是返回 null:null 在这套接口里的意思是「无事可做」,
187
+ * 用它表示「做不到」会让调用方静默丢掉这一份内容 —— 那是本设计明令要防的丢记忆形状。
188
+ * 抛出去之后由调用方把本轮变成失败/跳过(fail-closed),内容一份都不少。
189
+ */
190
+ function keepAsNewEntry(side: ConflictSide, file: string, taken: Map<string, { hash?: string }>): NewEntry {
191
+ const parsed = parseEntryFile(side.raw);
192
+ if (!parsed) {
193
+ throw new Error(`Cannot keep "${file}" as a new entry: unparsable frontmatter in the losing side`);
194
+ }
195
+ const stamp = stampOf(side.raw);
196
+ const name = `${parsed.meta.name}${stamp.suffix}`;
197
+ const raw = serializeEntryFile({ ...parsed.meta, name }, parsed.body);
198
+ return { name, file: pickFileName(parsed.meta.name, stamp, raw, taken), raw };
199
+ }
200
+
201
+ /**
202
+ * 谁留在原路径:`modified` 新的一方胜;`modified` 相同则 sha256 字典序大的一方胜。
203
+ *
204
+ * 两个判据都只依赖内容(字符串比较,不是 locale 序、不是解析成日期再比),所以两台设备独立算出
205
+ * 同一个胜者 —— 幂等,与谁先推、谁在拉无关。`modified` 是垃圾值时同样确定:比较退化成码点序,
206
+ * 仍然是「同一份输入 → 同一个胜者」。
207
+ */
208
+ function pickWinner(ours: ConflictSide, theirs: ConflictSide): ConflictSide {
209
+ if (ours.info.modified === theirs.info.modified) return ours.info.hash > theirs.info.hash ? ours : theirs;
210
+ return ours.info.modified > theirs.info.modified ? ours : theirs;
211
+ }
212
+
213
+ /**
214
+ * 收敛一次冲突,得到「原路径写什么」与「哪一份另存为新 entry」。
215
+ *
216
+ * 返回 `null` 只有一个意思:**无事可做**(两侧字节相同,或两侧都已删除 —— 差集不会送来这两种,
217
+ * 但调用方送来了也不该抛)。做不到(败者的字节解析不出)时抛错,不借用 null。
218
+ *
219
+ * `takenFileNames` 是调用方已知的占用清单,只用于「派生名是否已被同一份内容占用」这一个判断:
220
+ * 带 hash 才可能复用,只给名字视为 hash 未知、一律走确定性追加。传空数组即可得到「与本地状态完全无关」
221
+ * 的产物 —— 两台设备各自算出的名字之所以能相同,靠的正是这一点。
222
+ */
223
+ export function resolveConflict(
224
+ input: ConflictInput,
225
+ takenFileNames: Iterable<string | { file: string; hash?: string }>,
226
+ ): ConflictOutcome | null {
227
+ const { ours, theirs } = input;
228
+
229
+ const taken = indexOfTaken(takenFileNames);
230
+
231
+ // 一、至少一侧已删除。两侧都删 = 无事可做(差集不会送来,但送来也不该抛);
232
+ // 一侧删 = 删除照办(原路径要删 → winnerRaw = null),幸存的那份内容另存为新 entry。
233
+ // 被删的那一侧没有内容可留,所以这里不会出现「两份都留」。
234
+ if (!ours || !theirs) {
235
+ const survivor = ours ?? theirs;
236
+ if (!survivor) return null;
237
+ return { winnerRaw: null, loserEntry: keepAsNewEntry(survivor, input.file, taken) };
238
+ }
239
+
240
+ // 二、两侧内容相同:不是冲突。判据与差集同源(hash);raw 逐字相同是它的充分条件,一并认下来。
241
+ if (ours.info.hash === theirs.info.hash || ours.raw === theirs.raw) return null;
242
+
243
+ // 三、改 vs 改:胜者逐字留在原路径,败者另存为新 entry。
244
+ const winner = pickWinner(ours, theirs);
245
+ const loser = winner === ours ? theirs : ours;
246
+ return { winnerRaw: winner.raw, loserEntry: keepAsNewEntry(loser, input.file, taken) };
247
+ }