@xia-sc/dsh-cc-studio 0.9.0 → 0.10.0

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/lib/agent.js CHANGED
@@ -7,12 +7,19 @@ import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises";
7
7
  import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
+ // 卡组层(0.10.0):卡组作用域组装 / 世界书分段统计 / 条目体检的**唯一实现**。
11
+ // 宿主半 import 同一个文件 —— 以前这些逻辑两半各抄一份、靠测试逐字比对维持同步,
12
+ // 「改了四个、漏了第五个」就是这么来的(见 lib/group-layer.js 顶部)。
13
+ import { REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END,
14
+ WORLD_DIMS, AUTO_NAMES, isAutoEntry, looksLikeLegacyAuto,
15
+ estimateTokens, lorebookTokens, contentKeyOf, scanSharedEntries,
16
+ assembleScope, groupMetrics, lorebookMetrics, metricsBrief, auditLoreEntries, runGroupChecks } from "./group-layer.js";
10
17
  const name = "dsh-cc-studio-agent";
11
18
  // 版本标识(0.7.1):每次工具返回都带 `build`。
12
19
  // 由来:第五轮实测里测试者第一遍跑出「四项全无变化」,其实只是**没重启 dsh** —— LLM 侧完全无法
13
20
  // 分辨「代码没改」和「改了但没加载」。有了这个字段,任何一次调用都能先确认自己测的是哪一版。
14
21
  // 发版时与 package.json 的 version 同步。
15
- const BUILD = "0.9.0";
22
+ const BUILD = "0.10.0";
16
23
  const inject = ["tools", "agents"];
17
24
 
18
25
  function nowSec(){ return Math.floor(Date.now()/1000); }
@@ -327,10 +334,7 @@ function setDraft(ctx, draft, exec){
327
334
  // 「卡属于哪个组」记在库条目 meta.groupId(单一真源),分组文件里刻意**不存成员列表**。
328
335
  // 关系只活在插件侧;要让分享出去的卡自包含,用 cc_render_relations 渲染进 description 的固定标记区。
329
336
  // ——————————————————————————————————————————————————————————————
330
- const REL_TYPES = ["family","romance","mentor","friend","colleague","rival","enemy","debt","superior","other"];
331
- const REL_TYPE_LABELS = { family:"亲属", romance:"恋慕", mentor:"师徒", friend:"挚友", colleague:"同门", rival:"竞争", enemy:"敌对", debt:"恩义", superior:"上下级", other:"其他" };
332
- const REL_BEGIN = "<!-- cc-relations:begin";
333
- const REL_END = "<!-- cc-relations:end -->";
337
+ // 关系图谱常量(0.10.0 起由 lib/group-layer.js 提供,两半共用一份)—— 这里不再重复定义。
334
338
 
335
339
  function groupDirs(){ return dataRoots("cc-groups"); }
336
340
  function groupDir(){ return groupDirs()[0]; }
@@ -525,53 +529,19 @@ function spliceRelations(description, section, meta){
525
529
  const head = desc.replace(/\s*$/, "");
526
530
  return (head + (head ? "\n\n" : "") + block).trim();
527
531
  }
528
- function checkGroup(group, members, cards, sharedEntries){
529
- const g = normalizeGroup(group);
530
- const issues = [];
531
- const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
532
- const nodeIds = new Set(g.nodes.map(n => n.id));
533
- const byId = new Map((Array.isArray(cards) ? cards : []).map(c => [c.id, c]));
534
- const dangling = g.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
535
- if(dangling.length) add("error", "dangling-edge", dangling.length + " 条关系的端点节点已不存在(数据可能被外部改坏)", dangling.map(e => e.id));
536
- const orphans = g.nodes.filter(n => n.kind === "card" && n.cardId && !byId.has(n.cardId));
537
- if(orphans.length) add("warn", "orphan-node", orphans.length + " 个节点指向的卡已不在库里:" + orphans.map(n => n.name).join("、"), orphans.map(n => n.id));
538
- const seen = new Map();
539
- g.nodes.forEach(n => [n.name].concat(n.aliases || []).forEach(x => {
540
- const k = String(x || "").toLowerCase();
541
- if(!k) return;
542
- if(!seen.has(k)) seen.set(k, []);
543
- if(seen.get(k).indexOf(n.id) < 0) seen.get(k).push(n.id);
544
- }));
545
- const dup = [...seen.entries()].filter(p => p[1].length > 1);
546
- if(dup.length) add("error", "duplicate-name", dup.length + " 组名字/别名落在多个节点上(渲染会指向同一个人):" + dup.map(p => p[0]).join("、"), []);
547
- const unknown = g.edges.filter(e => REL_TYPES.indexOf(e.type) < 0);
548
- if(unknown.length) add("warn", "type-unknown", unknown.length + " 条关系用了未知类型", unknown.map(e => e.id));
549
- const memberCards = (Array.isArray(members) ? members : []).map(m => byId.get(m)).filter(Boolean);
550
- const unrendered = [], stale = [];
551
- memberCards.forEach(c => {
552
- const desc = String(c.description || "");
553
- const i = desc.indexOf(REL_BEGIN), j = desc.indexOf(REL_END);
554
- if(i < 0 || j < 0){ unrendered.push(c.id); return; }
555
- const m = /cc-relations:begin rev=(\d+)/.exec(desc.slice(i, j));
556
- if(!m || Number(m[1]) !== g.rev) stale.push(c.id);
557
- });
558
- if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
559
- if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
560
- const cardConst = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
561
- // 0.9.0 报告:共享世界书的 constant 条目在群聊里**同样每轮插入**(独立一本、由客户端叠加),
562
- // 所以「群聊每轮固定开销」必须是 卡内 + 共享 的合计 —— 只报卡内会低报一半。
563
- const sharedConst = (Array.isArray(sharedEntries) ? sharedEntries : [])
564
- .filter(e => e && e.enabled !== false && e.constant)
565
- .reduce((n, e) => n + estimateTokensLite(e && e.content), 0);
566
- const constTokens = cardConst + sharedConst;
567
- const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
568
- if((memberCards.length > 1 || sharedConst > 0) && constTokens > minBudget * 0.8){
569
- add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻 " + cardConst
570
- + (sharedConst ? " + 共享书常驻 " + sharedConst : "") + " = 合计约 " + constTokens
571
- + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
572
- }
573
- return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length,
574
- constantTokens: { card: cardConst, shared: sharedConst, total: constTokens }, minBudget } };
532
+ /**
533
+ * 跨卡体检(0.10.0 起是 lib/group-layer.js 的 `runGroupChecks()` 的薄包装)。
534
+ *
535
+ * 老签名与返回形状**逐字保留**(issues[].level/code/message/refs + stats),所以宿主 cc_groupCheck、
536
+ * 工具 cc_group_check 与既有测试都不用改;实现搬去卡组层是为了让「卡组的下一个新检查项」只加一个
537
+ * 注册表项(那里的 GROUP_CHECKS),而不是在三个地方各写一遍清单、各报一套 code。
538
+ * opts.checks 可只跑指定类别(structure / relations / budget / lore-quality / cross-card)。
539
+ */
540
+ function checkGroup(group, members, cards, sharedEntries, opts){
541
+ const o = opts || {};
542
+ return runGroupChecks({ group: normalizeGroup(group), members: members, cards: cards,
543
+ sharedEntries: sharedEntries, metrics: o.metrics || null, scope: o.scope || null,
544
+ opts: { checks: o.checks } });
575
545
  }
576
546
  async function readGroupRaw(id){
577
547
  const file = safeGroupId(id) + ".json";
@@ -616,22 +586,23 @@ async function groupMemberIds(groupId){
616
586
  const entries = await listLibraryEntries();
617
587
  return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
618
588
  }
619
- /** 体检 / 渲染需要的卡片摘要(卡不在库里就跳过)。 */
589
+ /** 体检 / 渲染需要的卡片摘要(卡不在库里就跳过)—— 0.10.0 起吃卡库快照,不再逐卡重复读盘。 */
620
590
  async function libraryCardsFor(ids){
591
+ const rows = await readLibrarySnapshot();
592
+ const byId = new Map(rows.map(r => [String(r.id), r]));
621
593
  const out = [];
622
594
  for(const id of (Array.isArray(ids) ? ids : [])){
623
- try{
624
- const obj = JSON.parse((await readLibraryRaw(id)).raw);
625
- const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
626
- const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
627
- out.push({
628
- id: String(id),
629
- name: String(d.name || ""),
630
- description: String(d.description || ""),
631
- constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
632
- tokenBudget: Number(d.character_book && d.character_book.token_budget) || 1200,
633
- });
634
- }catch{}
595
+ const row = byId.get(String(id));
596
+ if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
597
+ const d = (row.draft && row.draft.data) ? row.draft.data : {};
598
+ const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
599
+ out.push({
600
+ id: String(id),
601
+ name: String(d.name || ""),
602
+ description: String(d.description || ""),
603
+ constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
604
+ tokenBudget: Number(d.character_book && d.character_book.token_budget) || 1200,
605
+ });
635
606
  }
636
607
  return out;
637
608
  }
@@ -653,6 +624,42 @@ async function resolveGroupFor(ctx, exec, explicitGroupId){
653
624
  const myNode = cardId ? (g.nodes.find(n => n.kind === "card" && n.cardId === cardId) || null) : null;
654
625
  return { ok: true, cardId, groupId, group: g, myNode, groups };
655
626
  }
627
+ /**
628
+ * 卡组作用域(0.10.0)—— 取「这次操作哪个组、组里有哪些卡、这个组的书是什么」的**唯一入口**。
629
+ *
630
+ * 为什么立这一层:共享书上线后,每个工具都自己写了一遍「来源卡 → meta.groupId → 读书」,
631
+ * 实测连着两轮出现「改了四个、漏了第五个」。现在数据只从这里出:工具半传 sessionKey
632
+ * (草稿的来源卡 id 记在 sidecar origin.json 里),宿主半传 cardId / groupId。
633
+ * 纯组装在 lib/group-layer.js(两半共用一份),所以「模型看到的组」与「界面看到的组」形状只有一种。
634
+ * 拿不到来源卡 / 没进组 / 组不存在 / 组还没建书 → 返回 ok:false 的空作用域(调用方无需自己判空)。
635
+ */
636
+ async function groupScope(input){
637
+ const opt = input || {};
638
+ // 已经读过快照就传进来(opt.snapshot)—— 一次调用里只读一遍盘,也让「只读一次」可被测试钉住
639
+ const snap = opt.snapshot ? opt.snapshot : await readLibrarySnapshot();
640
+ const byId = new Map(snap.map(x => [String(x.id), x]));
641
+ let cardId = opt.cardId ? String(opt.cardId) : "";
642
+ if(!cardId && opt.sessionKey){
643
+ const origin = readOriginSync(String(opt.sessionKey));
644
+ cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
645
+ }
646
+ const mine = cardId ? (byId.get(cardId) || null) : null;
647
+ const explicit = opt.groupId ? safeGroupId(opt.groupId) : "";
648
+ const groupId = explicit || ((mine && mine.meta.groupId) ? String(mine.meta.groupId) : "");
649
+ const base = { cardId: cardId, include: opt.include, maxCards: opt.maxCards, missing: (cardId && !mine) ? [cardId] : [] };
650
+ if(!groupId) return assembleScope(Object.assign({ groupId: "" }, base));
651
+ // ⚠️ 组文件缺失(用户手工删过 cc-groups,或历史数据)时**不提前返回**:成员与共享书照样读出来,
652
+ // 只是 group 为 null / ok:false —— 旧实现(sharedBookScopeFor)根本不看组文件,提前返回会让
653
+ // 「组没了但书还在」的卡突然看不到自己的书(0.9.x 行为回归)。
654
+ const found = await readGroupRaw(groupId);
655
+ const cards = snap.filter(x => String(x.meta.groupId || "") === groupId);
656
+ const bf = await readBookRaw(groupId);
657
+ const book = bf ? normalizeBook(bf.obj, groupId) : null;
658
+ return assembleScope(Object.assign({
659
+ groupId: groupId, group: found ? normalizeGroup(found.obj) : null, cards: cards, book: book,
660
+ bookRev: book ? bookRevOf(book) : 0,
661
+ }, base));
662
+ }
656
663
 
657
664
  // —— 0.8.0:分组的增删改查(LLM 侧)——
658
665
  // 此前 LLM 只能读写**组内图谱**,改不了「哪张卡属于哪个组」:卡一旦因任何原因掉组,
@@ -770,9 +777,16 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
770
777
  updatedAt: now,
771
778
  };
772
779
  }
773
- async function listLibraryEntries(){
780
+ /**
781
+ * 卡库快照(0.10.0):一次目录遍历 + 一次 parse,把 meta 与 draft **一起**留下。
782
+ *
783
+ * 以前 listLibraryEntries() 解析完整份卡 JSON 却只留 meta,于是「组体检」要对每张卡再读一遍盘
784
+ * (N 张成员卡 = 2N 次读盘),而且拿不到 entries —— 卡组级检查(跨卡触发词冲突、五维漂移)根本做不了。
785
+ * 现在读一次就够:listLibraryEntries() 只是它的投影,groupScope() 直接吃它。
786
+ */
787
+ async function readLibrarySnapshot(){
774
788
  await ensureLibDir();
775
- const seen=new Map(); // id → meta:先到先得,主根排在前 → 同名卡片主根优先
789
+ const seen=new Map(); // id → {id, meta, draft, dir}:先到先得,主根排在前 → 同名卡片主根优先
776
790
  for(const dir of libDirs()){
777
791
  let files=[];
778
792
  try{ files=await readdir(dir); }catch{ continue; }
@@ -783,14 +797,19 @@ async function listLibraryEntries(){
783
797
  try{
784
798
  const raw=await readFile(join(dir,f),"utf8");
785
799
  const obj=JSON.parse(raw);
786
- if(obj && obj.meta && obj.id) seen.set(id,obj.meta);
787
- else if(obj && obj.draft) seen.set(id,makeLibraryEntryMeta(obj.draft,null,id));
800
+ if(obj && obj.meta && obj.id) seen.set(id,{ id:id, meta:obj.meta, draft:obj.draft||null, dir:dir });
801
+ else if(obj && obj.draft) seen.set(id,{ id:id, meta:makeLibraryEntryMeta(obj.draft,null,id), draft:obj.draft, dir:dir });
788
802
  }catch{}
789
803
  }
790
804
  }
791
- const entries=[...seen.values()];
792
- entries.sort((a,b)=>(b.updatedAt||0)-(a.updatedAt||0));
793
- return entries;
805
+ const rows=[...seen.values()];
806
+ rows.sort((a,b)=>((b.meta.updatedAt||0)-(a.meta.updatedAt||0)));
807
+ return rows;
808
+ }
809
+ /** 库条目 meta 列表(签名与排序语义未变,现在只是卡库快照的投影)。 */
810
+ async function listLibraryEntries(){
811
+ const rows = await readLibrarySnapshot();
812
+ return rows.map(r => r.meta);
794
813
  }
795
814
  async function saveLibraryEntry(draft, opts){
796
815
  const dir=await ensureLibDir();
@@ -862,20 +881,12 @@ function out(schema){ return { schema, render: (args, result) => [{ type:"text",
862
881
  // 的自相矛盾组合(模型无法据此决策,只能忽略);
863
882
  // ② **lore 的「完成」要求至少 1 条非自动条目**:否则 autoLorebook 一次生成 5 条就顶替了第 4 步的共创,
864
883
  // 而 `cc_add_lorebook_entries` 的契约写着「必调」—— 两条相反指令同时下发正是实测到的卡壳点。
865
- const WORLD_DIMS = ["timeline","factions","geo","power","daily"];
866
884
  const DIM_NAMES = { timeline:"年表", factions:"势力", geo:"地理", power:"力量体系", daily:"日常" };
867
885
  // 英文 key 与 0.4.x 生成过的完全一致(老卡里就是这个写法),避免升级后触发词看起来「悄悄变了」
868
886
  const DIM_EN = { timeline:"timeline", factions:"faction", geo:"geography", power:"power", daily:"daily" };
869
- // 旧数据没有 cc_auto 标记,靠「名字是维度名」兜底;但**必须同时看内容**,
870
- // 否则用户手工建的、恰好叫「日常」的条目会被下一次 cc_patch_world 静默删掉(0.5.0 修)。
871
- const AUTO_NAMES = new Set(["年表","势力","地理","力量体系","日常"]);
872
- function isAutoEntry(e){
873
- return !!(e && (e.cc_auto === true || (e.extensions && e.extensions.cc_auto)));
874
- }
875
- function looksLikeLegacyAuto(e){
876
- if(!e || !AUTO_NAMES.has(String(e.name||""))) return false;
877
- return /^\s*@@/.test(String(e.content||"")) || Array.isArray(e.keys) && e.keys.some(k => ["timeline","faction","geography","power","daily"].includes(String(k)));
878
- }
887
+ // WORLD_DIMS / AUTO_NAMES / isAutoEntry / looksLikeLegacyAuto 由 lib/group-layer.js 提供(两半共用)。
888
+ // 这里曾经各有一份:AUTO_NAMES 的判据(名字是维度名**且**内容/keys 像自动条目)如果两边漂移,
889
+ // 就会出现「工具以为这条是 auto、工坊以为不是」——同一份数据两种结论。
879
890
  /**
880
891
  * 工作流状态。第二个参数是**该组共享世界书**(`{ bookName, entries, tokens }`,没有就传 null/省略)。
881
892
  *
@@ -954,6 +965,8 @@ function workflowStatus(draft, shared){
954
965
  sharedBook: sharedLoreCount ? { name: String((shared && shared.bookName) || ""), entries: sharedLoreCount, tokens: Number((shared && shared.tokens) || 0) } : null,
955
966
  constantEntries, // { card, shared, total } —— 与 cc_lint_lorebook 同一口径
956
967
  sharedHasDecorator, // 共享书里有没有 @@decorator(存在性检查用合计)
968
+ metrics: metricsBrief(metrics), // 只有数字、**不带条目正文**:`all` 数组会随每个工具的返回出去
969
+ // (0.10.0 LLM 侧实测多带约 1800 token —— 见 metricsBrief 的注释)
957
970
  next,
958
971
  // 0.6.0(R1/R2):`hint` 只报**进度**,不再内嵌 `next` 全文 ——
959
972
  // 旧版 `hint = 进度 + " → " + next` 让同一句话在一份返回里出现三遍(hint 内嵌 next、message 又等于 hint)。
@@ -978,145 +991,16 @@ function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done
978
991
  + " " + mark(state.greetings) + "问候语"
979
992
  + (done ? " · 已完成,可导出/保存" : "");
980
993
  }
981
- // 粗略 token 估算:CJK 一字≈1 token,其余按 4 字符≈1 token(用于 validate 的预算告警与 lint/preview 报告)
982
- function estimateTokens(text){
983
- const s = String(text||"");
984
- let cjk = 0;
985
- for(const ch of s){ if(ch.charCodeAt(0) > 0x2E7F) cjk++; }
986
- const rest = s.length - cjk;
987
- return Math.ceil(cjk + rest/4);
988
- }
989
- function lorebookTokens(entries){
990
- return (Array.isArray(entries)? entries : []).reduce((n,e) => n + estimateTokens(e && e.content), 0);
991
- }
994
+ // —— 世界书口径与体检的实现都在 lib/group-layer.js(0.10.0)——
995
+ // estimateTokens / lorebookTokens / lorebookMetrics / auditLoreEntries / worstCaseTrigger 等
996
+ // 以前在这里各有一份、宿主半再抄一份,靠测试逐字比对维持同步(「改了四个、漏了第五个」的根因);
997
+ // 现在两半 import 同一个文件,改口径只改一处。
992
998
  /**
993
- * 世界书的三段基础统计(**卡内 / 共享书 / 合计**)—— 一处实现,多处引用。
994
- *
995
- * 为什么收口(第四轮报告的教训):共享书的条目/token 口径已经散落在 simulate / lint /
996
- * group_check / preview_export / validate 五个工具里,于是连着两轮出现「改了四个、漏了第五个」
997
- * 与「validate 改了、lint 漏了」——都是同一个语义写了两遍。基础统计收在这里(同
998
- * `outgoingEdgesOf()` 之于渲染与计数),各工具只在其上做自己的派生(最坏情况、单次触发、预算占用…)。
999
- *
1000
- * 同时给出两个**存在性**判断的合计口径:有没有 constant、有没有 `@@decorator` ——
1001
- * 这两条是「整本世界书」的性质,只数卡内会让「卡内没有、共享书里有」的组合被误报。
1002
- * ⚠️ **逐条质量**检查(死 keys / 只有维度名的 keys / keys 与正文无交集 / 触发词冲突)**不在这里**:
1003
- * 那是各工具自己决定范围的事 —— `cc_lint_lorebook` 的整改清单面向「你正在编辑的这张卡」,
1004
- * `cc_validate` 是卡的收尾判定,两者都只覆盖卡内条目(共享书的条目质量走 cc_get_shared_book 那条线)。
999
+ * 世界书的三段基础统计:见 lib/group-layer.js 的 `lorebookMetrics()`(0.10.0 起是 groupMetrics 的
1000
+ * 薄包装,形状与数字与 0.9.x 逐字一致)。历史与理由(为什么必须收口)也记在那个文件里。
1005
1001
  */
1006
- function lorebookMetrics(cardEntries, sharedEntries){
1007
- const card = Array.isArray(cardEntries) ? cardEntries : [];
1008
- const shared = Array.isArray(sharedEntries) ? sharedEntries : [];
1009
- const seg = (list) => ({
1010
- entries: list.length,
1011
- tokens: lorebookTokens(list),
1012
- constant: list.filter(e => e && e.constant === true).length,
1013
- constantTokens: list.filter(e => e && e.constant === true).reduce((n, e) => n + estimateTokens(e && e.content), 0),
1014
- manual: list.filter(e => !isAutoEntry(e)).length,
1015
- hasDecorator: list.some(e => String((e && e.content) || "").includes("@@")),
1016
- });
1017
- return { card: seg(card), shared: seg(shared), total: seg(card.concat(shared)), all: card.concat(shared) };
1018
- }
1019
- /**
1020
- * 世界书条目的**逐条质量检查** —— 一把尺子,卡内与共享书共用。
1021
- *
1022
- * 四类:非 constant 却没 keys / keys 只有维度名 / keys 与正文无交集 / 触发词被多条条目共用。
1023
- *
1024
- * 为什么收口(第五轮报告):这四类此前只写在 `cc_lint_lorebook` 里、只作用于**卡内**,当时的理由是
1025
- * 「共享书条目的质量走 `cc_get_shared_book` 那条线」—— 可那条线上**根本没有这个能力**(只有重复检测),
1026
- * 于是共享书成了没人体检的一格:往书里塞一条同时踩三类的坏条目,两个工具都不出声。
1027
- * 而共享书恰恰**比卡内更需要主动报**:它多卡共用(一条死 key 在**所有**成员卡上都不生效)、
1028
- * 又**没有「卡」这个载体**(用户不会顺手翻到它)。
1029
- *
1030
- * 用法:`auditLoreEntries([{ source:"card", entries }, { source:"shared", entries: sharedEntries }])`。
1031
- * 触发词冲突**跨来源一起算** —— 同一个词同时命中卡内与共享书的两条,是真实存在的 token 叠加。
1032
- * 每条 issue 带 `entries`(条目名,冲突类则是**冲突的触发词**)、`contentKeys`(便于直接定位改)、
1033
- * 与 `source`(`card` / `shared` / `mixed`)。
1034
- */
1035
- function auditLoreEntries(groups){
1036
- const gs = (Array.isArray(groups) ? groups : []).map(g => ({
1037
- source: String((g && g.source) || "card"),
1038
- list: (g && Array.isArray(g.entries) ? g.entries : []).filter(e => e && e.enabled !== false),
1039
- }));
1040
- const all = gs.reduce((n, g) => n.concat(g.list), []);
1041
- const sourceOf = (e) => { for(const g of gs){ if(g.list.indexOf(e) >= 0) return g.source; } return "card"; };
1042
- 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"); };
1043
- const issues = [];
1044
- const push = (level, code, message, items) => issues.push({ level, code, message,
1045
- entries: items.map(e => String((e && e.name) || "")).filter(Boolean),
1046
- contentKeys: items.map(contentKeyOf), source: srcOf(items) });
1047
- const isDimKey = k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k));
1048
- const nonConst = all.filter(e => !e.constant);
1049
- const keyed = nonConst.filter(e => Array.isArray(e.keys) && e.keys.length > 0);
1050
- const noKeys = nonConst.filter(e => !Array.isArray(e.keys) || e.keys.length === 0);
1051
- if(noKeys.length) push("error", "no-keys", noKeys.length + " 条非 constant 条目没有 keys(永远触发不了)", noKeys);
1052
- const dimOnly = keyed.filter(e => e.keys.every(isDimKey));
1053
- if(dimOnly.length) push("error", "dim-only-keys", dimOnly.length + " 条条目的 keys 只有维度名,真实对话不会命中(可用 cc_simulate_trigger 验证)", dimOnly);
1054
- const notInContent = keyed.filter(e => !e.keys.some(k => String(e.content || "").indexOf(String(k)) >= 0));
1055
- if(notInContent.length) push("warn", "key-not-in-content", notInContent.length + " 条条目的 keys 在自身 content 里找不到(可能误填或内容已改)", notInContent);
1056
- const seen = new Map();
1057
- all.forEach(e => (Array.isArray(e.keys) ? e.keys : []).forEach(k => {
1058
- const kk = String(k).toLowerCase();
1059
- if(!seen.has(kk)) seen.set(kk, []);
1060
- const arr = seen.get(kk);
1061
- if(arr.indexOf(e) < 0) arr.push(e);
1062
- }));
1063
- const dup = [...seen.entries()].filter(p => p[1].length > 1);
1064
- if(dup.length){
1065
- const items = [];
1066
- dup.forEach(p => p[1].forEach(e => { if(items.indexOf(e) < 0) items.push(e); }));
1067
- issues.push({ level: "warn", code: "duplicate-keys",
1068
- message: dup.length + " 个触发词被多条条目共用(会同时触发,可能互相打架)",
1069
- entries: dup.map(p => p[0]), // 与既有行为一致:这一项的 entries 是**冲突的触发词**
1070
- contentKeys: items.map(contentKeyOf), source: srcOf(items) });
1071
- }
1072
- return { issues, errorCount: issues.filter(i => i.level === "error").length };
1073
- }
1074
- /**
1075
- * 最坏情况单次触发量(0.7.0,N2)。
1076
- *
1077
- * 为什么不能只看总量:`token_budget` 管的是**一轮对话**里世界书能塞多少,而真正的坑是
1078
- * 「用户随便一句话被好几个条目同时命中」—— 第四轮实测:总量 2272 / 预算 1200 看着只是超一点,
1079
- * 但一句「剑冢到底是什么地方」单独就吃掉 **1139 token**(势力 + 地理 + 力量体系(constant) + 剑冢(constant))。
1080
- * 更糟的是它太好「修好」了:把 token_budget 调大,lint 立刻只剩一条 warning,可单次爆炸一点没变。
1081
- *
1082
- * 算法:把**共享同一个触发词的条目**并成一组(传递闭包 —— A/B 共享 x、B/C 共享 y,那么一句话
1083
- * 可以把三条全带出来),再叠加**每轮必插的 constant**,取最大的那组作为最坏情况。
1084
- * 单字符 key 不参与共现("a" 这种会把整本书并成一组,报告全是噪音)。
1085
- */
1086
- function worstCaseTrigger(entries){
1087
- const list = (Array.isArray(entries) ? entries : []).filter(e => e && e.enabled !== false);
1088
- const constantEntries = list.filter(e => e.constant);
1089
- const constantTokens = constantEntries.reduce((n, e) => n + estimateTokens(e && e.content), 0);
1090
- const varEntries = list.filter(e => !e.constant);
1091
- const parent = new Map();
1092
- 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; };
1093
- varEntries.forEach((e, i) => parent.set(i, i));
1094
- const byKey = new Map();
1095
- varEntries.forEach((e, i) => {
1096
- (Array.isArray(e.keys) ? e.keys : []).forEach(k => {
1097
- const kk = String(k).trim().toLowerCase();
1098
- if(kk.length < 2) return;
1099
- if(!byKey.has(kk)) byKey.set(kk, []);
1100
- byKey.get(kk).push(i);
1101
- });
1102
- });
1103
- byKey.forEach(idxList => { for(let i = 1; i < idxList.length; i++) parent.set(find(idxList[i]), find(idxList[0])); });
1104
- const groups = new Map();
1105
- varEntries.forEach((e, i) => {
1106
- const root = find(i);
1107
- if(!groups.has(root)) groups.set(root, { tokens: 0, names: [] });
1108
- const g = groups.get(root);
1109
- g.tokens += estimateTokens(e && e.content);
1110
- g.names.push(String((e && e.name) || ""));
1111
- });
1112
- let best = { tokens: constantTokens, names: [] };
1113
- groups.forEach(g => {
1114
- if(g.tokens + constantTokens > best.tokens) best = { tokens: g.tokens + constantTokens, names: g.names.slice() };
1115
- });
1116
- // 单条自己就最重的情况已含在上面(没有共享 key 的条目自成一组),这里只把常驻条目的名字补进名单。
1117
- const names = best.names.concat(constantEntries.map(e => String((e && e.name) || "")));
1118
- return { tokens: best.tokens, constantTokens, entries: names };
1119
- }
1002
+ /** 世界书条目的逐条质量检查:见 lib/group-layer.js 的 `auditLoreEntries()`(两半共用一把尺子)。 */
1003
+ /** 最坏情况单次触发量:见 lib/group-layer.js 的 `worstCaseTrigger()`(算法与理由记在那里)。 */
1120
1004
 
1121
1005
 
1122
1006
  function apply(ctx){
@@ -1277,7 +1161,7 @@ function apply(ctx){
1277
1161
 
1278
1162
  tool({
1279
1163
  name: "cc_patch_world",
1280
- description: "【工作流第3步·共创·必调】写入 5 维世界观(timeline/factions/geo/power/daily 至少 3 项)。若用户已提供世界观文档,先用 2-3 句归纳你理解的 5 维,再问用户“哪几维最想展开?有无私设要加?”;若世界观薄弱,则与用户讨论 1-2 个薄弱点后再填。严禁未与用户讨论就直接照搬文档填满 5 维。调后下一步是 cc_add_lorebook_entries。",
1164
+ description: "【工作流第3步·共创·必调】写入 5 维世界观(timeline/factions/geo/power/daily 至少 3 项)。若用户已提供世界观文档,先用 2-3 句归纳你理解的 5 维,再问用户“哪几维最想展开?有无私设要加?”;若世界观薄弱,则与用户讨论 1-2 个薄弱点后再填。严禁未与用户讨论就直接照搬文档填满 5 维。⚠️ 若本卡已在**有共享世界书**的分组里,本工具默认**不再**往卡内抄五维骨架(那些共同设定属于共享书,卡内副本会与它双重插入)—— 要卡内副本才显式传 autoLorebook:true。调后下一步是 cc_add_lorebook_entries。",
1281
1165
  parameters: {
1282
1166
  type:"object",
1283
1167
  properties:{
@@ -1286,7 +1170,7 @@ function apply(ctx){
1286
1170
  geo:{type:"string", description:"地理"},
1287
1171
  power:{type:"string", description:"力量体系"},
1288
1172
  daily:{type:"string", description:"日常"},
1289
- autoLorebook:{type:"boolean", description:"是否基于 5 维自动生成 3-6 条带 @@decorator 的 lorebook 条目,强烈建议 true"},
1173
+ autoLorebook:{type:"boolean", description:"是否基于 5 维在**卡内**自动生成 3-6 条带 @@decorator 的 lorebook 条目。**不传时**:本卡所属分组若已有共享世界书覆盖这些维度 → 不生成(避免与共享书双重插入、触发词互相打架);否则生成。true 强制生成,false 强制不生成。多卡同组时建议不传或传 false —— 共同设定留在共享书里。"},
1290
1174
  loreKeys:{
1291
1175
  type:"object",
1292
1176
  description:"**强烈建议提供**:为每一维自报触发词(真实对话里用户会说的实体名),如 {\"geo\":[\"旧校舍\",\"广播室\",\"天台\"],\"factions\":[\"夜巡\",\"学生会\"]}。不给的话自动条目只能用维度名当 key —— 用户永远不会说「地理」,那几条等于死条目。",
@@ -1310,7 +1194,17 @@ function apply(ctx){
1310
1194
  if(args.power!==undefined) w.power = String(args.power).slice(0,1000);
1311
1195
  if(args.daily!==undefined) w.daily = String(args.daily).slice(0,1000);
1312
1196
  cur.data.extensions.cc_world = w;
1313
- const shouldAuto = args.autoLorebook !== false; // 默认 true,防偷懒
1197
+ // 0.10.0:默认值必须看得见「组里已经有什么」——
1198
+ // 没显式传 autoLorebook 时,若本卡所属分组的共享书已覆盖这些维度,就**不在卡内生成副本**:
1199
+ // 用户只想改五维里的一个字,不该被抄回整套骨架(那会抵消共享书的去重价值,并立刻踩
1200
+ // 「卡内与书逐字重复」+「跨卡触发词冲突」两条检查)。显式 true / false 一律照办。
1201
+ const explicitAuto = (typeof args.autoLorebook === "boolean") ? args.autoLorebook : null;
1202
+ let shouldAuto = (explicitAuto === null) ? true : explicitAuto;
1203
+ let autoSkipped = null;
1204
+ if(explicitAuto === null){
1205
+ const cover = await sharedBookCoversDims(ctx, exec);
1206
+ if(cover.covered){ shouldAuto = false; autoSkipped = cover; }
1207
+ }
1314
1208
  const autoDropped = [];
1315
1209
  const autoEdited = [];
1316
1210
  if(shouldAuto){
@@ -1391,9 +1285,21 @@ function apply(ctx){
1391
1285
  }
1392
1286
  const res = { ok:true, worldFilled: wf.worldFilled, loreCount: wf.loreCount,
1393
1287
  autoLoreAdded: autoEntries.length, autoLoreReplaced: autoDropped.length, autoLoreEdited: autoEdited.length,
1288
+ autoLoreSkipped: !!autoSkipped,
1289
+ autoLoreSkipReason: autoSkipped ? "shared-book-covers-dims" : null,
1394
1290
  sharedLoreKeys, heavyAutoEntries: heavyAuto.length,
1395
1291
  draftStatus: draftStatusFor(k, cur), workflow: wf,
1396
- message: "已写入五维(" + wf.worldFilled + "/5),自动生成 " + autoEntries.length + " 条基础条目。" + wf.next };
1292
+ message: "已写入五维(" + wf.worldFilled + "/5),"
1293
+ + (autoSkipped
1294
+ ? "本组共享世界书已覆盖这些维度,**没有**在卡内生成副本(要卡内副本请显式传 autoLorebook:true)"
1295
+ : "自动生成 " + autoEntries.length + " 条基础条目")
1296
+ + "。" + wf.next };
1297
+ if(autoSkipped){
1298
+ warnings.push("本卡所属分组已有共享世界书「" + (autoSkipped.bookName || "未命名") + "」,其中 "
1299
+ + autoSkipped.count + " 条覆盖了 年表/势力/地理/力量体系/日常 —— 本次**没有**在卡内生成基础条目:"
1300
+ + "卡内副本会在群聊里与共享书双重插入,触发词也会互相打架(组体检会报「卡内条目与共享书逐字重复」和「跨卡触发词冲突」两类问题)。"
1301
+ + "确实需要卡内副本时显式传 autoLorebook:true;正常的下一步是 cc_add_lorebook_entries 补共创条目。");
1302
+ }
1397
1303
  if(autoEdited.length) warnings.push("被替换的 " + autoEdited.length + " 条旧自动条目(" + autoEdited.join("、") + ")的 keys 曾被手工改过,这次已重建回默认维度名,手工触发词会丢。想保住它们:先用 cc_update_lorebook_entry 写回,或以后调本工具时带上 loreKeys。");
1398
1304
  if(args.verbose === true) res.draft = cur;
1399
1305
  if(warnings.length) res.warnings = warnings;
@@ -1774,8 +1680,10 @@ function apply(ctx){
1774
1680
  const texts = [];
1775
1681
  // 0.9.0 报告:共享书里的条目在真实聊天里**同样会被触发**(客户端把全局书与卡内 character_book
1776
1682
  // 叠加),所以模拟必须把它们一起算 —— 否则会把「会命中」报成「不会命中」(实测低估约 3 倍)。
1683
+ // 0.10.0:作用域与 token 口径都走卡组层(sharedBookScopeFor / lorebookMetrics),本工具不再自己求和。
1777
1684
  const scope = await sharedBookScopeFor(ctx, exec);
1778
1685
  const shared = scope.entries;
1686
+ const metrics = lorebookMetrics(entries, shared);
1779
1687
  const pool = entries.map(e => ({ e: e, src: "card" })).concat(shared.map(e => ({ e: e, src: "shared" })));
1780
1688
  if(typeof args.text === "string" && args.text.trim()) texts.push(args.text);
1781
1689
  if(Array.isArray(args.texts)) args.texts.forEach(x => { if(typeof x === "string" && x.trim()) texts.push(x); });
@@ -1811,10 +1719,10 @@ function apply(ctx){
1811
1719
  if(pool.length && results.every(r => r.hitCount === 0)) warnings.push("这些测试句一条都没触发。若用户很可能这么说,说明世界书的 keys 需要改成实体名(cc_update_lorebook_entry 或下次 cc_patch_world 带 loreKeys)");
1812
1720
  const never = entries.filter(e => e.enabled !== false && !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
1813
1721
  if(never.length) warnings.push(never.length + " 条非 constant 条目没有 keys,永远触发不了:" + never.map(e=>String(e.name||"")).join("、"));
1814
- const totalTokens = lorebookTokens(entries.concat(shared));
1722
+ const totalTokens = metrics.total.tokens;
1815
1723
  const res = { ok:true, simulated: results.length, results,
1816
1724
  lorebookTokens: totalTokens,
1817
- tokenSplit: { card: lorebookTokens(entries), shared: lorebookTokens(shared), total: totalTokens },
1725
+ tokenSplit: { card: metrics.card.tokens, shared: metrics.shared.tokens, total: totalTokens },
1818
1726
  sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: scope.tokens } : null,
1819
1727
  scanDepth: book.scan_depth,
1820
1728
  message: "模拟完成:" + results.map(r => "「" + r.text.slice(0,16) + "」→ 命中 " + r.hitCount + " 条(约 " + r.tokens + " token"
@@ -1841,7 +1749,9 @@ function apply(ctx){
1841
1749
  // 所以预算、最坏情况都得把它们并起来算;同时拆出「卡内 / 共享 / 合计」三段,免得又变成一笔糊涂账。
1842
1750
  const scope = await sharedBookScopeFor(ctx, exec);
1843
1751
  const shared = scope.entries;
1844
- const m = lorebookMetrics(entries, shared); // 三段基础统计(一处实现,与 cc_validate 同一份)
1752
+ // 0.10.0:三段统计与「最坏情况」都从卡组层取 —— 数字只有一个来源,本工具不再自己求和。
1753
+ const metrics = groupMetrics({ card: entries, book: shared });
1754
+ const m = metrics.legacy; // { card, shared, total, all } —— 与 0.9.x 的 lorebookMetrics 同形
1845
1755
  const all = m.all;
1846
1756
  const cardTokens = m.card.tokens;
1847
1757
  const sharedTokens = m.shared.tokens;
@@ -1863,7 +1773,8 @@ function apply(ctx){
1863
1773
  + (shared.length ? " —— 注意共享书是**独立一本**、由客户端分别管预算,调本卡预算盖不住它" : ""), [], "card");
1864
1774
  }
1865
1775
  // N2(0.7.0):总量与「单次触发」是两个独立风险,只报总量会让「调大预算」看起来像修好了。
1866
- const worst = worstCaseTrigger(all);
1776
+ // 范围 = 卡内 + 共享书(derived.worstCase 就是按这个范围算的,与 0.9.x 逐字一致)。
1777
+ const worst = metrics.derived.worstCase;
1867
1778
  const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries,
1868
1779
  overBudget: budget > 0 && worst.tokens > budget, sharedEntries: shared.length };
1869
1780
  if(worstCase.overBudget){
@@ -2042,10 +1953,13 @@ function apply(ctx){
2042
1953
 
2043
1954
  tool({
2044
1955
  name: "cc_group_check",
2045
- description: "【关系·体检】跨卡检查一个分组:孤儿节点(卡已删除)、悬空关系、重名节点(渲染会指向同一个人)、未渲染/已过期的【关系】段,以及**群聊每轮固定开销**(所有成员卡 constant 条目之和 vs 最小预算 —— 群聊时卡越多这块钱越贵,一张卡的单卡体检看不出来)。做完一批群聊卡后调一次。",
1956
+ description: "【关系·体检】跨卡检查一个分组:图谱结构(孤儿节点 / 悬空关系 / 重名节点 / 未知类型)、卡内【关系】段(没渲染 / 落后于图谱)、**群聊每轮固定开销**(成员卡 constant + 共享书 constant vs 最小预算 —— 卡越多这块钱越贵,单卡体检看不出来)、**共享书条目质量**(死 key / 只有维度名的 key)、**跨卡一致性**(同一个触发词被多张卡共用、卡内条目与共享书逐字重复、组内五维漂移、成员卡全 auto)。做完一批群聊卡后调一次。用 checks 可只跑其中几类(structure / relations / budget / lore-quality / cross-card)。",
2046
1957
  parameters: {
2047
1958
  type:"object",
2048
- properties:{ groupId:{ type:"string", description:"可选:默认用当前卡所属分组" } },
1959
+ properties:{
1960
+ groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
1961
+ checks:{ type:"array", items:{ type:"string" }, description:"可选:只跑指定检查类别(structure / relations / budget / lore-quality / cross-card),缺省全跑" },
1962
+ },
2049
1963
  additionalProperties:false
2050
1964
  },
2051
1965
  output: out({ type:"object", additionalProperties:true }),
@@ -2056,16 +1970,19 @@ function apply(ctx){
2056
1970
  message: "这张卡还没入库或没加入分组,没有可体检的对象。" };
2057
1971
  }
2058
1972
  const g = r.group;
2059
- const memberIds = await groupMemberIds(g.id);
1973
+ // 0.10.0:成员与共享书从卡组作用域取(一次读盘、形状唯一)—— 不再自己 readBookRaw + normalizeBook。
1974
+ const scope = await groupScope({ groupId: g.id, include: "summary" });
1975
+ const memberIds = scope.memberIds;
2060
1976
  const cards = await libraryCardsFor(memberIds);
2061
- // 0.9.0:体检的是**这个组**,所以读这个组的共享书(显式传 groupId 时它与当前卡所在组可能不同)
2062
- const bkFound = await readBookRaw(g.id);
2063
- const sharedEntries = bkFound ? normalizeBook(bkFound.obj, g.id).data.entries.filter(e => e && e.enabled !== false) : [];
2064
- const res = checkGroup(g, memberIds, cards, sharedEntries);
1977
+ const sharedEntries = scope.book ? scope.book.entries : [];
1978
+ // 数字与跨卡检查的输入都来自卡组层:metrics 给预算类,scope 给跨卡一致性(成员条目与五维)
1979
+ const metrics = groupMetrics({ cardId: scope.cardId, card: [], members: scope.members, book: sharedEntries });
1980
+ const res = checkGroup(g, memberIds, cards, sharedEntries, { checks: args.checks, scope: scope, metrics: metrics });
2065
1981
  const errorCount = res.issues.filter(i => i.level === "error").length;
2066
1982
  return { ok:true,
2067
1983
  group: { id: g.id, name: g.name, rev: g.rev },
2068
1984
  issues: res.issues, stats: res.stats,
1985
+ checks: res.ran, byCheck: res.byCheck, // 跑过哪些类别 / 每类几条(0.10.0)
2069
1986
  errorCount, warningCount: res.issues.length - errorCount,
2070
1987
  members: cards.map(c => ({ id: c.id, name: c.name })),
2071
1988
  message: res.issues.length
@@ -2285,11 +2202,13 @@ function apply(ctx){
2285
2202
 
2286
2203
  // ———————————————— 组级共享世界书(0.9.0)————————————————
2287
2204
  // 一组一份:世界设定(年表/势力/地理/力量/日常)提到共享书,成员卡里只留角色私有条目。
2288
- // 导出后是 CCv3 官方 lorebook_v3 JSON,在客户端里绑成全局世界书即可,多卡共用、零重复。
2205
+ // 导出后有两种形态,各有各的读者(0.9.1 补):CCv3 官方 lorebook_v3 JSON 给认这个规范的客户端;
2206
+ // **SillyTavern 只认它自己的 `{entries:{…}}`**(ST 的 /api/worldinfo/import 首句就查顶层 entries,
2207
+ // 拖 lorebook_v3 进去实测 400)—— 工坊导出页两枚按钮分别对应,别混着说。
2289
2208
 
2290
2209
  tool({
2291
2210
  name: "cc_get_shared_book",
2292
- description: "【共享书·查】读当前(或指定)分组的**组级共享世界书**(一组一份、整组共用),并报出「成员卡之间有多少条重复条目」—— 那些就是该提升到共享书的。共享书是独立文件(CCv3 官方 lorebook_v3 格式),导出后可在客户端里绑成全局世界书,于是每张成员卡里不必再存这些设定。",
2211
+ description: "【共享书·查】读当前(或指定)分组的**组级共享世界书**(一组一份、整组共用),并报出「成员卡之间有多少条重复条目」—— 那些就是该提升到共享书的。共享书是独立文件,工坊导出页给两种形态:**CCv3 独立世界书**(`{spec:\"lorebook_v3\", data}`,给支持该规范的客户端绑成全局世界书)与 **SillyTavern 世界书**(`{entries:{…}}`)。⚠️ SillyTavern 的导入接口只认后者(它连 `lorebook_v3` 都不认,直接拖前者进去会被判 400 Bad Request)—— 所以「交给客户端」时要说清是哪种,别让用户拿到 CCv3 那份去导 ST。",
2293
2212
  parameters: {
2294
2213
  type:"object",
2295
2214
  properties:{
@@ -2304,9 +2223,11 @@ function apply(ctx){
2304
2223
  if(!r.ok) return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
2305
2224
  message:"当前卡还没入库或没加入分组,而共享世界书是按组存放的 —— 先 cc_create_group / cc_set_card_group 把它归到一组。" };
2306
2225
  const g = r.group;
2307
- const found = await readBookRaw(g.id);
2308
- const cards = await bookCardsFor(await groupMemberIds(g.id));
2309
- const book = found ? normalizeBook(found.obj, g.id) : null;
2226
+ // 0.10.0:书与成员都从卡组作用域取(一次读盘、形状唯一)—— 本工具不再自己 readBookRaw + normalizeBook。
2227
+ const scope = await groupScope({ groupId: g.id, include: "summary" });
2228
+ const cards = await bookCardsFor(scope.memberIds); // scan 要比内容,需要完整条目
2229
+ const book = (scope.book && scope.book.doc) ? scope.book.doc : null;
2230
+ const found = !!book;
2310
2231
  // 把**书**一起交给 scan —— 否则「卡内条目与共享书逐字重复」这一类(`cc_apply_shared_book` 回填进卡的副本)
2311
2232
  // 谁都看不见:卡片之间没有重复、书里也没有重复,于是两个工具都说「没有重复」,而那几份副本就躺在卡里
2312
2233
  // 等着和共享书双重插入(第二轮报告 P1)。
@@ -2494,21 +2415,22 @@ function apply(ctx){
2494
2415
  // 是「卡内 character_book + 该组共享书」叠加,所以这里必须把共享书单列一行。
2495
2416
  const scope = await sharedBookScopeFor(ctx, exec);
2496
2417
  const shared = scope.entries;
2497
- const cardTokens = lorebookTokens(entries);
2498
- const sharedTokens = lorebookTokens(shared);
2418
+ const metrics = lorebookMetrics(entries, shared); // 0.10.0:token 口径走卡组层(数字与文案都不变)
2419
+ const cardTokens = metrics.card.tokens;
2420
+ const sharedTokens = metrics.shared.tokens;
2499
2421
  return { ok:true,
2500
2422
  bytes, tokens: estimateTokens(json),
2501
2423
  lorebookTokens: cardTokens + sharedTokens,
2502
2424
  lorebookSplit: { card: cardTokens, shared: sharedTokens, total: cardTokens + sharedTokens },
2503
2425
  sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: sharedTokens,
2504
- hint: "独立一本,可用工坊「导出共享世界书」单独导出;若客户端不支持全局世界书,可在导出时并入本卡" } : null,
2426
+ hint: "独立一本,工坊导出页可单独导出(CCv3 客户端拿「CCv3 独立世界书」,**SillyTavern 拿「SillyTavern 世界书」**);若客户端不支持全局世界书,可在导出时并入本卡。导卡时(JSON / PNG / CHARX)会**自动在导出副本上**写 `data.extensions.world = <书名>`(ST 的角色↔世界书绑定),PNG 里同时写 `ccv3`(纯 CCv3)与 `chara`(V2 回填)两个块 —— 草稿与库里的卡一个字节都不动" } : null,
2505
2427
  exportedFields, extensionsExported: Object.keys(d.extensions || {}),
2506
2428
  worldDuplicatedChars: dupChars,
2507
2429
  loreCount: entries.length, manualLoreCount: entries.filter(e => !isAutoEntry(e)).length,
2508
2430
  message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token),其中本卡世界书 " + cardTokens + " token"
2509
2431
  + (shared.length
2510
2432
  ? ";**本卡所属组另有共享世界书「" + (String(scope.bookName||"").trim() || "未命名") + "」" + shared.length + " 条 / 约 " + sharedTokens
2511
- + " token** —— 客户端实际加载时两者叠加(合计 " + (cardTokens + sharedTokens) + " token),可单独导出该文件或导出时并入本卡"
2433
+ + " token** —— 客户端实际加载时两者叠加(合计 " + (cardTokens + sharedTokens) + " token),可单独导出该文件(**SillyTavern 要用「SillyTavern 世界书」那份**)或导出时并入本卡"
2512
2434
  : "")
2513
2435
  + ";五维(extensions.cc_world)会一起导出,与条目内容重复约 " + dupChars + " 字符。" };
2514
2436
  }
@@ -2540,7 +2462,7 @@ function apply(ctx){
2540
2462
  } else if(wf.loreCount < 5){
2541
2463
  // 达标靠的是组里那本共享书(第二轮报告 P0 的正解):**不阻塞**,但必须把「导出时要带上它」说清楚 ——
2542
2464
  // 否则用户把这张「卡内只有 3 条」的卡发出去,对方客户端没有共享书就等于世界书缺失。
2543
- warnings.push("本卡内只有 "+wf.loreCount+" 条世界书,达标靠的是本组共享世界书「"+(wf.sharedBook && String(wf.sharedBook.name||"").trim() ? wf.sharedBook.name : "未命名")+"」的 "+wf.sharedLoreCount+" 条(合计 "+wf.loreTotal+" 条)。**共享书不会跟着卡走**:导出时请把它一并交给客户端(工坊导出页可单独导出这本 `lorebook_v3`;对方客户端不支持全局世界书时,在导出页勾选「并入本卡」)。");
2465
+ warnings.push("本卡内只有 "+wf.loreCount+" 条世界书,达标靠的是本组共享世界书「"+(wf.sharedBook && String(wf.sharedBook.name||"").trim() ? wf.sharedBook.name : "未命名")+"」的 "+wf.sharedLoreCount+" 条(合计 "+wf.loreTotal+" 条)。**共享书不会跟着卡走**:导出时请把它一并交给客户端(工坊导出页有两枚按钮 —— 支持 `lorebook_v3` 的客户端拿「CCv3 独立世界书」,**SillyTavern 必须拿「SillyTavern 世界书」**,ST 的导入接口只认 `{entries:{…}}` 形态);对方客户端不支持全局世界书时,在导出页勾选「并入本卡」。导卡时若该组有共享书,会**自动在导出副本上**写上 ST 的世界书绑定(`data.extensions.world = 书名`),PNG 里同时带 `ccv3` 与 `chara` 两个块。");
2544
2466
  }
2545
2467
  // —— 质量(P1-5:此前 valid=true 只代表「四件套非空」,给用户「卡没问题了」的错觉)——
2546
2468
  if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充(缺失时模型只能靠 description 猜场景)");
@@ -2559,7 +2481,7 @@ function apply(ctx){
2559
2481
  if(dimOnly.length) warnings.push(dimOnly.length + " 条条目的 keys 只有维度名(" + dimOnly.map(e=>String(e.name||"未命名")).join("、") + "),真实对话不会命中;可用 cc_simulate_trigger 拿一句用户会说的话验证,建议改成实体名");
2560
2482
  const keyless = entries.filter(e => { const keys=Array.isArray(e.keys)?e.keys:[]; return keys.length>0 && !e.constant && !keys.some(k => String(e.content||"").indexOf(String(k))>=0); });
2561
2483
  if(keyless.length) warnings.push(keyless.length + " 条条目的 keys 在自身 content 里找不到(可能是误填):" + keyless.map(e=>String(e.name||"未命名")).join("、"));
2562
- const used = lorebookTokens(entries);
2484
+ const used = wf.metrics.card.tokens; // 0.10.0:数字来自卡组层(workflowStatus 里那一次统计)
2563
2485
  const budget = Number(book.token_budget||0) || 0;
2564
2486
  if(budget>0 && used > budget) warnings.push("世界书内容估算约 "+used+" token,已超出 token_budget="+budget+",建议调大预算或精简条目(cc_patch_book_meta)");
2565
2487
  // —— 终态:校验通过后 next 不再指向 cc_validate(P0-3:那正是弱模型死循环的诱因)——
@@ -2577,7 +2499,8 @@ function apply(ctx){
2577
2499
  hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid, wf.sharedLoreCount),
2578
2500
  });
2579
2501
  const warningsAll = [recoveredNotice(k, d)].filter(Boolean).concat(warnings); // 新草稿提示同上,交给包装
2580
- const sharedUsed = (wf.sharedBook && Number(wf.sharedBook.tokens)) || 0;
2502
+ // 同上:共享书那一段(wf.metrics 是 lorebookMetrics 的旧命名形状:card / shared / total / all)
2503
+ const sharedUsed = wf.metrics.shared.tokens;
2581
2504
  return { ok:true, valid, done: valid, errors, warnings: warningsAll,
2582
2505
  loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount, name: d.data.name,
2583
2506
  // 世界书的两个口径都报出来(第二轮报告 P0):卡内 / 共享 / 合计 —— `cc_lint_lorebook` 早就是这个口径了,
@@ -2686,22 +2609,17 @@ async function deleteBook(groupId){
2686
2609
  }
2687
2610
  return removed > 0;
2688
2611
  }
2689
- /** 内容签名(折叠空白 + djb2 + 长度):只用来**筛**,真正搬运/删除前一律再逐字比对内容。 */
2690
- function contentKeyOf(entry){
2691
- const s = String((entry && entry.content) != null ? entry.content : "").replace(/\s+/g, " ").trim();
2692
- let h = 5381;
2693
- for(let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
2694
- return "c" + h.toString(36) + "-" + s.length;
2695
- }
2612
+ /** 内容签名:见 lib/group-layer.js 的 `contentKeyOf()`(只用来筛,动手前一律再逐字比对内容)。 */
2696
2613
  async function bookCardsFor(ids){
2614
+ const rows = await readLibrarySnapshot();
2615
+ const byId = new Map(rows.map(r => [String(r.id), r]));
2697
2616
  const out = [];
2698
2617
  for(const id of (Array.isArray(ids) ? ids : [])){
2699
- try{
2700
- const obj = JSON.parse((await readLibraryRaw(id)).raw);
2701
- const d = (obj && obj.draft && obj.draft.data) ? obj.draft.data : {};
2702
- const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
2703
- out.push({ id: String(id), name: String(d.name || (obj && obj.meta && obj.meta.name) || ""), entries: entries.map(makeBookEntry) });
2704
- }catch{}
2618
+ const row = byId.get(String(id));
2619
+ if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
2620
+ const d = (row.draft && row.draft.data) ? row.draft.data : {};
2621
+ const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
2622
+ out.push({ id: String(id), name: String(d.name || (row.meta && row.meta.name) || ""), entries: entries.map(makeBookEntry) });
2705
2623
  }
2706
2624
  return out;
2707
2625
  }
@@ -2722,44 +2640,7 @@ async function newBookFor(gid, explicitName){
2722
2640
  try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
2723
2641
  return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
2724
2642
  }
2725
- function scanSharedEntries(cards, bookEntries){
2726
- const list = Array.isArray(cards) ? cards : [];
2727
- const seen = new Map();
2728
- list.forEach(c => {
2729
- (Array.isArray(c.entries) ? c.entries : []).forEach(e => {
2730
- if(!String((e && e.content) || "").trim()) return;
2731
- const sig = contentKeyOf(e);
2732
- 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 });
2733
- const g = seen.get(sig);
2734
- if(g.cards.indexOf(c.id) < 0){ g.cards.push(c.id); g.cardNames.push(c.name); }
2735
- if(String(e.name || "") !== g.name) g.nameMismatch = true;
2736
- if(JSON.stringify((e.keys || []).map(String)) !== JSON.stringify(g.keys)) g.keysMismatch = true;
2737
- });
2738
- });
2739
- const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
2740
- .sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
2741
- .map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
2742
- // 第二轮报告 P1:还有一类重复是「卡 ↔ 共享书」—— `cc_apply_shared_book` 回填进卡里的副本。
2743
- // 它和「卡间重复」的**动作不同**:书里已经有了,只需把卡里那份删掉,不必再往书里搬。
2744
- // 判据同样是**逐字相符**(签名只用来筛候选);回填过的、手工抄进卡的都算。
2745
- const bookDupes = [];
2746
- (Array.isArray(bookEntries) ? bookEntries : []).forEach(be => {
2747
- const content = String((be && be.content) == null ? "" : be.content);
2748
- const g = content.trim() ? seen.get(contentKeyOf(be)) : null;
2749
- if(!g || g.content !== content) return;
2750
- bookDupes.push({ kind: "card-vs-book", signature: g.signature, name: String((be && be.name) || g.name || ""),
2751
- content, keys: (be.keys || []).map(String), chars: content.length,
2752
- cards: g.cards.slice(), cardNames: g.cardNames.slice(),
2753
- preview: content.slice(0, 80).replace(/\s+/g, " "), savedChars: content.length * g.cards.length });
2754
- });
2755
- const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
2756
- return { candidates, bookDupes, stats: { cards: list.length, totalEntries,
2757
- duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
2758
- savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
2759
- bookDuplicatedEntries: bookDupes.reduce((n, g) => n + g.cards.length, 0),
2760
- bookDupeCount: bookDupes.length,
2761
- bookSavedChars: bookDupes.reduce((n, g) => n + g.savedChars, 0) } };
2762
- }
2643
+ /** 跨卡重复 / 卡内-书重复扫描:见 lib/group-layer.js 的 `scanSharedEntries()`(0.10.0 起两半共用一份)。 */
2763
2644
  function mergeBookEntries(bookEntries, cardEntries){
2764
2645
  const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
2765
2646
  const added = [];
@@ -3009,38 +2890,63 @@ async function applySharedBook(groupId, cardId, dryRun){
3009
2890
  }
3010
2891
 
3011
2892
  /**
3012
- * 度量类工具的统一口径:世界设定现在有一部分活在**组级共享书**里,所以任何「这张卡有多少条 /
3013
- * 多少 token」的统计都必须把它算上,并拆成 **卡内 / 共享 / 合计** 三段。
2893
+ * 度量类工具的统一口径(0.10.0 起是 `groupScope()` 的薄包装,返回形状与 0.9.x 逐字一致)。
3014
2894
  *
3015
- * 为什么单列一个函数:LLM 侧实测报告指出 —— 共享书改变了**数据的位置**,却没有改变工具的**度量口径**;
3016
- * 四个「读」类工具(simulate / lint / group_check / preview_export)全都默认「卡内 character_book
3017
- * 就是全部」,于是 simulate 把「会命中」判成「不会命中」(实测低估约 3 倍),lint 的 worstCase
3018
- * 漏掉共享书里的 constant 条目,group_check 的群聊固定开销也只算了一半。
2895
+ * 为什么当初单列一个函数:LLM 侧实测报告指出 —— 共享书改变了**数据的位置**,却没有改变工具的
2896
+ * **度量口径**;四个「读」类工具(simulate / lint / group_check / preview_export)全都默认
2897
+ * 「卡内 character_book 就是全部」,于是 simulate 把「会命中」判成「不会命中」(实测低估约 3 倍),
2898
+ * lint 的 worstCase 漏掉共享书里的 constant 条目,group_check 的群聊固定开销也只算了一半。
2899
+ * 现在「取作用域」只有 groupScope 一处、数字只有 groupMetrics 一处,这里只负责把书的字段摊平
2900
+ * 成调用方熟悉的 `{ bookName, entries, tokens, constantTokens }`。
3019
2901
  *
3020
2902
  * 没有来源卡 / 没有分组 / 该组还没有书 → 一律返回空(调用方无需自己判空)。
3021
2903
  */
3022
2904
  async function sharedBookScopeFor(ctx, exec){
3023
- const k = keyFor(ctx, exec);
3024
- const origin = readOriginSync(k);
3025
- const cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
3026
- const empty = { cardId, groupId: "", bookName: "", rev: 0, entries: [], tokens: 0, constantTokens: 0 };
3027
- if(!cardId) return empty;
3028
- const mine = (await listLibraryEntries()).find(e => e.id === cardId);
3029
- const gid = (mine && mine.groupId) ? String(mine.groupId) : "";
3030
- if(!gid) return empty;
3031
- const found = await readBookRaw(gid);
3032
- if(!found) return Object.assign({}, empty, { groupId: gid });
3033
- const book = normalizeBook(found.obj, gid);
3034
- const entries = book.data.entries.filter(e => e && e.enabled !== false);
2905
+ const scope = await groupScope({ sessionKey: keyFor(ctx, exec) });
2906
+ const cardId = scope.cardId || "";
2907
+ const groupId = scope.groupId || "";
2908
+ if(!scope.book) return { cardId: cardId, groupId: groupId, bookName: "", rev: 0, entries: [], tokens: 0, constantTokens: 0 };
2909
+ const entries = scope.book.entries;
3035
2910
  return {
3036
- cardId, groupId: gid, bookName: book.data.name, rev: bookRevOf(book), entries,
2911
+ cardId: cardId, groupId: groupId, bookName: scope.book.name, rev: scope.book.rev, entries: entries,
3037
2912
  tokens: lorebookTokens(entries),
3038
2913
  constantTokens: entries.filter(e => e.constant).reduce((n, e) => n + estimateTokens(e && e.content), 0),
3039
- lastPromote: (book.data.extensions && book.data.extensions.cc && book.data.extensions.cc.lastPromote) || null,
2914
+ lastPromote: scope.book.lastPromote || null,
3040
2915
  };
3041
2916
  }
2917
+ /**
2918
+ * 本卡所属分组的共享世界书**是否已经覆盖了这五个维度** —— 决定「没显式传 autoLorebook 时」还要不要在
2919
+ * 卡内生成一份骨架副本。
2920
+ *
2921
+ * 为什么需要它(0.10.0 LLM 侧回归报告第三节,实测踩到):`autoLorebook` 的默认值是**单卡时代**的 ——
2922
+ * 那时「生成骨架条目」是净收益。共享书上线后它变成净损失:用户只想改五维里的一个字,结果整套骨架被
2923
+ * 抄回卡里,正好抵消共享书的去重价值,还会立刻踩两条检查(卡内与书逐字重复 + 跨卡触发词冲突),
2924
+ * 而且新副本的 keys 只有维度名 —— 等于把当初修好的「实体词 keys」又退回成死条目。
2925
+ *
2926
+ * 判据**取窄**(宁可漏判、不可误判):只看书里明确是维度条目的那些 —— 名字就是维度名,或 keys 命中
2927
+ * 维度名/英文维度名。真要卡内副本,显式传 `autoLorebook:true` 即可。
2928
+ */
2929
+ async function sharedBookCoversDims(ctx, exec){
2930
+ const out = { covered: false, count: 0, bookName: "", names: [] };
2931
+ let scope = null;
2932
+ try{ scope = await sharedBookScopeFor(ctx, exec); }catch(e){ return out; }
2933
+ const entries = (scope && Array.isArray(scope.entries)) ? scope.entries : [];
2934
+ if(!entries.length) return out;
2935
+ const enKeys = WORLD_DIMS.map(d => DIM_EN[d]);
2936
+ const hits = entries.filter(e => {
2937
+ if(!e || e.enabled === false) return false;
2938
+ if(AUTO_NAMES.has(String(e.name || "").trim())) return true;
2939
+ const keys = (Array.isArray(e.keys) ? e.keys : []).map(k => String(k).trim().toLowerCase());
2940
+ return keys.some(k => WORLD_DIMS.indexOf(k) >= 0 || enKeys.indexOf(k) >= 0);
2941
+ });
2942
+ if(!hits.length) return out;
2943
+ return { covered: true, count: hits.length, bookName: String(scope.bookName || ""),
2944
+ names: hits.map(e => String(e.name || "")).filter(Boolean).slice(0, 6) };
2945
+ }
3042
2946
 
3043
2947
  export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
2948
+ // 卡组层(0.10.0):卡库快照 + 卡组作用域 —— 卡组数据的唯一入口,两半共用 lib/group-layer.js 的纯组装
2949
+ readLibrarySnapshot, groupScope,
3044
2950
  // 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
3045
2951
  groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
3046
2952
  renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, groupMemberIds, libraryCardsFor,