@xia-sc/dsh-cc-studio 0.9.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/lib/agent.js +290 -350
- package/lib/client.js +32 -7
- package/lib/group-layer.js +624 -0
- package/lib/index.js +124 -152
- package/package.json +2 -2
- package/presets/cc/agent.cordis.yml +10 -2
- package/presets/cc.patch.yml +10 -2
package/lib/agent.js
CHANGED
|
@@ -7,15 +7,49 @@ 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.3";
|
|
16
23
|
const inject = ["tools", "agents"];
|
|
17
24
|
|
|
18
25
|
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
26
|
+
|
|
27
|
+
// ── 字面转义解码(0.10.3,用户实机报告 P1)──────────────────────────────────────────
|
|
28
|
+
// 模型在 tool call 的 JSON 里经常把换行写成**字面** `\n`(JSON 层多转义一层:`"A\\nB"`),
|
|
29
|
+
// JSON.parse 之后字符串里就是「反斜杠 + n」两个字符。以前这些工具原样落盘,导出的卡在
|
|
30
|
+
// SillyTavern 里就显示「说完这句话。\n\n下一段」——同一张卡里有的字段正常、有的不正常
|
|
31
|
+
// (模型这次写对了),肉眼极难发现(用户实测 18 次调用里 6 次中招)。
|
|
32
|
+
// 卡内文本没有「字面反斜杠 n」的正当用途,所以写类工具统一把字面 `\n` / `\r` / `\t` 解成
|
|
33
|
+
// 真字符;多层转义(`A\\\\nB`)逐层收敛(最多 4 轮)。只作用于**模型输入的工具参数**,
|
|
34
|
+
// 不动工坊保存与导入路径(那两条链路本来就是真换行)。
|
|
35
|
+
function decodeEscapedText(v){
|
|
36
|
+
if(typeof v !== "string" || v.indexOf("\\") < 0) return v;
|
|
37
|
+
var s = v;
|
|
38
|
+
for(var round = 0; round < 4; round++){
|
|
39
|
+
var next = s
|
|
40
|
+
.replace(/\\\\/g, "\u0000") // 成对反斜杠先占位,避免被下面的 \n 规则重复匹配
|
|
41
|
+
.replace(/\\n/g, "\n")
|
|
42
|
+
.replace(/\\r/g, "\r")
|
|
43
|
+
.replace(/\\t/g, "\t")
|
|
44
|
+
.replace(/\u0000/g, "\\");
|
|
45
|
+
if(next === s) break;
|
|
46
|
+
s = next;
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
/** 工具参数里的文本字段:先解码字面转义,再按旧规则 String()。 */
|
|
51
|
+
function textArg(v){ return decodeEscapedText(String(v == null ? "" : v)); }
|
|
52
|
+
|
|
19
53
|
function draftsMap(){ if(!globalThis.__CC_DRAFTS__) globalThis.__CC_DRAFTS__ = new Map(); return globalThis.__CC_DRAFTS__; }
|
|
20
54
|
// —— 落盘根(0.3.6 起统一,与 lib/index.js 是同一套):DSH_HOME 优先,homedir()/.dsh 兜底 ——
|
|
21
55
|
// 草稿与角色库都必须与宿主半算出**同一个路径**,否则又会退化成 issue #5 那种「两边互相看不见」。
|
|
@@ -212,7 +246,7 @@ function makeEmptyCard(){
|
|
|
212
246
|
first_mes:"", alternate_greetings:[], group_only_greetings:[], mes_example:"",
|
|
213
247
|
creator_notes:"", creator_notes_multilingual:{}, source:[], assets:[{type:"icon",uri:"ccdefault:",name:"main",ext:"png"}],
|
|
214
248
|
creation_date: nowSec(), modification_date: nowSec(),
|
|
215
|
-
character_book:{ name:"", description:"", scan_depth:4, token_budget:
|
|
249
|
+
character_book:{ name:"", description:"", scan_depth:4, token_budget:2400, recursive_scanning:false, extensions:{}, entries:[] }
|
|
216
250
|
}
|
|
217
251
|
};
|
|
218
252
|
}
|
|
@@ -327,10 +361,7 @@ function setDraft(ctx, draft, exec){
|
|
|
327
361
|
// 「卡属于哪个组」记在库条目 meta.groupId(单一真源),分组文件里刻意**不存成员列表**。
|
|
328
362
|
// 关系只活在插件侧;要让分享出去的卡自包含,用 cc_render_relations 渲染进 description 的固定标记区。
|
|
329
363
|
// ——————————————————————————————————————————————————————————————
|
|
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 -->";
|
|
364
|
+
// 关系图谱常量(0.10.0 起由 lib/group-layer.js 提供,两半共用一份)—— 这里不再重复定义。
|
|
334
365
|
|
|
335
366
|
function groupDirs(){ return dataRoots("cc-groups"); }
|
|
336
367
|
function groupDir(){ return groupDirs()[0]; }
|
|
@@ -525,53 +556,19 @@ function spliceRelations(description, section, meta){
|
|
|
525
556
|
const head = desc.replace(/\s*$/, "");
|
|
526
557
|
return (head + (head ? "\n\n" : "") + block).trim();
|
|
527
558
|
}
|
|
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 } };
|
|
559
|
+
/**
|
|
560
|
+
* 跨卡体检(0.10.0 起是 lib/group-layer.js 的 `runGroupChecks()` 的薄包装)。
|
|
561
|
+
*
|
|
562
|
+
* 老签名与返回形状**逐字保留**(issues[].level/code/message/refs + stats),所以宿主 cc_groupCheck、
|
|
563
|
+
* 工具 cc_group_check 与既有测试都不用改;实现搬去卡组层是为了让「卡组的下一个新检查项」只加一个
|
|
564
|
+
* 注册表项(那里的 GROUP_CHECKS),而不是在三个地方各写一遍清单、各报一套 code。
|
|
565
|
+
* opts.checks 可只跑指定类别(structure / relations / budget / lore-quality / cross-card)。
|
|
566
|
+
*/
|
|
567
|
+
function checkGroup(group, members, cards, sharedEntries, opts){
|
|
568
|
+
const o = opts || {};
|
|
569
|
+
return runGroupChecks({ group: normalizeGroup(group), members: members, cards: cards,
|
|
570
|
+
sharedEntries: sharedEntries, metrics: o.metrics || null, scope: o.scope || null,
|
|
571
|
+
opts: { checks: o.checks } });
|
|
575
572
|
}
|
|
576
573
|
async function readGroupRaw(id){
|
|
577
574
|
const file = safeGroupId(id) + ".json";
|
|
@@ -616,22 +613,23 @@ async function groupMemberIds(groupId){
|
|
|
616
613
|
const entries = await listLibraryEntries();
|
|
617
614
|
return entries.filter(e => String(e.groupId || "") === gid).map(e => e.id);
|
|
618
615
|
}
|
|
619
|
-
/** 体检 /
|
|
616
|
+
/** 体检 / 渲染需要的卡片摘要(卡不在库里就跳过)—— 0.10.0 起吃卡库快照,不再逐卡重复读盘。 */
|
|
620
617
|
async function libraryCardsFor(ids){
|
|
618
|
+
const rows = await readLibrarySnapshot();
|
|
619
|
+
const byId = new Map(rows.map(r => [String(r.id), r]));
|
|
621
620
|
const out = [];
|
|
622
621
|
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
}catch{}
|
|
622
|
+
const row = byId.get(String(id));
|
|
623
|
+
if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
|
|
624
|
+
const d = (row.draft && row.draft.data) ? row.draft.data : {};
|
|
625
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
626
|
+
out.push({
|
|
627
|
+
id: String(id),
|
|
628
|
+
name: String(d.name || ""),
|
|
629
|
+
description: String(d.description || ""),
|
|
630
|
+
constantTokens: entries.filter(e => e && e.constant).reduce((n, e) => n + estimateTokensLite(e && e.content), 0),
|
|
631
|
+
tokenBudget: Number(d.character_book && d.character_book.token_budget) || 2400,
|
|
632
|
+
});
|
|
635
633
|
}
|
|
636
634
|
return out;
|
|
637
635
|
}
|
|
@@ -653,6 +651,42 @@ async function resolveGroupFor(ctx, exec, explicitGroupId){
|
|
|
653
651
|
const myNode = cardId ? (g.nodes.find(n => n.kind === "card" && n.cardId === cardId) || null) : null;
|
|
654
652
|
return { ok: true, cardId, groupId, group: g, myNode, groups };
|
|
655
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* 卡组作用域(0.10.0)—— 取「这次操作哪个组、组里有哪些卡、这个组的书是什么」的**唯一入口**。
|
|
656
|
+
*
|
|
657
|
+
* 为什么立这一层:共享书上线后,每个工具都自己写了一遍「来源卡 → meta.groupId → 读书」,
|
|
658
|
+
* 实测连着两轮出现「改了四个、漏了第五个」。现在数据只从这里出:工具半传 sessionKey
|
|
659
|
+
* (草稿的来源卡 id 记在 sidecar origin.json 里),宿主半传 cardId / groupId。
|
|
660
|
+
* 纯组装在 lib/group-layer.js(两半共用一份),所以「模型看到的组」与「界面看到的组」形状只有一种。
|
|
661
|
+
* 拿不到来源卡 / 没进组 / 组不存在 / 组还没建书 → 返回 ok:false 的空作用域(调用方无需自己判空)。
|
|
662
|
+
*/
|
|
663
|
+
async function groupScope(input){
|
|
664
|
+
const opt = input || {};
|
|
665
|
+
// 已经读过快照就传进来(opt.snapshot)—— 一次调用里只读一遍盘,也让「只读一次」可被测试钉住
|
|
666
|
+
const snap = opt.snapshot ? opt.snapshot : await readLibrarySnapshot();
|
|
667
|
+
const byId = new Map(snap.map(x => [String(x.id), x]));
|
|
668
|
+
let cardId = opt.cardId ? String(opt.cardId) : "";
|
|
669
|
+
if(!cardId && opt.sessionKey){
|
|
670
|
+
const origin = readOriginSync(String(opt.sessionKey));
|
|
671
|
+
cardId = (origin && origin.libraryId) ? String(origin.libraryId) : "";
|
|
672
|
+
}
|
|
673
|
+
const mine = cardId ? (byId.get(cardId) || null) : null;
|
|
674
|
+
const explicit = opt.groupId ? safeGroupId(opt.groupId) : "";
|
|
675
|
+
const groupId = explicit || ((mine && mine.meta.groupId) ? String(mine.meta.groupId) : "");
|
|
676
|
+
const base = { cardId: cardId, include: opt.include, maxCards: opt.maxCards, missing: (cardId && !mine) ? [cardId] : [] };
|
|
677
|
+
if(!groupId) return assembleScope(Object.assign({ groupId: "" }, base));
|
|
678
|
+
// ⚠️ 组文件缺失(用户手工删过 cc-groups,或历史数据)时**不提前返回**:成员与共享书照样读出来,
|
|
679
|
+
// 只是 group 为 null / ok:false —— 旧实现(sharedBookScopeFor)根本不看组文件,提前返回会让
|
|
680
|
+
// 「组没了但书还在」的卡突然看不到自己的书(0.9.x 行为回归)。
|
|
681
|
+
const found = await readGroupRaw(groupId);
|
|
682
|
+
const cards = snap.filter(x => String(x.meta.groupId || "") === groupId);
|
|
683
|
+
const bf = await readBookRaw(groupId);
|
|
684
|
+
const book = bf ? normalizeBook(bf.obj, groupId) : null;
|
|
685
|
+
return assembleScope(Object.assign({
|
|
686
|
+
groupId: groupId, group: found ? normalizeGroup(found.obj) : null, cards: cards, book: book,
|
|
687
|
+
bookRev: book ? bookRevOf(book) : 0,
|
|
688
|
+
}, base));
|
|
689
|
+
}
|
|
656
690
|
|
|
657
691
|
// —— 0.8.0:分组的增删改查(LLM 侧)——
|
|
658
692
|
// 此前 LLM 只能读写**组内图谱**,改不了「哪张卡属于哪个组」:卡一旦因任何原因掉组,
|
|
@@ -770,9 +804,16 @@ function makeLibraryEntryMeta(draft, existingMeta, id){
|
|
|
770
804
|
updatedAt: now,
|
|
771
805
|
};
|
|
772
806
|
}
|
|
773
|
-
|
|
807
|
+
/**
|
|
808
|
+
* 卡库快照(0.10.0):一次目录遍历 + 一次 parse,把 meta 与 draft **一起**留下。
|
|
809
|
+
*
|
|
810
|
+
* 以前 listLibraryEntries() 解析完整份卡 JSON 却只留 meta,于是「组体检」要对每张卡再读一遍盘
|
|
811
|
+
* (N 张成员卡 = 2N 次读盘),而且拿不到 entries —— 卡组级检查(跨卡触发词冲突、五维漂移)根本做不了。
|
|
812
|
+
* 现在读一次就够:listLibraryEntries() 只是它的投影,groupScope() 直接吃它。
|
|
813
|
+
*/
|
|
814
|
+
async function readLibrarySnapshot(){
|
|
774
815
|
await ensureLibDir();
|
|
775
|
-
const seen=new Map(); // id → meta:先到先得,主根排在前 → 同名卡片主根优先
|
|
816
|
+
const seen=new Map(); // id → {id, meta, draft, dir}:先到先得,主根排在前 → 同名卡片主根优先
|
|
776
817
|
for(const dir of libDirs()){
|
|
777
818
|
let files=[];
|
|
778
819
|
try{ files=await readdir(dir); }catch{ continue; }
|
|
@@ -783,14 +824,19 @@ async function listLibraryEntries(){
|
|
|
783
824
|
try{
|
|
784
825
|
const raw=await readFile(join(dir,f),"utf8");
|
|
785
826
|
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));
|
|
827
|
+
if(obj && obj.meta && obj.id) seen.set(id,{ id:id, meta:obj.meta, draft:obj.draft||null, dir:dir });
|
|
828
|
+
else if(obj && obj.draft) seen.set(id,{ id:id, meta:makeLibraryEntryMeta(obj.draft,null,id), draft:obj.draft, dir:dir });
|
|
788
829
|
}catch{}
|
|
789
830
|
}
|
|
790
831
|
}
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
return
|
|
832
|
+
const rows=[...seen.values()];
|
|
833
|
+
rows.sort((a,b)=>((b.meta.updatedAt||0)-(a.meta.updatedAt||0)));
|
|
834
|
+
return rows;
|
|
835
|
+
}
|
|
836
|
+
/** 库条目 meta 列表(签名与排序语义未变,现在只是卡库快照的投影)。 */
|
|
837
|
+
async function listLibraryEntries(){
|
|
838
|
+
const rows = await readLibrarySnapshot();
|
|
839
|
+
return rows.map(r => r.meta);
|
|
794
840
|
}
|
|
795
841
|
async function saveLibraryEntry(draft, opts){
|
|
796
842
|
const dir=await ensureLibDir();
|
|
@@ -862,20 +908,12 @@ function out(schema){ return { schema, render: (args, result) => [{ type:"text",
|
|
|
862
908
|
// 的自相矛盾组合(模型无法据此决策,只能忽略);
|
|
863
909
|
// ② **lore 的「完成」要求至少 1 条非自动条目**:否则 autoLorebook 一次生成 5 条就顶替了第 4 步的共创,
|
|
864
910
|
// 而 `cc_add_lorebook_entries` 的契约写着「必调」—— 两条相反指令同时下发正是实测到的卡壳点。
|
|
865
|
-
const WORLD_DIMS = ["timeline","factions","geo","power","daily"];
|
|
866
911
|
const DIM_NAMES = { timeline:"年表", factions:"势力", geo:"地理", power:"力量体系", daily:"日常" };
|
|
867
912
|
// 英文 key 与 0.4.x 生成过的完全一致(老卡里就是这个写法),避免升级后触发词看起来「悄悄变了」
|
|
868
913
|
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
|
-
}
|
|
914
|
+
// WORLD_DIMS / AUTO_NAMES / isAutoEntry / looksLikeLegacyAuto 由 lib/group-layer.js 提供(两半共用)。
|
|
915
|
+
// 这里曾经各有一份:AUTO_NAMES 的判据(名字是维度名**且**内容/keys 像自动条目)如果两边漂移,
|
|
916
|
+
// 就会出现「工具以为这条是 auto、工坊以为不是」——同一份数据两种结论。
|
|
879
917
|
/**
|
|
880
918
|
* 工作流状态。第二个参数是**该组共享世界书**(`{ bookName, entries, tokens }`,没有就传 null/省略)。
|
|
881
919
|
*
|
|
@@ -954,6 +992,8 @@ function workflowStatus(draft, shared){
|
|
|
954
992
|
sharedBook: sharedLoreCount ? { name: String((shared && shared.bookName) || ""), entries: sharedLoreCount, tokens: Number((shared && shared.tokens) || 0) } : null,
|
|
955
993
|
constantEntries, // { card, shared, total } —— 与 cc_lint_lorebook 同一口径
|
|
956
994
|
sharedHasDecorator, // 共享书里有没有 @@decorator(存在性检查用合计)
|
|
995
|
+
metrics: metricsBrief(metrics), // 只有数字、**不带条目正文**:`all` 数组会随每个工具的返回出去
|
|
996
|
+
// (0.10.0 LLM 侧实测多带约 1800 token —— 见 metricsBrief 的注释)
|
|
957
997
|
next,
|
|
958
998
|
// 0.6.0(R1/R2):`hint` 只报**进度**,不再内嵌 `next` 全文 ——
|
|
959
999
|
// 旧版 `hint = 进度 + " → " + next` 让同一句话在一份返回里出现三遍(hint 内嵌 next、message 又等于 hint)。
|
|
@@ -978,145 +1018,16 @@ function progressHint(step, state, worldFilled, loreCount, manualLoreCount, done
|
|
|
978
1018
|
+ " " + mark(state.greetings) + "问候语"
|
|
979
1019
|
+ (done ? " · 已完成,可导出/保存" : "");
|
|
980
1020
|
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
1021
|
+
// —— 世界书口径与体检的实现都在 lib/group-layer.js(0.10.0)——
|
|
1022
|
+
// estimateTokens / lorebookTokens / lorebookMetrics / auditLoreEntries / worstCaseTrigger 等
|
|
1023
|
+
// 以前在这里各有一份、宿主半再抄一份,靠测试逐字比对维持同步(「改了四个、漏了第五个」的根因);
|
|
1024
|
+
// 现在两半 import 同一个文件,改口径只改一处。
|
|
1019
1025
|
/**
|
|
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`)。
|
|
1026
|
+
* 世界书的三段基础统计:见 lib/group-layer.js 的 `lorebookMetrics()`(0.10.0 起是 groupMetrics 的
|
|
1027
|
+
* 薄包装,形状与数字与 0.9.x 逐字一致)。历史与理由(为什么必须收口)也记在那个文件里。
|
|
1034
1028
|
*/
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
}
|
|
1029
|
+
/** 世界书条目的逐条质量检查:见 lib/group-layer.js 的 `auditLoreEntries()`(两半共用一把尺子)。 */
|
|
1030
|
+
/** 最坏情况单次触发量:见 lib/group-layer.js 的 `worstCaseTrigger()`(算法与理由记在那里)。 */
|
|
1120
1031
|
|
|
1121
1032
|
|
|
1122
1033
|
function apply(ctx){
|
|
@@ -1220,7 +1131,7 @@ function apply(ctx){
|
|
|
1220
1131
|
|
|
1221
1132
|
tool({
|
|
1222
1133
|
name: "cc_patch_character",
|
|
1223
|
-
description: "【工作流第2步·共创】写入角色基础(name/nickname/description/personality/scenario/system_prompt/tags)。调用前必须已与用户讨论:至少问 1 个开放问题(如想要的气质/性别/年龄/与你的关系张力/禁忌),拿到用户偏好后再填;禁止仅凭世界观自行推断一次性填满。必须在 cc_get_card 之后、五维之前调用,之后下一步是 cc_patch_world
|
|
1134
|
+
description: "【工作流第2步·共创】写入角色基础(name/nickname/description/personality/scenario/system_prompt/tags)。调用前必须已与用户讨论:至少问 1 个开放问题(如想要的气质/性别/年龄/与你的关系张力/禁忌),拿到用户偏好后再填;禁止仅凭世界观自行推断一次性填满。必须在 cc_get_card 之后、五维之前调用,之后下一步是 cc_patch_world。一次只传 1-3 个字段(长字段分多次调用写);tags 等数组字段单独一次调用。参数必须是严格合法的 JSON(最后一个字段/数组元素后不写逗号,不要 ```json 代码块包裹,不要注释与单引号;字符串内换行要转义)。",
|
|
1224
1135
|
parameters: {
|
|
1225
1136
|
type:"object",
|
|
1226
1137
|
properties:{
|
|
@@ -1251,16 +1162,16 @@ function apply(ctx){
|
|
|
1251
1162
|
const changed = [];
|
|
1252
1163
|
// B2(0.7.1):`changed` 与 `rev` 用同一套判据 —— 只有值真的变了才记改动,否则会出现
|
|
1253
1164
|
// 「返回说改了 creator、rev 却纹丝不动」这种自相矛盾(第五轮实测 B2)。
|
|
1254
|
-
if(args.name!==undefined) assignIfChanged(cur.data, "name",
|
|
1255
|
-
if(args.nickname!==undefined) assignIfChanged(cur.data, "nickname",
|
|
1256
|
-
if(args.description!==undefined) assignIfChanged(cur.data, "description",
|
|
1257
|
-
if(args.personality!==undefined) assignIfChanged(cur.data, "personality",
|
|
1258
|
-
if(args.scenario!==undefined) assignIfChanged(cur.data, "scenario",
|
|
1259
|
-
if(args.system_prompt!==undefined) assignIfChanged(cur.data, "system_prompt",
|
|
1260
|
-
if(args.post_history_instructions!==undefined) assignIfChanged(cur.data, "post_history_instructions",
|
|
1261
|
-
if(Array.isArray(args.tags)) assignIfChanged(cur.data, "tags", args.tags.map(
|
|
1262
|
-
if(args.creator_notes!==undefined) assignIfChanged(cur.data, "creator_notes",
|
|
1263
|
-
if(args.creator!==undefined) assignIfChanged(cur.data, "creator",
|
|
1165
|
+
if(args.name!==undefined) assignIfChanged(cur.data, "name", textArg(args.name).slice(0,120), changed);
|
|
1166
|
+
if(args.nickname!==undefined) assignIfChanged(cur.data, "nickname", textArg(args.nickname).slice(0,120), changed);
|
|
1167
|
+
if(args.description!==undefined) assignIfChanged(cur.data, "description", textArg(args.description).slice(0,5000), changed);
|
|
1168
|
+
if(args.personality!==undefined) assignIfChanged(cur.data, "personality", textArg(args.personality).slice(0,5000), changed);
|
|
1169
|
+
if(args.scenario!==undefined) assignIfChanged(cur.data, "scenario", textArg(args.scenario).slice(0,5000), changed);
|
|
1170
|
+
if(args.system_prompt!==undefined) assignIfChanged(cur.data, "system_prompt", textArg(args.system_prompt).slice(0,3000), changed);
|
|
1171
|
+
if(args.post_history_instructions!==undefined) assignIfChanged(cur.data, "post_history_instructions", textArg(args.post_history_instructions).slice(0,3000), changed);
|
|
1172
|
+
if(Array.isArray(args.tags)) assignIfChanged(cur.data, "tags", args.tags.map(textArg).slice(0,12), changed);
|
|
1173
|
+
if(args.creator_notes!==undefined) assignIfChanged(cur.data, "creator_notes", textArg(args.creator_notes).slice(0,3000), changed);
|
|
1174
|
+
if(args.creator!==undefined) assignIfChanged(cur.data, "creator", textArg(args.creator).slice(0,120), changed);
|
|
1264
1175
|
setDraft(ctx, cur, exec);
|
|
1265
1176
|
const wf = await workflowFor(ctx, exec, cur);
|
|
1266
1177
|
const warnings = [];
|
|
@@ -1277,7 +1188,7 @@ function apply(ctx){
|
|
|
1277
1188
|
|
|
1278
1189
|
tool({
|
|
1279
1190
|
name: "cc_patch_world",
|
|
1280
|
-
description: "【工作流第3步·共创·必调】写入 5 维世界观(timeline/factions/geo/power/daily 至少 3 项)。若用户已提供世界观文档,先用 2-3 句归纳你理解的 5 维,再问用户“哪几维最想展开?有无私设要加?”;若世界观薄弱,则与用户讨论 1-2 个薄弱点后再填。严禁未与用户讨论就直接照搬文档填满 5
|
|
1191
|
+
description: "【工作流第3步·共创·必调】写入 5 维世界观(timeline/factions/geo/power/daily 至少 3 项)。若用户已提供世界观文档,先用 2-3 句归纳你理解的 5 维,再问用户“哪几维最想展开?有无私设要加?”;若世界观薄弱,则与用户讨论 1-2 个薄弱点后再填。严禁未与用户讨论就直接照搬文档填满 5 维。⚠️ 若本卡已在**有共享世界书**的分组里,本工具默认**不再**往卡内抄五维骨架(那些共同设定属于共享书,卡内副本会与它双重插入)—— 要卡内副本才显式传 autoLorebook:true。调后下一步是 cc_add_lorebook_entries。loreKeys 这类对象字段建议单独一次调用;参数必须是严格合法的 JSON(最后一个字段/数组元素后不写逗号,不要 ```json 代码块包裹,不要注释与单引号)。",
|
|
1281
1192
|
parameters: {
|
|
1282
1193
|
type:"object",
|
|
1283
1194
|
properties:{
|
|
@@ -1286,7 +1197,7 @@ function apply(ctx){
|
|
|
1286
1197
|
geo:{type:"string", description:"地理"},
|
|
1287
1198
|
power:{type:"string", description:"力量体系"},
|
|
1288
1199
|
daily:{type:"string", description:"日常"},
|
|
1289
|
-
autoLorebook:{type:"boolean", description:"是否基于 5
|
|
1200
|
+
autoLorebook:{type:"boolean", description:"是否基于 5 维在**卡内**自动生成 3-6 条带 @@decorator 的 lorebook 条目。**不传时**:本卡所属分组若已有共享世界书覆盖这些维度 → 不生成(避免与共享书双重插入、触发词互相打架);否则生成。true 强制生成,false 强制不生成。多卡同组时建议不传或传 false —— 共同设定留在共享书里。"},
|
|
1290
1201
|
loreKeys:{
|
|
1291
1202
|
type:"object",
|
|
1292
1203
|
description:"**强烈建议提供**:为每一维自报触发词(真实对话里用户会说的实体名),如 {\"geo\":[\"旧校舍\",\"广播室\",\"天台\"],\"factions\":[\"夜巡\",\"学生会\"]}。不给的话自动条目只能用维度名当 key —— 用户永远不会说「地理」,那几条等于死条目。",
|
|
@@ -1304,13 +1215,23 @@ function apply(ctx){
|
|
|
1304
1215
|
const recNote = recoveredNotice(k, cur);
|
|
1305
1216
|
cur.data.extensions = cur.data.extensions || {};
|
|
1306
1217
|
const w = cur.data.extensions.cc_world || {};
|
|
1307
|
-
if(args.timeline!==undefined) w.timeline =
|
|
1308
|
-
if(args.factions!==undefined) w.factions =
|
|
1309
|
-
if(args.geo!==undefined) w.geo =
|
|
1310
|
-
if(args.power!==undefined) w.power =
|
|
1311
|
-
if(args.daily!==undefined) w.daily =
|
|
1218
|
+
if(args.timeline!==undefined) w.timeline = textArg(args.timeline).slice(0,1000);
|
|
1219
|
+
if(args.factions!==undefined) w.factions = textArg(args.factions).slice(0,1000);
|
|
1220
|
+
if(args.geo!==undefined) w.geo = textArg(args.geo).slice(0,1000);
|
|
1221
|
+
if(args.power!==undefined) w.power = textArg(args.power).slice(0,1000);
|
|
1222
|
+
if(args.daily!==undefined) w.daily = textArg(args.daily).slice(0,1000);
|
|
1312
1223
|
cur.data.extensions.cc_world = w;
|
|
1313
|
-
|
|
1224
|
+
// 0.10.0:默认值必须看得见「组里已经有什么」——
|
|
1225
|
+
// 没显式传 autoLorebook 时,若本卡所属分组的共享书已覆盖这些维度,就**不在卡内生成副本**:
|
|
1226
|
+
// 用户只想改五维里的一个字,不该被抄回整套骨架(那会抵消共享书的去重价值,并立刻踩
|
|
1227
|
+
// 「卡内与书逐字重复」+「跨卡触发词冲突」两条检查)。显式 true / false 一律照办。
|
|
1228
|
+
const explicitAuto = (typeof args.autoLorebook === "boolean") ? args.autoLorebook : null;
|
|
1229
|
+
let shouldAuto = (explicitAuto === null) ? true : explicitAuto;
|
|
1230
|
+
let autoSkipped = null;
|
|
1231
|
+
if(explicitAuto === null){
|
|
1232
|
+
const cover = await sharedBookCoversDims(ctx, exec);
|
|
1233
|
+
if(cover.covered){ shouldAuto = false; autoSkipped = cover; }
|
|
1234
|
+
}
|
|
1314
1235
|
const autoDropped = [];
|
|
1315
1236
|
const autoEdited = [];
|
|
1316
1237
|
if(shouldAuto){
|
|
@@ -1353,7 +1274,7 @@ function apply(ctx){
|
|
|
1353
1274
|
if(w.timeline) push("timeline", "@@position after_desc\n"+w.timeline, 10);
|
|
1354
1275
|
if(w.factions) push("factions", "@@depth 4\n"+w.factions, 9);
|
|
1355
1276
|
if(w.geo) push("geo", "@@position scenario\n"+w.geo, 8);
|
|
1356
|
-
if(w.power) push("power", "@@activate\n"+w.power, 9, true);
|
|
1277
|
+
if(w.power) push("power", "@@activate 100\n"+w.power, 9, true);
|
|
1357
1278
|
if(w.daily) push("daily", "@@depth 5\n"+w.daily, 7);
|
|
1358
1279
|
}
|
|
1359
1280
|
setDraft(ctx, cur, exec);
|
|
@@ -1391,9 +1312,21 @@ function apply(ctx){
|
|
|
1391
1312
|
}
|
|
1392
1313
|
const res = { ok:true, worldFilled: wf.worldFilled, loreCount: wf.loreCount,
|
|
1393
1314
|
autoLoreAdded: autoEntries.length, autoLoreReplaced: autoDropped.length, autoLoreEdited: autoEdited.length,
|
|
1315
|
+
autoLoreSkipped: !!autoSkipped,
|
|
1316
|
+
autoLoreSkipReason: autoSkipped ? "shared-book-covers-dims" : null,
|
|
1394
1317
|
sharedLoreKeys, heavyAutoEntries: heavyAuto.length,
|
|
1395
1318
|
draftStatus: draftStatusFor(k, cur), workflow: wf,
|
|
1396
|
-
message: "已写入五维(" + wf.worldFilled + "/5
|
|
1319
|
+
message: "已写入五维(" + wf.worldFilled + "/5),"
|
|
1320
|
+
+ (autoSkipped
|
|
1321
|
+
? "本组共享世界书已覆盖这些维度,**没有**在卡内生成副本(要卡内副本请显式传 autoLorebook:true)"
|
|
1322
|
+
: "自动生成 " + autoEntries.length + " 条基础条目")
|
|
1323
|
+
+ "。" + wf.next };
|
|
1324
|
+
if(autoSkipped){
|
|
1325
|
+
warnings.push("本卡所属分组已有共享世界书「" + (autoSkipped.bookName || "未命名") + "」,其中 "
|
|
1326
|
+
+ autoSkipped.count + " 条覆盖了 年表/势力/地理/力量体系/日常 —— 本次**没有**在卡内生成基础条目:"
|
|
1327
|
+
+ "卡内副本会在群聊里与共享书双重插入,触发词也会互相打架(组体检会报「卡内条目与共享书逐字重复」和「跨卡触发词冲突」两类问题)。"
|
|
1328
|
+
+ "确实需要卡内副本时显式传 autoLorebook:true;正常的下一步是 cc_add_lorebook_entries 补共创条目。");
|
|
1329
|
+
}
|
|
1397
1330
|
if(autoEdited.length) warnings.push("被替换的 " + autoEdited.length + " 条旧自动条目(" + autoEdited.join("、") + ")的 keys 曾被手工改过,这次已重建回默认维度名,手工触发词会丢。想保住它们:先用 cc_update_lorebook_entry 写回,或以后调本工具时带上 loreKeys。");
|
|
1398
1331
|
if(args.verbose === true) res.draft = cur;
|
|
1399
1332
|
if(warnings.length) res.warnings = warnings;
|
|
@@ -1403,7 +1336,7 @@ function apply(ctx){
|
|
|
1403
1336
|
|
|
1404
1337
|
tool({
|
|
1405
1338
|
name: "cc_add_lorebook_entries",
|
|
1406
|
-
description: "【工作流第4步·共创·必调】添加 1-5 条 Lorebook,补到至少 5 条(年表/势力/地理/力量/日常各 1,至少 1 条 constant,@@decorator
|
|
1339
|
+
description: "【工作流第4步·共创·必调】添加 1-5 条 Lorebook,补到至少 5 条(年表/势力/地理/力量/日常各 1,至少 1 条 constant,@@decorator)。调用前应与用户讨论:问用户想要哪些触发词/常驻条目/要藏什么彩蛋,拿到偏好后再生成。必须在五维之后、问候语之前调用,禁止一次性无讨论批量生成。条目较多时分成多次调用(一次 1-2 条);参数必须是严格合法的 JSON(数组元素之间不写尾逗号,最后一个元素后不写逗号,不要 ```json 代码块包裹,不要注释与单引号)。",
|
|
1407
1340
|
parameters: {
|
|
1408
1341
|
type:"object",
|
|
1409
1342
|
properties:{
|
|
@@ -1435,14 +1368,14 @@ function apply(ctx){
|
|
|
1435
1368
|
const recNote = recoveredNotice(k, cur);
|
|
1436
1369
|
args.entries.forEach(function(e){
|
|
1437
1370
|
cur.data.character_book.entries.push({
|
|
1438
|
-
keys: Array.isArray(e.keys)? e.keys.map(
|
|
1439
|
-
content:
|
|
1371
|
+
keys: Array.isArray(e.keys)? e.keys.map(textArg): [],
|
|
1372
|
+
content: textArg(e.content).slice(0,5000),
|
|
1440
1373
|
enabled:true,
|
|
1441
1374
|
insertion_order: cur.data.character_book.entries.length,
|
|
1442
1375
|
case_sensitive:false,
|
|
1443
1376
|
use_regex: !!e.use_regex,
|
|
1444
1377
|
constant: !!e.constant,
|
|
1445
|
-
name:
|
|
1378
|
+
name: textArg(e.name||"未命名").slice(0,60),
|
|
1446
1379
|
priority: typeof e.priority==="number"? e.priority: 5,
|
|
1447
1380
|
id: Date.now()+Math.floor(Math.random()*10000)
|
|
1448
1381
|
});
|
|
@@ -1524,9 +1457,9 @@ function apply(ctx){
|
|
|
1524
1457
|
const e = cur.data.character_book.entries.find(x=> x.id===args.id);
|
|
1525
1458
|
if(!e) return { ok:false, error:"未找到 id="+args.id };
|
|
1526
1459
|
const changed = [];
|
|
1527
|
-
if(args.name!==undefined) assignIfChanged(e, "name",
|
|
1528
|
-
if(Array.isArray(args.keys)) assignIfChanged(e, "keys", args.keys.map(
|
|
1529
|
-
if(args.content!==undefined) assignIfChanged(e, "content",
|
|
1460
|
+
if(args.name!==undefined) assignIfChanged(e, "name", textArg(args.name).slice(0,60), changed);
|
|
1461
|
+
if(Array.isArray(args.keys)) assignIfChanged(e, "keys", args.keys.map(textArg), changed);
|
|
1462
|
+
if(args.content!==undefined) assignIfChanged(e, "content", textArg(args.content).slice(0,5000), changed);
|
|
1530
1463
|
if(args.enabled!==undefined) assignIfChanged(e, "enabled", !!args.enabled, changed);
|
|
1531
1464
|
if(args.constant!==undefined) assignIfChanged(e, "constant", !!args.constant, changed);
|
|
1532
1465
|
if(args.use_regex!==undefined) assignIfChanged(e, "use_regex", !!args.use_regex, changed);
|
|
@@ -1545,7 +1478,7 @@ function apply(ctx){
|
|
|
1545
1478
|
|
|
1546
1479
|
tool({
|
|
1547
1480
|
name: "cc_patch_greetings",
|
|
1548
|
-
description: "【工作流第5步·共创】写入开场与示例(first_mes / alternate_greetings≥2 / group_only_greetings≥1 / mes_example)。调用前必须问用户偏好的开场场景/关系阶段/群聊与私聊区分,拿到偏好后再写;禁止未与用户讨论就自行编造全部问候语。必须在角色+五维+世界书(≥5)
|
|
1481
|
+
description: "【工作流第5步·共创】写入开场与示例(first_mes / alternate_greetings≥2 / group_only_greetings≥1 / mes_example)。调用前必须问用户偏好的开场场景/关系阶段/群聊与私聊区分,拿到偏好后再写;禁止未与用户讨论就自行编造全部问候语。必须在角色+五维+世界书(≥5)后调用。一次只传 1-2 个字段(长问候语分多次调用写);alternate_greetings / group_only_greetings 这类数组字段单独一次调用。参数必须是严格合法的 JSON(最后一个字段/数组元素后不写逗号,不要 ```json 代码块包裹,不要注释与单引号)。",
|
|
1549
1482
|
parameters: {
|
|
1550
1483
|
type:"object",
|
|
1551
1484
|
properties:{
|
|
@@ -1563,10 +1496,10 @@ function apply(ctx){
|
|
|
1563
1496
|
const k = keyFor(ctx, exec);
|
|
1564
1497
|
const cur = JSON.parse(JSON.stringify(getDraft(ctx, exec)));
|
|
1565
1498
|
const recNote = recoveredNotice(k, cur);
|
|
1566
|
-
if(args.first_mes!==undefined) cur.data.first_mes =
|
|
1567
|
-
if(Array.isArray(args.alternate_greetings)) cur.data.alternate_greetings = args.alternate_greetings.map(
|
|
1568
|
-
if(Array.isArray(args.group_only_greetings)) cur.data.group_only_greetings = args.group_only_greetings.map(
|
|
1569
|
-
if(args.mes_example!==undefined) cur.data.mes_example =
|
|
1499
|
+
if(args.first_mes!==undefined) cur.data.first_mes = textArg(args.first_mes).slice(0,5000);
|
|
1500
|
+
if(Array.isArray(args.alternate_greetings)) cur.data.alternate_greetings = args.alternate_greetings.map(textArg).slice(0,10);
|
|
1501
|
+
if(Array.isArray(args.group_only_greetings)) cur.data.group_only_greetings = args.group_only_greetings.map(textArg).slice(0,10);
|
|
1502
|
+
if(args.mes_example!==undefined) cur.data.mes_example = textArg(args.mes_example).slice(0,5000);
|
|
1570
1503
|
setDraft(ctx, cur, exec);
|
|
1571
1504
|
const wf = await workflowFor(ctx, exec, cur);
|
|
1572
1505
|
// 若世界书/五维未齐,警告但仍写入,让 validate 卡住
|
|
@@ -1719,7 +1652,7 @@ function apply(ctx){
|
|
|
1719
1652
|
// 【世界书元信息】此前 name/description/scan_depth/token_budget/recursive_scanning 一个都改不了
|
|
1720
1653
|
tool({
|
|
1721
1654
|
name: "cc_patch_book_meta",
|
|
1722
|
-
description: "【世界书·元信息】改 character_book 的顶层参数:name / description / scan_depth / token_budget / recursive_scanning
|
|
1655
|
+
description: "【世界书·元信息】改 character_book 的顶层参数:name / description / scan_depth / token_budget / recursive_scanning。这些字段此前没有任何工具能改,导致导出的世界书永远是无名书、预算也调不了。合并式更新,只改传入的字段。参数必须是严格合法的 JSON(不要尾逗号、不要注释、不要单引号)。",
|
|
1723
1656
|
parameters: {
|
|
1724
1657
|
type:"object",
|
|
1725
1658
|
properties:{
|
|
@@ -1739,8 +1672,8 @@ function apply(ctx){
|
|
|
1739
1672
|
cur.data.character_book = cur.data.character_book || {};
|
|
1740
1673
|
const book = cur.data.character_book;
|
|
1741
1674
|
const changed = [];
|
|
1742
|
-
if(args.name!==undefined) assignIfChanged(book, "name",
|
|
1743
|
-
if(args.description!==undefined) assignIfChanged(book, "description",
|
|
1675
|
+
if(args.name!==undefined) assignIfChanged(book, "name", textArg(args.name).slice(0,120), changed);
|
|
1676
|
+
if(args.description!==undefined) assignIfChanged(book, "description", textArg(args.description).slice(0,500), changed);
|
|
1744
1677
|
if(typeof args.scan_depth==="number") assignIfChanged(book, "scan_depth", Math.max(1, Math.min(50, Math.round(args.scan_depth))), changed);
|
|
1745
1678
|
if(typeof args.token_budget==="number") assignIfChanged(book, "token_budget", Math.max(0, Math.round(args.token_budget)), changed);
|
|
1746
1679
|
if(args.recursive_scanning!==undefined) assignIfChanged(book, "recursive_scanning", !!args.recursive_scanning, changed);
|
|
@@ -1774,8 +1707,10 @@ function apply(ctx){
|
|
|
1774
1707
|
const texts = [];
|
|
1775
1708
|
// 0.9.0 报告:共享书里的条目在真实聊天里**同样会被触发**(客户端把全局书与卡内 character_book
|
|
1776
1709
|
// 叠加),所以模拟必须把它们一起算 —— 否则会把「会命中」报成「不会命中」(实测低估约 3 倍)。
|
|
1710
|
+
// 0.10.0:作用域与 token 口径都走卡组层(sharedBookScopeFor / lorebookMetrics),本工具不再自己求和。
|
|
1777
1711
|
const scope = await sharedBookScopeFor(ctx, exec);
|
|
1778
1712
|
const shared = scope.entries;
|
|
1713
|
+
const metrics = lorebookMetrics(entries, shared);
|
|
1779
1714
|
const pool = entries.map(e => ({ e: e, src: "card" })).concat(shared.map(e => ({ e: e, src: "shared" })));
|
|
1780
1715
|
if(typeof args.text === "string" && args.text.trim()) texts.push(args.text);
|
|
1781
1716
|
if(Array.isArray(args.texts)) args.texts.forEach(x => { if(typeof x === "string" && x.trim()) texts.push(x); });
|
|
@@ -1811,10 +1746,10 @@ function apply(ctx){
|
|
|
1811
1746
|
if(pool.length && results.every(r => r.hitCount === 0)) warnings.push("这些测试句一条都没触发。若用户很可能这么说,说明世界书的 keys 需要改成实体名(cc_update_lorebook_entry 或下次 cc_patch_world 带 loreKeys)");
|
|
1812
1747
|
const never = entries.filter(e => e.enabled !== false && !e.constant && (!Array.isArray(e.keys) || e.keys.length === 0));
|
|
1813
1748
|
if(never.length) warnings.push(never.length + " 条非 constant 条目没有 keys,永远触发不了:" + never.map(e=>String(e.name||"")).join("、"));
|
|
1814
|
-
const totalTokens =
|
|
1749
|
+
const totalTokens = metrics.total.tokens;
|
|
1815
1750
|
const res = { ok:true, simulated: results.length, results,
|
|
1816
1751
|
lorebookTokens: totalTokens,
|
|
1817
|
-
tokenSplit: { card:
|
|
1752
|
+
tokenSplit: { card: metrics.card.tokens, shared: metrics.shared.tokens, total: totalTokens },
|
|
1818
1753
|
sharedBook: shared.length ? { groupId: scope.groupId, name: scope.bookName, entries: shared.length, tokens: scope.tokens } : null,
|
|
1819
1754
|
scanDepth: book.scan_depth,
|
|
1820
1755
|
message: "模拟完成:" + results.map(r => "「" + r.text.slice(0,16) + "」→ 命中 " + r.hitCount + " 条(约 " + r.tokens + " token"
|
|
@@ -1841,7 +1776,9 @@ function apply(ctx){
|
|
|
1841
1776
|
// 所以预算、最坏情况都得把它们并起来算;同时拆出「卡内 / 共享 / 合计」三段,免得又变成一笔糊涂账。
|
|
1842
1777
|
const scope = await sharedBookScopeFor(ctx, exec);
|
|
1843
1778
|
const shared = scope.entries;
|
|
1844
|
-
|
|
1779
|
+
// 0.10.0:三段统计与「最坏情况」都从卡组层取 —— 数字只有一个来源,本工具不再自己求和。
|
|
1780
|
+
const metrics = groupMetrics({ card: entries, book: shared });
|
|
1781
|
+
const m = metrics.legacy; // { card, shared, total, all } —— 与 0.9.x 的 lorebookMetrics 同形
|
|
1845
1782
|
const all = m.all;
|
|
1846
1783
|
const cardTokens = m.card.tokens;
|
|
1847
1784
|
const sharedTokens = m.shared.tokens;
|
|
@@ -1863,7 +1800,8 @@ function apply(ctx){
|
|
|
1863
1800
|
+ (shared.length ? " —— 注意共享书是**独立一本**、由客户端分别管预算,调本卡预算盖不住它" : ""), [], "card");
|
|
1864
1801
|
}
|
|
1865
1802
|
// N2(0.7.0):总量与「单次触发」是两个独立风险,只报总量会让「调大预算」看起来像修好了。
|
|
1866
|
-
|
|
1803
|
+
// 范围 = 卡内 + 共享书(derived.worstCase 就是按这个范围算的,与 0.9.x 逐字一致)。
|
|
1804
|
+
const worst = metrics.derived.worstCase;
|
|
1867
1805
|
const worstCase = { tokens: worst.tokens, constantTokens: worst.constantTokens, budget, entries: worst.entries,
|
|
1868
1806
|
overBudget: budget > 0 && worst.tokens > budget, sharedEntries: shared.length };
|
|
1869
1807
|
if(worstCase.overBudget){
|
|
@@ -2042,10 +1980,13 @@ function apply(ctx){
|
|
|
2042
1980
|
|
|
2043
1981
|
tool({
|
|
2044
1982
|
name: "cc_group_check",
|
|
2045
|
-
description: "
|
|
1983
|
+
description: "【关系·体检】跨卡检查一个分组:图谱结构(孤儿节点 / 悬空关系 / 重名节点 / 未知类型)、卡内【关系】段(没渲染 / 落后于图谱)、**群聊每轮固定开销**(成员卡 constant + 共享书 constant vs 最小预算 —— 卡越多这块钱越贵,单卡体检看不出来)、**共享书条目质量**(死 key / 只有维度名的 key)、**跨卡一致性**(同一个触发词被多张卡共用、卡内条目与共享书逐字重复、组内五维漂移、成员卡全 auto)。做完一批群聊卡后调一次。用 checks 可只跑其中几类(structure / relations / budget / lore-quality / cross-card)。",
|
|
2046
1984
|
parameters: {
|
|
2047
1985
|
type:"object",
|
|
2048
|
-
properties:{
|
|
1986
|
+
properties:{
|
|
1987
|
+
groupId:{ type:"string", description:"可选:默认用当前卡所属分组" },
|
|
1988
|
+
checks:{ type:"array", items:{ type:"string" }, description:"可选:只跑指定检查类别(structure / relations / budget / lore-quality / cross-card),缺省全跑" },
|
|
1989
|
+
},
|
|
2049
1990
|
additionalProperties:false
|
|
2050
1991
|
},
|
|
2051
1992
|
output: out({ type:"object", additionalProperties:true }),
|
|
@@ -2056,16 +1997,19 @@ function apply(ctx){
|
|
|
2056
1997
|
message: "这张卡还没入库或没加入分组,没有可体检的对象。" };
|
|
2057
1998
|
}
|
|
2058
1999
|
const g = r.group;
|
|
2059
|
-
|
|
2000
|
+
// 0.10.0:成员与共享书从卡组作用域取(一次读盘、形状唯一)—— 不再自己 readBookRaw + normalizeBook。
|
|
2001
|
+
const scope = await groupScope({ groupId: g.id, include: "summary" });
|
|
2002
|
+
const memberIds = scope.memberIds;
|
|
2060
2003
|
const cards = await libraryCardsFor(memberIds);
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
const
|
|
2064
|
-
const res = checkGroup(g, memberIds, cards, sharedEntries);
|
|
2004
|
+
const sharedEntries = scope.book ? scope.book.entries : [];
|
|
2005
|
+
// 数字与跨卡检查的输入都来自卡组层:metrics 给预算类,scope 给跨卡一致性(成员条目与五维)
|
|
2006
|
+
const metrics = groupMetrics({ cardId: scope.cardId, card: [], members: scope.members, book: sharedEntries });
|
|
2007
|
+
const res = checkGroup(g, memberIds, cards, sharedEntries, { checks: args.checks, scope: scope, metrics: metrics });
|
|
2065
2008
|
const errorCount = res.issues.filter(i => i.level === "error").length;
|
|
2066
2009
|
return { ok:true,
|
|
2067
2010
|
group: { id: g.id, name: g.name, rev: g.rev },
|
|
2068
2011
|
issues: res.issues, stats: res.stats,
|
|
2012
|
+
checks: res.ran, byCheck: res.byCheck, // 跑过哪些类别 / 每类几条(0.10.0)
|
|
2069
2013
|
errorCount, warningCount: res.issues.length - errorCount,
|
|
2070
2014
|
members: cards.map(c => ({ id: c.id, name: c.name })),
|
|
2071
2015
|
message: res.issues.length
|
|
@@ -2306,9 +2250,11 @@ function apply(ctx){
|
|
|
2306
2250
|
if(!r.ok) return { ok:false, needGroup:true, cardId:r.cardId || null, groups:r.groups,
|
|
2307
2251
|
message:"当前卡还没入库或没加入分组,而共享世界书是按组存放的 —— 先 cc_create_group / cc_set_card_group 把它归到一组。" };
|
|
2308
2252
|
const g = r.group;
|
|
2309
|
-
|
|
2310
|
-
const
|
|
2311
|
-
const
|
|
2253
|
+
// 0.10.0:书与成员都从卡组作用域取(一次读盘、形状唯一)—— 本工具不再自己 readBookRaw + normalizeBook。
|
|
2254
|
+
const scope = await groupScope({ groupId: g.id, include: "summary" });
|
|
2255
|
+
const cards = await bookCardsFor(scope.memberIds); // scan 要比内容,需要完整条目
|
|
2256
|
+
const book = (scope.book && scope.book.doc) ? scope.book.doc : null;
|
|
2257
|
+
const found = !!book;
|
|
2312
2258
|
// 把**书**一起交给 scan —— 否则「卡内条目与共享书逐字重复」这一类(`cc_apply_shared_book` 回填进卡的副本)
|
|
2313
2259
|
// 谁都看不见:卡片之间没有重复、书里也没有重复,于是两个工具都说「没有重复」,而那几份副本就躺在卡里
|
|
2314
2260
|
// 等着和共享书双重插入(第二轮报告 P1)。
|
|
@@ -2496,8 +2442,9 @@ function apply(ctx){
|
|
|
2496
2442
|
// 是「卡内 character_book + 该组共享书」叠加,所以这里必须把共享书单列一行。
|
|
2497
2443
|
const scope = await sharedBookScopeFor(ctx, exec);
|
|
2498
2444
|
const shared = scope.entries;
|
|
2499
|
-
const
|
|
2500
|
-
const
|
|
2445
|
+
const metrics = lorebookMetrics(entries, shared); // 0.10.0:token 口径走卡组层(数字与文案都不变)
|
|
2446
|
+
const cardTokens = metrics.card.tokens;
|
|
2447
|
+
const sharedTokens = metrics.shared.tokens;
|
|
2501
2448
|
return { ok:true,
|
|
2502
2449
|
bytes, tokens: estimateTokens(json),
|
|
2503
2450
|
lorebookTokens: cardTokens + sharedTokens,
|
|
@@ -2561,7 +2508,7 @@ function apply(ctx){
|
|
|
2561
2508
|
if(dimOnly.length) warnings.push(dimOnly.length + " 条条目的 keys 只有维度名(" + dimOnly.map(e=>String(e.name||"未命名")).join("、") + "),真实对话不会命中;可用 cc_simulate_trigger 拿一句用户会说的话验证,建议改成实体名");
|
|
2562
2509
|
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
2510
|
if(keyless.length) warnings.push(keyless.length + " 条条目的 keys 在自身 content 里找不到(可能是误填):" + keyless.map(e=>String(e.name||"未命名")).join("、"));
|
|
2564
|
-
const used =
|
|
2511
|
+
const used = wf.metrics.card.tokens; // 0.10.0:数字来自卡组层(workflowStatus 里那一次统计)
|
|
2565
2512
|
const budget = Number(book.token_budget||0) || 0;
|
|
2566
2513
|
if(budget>0 && used > budget) warnings.push("世界书内容估算约 "+used+" token,已超出 token_budget="+budget+",建议调大预算或精简条目(cc_patch_book_meta)");
|
|
2567
2514
|
// —— 终态:校验通过后 next 不再指向 cc_validate(P0-3:那正是弱模型死循环的诱因)——
|
|
@@ -2579,7 +2526,8 @@ function apply(ctx){
|
|
|
2579
2526
|
hint: progressHint(valid ? 7 : wf.step, wf.state, wf.worldFilled, wf.loreCount, wf.manualLoreCount, valid, wf.sharedLoreCount),
|
|
2580
2527
|
});
|
|
2581
2528
|
const warningsAll = [recoveredNotice(k, d)].filter(Boolean).concat(warnings); // 新草稿提示同上,交给包装
|
|
2582
|
-
|
|
2529
|
+
// 同上:共享书那一段(wf.metrics 是 lorebookMetrics 的旧命名形状:card / shared / total / all)
|
|
2530
|
+
const sharedUsed = wf.metrics.shared.tokens;
|
|
2583
2531
|
return { ok:true, valid, done: valid, errors, warnings: warningsAll,
|
|
2584
2532
|
loreCount: wf.loreCount, manualLoreCount: wf.manualLoreCount, name: d.data.name,
|
|
2585
2533
|
// 世界书的两个口径都报出来(第二轮报告 P0):卡内 / 共享 / 合计 —— `cc_lint_lorebook` 早就是这个口径了,
|
|
@@ -2688,22 +2636,17 @@ async function deleteBook(groupId){
|
|
|
2688
2636
|
}
|
|
2689
2637
|
return removed > 0;
|
|
2690
2638
|
}
|
|
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
|
-
}
|
|
2639
|
+
/** 内容签名:见 lib/group-layer.js 的 `contentKeyOf()`(只用来筛,动手前一律再逐字比对内容)。 */
|
|
2698
2640
|
async function bookCardsFor(ids){
|
|
2641
|
+
const rows = await readLibrarySnapshot();
|
|
2642
|
+
const byId = new Map(rows.map(r => [String(r.id), r]));
|
|
2699
2643
|
const out = [];
|
|
2700
2644
|
for(const id of (Array.isArray(ids) ? ids : [])){
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
}catch{}
|
|
2645
|
+
const row = byId.get(String(id));
|
|
2646
|
+
if(!row) continue; // 卡不在库里:原实现是 readLibraryRaw 抛错→跳过,语义一致
|
|
2647
|
+
const d = (row.draft && row.draft.data) ? row.draft.data : {};
|
|
2648
|
+
const entries = Array.isArray(d.character_book && d.character_book.entries) ? d.character_book.entries : [];
|
|
2649
|
+
out.push({ id: String(id), name: String(d.name || (row.meta && row.meta.name) || ""), entries: entries.map(makeBookEntry) });
|
|
2707
2650
|
}
|
|
2708
2651
|
return out;
|
|
2709
2652
|
}
|
|
@@ -2724,44 +2667,7 @@ async function newBookFor(gid, explicitName){
|
|
|
2724
2667
|
try{ const gf = await readGroupRaw(gid); if(gf) gname = normalizeGroup(gf.obj).name; }catch{}
|
|
2725
2668
|
return normalizeBook({ data: { name: defaultBookName(gname) } }, gid);
|
|
2726
2669
|
}
|
|
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
|
-
}
|
|
2670
|
+
/** 跨卡重复 / 卡内-书重复扫描:见 lib/group-layer.js 的 `scanSharedEntries()`(0.10.0 起两半共用一份)。 */
|
|
2765
2671
|
function mergeBookEntries(bookEntries, cardEntries){
|
|
2766
2672
|
const have = new Set((Array.isArray(cardEntries) ? cardEntries : []).map(contentKeyOf));
|
|
2767
2673
|
const added = [];
|
|
@@ -2825,8 +2731,8 @@ async function patchSharedBook(groupId, args){
|
|
|
2825
2731
|
error: "共享世界书已被外部修改(期望 rev=" + args.expectedBookRev + ",实际 rev=" + bookRevOf(cur) + ")—— 先 cc_get_shared_book 重新读一遍再改。" };
|
|
2826
2732
|
}
|
|
2827
2733
|
const applied = [], rejected = [];
|
|
2828
|
-
if(args.name !== undefined) cur.data.name =
|
|
2829
|
-
if(args.description !== undefined) cur.data.description =
|
|
2734
|
+
if(args.name !== undefined) cur.data.name = textArg(args.name).slice(0, 80);
|
|
2735
|
+
if(args.description !== undefined) cur.data.description = textArg(args.description).slice(0, 300);
|
|
2830
2736
|
if(args.scan_depth !== undefined) cur.data.scan_depth = Math.max(1, Math.round(Number(args.scan_depth) || 4));
|
|
2831
2737
|
if(args.token_budget !== undefined) cur.data.token_budget = Math.max(100, Math.round(Number(args.token_budget) || 2000));
|
|
2832
2738
|
if(args.recursive_scanning !== undefined) cur.data.recursive_scanning = args.recursive_scanning === true;
|
|
@@ -2834,7 +2740,12 @@ async function patchSharedBook(groupId, args){
|
|
|
2834
2740
|
(Array.isArray(args.ops) ? args.ops : []).forEach(op => {
|
|
2835
2741
|
const kind = String((op && op.op) || "");
|
|
2836
2742
|
if(kind === "addEntry"){
|
|
2837
|
-
|
|
2743
|
+
// 0.10.3:模型可能把换行写成字面 \n —— 共享书条目也是卡内文本,先解码再归一化。
|
|
2744
|
+
const rawEntry = Object.assign({}, op.entry);
|
|
2745
|
+
if(rawEntry.content !== undefined) rawEntry.content = textArg(rawEntry.content);
|
|
2746
|
+
if(rawEntry.name !== undefined) rawEntry.name = textArg(rawEntry.name);
|
|
2747
|
+
if(Array.isArray(rawEntry.keys)) rawEntry.keys = rawEntry.keys.map(textArg);
|
|
2748
|
+
const e = makeBookEntry(rawEntry);
|
|
2838
2749
|
if(!e.content.trim()){ rejected.push({ op, error: "content 不能为空" }); return; }
|
|
2839
2750
|
if(cur.data.entries.some(x => contentKeyOf(x) === contentKeyOf(e))){ rejected.push({ op, error: "同内容的条目已存在" }); return; }
|
|
2840
2751
|
if(cur.data.entries.length >= BOOK_MAX_ENTRIES){ rejected.push({ op, error: "条目数已达上限 " + BOOK_MAX_ENTRIES }); return; }
|
|
@@ -2843,7 +2754,11 @@ async function patchSharedBook(groupId, args){
|
|
|
2843
2754
|
} else if(kind === "updateEntry"){
|
|
2844
2755
|
const idx = cur.data.entries.findIndex(x => contentKeyOf(x) === String(op.contentKey || ""));
|
|
2845
2756
|
if(idx < 0){ rejected.push({ op, error: "找不到条目(contentKey 不匹配)" }); return; }
|
|
2846
|
-
|
|
2757
|
+
const rawPatch = Object.assign({}, (op.patch && typeof op.patch === "object") ? op.patch : {});
|
|
2758
|
+
if(rawPatch.content !== undefined) rawPatch.content = textArg(rawPatch.content);
|
|
2759
|
+
if(rawPatch.name !== undefined) rawPatch.name = textArg(rawPatch.name);
|
|
2760
|
+
if(Array.isArray(rawPatch.keys)) rawPatch.keys = rawPatch.keys.map(textArg);
|
|
2761
|
+
cur.data.entries[idx] = makeBookEntry(Object.assign({}, cur.data.entries[idx], rawPatch));
|
|
2847
2762
|
applied.push({ op: kind, key: contentKeyOf(cur.data.entries[idx]) });
|
|
2848
2763
|
} else if(kind === "removeEntry"){
|
|
2849
2764
|
// 支持两种定位:`index`(工坊列表直接用它)或 `contentKey`(模型读到的签名)
|
|
@@ -3011,38 +2926,63 @@ async function applySharedBook(groupId, cardId, dryRun){
|
|
|
3011
2926
|
}
|
|
3012
2927
|
|
|
3013
2928
|
/**
|
|
3014
|
-
*
|
|
3015
|
-
* 多少 token」的统计都必须把它算上,并拆成 **卡内 / 共享 / 合计** 三段。
|
|
2929
|
+
* 度量类工具的统一口径(0.10.0 起是 `groupScope()` 的薄包装,返回形状与 0.9.x 逐字一致)。
|
|
3016
2930
|
*
|
|
3017
|
-
*
|
|
3018
|
-
*
|
|
3019
|
-
* 就是全部」,于是 simulate 把「会命中」判成「不会命中」(实测低估约 3 倍),
|
|
3020
|
-
* 漏掉共享书里的 constant 条目,group_check 的群聊固定开销也只算了一半。
|
|
2931
|
+
* 为什么当初单列一个函数:LLM 侧实测报告指出 —— 共享书改变了**数据的位置**,却没有改变工具的
|
|
2932
|
+
* **度量口径**;四个「读」类工具(simulate / lint / group_check / preview_export)全都默认
|
|
2933
|
+
* 「卡内 character_book 就是全部」,于是 simulate 把「会命中」判成「不会命中」(实测低估约 3 倍),
|
|
2934
|
+
* lint 的 worstCase 漏掉共享书里的 constant 条目,group_check 的群聊固定开销也只算了一半。
|
|
2935
|
+
* 现在「取作用域」只有 groupScope 一处、数字只有 groupMetrics 一处,这里只负责把书的字段摊平
|
|
2936
|
+
* 成调用方熟悉的 `{ bookName, entries, tokens, constantTokens }`。
|
|
3021
2937
|
*
|
|
3022
2938
|
* 没有来源卡 / 没有分组 / 该组还没有书 → 一律返回空(调用方无需自己判空)。
|
|
3023
2939
|
*/
|
|
3024
2940
|
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);
|
|
2941
|
+
const scope = await groupScope({ sessionKey: keyFor(ctx, exec) });
|
|
2942
|
+
const cardId = scope.cardId || "";
|
|
2943
|
+
const groupId = scope.groupId || "";
|
|
2944
|
+
if(!scope.book) return { cardId: cardId, groupId: groupId, bookName: "", rev: 0, entries: [], tokens: 0, constantTokens: 0 };
|
|
2945
|
+
const entries = scope.book.entries;
|
|
3037
2946
|
return {
|
|
3038
|
-
cardId, groupId:
|
|
2947
|
+
cardId: cardId, groupId: groupId, bookName: scope.book.name, rev: scope.book.rev, entries: entries,
|
|
3039
2948
|
tokens: lorebookTokens(entries),
|
|
3040
2949
|
constantTokens: entries.filter(e => e.constant).reduce((n, e) => n + estimateTokens(e && e.content), 0),
|
|
3041
|
-
lastPromote:
|
|
2950
|
+
lastPromote: scope.book.lastPromote || null,
|
|
3042
2951
|
};
|
|
3043
2952
|
}
|
|
2953
|
+
/**
|
|
2954
|
+
* 本卡所属分组的共享世界书**是否已经覆盖了这五个维度** —— 决定「没显式传 autoLorebook 时」还要不要在
|
|
2955
|
+
* 卡内生成一份骨架副本。
|
|
2956
|
+
*
|
|
2957
|
+
* 为什么需要它(0.10.0 LLM 侧回归报告第三节,实测踩到):`autoLorebook` 的默认值是**单卡时代**的 ——
|
|
2958
|
+
* 那时「生成骨架条目」是净收益。共享书上线后它变成净损失:用户只想改五维里的一个字,结果整套骨架被
|
|
2959
|
+
* 抄回卡里,正好抵消共享书的去重价值,还会立刻踩两条检查(卡内与书逐字重复 + 跨卡触发词冲突),
|
|
2960
|
+
* 而且新副本的 keys 只有维度名 —— 等于把当初修好的「实体词 keys」又退回成死条目。
|
|
2961
|
+
*
|
|
2962
|
+
* 判据**取窄**(宁可漏判、不可误判):只看书里明确是维度条目的那些 —— 名字就是维度名,或 keys 命中
|
|
2963
|
+
* 维度名/英文维度名。真要卡内副本,显式传 `autoLorebook:true` 即可。
|
|
2964
|
+
*/
|
|
2965
|
+
async function sharedBookCoversDims(ctx, exec){
|
|
2966
|
+
const out = { covered: false, count: 0, bookName: "", names: [] };
|
|
2967
|
+
let scope = null;
|
|
2968
|
+
try{ scope = await sharedBookScopeFor(ctx, exec); }catch(e){ return out; }
|
|
2969
|
+
const entries = (scope && Array.isArray(scope.entries)) ? scope.entries : [];
|
|
2970
|
+
if(!entries.length) return out;
|
|
2971
|
+
const enKeys = WORLD_DIMS.map(d => DIM_EN[d]);
|
|
2972
|
+
const hits = entries.filter(e => {
|
|
2973
|
+
if(!e || e.enabled === false) return false;
|
|
2974
|
+
if(AUTO_NAMES.has(String(e.name || "").trim())) return true;
|
|
2975
|
+
const keys = (Array.isArray(e.keys) ? e.keys : []).map(k => String(k).trim().toLowerCase());
|
|
2976
|
+
return keys.some(k => WORLD_DIMS.indexOf(k) >= 0 || enKeys.indexOf(k) >= 0);
|
|
2977
|
+
});
|
|
2978
|
+
if(!hits.length) return out;
|
|
2979
|
+
return { covered: true, count: hits.length, bookName: String(scope.bookName || ""),
|
|
2980
|
+
names: hits.map(e => String(e.name || "")).filter(Boolean).slice(0, 6) };
|
|
2981
|
+
}
|
|
3044
2982
|
|
|
3045
2983
|
export { apply, inject, name, safeDraftFile, draftDirs, libDirs, dataRoots, originPath, readOriginSync, writeOriginSync,
|
|
2984
|
+
// 卡组层(0.10.0):卡库快照 + 卡组作用域 —— 卡组数据的唯一入口,两半共用 lib/group-layer.js 的纯组装
|
|
2985
|
+
readLibrarySnapshot, groupScope,
|
|
3046
2986
|
// 人物关系图谱(0.8.0):导出纯函数与落盘根,供 tests/relations.test.mjs 与宿主半逐字比对同构实现
|
|
3047
2987
|
groupDirs, groupDir, groupPath, safeGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
|
|
3048
2988
|
renderRelationsSection, spliceRelations, checkGroup, listGroups, readGroupRaw, writeGroup, groupMemberIds, libraryCardsFor,
|