@a9i5k4/dsh-auto-memory 0.1.28 → 0.1.30
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 +352 -266
- package/README.zh-CN.md +370 -264
- package/cordis.patch.yml +9 -9
- package/lib/activation-host.js +455 -0
- package/lib/activation-inbox-state.js +261 -0
- package/lib/activation-inbox.js +426 -0
- package/lib/client.js +1395 -50
- package/lib/context-bridge.js +619 -0
- package/lib/context-host.js +712 -0
- package/lib/context-sink-python.js +90 -0
- package/lib/episodic-store.js +316 -0
- package/lib/evidence-store.js +272 -0
- package/lib/fact-store.js +418 -0
- package/lib/index-sync.js +160 -0
- package/lib/index.js +2386 -105
- package/lib/intent-clean.js +74 -0
- package/lib/m4-corpus.js +169 -0
- package/lib/m7-index-sync-host.js +194 -0
- package/lib/m7-wire.js +268 -0
- package/lib/memory-anchor.js +451 -0
- package/lib/memory-hub.js +259 -0
- package/lib/memory-index.js +145 -0
- package/lib/memory-writer.js +391 -0
- package/lib/policies/activation_policy_v2.json +88 -0
- package/lib/policies/recall_intent_lr_v1.json +1 -0
- package/lib/procedure-store.js +406 -0
- package/lib/python-sidecar-client.js +326 -0
- package/lib/semantic-decide.js +265 -0
- package/lib/semantic-js.js +381 -0
- package/lib/shadow-host.js +361 -0
- package/lib/shadow-retrieval.js +673 -0
- package/lib/storage-manage.js +203 -0
- package/package.json +2 -2
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchor/Sidecar/Dry-run Planner — M3b-1(系统地图 M-06 契约 + docs/M3B-CONTRACT.md §3-§7)。
|
|
3
|
+
* 只读分析层:解析独占行 anchor、构建/校验 sidecar、生成 dry-run 迁移计划。
|
|
4
|
+
* 不修改任何 Markdown,不接入真实写路径(写入事务属 M3b-2)。
|
|
5
|
+
*
|
|
6
|
+
* anchor 格式(契约 §3):
|
|
7
|
+
* memoryId = mem_<32 lowercase hex> (首次随机分配后永久稳定,禁止由内容/digest/路径/行号派生)
|
|
8
|
+
* anchorId = memory:<memoryId>
|
|
9
|
+
* marker = <!-- memory:<memoryId> --> (独占一行,置于记录内容之前)
|
|
10
|
+
*
|
|
11
|
+
* 解析状态(契约 §5):anchored / legacy / orphan-anchor / duplicate-anchor /
|
|
12
|
+
* malformed-anchor / orphan-content。duplicate/malformed/orphan 一律 conflict(fail closed),
|
|
13
|
+
* 写入闸门与迁移 planner 必须拒绝,不得静默重编号。
|
|
14
|
+
* legacy 段按 heading 行切块,每块是将来一个 insert-anchor 的迁移目标
|
|
15
|
+
* (契约 §7:"非空 preamble 形成一个 legacy 记录;每个旧 heading block 形成一个记录")。
|
|
16
|
+
*
|
|
17
|
+
* 字节语义与 M3a 一致:UTF-8 半开区间 [byteStart,byteEnd),多字节按字节计数;
|
|
18
|
+
* anchored 记录内容 = marker 行之后到下一 marker 行首之间的非空内容(首尾空行不纳入),
|
|
19
|
+
* 不含 marker 本身(契约 §4:byteStart/byteEnd 与 recordDigest 只覆盖记录内容)。
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
22
|
+
import { splitByteLines, INDEX_MAX_FILE_BYTES } from './memory-index.js'
|
|
23
|
+
|
|
24
|
+
export const SIDECAR_SCHEMA_VERSION = 1
|
|
25
|
+
export const SIDECAR_NAMESPACE = 'dsh-auto-memory'
|
|
26
|
+
export const ANCHOR_PREFIX = 'memory:'
|
|
27
|
+
export const MEMORY_ID_RE = /^mem_[0-9a-f]{32}$/
|
|
28
|
+
export const MARKER_RE = /^<!-- memory:(mem_[0-9a-f]{32}) -->$/
|
|
29
|
+
const MARKER_OPEN = '<!-- memory:'
|
|
30
|
+
const HEADING_RE = /^#{1,6}\s/
|
|
31
|
+
const HEX64_RE = /^[0-9a-f]{64}$/
|
|
32
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
33
|
+
|
|
34
|
+
/** 分配新 memoryId:mem_ + 32 位小写 hex(默认随机 UUID 去连字符;rnd 仅测试注入)。 */
|
|
35
|
+
export function newMemoryId(rnd) {
|
|
36
|
+
return 'mem_' + (rnd || randomUUID().replace(/-/g, ''))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sha256Hex(buf) {
|
|
40
|
+
return createHash('sha256').update(buf).digest('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 换行风格统计:'lf' / 'crlf' / 'mixed'(无任何换行视为 'lf')。 */
|
|
44
|
+
function detectNewline(buf) {
|
|
45
|
+
let crlf = 0
|
|
46
|
+
let lf = 0
|
|
47
|
+
for (let i = 0; i < buf.length; i++) {
|
|
48
|
+
if (buf[i] === 0x0a) {
|
|
49
|
+
if (i > 0 && buf[i - 1] === 0x0d) crlf++
|
|
50
|
+
else lf++
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (crlf > 0 && lf > 0) return 'mixed'
|
|
54
|
+
if (crlf > 0) return 'crlf'
|
|
55
|
+
return 'lf'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 统计 UTF-8 字节区间的字符数(与 M3a buildIndex 同一算法)。 */
|
|
59
|
+
function countChars(buf, s, e) {
|
|
60
|
+
let chars = 0
|
|
61
|
+
for (let i = s; i < e;) {
|
|
62
|
+
const c = buf.readUInt8(i)
|
|
63
|
+
i += c < 0x80 ? 1 : c < 0xe0 ? 2 : c < 0xf0 ? 3 : 4
|
|
64
|
+
chars += 1
|
|
65
|
+
}
|
|
66
|
+
return chars
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function headingOf(text) {
|
|
70
|
+
if (!HEADING_RE.test(text)) return null
|
|
71
|
+
return text.replace(/^#{1,6}\s*/, '').trim()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 解析 Markdown 为 anchor 记录流。
|
|
76
|
+
* @param {Buffer|string} buf
|
|
77
|
+
* @returns {{bom:boolean,newline:'lf'|'crlf'|'mixed',records:Array<object>,conflicts:Array<object>,status:'clean'|'conflict'}}
|
|
78
|
+
* records:kind='anchored'|'legacy';anchored 带 memoryId/anchorId/anchorLine;
|
|
79
|
+
* legacy 带 position:'preamble'|'interstitial'|'tail'(对 planner 无差别,仅信息)。
|
|
80
|
+
* conflicts:[{type,line,byteStart,byteEnd,memoryId?,detail}]。
|
|
81
|
+
*/
|
|
82
|
+
export function parseAnchors(buf) {
|
|
83
|
+
const input = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf), 'utf8')
|
|
84
|
+
// 超限防御与 M3a 一致:避免同步全量解析/哈希阻塞,超大文件由调用方决定拆分迁移。
|
|
85
|
+
if (input.length > INDEX_MAX_FILE_BYTES) {
|
|
86
|
+
return { bom: false, newline: 'lf', records: [], conflicts: [], status: 'oversized', oversized: true }
|
|
87
|
+
}
|
|
88
|
+
const bom = input.length >= 3 && input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf
|
|
89
|
+
const b = bom ? input.subarray(3) : input
|
|
90
|
+
// splitByteLines 行号 = 数组下标+1,此处补齐显式 lineNumber 供 locator 使用
|
|
91
|
+
const lines = splitByteLines(b).map((l, i) => ({ start: l.start, end: l.end, text: l.text, lineNumber: i + 1 }))
|
|
92
|
+
const newline = detectNewline(b)
|
|
93
|
+
const records = []
|
|
94
|
+
const conflicts = []
|
|
95
|
+
const seenIds = new Map()
|
|
96
|
+
let cur = null // 当前 anchored 段:{anchorLine,markerStart,markerEnd,memoryId,anchorId,lines:[]}
|
|
97
|
+
let legacyLines = [] // 当前 legacy 段原始行(含空行),段结束时按 heading 切块
|
|
98
|
+
let sawAnchored = false
|
|
99
|
+
|
|
100
|
+
const finalizeAnchored = () => {
|
|
101
|
+
if (!cur) return
|
|
102
|
+
// 空内容 → orphan-anchor(空记录无意义,fail closed)
|
|
103
|
+
let first = -1
|
|
104
|
+
let last = -1
|
|
105
|
+
for (let i = 0; i < cur.lines.length; i++) {
|
|
106
|
+
if (cur.lines[i].text.trim() !== '') { if (first === -1) first = i; last = i }
|
|
107
|
+
}
|
|
108
|
+
if (first === -1) {
|
|
109
|
+
conflicts.push({ type: 'orphan-anchor', line: cur.anchorLine, byteStart: cur.markerStart, byteEnd: cur.markerEnd, memoryId: cur.memoryId, detail: 'marker 无任何内容' })
|
|
110
|
+
cur = null
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const l0 = cur.lines[first]
|
|
114
|
+
const l1 = cur.lines[last]
|
|
115
|
+
const byteStart = l0.start
|
|
116
|
+
const byteEnd = l1.end
|
|
117
|
+
records.push({
|
|
118
|
+
kind: 'anchored',
|
|
119
|
+
memoryId: cur.memoryId,
|
|
120
|
+
anchorId: cur.anchorId,
|
|
121
|
+
anchorLine: cur.anchorLine,
|
|
122
|
+
markerByteStart: cur.markerStart,
|
|
123
|
+
markerByteEnd: cur.markerEnd,
|
|
124
|
+
heading: headingOf(l0.text),
|
|
125
|
+
lineStart: l0.lineNumber,
|
|
126
|
+
lineEnd: l1.lineNumber,
|
|
127
|
+
byteStart,
|
|
128
|
+
byteEnd,
|
|
129
|
+
bytes: byteEnd - byteStart,
|
|
130
|
+
chars: countChars(b, byteStart, byteEnd),
|
|
131
|
+
recordDigest: sha256Hex(b.subarray(byteStart, byteEnd)),
|
|
132
|
+
})
|
|
133
|
+
cur = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** legacy 段切块:段内首个 heading 之前的非空内容=块1(heading:null),之后每个 heading 行起一块。 */
|
|
137
|
+
const flushLegacy = (position) => {
|
|
138
|
+
if (!legacyLines.length) return
|
|
139
|
+
let first = -1
|
|
140
|
+
let last = -1
|
|
141
|
+
for (let i = 0; i < legacyLines.length; i++) {
|
|
142
|
+
if (legacyLines[i].text.trim() !== '') { if (first === -1) first = i; last = i }
|
|
143
|
+
}
|
|
144
|
+
if (first !== -1) {
|
|
145
|
+
let blk = null
|
|
146
|
+
for (let i = first; i <= last; i++) {
|
|
147
|
+
const ln = legacyLines[i]
|
|
148
|
+
if (HEADING_RE.test(ln.text)) {
|
|
149
|
+
if (blk) records.push(finishLegacyBlock(b, blk, position))
|
|
150
|
+
blk = { heading: headingOf(ln.text), first: ln, last: ln }
|
|
151
|
+
} else if (blk) {
|
|
152
|
+
blk.last = ln
|
|
153
|
+
} else {
|
|
154
|
+
blk = { heading: null, first: ln, last: ln }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (blk) records.push(finishLegacyBlock(b, blk, position))
|
|
158
|
+
}
|
|
159
|
+
legacyLines = []
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const ln of lines) {
|
|
163
|
+
if (MARKER_RE.test(ln.text)) {
|
|
164
|
+
// 边界:切换段
|
|
165
|
+
finalizeAnchored()
|
|
166
|
+
flushLegacy(sawAnchored ? 'interstitial' : 'preamble')
|
|
167
|
+
const memoryId = MARKER_RE.exec(ln.text)[1]
|
|
168
|
+
const prevLine = seenIds.has(memoryId) ? seenIds.get(memoryId) : null
|
|
169
|
+
seenIds.set(memoryId, ln.lineNumber)
|
|
170
|
+
if (prevLine) {
|
|
171
|
+
conflicts.push({ type: 'duplicate-anchor', line: ln.lineNumber, byteStart: ln.start, byteEnd: ln.end, memoryId, detail: 'memoryId 重复,先前出现于行 ' + prevLine })
|
|
172
|
+
}
|
|
173
|
+
cur = { anchorLine: ln.lineNumber, markerStart: ln.start, markerEnd: ln.end, memoryId, anchorId: ANCHOR_PREFIX + memoryId, lines: [] }
|
|
174
|
+
sawAnchored = true
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (ln.text.startsWith(MARKER_OPEN)) {
|
|
178
|
+
// 行首疑似 marker 但整行非法(ID 格式错/内容残缺)
|
|
179
|
+
conflicts.push({ type: 'malformed-anchor', line: ln.lineNumber, byteStart: ln.start, byteEnd: ln.end, detail: '非法的 anchor marker 行: ' + ln.text.slice(0, 60) })
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (ln.text.includes(MARKER_OPEN)) {
|
|
183
|
+
// 保留语法出现在行内(用户伪造/意外引用)→ fail closed
|
|
184
|
+
conflicts.push({ type: 'orphan-content', line: ln.lineNumber, byteStart: ln.start, byteEnd: ln.end, detail: '内容行包含保留 marker 语法' })
|
|
185
|
+
// 仍作为普通内容行处理(记录归属继续),冲突由调用方拒绝
|
|
186
|
+
}
|
|
187
|
+
if (cur) cur.lines.push(ln)
|
|
188
|
+
else legacyLines.push(ln)
|
|
189
|
+
}
|
|
190
|
+
finalizeAnchored()
|
|
191
|
+
flushLegacy(sawAnchored ? 'tail' : 'preamble')
|
|
192
|
+
|
|
193
|
+
return { bom, newline, records, conflicts, status: conflicts.length ? 'conflict' : 'clean' }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function finishLegacyBlock(b, blk, position) {
|
|
197
|
+
const byteStart = blk.first.start
|
|
198
|
+
const byteEnd = blk.last.end
|
|
199
|
+
return {
|
|
200
|
+
kind: 'legacy',
|
|
201
|
+
memoryId: null,
|
|
202
|
+
anchorId: null,
|
|
203
|
+
position,
|
|
204
|
+
heading: blk.heading,
|
|
205
|
+
lineStart: blk.first.lineNumber,
|
|
206
|
+
lineEnd: blk.last.lineNumber,
|
|
207
|
+
byteStart,
|
|
208
|
+
byteEnd,
|
|
209
|
+
bytes: byteEnd - byteStart,
|
|
210
|
+
chars: countChars(b, byteStart, byteEnd),
|
|
211
|
+
recordDigest: sha256Hex(b.subarray(byteStart, byteEnd)),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 构建 sidecar(契约 §6)。digest 不变 → 保持 sourceVersion;变化 → +1;
|
|
217
|
+
* 无 prev(新鲜/跨重启重建) → sourceVersion=1 + 新 sourceEpoch。
|
|
218
|
+
* 冲突文件不建 sidecar(返回 ok:false)。
|
|
219
|
+
* @returns {{ok:true,sidecar:object}|{ok:false,reason:string,conflicts?:Array<object>}}
|
|
220
|
+
*/
|
|
221
|
+
export function buildSidecar({ sourceFile, content, prev, sourceEpoch, now }) {
|
|
222
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
|
|
223
|
+
const parsed = parseAnchors(buf)
|
|
224
|
+
if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
225
|
+
if (parsed.status === 'conflict') {
|
|
226
|
+
return { ok: false, reason: 'conflict:' + parsed.conflicts.map((c) => c.type).join(','), conflicts: parsed.conflicts }
|
|
227
|
+
}
|
|
228
|
+
const fileDigest = sha256Hex(buf)
|
|
229
|
+
const prevOk = prev && typeof prev.sourceVersion === 'number' && typeof prev.fileDigest === 'string'
|
|
230
|
+
const sourceVersion = prevOk ? (prev.fileDigest === fileDigest ? prev.sourceVersion : prev.sourceVersion + 1) : 1
|
|
231
|
+
const epoch = (prevOk && typeof prev.sourceEpoch === 'string') ? prev.sourceEpoch : (sourceEpoch || randomUUID())
|
|
232
|
+
// per-record 携带构建时文件级身份(sourceVersion+fileDigest)与 marker 行字节区间:
|
|
233
|
+
// 1) 契约 §4 locator 字段完整(anchorByteStart/anchorByteEnd);
|
|
234
|
+
// 2) 文件任何位置变化 ⇒ 所有记录按 fileDigest 判 stale,与 M3a 的记录级文件身份语义对齐。
|
|
235
|
+
const records = parsed.records.filter((r) => r.kind === 'anchored').map((r) => ({
|
|
236
|
+
memoryId: r.memoryId,
|
|
237
|
+
anchorId: r.anchorId,
|
|
238
|
+
anchorLine: r.anchorLine,
|
|
239
|
+
anchorByteStart: r.markerByteStart,
|
|
240
|
+
anchorByteEnd: r.markerByteEnd,
|
|
241
|
+
heading: r.heading,
|
|
242
|
+
lineStart: r.lineStart,
|
|
243
|
+
lineEnd: r.lineEnd,
|
|
244
|
+
byteStart: r.byteStart,
|
|
245
|
+
byteEnd: r.byteEnd,
|
|
246
|
+
bytes: r.bytes,
|
|
247
|
+
chars: r.chars,
|
|
248
|
+
recordDigest: r.recordDigest,
|
|
249
|
+
sourceVersion,
|
|
250
|
+
fileDigest,
|
|
251
|
+
}))
|
|
252
|
+
const sidecar = {
|
|
253
|
+
schemaVersion: SIDECAR_SCHEMA_VERSION,
|
|
254
|
+
namespace: SIDECAR_NAMESPACE,
|
|
255
|
+
sourceFile,
|
|
256
|
+
sourceEpoch: epoch,
|
|
257
|
+
sourceVersion,
|
|
258
|
+
fileDigest,
|
|
259
|
+
newline: parsed.newline,
|
|
260
|
+
updatedAt: now || Date.now(),
|
|
261
|
+
records,
|
|
262
|
+
}
|
|
263
|
+
return { ok: true, sidecar }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 校验已落盘 sidecar 文本(JSON)。损坏/字段非法返回 ok:false + 具体 reason;
|
|
268
|
+
* 调用方据此隔离损坏文件并从 Markdown 重建(契约 §6),不修改 Markdown。
|
|
269
|
+
* @returns {{ok:true,sidecar:object}|{ok:false,reason:string}}
|
|
270
|
+
*/
|
|
271
|
+
export function parseSidecar(text) {
|
|
272
|
+
let src = text
|
|
273
|
+
if (typeof src === 'string' && src.charCodeAt(0) === 0xfeff) src = src.slice(1)
|
|
274
|
+
let obj
|
|
275
|
+
try {
|
|
276
|
+
obj = JSON.parse(src)
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return { ok: false, reason: 'bad-json:' + (e && e.message ? e.message : String(e)) }
|
|
279
|
+
}
|
|
280
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, reason: 'not-object' }
|
|
281
|
+
const problems = []
|
|
282
|
+
if (obj.schemaVersion !== SIDECAR_SCHEMA_VERSION) problems.push('schemaVersion')
|
|
283
|
+
if (obj.namespace !== SIDECAR_NAMESPACE) problems.push('namespace')
|
|
284
|
+
if (typeof obj.sourceFile !== 'string' || !obj.sourceFile) problems.push('sourceFile')
|
|
285
|
+
if (typeof obj.sourceEpoch !== 'string' || !UUID_RE.test(obj.sourceEpoch)) problems.push('sourceEpoch')
|
|
286
|
+
if (!Number.isInteger(obj.sourceVersion) || obj.sourceVersion < 1) problems.push('sourceVersion')
|
|
287
|
+
if (typeof obj.fileDigest !== 'string' || !HEX64_RE.test(obj.fileDigest)) problems.push('fileDigest')
|
|
288
|
+
if (obj.newline !== 'lf' && obj.newline !== 'crlf' && obj.newline !== 'mixed') problems.push('newline')
|
|
289
|
+
if (!Number.isFinite(obj.updatedAt) || obj.updatedAt <= 0) problems.push('updatedAt')
|
|
290
|
+
if (!Array.isArray(obj.records)) {
|
|
291
|
+
problems.push('records')
|
|
292
|
+
} else {
|
|
293
|
+
const seen = new Set()
|
|
294
|
+
obj.records.forEach((r, i) => {
|
|
295
|
+
const p = 'records[' + i + ']'
|
|
296
|
+
if (!r || typeof r !== 'object') { problems.push(p); return }
|
|
297
|
+
if (typeof r.memoryId !== 'string' || !MEMORY_ID_RE.test(r.memoryId)) {
|
|
298
|
+
problems.push(p + '.memoryId')
|
|
299
|
+
} else {
|
|
300
|
+
if (seen.has(r.memoryId)) problems.push(p + '.duplicate-id')
|
|
301
|
+
seen.add(r.memoryId)
|
|
302
|
+
}
|
|
303
|
+
if (typeof r.anchorId !== 'string' || r.anchorId !== ANCHOR_PREFIX + r.memoryId) problems.push(p + '.anchorId')
|
|
304
|
+
if (!Number.isInteger(r.anchorLine) || r.anchorLine < 1) problems.push(p + '.anchorLine')
|
|
305
|
+
if (!Number.isInteger(r.anchorByteStart) || !Number.isInteger(r.anchorByteEnd) || r.anchorByteStart < 0 || r.anchorByteEnd <= r.anchorByteStart) problems.push(p + '.anchorBytes')
|
|
306
|
+
if (!Number.isInteger(r.byteStart) || !Number.isInteger(r.byteEnd) || r.byteStart < 0 || r.byteEnd <= r.byteStart) problems.push(p + '.bytes')
|
|
307
|
+
if (typeof r.recordDigest !== 'string' || !HEX64_RE.test(r.recordDigest)) problems.push(p + '.recordDigest')
|
|
308
|
+
if (!Number.isInteger(r.sourceVersion) || r.sourceVersion < 1) problems.push(p + '.sourceVersion')
|
|
309
|
+
if (typeof r.fileDigest !== 'string' || !HEX64_RE.test(r.fileDigest)) problems.push(p + '.fileDigest')
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
if (problems.length) return { ok: false, reason: 'invalid:' + problems.join(',') }
|
|
313
|
+
return { ok: true, sidecar: obj }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Dry-run 迁移计划(契约 §7):只读 Markdown,产出 insert-anchor 操作,不修改任何文件。
|
|
318
|
+
* - preamble 与每个 legacy heading 块各一条 insert-anchor;已有合法 anchor 不重新分配。
|
|
319
|
+
* - planId 确定性派生(sha256(sourceFile+digest)):同一文件同一版本重跑得到同一 planId。
|
|
320
|
+
* - existingPlan 仅在 sourceFile 与 expectedFileDigest 都匹配时按 legacyRecordDigest 复用 memoryId;
|
|
321
|
+
* 文件变化 ⇒ 整份 plan stale,不复用(reusedIds=0 且分配全新 ID)。
|
|
322
|
+
* - 任何 conflict ⇒ aborted:true,operations 为空(fail closed)。
|
|
323
|
+
* @param {string} sourceFile 绝对路径(仅作标识)
|
|
324
|
+
* @param {Buffer|string} content
|
|
325
|
+
* @param {{existingPlan?:object,idFactory?:()=>string,now?:number}} opts
|
|
326
|
+
*/
|
|
327
|
+
export function planMigration(sourceFile, content, opts = {}) {
|
|
328
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
|
|
329
|
+
const parsed = parseAnchors(buf)
|
|
330
|
+
if (parsed.status === 'oversized') {
|
|
331
|
+
return {
|
|
332
|
+
schemaVersion: 1, planId: 'plan_' + sha256Hex(Buffer.from(sourceFile + '\u0000', 'utf8')).slice(0, 32),
|
|
333
|
+
createdAt: opts.now || Date.now(), sourceFile, expectedFileDigest: '', newline: 'lf',
|
|
334
|
+
operations: [], conflicts: [], aborted: true, oversized: true, reusedIds: 0,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const fileDigest = sha256Hex(buf)
|
|
338
|
+
const newline = parsed.newline
|
|
339
|
+
const createdAt = opts.now || Date.now()
|
|
340
|
+
const planId = 'plan_' + sha256Hex(Buffer.from(sourceFile + '\u0000' + fileDigest, 'utf8')).slice(0, 32)
|
|
341
|
+
if (parsed.status === 'conflict') {
|
|
342
|
+
return {
|
|
343
|
+
schemaVersion: 1, planId, createdAt, sourceFile, expectedFileDigest: fileDigest, newline,
|
|
344
|
+
operations: [], conflicts: parsed.conflicts, aborted: true, reusedIds: 0,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// 复用键 = digest + '#' + 出现序号:内容相同的两个 legacy 块(同 digest)仍须各自独立
|
|
348
|
+
// 且跨次规划按文件顺序稳定对应,防止塌缩成重复 anchor。
|
|
349
|
+
const reuse = new Map()
|
|
350
|
+
const ep = opts.existingPlan
|
|
351
|
+
const reuseable = ep && ep.sourceFile === sourceFile && ep.expectedFileDigest === fileDigest && Array.isArray(ep.operations)
|
|
352
|
+
if (reuseable) {
|
|
353
|
+
const epOcc = new Map()
|
|
354
|
+
for (const op of ep.operations) {
|
|
355
|
+
if (op && op.kind === 'insert-anchor' && typeof op.memoryId === 'string' && MEMORY_ID_RE.test(op.memoryId) && typeof op.legacyRecordDigest === 'string') {
|
|
356
|
+
const n = (epOcc.get(op.legacyRecordDigest) || 0) + 1
|
|
357
|
+
epOcc.set(op.legacyRecordDigest, n)
|
|
358
|
+
if (!reuse.has(op.legacyRecordDigest + '#' + n)) reuse.set(op.legacyRecordDigest + '#' + n, op.memoryId)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const idFactory = typeof opts.idFactory === 'function' ? opts.idFactory : newMemoryId
|
|
363
|
+
const usedIds = new Set(reuse.values())
|
|
364
|
+
const operations = []
|
|
365
|
+
let reusedIds = 0
|
|
366
|
+
const thisOcc = new Map()
|
|
367
|
+
for (const rec of parsed.records) {
|
|
368
|
+
if (rec.kind !== 'legacy') continue
|
|
369
|
+
const n = (thisOcc.get(rec.recordDigest) || 0) + 1
|
|
370
|
+
thisOcc.set(rec.recordDigest, n)
|
|
371
|
+
let memoryId = reuse.get(rec.recordDigest + '#' + n)
|
|
372
|
+
if (memoryId) {
|
|
373
|
+
reusedIds += 1
|
|
374
|
+
} else {
|
|
375
|
+
// 重试上限:注入恒定 idFactory 不得挂死(防御性)
|
|
376
|
+
let tries = 0
|
|
377
|
+
do {
|
|
378
|
+
memoryId = idFactory()
|
|
379
|
+
tries += 1
|
|
380
|
+
} while (usedIds.has(memoryId) && tries <= 100)
|
|
381
|
+
if (tries > 100) {
|
|
382
|
+
return {
|
|
383
|
+
schemaVersion: 1, planId, createdAt, sourceFile, expectedFileDigest: fileDigest, newline,
|
|
384
|
+
operations, conflicts: [{ type: 'id-exhausted', line: 0, byteStart: 0, byteEnd: 0, detail: 'idFactory 无法产生未占用 ID' }],
|
|
385
|
+
aborted: true, reusedIds,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
usedIds.add(memoryId)
|
|
389
|
+
}
|
|
390
|
+
operations.push({
|
|
391
|
+
kind: 'insert-anchor',
|
|
392
|
+
atByte: rec.byteStart,
|
|
393
|
+
memoryId,
|
|
394
|
+
anchorId: ANCHOR_PREFIX + memoryId,
|
|
395
|
+
legacyLineStart: rec.lineStart,
|
|
396
|
+
legacyLineEnd: rec.lineEnd,
|
|
397
|
+
legacyRecordDigest: rec.recordDigest,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
schemaVersion: 1, planId, createdAt, sourceFile, expectedFileDigest: fileDigest, newline,
|
|
402
|
+
operations, conflicts: [], aborted: false, reusedIds,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* anchor-aware 只读索引投影(M-06 memoryFileIndex.rebuild 的 anchor 语义,M3b-2 衔接)。
|
|
409
|
+
* 与 buildIndex(M3a,按标题切块) 形状兼容,但块边界按 anchor marker:
|
|
410
|
+
* anchored 块 = marker 行之后的内容(byteStart 为内容首字节,不含 marker);
|
|
411
|
+
* legacy 块 = 无 marker 的 heading 块,与 parseAnchors 完全一致。
|
|
412
|
+
* 文件级身份语义与 M3a 相同:records 携带构建时 sourceVersion+fileDigest。
|
|
413
|
+
* 超限(>INDEX_MAX_FILE_BYTES)返回 skipped:true(与 M3a 一致,零哈希)。
|
|
414
|
+
* @param {string} sourceFile 绝对路径(仅作标识)
|
|
415
|
+
* @param {Buffer|string} content
|
|
416
|
+
* @param {{version?:number,fileDigest?:string}} prev
|
|
417
|
+
* @returns {{sourceFile:string,fileDigest:string,sourceVersion:number,skipped?:boolean,records:Array<object>}}
|
|
418
|
+
*/
|
|
419
|
+
export function buildAnchoredIndex(sourceFile, content, prev) {
|
|
420
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
|
|
421
|
+
if (buf.length > INDEX_MAX_FILE_BYTES) {
|
|
422
|
+
return { sourceFile, fileDigest: '', sourceVersion: (prev && prev.version) || 1, skipped: true, records: [] }
|
|
423
|
+
}
|
|
424
|
+
const fileDigest = sha256Hex(buf)
|
|
425
|
+
const sourceVersion = prev && prev.fileDigest === fileDigest ? (prev.version || 1) : (prev ? (prev.version || 1) + 1 : 1)
|
|
426
|
+
const parsed = parseAnchors(buf)
|
|
427
|
+
if (parsed.status !== 'clean' && parsed.status !== 'oversized') {
|
|
428
|
+
// 冲突文件不产出索引投影(与写入闸门一致 fail closed)
|
|
429
|
+
return { sourceFile, fileDigest, sourceVersion, skipped: false, records: [], conflicts: parsed.conflicts }
|
|
430
|
+
}
|
|
431
|
+
const records = parsed.records.map((r) => ({
|
|
432
|
+
kind: r.kind,
|
|
433
|
+
memoryId: r.memoryId,
|
|
434
|
+
anchorId: r.anchorId,
|
|
435
|
+
anchorLine: r.anchorLine || null,
|
|
436
|
+
heading: r.heading,
|
|
437
|
+
position: r.position || null,
|
|
438
|
+
lineStart: r.lineStart,
|
|
439
|
+
lineEnd: r.lineEnd,
|
|
440
|
+
byteStart: r.byteStart,
|
|
441
|
+
byteEnd: r.byteEnd,
|
|
442
|
+
bytes: r.bytes,
|
|
443
|
+
chars: r.chars,
|
|
444
|
+
recordDigest: r.recordDigest,
|
|
445
|
+
sourceVersion,
|
|
446
|
+
fileDigest,
|
|
447
|
+
}))
|
|
448
|
+
return { sourceFile, fileDigest, sourceVersion, skipped: false, records }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export { detectNewline }
|