dsh-novel-generator 0.1.27 → 0.1.29

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 +8 -2
  2. package/index.js +78 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,8 +31,14 @@ 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
+ - **空段落点不调用模型**:如果某个网格点没有可收录的章节(例如一章就占满整个间隔),插件直接把上一版快照抄过来、注明"这段时间没有已写章节",既省一次模型调用,也避免模型凭空编出世界设定——那种编造会污染后续所有章节的提示。落点那一章不会因此丢失:写下一章时它照样出现在「已发生的章节梗概」里。
41
+ - 同一部作品同一时间只跑一个快照任务,避免并发把同一个点重复生成。
36
42
 
37
43
  - **重置**:时间线右上角的「重置快照」会清空全部快照,再按当前间隔从第 1 格逐格重建到故事末端(确认框里写明要生成几份)。
38
44
  - **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
package/index.js CHANGED
@@ -1201,7 +1201,10 @@ function stretchBefore(project, chapter, snapshot) {
1201
1201
  return (project.chapters || [])
1202
1202
  .filter((entry) => entry.chapterId !== chapter.chapterId
1203
1203
  && entry.startDay < chapter.startDay
1204
- && 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
1205
1208
  && ((entry.outline && entry.outline.synopsis) || '').trim() !== '')
1206
1209
  .sort((a, b) => a.startDay - b.startDay);
1207
1210
  }
@@ -1496,7 +1499,7 @@ async function bootstrap(ctx, root) {
1496
1499
  }
1497
1500
  }
1498
1501
  return {
1499
- revision: '2026-10-27.11',
1502
+ revision: '2026-10-27.17',
1500
1503
  projects,
1501
1504
  models: {
1502
1505
  default: modelCatalog.default || null,
@@ -2046,7 +2049,7 @@ function nextChapterIndex(project) {
2046
2049
  return max + 1;
2047
2050
  }
2048
2051
 
2049
- async function saveOutline(root, body) {
2052
+ async function saveOutline(ctx, root, registry, body) {
2050
2053
  const projectId = asText(body.projectId);
2051
2054
  const chapterId = asText(body.chapterId);
2052
2055
  const outline = normalizeOutline(body.outline);
@@ -2100,10 +2103,21 @@ async function saveOutline(root, body) {
2100
2103
  await exportChapterOutline(savedProject, savedChapter);
2101
2104
  await exportProjectInfo(savedProject);
2102
2105
  }
2103
- 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 };
2104
2118
  }
2105
2119
 
2106
- async function saveChapter(root, body) {
2120
+ async function saveChapter(ctx, root, registry, body) {
2107
2121
  const projectId = asText(body.projectId);
2108
2122
  const chapterId = asText(body.chapterId);
2109
2123
  const content = typeof body.content === 'string' ? body.content : '';
@@ -2123,7 +2137,16 @@ async function saveChapter(root, body) {
2123
2137
  return { chapter: { ...chapter, content: undefined }, project: summarize(project) };
2124
2138
  });
2125
2139
  await exportChapter(savedProject, savedChapter, content);
2126
- 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 };
2127
2150
  }
2128
2151
 
2129
2152
  /** Display name the plugin gives the sidebar workspace that holds its sessions. */
@@ -2256,15 +2279,21 @@ function previousChapter(project, chapter) {
2256
2279
  }
2257
2280
 
2258
2281
  /**
2259
- * Chapters that belong to the stretch `(fromDay, point]`: fully written up to
2260
- * the point, so a chapter that straddles it (the one still being written) is
2261
- * 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.
2262
2291
  */
2263
2292
  function chaptersForPoint(project, fromDay, point) {
2264
2293
  return (project.chapters || [])
2265
2294
  .filter((chapter) => {
2266
2295
  const span = chapterWindow(project, chapter);
2267
- if (span.end > point || span.end <= fromDay) return false;
2296
+ if (span.end >= point || span.end < fromDay) return false;
2268
2297
  return ((chapter.outline && chapter.outline.synopsis) || '').trim() !== ''
2269
2298
  || String(chapter.content || '').trim() !== '';
2270
2299
  })
@@ -2278,6 +2307,13 @@ function chaptersForPoint(project, fromDay, point) {
2278
2307
  * in order instead of skipping to the end.
2279
2308
  */
2280
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
+ }
2281
2317
  return startJob(registry, { kind: 'snapshot', projectId }, async (job) => {
2282
2318
  // The sequence is fixed from the state at the start of the job: recomputing
2283
2319
  // it every round would regenerate the last point forever once it exists.
@@ -2297,6 +2333,36 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
2297
2333
  const project = await readProject(root, projectId);
2298
2334
  const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
2299
2335
  const chapters = chaptersForPoint(project, base ? base.day : 0, point);
2336
+ // A point whose stretch holds no chapter is a blank update: carry the
2337
+ // previous snapshot forward verbatim instead of asking the model to invent
2338
+ // a world out of nothing — that invented text would then feed every later
2339
+ // chapter. No material, no call.
2340
+ if (chapters.length === 0) {
2341
+ const carried = {
2342
+ snapshotId: randomUUID(),
2343
+ day: point,
2344
+ createdAt: Date.now(),
2345
+ unitDays: unit,
2346
+ world: (base && base.world) || '',
2347
+ characters: (base && base.characters) || [],
2348
+ factions: (base && base.factions) || [],
2349
+ relations: (base && base.relations) || [],
2350
+ items: (base && base.items) || [],
2351
+ events: (base && base.events) || [],
2352
+ summary: base
2353
+ ? `第 ${base.day} 天至第 ${point} 天之间没有已写章节,沿用上一版设定。`
2354
+ : `第 ${point} 天之前还没有已写章节。`,
2355
+ };
2356
+ await mutateProject(root, projectId, (draft) => {
2357
+ draft.snapshots = (draft.snapshots || []).filter((entry) => entry.day !== point);
2358
+ draft.snapshots.push(carried);
2359
+ draft.snapshots.sort((a, b) => a.day - b.day);
2360
+ return carried;
2361
+ });
2362
+ await exportSnapshot(project, carried);
2363
+ made.push(point);
2364
+ continue;
2365
+ }
2300
2366
  job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
2301
2367
  const reply = await askAgent(ctx, root, project, snapshotPrompt(project, base, chapters, point), (note) => {
2302
2368
  job.message = note;
@@ -2733,9 +2799,9 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
2733
2799
  case '/project/import':
2734
2800
  return importProject(ctx, root, body);
2735
2801
  case '/outline/save':
2736
- return saveOutline(root, body);
2802
+ return saveOutline(ctx, root, registry, body);
2737
2803
  case '/chapter/save':
2738
- return saveChapter(root, body);
2804
+ return saveChapter(ctx, root, registry, body);
2739
2805
  case '/generate':
2740
2806
  return startGenerate(ctx, root, registry, body);
2741
2807
  case '/chapter/revise':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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",