novelforge-agent 0.1.1 → 0.3.0

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 (56) hide show
  1. package/README.md +45 -18
  2. package/dist/src/cli/index.js +81 -4
  3. package/dist/src/core/bibleStore.js +36 -0
  4. package/dist/src/core/characterStore.js +74 -0
  5. package/dist/src/core/contextBuilder.js +76 -1
  6. package/dist/src/core/fileNames.js +4 -0
  7. package/dist/src/core/index.js +4 -0
  8. package/dist/src/core/projectDiscovery.js +1 -0
  9. package/dist/src/core/projectOps.js +193 -0
  10. package/dist/src/core/projectStore.js +15 -1
  11. package/dist/src/core/prompts/en-US.js +247 -16
  12. package/dist/src/core/prompts/zh-CN.js +246 -15
  13. package/dist/src/core/retrieval/index.js +8 -0
  14. package/dist/src/core/schemas.js +121 -1
  15. package/dist/src/core/steps/architecture.js +7 -1
  16. package/dist/src/core/steps/architectureExtension.js +72 -0
  17. package/dist/src/core/steps/chapter.js +11 -1
  18. package/dist/src/core/steps/chapterReview.js +26 -1
  19. package/dist/src/core/steps/chapterRevision.js +17 -0
  20. package/dist/src/core/steps/index.js +4 -0
  21. package/dist/src/core/steps/memoryCard.js +26 -1
  22. package/dist/src/core/steps/novelMetadata.js +4 -2
  23. package/dist/src/core/steps/storyBible.js +1 -1
  24. package/dist/src/core/steps/styleGuide.js +12 -0
  25. package/dist/src/core/threadStore.js +150 -0
  26. package/dist/src/core/workflow.js +5 -3
  27. package/dist/src/mcp/tools.js +228 -20
  28. package/package.json +5 -1
  29. package/src/cli/index.ts +84 -3
  30. package/src/core/bibleStore.ts +57 -0
  31. package/src/core/characterStore.ts +93 -0
  32. package/src/core/contextBuilder.ts +74 -4
  33. package/src/core/fileNames.ts +5 -0
  34. package/src/core/index.ts +4 -0
  35. package/src/core/projectDiscovery.ts +2 -0
  36. package/src/core/projectOps.ts +251 -0
  37. package/src/core/projectStore.ts +19 -1
  38. package/src/core/prompts/en-US.ts +258 -25
  39. package/src/core/prompts/types.ts +4 -1
  40. package/src/core/prompts/zh-CN.ts +250 -17
  41. package/src/core/retrieval/index.ts +10 -0
  42. package/src/core/schemas.ts +133 -1
  43. package/src/core/steps/architecture.ts +7 -1
  44. package/src/core/steps/architectureExtension.ts +88 -0
  45. package/src/core/steps/chapter.ts +11 -1
  46. package/src/core/steps/chapterReview.ts +28 -1
  47. package/src/core/steps/chapterRevision.ts +18 -0
  48. package/src/core/steps/index.ts +4 -0
  49. package/src/core/steps/memoryCard.ts +27 -1
  50. package/src/core/steps/novelMetadata.ts +4 -2
  51. package/src/core/steps/storyBible.ts +1 -1
  52. package/src/core/steps/styleGuide.ts +13 -0
  53. package/src/core/threadStore.ts +173 -0
  54. package/src/core/types.ts +134 -1
  55. package/src/core/workflow.ts +5 -3
  56. package/src/mcp/tools.ts +351 -21
@@ -2,9 +2,12 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { chapterFileName, chapterReviewFileName, memoryFileName } from './fileNames.js';
4
4
  import { formatHits, retrieve } from './retrieval/index.js';
5
+ import { activeThreads, loadThreads } from './threadStore.js';
5
6
 
6
7
  export type ContextPurpose =
7
8
  | 'chapter_generation'
9
+ | 'style_guide'
10
+ | 'architecture_extension'
8
11
  | 'memory_extraction'
9
12
  | 'continuity_review'
10
13
  | 'revision'
@@ -31,19 +34,36 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
31
34
  const parts: string[] = [];
32
35
  const metadata = await readOptional(join(input.projectPath, 'novel.json'));
33
36
  const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
37
+ const styleGuideJson = await readOptional(join(input.projectPath, 'style-guide.json'));
34
38
  const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
39
+ const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
40
+ const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
35
41
 
36
42
  if (metadata) parts.push(`## Novel Metadata\n${metadata}`);
37
43
  if (storyBible) parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
44
+ if (styleGuideJson) parts.push(`## Style Guide\n${styleGuideJson}`);
45
+ if (charactersJson) parts.push(`## Character State Table\n${charactersJson}`);
46
+
47
+ function addVolumePacing(volumeId?: string): void {
48
+ if (!volumePacingJson) return;
49
+ try {
50
+ const boards = JSON.parse(volumePacingJson) as Array<{ volumeId: string }>;
51
+ const board = volumeId ? boards.find((item) => item.volumeId === volumeId) : undefined;
52
+ parts.push(`## Volume Pacing Board\n${JSON.stringify(board ?? boards, null, 2)}`);
53
+ } catch {
54
+ parts.push(`## Volume Pacing Board\n${volumePacingJson}`);
55
+ }
56
+ }
38
57
 
39
58
  if (input.purpose === 'chapter_generation' && input.chapterNumber) {
40
- let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string } | undefined;
59
+ let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string; volumeId?: string } | undefined;
41
60
  if (chaptersJson) {
42
- const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
61
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
43
62
  const chapter = chapters.find((item) => item.chapterNumber === input.chapterNumber);
44
63
  if (chapter) {
45
64
  currentArchitectureForQuery = chapter;
46
65
  parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
66
+ addVolumePacing(chapter.volumeId);
47
67
  }
48
68
  }
49
69
  if (input.chapterNumber > 1) {
@@ -67,6 +87,18 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
67
87
  const formatted = formatHits(hits);
68
88
  if (formatted) parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
69
89
  }
90
+
91
+ const allThreads = await loadThreads(input.projectPath);
92
+ const active = activeThreads(allThreads);
93
+ if (active.length) {
94
+ const lines = active.map((t) => {
95
+ const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
96
+ if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
97
+ if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
98
+ return `- ${t.description} (${flags.join(', ')})`;
99
+ });
100
+ parts.push(`## Active Foreshadow Threads (do not silently drop or contradict)\n${lines.join('\n')}`);
101
+ }
70
102
  }
71
103
  }
72
104
 
@@ -85,11 +117,40 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
85
117
  if (memoryParts.length) parts.push(`## Memory Cards\n${memoryParts.join('\n')}`);
86
118
  }
87
119
 
120
+ if (input.purpose === 'architecture_extension') {
121
+ if (chaptersJson) parts.push(`## Existing Chapter Architecture List\n${chaptersJson}`);
122
+ if (volumePacingJson) parts.push(`## Existing Volume Pacing Boards\n${volumePacingJson}`);
123
+
124
+ const start = Math.max(1, (input.chapterNumber ?? 1) - 5);
125
+ const end = Math.max(0, (input.chapterNumber ?? 1) - 1);
126
+ const memoryParts: string[] = [];
127
+ for (let i = start; i <= end; i += 1) {
128
+ const memory = await readOptional(join(input.projectPath, 'memory', memoryFileName(i)));
129
+ if (memory) memoryParts.push(`### Chapter ${i} Memory\n${memory}`);
130
+ }
131
+ if (memoryParts.length) parts.push(`## Recent Memory Cards\n${memoryParts.join('\n')}`);
132
+
133
+ const allThreads = await loadThreads(input.projectPath);
134
+ const active = activeThreads(allThreads);
135
+ if (active.length) {
136
+ const lines = active.map((t) => {
137
+ const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
138
+ if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
139
+ if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
140
+ return `- ${t.description} (${flags.join(', ')})`;
141
+ });
142
+ parts.push(`## Active Foreshadow Threads\n${lines.join('\n')}`);
143
+ }
144
+ }
145
+
88
146
  if (input.purpose === 'chapter_review' && input.chapterNumber) {
89
147
  if (chaptersJson) {
90
- const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
148
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
91
149
  const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
92
- if (arch) parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
150
+ if (arch) {
151
+ parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
152
+ addVolumePacing(arch.volumeId);
153
+ }
93
154
  }
94
155
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
95
156
  if (chapter) parts.push(`## Chapter ${input.chapterNumber} Text\n${chapter}`);
@@ -102,6 +163,15 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
102
163
  if (input.purpose === 'revision' && input.chapterNumber) {
103
164
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
104
165
  if (chapter) parts.push(`## Current Chapter Text\n${chapter}`);
166
+ if (chaptersJson) {
167
+ try {
168
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; volumeId?: string }>;
169
+ const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
170
+ addVolumePacing(arch?.volumeId);
171
+ } catch {
172
+ // ignore malformed architecture here; review feedback is still useful
173
+ }
174
+ }
105
175
  const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
106
176
  if (review) parts.push(`## Editor Review\n${review}`);
107
177
  if (input.feedback) parts.push(`## Additional Feedback\n${input.feedback}`);
@@ -46,3 +46,8 @@ export function chapterVersionFileName(chapterNumber: number, timestamp: string)
46
46
  const safeTs = timestamp.replace(/[:.]/g, '-');
47
47
  return `${padChapterNumber(chapterNumber)}.${safeTs}.md`;
48
48
  }
49
+
50
+ export function storyBibleVersionFileName(timestamp: string): string {
51
+ const safeTs = timestamp.replace(/[:.]/g, '-');
52
+ return `story-bible.${safeTs}.md`;
53
+ }
package/src/core/index.ts CHANGED
@@ -7,3 +7,7 @@ export * from './contextBuilder.js';
7
7
  export * from './prompts.js';
8
8
  export * from './retrieval/index.js';
9
9
  export * from './projectDiscovery.js';
10
+ export * from './threadStore.js';
11
+ export * from './bibleStore.js';
12
+ export * from './projectOps.js';
13
+ export * from './characterStore.js';
@@ -12,6 +12,7 @@ export interface ProjectSummary {
12
12
  currentStep: WorkflowStep;
13
13
  currentChapter: number;
14
14
  targetChapters: number;
15
+ plannedTotalChapters: number;
15
16
  completedSteps: number;
16
17
  chaptersWritten: number;
17
18
  updatedAt: string;
@@ -57,6 +58,7 @@ async function summarizeOne(projectPath: string): Promise<ProjectSummary | undef
57
58
  currentStep: state.currentStep,
58
59
  currentChapter: state.currentChapter,
59
60
  targetChapters: state.targetChapters,
61
+ plannedTotalChapters: state.plannedTotalChapters ?? state.targetChapters,
60
62
  completedSteps: state.completedSteps.length,
61
63
  chaptersWritten: countChapters(state),
62
64
  updatedAt: state.updatedAt,
@@ -0,0 +1,251 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { cp, readdir, rm, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { AgentState, WorkflowStep } from './types.js';
5
+ import { loadState, saveState } from './projectStore.js';
6
+ import { indexChapter, removeChapterFromIndex, removeMemoryCardFromIndex } from './retrieval/index.js';
7
+ import { chapterFileName, memoryFileName } from './fileNames.js';
8
+
9
+ // =============================================================================
10
+ // fork_project
11
+ // =============================================================================
12
+
13
+ export interface ForkProjectInput {
14
+ sourceProjectPath: string;
15
+ label?: string;
16
+ }
17
+
18
+ export interface ForkProjectResult {
19
+ newProjectPath: string;
20
+ newProjectId: string;
21
+ }
22
+
23
+ export async function forkProject(input: ForkProjectInput): Promise<ForkProjectResult> {
24
+ const source = resolve(input.sourceProjectPath);
25
+ const state = await loadState(source);
26
+ const suffix = randomBytes(3).toString('hex');
27
+ const label = (input.label ?? 'fork').replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'fork';
28
+ const targetName = `${basename(source)}-${label}-${suffix}`;
29
+ const target = join(dirname(source), targetName);
30
+
31
+ await cp(source, target, { recursive: true });
32
+
33
+ const forkedState: AgentState = {
34
+ ...state,
35
+ projectId: randomUUID(),
36
+ projectPath: target,
37
+ createdAt: new Date().toISOString(),
38
+ updatedAt: new Date().toISOString(),
39
+ };
40
+ await saveState(forkedState);
41
+ return { newProjectPath: target, newProjectId: forkedState.projectId };
42
+ }
43
+
44
+ // =============================================================================
45
+ // delete_chapter
46
+ // =============================================================================
47
+
48
+ export interface DeleteChapterInput {
49
+ projectPath: string;
50
+ chapterNumber: number;
51
+ }
52
+
53
+ export interface DeleteChapterResult {
54
+ removed: string[];
55
+ newCurrentChapter: number;
56
+ newCurrentStep: WorkflowStep;
57
+ }
58
+
59
+ async function tryUnlink(path: string): Promise<boolean> {
60
+ try {
61
+ await unlink(path);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ async function tryRmDirEntry(dirPath: string, prefix: string): Promise<string[]> {
69
+ const removed: string[] = [];
70
+ try {
71
+ const items = await readdir(dirPath);
72
+ for (const item of items) {
73
+ if (item.startsWith(prefix)) {
74
+ const full = join(dirPath, item);
75
+ try {
76
+ await unlink(full);
77
+ removed.push(full);
78
+ } catch {
79
+ // ignore
80
+ }
81
+ }
82
+ }
83
+ } catch {
84
+ // dir absent
85
+ }
86
+ return removed;
87
+ }
88
+
89
+ export async function deleteChapter(input: DeleteChapterInput): Promise<DeleteChapterResult> {
90
+ const state = await loadState(input.projectPath);
91
+ const n = input.chapterNumber;
92
+ if (n < 1) throw new Error('chapterNumber must be >= 1');
93
+
94
+ const removed: string[] = [];
95
+ const chapterRel = join('chapters', chapterFileName(n));
96
+ if (await tryUnlink(join(state.projectPath, chapterRel))) removed.push(chapterRel);
97
+
98
+ const memoryRel = join('memory', memoryFileName(n));
99
+ if (await tryUnlink(join(state.projectPath, memoryRel))) removed.push(memoryRel);
100
+
101
+ // Versions of this chapter
102
+ const versionsRemoved = await tryRmDirEntry(
103
+ join(state.projectPath, 'chapters/.versions'),
104
+ `${chapterFileName(n).replace(/\.md$/, '')}.`
105
+ );
106
+ removed.push(...versionsRemoved);
107
+
108
+ // Per-chapter review
109
+ const reviewName = `chapter-${String(n).padStart(3, '0')}.json`;
110
+ if (await tryUnlink(join(state.projectPath, 'reviews/chapter', reviewName))) {
111
+ removed.push(`reviews/chapter/${reviewName}`);
112
+ }
113
+
114
+ // Update state.files
115
+ const nextFiles: Record<string, string> = { ...state.files };
116
+ delete nextFiles[`chapter-${n}`];
117
+ delete nextFiles[`memory-${n}`];
118
+ delete nextFiles[`review-chapter-${n}`];
119
+
120
+ // Remove this chapter and its memory card from the lexical index
121
+ await removeChapterFromIndex(state.projectPath, n);
122
+ await removeMemoryCardFromIndex(state.projectPath, n);
123
+
124
+ // Adjust state.currentChapter & currentStep if needed
125
+ let newCurrentChapter = state.currentChapter;
126
+ let newCurrentStep: WorkflowStep = state.currentStep;
127
+ if (state.currentChapter > n) {
128
+ // user deleted an earlier chapter; current pointer becomes the deleted one to be regenerated
129
+ newCurrentChapter = n;
130
+ newCurrentStep = 'chapter';
131
+ } else if (state.currentChapter === n + 1 && (state.currentStep === 'chapter' || state.currentStep === 'memory_card')) {
132
+ // we just finished chapter n and were about to do n+1; step back
133
+ newCurrentChapter = n;
134
+ newCurrentStep = 'chapter';
135
+ }
136
+
137
+ const nextState: AgentState = {
138
+ ...state,
139
+ files: nextFiles,
140
+ currentChapter: newCurrentChapter,
141
+ currentStep: newCurrentStep,
142
+ pendingAction: undefined,
143
+ };
144
+ await saveState(nextState);
145
+ return { removed, newCurrentChapter, newCurrentStep };
146
+ }
147
+
148
+ // =============================================================================
149
+ // redo_step
150
+ // =============================================================================
151
+
152
+ export interface RedoStepInput {
153
+ projectPath: string;
154
+ step: WorkflowStep;
155
+ chapterNumber?: number;
156
+ }
157
+
158
+ export interface RedoStepResult {
159
+ removed: string[];
160
+ currentStep: WorkflowStep;
161
+ currentChapter: number;
162
+ }
163
+
164
+ const STEP_FILE_KEYS: Partial<Record<WorkflowStep, string[]>> = {
165
+ novel_metadata: ['novel'],
166
+ story_bible: ['storyBible'],
167
+ style_guide: ['styleGuide'],
168
+ architecture: ['architecture'],
169
+ continuity_review: ['continuityReview'],
170
+ };
171
+
172
+ const STEP_FILE_PATHS: Partial<Record<WorkflowStep, string[]>> = {
173
+ novel_metadata: ['novel.json'],
174
+ story_bible: ['story-bible.md'],
175
+ style_guide: ['style-guide.json'],
176
+ architecture: ['architecture/full.md', 'architecture/volumes.json', 'architecture/chapters.json'],
177
+ };
178
+
179
+ export async function redoStep(input: RedoStepInput): Promise<RedoStepResult> {
180
+ const state = await loadState(input.projectPath);
181
+ const removed: string[] = [];
182
+
183
+ if (input.step === 'chapter' || input.step === 'memory_card') {
184
+ const chapter = input.chapterNumber ?? state.currentChapter;
185
+ if (input.step === 'memory_card') {
186
+ const rel = join('memory', memoryFileName(chapter));
187
+ if (await tryUnlink(join(state.projectPath, rel))) removed.push(rel);
188
+ delete state.files[`memory-${chapter}`];
189
+ } else {
190
+ // chapter: also remove its memory + per-chapter review since they depend on it
191
+ const cRel = join('chapters', chapterFileName(chapter));
192
+ if (await tryUnlink(join(state.projectPath, cRel))) removed.push(cRel);
193
+ const mRel = join('memory', memoryFileName(chapter));
194
+ if (await tryUnlink(join(state.projectPath, mRel))) removed.push(mRel);
195
+ delete state.files[`chapter-${chapter}`];
196
+ delete state.files[`memory-${chapter}`];
197
+ await removeChapterFromIndex(state.projectPath, chapter);
198
+ await removeMemoryCardFromIndex(state.projectPath, chapter);
199
+ }
200
+ state.currentChapter = chapter;
201
+ state.currentStep = input.step;
202
+ state.pendingAction = undefined;
203
+ } else if (
204
+ input.step === 'novel_metadata'
205
+ || input.step === 'story_bible'
206
+ || input.step === 'style_guide'
207
+ || input.step === 'architecture'
208
+ || input.step === 'continuity_review'
209
+ ) {
210
+ const paths = STEP_FILE_PATHS[input.step] ?? [];
211
+ for (const p of paths) {
212
+ if (await tryUnlink(join(state.projectPath, p))) removed.push(p);
213
+ }
214
+ const keys = STEP_FILE_KEYS[input.step] ?? [];
215
+ for (const k of keys) {
216
+ delete state.files[k];
217
+ }
218
+ state.currentStep = input.step;
219
+ state.pendingAction = undefined;
220
+ if (input.step === 'novel_metadata') state.currentChapter = 1;
221
+ } else {
222
+ throw new Error(`redo_step does not support step: ${input.step}`);
223
+ }
224
+
225
+ // Trim completedSteps after the redo target
226
+ const idx = state.completedSteps.lastIndexOf(input.step);
227
+ if (idx >= 0) state.completedSteps = state.completedSteps.slice(0, idx);
228
+
229
+ await saveState(state);
230
+ return {
231
+ removed,
232
+ currentStep: state.currentStep,
233
+ currentChapter: state.currentChapter,
234
+ };
235
+ }
236
+
237
+ // =============================================================================
238
+ // guards
239
+ // =============================================================================
240
+
241
+ export function assertProjectPath(workspaceRoot: string, projectPath: string): void {
242
+ const root = resolve(workspaceRoot);
243
+ const target = resolve(projectPath);
244
+ const rel = relative(root, target);
245
+ if (rel.startsWith('..') || isAbsolute(rel)) {
246
+ throw new Error(`Refusing to operate outside workspace: ${target}`);
247
+ }
248
+ }
249
+
250
+ // keep tsc happy if no other refs
251
+ void writeFile;
@@ -10,6 +10,7 @@ export interface CreateProjectInput {
10
10
  language?: AgentState['language'];
11
11
  outputDir?: string;
12
12
  targetChapters?: number;
13
+ plannedTotalChapters?: number;
13
14
  }
14
15
 
15
16
  export interface CreateProjectResult {
@@ -30,6 +31,7 @@ export async function ensureProjectDirectories(projectPath: string): Promise<voi
30
31
  await mkdir(join(projectPath, 'architecture'), { recursive: true });
31
32
  await mkdir(join(projectPath, 'chapters'), { recursive: true });
32
33
  await mkdir(join(projectPath, 'chapters/.versions'), { recursive: true });
34
+ await mkdir(join(projectPath, 'story-bible-versions'), { recursive: true });
33
35
  await mkdir(join(projectPath, 'memory'), { recursive: true });
34
36
  await mkdir(join(projectPath, 'reviews'), { recursive: true });
35
37
  await mkdir(join(projectPath, 'reviews/chapter'), { recursive: true });
@@ -47,10 +49,25 @@ export async function archiveChapterVersion(projectPath: string, chapterRelative
47
49
  }
48
50
  }
49
51
 
52
+ export async function archiveStoryBible(projectPath: string, versionRelative: string): Promise<string | undefined> {
53
+ const sourcePath = join(projectPath, 'story-bible.md');
54
+ try {
55
+ const existing = await readFile(sourcePath, 'utf8');
56
+ return saveMarkdownFile(projectPath, versionRelative, existing);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
50
62
  export async function createProject(input: CreateProjectInput): Promise<CreateProjectResult> {
51
63
  const workspaceRoot = resolve(input.workspaceRoot);
52
64
  const baseDir = input.outputDir || 'novels';
53
- const targetChapters = Math.max(1, Math.floor(Number(input.targetChapters || 3)));
65
+ const hasExplicitTargetChapters = input.targetChapters !== undefined;
66
+ const targetChapters = Math.max(1, Math.floor(Number(input.targetChapters || 5)));
67
+ const plannedTotalChapters = Math.max(
68
+ targetChapters,
69
+ Math.floor(Number(input.plannedTotalChapters ?? (hasExplicitTargetChapters ? targetChapters : 12)))
70
+ );
54
71
  const baseSlug = makeProjectSlug(input.prompt.slice(0, 48));
55
72
  const suffix = randomBytes(3).toString('hex');
56
73
  const slug = `${baseSlug}-${suffix}`;
@@ -65,6 +82,7 @@ export async function createProject(input: CreateProjectInput): Promise<CreatePr
65
82
  initialPrompt: input.prompt,
66
83
  language: input.language || 'zh-CN',
67
84
  targetChapters,
85
+ plannedTotalChapters,
68
86
  currentStep: 'novel_metadata',
69
87
  currentChapter: 1,
70
88
  completedSteps: [],