@xia-sc/dsh-cc-studio 0.9.1 → 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/README.md +8 -5
- package/lib/agent.js +217 -313
- package/lib/client.js +32 -7
- package/lib/group-layer.js +624 -0
- package/lib/index.js +122 -150
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
|
792
|
-
|
|
793
|
-
return
|
|
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
|
-
//
|
|
870
|
-
//
|
|
871
|
-
|
|
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
|
-
//
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1722
|
+
const totalTokens = metrics.total.tokens;
|
|
1815
1723
|
const res = { ok:true, simulated: results.length, results,
|
|
1816
1724
|
lorebookTokens: totalTokens,
|
|
1817
|
-
tokenSplit: { card:
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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:{
|
|
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
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
2063
|
-
const
|
|
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
|
|
@@ -2306,9 +2223,11 @@ function apply(ctx){
|
|
|
2306
2223
|
if(!r.ok) return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
2307
2224
|
message:"当前卡还没入库或没加入分组,而共享世界书是按组存放的 —— 先 cc_create_group / cc_set_card_group 把它归到一组。" };
|
|
2308
2225
|
const g = r.group;
|
|
2309
|
-
|
|
2310
|
-
const
|
|
2311
|
-
const
|
|
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;
|
|
2312
2231
|
// 把**书**一起交给 scan —— 否则「卡内条目与共享书逐字重复」这一类(`cc_apply_shared_book` 回填进卡的副本)
|
|
2313
2232
|
// 谁都看不见:卡片之间没有重复、书里也没有重复,于是两个工具都说「没有重复」,而那几份副本就躺在卡里
|
|
2314
2233
|
// 等着和共享书双重插入(第二轮报告 P1)。
|
|
@@ -2496,8 +2415,9 @@ function apply(ctx){
|
|
|
2496
2415
|
// 是「卡内 character_book + 该组共享书」叠加,所以这里必须把共享书单列一行。
|
|
2497
2416
|
const scope = await sharedBookScopeFor(ctx, exec);
|
|
2498
2417
|
const shared = scope.entries;
|
|
2499
|
-
const
|
|
2500
|
-
const
|
|
2418
|
+
const metrics = lorebookMetrics(entries, shared); // 0.10.0:token 口径走卡组层(数字与文案都不变)
|
|
2419
|
+
const cardTokens = metrics.card.tokens;
|
|
2420
|
+
const sharedTokens = metrics.shared.tokens;
|
|
2501
2421
|
return { ok:true,
|
|
2502
2422
|
bytes, tokens: estimateTokens(json),
|
|
2503
2423
|
lorebookTokens: cardTokens + sharedTokens,
|
|
@@ -2561,7 +2481,7 @@ function apply(ctx){
|
|
|
2561
2481
|
if(dimOnly.length) warnings.push(dimOnly.length + " 条条目的 keys 只有维度名(" + dimOnly.map(e=>String(e.name||"未命名")).join("、") + "),真实对话不会命中;可用 cc_simulate_trigger 拿一句用户会说的话验证,建议改成实体名");
|
|
2562
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); });
|
|
2563
2483
|
if(keyless.length) warnings.push(keyless.length + " 条条目的 keys 在自身 content 里找不到(可能是误填):" + keyless.map(e=>String(e.name||"未命名")).join("、"));
|
|
2564
|
-
const used =
|
|
2484
|
+
const used = wf.metrics.card.tokens; // 0.10.0:数字来自卡组层(workflowStatus 里那一次统计)
|
|
2565
2485
|
const budget = Number(book.token_budget||0) || 0;
|
|
2566
2486
|
if(budget>0 && used > budget) warnings.push("世界书内容估算约 "+used+" token,已超出 token_budget="+budget+",建议调大预算或精简条目(cc_patch_book_meta)");
|
|
2567
2487
|
// —— 终态:校验通过后 next 不再指向 cc_validate(P0-3:那正是弱模型死循环的诱因)——
|
|
@@ -2579,7 +2499,8 @@ function apply(ctx){
|
|
|
2579
2499
|
hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid, wf.sharedLoreCount),
|
|
2580
2500
|
});
|
|
2581
2501
|
const warningsAll = [recoveredNotice(k, d)].filter(Boolean).concat(warnings); // 新草稿提示同上,交给包装
|
|
2582
|
-
|
|
2502
|
+
// 同上:共享书那一段(wf.metrics 是 lorebookMetrics 的旧命名形状:card / shared / total / all)
|
|
2503
|
+
const sharedUsed = wf.metrics.shared.tokens;
|
|
2583
2504
|
return { ok:true, valid, done: valid, errors, warnings: warningsAll,
|
|
2584
2505
|
loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount, name: d.data.name,
|
|
2585
2506
|
// 世界书的两个口径都报出来(第二轮报告 P0):卡内 / 共享 / 合计 —— `cc_lint_lorebook` 早就是这个口径了,
|
|
@@ -2688,22 +2609,17 @@ async function deleteBook(groupId){
|
|
|
2688
2609
|
}
|
|
2689
2610
|
return removed > 0;
|
|
2690
2611
|
}
|
|
2691
|
-
/**
|
|
2692
|
-
function contentKeyOf(entry){
|
|
2693
|
-
const s = String((entry && entry.content) != null ? entry.content : "").replace(/\s+/g, " ").trim();
|
|
2694
|
-
let h = 5381;
|
|
2695
|
-
for(let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
2696
|
-
return "c" + h.toString(36) + "-" + s.length;
|
|
2697
|
-
}
|
|
2612
|
+
/** 内容签名:见 lib/group-layer.js 的 `contentKeyOf()`(只用来筛,动手前一律再逐字比对内容)。 */
|
|
2698
2613
|
async function bookCardsFor(ids){
|
|
2614
|
+
const rows = await readLibrarySnapshot();
|
|
2615
|
+
const byId = new Map(rows.map(r => [String(r.id), r]));
|
|
2699
2616
|
const out = [];
|
|
2700
2617
|
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
}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) });
|
|
2707
2623
|
}
|
|
2708
2624
|
return out;
|
|
2709
2625
|
}
|
|
@@ -2724,44 +2640,7 @@ async function newBookFor(gid, explicitName){
|
|
|
2724
2640
|
try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
|
|
2725
2641
|
return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
|
|
2726
2642
|
}
|
|
2727
|
-
|
|
2728
|
-
const list = Array.isArray(cards) ? cards : [];
|
|
2729
|
-
const seen = new Map();
|
|
2730
|
-
list.forEach(c => {
|
|
2731
|
-
(Array.isArray(c.entries) ? c.entries : []).forEach(e => {
|
|
2732
|
-
if(!String((e && e.content) || "").trim()) return;
|
|
2733
|
-
const sig = contentKeyOf(e);
|
|
2734
|
-
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 });
|
|
2735
|
-
const g = seen.get(sig);
|
|
2736
|
-
if(g.cards.indexOf(c.id) < 0){ g.cards.push(c.id); g.cardNames.push(c.name); }
|
|
2737
|
-
if(String(e.name || "") !== g.name) g.nameMismatch = true;
|
|
2738
|
-
if(JSON.stringify((e.keys || []).map(String)) !== JSON.stringify(g.keys)) g.keysMismatch = true;
|
|
2739
|
-
});
|
|
2740
|
-
});
|
|
2741
|
-
const candidates = [...seen.values()].filter(g => g.cards.length >= 2)
|
|
2742
|
-
.sort((a, b) => b.cards.length - a.cards.length || b.chars - a.chars)
|
|
2743
|
-
.map(g => Object.assign({}, g, { preview: g.content.slice(0, 80).replace(/\s+/g, " "), savedChars: g.chars * (g.cards.length - 1) }));
|
|
2744
|
-
// 第二轮报告 P1:还有一类重复是「卡 ↔ 共享书」—— `cc_apply_shared_book` 回填进卡里的副本。
|
|
2745
|
-
// 它和「卡间重复」的**动作不同**:书里已经有了,只需把卡里那份删掉,不必再往书里搬。
|
|
2746
|
-
// 判据同样是**逐字相符**(签名只用来筛候选);回填过的、手工抄进卡的都算。
|
|
2747
|
-
const bookDupes = [];
|
|
2748
|
-
(Array.isArray(bookEntries) ? bookEntries : []).forEach(be => {
|
|
2749
|
-
const content = String((be && be.content) == null ? "" : be.content);
|
|
2750
|
-
const g = content.trim() ? seen.get(contentKeyOf(be)) : null;
|
|
2751
|
-
if(!g || g.content !== content) return;
|
|
2752
|
-
bookDupes.push({ kind: "card-vs-book", signature: g.signature, name: String((be && be.name) || g.name || ""),
|
|
2753
|
-
content, keys: (be.keys || []).map(String), chars: content.length,
|
|
2754
|
-
cards: g.cards.slice(), cardNames: g.cardNames.slice(),
|
|
2755
|
-
preview: content.slice(0, 80).replace(/\s+/g, " "), savedChars: content.length * g.cards.length });
|
|
2756
|
-
});
|
|
2757
|
-
const totalEntries = list.reduce((n, c) => n + (Array.isArray(c.entries) ? c.entries.length : 0), 0);
|
|
2758
|
-
return { candidates, bookDupes, stats: { cards: list.length, totalEntries,
|
|
2759
|
-
duplicatedEntries: candidates.reduce((n, g) => n + g.cards.length, 0),
|
|
2760
|
-
savedChars: candidates.reduce((n, g) => n + g.savedChars, 0),
|
|
2761
|
-
bookDuplicatedEntries: bookDupes.reduce((n, g) => n + g.cards.length, 0),
|
|
2762
|
-
bookDupeCount: bookDupes.length,
|
|
2763
|
-
bookSavedChars: bookDupes.reduce((n, g) => n + g.savedChars, 0) } };
|
|
2764
|
-
}
|
|
2643
|
+
/** 跨卡重复 / 卡内-书重复扫描:见 lib/group-layer.js 的 `scanSharedEntries()`(0.10.0 起两半共用一份)。 */
|
|
2765
2644
|
function mergeBookEntries(bookEntries, cardEntries){
|
|
2766
2645
|
const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
|
|
2767
2646
|
const added = [];
|
|
@@ -3011,38 +2890,63 @@ async function applySharedBook(groupId, cardId, dryRun){
|
|
|
3011
2890
|
}
|
|
3012
2891
|
|
|
3013
2892
|
/**
|
|
3014
|
-
*
|
|
3015
|
-
* 多少 token」的统计都必须把它算上,并拆成 **卡内 / 共享 / 合计** 三段。
|
|
2893
|
+
* 度量类工具的统一口径(0.10.0 起是 `groupScope()` 的薄包装,返回形状与 0.9.x 逐字一致)。
|
|
3016
2894
|
*
|
|
3017
|
-
*
|
|
3018
|
-
*
|
|
3019
|
-
* 就是全部」,于是 simulate 把「会命中」判成「不会命中」(实测低估约 3 倍),
|
|
3020
|
-
* 漏掉共享书里的 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 }`。
|
|
3021
2901
|
*
|
|
3022
2902
|
* 没有来源卡 / 没有分组 / 该组还没有书 → 一律返回空(调用方无需自己判空)。
|
|
3023
2903
|
*/
|
|
3024
2904
|
async function sharedBookScopeFor(ctx, exec){
|
|
3025
|
-
const
|
|
3026
|
-
const
|
|
3027
|
-
const
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
const mine = (await listLibraryEntries()).find(e => e.id === cardId);
|
|
3031
|
-
const gid = (mine && mine.groupId) ? String(mine.groupId) : "";
|
|
3032
|
-
if(!gid) return empty;
|
|
3033
|
-
const found = await readBookRaw(gid);
|
|
3034
|
-
if(!found) return Object.assign({}, empty, { groupId: gid });
|
|
3035
|
-
const book = normalizeBook(found.obj, gid);
|
|
3036
|
-
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;
|
|
3037
2910
|
return {
|
|
3038
|
-
cardId, groupId:
|
|
2911
|
+
cardId: cardId, groupId: groupId, bookName: scope.book.name, rev: scope.book.rev, entries: entries,
|
|
3039
2912
|
tokens: lorebookTokens(entries),
|
|
3040
2913
|
constantTokens: entries.filter(e => e.constant).reduce((n, e) => n + estimateTokens(e && e.content), 0),
|
|
3041
|
-
lastPromote:
|
|
2914
|
+
lastPromote: scope.book.lastPromote || null,
|
|
3042
2915
|
};
|
|
3043
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
|
+
}
|
|
3044
2946
|
|
|
3045
2947
|
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
|
|
2948
|
+
// 卡组层(0.10.0):卡库快照 + 卡组作用域 —— 卡组数据的唯一入口,两半共用 lib/group-layer.js 的纯组装
|
|
2949
|
+
readLibrarySnapshot, groupScope,
|
|
3046
2950
|
// 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
|
|
3047
2951
|
groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
|
|
3048
2952
|
renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, groupMemberIds, libraryCardsFor,
|