dsh-memu-memory 1.2.0 → 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.
- package/AGENTS.md +1 -1
- package/README.md +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +324 -0
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +191 -2
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lexicon-dedupe.mjs — 历史重复词条迁移工具(docs/lexicon-design.md §10 P2)
|
|
3
|
+
//
|
|
4
|
+
// 背景:词条冲突检查(findScopeCollision)落地之前的历史数据可能存在「同一作用域
|
|
5
|
+
// 内同规范词多条」的重复。本工具扫描 data/lexicon/*.json,按 normalizedTerm +
|
|
6
|
+
// sameFactContext 作用域找重复组,dry-run(默认)只打印报告,--apply 才执行合并。
|
|
7
|
+
//
|
|
8
|
+
// 判定与核心写入侧(lib/lexicon.js findScopeCollision)同口径:
|
|
9
|
+
// * 组键 = normalizedTerm + IDENTITIES(channelId/speakerId/characterId/worldId/
|
|
10
|
+
// branchId),与 lib/context.js sameFactContext 完全一致;knownBy 是可见性边界
|
|
11
|
+
// (contextVisible),不算作用域。
|
|
12
|
+
// * archived 条目不参与:归档不占用冲突位置(与核心一致)。
|
|
13
|
+
// * pending 且带 revisionOf 的条目不参与:它是某个 active 词条的待审修订草稿,
|
|
14
|
+
// 与其目标同词同 scope 是设计内的,不是重复。
|
|
15
|
+
//
|
|
16
|
+
// 保留规则:status active > pending > rejected(未知 status 按 rejected 档);
|
|
17
|
+
// 同档 updatedAt 新者优先;再按 termId 字典序收尾(结果稳定、可复算)。
|
|
18
|
+
// 合并规则(写入前按核心 lib/lexicon.js 的 §1.2 上限裁剪):
|
|
19
|
+
// * meaning 取组内最长(clip 500);usage 保留保留条目的;
|
|
20
|
+
// * aliases / examples 并集去重(按规范化形式 / 原文),clip 8×80 / 5×200;
|
|
21
|
+
// * strength / confidence 取 max;evidence 合并去重,clip 10 条 × quote 200;
|
|
22
|
+
// * hitCounts 三计数求和(审计历史不丢),lastInjectedAt 取最新;
|
|
23
|
+
// * status / kind / sensitivity / visibility / context 沿用保留条目(context 含
|
|
24
|
+
// knownBy,不并集——并集会放宽可见边界,交集会无故收紧,都不是"合并"该做的事);
|
|
25
|
+
// * createdAt 取组内最早(首次出现的时间);updatedAt = 本次合并时间;
|
|
26
|
+
// * 被并条目删除前,其 id 记入保留条目的 mergedFrom: [](可审计);指向被并
|
|
27
|
+
// 条目的 rejected 指纹改指保留条目(拒绝记忆不丢)。
|
|
28
|
+
// active+sensitive 的词条永不触碰:所在组整体跳过并报告。
|
|
29
|
+
//
|
|
30
|
+
// 用法:node scripts/lexicon-dedupe.mjs [--apply] [--library <id>]
|
|
31
|
+
// --apply 执行合并(默认 dry-run 只报告)
|
|
32
|
+
// --library <id> 只处理指定库(默认扫 data/lexicon/ 下全部安全命名的文件)
|
|
33
|
+
import fs from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { pathToFileURL } from 'node:url'
|
|
36
|
+
import { dataDir, readJsonStrict, readFailure, writeJson, withFileLock, isSafeId, shortHash, stamp } from '../lib/paths.js'
|
|
37
|
+
|
|
38
|
+
// 与 lib/context.js IDENTITIES 同组:作用域身份字段(knownBy 不在内)。
|
|
39
|
+
const IDENTITIES = ['channelId', 'speakerId', 'characterId', 'worldId', 'branchId']
|
|
40
|
+
// 核心 §1.2 上限(与 lib/lexicon.js 常量一致;这里不复用模块私有常量,写死并注释同源)。
|
|
41
|
+
const CAPS = { meaning: 500, usage: 300, aliases: 8, alias: 80, examples: 5, example: 200, evidence: 10, evidenceQuote: 200 }
|
|
42
|
+
const STATUS_RANK = { active: 3, pending: 2, rejected: 1 }
|
|
43
|
+
const STATUS_LABEL = { active: 'active', pending: 'pending', rejected: 'rejected' }
|
|
44
|
+
|
|
45
|
+
function clip(text, max) {
|
|
46
|
+
const s = String(text ?? '')
|
|
47
|
+
return s.length > max ? s.slice(0, max) : s
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 严格读取(与 lib/lexicon.js readLexiconStrict 同口径的本地副本,不走读缓存)。 */
|
|
51
|
+
function strictReadLexicon(file) {
|
|
52
|
+
const failure = readFailure(file)
|
|
53
|
+
if (failure) throw new Error(`write-protected by an earlier read failure (${failure.code || failure.message})`)
|
|
54
|
+
const result = readJsonStrict(file)
|
|
55
|
+
if (result.missing) return null
|
|
56
|
+
if (!result.ok) throw new Error(`could not be read (${result.kind === 'corrupt' ? 'invalid JSON' : result.error?.code || 'read failed'})`)
|
|
57
|
+
const value = result.value
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
59
|
+
|| (value.version !== undefined && value.version !== 1)
|
|
60
|
+
|| !value.terms || typeof value.terms !== 'object' || Array.isArray(value.terms)) {
|
|
61
|
+
throw new Error('has an invalid data shape')
|
|
62
|
+
}
|
|
63
|
+
for (const [termId, entry] of Object.entries(value.terms)) {
|
|
64
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
65
|
+
throw new Error(`entry ${termId} is not an object`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function scopeKeyOf(entry) {
|
|
72
|
+
return IDENTITIES.map(key => String(entry?.context?.[key] || '')).join('\u0001')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function scopeLabelOf(entry) {
|
|
76
|
+
const parts = IDENTITIES.filter(key => entry?.context?.[key]).map(key => `${key}=${entry.context[key]}`)
|
|
77
|
+
return parts.length ? parts.join(', ') : '(全场景)'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 判定词条是否参与重复分组。返回 null 表示排除(排除原因用于报告)。
|
|
82
|
+
*/
|
|
83
|
+
function participation(entry) {
|
|
84
|
+
const status = entry.status || 'active'
|
|
85
|
+
if (status === 'archived') return 'archived(不占用冲突位置,跳过)'
|
|
86
|
+
if (status === 'pending' && entry.revisionOf) return 'pending revision 草稿(active 词条的待审修订,跳过)'
|
|
87
|
+
if (!String(entry.normalizedTerm || '')) return '缺少 normalizedTerm,跳过'
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 扫描一本词典,返回 duplicate 组列表(每组 ≥2 条可参与成员)。 */
|
|
92
|
+
export function scanDuplicateGroups(data) {
|
|
93
|
+
const groups = new Map()
|
|
94
|
+
for (const [termId, entry] of Object.entries(data.terms || {})) {
|
|
95
|
+
const excluded = participation(entry)
|
|
96
|
+
if (excluded) continue
|
|
97
|
+
const key = `${String(entry.normalizedTerm)}\u0000${scopeKeyOf(entry)}`
|
|
98
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
99
|
+
groups.get(key).push({ termId, entry })
|
|
100
|
+
}
|
|
101
|
+
return [...groups.values()].filter(members => members.length >= 2)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 保留规则:active > pending > rejected → updatedAt 新者 → termId 字典序。 */
|
|
105
|
+
function keptFirst(a, b) {
|
|
106
|
+
const ra = STATUS_RANK[a.entry.status || 'active'] || 1
|
|
107
|
+
const rb = STATUS_RANK[b.entry.status || 'active'] || 1
|
|
108
|
+
if (ra !== rb) return rb - ra
|
|
109
|
+
const ta = String(a.entry.updatedAt || ''), tb = String(b.entry.updatedAt || '')
|
|
110
|
+
if (ta !== tb) return tb.localeCompare(ta)
|
|
111
|
+
return String(a.termId).localeCompare(String(b.termId))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 为一个组产出合并计划;含 active+sensitive 成员的组整组跳过。 */
|
|
115
|
+
export function planGroup(members) {
|
|
116
|
+
const sensitiveMember = members.find(m => (m.entry.status || 'active') === 'active' && m.entry.sensitivity === 'sensitive')
|
|
117
|
+
if (sensitiveMember) {
|
|
118
|
+
return { skip: true, reason: `含 active+sensitive 词条 ${sensitiveMember.termId},工具永不触碰,整组跳过` }
|
|
119
|
+
}
|
|
120
|
+
const ordered = [...members].sort(keptFirst)
|
|
121
|
+
const kept = ordered[0]
|
|
122
|
+
const removed = ordered.slice(1)
|
|
123
|
+
const keptEntry = kept.entry
|
|
124
|
+
const normalizedTerm = String(keptEntry.normalizedTerm)
|
|
125
|
+
|
|
126
|
+
// meaning 取组内最长;usage 沿用保留条目。
|
|
127
|
+
let meaning = String(keptEntry.meaning || '')
|
|
128
|
+
for (const { entry } of removed) {
|
|
129
|
+
if (String(entry.meaning || '').length > meaning.length) meaning = String(entry.meaning || '')
|
|
130
|
+
}
|
|
131
|
+
meaning = clip(meaning.trim(), CAPS.meaning)
|
|
132
|
+
|
|
133
|
+
// aliases/examples 并集去重(aliases 按规范化形式去重,examples 按原文去重)。
|
|
134
|
+
const aliases = []
|
|
135
|
+
const seenAlias = new Set()
|
|
136
|
+
for (const { entry } of ordered) {
|
|
137
|
+
for (const alias of (Array.isArray(entry.aliases) ? entry.aliases : [])) {
|
|
138
|
+
const text = clip(String(alias ?? '').trim(), CAPS.alias)
|
|
139
|
+
const normalized = text.normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
|
|
140
|
+
if (!text || normalized === normalizedTerm || seenAlias.has(normalized)) continue
|
|
141
|
+
seenAlias.add(normalized)
|
|
142
|
+
aliases.push(text)
|
|
143
|
+
if (aliases.length >= CAPS.aliases) break
|
|
144
|
+
}
|
|
145
|
+
if (aliases.length >= CAPS.aliases) break
|
|
146
|
+
}
|
|
147
|
+
const examples = []
|
|
148
|
+
const seenExample = new Set()
|
|
149
|
+
for (const { entry } of ordered) {
|
|
150
|
+
for (const example of (Array.isArray(entry.examples) ? entry.examples : [])) {
|
|
151
|
+
const text = clip(String(example ?? '').trim(), CAPS.example)
|
|
152
|
+
if (!text || seenExample.has(text)) continue
|
|
153
|
+
seenExample.add(text)
|
|
154
|
+
examples.push(text)
|
|
155
|
+
if (examples.length >= CAPS.examples) break
|
|
156
|
+
}
|
|
157
|
+
if (examples.length >= CAPS.examples) break
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// strength/confidence 取 max;evidence 合并去重;三计数求和;lastInjectedAt 取最新。
|
|
161
|
+
const numOf = value => (Number.isFinite(Number(value)) ? Number(value) : null)
|
|
162
|
+
let strength = numOf(keptEntry.strength)
|
|
163
|
+
let confidence = numOf(keptEntry.confidence)
|
|
164
|
+
const hitCounts = { candidate: 0, injection: 0, query: 0 }
|
|
165
|
+
let lastInjectedAt = keptEntry.lastInjectedAt || null
|
|
166
|
+
let createdAt = String(keptEntry.createdAt || '')
|
|
167
|
+
const evidence = []
|
|
168
|
+
const seenEvidence = new Set()
|
|
169
|
+
for (const { entry } of ordered) {
|
|
170
|
+
strength = Math.max(strength ?? 0, numOf(entry.strength) ?? 0)
|
|
171
|
+
confidence = Math.max(confidence ?? 0, numOf(entry.confidence) ?? 0)
|
|
172
|
+
for (const key of Object.keys(hitCounts)) {
|
|
173
|
+
hitCounts[key] += Number(entry.hitCounts?.[key]) || 0
|
|
174
|
+
}
|
|
175
|
+
if (entry.lastInjectedAt && (!lastInjectedAt || String(entry.lastInjectedAt) > String(lastInjectedAt))) {
|
|
176
|
+
lastInjectedAt = entry.lastInjectedAt
|
|
177
|
+
}
|
|
178
|
+
const entryCreated = String(entry.createdAt || '')
|
|
179
|
+
if (entryCreated && (!createdAt || entryCreated < createdAt)) createdAt = entryCreated
|
|
180
|
+
for (const item of (Array.isArray(entry.evidence) ? entry.evidence : [])) {
|
|
181
|
+
if (!item || typeof item !== 'object') continue
|
|
182
|
+
const quote = clip(typeof item.quote === 'string' ? item.quote : '', CAPS.evidenceQuote)
|
|
183
|
+
const id = clip(typeof item.id === 'string' ? item.id : '', 160)
|
|
184
|
+
const kind = clip(typeof item.kind === 'string' ? item.kind : 'message', 40)
|
|
185
|
+
if (!quote.trim() && !id) continue
|
|
186
|
+
const sig = JSON.stringify([kind, id, quote])
|
|
187
|
+
if (seenEvidence.has(sig)) continue
|
|
188
|
+
seenEvidence.add(sig)
|
|
189
|
+
evidence.push({ kind, id, quote })
|
|
190
|
+
if (evidence.length >= CAPS.evidence) break
|
|
191
|
+
}
|
|
192
|
+
if (evidence.length >= CAPS.evidence) break
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const merged = {
|
|
196
|
+
...keptEntry,
|
|
197
|
+
meaning,
|
|
198
|
+
aliases,
|
|
199
|
+
normalizedAliases: aliases.map(alias => alias.normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')).filter(Boolean),
|
|
200
|
+
examples,
|
|
201
|
+
strength: strength ?? 0.6,
|
|
202
|
+
confidence: confidence ?? 0.8,
|
|
203
|
+
evidence,
|
|
204
|
+
hitCounts,
|
|
205
|
+
lastInjectedAt,
|
|
206
|
+
createdAt: createdAt || keptEntry.createdAt,
|
|
207
|
+
fingerprint: keptEntry.fingerprint || shortHash(normalizedTerm),
|
|
208
|
+
// 被并条目的 id 记入保留条目,历史可审计。
|
|
209
|
+
mergedFrom: [...(Array.isArray(keptEntry.mergedFrom) ? keptEntry.mergedFrom : []), ...removed.map(item => item.termId)],
|
|
210
|
+
}
|
|
211
|
+
return { skip: false, keptId: kept.termId, keptEntry, removeIds: removed.map(item => item.termId), merged }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 把计划应用到词典对象上(CAS:调用方必须在 withFileLock 内对最新读取执行)。 */
|
|
215
|
+
export function applyPlans(data, plans, now = stamp()) {
|
|
216
|
+
for (const plan of plans) {
|
|
217
|
+
if (plan.skip) continue
|
|
218
|
+
for (const removeId of plan.removeIds) {
|
|
219
|
+
const removed = data.terms[removeId]
|
|
220
|
+
if (removed?.fingerprint && data.rejected && data.rejected[removed.fingerprint]?.termId === removeId) {
|
|
221
|
+
// 拒绝指纹改指保留条目:同一规范词的 model-proposal 依旧被拦。
|
|
222
|
+
data.rejected[removed.fingerprint].termId = plan.keptId
|
|
223
|
+
}
|
|
224
|
+
delete data.terms[removeId]
|
|
225
|
+
}
|
|
226
|
+
data.terms[plan.keptId] = { ...plan.merged, updatedAt: now }
|
|
227
|
+
}
|
|
228
|
+
data.revision = (Number(data.revision) || 0) + 1
|
|
229
|
+
data.version = 1
|
|
230
|
+
return data
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function listLibraryFiles(onlyId) {
|
|
234
|
+
const dir = path.join(dataDir(), 'lexicon')
|
|
235
|
+
if (onlyId) {
|
|
236
|
+
if (!isSafeId(onlyId)) throw new Error(`invalid library id: ${clip(onlyId, 40)}`)
|
|
237
|
+
const file = path.join(dir, `${onlyId}.json`)
|
|
238
|
+
if (!fs.existsSync(file)) throw new Error(`lexicon file not found: ${onlyId}.json`)
|
|
239
|
+
return [{ libraryId: onlyId, file }]
|
|
240
|
+
}
|
|
241
|
+
if (!fs.existsSync(dir)) return []
|
|
242
|
+
return fs.readdirSync(dir)
|
|
243
|
+
.filter(name => name.endsWith('.json') && isSafeId(name.slice(0, -'.json'.length)))
|
|
244
|
+
.sort()
|
|
245
|
+
.map(name => ({ libraryId: name.slice(0, -'.json'.length), file: path.join(dir, name) }))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function reportLibrary({ libraryId, file }, apply) {
|
|
249
|
+
const data = strictReadLexicon(file)
|
|
250
|
+
if (!data) {
|
|
251
|
+
console.log(`${libraryId}: 无文件,跳过`)
|
|
252
|
+
return { groups: 0, merges: 0, skipped: 0 }
|
|
253
|
+
}
|
|
254
|
+
const groups = scanDuplicateGroups(data)
|
|
255
|
+
const plans = groups.map(members => ({ label: `${String(members[0].entry.normalizedTerm)} @ ${scopeLabelOf(members[0].entry)}`, plan: planGroup(members) }))
|
|
256
|
+
const mergePlans = plans.filter(item => !item.plan.skip)
|
|
257
|
+
console.log(`${libraryId}: revision ${data.revision ?? 0},词条 ${Object.keys(data.terms || {}).length} 条,重复组 ${groups.length} 组`)
|
|
258
|
+
for (const { label, plan } of plans) {
|
|
259
|
+
if (plan.skip) {
|
|
260
|
+
console.log(` [跳过] ${label}:${plan.reason}`)
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
console.log(` [合并] ${label}:${plan.removeIds.length + 1} 条 → 保留 ${plan.keptId} (${plan.keptEntry.status || 'active'}, updatedAt ${plan.keptEntry.updatedAt || 'null'})`)
|
|
264
|
+
console.log(` 并入并删除: ${plan.removeIds.join(', ')}`)
|
|
265
|
+
console.log(` meaning 取最长(${plan.merged.meaning.length} 字), aliases 并集 ${plan.merged.aliases.length}, examples 并集 ${plan.merged.examples.length}, strength max ${plan.merged.strength}, evidence ${plan.merged.evidence.length} 条`)
|
|
266
|
+
console.log(` mergedFrom 记入: [${plan.merged.mergedFrom.join(', ')}]`)
|
|
267
|
+
}
|
|
268
|
+
if (groups.length === 0) console.log(' 无历史重复')
|
|
269
|
+
if (apply && mergePlans.length) {
|
|
270
|
+
withFileLock(file, () => {
|
|
271
|
+
// CAS:锁内重读最新落盘内容再套用计划,plan 基于「扫描时快照」之外的变化重算。
|
|
272
|
+
const fresh = strictReadLexicon(file)
|
|
273
|
+
if (!fresh) throw new Error('disappeared during apply')
|
|
274
|
+
const freshPlans = scanDuplicateGroups(fresh).map(members => planGroup(members))
|
|
275
|
+
applyPlans(fresh, freshPlans)
|
|
276
|
+
writeJson(file, fresh)
|
|
277
|
+
console.log(` [写入] revision → ${fresh.revision},词条 ${Object.keys(fresh.terms).length} 条`)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
return { groups: groups.length, merges: apply ? mergePlans.length : 0, skipped: plans.filter(item => item.plan.skip).length }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function main() {
|
|
284
|
+
const args = process.argv.slice(2)
|
|
285
|
+
const apply = args.includes('--apply')
|
|
286
|
+
const libraryFlagIndex = args.indexOf('--library')
|
|
287
|
+
const onlyId = libraryFlagIndex >= 0 ? String(args[libraryFlagIndex + 1] || '').trim() : null
|
|
288
|
+
if (args.some(arg => arg === '--help' || arg === '-h')) {
|
|
289
|
+
console.log('用法: node scripts/lexicon-dedupe.mjs [--apply] [--library <id>]')
|
|
290
|
+
console.log(' 默认 dry-run 只打印报告;--apply 才执行合并。')
|
|
291
|
+
process.exit(0)
|
|
292
|
+
}
|
|
293
|
+
console.log(apply ? 'lexicon-dedupe: --apply(将执行合并)' : 'lexicon-dedupe: dry-run(加 --apply 执行合并)')
|
|
294
|
+
let files
|
|
295
|
+
try {
|
|
296
|
+
files = listLibraryFiles(onlyId)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error(`lexicon-dedupe: ${String(error?.message || error)}`)
|
|
299
|
+
process.exit(2)
|
|
300
|
+
}
|
|
301
|
+
let totals = { groups: 0, merges: 0, skipped: 0 }
|
|
302
|
+
for (const entry of files) {
|
|
303
|
+
try {
|
|
304
|
+
const result = reportLibrary(entry, apply)
|
|
305
|
+
totals.groups += result.groups
|
|
306
|
+
totals.merges += result.merges
|
|
307
|
+
totals.skipped += result.skipped
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.log(`${entry.libraryId}: 读取失败,跳过(${String(error?.message || error)})`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
console.log(`汇总: 重复组 ${totals.groups},${apply ? `已合并 ${totals.merges}` : 'dry-run 未写入'},含 active+sensitive 跳过 ${totals.skipped} 组`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* istanbul ignore next */
|
|
316
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
317
|
+
main()
|
|
318
|
+
}
|