@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.
- 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 +1379 -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 +2357 -102
- 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,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
|
+
}
|