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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 草稿锁(LLM 创作保护 / 读写分离)—— 纯函数与常量,零 IO,**宿主半与预设半共用这一份**。
3
+ *
4
+ * 背景(0.11.0):LLM 正在工坊草稿里创作 A 卡时,用户点「载入」B 卡,`cc_loadFromLibrary`
5
+ * 直接把同一会话槽的草稿整份替换 —— LLM 已写入但未保存的内容被覆盖,只能从历史对话里捞。
6
+ * 现在 LLM 每次写草稿都会建立一把**创作锁**;锁 active 期间用户侧(浏览器 RPC)的写草稿动作
7
+ * 一律被拦,载入/导入只进入只读预览。权限完全由 LLM 控制:它调用 `cc_release_draft` 交还,
8
+ * 或锁超过 TTL 无写入自动失效(防 LLM 崩溃后永久锁死的安全阀)。
9
+ *
10
+ * 为什么判定放在这里而不是两半各写一份(同 lib/group-layer.js 的教训):
11
+ * 「锁算不算 active / 超时算不算过期 / 能不能写」是同一套语义,两半各写一份必然漂移 ——
12
+ * 一边说锁住了、另一边照写,比没有锁更危险。IO 仍由两半各持一份(各自的 dataRoots),
13
+ * 那是真正需要两份的东西。
14
+ *
15
+ * 锁文件:<主根>/cc-drafts/<safeDraftFile(key) 去掉 .json>.lock.json(与 .origin.json 同模式)。
16
+ * 锁**不写进草稿 JSON**:用户只读/LLM 交还不重写草稿,草稿的 `rev` 仍然只反映内容变化。
17
+ */
18
+
19
+ /** 目前唯一的锁持有者。用户侧没有「接管锁」这种状态 —— 权限由 LLM 持有或释放。 */
20
+ export const LOCK_OWNER_LLM = "llm";
21
+ /** 锁多久没有刷新算超时(秒)。LLM 每次写草稿都会续期;超时视为 LLM 已停止创作。 */
22
+ export const LOCK_TTL_SEC = 30 * 60;
23
+ /** 锁文件后缀(草稿文件是 .json,来源 sidecar 是 .origin.json,锁是 .lock.json)。 */
24
+ export const LOCK_FILE_SUFFIX = ".lock.json";
25
+
26
+ function nowOf(nowSec){
27
+ const n = Number(nowSec);
28
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : Math.floor(Date.now() / 1000);
29
+ }
30
+
31
+ /**
32
+ * 把磁盘上的锁对象归一成内部形状;任何不合法的输入一律返回 null(= 没有锁)。
33
+ *
34
+ * 降级方向必须是「无锁」:锁文件损坏、被手工改坏、或 key 对不上时,宁可放弃保护也不能
35
+ * 让用户永远编辑不了草稿。真正需要「锁得住」的是 LLM 自己刚写下的那份(写入即合法)。
36
+ */
37
+ export function normalizeLock(obj, key){
38
+ const src = (obj && typeof obj === "object") ? obj : null;
39
+ if(!src) return null;
40
+ if(src.owner !== LOCK_OWNER_LLM && String(src.owner == null ? "" : src.owner) !== LOCK_OWNER_LLM) return null;
41
+ const k = (key == null) ? "" : String(key);
42
+ // key 不匹配 = 这不是这把槽的锁(防 hash 碰撞 / 手工拷贝 / 改名后的残文件)
43
+ if(k && src.key != null && String(src.key) !== k) return null;
44
+ const updatedAt = Math.max(0, Math.round(Number(src.updatedAt) || 0));
45
+ if(updatedAt <= 0) return null;
46
+ const since = Math.max(0, Math.round(Number(src.since) || 0)) || updatedAt;
47
+ const rev = Math.max(0, Math.round(Number(src.rev) || 0));
48
+ return {
49
+ key: k || (src.key == null ? "" : String(src.key)),
50
+ owner: LOCK_OWNER_LLM,
51
+ since,
52
+ updatedAt,
53
+ rev,
54
+ cardName: String(src.cardName == null ? "" : src.cardName).slice(0, 120),
55
+ };
56
+ }
57
+
58
+ /** 锁是否已超时(TTL 内没有刷新)。无锁恒为 false。 */
59
+ export function isLockStale(lock, nowSec){
60
+ if(!lock) return false;
61
+ return (nowOf(nowSec) - lock.updatedAt) > LOCK_TTL_SEC;
62
+ }
63
+
64
+ /**
65
+ * 给界面 / 工具返回用的锁摘要。`stale` 不是「锁失效了但还挡着」—— 语义是
66
+ * 「LLM 已经很久没写了,视为可编辑;它若再写会自动重新上锁」。
67
+ * 无锁返回 null(工具返回里必须是显式 null,不能漏成 undefined,见 B1 的 lossless-JSON 教训)。
68
+ */
69
+ export function lockSummary(lock, nowSec){
70
+ if(!lock) return null;
71
+ const now = nowOf(nowSec);
72
+ const ageSec = Math.max(0, now - lock.updatedAt);
73
+ const stale = ageSec > LOCK_TTL_SEC;
74
+ return {
75
+ owner: lock.owner,
76
+ cardName: lock.cardName,
77
+ since: lock.since,
78
+ updatedAt: lock.updatedAt,
79
+ ageSec,
80
+ ttlSec: LOCK_TTL_SEC,
81
+ stale,
82
+ active: !stale,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * 用户侧写草稿之前问一句「现在能不能写」。
88
+ * - `force:true`:设置页把「LLM 创作保护」关掉时客户端会带上它 —— 等价于旧行为。
89
+ * - 无锁 / 已超时:放行(超时是安全阀,不是用户抢权)。
90
+ * - 锁 active:拦下,reason 给上层拼错误码与提示。
91
+ */
92
+ export function lockBlocksDraftWrite(lock, nowSec, force){
93
+ if(force === true) return { blocked:false, reason:"force", stale: isLockStale(lock, nowSec) };
94
+ if(!lock) return { blocked:false, reason:"none", stale:false };
95
+ const stale = isLockStale(lock, nowSec);
96
+ if(stale) return { blocked:false, reason:"stale", stale:true };
97
+ return { blocked:true, reason:"llm-locked", stale:false };
98
+ }
package/lib/index.js CHANGED
@@ -14,6 +14,10 @@ import { inflateSync as zlibInflateSync } from "node:zlib";
14
14
  import { REL_TYPES, REL_TYPE_LABELS, REL_BEGIN, REL_END,
15
15
  estimateTokens, contentKeyOf, scanSharedEntries,
16
16
  assembleScope, groupMetrics, runGroupChecks } from "./group-layer.js";
17
+ // 草稿锁(0.11.0):锁「算不算 active / 算不算超时 / 现在能不能写」只有一份实现,与预设半共用。
18
+ // 两半各写一份判定必然漂移 —— 一边说锁着、另一边照写,比没有锁更危险(同 lib/group-layer.js 的教训)。
19
+ // 这里只 import 判定;锁文件 IO 仍由两半各持一份(它们各自解析 DSH_HOME,那才是真正需要两份的东西)。
20
+ import { normalizeLock, lockSummary, lockBlocksDraftWrite } from "./draft-lock.js";
17
21
 
18
22
  const name = "dsh-cc-studio";
19
23
  // dsh >= 0.1.5-rc.1: this package owns its HTTP route outright.
@@ -190,6 +194,62 @@ function writeDraftOrigin(key, libraryId, name){
190
194
  writeFileSync(originFilePath(key), JSON.stringify({ key: String(key), libraryId: String(libraryId), name: String(name||""), savedAt: nowSec() }, null, 2), "utf8");
191
195
  }catch(e){ try{ console.error("[dsh-cc-studio] persist draft origin failed:", (e && e.message) || e); }catch{} }
192
196
  }
197
+ // —— 草稿锁的 IO(0.11.0,与 lib/agent.js 同构)——
198
+ // 锁文件 = <主根>/cc-drafts/<safe 去掉 .json>.lock.json(与 .origin.json 同模式;锁**不写进草稿 JSON**,
199
+ // 用户只读/LLM 交还不重写草稿,草稿的 rev 仍然只反映内容变化)。
200
+ // 路径规则与工具半必须逐字同构:同一个会话 key 两边必须算出同一个锁文件,否则 LLM 锁一把、用户写另一把。
201
+ // · 写只写主根(dataRoots()[0]);
202
+ // · 读回退旧根(升级后 LLM 写在旧根目录的锁仍然有效);
203
+ // · 清两个根都删(否则回退读会把刚交还的锁「复活」—— 与 deleteLibraryEntry 同一教训)。
204
+ function draftLockPath(key){ return join(draftDir(), safeDraftFile(key).replace(/\.json$/, ".lock.json")); }
205
+ function readDraftLockSync(key){
206
+ const file = safeDraftFile(key).replace(/\.json$/, ".lock.json");
207
+ for(const dir of draftDirs()){
208
+ const p = join(dir, file);
209
+ try{
210
+ if(!existsSync(p)) continue;
211
+ // 与工具半逐字同构:主根有锁就只认主根那份;坏文件/key 不符 → null(= 无锁,降级方向必须是放行)
212
+ return normalizeLock(JSON.parse(readFileSync(p, "utf8")), String(key));
213
+ }catch{}
214
+ }
215
+ return null;
216
+ }
217
+ function writeDraftLockSync(key, lock){
218
+ try{
219
+ mkdirSync(draftDir(), { recursive: true });
220
+ writeFileSync(draftLockPath(key), JSON.stringify(lock, null, 2), "utf8");
221
+ return true;
222
+ }catch(e){ try{ console.error("[dsh-cc-studio] persist draft lock failed:", (e && e.message) || e); }catch{} return false; }
223
+ }
224
+ function clearDraftLockSync(key){
225
+ const file = safeDraftFile(key).replace(/\.json$/, ".lock.json");
226
+ let removed = 0;
227
+ for(const dir of draftDirs()){
228
+ try{ unlinkSync(join(dir, file)); removed += 1; }catch{}
229
+ }
230
+ return removed > 0;
231
+ }
232
+ /** 给界面/返回值用的锁摘要:无锁显式 null(B1:不能漏成 undefined)。 */
233
+ function draftLockSummaryFor(key){ return lockSummary(readDraftLockSync(key), nowSec()); }
234
+ /**
235
+ * 「用户侧现在能不能写这把草稿槽」的唯一入口:锁 active 且没带 force 时给出拦截信息,
236
+ * 否则返回 null。判定委托 lib/draft-lock.js 的 lockBlocksDraftWrite(无锁/超时/force 都放行)。
237
+ * `action` 只用来拼中文提示(「载入」「导入」「本次修改」…)。
238
+ */
239
+ function draftLockBlockFor(key, args, action){
240
+ const lock = readDraftLockSync(key);
241
+ const verdict = lockBlocksDraftWrite(lock, nowSec(), args && args.force === true);
242
+ if(!verdict.blocked) return null;
243
+ const summary = lockSummary(lock, nowSec());
244
+ const who = (summary && summary.cardName) ? ("「" + summary.cardName + "」") : "这张卡";
245
+ const ageMin = Math.max(1, Math.round(((summary && summary.ageSec) || 0) / 60));
246
+ return {
247
+ lock: summary,
248
+ message: "LLM 正在创作" + who + "(最后写入约 " + ageMin + " 分钟前)," + (action || "本次修改") +
249
+ "未写入:工坊对这张草稿只读。等 LLM 创作完成、调用 cc_release_draft 释放草稿锁后再试;" +
250
+ "若确认放弃它这一轮的草稿,可带 force:true 直接覆盖。",
251
+ };
252
+ }
193
253
  function findDraftPath(key){
194
254
  const file = safeDraftFile(key);
195
255
  for(const dir of draftDirs()){
@@ -241,7 +301,9 @@ function draftStatusFor(key, draft){
241
301
  const m = draftMeta(key);
242
302
  // B1(0.7.1):缺 `creation_date` 的外部导入卡不能再漏出 `undefined`(工具侧会因此整次调用失败,
243
303
  // 前端也会把 undefined 渲染成字面量)。
244
- return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data && draft.data.creation_date !== undefined) ? draft.data.creation_date : null, key: String(key) };
304
+ // 0.11.0:把锁摘要塞进 draftStatus —— 前端只认这一条路径(cc_getDraft 不另开顶层 lock 字段),
305
+ // 无锁时显式 null(不是「省略字段」),否则客户端要分 undefined / null 两种没锁。
306
+ return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data && draft.data.creation_date !== undefined) ? draft.data.creation_date : null, key: String(key), lock: draftLockSummaryFor(key) };
245
307
  }
246
308
  /**
247
309
  * 解析「这次调用读写哪把草稿槽」,并把来源一并带出来(诊断用)。
@@ -289,6 +351,91 @@ function draftContentSummary(draft){
289
351
  if(entries) filled += 1;
290
352
  return { name: String(d.name==null ? "" : d.name), entries: entries, filled: filled };
291
353
  }
354
+ // —— 覆盖型动作前的草稿备份(0.11.0)——
355
+ // 为什么备份不放进草稿锁的判定层:锁保护的是「LLM 正在写的这一轮」,而备份保护的是
356
+ // 「用户/LLM 谁覆盖谁都别丢内容」—— 载入 B 卡、导入、force 覆盖都会整份替换同一把槽的草稿。
357
+ // 备份放在 <draftDir>/backups/ 子目录:listDraftSlotsOnDisk 只扫顶层,不会把备份误当成草稿槽。
358
+ // 文件名 `<safe 去 .json>-<毫秒时间戳>.json`;同一把槽最多留 20 份,空壳与「和最近一份内容相同」不备份
359
+ // (否则每次载入同一张卡都白写一份,备份目录很快就全是重复内容)。
360
+ const DRAFT_BACKUP_KEEP = 20;
361
+ function draftBackupDir(){ return join(draftDir(), "backups"); }
362
+ /** 盘上草稿当前的 rev(备份里记一份,方便知道备份对应哪个版本);没有文件 → 0。 */
363
+ function draftRevOnDisk(key){
364
+ try{
365
+ const p = findDraftPath(key);
366
+ if(!p) return 0;
367
+ const obj = JSON.parse(readFileSync(p, "utf8"));
368
+ const rev = Number(obj && obj.rev);
369
+ return Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0;
370
+ }catch{ return 0; }
371
+ }
372
+ /** 同槽备份文件名(不含目录)→ 时间戳;不是这把槽的备份返回 null。 */
373
+ function draftBackupStamp(file, base){
374
+ const prefix = base + "-";
375
+ if(String(file).indexOf(prefix) !== 0) return null;
376
+ const tail = String(file).slice(prefix.length);
377
+ if(!/^\d+\.json$/.test(tail)) return null;
378
+ return Number(tail.slice(0, -5));
379
+ }
380
+ /** 最近一份同槽备份的内容对象(解析失败当作没有);没有备份返回 null。 */
381
+ function latestDraftBackup(base){
382
+ const dir = draftBackupDir();
383
+ let names = [];
384
+ try{ names = readdirSync(dir); }catch{ return null; }
385
+ let latest = null;
386
+ for(const f of names){
387
+ const ts = draftBackupStamp(f, base);
388
+ if(ts === null) continue;
389
+ if(latest === null || ts > latest.ts) latest = { file: String(f), ts };
390
+ }
391
+ if(!latest) return null;
392
+ try{ return JSON.parse(readFileSync(join(dir, latest.file), "utf8")); }catch{ return null; }
393
+ }
394
+ /** 同槽备份只留最近 20 份(超过的按时间从旧到新删)。失败不抛。 */
395
+ function pruneDraftBackups(base){
396
+ const dir = draftBackupDir();
397
+ let names = [];
398
+ try{ names = readdirSync(dir); }catch{ return; }
399
+ const rows = [];
400
+ for(const f of names){
401
+ const ts = draftBackupStamp(f, base);
402
+ if(ts === null) continue;
403
+ rows.push({ file: String(f), ts });
404
+ }
405
+ rows.sort(function(a, b){ return b.ts - a.ts; });
406
+ for(let i = DRAFT_BACKUP_KEEP; i < rows.length; i++){
407
+ try{ unlinkSync(join(dir, rows[i].file)); }catch{}
408
+ }
409
+ }
410
+ /**
411
+ * 覆盖前把当前草稿留一份到 <draftDir>/backups/。返回 `{file, path, at}`;没备份返回 null。
412
+ * 不备份的三种情况:① 当前是空壳(盖掉也没损失,别制造噪音);② 与最近一份备份内容指纹相同
413
+ * (cardFingerprint 忽略 modification_date,同内容重复载入不会叠出十几份);③ 写失败(吞掉,
414
+ * 备份只是保险丝,绝不能因为备份写不进去而让用户连编辑都做不了)。
415
+ */
416
+ function backupDraftSync(key, reason){
417
+ const k = key || "default";
418
+ try{
419
+ const draft = globalThis.__CC_DRAFTS__.has(k) ? globalThis.__CC_DRAFTS__.get(k) : loadPersistedDraftSync(k);
420
+ if(!draft || typeof draft !== "object") return null;
421
+ if(draftContentSummary(draft).filled === 0) return null;
422
+ const base = safeDraftFile(k).replace(/\.json$/, "");
423
+ const prev = latestDraftBackup(base);
424
+ if(prev && prev.draft && typeof prev.draft === "object" && cardFingerprint(prev.draft) === cardFingerprint(draft)) return null;
425
+ const dir = draftBackupDir();
426
+ mkdirSync(dir, { recursive: true });
427
+ // 时间戳用毫秒:同一秒内连续两次覆盖(脚本/测试)不会互相盖掉同一份文件名
428
+ const atMs = Date.now();
429
+ const file = base + "-" + atMs + ".json";
430
+ const path = join(dir, file);
431
+ writeFileSync(path, JSON.stringify({ key: k, updatedAt: nowSec(), rev: draftRevOnDisk(k), reason: String(reason || ""), draft }, null, 2), "utf8");
432
+ pruneDraftBackups(base);
433
+ return { file: file, path: path, at: Math.floor(atMs / 1000) };
434
+ }catch(e){
435
+ try{ console.error("[dsh-cc-studio] backup draft failed:", (e && e.message) || e); }catch{}
436
+ return null;
437
+ }
438
+ }
292
439
  /**
293
440
  * 盘上还有哪些草稿槽(按写入时间倒序)。只用于「当前槽是空壳」时的救回提示 ——
294
441
  * 这是 issue #5 里用户唯一能自救的入口:内容没丢,只是被写进了另一把 key。
@@ -1573,24 +1720,40 @@ function apply(ctx, config){
1573
1720
  const toParts = draftKeyPartsFrom(ctx, { sessionId: (typeof args.to==="string" && args.to) ? args.to : (args.sessionId||null) });
1574
1721
  const to = toParts.key;
1575
1722
  if(from === to) return fail("same-slot", "源槽与目标槽相同(" + from + "),无需迁移");
1723
+ // 0.11.0:被锁的是**目标槽**(迁移会整份覆盖它);锁 active 时先别读源槽,整次动作都不做。
1724
+ const locked = draftLockBlockFor(to, args, "槽迁移");
1725
+ if(locked) return fail("draft-locked", locked.message, { lock: locked.lock });
1576
1726
  const src = loadPersistedDraftSync(from) || (globalThis.__CC_DRAFTS__.has(from) ? globalThis.__CC_DRAFTS__.get(from) : null);
1577
1727
  const tgt = loadPersistedDraftSync(to) || (globalThis.__CC_DRAFTS__.has(to) ? globalThis.__CC_DRAFTS__.get(to) : null);
1578
1728
  const decision = migrateDraftDecision(src, tgt, args.overwrite===true);
1579
1729
  if(!decision.ok) return fail(decision.code, decision.message, decision.details||{});
1730
+ // force = 用户明确抢走目标槽:先清锁,再把目标槽**将被覆盖的草稿**备份一份(LLM 那一轮的内容还能捞)
1731
+ if(args.force === true){
1732
+ clearDraftLockSync(to);
1733
+ backupDraftSync(to, "migrate");
1734
+ }
1580
1735
  setDraftFor(to, decision.draft);
1581
1736
  return ok({ draft: decision.draft, key: to, from: from, moved: decision.summary, draftStatus: draftStatusFor(to, decision.draft) });
1582
1737
  }
1583
1738
  case "cc_setDraft":{
1584
1739
  const k = draftKeyFrom(ctx, args);
1585
1740
  const draft = args.draft;
1741
+ // 0.11.0:锁 active 时工坊的写草稿动作一律拦下(参数校验之前 —— 锁定期间连一次写尝试都不落盘)。
1742
+ const locked = draftLockBlockFor(k, args, "本次修改");
1743
+ if(locked) return fail("draft-locked", locked.message, { lock: locked.lock });
1586
1744
  if(!draft || typeof draft!=="object") return fail("invalid-draft","draft 必须为对象");
1587
1745
  const v = validateCard(draft);
1588
1746
  if(!v.valid) return fail("invalid-card","校验未通过: "+v.errors.join("; "), {errors:v.errors});
1747
+ // force 放行并不备份:这是「用户明确选择覆盖 LLM 的草稿」,与载入/导入那种「换一张卡」不同,
1748
+ // 备份只服务于后者(见 backupDraftSync 的注释)。
1749
+ if(args.force === true) clearDraftLockSync(k);
1589
1750
  setDraftFor(k, draft);
1590
1751
  return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
1591
1752
  }
1592
1753
  case "cc_patchDraft":{
1593
1754
  const k = draftKeyFrom(ctx, args);
1755
+ const locked = draftLockBlockFor(k, args, "本次修改");
1756
+ if(locked) return fail("draft-locked", locked.message, { lock: locked.lock });
1594
1757
  const patch = args.patch && typeof args.patch==="object" ? args.patch : {};
1595
1758
  const cur = JSON.parse(JSON.stringify(getDraftFor(k)));
1596
1759
  if(patch.name!==undefined) cur.data.name = String(patch.name).slice(0,120);
@@ -1644,6 +1807,7 @@ function apply(ctx, config){
1644
1807
  cur.data.character_book.entries.push(entry);
1645
1808
  });
1646
1809
  }
1810
+ if(args.force === true) clearDraftLockSync(k); // 同 cc_setDraft:force 才是用户明确抢权
1647
1811
  setDraftFor(k, cur);
1648
1812
  return ok({ draft: cur, key:k, draftStatus: draftStatusFor(k, cur) });
1649
1813
  }
@@ -1683,11 +1847,21 @@ function apply(ctx, config){
1683
1847
  const id = args.id;
1684
1848
  if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
1685
1849
  const obj = await loadLibraryEntry(id);
1686
- // 载入到当前会话草稿,并记下来源 id(sidecar):之后不传 id 保存就是更新这一张
1687
1850
  const k = draftKeyFrom(ctx, args);
1851
+ // 0.11.0:锁 active 时**只读预览** —— 不写草稿、不写 origin。否则用户只是点了一下「载入」,
1852
+ // LLM 这一轮写进草稿的内容和「保存时该更新哪张卡」都会被一起改掉。
1853
+ // 这里刻意回 ok 帧(不是 fail):预览是正常交互,前端按 `locked:true` 切只读。
1854
+ const locked = draftLockBlockFor(k, args, "载入");
1855
+ if(locked) return ok({ locked: true, lock: locked.lock, preview: { id: obj.id, draft: obj.draft, meta: obj.meta }, key: k });
1856
+ // 覆盖前备份当前草稿(空壳 / 与最近一份同内容会自动跳过)
1857
+ const backup = backupDraftSync(k, "load");
1858
+ clearDraftLockSync(k);
1859
+ // 载入到当前会话草稿,并记下来源 id(sidecar):之后不传 id 保存就是更新这一张
1688
1860
  setDraftFor(k, JSON.parse(JSON.stringify(obj.draft)));
1689
1861
  writeDraftOrigin(k, obj.id, obj.meta ? obj.meta.name : "");
1690
- return ok({ draft: obj.draft, meta: obj.meta, key: k, originId: obj.id, draftStatus: draftStatusFor(k, obj.draft) });
1862
+ const out = { draft: obj.draft, meta: obj.meta, key: k, originId: obj.id, draftStatus: draftStatusFor(k, obj.draft) };
1863
+ if(backup) out.backup = backup; // 没备份就别回一个 null 字段(前端/测试都按「有就是有」读)
1864
+ return ok(out);
1691
1865
  }
1692
1866
  case "cc_deleteFromLibrary":{
1693
1867
  const id = args.id;
@@ -1706,7 +1880,11 @@ function apply(ctx, config){
1706
1880
  case "cc_setOrigin":{
1707
1881
  // 前端显式设/清「当前草稿的来源 id」:id 传 null/空 = 清(清空草稿、另存为新时用)
1708
1882
  const k = draftKeyFrom(ctx, args);
1883
+ // 0.11.0:锁 active 时不许改来源 —— 它决定 LLM 下一次保存更新哪张卡(错卡的后果比覆盖草稿还重)
1884
+ const locked = draftLockBlockFor(k, args, "修改草稿关联");
1885
+ if(locked) return fail("draft-locked", locked.message, { lock: locked.lock });
1709
1886
  const id = (typeof args.id === "string" && args.id) ? args.id : null;
1887
+ if(args.force === true) clearDraftLockSync(k);
1710
1888
  writeDraftOrigin(k, id, args.name);
1711
1889
  return ok({ key: k, originId: id });
1712
1890
  }
@@ -1833,6 +2011,10 @@ function apply(ctx, config){
1833
2011
  case "cc_renderRelations":{
1834
2012
  // 渲染进**当前草稿**(与工具半共用同一份草稿文件);要落库仍需再存一次
1835
2013
  const k = draftKeyFrom(ctx, args);
2014
+ // 0.11.0:这是写草稿 description 的显式动作,锁 active 时整次拦下(dryRun 预览也一并拦,
2015
+ // 因为预览里看到的是 LLM 正在写的草稿,让用户以为能写反而更糟)。
2016
+ const locked = draftLockBlockFor(k, args, "渲染关系");
2017
+ if(locked) return fail("draft-locked", locked.message, { lock: locked.lock });
1836
2018
  const origin = readDraftOrigin(k);
1837
2019
  const cardId = args.cardId ? String(args.cardId) : ((origin && origin.libraryId) || "");
1838
2020
  if(!cardId) return fail("no-card", "这张草稿还没入库(没有来源卡 id):先在工坊「保存当前」或显式传 cardId");
@@ -1851,6 +2033,11 @@ function apply(ctx, config){
1851
2033
  const changed = before !== after;
1852
2034
  if(args.dryRun === true) return ok({ changed, preview: after, section, groupId: g.id, rev: g.rev });
1853
2035
  if(changed){
2036
+ // 这段之前有 await(读库/读分组):LLM 可能刚在这期间重新上锁,写入前再问一次,别让锁漏在竞态里。
2037
+ const recheck = draftLockBlockFor(k, args, "渲染关系");
2038
+ if(recheck) return fail("draft-locked", recheck.message, { lock: recheck.lock });
2039
+ // force 只在**确实要写入**时才清锁:dryRun / 无变化的调用不该把 LLM 的锁带走。
2040
+ if(args.force === true) clearDraftLockSync(k);
1854
2041
  const copy = JSON.parse(JSON.stringify(draft));
1855
2042
  copy.data.description = after;
1856
2043
  setDraftFor(k, copy);
@@ -2006,8 +2193,15 @@ function apply(ctx, config){
2006
2193
  draft = normalizeDraft(draft);
2007
2194
  }catch(e){ return fail("parse-failed", String(e.message)); }
2008
2195
  const k = draftKeyFrom(ctx, args);
2196
+ // 0.11.0:解析可以先做(纯只读),但锁 active 时**不写草稿** —— 回 locked + preview 让界面进只读预览。
2197
+ const locked = draftLockBlockFor(k, args, "导入");
2198
+ if(locked) return ok({ locked: true, lock: locked.lock, preview: { draft, upgradedFrom }, key: k });
2199
+ const backup = backupDraftSync(k, "import");
2200
+ clearDraftLockSync(k);
2009
2201
  setDraftFor(k, draft);
2010
- return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
2202
+ const out = { draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom };
2203
+ if(backup) out.backup = backup;
2204
+ return ok(out);
2011
2205
  }
2012
2206
  case "cc_importFromJson":{
2013
2207
  // 工坊拖进来的 .json 走这里(0.9.1,B2):V2/V1 卡**升级**成 CCv3,而不是让界面报「spec 不符」。
@@ -2025,8 +2219,14 @@ function apply(ctx, config){
2025
2219
  draft = normalizeDraft(obj);
2026
2220
  }catch(e){ return fail("invalid-card", String(e.message)); }
2027
2221
  const k = draftKeyFrom(ctx, args);
2222
+ const locked = draftLockBlockFor(k, args, "导入");
2223
+ if(locked) return ok({ locked: true, lock: locked.lock, preview: { draft, upgradedFrom }, key: k });
2224
+ const backup = backupDraftSync(k, "import");
2225
+ clearDraftLockSync(k);
2028
2226
  setDraftFor(k, draft);
2029
- return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
2227
+ const out = { draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom };
2228
+ if(backup) out.backup = backup;
2229
+ return ok(out);
2030
2230
  }
2031
2231
  case "cc_exportPng":{
2032
2232
  const k = draftKeyFrom(ctx, args);
@@ -2066,8 +2266,14 @@ function apply(ctx, config){
2066
2266
  draft = normalizeDraft(obj);
2067
2267
  }catch(e){ return fail("parse-failed", String(e.message)); }
2068
2268
  const k = draftKeyFrom(ctx, args);
2269
+ const locked = draftLockBlockFor(k, args, "导入");
2270
+ if(locked) return ok({ locked: true, lock: locked.lock, preview: { draft, upgradedFrom }, key: k });
2271
+ const backup = backupDraftSync(k, "import");
2272
+ clearDraftLockSync(k);
2069
2273
  setDraftFor(k, draft);
2070
- return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom });
2274
+ const out = { draft, key:k, draftStatus: draftStatusFor(k, draft), upgradedFrom };
2275
+ if(backup) out.backup = backup;
2276
+ return ok(out);
2071
2277
  }
2072
2278
  case "cc_exportCharx":{
2073
2279
  const k = draftKeyFrom(ctx, args);
@@ -2335,6 +2541,9 @@ export { apply, inject, name, planPresetInstall, installCcPreset, supportsDeclar
2335
2541
  persistDraftSync, loadPersistedDraftSync, libDirs, libDir, getDraftFor, setDraftFor, cardFingerprint, normalizeCardShape,
2336
2542
  // 草稿来源 id 的 sidecar(0.5.0):两半同构,测试直接交叉读写验证互通
2337
2543
  readDraftOrigin, writeDraftOrigin, originFilePath,
2544
+ // 草稿锁(0.11.0):判定在 lib/draft-lock.js(两半共用),这里导出 IO / 备份供回归测试直接断言落盘
2545
+ draftLockPath, readDraftLockSync, writeDraftLockSync, clearDraftLockSync, draftLockSummaryFor, draftLockBlockFor,
2546
+ draftBackupDir, backupDraftSync, DRAFT_BACKUP_KEEP,
2338
2547
  listLibraryEntries, saveLibraryEntry, loadLibraryEntry, deleteLibraryEntry, renameLibraryEntry,
2339
2548
  // 人物关系图谱(0.8.0):纯函数 + 落盘,供 tests/relations.test.mjs 直接断言、并与工具半逐字比对同构实现
2340
2549
  groupDirs, groupDir, groupPath, safeGroupId, newGroupId, normalizeGroup, validateGroupOp, applyGroupOps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xia-sc/dsh-cc-studio",
3
- "version": "0.10.3",
3
+ "version": "0.11.1",
4
4
  "description": "CCv3 角色卡工坊:融合工坊 + CC 模式 + JSON/PNG/CHARX + 中英双语(跟随全局)",
5
5
  "keywords": [
6
6
  "dsh",
@@ -38,7 +38,7 @@
38
38
  "presets"
39
39
  ],
40
40
  "scripts": {
41
- "test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/relations.test.mjs && node tests/relations-view.test.mjs && node tests/shared-book.test.mjs && node tests/shared-book-st-export.test.mjs && node tests/st-compat.test.mjs && node tests/promote-draft-sync.test.mjs && node tests/group-scope.test.mjs && node tests/group-metrics.test.mjs && node tests/group-checks.test.mjs && node tests/group-consistency.test.mjs && node tests/group-layer-guard.test.mjs && node tests/data-root.test.mjs",
41
+ "test": "node tests/rpc-channel.test.mjs && node tests/preset-install.test.mjs && node tests/cc-detection.test.mjs && node tests/client-greetings.test.mjs && node tests/draft-slot-sync.test.mjs && node tests/draft-lock-client.test.mjs && node tests/nav-highlight.test.mjs && node tests/clear-card.test.mjs && node tests/export-entry.test.mjs && node tests/close-button.test.mjs && node tests/tools-contract.test.mjs && node tests/draft-lock.test.mjs && node tests/relations.test.mjs && node tests/relations-view.test.mjs && node tests/shared-book.test.mjs && node tests/shared-book-st-export.test.mjs && node tests/st-compat.test.mjs && node tests/promote-draft-sync.test.mjs && node tests/group-scope.test.mjs && node tests/group-metrics.test.mjs && node tests/group-checks.test.mjs && node tests/group-consistency.test.mjs && node tests/group-layer-guard.test.mjs && node tests/data-root.test.mjs",
42
42
  "prepublishOnly": "npm test"
43
43
  },
44
44
  "dsh": {
@@ -65,7 +65,8 @@
65
65
  "0.1.6-alpha.2": "compatible",
66
66
  "0.1.7-alpha.1": "compatible",
67
67
  "0.1.7-rc.1": "compatible",
68
- "0.2.0-rc.2": "compatible"
68
+ "0.2.0-rc.2": "compatible",
69
+ "0.2.1-rc.2": "compatible"
69
70
  }
70
71
  }
71
72
  },
@@ -41,11 +41,11 @@
41
41
  3) cc_patch_world — 补五维 timeline/factions/geo/power/daily,至少填 3 项,autoLorebook=true 自动生成带 @@decorator 的基础条目;**同时给 loreKeys**(每一维自报用户真会说的实体名,如 {geo:["旧校舍","广播室","天台"]})—— 不给的话自动条目的 keys 只有维度名,真实对话永远命中不了
42
42
  4) cc_add_lorebook_entries — 再补到至少 5 条,且**至少 1 条是你与用户共创的**(纯 autoLorebook 自动条目不算完成),覆盖年表/势力/地理/力量/日常各 1,至少 1 条 constant,content 头部写 @@position/@@depth/@@activate 等,支持 CBS 占位符(双花括号的 char / random / roll 形式);写完用 cc_simulate_trigger 拿一句用户会说的话验一下命中
43
43
  5) cc_patch_greetings — 补 first_mes、alternate_greetings(≥2)、group_only_greetings(≥1 必填)、mes_example
44
- 6) cc_validate — 必须调且 valid=true 才算完成,否则按返回的 errors/warnings 继续补;通过后提醒用户在工坊左下角「校验导出」入口打开导出页选 JSON / PNG / CHARX,或点「★ 保存当前」落盘 ~/.dsh/cc-library
44
+ 6) cc_validate — 必须调且 valid=true 才算完成,否则按返回的 errors/warnings 继续补;**通过后必须立刻调用 cc_release_draft 交还草稿编辑权**(不调用的话工坊一直只读、用户无法微调),然后再提醒用户在工坊左下角「校验导出」入口打开导出页选 JSON / PNG / CHARX,或点「★ 保存当前」落盘 ~/.dsh/cc-library
45
45
 
46
46
  辅助工具(按需,不必每步都调):cc_patch_book_meta(世界书命名 / scan_depth / token_budget)、cc_lint_lorebook(世界书静态体检)、cc_simulate_trigger(验触发词命中)、cc_preview_export(导出前干跑)、cc_new_card(重置草稿,**必须先经用户同意**再带 confirm:true)、cc_update_lorebook_entry / cc_delete_lorebook_entries(改删单条)、已存库 cc_list_library / cc_save_to_library / cc_load_from_library / cc_rename_in_library / cc_delete_from_library / cc_get_library_entry(载入后直接保存会更新原卡,不会新建重复卡)。
47
47
 
48
- 规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate。工具返回里只有一份 workflow.next(下一步该做什么)与 workflow.hint(进度摘要),照它走即可;用户还没回答就别抢跑。
48
+ 规则:每次对话只追问 1-2 个薄弱点,问完立刻调工具写入;用户说“随便/跳过”也要你合理推断补全,不能留空;严禁跳过 3/4 步直接做问候语;每调一次工具要简要总结“已写入 X,下一步 Y”,并让胶囊实时同步;结束前必须 cc_validate;**cc_validate 通过 = 创作完成,必须调用 cc_release_draft 交还草稿锁收口**(不调就算没做完;之后用户要改,你再写会自动重新上锁)。工具返回里只有一份 workflow.next(下一步该做什么)与 workflow.hint(进度摘要),照它走即可;用户还没回答就别抢跑。
49
49
 
50
50
  **工具参数必须是严格合法的 JSON**:最后一个字段 / 数组元素后不要写逗号,不要用 ```json 代码块包裹,不要注释、不要单引号;字符串里的换行写成 \n、双引号写成 \"。一次只传 1-3 个字段,长字段(description/personality/scenario)分多次调用写;tags / loreKeys 这类数组或对象字段单独一次调用,不要和长文本同批。参数 JSON 非法时 provider 会在调用前直接判本轮失败(用户只看到「本轮运行失败」),所以宁可分多次小调用。
51
51
 
@@ -54,6 +54,12 @@
54
54
  config:
55
55
  maxBytes: 65536
56
56
 
57
+ # 0.2.1 对齐:time-context 从 host 层挪进预设行(dsh 0.2.1-rc.2 的内置 standard 有这一行)。
58
+ # 包 @deepseek-ai/dsh-time-context 在 dsh 0.1.6-alpha.2 的依赖里也存在,
59
+ # 且那一代 host 层没有挂它,所以旧版目录形态上补这一行不会重复注册、不会 broken。
60
+ - id: time-context
61
+ name: '@deepseek-ai/dsh-time-context'
62
+
57
63
  # ── shell ───────────────────────────────────────────────────────────────────
58
64
 
59
65
  # `shell-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
@@ -104,11 +110,9 @@
104
110
  # carries whatever the deployment registered globally (repository plugins).
105
111
  - id: skill-filesystem
106
112
  name: '@deepseek-ai/dsh-skill-filesystem'
107
- disabled: true
108
113
 
109
114
  - id: tool-skill
110
115
  name: '@deepseek-ai/dsh-tool-skill'
111
- disabled: true
112
116
 
113
117
  # 0.3.7 补回:内置 `standard` 自 0.1.6-alpha.2 起就带这一行,本预设初次拷贝时整行漏掉了
114
118
  # (与 0.3.4 补回的 `present` 同类:不报错、不判 broken,只是少了能力)。
@@ -143,7 +147,7 @@
143
147
  name: '@deepseek-ai/dsh-plan-mode'
144
148
  config:
145
149
  section: |
146
- You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
150
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
147
151
 
148
152
  Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
149
153
 
@@ -225,36 +229,12 @@
225
229
  provider: spawn
226
230
  toolName: subagent
227
231
  modelSelectionSettings: true
228
- backgroundMode: continuable
229
232
 
230
233
  - id: tool-subagent-fork
231
234
  name: '@deepseek-ai/dsh-tool-subagent'
232
235
  config:
233
236
  provider: fork
234
237
  toolName: subagent_fork
235
- backgroundMode: continuable
236
-
237
- # Production dsh does not install these optional providers. Install the
238
- # matching Bundle in this Profile and restart the Host, then copy this
239
- # preset and remove `disabled` from the matching tool row. Host availability
240
- # alone grants no tool.
241
- - id: tool-subagent-codex
242
- name: '@deepseek-ai/dsh-tool-subagent'
243
- disabled: true
244
- config:
245
- provider: codex
246
- toolName: subagent_codex
247
- backgroundMode: one-shot
248
- maxDepth: provider-managed
249
-
250
- - id: tool-subagent-claude-code
251
- name: '@deepseek-ai/dsh-tool-subagent'
252
- disabled: true
253
- config:
254
- provider: claude-code
255
- toolName: subagent_claude_code
256
- backgroundMode: one-shot
257
- maxDepth: provider-managed
258
238
 
259
239
  # dsh 0.1.6-alpha.1: `dsh-workflow-worker-thread` is gone from the release;
260
240
  # the workflow engine is now `dsh-workflow-ptc` (sandboxed Node PTC runtime,
@@ -270,12 +250,6 @@
270
250
  - id: tool-workflow
271
251
  name: '@deepseek-ai/dsh-tool-workflow'
272
252
 
273
- - id: tool-ralph
274
- name: '@deepseek-ai/dsh-tool-ralph'
275
- config:
276
- subagentProvider: spawn
277
- maxRounds: 64
278
-
279
253
  # ── remaining model-facing rows ─────────────────────────────────────────────
280
254
 
281
255
  - id: tool-ask-user
@@ -291,7 +265,7 @@
291
265
  - id: tool-web
292
266
  name: '@deepseek-ai/dsh-tool-web'
293
267
  config:
294
- fetch: false
268
+ fetch: true
295
269
  searchTimeoutMs: 60000
296
270
 
297
271
  # 0.3.4 补回:`present` 是 standard 一直有的行,本预设此前整行漏抄。
@@ -309,6 +283,16 @@
309
283
  name: '@deepseek-ai/dsh-plugin-manager/tools'
310
284
  disabled: true
311
285
 
286
+ # ── 与 dsh 0.2.1-rc.2 内置 standard 的**有意差异**(下次升级 diff 别当成漏抄)──
287
+ # · `tool-schedule`:0.2.1 standard 有,但 `@deepseek-ai/dsh-tool-schedule` 在
288
+ # dsh ≤ 0.1.6-alpha.2 的依赖里不存在,而这份清单同时喂给目录形态(旧版);
289
+ # 非 disabled 行解析不到包会让**整个预设 broken**(unresolvableRows),
290
+ # 所以为兼容旧版不带这一行。CC 模式也不需要定时任务工具。
291
+ # · `tool-plugin-market`:0.2.1 standard 里是 disabled,但 0.1.6 没有
292
+ # `@deepseek-ai/dsh-plugin-market` 这个包;按家规「不拿 disabled 遮掩
293
+ # 一个已经不存在的包」,同样不带。
294
+ # 其余行与 standard 逐行对齐;本预设只保留 persona 定制与末尾的 cc-agent 行。
295
+
312
296
  # ── CC 角色卡工坊(CC 模式专用) ───────────────────────────────────
313
297
  - id: cc-agent
314
298
  name: '@xia-sc/dsh-cc-studio/agent'