dsh-novel-generator 0.1.12 → 0.1.14

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 +2 -1
  2. package/index.js +94 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,7 +38,8 @@ dsh plugin --profile web add dsh-novel-generator
38
38
  - **物品 / 事件**:快照里除了世界观、角色、势力、关系,还有物品(名称 + 下落/用途)和事件(故事天数 + 标题 + 一句话),事件按时间从新到旧排列。
39
39
  - **生成依据(统一规则)**:先找本章之前最近的那份世界快照。若「本章起始日 − 快照落点 > 当前间隔」,判定快照过旧,直接拒绝并提示先去时间长河更新快照;否则自动推演用「最近快照 + 快照落点之后、本章之前各章梗概」,写正文在此基础上再加一段「待生成的本章梗概」,并在提示里区分已发生与待生成两部分。
40
40
 
41
- ## 数据与接口
41
+ > 侧栏分组:DSH 的分组按目录归属,一个分组只收同目录下的会话,所以不同工作目录的作品会各有一个「小说工坊 · 目录名」分组;空分组与临时目录下的残留分组会被自动清理。
42
+
42
43
 
43
44
 
44
45
  - **索引文件**:`<DSH_HOME>/novel-studio/projects/<项目id>/project.json`(`DSH_HOME` 缺省为 `~/.dsh`,本机即 `C:\Users\Admin\.dsh\novel-studio`)。时间线、大纲、快照字段都记在这里;`dataDir` 配置可改这块的位置。
package/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { randomUUID } from 'node:crypto';
14
14
  import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
15
- import { homedir } from 'node:os';
15
+ import { homedir, tmpdir } from 'node:os';
16
16
  import { basename, join } from 'node:path';
17
17
 
18
18
  export const name = 'dsh-novel-generator';
@@ -859,7 +859,7 @@ async function rebindSession(ctx, root, project) {
859
859
  draft.sessionId = sessionId;
860
860
  });
861
861
  try {
862
- await ensureWorkspace(ctx, project);
862
+ await ensureWorkspace(ctx, root, project);
863
863
  } catch {
864
864
  // Grouping is cosmetic; the session works either way.
865
865
  }
@@ -916,6 +916,15 @@ async function askAgent(ctx, root, project, prompt, onNote) {
916
916
  const before = project.sessionId;
917
917
  const { controller, agent } = await resolveAgent(ctx, root, project, attempt === 0 ? onNote : undefined);
918
918
  if (project.sessionId !== before) rebuilt = true;
919
+ // Only messages added by THIS prompt are eligible: reading further back
920
+ // would hand back the previous turn's reply (a snapshot JSON, for one) when
921
+ // the model stays silent.
922
+ let seen = 0;
923
+ try {
924
+ seen = agent.session.deriveMessages().length;
925
+ } catch {
926
+ seen = 0;
927
+ }
919
928
  const admission = new AbortController();
920
929
  await withTimeout(controller.prompt({
921
930
  sessionId: project.sessionId,
@@ -932,7 +941,7 @@ async function askAgent(ctx, root, project, prompt, onNote) {
932
941
  }
933
942
  });
934
943
  const messages = agent.session.deriveMessages();
935
- for (let index = messages.length - 1; index >= 0; index -= 1) {
944
+ for (let index = messages.length - 1; index >= seen; index -= 1) {
936
945
  const message = messages[index];
937
946
  if (!message || message.role !== 'assistant') continue;
938
947
  const text = textOfMessage(message);
@@ -948,6 +957,36 @@ async function askAgent(ctx, root, project, prompt, onNote) {
948
957
  : httpError(502, '模型没有返回任何正文');
949
958
  }
950
959
 
960
+ /** True when a reply is structured data rather than prose (a snapshot JSON). */
961
+ function looksStructured(text) {
962
+ const head = String(text || '').trim();
963
+ if (head === '') return false;
964
+ if (!head.startsWith('{') && !head.startsWith('[')) {
965
+ return head.startsWith('```') && (head.includes('"world"') || head.includes('"characters"'));
966
+ }
967
+ try {
968
+ JSON.parse(head);
969
+ return true;
970
+ } catch {
971
+ return head.includes('"world"') || head.includes('"characters"') || head.includes('"synopsis"');
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Ask for prose. A model that answers a writing prompt with JSON (it just
977
+ * produced a snapshot, say) gets one firm reminder instead of having structured
978
+ * text saved as the chapter body.
979
+ */
980
+ async function askStory(ctx, root, project, prompt, onNote) {
981
+ const first = await askAgent(ctx, root, project, prompt, onNote);
982
+ if (!looksStructured(first)) return first;
983
+ if (onNote) onNote('模型返回了结构化数据,正在要求它改写为正文');
984
+ const retryPrompt = `${prompt}\n\n【纠正】上一轮你返回的是 JSON/结构化数据,那是错的。现在只输出这一章的小说正文(中文散文),不要 JSON、不要字段名、不要标题行。`;
985
+ const second = await askAgent(ctx, root, project, retryPrompt, onNote);
986
+ if (looksStructured(second)) throw httpError(502, '模型反复返回结构化数据而不是正文,请稍后重试或换一个模型');
987
+ return second;
988
+ }
989
+
951
990
  function snapshotBlock(snapshot) {
952
991
  if (!snapshot) return '(暂无世界快照,请依据本章梗概自行建立最基本的设定)';
953
992
  const lines = [];
@@ -1215,9 +1254,11 @@ async function bootstrap(ctx, root) {
1215
1254
  const projects = await listProjects(root);
1216
1255
  // Repair projects created before sessions were placed in the sidebar group,
1217
1256
  // and stamp works made before folders carried an ownership mark.
1257
+ const workspaceRegistry = ctx.get('workspaceRegistry');
1258
+ if (workspaceRegistry) tidyWorkspaceGroups(workspaceRegistry).catch(() => {});
1218
1259
  for (const project of projects) {
1219
1260
  try {
1220
- await ensureWorkspace(ctx, project);
1261
+ await ensureWorkspace(ctx, root, project);
1221
1262
  } catch {
1222
1263
  // A missing directory or an older harness simply leaves the session as is.
1223
1264
  }
@@ -1239,7 +1280,7 @@ async function bootstrap(ctx, root) {
1239
1280
  }
1240
1281
  }
1241
1282
  return {
1242
- revision: '2026-10-20.35',
1283
+ revision: '2026-10-20.40',
1243
1284
  projects,
1244
1285
  models: {
1245
1286
  default: modelCatalog.default || null,
@@ -1322,7 +1363,7 @@ async function createProject(ctx, root, body) {
1322
1363
  // Put the session in a sidebar workspace instead of leaving it ungrouped.
1323
1364
  let workspace = null;
1324
1365
  try {
1325
- workspace = await ensureWorkspace(ctx, project);
1366
+ workspace = await ensureWorkspace(ctx, root, project);
1326
1367
  } catch (error) {
1327
1368
  const message = `会话已创建,但没能放进「${WORKSPACE_TITLE}」分组:${error instanceof Error ? error.message : String(error)}`;
1328
1369
  warning = warning === null ? message : `${warning} ${message}`;
@@ -1489,14 +1530,18 @@ const WORKSPACE_TITLE = '小说工坊';
1489
1530
  const ensuredWorkspaces = new Set();
1490
1531
 
1491
1532
  /**
1492
- * Put a project's session under a sidebar workspace instead of 未分组: reuse the
1493
- * workspace that owns the project's directory, otherwise create one titled
1494
- * 小说工坊 for it. Idempotent, so bootstrap can repair older projects too.
1533
+ * Put a project's session under a sidebar workspace titled 小说工坊.
1534
+ *
1535
+ * DSH groups sessions by directory: a workspace owns one canonical path, and a
1536
+ * session only shows under the workspace matching its cwd. Works in different
1537
+ * directories therefore need different groups, so each group is titled with its
1538
+ * directory to keep them apart.
1495
1539
  */
1496
- async function ensureWorkspace(ctx, project) {
1540
+ async function ensureWorkspace(ctx, root, project) {
1497
1541
  const registry = ctx.get('workspaceRegistry');
1498
1542
  if (!registry || !project || !project.sessionId || !project.cwd) return null;
1499
1543
  if (ensuredWorkspaces.has(project.projectId)) return null;
1544
+
1500
1545
  const attached = registry.list()
1501
1546
  .some((workspace) => Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(project.sessionId));
1502
1547
  if (attached) {
@@ -1509,12 +1554,46 @@ async function ensureWorkspace(ctx, project) {
1509
1554
  } catch {
1510
1555
  workspace = undefined;
1511
1556
  }
1512
- if (!workspace) workspace = await registry.create(project.cwd, WORKSPACE_TITLE);
1557
+ if (!workspace) {
1558
+ const label = project.cwd.split(/[\\/]/).filter(Boolean).pop() || '根目录';
1559
+ workspace = await registry.create(project.cwd, `${WORKSPACE_TITLE} · ${label}`);
1560
+ }
1513
1561
  await workspace.attachSession(project.sessionId);
1514
1562
  ensuredWorkspaces.add(project.projectId);
1515
1563
  return workspace;
1516
1564
  }
1517
1565
 
1566
+ /** Remove empty groups this plugin leaves behind; never touches a used one. */
1567
+ async function tidyWorkspaceGroups(registry) {
1568
+ for (const workspace of registry.list()) {
1569
+ if (!workspace.title.startsWith(WORKSPACE_TITLE)) continue;
1570
+ const sessions = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : [];
1571
+ if (sessions.length === 0) {
1572
+ try {
1573
+ await registry.delete(workspace.id);
1574
+ } catch {
1575
+ // Leaving one empty group is harmless.
1576
+ }
1577
+ continue;
1578
+ }
1579
+ // A group rooted in the OS temp directory can only be a leftover from a
1580
+ // throwaway test work: archive what it holds, then drop the group.
1581
+ if (!workspace.path.toLowerCase().startsWith(tmpdir().toLowerCase())) continue;
1582
+ for (const sessionId of sessions) {
1583
+ try {
1584
+ await registry.archiveSession(sessionId, { stopActivity: true });
1585
+ } catch {
1586
+ // An active session stays; the group stays with it.
1587
+ }
1588
+ }
1589
+ try {
1590
+ await registry.delete(workspace.id);
1591
+ } catch {
1592
+ // The group stays visible if it could not be removed.
1593
+ }
1594
+ }
1595
+ }
1596
+
1518
1597
  /**
1519
1598
  * The grid point a new chapter brings the story to, or null when nothing new is
1520
1599
  * due. A chapter spanning several points targets the last point it covers; the
@@ -1719,7 +1798,7 @@ async function startGenerate(ctx, root, registry, body) {
1719
1798
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1720
1799
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
1721
1800
  job.message = '正在生成章节正文';
1722
- const content = await askAgent(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
1801
+ const content = await askStory(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
1723
1802
  job.message = note;
1724
1803
  });
1725
1804
  const saved = await mutateProject(root, projectId, (draft) => {
@@ -1813,7 +1892,7 @@ async function startCompose(ctx, root, registry, body) {
1813
1892
  job.message = note;
1814
1893
  });
1815
1894
  const deduced = parseJsonReply(deduceReply) || {};
1816
- const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', 'summary']));
1895
+ const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', '本章梗概']));
1817
1896
  if (synopsis === '') throw httpError(502, '模型没有给出章节梗概');
1818
1897
  const title = draft.title
1819
1898
  || asText(firstOf(deduced, ['title', '标题']))
@@ -1855,7 +1934,7 @@ async function startCompose(ctx, root, registry, body) {
1855
1934
  const fresh = await readProject(root, projectId);
1856
1935
  const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
1857
1936
  if (!target) throw httpError(500, '章节写入失败');
1858
- const content = await askAgent(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
1937
+ const content = await askStory(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
1859
1938
  job.message = note;
1860
1939
  });
1861
1940
 
@@ -1910,7 +1989,7 @@ async function startDeduce(ctx, root, registry, body) {
1910
1989
  });
1911
1990
  const parsed = parseJsonReply(reply);
1912
1991
  if (!parsed) throw httpError(502, '模型没有返回可解析的大纲 JSON');
1913
- const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', 'summary']));
1992
+ const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', '本章梗概']));
1914
1993
  const durationDays = Math.max(1, Math.round(Number(firstOf(parsed, ['durationDays', 'duration', '时间长度', 'days'])) || project.timeline.unitDays));
1915
1994
  const minWords = Math.max(1, Math.round(Number(firstOf(parsed, ['minWords', '最低字数'])) || 1200));
1916
1995
  const maxWords = Math.max(minWords + 1, Math.round(Number(firstOf(parsed, ['maxWords', '最多字数'])) || 3500));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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",