@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -7
- package/README.zh-CN.md +189 -7
- package/docs/CONTRIBUTORS.html +471 -0
- package/docs/FRONTEND-CO-CREATION.md +191 -0
- package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
- package/docs/HANDOFF-CRITERIA.md +92 -0
- package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
- package/docs/INTEGRATION-ANALYSIS.md +350 -348
- package/docs/PROMO-PROMPT-3.0.md +100 -0
- package/docs/USER-GUIDE.en.md +58 -3
- package/docs/USER-GUIDE.zh-CN.md +59 -4
- package/docs/WHITEPAPER.md +207 -0
- package/docs/internal/ACCEPT-35-LIVE.md +143 -0
- package/docs/internal/ACCEPTANCE-20260914.md +90 -0
- package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
- package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
- package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
- package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
- package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
- package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
- package/docs/internal/FEATURE-INVENTORY.md +531 -0
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
- package/docs/internal/G3-DESIGN-20260918.md +82 -0
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
- package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
- package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
- package/docs/internal/KICKOFF-P0.md +254 -0
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
- package/docs/internal/MASTER-PLAN-3.0.md +411 -0
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
- package/docs/internal/PENDING-FIXES-20260916.md +289 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
- package/docs/internal/PROGRESS-20260917.md +93 -0
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
- package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
- package/docs/internal/RESUME-20260918.md +171 -0
- package/docs/internal/RESUME-20260919.md +104 -0
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
- package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
- package/docs/internal/ROADMAP.md +106 -0
- package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
- package/docs/internal/T6-EXECUTION-20260920.md +130 -0
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
- package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
- package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
- package/docs/internal/TODO-BACKLOG.md +263 -142
- package/docs/internal/TODO-GRAPH.html +715 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
- package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
- package/docs/prompts/M8-3-enable-verify.md +49 -49
- package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
- package/lib/acceptance.js +71 -0
- package/lib/activation-host.js +153 -18
- package/lib/activation-inbox.js +25 -7
- package/lib/board-mode.js +30 -0
- package/lib/client.js +1758 -90
- package/lib/config-io.js +156 -0
- package/lib/context-bridge.js +5 -2
- package/lib/context-host.js +86 -15
- package/lib/degrade.js +385 -0
- package/lib/dsh-home.js +143 -0
- package/lib/engine-identity.js +149 -0
- package/lib/engine-switch.js +247 -0
- package/lib/episodic-store.js +63 -12
- package/lib/evidence-store.js +10 -3
- package/lib/fact-store.js +22 -3
- package/lib/fs-retry.js +46 -0
- package/lib/index-sync.js +13 -1
- package/lib/index.js +3446 -263
- package/lib/intent-clean-safe.js +258 -0
- package/lib/intent-clean.js +12 -16
- package/lib/l0-extract.js +478 -149
- package/lib/l0-index-sync.js +195 -0
- package/lib/l0-index.js +349 -239
- package/lib/ledger-criteria.js +142 -0
- package/lib/m4-corpus.js +8 -2
- package/lib/m7-index-sync-host.js +73 -5
- package/lib/m7-wire.js +3 -3
- package/lib/memory-anchor.js +56 -1
- package/lib/memory-envelope.js +257 -0
- package/lib/memory-hub.js +138 -13
- package/lib/memory-index.js +4 -2
- package/lib/memory-mutation.js +246 -0
- package/lib/memory-writer.js +204 -24
- package/lib/note-status-apply.js +118 -0
- package/lib/note-status.js +196 -0
- package/lib/procedure-observation.js +48 -0
- package/lib/procedure-store.js +118 -20
- package/lib/python-setup.js +1 -1
- package/lib/python-sidecar-client.js +29 -3
- package/lib/recall-fusion.js +83 -12
- package/lib/rerank-host.js +160 -0
- package/lib/rules-edit.js +159 -0
- package/lib/rules-layer.js +261 -0
- package/lib/semantic-decide.js +41 -8
- package/lib/semantic-js.js +66 -6
- package/lib/shadow-host.js +3 -5
- package/lib/shadow-retrieval.js +3 -3
- package/lib/skill-export-host.js +153 -0
- package/lib/skill-export.js +239 -0
- package/lib/state-commit.js +245 -0
- package/lib/storage-manage.js +6 -0
- package/lib/subagent-gc.js +4 -8
- package/lib/temporal-parse.js +191 -159
- package/lib/tier-layer-inject.js +650 -0
- package/lib/tier0-catalog.js +735 -0
- package/lib/water-window.js +263 -186
- package/lib/wb-contract.js +691 -0
- package/lib/wb-sidecar.js +890 -0
- package/lib/ws-overview-rank.js +2 -2
- package/package.json +1 -1
- package/python/m7_embedding_v1.py +5 -5
- package/python/worker_semantic_v1.py +17 -6
- package/python/worker_v1.py +38 -4
package/lib/memory-writer.js
CHANGED
|
@@ -17,8 +17,10 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
17
17
|
import { promises as fsDefault } from 'node:fs'
|
|
18
18
|
import {
|
|
19
19
|
parseAnchors, buildSidecar, parseSidecar, newMemoryId, MEMORY_ID_RE, ANCHOR_PREFIX, detectNewline,
|
|
20
|
+
MARKER_OPEN, checkReservedSyntaxInContent,
|
|
20
21
|
} from './memory-anchor.js'
|
|
21
22
|
import { INDEX_MAX_FILE_BYTES } from './memory-index.js'
|
|
23
|
+
import { retryRename } from './fs-retry.js'
|
|
22
24
|
|
|
23
25
|
function sha256Hex(buf) {
|
|
24
26
|
return createHash('sha256').update(buf).digest('hex')
|
|
@@ -40,6 +42,28 @@ function markerBuf(memoryId, nl) {
|
|
|
40
42
|
return Buffer.from('<!-- memory:' + memoryId + ' -->' + nl, 'utf8')
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* issue #54 P1(可诊断性):把 `parseAnchors` 的 conflicts 格式化为**带行号**的 reason。
|
|
47
|
+
*
|
|
48
|
+
* 旧实现统一 `.map(c => c.type)` ⇒ 丢掉 `line`/`byteStart`/`byteEnd`
|
|
49
|
+
* (`memory-anchor.js:184` 其实已经算好了),报错只说"冲突",
|
|
50
|
+
* 使用者(和模型)无从定位是哪一行,也不知道是自己这次写入引入的还是文件本来就有。
|
|
51
|
+
*
|
|
52
|
+
* 保持 `conflict:` 前缀不变(既有测试断言 `reason.startsWith('conflict')`)。
|
|
53
|
+
* @param {Array} conflicts parseAnchors 返回的冲突对象数组
|
|
54
|
+
* @returns {string} 形如 `conflict:orphan-content@12`(多个以 `,` 连接)
|
|
55
|
+
*/
|
|
56
|
+
function formatConflicts(conflicts) {
|
|
57
|
+
return 'conflict:' + (conflicts || [])
|
|
58
|
+
.map((c) => c.type + (Number.isFinite(c.line) ? '@' + c.line : ''))
|
|
59
|
+
.join(',')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** issue #54 P0:写入路径统一的保留语法前置校验(复用 memory-anchor-pre 的同一判据)。 */
|
|
63
|
+
function reservedSyntaxGuard(text) {
|
|
64
|
+
return checkReservedSyntaxInContent(text)
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
/** 逆序插入 marker 到指定行首位置(批次内部按 atByte 升序传入)。 */
|
|
44
68
|
function insertMarkers(buf, inserts, nl) {
|
|
45
69
|
let out = buf
|
|
@@ -65,7 +89,7 @@ export function applyMigrationPlan(content, plan) {
|
|
|
65
89
|
if (plan.expectedFileDigest !== sha256Hex(buf)) return { ok: false, reason: 'stale-plan' }
|
|
66
90
|
const parsed = parseAnchors(buf)
|
|
67
91
|
if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
68
|
-
if (parsed.status !== 'clean') return { ok: false, reason:
|
|
92
|
+
if (parsed.status !== 'clean') return { ok: false, reason: formatConflicts(parsed.conflicts), conflicts: parsed.conflicts }
|
|
69
93
|
const pending = plan.operations.filter((op) => op && op.kind === 'insert-anchor')
|
|
70
94
|
if (!pending.length) return { ok: true, applied: 0, text: buf }
|
|
71
95
|
const legacyStarts = new Set(parsed.records.filter((r) => r.kind === 'legacy').map((r) => r.byteStart))
|
|
@@ -95,11 +119,14 @@ export function applyMigrationPlan(content, plan) {
|
|
|
95
119
|
export function appendAnchoredRecord(content, { memoryId, text }) {
|
|
96
120
|
if (typeof memoryId !== 'string' || !MEMORY_ID_RE.test(memoryId)) return { ok: false, reason: 'bad-id' }
|
|
97
121
|
if (typeof text !== 'string' || !text.trim()) return { ok: false, reason: 'empty-record' }
|
|
122
|
+
// issue #54 P0:先校验**本次要写的正文**,再校验文件已有内容——顺序不可颠倒。
|
|
123
|
+
const guard = reservedSyntaxGuard(text)
|
|
124
|
+
if (!guard.ok) return { ok: false, reason: guard.reason, line: guard.line, detail: guard.detail }
|
|
98
125
|
const buf = toBuf(content)
|
|
99
126
|
if (buf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
|
|
100
127
|
const parsed = parseAnchors(buf)
|
|
101
128
|
if (parsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
102
|
-
if (parsed.status !== 'clean') return { ok: false, reason:
|
|
129
|
+
if (parsed.status !== 'clean') return { ok: false, reason: formatConflicts(parsed.conflicts), conflicts: parsed.conflicts }
|
|
103
130
|
if (parsed.records.some((r) => r.kind === 'anchored' && r.memoryId === memoryId)) return { ok: false, reason: 'duplicate-id' }
|
|
104
131
|
const nl = parsed.newline === 'crlf' ? '\r\n' : '\n'
|
|
105
132
|
const body = toEol(text, parsed.newline)
|
|
@@ -127,10 +154,10 @@ export function renderReplace(content, replacement, opts = {}) {
|
|
|
127
154
|
if (rep.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized-replacement' }
|
|
128
155
|
const oldParsed = parseAnchors(oldBuf)
|
|
129
156
|
if (oldParsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
130
|
-
if (oldParsed.status !== 'clean') return { ok: false, reason:
|
|
157
|
+
if (oldParsed.status !== 'clean') return { ok: false, reason: formatConflicts(oldParsed.conflicts), conflicts: oldParsed.conflicts }
|
|
131
158
|
const rp = parseAnchors(rep)
|
|
132
159
|
if (rp.status === 'oversized') return { ok: false, reason: 'oversized-replacement' }
|
|
133
|
-
if (rp.status !== 'clean') return { ok: false, reason:
|
|
160
|
+
if (rp.status !== 'clean') return { ok: false, reason: formatConflicts(rp.conflicts), conflicts: rp.conflicts }
|
|
134
161
|
const oldIds = new Set(oldParsed.records.filter((r) => r.kind === 'anchored' && r.memoryId).map((r) => r.memoryId))
|
|
135
162
|
const idFactory = (typeof opts.idFactory === 'function' ? opts.idFactory : newMemoryId)
|
|
136
163
|
const used = new Set(oldIds)
|
|
@@ -169,11 +196,14 @@ export function renderReplace(content, replacement, opts = {}) {
|
|
|
169
196
|
export function replaceSingleRecord(content, text, opts = {}) {
|
|
170
197
|
const body = typeof text === 'string' ? text : String(text == null ? '' : text)
|
|
171
198
|
if (!body.trim()) return { ok: false, reason: 'empty-record' }
|
|
199
|
+
// issue #54 P0:单记录写入的正文**整段都是内容**(marker 由本函数生成),故正文内出现保留语法必属误用 ⇒ 前置拒绝。
|
|
200
|
+
const guard = reservedSyntaxGuard(body)
|
|
201
|
+
if (!guard.ok) return { ok: false, reason: guard.reason, line: guard.line, detail: guard.detail }
|
|
172
202
|
const oldBuf = toBuf(content)
|
|
173
203
|
if (oldBuf.length > INDEX_MAX_FILE_BYTES) return { ok: false, reason: 'oversized' }
|
|
174
204
|
const oldParsed = parseAnchors(oldBuf)
|
|
175
205
|
if (oldParsed.status === 'oversized') return { ok: false, reason: 'oversized' }
|
|
176
|
-
if (oldParsed.status !== 'clean') return { ok: false, reason:
|
|
206
|
+
if (oldParsed.status !== 'clean') return { ok: false, reason: formatConflicts(oldParsed.conflicts), conflicts: oldParsed.conflicts }
|
|
177
207
|
const idFactory = typeof opts.idFactory === 'function' ? opts.idFactory : newMemoryId
|
|
178
208
|
const used = new Set(oldParsed.records.filter((r) => r.kind === 'anchored').map((r) => r.memoryId))
|
|
179
209
|
let memoryId
|
|
@@ -186,7 +216,7 @@ export function replaceSingleRecord(content, text, opts = {}) {
|
|
|
186
216
|
const nl = detectNewline(oldBuf.length ? oldBuf : Buffer.from(body, 'utf8')) === 'crlf' ? '\r\n' : '\n'
|
|
187
217
|
const candidate = Buffer.concat([markerBuf(memoryId, nl), Buffer.from(toEol(body, nl), 'utf8')])
|
|
188
218
|
const check = parseAnchors(candidate)
|
|
189
|
-
if (check.status !== 'clean') return { ok: false, reason:
|
|
219
|
+
if (check.status !== 'clean') return { ok: false, reason: formatConflicts(check.conflicts), conflicts: check.conflicts }
|
|
190
220
|
const anchored = check.records.filter((r) => r.kind === 'anchored')
|
|
191
221
|
if (anchored.length !== 1 || anchored[0].memoryId !== memoryId) return { ok: false, reason: 'not-single-record' }
|
|
192
222
|
return { ok: true, text: candidate, memoryId }
|
|
@@ -194,24 +224,70 @@ export function replaceSingleRecord(content, text, opts = {}) {
|
|
|
194
224
|
|
|
195
225
|
/**
|
|
196
226
|
* 原子替换默认 fs 适配器之外的注入目标(测试故障注入/sidecar 目录等)。
|
|
197
|
-
* 同目录临时文件 + fsync + rename
|
|
227
|
+
* 同目录临时文件 + fsync + **有界 rename 重试**(issue #48);替换失败保留完整候选快照,不覆盖回放。
|
|
228
|
+
*
|
|
229
|
+
* 2026-09-16 修正(issue #48):旧实现 rename 一次失败即硬失败并 unlink 临时文件,
|
|
230
|
+
* 在 Windows 并发子代理下(DSH 仍持有目标句柄)会把本可成功的写入连残骸一起丢掉。
|
|
231
|
+
* 现在:① 瞬时错误码(EPERM/EACCES/EBUSY)走退避重试;② 仍失败则把**完整候选快照**
|
|
232
|
+
* 改名保留为 `.dam-failed-*.tmp` 并在错误上回传 `recoveryPath`,由调用方人工比对
|
|
233
|
+
* (候选是**整篇快照**而非追加指令,绝不允许自动回放——期间可能有别的写入者推进了目标)。
|
|
198
234
|
*/
|
|
199
|
-
export async function atomicReplace(target, data, fsApi = fsDefault) {
|
|
235
|
+
export async function atomicReplace(target, data, fsApi = fsDefault, opts = {}) {
|
|
200
236
|
const dir = path.dirname(target)
|
|
201
|
-
const
|
|
237
|
+
const nonce = randomUUID()
|
|
238
|
+
// .tmp 后缀保证 待处理/恢复 快照不进入 *.md / *.json 扫描(见 issue #51 同类问题)
|
|
239
|
+
const tmp = path.join(dir, '.dam-pre-tmp-' + nonce + '-' + path.basename(target) + '.tmp')
|
|
202
240
|
await fsApi.mkdir(dir, { recursive: true })
|
|
203
241
|
let handle = null
|
|
242
|
+
let created = false
|
|
243
|
+
let complete = false
|
|
244
|
+
let stage = 'open'
|
|
204
245
|
try {
|
|
205
|
-
|
|
246
|
+
// 独占创建:碰撞时绝不截断/删除别人的临时文件
|
|
247
|
+
handle = await fsApi.open(tmp, 'wx', 0o600)
|
|
248
|
+
created = true
|
|
249
|
+
stage = 'write'
|
|
206
250
|
await handle.writeFile(data)
|
|
251
|
+
stage = 'sync'
|
|
207
252
|
await handle.sync()
|
|
253
|
+
stage = 'close'
|
|
208
254
|
await handle.close()
|
|
209
255
|
handle = null
|
|
210
|
-
|
|
211
|
-
|
|
256
|
+
complete = true
|
|
257
|
+
stage = 'rename'
|
|
258
|
+
await retryRename(tmp, target, { fs: fsApi, delays: opts.renameDelays, sleep: opts.sleep })
|
|
259
|
+
} catch (cause) {
|
|
212
260
|
if (handle) { try { await handle.close() } catch (_) {} }
|
|
213
|
-
|
|
214
|
-
|
|
261
|
+
const details = { stage, targetPath: target, recoveryComplete: false }
|
|
262
|
+
if (created && complete && opts.preserveOnFailure !== false) {
|
|
263
|
+
// 这是**整篇文档的候选快照**,不是追加指令 —— 绝不自动回放(别的写入者可能已推进目标)。
|
|
264
|
+
let recoveryPath = tmp
|
|
265
|
+
const failed = path.join(dir, '.dam-failed-' + Date.now() + '-' + nonce + '-' + path.basename(target) + '.tmp')
|
|
266
|
+
try { await fsApi.rename(tmp, failed); recoveryPath = failed } catch (_) {
|
|
267
|
+
// 恢复用的 rename 本身也可能被占用 ⇒ 保留原临时路径
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const retained = await fsApi.stat(recoveryPath)
|
|
271
|
+
if (!retained.isFile()) throw new Error('recovery snapshot is not a file')
|
|
272
|
+
details.recoveryPath = recoveryPath
|
|
273
|
+
details.recoveryComplete = true
|
|
274
|
+
} catch (_) {
|
|
275
|
+
// 快照可能已被扫描器/其它进程移走 —— 不能仅凭"本函数没 unlink"就宣称保全成功
|
|
276
|
+
details.recoveryUnavailable = true
|
|
277
|
+
}
|
|
278
|
+
} else if (created) {
|
|
279
|
+
try { await fsApi.unlink(tmp) } catch (cleanupError) {
|
|
280
|
+
details.partialPath = tmp
|
|
281
|
+
details.cleanupCode = cleanupError && cleanupError.code
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// 默认保留原始 fs 错误(含 code/errno/syscall/path/dest)。
|
|
285
|
+
// 冻结/非 Error 抛出不得用 TypeError 掩盖真实写入失败。
|
|
286
|
+
const error = cause instanceof Error && Object.isExtensible(cause)
|
|
287
|
+
? cause : new Error(cause && cause.message ? cause.message : String(cause), { cause })
|
|
288
|
+
if (error !== cause && cause && typeof cause.code === 'string') error.code = cause.code
|
|
289
|
+
Object.assign(error, details)
|
|
290
|
+
throw error
|
|
215
291
|
}
|
|
216
292
|
}
|
|
217
293
|
|
|
@@ -222,6 +298,39 @@ export async function atomicReplace(target, data, fsApi = fsDefault) {
|
|
|
222
298
|
* expectedDigest 不匹配(外部编辑) → 拒绝且不写。同文件并发写经队列串行,不丢失。
|
|
223
299
|
* fs/sidecarDir/backupDir 可注入(故障注入测试);sidecarDir 未配置则不做 sidecar 落盘。
|
|
224
300
|
*/
|
|
301
|
+
// 同一 fs 后端共享队列(注入的虚拟文件系统之间仍互相隔离)。**注意:这不是跨进程锁。**
|
|
302
|
+
const queuesByFs = new WeakMap()
|
|
303
|
+
function queuesFor(fsApi) {
|
|
304
|
+
let queues = queuesByFs.get(fsApi)
|
|
305
|
+
if (!queues) { queues = new Map(); queuesByFs.set(fsApi, queues) }
|
|
306
|
+
return queues
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 队列键:Windows 下大小写不敏感且需规范化,避免同一文件两条队列并行。 */
|
|
310
|
+
export function memoryWriteLockKey(filePath, platform = process.platform) {
|
|
311
|
+
const resolved = (platform === 'win32' ? path.win32 : path.posix).resolve(filePath)
|
|
312
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 把 store 的结构化写状态保留成 Error(issue #48):写入失败不可降级为一句无信息的文案。 */
|
|
316
|
+
export function memoryWriteError(operation, result) {
|
|
317
|
+
let message = 'memory-anchor-' + operation + '-failed:' + result.reason
|
|
318
|
+
if (result.recoveryPath) {
|
|
319
|
+
message += '; recoveryPath=' + JSON.stringify(result.recoveryPath) +
|
|
320
|
+
'; recovery is a candidate snapshot, compare with current document before manual recovery'
|
|
321
|
+
}
|
|
322
|
+
if (result.partialPath) message += '; partialPath=' + JSON.stringify(result.partialPath) + '; incomplete, not safe to restore'
|
|
323
|
+
if (result.written === true) message += '; written=true, verify current document before retrying'
|
|
324
|
+
const error = new Error(message)
|
|
325
|
+
error.code = result.errorCode || (result.written === true ? 'MEMORY_WRITE_VERIFY_FAILED' : 'MEMORY_WRITE_FAILED')
|
|
326
|
+
error.fsCode = result.fsCode
|
|
327
|
+
error.written = result.written === true
|
|
328
|
+
error.recoveryPath = result.recoveryPath
|
|
329
|
+
error.recoveryComplete = result.recoveryComplete === true
|
|
330
|
+
error.partialPath = result.partialPath
|
|
331
|
+
return error
|
|
332
|
+
}
|
|
333
|
+
|
|
225
334
|
export class MemoryDocumentStore {
|
|
226
335
|
constructor(opts = {}) {
|
|
227
336
|
this.fs = opts.fs || fsDefault
|
|
@@ -229,11 +338,12 @@ export class MemoryDocumentStore {
|
|
|
229
338
|
this.backupDir = opts.backupDir || null
|
|
230
339
|
this.now = opts.now || (() => Date.now())
|
|
231
340
|
this.idFactory = opts.idFactory || newMemoryId
|
|
232
|
-
this._locks =
|
|
341
|
+
this._locks = queuesFor(this.fs)
|
|
342
|
+
this.atomicOptions = opts.atomicOptions || {}
|
|
233
343
|
}
|
|
234
344
|
|
|
235
345
|
_queue(filePath, job) {
|
|
236
|
-
const key =
|
|
346
|
+
const key = memoryWriteLockKey(filePath)
|
|
237
347
|
const prev = this._locks.get(key) || Promise.resolve()
|
|
238
348
|
const run = prev.then(job, job)
|
|
239
349
|
const settled = run.then(() => {}, () => {})
|
|
@@ -247,13 +357,68 @@ export class MemoryDocumentStore {
|
|
|
247
357
|
try {
|
|
248
358
|
const buf = await this.fs.readFile(filePath)
|
|
249
359
|
const parsed = parseAnchors(buf)
|
|
250
|
-
|
|
360
|
+
// P1 步 3:状态版本(`expectedStateVersion`)取自 sidecar 的 `sourceVersion` + `epoch`。
|
|
361
|
+
// 仅当 sidecarDir 配置且 sidecar 可读时才有值;否则为 null ⇒ 状态闸按 "(unknown)" 拒绝
|
|
362
|
+
// (fail-closed:证明不了"同一版本"就不写,与身份门的既有口径一致)。
|
|
363
|
+
let stateVersion = null
|
|
364
|
+
if (this.sidecarDir) {
|
|
365
|
+
const cur = await this.readSidecar(filePath)
|
|
366
|
+
if (cur && cur.ok && cur.sidecar) {
|
|
367
|
+
const sv = cur.sidecar.sourceVersion
|
|
368
|
+
const ep = cur.sidecar.epoch
|
|
369
|
+
if (sv != null) stateVersion = (ep != null ? String(ep) + ':' : '') + String(sv)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { buf, parsed, fileDigest: sha256Hex(buf), stateVersion }
|
|
251
373
|
} catch (e) {
|
|
252
|
-
if (e && e.code === 'ENOENT') return { buf: null, parsed: null, fileDigest: null }
|
|
374
|
+
if (e && e.code === 'ENOENT') return { buf: null, parsed: null, fileDigest: null, stateVersion: null }
|
|
253
375
|
throw e
|
|
254
376
|
}
|
|
255
377
|
}
|
|
256
378
|
|
|
379
|
+
/**
|
|
380
|
+
* ★2026-09-15(P1 步 3 · 设计稿 §2.3):**提交边界内**的版本校验(并发原子边界)。
|
|
381
|
+
*
|
|
382
|
+
* **为什么必须在这里、而不是调用方**:P1 卡明确 —— "两个写者都先读 D、都通过比较、再分别
|
|
383
|
+
* 写 A 和 B ⇒ 后写者仍会覆盖前写者"。唯一正确的做法是让"读当前状态 → 比较 → 写"三步
|
|
384
|
+
* **在同一个队列任务内**完成(`_queue` 按路径串行,见 `:235`)。本助手只被 `_queue(...)`
|
|
385
|
+
* **内部**调用,因此天然满足该边界;绝不要在队列外用它做预检。
|
|
386
|
+
*
|
|
387
|
+
* 双闸(各自独立,都可单独启用):
|
|
388
|
+
* - `expectedDigest`:字节级(防"用户改了文件")—— 既有语义,保持不动。
|
|
389
|
+
* - `expectedStateVersion`:状态级(防"另一个窗口改了图/换了状态")—— P1 新增。
|
|
390
|
+
*
|
|
391
|
+
* **兼容档(T1-8)**:两者都可缺省;缺省即不校验,行为与 P1 之前**逐字节一致**。
|
|
392
|
+
* **可见冲突(T1-7C)**:拒绝时带 `expected/observed/target`,由调用方决定是否渲染成文本。
|
|
393
|
+
*
|
|
394
|
+
* ⚠️ 副作用零:只读 `state` 入参,不写盘、不改 state。
|
|
395
|
+
*/
|
|
396
|
+
_checkCommitBoundary(filePath, state, opts) {
|
|
397
|
+
const target = String(filePath || '')
|
|
398
|
+
const wantsDigest = opts.expectedDigest != null
|
|
399
|
+
const wantsStateVersion = opts.expectedStateVersion != null
|
|
400
|
+
// ① 字节闸(既有语义):不匹配 ⇒ 拒绝且不写
|
|
401
|
+
if (wantsDigest && state.fileDigest !== opts.expectedDigest) {
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
reason: 'conflict-external-edit',
|
|
405
|
+
conflict: { kind: 'digest', target, expected: String(opts.expectedDigest), observed: state.fileDigest == null ? '(missing)' : String(state.fileDigest) },
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ② 状态闸(P1 新增):sidecar 的 sourceVersion 为状态版本;无 sidecar/无 prev 时视为 unknown
|
|
409
|
+
if (wantsStateVersion) {
|
|
410
|
+
const cur = state.stateVersion == null ? null : String(state.stateVersion)
|
|
411
|
+
if (cur !== String(opts.expectedStateVersion)) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
reason: 'conflict-state-version',
|
|
415
|
+
conflict: { kind: 'state-version', target, expected: String(opts.expectedStateVersion), observed: cur == null ? '(unknown)' : cur },
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { ok: true }
|
|
420
|
+
}
|
|
421
|
+
|
|
257
422
|
/** sidecar 路径:sidecarDir + '<sha256(canonicalSourcePath)>.json'(契约 §6;canonical=resolve+正斜杠+小写)。 */
|
|
258
423
|
sidecarPath(filePath) {
|
|
259
424
|
if (!this.sidecarDir) return null
|
|
@@ -266,7 +431,9 @@ export class MemoryDocumentStore {
|
|
|
266
431
|
const sp = this.sidecarPath(filePath)
|
|
267
432
|
if (!sp) throw new Error('no-sidecar-dir')
|
|
268
433
|
await this.fs.mkdir(path.dirname(sp), { recursive: true })
|
|
269
|
-
|
|
434
|
+
// sidecar 是**可重建的派生数据**:失败时保留候选快照只会积累垃圾,故显式关闭 preserveOnFailure
|
|
435
|
+
// (与 Markdown 正文档相反——正文档失败必须保住快照供人工比对)。
|
|
436
|
+
await atomicReplace(sp, Buffer.from(JSON.stringify(sidecar, null, 2) + '\n', 'utf8'), this.fs, { ...this.atomicOptions, preserveOnFailure: false })
|
|
270
437
|
}
|
|
271
438
|
|
|
272
439
|
/** 读已落盘 sidecar;损坏返回 {ok:false,reason} 由调用方隔离并从 Markdown 重建。 */
|
|
@@ -297,9 +464,16 @@ export class MemoryDocumentStore {
|
|
|
297
464
|
}
|
|
298
465
|
}
|
|
299
466
|
try {
|
|
300
|
-
await atomicReplace(filePath, out, this.fs)
|
|
467
|
+
await atomicReplace(filePath, out, this.fs, this.atomicOptions)
|
|
301
468
|
} catch (e) {
|
|
302
|
-
|
|
469
|
+
// issue #48:结构化保留失败态(含 recoveryPath/partialPath),供上层给出可操作报错
|
|
470
|
+
return {
|
|
471
|
+
ok: false, reason: 'write-failed:' + (e && e.message ? e.message : String(e)),
|
|
472
|
+
errorCode: 'MEMORY_WRITE_FAILED', fsCode: e && e.code, written: false,
|
|
473
|
+
recoveryPath: e && e.recoveryPath, recoveryComplete: !!(e && e.recoveryComplete),
|
|
474
|
+
partialPath: e && e.partialPath, stage: e && e.stage,
|
|
475
|
+
recoveryUnavailable: !!(e && e.recoveryUnavailable),
|
|
476
|
+
}
|
|
303
477
|
}
|
|
304
478
|
let reread
|
|
305
479
|
try { reread = await this.fs.readFile(filePath) } catch (e) { return { ok: false, reason: 'verify-read-failed', written: true } }
|
|
@@ -331,7 +505,8 @@ export class MemoryDocumentStore {
|
|
|
331
505
|
append(filePath, text, opts = {}) {
|
|
332
506
|
return this._queue(filePath, async () => {
|
|
333
507
|
const state = await this._readState(filePath)
|
|
334
|
-
|
|
508
|
+
const gate = this._checkCommitBoundary(filePath, state, opts)
|
|
509
|
+
if (!gate.ok) return gate
|
|
335
510
|
const memoryId = opts.memoryId || this.idFactory()
|
|
336
511
|
const app = appendAnchoredRecord(state.buf, { memoryId, text })
|
|
337
512
|
if (!app.ok) return app
|
|
@@ -344,7 +519,8 @@ export class MemoryDocumentStore {
|
|
|
344
519
|
replace(filePath, replacement, opts = {}) {
|
|
345
520
|
return this._queue(filePath, async () => {
|
|
346
521
|
const state = await this._readState(filePath)
|
|
347
|
-
|
|
522
|
+
const gate = this._checkCommitBoundary(filePath, state, opts)
|
|
523
|
+
if (!gate.ok) return gate
|
|
348
524
|
const rr = renderReplace(state.buf, replacement, { idFactory: opts.idFactory || this.idFactory })
|
|
349
525
|
if (!rr.ok) return rr
|
|
350
526
|
const res = await this._commit(filePath, rr.text, { prevSidecar: opts.prevSidecar })
|
|
@@ -356,7 +532,8 @@ export class MemoryDocumentStore {
|
|
|
356
532
|
replaceSingle(filePath, text, opts = {}) {
|
|
357
533
|
return this._queue(filePath, async () => {
|
|
358
534
|
const state = await this._readState(filePath)
|
|
359
|
-
|
|
535
|
+
const gate = this._checkCommitBoundary(filePath, state, opts)
|
|
536
|
+
if (!gate.ok) return gate
|
|
360
537
|
const rr = replaceSingleRecord(state.buf, text, { idFactory: opts.idFactory || this.idFactory })
|
|
361
538
|
if (!rr.ok) return rr
|
|
362
539
|
const res = await this._commit(filePath, rr.text, { prevSidecar: opts.prevSidecar })
|
|
@@ -368,6 +545,9 @@ export class MemoryDocumentStore {
|
|
|
368
545
|
applyPlan(filePath, plan, opts = {}) {
|
|
369
546
|
return this._queue(filePath, async () => {
|
|
370
547
|
const state = await this._readState(filePath)
|
|
548
|
+
// P1 步 3:本方法同样写盘 ⇒ 必须走同一边界校验(此前它连 expectedDigest 都未检查)
|
|
549
|
+
const gate = this._checkCommitBoundary(filePath, state, opts)
|
|
550
|
+
if (!gate.ok) return gate
|
|
371
551
|
const ap = applyMigrationPlan(state.buf, plan)
|
|
372
552
|
if (!ap.ok) return ap
|
|
373
553
|
const res = await this._commit(filePath, ap.text, { prevSidecar: opts.prevSidecar })
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结论层状态 · **条目级应用**(G3 写盘的核心纯函数)
|
|
3
|
+
*
|
|
4
|
+
* 职责:把一条 `status` 落到**指定 memoryId 的条目正文末尾**,返回**新文本**(不写盘)。
|
|
5
|
+
* 与 `note-status.js` 的分工:
|
|
6
|
+
* - `note-status.js` = 状态行的**语法**(渲染/解析/剥离)
|
|
7
|
+
* - 本模块 = 状态行的**定位与落点**(在文件里找到那条、放到末尾)
|
|
8
|
+
*
|
|
9
|
+
* ── 与既有写入通道的关系(重要)───────────────────────────────
|
|
10
|
+
* 本模块**不代替** `memory-writer` 事务写入,只产出**新全文**;
|
|
11
|
+
* 落盘仍由调用方走既有通道(备份/校验/无 BOM 等纪律不绕过)。
|
|
12
|
+
*
|
|
13
|
+
* ── 为什么必须「只动目标条目」─────────────────────────────────
|
|
14
|
+
* MEMORY.md 是**用户可见的明文**且 25+ 锚点共存。任何"顺手重排/格式化"都会:
|
|
15
|
+
* ① 让无关条目的 `recordDigest` 变化 ⇒ sidecar sourceVersion 无谓 +1 ⇒ 全量缓存失效;
|
|
16
|
+
* ② 制造巨大的 diff,用户在 GUI 里看不出"到底改了什么"。
|
|
17
|
+
* ⇒ 契约:**除目标条目的状态行外,逐字节保持原样**(含 CRLF 行尾)。
|
|
18
|
+
*
|
|
19
|
+
* 纪律:纯函数、零 IO、fail-soft(不改动即返回 null,绝不返回半成品文本)。
|
|
20
|
+
*/
|
|
21
|
+
import { NOTE_STATUS_OPEN_V1, renderStatusLinePre, stripStatusLinePre, statusOfBodyPre } from './note-status.js'
|
|
22
|
+
|
|
23
|
+
/** 锚点:与 `l0-extract.js` / `memory-anchor.js` 同形态(此处独立声明,避免耦合)。 */
|
|
24
|
+
const ANCHOR_RE = /<!--\s*memory:(mem_[0-9a-f]{32})\s*-->/g
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 定位一条条目在原文中的**正文区间**。
|
|
28
|
+
*
|
|
29
|
+
* 语义与 `parseMemoryItemsPre` 一致:anchor marker **其后**的内容归该条,
|
|
30
|
+
* 直到**下一个** marker 之前(或文件末尾)。
|
|
31
|
+
*
|
|
32
|
+
* @param {string} text 文件全文
|
|
33
|
+
* @param {string} memoryId
|
|
34
|
+
* @returns {{start:number, end:number, id:string}|null} 正文的 [start,end) 字符区间
|
|
35
|
+
*/
|
|
36
|
+
export function locateRecordBodyPre(text, memoryId) {
|
|
37
|
+
try {
|
|
38
|
+
const src = String(text == null ? '' : text)
|
|
39
|
+
if (!src || !/^mem_[0-9a-f]{32}$/.test(String(memoryId || ''))) return null
|
|
40
|
+
ANCHOR_RE.lastIndex = 0
|
|
41
|
+
const marks = []
|
|
42
|
+
let m
|
|
43
|
+
while ((m = ANCHOR_RE.exec(src)) !== null) {
|
|
44
|
+
// 同时记录 marker 的**真实**起止(不重建字符串 —— 锚点允许空白浮动)
|
|
45
|
+
marks.push({ id: m[1], start: m.index, end: m.index + m[0].length })
|
|
46
|
+
if (m.index === ANCHOR_RE.lastIndex) ANCHOR_RE.lastIndex++
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < marks.length; i++) {
|
|
49
|
+
if (marks[i].id !== memoryId) continue
|
|
50
|
+
const start = marks[i].end
|
|
51
|
+
// 正文止于**下一个 marker 的起始**(该 marker 及其后内容不属于本条)
|
|
52
|
+
const end = i + 1 < marks.length ? marks[i + 1].start : src.length
|
|
53
|
+
return { start, end, id: memoryId }
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
} catch (_) { return null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ★ 主函数:给指定条目应用状态,返回**新全文**。
|
|
61
|
+
*
|
|
62
|
+
* 行为契约:
|
|
63
|
+
* - 目标不存在 ⇒ `null`(**fail-soft**:绝不凭空创建条目)
|
|
64
|
+
* - `status === 'current'` ⇒ **剥掉**既有状态行(撤销通道;正文其余不变)
|
|
65
|
+
* - 已是目标状态且 reason/by 未变 ⇒ 返回**原文**(幂等,调用方可据此跳过写盘)
|
|
66
|
+
* - 其余 ⇒ 剥旧状态行 + 在**正文末尾**追加新状态行
|
|
67
|
+
* - **行尾风格沿用原文件**(CRLF 保持 CRLF;本仓文件全 CRLF)
|
|
68
|
+
* - 任何异常 ⇒ `null`(绝不返回半成品)
|
|
69
|
+
*
|
|
70
|
+
* @param {string} text 文件全文
|
|
71
|
+
* @param {string} memoryId 目标条目
|
|
72
|
+
* @param {string} status current | superseded | retracted
|
|
73
|
+
* @param {{supersededBy?:string, reason?:string}} [opts]
|
|
74
|
+
* @returns {string|null} 新全文;不可应用时为 null
|
|
75
|
+
*/
|
|
76
|
+
export function applyStatusToRecordPre(text, memoryId, status, opts = {}) {
|
|
77
|
+
try {
|
|
78
|
+
const src = String(text == null ? '' : text)
|
|
79
|
+
if (!src) return null
|
|
80
|
+
const loc = locateRecordBodyPre(src, memoryId)
|
|
81
|
+
if (!loc) return null
|
|
82
|
+
|
|
83
|
+
const seg = src.slice(loc.start, loc.end)
|
|
84
|
+
// CRLF 感知:本仓文件全 CRLF,必须沿用,否则整文件 diff 爆炸
|
|
85
|
+
const eol = seg.includes('\r\n') ? '\r\n' : '\n'
|
|
86
|
+
|
|
87
|
+
// 先把段落按当前 EOL 归一化切分,处理后再拼回
|
|
88
|
+
const bodyClean = stripStatusLinePre(seg)
|
|
89
|
+
const trimmed = bodyClean.replace(/[\r\n\s]+$/, '')
|
|
90
|
+
|
|
91
|
+
const line = renderStatusLinePre(status, opts)
|
|
92
|
+
let next = line ? trimmed + eol + line + eol : (trimmed ? trimmed + eol : '')
|
|
93
|
+
// 段落与下一个 marker 之间保留一个空行(与既有文件形态一致)
|
|
94
|
+
next = next ? next + eol : next
|
|
95
|
+
|
|
96
|
+
const out = src.slice(0, loc.start) + next + src.slice(loc.end)
|
|
97
|
+
// 幂等:无变化 ⇒ 返回原文(调用方可据此跳过写盘,避免无谓 sourceVersion +1)
|
|
98
|
+
return out === src ? src : out
|
|
99
|
+
} catch (_) { return null }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 只读查询:某条目当前状态(供接线侧判断是否需要写)。
|
|
104
|
+
* 与 `statusOfBodyPre` 同源,此处补上「文件级」定位。
|
|
105
|
+
*
|
|
106
|
+
* @returns {{status:string, supersededBy?:string, reason?:string}|null} 条目不存在 ⇒ null
|
|
107
|
+
*/
|
|
108
|
+
export function readRecordStatusPre(text, memoryId) {
|
|
109
|
+
try {
|
|
110
|
+
const src = String(text == null ? '' : text)
|
|
111
|
+
const loc = locateRecordBodyPre(src, memoryId)
|
|
112
|
+
if (!loc) return null
|
|
113
|
+
return statusOfBodyPre(src.slice(loc.start, loc.end))
|
|
114
|
+
} catch (_) { return null }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 供反向锁使用:确认本模块**不碰锚点语法**。 */
|
|
118
|
+
export const NOTE_STATUS_MARKER_V1 = NOTE_STATUS_OPEN_V1
|