dsh-novel-generator 0.1.11 → 0.1.13

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 +150 -48
  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';
@@ -835,22 +835,59 @@ function normalizeSnapshot(raw) {
835
835
 
836
836
  /* ------------------------------------------------------------ agent work */
837
837
 
838
+ /**
839
+ * Bind a fresh session to a work whose old one disappeared (deleted in the
840
+ * sidebar, or gone from the session store). The work's memory — snapshots,
841
+ * chapters, outlines — lives in files, so a new session loses only chat
842
+ * history, not the novel itself.
843
+ */
844
+ async function rebindSession(ctx, root, project) {
845
+ const controller = ctx.get('sessionController');
846
+ if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法为作品重建会话');
847
+ const created = await controller.create(project.agentPreset ? { cwd: project.cwd, agentPreset: project.agentPreset } : { cwd: project.cwd });
848
+ const sessionId = created && created.sessionId ? created.sessionId : null;
849
+ if (!sessionId) throw httpError(502, '没能为作品重建会话');
850
+ if (project.model) {
851
+ try {
852
+ await controller.selectModel({ sessionId, ...project.model });
853
+ } catch {
854
+ // The rebuilt session still works with the profile's default model.
855
+ }
856
+ }
857
+ project.sessionId = sessionId;
858
+ await mutateProject(root, project.projectId, (draft) => {
859
+ draft.sessionId = sessionId;
860
+ });
861
+ try {
862
+ await ensureWorkspace(ctx, root, project);
863
+ } catch {
864
+ // Grouping is cosmetic; the session works either way.
865
+ }
866
+ const result = await controller.resolveAgent(sessionId);
867
+ if (!result || !result.agent) throw httpError(502, '重建的会话无法启动');
868
+ return { controller, agent: result.agent };
869
+ }
870
+
838
871
  /** Resolve (or resume) the ordinary Agent that owns one project's Session. */
839
- async function resolveAgent(ctx, project) {
872
+ async function resolveAgent(ctx, root, project, onNote) {
840
873
  const controller = ctx.get('sessionController');
841
874
  if (controller === undefined) throw httpError(503, '当前部署缺少 sessionController 服务,无法驱动创作会话');
842
- if (!project.sessionId) throw httpError(409, '这个作品还没有绑定会话');
843
- let result;
875
+ if (!project.sessionId) {
876
+ const rebuilt = await rebindSession(ctx, root, project);
877
+ if (onNote) onNote('作品没有绑定会话,已新建一个会话');
878
+ return rebuilt;
879
+ }
880
+ let result = null;
844
881
  try {
845
882
  result = await controller.resolveAgent(project.sessionId);
846
- } catch (error) {
847
- throw httpError(502, `无法恢复作品会话:${error instanceof Error ? error.message : String(error)}`);
848
- }
849
- if (!result || !result.agent) {
850
- const code = result && result.error && result.error.code ? result.error.code : 'unknown';
851
- throw httpError(502, `无法恢复作品会话(${code})`);
883
+ } catch {
884
+ result = null;
852
885
  }
853
- return { controller, agent: result.agent };
886
+ if (result && result.agent) return { controller, agent: result.agent };
887
+ // The old session is gone: rebuild instead of failing every later call.
888
+ const rebuilt = await rebindSession(ctx, root, project);
889
+ if (onNote) onNote('原作品会话已不存在,已自动新建一个会话继续');
890
+ return rebuilt;
854
891
  }
855
892
 
856
893
  function withTimeout(promise, ms, onTimeout) {
@@ -868,32 +905,47 @@ function withTimeout(promise, ms, onTimeout) {
868
905
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
869
906
  }
870
907
 
871
- /** Send one prompt into the project's own Session and return the reply text. */
872
- async function askAgent(ctx, project, prompt) {
873
- const { controller, agent } = await resolveAgent(ctx, project);
874
- const admission = new AbortController();
875
- await withTimeout(controller.prompt({
876
- sessionId: project.sessionId,
877
- requestId: `novel-${randomUUID()}`,
878
- mode: 'queue',
879
- content: [{ type: 'text', text: prompt }],
880
- clientTimeZone: 'Asia/Shanghai',
881
- }, admission.signal), 60 * 1000, () => admission.abort());
882
- await withTimeout(agent.whenIdle(), AGENT_TIMEOUT_MS, () => {
883
- try {
884
- agent.cancel({ kind: 'hook', reason: 'novel-studio generation timeout' });
885
- } catch {
886
- // Cancellation is best effort on the failure path.
908
+ /**
909
+ * Send one prompt into the project's own Session and return the reply text.
910
+ * A freshly rebuilt session occasionally answers nothing on its first turn, so
911
+ * one silent reply earns a single retry before the job fails.
912
+ */
913
+ async function askAgent(ctx, root, project, prompt, onNote) {
914
+ let rebuilt = false;
915
+ for (let attempt = 0; attempt < 2; attempt += 1) {
916
+ const before = project.sessionId;
917
+ const { controller, agent } = await resolveAgent(ctx, root, project, attempt === 0 ? onNote : undefined);
918
+ if (project.sessionId !== before) rebuilt = true;
919
+ const admission = new AbortController();
920
+ await withTimeout(controller.prompt({
921
+ sessionId: project.sessionId,
922
+ requestId: `novel-${randomUUID()}`,
923
+ mode: 'queue',
924
+ content: [{ type: 'text', text: prompt }],
925
+ clientTimeZone: 'Asia/Shanghai',
926
+ }, admission.signal), 60 * 1000, () => admission.abort());
927
+ await withTimeout(agent.whenIdle(), AGENT_TIMEOUT_MS, () => {
928
+ try {
929
+ agent.cancel({ kind: 'hook', reason: 'novel-studio generation timeout' });
930
+ } catch {
931
+ // Cancellation is best effort on the failure path.
932
+ }
933
+ });
934
+ const messages = agent.session.deriveMessages();
935
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
936
+ const message = messages[index];
937
+ if (!message || message.role !== 'assistant') continue;
938
+ const text = textOfMessage(message);
939
+ if (text !== '') return text;
940
+ }
941
+ if (attempt === 0) {
942
+ if (onNote) onNote('模型这一轮没有回复,重试一次');
943
+ await new Promise((resolve) => setTimeout(resolve, 1500));
887
944
  }
888
- });
889
- const messages = agent.session.deriveMessages();
890
- for (let index = messages.length - 1; index >= 0; index -= 1) {
891
- const message = messages[index];
892
- if (!message || message.role !== 'assistant') continue;
893
- const text = textOfMessage(message);
894
- if (text !== '') return text;
895
945
  }
896
- throw httpError(502, '模型没有返回任何正文');
946
+ throw rebuilt
947
+ ? httpError(502, '作品的旧会话已不存在,已为它新建一个会话,但模型没有回复;请在侧栏打开这部作品的会话(「小说工坊」分组里)后再试一次')
948
+ : httpError(502, '模型没有返回任何正文');
897
949
  }
898
950
 
899
951
  function snapshotBlock(snapshot) {
@@ -1163,9 +1215,11 @@ async function bootstrap(ctx, root) {
1163
1215
  const projects = await listProjects(root);
1164
1216
  // Repair projects created before sessions were placed in the sidebar group,
1165
1217
  // and stamp works made before folders carried an ownership mark.
1218
+ const workspaceRegistry = ctx.get('workspaceRegistry');
1219
+ if (workspaceRegistry) tidyWorkspaceGroups(workspaceRegistry).catch(() => {});
1166
1220
  for (const project of projects) {
1167
1221
  try {
1168
- await ensureWorkspace(ctx, project);
1222
+ await ensureWorkspace(ctx, root, project);
1169
1223
  } catch {
1170
1224
  // A missing directory or an older harness simply leaves the session as is.
1171
1225
  }
@@ -1187,7 +1241,7 @@ async function bootstrap(ctx, root) {
1187
1241
  }
1188
1242
  }
1189
1243
  return {
1190
- revision: '2026-10-20.32',
1244
+ revision: '2026-10-20.39',
1191
1245
  projects,
1192
1246
  models: {
1193
1247
  default: modelCatalog.default || null,
@@ -1270,7 +1324,7 @@ async function createProject(ctx, root, body) {
1270
1324
  // Put the session in a sidebar workspace instead of leaving it ungrouped.
1271
1325
  let workspace = null;
1272
1326
  try {
1273
- workspace = await ensureWorkspace(ctx, project);
1327
+ workspace = await ensureWorkspace(ctx, root, project);
1274
1328
  } catch (error) {
1275
1329
  const message = `会话已创建,但没能放进「${WORKSPACE_TITLE}」分组:${error instanceof Error ? error.message : String(error)}`;
1276
1330
  warning = warning === null ? message : `${warning} ${message}`;
@@ -1437,14 +1491,18 @@ const WORKSPACE_TITLE = '小说工坊';
1437
1491
  const ensuredWorkspaces = new Set();
1438
1492
 
1439
1493
  /**
1440
- * Put a project's session under a sidebar workspace instead of 未分组: reuse the
1441
- * workspace that owns the project's directory, otherwise create one titled
1442
- * 小说工坊 for it. Idempotent, so bootstrap can repair older projects too.
1494
+ * Put a project's session under a sidebar workspace titled 小说工坊.
1495
+ *
1496
+ * DSH groups sessions by directory: a workspace owns one canonical path, and a
1497
+ * session only shows under the workspace matching its cwd. Works in different
1498
+ * directories therefore need different groups, so each group is titled with its
1499
+ * directory to keep them apart.
1443
1500
  */
1444
- async function ensureWorkspace(ctx, project) {
1501
+ async function ensureWorkspace(ctx, root, project) {
1445
1502
  const registry = ctx.get('workspaceRegistry');
1446
1503
  if (!registry || !project || !project.sessionId || !project.cwd) return null;
1447
1504
  if (ensuredWorkspaces.has(project.projectId)) return null;
1505
+
1448
1506
  const attached = registry.list()
1449
1507
  .some((workspace) => Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(project.sessionId));
1450
1508
  if (attached) {
@@ -1457,12 +1515,46 @@ async function ensureWorkspace(ctx, project) {
1457
1515
  } catch {
1458
1516
  workspace = undefined;
1459
1517
  }
1460
- if (!workspace) workspace = await registry.create(project.cwd, WORKSPACE_TITLE);
1518
+ if (!workspace) {
1519
+ const label = project.cwd.split(/[\\/]/).filter(Boolean).pop() || '根目录';
1520
+ workspace = await registry.create(project.cwd, `${WORKSPACE_TITLE} · ${label}`);
1521
+ }
1461
1522
  await workspace.attachSession(project.sessionId);
1462
1523
  ensuredWorkspaces.add(project.projectId);
1463
1524
  return workspace;
1464
1525
  }
1465
1526
 
1527
+ /** Remove empty groups this plugin leaves behind; never touches a used one. */
1528
+ async function tidyWorkspaceGroups(registry) {
1529
+ for (const workspace of registry.list()) {
1530
+ if (!workspace.title.startsWith(WORKSPACE_TITLE)) continue;
1531
+ const sessions = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : [];
1532
+ if (sessions.length === 0) {
1533
+ try {
1534
+ await registry.delete(workspace.id);
1535
+ } catch {
1536
+ // Leaving one empty group is harmless.
1537
+ }
1538
+ continue;
1539
+ }
1540
+ // A group rooted in the OS temp directory can only be a leftover from a
1541
+ // throwaway test work: archive what it holds, then drop the group.
1542
+ if (!workspace.path.toLowerCase().startsWith(tmpdir().toLowerCase())) continue;
1543
+ for (const sessionId of sessions) {
1544
+ try {
1545
+ await registry.archiveSession(sessionId, { stopActivity: true });
1546
+ } catch {
1547
+ // An active session stays; the group stays with it.
1548
+ }
1549
+ }
1550
+ try {
1551
+ await registry.delete(workspace.id);
1552
+ } catch {
1553
+ // The group stays visible if it could not be removed.
1554
+ }
1555
+ }
1556
+ }
1557
+
1466
1558
  /**
1467
1559
  * The grid point a new chapter brings the story to, or null when nothing new is
1468
1560
  * due. A chapter spanning several points targets the last point it covers; the
@@ -1565,7 +1657,9 @@ function registerSnapshot(ctx, root, registry, projectId, targetDay) {
1565
1657
  const base = (project.snapshots || []).filter((snapshot) => snapshot.day < point).pop() || null;
1566
1658
  const chapters = chaptersForPoint(project, base ? base.day : 0, point);
1567
1659
  job.message = `正在生成第 ${point} 天的世界快照(第 ${made.length + 1}/${points.length} 份,目标第 ${target} 天)`;
1568
- const reply = await askAgent(ctx, project, snapshotPrompt(project, base, chapters, point));
1660
+ const reply = await askAgent(ctx, root, project, snapshotPrompt(project, base, chapters, point), (note) => {
1661
+ job.message = note;
1662
+ });
1569
1663
  const parsed = parseJsonReply(reply);
1570
1664
  if (!parsed) throw httpError(502, '模型没有返回可解析的快照 JSON');
1571
1665
  const normalized = normalizeSnapshot(parsed);
@@ -1665,7 +1759,9 @@ async function startGenerate(ctx, root, registry, body) {
1665
1759
  .filter((entry) => entry.chapterId !== chapter.chapterId && entry.startDay <= chapter.startDay)
1666
1760
  .sort((a, b) => b.startDay - a.startDay)[0] || null;
1667
1761
  job.message = '正在生成章节正文';
1668
- const content = await askAgent(ctx, project, chapterPrompt(project, chapter, previous));
1762
+ const content = await askAgent(ctx, root, project, chapterPrompt(project, chapter, previous), (note) => {
1763
+ job.message = note;
1764
+ });
1669
1765
  const saved = await mutateProject(root, projectId, (draft) => {
1670
1766
  const target = draft.chapters.find((entry) => entry.chapterId === chapterId);
1671
1767
  if (!target) throw httpError(404, `章节不存在: ${chapterId}`);
@@ -1753,7 +1849,9 @@ async function startCompose(ctx, root, registry, body) {
1753
1849
  assertSnapshotFresh(project, draft);
1754
1850
 
1755
1851
  job.message = '正在推演本章梗概与名称';
1756
- const deduceReply = await askAgent(ctx, project, deducePrompt(project, draft));
1852
+ const deduceReply = await askAgent(ctx, root, project, deducePrompt(project, draft), (note) => {
1853
+ job.message = note;
1854
+ });
1757
1855
  const deduced = parseJsonReply(deduceReply) || {};
1758
1856
  const synopsis = asText(firstOf(deduced, ['synopsis', '梗概', 'summary']));
1759
1857
  if (synopsis === '') throw httpError(502, '模型没有给出章节梗概');
@@ -1797,7 +1895,9 @@ async function startCompose(ctx, root, registry, body) {
1797
1895
  const fresh = await readProject(root, projectId);
1798
1896
  const target = fresh.chapters.find((entry) => entry.chapterId === chapterId);
1799
1897
  if (!target) throw httpError(500, '章节写入失败');
1800
- const content = await askAgent(ctx, fresh, chapterPrompt(fresh, target, previous));
1898
+ const content = await askAgent(ctx, root, fresh, chapterPrompt(fresh, target, previous), (note) => {
1899
+ job.message = note;
1900
+ });
1801
1901
 
1802
1902
  await mutateProject(root, projectId, (current) => {
1803
1903
  const entry = current.chapters.find((item) => item.chapterId === chapterId);
@@ -1845,7 +1945,9 @@ async function startDeduce(ctx, root, registry, body) {
1845
1945
  const project = await readProject(root, projectId);
1846
1946
  const chapter = chapterId === '' ? null : project.chapters.find((entry) => entry.chapterId === chapterId) || null;
1847
1947
  job.message = '正在推演下一章大纲';
1848
- const reply = await askAgent(ctx, project, deducePrompt(project, chapter));
1948
+ const reply = await askAgent(ctx, root, project, deducePrompt(project, chapter), (note) => {
1949
+ job.message = note;
1950
+ });
1849
1951
  const parsed = parseJsonReply(reply);
1850
1952
  if (!parsed) throw httpError(502, '模型没有返回可解析的大纲 JSON');
1851
1953
  const synopsis = asText(firstOf(parsed, ['synopsis', '梗概', 'summary']));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",