novelforge-agent 0.1.1 → 0.3.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 +45 -18
- package/dist/src/cli/index.js +81 -4
- package/dist/src/core/bibleStore.js +36 -0
- package/dist/src/core/characterStore.js +74 -0
- package/dist/src/core/contextBuilder.js +76 -1
- package/dist/src/core/fileNames.js +4 -0
- package/dist/src/core/index.js +4 -0
- package/dist/src/core/projectDiscovery.js +1 -0
- package/dist/src/core/projectOps.js +193 -0
- package/dist/src/core/projectStore.js +15 -1
- package/dist/src/core/prompts/en-US.js +247 -16
- package/dist/src/core/prompts/zh-CN.js +246 -15
- package/dist/src/core/retrieval/index.js +8 -0
- package/dist/src/core/schemas.js +121 -1
- package/dist/src/core/steps/architecture.js +7 -1
- package/dist/src/core/steps/architectureExtension.js +72 -0
- package/dist/src/core/steps/chapter.js +11 -1
- package/dist/src/core/steps/chapterReview.js +26 -1
- package/dist/src/core/steps/chapterRevision.js +17 -0
- package/dist/src/core/steps/index.js +4 -0
- package/dist/src/core/steps/memoryCard.js +26 -1
- package/dist/src/core/steps/novelMetadata.js +4 -2
- package/dist/src/core/steps/storyBible.js +1 -1
- package/dist/src/core/steps/styleGuide.js +12 -0
- package/dist/src/core/threadStore.js +150 -0
- package/dist/src/core/workflow.js +5 -3
- package/dist/src/mcp/tools.js +228 -20
- package/package.json +5 -1
- package/src/cli/index.ts +84 -3
- package/src/core/bibleStore.ts +57 -0
- package/src/core/characterStore.ts +93 -0
- package/src/core/contextBuilder.ts +74 -4
- package/src/core/fileNames.ts +5 -0
- package/src/core/index.ts +4 -0
- package/src/core/projectDiscovery.ts +2 -0
- package/src/core/projectOps.ts +251 -0
- package/src/core/projectStore.ts +19 -1
- package/src/core/prompts/en-US.ts +258 -25
- package/src/core/prompts/types.ts +4 -1
- package/src/core/prompts/zh-CN.ts +250 -17
- package/src/core/retrieval/index.ts +10 -0
- package/src/core/schemas.ts +133 -1
- package/src/core/steps/architecture.ts +7 -1
- package/src/core/steps/architectureExtension.ts +88 -0
- package/src/core/steps/chapter.ts +11 -1
- package/src/core/steps/chapterReview.ts +28 -1
- package/src/core/steps/chapterRevision.ts +18 -0
- package/src/core/steps/index.ts +4 -0
- package/src/core/steps/memoryCard.ts +27 -1
- package/src/core/steps/novelMetadata.ts +4 -2
- package/src/core/steps/storyBible.ts +1 -1
- package/src/core/steps/styleGuide.ts +13 -0
- package/src/core/threadStore.ts +173 -0
- package/src/core/types.ts +134 -1
- package/src/core/workflow.ts +5 -3
- package/src/mcp/tools.ts +351 -21
|
@@ -5,12 +5,37 @@ import { chapterReviewFileName } from '../fileNames.js';
|
|
|
5
5
|
import { parseJson } from './types.js';
|
|
6
6
|
export const chapterReviewHandler = async (state, content) => {
|
|
7
7
|
const parsed = ChapterReviewSchema.parse(parseJson(content));
|
|
8
|
+
const hasFailedAcceptance = Object.values(parsed.acceptance).some((check) => check.status === 'fail');
|
|
8
9
|
const target = state.pendingAction?.chapterNumber ?? parsed.chapterNumber;
|
|
9
10
|
const relative = join('reviews/chapter', chapterReviewFileName(target));
|
|
10
11
|
const path = await saveJsonFile(state.projectPath, relative, parsed);
|
|
12
|
+
if (state.pendingAction?.mode === 'side_track') {
|
|
13
|
+
return {
|
|
14
|
+
savedPaths: [path],
|
|
15
|
+
fileEntries: { [`review-chapter-${target}`]: relative },
|
|
16
|
+
next: { kind: 'sideTrackReturn' },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (parsed.status === 'clean' && !hasFailedAcceptance) {
|
|
20
|
+
return {
|
|
21
|
+
savedPaths: [path],
|
|
22
|
+
fileEntries: { [`review-chapter-${target}`]: relative },
|
|
23
|
+
next: { kind: 'linear', nextStep: 'memory_card' },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
11
26
|
return {
|
|
12
27
|
savedPaths: [path],
|
|
13
28
|
fileEntries: { [`review-chapter-${target}`]: relative },
|
|
14
|
-
next: {
|
|
29
|
+
next: {
|
|
30
|
+
kind: 'linear',
|
|
31
|
+
nextStep: 'chapter_revision',
|
|
32
|
+
statePatch: {
|
|
33
|
+
pendingAction: {
|
|
34
|
+
step: 'chapter_revision',
|
|
35
|
+
mode: 'gate',
|
|
36
|
+
chapterNumber: target,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
15
40
|
};
|
|
16
41
|
};
|
|
@@ -12,6 +12,23 @@ export const chapterRevisionHandler = async (state, content) => {
|
|
|
12
12
|
const savedPaths = archived ? [archived] : [];
|
|
13
13
|
savedPaths.push(await saveMarkdownFile(state.projectPath, chapterRelative, content));
|
|
14
14
|
await indexChapter(state.projectPath, target, content);
|
|
15
|
+
if (state.pendingAction?.mode === 'gate') {
|
|
16
|
+
return {
|
|
17
|
+
savedPaths,
|
|
18
|
+
fileEntries: { [`chapter-${target}`]: chapterRelative },
|
|
19
|
+
next: {
|
|
20
|
+
kind: 'linear',
|
|
21
|
+
nextStep: 'chapter_review',
|
|
22
|
+
statePatch: {
|
|
23
|
+
pendingAction: {
|
|
24
|
+
step: 'chapter_review',
|
|
25
|
+
mode: 'gate',
|
|
26
|
+
chapterNumber: target,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
15
32
|
return {
|
|
16
33
|
savedPaths,
|
|
17
34
|
fileEntries: { [`chapter-${target}`]: chapterRelative },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { architectureExtensionHandler } from './architectureExtension.js';
|
|
1
2
|
import { architectureHandler } from './architecture.js';
|
|
2
3
|
import { chapterHandler } from './chapter.js';
|
|
3
4
|
import { chapterReviewHandler } from './chapterReview.js';
|
|
@@ -6,11 +7,14 @@ import { continuityReviewHandler } from './continuityReview.js';
|
|
|
6
7
|
import { crossChapterReviewHandler } from './crossChapterReview.js';
|
|
7
8
|
import { memoryCardHandler } from './memoryCard.js';
|
|
8
9
|
import { novelMetadataHandler } from './novelMetadata.js';
|
|
10
|
+
import { styleGuideHandler } from './styleGuide.js';
|
|
9
11
|
import { storyBibleHandler } from './storyBible.js';
|
|
10
12
|
export const STEP_HANDLERS = {
|
|
11
13
|
novel_metadata: novelMetadataHandler,
|
|
12
14
|
story_bible: storyBibleHandler,
|
|
15
|
+
style_guide: styleGuideHandler,
|
|
13
16
|
architecture: architectureHandler,
|
|
17
|
+
architecture_extension: architectureExtensionHandler,
|
|
14
18
|
chapter: chapterHandler,
|
|
15
19
|
memory_card: memoryCardHandler,
|
|
16
20
|
continuity_review: continuityReviewHandler,
|
|
@@ -1,21 +1,46 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
1
2
|
import { join } from 'node:path';
|
|
2
3
|
import { MemoryCardSchema } from '../schemas.js';
|
|
3
4
|
import { saveJsonFile } from '../projectStore.js';
|
|
4
5
|
import { memoryFileName } from '../fileNames.js';
|
|
5
6
|
import { indexMemoryCard } from '../retrieval/index.js';
|
|
7
|
+
import { ingestMemoryCardThreads } from '../threadStore.js';
|
|
8
|
+
import { applyCharacterUpdates } from '../characterStore.js';
|
|
6
9
|
import { parseJson } from './types.js';
|
|
10
|
+
async function maxPlannedChapter(projectPath) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = await readFile(join(projectPath, 'architecture/chapters.json'), 'utf8');
|
|
13
|
+
const chapters = JSON.parse(raw);
|
|
14
|
+
return chapters.reduce((max, chapter) => {
|
|
15
|
+
const value = Number(chapter.chapterNumber);
|
|
16
|
+
return Number.isFinite(value) && value > max ? value : max;
|
|
17
|
+
}, 0);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
7
23
|
export const memoryCardHandler = async (state, content) => {
|
|
8
24
|
const parsed = MemoryCardSchema.parse(parseJson(content));
|
|
9
25
|
const relative = join('memory', memoryFileName(state.currentChapter));
|
|
10
26
|
const path = await saveJsonFile(state.projectPath, relative, parsed);
|
|
11
27
|
await indexMemoryCard(state.projectPath, state.currentChapter, parsed);
|
|
28
|
+
await ingestMemoryCardThreads(state.projectPath, state.currentChapter, parsed.threadActions);
|
|
29
|
+
await applyCharacterUpdates(state.projectPath, state.currentChapter, parsed.characterUpdates);
|
|
12
30
|
const nextChapter = state.currentChapter + 1;
|
|
31
|
+
const plannedTotalChapters = state.plannedTotalChapters ?? state.targetChapters;
|
|
32
|
+
const plannedMax = await maxPlannedChapter(state.projectPath);
|
|
33
|
+
const nextStep = nextChapter > plannedTotalChapters
|
|
34
|
+
? 'continuity_review'
|
|
35
|
+
: nextChapter > plannedMax
|
|
36
|
+
? 'architecture_extension'
|
|
37
|
+
: 'chapter';
|
|
13
38
|
return {
|
|
14
39
|
savedPaths: [path],
|
|
15
40
|
fileEntries: { [`memory-${state.currentChapter}`]: relative },
|
|
16
41
|
next: {
|
|
17
42
|
kind: 'linear',
|
|
18
|
-
nextStep
|
|
43
|
+
nextStep,
|
|
19
44
|
statePatch: { currentChapter: nextChapter },
|
|
20
45
|
},
|
|
21
46
|
};
|
|
@@ -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,12 @@
|
|
|
1
|
+
import { StyleGuideSchema } from '../schemas.js';
|
|
2
|
+
import { saveJsonFile } from '../projectStore.js';
|
|
3
|
+
import { parseJson } from './types.js';
|
|
4
|
+
export const styleGuideHandler = async (state, content) => {
|
|
5
|
+
const parsed = StyleGuideSchema.parse(parseJson(content));
|
|
6
|
+
const path = await saveJsonFile(state.projectPath, 'style-guide.json', parsed);
|
|
7
|
+
return {
|
|
8
|
+
savedPaths: [path],
|
|
9
|
+
fileEntries: { styleGuide: 'style-guide.json' },
|
|
10
|
+
next: { kind: 'linear', nextStep: 'architecture' },
|
|
11
|
+
};
|
|
12
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,8 @@ import { buildPromptForStep } from './prompts.js';
|
|
|
5
5
|
import { loadState, saveJsonFile, saveRecoveryFile, saveState } from './projectStore.js';
|
|
6
6
|
import { STEP_HANDLERS } from './steps/index.js';
|
|
7
7
|
const CONTEXT_RECIPES = {
|
|
8
|
+
style_guide: () => ({ purpose: 'style_guide' }),
|
|
9
|
+
architecture_extension: (s) => ({ purpose: 'architecture_extension', chapterNumber: s.currentChapter }),
|
|
8
10
|
chapter: (s) => ({ purpose: 'chapter_generation', chapterNumber: s.currentChapter }),
|
|
9
11
|
memory_card: (s) => ({ purpose: 'memory_extraction', chapterNumber: s.currentChapter }),
|
|
10
12
|
continuity_review: () => ({ purpose: 'continuity_review' }),
|
|
@@ -83,19 +85,19 @@ function buildPendingAction(state, input) {
|
|
|
83
85
|
case 'chapter_review': {
|
|
84
86
|
if (!input.chapterNumber)
|
|
85
87
|
throw new Error('chapter_review requires chapterNumber');
|
|
86
|
-
return { step: 'chapter_review', chapterNumber: input.chapterNumber };
|
|
88
|
+
return { step: 'chapter_review', mode: 'side_track', chapterNumber: input.chapterNumber };
|
|
87
89
|
}
|
|
88
90
|
case 'chapter_revision': {
|
|
89
91
|
if (!input.chapterNumber)
|
|
90
92
|
throw new Error('chapter_revision requires chapterNumber');
|
|
91
|
-
return { step: 'chapter_revision', chapterNumber: input.chapterNumber, feedback: input.feedback };
|
|
93
|
+
return { step: 'chapter_revision', mode: 'side_track', chapterNumber: input.chapterNumber, feedback: input.feedback };
|
|
92
94
|
}
|
|
93
95
|
case 'cross_chapter_review': {
|
|
94
96
|
const max = maxExistingChapter(state);
|
|
95
97
|
const range = input.range ?? { start: 1, end: max || state.currentChapter };
|
|
96
98
|
if (range.start < 1 || range.end < range.start)
|
|
97
99
|
throw new Error('Invalid range');
|
|
98
|
-
return { step: 'cross_chapter_review', range };
|
|
100
|
+
return { step: 'cross_chapter_review', mode: 'side_track', range };
|
|
99
101
|
}
|
|
100
102
|
default:
|
|
101
103
|
throw new Error(`Unknown side-track step: ${input.step}`);
|