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.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/dist/src/cli/index.js +125 -0
- package/dist/src/core/contextBuilder.js +128 -0
- package/dist/src/core/fileNames.js +41 -0
- package/dist/src/core/index.js +9 -0
- package/dist/src/core/projectDiscovery.js +141 -0
- package/dist/src/core/projectStore.js +85 -0
- package/dist/src/core/prompts/en-US.js +363 -0
- package/dist/src/core/prompts/types.js +1 -0
- package/dist/src/core/prompts/zh-CN.js +362 -0
- package/dist/src/core/prompts.js +15 -0
- package/dist/src/core/retrieval/chunker.js +77 -0
- package/dist/src/core/retrieval/index.js +125 -0
- package/dist/src/core/retrieval/tokenizer.js +43 -0
- package/dist/src/core/retrieval/types.js +1 -0
- package/dist/src/core/schemas.js +91 -0
- package/dist/src/core/steps/architecture.js +16 -0
- package/dist/src/core/steps/chapter.js +16 -0
- package/dist/src/core/steps/chapterReview.js +16 -0
- package/dist/src/core/steps/chapterRevision.js +20 -0
- package/dist/src/core/steps/continuityReview.js +13 -0
- package/dist/src/core/steps/crossChapterReview.js +15 -0
- package/dist/src/core/steps/index.js +20 -0
- package/dist/src/core/steps/memoryCard.js +22 -0
- package/dist/src/core/steps/novelMetadata.js +12 -0
- package/dist/src/core/steps/storyBible.js +13 -0
- package/dist/src/core/steps/types.js +7 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/workflow.js +186 -0
- package/dist/src/mcp/server.js +13 -0
- package/dist/src/mcp/tools.js +126 -0
- package/package.json +61 -0
- package/src/cli/index.ts +147 -0
- package/src/core/contextBuilder.ts +131 -0
- package/src/core/fileNames.ts +48 -0
- package/src/core/index.ts +9 -0
- package/src/core/projectDiscovery.ts +174 -0
- package/src/core/projectStore.ts +111 -0
- package/src/core/prompts/en-US.ts +376 -0
- package/src/core/prompts/types.ts +28 -0
- package/src/core/prompts/zh-CN.ts +375 -0
- package/src/core/prompts.ts +27 -0
- package/src/core/retrieval/chunker.ts +80 -0
- package/src/core/retrieval/index.ts +136 -0
- package/src/core/retrieval/tokenizer.ts +44 -0
- package/src/core/retrieval/types.ts +24 -0
- package/src/core/schemas.ts +101 -0
- package/src/core/steps/architecture.ts +17 -0
- package/src/core/steps/chapter.ts +17 -0
- package/src/core/steps/chapterReview.ts +17 -0
- package/src/core/steps/chapterRevision.ts +21 -0
- package/src/core/steps/continuityReview.ts +14 -0
- package/src/core/steps/crossChapterReview.ts +16 -0
- package/src/core/steps/index.ts +25 -0
- package/src/core/steps/memoryCard.ts +23 -0
- package/src/core/steps/novelMetadata.ts +13 -0
- package/src/core/steps/storyBible.ts +14 -0
- package/src/core/steps/types.ts +21 -0
- package/src/core/types.ts +115 -0
- package/src/core/workflow.ts +250 -0
- package/src/mcp/server.ts +15 -0
- package/src/mcp/tools.ts +227 -0
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "novelforge-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"codex",
|
|
10
|
+
"novel",
|
|
11
|
+
"fiction",
|
|
12
|
+
"writing",
|
|
13
|
+
"agent",
|
|
14
|
+
"workflow",
|
|
15
|
+
"bm25",
|
|
16
|
+
"retrieval"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/linkzhao/novelforge-agent#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/linkzhao/novelforge-agent/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/linkzhao/novelforge-agent.git"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "linkzhao <linkzhao8@gmail.com>",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"bin": {
|
|
30
|
+
"novelforge-agent": "./dist/src/cli/index.js",
|
|
31
|
+
"novelforge-agent-mcp": "./dist/src/mcp/server.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist/src/**/*.js",
|
|
35
|
+
"dist/src/**/*.d.ts",
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"test": "npm run build && node --test dist/test/*.test.js",
|
|
46
|
+
"dev:mcp": "tsx src/mcp/server.ts",
|
|
47
|
+
"dev:cli": "tsx src/cli/index.ts",
|
|
48
|
+
"prepublishOnly": "npm run build && npm test",
|
|
49
|
+
"prepare": "npm run build"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.17.0",
|
|
53
|
+
"minisearch": "^7.2.0",
|
|
54
|
+
"zod": "^3.25.76"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^20.19.3",
|
|
58
|
+
"tsx": "^4.20.3",
|
|
59
|
+
"typescript": "^5.8.3"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import {
|
|
4
|
+
buildContext,
|
|
5
|
+
createProject,
|
|
6
|
+
getNextStep,
|
|
7
|
+
getProjectStatus,
|
|
8
|
+
listProjects,
|
|
9
|
+
requestSideTrack,
|
|
10
|
+
retrieve,
|
|
11
|
+
submitStepResult,
|
|
12
|
+
} from '../core/index.js';
|
|
13
|
+
|
|
14
|
+
function valueAfter(args: string[], name: string): string | undefined {
|
|
15
|
+
const index = args.indexOf(name);
|
|
16
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseLanguage(value: string): 'zh-CN' | 'en-US' {
|
|
20
|
+
if (value === 'zh-CN' || value === 'en-US') return value;
|
|
21
|
+
throw new Error('Invalid --language. Use zh-CN or en-US');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runCli(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
|
|
25
|
+
const [command, projectPath] = argv;
|
|
26
|
+
|
|
27
|
+
if (command === 'start') {
|
|
28
|
+
const prompt = valueAfter(argv, '--prompt') || '';
|
|
29
|
+
if (!prompt.trim()) throw new Error('Missing --prompt');
|
|
30
|
+
const language = parseLanguage(valueAfter(argv, '--language') || 'zh-CN');
|
|
31
|
+
const chapters = Number(valueAfter(argv, '--chapters') || 3);
|
|
32
|
+
const outputDir = valueAfter(argv, '--output') || 'novels';
|
|
33
|
+
const result = await createProject({ workspaceRoot: cwd, prompt, language, outputDir, targetChapters: chapters });
|
|
34
|
+
const next = await getNextStep(result.state.projectPath);
|
|
35
|
+
console.log(JSON.stringify({ state: result.state, next }, null, 2));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (command === 'next') {
|
|
40
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
41
|
+
console.log(JSON.stringify(await getNextStep(projectPath), null, 2));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (command === 'submit') {
|
|
46
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
47
|
+
const step = valueAfter(argv, '--step');
|
|
48
|
+
const file = valueAfter(argv, '--file');
|
|
49
|
+
if (!step || !file) throw new Error('Missing --step or --file');
|
|
50
|
+
const content = await readFile(file, 'utf8');
|
|
51
|
+
console.log(JSON.stringify(await submitStepResult({ projectPath, step: step as any, content }), null, 2));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (command === 'context') {
|
|
56
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
57
|
+
const purpose = valueAfter(argv, '--purpose') || 'chapter_generation';
|
|
58
|
+
const chapter = valueAfter(argv, '--chapter');
|
|
59
|
+
const start = valueAfter(argv, '--start');
|
|
60
|
+
const end = valueAfter(argv, '--end');
|
|
61
|
+
console.log(await buildContext({
|
|
62
|
+
projectPath,
|
|
63
|
+
purpose: purpose as any,
|
|
64
|
+
chapterNumber: chapter ? Number(chapter) : undefined,
|
|
65
|
+
range: start && end ? { start: Number(start), end: Number(end) } : undefined,
|
|
66
|
+
}));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (command === 'review') {
|
|
71
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
72
|
+
const chapter = valueAfter(argv, '--chapter');
|
|
73
|
+
if (!chapter) throw new Error('Missing --chapter');
|
|
74
|
+
console.log(JSON.stringify(
|
|
75
|
+
await requestSideTrack({ projectPath, step: 'chapter_review', chapterNumber: Number(chapter) }),
|
|
76
|
+
null,
|
|
77
|
+
2
|
|
78
|
+
));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (command === 'revise') {
|
|
83
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
84
|
+
const chapter = valueAfter(argv, '--chapter');
|
|
85
|
+
if (!chapter) throw new Error('Missing --chapter');
|
|
86
|
+
const feedbackFile = valueAfter(argv, '--feedback-file');
|
|
87
|
+
const feedback = feedbackFile ? await readFile(feedbackFile, 'utf8') : valueAfter(argv, '--feedback');
|
|
88
|
+
console.log(JSON.stringify(
|
|
89
|
+
await requestSideTrack({ projectPath, step: 'chapter_revision', chapterNumber: Number(chapter), feedback }),
|
|
90
|
+
null,
|
|
91
|
+
2
|
|
92
|
+
));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (command === 'cross-review') {
|
|
97
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
98
|
+
const start = valueAfter(argv, '--start');
|
|
99
|
+
const end = valueAfter(argv, '--end');
|
|
100
|
+
const range = start && end ? { start: Number(start), end: Number(end) } : undefined;
|
|
101
|
+
console.log(JSON.stringify(
|
|
102
|
+
await requestSideTrack({ projectPath, step: 'cross_chapter_review', range }),
|
|
103
|
+
null,
|
|
104
|
+
2
|
|
105
|
+
));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (command === 'list') {
|
|
110
|
+
const outputDir = valueAfter(argv, '--output') || 'novels';
|
|
111
|
+
console.log(JSON.stringify(await listProjects({ workspaceRoot: cwd, outputDir }), null, 2));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (command === 'status') {
|
|
116
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
117
|
+
console.log(JSON.stringify(await getProjectStatus(projectPath), null, 2));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (command === 'retrieve') {
|
|
122
|
+
if (!projectPath) throw new Error('Missing projectPath');
|
|
123
|
+
const query = valueAfter(argv, '--query');
|
|
124
|
+
if (!query) throw new Error('Missing --query');
|
|
125
|
+
const topK = valueAfter(argv, '--top-k');
|
|
126
|
+
const start = valueAfter(argv, '--start');
|
|
127
|
+
const end = valueAfter(argv, '--end');
|
|
128
|
+
const typesArg = valueAfter(argv, '--types');
|
|
129
|
+
const types = typesArg ? (typesArg.split(',') as Array<'chapter' | 'bible' | 'memory'>) : undefined;
|
|
130
|
+
const hits = await retrieve(projectPath, query, {
|
|
131
|
+
topK: topK ? Number(topK) : undefined,
|
|
132
|
+
types,
|
|
133
|
+
chapterRange: start && end ? { start: Number(start), end: Number(end) } : undefined,
|
|
134
|
+
});
|
|
135
|
+
console.log(JSON.stringify({ query, hits }, null, 2));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw new Error('Usage: novelforge-agent start|list|status|next|submit|context|review|revise|cross-review|retrieve');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
143
|
+
runCli().catch((error) => {
|
|
144
|
+
console.error((error as Error).message);
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { chapterFileName, chapterReviewFileName, memoryFileName } from './fileNames.js';
|
|
4
|
+
import { formatHits, retrieve } from './retrieval/index.js';
|
|
5
|
+
|
|
6
|
+
export type ContextPurpose =
|
|
7
|
+
| 'chapter_generation'
|
|
8
|
+
| 'memory_extraction'
|
|
9
|
+
| 'continuity_review'
|
|
10
|
+
| 'revision'
|
|
11
|
+
| 'chapter_review'
|
|
12
|
+
| 'cross_chapter_review';
|
|
13
|
+
|
|
14
|
+
export interface BuildContextInput {
|
|
15
|
+
projectPath: string;
|
|
16
|
+
purpose: ContextPurpose;
|
|
17
|
+
chapterNumber?: number;
|
|
18
|
+
range?: { start: number; end: number };
|
|
19
|
+
feedback?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readOptional(path: string): Promise<string> {
|
|
23
|
+
try {
|
|
24
|
+
return await readFile(path, 'utf8');
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
31
|
+
const parts: string[] = [];
|
|
32
|
+
const metadata = await readOptional(join(input.projectPath, 'novel.json'));
|
|
33
|
+
const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
|
|
34
|
+
const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
|
|
35
|
+
|
|
36
|
+
if (metadata) parts.push(`## Novel Metadata\n${metadata}`);
|
|
37
|
+
if (storyBible) parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
|
|
38
|
+
|
|
39
|
+
if (input.purpose === 'chapter_generation' && input.chapterNumber) {
|
|
40
|
+
let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string } | undefined;
|
|
41
|
+
if (chaptersJson) {
|
|
42
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
|
|
43
|
+
const chapter = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
44
|
+
if (chapter) {
|
|
45
|
+
currentArchitectureForQuery = chapter;
|
|
46
|
+
parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (input.chapterNumber > 1) {
|
|
50
|
+
const previous = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber - 1)));
|
|
51
|
+
const previousMemory = await readOptional(join(input.projectPath, 'memory', memoryFileName(input.chapterNumber - 1)));
|
|
52
|
+
if (previous) parts.push(`## Previous Chapter Ending\n${previous.slice(-1600)}`);
|
|
53
|
+
if (previousMemory) parts.push(`## Previous Chapter Memory\n${previousMemory}`);
|
|
54
|
+
|
|
55
|
+
const queryPieces: string[] = [];
|
|
56
|
+
if (currentArchitectureForQuery?.title) queryPieces.push(currentArchitectureForQuery.title);
|
|
57
|
+
if (currentArchitectureForQuery?.summary) queryPieces.push(currentArchitectureForQuery.summary);
|
|
58
|
+
if (currentArchitectureForQuery?.requiredBeats?.length) {
|
|
59
|
+
queryPieces.push(currentArchitectureForQuery.requiredBeats.join(' '));
|
|
60
|
+
}
|
|
61
|
+
const query = queryPieces.join(' ').trim();
|
|
62
|
+
if (query) {
|
|
63
|
+
const hits = await retrieve(input.projectPath, query, {
|
|
64
|
+
topK: 5,
|
|
65
|
+
chapterRange: { start: 1, end: input.chapterNumber - 1 },
|
|
66
|
+
});
|
|
67
|
+
const formatted = formatHits(hits);
|
|
68
|
+
if (formatted) parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (input.purpose === 'memory_extraction' && input.chapterNumber) {
|
|
74
|
+
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
|
|
75
|
+
if (chapter) parts.push(`## Current Chapter\n${chapter}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (input.purpose === 'continuity_review') {
|
|
79
|
+
if (chaptersJson) parts.push(`## Chapter Architecture List\n${chaptersJson}`);
|
|
80
|
+
const memoryParts: string[] = [];
|
|
81
|
+
for (let i = 1; i <= 20; i += 1) {
|
|
82
|
+
const memory = await readOptional(join(input.projectPath, 'memory', memoryFileName(i)));
|
|
83
|
+
if (memory) memoryParts.push(`### Chapter ${i}\n${memory}`);
|
|
84
|
+
}
|
|
85
|
+
if (memoryParts.length) parts.push(`## Memory Cards\n${memoryParts.join('\n')}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (input.purpose === 'chapter_review' && input.chapterNumber) {
|
|
89
|
+
if (chaptersJson) {
|
|
90
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
|
|
91
|
+
const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
92
|
+
if (arch) parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
|
|
93
|
+
}
|
|
94
|
+
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
|
|
95
|
+
if (chapter) parts.push(`## Chapter ${input.chapterNumber} Text\n${chapter}`);
|
|
96
|
+
if (input.chapterNumber > 1) {
|
|
97
|
+
const prevMemory = await readOptional(join(input.projectPath, 'memory', memoryFileName(input.chapterNumber - 1)));
|
|
98
|
+
if (prevMemory) parts.push(`## Previous Chapter Memory\n${prevMemory}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (input.purpose === 'revision' && input.chapterNumber) {
|
|
103
|
+
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
|
|
104
|
+
if (chapter) parts.push(`## Current Chapter Text\n${chapter}`);
|
|
105
|
+
const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
|
|
106
|
+
if (review) parts.push(`## Editor Review\n${review}`);
|
|
107
|
+
if (input.feedback) parts.push(`## Additional Feedback\n${input.feedback}`);
|
|
108
|
+
if (input.chapterNumber > 1) {
|
|
109
|
+
const previous = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber - 1)));
|
|
110
|
+
if (previous) parts.push(`## Previous Chapter Ending\n${previous.slice(-1600)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (input.purpose === 'cross_chapter_review' && input.range) {
|
|
115
|
+
if (chaptersJson) parts.push(`## Chapter Architecture List\n${chaptersJson}`);
|
|
116
|
+
const memoryParts: string[] = [];
|
|
117
|
+
for (let i = input.range.start; i <= input.range.end; i += 1) {
|
|
118
|
+
const memory = await readOptional(join(input.projectPath, 'memory', memoryFileName(i)));
|
|
119
|
+
if (memory) memoryParts.push(`### Chapter ${i} Memory\n${memory}`);
|
|
120
|
+
}
|
|
121
|
+
if (memoryParts.length) parts.push(`## Memory Cards In Range\n${memoryParts.join('\n')}`);
|
|
122
|
+
const tailParts: string[] = [];
|
|
123
|
+
for (let i = input.range.start; i <= input.range.end; i += 1) {
|
|
124
|
+
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(i)));
|
|
125
|
+
if (chapter) tailParts.push(`### Chapter ${i} Last 800 Chars\n${chapter.slice(-800)}`);
|
|
126
|
+
}
|
|
127
|
+
if (tailParts.length) parts.push(`## Chapter Tails\n${tailParts.join('\n')}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parts.join('\n\n').trim();
|
|
131
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const PINYIN_FALLBACK: Record<string, string> = {
|
|
2
|
+
星: 'xing',
|
|
3
|
+
火: 'huo',
|
|
4
|
+
长: 'chang',
|
|
5
|
+
夜: 'ye',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function makeProjectSlug(title: string): string {
|
|
9
|
+
const replaced = title
|
|
10
|
+
.trim()
|
|
11
|
+
.split('')
|
|
12
|
+
.map((char) => PINYIN_FALLBACK[char] || char)
|
|
13
|
+
.join('-')
|
|
14
|
+
.normalize('NFKD')
|
|
15
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '');
|
|
19
|
+
return replaced || `novel-${Date.now()}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function padChapterNumber(chapterNumber: number): string {
|
|
23
|
+
if (!Number.isInteger(chapterNumber) || chapterNumber <= 0) {
|
|
24
|
+
throw new Error(`Invalid chapter number: ${chapterNumber}`);
|
|
25
|
+
}
|
|
26
|
+
return String(chapterNumber).padStart(3, '0');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function chapterFileName(chapterNumber: number): string {
|
|
30
|
+
return `${padChapterNumber(chapterNumber)}.md`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function memoryFileName(chapterNumber: number): string {
|
|
34
|
+
return `chapter-${padChapterNumber(chapterNumber)}.json`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function chapterReviewFileName(chapterNumber: number): string {
|
|
38
|
+
return `chapter-${padChapterNumber(chapterNumber)}.json`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function crossChapterReviewFileName(start: number, end: number): string {
|
|
42
|
+
return `cross-${padChapterNumber(start)}-${padChapterNumber(end)}.json`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function chapterVersionFileName(chapterNumber: number, timestamp: string): string {
|
|
46
|
+
const safeTs = timestamp.replace(/[:.]/g, '-');
|
|
47
|
+
return `${padChapterNumber(chapterNumber)}.${safeTs}.md`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './schemas.js';
|
|
3
|
+
export * from './fileNames.js';
|
|
4
|
+
export * from './projectStore.js';
|
|
5
|
+
export * from './workflow.js';
|
|
6
|
+
export * from './contextBuilder.js';
|
|
7
|
+
export * from './prompts.js';
|
|
8
|
+
export * from './retrieval/index.js';
|
|
9
|
+
export * from './projectDiscovery.js';
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { AgentState, NovelMetadata, WorkflowStep } from './types.js';
|
|
4
|
+
import { loadState } from './projectStore.js';
|
|
5
|
+
|
|
6
|
+
export interface ProjectSummary {
|
|
7
|
+
projectId: string;
|
|
8
|
+
projectPath: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
genre?: string;
|
|
11
|
+
language: AgentState['language'];
|
|
12
|
+
currentStep: WorkflowStep;
|
|
13
|
+
currentChapter: number;
|
|
14
|
+
targetChapters: number;
|
|
15
|
+
completedSteps: number;
|
|
16
|
+
chaptersWritten: number;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ListProjectsInput {
|
|
22
|
+
workspaceRoot: string;
|
|
23
|
+
outputDir?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readMetadata(projectPath: string): Promise<NovelMetadata | undefined> {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(join(projectPath, 'novel.json'), 'utf8');
|
|
29
|
+
return JSON.parse(raw) as NovelMetadata;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function countChapters(state: AgentState): number {
|
|
36
|
+
let count = 0;
|
|
37
|
+
for (const key of Object.keys(state.files)) {
|
|
38
|
+
if (/^chapter-\d+$/.test(key)) count += 1;
|
|
39
|
+
}
|
|
40
|
+
return count;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function summarizeOne(projectPath: string): Promise<ProjectSummary | undefined> {
|
|
44
|
+
let state: AgentState;
|
|
45
|
+
try {
|
|
46
|
+
state = await loadState(projectPath);
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const metadata = await readMetadata(projectPath);
|
|
51
|
+
return {
|
|
52
|
+
projectId: state.projectId,
|
|
53
|
+
projectPath,
|
|
54
|
+
title: metadata?.title,
|
|
55
|
+
genre: metadata?.genre,
|
|
56
|
+
language: state.language,
|
|
57
|
+
currentStep: state.currentStep,
|
|
58
|
+
currentChapter: state.currentChapter,
|
|
59
|
+
targetChapters: state.targetChapters,
|
|
60
|
+
completedSteps: state.completedSteps.length,
|
|
61
|
+
chaptersWritten: countChapters(state),
|
|
62
|
+
updatedAt: state.updatedAt,
|
|
63
|
+
createdAt: state.createdAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function listProjects(input: ListProjectsInput): Promise<ProjectSummary[]> {
|
|
68
|
+
const root = resolve(input.workspaceRoot, input.outputDir ?? 'novels');
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(root);
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const summaries = await Promise.all(
|
|
76
|
+
entries.map((entry) => summarizeOne(join(root, entry)))
|
|
77
|
+
);
|
|
78
|
+
return summaries
|
|
79
|
+
.filter((s): s is ProjectSummary => Boolean(s))
|
|
80
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ProjectStatus extends ProjectSummary {
|
|
84
|
+
pendingAction?: AgentState['pendingAction'];
|
|
85
|
+
files: Record<string, string>;
|
|
86
|
+
openThreads: string[];
|
|
87
|
+
latestReview?: {
|
|
88
|
+
type: 'chapter' | 'cross' | 'continuity';
|
|
89
|
+
path: string;
|
|
90
|
+
status?: string;
|
|
91
|
+
chapterNumber?: number;
|
|
92
|
+
range?: { start: number; end: number };
|
|
93
|
+
issueCount?: number;
|
|
94
|
+
};
|
|
95
|
+
done: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function findLatestReview(projectPath: string, files: Record<string, string>): Promise<ProjectStatus['latestReview']> {
|
|
99
|
+
const keys = Object.keys(files);
|
|
100
|
+
const chapterReviewKeys = keys.filter((k) => k.startsWith('review-chapter-'));
|
|
101
|
+
const crossReviewKeys = keys.filter((k) => k.startsWith('review-cross-'));
|
|
102
|
+
const continuityReviewKey = files.continuityReview;
|
|
103
|
+
|
|
104
|
+
type Candidate = { type: 'chapter' | 'cross' | 'continuity'; relative: string; key: string };
|
|
105
|
+
const candidates: Candidate[] = [];
|
|
106
|
+
for (const key of chapterReviewKeys) candidates.push({ type: 'chapter', relative: files[key], key });
|
|
107
|
+
for (const key of crossReviewKeys) candidates.push({ type: 'cross', relative: files[key], key });
|
|
108
|
+
if (continuityReviewKey) candidates.push({ type: 'continuity', relative: continuityReviewKey, key: 'continuityReview' });
|
|
109
|
+
if (!candidates.length) return undefined;
|
|
110
|
+
|
|
111
|
+
// pick the most-recently-modified one by reading mtimes; fall back to last in map order
|
|
112
|
+
const { stat } = await import('node:fs/promises');
|
|
113
|
+
let best: { c: Candidate; mtime: number } | undefined;
|
|
114
|
+
for (const c of candidates) {
|
|
115
|
+
try {
|
|
116
|
+
const s = await stat(join(projectPath, c.relative));
|
|
117
|
+
if (!best || s.mtimeMs > best.mtime) best = { c, mtime: s.mtimeMs };
|
|
118
|
+
} catch {
|
|
119
|
+
// skip missing files
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!best) return undefined;
|
|
123
|
+
try {
|
|
124
|
+
const raw = await readFile(join(projectPath, best.c.relative), 'utf8');
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
return {
|
|
127
|
+
type: best.c.type,
|
|
128
|
+
path: best.c.relative,
|
|
129
|
+
status: parsed?.status,
|
|
130
|
+
chapterNumber: parsed?.chapterNumber,
|
|
131
|
+
range: parsed?.range,
|
|
132
|
+
issueCount: Array.isArray(parsed?.issues) ? parsed.issues.length : undefined,
|
|
133
|
+
};
|
|
134
|
+
} catch {
|
|
135
|
+
return { type: best.c.type, path: best.c.relative };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function collectOpenThreads(projectPath: string, state: AgentState): Promise<string[]> {
|
|
140
|
+
const threads: string[] = [];
|
|
141
|
+
const memoryKeys = Object.keys(state.files).filter((k) => /^memory-\d+$/.test(k));
|
|
142
|
+
for (const key of memoryKeys) {
|
|
143
|
+
try {
|
|
144
|
+
const raw = await readFile(join(projectPath, state.files[key]), 'utf8');
|
|
145
|
+
const parsed = JSON.parse(raw) as { openThreads?: string[] };
|
|
146
|
+
if (Array.isArray(parsed.openThreads)) {
|
|
147
|
+
for (const thread of parsed.openThreads) {
|
|
148
|
+
if (thread && !threads.includes(thread)) threads.push(thread);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return threads;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function getProjectStatus(projectPath: string): Promise<ProjectStatus> {
|
|
159
|
+
const summary = await summarizeOne(projectPath);
|
|
160
|
+
if (!summary) throw new Error(`Not a NovelForge project: ${projectPath}`);
|
|
161
|
+
const state = await loadState(projectPath);
|
|
162
|
+
const [latestReview, openThreads] = await Promise.all([
|
|
163
|
+
findLatestReview(projectPath, state.files),
|
|
164
|
+
collectOpenThreads(projectPath, state),
|
|
165
|
+
]);
|
|
166
|
+
return {
|
|
167
|
+
...summary,
|
|
168
|
+
pendingAction: state.pendingAction,
|
|
169
|
+
files: state.files,
|
|
170
|
+
openThreads,
|
|
171
|
+
latestReview,
|
|
172
|
+
done: state.currentStep === 'complete',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { AgentState } from './types.js';
|
|
5
|
+
import { makeProjectSlug } from './fileNames.js';
|
|
6
|
+
|
|
7
|
+
export interface CreateProjectInput {
|
|
8
|
+
workspaceRoot: string;
|
|
9
|
+
prompt: string;
|
|
10
|
+
language?: AgentState['language'];
|
|
11
|
+
outputDir?: string;
|
|
12
|
+
targetChapters?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CreateProjectResult {
|
|
16
|
+
state: AgentState;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertInsideWorkspace(workspaceRoot: string, targetPath: string): void {
|
|
20
|
+
const root = resolve(workspaceRoot);
|
|
21
|
+
const target = resolve(targetPath);
|
|
22
|
+
const rel = relative(root, target);
|
|
23
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
24
|
+
throw new Error(`Refusing to write outside workspace: ${target}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function ensureProjectDirectories(projectPath: string): Promise<void> {
|
|
29
|
+
await mkdir(projectPath, { recursive: true });
|
|
30
|
+
await mkdir(join(projectPath, 'architecture'), { recursive: true });
|
|
31
|
+
await mkdir(join(projectPath, 'chapters'), { recursive: true });
|
|
32
|
+
await mkdir(join(projectPath, 'chapters/.versions'), { recursive: true });
|
|
33
|
+
await mkdir(join(projectPath, 'memory'), { recursive: true });
|
|
34
|
+
await mkdir(join(projectPath, 'reviews'), { recursive: true });
|
|
35
|
+
await mkdir(join(projectPath, 'reviews/chapter'), { recursive: true });
|
|
36
|
+
await mkdir(join(projectPath, 'reviews/cross'), { recursive: true });
|
|
37
|
+
await mkdir(join(projectPath, '.agent-recovery'), { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function archiveChapterVersion(projectPath: string, chapterRelative: string, versionRelative: string): Promise<string | undefined> {
|
|
41
|
+
const sourcePath = join(projectPath, chapterRelative);
|
|
42
|
+
try {
|
|
43
|
+
const existing = await readFile(sourcePath, 'utf8');
|
|
44
|
+
return saveMarkdownFile(projectPath, versionRelative, existing);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function createProject(input: CreateProjectInput): Promise<CreateProjectResult> {
|
|
51
|
+
const workspaceRoot = resolve(input.workspaceRoot);
|
|
52
|
+
const baseDir = input.outputDir || 'novels';
|
|
53
|
+
const targetChapters = Math.max(1, Math.floor(Number(input.targetChapters || 3)));
|
|
54
|
+
const baseSlug = makeProjectSlug(input.prompt.slice(0, 48));
|
|
55
|
+
const suffix = randomBytes(3).toString('hex');
|
|
56
|
+
const slug = `${baseSlug}-${suffix}`;
|
|
57
|
+
const projectPath = resolve(workspaceRoot, baseDir, slug);
|
|
58
|
+
assertInsideWorkspace(workspaceRoot, projectPath);
|
|
59
|
+
await ensureProjectDirectories(projectPath);
|
|
60
|
+
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
const state: AgentState = {
|
|
63
|
+
projectId: randomUUID(),
|
|
64
|
+
projectPath,
|
|
65
|
+
initialPrompt: input.prompt,
|
|
66
|
+
language: input.language || 'zh-CN',
|
|
67
|
+
targetChapters,
|
|
68
|
+
currentStep: 'novel_metadata',
|
|
69
|
+
currentChapter: 1,
|
|
70
|
+
completedSteps: [],
|
|
71
|
+
files: {},
|
|
72
|
+
createdAt: now,
|
|
73
|
+
updatedAt: now,
|
|
74
|
+
};
|
|
75
|
+
await saveState(state);
|
|
76
|
+
return { state };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loadState(projectPath: string): Promise<AgentState> {
|
|
80
|
+
const raw = await readFile(join(projectPath, 'agent-state.json'), 'utf8');
|
|
81
|
+
return JSON.parse(raw) as AgentState;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function saveState(state: AgentState): Promise<void> {
|
|
85
|
+
const nextState = { ...state, updatedAt: new Date().toISOString() };
|
|
86
|
+
await writeFile(
|
|
87
|
+
join(state.projectPath, 'agent-state.json'),
|
|
88
|
+
`${JSON.stringify(nextState, null, 2)}\n`,
|
|
89
|
+
'utf8'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function saveJsonFile(projectPath: string, relativePath: string, value: unknown): Promise<string> {
|
|
94
|
+
const fullPath = join(projectPath, relativePath);
|
|
95
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
96
|
+
await writeFile(fullPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
97
|
+
return fullPath;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function saveMarkdownFile(projectPath: string, relativePath: string, value: string): Promise<string> {
|
|
101
|
+
const fullPath = join(projectPath, relativePath);
|
|
102
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
103
|
+
await writeFile(fullPath, value.endsWith('\n') ? value : `${value}\n`, 'utf8');
|
|
104
|
+
return fullPath;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function saveRecoveryFile(projectPath: string, step: string, content: string): Promise<string> {
|
|
108
|
+
const safeStep = step.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
|
|
109
|
+
const fileName = `.agent-recovery/failed-${safeStep}-${Date.now()}.txt`;
|
|
110
|
+
return saveMarkdownFile(projectPath, fileName, content);
|
|
111
|
+
}
|