novelforge-agent 0.1.1 → 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 (46) hide show
  1. package/README.md +36 -13
  2. package/dist/src/cli/index.js +71 -2
  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 +44 -1
  6. package/dist/src/core/fileNames.js +4 -0
  7. package/dist/src/core/index.js +4 -0
  8. package/dist/src/core/projectOps.js +187 -0
  9. package/dist/src/core/projectStore.js +11 -0
  10. package/dist/src/core/prompts/en-US.js +117 -13
  11. package/dist/src/core/prompts/zh-CN.js +116 -12
  12. package/dist/src/core/retrieval/index.js +8 -0
  13. package/dist/src/core/schemas.js +98 -1
  14. package/dist/src/core/steps/architecture.js +7 -1
  15. package/dist/src/core/steps/chapter.js +11 -1
  16. package/dist/src/core/steps/chapterReview.js +25 -1
  17. package/dist/src/core/steps/chapterRevision.js +17 -0
  18. package/dist/src/core/steps/memoryCard.js +4 -0
  19. package/dist/src/core/steps/novelMetadata.js +4 -2
  20. package/dist/src/core/threadStore.js +150 -0
  21. package/dist/src/core/workflow.js +3 -3
  22. package/dist/src/mcp/tools.js +198 -18
  23. package/package.json +5 -1
  24. package/src/cli/index.ts +74 -1
  25. package/src/core/bibleStore.ts +57 -0
  26. package/src/core/characterStore.ts +93 -0
  27. package/src/core/contextBuilder.ts +44 -4
  28. package/src/core/fileNames.ts +5 -0
  29. package/src/core/index.ts +4 -0
  30. package/src/core/projectOps.ts +243 -0
  31. package/src/core/projectStore.ts +11 -0
  32. package/src/core/prompts/en-US.ts +126 -22
  33. package/src/core/prompts/types.ts +2 -1
  34. package/src/core/prompts/zh-CN.ts +118 -14
  35. package/src/core/retrieval/index.ts +10 -0
  36. package/src/core/schemas.ts +108 -1
  37. package/src/core/steps/architecture.ts +7 -1
  38. package/src/core/steps/chapter.ts +11 -1
  39. package/src/core/steps/chapterReview.ts +27 -1
  40. package/src/core/steps/chapterRevision.ts +18 -0
  41. package/src/core/steps/memoryCard.ts +4 -0
  42. package/src/core/steps/novelMetadata.ts +4 -2
  43. package/src/core/threadStore.ts +173 -0
  44. package/src/core/types.ts +102 -1
  45. package/src/core/workflow.ts +3 -3
  46. package/src/mcp/tools.ts +322 -19
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, Cursor, …) or any CLI shell.
4
4
 
5
- **The host's LLM writes the prose. This package does everything else** — it manages a 9-step state machine, returns the exact instruction and packed context the host should follow next, validates returned content against zod schemas, persists Markdown + JSON to a project directory, archives chapter versions on revision, and provides BM25 lexical retrieval over every word the project has ever produced.
5
+ **The host's LLM writes the prose. This package does everything else** — it manages a gated long-form state machine, returns the exact instruction and packed context the host should follow next, validates returned content against zod schemas, persists Markdown + JSON to a project directory, archives chapter versions on revision, and provides BM25 lexical retrieval over every word the project has ever produced.
6
6
 
7
7
  No external API. No LLM dependency. No vendor lock-in.
8
8
 
@@ -57,15 +57,17 @@ The installer is **idempotent and safe**: it never overwrites an existing entry
57
57
  |-------|------|---------------------|----------------------|
58
58
  | Setup | `novel_metadata` | Output JSON: title, genre, premise, cast | `novel.json` |
59
59
  | | `story_bible` | Output Markdown: characters, world rules, plot threads | `story-bible.md` |
60
- | | `architecture` | Output JSON: full / volume / chapter outlines | `architecture/{full.md, volumes.json, chapters.json}` |
60
+ | | `architecture` | Output JSON: full / volume / pacing / chapter outlines | `architecture/{full.md, volumes.json, volume-pacing.json, chapters.json}` |
61
61
  | Loop | `chapter` | Write chapter N Markdown | `chapters/NNN.md` |
62
- | | `memory_card` | Extract chapter N memory JSON | `memory/chapter-NNN.json` |
62
+ | | `chapter_review` | Enforce the chapter acceptance gate: required beats, plot/character/thread progress, story-bible consistency, ending hook, repetition check | `reviews/chapter/chapter-NNN.json` |
63
+ | | `chapter_revision` | If review finds issues, rewrite the chapter; previous version is archived | `chapters/.versions/NNN.<ts>.md` |
64
+ | | `memory_card` | After a clean review, extract chapter N memory JSON and update character/thread state | `memory/chapter-NNN.json`, `characters.json`, `threads.json` |
63
65
  | Wrap | `continuity_review` | Audit chapters 1..N for conflicts | `reviews/continuity-S-E.json` |
64
66
  | Side-track | `chapter_review` | Single-chapter editorial review | `reviews/chapter/chapter-NNN.json` |
65
67
  | | `chapter_revision` | Rewrite a chapter; previous version auto-archived | `chapters/.versions/NNN.<ts>.md` |
66
68
  | | `cross_chapter_review` | Cross-chapter continuity audit | `reviews/cross/cross-S-E.json` |
67
69
 
68
- Each chapter / bible / memory write also feeds a per-project BM25 index (`.index/`) so the agent can hand the host semantically relevant snippets when later chapters are generated, or answer ad-hoc `retrieve` queries from the host.
70
+ Each chapter / bible / memory write also feeds a per-project BM25 index (`.index/`) so the agent can hand the host semantically relevant snippets when later chapters are generated, or answer ad-hoc `retrieve` queries from the host. Chapter generation context also includes the independent character state table (`characters.json`) and current volume pacing board (`architecture/volume-pacing.json`) when available.
69
71
 
70
72
  ## Install
71
73
 
@@ -165,7 +167,7 @@ NOVELFORGE_WORKSPACE = "/absolute/path/where/projects/should/live"
165
167
 
166
168
  `list_projects` finds every project under `NOVELFORGE_WORKSPACE/novels/`, sorted newest first. The host should call it before anything else when a session opens; pick the desired `projectPath` from the result, then `get_project_status` for a one-screen briefing, then `get_next_step` to resume.
167
169
 
168
- ## Tool reference (13 MCP tools)
170
+ ## Tool reference
169
171
 
170
172
  ### Project lifecycle
171
173
  - **`start_novel_project`** `(prompt, language?, outputDir?, targetChapters?)` — create a new project under `<workspaceRoot>/<outputDir>/<slug>-<rand6>/` and return the first step's instruction.
@@ -185,6 +187,15 @@ NOVELFORGE_WORKSPACE = "/absolute/path/where/projects/should/live"
185
187
  - **`cross_chapter_review`** `(projectPath, start?, end?)` — switch into a cross-chapter audit side-track over the given range (defaults to all generated chapters).
186
188
  - **`save_chapter`** `(projectPath, chapterNumber, title, content)` — write a chapter Markdown file directly, without going through the state machine.
187
189
 
190
+ ### Project operations
191
+ - **`amend_story_bible`** `(projectPath, content, reason?)` — replace `story-bible.md`, archive the previous version, and rebuild the bible index.
192
+ - **`list_bible_versions`** `(projectPath)` — list archived story-bible versions.
193
+ - **`list_threads`** `(projectPath, status?)` — list foreshadow threads collected from memory cards.
194
+ - **`update_thread`** `(projectPath, id, patch)` — update one foreshadow thread.
195
+ - **`fork_project`** `(sourceProjectPath, label?)` — copy a project to a sibling fork with a new project id.
196
+ - **`delete_chapter`** `(projectPath, chapterNumber)` — remove a chapter, its memory, reviews, archived versions, and index entries.
197
+ - **`redo_step`** `(projectPath, step, chapterNumber?)` — roll the workflow back to regenerate an artifact.
198
+
188
199
  ### Retrieval
189
200
  - **`retrieve`** `(projectPath, query, topK?, types?, chapterStart?, chapterEnd?)` — BM25-style lexical retrieval over indexed paragraphs (chapters), bible H2 sections, and memory cards. Supports CJK + Latin queries via a built-in CJK bigram tokenizer; no external embedding model.
190
201
 
@@ -196,10 +207,12 @@ A project on disk:
196
207
  novels/<slug>-<rand6>/
197
208
  ├── agent-state.json # workflow state (currentStep, currentChapter, files map, …)
198
209
  ├── novel.json # metadata (NovelMetadataSchema)
210
+ ├── characters.json # independent character state table
199
211
  ├── story-bible.md
200
212
  ├── architecture/
201
213
  │ ├── full.md
202
214
  │ ├── volumes.json
215
+ │ ├── volume-pacing.json
203
216
  │ └── chapters.json
204
217
  ├── chapters/
205
218
  │ ├── 001.md
@@ -226,17 +239,26 @@ The whole directory is self-contained — copy it, share it, delete it.
226
239
  ```
227
240
  novel_metadata → story_bible → architecture → chapter
228
241
 
229
- memory_card
242
+ chapter_review
230
243
 
231
- ┌───────────────┴───────────────┐
232
- (more chapters) (all done)
233
-
234
- chapter continuity_review
235
-
236
- complete
244
+ ┌──────────┴──────────┐
245
+ clean issues_found
246
+
247
+ memory_card chapter_revision
248
+
249
+ ┌─────────────┴─────────────┐ │
250
+ (more chapters) (all done) │
251
+ ↓ ↓ │
252
+ chapter continuity_review│
253
+ ↓ │
254
+ complete │
255
+ (back to
256
+ chapter_review)
237
257
  ```
238
258
 
239
- Side-track steps (`chapter_review`, `chapter_revision`, `cross_chapter_review`) can be triggered at any moment via the semantic-action tools. When the side-track completes via `submit_step_result`, the workflow returns to whatever `currentStep` it was on before the side-track started.
259
+ `chapter_review` is both a manual side-track and the automatic chapter acceptance gate. In the normal chapter loop, the workflow cannot advance to `memory_card` until the review status is `clean`. If the review returns `issues_found`, the workflow forces `chapter_revision`, then returns to `chapter_review` for another pass.
260
+
261
+ Side-track steps (`chapter_review`, `chapter_revision`, `cross_chapter_review`) can still be triggered at any moment via the semantic-action tools. When a manual side-track completes via `submit_step_result`, the workflow returns to whatever `currentStep` it was on before the side-track started.
240
262
 
241
263
  The full transition map lives in the `next:` declaration of each handler under [src/core/steps/](src/core/steps/). To change the workflow, edit those files — that is the entire state machine, there is no graph engine.
242
264
 
@@ -249,6 +271,7 @@ src/
249
271
  │ ├── schemas.ts # zod schemas (the only validator)
250
272
  │ ├── projectStore.ts # filesystem persistence
251
273
  │ ├── projectDiscovery.ts # list / status
274
+ │ ├── characterStore.ts # independent character state table
252
275
  │ ├── prompts/ # per-language prompt packs (zh-CN, en-US)
253
276
  │ ├── steps/ # one file per WorkflowStep handler
254
277
  │ ├── retrieval/ # BM25 index + CJK tokenizer + chunker
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
- import { buildContext, createProject, getNextStep, getProjectStatus, listProjects, requestSideTrack, retrieve, submitStepResult, } from '../core/index.js';
3
+ import { amendStoryBible, buildContext, createProject, deleteChapter, forkProject, getNextStep, getProjectStatus, listProjects, loadThreads, redoStep, requestSideTrack, retrieve, submitStepResult, updateThread, } from '../core/index.js';
4
4
  import { formatInstallResult, runInstall } from './install.js';
5
5
  function valueAfter(args, name) {
6
6
  const index = args.indexOf(name);
@@ -136,7 +136,76 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd())
136
136
  console.log(JSON.stringify({ query, hits }, null, 2));
137
137
  return;
138
138
  }
139
- throw new Error('Usage: novelforge-agent install|start|list|status|next|submit|context|review|revise|cross-review|retrieve');
139
+ if (command === 'amend-bible') {
140
+ if (!projectPath)
141
+ throw new Error('Missing projectPath');
142
+ const file = valueAfter(argv, '--file');
143
+ const reason = valueAfter(argv, '--reason');
144
+ if (!file)
145
+ throw new Error('Missing --file with new bible Markdown');
146
+ const content = await readFile(file, 'utf8');
147
+ console.log(JSON.stringify(await amendStoryBible({ projectPath, content, reason }), null, 2));
148
+ return;
149
+ }
150
+ if (command === 'threads') {
151
+ if (!projectPath)
152
+ throw new Error('Missing projectPath');
153
+ const status = valueAfter(argv, '--status');
154
+ const all = await loadThreads(projectPath);
155
+ const filtered = status ? all.filter((t) => t.status === status) : all;
156
+ console.log(JSON.stringify(filtered, null, 2));
157
+ return;
158
+ }
159
+ if (command === 'update-thread') {
160
+ if (!projectPath)
161
+ throw new Error('Missing projectPath');
162
+ const id = valueAfter(argv, '--id');
163
+ if (!id)
164
+ throw new Error('Missing --id');
165
+ const status = valueAfter(argv, '--status');
166
+ const plannedPayoffAt = valueAfter(argv, '--planned-payoff');
167
+ const description = valueAfter(argv, '--description');
168
+ const notes = valueAfter(argv, '--notes');
169
+ const updated = await updateThread(projectPath, id, {
170
+ status,
171
+ plannedPayoffAt: plannedPayoffAt ? Number(plannedPayoffAt) : undefined,
172
+ description,
173
+ notes,
174
+ });
175
+ console.log(JSON.stringify(updated, null, 2));
176
+ return;
177
+ }
178
+ if (command === 'fork') {
179
+ if (!projectPath)
180
+ throw new Error('Missing projectPath');
181
+ const label = valueAfter(argv, '--label');
182
+ console.log(JSON.stringify(await forkProject({ sourceProjectPath: projectPath, label }), null, 2));
183
+ return;
184
+ }
185
+ if (command === 'delete-chapter') {
186
+ if (!projectPath)
187
+ throw new Error('Missing projectPath');
188
+ const chapter = valueAfter(argv, '--chapter');
189
+ if (!chapter)
190
+ throw new Error('Missing --chapter');
191
+ console.log(JSON.stringify(await deleteChapter({ projectPath, chapterNumber: Number(chapter) }), null, 2));
192
+ return;
193
+ }
194
+ if (command === 'redo') {
195
+ if (!projectPath)
196
+ throw new Error('Missing projectPath');
197
+ const step = valueAfter(argv, '--step');
198
+ if (!step)
199
+ throw new Error('Missing --step');
200
+ const chapter = valueAfter(argv, '--chapter');
201
+ console.log(JSON.stringify(await redoStep({
202
+ projectPath,
203
+ step,
204
+ chapterNumber: chapter ? Number(chapter) : undefined,
205
+ }), null, 2));
206
+ return;
207
+ }
208
+ throw new Error('Usage: novelforge-agent install|start|list|status|next|submit|context|review|revise|cross-review|retrieve|amend-bible|threads|update-thread|fork|delete-chapter|redo');
140
209
  }
141
210
  if (import.meta.url === `file://${process.argv[1]}`) {
142
211
  runCli().catch((error) => {
@@ -0,0 +1,36 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { archiveStoryBible, loadState, saveMarkdownFile, saveState, } from './projectStore.js';
4
+ import { storyBibleVersionFileName } from './fileNames.js';
5
+ import { indexStoryBible } from './retrieval/index.js';
6
+ function isEmpty(content) {
7
+ return !content || !content.trim();
8
+ }
9
+ export async function amendStoryBible(input) {
10
+ if (isEmpty(input.content))
11
+ throw new Error('Amended story bible content is empty');
12
+ const state = await loadState(input.projectPath);
13
+ // Archive current
14
+ const archived = await archiveStoryBible(state.projectPath, join('story-bible-versions', storyBibleVersionFileName(new Date().toISOString())));
15
+ // Save new
16
+ const savedPath = await saveMarkdownFile(state.projectPath, 'story-bible.md', input.content);
17
+ // Re-index
18
+ await indexStoryBible(state.projectPath, input.content);
19
+ // Track in state
20
+ const bibleVersion = (state.completedSteps.filter((s) => s === 'story_bible_amend').length ?? 0) + 1;
21
+ await saveState({
22
+ ...state,
23
+ completedSteps: [...state.completedSteps, 'story_bible_amend'],
24
+ files: { ...state.files, storyBible: 'story-bible.md' },
25
+ });
26
+ return { archivedPath: archived, bibleVersion, savedPath };
27
+ }
28
+ export async function listStoryBibleVersions(projectPath) {
29
+ try {
30
+ const items = await readdir(join(projectPath, 'story-bible-versions'));
31
+ return items.filter((f) => f.endsWith('.md')).sort();
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const CHARACTERS_FILE = 'characters.json';
4
+ function emptyState(member, chapterNumber = 0) {
5
+ return {
6
+ name: member.name,
7
+ role: member.role,
8
+ goal: '未确认',
9
+ belief: '未确认',
10
+ relationships: [],
11
+ abilities: [],
12
+ secrets: [],
13
+ emotionalState: member.description,
14
+ lastUpdatedAt: chapterNumber,
15
+ };
16
+ }
17
+ export async function loadCharacterStates(projectPath) {
18
+ try {
19
+ const raw = await readFile(join(projectPath, CHARACTERS_FILE), 'utf8');
20
+ const parsed = JSON.parse(raw);
21
+ return Array.isArray(parsed.characters) ? parsed.characters : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ export async function saveCharacterStates(projectPath, characters) {
28
+ const fullPath = join(projectPath, CHARACTERS_FILE);
29
+ await writeFile(fullPath, `${JSON.stringify({ characters }, null, 2)}\n`, 'utf8');
30
+ return fullPath;
31
+ }
32
+ export async function initializeCharacterStates(projectPath, coreCast) {
33
+ const existing = await loadCharacterStates(projectPath);
34
+ const byName = new Map(existing.map((c) => [c.name, c]));
35
+ for (const member of coreCast) {
36
+ if (!byName.has(member.name)) {
37
+ byName.set(member.name, emptyState(member));
38
+ }
39
+ }
40
+ return saveCharacterStates(projectPath, Array.from(byName.values()));
41
+ }
42
+ export async function applyCharacterUpdates(projectPath, chapterNumber, updates) {
43
+ const existing = await loadCharacterStates(projectPath);
44
+ if (!updates || !updates.length)
45
+ return existing;
46
+ const byName = new Map(existing.map((c) => [c.name, { ...c }]));
47
+ for (const update of updates) {
48
+ const current = byName.get(update.name) ?? {
49
+ name: update.name,
50
+ role: update.role,
51
+ goal: '未确认',
52
+ belief: '未确认',
53
+ relationships: [],
54
+ abilities: [],
55
+ secrets: [],
56
+ emotionalState: '未确认',
57
+ lastUpdatedAt: chapterNumber,
58
+ };
59
+ byName.set(update.name, {
60
+ ...current,
61
+ role: update.role ?? current.role,
62
+ goal: update.goal ?? current.goal,
63
+ belief: update.belief ?? current.belief,
64
+ relationships: update.relationships ?? current.relationships,
65
+ abilities: update.abilities ?? current.abilities,
66
+ secrets: update.secrets ?? current.secrets,
67
+ emotionalState: update.emotionalState ?? current.emotionalState,
68
+ lastUpdatedAt: chapterNumber,
69
+ });
70
+ }
71
+ const next = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
72
+ await saveCharacterStates(projectPath, next);
73
+ return next;
74
+ }
@@ -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
  async function readOptional(path) {
6
7
  try {
7
8
  return await readFile(path, 'utf8');
@@ -15,10 +16,26 @@ export async function buildContext(input) {
15
16
  const metadata = await readOptional(join(input.projectPath, 'novel.json'));
16
17
  const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
17
18
  const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
19
+ const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
20
+ const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
18
21
  if (metadata)
19
22
  parts.push(`## Novel Metadata\n${metadata}`);
20
23
  if (storyBible)
21
24
  parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
25
+ if (charactersJson)
26
+ parts.push(`## Character State Table\n${charactersJson}`);
27
+ function addVolumePacing(volumeId) {
28
+ if (!volumePacingJson)
29
+ return;
30
+ try {
31
+ const boards = JSON.parse(volumePacingJson);
32
+ const board = volumeId ? boards.find((item) => item.volumeId === volumeId) : undefined;
33
+ parts.push(`## Volume Pacing Board\n${JSON.stringify(board ?? boards, null, 2)}`);
34
+ }
35
+ catch {
36
+ parts.push(`## Volume Pacing Board\n${volumePacingJson}`);
37
+ }
38
+ }
22
39
  if (input.purpose === 'chapter_generation' && input.chapterNumber) {
23
40
  let currentArchitectureForQuery;
24
41
  if (chaptersJson) {
@@ -27,6 +44,7 @@ export async function buildContext(input) {
27
44
  if (chapter) {
28
45
  currentArchitectureForQuery = chapter;
29
46
  parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
47
+ addVolumePacing(chapter.volumeId);
30
48
  }
31
49
  }
32
50
  if (input.chapterNumber > 1) {
@@ -54,6 +72,19 @@ export async function buildContext(input) {
54
72
  if (formatted)
55
73
  parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
56
74
  }
75
+ const allThreads = await loadThreads(input.projectPath);
76
+ const active = activeThreads(allThreads);
77
+ if (active.length) {
78
+ const lines = active.map((t) => {
79
+ const flags = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
80
+ if (t.plannedPayoffAt)
81
+ flags.push(`payoff=ch${t.plannedPayoffAt}`);
82
+ if (t.lastTouchedAt !== t.plantedAt)
83
+ flags.push(`touched=ch${t.lastTouchedAt}`);
84
+ return `- ${t.description} (${flags.join(', ')})`;
85
+ });
86
+ parts.push(`## Active Foreshadow Threads (do not silently drop or contradict)\n${lines.join('\n')}`);
87
+ }
57
88
  }
58
89
  }
59
90
  if (input.purpose === 'memory_extraction' && input.chapterNumber) {
@@ -77,8 +108,10 @@ export async function buildContext(input) {
77
108
  if (chaptersJson) {
78
109
  const chapters = JSON.parse(chaptersJson);
79
110
  const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
80
- if (arch)
111
+ if (arch) {
81
112
  parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
113
+ addVolumePacing(arch.volumeId);
114
+ }
82
115
  }
83
116
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
84
117
  if (chapter)
@@ -93,6 +126,16 @@ export async function buildContext(input) {
93
126
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
94
127
  if (chapter)
95
128
  parts.push(`## Current Chapter Text\n${chapter}`);
129
+ if (chaptersJson) {
130
+ try {
131
+ const chapters = JSON.parse(chaptersJson);
132
+ const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
133
+ addVolumePacing(arch?.volumeId);
134
+ }
135
+ catch {
136
+ // ignore malformed architecture here; review feedback is still useful
137
+ }
138
+ }
96
139
  const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
97
140
  if (review)
98
141
  parts.push(`## Editor Review\n${review}`);
@@ -39,3 +39,7 @@ export function chapterVersionFileName(chapterNumber, timestamp) {
39
39
  const safeTs = timestamp.replace(/[:.]/g, '-');
40
40
  return `${padChapterNumber(chapterNumber)}.${safeTs}.md`;
41
41
  }
42
+ export function storyBibleVersionFileName(timestamp) {
43
+ const safeTs = timestamp.replace(/[:.]/g, '-');
44
+ return `story-bible.${safeTs}.md`;
45
+ }
@@ -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';
@@ -0,0 +1,187 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { cp, readdir, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { loadState, saveState } from './projectStore.js';
5
+ import { removeChapterFromIndex, removeMemoryCardFromIndex } from './retrieval/index.js';
6
+ import { chapterFileName, memoryFileName } from './fileNames.js';
7
+ export async function forkProject(input) {
8
+ const source = resolve(input.sourceProjectPath);
9
+ const state = await loadState(source);
10
+ const suffix = randomBytes(3).toString('hex');
11
+ const label = (input.label ?? 'fork').replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'fork';
12
+ const targetName = `${basename(source)}-${label}-${suffix}`;
13
+ const target = join(dirname(source), targetName);
14
+ await cp(source, target, { recursive: true });
15
+ const forkedState = {
16
+ ...state,
17
+ projectId: randomUUID(),
18
+ projectPath: target,
19
+ createdAt: new Date().toISOString(),
20
+ updatedAt: new Date().toISOString(),
21
+ };
22
+ await saveState(forkedState);
23
+ return { newProjectPath: target, newProjectId: forkedState.projectId };
24
+ }
25
+ async function tryUnlink(path) {
26
+ try {
27
+ await unlink(path);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function tryRmDirEntry(dirPath, prefix) {
35
+ const removed = [];
36
+ try {
37
+ const items = await readdir(dirPath);
38
+ for (const item of items) {
39
+ if (item.startsWith(prefix)) {
40
+ const full = join(dirPath, item);
41
+ try {
42
+ await unlink(full);
43
+ removed.push(full);
44
+ }
45
+ catch {
46
+ // ignore
47
+ }
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ // dir absent
53
+ }
54
+ return removed;
55
+ }
56
+ export async function deleteChapter(input) {
57
+ const state = await loadState(input.projectPath);
58
+ const n = input.chapterNumber;
59
+ if (n < 1)
60
+ throw new Error('chapterNumber must be >= 1');
61
+ const removed = [];
62
+ const chapterRel = join('chapters', chapterFileName(n));
63
+ if (await tryUnlink(join(state.projectPath, chapterRel)))
64
+ removed.push(chapterRel);
65
+ const memoryRel = join('memory', memoryFileName(n));
66
+ if (await tryUnlink(join(state.projectPath, memoryRel)))
67
+ removed.push(memoryRel);
68
+ // Versions of this chapter
69
+ const versionsRemoved = await tryRmDirEntry(join(state.projectPath, 'chapters/.versions'), `${chapterFileName(n).replace(/\.md$/, '')}.`);
70
+ removed.push(...versionsRemoved);
71
+ // Per-chapter review
72
+ const reviewName = `chapter-${String(n).padStart(3, '0')}.json`;
73
+ if (await tryUnlink(join(state.projectPath, 'reviews/chapter', reviewName))) {
74
+ removed.push(`reviews/chapter/${reviewName}`);
75
+ }
76
+ // Update state.files
77
+ const nextFiles = { ...state.files };
78
+ delete nextFiles[`chapter-${n}`];
79
+ delete nextFiles[`memory-${n}`];
80
+ delete nextFiles[`review-chapter-${n}`];
81
+ // Remove this chapter and its memory card from the lexical index
82
+ await removeChapterFromIndex(state.projectPath, n);
83
+ await removeMemoryCardFromIndex(state.projectPath, n);
84
+ // Adjust state.currentChapter & currentStep if needed
85
+ let newCurrentChapter = state.currentChapter;
86
+ let newCurrentStep = state.currentStep;
87
+ if (state.currentChapter > n) {
88
+ // user deleted an earlier chapter; current pointer becomes the deleted one to be regenerated
89
+ newCurrentChapter = n;
90
+ newCurrentStep = 'chapter';
91
+ }
92
+ else if (state.currentChapter === n + 1 && (state.currentStep === 'chapter' || state.currentStep === 'memory_card')) {
93
+ // we just finished chapter n and were about to do n+1; step back
94
+ newCurrentChapter = n;
95
+ newCurrentStep = 'chapter';
96
+ }
97
+ const nextState = {
98
+ ...state,
99
+ files: nextFiles,
100
+ currentChapter: newCurrentChapter,
101
+ currentStep: newCurrentStep,
102
+ pendingAction: undefined,
103
+ };
104
+ await saveState(nextState);
105
+ return { removed, newCurrentChapter, newCurrentStep };
106
+ }
107
+ const STEP_FILE_KEYS = {
108
+ novel_metadata: ['novel'],
109
+ story_bible: ['storyBible'],
110
+ architecture: ['architecture'],
111
+ continuity_review: ['continuityReview'],
112
+ };
113
+ const STEP_FILE_PATHS = {
114
+ novel_metadata: ['novel.json'],
115
+ story_bible: ['story-bible.md'],
116
+ architecture: ['architecture/full.md', 'architecture/volumes.json', 'architecture/chapters.json'],
117
+ };
118
+ export async function redoStep(input) {
119
+ const state = await loadState(input.projectPath);
120
+ const removed = [];
121
+ if (input.step === 'chapter' || input.step === 'memory_card') {
122
+ const chapter = input.chapterNumber ?? state.currentChapter;
123
+ if (input.step === 'memory_card') {
124
+ const rel = join('memory', memoryFileName(chapter));
125
+ if (await tryUnlink(join(state.projectPath, rel)))
126
+ removed.push(rel);
127
+ delete state.files[`memory-${chapter}`];
128
+ }
129
+ else {
130
+ // chapter: also remove its memory + per-chapter review since they depend on it
131
+ const cRel = join('chapters', chapterFileName(chapter));
132
+ if (await tryUnlink(join(state.projectPath, cRel)))
133
+ removed.push(cRel);
134
+ const mRel = join('memory', memoryFileName(chapter));
135
+ if (await tryUnlink(join(state.projectPath, mRel)))
136
+ removed.push(mRel);
137
+ delete state.files[`chapter-${chapter}`];
138
+ delete state.files[`memory-${chapter}`];
139
+ await removeChapterFromIndex(state.projectPath, chapter);
140
+ await removeMemoryCardFromIndex(state.projectPath, chapter);
141
+ }
142
+ state.currentChapter = chapter;
143
+ state.currentStep = input.step;
144
+ state.pendingAction = undefined;
145
+ }
146
+ else if (input.step === 'novel_metadata' || input.step === 'story_bible' || input.step === 'architecture' || input.step === 'continuity_review') {
147
+ const paths = STEP_FILE_PATHS[input.step] ?? [];
148
+ for (const p of paths) {
149
+ if (await tryUnlink(join(state.projectPath, p)))
150
+ removed.push(p);
151
+ }
152
+ const keys = STEP_FILE_KEYS[input.step] ?? [];
153
+ for (const k of keys) {
154
+ delete state.files[k];
155
+ }
156
+ state.currentStep = input.step;
157
+ state.pendingAction = undefined;
158
+ if (input.step === 'novel_metadata')
159
+ state.currentChapter = 1;
160
+ }
161
+ else {
162
+ throw new Error(`redo_step does not support step: ${input.step}`);
163
+ }
164
+ // Trim completedSteps after the redo target
165
+ const idx = state.completedSteps.lastIndexOf(input.step);
166
+ if (idx >= 0)
167
+ state.completedSteps = state.completedSteps.slice(0, idx);
168
+ await saveState(state);
169
+ return {
170
+ removed,
171
+ currentStep: state.currentStep,
172
+ currentChapter: state.currentChapter,
173
+ };
174
+ }
175
+ // =============================================================================
176
+ // guards
177
+ // =============================================================================
178
+ export function assertProjectPath(workspaceRoot, projectPath) {
179
+ const root = resolve(workspaceRoot);
180
+ const target = resolve(projectPath);
181
+ const rel = relative(root, target);
182
+ if (rel.startsWith('..') || isAbsolute(rel)) {
183
+ throw new Error(`Refusing to operate outside workspace: ${target}`);
184
+ }
185
+ }
186
+ // keep tsc happy if no other refs
187
+ void writeFile;
@@ -15,6 +15,7 @@ export async function ensureProjectDirectories(projectPath) {
15
15
  await mkdir(join(projectPath, 'architecture'), { recursive: true });
16
16
  await mkdir(join(projectPath, 'chapters'), { recursive: true });
17
17
  await mkdir(join(projectPath, 'chapters/.versions'), { recursive: true });
18
+ await mkdir(join(projectPath, 'story-bible-versions'), { recursive: true });
18
19
  await mkdir(join(projectPath, 'memory'), { recursive: true });
19
20
  await mkdir(join(projectPath, 'reviews'), { recursive: true });
20
21
  await mkdir(join(projectPath, 'reviews/chapter'), { recursive: true });
@@ -31,6 +32,16 @@ export async function archiveChapterVersion(projectPath, chapterRelative, versio
31
32
  return undefined;
32
33
  }
33
34
  }
35
+ export async function archiveStoryBible(projectPath, versionRelative) {
36
+ const sourcePath = join(projectPath, 'story-bible.md');
37
+ try {
38
+ const existing = await readFile(sourcePath, 'utf8');
39
+ return saveMarkdownFile(projectPath, versionRelative, existing);
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
34
45
  export async function createProject(input) {
35
46
  const workspaceRoot = resolve(input.workspaceRoot);
36
47
  const baseDir = input.outputDir || 'novels';