@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,673 @@
1
+ /**
2
+ * Shadow Retrieval — M4-1 纯核心(docs/M4-CONTRACT.md §6-§16)。
3
+ * 只读、纯函数、零 IO、零依赖(除 node:crypto);不接入 lib/index.js、不读真实文件、
4
+ * 不写 audit、不产生任何模型可见副作用(M4 唯一模型行为=不影响模型行为)。
5
+ *
6
+ * 组成:
7
+ * 1) 策略常量(SHADOW_GATE_POLICY_V1 / SHADOW_LEXICAL_BUDGET_V1 / 版本化词典)
8
+ * 2) RetrievalContextSnapshot validator + 快照纯校验
9
+ * 3) memoryIndexVersion(canonical corpus tuples → idx_ + sha256)
10
+ * 4) GateSignals/GateDecision + gate_v1(硬抑制顺序 + 权重滞回 + cooldown)
11
+ * 5) 确定性 tokenizer(NFKC/lowercase/词形保留/CJK 2-gram/stop words) + QueryPlan + queryDigest
12
+ * 6) lexical_v1(term/heading coverage、phrase、recency、total、排序/去重/预算/drop)
13
+ * 7) Candidate 身份(retrievalId/candidateId 确定性) + ShadowCandidate 构造
14
+ * 8) replay pure core(canonical 结果排除 recordedAt/latency/runtimeTag)
15
+ * 全部函数对同输入逐字段确定;所有新增文本 UTF-8 无 BOM。
16
+ */
17
+ import { createHash } from 'node:crypto'
18
+
19
+ const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex')
20
+ const sha256Str = (s) => sha256Hex(Buffer.from(String(s), 'utf8'))
21
+ const first32 = (h) => h.slice(0, 32)
22
+ const clamp01 = (x) => Math.max(0, Math.min(1, Number(x) || 0))
23
+
24
+ export const STOPWORDS_HIT_V2 = Object.freeze(['一一', 'sub', 'exp', 'sup', 'Lex', '第二', '一番', '一直', '一个', '一些', '许多', '种', '有的是', '也就是说', '啊', '阿', '哎', '哎呀', '哎哟', '唉', '俺', '俺们', '按', '按照', '吧', '吧哒', '把', '罢了', '被', '本', '本着', '比', '比方', '比如', '鄙人', '彼', '彼此', '边', '别', '别的', '别说', '并', '并且', '不比', '不成', '不单', '不但', '不独', '不管', '不光', '不过', '不仅', '不拘', '不论', '不怕', '不然', '不如', '不特', '不惟', '不问', '不只', '朝', '朝着', '趁', '趁着', '乘', '冲', '除', '除此之外', '除非', '除了', '此', '此间', '此外', '从', '从而', '打', '待', '但', '但是', '当', '当着', '到', '得', '的', '的话', '等', '等等', '地', '第', '叮咚', '对', '对于', '多', '多少', '而', '而况', '而且', '而是', '而外', '而言', '而已', '尔后', '反过来', '反过来说', '反之', '非但', '非徒', '否则', '嘎', '嘎登', '该', '赶', '个', '各', '各个', '各位', '各种', '各自', '给', '根据', '跟', '故', '故此', '固然', '关于', '管', '归', '果然', '果真', '过', '哈', '哈哈', '呵', '和', '何', '何处', '何况', '何时', '嘿', '哼', '哼唷', '呼哧', '乎', '哗', '还是', '还有', '换句话说', '换言之', '或', '或是', '或者', '极了', '及', '及其', '及至', '即', '即便', '即或', '即令', '即若', '即使', '几', '几时', '己', '既', '既然', '既是', '继而', '加之', '假如', '假若', '假使', '鉴于', '将', '较', '较之', '叫', '接着', '结果', '借', '紧接着', '进而', '尽', '尽管', '经', '经过', '就', '就是', '就是说', '据', '具体地说', '具体说来', '开始', '开外', '靠', '咳', '可', '可见', '可是', '可以', '况且', '啦', '来', '来着', '离', '例如', '哩', '连', '连同', '两者', '了', '临', '另', '另外', '另一方面', '论', '嘛', '吗', '慢说', '漫说', '冒', '么', '每', '每当', '们', '莫若', '某', '某个', '某些', '拿', '哪', '哪边', '哪儿', '哪个', '哪里', '哪年', '哪怕', '哪天', '哪些', '哪样', '那', '那边', '那儿', '那个', '那会儿', '那里', '那么', '那么些', '那么样', '那时', '那些', '那样', '乃', '乃至', '呢', '能', '你', '你们', '您', '宁', '宁可', '宁肯', '宁愿', '哦', '呕', '啪达', '旁人', '呸', '凭', '凭借', '其', '其次', '其二', '其他', '其它', '其一', '其余', '其中', '起', '起见', '岂但', '恰恰相反', '前后', '前者', '且', '然而', '然后', '然则', '让', '人家', '任', '任何', '任凭', '如', '如此', '如果', '如何', '如其', '如若', '如上所述', '若', '若非', '若是', '啥', '上下', '尚且', '设若', '设使', '甚而', '甚么', '甚至', '省得', '时候', '什么', '什么样', '使得', '是', '是的', '首先', '谁', '谁知', '顺', '顺着', '似的', '虽', '虽然', '虽说', '虽则', '随', '随着', '所', '所以', '他', '他们', '他人', '它', '它们', '她', '她们', '倘', '倘或', '倘然', '倘若', '倘使', '腾', '替', '通过', '同', '同时', '哇', '万一', '往', '望', '为', '为何', '为了', '为什么', '为着', '喂', '嗡嗡', '我', '我们', '呜', '呜呼', '乌乎', '无论', '无宁', '毋宁', '嘻', '吓', '相对而言', '像', '向', '向着', '嘘', '呀', '焉', '沿', '沿着', '要', '要不', '要不然', '要不是', '要么', '要是', '也', '也罢', '也好', '一', '一般', '一旦', '一方面', '一来', '一切', '一样', '一则', '依', '依照', '矣', '以', '以便', '以及', '以免', '以至', '以至于', '以致', '抑或', '因', '因此', '因而', '因为', '哟', '用', '由', '由此可见', '由于', '有', '有的', '有关', '有些', '又', '于', '于是', '于是乎', '与', '与此同时', '与否', '与其', '越是', '云云', '哉', '再说', '再者', '在', '在下', '咱', '咱们', '则', '怎', '怎么', '怎么办', '怎么样', '怎样', '咋', '照', '照着', '者', '这', '这边', '这儿', '这个', '这会儿', '这就是说', '这里', '这么', '这么点儿', '这么些', '这么样', '这时', '这些', '这样', '正如', '吱', '之', '之类', '之所以', '之一', '只是', '只限', '只要', '只有', '至', '至于', '诸位', '着', '着呢', '自', '自从', '自个儿', '自各儿', '自己', '自家', '自身', '综上所述', '总的来看', '总的来说', '总的说来', '总而言之', '总之', '纵', '纵令', '纵然', '纵使', '遵照', '作为', '兮', '呃', '呗', '咚', '咦', '喏', '啐', '喔唷', '嗬', '嗯', '嗳']);
25
+ export const GATE_POLICY_VERSION = 'gate_v1'
26
+ export const LEXICAL_POLICY_VERSION = 'lexical_v2'
27
+ export const INDEX_PREFIX = 'idx_'
28
+ export const RETRIEVAL_PREFIX = 'ret_'
29
+ export const CANDIDATE_PREFIX = 'cand_'
30
+ export const NAMESPACE = 'dsh-auto-memory'
31
+
32
+ /** §9.4 冻结权重(变更必须升级 policyVersion)。 */
33
+ export const SHADOW_GATE_POLICY_V1 = Object.freeze({
34
+ schemaVersion: 1,
35
+ policyVersion: GATE_POLICY_VERSION,
36
+ weights: Object.freeze({
37
+ explicitRecall: 0.30, toolFailure: 0.16, unresolved: 0.16, repeated: 0.12,
38
+ novelty: 0.08, phaseShift: 0.08, historical: 0.05, conflict: 0.03, unresolvedAge: 0.02,
39
+ goalDrift: 0, monitor: 0, reasoning: 0,
40
+ }),
41
+ hysteresisOn: 0.65,
42
+ hysteresisOff: 0.42,
43
+ retrieveThreshold: 0.80,
44
+ prefetchThreshold: 0.55,
45
+ cooldownSegments: 2,
46
+ explicitRecallFloor: 0.82,
47
+ // §9.3 词典版本(由 fixture 锁定)
48
+ dictionaries: Object.freeze({
49
+ version: 1,
50
+ explicitRecall: Object.freeze(['回忆', '查找', '之前', '上次', 'remember', 'recall', 'previous', 'decided', 'what we decided', 'how did we']),
51
+ phaseShift: Object.freeze(['接下来', '下一步', '现在开始', '换个', '开始', 'now', 'next', 'let us start', 'meanwhile']),
52
+ unresolved: Object.freeze(['未解决', '待办', '没找到', '失败', '还没', '仍缺', 'pending', 'unresolved', 'not found', 'failed', 'todo']),
53
+ conflict: Object.freeze(['不对', '错了', '不是', '反了', '纠正', 'wrong', 'incorrect', 'no that', 'actually']),
54
+ stopWords: Object.freeze(['的', '了', '是', '和', '与', '及', '在', '我', '你', '它', '这', '那', '就', '都', '也', 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'is', 'are', 'was', 'were', 'be', 'it', 'this', 'that']),
55
+ // §6:inputSource plugin allowlist(v1 为空=不按 inputSource 归类 plugin-generated;仅 sourcePlugin 非空才判定)
56
+ pluginInputAllowlist: Object.freeze([]),
57
+ // lexical_v2:BM25 参数(Lucene 经典默认)+停用词来源
58
+ bm25: Object.freeze({ k1: 1.2, b: 0.75 }),
59
+ stopwordsSource: 'hit_stopwords.txt (github leiyusi123/stopwords), filtered',
60
+ }),
61
+ })
62
+
63
+ /** §12.5 硬预算。 */
64
+ export const SHADOW_LEXICAL_BUDGET_V1 = Object.freeze({
65
+ windowSegments: 8,
66
+ windowChars: 4096,
67
+ queryTerms: 32,
68
+ queryBytes: 2048,
69
+ termBytes: 96,
70
+ corpusRecords: 512,
71
+ corpusBytes: 64 * 1024 * 1024,
72
+ sourceFiles: 3,
73
+ recordScanKiB: 16,
74
+ rawHits: 64,
75
+ rankedKept: 8,
76
+ excerptBytes: 480,
77
+ deadlineCoreMs: 50,
78
+ deadlineIoMs: 500,
79
+ recentHits: 64,
80
+ completedKeys: 256,
81
+ ignoredDigests: 64,
82
+ })
83
+
84
+ /** §13 drop reason 枚举(版本化;禁止自由文本驱动逻辑)。 */
85
+ export const DROP_REASONS = Object.freeze([
86
+ // Gate
87
+ 'config-disabled', 'anchors-disabled', 'no-owner', 'disposed', 'child-session', 'no-segment',
88
+ 'unsupported-trigger', 'plugin-generated-trigger', 'empty-query-signal', 'duplicate-context',
89
+ 'user-ignored', 'cooldown', 'below-threshold',
90
+ // Corpus/scope
91
+ 'no-corpus', 'source-budget', 'record-budget', 'corpus-byte-budget', 'record-scan-budget',
92
+ 'source-fingerprint-changed', 'source-out-of-scope', 'cross-workspace', 'external-disabled',
93
+ 'calendar-excluded', 'sidecar-missing', 'sidecar-invalid', 'source-mismatch', 'stale-source',
94
+ 'record-stale', 'index-conflict', 'oversized', 'future-dated',
95
+ // Query/rank
96
+ 'empty-query', 'query-term-oversize', 'no-lexical-match', 'below-score', 'duplicate-memory',
97
+ 'duplicate-content', 'candidate-budget', 'excerpt-budget', 'date-unknown',
98
+ // Async/audit
99
+ 'cancelled', 'disposed-before-complete', 'stale-context', 'stale-index', 'deadline',
100
+ 'audit-write-failed', 'internal-error',
101
+ ])
102
+ export const DROP_REASON_SET = new Set(DROP_REASONS)
103
+
104
+ /** §6 RetrievalContextSnapshot 校验(深拷贝最小字段;非法字段返回具体原因)。 */
105
+ export function validateSnapshot(snap) {
106
+ const problems = []
107
+ if (!snap || typeof snap !== 'object') return { ok: false, reason: 'not-object' }
108
+ if (snap.schemaVersion !== 1) problems.push('schemaVersion')
109
+ if (typeof snap.sessionId !== 'string' || !snap.sessionId) problems.push('sessionId')
110
+ if (typeof snap.agentId !== 'string') problems.push('agentId')
111
+ if (typeof snap.workspaceKey !== 'string' || !snap.workspaceKey) problems.push('workspaceKey')
112
+ if (snap.sessionClass !== 'top-level' && snap.sessionClass !== 'child') problems.push('sessionClass')
113
+ if (!Number.isInteger(snap.contextVersion) || snap.contextVersion < 0) problems.push('contextVersion')
114
+ if (!Number.isInteger(snap.eventSeq) || snap.eventSeq < 0) problems.push('eventSeq')
115
+ const t = snap.trigger
116
+ if (!t || typeof t !== 'object') problems.push('trigger')
117
+ else {
118
+ if (typeof t.segmentId !== 'string' || !t.segmentId) problems.push('trigger.segmentId')
119
+ if (typeof t.segmentDigest !== 'string' || t.segmentDigest.length < 16) problems.push('trigger.segmentDigest')
120
+ if (!['user', 'tool_call', 'tool_result', 'assistant', 'reasoning'].includes(t.kind)) problems.push('trigger.kind')
121
+ if (typeof t.eventType !== 'string' || !t.eventType) problems.push('trigger.eventType')
122
+ if (typeof t.ts !== 'number' || !Number.isFinite(t.ts)) problems.push('trigger.ts')
123
+ if (t.nativeSeq !== undefined && !Number.isInteger(t.nativeSeq)) problems.push('trigger.nativeSeq')
124
+ }
125
+ if (!Array.isArray(snap.window)) problems.push('window')
126
+ else {
127
+ if (snap.window.length > SHADOW_LEXICAL_BUDGET_V1.windowSegments) problems.push('window-exceeds-8')
128
+ let chars = 0
129
+ for (const w of snap.window) {
130
+ if (!w || typeof w !== 'object') { problems.push('window-entry'); continue }
131
+ if (typeof w.text !== 'string') problems.push('window.text')
132
+ else chars += w.text.length
133
+ if (typeof w.digest !== 'string' || !w.digest) problems.push('window.digest')
134
+ if (!Number.isInteger(w.eventSeq)) problems.push('window.eventSeq')
135
+ if (!Number.isInteger(w.contextVersion)) problems.push('window.contextVersion')
136
+ if (typeof w.ts !== 'number') problems.push('window.ts')
137
+ }
138
+ if (chars > SHADOW_LEXICAL_BUDGET_V1.windowChars) problems.push('window-exceeds-4096-chars')
139
+ }
140
+ if (problems.length) return { ok: false, reason: 'invalid:' + problems.join(',') }
141
+ return { ok: true, snapshot: snap }
142
+ }
143
+
144
+ /** §8 memoryIndexVersion:canonical corpus tuples → idx_ + first32hex(sha256)。 */
145
+ export function memoryIndexVersion(sources) {
146
+ // source tuple = [scope, sourceRef, sourceEpoch, sourceVersion, fileDigest]
147
+ const tuples = (Array.isArray(sources) ? sources : []).map((s) => [
148
+ String(s.scope || ''), String(s.sourceRef || ''), String(s.sourceEpoch || ''),
149
+ Number(s.sourceVersion || 1), String(s.fileDigest || ''),
150
+ ])
151
+ // 排序只按 scope、sourceRef;完全相同时再按 epoch/version/digest(§8)
152
+ tuples.sort((a, b) => {
153
+ for (let i = 0; i < 5; i++) {
154
+ const x = String(a[i]); const y = String(b[i])
155
+ if (x < y) return -1
156
+ if (x > y) return 1
157
+ }
158
+ return 0
159
+ })
160
+ const canonical = tuples.map((t) => JSON.stringify(t)).join('\n')
161
+ return INDEX_PREFIX + first32(sha256Str(canonical))
162
+ }
163
+
164
+ // ========== §10 确定性 tokenizer + QueryPlan ==========
165
+
166
+ const CJK_RE = /[\u3400-\u9FFF\uF900-\uFAFF]/
167
+ /** 标识符形态:路径段/包名/版本号/错误码/snake/kebab/camel/数字。 */
168
+ const TOKEN_RE = /[A-Za-z0-9._\-/+]+/g
169
+
170
+ /** NFKC normalize + locale-independent lowercase(Latin)。 */
171
+ export function normalizeText(text) {
172
+ return String(text == null ? '' : text)
173
+ .normalize('NFKC')
174
+ .replace(/[A-Z]/g, (c) => c.toLowerCase()) // 不依赖系统 locale
175
+ }
176
+
177
+ /** 单个连续 CJK run 生成 2-gram;每 Segment 最多 64 个。 */
178
+ function cjkGrams(run, out, limit) {
179
+ const chars = [...run]
180
+ for (let i = 0; i + 1 < chars.length && out.length < limit; i++) {
181
+ out.push(chars[i] + chars[i + 1])
182
+ }
183
+ }
184
+
185
+ /** 确定性子词 tokenizer(§10.2)。 */
186
+ export function tokenize(text, opts = {}) {
187
+ const maxCjk = opts.maxCjkGrams || 64
188
+ const norm = normalizeText(text)
189
+ const out = []
190
+ const seen = new Set()
191
+ const cjkRuns = norm.split(/[^\u3400-\u9FFF\uF900-\uFAFF]+/)
192
+ let cjkCount = 0
193
+ for (const run of cjkRuns) {
194
+ if (!run) continue
195
+ cjkGrams(run, out, maxCjk - cjkCount)
196
+ cjkCount = out.length
197
+ // 保留长度合适的完整词串
198
+ if (run.length >= 2 && run.length <= 16) {
199
+ if (!seen.has(run)) { seen.add(run); out.push(run) }
200
+ }
201
+ }
202
+ // Latin/数字/标识符
203
+ for (const m of norm.matchAll(TOKEN_RE)) {
204
+ const t = m[0]
205
+ if (t.length > 1 || /^[0-9]$/.test(t)) {
206
+ if (!seen.has(t)) { seen.add(t); out.push(t) }
207
+ }
208
+ }
209
+ return out
210
+ }
211
+
212
+ /** 版本化 stop words 过滤(§10.2#5):lexical_v2 起用哈工大停用词表(STOPWORDS_HIT_V2,507 词)+原版本化小表兜底;错误码/文件名/长度>1 的标识符不可移除。 */
213
+ const STOPWORDS_HIT_SET = new Set(STOPWORDS_HIT_V2)
214
+ export function isStopWord(term) {
215
+ if (STOPWORDS_HIT_SET.has(term)) return true
216
+ const d = SHADOW_GATE_POLICY_V1.dictionaries
217
+ return d.stopWords.includes(term)
218
+ }
219
+
220
+ /** §10.1 QueryPlan:确定性 terms/phrases + queryDigest。 */
221
+ export function buildQueryPlan(snapshot, opts = {}) {
222
+ const policyVersion = opts.lexicalPolicyVersion || LEXICAL_POLICY_VERSION
223
+ const weights = { trigger: 1.0, 'recent-user': 0.8, 'reasoning': 0.5, 'tool-result': 0.6, 'tool-call': 0.4, assistant: 0.2 }
224
+ const seenTerms = new Map()
225
+ const phrases = []
226
+ const oversize = []
227
+ let termBytes = 0
228
+
229
+ // trigger 文本
230
+ const trigText = (snapshot.trigger && snapshot.trigger.segmentDigest ? '' : '') // digest 不是文本,trigger 不含 text——快照 trigger 无 text 字段
231
+ // window 提供文本来源
232
+ for (const w of snapshot.window || []) {
233
+ const origin = w.kind === 'user' ? 'recent-user' : w.kind === 'reasoning' ? 'reasoning' : w.kind === 'tool_result' ? 'tool-result' : w.kind === 'tool_call' ? 'tool-call' : 'assistant'
234
+ for (const t of tokenize(w.text, opts)) {
235
+ if (isStopWord(t)) continue
236
+ const cur = seenTerms.get(t)
237
+ const wgt = Math.max(cur ? cur.weight : 0, weights[origin] || 0)
238
+ if (Buffer.byteLength(t, 'utf8') > SHADOW_LEXICAL_BUDGET_V1.termBytes) {
239
+ oversize.push(t)
240
+ continue
241
+ }
242
+ seenTerms.set(t, { term: t, weight: wgt, origin })
243
+ }
244
+ }
245
+ // 排序稳定:按 term 字典序
246
+ const terms = [...seenTerms.values()].sort((a, b) => (a.term < b.term ? -1 : a.term > b.term ? 1 : 0))
247
+ let truncated = false
248
+ if (terms.length > SHADOW_LEXICAL_BUDGET_V1.queryTerms) {
249
+ terms.length = SHADOW_LEXICAL_BUDGET_V1.queryTerms
250
+ truncated = true
251
+ }
252
+ for (const t of terms) termBytes += Buffer.byteLength(t.term, 'utf8')
253
+ if (termBytes > SHADOW_LEXICAL_BUDGET_V1.queryBytes) {
254
+ // 从尾部丢弃直到 ≤2048
255
+ while (termBytes > SHADOW_LEXICAL_BUDGET_V1.queryBytes && terms.length) {
256
+ termBytes -= Buffer.byteLength(terms.pop().term, 'utf8')
257
+ truncated = true
258
+ }
259
+ }
260
+ // phrases:触发词 + 显式回忆短语完整词
261
+ const triggerPhrase = (snapshot.trigger && snapshot.trigger.segmentDigest) ? '' : ''
262
+ // 简单 phrase 提取:window 最近 user 文本的 2-4 词短语(规范化)
263
+ const recentUser = [...(snapshot.window || [])].reverse().find((w) => w.kind === 'user')
264
+ if (recentUser) {
265
+ const words = tokenize(recentUser.text, opts).filter((t) => !isStopWord(t)).slice(0, 4)
266
+ if (words.length >= 2) phrases.push(words.join(' '))
267
+ }
268
+ // phrase 预算 8
269
+ while (phrases.length > 8) phrases.pop()
270
+
271
+ const canonical = JSON.stringify({ policyVersion, terms, phrases })
272
+ const queryDigest = first32(sha256Str(canonical))
273
+ return {
274
+ schemaVersion: 1, policyVersion, contextVersion: snapshot.contextVersion,
275
+ queryDigest, terms, phrases, truncated, oversizeCount: oversize.length,
276
+ }
277
+ }
278
+
279
+ // ========== §9 Gate ==========
280
+
281
+ const D = () => SHADOW_GATE_POLICY_V1.dictionaries
282
+
283
+ function hasAny(text, list) {
284
+ const norm = normalizeText(text)
285
+ return list.some((p) => norm.includes(normalizeText(p)))
286
+ }
287
+
288
+ /** §9.3 信号计算(纯函数;输入仅 snapshot.window/trigger/QueryPlan/recentHits)。 */
289
+ export function computeSignals(snapshot, queryPlan, recentHits = []) {
290
+ const trig = snapshot.trigger || {}
291
+ const win = snapshot.window || []
292
+ const signals = {
293
+ explicitRecall: 0, novelty: 0, unresolved: 0, phaseShift: 0, toolFailure: 0,
294
+ conflict: 0, historical: 0, repeated: 0, unresolvedAge: 0, goalDrift: 0, monitor: 0,
295
+ }
296
+ // novelty:trigger digest 是否在前序 window
297
+ signals.novelty = win.some((w) => w.digest === trig.segmentDigest) ? 0 : 1
298
+ // explicitRecall:user trigger 命中明确回忆短语
299
+ if (trig.kind === 'user') {
300
+ // 需要 trigger 文本——快照 trigger 无 text;用最近 user window 文本近似?契約 9.3 说 trigger.kind='user' 且命中短语
301
+ // snapshot.trigger 没有 text 字段,这里由调用方在 snapshot.trigger.text 传入(扩展字段,非契约禁止)
302
+ const trigText = trig.text || ''
303
+ signals.explicitRecall = hasAny(trigText, D().explicitRecall) ? 1 : 0
304
+ }
305
+ // repeated:window 内同 toolName 的 tool_call 计数
306
+ const toolCounts = new Map()
307
+ for (const w of win) {
308
+ if (w.kind === 'tool_call' && w.toolName) {
309
+ toolCounts.set(w.toolName, (toolCounts.get(w.toolName) || 0) + 1)
310
+ }
311
+ }
312
+ const maxSame = Math.max(0, ...toolCounts.values())
313
+ signals.repeated = Math.min(1, Math.max(0, maxSame - 1) / 2)
314
+ // phaseShift:user + 转折词典 + 之前窗口有 tool_call/tool_result
315
+ if (trig.kind === 'user' && hasAny(trig.text || '', D().phaseShift)) {
316
+ const hasTool = win.some((w) => w.kind === 'tool_call' || w.kind === 'tool_result')
317
+ signals.phaseShift = hasTool ? 1 : 0
318
+ }
319
+ // unresolved:最近 user 命中未决词典且其后无针对性 tool_result
320
+ const users = win.filter((w) => w.kind === 'user')
321
+ const lastUser = users[users.length - 1]
322
+ if (lastUser && hasAny(lastUser.text, D().unresolved)) {
323
+ const after = win.indexOf(lastUser)
324
+ const hasResult = win.slice(after + 1).some((w) => w.kind === 'tool_result')
325
+ signals.unresolved = hasResult ? 0 : 1
326
+ }
327
+ // unresolvedAge:trigger.ts - oldestUnresolvedUserTs
328
+ const firstUnresolved = users.find((w) => hasAny(w.text, D().unresolved))
329
+ if (firstUnresolved && trig.ts) {
330
+ signals.unresolvedAge = Math.min(1, Math.max(0, (trig.ts - firstUnresolved.ts) / 300000))
331
+ }
332
+ // toolFailure:trigger.toolOk=false 或最近 tool_result error 标量
333
+ if (trig.toolOk === false || trig.errorName || trig.errorCode) signals.toolFailure = 1
334
+ else {
335
+ const lastResult = [...win].reverse().find((w) => w.kind === 'tool_result')
336
+ if (lastResult && (lastResult.errorName || lastResult.errorCode)) signals.toolFailure = 1
337
+ }
338
+ // historical:recentHits 中同 queryDigest 且 fresh
339
+ if (queryPlan && queryPlan.queryDigest) {
340
+ signals.historical = (recentHits || []).some((h) => h.queryDigest === queryPlan.queryDigest && h.fresh !== false) ? 1 : 0
341
+ }
342
+ // conflict M4 v1 固定 0(§9.3)
343
+ signals.conflict = 0
344
+ return signals
345
+ }
346
+
347
+ /** §9.4 gate_v1:硬抑制 → 权重滞回 → 决策。同步纯函数,零 IO。 */
348
+ export function gatePreV1(snapshot, opts = {}) {
349
+ const policy = SHADOW_GATE_POLICY_V1
350
+ const latchedPrev = opts.previousLatch === true
351
+ const cooldownRemaining = Math.max(0, Number(opts.cooldownRemaining) || 0)
352
+ const hard = opts.hardSuppress || {}
353
+ const signals = opts.signals || computeSignals(snapshot, opts.queryPlan, opts.recentHits)
354
+
355
+ const base = {
356
+ schemaVersion: 1, policyVersion: GATE_POLICY_VERSION, signals,
357
+ contextVersion: snapshot.contextVersion,
358
+ }
359
+ // 硬抑制顺序(§9.2)
360
+ const HARD_ORDER = ['no-owner', 'disposed', 'child-session', 'no-segment', 'unsupported-trigger',
361
+ 'plugin-generated-trigger', 'empty-query-signal', 'duplicate-context', 'user-ignored']
362
+ for (const r of HARD_ORDER) {
363
+ if (hard[r]) return { ...base, action: 'suppress', state: 'normal', reason: r, rawScore: 0, hesitation: 0, latched: false, cooldownRemaining, explicitRecallBypass: false }
364
+ }
365
+ // cooldown
366
+ if (cooldownRemaining > 0 && signals.explicitRecall !== 1) {
367
+ return { ...base, action: 'suppress', state: 'cooldown', reason: 'cooldown', rawScore: 0, hesitation: 0, latched: latchedPrev, cooldownRemaining, explicitRecallBypass: false }
368
+ }
369
+ const w = policy.weights
370
+ const weightedRaw = clamp01(
371
+ w.explicitRecall * clamp01(signals.explicitRecall) +
372
+ w.toolFailure * clamp01(signals.toolFailure) +
373
+ w.unresolved * clamp01(signals.unresolved) +
374
+ w.repeated * clamp01(signals.repeated) +
375
+ w.novelty * clamp01(signals.novelty) +
376
+ w.phaseShift * clamp01(signals.phaseShift) +
377
+ w.historical * clamp01(signals.historical) +
378
+ w.conflict * clamp01(signals.conflict) +
379
+ w.unresolvedAge * clamp01(signals.unresolvedAge),
380
+ )
381
+ const rawScore = signals.explicitRecall === 1 ? Math.max(weightedRaw, policy.explicitRecallFloor) : weightedRaw
382
+ const latchedNext = latchedPrev ? rawScore >= policy.hysteresisOff : rawScore >= policy.hysteresisOn
383
+ const hesitation = clamp01(latchedNext ? Math.max(rawScore, policy.prefetchThreshold) : rawScore)
384
+ let action = 'suppress'
385
+ let state = 'normal'
386
+ let reason = 'below-threshold'
387
+ if (hesitation >= policy.retrieveThreshold) { action = 'retrieve'; state = 'armed'; reason = 'armed' }
388
+ else if (hesitation >= policy.prefetchThreshold) { action = 'prefetch'; state = 'prefetch'; reason = 'warm' }
389
+ else { action = 'suppress'; state = 'normal'; reason = 'below-threshold' }
390
+ const nextCooldown = action === 'retrieve' ? policy.cooldownSegments : 0
391
+ return {
392
+ ...base, action, state, reason, rawScore, hesitation, latched: latchedNext,
393
+ cooldownRemaining: nextCooldown, explicitRecallBypass: signals.explicitRecall === 1,
394
+ }
395
+ }
396
+
397
+
398
+
399
+ // ========== §11 Candidate 身份 + §12 lexical_v1 ==========
400
+
401
+ const MEMORY_ID_STRICT = /^mem_[0-9a-f]{32}$/
402
+
403
+ /** §11 retrievalId(确定性,可重放;非长期内容身份)。 */
404
+ export function buildRetrievalId(sessionId, contextVersion, triggerSegmentId, memoryIndexVersion) {
405
+ const sessionIdHash = first32(sha256Str('retrieval-pre-v1\u0000' + sessionId))
406
+ const parts = ['retrieval-pre-v1', sessionIdHash, contextVersion, triggerSegmentId, memoryIndexVersion, GATE_POLICY_VERSION, LEXICAL_POLICY_VERSION]
407
+ return RETRIEVAL_PREFIX + first32(sha256Str(JSON.stringify(parts)))
408
+ }
409
+
410
+ /** §11 candidateId:同 memoryId 内容变化后 candidateId 因版本/digest 改变。 */
411
+ export function buildCandidateId(retrievalId, memoryId, sourceEpoch, sourceVersion, recordDigest) {
412
+ const parts = ['candidate-pre-v1', retrievalId, memoryId, sourceEpoch, sourceVersion, recordDigest]
413
+ return CANDIDATE_PREFIX + first32(sha256Str(JSON.stringify(parts)))
414
+ }
415
+
416
+ /** §12.2 插件日界换算(dayBoundaryMinutes=450 → 00:00-07:30 归插件前一日)。 */
417
+ export function canonicalPlugDate(ts, dayBoundaryMinutes) {
418
+ const dbm = Number.isInteger(dayBoundaryMinutes) && dayBoundaryMinutes > 0 ? dayBoundaryMinutes : 450
419
+ const shifted = new Date(Number(ts) - dbm * 60000)
420
+ return shifted.getFullYear() + '-' + String(shifted.getMonth() + 1).padStart(2, '0') + '-' + String(shifted.getDate()).padStart(2, '0')
421
+ }
422
+
423
+ /** workspace-log 文件名日期(sourceRef 形如 workspace-log:2026-08-22.md);解析失败 null。 */
424
+ export function logDateFromSourceRef(sourceRef) {
425
+ const m = /(\d{4})-(\d{2})-(\d{2})\.md$/.exec(String(sourceRef || ''))
426
+ if (!m) return null
427
+ return { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) }
428
+ }
429
+
430
+ /** §10.2 phrase 边界匹配(NFKC 后子串;Latin 两端非字母数字,CJK 不被同 run 扩展)。 */
431
+ function phraseHit(normText, normPhrase) {
432
+ if (!normPhrase) return false
433
+ let idx = normText.indexOf(normPhrase)
434
+ while (idx !== -1) {
435
+ const before = idx > 0 ? normText[idx - 1] : ''
436
+ const after = idx + normPhrase.length < normText.length ? normText[idx + normPhrase.length] : ''
437
+ const isAlnum = (c) => /[A-Za-z0-9]/.test(c)
438
+ const isCjk = (c) => /[\u3400-\u9FFF\uF900-\uFAFF]/.test(c)
439
+ const firstCjk = isCjk(normPhrase[0])
440
+ const lastCjk = isCjk(normPhrase[normPhrase.length - 1])
441
+ const okBefore = !before || !(firstCjk ? (isCjk(before) || isAlnum(before)) : isAlnum(before))
442
+ const okAfter = !after || !(lastCjk ? (isCjk(after) || isAlnum(after)) : isAlnum(after))
443
+ if (okBefore && okAfter) return true
444
+ idx = normText.indexOf(normPhrase, idx + 1)
445
+ }
446
+ return false
447
+ }
448
+
449
+ /** excerpt 清洗+UTF-8 截断 480 bytes(retrieve volatile 专用)。 */
450
+ export function sanitizeExcerpt(text) {
451
+ const cleaned = String(text == null ? '' : text).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
452
+ const buf = Buffer.from(cleaned, 'utf8')
453
+ if (buf.length <= SHADOW_LEXICAL_BUDGET_V1.excerptBytes) return cleaned
454
+ return buf.subarray(0, SHADOW_LEXICAL_BUDGET_V1.excerptBytes).toString('utf8').replace(/[\uFFFD]+$/, '')
455
+ }
456
+
457
+ /**
458
+ * lexical_v1 检索核心(§10-§13):纯函数,零 IO。
459
+ * @param {{memoryIndexVersion?:string,sources:Array,records:Array}} corpus 纯内存 fixture
460
+ * @param {object} queryPlan buildQueryPlan 输出
461
+ * @param {{triggerTs?:number,mode?:'retrieve'|'prefetch',dayBoundaryMinutes?:number}} opts
462
+ */
463
+ export function lexicalSearch(corpus, queryPlan, opts = {}) {
464
+ const B = SHADOW_LEXICAL_BUDGET_V1
465
+ const dropped = []
466
+ const drop = (stage, reason, extra) => dropped.push(Object.assign({ stage, reason }, extra || {}))
467
+ const records = Array.isArray(corpus && corpus.records) ? corpus.records : []
468
+ const sources = Array.isArray(corpus && corpus.sources) ? corpus.sources : []
469
+ if (records.length > B.corpusRecords) return { rawHits: [], kept: [], dropped: [{ stage: 'corpus', reason: 'record-budget' }], counts: { sources: sources.length, records: records.length, legacyConflicts: 0, rawHits: 0, kept: 0, dropped: 1 } }
470
+ let corpusBytes = 0
471
+ for (const r of records) corpusBytes += Number(r.bytes || 0)
472
+ if (corpusBytes > B.corpusBytes) return { rawHits: [], kept: [], dropped: [{ stage: 'corpus', reason: 'corpus-byte-budget' }], counts: { sources: sources.length, records: records.length, legacyConflicts: 0, rawHits: 0, kept: 0, dropped: 1 } }
473
+ if (sources.length > B.sourceFiles) drop('corpus', 'source-budget', { detail: sources.length })
474
+
475
+ const trigTs = Number(opts.triggerTs) || Date.now()
476
+ const dbm = opts.dayBoundaryMinutes
477
+ const mode = opts.mode === 'prefetch' ? 'prefetch' : 'retrieve'
478
+ const termTotalWeight = queryPlan.terms.reduce((a, t) => a + t.weight, 0) || 1
479
+ const normPhrases = (queryPlan.phrases || []).map((p) => normalizeText(p))
480
+
481
+ // lexical_v2:BM25 语料统计(df/avgdl;Okapi 经典参数 k1=1.2 b=0.75)
482
+ const bm25 = SHADOW_GATE_POLICY_V1.dictionaries.bm25 || { k1: 1.2, b: 0.75 }
483
+ let totalTokens = 0
484
+ const docTokensList = []
485
+ const DF = new Map() // term → 出现该词的记录数(document frequency)
486
+ for (const rec of records) {
487
+ if (!rec.memoryId || !MEMORY_ID_STRICT.test(rec.memoryId)) continue
488
+ const toks = tokenize((rec.heading ? rec.heading + ' ' : '') + String(rec.text || ''))
489
+ totalTokens += toks.length
490
+ docTokensList.push(toks)
491
+ for (const tk of new Set(toks)) DF.set(tk, (DF.get(tk) || 0) + 1)
492
+ }
493
+ const N = Math.max(1, docTokensList.length)
494
+ const avgdl = totalTokens / N || 1
495
+
496
+ const rawHits = []
497
+ let legacyConflicts = 0
498
+ for (const rec of records) {
499
+ if (!rec.memoryId || !MEMORY_ID_STRICT.test(rec.memoryId)) { legacyConflicts++; continue }
500
+ let text = String(rec.text || '')
501
+ if (Buffer.byteLength(text, 'utf8') > B.recordScanKiB * 1024) {
502
+ text = Buffer.from(text, 'utf8').subarray(0, B.recordScanKiB * 1024).toString('utf8')
503
+ drop('rank', 'record-scan-budget', { memoryId: rec.memoryId })
504
+ }
505
+ const normHeading = normalizeText(rec.heading || '')
506
+ const normBody = normalizeText(text)
507
+ // lexical_v2:BM25 覆盖率——idf 加权(稀有词贡献大)+ tf 饱和(k1)+ 文档长度归一(b)
508
+ const docTokens = tokenize(normHeading + ' ' + normBody)
509
+ const tfMap = new Map()
510
+ for (const tk of docTokens) tfMap.set(tk, (tfMap.get(tk) || 0) + 1)
511
+ const dl = Math.max(1, docTokens.length)
512
+ let hitBm25 = 0
513
+ let headingBm25 = 0
514
+ let totalIdf = 0
515
+ const matchedDigests = []
516
+ for (const t of queryPlan.terms) {
517
+ const df = DF.get(t.term) || 0
518
+ if (!df && !normBody.includes(t.term) && !normHeading.includes(t.term)) continue
519
+ const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5))
520
+ const inHeading = normHeading.includes(t.term)
521
+ const inBody = tfMap.has(t.term) || normBody.includes(t.term)
522
+ if (!inHeading && !inBody) continue
523
+ const tfv = Math.max(tfMap.get(t.term) || 0, normHeading.includes(t.term) ? 1 : 0, normBody.includes(t.term) ? 1 : 0)
524
+ const sat = (tfv * (bm25.k1 + 1)) / (tfv + bm25.k1 * (1 - bm25.b + bm25.b * dl / avgdl)) / (bm25.k1 + 1)
525
+ hitBm25 += idf * sat * t.weight
526
+ if (inHeading) headingBm25 += idf * sat
527
+ totalIdf += idf * t.weight
528
+ matchedDigests.push(first32(sha256Str(t.term)))
529
+ }
530
+ let phraseMatch = 0
531
+ for (const np of normPhrases) { if (phraseHit(normBody, np) || phraseHit(normHeading, np)) { phraseMatch = 1; break } }
532
+ // BM25 覆盖率归一:idf 加权命中 / idf 加权查询总量 → [0,1]
533
+ const termCoverage = totalIdf > 0 ? clamp01(hitBm25 / totalIdf) : 0
534
+ const headingCoverage = totalIdf > 0 ? clamp01(headingBm25 / totalIdf) : 0
535
+ if (!matchedDigests.length && !phraseMatch) { drop('rank', 'no-lexical-match', { memoryId: rec.memoryId, sourceRef: rec.sourceRef }); continue }
536
+ const srcRef = String(rec.sourceRef || '')
537
+ let recency = 0.5
538
+ if (/workspace-log/.test(srcRef)) {
539
+ const ld = logDateFromSourceRef(srcRef)
540
+ if (!ld) { drop('rank', 'date-unknown', { memoryId: rec.memoryId }); continue }
541
+ const plugDate = canonicalPlugDate(trigTs, dbm)
542
+ const fileNum = new Date(ld.y, ld.m - 1, ld.d).getTime()
543
+ const plugNum = new Date(plugDate.replace(/-/g, '/')).getTime()
544
+ if (fileNum - plugNum > 24 * 3600000) { drop('rank', 'future-dated', { memoryId: rec.memoryId, sourceRef: srcRef }); continue }
545
+ const ageDays = Math.max(0, (trigTs - fileNum) / 86400000)
546
+ recency = Math.pow(2, -ageDays / 30)
547
+ }
548
+ if (termCoverage <= 0 && phraseMatch !== 1) continue
549
+ const total = clamp01(0.72 * termCoverage + 0.15 * headingCoverage + 0.08 * phraseMatch + 0.05 * recency)
550
+ if (total < 0.10) { drop('rank', 'below-score', { memoryId: rec.memoryId, detail: total }); continue }
551
+ rawHits.push({
552
+ memoryId: rec.memoryId, anchorId: rec.anchorId || ('memory:' + rec.memoryId),
553
+ scope: rec.scope, sourceClass: rec.sourceClass, sourceRef: srcRef,
554
+ sourceEpoch: rec.sourceEpoch, sourceVersion: rec.sourceVersion,
555
+ fileDigest: rec.fileDigest, recordDigest: rec.recordDigest,
556
+ lineStart: rec.lineStart, lineEnd: rec.lineEnd, byteStart: rec.byteStart, byteEnd: rec.byteEnd,
557
+ heading: rec.heading != null ? rec.heading : null,
558
+ scores: { termCoverage, headingCoverage, phraseMatch, recency, total },
559
+ matchedTermDigests: matchedDigests, estimatedBytes: Number(rec.bytes || 0),
560
+ text,
561
+ })
562
+ }
563
+ const hits = rawHits.slice(0, B.rawHits)
564
+ for (let i = B.rawHits; i < rawHits.length; i++) drop('rank', 'candidate-budget', { memoryId: rawHits[i].memoryId })
565
+
566
+ const byMemory = new Map()
567
+ for (const h of hits) {
568
+ if (!byMemory.has(h.memoryId)) byMemory.set(h.memoryId, [])
569
+ byMemory.get(h.memoryId).push(h)
570
+ }
571
+ const deduped = []
572
+ for (const [mid, group] of byMemory) {
573
+ const digests = new Set(group.map((h) => h.recordDigest))
574
+ if (digests.size > 1) {
575
+ drop('dedupe', 'index-conflict', { memoryId: mid, detail: digests.size })
576
+ continue
577
+ }
578
+ deduped.push(group[0])
579
+ for (const dup of group.slice(1)) drop('dedupe', 'duplicate-content', { memoryId: mid, sourceRef: dup.sourceRef })
580
+ }
581
+ deduped.sort((x, y) => {
582
+ if (y.scores.total !== x.scores.total) return y.scores.total - x.scores.total
583
+ if (y.scores.termCoverage !== x.scores.termCoverage) return y.scores.termCoverage - x.scores.termCoverage
584
+ if (y.scores.headingCoverage !== x.scores.headingCoverage) return y.scores.headingCoverage - x.scores.headingCoverage
585
+ const xs = x.scope === 'Workspace' ? 0 : 1
586
+ const ys = y.scope === 'Workspace' ? 0 : 1
587
+ if (xs !== ys) return xs - ys
588
+ return x.memoryId < y.memoryId ? -1 : x.memoryId > y.memoryId ? 1 : 0
589
+ })
590
+ // §12.4 跨 memoryId 同 recordDigest 去重:排序后保留更高者,其余 duplicate-content(保留 provenance)
591
+ const finalList = []
592
+ const seenRecordDigest = new Set()
593
+ for (const h of deduped) {
594
+ if (seenRecordDigest.has(h.recordDigest)) {
595
+ drop('dedupe', 'duplicate-content', { memoryId: h.memoryId, sourceRef: h.sourceRef })
596
+ continue
597
+ }
598
+ seenRecordDigest.add(h.recordDigest)
599
+ finalList.push(h)
600
+ }
601
+ const keptHits = finalList.slice(0, B.rankedKept)
602
+ for (const d of finalList.slice(B.rankedKept)) drop('rank', 'candidate-budget', { memoryId: d.memoryId })
603
+ return {
604
+ rawHits: hits, kept: keptHits, dropped,
605
+ counts: { sources: sources.length, records: records.length, legacyConflicts, rawHits: hits.length, kept: keptHits.length, dropped: dropped.length },
606
+ }
607
+ }
608
+
609
+ /** ShadowCandidate 构造(§11 schema+确定性 candidateId;excerpt 仅 retrieve 且 sanitize)。 */
610
+ export function buildCandidates(retrievalId, kept, mode) {
611
+ const out = []
612
+ for (const k of kept) {
613
+ const c = {
614
+ schemaVersion: 1,
615
+ candidateId: buildCandidateId(retrievalId, k.memoryId, k.sourceEpoch, k.sourceVersion, k.recordDigest),
616
+ retrievalId,
617
+ memoryId: k.memoryId, anchorId: k.anchorId,
618
+ scope: k.scope, sourceClass: k.sourceClass, sourceRef: k.sourceRef,
619
+ sourceEpoch: k.sourceEpoch, sourceVersion: k.sourceVersion,
620
+ fileDigest: k.fileDigest, recordDigest: k.recordDigest,
621
+ lineStart: k.lineStart, lineEnd: k.lineEnd, byteStart: k.byteStart, byteEnd: k.byteEnd,
622
+ heading: k.heading != null ? k.heading : null,
623
+ scores: k.scores, matchedTermDigests: k.matchedTermDigests,
624
+ reasonCodes: ['shadow-only'], estimatedBytes: k.estimatedBytes,
625
+ }
626
+ if (mode === 'retrieve') c.excerpt = sanitizeExcerpt(k.text || '')
627
+ out.push(c)
628
+ }
629
+ return out
630
+ }
631
+
632
+ // ========== §16 replay pure core ==========
633
+
634
+ export function replay({ contextSnapshots, corpusSnapshot, gatePolicy, lexicalPolicy, labels }) {
635
+ const results = []
636
+ let latch = false
637
+ let cooldown = 0
638
+ for (let i = 0; i < contextSnapshots.length; i++) {
639
+ const snap = contextSnapshots[i]
640
+ const v = validateSnapshot(snap)
641
+ if (!v.ok) { results.push({ canonical: { gate: { action: 'suppress', state: 'normal', reason: v.reason } }, error: true }); continue }
642
+ const qp = buildQueryPlan(snap, { lexicalPolicyVersion: lexicalPolicy || LEXICAL_POLICY_VERSION })
643
+ const signals = computeSignals(snap, qp)
644
+ const dec = gatePreV1(snap, {
645
+ previousLatch: latch, cooldownRemaining: cooldown, signals, queryPlan: qp,
646
+ hardSuppress: snap.sessionClass === 'child' ? { 'child-session': true } : {},
647
+ })
648
+ const miv = (corpusSnapshot && corpusSnapshot.memoryIndexVersion) || INDEX_PREFIX + 'unknown'
649
+ const retrievalId = buildRetrievalId(snap.sessionId, snap.contextVersion, snap.trigger.segmentId, miv)
650
+ let candidates = []
651
+ let dropped = []
652
+ let counts = { sources: 0, records: 0, legacyConflicts: 0, rawHits: 0, kept: 0, dropped: 0 }
653
+ if (dec.action === 'retrieve' || dec.action === 'prefetch') {
654
+ const ls = lexicalSearch(corpusSnapshot, qp, { triggerTs: snap.trigger.ts, mode: dec.action === 'retrieve' ? 'retrieve' : 'prefetch' })
655
+ candidates = buildCandidates(retrievalId, ls.kept, dec.action)
656
+ dropped = ls.dropped
657
+ counts = ls.counts
658
+ }
659
+ results.push({
660
+ canonical: {
661
+ gate: { action: dec.action, state: dec.state, reason: dec.reason, signals: dec.signals, rawScore: dec.rawScore, hesitation: dec.hesitation, latched: dec.latched },
662
+ queryDigest: qp.queryDigest,
663
+ retrievalId,
664
+ candidates: candidates.map((c) => ({ memoryId: c.memoryId, sourceVersion: c.sourceVersion, fileDigest: c.fileDigest, recordDigest: c.recordDigest, score: c.scores.total, candidateId: c.candidateId })),
665
+ dropped, counts,
666
+ label: labels && labels[i] != null ? labels[i] : undefined,
667
+ },
668
+ })
669
+ latch = dec.latched
670
+ cooldown = dec.action === 'retrieve' ? SHADOW_GATE_POLICY_V1.cooldownSegments : Math.max(0, cooldown - 1)
671
+ }
672
+ return results
673
+ }