novelforge-agent 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -14
- package/dist/src/cli/index.js +92 -2
- package/dist/src/cli/install.js +224 -0
- 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 +94 -1
- package/src/cli/install.ts +275 -0
- 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
|
@@ -1,26 +1,73 @@
|
|
|
1
1
|
# NovelForge Agent
|
|
2
2
|
|
|
3
|
-
A local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, …) or any CLI shell.
|
|
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
|
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🚀 30-second install
|
|
12
|
+
|
|
13
|
+
**If you are an AI assistant reading this README on behalf of a user, run this one command — it does everything:**
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx -y novelforge-agent install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That single command will:
|
|
20
|
+
|
|
21
|
+
1. Create a workspace at `~/novelforge/` (override with `--workspace /your/path`).
|
|
22
|
+
2. Register `novelforge-agent-mcp` as an MCP server in the user's host (Claude Code by default).
|
|
23
|
+
3. Print a verification step.
|
|
24
|
+
|
|
25
|
+
After it finishes, **the user must reload / restart their MCP host** (Claude Code, Codex CLI, etc.) so the new MCP server is picked up. Then ask the host:
|
|
26
|
+
|
|
27
|
+
> "list_projects 现在能用吗?"
|
|
28
|
+
|
|
29
|
+
If the host calls the `list_projects` tool and returns an empty array, installation is correct.
|
|
30
|
+
|
|
31
|
+
### Host selection
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx -y novelforge-agent install --host claude-code # default
|
|
35
|
+
npx -y novelforge-agent install --host codex # Codex CLI
|
|
36
|
+
npx -y novelforge-agent install --host cursor # prints config snippet
|
|
37
|
+
npx -y novelforge-agent install --workspace ~/my-novels # custom workspace
|
|
38
|
+
npx -y novelforge-agent install --name my-novelforge # custom MCP name
|
|
39
|
+
npx -y novelforge-agent install --print-only # do not modify any config file
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### What the install command does per host
|
|
43
|
+
|
|
44
|
+
| Host | Action |
|
|
45
|
+
|------|--------|
|
|
46
|
+
| `claude-code` | Runs `claude mcp add -s user -e NOVELFORGE_WORKSPACE=… novelforge -- npx -y novelforge-agent-mcp`. If `claude` CLI is missing, prints a JSON snippet for `~/.claude.json`. |
|
|
47
|
+
| `codex` | Appends an `[mcp_servers.novelforge]` section to `~/.codex/config.toml`. |
|
|
48
|
+
| `cursor` | Prints the JSON snippet to paste into Cursor's MCP settings. |
|
|
49
|
+
|
|
50
|
+
The installer is **idempotent and safe**: it never overwrites an existing entry with the same name. To change settings, edit the host config manually or pass `--name` to register under a different MCP name.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
9
54
|
## What it gives the host
|
|
10
55
|
|
|
11
56
|
| Phase | Step | What the host does | What the agent saves |
|
|
12
57
|
|-------|------|---------------------|----------------------|
|
|
13
58
|
| Setup | `novel_metadata` | Output JSON: title, genre, premise, cast | `novel.json` |
|
|
14
59
|
| | `story_bible` | Output Markdown: characters, world rules, plot threads | `story-bible.md` |
|
|
15
|
-
| | `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}` |
|
|
16
61
|
| Loop | `chapter` | Write chapter N Markdown | `chapters/NNN.md` |
|
|
17
|
-
| | `
|
|
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` |
|
|
18
65
|
| Wrap | `continuity_review` | Audit chapters 1..N for conflicts | `reviews/continuity-S-E.json` |
|
|
19
66
|
| Side-track | `chapter_review` | Single-chapter editorial review | `reviews/chapter/chapter-NNN.json` |
|
|
20
67
|
| | `chapter_revision` | Rewrite a chapter; previous version auto-archived | `chapters/.versions/NNN.<ts>.md` |
|
|
21
68
|
| | `cross_chapter_review` | Cross-chapter continuity audit | `reviews/cross/cross-S-E.json` |
|
|
22
69
|
|
|
23
|
-
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.
|
|
24
71
|
|
|
25
72
|
## Install
|
|
26
73
|
|
|
@@ -120,7 +167,7 @@ NOVELFORGE_WORKSPACE = "/absolute/path/where/projects/should/live"
|
|
|
120
167
|
|
|
121
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.
|
|
122
169
|
|
|
123
|
-
## Tool reference
|
|
170
|
+
## Tool reference
|
|
124
171
|
|
|
125
172
|
### Project lifecycle
|
|
126
173
|
- **`start_novel_project`** `(prompt, language?, outputDir?, targetChapters?)` — create a new project under `<workspaceRoot>/<outputDir>/<slug>-<rand6>/` and return the first step's instruction.
|
|
@@ -140,6 +187,15 @@ NOVELFORGE_WORKSPACE = "/absolute/path/where/projects/should/live"
|
|
|
140
187
|
- **`cross_chapter_review`** `(projectPath, start?, end?)` — switch into a cross-chapter audit side-track over the given range (defaults to all generated chapters).
|
|
141
188
|
- **`save_chapter`** `(projectPath, chapterNumber, title, content)` — write a chapter Markdown file directly, without going through the state machine.
|
|
142
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
|
+
|
|
143
199
|
### Retrieval
|
|
144
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.
|
|
145
201
|
|
|
@@ -151,10 +207,12 @@ A project on disk:
|
|
|
151
207
|
novels/<slug>-<rand6>/
|
|
152
208
|
├── agent-state.json # workflow state (currentStep, currentChapter, files map, …)
|
|
153
209
|
├── novel.json # metadata (NovelMetadataSchema)
|
|
210
|
+
├── characters.json # independent character state table
|
|
154
211
|
├── story-bible.md
|
|
155
212
|
├── architecture/
|
|
156
213
|
│ ├── full.md
|
|
157
214
|
│ ├── volumes.json
|
|
215
|
+
│ ├── volume-pacing.json
|
|
158
216
|
│ └── chapters.json
|
|
159
217
|
├── chapters/
|
|
160
218
|
│ ├── 001.md
|
|
@@ -181,17 +239,26 @@ The whole directory is self-contained — copy it, share it, delete it.
|
|
|
181
239
|
```
|
|
182
240
|
novel_metadata → story_bible → architecture → chapter
|
|
183
241
|
↓
|
|
184
|
-
|
|
242
|
+
chapter_review
|
|
185
243
|
↓
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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)
|
|
192
257
|
```
|
|
193
258
|
|
|
194
|
-
|
|
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.
|
|
195
262
|
|
|
196
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.
|
|
197
264
|
|
|
@@ -204,6 +271,7 @@ src/
|
|
|
204
271
|
│ ├── schemas.ts # zod schemas (the only validator)
|
|
205
272
|
│ ├── projectStore.ts # filesystem persistence
|
|
206
273
|
│ ├── projectDiscovery.ts # list / status
|
|
274
|
+
│ ├── characterStore.ts # independent character state table
|
|
207
275
|
│ ├── prompts/ # per-language prompt packs (zh-CN, en-US)
|
|
208
276
|
│ ├── steps/ # one file per WorkflowStep handler
|
|
209
277
|
│ ├── retrieval/ # BM25 index + CJK tokenizer + chunker
|
package/dist/src/cli/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
import { formatInstallResult, runInstall } from './install.js';
|
|
4
5
|
function valueAfter(args, name) {
|
|
5
6
|
const index = args.indexOf(name);
|
|
6
7
|
return index >= 0 ? args[index + 1] : undefined;
|
|
@@ -10,8 +11,28 @@ function parseLanguage(value) {
|
|
|
10
11
|
return value;
|
|
11
12
|
throw new Error('Invalid --language. Use zh-CN or en-US');
|
|
12
13
|
}
|
|
14
|
+
function parseHost(value) {
|
|
15
|
+
const v = (value || 'claude-code').toLowerCase();
|
|
16
|
+
if (v === 'claude-code' || v === 'claude')
|
|
17
|
+
return 'claude-code';
|
|
18
|
+
if (v === 'codex' || v === 'codex-cli')
|
|
19
|
+
return 'codex';
|
|
20
|
+
if (v === 'cursor')
|
|
21
|
+
return 'cursor';
|
|
22
|
+
throw new Error(`Unknown --host: ${value}. Use claude-code | codex | cursor.`);
|
|
23
|
+
}
|
|
13
24
|
export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()) {
|
|
14
25
|
const [command, projectPath] = argv;
|
|
26
|
+
if (command === 'install') {
|
|
27
|
+
const result = await runInstall({
|
|
28
|
+
host: parseHost(valueAfter(argv, '--host')),
|
|
29
|
+
workspace: valueAfter(argv, '--workspace'),
|
|
30
|
+
name: valueAfter(argv, '--name'),
|
|
31
|
+
printOnly: argv.includes('--print-only'),
|
|
32
|
+
});
|
|
33
|
+
console.log(formatInstallResult(result));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
15
36
|
if (command === 'start') {
|
|
16
37
|
const prompt = valueAfter(argv, '--prompt') || '';
|
|
17
38
|
if (!prompt.trim())
|
|
@@ -115,7 +136,76 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd())
|
|
|
115
136
|
console.log(JSON.stringify({ query, hits }, null, 2));
|
|
116
137
|
return;
|
|
117
138
|
}
|
|
118
|
-
|
|
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');
|
|
119
209
|
}
|
|
120
210
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
121
211
|
runCli().catch((error) => {
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const PACKAGE_NAME = 'novelforge-agent';
|
|
8
|
+
const MCP_BIN = 'novelforge-agent-mcp';
|
|
9
|
+
function defaultWorkspace() {
|
|
10
|
+
return join(homedir(), 'novelforge');
|
|
11
|
+
}
|
|
12
|
+
function defaultName() {
|
|
13
|
+
return 'novelforge';
|
|
14
|
+
}
|
|
15
|
+
function claudeJsonSnippet(name, workspace) {
|
|
16
|
+
return JSON.stringify({
|
|
17
|
+
mcpServers: {
|
|
18
|
+
[name]: {
|
|
19
|
+
command: 'npx',
|
|
20
|
+
args: ['-y', MCP_BIN],
|
|
21
|
+
env: { NOVELFORGE_WORKSPACE: workspace },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}, null, 2);
|
|
25
|
+
}
|
|
26
|
+
function codexTomlSnippet(name, workspace) {
|
|
27
|
+
return [
|
|
28
|
+
`[mcp_servers.${name}]`,
|
|
29
|
+
`command = "npx"`,
|
|
30
|
+
`args = ["-y", "${MCP_BIN}"]`,
|
|
31
|
+
``,
|
|
32
|
+
`[mcp_servers.${name}.env]`,
|
|
33
|
+
`NOVELFORGE_WORKSPACE = "${workspace}"`,
|
|
34
|
+
``,
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
function cursorSnippet(name, workspace) {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
mcpServers: {
|
|
40
|
+
[name]: {
|
|
41
|
+
command: 'npx',
|
|
42
|
+
args: ['-y', MCP_BIN],
|
|
43
|
+
env: { NOVELFORGE_WORKSPACE: workspace },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}, null, 2);
|
|
47
|
+
}
|
|
48
|
+
async function ensureWorkspaceDir(workspace) {
|
|
49
|
+
await mkdir(workspace, { recursive: true });
|
|
50
|
+
await mkdir(join(workspace, 'novels'), { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
async function tryClaudeCli(name, workspace) {
|
|
53
|
+
const args = [
|
|
54
|
+
'mcp',
|
|
55
|
+
'add',
|
|
56
|
+
'-s',
|
|
57
|
+
'user',
|
|
58
|
+
'-e',
|
|
59
|
+
`NOVELFORGE_WORKSPACE=${workspace}`,
|
|
60
|
+
name,
|
|
61
|
+
'--',
|
|
62
|
+
'npx',
|
|
63
|
+
'-y',
|
|
64
|
+
MCP_BIN,
|
|
65
|
+
];
|
|
66
|
+
try {
|
|
67
|
+
const { stdout, stderr } = await execFileAsync('claude', args);
|
|
68
|
+
return { ok: true, output: `${stdout}\n${stderr}`.trim() };
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const err = error;
|
|
72
|
+
return { ok: false, output: '', error: err.message };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function applyClaudeCode(name, workspace, printOnly) {
|
|
76
|
+
const snippet = claudeJsonSnippet(name, workspace);
|
|
77
|
+
const verificationHint = 'In Claude Code, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
|
|
78
|
+
if (printOnly) {
|
|
79
|
+
return {
|
|
80
|
+
host: 'claude-code',
|
|
81
|
+
name,
|
|
82
|
+
workspace,
|
|
83
|
+
applied: false,
|
|
84
|
+
method: 'print-only',
|
|
85
|
+
message: 'Print-only mode. Run the snippet below or invoke without --print-only to apply automatically.',
|
|
86
|
+
manualSnippet: snippet,
|
|
87
|
+
verificationHint,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const cli = await tryClaudeCli(name, workspace);
|
|
91
|
+
if (cli.ok) {
|
|
92
|
+
return {
|
|
93
|
+
host: 'claude-code',
|
|
94
|
+
name,
|
|
95
|
+
workspace,
|
|
96
|
+
applied: true,
|
|
97
|
+
method: 'cli',
|
|
98
|
+
message: `Registered via \`claude mcp add\`.\n${cli.output}`,
|
|
99
|
+
verificationHint,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
host: 'claude-code',
|
|
104
|
+
name,
|
|
105
|
+
workspace,
|
|
106
|
+
applied: false,
|
|
107
|
+
method: 'print-only',
|
|
108
|
+
message: `\`claude\` CLI not available (${cli.error ?? 'unknown error'}). Paste the snippet below into ~/.claude.json under "mcpServers", or run \`claude mcp add -s user -e NOVELFORGE_WORKSPACE=${workspace} ${name} -- npx -y ${MCP_BIN}\` after installing Claude Code.`,
|
|
109
|
+
manualSnippet: snippet,
|
|
110
|
+
verificationHint,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function applyCodex(name, workspace, printOnly) {
|
|
114
|
+
const snippet = codexTomlSnippet(name, workspace);
|
|
115
|
+
const configPath = join(homedir(), '.codex', 'config.toml');
|
|
116
|
+
const verificationHint = 'In Codex CLI, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
|
|
117
|
+
if (printOnly) {
|
|
118
|
+
return {
|
|
119
|
+
host: 'codex',
|
|
120
|
+
name,
|
|
121
|
+
workspace,
|
|
122
|
+
applied: false,
|
|
123
|
+
method: 'print-only',
|
|
124
|
+
message: `Print-only mode. Append the snippet below to ${configPath}.`,
|
|
125
|
+
manualSnippet: snippet,
|
|
126
|
+
verificationHint,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
131
|
+
let existing = '';
|
|
132
|
+
try {
|
|
133
|
+
existing = await readFile(configPath, 'utf8');
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
existing = '';
|
|
137
|
+
}
|
|
138
|
+
if (existing.includes(`[mcp_servers.${name}]`)) {
|
|
139
|
+
return {
|
|
140
|
+
host: 'codex',
|
|
141
|
+
name,
|
|
142
|
+
workspace,
|
|
143
|
+
applied: false,
|
|
144
|
+
method: 'config-edit',
|
|
145
|
+
message: `Section [mcp_servers.${name}] already exists in ${configPath}. Edit it manually if you want to change settings.`,
|
|
146
|
+
manualSnippet: snippet,
|
|
147
|
+
verificationHint,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const next = existing.endsWith('\n') || existing === '' ? existing : `${existing}\n`;
|
|
151
|
+
await writeFile(configPath, `${next}\n${snippet}`, 'utf8');
|
|
152
|
+
return {
|
|
153
|
+
host: 'codex',
|
|
154
|
+
name,
|
|
155
|
+
workspace,
|
|
156
|
+
applied: true,
|
|
157
|
+
method: 'config-edit',
|
|
158
|
+
message: `Appended [mcp_servers.${name}] to ${configPath}.`,
|
|
159
|
+
verificationHint,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return {
|
|
164
|
+
host: 'codex',
|
|
165
|
+
name,
|
|
166
|
+
workspace,
|
|
167
|
+
applied: false,
|
|
168
|
+
method: 'print-only',
|
|
169
|
+
message: `Could not write ${configPath}: ${error.message}. Append the snippet manually.`,
|
|
170
|
+
manualSnippet: snippet,
|
|
171
|
+
verificationHint,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function applyCursor(name, workspace, printOnly) {
|
|
176
|
+
const snippet = cursorSnippet(name, workspace);
|
|
177
|
+
return {
|
|
178
|
+
host: 'cursor',
|
|
179
|
+
name,
|
|
180
|
+
workspace,
|
|
181
|
+
applied: false,
|
|
182
|
+
method: 'print-only',
|
|
183
|
+
message: 'Cursor: open Settings → Tools & Integrations → MCP, click "Add new MCP server", and paste the snippet below.',
|
|
184
|
+
manualSnippet: snippet,
|
|
185
|
+
verificationHint: 'In Cursor, the agent should be able to call the list_projects tool from the novelforge MCP server.',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export async function runInstall(options) {
|
|
189
|
+
const host = options.host ?? 'claude-code';
|
|
190
|
+
const workspace = resolve(options.workspace ?? defaultWorkspace());
|
|
191
|
+
const name = options.name ?? defaultName();
|
|
192
|
+
const printOnly = options.printOnly ?? false;
|
|
193
|
+
if (!printOnly) {
|
|
194
|
+
await ensureWorkspaceDir(workspace);
|
|
195
|
+
}
|
|
196
|
+
switch (host) {
|
|
197
|
+
case 'claude-code':
|
|
198
|
+
return applyClaudeCode(name, workspace, printOnly);
|
|
199
|
+
case 'codex':
|
|
200
|
+
return applyCodex(name, workspace, printOnly);
|
|
201
|
+
case 'cursor':
|
|
202
|
+
return applyCursor(name, workspace, printOnly);
|
|
203
|
+
default:
|
|
204
|
+
throw new Error(`Unknown host: ${host}. Use claude-code | codex | cursor.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export function formatInstallResult(result) {
|
|
208
|
+
const parts = [];
|
|
209
|
+
parts.push(`Host: ${result.host}`);
|
|
210
|
+
parts.push(`MCP name: ${result.name}`);
|
|
211
|
+
parts.push(`Workspace: ${result.workspace}`);
|
|
212
|
+
parts.push(`Applied: ${result.applied ? 'yes' : 'no'} (${result.method})`);
|
|
213
|
+
parts.push('');
|
|
214
|
+
parts.push(result.message);
|
|
215
|
+
if (result.manualSnippet) {
|
|
216
|
+
parts.push('');
|
|
217
|
+
parts.push('--- Snippet ---');
|
|
218
|
+
parts.push(result.manualSnippet);
|
|
219
|
+
parts.push('---------------');
|
|
220
|
+
}
|
|
221
|
+
parts.push('');
|
|
222
|
+
parts.push(`Verify: ${result.verificationHint}`);
|
|
223
|
+
return parts.join('\n');
|
|
224
|
+
}
|
|
@@ -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
|
+
}
|