@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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8-3 Memory Hub 编排器(docs/PROJECT-FREEZE-AND-ROADMAP.md M8/M9; 记忆中枢)。
|
|
3
|
+
* 纯内存编排,零 IO 依赖(node:crypto 仅身份);把三层记忆串成一条可即插即用的链:
|
|
4
|
+
*
|
|
5
|
+
* M-02 Episodic(经历) → M-03 Semantic/Profile(事实) → M-04 Procedural(技能)
|
|
6
|
+
* ↑ ↑ ↑
|
|
7
|
+
* M2 segments / M5 evidence → judgement-shadow.jsonl → M5 success/reuse evidence
|
|
8
|
+
*
|
|
9
|
+
* 职责:
|
|
10
|
+
* 1) 消费 M7 judgement-shadow 的 semantic/profile/procedure_candidate,喂给对应 store。
|
|
11
|
+
* 2) 把 episodic 巩固后的 episode 转成 episodic_candidate 供上层消费。
|
|
12
|
+
* 3) 把 active procedure 渲染成 checklist(供 M7 召回系统在相似场景注入)。
|
|
13
|
+
* 4) 对外提供统一查询/统计/快照,供设置页「记忆中枢」与前端「记忆中枢」窗口展示。
|
|
14
|
+
* 5) 参数全部走 config(设置页可调),本模块只读不写。
|
|
15
|
+
*
|
|
16
|
+
* 设计原则:
|
|
17
|
+
* - 不杂糅: 三层各管各的 store(episodic-store / fact-store / procedure-store),
|
|
18
|
+
* hub 只做编排和转发,不重实现任何一层的逻辑。
|
|
19
|
+
* - 即插即用: Host 接线时传入 { episodic, facts, procedures } 三个 store 实例
|
|
20
|
+
* (或让 hub 用默认内存实例),即可工作;换成带 IO 的实例即持久化。
|
|
21
|
+
* - 全 fail-closed: 任一 store 缺失/失败,该路静默跳过,不阻断其他路。
|
|
22
|
+
*
|
|
23
|
+
* 全部同输入确定; UTF-8 无 BOM。
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
|
|
27
|
+
export const MEMORY_HUB_POLICY_VERSION = 'memory_hub_v1'
|
|
28
|
+
|
|
29
|
+
/** judgement-shadow 8 类候选 → 记忆层映射(不识别的不消费)。 */
|
|
30
|
+
export const KIND_TO_LAYER_V1 = Object.freeze({
|
|
31
|
+
semantic_candidate: 'semantic',
|
|
32
|
+
profile_candidate: 'semantic',
|
|
33
|
+
procedure_candidate: 'procedure',
|
|
34
|
+
episodic_candidate: 'episodic',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Memory Hub 工厂。
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {object} opts.stores { episodic?, facts?, procedures? } — 缺省用内存实例
|
|
41
|
+
* @param {object} opts.config 记忆中枢参数(从设置页读): { episodicMinSegments?, episodicRetention?,
|
|
42
|
+
* procedureMinSessions?, procedureMinSuccess?, procedureCorrectionCap?,
|
|
43
|
+
* procedureHighRiskApproval?, procedureActiveLevel? }
|
|
44
|
+
* @param {function} opts.now
|
|
45
|
+
* @param {function} opts.log 可选诊断(默认静默)
|
|
46
|
+
*/
|
|
47
|
+
export function createMemoryHubPre(opts = {}) {
|
|
48
|
+
// 惰性 import 避免循环依赖(各 store 是独立模块)
|
|
49
|
+
const { createEpisodicStorePre } = opts._stores || {}
|
|
50
|
+
const { createFactStorePre } = opts._stores || {}
|
|
51
|
+
const { createProcedureStorePre } = opts._stores || {}
|
|
52
|
+
const nowFn = typeof opts.now === 'function' ? opts.now : () => Date.now()
|
|
53
|
+
const log = typeof opts.log === 'function' ? opts.log : () => {}
|
|
54
|
+
|
|
55
|
+
// 三层 store(可注入;缺省内存版)
|
|
56
|
+
const stores = {}
|
|
57
|
+
stores.episodic = opts.stores && opts.stores.episodic
|
|
58
|
+
? opts.stores.episodic
|
|
59
|
+
: (createEpisodicStorePre ? createEpisodicStorePre({ now: nowFn }) : null)
|
|
60
|
+
stores.facts = opts.stores && opts.stores.facts
|
|
61
|
+
? opts.stores.facts
|
|
62
|
+
: (createFactStorePre ? createFactStorePre({ now: nowFn }) : null)
|
|
63
|
+
stores.procedures = opts.stores && opts.stores.procedures
|
|
64
|
+
? opts.stores.procedures
|
|
65
|
+
: (createProcedureStorePre ? createProcedureStorePre({ now: nowFn }) : null)
|
|
66
|
+
|
|
67
|
+
const cfg = opts.config || {}
|
|
68
|
+
const stats = { judgedRows: 0, consumedSemantic: 0, consumedProcedure: 0, consumedEpisodic: 0, skipped: 0, checklistsRendered: 0 }
|
|
69
|
+
|
|
70
|
+
// ---- 1) 消费 judgement-shadow(喂给对应 store) ----
|
|
71
|
+
function ingestJudgement(row) {
|
|
72
|
+
if (!row || typeof row !== 'object') { stats.skipped++; return { skipped: true, reason: 'not-object' } }
|
|
73
|
+
const kind = row.kindCandidate
|
|
74
|
+
const layer = KIND_TO_LAYER_V1[kind]
|
|
75
|
+
if (!layer) { stats.skipped++; return { skipped: true, reason: 'unknown-kind:' + kind } }
|
|
76
|
+
stats.judgedRows++
|
|
77
|
+
try {
|
|
78
|
+
if (layer === 'semantic' && stores.facts) {
|
|
79
|
+
// 复用 fact-store 的 judgement 消费(需 fact-store 提供 factCandidateFromJudgementRow/ingest)
|
|
80
|
+
const cand = typeof stores.facts.factCandidateFromJudgementRow === 'function'
|
|
81
|
+
? stores.facts.factCandidateFromJudgementRow(row)
|
|
82
|
+
: factCandidateFromRow(row)
|
|
83
|
+
if (!cand) { stats.skipped++; return { skipped: true, reason: 'not-fact' } }
|
|
84
|
+
const r = cand._suggestion === 'supersede_suggest' ? stores.facts.supersede(cand) : stores.facts.upsert(cand)
|
|
85
|
+
stats.consumedSemantic++
|
|
86
|
+
return { consumed: 'semantic', outcome: r.outcome }
|
|
87
|
+
}
|
|
88
|
+
if (layer === 'procedure' && stores.procedures) {
|
|
89
|
+
// 从 row 构造 procedure candidate(行内可能只有 sourceIds + 摘要)
|
|
90
|
+
const cand = procedureCandidateFromRow(row)
|
|
91
|
+
if (!cand) { stats.skipped++; return { skipped: true, reason: 'not-procedure' } }
|
|
92
|
+
const r = stores.procedures.observe(cand)
|
|
93
|
+
stats.consumedProcedure++
|
|
94
|
+
return { consumed: 'procedure', outcome: r.ok ? 'observed' : r.reason }
|
|
95
|
+
}
|
|
96
|
+
if (layer === 'episodic' && stores.episodic) {
|
|
97
|
+
// episodic_candidate 已是巩固后的 episode,直接喂
|
|
98
|
+
const r = stores.episodic.restore({ schemaVersion: 1, episodes: [row] })
|
|
99
|
+
stats.consumedEpisodic++
|
|
100
|
+
return { consumed: 'episodic', outcome: r.ok ? 'restored' : r.reason }
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
log('memory-hub ingest error: ' + String(e && e.message || e))
|
|
104
|
+
stats.skipped++
|
|
105
|
+
return { skipped: true, reason: 'error' }
|
|
106
|
+
}
|
|
107
|
+
stats.skipped++
|
|
108
|
+
return { skipped: true, reason: 'no-store' }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ingestJudgementRows(rows) {
|
|
112
|
+
const out = []
|
|
113
|
+
for (const r of rows) out.push(ingestJudgement(r))
|
|
114
|
+
return { results: out }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- 2) episodic 巩固钩子(会话结束/空闲期调) ----
|
|
118
|
+
function consolidateEpisodes() {
|
|
119
|
+
if (!stores.episodic) return { ok: false, reason: 'no-episodic-store' }
|
|
120
|
+
const r = stores.episodic.flush()
|
|
121
|
+
return r
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- 3) 把 episode 转成 candidate 喂给 fact/procedure(举一反三) ----
|
|
125
|
+
function crossFeed(sessionRef) {
|
|
126
|
+
if (!stores.episodic) return { ok: false, reason: 'no-episodic-store' }
|
|
127
|
+
const eps = stores.episodic.query({ sessionRef })
|
|
128
|
+
const out = []
|
|
129
|
+
for (const ep of eps) {
|
|
130
|
+
// 成功 episode → procedure 观察(固定流程雏形)
|
|
131
|
+
if (ep.success && stores.procedures && ep.intent && ep.actions && ep.actions.length) {
|
|
132
|
+
const cand = {
|
|
133
|
+
title: ep.intent.slice(0, 40),
|
|
134
|
+
riskLevel: 'low',
|
|
135
|
+
steps: ep.actions.map((a, i) => '步骤' + (i + 1) + ': ' + a),
|
|
136
|
+
sourceEpisodes: [ep.episodeId],
|
|
137
|
+
sourceMemoryIds: [],
|
|
138
|
+
}
|
|
139
|
+
const r = stores.procedures.observe(cand)
|
|
140
|
+
out.push({ from: 'episode', to: 'procedure', outcome: r.ok ? 'observed' : r.reason })
|
|
141
|
+
}
|
|
142
|
+
// 有未决事项的 episode → 事实候选(不直接固化,留给 judgement)
|
|
143
|
+
if (ep.unresolved && ep.unresolved.length && stores.facts) {
|
|
144
|
+
const cand = {
|
|
145
|
+
scope: 'Workspace', subject: ep.intent.slice(0, 30) || 'episode', predicate: '有未决事项',
|
|
146
|
+
object: ep.unresolved[0].slice(0, 60), sourceKind: 'inference',
|
|
147
|
+
sourceClass: 'semantic-candidate', provenance: [ep.episodeId],
|
|
148
|
+
}
|
|
149
|
+
const r = stores.facts.upsert(cand)
|
|
150
|
+
out.push({ from: 'episode', to: 'fact', outcome: r.outcome })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, fed: out }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- 4) active procedure → checklist(供 M7 召回) ----
|
|
157
|
+
function renderChecklists() {
|
|
158
|
+
if (!stores.procedures) return []
|
|
159
|
+
const actives = stores.procedures.activeProcedures()
|
|
160
|
+
const out = []
|
|
161
|
+
for (const p of actives) {
|
|
162
|
+
const r = stores.procedures.renderChecklist(p.procedureId)
|
|
163
|
+
if (r) { out.push(r); stats.checklistsRendered++ }
|
|
164
|
+
}
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- 5) 统一查询/快照(前端「记忆中枢」窗口 + 设置页) ----
|
|
169
|
+
function overview() {
|
|
170
|
+
return {
|
|
171
|
+
policyVersion: MEMORY_HUB_POLICY_VERSION,
|
|
172
|
+
stats: { ...stats },
|
|
173
|
+
episodic: stores.episodic ? {
|
|
174
|
+
size: stores.episodic.size, recent: stores.episodic.recent(5),
|
|
175
|
+
stats: stores.episodic.getStats ? stores.episodic.getStats() : null,
|
|
176
|
+
} : null,
|
|
177
|
+
facts: stores.facts ? {
|
|
178
|
+
size: stores.facts.size, conflictCount: stores.facts.conflictCount,
|
|
179
|
+
pendingConflicts: stores.facts.pendingConflicts ? stores.facts.pendingConflicts() : [],
|
|
180
|
+
recent: stores.facts.query ? stores.facts.query().slice(-5) : [],
|
|
181
|
+
stats: stores.facts.getStats ? stores.facts.getStats() : null,
|
|
182
|
+
} : null,
|
|
183
|
+
procedures: stores.procedures ? {
|
|
184
|
+
size: stores.procedures.size,
|
|
185
|
+
active: stores.procedures.activeProcedures().map((p) => ({ procedureId: p.procedureId, title: p.title, stage: p.stage, riskLevel: p.riskLevel, evidence: p.evidence })),
|
|
186
|
+
candidates: stores.procedures.query({ stage: 'candidate' }).map((p) => ({ procedureId: p.procedureId, title: p.title, stage: p.stage })),
|
|
187
|
+
// 2026-08-30 审批面:全部未 active/未 deprecated 技能(observed/candidate/validated),
|
|
188
|
+
// 供 hubTab 审批按钮(晋升/激活/弃用)操作
|
|
189
|
+
pipeline: stores.procedures.query()
|
|
190
|
+
.filter((p) => p.stage !== 'active' && p.stage !== 'deprecated')
|
|
191
|
+
.map((p) => ({ procedureId: p.procedureId, title: p.title, stage: p.stage, riskLevel: p.riskLevel, evidence: p.evidence, pinned: !!p.pinned })),
|
|
192
|
+
stats: stores.procedures.getStats ? stores.procedures.getStats() : null,
|
|
193
|
+
} : null,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function snapshot() {
|
|
198
|
+
return {
|
|
199
|
+
schemaVersion: 1, namespace: 'dsh-auto-memory', policyVersion: MEMORY_HUB_POLICY_VERSION,
|
|
200
|
+
savedAt: nowFn(),
|
|
201
|
+
episodic: stores.episodic ? stores.episodic.snapshot() : null,
|
|
202
|
+
facts: stores.facts ? stores.facts.snapshot({ includeRevoked: true }) : null,
|
|
203
|
+
procedures: stores.procedures ? stores.procedures.snapshot() : null,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function dispose(reason) {
|
|
208
|
+
for (const k of ['episodic', 'facts', 'procedures']) {
|
|
209
|
+
if (stores[k] && typeof stores[k].dispose === 'function') { try { stores[k].dispose(reason) } catch (_) {} }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
ingestJudgement, ingestJudgementRows, consolidateEpisodes, crossFeed,
|
|
215
|
+
renderChecklists, overview, snapshot, dispose,
|
|
216
|
+
get stores() { return stores },
|
|
217
|
+
getStats: () => ({ ...stats }),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- 行 → candidate 转换(独立纯函数,供 hub 与测试) ----
|
|
222
|
+
|
|
223
|
+
/** judgement 行 → fact candidate(与 fact-store 的 factCandidateFromJudgementRow 同语义)。 */
|
|
224
|
+
export function factCandidateFromRow(row) {
|
|
225
|
+
const kind = row && row.kindCandidate
|
|
226
|
+
if (kind !== 'semantic_candidate' && kind !== 'profile_candidate') return null
|
|
227
|
+
const sourceIds = Array.isArray(row.sourceIds) ? row.sourceIds : []
|
|
228
|
+
if (!sourceIds.length) return null
|
|
229
|
+
return {
|
|
230
|
+
scope: row.scope === 'User' ? 'User' : 'Workspace',
|
|
231
|
+
subject: String(row.subject || sourceIds[0]),
|
|
232
|
+
predicate: String(row.predicate || 'relation'),
|
|
233
|
+
object: row.object === undefined || row.object === null ? null : String(row.object),
|
|
234
|
+
sourceKind: 'inference',
|
|
235
|
+
sourceClass: kind === 'profile_candidate' ? 'profile-candidate' : 'semantic-candidate',
|
|
236
|
+
provenance: [...sourceIds],
|
|
237
|
+
confidence: typeof row.confidence === 'number' ? row.confidence : null,
|
|
238
|
+
_suggestion: row.suggestion || 'keep_suggest',
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** judgement 行 → procedure candidate(procedure_candidate 行)。 */
|
|
243
|
+
export function procedureCandidateFromRow(row) {
|
|
244
|
+
const kind = row && row.kindCandidate
|
|
245
|
+
if (kind !== 'procedure_candidate') return null
|
|
246
|
+
const sourceIds = Array.isArray(row.sourceIds) ? row.sourceIds : []
|
|
247
|
+
if (!sourceIds.length) return null
|
|
248
|
+
// 行内通常只有 memoryId 引用 + 摘要; 步骤用可用的文本特征
|
|
249
|
+
const title = String(row.title || ('流程 ' + sourceIds[0].slice(-8)))
|
|
250
|
+
const excerpt = String(row.excerpt || row.predicate || '').slice(0, 200)
|
|
251
|
+
return {
|
|
252
|
+
title: title.slice(0, 60),
|
|
253
|
+
riskLevel: row.riskLevel === 'high' || row.riskLevel === 'medium' ? row.riskLevel : 'low',
|
|
254
|
+
steps: excerpt ? [excerpt] : ['参考来源 ' + sourceIds[0]],
|
|
255
|
+
sourceMemoryIds: sourceIds,
|
|
256
|
+
sourceEpisodes: Array.isArray(row.sourceEpisodes) ? row.sourceEpisodes : [],
|
|
257
|
+
successCriteria: Array.isArray(row.successCriteria) ? row.successCriteria : [],
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryFileIndex — 只读记忆文件索引(M3a,系统地图 M-06 契约)。
|
|
3
|
+
* 不修改任何 Markdown;对记忆文件(用户级/项目笔记/每日日志/反思/日历)按标题行切块,
|
|
4
|
+
* 建立 UTF-8 半开字节区间 [byteStart,byteEnd)、行号 locator、recordDigest 与文件级 sourceVersion。
|
|
5
|
+
* stale 判定 = 当前文件在该字节区间的切片 digest 与记录不一致(含前置插入导致的位移);
|
|
6
|
+
* stale ≠ coverage=0。多字节字符按 UTF-8 字节计数,与模型 read 返回的字节区间一致。
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
/** 单文件索引构建上限(字节),超出跳过(返回 skipped),避免大文件同步阻塞。 */
|
|
11
|
+
const INDEX_MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
/** 标题行识别:行首 1-6 个 '#' + 空白。 */
|
|
14
|
+
const HEADING_RE = /^#{1,6}\s/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 按 0x0A 切行为字节行,保留各自 [start,end) 半开区间(end 含换行符)。
|
|
18
|
+
* CRLF:0x0D 保留在行内,行文本解码时剔除尾部 \r,字节区间不受影响。
|
|
19
|
+
* @param {Buffer} buf
|
|
20
|
+
* @returns {{start:number,end:number,text:string}[]} 行号 = 数组下标 + 1
|
|
21
|
+
*/
|
|
22
|
+
function splitByteLines(buf) {
|
|
23
|
+
const lines = []
|
|
24
|
+
let start = 0
|
|
25
|
+
for (let i = 0; i < buf.length; i++) {
|
|
26
|
+
if (buf[i] === 0x0a) {
|
|
27
|
+
lines.push({ start, end: i + 1, text: decodedLine(buf, start, i) })
|
|
28
|
+
start = i + 1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (start < buf.length) lines.push({ start, end: buf.length, text: decodedLine(buf, start, buf.length) })
|
|
32
|
+
else if (start === buf.length && buf.length === 0) { /* empty file */ }
|
|
33
|
+
return lines
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 行字节 → UTF-8 文本(剔除结尾 CR)。 */
|
|
37
|
+
function decodedLine(buf, s, e) {
|
|
38
|
+
let end = e
|
|
39
|
+
if (end > s && buf[end - 1] === 0x0d) end -= 1
|
|
40
|
+
if (end <= s) return ''
|
|
41
|
+
return buf.toString('utf8', s, end)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 构建单个文件的记录索引。
|
|
46
|
+
* 超限保护在模块自身生效:content 超过 INDEX_MAX_FILE_BYTES 时直接返回
|
|
47
|
+
* { sourceFile, skipped:true, records:[] } 且 fileDigest 为空,不做任何切片/摘要计算。
|
|
48
|
+
* 每条记录携带构建时的 sourceVersion 与 fileDigest(文件级身份);
|
|
49
|
+
* verifyRecord 因此能实现"文件任何位置变化 ⇒ 本文件所有 locator stale"的文件级语义。
|
|
50
|
+
* @param {string} sourceFile 绝对路径(仅作标识,不读取)
|
|
51
|
+
* @param {Buffer|string} content 文件字节/文本(文本视为 UTF-8)
|
|
52
|
+
* @param {{version?:number,fileDigest?:string}} prev 上次构建信息(用于版本递增)
|
|
53
|
+
* @returns {{sourceFile:string,fileDigest:string,sourceVersion:number,skipped?:boolean,records:Array<object>}}
|
|
54
|
+
*/
|
|
55
|
+
function buildIndex(sourceFile, content, prev) {
|
|
56
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8')
|
|
57
|
+
if (buf.length > INDEX_MAX_FILE_BYTES) {
|
|
58
|
+
return { sourceFile, fileDigest: '', sourceVersion: (prev && prev.version) || 1, skipped: true, records: [] }
|
|
59
|
+
}
|
|
60
|
+
const fileDigest = createHash('sha256').update(buf).digest('hex')
|
|
61
|
+
const sourceVersion = prev && prev.fileDigest === fileDigest ? (prev.version || 1) : (prev ? (prev.version || 1) + 1 : 1)
|
|
62
|
+
const lines = splitByteLines(buf)
|
|
63
|
+
const records = []
|
|
64
|
+
let cur = null
|
|
65
|
+
lines.forEach((line, idx) => {
|
|
66
|
+
if (HEADING_RE.test(line.text)) {
|
|
67
|
+
if (cur) records.push(cur)
|
|
68
|
+
cur = {
|
|
69
|
+
sourceFile,
|
|
70
|
+
heading: line.text.replace(/^#{1,6}\s*/, '').trim(),
|
|
71
|
+
lineStart: idx + 1,
|
|
72
|
+
lineEnd: idx + 1,
|
|
73
|
+
byteStart: line.start,
|
|
74
|
+
byteEnd: line.end,
|
|
75
|
+
bytes: 0,
|
|
76
|
+
chars: 0,
|
|
77
|
+
recordDigest: '',
|
|
78
|
+
sourceVersion: sourceVersion,
|
|
79
|
+
fileDigest,
|
|
80
|
+
}
|
|
81
|
+
} else if (cur) {
|
|
82
|
+
cur.lineEnd = idx + 1
|
|
83
|
+
cur.byteEnd = line.end
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
if (cur) records.push(cur)
|
|
87
|
+
for (const rec of records) {
|
|
88
|
+
const safeEnd = Math.min(rec.byteEnd, buf.length)
|
|
89
|
+
const safeStart = Math.min(rec.byteStart, buf.length)
|
|
90
|
+
rec.bytes = Math.max(0, safeEnd - safeStart)
|
|
91
|
+
rec.chars = 0
|
|
92
|
+
for (let i = safeStart; i < safeEnd;) {
|
|
93
|
+
const c = buf.readUInt8(i)
|
|
94
|
+
const n = c < 0x80 ? 1 : c < 0xe0 ? 2 : c < 0xf0 ? 3 : 4
|
|
95
|
+
i += n
|
|
96
|
+
rec.chars += 1
|
|
97
|
+
}
|
|
98
|
+
rec.recordDigest = createHash('sha256').update(buf.subarray(safeStart, safeEnd)).digest('hex')
|
|
99
|
+
// 文件级身份:每条记录携带本次构建的 sourceVersion 与 fileDigest
|
|
100
|
+
rec.sourceVersion = sourceVersion
|
|
101
|
+
rec.fileDigest = fileDigest
|
|
102
|
+
}
|
|
103
|
+
return { sourceFile, fileDigest, sourceVersion, records }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 校验 locator 在当前文件中是否 fresh。
|
|
108
|
+
* 文件级语义(审查闭环):记录携带构建时的 fileDigest,当前文件整体 digest 与之一致
|
|
109
|
+
* 且原字节切片 digest 一致 → fresh;文件任何位置发生变化 ⇒ 本文件所有记录 stale。
|
|
110
|
+
* 无 fileDigest 的旧记录退化为只做切片比对(向后兼容)。
|
|
111
|
+
* @param {object} record buildIndex 产出的记录
|
|
112
|
+
* @param {Buffer} current 当前文件字节
|
|
113
|
+
* @returns {{fresh:boolean,bytes:number|null,digest:string|null,fileLevel:boolean}}
|
|
114
|
+
*/
|
|
115
|
+
function verifyRecord(record, current) {
|
|
116
|
+
if (!record || !Buffer.isBuffer(current)) return { fresh: false, bytes: null, digest: null, fileLevel: false }
|
|
117
|
+
if (record.byteStart < 0 || record.byteEnd > current.length || record.byteEnd <= record.byteStart) return { fresh: false, bytes: null, digest: null, fileLevel: false }
|
|
118
|
+
if (record.fileDigest) {
|
|
119
|
+
const fileLevel = createHash('sha256').update(current).digest('hex') === record.fileDigest
|
|
120
|
+
if (!fileLevel) return { fresh: false, bytes: 0, digest: null, fileLevel: false }
|
|
121
|
+
const slice = current.subarray(record.byteStart, record.byteEnd)
|
|
122
|
+
const digest = createHash('sha256').update(slice).digest('hex')
|
|
123
|
+
return { fresh: digest === record.recordDigest, bytes: slice.length, digest, fileLevel: true }
|
|
124
|
+
}
|
|
125
|
+
const slice = current.subarray(record.byteStart, record.byteEnd)
|
|
126
|
+
const digest = createHash('sha256').update(slice).digest('hex')
|
|
127
|
+
return { fresh: digest === record.recordDigest, bytes: slice.length, digest, fileLevel: false }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 计算一次 read 返回区间与记录的重合覆盖率(M-06)。
|
|
132
|
+
* readRange = [start,end) 半开 UTF-8 字节区间;stale 记录直接拒绝,不做伪装 coverage=0。
|
|
133
|
+
* @returns {{status:'fresh'|'stale'|'out-of-range',matchedBytes:number,totalBytes:number,ratio:number}}
|
|
134
|
+
*/
|
|
135
|
+
function coverage(readRange, record, current) {
|
|
136
|
+
if (!record || !Array.isArray(readRange) || readRange.length < 2) return { status: 'out-of-range', matchedBytes: 0, totalBytes: 0, ratio: 0 }
|
|
137
|
+
const [s, e] = [Math.max(0, Number(readRange[0]) || 0), Math.max(0, Number(readRange[1]) || 0)]
|
|
138
|
+
const ver = verifyRecord(record, current)
|
|
139
|
+
if (!ver.fresh) return { status: 'stale', matchedBytes: 0, totalBytes: Math.max(1, record.byteEnd - record.byteStart), ratio: 0 }
|
|
140
|
+
const totalBytes = Math.max(1, record.byteEnd - record.byteStart)
|
|
141
|
+
const matched = Math.max(0, Math.min(e, record.byteEnd) - Math.max(s, record.byteStart))
|
|
142
|
+
return { status: 'fresh', matchedBytes: matched, totalBytes, ratio: matched / totalBytes }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { buildIndex, verifyRecord, coverage, splitByteLines, INDEX_MAX_FILE_BYTES }
|