novelforge-agent 0.1.0 → 0.2.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 (48) hide show
  1. package/README.md +82 -14
  2. package/dist/src/cli/index.js +92 -2
  3. package/dist/src/cli/install.js +224 -0
  4. package/dist/src/core/bibleStore.js +36 -0
  5. package/dist/src/core/characterStore.js +74 -0
  6. package/dist/src/core/contextBuilder.js +44 -1
  7. package/dist/src/core/fileNames.js +4 -0
  8. package/dist/src/core/index.js +4 -0
  9. package/dist/src/core/projectOps.js +187 -0
  10. package/dist/src/core/projectStore.js +11 -0
  11. package/dist/src/core/prompts/en-US.js +117 -13
  12. package/dist/src/core/prompts/zh-CN.js +116 -12
  13. package/dist/src/core/retrieval/index.js +8 -0
  14. package/dist/src/core/schemas.js +98 -1
  15. package/dist/src/core/steps/architecture.js +7 -1
  16. package/dist/src/core/steps/chapter.js +11 -1
  17. package/dist/src/core/steps/chapterReview.js +25 -1
  18. package/dist/src/core/steps/chapterRevision.js +17 -0
  19. package/dist/src/core/steps/memoryCard.js +4 -0
  20. package/dist/src/core/steps/novelMetadata.js +4 -2
  21. package/dist/src/core/threadStore.js +150 -0
  22. package/dist/src/core/workflow.js +3 -3
  23. package/dist/src/mcp/tools.js +198 -18
  24. package/package.json +5 -1
  25. package/src/cli/index.ts +94 -1
  26. package/src/cli/install.ts +275 -0
  27. package/src/core/bibleStore.ts +57 -0
  28. package/src/core/characterStore.ts +93 -0
  29. package/src/core/contextBuilder.ts +44 -4
  30. package/src/core/fileNames.ts +5 -0
  31. package/src/core/index.ts +4 -0
  32. package/src/core/projectOps.ts +243 -0
  33. package/src/core/projectStore.ts +11 -0
  34. package/src/core/prompts/en-US.ts +126 -22
  35. package/src/core/prompts/types.ts +2 -1
  36. package/src/core/prompts/zh-CN.ts +118 -14
  37. package/src/core/retrieval/index.ts +10 -0
  38. package/src/core/schemas.ts +108 -1
  39. package/src/core/steps/architecture.ts +7 -1
  40. package/src/core/steps/chapter.ts +11 -1
  41. package/src/core/steps/chapterReview.ts +27 -1
  42. package/src/core/steps/chapterRevision.ts +18 -0
  43. package/src/core/steps/memoryCard.ts +4 -0
  44. package/src/core/steps/novelMetadata.ts +4 -2
  45. package/src/core/threadStore.ts +173 -0
  46. package/src/core/types.ts +102 -1
  47. package/src/core/workflow.ts +3 -3
  48. package/src/mcp/tools.ts +322 -19
@@ -0,0 +1,275 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export type InstallHost = 'claude-code' | 'codex' | 'cursor';
10
+
11
+ export interface InstallOptions {
12
+ host?: InstallHost;
13
+ workspace?: string;
14
+ name?: string;
15
+ printOnly?: boolean;
16
+ }
17
+
18
+ export interface InstallResult {
19
+ host: InstallHost;
20
+ name: string;
21
+ workspace: string;
22
+ applied: boolean;
23
+ method: 'cli' | 'config-edit' | 'print-only';
24
+ message: string;
25
+ manualSnippet?: string;
26
+ verificationHint: string;
27
+ }
28
+
29
+ const PACKAGE_NAME = 'novelforge-agent';
30
+ const MCP_BIN = 'novelforge-agent-mcp';
31
+
32
+ function defaultWorkspace(): string {
33
+ return join(homedir(), 'novelforge');
34
+ }
35
+
36
+ function defaultName(): string {
37
+ return 'novelforge';
38
+ }
39
+
40
+ function claudeJsonSnippet(name: string, workspace: string): string {
41
+ return JSON.stringify(
42
+ {
43
+ mcpServers: {
44
+ [name]: {
45
+ command: 'npx',
46
+ args: ['-y', MCP_BIN],
47
+ env: { NOVELFORGE_WORKSPACE: workspace },
48
+ },
49
+ },
50
+ },
51
+ null,
52
+ 2
53
+ );
54
+ }
55
+
56
+ function codexTomlSnippet(name: string, workspace: string): string {
57
+ return [
58
+ `[mcp_servers.${name}]`,
59
+ `command = "npx"`,
60
+ `args = ["-y", "${MCP_BIN}"]`,
61
+ ``,
62
+ `[mcp_servers.${name}.env]`,
63
+ `NOVELFORGE_WORKSPACE = "${workspace}"`,
64
+ ``,
65
+ ].join('\n');
66
+ }
67
+
68
+ function cursorSnippet(name: string, workspace: string): string {
69
+ return JSON.stringify(
70
+ {
71
+ mcpServers: {
72
+ [name]: {
73
+ command: 'npx',
74
+ args: ['-y', MCP_BIN],
75
+ env: { NOVELFORGE_WORKSPACE: workspace },
76
+ },
77
+ },
78
+ },
79
+ null,
80
+ 2
81
+ );
82
+ }
83
+
84
+ async function ensureWorkspaceDir(workspace: string): Promise<void> {
85
+ await mkdir(workspace, { recursive: true });
86
+ await mkdir(join(workspace, 'novels'), { recursive: true });
87
+ }
88
+
89
+ async function tryClaudeCli(name: string, workspace: string): Promise<{ ok: boolean; output: string; error?: string }> {
90
+ const args = [
91
+ 'mcp',
92
+ 'add',
93
+ '-s',
94
+ 'user',
95
+ '-e',
96
+ `NOVELFORGE_WORKSPACE=${workspace}`,
97
+ name,
98
+ '--',
99
+ 'npx',
100
+ '-y',
101
+ MCP_BIN,
102
+ ];
103
+ try {
104
+ const { stdout, stderr } = await execFileAsync('claude', args);
105
+ return { ok: true, output: `${stdout}\n${stderr}`.trim() };
106
+ } catch (error) {
107
+ const err = error as Error & { code?: string };
108
+ return { ok: false, output: '', error: err.message };
109
+ }
110
+ }
111
+
112
+ async function applyClaudeCode(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
113
+ const snippet = claudeJsonSnippet(name, workspace);
114
+ const verificationHint =
115
+ 'In Claude Code, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
116
+
117
+ if (printOnly) {
118
+ return {
119
+ host: 'claude-code',
120
+ name,
121
+ workspace,
122
+ applied: false,
123
+ method: 'print-only',
124
+ message:
125
+ 'Print-only mode. Run the snippet below or invoke without --print-only to apply automatically.',
126
+ manualSnippet: snippet,
127
+ verificationHint,
128
+ };
129
+ }
130
+
131
+ const cli = await tryClaudeCli(name, workspace);
132
+ if (cli.ok) {
133
+ return {
134
+ host: 'claude-code',
135
+ name,
136
+ workspace,
137
+ applied: true,
138
+ method: 'cli',
139
+ message: `Registered via \`claude mcp add\`.\n${cli.output}`,
140
+ verificationHint,
141
+ };
142
+ }
143
+ return {
144
+ host: 'claude-code',
145
+ name,
146
+ workspace,
147
+ applied: false,
148
+ method: 'print-only',
149
+ message:
150
+ `\`claude\` CLI not available (${cli.error ?? 'unknown error'}). Paste the snippet below into ~/.claude.json under "mcpServers", or run \`claude mcp add -s user -e NOVELFORGE_WORKSPACE=${workspace} ${name} -- npx -y ${MCP_BIN}\` after installing Claude Code.`,
151
+ manualSnippet: snippet,
152
+ verificationHint,
153
+ };
154
+ }
155
+
156
+ async function applyCodex(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
157
+ const snippet = codexTomlSnippet(name, workspace);
158
+ const configPath = join(homedir(), '.codex', 'config.toml');
159
+ const verificationHint =
160
+ 'In Codex CLI, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
161
+
162
+ if (printOnly) {
163
+ return {
164
+ host: 'codex',
165
+ name,
166
+ workspace,
167
+ applied: false,
168
+ method: 'print-only',
169
+ message: `Print-only mode. Append the snippet below to ${configPath}.`,
170
+ manualSnippet: snippet,
171
+ verificationHint,
172
+ };
173
+ }
174
+
175
+ try {
176
+ await mkdir(dirname(configPath), { recursive: true });
177
+ let existing = '';
178
+ try {
179
+ existing = await readFile(configPath, 'utf8');
180
+ } catch {
181
+ existing = '';
182
+ }
183
+ if (existing.includes(`[mcp_servers.${name}]`)) {
184
+ return {
185
+ host: 'codex',
186
+ name,
187
+ workspace,
188
+ applied: false,
189
+ method: 'config-edit',
190
+ message: `Section [mcp_servers.${name}] already exists in ${configPath}. Edit it manually if you want to change settings.`,
191
+ manualSnippet: snippet,
192
+ verificationHint,
193
+ };
194
+ }
195
+ const next = existing.endsWith('\n') || existing === '' ? existing : `${existing}\n`;
196
+ await writeFile(configPath, `${next}\n${snippet}`, 'utf8');
197
+ return {
198
+ host: 'codex',
199
+ name,
200
+ workspace,
201
+ applied: true,
202
+ method: 'config-edit',
203
+ message: `Appended [mcp_servers.${name}] to ${configPath}.`,
204
+ verificationHint,
205
+ };
206
+ } catch (error) {
207
+ return {
208
+ host: 'codex',
209
+ name,
210
+ workspace,
211
+ applied: false,
212
+ method: 'print-only',
213
+ message: `Could not write ${configPath}: ${(error as Error).message}. Append the snippet manually.`,
214
+ manualSnippet: snippet,
215
+ verificationHint,
216
+ };
217
+ }
218
+ }
219
+
220
+ async function applyCursor(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
221
+ const snippet = cursorSnippet(name, workspace);
222
+ return {
223
+ host: 'cursor',
224
+ name,
225
+ workspace,
226
+ applied: false,
227
+ method: 'print-only',
228
+ message:
229
+ 'Cursor: open Settings → Tools & Integrations → MCP, click "Add new MCP server", and paste the snippet below.',
230
+ manualSnippet: snippet,
231
+ verificationHint:
232
+ 'In Cursor, the agent should be able to call the list_projects tool from the novelforge MCP server.',
233
+ };
234
+ }
235
+
236
+ export async function runInstall(options: InstallOptions): Promise<InstallResult> {
237
+ const host: InstallHost = options.host ?? 'claude-code';
238
+ const workspace = resolve(options.workspace ?? defaultWorkspace());
239
+ const name = options.name ?? defaultName();
240
+ const printOnly = options.printOnly ?? false;
241
+
242
+ if (!printOnly) {
243
+ await ensureWorkspaceDir(workspace);
244
+ }
245
+
246
+ switch (host) {
247
+ case 'claude-code':
248
+ return applyClaudeCode(name, workspace, printOnly);
249
+ case 'codex':
250
+ return applyCodex(name, workspace, printOnly);
251
+ case 'cursor':
252
+ return applyCursor(name, workspace, printOnly);
253
+ default:
254
+ throw new Error(`Unknown host: ${host}. Use claude-code | codex | cursor.`);
255
+ }
256
+ }
257
+
258
+ export function formatInstallResult(result: InstallResult): string {
259
+ const parts: string[] = [];
260
+ parts.push(`Host: ${result.host}`);
261
+ parts.push(`MCP name: ${result.name}`);
262
+ parts.push(`Workspace: ${result.workspace}`);
263
+ parts.push(`Applied: ${result.applied ? 'yes' : 'no'} (${result.method})`);
264
+ parts.push('');
265
+ parts.push(result.message);
266
+ if (result.manualSnippet) {
267
+ parts.push('');
268
+ parts.push('--- Snippet ---');
269
+ parts.push(result.manualSnippet);
270
+ parts.push('---------------');
271
+ }
272
+ parts.push('');
273
+ parts.push(`Verify: ${result.verificationHint}`);
274
+ return parts.join('\n');
275
+ }
@@ -0,0 +1,57 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import {
4
+ archiveStoryBible,
5
+ loadState,
6
+ saveMarkdownFile,
7
+ saveState,
8
+ } from './projectStore.js';
9
+ import { storyBibleVersionFileName } from './fileNames.js';
10
+ import { indexStoryBible } from './retrieval/index.js';
11
+
12
+ export interface AmendStoryBibleInput {
13
+ projectPath: string;
14
+ content: string;
15
+ reason?: string;
16
+ }
17
+
18
+ export interface AmendStoryBibleResult {
19
+ archivedPath?: string;
20
+ bibleVersion: number;
21
+ savedPath: string;
22
+ }
23
+
24
+ function isEmpty(content: string): boolean {
25
+ return !content || !content.trim();
26
+ }
27
+
28
+ export async function amendStoryBible(input: AmendStoryBibleInput): Promise<AmendStoryBibleResult> {
29
+ if (isEmpty(input.content)) throw new Error('Amended story bible content is empty');
30
+ const state = await loadState(input.projectPath);
31
+ // Archive current
32
+ const archived = await archiveStoryBible(
33
+ state.projectPath,
34
+ join('story-bible-versions', storyBibleVersionFileName(new Date().toISOString()))
35
+ );
36
+ // Save new
37
+ const savedPath = await saveMarkdownFile(state.projectPath, 'story-bible.md', input.content);
38
+ // Re-index
39
+ await indexStoryBible(state.projectPath, input.content);
40
+ // Track in state
41
+ const bibleVersion = (state.completedSteps.filter((s) => s === 'story_bible_amend').length ?? 0) + 1;
42
+ await saveState({
43
+ ...state,
44
+ completedSteps: [...state.completedSteps, 'story_bible_amend' as const],
45
+ files: { ...state.files, storyBible: 'story-bible.md' },
46
+ });
47
+ return { archivedPath: archived, bibleVersion, savedPath };
48
+ }
49
+
50
+ export async function listStoryBibleVersions(projectPath: string): Promise<string[]> {
51
+ try {
52
+ const items = await readdir(join(projectPath, 'story-bible-versions'));
53
+ return items.filter((f) => f.endsWith('.md')).sort();
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
@@ -0,0 +1,93 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CharacterState, CharacterStateUpdate, CoreCastMember } from './types.js';
4
+
5
+ const CHARACTERS_FILE = 'characters.json';
6
+
7
+ export interface CharactersBundle {
8
+ characters: CharacterState[];
9
+ }
10
+
11
+ function emptyState(member: CoreCastMember, chapterNumber = 0): CharacterState {
12
+ return {
13
+ name: member.name,
14
+ role: member.role,
15
+ goal: '未确认',
16
+ belief: '未确认',
17
+ relationships: [],
18
+ abilities: [],
19
+ secrets: [],
20
+ emotionalState: member.description,
21
+ lastUpdatedAt: chapterNumber,
22
+ };
23
+ }
24
+
25
+ export async function loadCharacterStates(projectPath: string): Promise<CharacterState[]> {
26
+ try {
27
+ const raw = await readFile(join(projectPath, CHARACTERS_FILE), 'utf8');
28
+ const parsed = JSON.parse(raw) as CharactersBundle;
29
+ return Array.isArray(parsed.characters) ? parsed.characters : [];
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ export async function saveCharacterStates(projectPath: string, characters: CharacterState[]): Promise<string> {
36
+ const fullPath = join(projectPath, CHARACTERS_FILE);
37
+ await writeFile(fullPath, `${JSON.stringify({ characters }, null, 2)}\n`, 'utf8');
38
+ return fullPath;
39
+ }
40
+
41
+ export async function initializeCharacterStates(
42
+ projectPath: string,
43
+ coreCast: CoreCastMember[]
44
+ ): Promise<string> {
45
+ const existing = await loadCharacterStates(projectPath);
46
+ const byName = new Map(existing.map((c) => [c.name, c]));
47
+ for (const member of coreCast) {
48
+ if (!byName.has(member.name)) {
49
+ byName.set(member.name, emptyState(member));
50
+ }
51
+ }
52
+ return saveCharacterStates(projectPath, Array.from(byName.values()));
53
+ }
54
+
55
+ export async function applyCharacterUpdates(
56
+ projectPath: string,
57
+ chapterNumber: number,
58
+ updates: CharacterStateUpdate[] | undefined
59
+ ): Promise<CharacterState[]> {
60
+ const existing = await loadCharacterStates(projectPath);
61
+ if (!updates || !updates.length) return existing;
62
+
63
+ const byName = new Map(existing.map((c) => [c.name, { ...c }]));
64
+ for (const update of updates) {
65
+ const current = byName.get(update.name) ?? {
66
+ name: update.name,
67
+ role: update.role,
68
+ goal: '未确认',
69
+ belief: '未确认',
70
+ relationships: [],
71
+ abilities: [],
72
+ secrets: [],
73
+ emotionalState: '未确认',
74
+ lastUpdatedAt: chapterNumber,
75
+ };
76
+
77
+ byName.set(update.name, {
78
+ ...current,
79
+ role: update.role ?? current.role,
80
+ goal: update.goal ?? current.goal,
81
+ belief: update.belief ?? current.belief,
82
+ relationships: update.relationships ?? current.relationships,
83
+ abilities: update.abilities ?? current.abilities,
84
+ secrets: update.secrets ?? current.secrets,
85
+ emotionalState: update.emotionalState ?? current.emotionalState,
86
+ lastUpdatedAt: chapterNumber,
87
+ });
88
+ }
89
+
90
+ const next = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
91
+ await saveCharacterStates(projectPath, next);
92
+ return next;
93
+ }
@@ -2,6 +2,7 @@ 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'
@@ -32,18 +33,33 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
32
33
  const metadata = await readOptional(join(input.projectPath, 'novel.json'));
33
34
  const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
34
35
  const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
36
+ const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
37
+ const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
35
38
 
36
39
  if (metadata) parts.push(`## Novel Metadata\n${metadata}`);
37
40
  if (storyBible) parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
41
+ if (charactersJson) parts.push(`## Character State Table\n${charactersJson}`);
42
+
43
+ function addVolumePacing(volumeId?: string): void {
44
+ if (!volumePacingJson) return;
45
+ try {
46
+ const boards = JSON.parse(volumePacingJson) as Array<{ volumeId: string }>;
47
+ const board = volumeId ? boards.find((item) => item.volumeId === volumeId) : undefined;
48
+ parts.push(`## Volume Pacing Board\n${JSON.stringify(board ?? boards, null, 2)}`);
49
+ } catch {
50
+ parts.push(`## Volume Pacing Board\n${volumePacingJson}`);
51
+ }
52
+ }
38
53
 
39
54
  if (input.purpose === 'chapter_generation' && input.chapterNumber) {
40
- let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string } | undefined;
55
+ let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string; volumeId?: string } | undefined;
41
56
  if (chaptersJson) {
42
- const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
57
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
43
58
  const chapter = chapters.find((item) => item.chapterNumber === input.chapterNumber);
44
59
  if (chapter) {
45
60
  currentArchitectureForQuery = chapter;
46
61
  parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
62
+ addVolumePacing(chapter.volumeId);
47
63
  }
48
64
  }
49
65
  if (input.chapterNumber > 1) {
@@ -67,6 +83,18 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
67
83
  const formatted = formatHits(hits);
68
84
  if (formatted) parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
69
85
  }
86
+
87
+ const allThreads = await loadThreads(input.projectPath);
88
+ const active = activeThreads(allThreads);
89
+ if (active.length) {
90
+ const lines = active.map((t) => {
91
+ const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
92
+ if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
93
+ if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
94
+ return `- ${t.description} (${flags.join(', ')})`;
95
+ });
96
+ parts.push(`## Active Foreshadow Threads (do not silently drop or contradict)\n${lines.join('\n')}`);
97
+ }
70
98
  }
71
99
  }
72
100
 
@@ -87,9 +115,12 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
87
115
 
88
116
  if (input.purpose === 'chapter_review' && input.chapterNumber) {
89
117
  if (chaptersJson) {
90
- const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
118
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
91
119
  const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
92
- if (arch) parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
120
+ if (arch) {
121
+ parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
122
+ addVolumePacing(arch.volumeId);
123
+ }
93
124
  }
94
125
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
95
126
  if (chapter) parts.push(`## Chapter ${input.chapterNumber} Text\n${chapter}`);
@@ -102,6 +133,15 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
102
133
  if (input.purpose === 'revision' && input.chapterNumber) {
103
134
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
104
135
  if (chapter) parts.push(`## Current Chapter Text\n${chapter}`);
136
+ if (chaptersJson) {
137
+ try {
138
+ const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; volumeId?: string }>;
139
+ const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
140
+ addVolumePacing(arch?.volumeId);
141
+ } catch {
142
+ // ignore malformed architecture here; review feedback is still useful
143
+ }
144
+ }
105
145
  const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
106
146
  if (review) parts.push(`## Editor Review\n${review}`);
107
147
  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';