@xia-sc/dsh-cc-studio 0.8.2 → 0.9.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
@@ -12,7 +12,7 @@ const name = "dsh-cc-studio-agent";
12
12
  // 由来:第五轮实测里测试者第一遍跑出「四项全无变化」,其实只是**没重启 dsh** —— LLM 侧完全无法
13
13
  // 分辨「代码没改」和「改了但没加载」。有了这个字段,任何一次调用都能先确认自己测的是哪一版。
14
14
  // 发版时与 package.json 的 version 同步。
15
- const BUILD = "0.8.2";
15
+ const BUILD = "0.9.0";
16
16
  const inject = ["tools", "agents"];
17
17
 
18
18
  function nowSec(){ return Math.floor(Date.now()/1000); }
@@ -525,7 +525,7 @@ function spliceRelations(description, section, meta){
525
525
  const head = desc.replace(/\s*$/, "");
526
526
  return (head + (head ? "\n\n" : "") + block).trim();
527
527
  }
528
- function checkGroup(group, members, cards){
528
+ function checkGroup(group, members, cards, sharedEntries){
529
529
  const g = normalizeGroup(group);
530
530
  const issues = [];
531
531
  const add = (level, code, message, refs) => issues.push({ level, code, message, refs: refs || [] });
@@ -557,12 +557,21 @@ function checkGroup(group, members, cards){
557
557
  });
558
558
  if(unrendered.length) add("warn", "unrendered", unrendered.length + " 张成员卡的 description 里还没有【关系】段(关系目前只存在于图谱里,导出的卡看不到)", unrendered);
559
559
  if(stale.length) add("warn", "stale-render", stale.length + " 张成员卡的【关系】段落后于图谱(标记里的 rev 与当前 rev 不一致),建议重新渲染", stale);
560
- const constTokens = memberCards.reduce((n, c) => n + (Number(c.constantTokens) || 0), 0);
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;
561
567
  const minBudget = memberCards.length ? memberCards.reduce((n, c) => Math.min(n, Number(c.tokenBudget) || 1200), Infinity) : 1200;
562
- if(memberCards.length > 1 && constTokens > minBudget * 0.8){
563
- add("warn", "constant-budget", "群聊每轮固定开销偏高:成员卡常驻条目合计约 " + constTokens + " token(已达最小预算 " + minBudget + " 的 " + Math.round(constTokens / minBudget * 100) + "%)");
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) + "%)");
564
572
  }
565
- return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length, constantTokens: constTokens, minBudget } };
573
+ return { issues, stats: { nodes: g.nodes.length, edges: g.edges.length, members: memberCards.length,
574
+ constantTokens: { card: cardConst, shared: sharedConst, total: constTokens }, minBudget } };
566
575
  }
567
576
  async function readGroupRaw(id){
568
577
  const file = safeGroupId(id) + ".json";
@@ -867,20 +876,52 @@ function looksLikeLegacyAuto(e){
867
876
  if(!e || !AUTO_NAMES.has(String(e.name||""))) return false;
868
877
  return /^\s*@@/.test(String(e.content||"")) || Array.isArray(e.keys) && e.keys.some(k => ["timeline","faction","geography","power","daily"].includes(String(k)));
869
878
  }
870
- function workflowStatus(draft){
879
+ /**
880
+ * 工作流状态。第二个参数是**该组共享世界书**(`{ bookName, entries, tokens }`,没有就传 null/省略)。
881
+ *
882
+ * 第二轮报告 P0:共享书把一部分世界设定挪到了**组**上,而「≥5 条且至少 1 条共创」是**收尾判定** ——
883
+ * 只数卡内的话,「按推荐做法去重」的卡(重复条目提升进共享书、卡里只剩私密条目)反而永远判不合格:
884
+ * 用户完全照最佳实践做,系统判他不通过,只剩两条路(忍受失败 / 把冗余抄回卡里 —— 后者等于取消共享书)。
885
+ * 所以**达标判定走合计口径**,同时把卡内/共享两个数分开报出来,谁都不必去猜:
886
+ * loreCount / manualLoreCount = 卡内(写类工具的文案仍按它说,才不会被误读)
887
+ * sharedLoreCount / sharedManualCount = 共享书
888
+ * loreTotal / manualLoreTotal = 合计(`state.lore` 与 `readyToValidate` 认它)
889
+ */
890
+ function workflowStatus(draft, shared){
871
891
  const d = draft && draft.data ? draft.data : {};
872
892
  const w = (d.extensions && d.extensions.cc_world) ? d.extensions.cc_world : {};
873
893
  const entries = (d.character_book && Array.isArray(d.character_book.entries)) ? d.character_book.entries : [];
874
894
  const loreCount = entries.length;
875
895
  const manualLoreCount = entries.filter(e => !isAutoEntry(e)).length;
896
+ const sharedEntries = (shared && Array.isArray(shared.entries)) ? shared.entries : [];
897
+ const sharedLoreCount = sharedEntries.length;
898
+ const sharedManualCount = sharedEntries.filter(e => !isAutoEntry(e)).length;
899
+ const loreTotal = loreCount + sharedLoreCount;
900
+ const manualLoreTotal = manualLoreCount + sharedManualCount;
876
901
  const worldFilled = WORLD_DIMS.filter(k => typeof w[k]==="string" && String(w[k]).trim().length>=6).length;
902
+ // 一行「世界书多少条」的说法:卡内 8 条就说「8 条」,卡内 3 + 共享 5 就说「8 条 = 卡内 3 + 共享书 5」。
903
+ const lorePhrase = sharedLoreCount
904
+ ? loreTotal + " 条 = 卡内 " + loreCount + " + 共享书 " + sharedLoreCount
905
+ : loreTotal + " 条";
906
+ // 第三轮报告:`constant` / `@@decorator` 这两条是**关于整本世界书**的检查,口径必须与
907
+ // `cc_lint_lorebook` 一致(卡内 + 共享书)—— 只数卡内会让「卡里没有 constant、共享书里有」的卡
908
+ // 每次都收到一条无意义的提醒,而误报会被训练成「忽略 warning」。
909
+ // 范围:**存在性**检查(有没有 constant / 有没有 @@)走合计;**逐条质量**检查(死 keys /
910
+ // 只有维度名的 keys / keys 与正文无交集)在 validate 里**只看卡内** —— 这是**有意**的分工(第五轮报告
911
+ // 实测确认过):validate 是「这张卡」的收尾判定,列条目名不属于它。全量的逐条质量检查归
912
+ // `cc_lint_lorebook`(覆盖卡内 + 共享书,每条 issue 带 `source`),共享书自己那份由
913
+ // `cc_get_shared_book` 的 `lint` 段给出 —— 三处口径不同是分工,不是漏改(上一版这里曾把
914
+ // 「共享书走 cc_get_shared_book」写成事实,而那条线上当时并没有这个能力,这个错已修)。
915
+ const metrics = lorebookMetrics(entries, sharedEntries); // 三段基础统计:一处实现(见该函数注释)
916
+ const constantEntries = { card: metrics.card.constant, shared: metrics.shared.constant, total: metrics.total.constant };
917
+ const sharedHasDecorator = metrics.shared.hasDecorator;
877
918
 
878
919
  const nameOk = !!String(d.name||"").trim();
879
920
  const descOk = !!String(d.description||"").trim();
880
921
  const characterState = (nameOk && descOk && String(d.personality||"").trim() && String(d.scenario||"").trim())
881
922
  ? "ok" : ((nameOk && descOk) ? "partial" : "missing");
882
923
  const worldState = worldFilled >= 3 ? "ok" : (worldFilled > 0 ? "partial" : "missing");
883
- const loreState = (loreCount >= 5 && manualLoreCount >= 1) ? "ok" : (loreCount > 0 ? "partial" : "missing");
924
+ const loreState = (loreTotal >= 5 && manualLoreTotal >= 1) ? "ok" : (loreTotal > 0 ? "partial" : "missing");
884
925
  const gFirst = !!String(d.first_mes||"").trim();
885
926
  const gAlt = Array.isArray(d.alternate_greetings) ? d.alternate_greetings.length : 0;
886
927
  const gGroup = Array.isArray(d.group_only_greetings) ? d.group_only_greetings.length : 0;
@@ -893,11 +934,11 @@ function workflowStatus(draft){
893
934
  step = 3; next = "角色基础已齐,下一步必须 cc_patch_world:先与用户讨论世界观侧重(哪几维最想展开/有无私设)再补 5 维(≥3 项),**并为每一维自报触发词 loreKeys**(真实对话里用户会说的实体名,如 {geo:[\"旧校舍\",\"广播室\"]})——不给的话自动条目只能用维度名当 key,永远命中不了";
894
935
  } else if(loreState !== "ok"){
895
936
  step = 4;
896
- next = manualLoreCount === 0
897
- ? "五维已齐,autoLorebook 已自动生成 "+loreCount+" 条基础条目,但它们以维度名当触发词、且不满足共创要求:下一步必须 cc_add_lorebook_entries,先与用户讨论触发词/常驻偏好,至少再补 1 条(总≥5 条、至少 1 constant、content 头部写 @@decorator)"
898
- : "五维已齐,下一步必须 cc_add_lorebook_entries:与用户讨论触发词/常驻偏好后补到≥5 条(年表/势力/地理/力量/日常各 1,至少 1 constant,@@decorator),当前 "+loreCount+" 条(其中共创 "+manualLoreCount+" 条)";
937
+ next = manualLoreTotal === 0
938
+ ? "五维已齐,autoLorebook 已自动生成 "+loreTotal+" 条基础条目("+lorePhrase+"),但它们以维度名当触发词、且不满足共创要求:下一步必须 cc_add_lorebook_entries,先与用户讨论触发词/常驻偏好,至少再补 1 条(合计≥5 条、至少 1 constant、content 头部写 @@decorator)"
939
+ : "五维已齐,下一步必须 cc_add_lorebook_entries:与用户讨论触发词/常驻偏好后补到合计≥5 条(年表/势力/地理/力量/日常各 1,至少 1 constant,@@decorator),当前 "+lorePhrase+"(其中共创 "+manualLoreTotal+" 条)";
899
940
  } else if(greetingsState !== "ok"){
900
- step = 5; next = "世界书已齐("+loreCount+" 条,其中共创 "+manualLoreCount+" 条),下一步必须 cc_patch_greetings:先问用户偏好的开场场景/关系阶段/群聊与私聊区分,再补问候语(first_mes + alternate≥2 + group≥1)";
941
+ step = 5; next = "世界书已齐("+lorePhrase+",其中共创 "+manualLoreTotal+" 条),下一步必须 cc_patch_greetings:先问用户偏好的开场场景/关系阶段/群聊与私聊区分,再补问候语(first_mes + alternate≥2 + group≥1)";
901
942
  } else {
902
943
  step = 6; next = "结构已齐,下一步必须 cc_validate 校验,valid=true 才可收尾;通过后提醒用户在工坊左下角「校验导出」入口打开导出页(JSON / PNG / CHARX),或点「★ 保存当前」落盘";
903
944
  }
@@ -906,21 +947,34 @@ function workflowStatus(draft){
906
947
  step,
907
948
  readyToValidate: step === 6,
908
949
  state,
909
- worldFilled, loreCount, manualLoreCount,
950
+ worldFilled,
951
+ loreCount, manualLoreCount, // 卡内
952
+ sharedLoreCount, sharedManualCount, // 共享书(没有就是 0)
953
+ loreTotal, manualLoreTotal, // 合计 —— `state.lore` 认的是这两个
954
+ sharedBook: sharedLoreCount ? { name: String((shared && shared.bookName) || ""), entries: sharedLoreCount, tokens: Number((shared && shared.tokens) || 0) } : null,
955
+ constantEntries, // { card, shared, total } —— 与 cc_lint_lorebook 同一口径
956
+ sharedHasDecorator, // 共享书里有没有 @@decorator(存在性检查用合计)
910
957
  next,
911
958
  // 0.6.0(R1/R2):`hint` 只报**进度**,不再内嵌 `next` 全文 ——
912
959
  // 旧版 `hint = 进度 + " → " + next` 让同一句话在一份返回里出现三遍(hint 内嵌 next、message 又等于 hint)。
913
960
  // 终态(校验通过)也走同一个函数,所以 `hint` 不会再说「下一步必须 cc_validate」(那是第一轮 P0-3 的半修残留)。
914
- hint: progressHint(step, state, worldFilled, loreCount, manualLoreCount, false),
961
+ hint: progressHint(step, state, worldFilled, loreCount, manualLoreCount, false, sharedLoreCount),
915
962
  };
916
963
  }
964
+ /** 带共享书口径的工作流状态:**判定完成度**一律用它(纯纯汇报卡内条数的地方才继续用 workflowStatus)。
965
+ * 拿不到共享书(没有来源卡 / 没进组 / 该组还没建书)时,等价于只算卡内 —— 与旧行为完全一致。 */
966
+ async function workflowFor(ctx, exec, draft){
967
+ let shared = null;
968
+ try{ shared = await sharedBookScopeFor(ctx, exec); }catch(e){ shared = null; }
969
+ return workflowStatus(draft, shared);
970
+ }
917
971
  /** 一行进度摘要(**不含 next** —— 下一步是 workflow.next 的职责,别再复制一份)。 */
918
- function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done){
972
+ function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done, sharedLoreCount){
919
973
  const mark = s => s==="ok" ? "✓" : (s==="partial" ? "◐" : "✗");
920
974
  return "工作流 " + step + "/" + (done ? 7 : 6)
921
975
  + " " + mark(state.character) + "角色"
922
976
  + " " + mark(state.world) + "五维(" + worldFilled + "/5)"
923
- + " " + mark(state.lore) + "世界书(" + loreCount + (manualLoreCount > 0 ? ",共创" + manualLoreCount : "") + ")"
977
+ + " " + mark(state.lore) + "世界书(" + loreCount + (sharedLoreCount ? "+" + sharedLoreCount : "") + (manualLoreCount > 0 ? ",共创" + manualLoreCount : "") + ")"
924
978
  + " " + mark(state.greetings) + "问候语"
925
979
  + (done ? " · 已完成,可导出/保存" : "");
926
980
  }
@@ -935,6 +989,88 @@ function estimateTokens(text){
935
989
  function lorebookTokens(entries){
936
990
  return (Array.isArray(entries)? entries : []).reduce((n,e) => n + estimateTokens(e && e.content), 0);
937
991
  }
992
+ /**
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 那条线)。
1005
+ */
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
+ }
938
1074
  /**
939
1075
  * 最坏情况单次触发量(0.7.0,N2)。
940
1076
  *
@@ -1063,7 +1199,7 @@ function apply(ctx){
1063
1199
  const draft = getDraft(ctx, exec);
1064
1200
  const k = keyFor(ctx, exec);
1065
1201
  const st = draftStatusFor(k, draft);
1066
- const wf = workflowStatus(draft);
1202
+ const wf = await workflowFor(ctx, exec, draft);
1067
1203
  const warnings = [recoveredNotice(k, draft)].filter(Boolean); // 新草稿提示由 tool() 包装统一注入(S2,只出一份)
1068
1204
  const fields = Array.isArray(args.fields) ? args.fields.map(String).filter(Boolean) : [];
1069
1205
  let outDraft = draft;
@@ -1126,7 +1262,7 @@ function apply(ctx){
1126
1262
  if(args.creator_notes!==undefined) assignIfChanged(cur.data, "creator_notes", String(args.creator_notes).slice(0,3000), changed);
1127
1263
  if(args.creator!==undefined) assignIfChanged(cur.data, "creator", String(args.creator).slice(0,120), changed);
1128
1264
  setDraft(ctx, cur, exec);
1129
- const wf = workflowStatus(cur);
1265
+ const wf = await workflowFor(ctx, exec, cur);
1130
1266
  const warnings = [];
1131
1267
  if(recNote) warnings.push(recNote);
1132
1268
  // P1-4:patch 类默认不再回吐整份 draft —— 长卡下每改一个字段都要模型重读全卡,纯烧上下文。
@@ -1221,7 +1357,7 @@ function apply(ctx){
1221
1357
  if(w.daily) push("daily", "@@depth 5\n"+w.daily, 7);
1222
1358
  }
1223
1359
  setDraft(ctx, cur, exec);
1224
- const wf = workflowStatus(cur);
1360
+ const wf = await workflowFor(ctx, exec, cur);
1225
1361
  const autoEntries = cur.data.character_book.entries.filter(isAutoEntry);
1226
1362
  const warnings = [];
1227
1363
  if(recNote) warnings.push(recNote);
@@ -1312,7 +1448,7 @@ function apply(ctx){
1312
1448
  });
1313
1449
  });
1314
1450
  setDraft(ctx, cur, exec);
1315
- const wf = workflowStatus(cur);
1451
+ const wf = await workflowFor(ctx, exec, cur);
1316
1452
  const warnings = [];
1317
1453
  if(recNote) warnings.push(recNote);
1318
1454
  const res = { ok:true, added: args.entries.length, loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount,
@@ -1349,7 +1485,7 @@ function apply(ctx){
1349
1485
  // 重排 insertion_order
1350
1486
  cur.data.character_book.entries.forEach((e,i)=> e.insertion_order=i);
1351
1487
  setDraft(ctx, cur, exec);
1352
- const wf = workflowStatus(cur);
1488
+ const wf = await workflowFor(ctx, exec, cur);
1353
1489
  const deleted = beforeCount - wf.loreCount;
1354
1490
  const warnings = [];
1355
1491
  if(beforeRec) warnings.push(beforeRec);
@@ -1396,7 +1532,7 @@ function apply(ctx){
1396
1532
  if(args.use_regex!==undefined) assignIfChanged(e, "use_regex", !!args.use_regex, changed);
1397
1533
  if(typeof args.priority==="number") assignIfChanged(e, "priority", args.priority, changed);
1398
1534
  setDraft(ctx, cur, exec);
1399
- const wf = workflowStatus(cur);
1535
+ const wf = await workflowFor(ctx, exec, cur);
1400
1536
  const warnings = [];
1401
1537
  if(recNote) warnings.push(recNote);
1402
1538
  const res = { ok:true, entry:e, changed, loreCount: wf.loreCount,
@@ -1432,7 +1568,7 @@ function apply(ctx){
1432
1568
  if(Array.isArray(args.group_only_greetings)) cur.data.group_only_greetings = args.group_only_greetings.map(String).slice(0,10);
1433
1569
  if(args.mes_example!==undefined) cur.data.mes_example = String(args.mes_example).slice(0,5000);
1434
1570
  setDraft(ctx, cur, exec);
1435
- const wf = workflowStatus(cur);
1571
+ const wf = await workflowFor(ctx, exec, cur);
1436
1572
  // 若世界书/五维未齐,警告但仍写入,让 validate 卡住
1437
1573
  const warnings = [];
1438
1574
  if(recNote) warnings.push(recNote);
@@ -1495,7 +1631,7 @@ function apply(ctx){
1495
1631
  setDraft(ctx,cur,exec);
1496
1632
  const k = keyFor(ctx, exec);
1497
1633
  writeOriginSync(k, obj.id, obj.meta.name);
1498
- const wf = workflowStatus(cur);
1634
+ const wf = await workflowFor(ctx, exec, cur);
1499
1635
  const res = { ok:true, id:obj.id, meta:obj.meta, draftStatus: draftStatusFor(k, cur), workflow: wf,
1500
1636
  loreCount: wf.loreCount,
1501
1637
  message: "已载入「" + obj.meta.name + "」(id " + obj.id + ",世界书 " + wf.loreCount + " 条)。之后保存(不传 id)会更新这一张。" };
@@ -1561,7 +1697,7 @@ function apply(ctx){
1561
1697
  const k = keyFor(ctx, exec);
1562
1698
  if(args.confirm !== true){
1563
1699
  const cur = getDraft(ctx, exec);
1564
- const wf = workflowStatus(cur);
1700
+ const wf = await workflowFor(ctx, exec, cur);
1565
1701
  return { ok:false, needsConfirm:true, workflow: wf,
1566
1702
  message:"重置会清空当前草稿的全部内容(角色/五维/世界书/问候语)且无法撤销。当前草稿:" + (String(cur.data.name||"").trim() || "(未命名)") + ",世界书 " + wf.loreCount + " 条。请先向用户说明并征求同意;同意后再以 confirm:true 重新调用。" };
1567
1703
  }
@@ -1574,7 +1710,7 @@ function apply(ctx){
1574
1710
  // S2(0.7.0):成因是「有意重置」,不是「会话里突然没有草稿了」—— 清掉待告知标记,
1575
1711
  // 之后写入不再报「此前有未保存的内容可能已丢失」(第四轮实测:刚重置完就被吓一句,纯误报)。
1576
1712
  draftMeta(k).pendingFresh = null;
1577
- const wf = workflowStatus(fresh);
1713
+ const wf = await workflowFor(ctx, exec, fresh);
1578
1714
  return { ok:true, reset:true, workflow: wf,
1579
1715
  message:"已重置为空白草稿(并解除与已存卡的来源关联,之后保存会新建一张)。" + wf.next };
1580
1716
  }
@@ -1609,7 +1745,7 @@ function apply(ctx){
1609
1745
  if(typeof args.token_budget==="number") assignIfChanged(book, "token_budget", Math.max(0, Math.round(args.token_budget)), changed);
1610
1746
  if(args.recursive_scanning!==undefined) assignIfChanged(book, "recursive_scanning", !!args.recursive_scanning, changed);
1611
1747
  setDraft(ctx, cur, exec);
1612
- const wf = workflowStatus(cur);
1748
+ const wf = await workflowFor(ctx, exec, cur);
1613
1749
  const used = lorebookTokens(book.entries);
1614
1750
  const res = { ok:true, changed, book: { name:book.name, description:book.description, scan_depth:book.scan_depth, token_budget:book.token_budget, recursive_scanning:book.recursive_scanning },
1615
1751
  lorebookTokens: used, draftStatus: draftStatusFor(k, cur), workflow: wf,
@@ -1636,12 +1772,17 @@ function apply(ctx){
1636
1772
  const book = cur.data.character_book || {};
1637
1773
  const entries = Array.isArray(book.entries) ? book.entries : [];
1638
1774
  const texts = [];
1775
+ // 0.9.0 报告:共享书里的条目在真实聊天里**同样会被触发**(客户端把全局书与卡内 character_book
1776
+ // 叠加),所以模拟必须把它们一起算 —— 否则会把「会命中」报成「不会命中」(实测低估约 3 倍)。
1777
+ const scope = await sharedBookScopeFor(ctx, exec);
1778
+ const shared = scope.entries;
1779
+ const pool = entries.map(e => ({ e: e, src: "card" })).concat(shared.map(e => ({ e: e, src: "shared" })));
1639
1780
  if(typeof args.text === "string" && args.text.trim()) texts.push(args.text);
1640
1781
  if(Array.isArray(args.texts)) args.texts.forEach(x => { if(typeof x === "string" && x.trim()) texts.push(x); });
1641
1782
  if(!texts.length) return { ok:false, error:"请用 text 或 texts 提供至少一句测试文本" };
1642
1783
  const hitsOf = (line) => {
1643
1784
  const lower = line.toLowerCase();
1644
- return entries.filter(e => {
1785
+ return pool.filter(({ e }) => {
1645
1786
  if(!e || e.enabled === false) return false;
1646
1787
  if(e.constant) return true; // 常驻条目永远命中
1647
1788
  const keys = Array.isArray(e.keys) ? e.keys : [];
@@ -1655,19 +1796,30 @@ function apply(ctx){
1655
1796
  };
1656
1797
  const results = texts.map(line => {
1657
1798
  const hits = hitsOf(line);
1799
+ const cardHits = hits.filter(h => h.src === "card").map(h => h.e);
1800
+ const sharedHits = hits.filter(h => h.src === "shared").map(h => h.e);
1658
1801
  return {
1659
- text: line, hitCount: hits.length, tokens: lorebookTokens(hits),
1660
- hits: hits.map(e => ({ name: String(e.name||""), constant: !!e.constant, keys: Array.isArray(e.keys)?e.keys:[], priority: e.priority })),
1661
- missed: entries.filter(e => e.enabled !== false && hits.indexOf(e) < 0).map(e => String(e.name||"")),
1802
+ text: line, hitCount: hits.length, tokens: lorebookTokens(hits.map(h => h.e)),
1803
+ cardTokens: lorebookTokens(cardHits), sharedTokens: lorebookTokens(sharedHits),
1804
+ hits: hits.map(h => ({ name: String(h.e.name||""), source: h.src, constant: !!h.e.constant,
1805
+ keys: Array.isArray(h.e.keys)?h.e.keys:[], priority: h.e.priority })),
1806
+ missed: pool.filter(p => p.e.enabled !== false && hits.indexOf(p) < 0)
1807
+ .map(p => String(p.e.name||"") + (p.src === "shared" ? "(共享书)" : "")),
1662
1808
  };
1663
1809
  });
1664
1810
  const warnings = [];
1665
- if(entries.length && results.every(r => r.hitCount === 0)) warnings.push("这些测试句一条都没触发。若用户很可能这么说,说明世界书的 keys 需要改成实体名(cc_update_lorebook_entry 或下次 cc_patch_world 带 loreKeys)");
1811
+ if(pool.length && results.every(r => r.hitCount === 0)) warnings.push("这些测试句一条都没触发。若用户很可能这么说,说明世界书的 keys 需要改成实体名(cc_update_lorebook_entry 或下次 cc_patch_world 带 loreKeys)");
1666
1812
  const never = entries.filter(e => e.enabled !== false && !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
1667
1813
  if(never.length) warnings.push(never.length + " 条非 constant 条目没有 keys,永远触发不了:" + never.map(e=>String(e.name||"")).join("、"));
1814
+ const totalTokens = lorebookTokens(entries.concat(shared));
1668
1815
  const res = { ok:true, simulated: results.length, results,
1669
- lorebookTokens: lorebookTokens(entries), scanDepth: book.scan_depth,
1670
- message: "模拟完成:" + results.map(r => "「" + r.text.slice(0,16) + "」→ 命中 " + r.hitCount + " 条(约 " + r.tokens + " token)").join(";") };
1816
+ lorebookTokens: totalTokens,
1817
+ tokenSplit: { card: lorebookTokens(entries), shared: lorebookTokens(shared), total: totalTokens },
1818
+ sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: scope.tokens } : null,
1819
+ scanDepth: book.scan_depth,
1820
+ message: "模拟完成:" + results.map(r => "「" + r.text.slice(0,16) + "」→ 命中 " + r.hitCount + " 条(约 " + r.tokens + " token"
1821
+ + (r.sharedTokens ? ",其中共享书 " + r.sharedTokens : "") + ")").join(";")
1822
+ + (shared.length ? "。已把该组的共享世界书(" + shared.length + " 条 / 约 " + scope.tokens + " token)一并算入。" : "") };
1671
1823
  if(warnings.length) res.warnings = warnings;
1672
1824
  return res;
1673
1825
  }
@@ -1684,39 +1836,57 @@ function apply(ctx){
1684
1836
  const book = cur.data.character_book || {};
1685
1837
  const entries = Array.isArray(book.entries) ? book.entries : [];
1686
1838
  const issues = [];
1687
- const add = (level, message, names) => issues.push({ level, message, entries: names || [] });
1688
- const isDimKey = k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k));
1689
- const noKeys = entries.filter(e => !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
1690
- if(noKeys.length) add("error", noKeys.length + " 条非 constant 条目没有 keys(永远触发不了)", noKeys.map(e=>String(e.name||"")));
1691
- const dimOnly = entries.filter(e => !e.constant && Array.isArray(e.keys) && e.keys.length>0 && e.keys.every(isDimKey));
1692
- if(dimOnly.length) add("error", dimOnly.length + " 条条目的 keys 只有维度名,真实对话不会命中(可用 cc_simulate_trigger 验证)", dimOnly.map(e=>String(e.name||"")));
1693
- const notInContent = entries.filter(e => !e.constant && Array.isArray(e.keys) && e.keys.length>0 && !e.keys.some(k => String(e.content||"").indexOf(String(k))>=0));
1694
- if(notInContent.length) add("warn", notInContent.length + " 条条目的 keys 在自身 content 里找不到(可能误填或内容已改)", notInContent.map(e=>String(e.name||"")));
1695
- const seen = new Map();
1696
- entries.forEach(e => (Array.isArray(e.keys)? e.keys : []).forEach(k => { const kk=String(k).toLowerCase(); seen.set(kk, (seen.get(kk)||[]).concat(String(e.name||""))); }));
1697
- const dup = [...seen.entries()].filter(pair => pair[1].length > 1);
1698
- if(dup.length) add("warn", dup.length + " 个触发词被多条条目共用(会同时触发,可能互相打架)", dup.map(pair => pair[0]));
1699
- if(entries.length && !entries.some(e=>e.constant)) add("warn", "没有 constant 常驻条目:世界观基调只在命中触发词时才出现");
1700
- if(entries.length && entries.every(e=> !String(e.content||"").includes("@@"))) add("warn", "所有条目都没有 @@decorator(@@position/@@depth/@@activate),插入位置全用默认值");
1701
- const used = lorebookTokens(entries);
1839
+ const add = (level, code, message, names, source) => issues.push({ level, code, message, entries: names || [], source: source || "card" });
1840
+ // 0.9.0 报告:共享世界书是同一个世界观的一部分,聊天时与卡内 character_book **一起进 prompt**,
1841
+ // 所以预算、最坏情况都得把它们并起来算;同时拆出「卡内 / 共享 / 合计」三段,免得又变成一笔糊涂账。
1842
+ const scope = await sharedBookScopeFor(ctx, exec);
1843
+ const shared = scope.entries;
1844
+ const m = lorebookMetrics(entries, shared); // 三段基础统计(一处实现,与 cc_validate 同一份)
1845
+ const all = m.all;
1846
+ const cardTokens = m.card.tokens;
1847
+ const sharedTokens = m.shared.tokens;
1848
+ const used = m.total.tokens;
1849
+ // —— 逐条质量检查:卡内与共享书**都要查**,触发词冲突跨两者一起算 ——
1850
+ // 第五轮报告修正了上一版写下的「边界」:那时说「共享书条目的质量走 cc_get_shared_book 那条线」,
1851
+ // 可那条线上根本没有这个能力(只有重复检测),共享书于是成了没人体检的一格 —— 往书里塞一条同时
1852
+ // 踩三类的坏条目,两个工具都不出声。共享书多卡共用、又没有「卡」这个载体,**比卡内更需要主动报**。
1853
+ // 现在两边共用 auditLoreEntries 这一把尺子。
1854
+ auditLoreEntries([{ source: "card", entries: entries }, { source: "shared", entries: shared }])
1855
+ .issues.forEach(i => issues.push(i));
1856
+ // 存在性检查走**合计**口径(卡内 + 共享书)—— 第四轮报告:这里此前只数卡内,于是同一份返回里
1857
+ // 「constantEntries: 1」与「没有 constant 常驻条目」并列出现(计算已经改对了,判断没跟着换变量)。
1858
+ if(all.length && !m.total.constant) add("warn", "no-constant", "没有 constant 常驻条目(卡内与共享书都没有):世界观基调只在命中触发词时才出现", [], "mixed");
1859
+ if(all.length && !m.total.hasDecorator) add("warn", "no-decorator", "所有条目都没有 @@decorator(@@position/@@depth/@@activate),插入位置全用默认值", [], "mixed");
1702
1860
  const budget = Number(book.token_budget||0) || 0;
1703
- if(budget>0 && used>budget) add("warn", "估算 " + used + " token,已超出 token_budget=" + budget);
1861
+ if(budget>0 && used>budget){
1862
+ add("warn", "over-budget", "估算 " + used + " token(卡内 " + cardTokens + " + 共享书 " + sharedTokens + "),已超出本卡 token_budget=" + budget
1863
+ + (shared.length ? " —— 注意共享书是**独立一本**、由客户端分别管预算,调本卡预算盖不住它" : ""), [], "card");
1864
+ }
1704
1865
  // N2(0.7.0):总量与「单次触发」是两个独立风险,只报总量会让「调大预算」看起来像修好了。
1705
- const worst = worstCaseTrigger(entries);
1706
- const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries, overBudget: budget > 0 && worst.tokens > budget };
1866
+ const worst = worstCaseTrigger(all);
1867
+ const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries,
1868
+ overBudget: budget > 0 && worst.tokens > budget, sharedEntries: shared.length };
1707
1869
  if(worstCase.overBudget){
1708
- add("warn", "最坏情况单次触发约 " + worst.tokens + " token(含常驻 " + worst.constantTokens + ",预算 " + budget + "):「" + worst.entries.join(" + ") + "」一句话就吃光整份预算(调大 token_budget 盖不住这条)。", worst.entries);
1870
+ add("warn", "worst-case-over-budget", "最坏情况单次触发约 " + worst.tokens + " token(含常驻 " + worst.constantTokens + ",预算 " + budget + "):「" + worst.entries.join(" + ") + "」一句话就吃光整份预算(调大 token_budget 盖不住这条)。", worst.entries, "mixed");
1709
1871
  }
1710
- if(!String(book.name||"").trim()) add("warn", "世界书没有名字(character_book.name 为空,导出后是一本无名的书)");
1872
+ if(!String(book.name||"").trim()) add("warn", "book-unnamed", "世界书没有名字(character_book.name 为空,导出后是一本无名的书)", [], "card");
1873
+ if(shared.length && !String(scope.bookName||"").trim()) add("warn", "shared-book-unnamed", "该组的共享世界书没有名字(导出成 lorebook_v3 文件后别人不知道叫什么)—— 用 cc_patch_shared_book 的 name 参数命名", [], "shared");
1711
1874
  const errorCount = issues.filter(i => i.level === "error").length;
1875
+ const lints = issues.filter(i => i.level === "error" || i.level === "warn").length;
1712
1876
  return { ok:true,
1713
- entries: entries.length, manualEntries: entries.filter(e => !isAutoEntry(e)).length,
1714
- constantEntries: entries.filter(e => e.constant).length,
1715
- lorebookTokens: used, tokenBudget: budget, worstCase,
1877
+ entries: all.length,
1878
+ entriesSplit: { card: entries.length, shared: shared.length, total: all.length },
1879
+ manualEntries: m.card.manual,
1880
+ constantEntries: m.total.constant,
1881
+ lorebookTokens: used,
1882
+ tokenSplit: { card: cardTokens, shared: sharedTokens, total: used },
1883
+ sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: scope.tokens } : null,
1884
+ tokenBudget: budget, worstCase,
1716
1885
  issues, errorCount, warningCount: issues.length - errorCount,
1717
- message: issues.length
1886
+ message: (issues.length
1718
1887
  ? "体检发现 " + issues.length + " 个问题(" + errorCount + " 个会真的不工作):" + issues.map(i=>i.message).join(";")
1719
- : "世界书体检通过:没有发现结构性问题。" };
1888
+ : "世界书体检通过:没有发现结构性问题。")
1889
+ + (shared.length ? "(含该组共享世界书 " + shared.length + " 条 / 约 " + sharedTokens + " token,共 " + lints + " 项在册)" : "") };
1720
1890
  }
1721
1891
  });
1722
1892
 
@@ -1797,7 +1967,7 @@ function apply(ctx){
1797
1967
  // ⚠️ 分组 rev 与草稿 rev 是两件事:expectedGroupRev 校验**图谱**,expectedRev(包装层)校验**草稿**。
1798
1968
  // 曾经两者同名,结果传分组 rev 会被包装层当成草稿 rev 拒掉(且错误文案说「草稿已被外部修改」,极具误导性)。
1799
1969
  if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
1800
- return { ok:false, staleRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
1970
+ return { ok:false, staleGroupRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
1801
1971
  group: { id: g.id, name: g.name, rev: g.rev },
1802
1972
  error: "分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重新读一遍再改。" };
1803
1973
  }
@@ -1841,7 +2011,7 @@ function apply(ctx){
1841
2011
  return { ok:false, error:"分组「" + g.name + "」里没有这张卡的节点:先在工坊关系视图里把它加入分组(或用 cc_patch_relations 加一个 kind:\"card\" 的节点)。" };
1842
2012
  }
1843
2013
  if(typeof args.expectedGroupRev === "number" && args.expectedGroupRev !== g.rev){
1844
- return { ok:false, staleRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
2014
+ return { ok:false, staleGroupRev:true, expectedGroupRev: args.expectedGroupRev, actualRev: g.rev,
1845
2015
  error:"分组「" + g.name + "」已被外部修改(期望 rev=" + args.expectedGroupRev + ",实际 rev=" + g.rev + ")—— 先 cc_get_relations 重读再渲染。" };
1846
2016
  }
1847
2017
  const section = renderRelationsSection(r.myNode.id, g.nodes, g.edges, { includeIncoming: args.includeIncoming === true });
@@ -1864,7 +2034,7 @@ function apply(ctx){
1864
2034
  setDraft(ctx, cur, exec);
1865
2035
  }
1866
2036
  return { ok:true, changed, changedFields: changed ? ["description"] : [], outgoing, description: after,
1867
- group: { id: g.id, name: g.name, rev: g.rev }, workflow: workflowStatus(cur),
2037
+ group: { id: g.id, name: g.name, rev: g.rev }, workflow: await workflowFor(ctx, exec, cur),
1868
2038
  message: (changed ? "已把 " + outgoing + " 条关系写进 description 的【关系】段" : "description 里已经是最新关系,无需改动")
1869
2039
  + "。注意:这只改了**当前草稿**,要落库请再调 cc_save_to_library(或让用户在工坊点「保存当前」)。" };
1870
2040
  }
@@ -1888,7 +2058,10 @@ function apply(ctx){
1888
2058
  const g = r.group;
1889
2059
  const memberIds = await groupMemberIds(g.id);
1890
2060
  const cards = await libraryCardsFor(memberIds);
1891
- const res = checkGroup(g, memberIds, cards);
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);
1892
2065
  const errorCount = res.issues.filter(i => i.level === "error").length;
1893
2066
  return { ok:true,
1894
2067
  group: { id: g.id, name: g.name, rev: g.rev },
@@ -2037,9 +2210,16 @@ function apply(ctx){
2037
2210
  const g = normalizeGroup(found.obj);
2038
2211
  const members = await groupMemberIds(gid);
2039
2212
  if(args.confirm !== true){
2213
+ // 盘点必须把**共享世界书**也算进「会被删掉且不可恢复」的东西里(第二轮报告 4.1):
2214
+ // 那可能是用户花了很多时间整理的设定,而原先的破坏性提示只提了节点/关系。
2215
+ const bf = await readBookRaw(gid);
2216
+ const bookCount = bf ? normalizeBook(bf.obj, gid).data.entries.length : 0;
2040
2217
  return { ok:false, needConfirm:true,
2041
2218
  group: { id:g.id, name:g.name, nodeCount:g.nodes.length, edgeCount:g.edges.length, memberCount:members.length },
2042
- message:"「" + g.name + "」里有 " + g.nodes.length + " 个节点 / " + g.edges.length + " 条关系,删组会一起删掉且不可恢复;" + members.length + " 张成员卡不会被删,只会离开这个组。与用户确认后重调并带 confirm:true。" };
2219
+ bookEntries: bookCount, hasBook: !!bf,
2220
+ message:"「" + g.name + "」里有 " + g.nodes.length + " 个节点 / " + g.edges.length + " 条关系,删组会一起删掉且不可恢复;" + members.length + " 张成员卡不会被删,只会离开这个组。"
2221
+ + (bf ? "本组另有共享世界书 " + bookCount + " 条(" + (String(normalizeBook(bf.obj, gid).data.name||"").trim() || "未命名") + "),也会一并删除且不可恢复。" : "")
2222
+ + "与用户确认后重调并带 confirm:true。" };
2043
2223
  }
2044
2224
  const del = await deleteGroupWithMembers(gid);
2045
2225
  const bookDeleted = await deleteBook(gid); // 0.9.0:组级共享世界书随组一起删(留下就是孤儿文件)
@@ -2126,26 +2306,44 @@ function apply(ctx){
2126
2306
  const g = r.group;
2127
2307
  const found = await readBookRaw(g.id);
2128
2308
  const cards = await bookCardsFor(await groupMemberIds(g.id));
2129
- const scan = scanSharedEntries(cards);
2309
+ const book = found ? normalizeBook(found.obj, g.id) : null;
2310
+ // 把**书**一起交给 scan —— 否则「卡内条目与共享书逐字重复」这一类(`cc_apply_shared_book` 回填进卡的副本)
2311
+ // 谁都看不见:卡片之间没有重复、书里也没有重复,于是两个工具都说「没有重复」,而那几份副本就躺在卡里
2312
+ // 等着和共享书双重插入(第二轮报告 P1)。
2313
+ const scan = scanSharedEntries(cards, book ? book.data.entries : []);
2130
2314
  const duplicates = scan.candidates.map(c => ({ signature: c.signature, name: c.name, cardNames: c.cardNames,
2131
2315
  chars: c.chars, savedChars: c.savedChars, nameMismatch: c.nameMismatch, keysMismatch: c.keysMismatch, preview: c.preview }));
2316
+ const bookDuplicates = scan.bookDupes.map(c => ({ kind: c.kind, signature: c.signature, name: c.name, cardNames: c.cardNames,
2317
+ chars: c.chars, savedChars: c.savedChars, preview: c.preview }));
2318
+ // 共享书**自己的**逐条质量检查(第五轮报告:这条线上此前只有重复检测,那四类检查在书上是空的)。
2319
+ // 与 cc_lint_lorebook 共用同一把尺子(auditLoreEntries)—— 一份 issue 带 contentKeys,可直接改。
2320
+ const bookLint = auditLoreEntries([{ source: "shared", entries: book ? book.data.entries : [] }]);
2132
2321
  const base = { ok:true, group:{ id:g.id, name:g.name, rev:g.rev }, exists: !!found,
2133
- bookRev: found ? bookRevOf(normalizeBook(found.obj, g.id)) : 0,
2322
+ bookRev: book ? bookRevOf(book) : 0,
2134
2323
  cards: cards.map(c => ({ id:c.id, name:c.name, entries:(c.entries||[]).length })),
2135
- duplicates, duplicateStats: scan.stats };
2324
+ duplicates, bookDuplicates, duplicateStats: scan.stats,
2325
+ lint: { issues: bookLint.issues, errorCount: bookLint.errorCount, warningCount: bookLint.issues.length - bookLint.errorCount } };
2136
2326
  if(!found){
2137
2327
  return Object.assign(base, { message: "分组「" + g.name + "」还没有共享世界书。"
2138
2328
  + (scan.candidates.length
2139
2329
  ? "成员卡之间有 " + scan.candidates.length + " 条内容重复的条目(合计可省约 " + scan.stats.savedChars + " 字符)—— 用 cc_promote_shared_entries 提升到共享书,再从各卡里删掉。"
2140
2330
  : "成员卡之间也没有内容重复的条目。") });
2141
2331
  }
2142
- const book = normalizeBook(found.obj, g.id);
2143
2332
  const tokens = estimateTokens(book.data.entries.map(e => e.content).join("\n"));
2144
2333
  return Object.assign(base, {
2145
2334
  book: bookBrief(book, args.includeContent === true), tokens,
2335
+ lastPromote: (book.data.extensions && book.data.extensions.cc && book.data.extensions.cc.lastPromote) || null,
2146
2336
  message: "共享世界书「" + book.data.name + "」共 " + book.data.entries.length + " 条(rev " + bookRevOf(book) + ",约 " + tokens + " token"
2147
2337
  + ",预算 " + book.data.token_budget + ");分组里有 " + cards.length + " 张成员卡"
2148
- + (scan.candidates.length ? ",其中仍有 " + scan.candidates.length + " 条内容重复(可用 cc_promote_shared_entries 收进共享书)" : ",卡片间已无重复条目") + "。" });
2338
+ + (scan.candidates.length ? ",卡片之间有 " + scan.candidates.length + " 条内容重复(可用 cc_promote_shared_entries 收进共享书)" : ",卡片之间已无重复条目")
2339
+ + (scan.bookDupes.length
2340
+ ? ";另有 " + scan.bookDupes.length + " 条在卡里与共享书**逐字重复**(共 " + scan.stats.bookDuplicatedEntries + " 份副本、约 " + scan.stats.bookSavedChars + " 字符)—— 共享书里已经有了,用 cc_promote_shared_entries 清掉卡里那几份即可(不会再往书里加)"
2341
+ : "")
2342
+ + (bookLint.issues.length
2343
+ ? ";⚠️ **这本书自身有 " + bookLint.issues.length + " 个条目质量问题**(" + bookLint.issues.map(i => i.message).join(";")
2344
+ + ")—— 共享书在**每一张**成员卡上都生效:一条死 key 等于在所有卡上都没写、一条维度名 key 等于在每张卡上白吃 token。按 lint.issues[].contentKeys 用 cc_patch_shared_book 直接改"
2345
+ : "")
2346
+ + "。" });
2149
2347
  }
2150
2348
  });
2151
2349
 
@@ -2173,7 +2371,7 @@ function apply(ctx){
2173
2371
  message:"当前卡还没入库或没加入分组,共享世界书无处可放 —— 先把它归到一组。" };
2174
2372
  const res = await patchSharedBook(r.group.id, args);
2175
2373
  if(!res.ok){
2176
- return { ok:false, staleRev: res.staleRev === true,
2374
+ return { ok:false, staleBookRev: res.staleBookRev === true,
2177
2375
  expectedBookRev: res.expectedBookRev == null ? null : res.expectedBookRev,
2178
2376
  actualBookRev: res.actualBookRev == null ? null : res.actualBookRev,
2179
2377
  error: String(res.error || "") };
@@ -2209,19 +2407,35 @@ function apply(ctx){
2209
2407
  const res = await promoteSharedBook(r.group.id, args);
2210
2408
  if(!res.ok) return { ok:false, error: String(res.error || "") };
2211
2409
  if(res.dryRun){
2212
- return { ok:true, dryRun:true, wouldPromote: res.wouldPromote, unknownKeys: res.unknownKeys,
2213
- removedTotal: res.removedTotal, perCard: res.perCard, duplicates: res.candidates, stats: res.stats,
2410
+ const nothing = !res.wouldPromote && !res.wouldClean;
2411
+ return { ok:true, dryRun:true, wouldPromote: res.wouldPromote, wouldClean: res.wouldClean, unknownKeys: res.unknownKeys,
2412
+ removedTotal: res.removedTotal, savedChars: res.savedChars, perCard: res.perCard, duplicates: res.candidates,
2413
+ bookDuplicates: res.bookDuplicates, stats: res.stats,
2214
2414
  group:{ id:r.group.id, name:r.group.name }, bookRev: res.rev,
2215
- message: res.wouldPromote
2216
- ? "预览:将把 " + res.wouldPromote + " 条提升进共享世界书,并从成员卡里共删掉 " + res.removedTotal + " 条重复(可省约 " + res.stats.savedChars + " 字符)。确认后带 apply:true 重调。"
2217
- : "没有可提升的重复条目" + (res.unknownKeys.length ? "(" + res.unknownKeys.length + " 个 signature 不认识,已忽略)" : "") + "。" };
2415
+ message: nothing
2416
+ ? "没有可提升的重复条目" + (res.unknownKeys.length ? "(" + res.unknownKeys.length + " 个 signature 不认识,已忽略)" : "") + "。"
2417
+ : "预览:"
2418
+ + (res.wouldPromote ? "把 " + res.wouldPromote + " 条提升进共享世界书," : "")
2419
+ + (res.wouldClean ? "清掉卡里 " + res.wouldClean + " 条与共享书逐字重复的副本(书里已经有了,不会再往书里加)," : "")
2420
+ + "将从成员卡里共删掉 " + res.removedTotal + " 条(可省约 " + res.savedChars + " 字符)。确认后带 apply:true 重调。" };
2218
2421
  }
2219
- return { ok:true, dryRun:false, promoted: res.promoted, removedTotal: res.removedTotal, unknownKeys: res.unknownKeys,
2422
+ // 库里已经删干净了,但**草稿是另一个副本** —— 顺手同步(见 syncDraftAfterPromote 的注释),
2423
+ // 同步不了就退回警告,绝不静默地让重复在下次保存时复活。
2424
+ const draftSync = await syncDraftAfterPromote(ctx, exec, r.group.id,
2425
+ (res.perCard || []).filter(c => c && !c.error && c.removed > 0).map(c => c.id));
2426
+ return {
2427
+ ok:true, dryRun:false, promoted: res.promoted, cleaned: res.cleaned, removedTotal: res.removedTotal, unknownKeys: res.unknownKeys,
2220
2428
  bookRev: res.bookRev, bookEntries: res.bookEntries, perCard: res.perCard, stats: res.stats,
2221
- group:{ id:r.group.id, name:r.group.name },
2429
+ bookDuplicates: res.bookDuplicates,
2430
+ group:{ id:r.group.id, name:r.group.name }, draftSync,
2222
2431
  message: "已把 " + res.promoted + " 条设定提升进共享世界书(rev " + res.bookRev + ",共 " + res.bookEntries + " 条),并从成员卡里删掉 " + res.removedTotal + " 条重复:"
2223
2432
  + res.perCard.map(c => c.name + " -" + (c.removed || 0)).join("、")
2224
- + "。导出时记得一并给客户端配这本共享世界书(工坊导出页可导出独立 JSON)。" };
2433
+ + (res.cleaned ? "(其中 " + res.cleaned + " 条是共享书里早就有的副本,只做了清理)" : "")
2434
+ + (draftSync.synced ? "。你正在编辑的草稿就是其中一张,已同步删掉 " + draftSync.removed + " 条 —— 接着保存不会把重复写回去。" : "")
2435
+ + (draftSync.remaining ? "。⚠️ 当前草稿里还有 " + draftSync.remaining + " 条与共享书重复(" + draftSync.names.slice(0,5).join("、") + "):这次只改了库里的卡,草稿里那份还在 —— 保存前先 cc_load_from_library 重新载入,否则重复会被原样写回卡里。" : "")
2436
+ + "导出时记得一并给客户端配这本共享世界书(工坊导出页可导出独立 JSON)。",
2437
+ warnings: draftSync.remaining ? ["这次提升只改了**库里的卡**,你正在编辑的那份草稿没被改动,里面仍有 " + draftSync.remaining + " 条与共享书逐字重复(" + draftSync.names.slice(0,5).join("、") + ")。接着 cc_save_to_library 会把它们原样写回卡里、重复照旧 —— 保存前先 cc_load_from_library 重新载入这张卡(或手动删掉这几条)。"] : undefined,
2438
+ };
2225
2439
  }
2226
2440
  });
2227
2441
 
@@ -2276,12 +2490,27 @@ function apply(ctx){
2276
2490
  entries.forEach(e => { const c = String(e.content||""); WORLD_DIMS.forEach(k => { const t = String(w[k]||""); if(t && c.indexOf(t) >= 0) dupChars += t.length; }); });
2277
2491
  const exportedFields = ["name","nickname","tags","creator","character_version","description","personality","scenario","system_prompt","post_history_instructions","first_mes","alternate_greetings","group_only_greetings","mes_example","creator_notes","character_book","assets","creation_date","modification_date"];
2278
2492
  const bytes = Buffer.byteLength(json, "utf8");
2493
+ // 0.9.0 报告:导出预览只说「卡内世界书」,用户会以为这就是全部 —— 而客户端实际加载时
2494
+ // 是「卡内 character_book + 该组共享书」叠加,所以这里必须把共享书单列一行。
2495
+ const scope = await sharedBookScopeFor(ctx, exec);
2496
+ const shared = scope.entries;
2497
+ const cardTokens = lorebookTokens(entries);
2498
+ const sharedTokens = lorebookTokens(shared);
2279
2499
  return { ok:true,
2280
- bytes, tokens: estimateTokens(json), lorebookTokens: lorebookTokens(entries),
2500
+ bytes, tokens: estimateTokens(json),
2501
+ lorebookTokens: cardTokens + sharedTokens,
2502
+ lorebookSplit: { card: cardTokens, shared: sharedTokens, total: cardTokens + sharedTokens },
2503
+ sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: sharedTokens,
2504
+ hint: "独立一本,可用工坊「导出共享世界书」单独导出;若客户端不支持全局世界书,可在导出时并入本卡" } : null,
2281
2505
  exportedFields, extensionsExported: Object.keys(d.extensions || {}),
2282
2506
  worldDuplicatedChars: dupChars,
2283
2507
  loreCount: entries.length, manualLoreCount: entries.filter(e => !isAutoEntry(e)).length,
2284
- message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token),其中世界书 " + lorebookTokens(entries) + " token;五维(extensions.cc_world)会一起导出,与条目内容重复约 " + dupChars + " 字符。" };
2508
+ message: "预计导出 " + bytes + " 字节(约 " + estimateTokens(json) + " token),其中本卡世界书 " + cardTokens + " token"
2509
+ + (shared.length
2510
+ ? ";**本卡所属组另有共享世界书「" + (String(scope.bookName||"").trim() || "未命名") + "」" + shared.length + " 条 / 约 " + sharedTokens
2511
+ + " token** —— 客户端实际加载时两者叠加(合计 " + (cardTokens + sharedTokens) + " token),可单独导出该文件或导出时并入本卡"
2512
+ : "")
2513
+ + ";五维(extensions.cc_world)会一起导出,与条目内容重复约 " + dupChars + " 字符。" };
2285
2514
  }
2286
2515
  });
2287
2516
 
@@ -2294,7 +2523,7 @@ function apply(ctx){
2294
2523
  const d = getDraft(ctx, exec);
2295
2524
  const k = keyFor(ctx, exec);
2296
2525
  const st = draftStatusFor(k, d);
2297
- const wf = workflowStatus(d);
2526
+ const wf = await workflowFor(ctx, exec, d);
2298
2527
  const book = d.data.character_book || {};
2299
2528
  const entries = Array.isArray(book.entries) ? book.entries : [];
2300
2529
  const errors=[], warnings=[];
@@ -2305,9 +2534,13 @@ function apply(ctx){
2305
2534
  if(!Array.isArray(d.data.group_only_greetings) || d.data.group_only_greetings.length<1) errors.push("缺 group_only_greetings(至少 1 条必填)");
2306
2535
  if(wf.state.world !== "ok") errors.push("五维世界观未齐:需 timeline/factions/geo/power/daily 至少 3 项(当前 "+wf.worldFilled+"/5),请调 cc_patch_world");
2307
2536
  if(wf.state.lore !== "ok"){
2308
- errors.push(wf.manualLoreCount === 0
2309
- ? "世界书只有自动条目("+wf.loreCount+" 条,全部由 autoLorebook 生成),未完成共创:请调 cc_add_lorebook_entries 至少补 1 条(总≥5 条)"
2310
- : "世界书未齐:需至少 5 条(当前 "+wf.loreCount+" 条),请调 cc_add_lorebook_entries 补到 5 条");
2537
+ errors.push(wf.manualLoreTotal === 0
2538
+ ? "世界书只有自动条目(合计 "+wf.loreTotal+" 条"+(wf.sharedLoreCount ? ",其中共享书 "+wf.sharedLoreCount+" 条" : "")+",全部由 autoLorebook 生成),未完成共创:请调 cc_add_lorebook_entries 至少补 1 条(合计≥5 条)"
2539
+ : "世界书未齐:卡内 "+wf.loreCount+" 条 + 共享书 "+wf.sharedLoreCount+" 条 = 合计 "+wf.loreTotal+" 条,需至少 5 条,请调 cc_add_lorebook_entries 补到 5 条");
2540
+ } else if(wf.loreCount < 5){
2541
+ // 达标靠的是组里那本共享书(第二轮报告 P0 的正解):**不阻塞**,但必须把「导出时要带上它」说清楚 ——
2542
+ // 否则用户把这张「卡内只有 3 条」的卡发出去,对方客户端没有共享书就等于世界书缺失。
2543
+ warnings.push("本卡内只有 "+wf.loreCount+" 条世界书,达标靠的是本组共享世界书「"+(wf.sharedBook && String(wf.sharedBook.name||"").trim() ? wf.sharedBook.name : "未命名")+"」的 "+wf.sharedLoreCount+" 条(合计 "+wf.loreTotal+" 条)。**共享书不会跟着卡走**:导出时请把它一并交给客户端(工坊导出页可单独导出这本 `lorebook_v3`;对方客户端不支持全局世界书时,在导出页勾选「并入本卡」)。");
2311
2544
  }
2312
2545
  // —— 质量(P1-5:此前 valid=true 只代表「四件套非空」,给用户「卡没问题了」的错觉)——
2313
2546
  if(!String(d.data.scenario||"").trim()) warnings.push("scenario 为空,建议补充(缺失时模型只能靠 description 猜场景)");
@@ -2317,8 +2550,9 @@ function apply(ctx){
2317
2550
  if(!String(d.data.creator||"").trim()) warnings.push("data.creator 为空(导出后别人看不到作者)—— 用 cc_patch_character 的 creator 参数写入,或让用户在工坊「角色基础」里手动填。");
2318
2551
  if(!String(book.name||"").trim()) warnings.push("世界书没有名字(character_book.name 为空),导出后是一本无名的书;可用 cc_patch_book_meta 命名");
2319
2552
  if(!String(book.description||"").trim()) warnings.push("character_book.description 为空,建议一句话说明这本世界书的作用");
2320
- if(entries.length>0 && !entries.some(e=>e.constant)) warnings.push("世界书建议至少 1 条 constant 常驻条目(否则全靠触发词命中)");
2321
- if(entries.length>0 && entries.every(e=> !String(e.content||"").includes("@@"))) warnings.push("世界书条目建议带 @@decorator(@@position/@@depth/@@activate)");
2553
+ // 存在性检查按**合计**口径(卡内 + 共享书)—— 见 workflowStatus 里 constantEntries 的注释
2554
+ if(wf.loreTotal > 0 && wf.constantEntries.total === 0) warnings.push("世界书(卡内 + 共享书)没有任何 constant 常驻条目(否则全靠触发词命中)");
2555
+ if(wf.loreTotal > 0 && !entries.some(e => String((e && e.content) || "").includes("@@")) && wf.sharedHasDecorator !== true) warnings.push("世界书条目建议带 @@decorator(@@position/@@depth/@@activate)");
2322
2556
  const deadKeys = entries.filter(e => !e.constant && (!Array.isArray(e.keys) || e.keys.length===0));
2323
2557
  if(deadKeys.length) warnings.push(deadKeys.length + " 条非 constant 条目没有 keys(永远触发不了):" + deadKeys.map(e=>String(e.name||"未命名")).join("、"));
2324
2558
  const dimOnly = entries.filter(e => !e.constant && Array.isArray(e.keys) && e.keys.length>0 && e.keys.every(k => AUTO_NAMES.has(String(k)) || ["timeline","faction","geography","power","daily"].includes(String(k))));
@@ -2340,17 +2574,27 @@ function apply(ctx){
2340
2574
  done: valid,
2341
2575
  readyToValidate: !valid && wf.readyToValidate,
2342
2576
  next,
2343
- hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid),
2577
+ hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid, wf.sharedLoreCount),
2344
2578
  });
2345
2579
  const warningsAll = [recoveredNotice(k, d)].filter(Boolean).concat(warnings); // 新草稿提示同上,交给包装
2580
+ const sharedUsed = (wf.sharedBook && Number(wf.sharedBook.tokens)) || 0;
2346
2581
  return { ok:true, valid, done: valid, errors, warnings: warningsAll,
2347
2582
  loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount, name: d.data.name,
2348
- lorebookTokens: used, tokenBudget: budget,
2583
+ // 世界书的两个口径都报出来(第二轮报告 P0):卡内 / 共享 / 合计 —— `cc_lint_lorebook` 早就是这个口径了,
2584
+ // 「收尾判定」再只数卡内,等于让按最佳实践去重过的卡永远不合格。
2585
+ sharedLoreCount: wf.sharedLoreCount, loreTotal: wf.loreTotal,
2586
+ constantEntries: wf.constantEntries,
2587
+ lorebookTokens: used + sharedUsed,
2588
+ lorebookSplit: { card: used, shared: sharedUsed, total: used + sharedUsed },
2589
+ sharedBook: wf.sharedBook,
2590
+ tokenBudget: budget,
2349
2591
  draftStatus: st, workflow: wfDone,
2350
2592
  // R3(0.6.0):message 不再拼接 next —— 那会让「校验通过 ✓」连说两遍(两个来源拼接未去重)。
2351
2593
  // 下一步只保留 workflow.next 一处。
2352
2594
  message: valid
2353
- ? "校验通过 ✓ 工作流完成(世界书 " + wf.loreCount + " 条 / 估算 " + used + " token)。"
2595
+ ? "校验通过 ✓ 工作流完成(世界书 " + wf.loreTotal + " 条"
2596
+ + (wf.sharedLoreCount ? " = 卡内 " + wf.loreCount + " + 共享书 " + wf.sharedLoreCount : "")
2597
+ + " / 估算 " + (used + sharedUsed) + " token)。"
2354
2598
  : "校验未通过:" + errors.length + " 项待补。" };
2355
2599
  }
2356
2600
  });
@@ -2461,7 +2705,24 @@ async function bookCardsFor(ids){
2461
2705
  }
2462
2706
  return out;
2463
2707
  }
2464
- function scanSharedEntries(cards){
2708
+ /**
2709
+ * 新建共享书的默认名:`<组名>·共享设定`(拿不到组名才退回「共享设定」)。
2710
+ * 第二轮报告 4.2:同一个人的多个组各建一本、全叫「共享设定」,导出成文件后根本分不清是哪一组。
2711
+ * 两半同构(`tests/shared-book.test.mjs` 直接比对),改这里记得同步 `lib/index.js`。
2712
+ */
2713
+ function defaultBookName(groupName){
2714
+ const n = String(groupName == null ? "" : groupName).trim();
2715
+ return n ? n + "·共享设定" : "共享设定";
2716
+ }
2717
+ /** 新建一本空共享书(名字优先用调用方给的,否则 `<组名>·共享设定`)。两半同构。 */
2718
+ async function newBookFor(gid, explicitName){
2719
+ const want = String(explicitName == null ? "" : explicitName).trim();
2720
+ if(want) return normalizeBook({ data: { name: want } }, gid);
2721
+ let gname = "";
2722
+ try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
2723
+ return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
2724
+ }
2725
+ function scanSharedEntries(cards, bookEntries){
2465
2726
  const list = Array.isArray(cards) ? cards : [];
2466
2727
  const seen = new Map();
2467
2728
  list.forEach(c => {
@@ -2478,10 +2739,26 @@ function scanSharedEntries(cards){
2478
2739
  const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
2479
2740
  .sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
2480
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
+ });
2481
2755
  const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
2482
- return { candidates, stats: { cards: list.length, totalEntries,
2756
+ return { candidates, bookDupes, stats: { cards: list.length, totalEntries,
2483
2757
  duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
2484
- savedChars: candidates.reduce((n, g) => n + g.savedChars, 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) } };
2485
2762
  }
2486
2763
  function mergeBookEntries(bookEntries, cardEntries){
2487
2764
  const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
@@ -2501,19 +2778,22 @@ function applyBookPromotion(cards, bookEntries, signatures){
2501
2778
  const promoted = [];
2502
2779
  const nextCards = (Array.isArray(cards) ? cards : []).map(c => {
2503
2780
  const kept = [];
2504
- let removed = 0;
2781
+ let removed = 0, removedChars = 0;
2505
2782
  (Array.isArray(c.entries) ? c.entries : []).forEach(e => {
2506
2783
  const sig = contentKeyOf(e);
2507
2784
  if(want.has(sig)){
2508
2785
  if(!have.has(sig)){ have.add(sig); book.push(makeBookEntry(e)); promoted.push(sig); }
2509
2786
  removed += 1;
2787
+ removedChars += String(e.content == null ? "" : e.content).length;
2510
2788
  return;
2511
2789
  }
2512
2790
  kept.push(makeBookEntry(e));
2513
2791
  });
2514
- return { id: c.id, name: c.name, entries: kept, removed };
2792
+ return { id: c.id, name: c.name, entries: kept, removed, removedChars };
2515
2793
  });
2516
- return { bookEntries: book, cards: nextCards, promoted, removedTotal: nextCards.reduce((n, c) => n + c.removed, 0) };
2794
+ return { bookEntries: book, cards: nextCards, promoted,
2795
+ removedTotal: nextCards.reduce((n, c) => n + c.removed, 0),
2796
+ savedChars: nextCards.reduce((n, c) => n + c.removedChars, 0) };
2517
2797
  }
2518
2798
 
2519
2799
  // —— 0.9.0:三个编排动作(与宿主半 cc_patchBook / cc_promoteBook / cc_applyBook 端点行为等价)——
@@ -2537,9 +2817,9 @@ async function patchSharedBook(groupId, args){
2537
2817
  const gid = safeGroupId(groupId);
2538
2818
  if(!gid) return { ok: false, error: "需要 groupId" };
2539
2819
  const found = await readBookRaw(gid);
2540
- const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({ data: { name: String(args.name || "共享设定") } }, gid);
2820
+ const cur = found ? normalizeBook(found.obj, gid) : await newBookFor(gid, args.name);
2541
2821
  if(typeof args.expectedBookRev === "number" && args.expectedBookRev !== bookRevOf(cur)){
2542
- return { ok: false, staleRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur),
2822
+ return { ok: false, staleBookRev: true, expectedBookRev: args.expectedBookRev, actualBookRev: bookRevOf(cur),
2543
2823
  error: "共享世界书已被外部修改(期望 rev=" + args.expectedBookRev + ",实际 rev=" + bookRevOf(cur) + ")—— 先 cc_get_shared_book 重新读一遍再改。" };
2544
2824
  }
2545
2825
  const applied = [], rejected = [];
@@ -2586,21 +2866,44 @@ async function promoteSharedBook(groupId, args){
2586
2866
  const gid = safeGroupId(groupId);
2587
2867
  if(!gid) return { ok: false, error: "需要 groupId" };
2588
2868
  const found = await readBookRaw(gid);
2589
- const cur = found ? normalizeBook(found.obj, gid) : normalizeBook({}, gid);
2869
+ let cur;
2870
+ if(found){ cur = normalizeBook(found.obj, gid); }
2871
+ else cur = await newBookFor(gid); // 默认名 `<组名>·共享设定`(第二轮报告 4.2)
2590
2872
  const cards = await bookCardsFor(await groupMemberIds(gid));
2591
- const scan = scanSharedEntries(cards);
2592
- const known = new Map(scan.candidates.map(c => [c.signature, c]));
2593
- const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String) : scan.candidates.map(c => c.signature);
2873
+ // 候选有两类:① 卡与卡之间重复(提升进书);② 卡内条目与**共享书**逐字重复(书里已经有,只需清理卡内副本)。
2874
+ // ② 是 `cc_apply_shared_book` 回填卡之后的必然产物:卡里那份与共享书会**双重插入**,而此前两个工具都看不见它
2875
+ // (卡片之间没重复、书里也没重复)—— 第二轮报告 P1。
2876
+ const scan = scanSharedEntries(cards, cur.data.entries);
2877
+ const known = new Map(scan.candidates.concat(scan.bookDupes).map(c => [c.signature, c]));
2878
+ const requested = (Array.isArray(args.keys) && args.keys.length) ? args.keys.map(String)
2879
+ : scan.candidates.map(c => c.signature).concat(scan.bookDupes.map(c => c.signature));
2594
2880
  const unknownKeys = requested.filter(s => !known.has(s));
2595
2881
  const use = requested.filter(s => known.has(s));
2882
+ const bookSigs = new Set(cur.data.entries.map(contentKeyOf));
2883
+ const promoteSigs = use.filter(s => !bookSigs.has(s)); // 真·提升(书里还没有)
2884
+ const cleanSigs = use.filter(s => bookSigs.has(s)); // 书里已有 → 只删卡里那份,不再往书里加
2596
2885
  const plan = applyBookPromotion(cards, cur.data.entries, use);
2597
- const summary = (g) => ({ signature: g.signature, name: g.name, cards: g.cards.length, cardNames: g.cardNames, chars: g.chars, savedChars: g.savedChars, nameMismatch: g.nameMismatch, keysMismatch: g.keysMismatch, preview: g.preview });
2886
+ const summary = (g) => ({ kind: g.kind || "card-vs-card", signature: g.signature, name: g.name, cards: g.cards.length, cardNames: g.cardNames,
2887
+ chars: g.chars, savedChars: g.savedChars, nameMismatch: g.nameMismatch === true, keysMismatch: g.keysMismatch === true, preview: g.preview });
2598
2888
  if(args.apply !== true){
2599
- return { ok: true, dryRun: true, changed: false, wouldPromote: use.length, unknownKeys, removedTotal: plan.removedTotal,
2889
+ return { ok: true, dryRun: true, changed: false, wouldPromote: promoteSigs.length, wouldClean: cleanSigs.length, unknownKeys, removedTotal: plan.removedTotal, savedChars: plan.savedChars,
2600
2890
  perCard: plan.cards.map(c => ({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length })),
2601
- candidates: scan.candidates.map(summary), stats: scan.stats, rev: bookRevOf(cur) };
2891
+ candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats, rev: bookRevOf(cur) };
2602
2892
  }
2603
- const saved = await writeBook(gid, Object.assign({}, cur, { data: Object.assign({}, cur.data, { entries: plan.bookEntries }) }));
2893
+ // 这次删掉的条目里,有多少是「提升」(卡内副本搬进书)之外的**纯清理**(书里早就有)—— 报告 P1 要的可对账字段。
2894
+ const promotedCopies = cards.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.filter(e => promoteSigs.indexOf(contentKeyOf(e)) >= 0).length : 0), 0);
2895
+ const cleaned = Math.max(0, plan.removedTotal - promotedCopies);
2896
+ // 记下「这次提升省了多少」——报告 4.3:去重完再查时 savedChars 已经是 0,收益就查不到了;
2897
+ // 存进 extensions.cc.lastPromote,让 cc_get_shared_book 能一直回显。
2898
+ const ccExt = Object.assign({}, (cur.data.extensions && cur.data.extensions.cc) || {}, {
2899
+ lastPromote: { savedChars: plan.savedChars, removed: plan.removedTotal, promoted: plan.promoted.length, cleaned, at: nowSec() },
2900
+ });
2901
+ const saved = await writeBook(gid, Object.assign({}, cur, {
2902
+ data: Object.assign({}, cur.data, {
2903
+ entries: plan.bookEntries,
2904
+ extensions: Object.assign({}, cur.data.extensions, { cc: ccExt }),
2905
+ }),
2906
+ }));
2604
2907
  const touched = [];
2605
2908
  for(const c of plan.cards){
2606
2909
  if(!c.removed) continue;
@@ -2613,9 +2916,77 @@ async function promoteSharedBook(groupId, args){
2613
2916
  touched.push({ id: c.id, name: c.name, removed: c.removed, left: c.entries.length });
2614
2917
  }catch(e){ touched.push({ id: c.id, name: c.name, error: String(e && e.message || e) }); }
2615
2918
  }
2616
- return { ok: true, dryRun: false, changed: true, promoted: plan.promoted.length, removedTotal: plan.removedTotal, unknownKeys,
2617
- bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched, candidates: scan.candidates.map(summary), stats: scan.stats };
2919
+ return { ok: true, dryRun: false, changed: true, promoted: plan.promoted.length, cleaned, removedTotal: plan.removedTotal, savedChars: plan.savedChars, unknownKeys,
2920
+ bookRev: bookRevOf(saved), bookEntries: saved.data.entries.length, perCard: touched,
2921
+ candidates: scan.candidates.map(summary), bookDuplicates: scan.bookDupes.map(summary), stats: scan.stats };
2922
+ }
2923
+ // —— 提升之后的「草稿同步」(用户报告的第 1 条)——
2924
+ // 为什么必须有这一步:`cc_promote_shared_entries` 改的是**库里的卡**,而模型正在编辑的是**草稿**
2925
+ // ——两个副本。库里那张卡的重复条目被删掉了,草稿里却还原样留着,模型接着调 cc_save_to_library
2926
+ // 就会把重复**原样写回卡里**:刚做完的去重等于白做,而且全程零提示(测试里能直接看到
2927
+ // 「simulate 同时命中 年表@card 与 年表@shared」就是这个状态)。
2928
+ //
2929
+ // 判据与库里那一次**完全相同**:内容已经进了共享书、且**逐字相符**。内容签名只用来筛候选,
2930
+ // 绝不按哈希删数据(哈希碰撞不能变成删用户内容)——内容被改过的那几条一律不动,只报告。
2931
+ /** 草稿里「与共享书逐字重复」的条目(`dupes`)与其余条目(`kept`)。纯函数,便于直接测。 */
2932
+ function draftBookDupes(draftEntries, bookEntries){
2933
+ const book = new Map();
2934
+ (Array.isArray(bookEntries) ? bookEntries : []).forEach(e => {
2935
+ const content = String((e && e.content) == null ? "" : e.content);
2936
+ if(!content.trim()) return;
2937
+ const sig = contentKeyOf(e);
2938
+ if(!book.has(sig)) book.set(sig, content);
2939
+ });
2940
+ const dupes = [], kept = [];
2941
+ (Array.isArray(draftEntries) ? draftEntries : []).forEach(e => {
2942
+ const content = String((e && e.content) == null ? "" : e.content);
2943
+ if(content.trim() && book.get(contentKeyOf(e)) === content) dupes.push(e);
2944
+ else kept.push(e);
2945
+ });
2946
+ return { dupes, kept };
2947
+ }
2948
+ /**
2949
+ * 提升执行后同步草稿:若**当前草稿的来源卡正是这次被删过条目的卡之一**,就把草稿里那几条一起删掉。
2950
+ *
2951
+ * 两条出口:
2952
+ * - `synced:true` → 已同步(`removed` 条),模型可以放心 `cc_save_to_library`;
2953
+ * - `remaining>0` → 草稿里仍有 N 条与共享书重复(来源卡不适用,或内容被改过逐字比对不中)
2954
+ * —— **不动手**,只把条数报回去,由调用方给出「保存前先 cc_load_from_library 重新载入」的警告。
2955
+ *
2956
+ * 并发安全:`getDraft()` 会以盘上 rev 为准自动重载,而「读草稿 → 过滤 → `setDraft`」之间
2957
+ * **没有任何 await**,所以别人的写入不会被半路覆盖(要么它在我读之前完成、要么在我写之后)。
2958
+ */
2959
+ async function syncDraftAfterPromote(ctx, exec, groupId, promotedCardIds){
2960
+ const ids = (Array.isArray(promotedCardIds) ? promotedCardIds : []).map(String).filter(Boolean);
2961
+ const gid = safeGroupId(groupId) || String(groupId || "");
2962
+ const k = keyFor(ctx, exec);
2963
+ const origin = readOriginSync(k);
2964
+ const myCardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
2965
+ const applicable = !!myCardId && ids.indexOf(myCardId) >= 0;
2966
+ const found = gid ? await readBookRaw(gid) : null;
2967
+ if(!found) return { applicable, synced:false, removed:0, remaining:0, names:[], cardId: myCardId || null };
2968
+ const book = normalizeBook(found.obj, gid);
2969
+ let draft;
2970
+ try{ draft = getDraft(ctx, exec); }
2971
+ catch(e){ return { applicable, synced:false, removed:0, remaining:0, names:[], cardId: myCardId || null, error: String((e && e.message) || e) }; }
2972
+ const cb = (draft && draft.data && draft.data.character_book) || {};
2973
+ const entries = Array.isArray(cb.entries) ? cb.entries : [];
2974
+ const overlap = draftBookDupes(entries, book.data.entries);
2975
+ const names = overlap.dupes.map(e => String((e && e.name) || "").trim() || "(未命名)");
2976
+ if(!overlap.dupes.length) return { applicable, synced:false, removed:0, remaining:0, names:[], cardId: myCardId || null };
2977
+ if(!applicable) return { applicable:false, synced:false, removed:0, remaining: overlap.dupes.length, names, cardId: myCardId || null };
2978
+ try{
2979
+ const data = draft.data;
2980
+ const next = Object.assign({}, draft, { data: Object.assign({}, data, {
2981
+ character_book: Object.assign({}, cb, { entries: overlap.kept }),
2982
+ }) });
2983
+ setDraft(ctx, next, exec); // 走同一套落盘 + rev 递增(N4:内容没变则一次都不写)
2984
+ return { applicable:true, synced:true, removed: overlap.dupes.length, remaining:0, names:[], cardId: myCardId };
2985
+ }catch(e){
2986
+ return { applicable:true, synced:false, removed:0, remaining: overlap.dupes.length, names, cardId: myCardId, error: String((e && e.message) || e) };
2987
+ }
2618
2988
  }
2989
+
2619
2990
  /** 回填:把共享书条目合并进某张卡(同内容已存在则跳过)—— 给不支持全局世界书的客户端兜底。 */
2620
2991
  async function applySharedBook(groupId, cardId, dryRun){
2621
2992
  const gid = safeGroupId(groupId);
@@ -2637,6 +3008,38 @@ async function applySharedBook(groupId, cardId, dryRun){
2637
3008
  return { ok: true, cardId, added: merged.added.length, changed: true, loreCount: merged.entries.length };
2638
3009
  }
2639
3010
 
3011
+ /**
3012
+ * 度量类工具的统一口径:世界设定现在有一部分活在**组级共享书**里,所以任何「这张卡有多少条 /
3013
+ * 多少 token」的统计都必须把它算上,并拆成 **卡内 / 共享 / 合计** 三段。
3014
+ *
3015
+ * 为什么单列一个函数:LLM 侧实测报告指出 —— 共享书改变了**数据的位置**,却没有改变工具的**度量口径**;
3016
+ * 四个「读」类工具(simulate / lint / group_check / preview_export)全都默认「卡内 character_book
3017
+ * 就是全部」,于是 simulate 把「会命中」判成「不会命中」(实测低估约 3 倍),lint 的 worstCase
3018
+ * 漏掉共享书里的 constant 条目,group_check 的群聊固定开销也只算了一半。
3019
+ *
3020
+ * 没有来源卡 / 没有分组 / 该组还没有书 → 一律返回空(调用方无需自己判空)。
3021
+ */
3022
+ 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);
3035
+ return {
3036
+ cardId, groupId: gid, bookName: book.data.name, rev: bookRevOf(book), entries,
3037
+ tokens: lorebookTokens(entries),
3038
+ 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,
3040
+ };
3041
+ }
3042
+
2640
3043
  export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
2641
3044
  // 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
2642
3045
  groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
@@ -2648,4 +3051,6 @@ export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, orig
2648
3051
  BOOK_SPEC, bookDirs, bookDir, bookPath, makeBookEntry, normalizeBook, bookRevOf, readBookRaw, writeBook, deleteBook,
2649
3052
  contentKeyOf, bookCardsFor, scanSharedEntries, mergeBookEntries, applyBookPromotion,
2650
3053
  bookEntryBrief, bookBrief, patchSharedBook, promoteSharedBook, applySharedBook,
3054
+ // 提升后的草稿同步(用户报告的第 1、2 条):纯函数 + 同步动作,测试直接比对行为
3055
+ draftBookDupes, syncDraftAfterPromote,
2651
3056
  REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END };