dsh-novel-generator 0.1.26 → 0.1.28

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.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/index.js +78 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,8 +31,13 @@ dsh plugin --profile web add dsh-novel-generator
31
31
 
32
32
  - 时间线以「天」计(0 = 创建作品那天),你选的「快照间隔」(7 / 15 / 30 / 90 / 180 / 365 天,可手输)决定了一条**网格**:第 15、30、45… 天。
33
33
  - **手动**:把时间线拖到网格点上(第 N 天且 N 能被间隔整除),标题栏出现「生成世界快照 · 第 N 天」;该点已有快照时按钮变成「重新生成快照 · 第 N 天」,会替换旧的那份。两种情况都先弹确认。
34
- - **自动**:每次生成章节后检查两个条件,缺一不可:① 本章结束时间**正好落在网格点上**;② **上一格**(该点 − 一个间隔)已经有快照(第 0 天视为已覆盖)。满足才自动生成这一格的快照。
35
- - 这样改间隔不会把旧快照重排:网格随当前间隔移动,自动生成只沿着「上一格存在」的链条向前推进,断档的地方留给你手动补。
34
+ - **自动**:只要故事时间被推进到新的网格点就会触发——**新建章节(含只有大纲的空章节)、自动推演、生成正文、手动保存正文**都算。判定是幂等的:已经存在的点不会被普通保存再次生成。
35
+ - **每个点收录哪些章节**(`[上一份快照日, 本点)` 半开区间):
36
+ - 只收录**结束时间严格早于本点**的章节——**结束在本点那一天的那一章不算**,它正是这次快照要"为之而拍"的那段,快照生成时它还没写出来;
37
+ - 也不收录上一份快照已经收录过的章节(`结束日 < 上一快照日` 的跳过),所以**每一章恰好被一份快照收录一次**。
38
+ - 例:间隔 30、章节各 10 天 → 第 30 天快照收录第 1、2 章;第 60 天快照收录第 3、4、5 章。
39
+ - 这样改间隔不会把旧快照重排:网格随当前间隔移动,自动生成只沿着链条向前推进,断档的地方留给你手动补。
40
+ - 同一部作品同一时间只跑一个快照任务,避免并发把同一个点重复生成。
36
41
 
37
42
  - **重置**:时间线右上角的「重置快照」会清空全部快照,再按当前间隔从第 1 格逐格重建到故事末端(确认框里写明要生成几份)。
38
43
  - **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
@@ -81,6 +86,15 @@ dsh plugin --profile web add dsh-novel-generator
81
86
  - 正文为空时按钮不可点,接口也会拒绝(400);修改意见为空同样拒绝。
82
87
  - **重写结果只放进编辑区、不自动保存**,状态变成「未保存」,你确认后再点「保存」——避免 AI 的改动直接覆盖原稿。
83
88
 
89
+ ## 附属子插件:小说工坊 · 作者预设
90
+
91
+ 另有一个**独立包** `dsh-novel-author-preset`,只负责往 DSH 注册一个 Agent 预设「小说工坊 · 作者」:
92
+
93
+ - 装上它(`dsh plugin --profile web add dsh-novel-author-preset`),作品列表的「预设」与「设置 → 预设」里就多出这一项,**新建作品默认用它**;
94
+ - 卸掉它,预设立刻消失;已绑定的作品不会因此损坏——重建会话时检测到预设已不在,会自动退回 profile 的默认预设。
95
+
96
+ 预设内容刻意做得很薄:三句人设(你是小说写作者/只输出正文/文风视角篇幅以用户要求为准)+ 关闭运行时上下文 + **不挂任何工具** + 只保留上下文压缩。所有文学倾向都交给作品级「前置提示词」。
97
+
84
98
  ## 迁移、导入与会话
85
99
 
86
100
  - **改预设**:作品列表每行的「预设」改的是「下次建会话时用哪个预设」。会话在创建时就固定了预设,所以保存后会提示「重建会话后生效」——要立刻换,点同一行的「重建会话」。
package/index.js CHANGED
@@ -922,6 +922,20 @@ function normalizeSnapshot(raw) {
922
922
 
923
923
  /* ------------------------------------------------------------ agent work */
924
924
 
925
+ /** The preset a work records, but only when the deployment still offers it. */
926
+ async function usablePreset(ctx, presetId) {
927
+ if (!presetId) return false;
928
+ const presets = ctx.get('agentPresets');
929
+ if (!presets || typeof presets.list !== 'function') return true;
930
+ try {
931
+ const list = await presets.list();
932
+ if (!Array.isArray(list) || list.length === 0) return true;
933
+ return list.some((entry) => entry.id === presetId);
934
+ } catch {
935
+ return true;
936
+ }
937
+ }
938
+
925
939
  /**
926
940
  * Bind a fresh session to a work whose old one disappeared (deleted in the
927
941
  * sidebar, or gone from the session store). The work's memory — snapshots,
@@ -931,7 +945,12 @@ function normalizeSnapshot(raw) {
931
945
  async function rebindSession(ctx, root, project) {
932
946
  const controller = ctx.get('sessionController');
933
947
  if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法为作品重建会话');
934
- const created = await controller.create(project.agentPreset ? { cwd: project.cwd, agentPreset: project.agentPreset } : { cwd: project.cwd });
948
+ // A preset can disappear when its plugin is uninstalled; falling back to the
949
+ // profile default keeps the work usable instead of failing every rebuild.
950
+ const presetOk = await usablePreset(ctx, project.agentPreset);
951
+ const created = await controller.create(presetOk && project.agentPreset
952
+ ? { cwd: project.cwd, agentPreset: project.agentPreset }
953
+ : { cwd: project.cwd });
935
954
  const sessionId = created && created.sessionId ? created.sessionId : null;
936
955
  if (!sessionId) throw httpError(502, '没能为作品重建会话');
937
956
  if (project.model) {
@@ -1182,7 +1201,10 @@ function stretchBefore(project, chapter, snapshot) {
1182
1201
  return (project.chapters || [])
1183
1202
  .filter((entry) => entry.chapterId !== chapter.chapterId
1184
1203
  && entry.startDay < chapter.startDay
1185
- && chapterWindow(project, entry).end > base
1204
+ // The snapshot at day `base` covers chapters ending strictly before it, so
1205
+ // a chapter ending exactly on that day is still unwritten history for this
1206
+ // stretch and must be handed to the writer (>= rather than >).
1207
+ && chapterWindow(project, entry).end >= base
1186
1208
  && ((entry.outline && entry.outline.synopsis) || '').trim() !== '')
1187
1209
  .sort((a, b) => a.startDay - b.startDay);
1188
1210
  }
@@ -1477,7 +1499,7 @@ async function bootstrap(ctx, root) {
1477
1499
  }
1478
1500
  }
1479
1501
  return {
1480
- revision: '2026-10-27.10',
1502
+ revision: '2026-10-27.16',
1481
1503
  projects,
1482
1504
  models: {
1483
1505
  default: modelCatalog.default || null,
@@ -1511,6 +1533,11 @@ async function createProject(ctx, root, body) {
1511
1533
  const cwd = asText(body.cwd);
1512
1534
  if (cwd === '') throw httpError(400, '请先选择作品的工作目录');
1513
1535
  const agentPreset = asText(body.agentPreset) || undefined;
1536
+ // New works prefer the bundled author preset when its plugin is installed, and
1537
+ // otherwise fall back to the profile default instead of failing.
1538
+ const chosenPreset = agentPreset !== undefined && await usablePreset(ctx, agentPreset)
1539
+ ? agentPreset
1540
+ : (await usablePreset(ctx, AUTHOR_PRESET_ID) ? AUTHOR_PRESET_ID : undefined);
1514
1541
  const model = body.model && asText(body.model.provider) && asText(body.model.model)
1515
1542
  ? {
1516
1543
  provider: asText(body.model.provider),
@@ -1529,7 +1556,7 @@ async function createProject(ctx, root, body) {
1529
1556
  throw httpError(400, `无法在工作目录下创建作品文件夹:${error instanceof Error ? error.message : String(error)}`);
1530
1557
  }
1531
1558
 
1532
- const created = await controller.create(agentPreset === undefined ? { cwd } : { cwd, agentPreset });
1559
+ const created = await controller.create(chosenPreset === undefined ? { cwd } : { cwd, agentPreset: chosenPreset });
1533
1560
  const sessionId = created && created.sessionId ? created.sessionId : null;
1534
1561
  let warning = null;
1535
1562
  if (sessionId && model) {
@@ -1546,7 +1573,7 @@ async function createProject(ctx, root, body) {
1546
1573
  projectId,
1547
1574
  title,
1548
1575
  cwd,
1549
- agentPreset: agentPreset || created.agentPreset || null,
1576
+ agentPreset: chosenPreset || created.agentPreset || null,
1550
1577
  model,
1551
1578
  // Every new work starts with the default narrative preface; the author can
1552
1579
  // edit or clear it from the work list.
@@ -1914,6 +1941,9 @@ const PRELUDE_PRESETS = [
1914
1941
  /** The preface a fresh work starts with. */
1915
1942
  const DEFAULT_PRELUDE_MODE = 'thirdOmniscient';
1916
1943
 
1944
+ /** Preset id contributed by the optional `dsh-novel-author-preset` sub-plugin. */
1945
+ const AUTHOR_PRESET_ID = 'novel-studio-author';
1946
+
1917
1947
  function defaultPrelude() {
1918
1948
  const preset = PRELUDE_PRESETS.find((entry) => entry.id === DEFAULT_PRELUDE_MODE);
1919
1949
  return preset ? { mode: preset.id, text: preset.text, updatedAt: Date.now() } : null;
@@ -2019,7 +2049,7 @@ function nextChapterIndex(project) {
2019
2049
  return max + 1;
2020
2050
  }
2021
2051
 
2022
- async function saveOutline(root, body) {
2052
+ async function saveOutline(ctx, root, registry, body) {
2023
2053
  const projectId = asText(body.projectId);
2024
2054
  const chapterId = asText(body.chapterId);
2025
2055
  const outline = normalizeOutline(body.outline);
@@ -2073,10 +2103,21 @@ async function saveOutline(root, body) {
2073
2103
  await exportChapterOutline(savedProject, savedChapter);
2074
2104
  await exportProjectInfo(savedProject);
2075
2105
  }
2076
- return result;
2106
+ // Creating (or re-timing) a chapter that brings the story onto an interval
2107
+ // point is what earns the snapshot for that point: the chapter ending exactly
2108
+ // there is still unwritten, so the snapshot is built from what came before it.
2109
+ let snapshotJobId = null;
2110
+ if (savedChapter) {
2111
+ const fresh = await readProject(root, projectId);
2112
+ const target = autoSnapshotTarget(fresh, chapterWindow(fresh, savedChapter).end);
2113
+ if (target !== null) {
2114
+ snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
2115
+ }
2116
+ }
2117
+ return snapshotJobId === null ? result : { ...result, snapshotJobId };
2077
2118
  }
2078
2119
 
2079
- async function saveChapter(root, body) {
2120
+ async function saveChapter(ctx, root, registry, body) {
2080
2121
  const projectId = asText(body.projectId);
2081
2122
  const chapterId = asText(body.chapterId);
2082
2123
  const content = typeof body.content === 'string' ? body.content : '';
@@ -2096,7 +2137,16 @@ async function saveChapter(root, body) {
2096
2137
  return { chapter: { ...chapter, content: undefined }, project: summarize(project) };
2097
2138
  });
2098
2139
  await exportChapter(savedProject, savedChapter, content);
2099
- return result;
2140
+ // Saving prose is the other way story time advances, so a point that is due
2141
+ // gets its snapshot here too. The check is idempotent: an existing point is
2142
+ // never re-created by an ordinary save.
2143
+ let snapshotJobId = null;
2144
+ if (savedChapter) {
2145
+ const fresh = await readProject(root, projectId);
2146
+ const target = autoSnapshotTarget(fresh, chapterWindow(fresh, savedChapter).end);
2147
+ if (target !== null) snapshotJobId = registerSnapshot(ctx, root, registry, projectId, target).jobId;
2148
+ }
2149
+ return snapshotJobId === null ? result : { ...result, snapshotJobId };
2100
2150
  }
2101
2151
 
2102
2152
  /** Display name the plugin gives the sidebar workspace that holds its sessions. */
@@ -2229,15 +2279,21 @@ function previousChapter(project, chapter) {
2229
2279
  }
2230
2280
 
2231
2281
  /**
2232
- * Chapters that belong to the stretch `(fromDay, point]`: fully written up to
2233
- * the point, so a chapter that straddles it (the one still being written) is
2234
- * left out. The chapter starting exactly at `fromDay` is included.
2282
+ * Chapters that feed the snapshot at `point`: the stretch `[fromDay, point)`.
2283
+ *
2284
+ * A chapter belongs to the snapshot of the *next* point after it ends:
2285
+ *
2286
+ * - `end < point` — the chapter ending exactly on the point is the one this
2287
+ * snapshot is being taken for; it is still unwritten when the snapshot is
2288
+ * created, so counting it would pull unwritten plot into the world state.
2289
+ * - `end >= fromDay` — every chapter is read by exactly one snapshot, and the
2290
+ * previous snapshot's last chapter is not read a second time.
2235
2291
  */
2236
2292
  function chaptersForPoint(project, fromDay, point) {
2237
2293
  return (project.chapters || [])
2238
2294
  .filter((chapter) => {
2239
2295
  const span = chapterWindow(project, chapter);
2240
- if (span.end > point || span.end <= fromDay) return false;
2296
+ if (span.end >= point || span.end < fromDay) return false;
2241
2297
  return ((chapter.outline && chapter.outline.synopsis) || '').trim() !== ''
2242
2298
  || String(chapter.content || '').trim() !== '';
2243
2299
  })
@@ -2251,6 +2307,13 @@ function chaptersForPoint(project, fromDay, point) {
2251
2307
  * in order instead of skipping to the end.
2252
2308
  */
2253
2309
  function registerSnapshot(ctx, root, registry, projectId, targetDay) {
2310
+ // One snapshot job per work at a time: two concurrent cascades would each read
2311
+ // a half-written snapshot list and produce the same point twice.
2312
+ for (const running of registry.jobs.values()) {
2313
+ if (running.kind === 'snapshot' && running.projectId === projectId && running.state === 'running') {
2314
+ return running;
2315
+ }
2316
+ }
2254
2317
  return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
2255
2318
  // The sequence is fixed from the state at the start of the job: recomputing
2256
2319
  // it every round would regenerate the last point forever once it exists.
@@ -2706,9 +2769,9 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2706
2769
  case '/project/import':
2707
2770
  return importProject(ctx, root, body);
2708
2771
  case '/outline/save':
2709
- return saveOutline(root, body);
2772
+ return saveOutline(ctx, root, registry, body);
2710
2773
  case '/chapter/save':
2711
- return saveChapter(root, body);
2774
+ return saveChapter(ctx, root, registry, body);
2712
2775
  case '/generate':
2713
2776
  return startGenerate(ctx, root, registry, body);
2714
2777
  case '/chapter/revise':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "Novel studio for DeepSeek Harness: a draggable story timeline, world snapshots, chapter outlines, and chapters written by the work's own agent.",
6
6
  "license": "MIT",