@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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * M5-1 Context / Evidence Bridge 纯核心(docs/M5-CONTRACT.md §4-§11)。
3
+ * 零 IO、零依赖(除 node:crypto);不接 Host、不写文件、不启动 Python、不改 prompt。
4
+ *
5
+ * 组成:
6
+ * 1) 策略/预算常量(CONTEXT_BRIDGE_BUDGET_V1 / 版本化词典)
7
+ * 2) ContextSegmentPre / AuthorizedMemoryRefPre / EvidenceAggregatePre validator
8
+ * 3) ContextPushEnvelopePre validator + canonical identity(observationId)
9
+ * 4) AccessEvidencePre validator + deterministic evidenceId
10
+ * 5) read coverage adapter(M3 UTF-8 byte 半开区间 + 归一化包含判定;freshness 门)
11
+ * 6) 六类证据纯构造器(seen 仅供 M6 delivered 后调用;cite/correction 文本扫描;
12
+ * reuse/success 身份对齐 episode tracker —— precision-first,只认显式身份 token)
13
+ * 7) Null/Fake ContextSinkPre(fake 只记录 canonical frame,零进程零网络)
14
+ * 8) push bridge(observationId 幂等 + latest-wins supersede + abort)
15
+ * 9) replay pure core(canonical 输出排除墙钟)
16
+ * 全部函数同输入逐字段确定;所有文本 UTF-8 无 BOM。
17
+ */
18
+ import { createHash } from 'node:crypto'
19
+ import { sanitizeExcerpt, NAMESPACE } from './shadow-retrieval.js'
20
+
21
+ const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex')
22
+ const sha256Str = (s) => sha256Hex(Buffer.from(String(s), 'utf8'))
23
+ const first32 = (h) => h.slice(0, 32)
24
+ const clamp01 = (x) => Math.max(0, Math.min(1, Number(x) || 0))
25
+
26
+ export { NAMESPACE }
27
+ export const CONTEXT_BRIDGE_POLICY_VERSION = 'context_bridge_v1'
28
+ export const EVIDENCE_POLICY_VERSION = 'evidence_v1'
29
+ export const OBSERVATION_PREFIX = 'obs_'
30
+ export const EVIDENCE_PREFIX = 'ev_'
31
+
32
+ /** §4 budget(冻结;变更必须升级 contextPolicyVersion)。 */
33
+ export const CONTEXT_BRIDGE_BUDGET_V1 = Object.freeze({
34
+ maxSegments: 8,
35
+ maxInputBytes: 4096,
36
+ maxMemoryRefs: 8,
37
+ maxEvidenceItems: 16,
38
+ excerptBytes: 480,
39
+ frameMaxBytes: 64 * 1024,
40
+ deadlineMs: 5000,
41
+ sentObservationIds: 256,
42
+ })
43
+
44
+ /** §6 AccessKindPre 枚举。 */
45
+ export const ACCESS_KINDS_V1 = Object.freeze(['seen', 'read', 'cite', 'reuse', 'success', 'correction'])
46
+
47
+ /** §5 ContextAckPre reason 枚举(版本化)。 */
48
+ export const ACK_REASONS_V1 = Object.freeze(['ok', 'disabled', 'busy', 'unsupported', 'oversize', 'stale'])
49
+
50
+ /** §7 cite 引用分类器 v1:只认完整 memoryId token(保守;变更必须升级版本)。 */
51
+ export const CITATION_MEMORY_ID_PATTERN_V1 = /mem_[0-9a-f]{32}/g
52
+
53
+ /** §7 correction 分类器 v1 冻结词典(用户纠正/拒绝/反例;precision-first)。 */
54
+ export const CORRECTION_LEXICON_V1 = Object.freeze([
55
+ '不对', '错了', '不是这样', '反了', '纠正', '过时', '失效', '别再用', '作废',
56
+ 'wrong', 'incorrect', 'not right', 'actually no', 'outdated', 'deprecated', 'obsolete', 'disagree',
57
+ ])
58
+
59
+ // ========== validators ==========
60
+
61
+ const MEMORY_ID_RE = /^mem_[0-9a-f]{32}$/
62
+ const HEX64_RE = /^[0-9a-f]{64}$/
63
+ /** sourceRef 只允许稳定相对引用(user:/workspace:/workspace-log:+文件名;禁止绝对路径)。 */
64
+ const SOURCE_REF_RE = new RegExp('^(user|workspace|workspace-log):[A-Za-z0-9._\\u4e00-\\u9fff-]+$')
65
+
66
+ /** ContextSegmentPre 校验(§4)。 */
67
+ export function validateContextSegmentPre(seg) {
68
+ const p = []
69
+ if (!seg || typeof seg !== 'object') return { ok: false, reason: 'not-object' }
70
+ if (typeof seg.segmentId !== 'string' || !seg.segmentId) p.push('segmentId')
71
+ if (typeof seg.digest !== 'string' || seg.digest.length < 16) p.push('digest')
72
+ if (!['user', 'tool_call', 'tool_result', 'assistant', 'reasoning'].includes(seg.kind)) p.push('kind')
73
+ if (!Number.isInteger(seg.eventSeq) || seg.eventSeq < 0) p.push('eventSeq')
74
+ if (!Number.isInteger(seg.contextVersion) || seg.contextVersion < 0) p.push('contextVersion')
75
+ if (typeof seg.ts !== 'number' || !Number.isFinite(seg.ts)) p.push('ts')
76
+ if (typeof seg.text !== 'string') p.push('text')
77
+ if (seg.toolName !== undefined && seg.toolName !== null && typeof seg.toolName !== 'string') p.push('toolName')
78
+ if (seg.toolOk !== undefined && seg.toolOk !== null && typeof seg.toolOk !== 'boolean') p.push('toolOk')
79
+ if (seg.errorName !== undefined && seg.errorName !== null && typeof seg.errorName !== 'string') p.push('errorName')
80
+ if (seg.errorCode !== undefined && seg.errorCode !== null && typeof seg.errorCode !== 'string') p.push('errorCode')
81
+ if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
82
+ return { ok: true, segment: seg }
83
+ }
84
+
85
+ /** AuthorizedMemoryRefPre 校验(§4;scope 仅 Workspace|User;provenance 全链必填)。 */
86
+ export function validateAuthorizedMemoryRefPre(ref) {
87
+ const p = []
88
+ if (!ref || typeof ref !== 'object') return { ok: false, reason: 'not-object' }
89
+ if (typeof ref.memoryId !== 'string' || !MEMORY_ID_RE.test(ref.memoryId)) p.push('memoryId')
90
+ if (typeof ref.anchorId !== 'string' || !ref.anchorId) p.push('anchorId')
91
+ if (ref.scope !== 'Workspace' && ref.scope !== 'User') p.push('scope')
92
+ if (typeof ref.sourceRef !== 'string' || !SOURCE_REF_RE.test(ref.sourceRef)) p.push('sourceRef')
93
+ if (typeof ref.sourceEpoch !== 'string' || !ref.sourceEpoch) p.push('sourceEpoch')
94
+ if (!Number.isInteger(ref.sourceVersion) || ref.sourceVersion < 1) p.push('sourceVersion')
95
+ if (typeof ref.fileDigest !== 'string' || !HEX64_RE.test(ref.fileDigest)) p.push('fileDigest')
96
+ if (typeof ref.recordDigest !== 'string' || !HEX64_RE.test(ref.recordDigest)) p.push('recordDigest')
97
+ if (ref.excerpt !== undefined) {
98
+ if (typeof ref.excerpt !== 'string') p.push('excerpt')
99
+ else if (Buffer.byteLength(ref.excerpt, 'utf8') > CONTEXT_BRIDGE_BUDGET_V1.excerptBytes) p.push('excerpt-budget')
100
+ }
101
+ if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
102
+ return { ok: true, ref }
103
+ }
104
+
105
+ /** EvidenceAggregatePre 校验(§11)。 */
106
+ export function validateEvidenceAggregatePre(a) {
107
+ const p = []
108
+ if (!a || typeof a !== 'object') return { ok: false, reason: 'not-object' }
109
+ if (typeof a.memoryId !== 'string' || !MEMORY_ID_RE.test(a.memoryId)) p.push('memoryId')
110
+ if (a.scope !== 'Workspace' && a.scope !== 'User') p.push('scope')
111
+ if (!['fresh', 'stale', 'unknown'].includes(a.freshness)) p.push('freshness')
112
+ for (const k of ['distinctSessions', 'seen', 'read', 'cite', 'reuse', 'success', 'correction']) {
113
+ if (!Number.isInteger(a[k]) || a[k] < 0) p.push(k)
114
+ }
115
+ if (typeof a.lastEvidenceAt !== 'number' || !Number.isFinite(a.lastEvidenceAt)) p.push('lastEvidenceAt')
116
+ if (a.policyVersion !== EVIDENCE_POLICY_VERSION) p.push('policyVersion')
117
+ if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
118
+ return { ok: true, aggregate: a }
119
+ }
120
+ /** AccessEvidencePre 校验(§6 全 schema)。 */
121
+ export function validateAccessEvidencePre(ev) {
122
+ const p = []
123
+ if (!ev || typeof ev !== 'object') return { ok: false, reason: 'not-object' }
124
+ if (ev.schemaVersion !== 1) p.push('schemaVersion')
125
+ if (ev.namespace !== NAMESPACE) p.push('namespace')
126
+ if (typeof ev.evidenceId !== 'string' || !ev.evidenceId.startsWith(EVIDENCE_PREFIX)) p.push('evidenceId')
127
+ if (!ACCESS_KINDS_V1.includes(ev.kind)) p.push('kind')
128
+ if (typeof ev.memoryId !== 'string' || !MEMORY_ID_RE.test(ev.memoryId)) p.push('memoryId')
129
+ if (typeof ev.anchorId !== 'string' || !ev.anchorId) p.push('anchorId')
130
+ if (!['Session', 'Workspace', 'User'].includes(ev.scope)) p.push('scope')
131
+ if (typeof ev.workspaceKey !== 'string' || !ev.workspaceKey) p.push('workspaceKey')
132
+ const e = ev.event
133
+ if (!e || typeof e !== 'object') p.push('event')
134
+ else {
135
+ if (typeof e.sessionId !== 'string' || !e.sessionId) p.push('event.sessionId')
136
+ if (!Number.isInteger(e.eventSeq) || e.eventSeq < 0) p.push('event.eventSeq')
137
+ if (e.nativeSeq !== undefined && !Number.isInteger(e.nativeSeq)) p.push('event.nativeSeq')
138
+ if (!Number.isInteger(e.contextVersion) || e.contextVersion < 0) p.push('event.contextVersion')
139
+ if (e.callId !== undefined && e.callId !== null && typeof e.callId !== 'string') p.push('event.callId')
140
+ if (typeof e.ts !== 'number' || !Number.isFinite(e.ts)) p.push('event.ts')
141
+ }
142
+ const s = ev.source
143
+ if (!s || typeof s !== 'object') p.push('source')
144
+ else {
145
+ if (typeof s.sourceRef !== 'string' || !SOURCE_REF_RE.test(s.sourceRef)) p.push('source.sourceRef')
146
+ if (typeof s.sourceEpoch !== 'string' || !s.sourceEpoch) p.push('source.sourceEpoch')
147
+ if (!Number.isInteger(s.sourceVersion) || s.sourceVersion < 1) p.push('source.sourceVersion')
148
+ if (typeof s.fileDigest !== 'string' || !HEX64_RE.test(s.fileDigest)) p.push('source.fileDigest')
149
+ if (typeof s.recordDigest !== 'string' || !HEX64_RE.test(s.recordDigest)) p.push('source.recordDigest')
150
+ }
151
+ if (ev.coverage !== undefined && (typeof ev.coverage !== 'number' || ev.coverage < 0 || ev.coverage > 1)) p.push('coverage')
152
+ if (ev.episodeId !== undefined && ev.episodeId !== null && typeof ev.episodeId !== 'string') p.push('episodeId')
153
+ if (ev.policyVersion !== EVIDENCE_POLICY_VERSION) p.push('policyVersion')
154
+ if (p.length) return { ok: false, reason: 'invalid:' + p.join(',') }
155
+ return { ok: true, evidence: ev }
156
+ }
157
+
158
+ /** §4 canonical identity:observationId 由 sessionId+contextVersion+trigger digest+policy 决定。 */
159
+ export function buildObservationId(sessionId, contextVersion, triggerDigest, policyVersion = CONTEXT_BRIDGE_POLICY_VERSION) {
160
+ const parts = ['context-push-pre-v1', String(sessionId || ''), Number(contextVersion) || 0, String(triggerDigest || ''), policyVersion]
161
+ return OBSERVATION_PREFIX + first32(sha256Str(JSON.stringify(parts)))
162
+ }
163
+
164
+ /** §9 幂等 identity:evidenceId 由 kind/memory/session 坐标/policy 决定(同事件重放同 id)。 */
165
+ export function buildEvidenceId(input) {
166
+ const parts = ['access-evidence-v1', input.kind, input.memoryId, String(input.sessionId || ''), input.eventSeq | 0,
167
+ input.nativeSeq === undefined ? null : input.nativeSeq, input.callId || null, input.contextVersion | 0,
168
+ String(input.workspaceKey || ''), input.policyVersion || EVIDENCE_POLICY_VERSION]
169
+ return EVIDENCE_PREFIX + first32(sha256Str(JSON.stringify(parts)))
170
+ }
171
+
172
+ // ========== §8 coverage adapter(M3 byte-range 语义 + freshness 门) ==========
173
+
174
+ /**
175
+ * M3 UTF-8 byte 半开区间 [start,end) 重叠覆盖率:overlap/(recordBytes);无重叠=0。
176
+ */
177
+ export function computeRangeCoverage(record, range) {
178
+ const rs = Number(record && record.byteStart), re = Number(record && record.byteEnd)
179
+ const gs = Number(range && range.start), ge = Number(range && range.end)
180
+ if (![rs, re, gs, ge].every(Number.isFinite)) return { coverage: 0 }
181
+ const overlap = Math.min(re, ge) - Math.max(rs, gs)
182
+ const len = Math.max(1, re - rs)
183
+ return { coverage: clamp01(Math.max(0, overlap) / len) }
184
+ }
185
+
186
+ /** 读结果归一化:剥离行首行号前缀(如 ' 123|text'),NFKC。 */
187
+ export function normalizeReadText(text) {
188
+ return String(text == null ? '' : text)
189
+ .split(/\r?\n/)
190
+ .map((l) => l.replace(/^\s*\d+\s*[|:)\]〕】]\s?/, ''))
191
+ .join('\n')
192
+ .normalize('NFKC')
193
+ }
194
+
195
+ /**
196
+ * 包含判定(v2):record 归一化文本的最长匹配前缀占全文比例 → [0,1]。
197
+ * 完整包含=1;部分重叠(读结果截断/部分读取)按字节比例单调取值;零重叠=0。
198
+ */
199
+ export function computeContainmentCoverage(recordText, readText) {
200
+ const needle = normalizeReadText(recordText).trim()
201
+ if (!needle) return { coverage: 0 }
202
+ const hay = normalizeReadText(readText)
203
+ if (hay.includes(needle)) return { coverage: 1 }
204
+ const chars = [...needle]
205
+ let lo = 0
206
+ let hi = chars.length
207
+ while (lo < hi) {
208
+ const mid = Math.ceil((lo + hi + 1) / 2)
209
+ if (hay.includes(chars.slice(0, mid).join(''))) lo = mid
210
+ else hi = mid - 1
211
+ }
212
+ if (!lo) return { coverage: 0 }
213
+ const matched = chars.slice(0, lo).join('')
214
+ return { coverage: clamp01(Buffer.byteLength(matched, 'utf8') / Buffer.byteLength(needle, 'utf8')) }
215
+ }
216
+
217
+ /**
218
+ * §8 read coverage:freshness 门(observedFileDigest 与记录 provenance 不一致=stale fail closed,不建 evidence)
219
+ * → 每条命中的 memoryId 单独产出 coverage 条目;range 优先,否则文本包含判定。stale ≠ coverage=0。
220
+ */
221
+ export function computeReadCoverage(records, read) {
222
+ const out = { ok: true, stale: [], covered: [] }
223
+ const list = Array.isArray(records) ? records : []
224
+ const byMemory = new Map()
225
+ for (const rec of list) {
226
+ if (read && read.observedFileDigest && rec.fileDigest && read.observedFileDigest !== rec.fileDigest) {
227
+ out.stale.push({ memoryId: rec.memoryId, reason: 'stale-source' })
228
+ continue
229
+ }
230
+ let cov = { coverage: 0 }
231
+ if (read && read.range) cov = computeRangeCoverage(rec, read.range)
232
+ else if (read && typeof read.text === 'string' && typeof rec.text === 'string') cov = computeContainmentCoverage(rec.text, read.text)
233
+ if (cov.coverage > 0) {
234
+ byMemory.set(rec.memoryId, { memoryId: rec.memoryId, recordDigest: rec.recordDigest, coverage: cov.coverage, record: rec })
235
+ }
236
+ }
237
+ out.covered = [...byMemory.values()]
238
+ return out
239
+ }
240
+
241
+ // ========== §7 六类证据纯构造器 ==========
242
+
243
+ /** 通用构造:确定性 evidenceId → AccessEvidencePre(校验失败 fail closed)。 */
244
+ export function createAccessEvidencePre(input) {
245
+ const base = {
246
+ schemaVersion: 1,
247
+ namespace: NAMESPACE,
248
+ kind: input.kind,
249
+ memoryId: input.memoryId,
250
+ anchorId: input.anchorId,
251
+ scope: input.scope,
252
+ workspaceKey: input.workspaceKey,
253
+ event: {
254
+ sessionId: input.sessionId,
255
+ eventSeq: input.eventSeq,
256
+ nativeSeq: input.nativeSeq === undefined ? undefined : input.nativeSeq,
257
+ contextVersion: input.contextVersion,
258
+ callId: input.callId === undefined ? undefined : input.callId,
259
+ ts: input.ts,
260
+ },
261
+ source: {
262
+ sourceRef: input.sourceRef,
263
+ sourceEpoch: input.sourceEpoch,
264
+ sourceVersion: input.sourceVersion,
265
+ fileDigest: input.fileDigest,
266
+ recordDigest: input.recordDigest,
267
+ },
268
+ policyVersion: EVIDENCE_POLICY_VERSION,
269
+ }
270
+ if (input.coverage !== undefined) base.coverage = clamp01(input.coverage)
271
+ if (input.episodeId) base.episodeId = String(input.episodeId)
272
+ base.evidenceId = buildEvidenceId({
273
+ kind: base.kind, memoryId: base.memoryId, sessionId: base.event.sessionId,
274
+ eventSeq: base.event.eventSeq, nativeSeq: base.event.nativeSeq, callId: base.event.callId,
275
+ contextVersion: base.event.contextVersion, workspaceKey: base.workspaceKey,
276
+ })
277
+ const v = validateAccessEvidencePre(base)
278
+ return v.ok ? { ok: true, evidence: v.evidence } : { ok: false, reason: v.reason }
279
+ }
280
+
281
+ /**
282
+ * cite 扫描:可见文本中出现的完整 memoryId token(去重排序),且必须能在 knownRecords 中找到
283
+ * 完整 provenance(无可靠 owner 不建 evidence,§3)→ 每个一条 cite evidence。precision-first。
284
+ */
285
+ export function createCiteEvidencesFromText(input) {
286
+ const ids = [...new Set(String(input.text == null ? '' : input.text).match(CITATION_MEMORY_ID_PATTERN_V1) || [])].sort()
287
+ const out = []
288
+ for (const mid of ids) {
289
+ const rec = (input.knownRecords || []).find((r) => r.memoryId === mid)
290
+ if (!rec) continue
291
+ const r = createAccessEvidencePre({
292
+ ...input.coords, kind: 'cite', memoryId: mid, anchorId: rec.anchorId, scope: rec.scope,
293
+ sourceRef: rec.sourceRef, sourceEpoch: rec.sourceEpoch, sourceVersion: rec.sourceVersion,
294
+ fileDigest: rec.fileDigest, recordDigest: rec.recordDigest,
295
+ })
296
+ if (r.ok) out.push(r.evidence)
297
+ }
298
+ return out
299
+ }
300
+
301
+ /** correction 扫描:同一文本同时出现完整 memoryId 且命中纠正词典 → 每个一条 correction。 */
302
+ export function createCorrectionEvidencesFromText(input) {
303
+ const norm = String(input.text == null ? '' : input.text).normalize('NFKC').replace(/[A-Z]/g, (c) => c.toLowerCase())
304
+ if (!CORRECTION_LEXICON_V1.some((w) => norm.includes(w))) return []
305
+ return createCiteEvidencesFromText(input).map((ev) => ({
306
+ ...ev,
307
+ kind: 'correction',
308
+ evidenceId: buildEvidenceId({
309
+ kind: 'correction', memoryId: ev.memoryId, sessionId: input.coords.sessionId, eventSeq: input.coords.eventSeq,
310
+ nativeSeq: input.coords.nativeSeq, callId: input.coords.callId, contextVersion: input.coords.contextVersion,
311
+ workspaceKey: input.coords.workspaceKey,
312
+ }),
313
+ }))
314
+ }
315
+
316
+ /**
317
+ * reuse/success 身份对齐 tracker(precision-first):
318
+ * reuse:工具调用参数预览中显式出现已 cite/read 过记忆的 anchorId 或 recordDigest 前 16 位;
319
+ * success:由调用方在该 callId 的 tools/result ok=true 时落 success(纯函数 createSuccessEvidence)。
320
+ * episodeId 固定为触发对齐的那条历史 evidenceId(可解释回链)。
321
+ */
322
+ export class IdentityEpisodeTracker {
323
+ constructor(opts = {}) { this._bySession = new Map(); this._digestLen = opts.digestPrefixLen || 16 }
324
+ _sess(sid) { let m = this._sessMap(sid); return m }
325
+ _sessMap(sid) { let m = this._bySession.get(sid); if (!m) { m = new Map(); this._bySession.set(sid, m) } return m }
326
+ /** 记录一条 cite/read/seen evidence 为可对齐锚点(同 memoryId 保留最新)。 */
327
+ registerAnchor(evidence) {
328
+ if (!evidence || (evidence.kind !== 'cite' && evidence.kind !== 'read' && evidence.kind !== 'seen')) return
329
+ const m = this._sessMap(evidence.event.sessionId)
330
+ const prev = m.get(evidence.memoryId)
331
+ if (!prev || evidence.event.ts >= prev.event.ts) m.set(evidence.memoryId, evidence)
332
+ }
333
+ /** 工具调用对齐检测:返回 [{memoryId, episodeId}](按 memoryId 排序)或空。 */
334
+ alignToolCall(sessionId, toolName, argPreview) {
335
+ void toolName
336
+ const m = this._sessMap(sessionId)
337
+ if (!m.size || !argPreview) return []
338
+ const text = String(argPreview)
339
+ const hits = []
340
+ for (const [memoryId, ev] of m) {
341
+ const digestPrefix = String(ev.source.recordDigest || '').slice(0, this._digestLen)
342
+ if ((ev.anchorId && text.includes(ev.anchorId)) || (digestPrefix.length === this._digestLen && text.includes(digestPrefix))) {
343
+ hits.push({ memoryId, episodeId: ev.evidenceId })
344
+ }
345
+ }
346
+ return hits.sort((a, b) => (a.memoryId < b.memoryId ? -1 : 1))
347
+ }
348
+ clearSession(sessionId) { this._bySession.delete(sessionId) }
349
+ }
350
+
351
+ /** success 构造:reuse episode 之后出现明确工具成功(ok=true)或用户确认。 */
352
+ export function createSuccessEvidencePre(input) {
353
+ return createAccessEvidencePre({ ...input, kind: 'success' })
354
+ }
355
+ // ========== §5 Sinks(Null / Fake) ==========
356
+
357
+ /** Null sink:关闭态语义——push 永远 accepted:false/disabled,零 IO、零留存。 */
358
+ export function createNullContextSinkPre() {
359
+ return {
360
+ kind: 'null',
361
+ async push(frame) {
362
+ const id = frame && frame.observationId ? frame.observationId : ''
363
+ return { observationId: id, accepted: false, reason: 'disabled' }
364
+ },
365
+ async closeSession() {},
366
+ async dispose() {},
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Fake sink:fixtures/replay 用。只在校验后的 canonical frame 上记账(bounded ring),
372
+ * 同一 observationId 只接受一次;零进程、零网络、零 Python。
373
+ */
374
+ export function createFakeContextSinkPre(opts = {}) {
375
+ const capacity = Math.max(1, Number(opts.capacity) || 64)
376
+ const frames = []
377
+ const seenIds = new Set()
378
+ const stats = { pushed: 0, accepted: 0, duplicate: 0, closedSessions: 0 }
379
+ return {
380
+ kind: 'fake',
381
+ frames,
382
+ stats,
383
+ async push(frame) {
384
+ stats.pushed++
385
+ const id = frame && frame.observationId ? String(frame.observationId) : ''
386
+ if (seenIds.has(id)) { stats.duplicate++; return { observationId: id, accepted: false, reason: 'busy' } }
387
+ seenIds.add(id)
388
+ frames.push(JSON.parse(JSON.stringify(frame)))
389
+ if (frames.length > capacity) frames.shift()
390
+ stats.accepted++
391
+ return { observationId: id, accepted: true, workerEpoch: 'fake-epoch-pre-v1', reason: 'ok' }
392
+ },
393
+ async closeSession() { stats.closedSessions++ },
394
+ async dispose() { frames.length = 0 },
395
+ }
396
+ }
397
+
398
+ /** ContextAckPre 校验(§5)。 */
399
+ export function validateContextAckPre(ack) {
400
+ if (!ack || typeof ack !== 'object') return { ok: false, reason: 'not-object' }
401
+ if (typeof ack.observationId !== 'string' || !ack.observationId) return { ok: false, reason: 'observationId' }
402
+ if (typeof ack.accepted !== 'boolean') return { ok: false, reason: 'accepted' }
403
+ if (ack.workerEpoch !== undefined && typeof ack.workerEpoch !== 'string') return { ok: false, reason: 'workerEpoch' }
404
+ if (ack.reason !== undefined && !ACK_REASONS_V1.includes(ack.reason)) return { ok: false, reason: 'reason' }
405
+ return { ok: true, ack }
406
+ }
407
+
408
+ // ========== §4 envelope builder + push bridge ==========
409
+
410
+ /** 由 corpus 记录构造授权引用(excerpt 先清洗再预算)。 */
411
+ export function buildAuthorizedMemoryRefFromRecord(rec, excerptText) {
412
+ const ref = {
413
+ memoryId: rec.memoryId, anchorId: rec.anchorId, scope: rec.scope,
414
+ sourceRef: rec.sourceRef, sourceEpoch: rec.sourceEpoch, sourceVersion: rec.sourceVersion,
415
+ fileDigest: rec.fileDigest, recordDigest: rec.recordDigest,
416
+ }
417
+ if (excerptText != null) ref.excerpt = sanitizeExcerpt(excerptText)
418
+ const v = validateAuthorizedMemoryRefPre(ref)
419
+ return v.ok ? { ok: true, ref: v.ref } : { ok: false, reason: v.reason }
420
+ }
421
+
422
+ /**
423
+ * 组装并校验 ContextPushEnvelopePre(§4):确定性 observationId;超预算 fail-closed 截断计账。
424
+ * input: {session, cursor, index, trigger, window, memoryRefs, evidence, now, policyVersionGate?, policyVersionLexical?}
425
+ */
426
+ export function buildContextPushEnvelopePre(input) {
427
+ const B = CONTEXT_BRIDGE_BUDGET_V1
428
+ const drop = []
429
+ const session = input && input.session ? input.session : {}
430
+ const cursor = input && input.cursor ? input.cursor : {}
431
+ const index = input && input.index ? input.index : {}
432
+ const now = Number.isFinite(input && input.now) ? input.now : Date.now()
433
+
434
+ const tv = validateContextSegmentPre(input && input.trigger)
435
+ if (!tv.ok) return { ok: false, reason: 'trigger:' + tv.reason }
436
+ const rawWindow = Array.isArray(input.window) ? input.window : []
437
+ let window = rawWindow.slice(-B.maxSegments).map((w) => validateContextSegmentPre(w))
438
+ if (window.some((w) => !w.ok)) return { ok: false, reason: 'window:' + window.find((w) => !w.ok).reason }
439
+ if (rawWindow.length > B.maxSegments) drop.push('window-truncated')
440
+ const winSegs = window.map((w) => w.segment)
441
+ let inputBytes = winSegs.reduce((a, w) => a + Buffer.byteLength(w.text || '', 'utf8'), 0) + Buffer.byteLength(tv.segment.text || '', 'utf8')
442
+ while (inputBytes > B.maxInputBytes && winSegs.length) {
443
+ const removed = winSegs.shift()
444
+ inputBytes -= Buffer.byteLength(removed.text || '', 'utf8')
445
+ drop.push('window-byte-budget')
446
+ }
447
+ // trigger 自身超预算且无 window 可弃 → fail closed(trigger 文本截断会破坏 digest 一致性)
448
+ if (inputBytes > B.maxInputBytes) return { ok: false, reason: 'trigger-oversize' }
449
+
450
+ const rawRefs = Array.isArray(input.memoryRefs) ? input.memoryRefs : []
451
+ let refs = rawRefs.slice(0, B.maxMemoryRefs).map((r) => validateAuthorizedMemoryRefPre(r))
452
+ if (refs.some((r) => !r.ok)) return { ok: false, reason: 'memoryRefs:' + refs.find((r) => !r.ok).reason }
453
+ if (rawRefs.length > B.maxMemoryRefs) drop.push('memory-ref-budget')
454
+
455
+ const rawAggs = Array.isArray(input.evidence) ? input.evidence : []
456
+ let aggs = rawAggs.slice(0, B.maxEvidenceItems).map((a) => validateEvidenceAggregatePre(a))
457
+ if (aggs.some((a) => !a.ok)) return { ok: false, reason: 'evidence:' + aggs.find((a) => !a.ok).reason }
458
+ if (rawAggs.length > B.maxEvidenceItems) drop.push('evidence-budget')
459
+
460
+ if (!index.memoryIndexVersion || typeof index.memoryIndexVersion !== 'string') return { ok: false, reason: 'memoryIndexVersion' }
461
+ const epochs = Array.isArray(index.sourceEpochs) ? index.sourceEpochs.map(String).sort() : []
462
+
463
+ const frame = {
464
+ schemaVersion: 1,
465
+ namespace: NAMESPACE,
466
+ kind: 'context_push',
467
+ observationId: '',
468
+ session: {
469
+ sessionId: String(session.sessionId || ''),
470
+ agentId: String(session.agentId || ''),
471
+ workspaceKey: String(session.workspaceKey || ''),
472
+ scope: session.scope,
473
+ },
474
+ cursor: {
475
+ eventSeq: cursor.eventSeq | 0,
476
+ nativeSeq: cursor.nativeSeq === undefined ? undefined : cursor.nativeSeq,
477
+ contextVersion: cursor.contextVersion | 0,
478
+ },
479
+ index: { memoryIndexVersion: String(index.memoryIndexVersion), sourceEpochs: epochs },
480
+ trigger: tv.segment,
481
+ window: winSegs,
482
+ memoryRefs: refs.map((r) => r.ref),
483
+ evidence: aggs.map((a) => a.aggregate),
484
+ policy: {
485
+ contextPolicyVersion: CONTEXT_BRIDGE_POLICY_VERSION,
486
+ gatePolicyVersion: String(input.policyVersionGate || 'gate_v1'),
487
+ lexicalPolicyVersion: String(input.policyVersionLexical || 'lexical_v2'),
488
+ evidencePolicyVersion: EVIDENCE_POLICY_VERSION,
489
+ },
490
+ budget: { maxSegments: B.maxSegments, maxInputBytes: B.maxInputBytes, maxMemoryRefs: B.maxMemoryRefs, maxEvidenceItems: B.maxEvidenceItems },
491
+ observedAt: now,
492
+ deadlineAt: now + B.deadlineMs,
493
+ }
494
+ if (!['Session', 'Workspace', 'User'].includes(frame.session.scope)) return { ok: false, reason: 'session.scope' }
495
+ if (!frame.session.workspaceKey) return { ok: false, reason: 'session.workspaceKey' }
496
+ if (frame.cursor.nativeSeq !== undefined && !Number.isInteger(frame.cursor.nativeSeq)) return { ok: false, reason: 'cursor.nativeSeq' }
497
+ frame.observationId = buildObservationId(frame.session.sessionId, frame.cursor.contextVersion, frame.trigger.digest)
498
+
499
+ let json = JSON.stringify(frame)
500
+ while (Buffer.byteLength(json, 'utf8') > B.frameMaxBytes && frame.window.length) {
501
+ const removed = frame.window.pop()
502
+ inputBytes -= Buffer.byteLength(removed.text || '', 'utf8')
503
+ drop.push('frame-bytes-budget')
504
+ json = JSON.stringify(frame)
505
+ }
506
+ if (Buffer.byteLength(json, 'utf8') > B.frameMaxBytes) return { ok: false, reason: 'frame-oversize' }
507
+ return { ok: true, frame, dropped: drop, inputBytes }
508
+ }
509
+
510
+ /** latest-wins:同 session 新 contextVersion 使旧 in-flight 失效。 */
511
+ export function isSuperseded(oldFrame, newFrame) {
512
+ if (!oldFrame || !newFrame) return false
513
+ if (oldFrame.session.sessionId !== newFrame.session.sessionId) return false
514
+ return newFrame.cursor.contextVersion > oldFrame.cursor.contextVersion
515
+ }
516
+
517
+ /** 有界 Set(M5 sentObservationIds 与 M6 deliveredPacketIds 复用;dispose 可清空)。 */
518
+ export class BoundedIdSet {
519
+ constructor(capacity) { this.capacity = Math.max(1, Number(capacity) || 256); this._set = new Set() }
520
+ has(id) { return this._set.has(id) }
521
+ add(id) { this._set.add(id); if (this._set.size > this.capacity) { const first = this._set.values().next().value; this._set.delete(first) } }
522
+ get size() { return this._set.size }
523
+ clear() { this._set.clear() }
524
+ }
525
+
526
+ /**
527
+ * Push bridge:observationId 幂等(至多成功发送一次)+ latest-wins 取消 + AbortSignal 贯通。
528
+ * 关闭态应使用 null sink(本桥不判开关);sink 必须是 Null/Fake(M5 禁止 spawn/HTTP/Python 路径)。
529
+ */
530
+ export function createContextPushBridge(opts = {}) {
531
+ const sink = opts.sink
532
+ if (!sink || typeof sink.push !== 'function') throw new Error('context-bridge: sink required')
533
+ const signal = opts.signal
534
+ const sent = new BoundedIdSet(CONTEXT_BRIDGE_BUDGET_V1.sentObservationIds)
535
+ const inflight = new Map()
536
+ const stats = { sent: 0, accepted: 0, duplicates: 0, superseded: 0, aborted: 0, errors: 0 }
537
+ async function push(frame) {
538
+ try {
539
+ if (signal && signal.aborted) { stats.aborted++; return { observationId: frame.observationId, accepted: false, reason: 'stale' } }
540
+ if (sent.has(frame.observationId)) { stats.duplicates++; return { observationId: frame.observationId, accepted: false, reason: 'busy' } }
541
+ const prev = inflight.get(frame.session.sessionId)
542
+ if (prev && isSuperseded(prev.frame, frame)) {
543
+ try { prev.controller.abort('superseded') } catch (_) {}
544
+ stats.superseded++
545
+ }
546
+ const controller = new AbortController()
547
+ inflight.set(frame.session.sessionId, { frame, controller })
548
+ sent.add(frame.observationId)
549
+ stats.sent++
550
+ const ack = await sink.push(frame, controller.signal)
551
+ const v = validateContextAckPre(ack)
552
+ if (v.ok && ack.accepted) stats.accepted++
553
+ const cur = inflight.get(frame.session.sessionId)
554
+ if (cur && cur.controller === controller) inflight.delete(frame.session.sessionId)
555
+ return ack
556
+ } catch (e) {
557
+ stats.errors++
558
+ return { observationId: (frame && frame.observationId) || '', accepted: false, reason: 'unsupported' }
559
+ }
560
+ }
561
+ function cancelStale(sessionId, keepContextVersion) {
562
+ const cur = inflight.get(sessionId)
563
+ if (cur && cur.frame.cursor.contextVersion !== keepContextVersion) {
564
+ try { cur.controller.abort('superseded') } catch (_) {}
565
+ inflight.delete(sessionId)
566
+ stats.superseded++
567
+ }
568
+ }
569
+ async function closeSession(sessionId) {
570
+ const cur = inflight.get(sessionId)
571
+ if (cur) { try { cur.controller.abort('session-close') } catch (_) {}; inflight.delete(sessionId) }
572
+ if (typeof sink.closeSession === 'function') await sink.closeSession(sessionId)
573
+ }
574
+ async function dispose(reason) {
575
+ for (const [, cur] of inflight) { try { cur.controller.abort(reason || 'disposed') } catch (_) {} }
576
+ inflight.clear()
577
+ if (typeof sink.dispose === 'function') await sink.dispose(reason)
578
+ }
579
+ return { push, cancelStale, closeSession, dispose, sent, stats, sinkKind: () => sink.kind || 'unknown' }
580
+ }
581
+ // ========== replay pure core ==========
582
+
583
+ /**
584
+ * §replay:对 fixture 事件序列跑 classifier → evidence/envelope,输出 canonical 结果。
585
+ * 排除墙钟:ts/now 全部来自 fixture;同输入逐字段确定。sink 为进程内 fake,零 IO。
586
+ */
587
+ export function replayContextBridge(input) {
588
+ const events = Array.isArray(input && input.events) ? input.events : []
589
+ const records = Array.isArray(input && input.records) ? input.records : []
590
+ const sink = createFakeContextSinkPre({ capacity: input && input.sinkCapacity })
591
+ const bridge = createContextPushBridge({ sink })
592
+ const results = []
593
+ const tracker = new IdentityEpisodeTracker()
594
+ for (const step of events) {
595
+ const r = { label: step.label }
596
+ if (step.type === 'envelope') {
597
+ const built = buildContextPushEnvelopePre({ ...step.input, now: step.now })
598
+ r.envelopeOk = built.ok
599
+ if (built.ok) {
600
+ r.observationId = built.frame.observationId
601
+ r.dropped = built.dropped
602
+ } else {
603
+ r.reason = built.reason
604
+ }
605
+ results.push(r)
606
+ if (built.ok) step._pendingAck = bridge.push(built.frame)
607
+ } else if (step.type === 'cite' || step.type === 'correction') {
608
+ const maker = step.type === 'cite' ? createCiteEvidencesFromText : createCorrectionEvidencesFromText
609
+ const evs = maker({ coords: { ...step.coords, ts: step.now }, text: step.text, knownRecords: records })
610
+ for (const ev of evs) tracker.registerAnchor(ev)
611
+ r.evidence = evs.map((e) => e.evidenceId)
612
+ results.push(r)
613
+ } else if (step.type === 'align') {
614
+ r.alignments = tracker.alignToolCall(step.sessionId, step.toolName, step.argPreview)
615
+ results.push(r)
616
+ }
617
+ }
618
+ return { results, sink, bridge }
619
+ }