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
|
@@ -3,12 +3,16 @@ import { MemoryCardSchema } from '../schemas.js';
|
|
|
3
3
|
import { saveJsonFile } from '../projectStore.js';
|
|
4
4
|
import { memoryFileName } from '../fileNames.js';
|
|
5
5
|
import { indexMemoryCard } from '../retrieval/index.js';
|
|
6
|
+
import { ingestMemoryCardThreads } from '../threadStore.js';
|
|
7
|
+
import { applyCharacterUpdates } from '../characterStore.js';
|
|
6
8
|
import { parseJson } from './types.js';
|
|
7
9
|
export const memoryCardHandler = async (state, content) => {
|
|
8
10
|
const parsed = MemoryCardSchema.parse(parseJson(content));
|
|
9
11
|
const relative = join('memory', memoryFileName(state.currentChapter));
|
|
10
12
|
const path = await saveJsonFile(state.projectPath, relative, parsed);
|
|
11
13
|
await indexMemoryCard(state.projectPath, state.currentChapter, parsed);
|
|
14
|
+
await ingestMemoryCardThreads(state.projectPath, state.currentChapter, parsed.threadActions);
|
|
15
|
+
await applyCharacterUpdates(state.projectPath, state.currentChapter, parsed.characterUpdates);
|
|
12
16
|
const nextChapter = state.currentChapter + 1;
|
|
13
17
|
return {
|
|
14
18
|
savedPaths: [path],
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { NovelMetadataSchema } from '../schemas.js';
|
|
2
2
|
import { saveJsonFile } from '../projectStore.js';
|
|
3
|
+
import { initializeCharacterStates } from '../characterStore.js';
|
|
3
4
|
import { parseJson } from './types.js';
|
|
4
5
|
export const novelMetadataHandler = async (state, content) => {
|
|
5
6
|
const parsed = NovelMetadataSchema.parse(parseJson(content));
|
|
6
7
|
const path = await saveJsonFile(state.projectPath, 'novel.json', parsed);
|
|
8
|
+
const charactersPath = await initializeCharacterStates(state.projectPath, parsed.coreCast);
|
|
7
9
|
return {
|
|
8
|
-
savedPaths: [path],
|
|
9
|
-
fileEntries: { novel: 'novel.json' },
|
|
10
|
+
savedPaths: [path, charactersPath],
|
|
11
|
+
fileEntries: { novel: 'novel.json', characters: 'characters.json' },
|
|
10
12
|
next: { kind: 'linear', nextStep: 'story_bible' },
|
|
11
13
|
};
|
|
12
14
|
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const THREADS_FILE = 'threads.json';
|
|
5
|
+
export async function loadThreads(projectPath) {
|
|
6
|
+
try {
|
|
7
|
+
const raw = await readFile(join(projectPath, THREADS_FILE), 'utf8');
|
|
8
|
+
const parsed = JSON.parse(raw);
|
|
9
|
+
return Array.isArray(parsed.threads) ? parsed.threads : [];
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function saveThreads(projectPath, threads) {
|
|
16
|
+
const fullPath = join(projectPath, THREADS_FILE);
|
|
17
|
+
const bundle = { threads };
|
|
18
|
+
await writeFile(fullPath, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8');
|
|
19
|
+
return fullPath;
|
|
20
|
+
}
|
|
21
|
+
function newThreadId(existing) {
|
|
22
|
+
let candidate = `t_${randomBytes(3).toString('hex')}`;
|
|
23
|
+
while (existing.has(candidate)) {
|
|
24
|
+
candidate = `t_${randomBytes(3).toString('hex')}`;
|
|
25
|
+
}
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
function findByDescription(threads, description) {
|
|
29
|
+
const target = description.trim();
|
|
30
|
+
return threads.find((t) => t.description.trim() === target);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Apply the threadActions emitted by a memory_card for chapter `chapterNumber`.
|
|
34
|
+
* Behavior:
|
|
35
|
+
* - 'plant' → create new thread (or reuse if identical description already planted)
|
|
36
|
+
* - 'build' → mark existing thread status = 'building', bump lastTouchedAt
|
|
37
|
+
* - 'pay' → mark existing thread status = 'paid', set paidOffAt
|
|
38
|
+
* - 'drop' → mark existing thread status = 'dropped', set droppedAt
|
|
39
|
+
* Unknown threadIds for non-plant actions are tolerated (a new thread is created and marked appropriately, so we never lose user intent).
|
|
40
|
+
*/
|
|
41
|
+
export function applyThreadActions(existing, chapterNumber, actions) {
|
|
42
|
+
if (!actions || !actions.length)
|
|
43
|
+
return existing;
|
|
44
|
+
const next = existing.map((t) => ({ ...t }));
|
|
45
|
+
const byId = new Map(next.map((t) => [t.id, t]));
|
|
46
|
+
const usedIds = new Set(next.map((t) => t.id));
|
|
47
|
+
for (const action of actions) {
|
|
48
|
+
if (action.kind === 'plant') {
|
|
49
|
+
const dup = findByDescription(next, action.description);
|
|
50
|
+
if (dup) {
|
|
51
|
+
dup.lastTouchedAt = Math.max(dup.lastTouchedAt, chapterNumber);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const id = action.threadId && !usedIds.has(action.threadId)
|
|
55
|
+
? action.threadId
|
|
56
|
+
: newThreadId(usedIds);
|
|
57
|
+
usedIds.add(id);
|
|
58
|
+
const planted = {
|
|
59
|
+
id,
|
|
60
|
+
description: action.description.trim(),
|
|
61
|
+
status: 'planted',
|
|
62
|
+
plantedAt: chapterNumber,
|
|
63
|
+
lastTouchedAt: chapterNumber,
|
|
64
|
+
};
|
|
65
|
+
next.push(planted);
|
|
66
|
+
byId.set(id, planted);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// build / pay / drop need an existing thread
|
|
70
|
+
let target = action.threadId ? byId.get(action.threadId) : undefined;
|
|
71
|
+
if (!target) {
|
|
72
|
+
target = findByDescription(next, action.description);
|
|
73
|
+
}
|
|
74
|
+
if (!target) {
|
|
75
|
+
// Create a placeholder so the user intent is captured; mark planted+touched on this chapter
|
|
76
|
+
const id = newThreadId(usedIds);
|
|
77
|
+
usedIds.add(id);
|
|
78
|
+
target = {
|
|
79
|
+
id,
|
|
80
|
+
description: action.description.trim(),
|
|
81
|
+
status: 'planted',
|
|
82
|
+
plantedAt: chapterNumber,
|
|
83
|
+
lastTouchedAt: chapterNumber,
|
|
84
|
+
notes: `Auto-created from a ${action.kind} action without a known threadId.`,
|
|
85
|
+
};
|
|
86
|
+
next.push(target);
|
|
87
|
+
byId.set(id, target);
|
|
88
|
+
}
|
|
89
|
+
target.lastTouchedAt = chapterNumber;
|
|
90
|
+
if (action.kind === 'build') {
|
|
91
|
+
target.status = 'building';
|
|
92
|
+
}
|
|
93
|
+
else if (action.kind === 'pay') {
|
|
94
|
+
target.status = 'paid';
|
|
95
|
+
target.paidOffAt = chapterNumber;
|
|
96
|
+
}
|
|
97
|
+
else if (action.kind === 'drop') {
|
|
98
|
+
target.status = 'dropped';
|
|
99
|
+
target.droppedAt = chapterNumber;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return next;
|
|
103
|
+
}
|
|
104
|
+
export function activeThreads(threads) {
|
|
105
|
+
return threads.filter((t) => t.status === 'planted' || t.status === 'building');
|
|
106
|
+
}
|
|
107
|
+
export async function ingestMemoryCardThreads(projectPath, chapterNumber, actions) {
|
|
108
|
+
if (!actions || !actions.length)
|
|
109
|
+
return loadThreads(projectPath);
|
|
110
|
+
const existing = await loadThreads(projectPath);
|
|
111
|
+
const next = applyThreadActions(existing, chapterNumber, actions);
|
|
112
|
+
await saveThreads(projectPath, next);
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
export async function updateThread(projectPath, id, patch) {
|
|
116
|
+
const existing = await loadThreads(projectPath);
|
|
117
|
+
const target = existing.find((t) => t.id === id);
|
|
118
|
+
if (!target)
|
|
119
|
+
throw new Error(`Thread not found: ${id}`);
|
|
120
|
+
if (patch.status)
|
|
121
|
+
target.status = patch.status;
|
|
122
|
+
if (patch.description)
|
|
123
|
+
target.description = patch.description.trim();
|
|
124
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'plannedPayoffAt')) {
|
|
125
|
+
if (patch.plannedPayoffAt === null)
|
|
126
|
+
delete target.plannedPayoffAt;
|
|
127
|
+
else if (typeof patch.plannedPayoffAt === 'number')
|
|
128
|
+
target.plannedPayoffAt = patch.plannedPayoffAt;
|
|
129
|
+
}
|
|
130
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'paidOffAt')) {
|
|
131
|
+
if (patch.paidOffAt === null)
|
|
132
|
+
delete target.paidOffAt;
|
|
133
|
+
else if (typeof patch.paidOffAt === 'number')
|
|
134
|
+
target.paidOffAt = patch.paidOffAt;
|
|
135
|
+
}
|
|
136
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'droppedAt')) {
|
|
137
|
+
if (patch.droppedAt === null)
|
|
138
|
+
delete target.droppedAt;
|
|
139
|
+
else if (typeof patch.droppedAt === 'number')
|
|
140
|
+
target.droppedAt = patch.droppedAt;
|
|
141
|
+
}
|
|
142
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'notes')) {
|
|
143
|
+
if (patch.notes === null)
|
|
144
|
+
delete target.notes;
|
|
145
|
+
else if (typeof patch.notes === 'string')
|
|
146
|
+
target.notes = patch.notes;
|
|
147
|
+
}
|
|
148
|
+
await saveThreads(projectPath, existing);
|
|
149
|
+
return target;
|
|
150
|
+
}
|
|
@@ -83,19 +83,19 @@ function buildPendingAction(state, input) {
|
|
|
83
83
|
case 'chapter_review': {
|
|
84
84
|
if (!input.chapterNumber)
|
|
85
85
|
throw new Error('chapter_review requires chapterNumber');
|
|
86
|
-
return { step: 'chapter_review', chapterNumber: input.chapterNumber };
|
|
86
|
+
return { step: 'chapter_review', mode: 'side_track', chapterNumber: input.chapterNumber };
|
|
87
87
|
}
|
|
88
88
|
case 'chapter_revision': {
|
|
89
89
|
if (!input.chapterNumber)
|
|
90
90
|
throw new Error('chapter_revision requires chapterNumber');
|
|
91
|
-
return { step: 'chapter_revision', chapterNumber: input.chapterNumber, feedback: input.feedback };
|
|
91
|
+
return { step: 'chapter_revision', mode: 'side_track', chapterNumber: input.chapterNumber, feedback: input.feedback };
|
|
92
92
|
}
|
|
93
93
|
case 'cross_chapter_review': {
|
|
94
94
|
const max = maxExistingChapter(state);
|
|
95
95
|
const range = input.range ?? { start: 1, end: max || state.currentChapter };
|
|
96
96
|
if (range.start < 1 || range.end < range.start)
|
|
97
97
|
throw new Error('Invalid range');
|
|
98
|
-
return { step: 'cross_chapter_review', range };
|
|
98
|
+
return { step: 'cross_chapter_review', mode: 'side_track', range };
|
|
99
99
|
}
|
|
100
100
|
default:
|
|
101
101
|
throw new Error(`Unknown side-track step: ${input.step}`);
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { amendStoryBible, assertProjectPath, buildContext, createProject, deleteChapter, forkProject, getNextStep, getProjectStatus, listProjects, listStoryBibleVersions, loadState, loadThreads, redoStep, requestSideTrack, retrieve, submitStepResult, updateThread, } from '../core/index.js';
|
|
5
|
+
const MCP_SERVER_VERSION = '0.2.0';
|
|
4
6
|
function textResult(value) {
|
|
5
7
|
return {
|
|
6
8
|
content: [{
|
|
@@ -10,9 +12,17 @@ function textResult(value) {
|
|
|
10
12
|
};
|
|
11
13
|
}
|
|
12
14
|
export function createNovelAgentServer(options) {
|
|
15
|
+
function checkedProjectPath(projectPath) {
|
|
16
|
+
assertProjectPath(options.workspaceRoot, projectPath);
|
|
17
|
+
return projectPath;
|
|
18
|
+
}
|
|
19
|
+
function checkedOutputDir(outputDir) {
|
|
20
|
+
assertProjectPath(options.workspaceRoot, resolve(options.workspaceRoot, outputDir));
|
|
21
|
+
return outputDir;
|
|
22
|
+
}
|
|
13
23
|
const server = new McpServer({
|
|
14
24
|
name: 'novelforge-agent',
|
|
15
|
-
version:
|
|
25
|
+
version: MCP_SERVER_VERSION,
|
|
16
26
|
});
|
|
17
27
|
server.tool('start_novel_project', 'Create a local novel project and return the first generation instruction.', {
|
|
18
28
|
prompt: z.string().min(1),
|
|
@@ -31,9 +41,9 @@ export function createNovelAgentServer(options) {
|
|
|
31
41
|
});
|
|
32
42
|
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
43
|
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)));
|
|
44
|
+
}, async ({ outputDir }) => textResult(await listProjects({ workspaceRoot: options.workspaceRoot, outputDir: checkedOutputDir(outputDir) })));
|
|
45
|
+
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(checkedProjectPath(projectPath))));
|
|
46
|
+
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(checkedProjectPath(projectPath))));
|
|
37
47
|
server.tool('submit_step_result', 'Submit host-generated content for validation, saving, and workflow advancement.', {
|
|
38
48
|
projectPath: z.string().min(1),
|
|
39
49
|
step: z.enum([
|
|
@@ -49,7 +59,7 @@ export function createNovelAgentServer(options) {
|
|
|
49
59
|
'complete',
|
|
50
60
|
]),
|
|
51
61
|
content: z.string(),
|
|
52
|
-
}, async ({ projectPath, step, content }) => textResult(await submitStepResult({ projectPath, step, content })));
|
|
62
|
+
}, async ({ projectPath, step, content }) => textResult(await submitStepResult({ projectPath: checkedProjectPath(projectPath), step, content })));
|
|
53
63
|
server.tool('get_context', 'Build purpose-specific context for generation, memory extraction, review, or revision.', {
|
|
54
64
|
projectPath: z.string().min(1),
|
|
55
65
|
purpose: z.enum([
|
|
@@ -64,44 +74,51 @@ export function createNovelAgentServer(options) {
|
|
|
64
74
|
start: z.number().int().positive().optional(),
|
|
65
75
|
end: z.number().int().positive().optional(),
|
|
66
76
|
}, async ({ projectPath, purpose, chapterNumber, start, end }) => textResult(await buildContext({
|
|
67
|
-
projectPath,
|
|
77
|
+
projectPath: checkedProjectPath(projectPath),
|
|
68
78
|
purpose,
|
|
69
79
|
chapterNumber,
|
|
70
80
|
range: start && end ? { start, end } : undefined,
|
|
71
81
|
})));
|
|
72
|
-
server.tool('save_chapter', '
|
|
82
|
+
server.tool('save_chapter', 'Submit a generated chapter Markdown draft through the workflow state machine. This requires currentStep="chapter" and advances to chapter_review.', {
|
|
73
83
|
projectPath: z.string().min(1),
|
|
74
84
|
chapterNumber: z.number().int().positive(),
|
|
75
85
|
title: z.string().min(1),
|
|
76
86
|
content: z.string().min(1),
|
|
77
87
|
}, async ({ projectPath, chapterNumber, title, content }) => {
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
88
|
+
const checked = checkedProjectPath(projectPath);
|
|
89
|
+
const state = await loadState(checked);
|
|
90
|
+
if (state.currentStep !== 'chapter' || state.currentChapter !== chapterNumber) {
|
|
91
|
+
throw new Error(`save_chapter requires currentStep="chapter" and currentChapter=${chapterNumber}; got currentStep="${state.currentStep}", currentChapter=${state.currentChapter}`);
|
|
92
|
+
}
|
|
93
|
+
return textResult(await submitStepResult({
|
|
94
|
+
projectPath: checked,
|
|
95
|
+
step: 'chapter',
|
|
96
|
+
content: `# ${title}\n\n${content}`,
|
|
97
|
+
}));
|
|
81
98
|
});
|
|
82
99
|
server.tool('generate_chapter', 'Build the chapter-generation context and instruction for a specific chapter without changing workflow state.', {
|
|
83
100
|
projectPath: z.string().min(1),
|
|
84
101
|
chapterNumber: z.number().int().positive(),
|
|
85
102
|
}, async ({ projectPath, chapterNumber }) => textResult({
|
|
86
|
-
context: await buildContext({ projectPath, purpose: 'chapter_generation', chapterNumber }),
|
|
87
|
-
hint: 'Persist the result via
|
|
103
|
+
context: await buildContext({ projectPath: checkedProjectPath(projectPath), purpose: 'chapter_generation', chapterNumber }),
|
|
104
|
+
hint: 'Persist the result via submit_step_result(step="chapter") when the workflow currentStep is "chapter"; the workflow then requires chapter_review before memory_card.',
|
|
88
105
|
}));
|
|
89
106
|
server.tool('extract_memory_card', 'Build the memory-extraction context for a specific chapter without changing workflow state.', {
|
|
90
107
|
projectPath: z.string().min(1),
|
|
91
108
|
chapterNumber: z.number().int().positive(),
|
|
92
109
|
}, async ({ projectPath, chapterNumber }) => textResult({
|
|
93
|
-
context: await buildContext({ projectPath, purpose: 'memory_extraction', chapterNumber }),
|
|
110
|
+
context: await buildContext({ projectPath: checkedProjectPath(projectPath), purpose: 'memory_extraction', chapterNumber }),
|
|
94
111
|
hint: 'Submit the extracted memory card via submit_step_result with step="memory_card" when the workflow currentStep matches.',
|
|
95
112
|
}));
|
|
96
113
|
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
114
|
projectPath: z.string().min(1),
|
|
98
115
|
chapterNumber: z.number().int().positive(),
|
|
99
|
-
}, async ({ projectPath, chapterNumber }) => textResult(await requestSideTrack({ projectPath, step: 'chapter_review', chapterNumber })));
|
|
116
|
+
}, async ({ projectPath, chapterNumber }) => textResult(await requestSideTrack({ projectPath: checkedProjectPath(projectPath), step: 'chapter_review', chapterNumber })));
|
|
100
117
|
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
118
|
projectPath: z.string().min(1),
|
|
102
119
|
chapterNumber: z.number().int().positive(),
|
|
103
120
|
feedback: z.string().optional(),
|
|
104
|
-
}, async ({ projectPath, chapterNumber, feedback }) => textResult(await requestSideTrack({ projectPath, step: 'chapter_revision', chapterNumber, feedback })));
|
|
121
|
+
}, async ({ projectPath, chapterNumber, feedback }) => textResult(await requestSideTrack({ projectPath: checkedProjectPath(projectPath), step: 'chapter_revision', chapterNumber, feedback })));
|
|
105
122
|
server.tool('retrieve', 'Lexical BM25-style retrieval over indexed chapter paragraphs, story-bible sections, and memory cards. Returns ranked snippets with chapter attribution.', {
|
|
106
123
|
projectPath: z.string().min(1),
|
|
107
124
|
query: z.string().min(1),
|
|
@@ -111,7 +128,7 @@ export function createNovelAgentServer(options) {
|
|
|
111
128
|
chapterEnd: z.number().int().positive().optional(),
|
|
112
129
|
}, async ({ projectPath, query, topK, types, chapterStart, chapterEnd }) => {
|
|
113
130
|
const chapterRange = chapterStart && chapterEnd ? { start: chapterStart, end: chapterEnd } : undefined;
|
|
114
|
-
const hits = await retrieve(projectPath, query, { topK, types, chapterRange });
|
|
131
|
+
const hits = await retrieve(checkedProjectPath(projectPath), query, { topK, types, chapterRange });
|
|
115
132
|
return textResult({ query, hits });
|
|
116
133
|
});
|
|
117
134
|
server.tool('cross_chapter_review', 'Ask the host to review a chapter range for cross-chapter continuity conflicts. Defaults to all generated chapters.', {
|
|
@@ -120,7 +137,170 @@ export function createNovelAgentServer(options) {
|
|
|
120
137
|
end: z.number().int().positive().optional(),
|
|
121
138
|
}, async ({ projectPath, start, end }) => {
|
|
122
139
|
const range = start && end ? { start, end } : undefined;
|
|
123
|
-
return textResult(await requestSideTrack({ projectPath, step: 'cross_chapter_review', range }));
|
|
140
|
+
return textResult(await requestSideTrack({ projectPath: checkedProjectPath(projectPath), step: 'cross_chapter_review', range }));
|
|
124
141
|
});
|
|
142
|
+
// ----- v0.2 tools -----
|
|
143
|
+
server.tool('amend_story_bible', 'Replace the story bible with a revised version. Old version is auto-archived under story-bible-versions/ and the lexical index is rebuilt for the new content.', {
|
|
144
|
+
projectPath: z.string().min(1),
|
|
145
|
+
content: z.string().min(1),
|
|
146
|
+
reason: z.string().optional(),
|
|
147
|
+
}, async ({ projectPath, content, reason }) => textResult(await amendStoryBible({ projectPath: checkedProjectPath(projectPath), content, reason })));
|
|
148
|
+
server.tool('list_bible_versions', 'List archived story-bible versions for a project (filenames sorted oldest first).', { projectPath: z.string().min(1) }, async ({ projectPath }) => textResult({ versions: await listStoryBibleVersions(checkedProjectPath(projectPath)) }));
|
|
149
|
+
server.tool('list_threads', 'List foreshadow threads for a project, optionally filtered by status. Threads are aggregated from memory_card.threadActions.', {
|
|
150
|
+
projectPath: z.string().min(1),
|
|
151
|
+
status: z.enum(['planted', 'building', 'paid', 'dropped']).optional(),
|
|
152
|
+
}, async ({ projectPath, status }) => {
|
|
153
|
+
const all = await loadThreads(checkedProjectPath(projectPath));
|
|
154
|
+
const filtered = status ? all.filter((t) => t.status === status) : all;
|
|
155
|
+
return textResult({ threads: filtered });
|
|
156
|
+
});
|
|
157
|
+
server.tool('update_thread', 'Update a single foreshadow thread (override status, plannedPayoffAt, paidOffAt, droppedAt, description, notes).', {
|
|
158
|
+
projectPath: z.string().min(1),
|
|
159
|
+
id: z.string().min(1),
|
|
160
|
+
status: z.enum(['planted', 'building', 'paid', 'dropped']).optional(),
|
|
161
|
+
plannedPayoffAt: z.number().int().positive().nullable().optional(),
|
|
162
|
+
paidOffAt: z.number().int().positive().nullable().optional(),
|
|
163
|
+
droppedAt: z.number().int().positive().nullable().optional(),
|
|
164
|
+
description: z.string().min(1).optional(),
|
|
165
|
+
notes: z.string().nullable().optional(),
|
|
166
|
+
}, async ({ projectPath, id, ...patch }) => textResult(await updateThread(checkedProjectPath(projectPath), id, patch)));
|
|
167
|
+
server.tool('fork_project', 'Copy an existing project to a new sibling directory with a new projectId. Use to try alternate plot branches without losing the original.', {
|
|
168
|
+
sourceProjectPath: z.string().min(1),
|
|
169
|
+
label: z.string().optional(),
|
|
170
|
+
}, async ({ sourceProjectPath, label }) => textResult(await forkProject({ sourceProjectPath: checkedProjectPath(sourceProjectPath), label })));
|
|
171
|
+
server.tool('delete_chapter', 'Delete a chapter, its memory card, its single-chapter review, and all archived versions. Removes the chapter from the lexical index and rewinds the workflow if needed.', {
|
|
172
|
+
projectPath: z.string().min(1),
|
|
173
|
+
chapterNumber: z.number().int().positive(),
|
|
174
|
+
}, async ({ projectPath, chapterNumber }) => textResult(await deleteChapter({ projectPath: checkedProjectPath(projectPath), chapterNumber })));
|
|
175
|
+
server.tool('redo_step', 'Roll the workflow back to a specific step. Files produced by that step (and dependent chapter content for chapter/memory_card steps) are removed; the host must regenerate.', {
|
|
176
|
+
projectPath: z.string().min(1),
|
|
177
|
+
step: z.enum([
|
|
178
|
+
'novel_metadata',
|
|
179
|
+
'story_bible',
|
|
180
|
+
'architecture',
|
|
181
|
+
'chapter',
|
|
182
|
+
'memory_card',
|
|
183
|
+
'continuity_review',
|
|
184
|
+
]),
|
|
185
|
+
chapterNumber: z.number().int().positive().optional(),
|
|
186
|
+
}, async ({ projectPath, step, chapterNumber }) => textResult(await redoStep({ projectPath: checkedProjectPath(projectPath), step, chapterNumber })));
|
|
187
|
+
// ===== MCP Prompts (slash commands) =====
|
|
188
|
+
server.prompt('nf-start', 'Start a brand new novel project under the configured workspace.', {
|
|
189
|
+
prompt: z.string().describe('User idea / premise / genre, in any language.'),
|
|
190
|
+
chapters: z.string().optional().describe('Target number of chapters as a string. Defaults to 5.'),
|
|
191
|
+
}, ({ prompt, chapters }) => ({
|
|
192
|
+
messages: [{
|
|
193
|
+
role: 'user',
|
|
194
|
+
content: {
|
|
195
|
+
type: 'text',
|
|
196
|
+
text: `Use the novelforge MCP server. Call start_novel_project with prompt="${prompt}", targetChapters=${chapters ?? '5'}, then enter the autonomous loop: read next.instruction, generate the requested content, call submit_step_result, repeat until currentStep is "complete". Show me the projectPath after start_novel_project returns.`,
|
|
197
|
+
},
|
|
198
|
+
}],
|
|
199
|
+
}));
|
|
200
|
+
server.prompt('nf-next', 'Continue the current novelforge workflow by one step.', {
|
|
201
|
+
projectPath: z.string().describe('Absolute path to the project.'),
|
|
202
|
+
}, ({ projectPath }) => ({
|
|
203
|
+
messages: [{
|
|
204
|
+
role: 'user',
|
|
205
|
+
content: {
|
|
206
|
+
type: 'text',
|
|
207
|
+
text: `Use the novelforge MCP server. Call get_next_step with projectPath="${projectPath}". Read the returned instruction + context and produce the requested artifact, then call submit_step_result. Show me what step was advanced.`,
|
|
208
|
+
},
|
|
209
|
+
}],
|
|
210
|
+
}));
|
|
211
|
+
server.prompt('nf-list', 'List all novelforge projects in the workspace.', {}, () => ({
|
|
212
|
+
messages: [{
|
|
213
|
+
role: 'user',
|
|
214
|
+
content: {
|
|
215
|
+
type: 'text',
|
|
216
|
+
text: 'Use the novelforge MCP server: call list_projects with no arguments. Show me the result in a compact table (title, currentStep, chaptersWritten/targetChapters, updatedAt, projectPath).',
|
|
217
|
+
},
|
|
218
|
+
}],
|
|
219
|
+
}));
|
|
220
|
+
server.prompt('nf-status', 'Show a one-screen status for a novelforge project.', {
|
|
221
|
+
projectPath: z.string(),
|
|
222
|
+
}, ({ projectPath }) => ({
|
|
223
|
+
messages: [{
|
|
224
|
+
role: 'user',
|
|
225
|
+
content: {
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: `Use the novelforge MCP server: call get_project_status with projectPath="${projectPath}". Summarize: title, current step, chapters written, open threads count, latest review verdict.`,
|
|
228
|
+
},
|
|
229
|
+
}],
|
|
230
|
+
}));
|
|
231
|
+
server.prompt('nf-review-chapter', 'Run a single-chapter editorial review.', {
|
|
232
|
+
projectPath: z.string(),
|
|
233
|
+
chapterNumber: z.string(),
|
|
234
|
+
}, ({ projectPath, chapterNumber }) => ({
|
|
235
|
+
messages: [{
|
|
236
|
+
role: 'user',
|
|
237
|
+
content: {
|
|
238
|
+
type: 'text',
|
|
239
|
+
text: `Use the novelforge MCP server: call review_chapter with projectPath="${projectPath}", chapterNumber=${chapterNumber}. Read the returned instruction + context, produce the JSON chapter review, then call submit_step_result with step="chapter_review". Summarize the findings for me.`,
|
|
240
|
+
},
|
|
241
|
+
}],
|
|
242
|
+
}));
|
|
243
|
+
server.prompt('nf-revise-chapter', 'Revise a chapter based on review feedback or new instructions.', {
|
|
244
|
+
projectPath: z.string(),
|
|
245
|
+
chapterNumber: z.string(),
|
|
246
|
+
feedback: z.string().optional(),
|
|
247
|
+
}, ({ projectPath, chapterNumber, feedback }) => ({
|
|
248
|
+
messages: [{
|
|
249
|
+
role: 'user',
|
|
250
|
+
content: {
|
|
251
|
+
type: 'text',
|
|
252
|
+
text: `Use the novelforge MCP server: call revise_chapter with projectPath="${projectPath}", chapterNumber=${chapterNumber}${feedback ? `, feedback=${JSON.stringify(feedback)}` : ''}. Read the returned instruction + context, produce the revised Markdown chapter, then call submit_step_result with step="chapter_revision". Confirm the previous version was archived.`,
|
|
253
|
+
},
|
|
254
|
+
}],
|
|
255
|
+
}));
|
|
256
|
+
server.prompt('nf-cross-review', 'Cross-chapter continuity review over a range.', {
|
|
257
|
+
projectPath: z.string(),
|
|
258
|
+
start: z.string().optional(),
|
|
259
|
+
end: z.string().optional(),
|
|
260
|
+
}, ({ projectPath, start, end }) => ({
|
|
261
|
+
messages: [{
|
|
262
|
+
role: 'user',
|
|
263
|
+
content: {
|
|
264
|
+
type: 'text',
|
|
265
|
+
text: `Use the novelforge MCP server: call cross_chapter_review with projectPath="${projectPath}"${start && end ? `, start=${start}, end=${end}` : ''}. Read the returned instruction + context, produce the JSON cross-chapter review, then call submit_step_result with step="cross_chapter_review". Summarize verdict and any issues.`,
|
|
266
|
+
},
|
|
267
|
+
}],
|
|
268
|
+
}));
|
|
269
|
+
server.prompt('nf-retrieve', 'Lexical retrieval over a project (BM25-style).', {
|
|
270
|
+
projectPath: z.string(),
|
|
271
|
+
query: z.string(),
|
|
272
|
+
}, ({ projectPath, query }) => ({
|
|
273
|
+
messages: [{
|
|
274
|
+
role: 'user',
|
|
275
|
+
content: {
|
|
276
|
+
type: 'text',
|
|
277
|
+
text: `Use the novelforge MCP server: call retrieve with projectPath="${projectPath}", query=${JSON.stringify(query)}, topK=8. List the hits with chapter attribution and short excerpts.`,
|
|
278
|
+
},
|
|
279
|
+
}],
|
|
280
|
+
}));
|
|
281
|
+
server.prompt('nf-amend-bible', 'Amend the story bible with new content (previous version auto-archived).', {
|
|
282
|
+
projectPath: z.string(),
|
|
283
|
+
reason: z.string().optional(),
|
|
284
|
+
}, ({ projectPath, reason }) => ({
|
|
285
|
+
messages: [{
|
|
286
|
+
role: 'user',
|
|
287
|
+
content: {
|
|
288
|
+
type: 'text',
|
|
289
|
+
text: `Use the novelforge MCP server. First call get_project_status with projectPath="${projectPath}" to confirm the project exists, then read the current story-bible.md (you may use the host's filesystem tools). Apply the following amendment intent and produce a complete revised story bible Markdown:\n\n${reason ?? '(no specific reason supplied — ask the user what to change)'}\n\nThen call amend_story_bible with projectPath="${projectPath}" and the new content. Confirm the archived version path.`,
|
|
290
|
+
},
|
|
291
|
+
}],
|
|
292
|
+
}));
|
|
293
|
+
server.prompt('nf-threads', 'Show active foreshadow threads for a project.', {
|
|
294
|
+
projectPath: z.string(),
|
|
295
|
+
status: z.enum(['planted', 'building', 'paid', 'dropped']).optional(),
|
|
296
|
+
}, ({ projectPath, status }) => ({
|
|
297
|
+
messages: [{
|
|
298
|
+
role: 'user',
|
|
299
|
+
content: {
|
|
300
|
+
type: 'text',
|
|
301
|
+
text: `Use the novelforge MCP server: call list_threads with projectPath="${projectPath}"${status ? `, status="${status}"` : ''}. Show me the threads as a compact list: id, status, plantedAt, plannedPayoffAt (if set), description.`,
|
|
302
|
+
},
|
|
303
|
+
}],
|
|
304
|
+
}));
|
|
125
305
|
return server;
|
|
126
306
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "novelforge-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, …) or CLI. State machine + zod schemas + BM25 retrieval + persistent project state. No LLM dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc -p tsconfig.json",
|
|
45
45
|
"test": "npm run build && node --test dist/test/*.test.js",
|
|
46
|
+
"test:e2e": "npm run build && bash scripts/e2e.sh",
|
|
47
|
+
"inspect": "npm run build && npx -y @modelcontextprotocol/inspector node dist/src/mcp/server.js",
|
|
48
|
+
"inspect:tools": "npm run build && npx -y @modelcontextprotocol/inspector --cli node dist/src/mcp/server.js --method tools/list",
|
|
49
|
+
"inspect:prompts": "npm run build && npx -y @modelcontextprotocol/inspector --cli node dist/src/mcp/server.js --method prompts/list",
|
|
46
50
|
"dev:mcp": "tsx src/mcp/server.ts",
|
|
47
51
|
"dev:cli": "tsx src/cli/index.ts",
|
|
48
52
|
"prepublishOnly": "npm run build && npm test",
|
package/src/cli/index.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import {
|
|
4
|
+
amendStoryBible,
|
|
4
5
|
buildContext,
|
|
5
6
|
createProject,
|
|
7
|
+
deleteChapter,
|
|
8
|
+
forkProject,
|
|
6
9
|
getNextStep,
|
|
7
10
|
getProjectStatus,
|
|
8
11
|
listProjects,
|
|
12
|
+
loadThreads,
|
|
13
|
+
redoStep,
|
|
9
14
|
requestSideTrack,
|
|
10
15
|
retrieve,
|
|
11
16
|
submitStepResult,
|
|
17
|
+
updateThread,
|
|
12
18
|
} from '../core/index.js';
|
|
13
19
|
import { formatInstallResult, runInstall, InstallHost } from './install.js';
|
|
14
20
|
|
|
@@ -156,7 +162,74 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()):
|
|
|
156
162
|
return;
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
|
|
165
|
+
if (command === 'amend-bible') {
|
|
166
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
167
|
+
const file = valueAfter(argv, '--file');
|
|
168
|
+
const reason = valueAfter(argv, '--reason');
|
|
169
|
+
if (!file) throw new Error('Missing --file with new bible Markdown');
|
|
170
|
+
const content = await readFile(file, 'utf8');
|
|
171
|
+
console.log(JSON.stringify(await amendStoryBible({ projectPath, content, reason }), null, 2));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (command === 'threads') {
|
|
176
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
177
|
+
const status = valueAfter(argv, '--status') as 'planted' | 'building' | 'paid' | 'dropped' | undefined;
|
|
178
|
+
const all = await loadThreads(projectPath);
|
|
179
|
+
const filtered = status ? all.filter((t) => t.status === status) : all;
|
|
180
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (command === 'update-thread') {
|
|
185
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
186
|
+
const id = valueAfter(argv, '--id');
|
|
187
|
+
if (!id) throw new Error('Missing --id');
|
|
188
|
+
const status = valueAfter(argv, '--status') as 'planted' | 'building' | 'paid' | 'dropped' | undefined;
|
|
189
|
+
const plannedPayoffAt = valueAfter(argv, '--planned-payoff');
|
|
190
|
+
const description = valueAfter(argv, '--description');
|
|
191
|
+
const notes = valueAfter(argv, '--notes');
|
|
192
|
+
const updated = await updateThread(projectPath, id, {
|
|
193
|
+
status,
|
|
194
|
+
plannedPayoffAt: plannedPayoffAt ? Number(plannedPayoffAt) : undefined,
|
|
195
|
+
description,
|
|
196
|
+
notes,
|
|
197
|
+
});
|
|
198
|
+
console.log(JSON.stringify(updated, null, 2));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (command === 'fork') {
|
|
203
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
204
|
+
const label = valueAfter(argv, '--label');
|
|
205
|
+
console.log(JSON.stringify(await forkProject({ sourceProjectPath: projectPath, label }), null, 2));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (command === 'delete-chapter') {
|
|
210
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
211
|
+
const chapter = valueAfter(argv, '--chapter');
|
|
212
|
+
if (!chapter) throw new Error('Missing --chapter');
|
|
213
|
+
console.log(JSON.stringify(await deleteChapter({ projectPath, chapterNumber: Number(chapter) }), null, 2));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (command === 'redo') {
|
|
218
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
219
|
+
const step = valueAfter(argv, '--step') as
|
|
220
|
+
| 'novel_metadata' | 'story_bible' | 'architecture' | 'chapter' | 'memory_card' | 'continuity_review'
|
|
221
|
+
| undefined;
|
|
222
|
+
if (!step) throw new Error('Missing --step');
|
|
223
|
+
const chapter = valueAfter(argv, '--chapter');
|
|
224
|
+
console.log(JSON.stringify(await redoStep({
|
|
225
|
+
projectPath,
|
|
226
|
+
step,
|
|
227
|
+
chapterNumber: chapter ? Number(chapter) : undefined,
|
|
228
|
+
}), null, 2));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
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');
|
|
160
233
|
}
|
|
161
234
|
|
|
162
235
|
if (import.meta.url === `file://${process.argv[1]}`) {
|