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
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,18 @@ 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
+ | | `style_guide` | Output JSON: narrative voice, pacing, diction, dialogue rules, prohibited patterns, prose rhythm, sample prose | `style-guide.json` |
61
+ | | `architecture` | Output JSON: full / volume / pacing / chapter outlines | `architecture/{full.md, volumes.json, volume-pacing.json, chapters.json}` |
61
62
  | Loop | `chapter` | Write chapter N Markdown | `chapters/NNN.md` |
62
- | | `memory_card` | Extract chapter N memory JSON | `memory/chapter-NNN.json` |
63
+ | | `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` |
64
+ | | `chapter_revision` | If review finds issues, rewrite the chapter; previous version is archived | `chapters/.versions/NNN.<ts>.md` |
65
+ | | `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
66
  | Wrap | `continuity_review` | Audit chapters 1..N for conflicts | `reviews/continuity-S-E.json` |
64
67
  | Side-track | `chapter_review` | Single-chapter editorial review | `reviews/chapter/chapter-NNN.json` |
65
68
  | | `chapter_revision` | Rewrite a chapter; previous version auto-archived | `chapters/.versions/NNN.<ts>.md` |
66
69
  | | `cross_chapter_review` | Cross-chapter continuity audit | `reviews/cross/cross-S-E.json` |
67
70
 
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.
71
+ 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 style guide (`style-guide.json`), independent character state table (`characters.json`), and current volume pacing board (`architecture/volume-pacing.json`) when available. The style guide includes `proseRhythm`, which checks rhythm anti-patterns such as excessive short-sentence density, consecutive one-sentence paragraphs, fake rhythm through line breaks, overly direct interior explanation, and repeated sentence patterns.
69
72
 
70
73
  ## Install
71
74
 
@@ -165,10 +168,10 @@ NOVELFORGE_WORKSPACE = "/absolute/path/where/projects/should/live"
165
168
 
166
169
  `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
170
 
168
- ## Tool reference (13 MCP tools)
171
+ ## Tool reference
169
172
 
170
173
  ### Project lifecycle
171
- - **`start_novel_project`** `(prompt, language?, outputDir?, targetChapters?)` — create a new project under `<workspaceRoot>/<outputDir>/<slug>-<rand6>/` and return the first step's instruction.
174
+ - **`start_novel_project`** `(prompt, language?, outputDir?, targetChapters?, plannedTotalChapters?)` — create a new project under `<workspaceRoot>/<outputDir>/<slug>-<rand6>/` and return the first step's instruction. `targetChapters` is the per-batch planning size; MCP defaults to 5. `plannedTotalChapters` is the whole-book target; MCP defaults to 12.
172
175
  - **`list_projects`** `(outputDir?)` — list all projects in the workspace, newest first.
173
176
  - **`get_project_status`** `(projectPath)` — compact summary: current step, chapters written, open threads, latest review verdict.
174
177
  - **`get_next_step`** `(projectPath)` — return the prompt + packed context for whatever the workflow expects next.
@@ -177,13 +180,24 @@ NOVELFORGE_WORKSPACE = "/absolute/path/where/projects/should/live"
177
180
  - **`submit_step_result`** `(projectPath, step, content)` — validate `content` against the step's zod schema, persist it, advance the state machine. On failure the bad submission is written to `.agent-recovery/failed-*.txt` and the state does not advance.
178
181
  - **`get_context`** `(projectPath, purpose, chapterNumber?, start?, end?)` — build purpose-specific context without changing state. Useful when the host wants to read what the agent *would* have packed.
179
182
 
183
+ Dynamic planning is built into the state machine: after each accepted chapter and memory card, the agent checks `plannedTotalChapters` and the highest chapter covered by `architecture/chapters.json`. If the next chapter is still inside the whole-book target but not yet planned, the next step becomes `architecture_extension`; after the host submits that JSON, generation resumes at `chapter`.
184
+
180
185
  ### Semantic actions (verb-style; safe to call any time)
181
186
  - **`generate_chapter`** `(projectPath, chapterNumber)` — return generation context for a specific chapter.
182
187
  - **`extract_memory_card`** `(projectPath, chapterNumber)` — return memory-extraction context for a specific chapter.
183
188
  - **`review_chapter`** `(projectPath, chapterNumber)` — switch into a single-chapter editorial review side-track and return its prompt. After `submit_step_result(step="chapter_review")`, the workflow resumes its prior step automatically.
184
189
  - **`revise_chapter`** `(projectPath, chapterNumber, feedback?)` — switch into a chapter-revision side-track. Submitting `chapter_revision` content auto-archives the previous version under `chapters/.versions/`.
185
190
  - **`cross_chapter_review`** `(projectPath, start?, end?)` — switch into a cross-chapter audit side-track over the given range (defaults to all generated chapters).
186
- - **`save_chapter`** `(projectPath, chapterNumber, title, content)` — write a chapter Markdown file directly, without going through the state machine.
191
+ - **`save_chapter`** `(projectPath, chapterNumber, title, content)` — submit the current chapter through the state machine; it requires `currentStep="chapter"` and then advances to mandatory `chapter_review`.
192
+
193
+ ### Project operations
194
+ - **`amend_story_bible`** `(projectPath, content, reason?)` — replace `story-bible.md`, archive the previous version, and rebuild the bible index.
195
+ - **`list_bible_versions`** `(projectPath)` — list archived story-bible versions.
196
+ - **`list_threads`** `(projectPath, status?)` — list foreshadow threads collected from memory cards.
197
+ - **`update_thread`** `(projectPath, id, patch)` — update one foreshadow thread.
198
+ - **`fork_project`** `(sourceProjectPath, label?)` — copy a project to a sibling fork with a new project id.
199
+ - **`delete_chapter`** `(projectPath, chapterNumber)` — remove a chapter, its memory, reviews, archived versions, and index entries.
200
+ - **`redo_step`** `(projectPath, step, chapterNumber?)` — roll the workflow back to regenerate an artifact.
187
201
 
188
202
  ### Retrieval
189
203
  - **`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.
@@ -196,10 +210,13 @@ A project on disk:
196
210
  novels/<slug>-<rand6>/
197
211
  ├── agent-state.json # workflow state (currentStep, currentChapter, files map, …)
198
212
  ├── novel.json # metadata (NovelMetadataSchema)
213
+ ├── characters.json # independent character state table
199
214
  ├── story-bible.md
215
+ ├── style-guide.json # enforceable prose style guide
200
216
  ├── architecture/
201
217
  │ ├── full.md
202
218
  │ ├── volumes.json
219
+ │ ├── volume-pacing.json
203
220
  │ └── chapters.json
204
221
  ├── chapters/
205
222
  │ ├── 001.md
@@ -224,19 +241,28 @@ The whole directory is self-contained — copy it, share it, delete it.
224
241
  ## How the workflow advances
225
242
 
226
243
  ```
227
- novel_metadata → story_bible → architecture → chapter
228
-
229
- memory_card
230
-
231
- ┌───────────────┴───────────────┐
232
- (more chapters) (all done)
233
-
234
- chapter continuity_review
235
-
236
- complete
244
+ novel_metadata → story_bible → style_guide → architecture → chapter
245
+
246
+ chapter_review
247
+
248
+ ┌──────────┴──────────┐
249
+ clean issues_found
250
+
251
+ memory_card chapter_revision
252
+
253
+ ┌─────────────┴─────────────┐ │
254
+ (more chapters) (all done) │
255
+ ↓ ↓ │
256
+ chapter continuity_review│
257
+ ↓ │
258
+ complete │
259
+ (back to
260
+ chapter_review)
237
261
  ```
238
262
 
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.
263
+ `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.
264
+
265
+ 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
266
 
241
267
  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
268
 
@@ -249,6 +275,7 @@ src/
249
275
  │ ├── schemas.ts # zod schemas (the only validator)
250
276
  │ ├── projectStore.ts # filesystem persistence
251
277
  │ ├── projectDiscovery.ts # list / status
278
+ │ ├── characterStore.ts # independent character state table
252
279
  │ ├── prompts/ # per-language prompt packs (zh-CN, en-US)
253
280
  │ ├── steps/ # one file per WorkflowStep handler
254
281
  │ ├── 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);
@@ -38,9 +38,17 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd())
38
38
  if (!prompt.trim())
39
39
  throw new Error('Missing --prompt');
40
40
  const language = parseLanguage(valueAfter(argv, '--language') || 'zh-CN');
41
- const chapters = Number(valueAfter(argv, '--chapters') || 3);
41
+ const chapters = Number(valueAfter(argv, '--chapters') || 5);
42
+ const totalChapters = Number(valueAfter(argv, '--total-chapters') || 12);
42
43
  const outputDir = valueAfter(argv, '--output') || 'novels';
43
- const result = await createProject({ workspaceRoot: cwd, prompt, language, outputDir, targetChapters: chapters });
44
+ const result = await createProject({
45
+ workspaceRoot: cwd,
46
+ prompt,
47
+ language,
48
+ outputDir,
49
+ targetChapters: chapters,
50
+ plannedTotalChapters: totalChapters,
51
+ });
44
52
  const next = await getNextStep(result.state.projectPath);
45
53
  console.log(JSON.stringify({ state: result.state, next }, null, 2));
46
54
  return;
@@ -136,7 +144,76 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd())
136
144
  console.log(JSON.stringify({ query, hits }, null, 2));
137
145
  return;
138
146
  }
139
- throw new Error('Usage: novelforge-agent install|start|list|status|next|submit|context|review|revise|cross-review|retrieve');
147
+ if (command === 'amend-bible') {
148
+ if (!projectPath)
149
+ throw new Error('Missing projectPath');
150
+ const file = valueAfter(argv, '--file');
151
+ const reason = valueAfter(argv, '--reason');
152
+ if (!file)
153
+ throw new Error('Missing --file with new bible Markdown');
154
+ const content = await readFile(file, 'utf8');
155
+ console.log(JSON.stringify(await amendStoryBible({ projectPath, content, reason }), null, 2));
156
+ return;
157
+ }
158
+ if (command === 'threads') {
159
+ if (!projectPath)
160
+ throw new Error('Missing projectPath');
161
+ const status = valueAfter(argv, '--status');
162
+ const all = await loadThreads(projectPath);
163
+ const filtered = status ? all.filter((t) => t.status === status) : all;
164
+ console.log(JSON.stringify(filtered, null, 2));
165
+ return;
166
+ }
167
+ if (command === 'update-thread') {
168
+ if (!projectPath)
169
+ throw new Error('Missing projectPath');
170
+ const id = valueAfter(argv, '--id');
171
+ if (!id)
172
+ throw new Error('Missing --id');
173
+ const status = valueAfter(argv, '--status');
174
+ const plannedPayoffAt = valueAfter(argv, '--planned-payoff');
175
+ const description = valueAfter(argv, '--description');
176
+ const notes = valueAfter(argv, '--notes');
177
+ const updated = await updateThread(projectPath, id, {
178
+ status,
179
+ plannedPayoffAt: plannedPayoffAt ? Number(plannedPayoffAt) : undefined,
180
+ description,
181
+ notes,
182
+ });
183
+ console.log(JSON.stringify(updated, null, 2));
184
+ return;
185
+ }
186
+ if (command === 'fork') {
187
+ if (!projectPath)
188
+ throw new Error('Missing projectPath');
189
+ const label = valueAfter(argv, '--label');
190
+ console.log(JSON.stringify(await forkProject({ sourceProjectPath: projectPath, label }), null, 2));
191
+ return;
192
+ }
193
+ if (command === 'delete-chapter') {
194
+ if (!projectPath)
195
+ throw new Error('Missing projectPath');
196
+ const chapter = valueAfter(argv, '--chapter');
197
+ if (!chapter)
198
+ throw new Error('Missing --chapter');
199
+ console.log(JSON.stringify(await deleteChapter({ projectPath, chapterNumber: Number(chapter) }), null, 2));
200
+ return;
201
+ }
202
+ if (command === 'redo') {
203
+ if (!projectPath)
204
+ throw new Error('Missing projectPath');
205
+ const step = valueAfter(argv, '--step');
206
+ if (!step)
207
+ throw new Error('Missing --step');
208
+ const chapter = valueAfter(argv, '--chapter');
209
+ console.log(JSON.stringify(await redoStep({
210
+ projectPath,
211
+ step,
212
+ chapterNumber: chapter ? Number(chapter) : undefined,
213
+ }), null, 2));
214
+ return;
215
+ }
216
+ 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
217
  }
141
218
  if (import.meta.url === `file://${process.argv[1]}`) {
142
219
  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');
@@ -14,11 +15,30 @@ export async function buildContext(input) {
14
15
  const parts = [];
15
16
  const metadata = await readOptional(join(input.projectPath, 'novel.json'));
16
17
  const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
18
+ const styleGuideJson = await readOptional(join(input.projectPath, 'style-guide.json'));
17
19
  const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
20
+ const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
21
+ const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
18
22
  if (metadata)
19
23
  parts.push(`## Novel Metadata\n${metadata}`);
20
24
  if (storyBible)
21
25
  parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
26
+ if (styleGuideJson)
27
+ parts.push(`## Style Guide\n${styleGuideJson}`);
28
+ if (charactersJson)
29
+ parts.push(`## Character State Table\n${charactersJson}`);
30
+ function addVolumePacing(volumeId) {
31
+ if (!volumePacingJson)
32
+ return;
33
+ try {
34
+ const boards = JSON.parse(volumePacingJson);
35
+ const board = volumeId ? boards.find((item) => item.volumeId === volumeId) : undefined;
36
+ parts.push(`## Volume Pacing Board\n${JSON.stringify(board ?? boards, null, 2)}`);
37
+ }
38
+ catch {
39
+ parts.push(`## Volume Pacing Board\n${volumePacingJson}`);
40
+ }
41
+ }
22
42
  if (input.purpose === 'chapter_generation' && input.chapterNumber) {
23
43
  let currentArchitectureForQuery;
24
44
  if (chaptersJson) {
@@ -27,6 +47,7 @@ export async function buildContext(input) {
27
47
  if (chapter) {
28
48
  currentArchitectureForQuery = chapter;
29
49
  parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
50
+ addVolumePacing(chapter.volumeId);
30
51
  }
31
52
  }
32
53
  if (input.chapterNumber > 1) {
@@ -54,6 +75,19 @@ export async function buildContext(input) {
54
75
  if (formatted)
55
76
  parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
56
77
  }
78
+ const allThreads = await loadThreads(input.projectPath);
79
+ const active = activeThreads(allThreads);
80
+ if (active.length) {
81
+ const lines = active.map((t) => {
82
+ const flags = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
83
+ if (t.plannedPayoffAt)
84
+ flags.push(`payoff=ch${t.plannedPayoffAt}`);
85
+ if (t.lastTouchedAt !== t.plantedAt)
86
+ flags.push(`touched=ch${t.lastTouchedAt}`);
87
+ return `- ${t.description} (${flags.join(', ')})`;
88
+ });
89
+ parts.push(`## Active Foreshadow Threads (do not silently drop or contradict)\n${lines.join('\n')}`);
90
+ }
57
91
  }
58
92
  }
59
93
  if (input.purpose === 'memory_extraction' && input.chapterNumber) {
@@ -73,12 +107,43 @@ export async function buildContext(input) {
73
107
  if (memoryParts.length)
74
108
  parts.push(`## Memory Cards\n${memoryParts.join('\n')}`);
75
109
  }
110
+ if (input.purpose === 'architecture_extension') {
111
+ if (chaptersJson)
112
+ parts.push(`## Existing Chapter Architecture List\n${chaptersJson}`);
113
+ if (volumePacingJson)
114
+ parts.push(`## Existing Volume Pacing Boards\n${volumePacingJson}`);
115
+ const start = Math.max(1, (input.chapterNumber ?? 1) - 5);
116
+ const end = Math.max(0, (input.chapterNumber ?? 1) - 1);
117
+ const memoryParts = [];
118
+ for (let i = start; i <= end; i += 1) {
119
+ const memory = await readOptional(join(input.projectPath, 'memory', memoryFileName(i)));
120
+ if (memory)
121
+ memoryParts.push(`### Chapter ${i} Memory\n${memory}`);
122
+ }
123
+ if (memoryParts.length)
124
+ parts.push(`## Recent Memory Cards\n${memoryParts.join('\n')}`);
125
+ const allThreads = await loadThreads(input.projectPath);
126
+ const active = activeThreads(allThreads);
127
+ if (active.length) {
128
+ const lines = active.map((t) => {
129
+ const flags = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
130
+ if (t.plannedPayoffAt)
131
+ flags.push(`payoff=ch${t.plannedPayoffAt}`);
132
+ if (t.lastTouchedAt !== t.plantedAt)
133
+ flags.push(`touched=ch${t.lastTouchedAt}`);
134
+ return `- ${t.description} (${flags.join(', ')})`;
135
+ });
136
+ parts.push(`## Active Foreshadow Threads\n${lines.join('\n')}`);
137
+ }
138
+ }
76
139
  if (input.purpose === 'chapter_review' && input.chapterNumber) {
77
140
  if (chaptersJson) {
78
141
  const chapters = JSON.parse(chaptersJson);
79
142
  const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
80
- if (arch)
143
+ if (arch) {
81
144
  parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
145
+ addVolumePacing(arch.volumeId);
146
+ }
82
147
  }
83
148
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
84
149
  if (chapter)
@@ -93,6 +158,16 @@ export async function buildContext(input) {
93
158
  const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
94
159
  if (chapter)
95
160
  parts.push(`## Current Chapter Text\n${chapter}`);
161
+ if (chaptersJson) {
162
+ try {
163
+ const chapters = JSON.parse(chaptersJson);
164
+ const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
165
+ addVolumePacing(arch?.volumeId);
166
+ }
167
+ catch {
168
+ // ignore malformed architecture here; review feedback is still useful
169
+ }
170
+ }
96
171
  const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
97
172
  if (review)
98
173
  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';
@@ -36,6 +36,7 @@ async function summarizeOne(projectPath) {
36
36
  currentStep: state.currentStep,
37
37
  currentChapter: state.currentChapter,
38
38
  targetChapters: state.targetChapters,
39
+ plannedTotalChapters: state.plannedTotalChapters ?? state.targetChapters,
39
40
  completedSteps: state.completedSteps.length,
40
41
  chaptersWritten: countChapters(state),
41
42
  updatedAt: state.updatedAt,