@a9i5k4/dsh-auto-memory 0.1.29 → 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 +1379 -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 +2357 -102
- 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,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryDocumentWriter — M3b-2 原子写入基础设施(契约 §8-§9,M-06 project/atomicWrite 对应层)。
|
|
3
|
+
* 本阶段不接入真实写路径(M3b-3 才逐路径迁移)、不迁移真实 Markdown(memoryAnchorEnabled=false)。
|
|
4
|
+
*
|
|
5
|
+
* 组成:
|
|
6
|
+
* 1) 纯渲染原语(无 fs、零副作用):applyMigrationPlan / appendAnchoredRecord / renderReplace,
|
|
7
|
+
* 与 memory-anchor.js 的 parseAnchors 成对,保证 render→parse 幂等与身份稳定。
|
|
8
|
+
* 2) atomicReplace(target, data, fs):同目录临时文件 + fsync + rename(Windows 覆盖须实测)。
|
|
9
|
+
* 3) MemoryDocumentStore:per-file 串行 Promise 队列、digest precondition、backup、
|
|
10
|
+
* sidecar 落盘/重建(契约 §6 路径语义)、故障注入接口(fs 可注入)。
|
|
11
|
+
*
|
|
12
|
+
* 渲染规则:marker 独占一行(<!-- memory:mem_xxx -->);追加时 marker 后空行分隔内容;
|
|
13
|
+
* 换行风格沿用目标文件既有风格(LF/CRLF 保持,契约 §10);输出从不含 BOM。
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
17
|
+
import { promises as fsDefault } from 'node:fs'
|
|
18
|
+
import {
|
|
19
|
+
parseAnchors, buildSidecar, parseSidecar, newMemoryId, MEMORY_ID_RE, ANCHOR_PREFIX, detectNewline,
|
|
20
|
+
} from './memory-anchor.js'
|
|
21
|
+
import { INDEX_MAX_FILE_BYTES } from './memory-index.js'
|
|
22
|
+
|
|
23
|
+
function sha256Hex(buf) {
|
|
24
|
+
return createHash('sha256').update(buf).digest('hex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toBuf(content) {
|
|
28
|
+
return Buffer.isBuffer(content) ? content : Buffer.from(content == null ? '' : String(content), 'utf8')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 统一文本行尾风格:newline='crlf' 时全部
|
|
32
|
+
,否则全部
|
|
33
|
+
。 */
|
|
34
|
+
export function toEol(text, newline) {
|
|
35
|
+
const s = String(text)
|
|
36
|
+
return newline === 'crlf' ? s.replace(/\r?\n/g, '\r\n') : s.replace(/\r\n/g, '\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function markerBuf(memoryId, nl) {
|
|
40
|
+
return Buffer.from('<!-- memory:' + memoryId + ' -->' + nl, 'utf8')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 逆序插入 marker 到指定行首位置(批次内部按 atByte 升序传入)。 */
|
|
44
|
+
function insertMarkers(buf, inserts, nl) {
|
|
45
|
+
let out = buf
|
|
46
|
+
for (let i = inserts.length - 1; i >= 0; i--) {
|
|
47
|
+
const at = inserts[i].atByte
|
|
48
|
+
out = Buffer.concat([out.subarray(0, at), markerBuf(inserts[i].memoryId, nl), out.subarray(at)])
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 应用 dry-run 迁移计划(契约 §7 的应用器):在 legacy 块 byteStart 前插入 anchor marker。
|
|
55
|
+
* 校验:计划 aborted/字段非法、expectedFileDigest 不匹配(整份 stale)、超限、
|
|
56
|
+
* 当前文件含冲突、ID 重复、atByte 非升序、atByte 不是 legacy 块起点、非行首 —— 全部拒绝。
|
|
57
|
+
* 已迁移文件(无 legacy 块)会因 not-legacy-start 拒绝重放,天然幂等。
|
|
58
|
+
* @returns {{ok:true,text:Buffer,applied:number}|{ok:false,reason:string}}
|
|
59
|
+
*/
|
|
60
|
+
export function applyMigrationPlan(content, plan) {
|
|
61
|
+
if (!plan || typeof plan !== 'object' || !Array.isArray(plan.operations)) return { ok: false, reason: 'bad-plan' }
|
|
62
|
+
if (plan.aborted) return { ok: false, reason: 'aborted:' + (plan.conflicts || []).map((c) => c.type).join(',') }
|
|
63
|
+
const buf = toBuf(content)
|
|
64
|
+
if (buf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
|
|
65
|
+
if (plan.expectedFileDigest !== sha256Hex(buf)) return { ok: false, reason: 'stale-plan' }
|
|
66
|
+
const parsed = parseAnchors(buf)
|
|
67
|
+
if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
68
|
+
if (parsed.status !== 'clean') return { ok: false, reason: 'conflict:' + parsed.conflicts.map((c) => c.type).join(',') }
|
|
69
|
+
const pending = plan.operations.filter((op) => op && op.kind === 'insert-anchor')
|
|
70
|
+
if (!pending.length) return { ok: true, applied: 0, text: buf }
|
|
71
|
+
const legacyStarts = new Set(parsed.records.filter((r) => r.kind === 'legacy').map((r) => r.byteStart))
|
|
72
|
+
const seen = new Set()
|
|
73
|
+
let prevByte = -1
|
|
74
|
+
for (const op of pending) {
|
|
75
|
+
if (typeof op.atByte !== 'number' || !Number.isInteger(op.atByte) || op.atByte < 0 || op.atByte > buf.length) return { ok: false, reason: 'bad-atByte' }
|
|
76
|
+
if (typeof op.memoryId !== 'string' || !MEMORY_ID_RE.test(op.memoryId)) return { ok: false, reason: 'bad-id' }
|
|
77
|
+
if (seen.has(op.memoryId)) return { ok: false, reason: 'duplicate-id' }
|
|
78
|
+
seen.add(op.memoryId)
|
|
79
|
+
if (op.atByte <= prevByte) return { ok: false, reason: 'out-of-order' }
|
|
80
|
+
prevByte = op.atByte
|
|
81
|
+
if (!legacyStarts.has(op.atByte)) return { ok: false, reason: 'not-legacy-start' }
|
|
82
|
+
if (op.atByte > 0 && buf[op.atByte - 1] !== 0x0a) return { ok: false, reason: 'not-line-start' }
|
|
83
|
+
}
|
|
84
|
+
const nl = parsed.newline === 'crlf' ? '\r\n' : '\n'
|
|
85
|
+
const text = insertMarkers(buf, pending.map((op) => ({ atByte: op.atByte, memoryId: op.memoryId })), nl)
|
|
86
|
+
return { ok: true, text, applied: pending.length }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 尾部追加一条 anchored 记录(契约粒度:一次写入事务 = 一个 memoryId)。
|
|
91
|
+
* 渲染:尾部换行保证 + '<!-- marker -->' 独占行 + 空行 + 内容行 + 尾换行;行尾风格沿用文件。
|
|
92
|
+
* 重复 memoryId/冲突文件/超限 → 拒绝。
|
|
93
|
+
* @returns {{ok:true,text:Buffer,memoryId:string}|{ok:false,reason:string}}
|
|
94
|
+
*/
|
|
95
|
+
export function appendAnchoredRecord(content, { memoryId, text }) {
|
|
96
|
+
if (typeof memoryId !== 'string' || !MEMORY_ID_RE.test(memoryId)) return { ok: false, reason: 'bad-id' }
|
|
97
|
+
if (typeof text !== 'string' || !text.trim()) return { ok: false, reason: 'empty-record' }
|
|
98
|
+
const buf = toBuf(content)
|
|
99
|
+
if (buf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
|
|
100
|
+
const parsed = parseAnchors(buf)
|
|
101
|
+
if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
102
|
+
if (parsed.status !== 'clean') return { ok: false, reason: 'conflict:' + parsed.conflicts.map((c) => c.type).join(',') }
|
|
103
|
+
if (parsed.records.some((r) => r.kind === 'anchored' && r.memoryId === memoryId)) return { ok: false, reason: 'duplicate-id' }
|
|
104
|
+
const nl = parsed.newline === 'crlf' ? '\r\n' : '\n'
|
|
105
|
+
const body = toEol(text, parsed.newline)
|
|
106
|
+
let out = buf
|
|
107
|
+
if (out.length && out[out.length - 1] !== 0x0a) out = Buffer.concat([out, Buffer.from(nl, 'utf8')])
|
|
108
|
+
let block = '<!-- memory:' + memoryId + ' -->' + nl + nl + body
|
|
109
|
+
if (!block.endsWith(nl)) block += nl
|
|
110
|
+
out = Buffer.concat([out, Buffer.from(block, 'utf8')])
|
|
111
|
+
return { ok: true, text: out, memoryId }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 整篇替换(§9 语义,最高风险路径):解析 replacement 文档——
|
|
116
|
+
* - 原有合法 anchor 保持原 ID(kept);未带 anchor 的新块分配新 ID(added);
|
|
117
|
+
* - replacement 中带旧文档没有的 ID → 显式声明,保留(foreign,不做相似文本猜测);
|
|
118
|
+
* - 旧文档中被省略的 ID → 返回 removed(视为删除);
|
|
119
|
+
* - duplicate/malformed/orphan 冲突 → 拒绝(conflict)。
|
|
120
|
+
* 输出 = replacement 字节最小扰动(仅 legacy 块前插入 marker 行),anchored 块原样保留。
|
|
121
|
+
* @returns {{ok:true,text:Buffer,added:Array,kept:Array,foreign:Array,removed:Array}|{ok:false,reason:string,conflicts?:Array}}
|
|
122
|
+
*/
|
|
123
|
+
export function renderReplace(content, replacement, opts = {}) {
|
|
124
|
+
const oldBuf = toBuf(content)
|
|
125
|
+
if (oldBuf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
|
|
126
|
+
const rep = toBuf(replacement)
|
|
127
|
+
if (rep.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized-replacement' }
|
|
128
|
+
const oldParsed = parseAnchors(oldBuf)
|
|
129
|
+
if (oldParsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
130
|
+
if (oldParsed.status !== 'clean') return { ok: false, reason: 'conflict:' + oldParsed.conflicts.map((c) => c.type).join(',') }
|
|
131
|
+
const rp = parseAnchors(rep)
|
|
132
|
+
if (rp.status === 'oversized') return { ok: false, reason: 'oversized-replacement' }
|
|
133
|
+
if (rp.status !== 'clean') return { ok: false, reason: 'conflict:' + rp.conflicts.map((c) => c.type).join(','), conflicts: rp.conflicts }
|
|
134
|
+
const oldIds = new Set(oldParsed.records.filter((r) => r.kind === 'anchored' && r.memoryId).map((r) => r.memoryId))
|
|
135
|
+
const idFactory = (typeof opts.idFactory === 'function' ? opts.idFactory : newMemoryId)
|
|
136
|
+
const used = new Set(oldIds)
|
|
137
|
+
const added = []
|
|
138
|
+
const kept = []
|
|
139
|
+
const foreign = []
|
|
140
|
+
const inserts = []
|
|
141
|
+
for (const rec of rp.records) {
|
|
142
|
+
if (rec.kind === 'legacy') {
|
|
143
|
+
let id
|
|
144
|
+
let tries = 0
|
|
145
|
+
do {
|
|
146
|
+
id = idFactory()
|
|
147
|
+
tries += 1
|
|
148
|
+
} while (used.has(id) && tries <= 100)
|
|
149
|
+
if (tries > 100) return { ok: false, reason: 'id-exhausted' }
|
|
150
|
+
used.add(id)
|
|
151
|
+
added.push({ memoryId: id, anchorId: ANCHOR_PREFIX + id, lineStart: rec.lineStart, lineEnd: rec.lineEnd })
|
|
152
|
+
inserts.push({ atByte: rec.byteStart, memoryId: id })
|
|
153
|
+
} else {
|
|
154
|
+
if (oldIds.has(rec.memoryId)) kept.push(rec.memoryId)
|
|
155
|
+
else foreign.push(rec.memoryId)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const removed = [...oldIds].filter((id) => !rp.records.some((r) => r.kind === 'anchored' && r.memoryId === id))
|
|
159
|
+
const nl = rp.newline === 'crlf' ? '\r\n' : '\n'
|
|
160
|
+
const text = inserts.length ? insertMarkers(rep, inserts, nl) : rep
|
|
161
|
+
return { ok: true, text, added, kept, foreign, removed }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 单记录整篇替换(契约 §3 粒度:"一次写入事务 = 一个 memoryId",用于 reflection 等单记录文档):
|
|
166
|
+
* 全文以单个新 marker 开头、整体作为一条 anchored 记录;文本含保留 marker 语法 → 拒绝。
|
|
167
|
+
* @returns {{ok:true,text:Buffer,memoryId:string}|{ok:false,reason:string}}
|
|
168
|
+
*/
|
|
169
|
+
export function replaceSingleRecord(content, text, opts = {}) {
|
|
170
|
+
const body = typeof text === 'string' ? text : String(text == null ? '' : text)
|
|
171
|
+
if (!body.trim()) return { ok: false, reason: 'empty-record' }
|
|
172
|
+
const oldBuf = toBuf(content)
|
|
173
|
+
if (oldBuf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
|
|
174
|
+
const oldParsed = parseAnchors(oldBuf)
|
|
175
|
+
if (oldParsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
176
|
+
if (oldParsed.status !== 'clean') return { ok: false, reason: 'conflict:' + oldParsed.conflicts.map((c) => c.type).join(',') }
|
|
177
|
+
const idFactory = typeof opts.idFactory === 'function' ? opts.idFactory : newMemoryId
|
|
178
|
+
const used = new Set(oldParsed.records.filter((r) => r.kind === 'anchored').map((r) => r.memoryId))
|
|
179
|
+
let memoryId
|
|
180
|
+
let tries = 0
|
|
181
|
+
do {
|
|
182
|
+
memoryId = idFactory()
|
|
183
|
+
tries += 1
|
|
184
|
+
} while (used.has(memoryId) && tries <= 100)
|
|
185
|
+
if (tries > 100) return { ok: false, reason: 'id-exhausted' }
|
|
186
|
+
const nl = detectNewline(oldBuf.length ? oldBuf : Buffer.from(body, 'utf8')) === 'crlf' ? '\r\n' : '\n'
|
|
187
|
+
const candidate = Buffer.concat([markerBuf(memoryId, nl), Buffer.from(toEol(body, nl), 'utf8')])
|
|
188
|
+
const check = parseAnchors(candidate)
|
|
189
|
+
if (check.status !== 'clean') return { ok: false, reason: 'conflict:' + check.conflicts.map((c) => c.type).join(','), conflicts: check.conflicts }
|
|
190
|
+
const anchored = check.records.filter((r) => r.kind === 'anchored')
|
|
191
|
+
if (anchored.length !== 1 || anchored[0].memoryId !== memoryId) return { ok: false, reason: 'not-single-record' }
|
|
192
|
+
return { ok: true, text: candidate, memoryId }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 原子替换默认 fs 适配器之外的注入目标(测试故障注入/sidecar 目录等)。
|
|
197
|
+
* 同目录临时文件 + fsync + rename;任何失败清理临时文件并抛出。
|
|
198
|
+
*/
|
|
199
|
+
export async function atomicReplace(target, data, fsApi = fsDefault) {
|
|
200
|
+
const dir = path.dirname(target)
|
|
201
|
+
const tmp = path.join(dir, '.dam-pre-tmp-' + randomUUID().slice(0, 8) + '-' + path.basename(target))
|
|
202
|
+
await fsApi.mkdir(dir, { recursive: true })
|
|
203
|
+
let handle = null
|
|
204
|
+
try {
|
|
205
|
+
handle = await fsApi.open(tmp, 'w')
|
|
206
|
+
await handle.writeFile(data)
|
|
207
|
+
await handle.sync()
|
|
208
|
+
await handle.close()
|
|
209
|
+
handle = null
|
|
210
|
+
await fsApi.rename(tmp, target)
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (handle) { try { await handle.close() } catch (_) {} }
|
|
213
|
+
try { await fsApi.unlink(tmp) } catch (_) {}
|
|
214
|
+
throw e
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* per-file 串行写入事务(契约 §8 步骤 1-10):
|
|
220
|
+
* 读当前字节 → parse 验证 → 生成新内容 → 无 BOM/anchor 唯一校验 → tmp+fsync →
|
|
221
|
+
* backup → rename → 重读校验 digest → sidecar 重建(失败标记 dirty,不回滚 Markdown)。
|
|
222
|
+
* expectedDigest 不匹配(外部编辑) → 拒绝且不写。同文件并发写经队列串行,不丢失。
|
|
223
|
+
* fs/sidecarDir/backupDir 可注入(故障注入测试);sidecarDir 未配置则不做 sidecar 落盘。
|
|
224
|
+
*/
|
|
225
|
+
export class MemoryDocumentStore {
|
|
226
|
+
constructor(opts = {}) {
|
|
227
|
+
this.fs = opts.fs || fsDefault
|
|
228
|
+
this.sidecarDir = opts.sidecarDir || null
|
|
229
|
+
this.backupDir = opts.backupDir || null
|
|
230
|
+
this.now = opts.now || (() => Date.now())
|
|
231
|
+
this.idFactory = opts.idFactory || newMemoryId
|
|
232
|
+
this._locks = new Map()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
_queue(filePath, job) {
|
|
236
|
+
const key = path.resolve(filePath)
|
|
237
|
+
const prev = this._locks.get(key) || Promise.resolve()
|
|
238
|
+
const run = prev.then(job, job)
|
|
239
|
+
const settled = run.then(() => {}, () => {})
|
|
240
|
+
this._locks.set(key, settled)
|
|
241
|
+
// 队列空闲即回收条目,避免长期运行 Map 无界增长;新任务到达时会重新建立链条
|
|
242
|
+
settled.then(() => { if (this._locks.get(key) === settled) this._locks.delete(key) })
|
|
243
|
+
return run
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async _readState(filePath) {
|
|
247
|
+
try {
|
|
248
|
+
const buf = await this.fs.readFile(filePath)
|
|
249
|
+
const parsed = parseAnchors(buf)
|
|
250
|
+
return { buf, parsed, fileDigest: sha256Hex(buf) }
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if (e && e.code === 'ENOENT') return { buf: null, parsed: null, fileDigest: null }
|
|
253
|
+
throw e
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** sidecar 路径:sidecarDir + '<sha256(canonicalSourcePath)>.json'(契约 §6;canonical=resolve+正斜杠+小写)。 */
|
|
258
|
+
sidecarPath(filePath) {
|
|
259
|
+
if (!this.sidecarDir) return null
|
|
260
|
+
const canon = path.resolve(filePath).replace(/\\/g, '/').toLowerCase()
|
|
261
|
+
const hash = createHash('sha256').update(canon, 'utf8').digest('hex')
|
|
262
|
+
return path.join(this.sidecarDir, hash + '.json')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async _writeSidecar(filePath, sidecar) {
|
|
266
|
+
const sp = this.sidecarPath(filePath)
|
|
267
|
+
if (!sp) throw new Error('no-sidecar-dir')
|
|
268
|
+
await this.fs.mkdir(path.dirname(sp), { recursive: true })
|
|
269
|
+
await atomicReplace(sp, Buffer.from(JSON.stringify(sidecar, null, 2) + '\n', 'utf8'), this.fs)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 读已落盘 sidecar;损坏返回 {ok:false,reason} 由调用方隔离并从 Markdown 重建。 */
|
|
273
|
+
async readSidecar(filePath) {
|
|
274
|
+
const sp = this.sidecarPath(filePath)
|
|
275
|
+
if (!sp) return { ok: false, reason: 'no-sidecar-dir' }
|
|
276
|
+
try {
|
|
277
|
+
const text = await this.fs.readFile(sp, 'utf8')
|
|
278
|
+
return parseSidecar(text)
|
|
279
|
+
} catch (e) {
|
|
280
|
+
return { ok: false, reason: e && e.code === 'ENOENT' ? 'missing' : 'io-error' }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 事务提交(步骤 4-10):校验无 BOM → backup → atomicReplace → 重读校验 → sidecar。 */
|
|
285
|
+
async _commit(filePath, content, opts = {}) {
|
|
286
|
+
const out = toBuf(content)
|
|
287
|
+
if (out.length >= 3 && out[0] === 0xef && out[1] === 0xbb && out[2] === 0xbf) return { ok: false, reason: 'bom-rejected' }
|
|
288
|
+
let existed = true
|
|
289
|
+
try { await this.fs.stat(filePath) } catch (e) { if (e && e.code === 'ENOENT') existed = false; else throw e }
|
|
290
|
+
if (this.backupDir && existed) {
|
|
291
|
+
try {
|
|
292
|
+
await this.fs.mkdir(this.backupDir, { recursive: true })
|
|
293
|
+
const bakName = this.now().toString() + '-' + randomUUID().slice(0, 8) + '-' + path.basename(filePath)
|
|
294
|
+
await this.fs.copyFile(filePath, path.join(this.backupDir, bakName))
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return { ok: false, reason: 'backup-failed:' + (e && e.message ? e.message : String(e)) }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
await atomicReplace(filePath, out, this.fs)
|
|
301
|
+
} catch (e) {
|
|
302
|
+
return { ok: false, reason: 'write-failed:' + (e && e.message ? e.message : String(e)) }
|
|
303
|
+
}
|
|
304
|
+
let reread
|
|
305
|
+
try { reread = await this.fs.readFile(filePath) } catch (e) { return { ok: false, reason: 'verify-read-failed', written: true } }
|
|
306
|
+
const digest = sha256Hex(reread)
|
|
307
|
+
// 契约 §8 步骤 8:重读内容必须与预期写入字节一致,否则视为写入后损坏(不回滚,显式报错)
|
|
308
|
+
if (digest !== sha256Hex(out)) return { ok: false, reason: 'verify-mismatch', written: true }
|
|
309
|
+
// sidecar 尽力落盘;失败标记 dirty,不回滚已成功写入的 Markdown(契约 §6)。
|
|
310
|
+
// prev 优先用调用方传入;否则自动读已落盘 sidecar → digest 变化即 version+1、epoch 保持,
|
|
311
|
+
// sidecar 缺失/损坏 → 视为无 prev → 新 epoch(契约 §6 重建语义)。
|
|
312
|
+
let dirty = false
|
|
313
|
+
let sidecar = null
|
|
314
|
+
if (this.sidecarDir) {
|
|
315
|
+
let prevSidecar = opts.prevSidecar
|
|
316
|
+
if (!prevSidecar) {
|
|
317
|
+
const cur = await this.readSidecar(filePath)
|
|
318
|
+
if (cur.ok) prevSidecar = cur.sidecar
|
|
319
|
+
}
|
|
320
|
+
const sb = buildSidecar({ sourceFile: filePath, content: reread, prev: prevSidecar })
|
|
321
|
+
if (sb.ok) {
|
|
322
|
+
try { await this._writeSidecar(filePath, sb.sidecar); sidecar = sb.sidecar } catch (_) { dirty = true }
|
|
323
|
+
} else {
|
|
324
|
+
dirty = true
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { ok: true, digest, dirty, sidecar, written: true }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 尾部追加记录(事务);同文件串行。 */
|
|
331
|
+
append(filePath, text, opts = {}) {
|
|
332
|
+
return this._queue(filePath, async () => {
|
|
333
|
+
const state = await this._readState(filePath)
|
|
334
|
+
if (opts.expectedDigest != null && state.fileDigest !== opts.expectedDigest) return { ok: false, reason: 'conflict-external-edit' }
|
|
335
|
+
const memoryId = opts.memoryId || this.idFactory()
|
|
336
|
+
const app = appendAnchoredRecord(state.buf, { memoryId, text })
|
|
337
|
+
if (!app.ok) return app
|
|
338
|
+
const res = await this._commit(filePath, app.text, { prevSidecar: opts.prevSidecar })
|
|
339
|
+
return { ...res, memoryId }
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 整篇替换(§9);同文件串行。 */
|
|
344
|
+
replace(filePath, replacement, opts = {}) {
|
|
345
|
+
return this._queue(filePath, async () => {
|
|
346
|
+
const state = await this._readState(filePath)
|
|
347
|
+
if (opts.expectedDigest != null && state.fileDigest !== opts.expectedDigest) return { ok: false, reason: 'conflict-external-edit' }
|
|
348
|
+
const rr = renderReplace(state.buf, replacement, { idFactory: opts.idFactory || this.idFactory })
|
|
349
|
+
if (!rr.ok) return rr
|
|
350
|
+
const res = await this._commit(filePath, rr.text, { prevSidecar: opts.prevSidecar })
|
|
351
|
+
return { ...res, added: rr.added, kept: rr.kept, foreign: rr.foreign, removed: rr.removed }
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 单记录整篇替换(reflection 等单记录文档,契约 §3 粒度);同文件串行。 */
|
|
356
|
+
replaceSingle(filePath, text, opts = {}) {
|
|
357
|
+
return this._queue(filePath, async () => {
|
|
358
|
+
const state = await this._readState(filePath)
|
|
359
|
+
if (opts.expectedDigest != null && state.fileDigest !== opts.expectedDigest) return { ok: false, reason: 'conflict-external-edit' }
|
|
360
|
+
const rr = replaceSingleRecord(state.buf, text, { idFactory: opts.idFactory || this.idFactory })
|
|
361
|
+
if (!rr.ok) return rr
|
|
362
|
+
const res = await this._commit(filePath, rr.text, { prevSidecar: opts.prevSidecar })
|
|
363
|
+
return { ...res, memoryId: rr.memoryId }
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 应用迁移计划(事务);同文件串行。 */
|
|
368
|
+
applyPlan(filePath, plan, opts = {}) {
|
|
369
|
+
return this._queue(filePath, async () => {
|
|
370
|
+
const state = await this._readState(filePath)
|
|
371
|
+
const ap = applyMigrationPlan(state.buf, plan)
|
|
372
|
+
if (!ap.ok) return ap
|
|
373
|
+
const res = await this._commit(filePath, ap.text, { prevSidecar: opts.prevSidecar })
|
|
374
|
+
return { ...res, applied: ap.applied }
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 只重建 sidecar(不改 Markdown);无 prev → 新 epoch + sourceVersion=1(契约 §6)。 */
|
|
379
|
+
rebuildSidecar(filePath, prev) {
|
|
380
|
+
return this._queue(filePath, async () => {
|
|
381
|
+
const state = await this._readState(filePath)
|
|
382
|
+
if (!state.buf) return { ok: false, reason: 'missing' }
|
|
383
|
+
const sb = buildSidecar({ sourceFile: filePath, content: state.buf, prev: prev || undefined })
|
|
384
|
+
if (!sb.ok) return sb
|
|
385
|
+
await this._writeSidecar(filePath, sb.sidecar)
|
|
386
|
+
return { ok: true, sidecar: sb.sidecar }
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { toBuf }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"policyVersion": "activation_policy_v2",
|
|
4
|
+
"parentPolicyVersion": "m7_semantic_threshold_v1",
|
|
5
|
+
"createdAt": "2026-08-25",
|
|
6
|
+
"runId": "label-review-cal20260824-1954",
|
|
7
|
+
"goldDigest": "e74ccdcda998ae51596adf8119c818ac84fc52a4fa657fd687daeda7d14a072d",
|
|
8
|
+
"mode": "shadow-candidate",
|
|
9
|
+
"decisionRecordId": "activation-v2-delta-exp-override-20260824",
|
|
10
|
+
"deltaDeviationFromBrief": {
|
|
11
|
+
"briefFrozen": 0.02,
|
|
12
|
+
"effective": 0.03,
|
|
13
|
+
"reason": "production-completeness refit: delta=0.02 fails gates (cal-0008 emitOnP=1); nearest passing = delta_exp=0.03; requires retro-ratification"
|
|
14
|
+
},
|
|
15
|
+
"decisionOrder": [
|
|
16
|
+
"js_hard_gates",
|
|
17
|
+
"lane_decision",
|
|
18
|
+
"explicit_lane",
|
|
19
|
+
"proactive_lane",
|
|
20
|
+
"completeness_margin",
|
|
21
|
+
"decision"
|
|
22
|
+
],
|
|
23
|
+
"thresholds": {
|
|
24
|
+
"tauLane": 0.45,
|
|
25
|
+
"tauHi": 0.45,
|
|
26
|
+
"tauLo": 0.35,
|
|
27
|
+
"deltaExp": 0.03,
|
|
28
|
+
"deltaPro": 0.05
|
|
29
|
+
},
|
|
30
|
+
"echoVeto": {
|
|
31
|
+
"scope": "proactive-lane-only",
|
|
32
|
+
"containmentArm": 0.3,
|
|
33
|
+
"denseTopArm": 0.7,
|
|
34
|
+
"requiresMarkZero": true,
|
|
35
|
+
"requiresIntentBelow": 0.5
|
|
36
|
+
},
|
|
37
|
+
"completenessGate": {
|
|
38
|
+
"phase": 1,
|
|
39
|
+
"lexicon": [
|
|
40
|
+
"对比",
|
|
41
|
+
"分别",
|
|
42
|
+
"两个",
|
|
43
|
+
"一起",
|
|
44
|
+
"都调",
|
|
45
|
+
"各自"
|
|
46
|
+
],
|
|
47
|
+
"rule": "status!=complete -> max prefetch",
|
|
48
|
+
"outputs": [
|
|
49
|
+
"requiredTargetCount",
|
|
50
|
+
"resolvedTargetCount",
|
|
51
|
+
"status"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"repetition": {
|
|
55
|
+
"round": "logging-only",
|
|
56
|
+
"suppressToPrefetchAllowed": true,
|
|
57
|
+
"activateOnCountsAlone": false
|
|
58
|
+
},
|
|
59
|
+
"hardGates": [
|
|
60
|
+
"harmful",
|
|
61
|
+
"correction",
|
|
62
|
+
"ignored",
|
|
63
|
+
"stale",
|
|
64
|
+
"wrong_scope",
|
|
65
|
+
"pii_class_never_proactive"
|
|
66
|
+
],
|
|
67
|
+
"reasonCodes": [
|
|
68
|
+
"hard_gate_harmful",
|
|
69
|
+
"echo_veto_proactive",
|
|
70
|
+
"explicit_lane",
|
|
71
|
+
"explicit_lane_weak",
|
|
72
|
+
"completeness_complete",
|
|
73
|
+
"completeness_partial",
|
|
74
|
+
"completeness_unknown",
|
|
75
|
+
"proactive_margin",
|
|
76
|
+
"margin_below_delta",
|
|
77
|
+
"suppress_low_signal"
|
|
78
|
+
],
|
|
79
|
+
"offlineMetrics86": {
|
|
80
|
+
"actPrecision": 1.0,
|
|
81
|
+
"actRecall": 0.289,
|
|
82
|
+
"emitOnP": 0,
|
|
83
|
+
"sViolations": 0,
|
|
84
|
+
"emits": 11,
|
|
85
|
+
"emitCorrectA": 11
|
|
86
|
+
},
|
|
87
|
+
"configHash": "cfgh_31e6d977a2c40d5e8bf4edf900557b8b"
|
|
88
|
+
}
|