dsh-memu-memory 1.2.1 → 1.2.2

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.
Files changed (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
package/lib/lexicon.js ADDED
@@ -0,0 +1,1115 @@
1
+ // ── 词典层(Lexicon)核心:P0 存储安全 + 生命周期 active/archived + P1 读取检索 ──
2
+ // 契约:docs/memory-semantics.md §8.13(当前生命周期与注入语义)。
3
+ // 词典不替代记忆、不触发下载、不接 settings:冷却与预算全部用本模块常量 + 模块内
4
+ // 状态(见 lexiconCooldownStore)。
5
+ //
6
+ // 存储安全不变量(§1-§2,每一条都有对应测试):
7
+ // * 路径只能是 dataDir()/lexicon/<libraryId>.json,libraryId 过 isSafeId(拒绝
8
+ // 绝对路径、..、路径分隔符——isSafeId 的白名单字符集天然挡掉它们)。
9
+ // * 读:缺失 → 空结构;损坏/IO 失败/曾 quarantined → typed error,绝不当空处理
10
+ // (paths.readJsonStrict 的口径:吞掉的读失败会把下一次写变成整库覆盖)。
11
+ // * 写:一切写函数在 withFileLock 内重读磁盘上的最新 revision 再改(CAS),
12
+ // revision 单调 +1 后 writeJson 原子替换(tmp + fsync + rename)。锁是同步作用
13
+ // 域,从不跨 await。调用方手里的任何旧副本都写不回磁盘——写路径不接受外部对象。
14
+ // * 记录格式 §1.2:normalizedTerm/normalizedAliases、evidence 短证据、hitCounts
15
+ // 三计数(candidate/injection/query)。
16
+ // * 生命周期(§2.1):公开两态 active/archived;`pending` 只作为旧版的 legacy
17
+ // 数据被读(迁移见 migrateLegacyLexiconStatuses),`rejected` 只作为内部墓碑 +
18
+ // fingerprint 存在(永不注入、永不列出、永不计数)。新写路径不再产出这两种状态。
19
+ // * 读路径(§10 P1):每本词典一份 revision-keyed 内存快照 + 派生索引,见
20
+ // lexiconIndexCache 的注释——JSON 是唯一主事实,索引永远是可重建的派生物。
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+ import { dataDir, readJsonStrict, readFailure, writeJson, withFileLock, isSafeId, shortHash, warn, stamp } from './paths.js'
24
+ import { normalizeMemoryContext, contextVisible, sameFactContext } from './context.js'
25
+ import { bigrams, dice } from './text-similarity.js'
26
+ import { estTokens } from './budget.js'
27
+
28
+ // 容量与长度上限(§1.2)。刻意不接 settings:容量是存储安全边界,不该被运行时配置放宽。
29
+ export const LEXICON_CAP = 5000
30
+ const TERM_MAX_CHARS = 80
31
+ const MEANING_MAX_CHARS = 500
32
+ const USAGE_MAX_CHARS = 300
33
+ const ALIAS_MAX_COUNT = 8
34
+ const ALIAS_MAX_CHARS = 80
35
+ const EXAMPLE_MAX_COUNT = 5
36
+ const EXAMPLE_MAX_CHARS = 200
37
+ const EVIDENCE_MAX_COUNT = 10
38
+ const EVIDENCE_QUOTE_MAX_CHARS = 200
39
+
40
+ // 匹配流水线常量(§3.1-§3.2)。threshold 0.60 是起步标定值,P4 用标注样本复核。
41
+ export const LEXICON_DICE_THRESHOLD = 0.6
42
+ export const LEXICON_MAX_BUCKETS = 16
43
+ export const LEXICON_MAX_CANDIDATES = 64
44
+ export const LEXICON_MAX_TERMS = 8
45
+ // 词面包含(includes)命中的最小规范化长度:单字词在 CJK 里到处出现,放进精确层
46
+ // 只会制造噪声。等值匹配(整句恰好等于词条)不受此限制。
47
+ export const LEXICON_MIN_INCLUDE_LEN = 2
48
+ // 前缀桶宽度:词条侧取 normalizedTerm 的前 2 字符做桶键,文本侧穷举 2 字符窗口。
49
+ const BUCKET_WIDTH = 2
50
+
51
+ // 冷却与回合预算(§3.3)。冷却按 binding/session 记,选择"模块内 Map"而不是
52
+ // binding 字段:binding 持久化在 state.json,注入路径每回合都写会把状态文件变成
53
+ // 热路径,而冷却丢一批(进程重启)最坏只是同一个词多注入一次,代价不对称。
54
+ // key = binding 的 sessionId(一个 session 只有一个 binding → 一本私有库)。
55
+ export const LEXICON_RECALL_COOLDOWN_MINUTES = 30
56
+ export const LEXICON_MAX_PER_TURN = 2
57
+ export const lexiconCooldownStore = new Map() // key: sessionKey → Map(termId → lastInjectedAt ms)
58
+
59
+ // 频率先验(施工稿 §6.3 包 C):三计数的写入上限与 Wilson 下界的 z 值。上限挡住
60
+ // 病态数据把 effective 因子顶出 [0.85,1.00] 的界;z=1.96 即 95% 单侧置信。
61
+ export const HIT_COUNT_CAP = 1_000_000
62
+ export const WILSON_Z = 1.96
63
+
64
+ const KINDS = ['slang', 'meme', 'trigger', 'term']
65
+ // 公开生命周期只有两态(§2.1)。pending/rejected 仍是磁盘上可能存在的 legacy 状态,
66
+ // 但新调用方不能把词条写成它们(见 upsertLexiconTerm 的 status 收口)。
67
+ const PUBLIC_STATUSES = ['active', 'archived']
68
+ const SOURCES = ['user', 'curator', 'model-proposal']
69
+
70
+ function lexiconError(code, message) {
71
+ const error = new Error(message)
72
+ error.code = code
73
+ return error
74
+ }
75
+
76
+ function clip(text, max) {
77
+ const s = String(text ?? '')
78
+ return s.length > max ? s.slice(0, max) : s
79
+ }
80
+
81
+ /**
82
+ * 词条词面的统一规范化:NFKC → 大小写折叠 → 去空白/标点/符号。
83
+ * 与 lib/text-similarity.js clean()(memory.js 矛盾检测的既有预处理)同风格,
84
+ * 只多一步 NFKC:全角/半角和兼容字符必须折叠成同一个词面,否则唯一性约束会被
85
+ * 「糖刀」和「糖刀」(全角感叹号)绕开。
86
+ */
87
+ export function normalizeLexeme(text) {
88
+ return String(text ?? '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
89
+ }
90
+
91
+ function lexiconDir() {
92
+ return path.join(dataDir(), 'lexicon')
93
+ }
94
+
95
+ function lexiconFile(libraryId) {
96
+ // isSafeId 只放行 [A-Za-z0-9._-]{1,128} 且拒绝 . / ..:绝对路径、反斜杠、
97
+ // ../ 和任意文件名在这里就死掉,不会拼进路径。
98
+ if (!isSafeId(libraryId)) throw lexiconError('LEXICON_INVALID_LIBRARY_ID', `invalid lexicon library id: ${clip(libraryId, 40)}`)
99
+ return path.join(lexiconDir(), `${libraryId}.json`)
100
+ }
101
+
102
+ function emptyLexicon(libraryId) {
103
+ return { version: 1, revision: 0, libraryId, terms: {}, rejected: {} }
104
+ }
105
+
106
+ function readLexiconStrict(file, libraryId) {
107
+ // 曾被 quarantine(读失败被挪走)的库不能当空库处理:读回来是"空"的下一个写
108
+ // 就会把整本词典覆盖掉。这里宁可 typed error 也绝不静默清零。
109
+ const failure = readFailure(file)
110
+ if (failure) {
111
+ throw lexiconError('LEXICON_READ_FAILED', `lexicon ${libraryId} is write-protected by an earlier read failure (${failure.code || failure.message})`)
112
+ }
113
+ const result = readJsonStrict(file)
114
+ if (result.missing) return emptyLexicon(libraryId)
115
+ if (!result.ok) {
116
+ const error = lexiconError('LEXICON_CORRUPT', `lexicon ${libraryId} could not be read (${result.kind === 'corrupt' ? 'invalid JSON' : result.error?.code || 'read failed'}); nothing is treated as empty and writes stay blocked until it is restored`)
117
+ if (result.error) error.cause = result.error
118
+ throw error
119
+ }
120
+ const value = result.value
121
+ // 形状校验:合法 JSON 但不是词典(数组、version 不对、terms 不是对象表、行不是
122
+ // 对象)同样是"损坏"——照单全收会让下一次写把陌生形状固化下来。
123
+ if (!value || typeof value !== 'object' || Array.isArray(value)
124
+ || (value.version !== undefined && value.version !== 1)
125
+ || !value.terms || typeof value.terms !== 'object' || Array.isArray(value.terms)) {
126
+ throw lexiconError('LEXICON_CORRUPT', `lexicon ${libraryId} has an invalid data shape; refusing to treat it as empty`)
127
+ }
128
+ for (const [termId, entry] of Object.entries(value.terms)) {
129
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
130
+ throw lexiconError('LEXICON_CORRUPT', `lexicon ${libraryId} entry ${termId} is not an object`)
131
+ }
132
+ }
133
+ return {
134
+ version: 1,
135
+ revision: Number.isSafeInteger(value.revision) && value.revision >= 0 ? value.revision : 0,
136
+ libraryId,
137
+ terms: value.terms,
138
+ rejected: value.rejected && typeof value.rejected === 'object' && !Array.isArray(value.rejected) ? value.rejected : {},
139
+ }
140
+ }
141
+
142
+ // ── revision-keyed 内存索引(§10 P1 / §8.2 的 O(N) 优化项)───────────────────
143
+ // 每本词典在进程内保留一份「读取快照 + 派生索引」,key = `${libraryId}@${revision}`:
144
+ // data 严格读取出的词典对象(JSON 永远是主事实,快照只是派生物)
145
+ // activeEntries / archivedEntries / pendingEntries 构建时分好 status 并按展示序
146
+ // 排好的词条;archived 里可能包含 legacy revision 草稿(带 revisionOf,
147
+ // 由 restoreLexiconTerm 拒绝恢复);pendingEntries 只服务迁移/审计,
148
+ // 任何注入/检索/列表面都不读它;rejected 不进任何列表(墓碑)。
149
+ // byNormalizedTerm / byAlias 等值查询:Map<规范化词面, termId[]>
150
+ // prefixBuckets Map<词面前 BUCKET_WIDTH 字, Set<termId>>(dice 候选 + 正向包含候选)
151
+ // grams Map<词面任意 2 字 gram, Set<termId>>(searchLexicon 反向包含候选)
152
+ // surfacesByTerm Map<termId, [{ surface, via }]>(term 在前、aliases 随后,含/等值判定用)
153
+ //
154
+ // 失效策略(正确性红线:任何绕过本进程的文件改动不得让缓存返回旧数据):
155
+ // * 进程内一切写都走 mutateLexicon → 写成功后立即用刚落盘的对象重建快照
156
+ // (revision 已 +1,旧 key 随之作废)。
157
+ // * 进程外的改动(测试直写 paths.writeJson、backup 恢复、手工修复文件):缓存
158
+ // 命中前对文件 stat 一次并比对 mtimeMs + size。一次 stat(µs 级)远比全量
159
+ // JSON.parse(N=5k 时 ms 级)便宜;mtime 与 size 同时不变的覆写只剩「同毫秒内
160
+ // 等字节覆写」一条理论缝,在 NTFS/APFS/ext4(亚毫秒 mtime 精度)上实际不可达。
161
+ // 选 stat 比对而不是 revision 校验:revision 在 JSON 内部,读它就得先 parse,
162
+ // 等于放弃缓存。quarantine 把文件挪走 → stat 失败 → 必然 miss 回严格读取,
163
+ // typed error 语义不变。
164
+ // * 文件缺失(空库)不缓存:外部新建的文件必须立刻被下一次读取看见。
165
+ const lexiconIndexCache = new Map() // key: `${libraryId}@${revision}` → snapshot
166
+ const lexiconIndexHint = new Map() // libraryId → { key, mtimeMs, size }
167
+
168
+ // 展示序:strength → updatedAt → termId(构建索引与 merged 列表共用同一把尺)。
169
+ function displayRank(a, b) {
170
+ return (Number(b.entry.strength) || 0) - (Number(a.entry.strength) || 0)
171
+ || String(b.entry.updatedAt || '').localeCompare(String(a.entry.updatedAt || ''))
172
+ || String(a.termId).localeCompare(String(b.termId))
173
+ }
174
+
175
+ function buildLexiconIndex(data) {
176
+ const byNormalizedTerm = new Map()
177
+ const byAlias = new Map()
178
+ const prefixBuckets = new Map()
179
+ const grams = new Map()
180
+ const surfacesByTerm = new Map()
181
+ const activeEntries = []
182
+ const archivedEntries = []
183
+ const pendingEntries = []
184
+ for (const [termId, entry] of Object.entries(data.terms)) {
185
+ const status = entry.status || 'active'
186
+ if (status === 'active') activeEntries.push({ termId, entry })
187
+ else if (status === 'archived') archivedEntries.push({ termId, entry })
188
+ else if (status === 'pending') pendingEntries.push({ termId, entry })
189
+ const surfaces = []
190
+ if (entry.normalizedTerm) surfaces.push({ surface: entry.normalizedTerm, via: 'term' })
191
+ const aliases = Array.isArray(entry.normalizedAliases) ? entry.normalizedAliases : []
192
+ for (const alias of aliases) {
193
+ if (alias && alias !== entry.normalizedTerm) surfaces.push({ surface: alias, via: 'alias' })
194
+ }
195
+ if (surfaces.length) surfacesByTerm.set(termId, surfaces)
196
+ for (const { surface, via } of surfaces) {
197
+ const target = via === 'term' ? byNormalizedTerm : byAlias
198
+ let ids = target.get(surface)
199
+ if (!ids) { ids = []; target.set(surface, ids) }
200
+ ids.push(termId)
201
+ // 前缀桶:与原 bucketKeysOf 同口径——词面前 BUCKET_WIDTH 字,过短词面用自身。
202
+ const prefixKey = surface.length >= BUCKET_WIDTH ? surface.slice(0, BUCKET_WIDTH) : surface
203
+ let bucket = prefixBuckets.get(prefixKey)
204
+ if (!bucket) { bucket = new Set(); prefixBuckets.set(prefixKey, bucket) }
205
+ bucket.add(termId)
206
+ // gram 倒排:searchLexicon 的反向包含(query ⊆ 词面)靠它收候选。正向包含
207
+ // (词面 ⊆ query)也可以用它,但前缀桶的候选集更小(词面必须以该窗口开头),
208
+ // 所以正向走前缀桶。
209
+ for (let i = 0; i + BUCKET_WIDTH <= surface.length; i++) {
210
+ const gram = surface.slice(i, i + BUCKET_WIDTH)
211
+ let set = grams.get(gram)
212
+ if (!set) { set = new Set(); grams.set(gram, set) }
213
+ set.add(termId)
214
+ }
215
+ }
216
+ }
217
+ // 展示序在构建时定死(strength → updatedAt → termId):过滤是保序的,调用方
218
+ // 不必再对可见子集排序——排序键全是静态字段,与可见性无关,两个顺序等价。
219
+ activeEntries.sort(displayRank)
220
+ archivedEntries.sort(displayRank)
221
+ pendingEntries.sort(displayRank)
222
+ return { data, activeEntries, archivedEntries, pendingEntries, byNormalizedTerm, byAlias, prefixBuckets, grams, surfacesByTerm }
223
+ }
224
+
225
+ function storeLexiconIndex(libraryId, file, data) {
226
+ const snapshot = buildLexiconIndex(data)
227
+ const key = `${libraryId}@${data.revision}`
228
+ const previous = lexiconIndexHint.get(libraryId)
229
+ if (previous) lexiconIndexCache.delete(previous.key)
230
+ let stat = null
231
+ try { stat = fs.statSync(file) } catch {}
232
+ if (stat) {
233
+ lexiconIndexCache.set(key, snapshot)
234
+ lexiconIndexHint.set(libraryId, { key, mtimeMs: stat.mtimeMs, size: stat.size })
235
+ } else {
236
+ // 空库(文件还不存在)不缓存缺失状态。
237
+ lexiconIndexHint.delete(libraryId)
238
+ }
239
+ return snapshot
240
+ }
241
+
242
+ /** 带缓存的内部读取:命中走 stat 比对,未命中走严格读取 + 建索引。 */
243
+ function lexiconIndex(libraryId) {
244
+ const file = lexiconFile(libraryId)
245
+ // quarantine(读失败保护)优先于一切缓存:曾 quarantined 的库必须继续 typed error。
246
+ const failure = readFailure(file)
247
+ if (failure) {
248
+ throw lexiconError('LEXICON_READ_FAILED', `lexicon ${libraryId} is write-protected by an earlier read failure (${failure.code || failure.message})`)
249
+ }
250
+ const hint = lexiconIndexHint.get(libraryId)
251
+ if (hint) {
252
+ let stat = null
253
+ try { stat = fs.statSync(file) } catch {}
254
+ if (stat && stat.mtimeMs === hint.mtimeMs && stat.size === hint.size) {
255
+ const snapshot = lexiconIndexCache.get(hint.key)
256
+ if (snapshot) return snapshot
257
+ }
258
+ }
259
+ const data = readLexiconStrict(file, libraryId)
260
+ return storeLexiconIndex(libraryId, file, data)
261
+ }
262
+
263
+ /**
264
+ * 读一本词典。缺失 → 空结构(revision 0);损坏 → LEXICON_CORRUPT,绝不当空处理。
265
+ * 命中缓存时返回快照的浅拷贝:顶层字段与 terms 表逐键复制,防止调用方增删键污染
266
+ * 缓存;词条对象按只读契约共享——库内全部读路径都不改词条对象,测试里「改完立刻
267
+ * 写盘」的直改模式由 mtime 失效兜底(写盘 ⇒ 下一次 stat 必不匹配 ⇒ 重建)。
268
+ */
269
+ export function loadLexicon(libraryId) {
270
+ const snapshot = lexiconIndex(libraryId)
271
+ return { ...snapshot.data, terms: { ...snapshot.data.terms } }
272
+ }
273
+
274
+ /**
275
+ * 一切写操作的唯一入口:锁内重读最新 revision(CAS)→ mutator 同步改 → revision+1
276
+ * → writeJson 原子替换。mutator 必须同步(withFileLock 的锁不能跨 await)。
277
+ * 写成功后用刚落盘的对象重建内存索引:省掉下一次读取的整个 JSON.parse,重建成本
278
+ * 与建索引同阶且本来就在锁内。
279
+ *
280
+ * `onlyWhenChanged` 给迁移类写用(migrateLegacyLexiconStatuses):mutator 通过
281
+ * `changed === true` 声明真的改了数据;否则不落盘、不 bump revision——没有变化的
282
+ * 迁移不该把文件变成热写(revision 是别的进程判新的依据,空转 +1 只会制造噪声)。
283
+ */
284
+ function mutateLexicon(libraryId, mutator, { onlyWhenChanged = false } = {}) {
285
+ const file = lexiconFile(libraryId)
286
+ return withFileLock(file, () => {
287
+ const current = readLexiconStrict(file, libraryId)
288
+ const outcome = mutator(current) || {}
289
+ if (onlyWhenChanged && outcome.changed !== true) return outcome
290
+ current.revision = (Number(current.revision) || 0) + 1
291
+ current.libraryId = libraryId
292
+ current.version = 1
293
+ writeJson(file, current)
294
+ storeLexiconIndex(libraryId, file, current)
295
+ return outcome
296
+ })
297
+ }
298
+
299
+ function withTermId(entry, termId) {
300
+ return { termId, ...entry }
301
+ }
302
+
303
+ function normalizeEvidence(evidence) {
304
+ if (evidence === undefined || evidence === null) return []
305
+ if (!Array.isArray(evidence)) throw lexiconError('LEXICON_INVALID_EVIDENCE', 'evidence must be an array of { kind, id, quote }')
306
+ return evidence
307
+ .filter(item => item && typeof item === 'object')
308
+ .map(item => ({
309
+ kind: clip(typeof item.kind === 'string' ? item.kind : 'message', 40),
310
+ id: clip(typeof item.id === 'string' ? item.id : '', 160),
311
+ quote: clip(typeof item.quote === 'string' ? item.quote : '', EVIDENCE_QUOTE_MAX_CHARS),
312
+ }))
313
+ .filter(item => item.quote.trim() || item.id)
314
+ .slice(0, EVIDENCE_MAX_COUNT)
315
+ }
316
+
317
+ function normalizeStringList(value, { maxCount, maxChars, label }) {
318
+ if (value === undefined || value === null) return []
319
+ if (!Array.isArray(value)) throw lexiconError(`LEXICON_INVALID_${label}`, `${label} must be an array of strings`)
320
+ return value
321
+ .map(item => clip(String(item ?? ''), maxChars))
322
+ .map(item => item.trim())
323
+ .filter(Boolean)
324
+ .slice(0, maxCount)
325
+ }
326
+
327
+ /**
328
+ * 同一规范化 term/alias 在同一可见范围内的冲突检查。返回撞线的既有条目或 null。
329
+ * 可见范围 = sameFactContext(channel/speaker/character/world/branch 身份组一致)。
330
+ *
331
+ * 状态口径(§2.1):
332
+ * * includeArchived=false:归档条目完全不参与(旧口径;恢复检查用)。
333
+ * * includeArchived=true:归档条目按 term 等值参与(via: 'archived')——归档不占
334
+ * 别名位置,但同规范词的新写入必须看见它,否则用户/模型会在归档条目旁边建出
335
+ * 一条同词重复,之后 restore 必然撞线。
336
+ * * skipRejected=true:rejected 是墓碑,不占命名空间(恢复一个归档条目时,墓碑
337
+ * 不该阻止它回到 active)。
338
+ * * legacy revision 草稿(带 revisionOf)排在普通词条之后:同词同时存在目标与草稿
339
+ * 时,撞线对象永远是目标词条,草稿只作为兜底被看见。
340
+ */
341
+ function findScopeCollision(terms, {
342
+ normalizedTerm, normalizedAliases, context, ignoreTermId = null, includeArchived = false, skipRejected = false,
343
+ }) {
344
+ let best = null
345
+ let bestRank = Infinity
346
+ for (const [termId, entry] of Object.entries(terms)) {
347
+ if (termId === ignoreTermId) continue
348
+ const status = entry.status || 'active'
349
+ let via = null
350
+ if (status === 'archived') {
351
+ if (!includeArchived) continue
352
+ if (entry.normalizedTerm === normalizedTerm) via = 'archived'
353
+ } else if (status === 'rejected' && skipRejected) {
354
+ continue
355
+ } else if (sameFactContext(entry.context || {}, context || {})) {
356
+ if (entry.normalizedTerm === normalizedTerm) via = 'term'
357
+ else if ((entry.normalizedAliases || []).includes(normalizedTerm)) via = 'alias'
358
+ else if (normalizedAliases.includes(entry.normalizedTerm)) via = 'new-alias-is-term'
359
+ else if ((entry.normalizedAliases || []).some(alias => normalizedAliases.includes(alias))) via = 'alias'
360
+ }
361
+ if (!via) continue
362
+ const rank = (status === 'archived' ? 1 : 0) + (entry.revisionOf ? 2 : 0)
363
+ if (rank < bestRank) { best = { termId, entry, via }; bestRank = rank }
364
+ }
365
+ return best
366
+ }
367
+
368
+ // active 条目的敏感字段(§2.3 + 契约):这些字段变化时不许非用户来源静默覆盖。
369
+ // usage/aliases/examples/confidence/strength/evidence 属于非敏感字段,原地更新。
370
+ const SENSITIVE_FIELDS = ['term', 'kind', 'sensitivity', 'context', 'meaning']
371
+
372
+ function sensitiveDelta(existing, next) {
373
+ const changed = []
374
+ for (const field of SENSITIVE_FIELDS) {
375
+ const before = field === 'context' ? JSON.stringify(existing.context || {}) : String(existing[field] ?? '')
376
+ const after = field === 'context' ? JSON.stringify(next.context || {}) : String(next[field] ?? '')
377
+ if (before !== after) changed.push(field)
378
+ }
379
+ return changed
380
+ }
381
+
382
+ /**
383
+ * 写入/更新一个词条(§2 统一 op 的落点)。
384
+ *
385
+ * 公开生命周期只有 active / archived(§2.1);pending / rejected 只作为 legacy 数据
386
+ * 与内部墓碑存在,任何新写都不会产出它们:
387
+ * * 来源 'user':用户显式操作,字段(含敏感字段)直接生效。显式 status:'archived'
388
+ * 允许;pending/rejected 一律收口成 active。撞上 archived / rejected / legacy
389
+ * pending 词条 = 复活并应用字段(rejected 同时清掉拒绝指纹)。
390
+ * * 来源 'model-proposal' / 'curator':普通词条直接 active(不再 pending、不再
391
+ * needs_review)。sensitive trigger 一律 typed 拒绝(LEXICON_SENSITIVE_FORBIDDEN);
392
+ * 已存在的 sensitive trigger 的任何更新同样拒绝——只有用户能管敏感词。
393
+ * * 非用户更新 active / legacy pending 普通词条:敏感字段(term/kind/sensitivity/
394
+ * context/meaning)有任何变化 → typed 拒绝(LEXICON_SENSITIVE_FIELDS_FORBIDDEN),
395
+ * 绝不生成 pending revision;非敏感字段(usage/aliases/examples/confidence/
396
+ * strength/evidence)原地更新。visibility 也不归非用户改(secret 是披露边界,
397
+ * 不在 §2.1 的非敏感名单里)。legacy pending 被非用户写入时收口为 active。
398
+ * * 撞上 archived 词条:非用户不复活、不新建(notOwned);撞上 rejected 墓碑:
399
+ * model-proposal 仍是 LEXICON_PROPOSAL_REJECTED,curator 得到 notOwned;
400
+ * 撞上 legacy revision 草稿(revisionOf):非用户不复活(notOwned)。
401
+ * * 同规范词(term)在 sameFactContext 作用域内唯一;别名撞线仍是
402
+ * LEXICON_ALIAS_CONFLICT(不静默合并——合并会改写别人的记录)。
403
+ */
404
+ export function upsertLexiconTerm({
405
+ libraryId, term, kind = 'term', meaning = '', usage = '', aliases = [], examples = [],
406
+ context = {}, knownBy = [], visibility = 'shared', sensitivity, confidence, strength,
407
+ evidence = [], source = 'user', status,
408
+ }) {
409
+ if (!SOURCES.includes(source)) throw lexiconError('LEXICON_INVALID_SOURCE', `lexicon source must be one of ${SOURCES.join(', ')}`)
410
+ const isUser = source === 'user'
411
+ const rawTerm = String(term ?? '').trim()
412
+ if (!rawTerm) throw lexiconError('LEXICON_INVALID_TERM', 'lexicon term must be a non-empty string')
413
+ if (rawTerm.length > TERM_MAX_CHARS) throw lexiconError('LEXICON_INVALID_TERM', `lexicon term exceeds ${TERM_MAX_CHARS} characters`)
414
+ if (!KINDS.includes(kind)) throw lexiconError('LEXICON_INVALID_KIND', `lexicon kind must be one of ${KINDS.join(', ')}`)
415
+ if (!['shared', 'secret'].includes(visibility)) throw lexiconError('LEXICON_INVALID_VISIBILITY', 'lexicon visibility must be shared or secret')
416
+ // trigger 默认 sensitive(§1.2);显式传值也只接受两个档位。
417
+ const resolvedSensitivity = sensitivity === undefined || sensitivity === null
418
+ ? (kind === 'trigger' ? 'sensitive' : 'normal')
419
+ : String(sensitivity)
420
+ if (!['normal', 'sensitive'].includes(resolvedSensitivity)) throw lexiconError('LEXICON_INVALID_SENSITIVITY', 'lexicon sensitivity must be normal or sensitive')
421
+ // 敏感触发词不允许模型/curator 猜(§0.2 / §2.1):非用户来源建 sensitive trigger
422
+ // 直接 typed 拒绝,只有用户能管理敏感词。
423
+ if (!isUser && kind === 'trigger' && resolvedSensitivity === 'sensitive') {
424
+ throw lexiconError('LEXICON_SENSITIVE_FORBIDDEN', `${source} cannot create sensitive triggers; sensitive vocabulary is user-managed`)
425
+ }
426
+ const normalizedContext = normalizeMemoryContext(context && typeof context === 'object' ? context : {})
427
+ const normalizedKnownBy = normalizeStringList(knownBy, { maxCount: 30, maxChars: 128, label: 'KNOWNBY' })
428
+ if (normalizedKnownBy.length) normalizedContext.knownBy = normalizedKnownBy
429
+ const normalizedTerm = normalizeLexeme(rawTerm)
430
+ if (!normalizedTerm) throw lexiconError('LEXICON_INVALID_TERM', 'lexicon term normalizes to empty')
431
+ const aliasList = normalizeStringList(aliases, { maxCount: ALIAS_MAX_COUNT, maxChars: ALIAS_MAX_CHARS, label: 'ALIASES' })
432
+ const normalizedAliases = aliasList.map(normalizeLexeme).filter(Boolean)
433
+ .filter(alias => alias !== normalizedTerm)
434
+ const resolvedConfidence = Number.isFinite(Number(confidence)) ? Math.max(0, Math.min(1, Number(confidence))) : 0.8
435
+ // strength 是展示权重不是语义字段(不在 SENSITIVE_FIELDS):改它走原地更新。
436
+ // 未提供时保留既有值/默认 0.6。
437
+ const resolvedStrength = Number.isFinite(Number(strength)) ? Math.max(0, Math.min(1, Number(strength))) : null
438
+ // 公开两态(§2.1):pending/rejected 不再由新调用方可设,统一收口为 active;
439
+ // 非用户来源连 archived 也不能设(归档是用户动作)。
440
+ const explicitStatus = status !== undefined && status !== null && String(status).trim() !== ''
441
+ const requestedStatus = PUBLIC_STATUSES.includes(String(status ?? '')) ? String(status) : 'active'
442
+ const wantedStatus = isUser ? requestedStatus : 'active'
443
+ // 输入侧归一化一次,创建与更新共用(敏感字段比较用同一份结果)。
444
+ const fields = {
445
+ kind,
446
+ meaning: clip(String(meaning ?? ''), MEANING_MAX_CHARS),
447
+ usage: clip(String(usage ?? ''), USAGE_MAX_CHARS),
448
+ aliases: aliasList,
449
+ normalizedAliases,
450
+ examples: normalizeStringList(examples, { maxCount: EXAMPLE_MAX_COUNT, maxChars: EXAMPLE_MAX_CHARS, label: 'EXAMPLES' }),
451
+ context: normalizedContext,
452
+ visibility,
453
+ sensitivity: resolvedSensitivity,
454
+ confidence: resolvedConfidence,
455
+ evidence: normalizeEvidence(evidence),
456
+ }
457
+
458
+ return mutateLexicon(libraryId, (lex) => {
459
+ const terms = lex.terms
460
+ // 同 fingerprint 的 model-proposal 再来 → 直接拒绝(§2.3):rejected 留指纹就是
461
+ // 为了让同一句错话不要每轮重放。
462
+ const fingerprint = shortHash(normalizedTerm)
463
+ if (source === 'model-proposal' && lex.rejected?.[fingerprint]) {
464
+ const prior = lex.rejected[fingerprint]
465
+ throw lexiconError('LEXICON_PROPOSAL_REJECTED', `this normalized term was already rejected${prior?.reason ? `: ${prior.reason}` : ''}`)
466
+ }
467
+ const collision = findScopeCollision(terms, {
468
+ normalizedTerm, normalizedAliases, context: normalizedContext, includeArchived: true,
469
+ })
470
+ const now = stamp()
471
+
472
+ if (collision && (collision.via === 'term' || collision.via === 'archived')) {
473
+ const { termId, entry } = collision
474
+ const entryStatus = entry.status || 'active'
475
+ // 兜底的指纹拦截:rejected 留在 terms 表里,指纹表缺失(手工修复过的文件)时
476
+ // 也不能让 model-proposal 把一条被拒的词条洗回生命周期。
477
+ if (source === 'model-proposal' && entryStatus === 'rejected') {
478
+ throw lexiconError('LEXICON_PROPOSAL_REJECTED', 'this normalized term was already rejected; resubmission is blocked')
479
+ }
480
+ // 归档 / 墓碑 / 修订草稿:非用户来源一律不复活、不改写,返回既有条目并标
481
+ // notOwned —— 这些状态只由用户(或迁移)处置。
482
+ const immutable = entryStatus === 'archived' || entryStatus === 'rejected'
483
+ || (entryStatus === 'pending' && Boolean(entry.revisionOf))
484
+ if (immutable && !isUser) {
485
+ return { term: withTermId(entry, termId), created: false, notOwned: true }
486
+ }
487
+ if (!isUser) {
488
+ // 已存在的 sensitive trigger:所有非用户更新都拒绝(conservative,§2.1)。
489
+ if (entry.kind === 'trigger' && entry.sensitivity === 'sensitive') {
490
+ throw lexiconError('LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `${source} cannot update the sensitive trigger "${entry.term}"; only the user manages sensitive entries`)
491
+ }
492
+ // visibility 不在"非敏感字段"名单里(§2.1 只列 usage/aliases/examples/
493
+ // confidence/strength/evidence):secret 是披露边界,模型/curator 不得通过更新
494
+ // 把一条 secret 词条洗成 shared。沉默忽略也不好——调用方会以为写成功了。
495
+ if ((entry.visibility || 'shared') !== visibility) {
496
+ throw lexiconError('LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `${source} cannot change the visibility of "${entry.term}" (${entry.visibility || 'shared'} → ${visibility}); only the user can`)
497
+ }
498
+ const next = {
499
+ ...entry, ...fields, updatedAt: now, strength: resolvedStrength ?? (Number(entry.strength) || 0.6),
500
+ }
501
+ // 敏感字段变化 → typed 拒绝(§2.1:不再生成 pending revision 等人审)。
502
+ const changedSensitive = sensitiveDelta(entry, next)
503
+ if (changedSensitive.length) {
504
+ throw lexiconError('LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `${source} cannot change sensitive fields (${changedSensitive.join(', ')}) of the existing term "${entry.term}"; ask the user to edit it`)
505
+ }
506
+ // legacy pending 普通词条:非用户写入直接收口为 active(迁移前的过渡路径)。
507
+ if (entryStatus === 'pending') next.status = 'active'
508
+ next.normalizedTerm = normalizedTerm
509
+ terms[termId] = next
510
+ return { term: withTermId(next, termId), created: false }
511
+ }
512
+ // 用户:直接应用(含敏感字段)。编辑一条已归档词条不应等于悄悄恢复:
513
+ // 只有显式 status(或 restoreLexiconTerm)才把它带回 active。显式 status 的
514
+ // 复活会断开草稿链(revisionOf 是"某条 active 的下一版草稿"的标记)。
515
+ const userStatus = entryStatus === 'archived' && !explicitStatus ? 'archived' : wantedStatus
516
+ const next = {
517
+ ...entry,
518
+ ...fields,
519
+ updatedAt: now,
520
+ strength: resolvedStrength ?? (Number(entry.strength) || 0.6),
521
+ status: userStatus,
522
+ normalizedTerm,
523
+ fingerprint,
524
+ }
525
+ delete next.revisionOf
526
+ delete next.previousRevision
527
+ const revived = entryStatus !== 'active' && userStatus === 'active'
528
+ if (revived) {
529
+ next.reviewedAt = now
530
+ next.reviewedBy = source
531
+ next.reviewReason = null
532
+ // rejected 的翻案:清掉拒绝指纹,同词的 model-proposal 不再被无限拦截。
533
+ if (entry.fingerprint && lex.rejected?.[entry.fingerprint]) delete lex.rejected[entry.fingerprint]
534
+ }
535
+ terms[termId] = next
536
+ return { term: withTermId(next, termId), created: false, ...(revived ? { revived: true } : {}) }
537
+ }
538
+
539
+ if (collision) {
540
+ // 新词条的 alias 撞了别的词条:宁可报错也不静默合并——合并会改写别人的记录。
541
+ throw lexiconError('LEXICON_ALIAS_CONFLICT', `normalized alias collides with existing term "${collision.entry.term}" (${collision.termId})`)
542
+ }
543
+
544
+ if (Object.keys(terms).length >= LEXICON_CAP) {
545
+ throw lexiconError('LEXICON_CAP_REACHED', `lexicon ${libraryId} is at its ${LEXICON_CAP} term capacity; archive or delete before adding`)
546
+ }
547
+
548
+ const termId = newTermId()
549
+ const entry = {
550
+ term: rawTerm,
551
+ normalizedTerm,
552
+ ...fields,
553
+ strength: resolvedStrength ?? 0.6,
554
+ status: wantedStatus,
555
+ source,
556
+ fingerprint,
557
+ createdAt: now,
558
+ updatedAt: now,
559
+ reviewedAt: null,
560
+ reviewedBy: null,
561
+ reviewReason: null,
562
+ hitCounts: { candidate: 0, injection: 0, query: 0 },
563
+ lastInjectedAt: null,
564
+ }
565
+ terms[termId] = entry
566
+ return { term: withTermId(entry, termId), created: true }
567
+ })
568
+ }
569
+
570
+ function newTermId() {
571
+ return `lex-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
572
+ }
573
+
574
+ /**
575
+ * 归档(§2.1 / §2.3):只有 active → archived;其余状态 typed 拒绝(legacy pending
576
+ * 先走迁移收口,rejected 是墓碑、没有归档语义)。归档不写 rejected 指纹——归档是
577
+ * 可恢复的退场,不是拒绝。
578
+ */
579
+ export function archiveLexiconTerm({ libraryId, termId }) {
580
+ return mutateLexicon(libraryId, (lex) => {
581
+ const entry = lex.terms[termId]
582
+ if (!entry) throw lexiconError('LEXICON_NOT_FOUND', `lexicon term ${termId} does not exist in ${libraryId}`)
583
+ const status = entry.status || 'active'
584
+ if (status !== 'active') throw lexiconError('LEXICON_INVALID_STATUS', `only active terms can be archived; ${termId} is ${status}`)
585
+ const now = stamp()
586
+ entry.status = 'archived'
587
+ entry.updatedAt = now
588
+ return { term: withTermId(entry, termId), termId, action: 'archive' }
589
+ })
590
+ }
591
+
592
+ /**
593
+ * 恢复(§2.1 / §2.3):只有 archived → active。两条硬拒绝:
594
+ * * legacy revision 草稿(revisionOf 指向仍 active 的目标)不能独立恢复——它是
595
+ * 目标词条的下一版草稿,不是词条(LEXICON_REVISION_IMMUTABLE)。
596
+ * * 恢复会与同作用域的 active 词条撞同一个规范词面/别名 → LEXICON_ALIAS_CONFLICT
597
+ * (不制造同词重复;用户想合并就在两个词条上分别编辑)。
598
+ * 墓碑(rejected)不参与撞线:它不占命名空间。
599
+ */
600
+ export function restoreLexiconTerm({ libraryId, termId }) {
601
+ return mutateLexicon(libraryId, (lex) => {
602
+ const entry = lex.terms[termId]
603
+ if (!entry) throw lexiconError('LEXICON_NOT_FOUND', `lexicon term ${termId} does not exist in ${libraryId}`)
604
+ const status = entry.status || 'active'
605
+ if (status !== 'archived') throw lexiconError('LEXICON_INVALID_STATUS', `only archived terms can be restored; ${termId} is ${status}`)
606
+ if (entry.revisionOf && (lex.terms[entry.revisionOf]?.status || 'active') === 'active') {
607
+ throw lexiconError('LEXICON_REVISION_IMMUTABLE', `lexicon term ${termId} is a legacy revision draft of ${entry.revisionOf}; edit the active term instead`)
608
+ }
609
+ const normalizedAliases = Array.isArray(entry.normalizedAliases) ? entry.normalizedAliases : []
610
+ const collision = findScopeCollision(lex.terms, {
611
+ normalizedTerm: entry.normalizedTerm,
612
+ normalizedAliases,
613
+ context: entry.context || {},
614
+ ignoreTermId: termId,
615
+ skipRejected: true,
616
+ })
617
+ if (collision) {
618
+ throw lexiconError('LEXICON_ALIAS_CONFLICT', `restoring "${entry.term}" would collide with active term "${collision.entry.term}" (${collision.termId})`)
619
+ }
620
+ const now = stamp()
621
+ entry.status = 'active'
622
+ entry.updatedAt = now
623
+ return { term: withTermId(entry, termId), termId, action: 'restore' }
624
+ })
625
+ }
626
+
627
+ /**
628
+ * 删除词条。刻意只暴露给管理面(RPC/UI/P3):模型没有任何删除路径——
629
+ * tools.js 的模型工具面拿不到这个函数,curator op 白名单里也没有 delete。
630
+ */
631
+ export function deleteLexiconTerm({ libraryId, termId }) {
632
+ return mutateLexicon(libraryId, (lex) => {
633
+ const entry = lex.terms[termId]
634
+ if (!entry) throw lexiconError('LEXICON_NOT_FOUND', `lexicon term ${termId} does not exist in ${libraryId}`)
635
+ delete lex.terms[termId]
636
+ if (entry.fingerprint && lex.rejected?.[entry.fingerprint]?.termId === termId) {
637
+ delete lex.rejected[entry.fingerprint]
638
+ }
639
+ return { deleted: true, termId, term: entry.term }
640
+ })
641
+ }
642
+
643
+ /**
644
+ * §1.3 / §2.1:可见词条,逐条过与记忆同一把尺子(contextVisible)。过滤失败的绝不返回。
645
+ * 默认只有 active;`includeArchived: true`(管理面/浏览器)追加已归档词条(含 legacy
646
+ * revision 草稿)。pending/rejected 永远不出现——前者只服务迁移,后者是内部墓碑。
647
+ * 状态分组与展示序都在索引构建时定好,这里只剩 contextVisible 过滤本身;active 与
648
+ * archived 混排时重排一次(两段各自有序,合并后必须保持同一把展示序)。
649
+ */
650
+ export function visibleLexiconTerms(libraryId, memoryContext, { includeArchived = false } = {}) {
651
+ const index = lexiconIndex(libraryId)
652
+ const out = []
653
+ for (const { termId, entry } of index.activeEntries) {
654
+ if (!contextVisible(entry.context, memoryContext)) continue
655
+ out.push(withTermId(entry, termId))
656
+ }
657
+ if (includeArchived) {
658
+ for (const { termId, entry } of index.archivedEntries) {
659
+ if (!contextVisible(entry.context, memoryContext)) continue
660
+ out.push(withTermId(entry, termId))
661
+ }
662
+ out.sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0)
663
+ || String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))
664
+ || String(a.termId).localeCompare(String(b.termId)))
665
+ }
666
+ return out
667
+ }
668
+
669
+ function clamp01(value) {
670
+ const n = Number(value)
671
+ if (!Number.isFinite(n)) return 0
672
+ return Math.max(0, Math.min(1, n))
673
+ }
674
+
675
+ /**
676
+ * Wilson 下界(§6.3 包 C 的有界频率先验):hitCounts 三计数(candidate/injection/
677
+ * query)→ 95% Wilson 置信下界。n=candidate 是分母(候选池大小),成功 =
678
+ * injection+query(真的注入了 / 被显式查过),恒 ≤ n。设计约束:
679
+ * * n=0 → 0(从未候选过的词条没有任何频率证据,因子取下限 0.85)。
680
+ * * 1/1 的下界 ≈ 0.207,因子最多 ~3% 差距——一条偶发命中压不过长期稳定使用的词。
681
+ * * 100/120 的下界明显更高——长期被真正使用的词条温和提升。
682
+ * * 输出 clamp 到 [0,1],与 strength/confidence 的 clamp01 同口径;乘上
683
+ * (0.85 + 0.15 * lb) 后 effective 因子恒在 [0.85,1.00],不 runaway。
684
+ */
685
+ export function usageLowerBound(counts) {
686
+ const n = Math.max(0, Math.floor(Number(counts?.candidate) || 0))
687
+ if (n === 0) return 0
688
+ const success = Math.min(n, Math.max(0, (Number(counts?.injection) || 0) + (Number(counts?.query) || 0)))
689
+ const p = success / n
690
+ const z = WILSON_Z, z2 = z * z
691
+ const lb = (p + z2 / (2 * n) - z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n)) / (1 + z2 / n)
692
+ return clamp01(lb)
693
+ }
694
+
695
+ /**
696
+ * effective 展示权重(§6.3 排序键):strength × confidence × (0.85 + 0.15 × Wilson
697
+ * 下界)。因子固定在 [0.85,1.00],只做温和的先验,绝不压过 exact/alias/Dice 的
698
+ * 匹配层级——那三层在 stableRank 里先于 effective 生效。
699
+ */
700
+ function effectiveWeight(hit) {
701
+ return clamp01(hit.strength) * clamp01(hit.confidence) * (0.85 + 0.15 * usageLowerBound(hit.hitCounts))
702
+ }
703
+
704
+ /** §3.2 稳定排序:排序键固定,末位 id 字典序,绝不用当前时间或遍历序做 tie-break。 */
705
+ function stableRank(hits) {
706
+ return [...hits].sort((a, b) => {
707
+ const sa = sensitivityRank(a), sb = sensitivityRank(b)
708
+ if (sa !== sb) return sb - sa
709
+ const ea = exactRank(a.kindOfHit), eb = exactRank(b.kindOfHit)
710
+ if (ea !== eb) return eb - ea
711
+ if (ea === 1 && Math.abs((b.score || 0) - (a.score || 0)) > 1e-9) return (b.score || 0) - (a.score || 0)
712
+ const wa = effectiveWeight(a), wb = effectiveWeight(b)
713
+ if (Math.abs(wb - wa) > 1e-9) return wb - wa
714
+ const ga = scopeSpecificity(a.context), gb = scopeSpecificity(b.context)
715
+ if (ga !== gb) return gb - ga
716
+ const ta = String(a.updatedAt || ''), tb = String(b.updatedAt || '')
717
+ if (ta !== tb) return tb.localeCompare(ta)
718
+ return String(a.termId).localeCompare(String(b.termId))
719
+ })
720
+ }
721
+
722
+ // 敏感优先的定义(§8.3 统一项):与锚点摘要(lexiconAnchorSummary 按
723
+ // sensitivity === 'sensitive' 单列)同一把尺——sensitivity 即敏感,不再要求
724
+ // kind === 'trigger'。旧实现 B 通道只抬 trigger+sensitive、锚点按全部 sensitive
725
+ // 分组,两条通道对"敏感"的判定不一致;统一为「只要 sensitive 就优先」,与
726
+ // §8.1「敏感词安全高于自动化」一致,也和写入侧 trigger 默认 sensitive 的口径互补。
727
+ function sensitivityRank(hit) {
728
+ return hit.sensitivity === 'sensitive' ? 1 : 0
729
+ }
730
+
731
+ // exact term > exact alias > dice(§3.2 的 2-4 键)。
732
+ function exactRank(kindOfHit) {
733
+ if (kindOfHit === 'exact-term') return 3
734
+ if (kindOfHit === 'exact-alias') return 2
735
+ return 1
736
+ }
737
+
738
+ // 作用域具体度(§3.2 排序键之一,显式定义):词条 context 里已定义的身份字段个数。
739
+ // 字段集与 lib/context.js 的 IDENTITIES 完全同组(channelId/speakerId/characterId/
740
+ // worldId/branchId),非空数越多越具体,具体者优先。knownBy 是可见性边界
741
+ // (contextVisible 的事),不是作用域,不计入;空白 context = 全场景共享,具体度
742
+ // 0,排在任何带作用域的词条之后。
743
+ function scopeSpecificity(context) {
744
+ if (!context || typeof context !== 'object') return 0
745
+ return ['channelId', 'speakerId', 'characterId', 'worldId', 'branchId'].filter(key => context[key]).length
746
+ }
747
+
748
+ /** 文本侧:穷举 BUCKET_WIDTH 字符窗口,按出现顺序去重,上限 MAX_BUCKETS。 */
749
+ function textBuckets(normalizedText, maxBuckets = LEXICON_MAX_BUCKETS) {
750
+ const keys = []
751
+ const seen = new Set()
752
+ for (let i = 0; i + BUCKET_WIDTH <= normalizedText.length; i++) {
753
+ const key = normalizedText.slice(i, i + BUCKET_WIDTH)
754
+ if (seen.has(key)) continue
755
+ seen.add(key)
756
+ keys.push(key)
757
+ if (keys.length >= maxBuckets) break
758
+ }
759
+ return keys
760
+ }
761
+
762
+ /**
763
+ * 桶内 dice 兜底:对每个候选词条,在文本里定位它桶键的出现位置,取等长窗口算
764
+ * Dice,取最大值。候选 ≤ maxCandidates、命中 ≤ maxTerms,O(C) 有界(§6)。
765
+ */
766
+ function diceFallback(normalizedText, candidates, { threshold = LEXICON_DICE_THRESHOLD, maxCandidates = LEXICON_MAX_CANDIDATES, maxTerms = LEXICON_MAX_TERMS } = {}) {
767
+ const textGrams = bigrams(normalizedText)
768
+ if (!textGrams.size) return []
769
+ const scored = []
770
+ for (const hit of candidates.slice(0, maxCandidates)) {
771
+ let best = 0
772
+ for (const surface of [hit.normalizedTerm, ...(hit.normalizedAliases || [])]) {
773
+ if (!surface || surface.length < BUCKET_WIDTH) continue
774
+ const key = surface.slice(0, BUCKET_WIDTH)
775
+ let pos = normalizedText.indexOf(key)
776
+ while (pos >= 0) {
777
+ const window = normalizedText.slice(pos, pos + surface.length)
778
+ const score = dice(bigrams(window), bigrams(surface))
779
+ if (score > best) best = score
780
+ if (best >= 1) break
781
+ pos = normalizedText.indexOf(key, pos + 1)
782
+ }
783
+ if (best >= threshold) break
784
+ }
785
+ if (best >= threshold) scored.push({ ...hit, kindOfHit: 'dice', score: best })
786
+ }
787
+ scored.sort((a, b) => (b.score - a.score)
788
+ || (Number(b.strength) || 0) * (Number(b.confidence) || 0) - (Number(a.strength) || 0) * (Number(a.confidence) || 0)
789
+ || String(a.termId).localeCompare(String(b.termId)))
790
+ return scored.slice(0, maxTerms)
791
+ }
792
+
793
+ function hitOf(entry, termId, kindOfHit, score) {
794
+ return {
795
+ termId,
796
+ term: entry.term,
797
+ normalizedTerm: entry.normalizedTerm,
798
+ kind: entry.kind,
799
+ meaning: entry.meaning,
800
+ usage: entry.usage,
801
+ sensitivity: entry.sensitivity,
802
+ strength: entry.strength,
803
+ confidence: entry.confidence,
804
+ // 频率先验(§6.3)的输入:stableRank 的 effective 因子按三计数算 Wilson 下界。
805
+ hitCounts: entry.hitCounts && typeof entry.hitCounts === 'object' ? entry.hitCounts : { candidate: 0, injection: 0, query: 0 },
806
+ context: entry.context,
807
+ updatedAt: entry.updatedAt,
808
+ status: entry.status,
809
+ kindOfHit,
810
+ score,
811
+ }
812
+ }
813
+
814
+ /**
815
+ * §3.1-§3.2 匹配流水线:精确(等值/包含)→ 前缀桶 → 桶内 dice 兜底 → stableRank。
816
+ * 只读不写:candidate/injection 计数由调用方(inject.js)在真正发生时记。
817
+ * 只匹配 active 词条:archived 已退场、rejected 是墓碑、legacy pending 等迁移收口,
818
+ * 三种状态都不进注入(§2.1)。
819
+ *
820
+ * 全部走 revision-keyed 索引(§10 P1):等值 O(1) 查 Map;包含层用「词面 ⊆ 文本
821
+ * ⇒ 词面前缀 = 文本某个 2 字窗口」的性质从前缀桶收候选(窗口穷举不设 16 上限——
822
+ * 16 上限只属于 dice 候选,精确层语义从来没有窗口上限);dice 兜底仍按桶扫且
823
+ * C ≤ 64 不变(§6)。可见性(status + contextVisible)在收候选时逐个复核。
824
+ */
825
+ export function matchLexiconHits(libraryId, userText, memoryContext) {
826
+ const normalizedText = normalizeLexeme(userText)
827
+ if (!normalizedText) return []
828
+ const index = lexiconIndex(libraryId)
829
+ const data = index.data
830
+ const isVisible = termId => {
831
+ const entry = data.terms[termId]
832
+ return Boolean(entry) && (entry.status || 'active') === 'active' && contextVisible(entry.context, memoryContext)
833
+ }
834
+ // 精确层·等值:Map 直查(同库同词面可有多条跨 scope 词条,值是 termId 列表)。
835
+ const hits = []
836
+ const exactIds = new Set()
837
+ const pushExact = (termId, kindOfHit) => {
838
+ if (exactIds.has(termId) || !isVisible(termId)) return
839
+ exactIds.add(termId)
840
+ hits.push(hitOf(data.terms[termId], termId, kindOfHit, 1))
841
+ }
842
+ for (const termId of index.byNormalizedTerm.get(normalizedText) || []) pushExact(termId, 'exact-term')
843
+ for (const termId of index.byAlias.get(normalizedText) || []) pushExact(termId, 'exact-alias')
844
+ // 精确层·包含:词面(≥MIN_INCLUDE_LEN)出现在文本里。前缀桶给出候选超集,
845
+ // 逐候选复核 includes 并保留原判定序(term 在前、aliases 随后,第一个命中的
846
+ // 词面决定 exact-term / exact-alias 标签)。
847
+ const containIds = []
848
+ for (let i = 0; i + BUCKET_WIDTH <= normalizedText.length; i++) {
849
+ for (const termId of index.prefixBuckets.get(normalizedText.slice(i, i + BUCKET_WIDTH)) || []) {
850
+ if (!exactIds.has(termId) && !containIds.includes(termId)) containIds.push(termId)
851
+ }
852
+ }
853
+ for (const termId of containIds) {
854
+ if (!isVisible(termId)) continue
855
+ const entry = data.terms[termId]
856
+ let matched = null
857
+ for (const item of index.surfacesByTerm.get(termId) || []) {
858
+ if (item.surface.length >= LEXICON_MIN_INCLUDE_LEN && normalizedText.includes(item.surface)) { matched = item; break }
859
+ }
860
+ if (matched) {
861
+ exactIds.add(termId)
862
+ hits.push(hitOf(entry, termId, matched.via === 'term' ? 'exact-term' : 'exact-alias', 1))
863
+ }
864
+ }
865
+ // dice 候选:仍按 textBuckets 的 16 窗口上限收(§4.2 的 C≤64 语义不变)。
866
+ const candidateIds = []
867
+ for (const key of textBuckets(normalizedText)) {
868
+ for (const termId of index.prefixBuckets.get(key) || []) {
869
+ if (exactIds.has(termId) || candidateIds.includes(termId) || !isVisible(termId)) continue
870
+ candidateIds.push(termId)
871
+ }
872
+ }
873
+ const candidates = candidateIds
874
+ .map(termId => hitOf(data.terms[termId], termId, 'dice', 0))
875
+ .sort((a, b) => (Number(b.strength) || 0) * (Number(b.confidence) || 0) - (Number(a.strength) || 0) * (Number(a.confidence) || 0))
876
+ const fuzzy = diceFallback(normalizedText, candidates)
877
+ // stableRank 的末位 tie-break 是唯一 termId,命中顺序不影响最终排序。
878
+ return stableRank([...hits, ...fuzzy])
879
+ }
880
+
881
+ /**
882
+ * 精确层检索(§3.3 通道 C 的数据面):等值 + includes,不做 dice、不做语义。
883
+ * 默认只回 active;`includeArchived: true` 让管理面/浏览器显式看到归档词条。
884
+ * pending(迁移前的老提案)与 rejected(墓碑)在任何模式下都不出现。
885
+ * 走 revision-keyed 索引:等值 O(1) 查 Map;正向包含(词面 ⊆ query)用前缀桶
886
+ * 收候选(穷举窗口、不设 16 上限——精确层语义没有窗口上限);反向包含
887
+ * (query ⊆ 词面)用 gram 倒排收候选。两个候选集都是超集,逐候选复核原始谓词。
888
+ */
889
+ export function searchLexicon({ libraryId, query, memoryContext }, { includeArchived = false } = {}) {
890
+ const normalizedQuery = normalizeLexeme(query)
891
+ const out = []
892
+ if (!normalizedQuery) return out
893
+ const index = lexiconIndex(libraryId)
894
+ const data = index.data
895
+ const matched = []
896
+ const considered = new Set()
897
+ const consider = termId => {
898
+ if (considered.has(termId)) return
899
+ considered.add(termId)
900
+ const entry = data.terms[termId]
901
+ const status = entry.status || 'active'
902
+ if (status !== 'active' && !(includeArchived && status === 'archived')) return
903
+ if (!contextVisible(entry.context, memoryContext)) return
904
+ const aliases = Array.isArray(entry.normalizedAliases) ? entry.normalizedAliases : []
905
+ let matchedBy = null
906
+ if (entry.normalizedTerm === normalizedQuery) matchedBy = 'term'
907
+ else if (aliases.includes(normalizedQuery)) matchedBy = 'alias'
908
+ else if (entry.normalizedTerm.length >= LEXICON_MIN_INCLUDE_LEN
909
+ && (normalizedQuery.includes(entry.normalizedTerm) || entry.normalizedTerm.includes(normalizedQuery))) matchedBy = 'contains'
910
+ else if (aliases.some(alias => alias.length >= LEXICON_MIN_INCLUDE_LEN
911
+ && (normalizedQuery.includes(alias) || alias.includes(normalizedQuery)))) matchedBy = 'alias-contains'
912
+ if (matchedBy) matched.push({ ...withTermId(entry, termId), matchedBy })
913
+ }
914
+ for (const termId of index.byNormalizedTerm.get(normalizedQuery) || []) consider(termId)
915
+ for (const termId of index.byAlias.get(normalizedQuery) || []) consider(termId)
916
+ if (normalizedQuery.length >= BUCKET_WIDTH) {
917
+ const forward = new Set()
918
+ const reverse = new Set()
919
+ for (let i = 0; i + BUCKET_WIDTH <= normalizedQuery.length; i++) {
920
+ const window = normalizedQuery.slice(i, i + BUCKET_WIDTH)
921
+ for (const termId of index.prefixBuckets.get(window) || []) {
922
+ if (!considered.has(termId)) forward.add(termId)
923
+ }
924
+ for (const termId of index.grams.get(window) || []) {
925
+ if (!considered.has(termId) && !forward.has(termId)) reverse.add(termId)
926
+ }
927
+ }
928
+ for (const termId of forward) consider(termId)
929
+ for (const termId of reverse) consider(termId)
930
+ } else {
931
+ // 单字 query 没有 2 字窗口,反向包含(词面含该字)退化为对索引词面表的全扫:
932
+ // 原生 includes 逐词面跑,N=5k 时仍是毫秒级;语义与逐条扫描完全一致。
933
+ for (const [termId, surfaces] of index.surfacesByTerm) {
934
+ if (considered.has(termId)) continue
935
+ if (surfaces.some(item => item.surface.length >= LEXICON_MIN_INCLUDE_LEN && item.surface.includes(normalizedQuery))) {
936
+ consider(termId)
937
+ }
938
+ }
939
+ }
940
+ matched.sort((a, b) => {
941
+ const ra = a.matchedBy === 'term' ? 3 : a.matchedBy === 'alias' ? 2 : 1
942
+ const rb = b.matchedBy === 'term' ? 3 : b.matchedBy === 'alias' ? 2 : 1
943
+ if (ra !== rb) return rb - ra
944
+ return String(a.termId).localeCompare(String(b.termId))
945
+ })
946
+ // query 计数(§3.3 三计数之一):显式查询是注意力,best-effort 记账,失败不影响
947
+ // 结果。只给 active 记账——管理面翻归档(includeArchived)不是"这次检索用到了它",
948
+ // 归档词条不进注入,给它加 query 计数只会污染频率先验。一次批量记账 = 一次锁内
949
+ // CAS 写,不把检索变成 N 次磁盘写。
950
+ const countable = matched.filter(item => (item.status || 'active') === 'active').slice(0, 20)
951
+ if (countable.length) {
952
+ try { lexiconRecordHits(libraryId, countable.map(item => item.termId), 'query') } catch (error) {
953
+ warn(`lexicon query accounting failed: ${error?.message || error}`)
954
+ }
955
+ }
956
+ return matched
957
+ }
958
+
959
+ /**
960
+ * B 通道的渲染与成本共用同一把尺:cooldownAndBudget 按 estTokens(line) 扣预算。
961
+ * “词典也是标题”(§2.2):term 是标题(≤40 字),meaning/usage 只给短义(≤80 字),
962
+ * 不展开 usage/examples——注入行永远是「标题 + 短释义」。
963
+ */
964
+ export function lexiconTurnLine(hit) {
965
+ const body = clip(String(hit.meaning || hit.usage || ''), 80)
966
+ return `「${clip(String(hit.term || ''), 40)}」${body ? `:${body}` : ''}`
967
+ }
968
+
969
+ /**
970
+ * 冷却 + 每回合预算(§3.3):冷却内的词条仍出现在 hits 里(可计 candidate),
971
+ * 但不进 selected(不计 injection)。预算按渲染行的 estTokens 顺序扣,扣不动就停。
972
+ * bindingState 传 sessionId(string)或带 sessionId 的 binding 对象。
973
+ */
974
+ export function cooldownAndBudget(hits, bindingState, tokenBudget) {
975
+ const sessionKey = typeof bindingState === 'string' ? bindingState : String(bindingState?.sessionId || '')
976
+ const now = Date.now()
977
+ const cooldownMs = LEXICON_RECALL_COOLDOWN_MINUTES * 60_000
978
+ const last = lexiconCooldownStore.get(sessionKey)
979
+ const selected = []
980
+ const onCooldown = []
981
+ let used = 0
982
+ const budget = Math.max(0, Number(tokenBudget) || 0)
983
+ for (const hit of hits) {
984
+ const injectedAt = last?.get(hit.termId) || 0
985
+ if (injectedAt && now - injectedAt < cooldownMs) { onCooldown.push(hit); continue }
986
+ if (selected.length >= LEXICON_MAX_PER_TURN) continue
987
+ const cost = estTokens(lexiconTurnLine(hit)) + (selected.length ? 1 : 0)
988
+ if (used + cost > budget) continue
989
+ used += cost
990
+ selected.push(hit)
991
+ }
992
+ return { selected, onCooldown, used }
993
+ }
994
+
995
+ /** 注入真的发生之后才调用:记冷却,下一次同词在冷却窗口内只做 candidate。 */
996
+ export function lexiconMarkInjected(bindingKey, termIds, now = Date.now()) {
997
+ const sessionKey = String(bindingKey || '')
998
+ if (!sessionKey || !Array.isArray(termIds) || termIds.length === 0) return
999
+ let last = lexiconCooldownStore.get(sessionKey)
1000
+ if (!last) { last = new Map(); lexiconCooldownStore.set(sessionKey, last) }
1001
+ for (const termId of termIds) last.set(termId, now)
1002
+ // Map 只增不减会陪着进程活着变大;词条冷却窗口 30 分钟,过期键在读取时本就无效,
1003
+ // 这里顺手把整本已过期的 session 键清掉,成本 O(sessions)。
1004
+ if (lexiconCooldownStore.size > 512) {
1005
+ for (const [key, value] of lexiconCooldownStore) {
1006
+ const freshest = Math.max(...value.values())
1007
+ if (now - freshest >= cooldownWindowMs()) lexiconCooldownStore.delete(key)
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ function cooldownWindowMs() { return LEXICON_RECALL_COOLDOWN_MINUTES * 60_000 }
1013
+
1014
+ export function lexiconOnCooldown(bindingKey, termId, now = Date.now()) {
1015
+ const last = lexiconCooldownStore.get(String(bindingKey || ''))?.get(termId) || 0
1016
+ return last > 0 && now - last < cooldownWindowMs()
1017
+ }
1018
+
1019
+ /**
1020
+ * 三计数分开累加(§3.3):candidate(进过匹配结果)/ injection(真的注入了)/
1021
+ * query(显式检索命中)。写路径走 mutateLexicon(锁内 CAS + revision+1)。
1022
+ */
1023
+ export function lexiconRecordHit(libraryId, termId, kindOfHit) {
1024
+ if (!['candidate', 'injection', 'query'].includes(kindOfHit)) {
1025
+ throw lexiconError('LEXICON_INVALID_HIT_KIND', 'hit kind must be candidate, injection or query')
1026
+ }
1027
+ const result = lexiconRecordHits(libraryId, [termId], kindOfHit)
1028
+ return { termId, hitCounts: result.counts[termId] }
1029
+ }
1030
+
1031
+ /** 一次锁内写累加多个词条的同一类计数;未知词条抛 LEXICON_NOT_FOUND。 */
1032
+ export function lexiconRecordHits(libraryId, termIds, kindOfHit) {
1033
+ return mutateLexicon(libraryId, (lex) => {
1034
+ const counts = {}
1035
+ for (const termId of termIds) {
1036
+ const entry = lex.terms[termId]
1037
+ if (!entry) throw lexiconError('LEXICON_NOT_FOUND', `lexicon term ${termId} does not exist in ${libraryId}`)
1038
+ entry.hitCounts = entry.hitCounts && typeof entry.hitCounts === 'object'
1039
+ ? entry.hitCounts
1040
+ : { candidate: 0, injection: 0, query: 0 }
1041
+ // 计数上限(§6.3 HIT_COUNT_CAP):累加后 clamp 到 1,000,000——既有值越界
1042
+ // (手工修复/损坏数据)也在同一步被拉回界内,effective 因子的输入永远有界。
1043
+ for (const key of ['candidate', 'injection', 'query']) {
1044
+ entry.hitCounts[key] = Math.min(HIT_COUNT_CAP, Math.max(0, Number(entry.hitCounts[key]) || 0))
1045
+ }
1046
+ entry.hitCounts[kindOfHit] = Math.min(HIT_COUNT_CAP, (Number(entry.hitCounts[kindOfHit]) || 0) + 1)
1047
+ if (kindOfHit === 'injection') entry.lastInjectedAt = stamp()
1048
+ counts[termId] = { ...entry.hitCounts }
1049
+ }
1050
+ return { counts }
1051
+ })
1052
+ }
1053
+
1054
+ /**
1055
+ * 锚点摘要(§3.3 通道 A 的数据面):top 8 by strength;敏感词条单列且 ≤2。
1056
+ * 行已按优先级排好——inject.js 超 300 token 时从尾部丢,丢的永远是最弱的一条。
1057
+ *
1058
+ * visibility 与记忆同一套语义(lib/memory.js visibleToSession 的契约:secret 只是
1059
+ * "不主动浮出",不改变"被点名时"的可见性):锚点是主动浮出,secret 词条不进摘要;
1060
+ * B 通道是用户消息点到了词面(等价于按词点名),secret 词条仍然命中并解释。
1061
+ */
1062
+ export function lexiconAnchorSummary(libraryId, memoryContext) {
1063
+ const visible = visibleLexiconTerms(libraryId, memoryContext)
1064
+ const anchorable = visible.filter(entry => entry.visibility !== 'secret')
1065
+ const sensitive = anchorable.filter(entry => entry.sensitivity === 'sensitive')
1066
+ const normal = anchorable.filter(entry => entry.sensitivity !== 'sensitive')
1067
+ const line = entry => `「${clip(String(entry.term || ''), 40)}」(${entry.kind})${entry.meaning ? ` ${clip(String(entry.meaning), 80)}` : ''}`
1068
+ const byStrength = list => [...list].sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0)
1069
+ || String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))
1070
+ || String(a.termId).localeCompare(String(b.termId)))
1071
+ return {
1072
+ lines: byStrength(normal).slice(0, 8).map(line),
1073
+ sensitiveTriggers: byStrength(sensitive).slice(0, 2).map(line),
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * legacy 生命周期迁移(§2.1):旧版 pending 在当前生命周期不再存在,读到就跑一次收口。
1079
+ * * pending 且 !revisionOf(老提案)→ active;敏感词 → archived(敏感词只能由用户
1080
+ * 显式管理,不能因为迁移悄悄进入注入面)。legacy 数据缺 sensitivity 的 trigger
1081
+ * 默认按 sensitive 处理(§1.2 的缺省值),同样归档。
1082
+ * * pending 且 revisionOf(老修订草稿)→ archived:目标词条不动,草稿留审计。
1083
+ * * rejected → 原样保留(内部墓碑 + fingerprint 不变)。
1084
+ * 只写有变化的文件:没有 pending 时直接返回,不 bump revision(迁移是巡检常客,
1085
+ * 空转写盘会让 revision 变成噪声,也会把没改过的文件推成热文件)。
1086
+ * 返回 { libraryId, migrated, details? };损坏/读失败仍是 typed error(调用方决定
1087
+ * 是否吞掉——lib/migrate.js 的巡检按库 catch)。
1088
+ */
1089
+ export function migrateLegacyLexiconStatuses(libraryId) {
1090
+ // 快速路径:索引已经知道有没有 legacy pending;没有就不进锁、不写盘。
1091
+ const index = lexiconIndex(libraryId)
1092
+ if (!index.pendingEntries.length) return { libraryId, migrated: 0 }
1093
+ const outcome = mutateLexicon(libraryId, (lex) => {
1094
+ const now = stamp()
1095
+ const details = []
1096
+ for (const [termId, entry] of Object.entries(lex.terms)) {
1097
+ if ((entry.status || 'active') !== 'pending') continue
1098
+ // trigger 的 sensitivity 缺省是 sensitive(§1.2);legacy 数据可能没写这个字段。
1099
+ const sensitive = entry.sensitivity === 'sensitive'
1100
+ || (entry.kind === 'trigger' && entry.sensitivity !== 'normal')
1101
+ const next = entry.revisionOf || sensitive ? 'archived' : 'active'
1102
+ entry.status = next
1103
+ entry.migratedFromStatus = 'pending'
1104
+ entry.statusMigratedAt = now
1105
+ details.push({
1106
+ termId,
1107
+ status: next,
1108
+ reason: entry.revisionOf ? 'revision-draft' : sensitive ? 'sensitive' : 'proposal',
1109
+ ...(entry.revisionOf ? { revisionOf: entry.revisionOf } : {}),
1110
+ })
1111
+ }
1112
+ return { changed: details.length > 0, migrated: details.length, details }
1113
+ }, { onlyWhenChanged: true })
1114
+ return { libraryId, migrated: outcome.migrated, details: outcome.details }
1115
+ }