@xia-sc/dsh-cc-studio 0.9.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,624 @@
1
+ /**
2
+ * 卡组层(0.10.0)—— 「人物关系分组 + 组级共享世界书」的**唯一口径**。
3
+ *
4
+ * 为什么要有这个文件(一句话):共享书把一部分世界设定挪到了「组」上之后,每个工具都自己写了一遍
5
+ * 「取作用域 → 数条目 → 拼数字」,于是连着两轮出现「改了四个、漏了第五个」(见 CHANGELOG 0.9.0/0.9.1)。
6
+ * 根因不是谁手滑,而是**同一个语义存在多份实现**:卡组数据读取两半各一份、取作用域每个工具各一遍、
7
+ * 卡组级统计与 lorebookMetrics 平行两套。
8
+ *
9
+ * 所以这里放**纯函数**(零 IO、零环境变量、不读 DSH_HOME):
10
+ * · assembleScope() —— 卡组作用域组装(两半各自读盘后都交给它,形状只有一种)
11
+ * · groupMetrics() —— 卡内 / 成员卡 / 共享书 / 合计 的分段统计(数字只在这里产生)
12
+ * · auditLoreEntries() —— 条目逐条质量(一把尺子,卡内与共享书共用)
13
+ * · worstCaseTrigger() —— 最坏情况单次触发量
14
+ * 两半(lib/index.js 宿主半、lib/agent.js 预设半)**都 import 这个文件**,而不再各自抄一份:
15
+ * 以前「两半同构」是靠测试逐字比对两份拷贝维持的,改一处漏一处就是线上分叉。
16
+ * ⚠️ 落盘根(dataRoots/libDirs/groupDirs/bookDirs)与读写盘**不在这里** —— 那部分仍是两半各自的
17
+ * 同构实现(见 AGENTS.md §2.6),本文件只吃「已经读到的数据」。
18
+ * ⚠️ 这里是**基础统计与体检**的唯一实现;各工具只在其上做自己的派生(文案、预算、建议),
19
+ * 不许再自己 for 循环数条目 —— 那正是这一层要消灭的东西。
20
+ */
21
+
22
+ /** 关系图谱的三个常量(曾同时存在于两半;现在两半都从这里取,改一处就够)。 */
23
+ const REL_TYPES = ["family","romance","mentor","friend","colleague","rival","enemy","debt","superior","other"];
24
+ const REL_TYPE_LABELS = { family:"亲属", romance:"恋慕", mentor:"师徒", friend:"挚友", colleague:"同门", rival:"竞争", enemy:"敌对", debt:"恩义", superior:"上下级", other:"其他" };
25
+ const REL_BEGIN = "<!-- cc-relations:begin";
26
+ const REL_END = "<!-- cc-relations:end -->";
27
+
28
+ /** 五维(世界观)与 autoLorebook 生成的条目名(旧数据没有 cc_auto 标记,靠名字兜底)。 */
29
+ const WORLD_DIMS = ["timeline","factions","geo","power","daily"];
30
+ const WORLD_DIM_LABELS = { timeline:"年表", factions:"势力", geo:"地理", power:"力量体系", daily:"日常" };
31
+ const AUTO_NAMES = new Set(["年表","势力","地理","力量体系","日常"]);
32
+
33
+ /** 是不是 autoLorebook 自动生成的条目(0.5.0:必须看标记,名字只用于旧数据兜底)。 */
34
+ function isAutoEntry(e){
35
+ return !!(e && (e.cc_auto === true || (e.extensions && e.extensions.cc_auto)));
36
+ }
37
+ /** 旧数据(没有 cc_auto 标记)的自动条目:名字是维度名**且**内容/keys 符合自动条目的特征。 */
38
+ function looksLikeLegacyAuto(e){
39
+ if(!e || !AUTO_NAMES.has(String(e.name||""))) return false;
40
+ return /^\s*@@/.test(String(e.content||"")) || Array.isArray(e.keys) && e.keys.some(k => ["timeline","faction","geography","power","daily"].includes(String(k)));
41
+ }
42
+ /** 粗略 token 估算:CJK 一字≈1 token,其余按 4 字符≈1 token。 */
43
+ function estimateTokens(text){
44
+ const s = String(text||"");
45
+ let cjk = 0;
46
+ for(const ch of s){ if(ch.charCodeAt(0) > 0x2E7F) cjk++; }
47
+ const rest = s.length - cjk;
48
+ return Math.ceil(cjk + rest/4);
49
+ }
50
+ function lorebookTokens(entries){
51
+ return (Array.isArray(entries)? entries : []).reduce((n,e) => n + estimateTokens(e && e.content), 0);
52
+ }
53
+ /** 内容签名(折叠空白 + djb2 + 长度):只用来**筛**,真正搬运/删除前一律再逐字比对内容。 */
54
+ function contentKeyOf(entry){
55
+ const s = String((entry && entry.content) != null ? entry.content : "").replace(/\s+/g, " ").trim();
56
+ let h = 5381;
57
+ for(let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
58
+ return "c" + h.toString(36) + "-" + s.length;
59
+ }
60
+
61
+ /**
62
+ * 卡组作用域的**纯组装**(零 IO)。
63
+ *
64
+ * 两半各自负责读盘(各自的 libDirs/groupDirs/bookDirs 与回退语义),读完统一交给它 —— 于是
65
+ * 「模型看到的组」与「界面看到的组」形状只有一种,不会一边有 book 一边没有。
66
+ *
67
+ * 入参:{ groupId, cardId, group(已 normalize), cards(该组成员的库快照行), book(已 normalize),
68
+ * bookRev, include: "summary"|"full", maxCards }
69
+ * ⚠️ members[].entries 是**原始条目数组**(不是计数)—— 计数属于 groupMetrics 的口径,不在这一层产生,
70
+ * 这样「数字从哪来」永远只有一个答案。返回给模型/界面时由调用方决定放摘要还是放全文。
71
+ */
72
+ function assembleScope(input){
73
+ const opt = input || {};
74
+ const include = opt.include === "full" ? "full" : "summary";
75
+ const maxCards = Number(opt.maxCards) > 0 ? Math.floor(Number(opt.maxCards)) : 50;
76
+ const group = opt.group || null;
77
+ const groupId = String((group && group.id) || opt.groupId || "");
78
+ const rows = Array.isArray(opt.cards) ? opt.cards : [];
79
+ const allIds = rows.map(r => String(r.id));
80
+ const memberIds = allIds.slice(0, maxCards);
81
+ const truncated = allIds.length > memberIds.length;
82
+ const byId = new Map(rows.map(r => [String(r.id), r]));
83
+ const members = memberIds.map(id => {
84
+ const row = byId.get(id) || {};
85
+ const draft = row.draft || null;
86
+ const d = (draft && draft.data) ? draft.data : {};
87
+ const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
88
+ const m = {
89
+ id: id,
90
+ name: String(d.name || (row.meta && row.meta.name) || ""),
91
+ groupId: String((row.meta && row.meta.groupId) || ""),
92
+ entries: entries,
93
+ tokenBudget: Number(d.character_book && d.character_book.token_budget) || 2400,
94
+ // 五维(extensions.cc_world):组内世界观漂移检查要它 —— 是在这里顺手带出,而不是让每个
95
+ // 检查自己去翻 draft(summary 模式本来不带 draft,翻不到就会「静默不检查」)。
96
+ world: (d.extensions && d.extensions.cc_world && typeof d.extensions.cc_world === "object") ? d.extensions.cc_world : {},
97
+ };
98
+ if(include === "full") m.draft = draft;
99
+ return m;
100
+ });
101
+ const book = opt.book || null;
102
+ const bookEntries = (book && book.data && Array.isArray(book.data.entries))
103
+ ? book.data.entries.filter(e => e && e.enabled !== false) : [];
104
+ const ext = (book && book.data && book.data.extensions) || {};
105
+ const cc = (ext.cc && typeof ext.cc === "object") ? ext.cc : {};
106
+ return {
107
+ ok: !!(group && groupId),
108
+ groupId: groupId,
109
+ group: group,
110
+ rev: group ? (Number(group.rev) || 0) : 0,
111
+ cardId: String(opt.cardId || ""),
112
+ members: members,
113
+ memberIds: memberIds,
114
+ missing: Array.isArray(opt.missing) ? opt.missing.slice() : [],
115
+ truncated: truncated,
116
+ book: book ? {
117
+ name: String((book.data && book.data.name) || ""),
118
+ rev: Number.isFinite(Number(opt.bookRev)) ? Number(opt.bookRev) : 0,
119
+ entries: bookEntries,
120
+ lastPromote: (cc.lastPromote && typeof cc.lastPromote === "object") ? cc.lastPromote : null,
121
+ // 完整的归一化文档(引用同一份,不额外占内存):书级参数(description / scan_depth / token_budget…)
122
+ // 与「全部条目(含 enabled:false)」只有它上面才有 —— 导出/编辑类调用点需要,摘要不够。
123
+ doc: book,
124
+ } : null,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * 一条世界书条目的基础统计(「段」)。字段名与 0.9.x `lorebookMetrics` 的输出**逐字一致** ——
130
+ * 老调用点与既有断言不许因为这次收口而变。
131
+ */
132
+ function seg(list){
133
+ const l = Array.isArray(list) ? list : [];
134
+ return {
135
+ entries: l.length,
136
+ tokens: lorebookTokens(l),
137
+ constant: l.filter(e => e && e.constant === true).length,
138
+ constantTokens: l.filter(e => e && e.constant === true).reduce((n, e) => n + estimateTokens(e && e.content), 0),
139
+ manual: l.filter(e => !isAutoEntry(e)).length,
140
+ hasDecorator: l.some(e => String((e && e.content) || "").includes("@@")),
141
+ };
142
+ }
143
+ /**
144
+ * 卡组口径:**所有「多少条 / 多少 token」的数字只在这里产生**。
145
+ *
146
+ * 为什么收口(0.9.0/0.9.1 两轮实测的教训):共享书改变了**数据的位置**,却没有改变工具的**度量口径** ——
147
+ * simulate / lint / group_check / preview_export / validate 五个工具各自补了一遍「把共享书算进来」,
148
+ * 于是连着两轮出现「改了四个、漏了第五个」。这个函数就是那唯一一处:它同时给
149
+ * 「当前卡 / 每张成员卡 / 成员卡合计 / 共享书 / 全部」五个范围,各消费点只是挑自己那个范围。
150
+ *
151
+ * 入参:{ cardId?, card: 当前卡条目, members: [{id, name, entries, tokenBudget, world}], book: 共享书条目 }
152
+ * 出参:
153
+ * card / book —— 两段基本统计
154
+ * members.byId / .total —— 每张成员卡 / 成员卡合计
155
+ * group —— 成员卡合计(别名,读起来顺)
156
+ * all —— 当前卡 + 成员卡 + 共享书(群聊时真正会一起进 prompt 的那些)
157
+ * legacy —— 0.9.x `lorebookMetrics` 的形状(card/shared/total/all),供旧调用点
158
+ * derived —— 派生量:群聊固定开销 / 最坏情况单次触发 / 跨卡重复触发词 / 组内五维漂移
159
+ *
160
+ * `opts.derived === false` 跳过派生计算(旧的 lorebookMetrics 包装用它,避免每次白算一遍)。
161
+ * ⚠️ `derived.worstCase` 的范围是**当前卡 + 共享书**(与 0.9.x 的 cc_lint_lorebook 逐字一致);
162
+ * 组级的「成员卡合计最坏情况」由需要它的检查用同一个 worstCaseTrigger() 换范围算 —— 口径同一个,范围各表。
163
+ */
164
+ function groupMetrics(input, opts){
165
+ const opt = input || {};
166
+ const wantDerived = !(opts && opts.derived === false);
167
+ const card = Array.isArray(opt.card) ? opt.card : [];
168
+ const rows = (Array.isArray(opt.members) ? opt.members : []).map(r => ({
169
+ id: String((r && r.id) || ""),
170
+ name: String((r && r.name) || ""),
171
+ entries: (r && Array.isArray(r.entries)) ? r.entries : [],
172
+ tokenBudget: Number(r && r.tokenBudget) || 2400,
173
+ world: (r && r.world && typeof r.world === "object") ? r.world : {},
174
+ }));
175
+ const book = Array.isArray(opt.book) ? opt.book : [];
176
+ const byId = {};
177
+ const memberAll = [];
178
+ rows.forEach(r => { byId[r.id] = seg(r.entries); memberAll.push(...r.entries); });
179
+ const memberSeg = seg(memberAll);
180
+ const cardSeg = seg(card);
181
+ const bookSeg = seg(book);
182
+ const out = {
183
+ card: cardSeg,
184
+ members: { byId: byId, total: memberSeg },
185
+ book: bookSeg,
186
+ group: memberSeg,
187
+ all: seg(card.concat(memberAll, book)),
188
+ legacy: { card: cardSeg, shared: bookSeg, total: seg(card.concat(book)), all: card.concat(book) },
189
+ };
190
+ if(!wantDerived) return out;
191
+ // 群聊每轮固定开销 = 所有成员卡的 constant 之和 + 共享书的 constant(书在群聊里同样每轮插入)。
192
+ // 判据与 0.9.x 的 checkGroup 完全一致(那里就是这两项相加),数字现在是同一份。
193
+ const cardConst = rows.reduce((n, r) => n + byId[r.id].constantTokens, 0);
194
+ const sharedConst = bookSeg.constantTokens;
195
+ const minBudget = rows.length ? rows.reduce((n, r) => Math.min(n, r.tokenBudget), Infinity) : 2400;
196
+ out.derived = {
197
+ groupConstant: {
198
+ card: cardConst, shared: sharedConst, total: cardConst + sharedConst,
199
+ minBudget: minBudget, ratio: minBudget > 0 ? (cardConst + sharedConst) / minBudget : 0,
200
+ },
201
+ worstCase: worstCaseTrigger(card.concat(book)),
202
+ duplicateKeys: duplicateKeyGroups(
203
+ [{ kind: "card", id: String(opt.cardId || ""), entries: card }]
204
+ .concat(rows.map(r => ({ kind: "card", id: r.id, name: r.name, entries: r.entries })))
205
+ .concat([{ kind: "book", id: "", entries: book }])),
206
+ worldDrift: worldDriftOf(rows),
207
+ };
208
+ return out;
209
+ }
210
+ /**
211
+ * 0.9.x 的公开形状(逐字保留):`{card, shared, total, all}`。
212
+ * 现在只是 groupMetrics 的一层薄包装 —— 老调用点(lint / validate / 测试)一个都不用改。
213
+ */
214
+ function lorebookMetrics(cardEntries, sharedEntries){
215
+ return groupMetrics({ card: cardEntries, book: sharedEntries }, { derived: false }).legacy;
216
+ }
217
+ /**
218
+ * 分段统计的**轻量投影**(去掉 `all` 原始条目数组)。
219
+ *
220
+ * 为什么必须瘦身:`workflow` 会随**每一个**工具的返回出去,而 `lorebookMetrics().all` 是条目的
221
+ * 原始数组(含 content 全文)—— 0.10.0 的 LLM 侧回归实测:四卡套里一次
222
+ * `cc_load_from_library(includeDraft:false)` 被它多带出约 1800 token,把「省 token」的初衷整个抵消。
223
+ * 数字(entries / tokens / constant / constantTokens / manual / hasDecorator)一个不少,条目正文一个不带。
224
+ */
225
+ function metricsBrief(m){
226
+ const src = (m && typeof m === "object") ? m : {};
227
+ const seg = (s) => (s && typeof s === "object") ? {
228
+ entries: s.entries, tokens: s.tokens, constant: s.constant,
229
+ constantTokens: s.constantTokens, manual: s.manual, hasDecorator: s.hasDecorator,
230
+ } : null;
231
+ return { card: seg(src.card), shared: seg(src.shared), total: seg(src.total) };
232
+ }
233
+ /**
234
+ * 组内五维漂移:同一维度在多张成员卡上都写了、且**实质不一致**才算。
235
+ * 逐字相同不算(同一份设定复制过去的,很正常);一方是另一方子串也不算(详略不同);
236
+ * 维度内容短于 6 个字符不算(与「五维填了没」的既有判据一致,避免把「TBD」当设定)。
237
+ */
238
+ function worldDriftOf(rows){
239
+ const out = [];
240
+ const list = Array.isArray(rows) ? rows : [];
241
+ WORLD_DIMS.forEach(dim => {
242
+ const hits = list
243
+ .map(r => ({ id: String((r && r.id) || ""), name: String((r && r.name) || ""), text: String(((r && r.world) || {})[dim] || "").trim() }))
244
+ .filter(h => h.text.length >= 6);
245
+ if(hits.length < 2) return;
246
+ const distinct = [];
247
+ hits.forEach(h => {
248
+ if(!distinct.some(x => x.text === h.text || x.text.indexOf(h.text) >= 0 || h.text.indexOf(x.text) >= 0)) distinct.push(h);
249
+ });
250
+ if(distinct.length >= 2) out.push({ dim: dim, cards: distinct });
251
+ });
252
+ return out;
253
+ }
254
+ /**
255
+ * 触发词冲突(并查集口径):同一个词被 ≥2 条条目共用 = 用户一句话会把它们同时带出来。
256
+ * 跨来源一起算(当前卡 / 各成员卡 / 共享书)—— 群聊时它们本来就会一起进 prompt。
257
+ * 大小写不敏感(与 cc_lint_lorebook 的既有口径一致)。
258
+ */
259
+ function duplicateKeyGroups(sources){
260
+ const srcs = (Array.isArray(sources) ? sources : []).map(s => ({
261
+ kind: String((s && s.kind) || "card"),
262
+ id: String((s && s.id) || ""),
263
+ name: String((s && s.name) || ""),
264
+ entries: (s && Array.isArray(s.entries) ? s.entries : []).filter(e => e && e.enabled !== false),
265
+ }));
266
+ const seen = new Map();
267
+ srcs.forEach(s => s.entries.forEach(e => (Array.isArray(e.keys) ? e.keys : []).forEach(k => {
268
+ const kk = String(k).trim().toLowerCase();
269
+ if(!kk) return;
270
+ if(!seen.has(kk)) seen.set(kk, []);
271
+ seen.get(kk).push({ kind: s.kind, id: s.id, cardName: s.name, name: String(e.name || ""), content: String(e.content || "") });
272
+ })));
273
+ const out = [];
274
+ seen.forEach((hits, key) => { if(hits.length > 1) out.push({ key: key, hits: hits }); });
275
+ out.sort((a, b) => b.hits.length - a.hits.length || (a.key < b.key ? -1 : 1));
276
+ return out;
277
+ }
278
+ /**
279
+ * 最坏情况单次触发量(0.7.0,N2;0.10.0 搬到本文件,范围由调用方决定)。
280
+ *
281
+ * 为什么不能只看总量:`token_budget` 管的是**一轮对话**里世界书能塞多少,而真正的坑是
282
+ * 「用户随便一句话被好几个条目同时命中」—— 实测:总量 2272 / 预算 1200 看着只是超一点,
283
+ * 但一句「剑冢到底是什么地方」单独就吃掉 1139 token。
284
+ * 更糟的是它太好「修好」了:把 token_budget 调大,lint 立刻只剩一条 warning,可单次爆炸一点没变。
285
+ *
286
+ * 算法:把**共享同一个触发词的条目**并成一组(传递闭包 —— A/B 共享 x、B/C 共享 y,那么一句话
287
+ * 可以把三条全带出来),再叠加**每轮必插的 constant**,取最大的那组作为最坏情况。
288
+ * 单字符 key 不参与共现("a" 这种会把整本书并成一组,报告全是噪音)。
289
+ */
290
+ function worstCaseTrigger(entries){
291
+ const list = (Array.isArray(entries) ? entries : []).filter(e => e && e.enabled !== false);
292
+ const constantEntries = list.filter(e => e.constant);
293
+ const constantTokens = constantEntries.reduce((n, e) => n + estimateTokens(e && e.content), 0);
294
+ const varEntries = list.filter(e => !e.constant);
295
+ const parent = new Map();
296
+ const find = x => { let r = x; while(parent.get(r) !== r) r = parent.get(r); while(parent.get(x) !== r){ const nx = parent.get(x); parent.set(x, r); x = nx; } return r; };
297
+ varEntries.forEach((e, i) => parent.set(i, i));
298
+ const byKey = new Map();
299
+ varEntries.forEach((e, i) => {
300
+ (Array.isArray(e.keys) ? e.keys : []).forEach(k => {
301
+ const kk = String(k).trim().toLowerCase();
302
+ if(kk.length < 2) return;
303
+ if(!byKey.has(kk)) byKey.set(kk, []);
304
+ byKey.get(kk).push(i);
305
+ });
306
+ });
307
+ byKey.forEach(idxList => { for(let i = 1; i < idxList.length; i++) parent.set(find(idxList[i]), find(idxList[0])); });
308
+ const groups = new Map();
309
+ varEntries.forEach((e, i) => {
310
+ const root = find(i);
311
+ if(!groups.has(root)) groups.set(root, { tokens: 0, names: [] });
312
+ const g = groups.get(root);
313
+ g.tokens += estimateTokens(e && e.content);
314
+ g.names.push(String((e && e.name) || ""));
315
+ });
316
+ let best = { tokens: constantTokens, names: [] };
317
+ groups.forEach(g => {
318
+ if(g.tokens + constantTokens > best.tokens) best = { tokens: g.tokens + constantTokens, names: g.names.slice() };
319
+ });
320
+ // 单条自己就最重的情况已含在上面(没有共享 key 的条目自成一组),这里只把常驻条目的名字补进名单。
321
+ const names = best.names.concat(constantEntries.map(e => String((e && e.name) || "")));
322
+ return { tokens: best.tokens, constantTokens, entries: names };
323
+ }
324
+ /**
325
+ * 世界书条目的**逐条质量检查** —— 一把尺子,卡内与共享书共用。
326
+ *
327
+ * 四类:非 constant 却没 keys / keys 只有维度名 / keys 与正文无交集 / 触发词被多条条目共用。
328
+ *
329
+ * 为什么收口(第五轮报告):这四类此前只写在 `cc_lint_lorebook` 里、只作用于**卡内**,当时的理由是
330
+ * 「共享书条目的质量走 `cc_get_shared_book` 那条线」—— 可那条线上**根本没有这个能力**(只有重复检测),
331
+ * 于是共享书成了没人体检的一格:往书里塞一条同时踩三类的坏条目,两个工具都不出声。
332
+ * 而共享书恰恰**比卡内更需要主动报**:它多卡共用(一条死 key 在**所有**成员卡上都不生效)、
333
+ * 又**没有「卡」这个载体**(用户不会顺手翻到它)。
334
+ *
335
+ * 用法:`auditLoreEntries([{ source:"card", entries }, { source:"shared", entries: sharedEntries }])`。
336
+ * 来源标签可以更细(0.10.0 起组体检会传 `card:<id>` / `book`)—— 单卡调用时的输出与 0.9.x 逐字一致。
337
+ * 触发词冲突**跨来源一起算** —— 同一个词同时命中卡内与共享书的两条,是真实存在的 token 叠加。
338
+ * 每条 issue 带 `entries`(条目名,冲突类则是**冲突的触发词**)、`contentKeys`(便于直接定位改)、
339
+ * 与 `source`(单一来源名 / `mixed`)。
340
+ */
341
+ function auditLoreEntries(groups){
342
+ const gs = (Array.isArray(groups) ? groups : []).map(g => ({
343
+ source: String((g && g.source) || "card"),
344
+ list: (g && Array.isArray(g.entries) ? g.entries : []).filter(e => e && e.enabled !== false),
345
+ }));
346
+ const all = gs.reduce((n, g) => n.concat(g.list), []);
347
+ // 来源按**对象身份**查(以前是 indexOf 线性查找 —— 条目一多就是 O(n²),组体检会把整组条目并起来算)
348
+ const ownerOf = new Map();
349
+ gs.forEach(g => g.list.forEach(e => { if(!ownerOf.has(e)) ownerOf.set(e, g.source); }));
350
+ const sourceOf = (e) => ownerOf.get(e) || "card";
351
+ const srcOf = (items) => { const out = []; items.forEach(e => { const s = sourceOf(e); if(out.indexOf(s) < 0) out.push(s); }); return out.length > 1 ? "mixed" : (out[0] || "card"); };
352
+ const issues = [];
353
+ const push = (level, code, message, items) => issues.push({ level, code, message,
354
+ entries: items.map(e => String((e && e.name) || "")).filter(Boolean),
355
+ contentKeys: items.map(contentKeyOf), source: srcOf(items) });
356
+ const isDimKey = k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k));
357
+ const nonConst = all.filter(e => !e.constant);
358
+ const keyed = nonConst.filter(e => Array.isArray(e.keys) && e.keys.length > 0);
359
+ const noKeys = nonConst.filter(e => !Array.isArray(e.keys) || e.keys.length === 0);
360
+ if(noKeys.length) push("error", "no-keys", noKeys.length + " 条非 constant 条目没有 keys(永远触发不了)", noKeys);
361
+ const dimOnly = keyed.filter(e => e.keys.every(isDimKey));
362
+ if(dimOnly.length) push("error", "dim-only-keys", dimOnly.length + " 条条目的 keys 只有维度名,真实对话不会命中(可用 cc_simulate_trigger 验证)", dimOnly);
363
+ const notInContent = keyed.filter(e => !e.keys.some(k => String(e.content || "").indexOf(String(k)) >= 0));
364
+ if(notInContent.length) push("warn", "key-not-in-content", notInContent.length + " 条条目的 keys 在自身 content 里找不到(可能误填或内容已改)", notInContent);
365
+ const seen = new Map();
366
+ all.forEach(e => (Array.isArray(e.keys) ? e.keys : []).forEach(k => {
367
+ const kk = String(k).toLowerCase();
368
+ if(!seen.has(kk)) seen.set(kk, []);
369
+ const arr = seen.get(kk);
370
+ if(arr.indexOf(e) < 0) arr.push(e);
371
+ }));
372
+ const dup = [...seen.entries()].filter(p => p[1].length > 1);
373
+ if(dup.length){
374
+ const items = [];
375
+ dup.forEach(p => p[1].forEach(e => { if(items.indexOf(e) < 0) items.push(e); }));
376
+ issues.push({ level: "warn", code: "duplicate-keys",
377
+ message: dup.length + " 个触发词被多条条目共用(会同时触发,可能互相打架)",
378
+ entries: dup.map(p => p[0]), // 与既有行为一致:这一项的 entries 是**冲突的触发词**
379
+ contentKeys: items.map(contentKeyOf), source: srcOf(items) });
380
+ }
381
+ return { issues, errorCount: issues.filter(i => i.level === "error").length };
382
+ }
383
+
384
+ /**
385
+ * 跨成员卡找「同一份设定」+「卡内条目与共享书逐字重复」(纯函数,0.9.0 起就有,0.10.0 搬到这里共用)。
386
+ *
387
+ * `candidates`:内容签名相同、出现在 **≥2 张卡**里的条目(动作 = 提升到共享书)。
388
+ * `bookDupes` :卡内条目与共享书**逐字相符**(动作 = 只清理卡内副本,不必再搬进书)。
389
+ * 只报候选、不下结论 —— 同内容不同名、同名不同内容都如实标注(nameMismatch / keysMismatch)。
390
+ * ⚠️ 签名(contentKeyOf)只用来**筛**,判等一律再逐字比对内容 —— 哈希碰撞绝不能变成搬错数据。
391
+ */
392
+ function scanSharedEntries(cards, bookEntries){
393
+ const list = Array.isArray(cards) ? cards : [];
394
+ const seen = new Map();
395
+ list.forEach(c => {
396
+ (Array.isArray(c.entries) ? c.entries : []).forEach(e => {
397
+ if(!String((e && e.content) || "").trim()) return;
398
+ const sig = contentKeyOf(e);
399
+ if(!seen.has(sig)) seen.set(sig, { signature: sig, content: String(e.content), name: String(e.name || ""), keys: (e.keys || []).map(String), constant: e.constant === true, chars: String(e.content).length, cards: [], cardNames: [], nameMismatch: false, keysMismatch: false });
400
+ const g = seen.get(sig);
401
+ if(g.cards.indexOf(c.id) < 0){ g.cards.push(c.id); g.cardNames.push(c.name); }
402
+ if(String(e.name || "") !== g.name) g.nameMismatch = true;
403
+ if(JSON.stringify((e.keys || []).map(String)) !== JSON.stringify(g.keys)) g.keysMismatch = true;
404
+ });
405
+ });
406
+ const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
407
+ .sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
408
+ .map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
409
+ // 第二轮报告 P1:还有一类重复是「卡 ↔ 共享书」—— `cc_apply_shared_book` 回填进卡里的副本。
410
+ // 它和「卡间重复」的**动作不同**:书里已经有了,只需把卡里那份删掉,不必再往书里搬。
411
+ // 判据同样是**逐字相符**(签名只用来筛候选);回填过的、手工抄进卡的都算。
412
+ const bookDupes = [];
413
+ (Array.isArray(bookEntries) ? bookEntries : []).forEach(be => {
414
+ const content = String((be && be.content) == null ? "" : be.content);
415
+ const g = content.trim() ? seen.get(contentKeyOf(be)) : null;
416
+ if(!g || g.content !== content) return;
417
+ bookDupes.push({ kind: "card-vs-book", signature: g.signature, name: String((be && be.name) || g.name || ""),
418
+ content, keys: (be.keys || []).map(String), chars: content.length,
419
+ cards: g.cards.slice(), cardNames: g.cardNames.slice(),
420
+ preview: content.slice(0, 80).replace(/\s+/g, " "), savedChars: content.length * g.cards.length });
421
+ });
422
+ const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
423
+ return { candidates, bookDupes, stats: { cards: list.length, totalEntries,
424
+ duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
425
+ savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
426
+ bookDuplicatedEntries: bookDupes.reduce((n, g) => n + g.cards.length, 0),
427
+ bookDupeCount: bookDupes.length,
428
+ bookSavedChars: bookDupes.reduce((n, g) => n + g.savedChars, 0) } };
429
+ }
430
+
431
+ /**
432
+ * 卡组检查注册表 —— **卡组的下一个新功能就是往这里加一项**(0.10.0 的验收标准)。
433
+ *
434
+ * 为什么做成注册表而不是再多一个工具:0.8/0.9 两轮里,「卡组体检」的能力是长在 checkGroup 里的一个
435
+ * if 序列,而「共享书质量」长在 cc_get_shared_book 里、重复检测又长在 scanSharedEntries 里 ——
436
+ * 三个地方各报各的,用户看到的是三份格式相近、口径不同的清单(issue 的 code / level / refs 各一套)。
437
+ * 现在一份清单、一套 issue 形状、一个 byCheck 计数;加一项检查 = 加一个 code + 一段 run 逻辑,
438
+ * 不需要动任何既有工具的口径(`tests/group-layer-guard.test.mjs` 会钉住这一点)。
439
+ *
440
+ * 每个 code 只归属一个类别 —— 否则 byCheck 会出现「同一个 code 算两次」的糊涂账。
441
+ */
442
+ const GROUP_CHECKS = [
443
+ { id: "structure", title: "图谱结构", codes: ["dangling-edge", "orphan-node", "duplicate-name", "type-unknown"] },
444
+ { id: "relations", title: "卡内关系段", codes: ["unrendered", "stale-render"] },
445
+ { id: "budget", title: "群聊固定开销", codes: ["constant-budget"] },
446
+ { id: "lore-quality", title: "共享书条目质量", codes: ["no-keys", "dim-only-keys", "key-not-in-content", "duplicate-keys"] },
447
+ { id: "cross-card", title: "跨卡一致性", codes: ["cross-card-duplicate-keys", "book-duplicate-in-card", "world-drift", "card-auto-only"] },
448
+ ];
449
+
450
+ /**
451
+ * 跑一组检查(纯函数)。入参:
452
+ * { group(已 normalize), members: [cardId], cards: [{id,name,description,constantTokens,tokenBudget}],
453
+ * sharedEntries, metrics(可选:groupMetrics 的结果,预算类检查的数字来源), opts: { checks: [类别 id] } }
454
+ * 出参:{ issues, errorCount, stats, byCheck, ran }
455
+ *
456
+ * `issues[].refs` 的语义与 0.8/0.9 完全一致(节点 id / 边 id / 卡 id),新增 `check` 字段标明类别。
457
+ * **不传 metrics 时退化为自己求和** —— 这样老的 `checkGroup(group, members, cards, sharedEntries)`
458
+ * 调用点(含既有测试)行为逐字不变。
459
+ */
460
+ function runGroupChecks(input){
461
+ const opt = input || {};
462
+ const g = opt.group || {};
463
+ const nodes = Array.isArray(g.nodes) ? g.nodes : [];
464
+ const edges = Array.isArray(g.edges) ? g.edges : [];
465
+ const rev = Number(g.rev) || 0;
466
+ const cards = Array.isArray(opt.cards) ? opt.cards : [];
467
+ const members = Array.isArray(opt.members) ? opt.members : [];
468
+ const sharedEntries = Array.isArray(opt.sharedEntries) ? opt.sharedEntries : [];
469
+ const metrics = opt.metrics || null;
470
+ // scope(groupScope 的结果):跨卡一致性检查要成员卡的**条目与五维** —— cards 摘要是给关系类检查用的,
471
+ // 里面没有条目。没传 scope 时 cross-card 整类自动跳过(老的 checkGroup 调用点就是这个情形)。
472
+ const scope = opt.scope || null;
473
+ const want = (opt.opts && Array.isArray(opt.opts.checks) && opt.opts.checks.length)
474
+ ? opt.opts.checks.map(String) : GROUP_CHECKS.map(c => c.id);
475
+ const on = (id) => want.indexOf(id) >= 0;
476
+ const issues = [];
477
+ const add = (check, level, code, message, refs) => issues.push({ check: check, level: level, code: code, message: message, refs: refs || [] });
478
+
479
+ const nodeIds = new Set(nodes.map(n => n.id));
480
+ const byId = new Map(cards.map(c => [c.id, c]));
481
+ const memberCards = members.map(m => byId.get(m)).filter(Boolean);
482
+
483
+ // —— 图谱结构(0.8.0 起就在报的四类)——
484
+ if(on("structure")){
485
+ const dangling = edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
486
+ if(dangling.length) add("structure", "error", "dangling-edge", dangling.length + " 条关系的端点节点已不存在(数据可能被外部改坏)", dangling.map(e => e.id));
487
+ const orphans = nodes.filter(n => n.kind === "card" && n.cardId && !byId.has(n.cardId));
488
+ if(orphans.length) add("structure", "warn", "orphan-node", orphans.length + " 个节点指向的卡已不在库里:" + orphans.map(n => n.name).join("、"), orphans.map(n => n.id));
489
+ const seen = new Map();
490
+ nodes.forEach(n => [n.name].concat(n.aliases || []).forEach(x => {
491
+ const k = String(x || "").toLowerCase();
492
+ if(!k) return;
493
+ if(!seen.has(k)) seen.set(k, []);
494
+ if(seen.get(k).indexOf(n.id) < 0) seen.get(k).push(n.id);
495
+ }));
496
+ const dup = [...seen.entries()].filter(p => p[1].length > 1);
497
+ if(dup.length) add("structure", "error", "duplicate-name", dup.length + " 组名字/别名落在多个节点上(渲染会指向同一个人):" + dup.map(p => p[0]).join("、"), []);
498
+ const unknown = edges.filter(e => REL_TYPES.indexOf(e.type) < 0);
499
+ if(unknown.length) add("structure", "warn", "type-unknown", unknown.length + " 条关系用了未知类型", unknown.map(e => e.id));
500
+ }
501
+
502
+ // —— 卡内【关系】段(渲染过没有 / 是否落后于图谱 rev)——
503
+ if(on("relations")){
504
+ const unrendered = [], stale = [];
505
+ memberCards.forEach(c => {
506
+ const desc = String(c.description || "");
507
+ const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
508
+ if(i < 0 || j < 0){ unrendered.push(c.id); return; }
509
+ const m = /cc-relations:begin rev=(\d+)/.exec(desc.slice(i, j));
510
+ if(!m || Number(m[1]) !== rev) stale.push(c.id);
511
+ });
512
+ if(unrendered.length) add("relations", "warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
513
+ if(stale.length) add("relations", "warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
514
+ }
515
+
516
+ // —— 群聊每轮固定开销(成员卡 constant + 共享书 constant vs 最小预算)——
517
+ // 数字优先取 metrics.derived.groupConstant(卡组层那唯一一份口径);没传就按老算法自己求和。
518
+ const gc = (metrics && metrics.derived && metrics.derived.groupConstant) ? metrics.derived.groupConstant : null;
519
+ const cardConst = gc ? gc.card : memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
520
+ const sharedConst = gc ? gc.shared : sharedEntries
521
+ .filter(e => e && e.enabled !== false && e.constant)
522
+ .reduce((n, e) => n + estimateTokens(e && e.content), 0);
523
+ const constTokens = cardConst + sharedConst;
524
+ const minBudget = gc ? gc.minBudget
525
+ : (memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 2400), Infinity) : 2400);
526
+ if(on("budget") && (memberCards.length > 1 || sharedConst > 0) && constTokens > minBudget * 0.8){
527
+ add("budget", "warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻 " + cardConst
528
+ + (sharedConst ? " + 共享书常驻 " + sharedConst : "") + " = 合计约 " + constTokens
529
+ + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
530
+ }
531
+
532
+ // —— 共享书**自身**的条目质量(多卡共用 → 坏 key 的代价 ×成员数)——
533
+ if(on("lore-quality") && sharedEntries.length){
534
+ auditLoreEntries([{ source: "book", entries: sharedEntries }]).issues
535
+ .forEach(i => issues.push({ check: "lore-quality", level: i.level, code: i.code, message: i.message, refs: [], contentKeys: i.contentKeys, source: i.source }));
536
+ }
537
+
538
+ // —— 跨卡一致性(0.10.0 新增的类别;用户的第 5 个卡组功能就落在这里)——
539
+ // 单卡体检永远看不到这些问题:它们在「卡与卡之间」,而共享书既是成员又是被比较的对象。
540
+ if(on("cross-card") && scope){
541
+ const mems = (Array.isArray(scope.members) ? scope.members : []).map(m => ({
542
+ id: String((m && m.id) || ""),
543
+ name: String((m && m.name) || ""),
544
+ entries: (m && Array.isArray(m.entries)) ? m.entries : [],
545
+ world: (m && m.world && typeof m.world === "object") ? m.world : {},
546
+ }));
547
+
548
+ // ① 跨卡触发词冲突(含共享书):全组并查集,同一个词被多条共用 → 一句话同时带出它们
549
+ // ⚠️ 只报**涉及 ≥2 个来源**的(卡↔卡 / 卡↔书):同一张卡内部两条条目撞词是单卡世界书的自检
550
+ // (cc_lint_lorebook 的 duplicate-keys 已经在报),放在这里报会变成对同一个问题喊两次。
551
+ const dupKeys = duplicateKeyGroups(
552
+ mems.map(m => ({ kind: "card", id: m.id, name: m.name, entries: m.entries }))
553
+ .concat([{ kind: "book", id: "", entries: sharedEntries }]))
554
+ .filter(d => {
555
+ const srcs = [];
556
+ d.hits.forEach(h => { const tag = h.kind + ":" + h.id; if(srcs.indexOf(tag) < 0) srcs.push(tag); });
557
+ return srcs.length > 1;
558
+ });
559
+ if(dupKeys.length){
560
+ const who = [];
561
+ dupKeys.forEach(d => d.hits.forEach(h => {
562
+ const tag = (h.kind === "book") ? "共享书" : (h.cardName || h.id);
563
+ if(tag && who.indexOf(tag) < 0) who.push(tag);
564
+ }));
565
+ const keys = dupKeys.map(d => String(d.key));
566
+ add("cross-card", "warn", "cross-card-duplicate-keys",
567
+ keys.length + " 个触发词被多张卡/共享书共用(群聊时一句话会同时把它们带进上下文):"
568
+ + keys.slice(0, 8).join("、") + (keys.length > 8 ? " 等" : "")
569
+ + "(涉及:" + who.slice(0, 6).join("、") + ")", keys.slice(0, 8));
570
+ }
571
+
572
+ // ② 卡内条目与共享书**逐字重复**:书里已经有了,卡里那几份是纯浪费(还可能双重插入)
573
+ if(sharedEntries.length && mems.length){
574
+ const scan = scanSharedEntries(mems.map(m => ({ id: m.id, name: m.name, entries: m.entries })), sharedEntries);
575
+ if(scan.bookDupes.length){
576
+ const cardsHit = [];
577
+ scan.bookDupes.forEach(d => d.cards.forEach(cid => { if(cardsHit.indexOf(cid) < 0) cardsHit.push(cid); }));
578
+ add("cross-card", "warn", "book-duplicate-in-card",
579
+ scan.bookDupes.length + " 条条目在卡内与共享书逐字重复(共 " + scan.stats.bookDuplicatedEntries
580
+ + " 份副本、约 " + scan.stats.bookSavedChars + " 字符):共享书里已经有了,用 cc_promote_shared_entries 清掉卡里那几份即可(不会再往书里加)",
581
+ cardsHit);
582
+ }
583
+ }
584
+
585
+ // ③ 组内五维漂移:同一维度在两三张卡里写成不同设定 —— 群聊时同一件事会有两套说法
586
+ const drift = worldDriftOf(mems);
587
+ if(drift.length){
588
+ const totalCards = drift.reduce((n, d) => n + d.cards.length, 0);
589
+ add("cross-card", "warn", "world-drift",
590
+ "世界观有 " + drift.length + " 个维度在成员卡之间不一致:" + drift.map(d => WORLD_DIM_LABELS[d.dim] || d.dim).join("、")
591
+ + "(涉及 " + totalCards + " 张卡)—— 建议把该维度的共同设定提升进共享书,各卡只留自己独有的部分",
592
+ drift.map(d => String(d.dim)));
593
+ }
594
+
595
+ // ④ 成员卡的世界书全是 autoLorebook 生成的条目:在组里等于「这张卡没有自己的设定」
596
+ const autoOnly = mems.filter(m => {
597
+ const live = m.entries.filter(e => e && e.enabled !== false);
598
+ return live.length > 0 && live.every(e => isAutoEntry(e));
599
+ });
600
+ if(autoOnly.length){
601
+ add("cross-card", "warn", "card-auto-only",
602
+ autoOnly.length + " 张成员卡的世界书全是 autoLorebook 生成的条目(没有共创内容):"
603
+ + autoOnly.map(m => m.name || m.id).join("、")
604
+ + " —— 这些卡在组里等于没有自己的设定,补 cc_add_lorebook_entries 或把共有内容并进共享书",
605
+ autoOnly.map(m => m.id));
606
+ }
607
+ }
608
+
609
+ const byCheck = {};
610
+ GROUP_CHECKS.forEach(c => { byCheck[c.id] = issues.filter(i => i.check === c.id).length; });
611
+ return {
612
+ issues: issues,
613
+ errorCount: issues.filter(i => i.level === "error").length,
614
+ stats: { nodes: nodes.length, edges: edges.length, members: memberCards.length,
615
+ constantTokens: { card: cardConst, shared: sharedConst, total: constTokens }, minBudget: minBudget },
616
+ byCheck: byCheck,
617
+ ran: want,
618
+ };
619
+ }
620
+
621
+ export { REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END, WORLD_DIMS, WORLD_DIM_LABELS, AUTO_NAMES,
622
+ isAutoEntry, looksLikeLegacyAuto, estimateTokens, lorebookTokens, contentKeyOf, assembleScope,
623
+ seg, groupMetrics, lorebookMetrics, metricsBrief, worstCaseTrigger, auditLoreEntries, duplicateKeyGroups, worldDriftOf,
624
+ scanSharedEntries, GROUP_CHECKS, runGroupChecks };