@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.
@@ -0,0 +1,361 @@
1
+ /**
2
+ * M4-3 Host Shadow Wiring(docs/M4-CONTRACT.md §5/§14/§15/§17)。
3
+ * 桥接 lib/index.js(M2 ContextObserver/M3 sidecar)与 M4-1 纯核心:
4
+ * - per-runtime Shadow state(WeakMap,lazy;enableEpoch/completedKeys/recentHits/cooldown/latch)
5
+ * - accepted Segment → 同步捕获 paths 快照 → 异步 latest-wins 调度(gate→corpus→lexical→audit)
6
+ * - durable audit:<DSH_HOME>/memory/retrieval-pre/audit/YYYY-MM-DD.jsonl(engine 级串行,32KiB 截断,
7
+ * 隐私投影:无原文/无绝对路径/无 sessionId/term 只存 digest)+ retention(14 天/32MiB)
8
+ * - debugView(§17 最小投影;关闭时严格 {enabled:false})
9
+ * 默认关闭:三开关任一为 false 时零构造、零 IO、零留存。
10
+ */
11
+ import { mkdirSync, appendFileSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync, rmSync } from 'node:fs'
12
+ import path from 'node:path'
13
+ import { homedir } from 'node:os'
14
+ import { createHash } from 'node:crypto'
15
+ import {
16
+ validateSnapshot, buildQueryPlan, computeSignals, gatePreV1,
17
+ lexicalSearch, buildCandidates, buildRetrievalId, replay as replayCore,
18
+ SHADOW_GATE_POLICY_V1, LEXICAL_POLICY_VERSION, GATE_POLICY_VERSION, NAMESPACE,
19
+ } from './shadow-retrieval.js'
20
+ import { buildSourceCatalog, loadCorpusSnapshot, CorpusRegistry, canonicalize } from './m4-corpus.js'
21
+
22
+ const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex')
23
+ const first32 = (s) => String(s || '').slice(0, 32)
24
+ const AUDIT_KEEP_DAYS = 14
25
+ const AUDIT_MAX_BYTES = 32 * 1024 * 1024
26
+ const AUDIT_EVENT_MAX = 32 * 1024
27
+
28
+ /** 单 audit 事件 32KiB 上限:超限按 candidate/drop 尾部裁剪并标记。 */
29
+ export function truncateAuditEvent(ev) {
30
+ let e = ev
31
+ if (Buffer.byteLength(JSON.stringify(e), 'utf8') <= AUDIT_EVENT_MAX) return e
32
+ e = JSON.parse(JSON.stringify(ev))
33
+ e.auditTruncated = true
34
+ while (Buffer.byteLength(JSON.stringify(e), 'utf8') > AUDIT_EVENT_MAX && (e.candidates.length || e.dropped.length)) {
35
+ if (e.candidates.length >= e.dropped.length) e.candidates.pop()
36
+ else e.dropped.pop()
37
+ e.counts.kept = e.candidates.length
38
+ }
39
+ return e
40
+ }
41
+
42
+ export function createShadowHost({ engine }) {
43
+ const states = new WeakMap() // runtime → shadow state(lazy)
44
+ const volatileRing = [] // ≤64 events(最小投影)
45
+ const stats = { evaluated: 0, retrieved: 0, prefetched: 0, suppressed: 0, stale: 0, errors: 0 }
46
+ const registry = new CorpusRegistry({ sidecarDir: path.join(dshHome(), 'memory', 'index', 'files') })
47
+ let lastMemoryIndexVersion = null
48
+ let lastCombo = null
49
+ let enableEpoch = 0
50
+ let lastGate = null
51
+ let inFlightCount = 0
52
+ let lastAuditAt = 0
53
+ let auditSwept = false
54
+
55
+ const effectiveEnabled = () =>
56
+ engine.config.associativeMemoryEnabled === true &&
57
+ engine.config.shadowRetrievalEnabled === true &&
58
+ engine.config.memoryAnchorEnabled === true
59
+
60
+ function stateFor(runtime) {
61
+ let st = states.get(runtime)
62
+ if (!st) {
63
+ st = {
64
+ enableEpoch, processedSegmentCount: 0, cooldownUntilSegment: 0,
65
+ latched: false, recentHits: [], completedKeys: new Set(),
66
+ ignoredDigests: [], inFlight: null, runtimeTag: null, lastGate: null,
67
+ }
68
+ states.set(runtime, st)
69
+ }
70
+ return st
71
+ }
72
+
73
+ /** §17 debugView:关闭时严格 {enabled:false}。 */
74
+ function debugView() {
75
+ if (!effectiveEnabled()) return { enabled: false }
76
+ const runtimes = []
77
+ for (const pair of collectStates()) {
78
+ const st = pair.state
79
+ runtimes.push({
80
+ runtimeTag: st.runtimeTag ? st.runtimeTag.slice(0, 8) : undefined,
81
+ contextVersion: pair.runtime.contextVersion,
82
+ gate: st.lastGate ? { action: st.lastGate.action, hesitation: st.lastGate.hesitation } : null,
83
+ inFlight: !!st.inFlight,
84
+ processedSegmentCount: st.processedSegmentCount,
85
+ enableEpoch: st.enableEpoch,
86
+ })
87
+ }
88
+ return {
89
+ enabled: true, shadowOnly: true,
90
+ gatePolicyVersion: GATE_POLICY_VERSION, lexicalPolicyVersion: LEXICAL_POLICY_VERSION,
91
+ memoryIndexVersion: lastMemoryIndexVersion,
92
+ corpus: { sources: lastCorpusCounts.sources, records: lastCorpusCounts.records, legacyConflicts: lastCorpusCounts.legacyConflicts, staleSources: lastCorpusCounts.staleSources || 0 },
93
+ stats: { ...stats },
94
+ lastAuditError,
95
+ auditDirPath: auditDir(),
96
+ auditWritten,
97
+ runtimes,
98
+ recentAudit: volatileRing.slice(-3),
99
+ }
100
+ }
101
+
102
+ function collectStates() {
103
+ // WeakMap 不可枚举:debug 用 engine.runtimes 反查
104
+ const out = []
105
+ for (const rt of engine.runtimes.values()) {
106
+ const st = states.get(rt)
107
+ if (st) out.push({ runtime: rt, state: st })
108
+ }
109
+ return out
110
+ }
111
+
112
+ const lastCorpusCounts = { sources: 0, records: 0, legacyConflicts: 0, staleSources: 0 }
113
+
114
+ /** 调度瞬间同步捕获的 paths 快照由 index.js 写入(refresh 完成时)。 */
115
+ const pathsByKey = new Map()
116
+ function capturePaths(runtimeKey, p) {
117
+ pathsByKey.set(String(runtimeKey || ''), {
118
+ workspaceKey: canonicalize(p.ws),
119
+ // engine.state 无 userFile 字段(§refresh 只写 userDir):用户级=state.userDir/MEMORY.md(与 resolvePaths 公式一致)
120
+ userMemoryPath: p.userDir ? path.join(p.userDir, 'MEMORY.md') : undefined,
121
+ workspaceMemoryPath: p.notesPath,
122
+ todayLogPath: p.logPath,
123
+ })
124
+ }
125
+
126
+ /** durable audit 目录与日期分片。 */
127
+ function auditDir() { return path.join(dshHome(), 'memory', 'retrieval-pre', 'audit') }
128
+ function dshHome() {
129
+ const env = process.env.DSH_HOME
130
+ if (env && env.trim()) return env.trim()
131
+ // M4-4 修复:必须拼接 .dsh(此前漏拼导致 audit 写到 <home>/memory 错误位置)
132
+ const base = engine.__homedirFn ? engine.__homedirFn() : (process.env.USERPROFILE || process.env.HOME || '')
133
+ return base ? path.join(base, '.dsh') : '.'
134
+ }
135
+
136
+ /** 串行 durable append(§15.4):engine 级链式;失败只计 audit-write-failed 不重试不污染 Session。 */
137
+ let auditChainTail = Promise.resolve()
138
+ let auditWritten = 0
139
+ let lastAuditError = null
140
+ function appendAuditDurable(event) {
141
+ const trimmed = truncateAuditEvent(event)
142
+ const date = new Date(event.recordedAt)
143
+ const fname = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0') + '.jsonl'
144
+ auditChainTail = auditChainTail.then(() => {
145
+ try {
146
+ const dir = auditDir()
147
+ mkdirSync(dir, { recursive: true })
148
+ appendFileSync(path.join(dir, fname), JSON.stringify(trimmed) + '\n', 'utf8')
149
+ maybeRetentionSweep(dir)
150
+ auditWritten++
151
+ return true
152
+ } catch (e2) {
153
+ lastAuditError = 'audit-write-failed:' + (e2 && e2.message ? e2.message : String(e2))
154
+ try { console.error('[shadow-audit] ' + lastAuditError) } catch (_) {}
155
+ return false
156
+ }
157
+ })
158
+ return auditChainTail
159
+ }
160
+
161
+ /** retention:保留 14 天且总量 ≤32MiB;只清 audit 分片,不动 Markdown/sidecar。 */
162
+ function maybeRetentionSweep(dir) {
163
+ if (auditSwept) return
164
+ auditSwept = true
165
+ try {
166
+ const now = Date.now()
167
+ let total = 0
168
+ const files = []
169
+ for (const f of readdirSync(dir)) {
170
+ if (!f.endsWith('.jsonl')) continue
171
+ const fp = path.join(dir, f)
172
+ const st = statSync(fp)
173
+ total += st.size
174
+ files.push({ fp, mtimeMs: st.mtimeMs, size: st.size })
175
+ }
176
+ for (const f of files) {
177
+ const ageDays = (now - f.mtimeMs) / 86400000
178
+ if (ageDays > AUDIT_KEEP_DAYS || total > AUDIT_MAX_BYTES) {
179
+ try { rmSyncSafe(f.fp); total -= f.size } catch (_) {}
180
+ }
181
+ if (total <= AUDIT_MAX_BYTES && ageDays <= AUDIT_KEEP_DAYS) break
182
+ }
183
+ } catch (_) {}
184
+ }
185
+ function rmSyncSafe(p) { try { rmSync(p, { force: true }) } catch (_) {} }
186
+
187
+ return {
188
+ init() { /* 兼容占位:运行期依赖已静态化 */ },
189
+ capturePaths,
190
+ effectiveEnabled,
191
+ isLive: effectiveEnabled,
192
+
193
+ /**
194
+ * M2 Segment accept 后调用(index.js ingestEnvelope 内 fire-and-forget)。
195
+ * 同步段:paths 快照/snapshot 构造/gate/latest-wins abort;异步段:corpus+rank+audit。
196
+ */
197
+ onSegmentAccepted(runtime, seg, envelope) {
198
+ try {
199
+ const combo = [
200
+ engine.config.associativeMemoryEnabled === true,
201
+ engine.config.shadowRetrievalEnabled === true,
202
+ engine.config.memoryAnchorEnabled === true,
203
+ ]
204
+ const comboStr = combo.join(',')
205
+ if (lastCombo !== null && comboStr !== lastCombo) {
206
+ enableEpoch++
207
+ states.delete(runtime) // 简化:该 runtime 状态清零(其它 runtime 下次触发时同样处理)
208
+ }
209
+ lastCombo = comboStr
210
+ if (!effectiveEnabled()) return // 关闭零构造零留存
211
+ const st = stateFor(runtime)
212
+ st.processedSegmentCount += 1
213
+ if (runtime.disposed) return
214
+ const cooldownRemaining = Math.max(0, st.cooldownUntilSegment - st.processedSegmentCount)
215
+ // child session hard suppress(§6:parentSession 存在即 child;volatile counter only)
216
+ const isChild = !!(runtime.agent && runtime.agent.session && runtime.agent.session.header && runtime.agent.session.header.parentSession)
217
+ if (isChild) {
218
+ stats.suppressed++
219
+ pushVolatile({ reason: 'child-session', contextVersion: seg.contextVersion })
220
+ return
221
+ }
222
+ // plugin-generated user trigger 不得单独触发 retrieve(§6);标量在 envelope.payload 最小投影
223
+ const envPayload = (envelope && envelope.payload) || {}
224
+ if (seg.kind === 'user' && envPayload.sourcePlugin) {
225
+ stats.suppressed++
226
+ pushVolatile({ reason: 'plugin-generated-trigger', contextVersion: seg.contextVersion })
227
+ return
228
+ }
229
+ const paths = pathsByKey.get(String(runtime.key || '')) || null
230
+ if (!paths) { console.error('[shadow-diag] no-paths'); stats.suppressed++; pushVolatile({ reason: 'no-paths-captured', contextVersion: seg.contextVersion }); return }
231
+ // window:segments ring 最近 8 条(含当前)
232
+ const ringItems = runtime.segments ? runtime.segments.snapshot() : []
233
+ const win = ringItems.slice(-8).map((w) => ({
234
+ segmentId: w.id, digest: w.digest, kind: w.kind, eventSeq: w.eventSeq,
235
+ contextVersion: w.contextVersion, ts: w.ts, text: w.text,
236
+ toolName: w.toolName != null ? w.toolName : null,
237
+ toolOk: w.toolOk != null ? w.toolOk : null,
238
+ errorName: w.errorName != null ? w.errorName : null,
239
+ errorCode: w.errorCode != null ? w.errorCode : null,
240
+ }))
241
+ const snap = validateSnapshot({
242
+ schemaVersion: 1, sessionId: envelope.sessionId || '', agentId: runtime.agentId || '',
243
+ workspaceKey: paths.workspaceKey, sessionClass: 'top-level',
244
+ contextVersion: runtime.contextVersion, eventSeq: seg.eventSeq,
245
+ trigger: {
246
+ segmentId: seg.id, segmentDigest: seg.digest, kind: seg.kind, eventType: seg.eventType,
247
+ nativeSeq: seg.nativeSeq != null ? seg.nativeSeq : undefined, ts: seg.ts,
248
+ text: seg.text,
249
+ inputSource: envPayload.inputSource || null,
250
+ sourcePlugin: envPayload.sourcePlugin || null,
251
+ toolName: seg.toolName != null ? seg.toolName : null,
252
+ toolOk: seg.toolOk != null ? seg.toolOk : null,
253
+ errorName: seg.errorName != null ? seg.errorName : null,
254
+ errorCode: seg.errorCode != null ? seg.errorCode : null,
255
+ callId: (envelope && envelope.callId) || null,
256
+ rootCallId: (envelope && envelope.rootCallId) || null,
257
+ },
258
+ window: win,
259
+ })
260
+ if (!snap.ok) { stats.suppressed++; pushVolatile({ reason: snap.reason, contextVersion: runtime.contextVersion }); return }
261
+ const snapshot = snap.snapshot
262
+ const qp = buildQueryPlan(snapshot)
263
+ const dec = gatePreV1(snapshot, { previousLatch: st.latched, cooldownRemaining, signals: computeSignals(snapshot, qp, st.recentHits), queryPlan: qp })
264
+ st.lastGate = { action: dec.action, hesitation: dec.hesitation, rawScore: dec.rawScore, latched: dec.latched }
265
+ st.latched = dec.latched
266
+ st.cooldownUntilSegment = dec.action === 'retrieve' ? (st.processedSegmentCount + SHADOW_GATE_POLICY_V1.cooldownSegments) : st.cooldownUntilSegment
267
+ stats.evaluated++
268
+ if (dec.action === 'suppress') { stats.suppressed++; pushSuppressed(dec, snapshot); return }
269
+ // latest-wins abort
270
+ if (st.inFlight && st.inFlight.contextVersion !== snapshot.contextVersion) {
271
+ try { st.inFlight.controller.abort() } catch (_) {}
272
+ stats.stale++
273
+ }
274
+ const controller = new AbortController()
275
+ const retrievalIdSeed = { sessionId: snapshot.sessionId, contextVersion: snapshot.contextVersion, segmentId: snapshot.trigger.segmentId }
276
+ st.inFlight = { contextVersion: snapshot.contextVersion, controller, retrievalIdSeed }
277
+ inFlightCount++
278
+ void runRetrieval({ runtime, st, snapshot, qp, dec, cat: buildSourceCatalog(paths), controller, retrievalIdSeed })
279
+ .catch((e) => { stats.errors++; pushVolatile({ reason: 'internal-error', detail: e && e.message }) })
280
+ } catch (e) { stats.errors++ }
281
+ },
282
+
283
+ debugView,
284
+ getStats: () => ({ ...stats }),
285
+ disposeRuntime(runtime) {
286
+ const st = states.get(runtime)
287
+ if (st && st.inFlight) { try { st.inFlight.controller.abort() } catch (_) {} ; st.inFlight = null }
288
+ states.delete(runtime)
289
+ },
290
+ disposeAll(reason) {
291
+ for (const pair of collectStates()) this.disposeRuntime(pair.runtime)
292
+ volatileRing.length = 0
293
+ },
294
+ replayFromFile,
295
+ _volatileRing: volatileRing,
296
+ _statsForTest: stats,
297
+ }
298
+
299
+ function pushVolatile(entry) {
300
+ volatileRing.push({ at: Date.now(), ...entry })
301
+ if (volatileRing.length > 64) volatileRing.shift()
302
+ }
303
+ function pushSuppressed(dec, snapshot) {
304
+ pushVolatile({ reason: dec.reason, action: 'suppress', contextVersion: snapshot.contextVersion, hesitation: dec.hesitation })
305
+ }
306
+
307
+ async function runRetrieval(ctxR) {
308
+ const { runtime, st, snapshot, qp, dec, controller } = ctxR
309
+ if (controller.signal.aborted) { stats.stale++; return }
310
+ const catalog = ctxR.cat
311
+ const corpusRes = registry.get(catalog)
312
+ if (!corpusRes.ok) { stats.suppressed++; pushVolatile({ reason: corpusRes.reason }); return }
313
+ const snapshotOut = corpusRes.snapshot
314
+ if (controller.signal.aborted) { stats.stale++; return }
315
+ lastMemoryIndexVersion = snapshotOut.memoryIndexVersion
316
+ lastCorpusCounts.sources = snapshotOut.counts.sources
317
+ lastCorpusCounts.records = snapshotOut.records.length
318
+ const ls = lexicalSearch(snapshotOut, qp, { triggerTs: snapshot.trigger.ts, mode: dec.action === 'retrieve' ? 'retrieve' : 'prefetch', dayBoundaryMinutes: Number(engine.config.dayBoundaryMinutes) || 450 })
319
+ if (controller.signal.aborted) { stats.stale++; return }
320
+ const retrievalId = buildRetrievalId(snapshot.sessionId, snapshot.contextVersion, snapshot.trigger.segmentId, snapshotOut.memoryIndexVersion)
321
+ const candidates = buildCandidates(retrievalId, ls.kept, dec.action === 'retrieve' ? 'retrieve' : 'prefetch')
322
+ if (dec.action === 'retrieve') stats.retrieved++
323
+ else stats.prefetched++
324
+ st.recentHits.push({ queryDigest: qp.queryDigest, fresh: ls.kept.length > 0, memoryIds: ls.kept.map((k) => k.memoryId) })
325
+ if (st.recentHits.length > 64) st.recentHits.shift()
326
+ st.completedKeys.add(qp.queryDigest + ':' + snapshotOut.memoryIndexVersion)
327
+ if (st.completedKeys.size > 256) { const first = st.completedKeys.values().next().value; st.completedKeys.delete(first) }
328
+ // durable audit(§15.2 schema,隐私投影)
329
+ const ev = truncateAuditEvent({
330
+ schemaVersion: 1, namespace: NAMESPACE,
331
+ retrievalId, recordedAt: Date.now(), triggerTs: snapshot.trigger.ts,
332
+ contextVersion: snapshot.contextVersion, eventSeq: snapshot.eventSeq,
333
+ triggerSegmentId: snapshot.trigger.segmentId, triggerSegmentDigest: snapshot.trigger.segmentDigest,
334
+ triggerKind: snapshot.trigger.kind,
335
+ memoryIndexVersion: snapshotOut.memoryIndexVersion,
336
+ gatePolicyVersion: GATE_POLICY_VERSION, lexicalPolicyVersion: LEXICAL_POLICY_VERSION,
337
+ queryDigest: qp.queryDigest,
338
+ gate: { action: dec.action, state: dec.state, reason: dec.reason, rawScore: dec.rawScore, hesitation: dec.hesitation, signals: dec.signals },
339
+ outcome: candidates.length ? 'completed' : 'empty',
340
+ candidates: candidates.map((c) => ({
341
+ candidateId: c.candidateId, memoryId: c.memoryId, anchorId: c.anchorId,
342
+ scope: c.scope, sourceClass: c.sourceClass, sourceRef: c.sourceRef,
343
+ sourceEpoch: c.sourceEpoch, sourceVersion: c.sourceVersion, fileDigest: c.fileDigest, recordDigest: c.recordDigest,
344
+ score: c.scores.total, reasonCodes: c.reasonCodes,
345
+ })),
346
+ dropped: ls.dropped.map((d) => ({ stage: d.stage, reason: d.reason, memoryId: d.memoryId, sourceRef: d.sourceRef })),
347
+ counts: { sources: ls.counts.sources, records: ls.counts.records, legacyConflicts: ls.counts.legacyConflicts, rawHits: ls.counts.rawHits, kept: ls.counts.kept, dropped: ls.counts.dropped },
348
+ latencyMs: { gate: 0, corpus: 0, search: 0, audit: 0, total: 0 },
349
+ shadowOnly: true, injected: false, packetId: null, delivered: false, accessEvidenceCreated: false,
350
+ })
351
+ await appendAuditDurable(ev)
352
+ lastAuditAt = Date.now()
353
+ if (st.inFlight && st.inFlight.contextVersion === snapshot.contextVersion) st.inFlight = null
354
+ }
355
+
356
+ /** §16 显式 replay(纯核心;persist=false 不污染 live audit)。 */
357
+ function replayFromFile(inputPath) {
358
+ const fixture = JSON.parse(readFileSync(inputPath, 'utf8'))
359
+ return replay(fixture)
360
+ }
361
+ }