novelforge-agent 0.1.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -0
  3. package/dist/src/cli/index.js +125 -0
  4. package/dist/src/core/contextBuilder.js +128 -0
  5. package/dist/src/core/fileNames.js +41 -0
  6. package/dist/src/core/index.js +9 -0
  7. package/dist/src/core/projectDiscovery.js +141 -0
  8. package/dist/src/core/projectStore.js +85 -0
  9. package/dist/src/core/prompts/en-US.js +363 -0
  10. package/dist/src/core/prompts/types.js +1 -0
  11. package/dist/src/core/prompts/zh-CN.js +362 -0
  12. package/dist/src/core/prompts.js +15 -0
  13. package/dist/src/core/retrieval/chunker.js +77 -0
  14. package/dist/src/core/retrieval/index.js +125 -0
  15. package/dist/src/core/retrieval/tokenizer.js +43 -0
  16. package/dist/src/core/retrieval/types.js +1 -0
  17. package/dist/src/core/schemas.js +91 -0
  18. package/dist/src/core/steps/architecture.js +16 -0
  19. package/dist/src/core/steps/chapter.js +16 -0
  20. package/dist/src/core/steps/chapterReview.js +16 -0
  21. package/dist/src/core/steps/chapterRevision.js +20 -0
  22. package/dist/src/core/steps/continuityReview.js +13 -0
  23. package/dist/src/core/steps/crossChapterReview.js +15 -0
  24. package/dist/src/core/steps/index.js +20 -0
  25. package/dist/src/core/steps/memoryCard.js +22 -0
  26. package/dist/src/core/steps/novelMetadata.js +12 -0
  27. package/dist/src/core/steps/storyBible.js +13 -0
  28. package/dist/src/core/steps/types.js +7 -0
  29. package/dist/src/core/types.js +1 -0
  30. package/dist/src/core/workflow.js +186 -0
  31. package/dist/src/mcp/server.js +13 -0
  32. package/dist/src/mcp/tools.js +126 -0
  33. package/package.json +61 -0
  34. package/src/cli/index.ts +147 -0
  35. package/src/core/contextBuilder.ts +131 -0
  36. package/src/core/fileNames.ts +48 -0
  37. package/src/core/index.ts +9 -0
  38. package/src/core/projectDiscovery.ts +174 -0
  39. package/src/core/projectStore.ts +111 -0
  40. package/src/core/prompts/en-US.ts +376 -0
  41. package/src/core/prompts/types.ts +28 -0
  42. package/src/core/prompts/zh-CN.ts +375 -0
  43. package/src/core/prompts.ts +27 -0
  44. package/src/core/retrieval/chunker.ts +80 -0
  45. package/src/core/retrieval/index.ts +136 -0
  46. package/src/core/retrieval/tokenizer.ts +44 -0
  47. package/src/core/retrieval/types.ts +24 -0
  48. package/src/core/schemas.ts +101 -0
  49. package/src/core/steps/architecture.ts +17 -0
  50. package/src/core/steps/chapter.ts +17 -0
  51. package/src/core/steps/chapterReview.ts +17 -0
  52. package/src/core/steps/chapterRevision.ts +21 -0
  53. package/src/core/steps/continuityReview.ts +14 -0
  54. package/src/core/steps/crossChapterReview.ts +16 -0
  55. package/src/core/steps/index.ts +25 -0
  56. package/src/core/steps/memoryCard.ts +23 -0
  57. package/src/core/steps/novelMetadata.ts +13 -0
  58. package/src/core/steps/storyBible.ts +14 -0
  59. package/src/core/steps/types.ts +21 -0
  60. package/src/core/types.ts +115 -0
  61. package/src/core/workflow.ts +250 -0
  62. package/src/mcp/server.ts +15 -0
  63. package/src/mcp/tools.ts +227 -0
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ export const CoreCastMemberSchema = z.object({
3
+ name: z.string().min(1),
4
+ role: z.string().min(1),
5
+ description: z.string().min(1),
6
+ });
7
+ export const NovelMetadataSchema = z.object({
8
+ title: z.string().min(1),
9
+ genre: z.string().min(1),
10
+ premise: z.string().min(1),
11
+ language: z.string().min(1).default('zh-CN'),
12
+ style: z.string().min(1).default('清晰、连贯、适合长篇连载'),
13
+ coreCast: z.array(CoreCastMemberSchema).min(1),
14
+ });
15
+ export const VolumeArchitectureSchema = z.object({
16
+ id: z.string().min(1),
17
+ title: z.string().min(1),
18
+ summary: z.string().min(1),
19
+ order: z.number().int().positive(),
20
+ });
21
+ export const ChapterArchitectureSchema = z.object({
22
+ chapterNumber: z.number().int().positive(),
23
+ title: z.string().min(1),
24
+ volumeId: z.string().min(1),
25
+ summary: z.string().min(1),
26
+ requiredBeats: z.array(z.string().min(1)).min(1),
27
+ });
28
+ export const ArchitecturePayloadSchema = z.object({
29
+ full: z.string().min(1),
30
+ volumes: z.array(VolumeArchitectureSchema).min(1),
31
+ chapters: z.array(ChapterArchitectureSchema).min(1),
32
+ });
33
+ export const MemoryCardSchema = z.object({
34
+ summary: z.string().min(1),
35
+ keyEvents: z.array(z.string().min(1)),
36
+ entities: z.array(z.object({
37
+ name: z.string().min(1),
38
+ type: z.string().min(1),
39
+ state: z.string().min(1),
40
+ })),
41
+ facts: z.array(z.object({
42
+ subject: z.string().min(1),
43
+ predicate: z.string().min(1),
44
+ object: z.string().min(1),
45
+ })),
46
+ stateChanges: z.array(z.object({
47
+ entity: z.string().min(1),
48
+ before: z.string().min(1),
49
+ after: z.string().min(1),
50
+ })),
51
+ openThreads: z.array(z.string().min(1)),
52
+ });
53
+ export const ContinuityReviewSchema = z.object({
54
+ range: z.object({
55
+ start: z.number().int().positive(),
56
+ end: z.number().int().positive(),
57
+ }),
58
+ status: z.enum(['clean', 'issues_found']),
59
+ issues: z.array(z.object({
60
+ severity: z.enum(['low', 'medium', 'high']),
61
+ description: z.string().min(1),
62
+ evidence: z.string().min(1),
63
+ suggestion: z.string().min(1),
64
+ })),
65
+ });
66
+ export const ChapterReviewIssueSchema = z.object({
67
+ severity: z.enum(['low', 'medium', 'high']),
68
+ category: z.enum(['character', 'world', 'timeline', 'item', 'knowledge', 'pacing', 'style', 'architecture']),
69
+ description: z.string().min(1),
70
+ evidence: z.string().min(1),
71
+ suggestion: z.string().min(1),
72
+ });
73
+ export const ChapterReviewSchema = z.object({
74
+ chapterNumber: z.number().int().positive(),
75
+ status: z.enum(['clean', 'issues_found']),
76
+ issues: z.array(ChapterReviewIssueSchema),
77
+ });
78
+ export const CrossChapterReviewSchema = z.object({
79
+ range: z.object({
80
+ start: z.number().int().positive(),
81
+ end: z.number().int().positive(),
82
+ }),
83
+ status: z.enum(['clean', 'issues_found']),
84
+ issues: z.array(z.object({
85
+ severity: z.enum(['low', 'medium', 'high']),
86
+ chapters: z.array(z.number().int().positive()).min(1),
87
+ description: z.string().min(1),
88
+ evidence: z.string().min(1),
89
+ suggestion: z.string().min(1),
90
+ })),
91
+ });
@@ -0,0 +1,16 @@
1
+ import { ArchitecturePayloadSchema } from '../schemas.js';
2
+ import { saveJsonFile, saveMarkdownFile } from '../projectStore.js';
3
+ import { parseJson } from './types.js';
4
+ export const architectureHandler = async (state, content) => {
5
+ const parsed = ArchitecturePayloadSchema.parse(parseJson(content));
6
+ const savedPaths = [
7
+ await saveMarkdownFile(state.projectPath, 'architecture/full.md', parsed.full),
8
+ await saveJsonFile(state.projectPath, 'architecture/volumes.json', parsed.volumes),
9
+ await saveJsonFile(state.projectPath, 'architecture/chapters.json', parsed.chapters),
10
+ ];
11
+ return {
12
+ savedPaths,
13
+ fileEntries: { architecture: 'architecture/chapters.json' },
14
+ next: { kind: 'linear', nextStep: 'chapter' },
15
+ };
16
+ };
@@ -0,0 +1,16 @@
1
+ import { join } from 'node:path';
2
+ import { saveMarkdownFile } from '../projectStore.js';
3
+ import { chapterFileName } from '../fileNames.js';
4
+ import { indexChapter } from '../retrieval/index.js';
5
+ import { requireNonEmpty } from './types.js';
6
+ export const chapterHandler = async (state, content) => {
7
+ requireNonEmpty(content, 'Chapter Markdown');
8
+ const relative = join('chapters', chapterFileName(state.currentChapter));
9
+ const path = await saveMarkdownFile(state.projectPath, relative, content);
10
+ await indexChapter(state.projectPath, state.currentChapter, content);
11
+ return {
12
+ savedPaths: [path],
13
+ fileEntries: { [`chapter-${state.currentChapter}`]: relative },
14
+ next: { kind: 'linear', nextStep: 'memory_card' },
15
+ };
16
+ };
@@ -0,0 +1,16 @@
1
+ import { join } from 'node:path';
2
+ import { ChapterReviewSchema } from '../schemas.js';
3
+ import { saveJsonFile } from '../projectStore.js';
4
+ import { chapterReviewFileName } from '../fileNames.js';
5
+ import { parseJson } from './types.js';
6
+ export const chapterReviewHandler = async (state, content) => {
7
+ const parsed = ChapterReviewSchema.parse(parseJson(content));
8
+ const target = state.pendingAction?.chapterNumber ?? parsed.chapterNumber;
9
+ const relative = join('reviews/chapter', chapterReviewFileName(target));
10
+ const path = await saveJsonFile(state.projectPath, relative, parsed);
11
+ return {
12
+ savedPaths: [path],
13
+ fileEntries: { [`review-chapter-${target}`]: relative },
14
+ next: { kind: 'sideTrackReturn' },
15
+ };
16
+ };
@@ -0,0 +1,20 @@
1
+ import { join } from 'node:path';
2
+ import { archiveChapterVersion, saveMarkdownFile } from '../projectStore.js';
3
+ import { chapterFileName, chapterVersionFileName } from '../fileNames.js';
4
+ import { indexChapter } from '../retrieval/index.js';
5
+ import { requireNonEmpty } from './types.js';
6
+ export const chapterRevisionHandler = async (state, content) => {
7
+ requireNonEmpty(content, 'Chapter revision Markdown');
8
+ const target = state.pendingAction?.chapterNumber ?? state.currentChapter;
9
+ const chapterRelative = join('chapters', chapterFileName(target));
10
+ const versionRelative = join('chapters/.versions', chapterVersionFileName(target, new Date().toISOString()));
11
+ const archived = await archiveChapterVersion(state.projectPath, chapterRelative, versionRelative);
12
+ const savedPaths = archived ? [archived] : [];
13
+ savedPaths.push(await saveMarkdownFile(state.projectPath, chapterRelative, content));
14
+ await indexChapter(state.projectPath, target, content);
15
+ return {
16
+ savedPaths,
17
+ fileEntries: { [`chapter-${target}`]: chapterRelative },
18
+ next: { kind: 'sideTrackReturn' },
19
+ };
20
+ };
@@ -0,0 +1,13 @@
1
+ import { ContinuityReviewSchema } from '../schemas.js';
2
+ import { saveJsonFile } from '../projectStore.js';
3
+ import { parseJson } from './types.js';
4
+ export const continuityReviewHandler = async (state, content) => {
5
+ const parsed = ContinuityReviewSchema.parse(parseJson(content));
6
+ const relative = `reviews/continuity-${parsed.range.start}-${parsed.range.end}.json`;
7
+ const path = await saveJsonFile(state.projectPath, relative, parsed);
8
+ return {
9
+ savedPaths: [path],
10
+ fileEntries: { continuityReview: relative },
11
+ next: { kind: 'linear', nextStep: 'complete' },
12
+ };
13
+ };
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path';
2
+ import { CrossChapterReviewSchema } from '../schemas.js';
3
+ import { saveJsonFile } from '../projectStore.js';
4
+ import { crossChapterReviewFileName } from '../fileNames.js';
5
+ import { parseJson } from './types.js';
6
+ export const crossChapterReviewHandler = async (state, content) => {
7
+ const parsed = CrossChapterReviewSchema.parse(parseJson(content));
8
+ const relative = join('reviews/cross', crossChapterReviewFileName(parsed.range.start, parsed.range.end));
9
+ const path = await saveJsonFile(state.projectPath, relative, parsed);
10
+ return {
11
+ savedPaths: [path],
12
+ fileEntries: { [`review-cross-${parsed.range.start}-${parsed.range.end}`]: relative },
13
+ next: { kind: 'sideTrackReturn' },
14
+ };
15
+ };
@@ -0,0 +1,20 @@
1
+ import { architectureHandler } from './architecture.js';
2
+ import { chapterHandler } from './chapter.js';
3
+ import { chapterReviewHandler } from './chapterReview.js';
4
+ import { chapterRevisionHandler } from './chapterRevision.js';
5
+ import { continuityReviewHandler } from './continuityReview.js';
6
+ import { crossChapterReviewHandler } from './crossChapterReview.js';
7
+ import { memoryCardHandler } from './memoryCard.js';
8
+ import { novelMetadataHandler } from './novelMetadata.js';
9
+ import { storyBibleHandler } from './storyBible.js';
10
+ export const STEP_HANDLERS = {
11
+ novel_metadata: novelMetadataHandler,
12
+ story_bible: storyBibleHandler,
13
+ architecture: architectureHandler,
14
+ chapter: chapterHandler,
15
+ memory_card: memoryCardHandler,
16
+ continuity_review: continuityReviewHandler,
17
+ chapter_review: chapterReviewHandler,
18
+ chapter_revision: chapterRevisionHandler,
19
+ cross_chapter_review: crossChapterReviewHandler,
20
+ };
@@ -0,0 +1,22 @@
1
+ import { join } from 'node:path';
2
+ import { MemoryCardSchema } from '../schemas.js';
3
+ import { saveJsonFile } from '../projectStore.js';
4
+ import { memoryFileName } from '../fileNames.js';
5
+ import { indexMemoryCard } from '../retrieval/index.js';
6
+ import { parseJson } from './types.js';
7
+ export const memoryCardHandler = async (state, content) => {
8
+ const parsed = MemoryCardSchema.parse(parseJson(content));
9
+ const relative = join('memory', memoryFileName(state.currentChapter));
10
+ const path = await saveJsonFile(state.projectPath, relative, parsed);
11
+ await indexMemoryCard(state.projectPath, state.currentChapter, parsed);
12
+ const nextChapter = state.currentChapter + 1;
13
+ return {
14
+ savedPaths: [path],
15
+ fileEntries: { [`memory-${state.currentChapter}`]: relative },
16
+ next: {
17
+ kind: 'linear',
18
+ nextStep: nextChapter > state.targetChapters ? 'continuity_review' : 'chapter',
19
+ statePatch: { currentChapter: nextChapter },
20
+ },
21
+ };
22
+ };
@@ -0,0 +1,12 @@
1
+ import { NovelMetadataSchema } from '../schemas.js';
2
+ import { saveJsonFile } from '../projectStore.js';
3
+ import { parseJson } from './types.js';
4
+ export const novelMetadataHandler = async (state, content) => {
5
+ const parsed = NovelMetadataSchema.parse(parseJson(content));
6
+ const path = await saveJsonFile(state.projectPath, 'novel.json', parsed);
7
+ return {
8
+ savedPaths: [path],
9
+ fileEntries: { novel: 'novel.json' },
10
+ next: { kind: 'linear', nextStep: 'story_bible' },
11
+ };
12
+ };
@@ -0,0 +1,13 @@
1
+ import { saveMarkdownFile } from '../projectStore.js';
2
+ import { indexStoryBible } from '../retrieval/index.js';
3
+ import { requireNonEmpty } from './types.js';
4
+ export const storyBibleHandler = async (state, content) => {
5
+ requireNonEmpty(content, 'Story bible Markdown');
6
+ const path = await saveMarkdownFile(state.projectPath, 'story-bible.md', content);
7
+ await indexStoryBible(state.projectPath, content);
8
+ return {
9
+ savedPaths: [path],
10
+ fileEntries: { storyBible: 'story-bible.md' },
11
+ next: { kind: 'linear', nextStep: 'architecture' },
12
+ };
13
+ };
@@ -0,0 +1,7 @@
1
+ export function parseJson(content) {
2
+ return JSON.parse(content);
3
+ }
4
+ export function requireNonEmpty(content, label) {
5
+ if (!content.trim())
6
+ throw new Error(`${label} is empty`);
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,186 @@
1
+ import { readFile, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { buildContext } from './contextBuilder.js';
4
+ import { buildPromptForStep } from './prompts.js';
5
+ import { loadState, saveJsonFile, saveRecoveryFile, saveState } from './projectStore.js';
6
+ import { STEP_HANDLERS } from './steps/index.js';
7
+ const CONTEXT_RECIPES = {
8
+ chapter: (s) => ({ purpose: 'chapter_generation', chapterNumber: s.currentChapter }),
9
+ memory_card: (s) => ({ purpose: 'memory_extraction', chapterNumber: s.currentChapter }),
10
+ continuity_review: () => ({ purpose: 'continuity_review' }),
11
+ chapter_review: (s) => ({
12
+ purpose: 'chapter_review',
13
+ chapterNumber: s.pendingAction?.chapterNumber ?? s.currentChapter,
14
+ }),
15
+ chapter_revision: (s) => ({
16
+ purpose: 'revision',
17
+ chapterNumber: s.pendingAction?.chapterNumber ?? s.currentChapter,
18
+ feedback: s.pendingAction?.feedback,
19
+ }),
20
+ cross_chapter_review: (s) => ({
21
+ purpose: 'cross_chapter_review',
22
+ range: s.pendingAction?.range,
23
+ }),
24
+ };
25
+ async function contextForStep(state) {
26
+ const recipe = CONTEXT_RECIPES[state.currentStep];
27
+ if (!recipe)
28
+ return '';
29
+ return buildContext({ projectPath: state.projectPath, ...recipe(state) });
30
+ }
31
+ async function instructionFor(state) {
32
+ const base = {
33
+ projectId: state.projectId,
34
+ projectPath: state.projectPath,
35
+ currentStep: state.currentStep,
36
+ };
37
+ if (state.currentStep === 'complete') {
38
+ return { ...base, instruction: 'The workflow is complete.', expectedFormat: 'No output required', context: '' };
39
+ }
40
+ const context = await contextForStep(state);
41
+ const prompt = buildPromptForStep({ state, context });
42
+ return { ...base, instruction: prompt.prompt, expectedFormat: prompt.expectedFormat, context };
43
+ }
44
+ export async function getNextStep(projectPath) {
45
+ return instructionFor(await loadState(projectPath));
46
+ }
47
+ const SIDE_TRACK_FILE = '.agent-recovery/side-track.json';
48
+ async function saveSideTrack(state, entry) {
49
+ await saveState(state);
50
+ await saveJsonFile(state.projectPath, SIDE_TRACK_FILE, entry);
51
+ }
52
+ async function loadSideTrack(projectPath) {
53
+ try {
54
+ const raw = await readFile(join(projectPath, SIDE_TRACK_FILE), 'utf8');
55
+ return JSON.parse(raw);
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ async function clearSideTrack(projectPath) {
62
+ try {
63
+ await unlink(join(projectPath, SIDE_TRACK_FILE));
64
+ }
65
+ catch {
66
+ // ignore: nothing to clear
67
+ }
68
+ }
69
+ function maxExistingChapter(state) {
70
+ let max = 0;
71
+ for (const key of Object.keys(state.files)) {
72
+ const match = key.match(/^chapter-(\d+)$/);
73
+ if (match) {
74
+ const num = Number(match[1]);
75
+ if (num > max)
76
+ max = num;
77
+ }
78
+ }
79
+ return max;
80
+ }
81
+ function buildPendingAction(state, input) {
82
+ switch (input.step) {
83
+ case 'chapter_review': {
84
+ if (!input.chapterNumber)
85
+ throw new Error('chapter_review requires chapterNumber');
86
+ return { step: 'chapter_review', chapterNumber: input.chapterNumber };
87
+ }
88
+ case 'chapter_revision': {
89
+ if (!input.chapterNumber)
90
+ throw new Error('chapter_revision requires chapterNumber');
91
+ return { step: 'chapter_revision', chapterNumber: input.chapterNumber, feedback: input.feedback };
92
+ }
93
+ case 'cross_chapter_review': {
94
+ const max = maxExistingChapter(state);
95
+ const range = input.range ?? { start: 1, end: max || state.currentChapter };
96
+ if (range.start < 1 || range.end < range.start)
97
+ throw new Error('Invalid range');
98
+ return { step: 'cross_chapter_review', range };
99
+ }
100
+ default:
101
+ throw new Error(`Unknown side-track step: ${input.step}`);
102
+ }
103
+ }
104
+ export async function requestSideTrack(input) {
105
+ const state = await loadState(input.projectPath);
106
+ const pendingAction = buildPendingAction(state, input);
107
+ const next = { ...state, currentStep: input.step, pendingAction };
108
+ await saveSideTrack(next, {
109
+ step: input.step,
110
+ resumeStep: state.currentStep,
111
+ resumeChapter: state.currentChapter,
112
+ pendingAction,
113
+ });
114
+ return instructionFor(next);
115
+ }
116
+ // =============================================================================
117
+ // Submit dispatcher — thin glue over STEP_HANDLERS
118
+ // =============================================================================
119
+ function advanceLinear(state, nextStep, fileEntries = {}, statePatch = {}) {
120
+ const { pendingAction: _pending, ...rest } = state;
121
+ return {
122
+ ...rest,
123
+ ...statePatch,
124
+ currentStep: nextStep,
125
+ completedSteps: [...state.completedSteps, state.currentStep],
126
+ files: { ...state.files, ...fileEntries },
127
+ };
128
+ }
129
+ async function resumeFromSideTrack(state, fileEntries) {
130
+ const sideTrack = await loadSideTrack(state.projectPath);
131
+ await clearSideTrack(state.projectPath);
132
+ return {
133
+ ...state,
134
+ currentStep: sideTrack?.resumeStep ?? state.currentStep,
135
+ currentChapter: sideTrack?.resumeChapter ?? state.currentChapter,
136
+ completedSteps: [...state.completedSteps, state.currentStep],
137
+ files: { ...state.files, ...fileEntries },
138
+ pendingAction: undefined,
139
+ };
140
+ }
141
+ export async function submitStepResult(input) {
142
+ const state = await loadState(input.projectPath);
143
+ if (state.currentStep !== input.step) {
144
+ const recoveryPath = await saveRecoveryFile(state.projectPath, input.step, input.content);
145
+ return {
146
+ validation: { ok: false, message: `Expected step ${state.currentStep}, got ${input.step}` },
147
+ state,
148
+ savedPaths: [],
149
+ recoveryPath,
150
+ next: await instructionFor(state),
151
+ };
152
+ }
153
+ const handler = STEP_HANDLERS[input.step];
154
+ if (!handler) {
155
+ return {
156
+ validation: { ok: false, message: `Step ${input.step} accepts no submission` },
157
+ state,
158
+ savedPaths: [],
159
+ next: await instructionFor(state),
160
+ };
161
+ }
162
+ try {
163
+ const result = await handler(state, input.content);
164
+ const fileEntries = result.fileEntries ?? {};
165
+ const nextState = result.next.kind === 'linear'
166
+ ? advanceLinear(state, result.next.nextStep, fileEntries, result.next.statePatch)
167
+ : await resumeFromSideTrack(state, fileEntries);
168
+ await saveState(nextState);
169
+ return {
170
+ validation: { ok: true, message: 'Saved' },
171
+ state: nextState,
172
+ savedPaths: result.savedPaths,
173
+ next: await instructionFor(nextState),
174
+ };
175
+ }
176
+ catch (error) {
177
+ const recoveryPath = await saveRecoveryFile(state.projectPath, input.step, input.content);
178
+ return {
179
+ validation: { ok: false, message: error.message },
180
+ state,
181
+ savedPaths: [],
182
+ recoveryPath,
183
+ next: await instructionFor(state),
184
+ };
185
+ }
186
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createNovelAgentServer } from './tools.js';
4
+ async function main() {
5
+ const workspaceRoot = process.env.NOVELFORGE_WORKSPACE || process.cwd();
6
+ const server = createNovelAgentServer({ workspaceRoot });
7
+ const transport = new StdioServerTransport();
8
+ await server.connect(transport);
9
+ }
10
+ main().catch((error) => {
11
+ console.error(error.message);
12
+ process.exitCode = 1;
13
+ });
@@ -0,0 +1,126 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { buildContext, chapterFileName, createProject, getNextStep, getProjectStatus, listProjects, requestSideTrack, retrieve, saveMarkdownFile, submitStepResult, } from '../core/index.js';
4
+ function textResult(value) {
5
+ return {
6
+ content: [{
7
+ type: 'text',
8
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
9
+ }],
10
+ };
11
+ }
12
+ export function createNovelAgentServer(options) {
13
+ const server = new McpServer({
14
+ name: 'novelforge-agent',
15
+ version: '0.1.0',
16
+ });
17
+ server.tool('start_novel_project', 'Create a local novel project and return the first generation instruction.', {
18
+ prompt: z.string().min(1),
19
+ language: z.enum(['zh-CN', 'en-US']).default('zh-CN'),
20
+ outputDir: z.string().default('novels'),
21
+ targetChapters: z.number().int().positive().default(3),
22
+ }, async ({ prompt, language, outputDir, targetChapters }) => {
23
+ const result = await createProject({
24
+ workspaceRoot: options.workspaceRoot,
25
+ prompt,
26
+ language,
27
+ outputDir,
28
+ targetChapters,
29
+ });
30
+ return textResult({ state: result.state, next: await getNextStep(result.state.projectPath) });
31
+ });
32
+ server.tool('list_projects', 'List all NovelForge projects under the workspace, sorted by most recently updated. Use this to find an existing projectPath instead of asking the user.', {
33
+ outputDir: z.string().default('novels'),
34
+ }, async ({ outputDir }) => textResult(await listProjects({ workspaceRoot: options.workspaceRoot, outputDir })));
35
+ server.tool('get_project_status', 'Return a compact, one-screen summary of a project: current step, chapters written, open threads, latest review verdict, completion state.', { projectPath: z.string().min(1) }, async ({ projectPath }) => textResult(await getProjectStatus(projectPath)));
36
+ server.tool('get_next_step', 'Return the next required generation step for a novel project.', { projectPath: z.string().min(1) }, async ({ projectPath }) => textResult(await getNextStep(projectPath)));
37
+ server.tool('submit_step_result', 'Submit host-generated content for validation, saving, and workflow advancement.', {
38
+ projectPath: z.string().min(1),
39
+ step: z.enum([
40
+ 'novel_metadata',
41
+ 'story_bible',
42
+ 'architecture',
43
+ 'chapter',
44
+ 'memory_card',
45
+ 'continuity_review',
46
+ 'chapter_review',
47
+ 'chapter_revision',
48
+ 'cross_chapter_review',
49
+ 'complete',
50
+ ]),
51
+ content: z.string(),
52
+ }, async ({ projectPath, step, content }) => textResult(await submitStepResult({ projectPath, step, content })));
53
+ server.tool('get_context', 'Build purpose-specific context for generation, memory extraction, review, or revision.', {
54
+ projectPath: z.string().min(1),
55
+ purpose: z.enum([
56
+ 'chapter_generation',
57
+ 'memory_extraction',
58
+ 'continuity_review',
59
+ 'revision',
60
+ 'chapter_review',
61
+ 'cross_chapter_review',
62
+ ]),
63
+ chapterNumber: z.number().int().positive().optional(),
64
+ start: z.number().int().positive().optional(),
65
+ end: z.number().int().positive().optional(),
66
+ }, async ({ projectPath, purpose, chapterNumber, start, end }) => textResult(await buildContext({
67
+ projectPath,
68
+ purpose,
69
+ chapterNumber,
70
+ range: start && end ? { start, end } : undefined,
71
+ })));
72
+ server.tool('save_chapter', 'Save a generated chapter directly as Markdown.', {
73
+ projectPath: z.string().min(1),
74
+ chapterNumber: z.number().int().positive(),
75
+ title: z.string().min(1),
76
+ content: z.string().min(1),
77
+ }, async ({ projectPath, chapterNumber, title, content }) => {
78
+ const fileName = `chapters/${chapterFileName(chapterNumber)}`;
79
+ const savedPath = await saveMarkdownFile(projectPath, fileName, `# ${title}\n\n${content}`);
80
+ return textResult({ savedPath, suggestedNextStep: 'memory_card' });
81
+ });
82
+ server.tool('generate_chapter', 'Build the chapter-generation context and instruction for a specific chapter without changing workflow state.', {
83
+ projectPath: z.string().min(1),
84
+ chapterNumber: z.number().int().positive(),
85
+ }, async ({ projectPath, chapterNumber }) => textResult({
86
+ context: await buildContext({ projectPath, purpose: 'chapter_generation', chapterNumber }),
87
+ hint: 'Persist the result via save_chapter or submit_step_result(step="chapter") when the workflow currentStep is "chapter".',
88
+ }));
89
+ server.tool('extract_memory_card', 'Build the memory-extraction context for a specific chapter without changing workflow state.', {
90
+ projectPath: z.string().min(1),
91
+ chapterNumber: z.number().int().positive(),
92
+ }, async ({ projectPath, chapterNumber }) => textResult({
93
+ context: await buildContext({ projectPath, purpose: 'memory_extraction', chapterNumber }),
94
+ hint: 'Submit the extracted memory card via submit_step_result with step="memory_card" when the workflow currentStep matches.',
95
+ }));
96
+ server.tool('review_chapter', 'Ask the host to review a specific chapter. Switches the workflow into chapter_review side-track and returns the review prompt + packed context. Resume original step after submit_step_result(step="chapter_review").', {
97
+ projectPath: z.string().min(1),
98
+ chapterNumber: z.number().int().positive(),
99
+ }, async ({ projectPath, chapterNumber }) => textResult(await requestSideTrack({ projectPath, step: 'chapter_review', chapterNumber })));
100
+ server.tool('revise_chapter', 'Ask the host to rewrite a specific chapter based on prior review feedback and optional extra instructions. Previous version is archived under chapters/.versions/.', {
101
+ projectPath: z.string().min(1),
102
+ chapterNumber: z.number().int().positive(),
103
+ feedback: z.string().optional(),
104
+ }, async ({ projectPath, chapterNumber, feedback }) => textResult(await requestSideTrack({ projectPath, step: 'chapter_revision', chapterNumber, feedback })));
105
+ server.tool('retrieve', 'Lexical BM25-style retrieval over indexed chapter paragraphs, story-bible sections, and memory cards. Returns ranked snippets with chapter attribution.', {
106
+ projectPath: z.string().min(1),
107
+ query: z.string().min(1),
108
+ topK: z.number().int().positive().max(50).default(6),
109
+ types: z.array(z.enum(['chapter', 'bible', 'memory'])).optional(),
110
+ chapterStart: z.number().int().positive().optional(),
111
+ chapterEnd: z.number().int().positive().optional(),
112
+ }, async ({ projectPath, query, topK, types, chapterStart, chapterEnd }) => {
113
+ const chapterRange = chapterStart && chapterEnd ? { start: chapterStart, end: chapterEnd } : undefined;
114
+ const hits = await retrieve(projectPath, query, { topK, types, chapterRange });
115
+ return textResult({ query, hits });
116
+ });
117
+ server.tool('cross_chapter_review', 'Ask the host to review a chapter range for cross-chapter continuity conflicts. Defaults to all generated chapters.', {
118
+ projectPath: z.string().min(1),
119
+ start: z.number().int().positive().optional(),
120
+ end: z.number().int().positive().optional(),
121
+ }, async ({ projectPath, start, end }) => {
122
+ const range = start && end ? { start, end } : undefined;
123
+ return textResult(await requestSideTrack({ projectPath, step: 'cross_chapter_review', range }));
124
+ });
125
+ return server;
126
+ }