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
|
@@ -2,9 +2,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { chapterFileName, chapterReviewFileName, memoryFileName } from './fileNames.js';
|
|
4
4
|
import { formatHits, retrieve } from './retrieval/index.js';
|
|
5
|
+
import { activeThreads, loadThreads } from './threadStore.js';
|
|
5
6
|
|
|
6
7
|
export type ContextPurpose =
|
|
7
8
|
| 'chapter_generation'
|
|
9
|
+
| 'style_guide'
|
|
10
|
+
| 'architecture_extension'
|
|
8
11
|
| 'memory_extraction'
|
|
9
12
|
| 'continuity_review'
|
|
10
13
|
| 'revision'
|
|
@@ -31,19 +34,36 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
31
34
|
const parts: string[] = [];
|
|
32
35
|
const metadata = await readOptional(join(input.projectPath, 'novel.json'));
|
|
33
36
|
const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
|
|
37
|
+
const styleGuideJson = await readOptional(join(input.projectPath, 'style-guide.json'));
|
|
34
38
|
const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
|
|
39
|
+
const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
|
|
40
|
+
const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
|
|
35
41
|
|
|
36
42
|
if (metadata) parts.push(`## Novel Metadata\n${metadata}`);
|
|
37
43
|
if (storyBible) parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
|
|
44
|
+
if (styleGuideJson) parts.push(`## Style Guide\n${styleGuideJson}`);
|
|
45
|
+
if (charactersJson) parts.push(`## Character State Table\n${charactersJson}`);
|
|
46
|
+
|
|
47
|
+
function addVolumePacing(volumeId?: string): void {
|
|
48
|
+
if (!volumePacingJson) return;
|
|
49
|
+
try {
|
|
50
|
+
const boards = JSON.parse(volumePacingJson) as Array<{ volumeId: string }>;
|
|
51
|
+
const board = volumeId ? boards.find((item) => item.volumeId === volumeId) : undefined;
|
|
52
|
+
parts.push(`## Volume Pacing Board\n${JSON.stringify(board ?? boards, null, 2)}`);
|
|
53
|
+
} catch {
|
|
54
|
+
parts.push(`## Volume Pacing Board\n${volumePacingJson}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
38
57
|
|
|
39
58
|
if (input.purpose === 'chapter_generation' && input.chapterNumber) {
|
|
40
|
-
let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string } | undefined;
|
|
59
|
+
let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string; volumeId?: string } | undefined;
|
|
41
60
|
if (chaptersJson) {
|
|
42
|
-
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
|
|
61
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
|
|
43
62
|
const chapter = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
44
63
|
if (chapter) {
|
|
45
64
|
currentArchitectureForQuery = chapter;
|
|
46
65
|
parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
|
|
66
|
+
addVolumePacing(chapter.volumeId);
|
|
47
67
|
}
|
|
48
68
|
}
|
|
49
69
|
if (input.chapterNumber > 1) {
|
|
@@ -67,6 +87,18 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
67
87
|
const formatted = formatHits(hits);
|
|
68
88
|
if (formatted) parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
|
|
69
89
|
}
|
|
90
|
+
|
|
91
|
+
const allThreads = await loadThreads(input.projectPath);
|
|
92
|
+
const active = activeThreads(allThreads);
|
|
93
|
+
if (active.length) {
|
|
94
|
+
const lines = active.map((t) => {
|
|
95
|
+
const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
|
|
96
|
+
if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
|
|
97
|
+
if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
|
|
98
|
+
return `- ${t.description} (${flags.join(', ')})`;
|
|
99
|
+
});
|
|
100
|
+
parts.push(`## Active Foreshadow Threads (do not silently drop or contradict)\n${lines.join('\n')}`);
|
|
101
|
+
}
|
|
70
102
|
}
|
|
71
103
|
}
|
|
72
104
|
|
|
@@ -85,11 +117,40 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
85
117
|
if (memoryParts.length) parts.push(`## Memory Cards\n${memoryParts.join('\n')}`);
|
|
86
118
|
}
|
|
87
119
|
|
|
120
|
+
if (input.purpose === 'architecture_extension') {
|
|
121
|
+
if (chaptersJson) parts.push(`## Existing Chapter Architecture List\n${chaptersJson}`);
|
|
122
|
+
if (volumePacingJson) parts.push(`## Existing Volume Pacing Boards\n${volumePacingJson}`);
|
|
123
|
+
|
|
124
|
+
const start = Math.max(1, (input.chapterNumber ?? 1) - 5);
|
|
125
|
+
const end = Math.max(0, (input.chapterNumber ?? 1) - 1);
|
|
126
|
+
const memoryParts: string[] = [];
|
|
127
|
+
for (let i = start; i <= end; i += 1) {
|
|
128
|
+
const memory = await readOptional(join(input.projectPath, 'memory', memoryFileName(i)));
|
|
129
|
+
if (memory) memoryParts.push(`### Chapter ${i} Memory\n${memory}`);
|
|
130
|
+
}
|
|
131
|
+
if (memoryParts.length) parts.push(`## Recent Memory Cards\n${memoryParts.join('\n')}`);
|
|
132
|
+
|
|
133
|
+
const allThreads = await loadThreads(input.projectPath);
|
|
134
|
+
const active = activeThreads(allThreads);
|
|
135
|
+
if (active.length) {
|
|
136
|
+
const lines = active.map((t) => {
|
|
137
|
+
const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
|
|
138
|
+
if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
|
|
139
|
+
if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
|
|
140
|
+
return `- ${t.description} (${flags.join(', ')})`;
|
|
141
|
+
});
|
|
142
|
+
parts.push(`## Active Foreshadow Threads\n${lines.join('\n')}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
88
146
|
if (input.purpose === 'chapter_review' && input.chapterNumber) {
|
|
89
147
|
if (chaptersJson) {
|
|
90
|
-
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
|
|
148
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
|
|
91
149
|
const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
92
|
-
if (arch)
|
|
150
|
+
if (arch) {
|
|
151
|
+
parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
|
|
152
|
+
addVolumePacing(arch.volumeId);
|
|
153
|
+
}
|
|
93
154
|
}
|
|
94
155
|
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
|
|
95
156
|
if (chapter) parts.push(`## Chapter ${input.chapterNumber} Text\n${chapter}`);
|
|
@@ -102,6 +163,15 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
102
163
|
if (input.purpose === 'revision' && input.chapterNumber) {
|
|
103
164
|
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
|
|
104
165
|
if (chapter) parts.push(`## Current Chapter Text\n${chapter}`);
|
|
166
|
+
if (chaptersJson) {
|
|
167
|
+
try {
|
|
168
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; volumeId?: string }>;
|
|
169
|
+
const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
170
|
+
addVolumePacing(arch?.volumeId);
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore malformed architecture here; review feedback is still useful
|
|
173
|
+
}
|
|
174
|
+
}
|
|
105
175
|
const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
|
|
106
176
|
if (review) parts.push(`## Editor Review\n${review}`);
|
|
107
177
|
if (input.feedback) parts.push(`## Additional Feedback\n${input.feedback}`);
|
package/src/core/fileNames.ts
CHANGED
|
@@ -46,3 +46,8 @@ export function chapterVersionFileName(chapterNumber: number, timestamp: string)
|
|
|
46
46
|
const safeTs = timestamp.replace(/[:.]/g, '-');
|
|
47
47
|
return `${padChapterNumber(chapterNumber)}.${safeTs}.md`;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
export function storyBibleVersionFileName(timestamp: string): string {
|
|
51
|
+
const safeTs = timestamp.replace(/[:.]/g, '-');
|
|
52
|
+
return `story-bible.${safeTs}.md`;
|
|
53
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -7,3 +7,7 @@ export * from './contextBuilder.js';
|
|
|
7
7
|
export * from './prompts.js';
|
|
8
8
|
export * from './retrieval/index.js';
|
|
9
9
|
export * from './projectDiscovery.js';
|
|
10
|
+
export * from './threadStore.js';
|
|
11
|
+
export * from './bibleStore.js';
|
|
12
|
+
export * from './projectOps.js';
|
|
13
|
+
export * from './characterStore.js';
|
|
@@ -12,6 +12,7 @@ export interface ProjectSummary {
|
|
|
12
12
|
currentStep: WorkflowStep;
|
|
13
13
|
currentChapter: number;
|
|
14
14
|
targetChapters: number;
|
|
15
|
+
plannedTotalChapters: number;
|
|
15
16
|
completedSteps: number;
|
|
16
17
|
chaptersWritten: number;
|
|
17
18
|
updatedAt: string;
|
|
@@ -57,6 +58,7 @@ async function summarizeOne(projectPath: string): Promise<ProjectSummary | undef
|
|
|
57
58
|
currentStep: state.currentStep,
|
|
58
59
|
currentChapter: state.currentChapter,
|
|
59
60
|
targetChapters: state.targetChapters,
|
|
61
|
+
plannedTotalChapters: state.plannedTotalChapters ?? state.targetChapters,
|
|
60
62
|
completedSteps: state.completedSteps.length,
|
|
61
63
|
chaptersWritten: countChapters(state),
|
|
62
64
|
updatedAt: state.updatedAt,
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { cp, readdir, rm, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { AgentState, WorkflowStep } from './types.js';
|
|
5
|
+
import { loadState, saveState } from './projectStore.js';
|
|
6
|
+
import { indexChapter, removeChapterFromIndex, removeMemoryCardFromIndex } from './retrieval/index.js';
|
|
7
|
+
import { chapterFileName, memoryFileName } from './fileNames.js';
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// fork_project
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
export interface ForkProjectInput {
|
|
14
|
+
sourceProjectPath: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ForkProjectResult {
|
|
19
|
+
newProjectPath: string;
|
|
20
|
+
newProjectId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function forkProject(input: ForkProjectInput): Promise<ForkProjectResult> {
|
|
24
|
+
const source = resolve(input.sourceProjectPath);
|
|
25
|
+
const state = await loadState(source);
|
|
26
|
+
const suffix = randomBytes(3).toString('hex');
|
|
27
|
+
const label = (input.label ?? 'fork').replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'fork';
|
|
28
|
+
const targetName = `${basename(source)}-${label}-${suffix}`;
|
|
29
|
+
const target = join(dirname(source), targetName);
|
|
30
|
+
|
|
31
|
+
await cp(source, target, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const forkedState: AgentState = {
|
|
34
|
+
...state,
|
|
35
|
+
projectId: randomUUID(),
|
|
36
|
+
projectPath: target,
|
|
37
|
+
createdAt: new Date().toISOString(),
|
|
38
|
+
updatedAt: new Date().toISOString(),
|
|
39
|
+
};
|
|
40
|
+
await saveState(forkedState);
|
|
41
|
+
return { newProjectPath: target, newProjectId: forkedState.projectId };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// delete_chapter
|
|
46
|
+
// =============================================================================
|
|
47
|
+
|
|
48
|
+
export interface DeleteChapterInput {
|
|
49
|
+
projectPath: string;
|
|
50
|
+
chapterNumber: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DeleteChapterResult {
|
|
54
|
+
removed: string[];
|
|
55
|
+
newCurrentChapter: number;
|
|
56
|
+
newCurrentStep: WorkflowStep;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function tryUnlink(path: string): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
await unlink(path);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function tryRmDirEntry(dirPath: string, prefix: string): Promise<string[]> {
|
|
69
|
+
const removed: string[] = [];
|
|
70
|
+
try {
|
|
71
|
+
const items = await readdir(dirPath);
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
if (item.startsWith(prefix)) {
|
|
74
|
+
const full = join(dirPath, item);
|
|
75
|
+
try {
|
|
76
|
+
await unlink(full);
|
|
77
|
+
removed.push(full);
|
|
78
|
+
} catch {
|
|
79
|
+
// ignore
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// dir absent
|
|
85
|
+
}
|
|
86
|
+
return removed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function deleteChapter(input: DeleteChapterInput): Promise<DeleteChapterResult> {
|
|
90
|
+
const state = await loadState(input.projectPath);
|
|
91
|
+
const n = input.chapterNumber;
|
|
92
|
+
if (n < 1) throw new Error('chapterNumber must be >= 1');
|
|
93
|
+
|
|
94
|
+
const removed: string[] = [];
|
|
95
|
+
const chapterRel = join('chapters', chapterFileName(n));
|
|
96
|
+
if (await tryUnlink(join(state.projectPath, chapterRel))) removed.push(chapterRel);
|
|
97
|
+
|
|
98
|
+
const memoryRel = join('memory', memoryFileName(n));
|
|
99
|
+
if (await tryUnlink(join(state.projectPath, memoryRel))) removed.push(memoryRel);
|
|
100
|
+
|
|
101
|
+
// Versions of this chapter
|
|
102
|
+
const versionsRemoved = await tryRmDirEntry(
|
|
103
|
+
join(state.projectPath, 'chapters/.versions'),
|
|
104
|
+
`${chapterFileName(n).replace(/\.md$/, '')}.`
|
|
105
|
+
);
|
|
106
|
+
removed.push(...versionsRemoved);
|
|
107
|
+
|
|
108
|
+
// Per-chapter review
|
|
109
|
+
const reviewName = `chapter-${String(n).padStart(3, '0')}.json`;
|
|
110
|
+
if (await tryUnlink(join(state.projectPath, 'reviews/chapter', reviewName))) {
|
|
111
|
+
removed.push(`reviews/chapter/${reviewName}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Update state.files
|
|
115
|
+
const nextFiles: Record<string, string> = { ...state.files };
|
|
116
|
+
delete nextFiles[`chapter-${n}`];
|
|
117
|
+
delete nextFiles[`memory-${n}`];
|
|
118
|
+
delete nextFiles[`review-chapter-${n}`];
|
|
119
|
+
|
|
120
|
+
// Remove this chapter and its memory card from the lexical index
|
|
121
|
+
await removeChapterFromIndex(state.projectPath, n);
|
|
122
|
+
await removeMemoryCardFromIndex(state.projectPath, n);
|
|
123
|
+
|
|
124
|
+
// Adjust state.currentChapter & currentStep if needed
|
|
125
|
+
let newCurrentChapter = state.currentChapter;
|
|
126
|
+
let newCurrentStep: WorkflowStep = state.currentStep;
|
|
127
|
+
if (state.currentChapter > n) {
|
|
128
|
+
// user deleted an earlier chapter; current pointer becomes the deleted one to be regenerated
|
|
129
|
+
newCurrentChapter = n;
|
|
130
|
+
newCurrentStep = 'chapter';
|
|
131
|
+
} else if (state.currentChapter === n + 1 && (state.currentStep === 'chapter' || state.currentStep === 'memory_card')) {
|
|
132
|
+
// we just finished chapter n and were about to do n+1; step back
|
|
133
|
+
newCurrentChapter = n;
|
|
134
|
+
newCurrentStep = 'chapter';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const nextState: AgentState = {
|
|
138
|
+
...state,
|
|
139
|
+
files: nextFiles,
|
|
140
|
+
currentChapter: newCurrentChapter,
|
|
141
|
+
currentStep: newCurrentStep,
|
|
142
|
+
pendingAction: undefined,
|
|
143
|
+
};
|
|
144
|
+
await saveState(nextState);
|
|
145
|
+
return { removed, newCurrentChapter, newCurrentStep };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// =============================================================================
|
|
149
|
+
// redo_step
|
|
150
|
+
// =============================================================================
|
|
151
|
+
|
|
152
|
+
export interface RedoStepInput {
|
|
153
|
+
projectPath: string;
|
|
154
|
+
step: WorkflowStep;
|
|
155
|
+
chapterNumber?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface RedoStepResult {
|
|
159
|
+
removed: string[];
|
|
160
|
+
currentStep: WorkflowStep;
|
|
161
|
+
currentChapter: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const STEP_FILE_KEYS: Partial<Record<WorkflowStep, string[]>> = {
|
|
165
|
+
novel_metadata: ['novel'],
|
|
166
|
+
story_bible: ['storyBible'],
|
|
167
|
+
style_guide: ['styleGuide'],
|
|
168
|
+
architecture: ['architecture'],
|
|
169
|
+
continuity_review: ['continuityReview'],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const STEP_FILE_PATHS: Partial<Record<WorkflowStep, string[]>> = {
|
|
173
|
+
novel_metadata: ['novel.json'],
|
|
174
|
+
story_bible: ['story-bible.md'],
|
|
175
|
+
style_guide: ['style-guide.json'],
|
|
176
|
+
architecture: ['architecture/full.md', 'architecture/volumes.json', 'architecture/chapters.json'],
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export async function redoStep(input: RedoStepInput): Promise<RedoStepResult> {
|
|
180
|
+
const state = await loadState(input.projectPath);
|
|
181
|
+
const removed: string[] = [];
|
|
182
|
+
|
|
183
|
+
if (input.step === 'chapter' || input.step === 'memory_card') {
|
|
184
|
+
const chapter = input.chapterNumber ?? state.currentChapter;
|
|
185
|
+
if (input.step === 'memory_card') {
|
|
186
|
+
const rel = join('memory', memoryFileName(chapter));
|
|
187
|
+
if (await tryUnlink(join(state.projectPath, rel))) removed.push(rel);
|
|
188
|
+
delete state.files[`memory-${chapter}`];
|
|
189
|
+
} else {
|
|
190
|
+
// chapter: also remove its memory + per-chapter review since they depend on it
|
|
191
|
+
const cRel = join('chapters', chapterFileName(chapter));
|
|
192
|
+
if (await tryUnlink(join(state.projectPath, cRel))) removed.push(cRel);
|
|
193
|
+
const mRel = join('memory', memoryFileName(chapter));
|
|
194
|
+
if (await tryUnlink(join(state.projectPath, mRel))) removed.push(mRel);
|
|
195
|
+
delete state.files[`chapter-${chapter}`];
|
|
196
|
+
delete state.files[`memory-${chapter}`];
|
|
197
|
+
await removeChapterFromIndex(state.projectPath, chapter);
|
|
198
|
+
await removeMemoryCardFromIndex(state.projectPath, chapter);
|
|
199
|
+
}
|
|
200
|
+
state.currentChapter = chapter;
|
|
201
|
+
state.currentStep = input.step;
|
|
202
|
+
state.pendingAction = undefined;
|
|
203
|
+
} else if (
|
|
204
|
+
input.step === 'novel_metadata'
|
|
205
|
+
|| input.step === 'story_bible'
|
|
206
|
+
|| input.step === 'style_guide'
|
|
207
|
+
|| input.step === 'architecture'
|
|
208
|
+
|| input.step === 'continuity_review'
|
|
209
|
+
) {
|
|
210
|
+
const paths = STEP_FILE_PATHS[input.step] ?? [];
|
|
211
|
+
for (const p of paths) {
|
|
212
|
+
if (await tryUnlink(join(state.projectPath, p))) removed.push(p);
|
|
213
|
+
}
|
|
214
|
+
const keys = STEP_FILE_KEYS[input.step] ?? [];
|
|
215
|
+
for (const k of keys) {
|
|
216
|
+
delete state.files[k];
|
|
217
|
+
}
|
|
218
|
+
state.currentStep = input.step;
|
|
219
|
+
state.pendingAction = undefined;
|
|
220
|
+
if (input.step === 'novel_metadata') state.currentChapter = 1;
|
|
221
|
+
} else {
|
|
222
|
+
throw new Error(`redo_step does not support step: ${input.step}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Trim completedSteps after the redo target
|
|
226
|
+
const idx = state.completedSteps.lastIndexOf(input.step);
|
|
227
|
+
if (idx >= 0) state.completedSteps = state.completedSteps.slice(0, idx);
|
|
228
|
+
|
|
229
|
+
await saveState(state);
|
|
230
|
+
return {
|
|
231
|
+
removed,
|
|
232
|
+
currentStep: state.currentStep,
|
|
233
|
+
currentChapter: state.currentChapter,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// =============================================================================
|
|
238
|
+
// guards
|
|
239
|
+
// =============================================================================
|
|
240
|
+
|
|
241
|
+
export function assertProjectPath(workspaceRoot: string, projectPath: string): void {
|
|
242
|
+
const root = resolve(workspaceRoot);
|
|
243
|
+
const target = resolve(projectPath);
|
|
244
|
+
const rel = relative(root, target);
|
|
245
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
246
|
+
throw new Error(`Refusing to operate outside workspace: ${target}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// keep tsc happy if no other refs
|
|
251
|
+
void writeFile;
|
package/src/core/projectStore.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface CreateProjectInput {
|
|
|
10
10
|
language?: AgentState['language'];
|
|
11
11
|
outputDir?: string;
|
|
12
12
|
targetChapters?: number;
|
|
13
|
+
plannedTotalChapters?: number;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface CreateProjectResult {
|
|
@@ -30,6 +31,7 @@ export async function ensureProjectDirectories(projectPath: string): Promise<voi
|
|
|
30
31
|
await mkdir(join(projectPath, 'architecture'), { recursive: true });
|
|
31
32
|
await mkdir(join(projectPath, 'chapters'), { recursive: true });
|
|
32
33
|
await mkdir(join(projectPath, 'chapters/.versions'), { recursive: true });
|
|
34
|
+
await mkdir(join(projectPath, 'story-bible-versions'), { recursive: true });
|
|
33
35
|
await mkdir(join(projectPath, 'memory'), { recursive: true });
|
|
34
36
|
await mkdir(join(projectPath, 'reviews'), { recursive: true });
|
|
35
37
|
await mkdir(join(projectPath, 'reviews/chapter'), { recursive: true });
|
|
@@ -47,10 +49,25 @@ export async function archiveChapterVersion(projectPath: string, chapterRelative
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
export async function archiveStoryBible(projectPath: string, versionRelative: string): Promise<string | undefined> {
|
|
53
|
+
const sourcePath = join(projectPath, 'story-bible.md');
|
|
54
|
+
try {
|
|
55
|
+
const existing = await readFile(sourcePath, 'utf8');
|
|
56
|
+
return saveMarkdownFile(projectPath, versionRelative, existing);
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
export async function createProject(input: CreateProjectInput): Promise<CreateProjectResult> {
|
|
51
63
|
const workspaceRoot = resolve(input.workspaceRoot);
|
|
52
64
|
const baseDir = input.outputDir || 'novels';
|
|
53
|
-
const
|
|
65
|
+
const hasExplicitTargetChapters = input.targetChapters !== undefined;
|
|
66
|
+
const targetChapters = Math.max(1, Math.floor(Number(input.targetChapters || 5)));
|
|
67
|
+
const plannedTotalChapters = Math.max(
|
|
68
|
+
targetChapters,
|
|
69
|
+
Math.floor(Number(input.plannedTotalChapters ?? (hasExplicitTargetChapters ? targetChapters : 12)))
|
|
70
|
+
);
|
|
54
71
|
const baseSlug = makeProjectSlug(input.prompt.slice(0, 48));
|
|
55
72
|
const suffix = randomBytes(3).toString('hex');
|
|
56
73
|
const slug = `${baseSlug}-${suffix}`;
|
|
@@ -65,6 +82,7 @@ export async function createProject(input: CreateProjectInput): Promise<CreatePr
|
|
|
65
82
|
initialPrompt: input.prompt,
|
|
66
83
|
language: input.language || 'zh-CN',
|
|
67
84
|
targetChapters,
|
|
85
|
+
plannedTotalChapters,
|
|
68
86
|
currentStep: 'novel_metadata',
|
|
69
87
|
currentChapter: 1,
|
|
70
88
|
completedSteps: [],
|