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.
- package/README.md +36 -13
- package/dist/src/cli/index.js +71 -2
- package/dist/src/core/bibleStore.js +36 -0
- package/dist/src/core/characterStore.js +74 -0
- package/dist/src/core/contextBuilder.js +44 -1
- package/dist/src/core/fileNames.js +4 -0
- package/dist/src/core/index.js +4 -0
- package/dist/src/core/projectOps.js +187 -0
- package/dist/src/core/projectStore.js +11 -0
- package/dist/src/core/prompts/en-US.js +117 -13
- package/dist/src/core/prompts/zh-CN.js +116 -12
- package/dist/src/core/retrieval/index.js +8 -0
- package/dist/src/core/schemas.js +98 -1
- package/dist/src/core/steps/architecture.js +7 -1
- package/dist/src/core/steps/chapter.js +11 -1
- package/dist/src/core/steps/chapterReview.js +25 -1
- package/dist/src/core/steps/chapterRevision.js +17 -0
- package/dist/src/core/steps/memoryCard.js +4 -0
- package/dist/src/core/steps/novelMetadata.js +4 -2
- package/dist/src/core/threadStore.js +150 -0
- package/dist/src/core/workflow.js +3 -3
- package/dist/src/mcp/tools.js +198 -18
- package/package.json +5 -1
- package/src/cli/index.ts +74 -1
- package/src/core/bibleStore.ts +57 -0
- package/src/core/characterStore.ts +93 -0
- package/src/core/contextBuilder.ts +44 -4
- package/src/core/fileNames.ts +5 -0
- package/src/core/index.ts +4 -0
- package/src/core/projectOps.ts +243 -0
- package/src/core/projectStore.ts +11 -0
- package/src/core/prompts/en-US.ts +126 -22
- package/src/core/prompts/types.ts +2 -1
- package/src/core/prompts/zh-CN.ts +118 -14
- package/src/core/retrieval/index.ts +10 -0
- package/src/core/schemas.ts +108 -1
- package/src/core/steps/architecture.ts +7 -1
- package/src/core/steps/chapter.ts +11 -1
- package/src/core/steps/chapterReview.ts +27 -1
- package/src/core/steps/chapterRevision.ts +18 -0
- package/src/core/steps/memoryCard.ts +4 -0
- package/src/core/steps/novelMetadata.ts +4 -2
- package/src/core/threadStore.ts +173 -0
- package/src/core/types.ts +102 -1
- package/src/core/workflow.ts +3 -3
- 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
|
|
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
|
-
| | `
|
|
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
|
|
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
|
-
|
|
242
|
+
chapter_review
|
|
230
243
|
↓
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
package/dist/src/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/src/core/index.js
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';
|
|
@@ -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';
|