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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
archiveStoryBible,
|
|
5
|
+
loadState,
|
|
6
|
+
saveMarkdownFile,
|
|
7
|
+
saveState,
|
|
8
|
+
} from './projectStore.js';
|
|
9
|
+
import { storyBibleVersionFileName } from './fileNames.js';
|
|
10
|
+
import { indexStoryBible } from './retrieval/index.js';
|
|
11
|
+
|
|
12
|
+
export interface AmendStoryBibleInput {
|
|
13
|
+
projectPath: string;
|
|
14
|
+
content: string;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AmendStoryBibleResult {
|
|
19
|
+
archivedPath?: string;
|
|
20
|
+
bibleVersion: number;
|
|
21
|
+
savedPath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isEmpty(content: string): boolean {
|
|
25
|
+
return !content || !content.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function amendStoryBible(input: AmendStoryBibleInput): Promise<AmendStoryBibleResult> {
|
|
29
|
+
if (isEmpty(input.content)) throw new Error('Amended story bible content is empty');
|
|
30
|
+
const state = await loadState(input.projectPath);
|
|
31
|
+
// Archive current
|
|
32
|
+
const archived = await archiveStoryBible(
|
|
33
|
+
state.projectPath,
|
|
34
|
+
join('story-bible-versions', storyBibleVersionFileName(new Date().toISOString()))
|
|
35
|
+
);
|
|
36
|
+
// Save new
|
|
37
|
+
const savedPath = await saveMarkdownFile(state.projectPath, 'story-bible.md', input.content);
|
|
38
|
+
// Re-index
|
|
39
|
+
await indexStoryBible(state.projectPath, input.content);
|
|
40
|
+
// Track in state
|
|
41
|
+
const bibleVersion = (state.completedSteps.filter((s) => s === 'story_bible_amend').length ?? 0) + 1;
|
|
42
|
+
await saveState({
|
|
43
|
+
...state,
|
|
44
|
+
completedSteps: [...state.completedSteps, 'story_bible_amend' as const],
|
|
45
|
+
files: { ...state.files, storyBible: 'story-bible.md' },
|
|
46
|
+
});
|
|
47
|
+
return { archivedPath: archived, bibleVersion, savedPath };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function listStoryBibleVersions(projectPath: string): Promise<string[]> {
|
|
51
|
+
try {
|
|
52
|
+
const items = await readdir(join(projectPath, 'story-bible-versions'));
|
|
53
|
+
return items.filter((f) => f.endsWith('.md')).sort();
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CharacterState, CharacterStateUpdate, CoreCastMember } from './types.js';
|
|
4
|
+
|
|
5
|
+
const CHARACTERS_FILE = 'characters.json';
|
|
6
|
+
|
|
7
|
+
export interface CharactersBundle {
|
|
8
|
+
characters: CharacterState[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function emptyState(member: CoreCastMember, chapterNumber = 0): CharacterState {
|
|
12
|
+
return {
|
|
13
|
+
name: member.name,
|
|
14
|
+
role: member.role,
|
|
15
|
+
goal: '未确认',
|
|
16
|
+
belief: '未确认',
|
|
17
|
+
relationships: [],
|
|
18
|
+
abilities: [],
|
|
19
|
+
secrets: [],
|
|
20
|
+
emotionalState: member.description,
|
|
21
|
+
lastUpdatedAt: chapterNumber,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadCharacterStates(projectPath: string): Promise<CharacterState[]> {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(join(projectPath, CHARACTERS_FILE), 'utf8');
|
|
28
|
+
const parsed = JSON.parse(raw) as CharactersBundle;
|
|
29
|
+
return Array.isArray(parsed.characters) ? parsed.characters : [];
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function saveCharacterStates(projectPath: string, characters: CharacterState[]): Promise<string> {
|
|
36
|
+
const fullPath = join(projectPath, CHARACTERS_FILE);
|
|
37
|
+
await writeFile(fullPath, `${JSON.stringify({ characters }, null, 2)}\n`, 'utf8');
|
|
38
|
+
return fullPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function initializeCharacterStates(
|
|
42
|
+
projectPath: string,
|
|
43
|
+
coreCast: CoreCastMember[]
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const existing = await loadCharacterStates(projectPath);
|
|
46
|
+
const byName = new Map(existing.map((c) => [c.name, c]));
|
|
47
|
+
for (const member of coreCast) {
|
|
48
|
+
if (!byName.has(member.name)) {
|
|
49
|
+
byName.set(member.name, emptyState(member));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return saveCharacterStates(projectPath, Array.from(byName.values()));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function applyCharacterUpdates(
|
|
56
|
+
projectPath: string,
|
|
57
|
+
chapterNumber: number,
|
|
58
|
+
updates: CharacterStateUpdate[] | undefined
|
|
59
|
+
): Promise<CharacterState[]> {
|
|
60
|
+
const existing = await loadCharacterStates(projectPath);
|
|
61
|
+
if (!updates || !updates.length) return existing;
|
|
62
|
+
|
|
63
|
+
const byName = new Map(existing.map((c) => [c.name, { ...c }]));
|
|
64
|
+
for (const update of updates) {
|
|
65
|
+
const current = byName.get(update.name) ?? {
|
|
66
|
+
name: update.name,
|
|
67
|
+
role: update.role,
|
|
68
|
+
goal: '未确认',
|
|
69
|
+
belief: '未确认',
|
|
70
|
+
relationships: [],
|
|
71
|
+
abilities: [],
|
|
72
|
+
secrets: [],
|
|
73
|
+
emotionalState: '未确认',
|
|
74
|
+
lastUpdatedAt: chapterNumber,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
byName.set(update.name, {
|
|
78
|
+
...current,
|
|
79
|
+
role: update.role ?? current.role,
|
|
80
|
+
goal: update.goal ?? current.goal,
|
|
81
|
+
belief: update.belief ?? current.belief,
|
|
82
|
+
relationships: update.relationships ?? current.relationships,
|
|
83
|
+
abilities: update.abilities ?? current.abilities,
|
|
84
|
+
secrets: update.secrets ?? current.secrets,
|
|
85
|
+
emotionalState: update.emotionalState ?? current.emotionalState,
|
|
86
|
+
lastUpdatedAt: chapterNumber,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const next = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
91
|
+
await saveCharacterStates(projectPath, next);
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
@@ -2,6 +2,7 @@ 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'
|
|
@@ -32,18 +33,33 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
32
33
|
const metadata = await readOptional(join(input.projectPath, 'novel.json'));
|
|
33
34
|
const storyBible = await readOptional(join(input.projectPath, 'story-bible.md'));
|
|
34
35
|
const chaptersJson = await readOptional(join(input.projectPath, 'architecture/chapters.json'));
|
|
36
|
+
const charactersJson = await readOptional(join(input.projectPath, 'characters.json'));
|
|
37
|
+
const volumePacingJson = await readOptional(join(input.projectPath, 'architecture/volume-pacing.json'));
|
|
35
38
|
|
|
36
39
|
if (metadata) parts.push(`## Novel Metadata\n${metadata}`);
|
|
37
40
|
if (storyBible) parts.push(`## Story Bible\n${storyBible.slice(0, 4000)}`);
|
|
41
|
+
if (charactersJson) parts.push(`## Character State Table\n${charactersJson}`);
|
|
42
|
+
|
|
43
|
+
function addVolumePacing(volumeId?: string): void {
|
|
44
|
+
if (!volumePacingJson) return;
|
|
45
|
+
try {
|
|
46
|
+
const boards = JSON.parse(volumePacingJson) as Array<{ volumeId: string }>;
|
|
47
|
+
const board = volumeId ? boards.find((item) => item.volumeId === volumeId) : undefined;
|
|
48
|
+
parts.push(`## Volume Pacing Board\n${JSON.stringify(board ?? boards, null, 2)}`);
|
|
49
|
+
} catch {
|
|
50
|
+
parts.push(`## Volume Pacing Board\n${volumePacingJson}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
38
53
|
|
|
39
54
|
if (input.purpose === 'chapter_generation' && input.chapterNumber) {
|
|
40
|
-
let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string } | undefined;
|
|
55
|
+
let currentArchitectureForQuery: { summary?: string; requiredBeats?: string[]; title?: string; volumeId?: string } | undefined;
|
|
41
56
|
if (chaptersJson) {
|
|
42
|
-
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
|
|
57
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
|
|
43
58
|
const chapter = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
44
59
|
if (chapter) {
|
|
45
60
|
currentArchitectureForQuery = chapter;
|
|
46
61
|
parts.push(`## Current Chapter Architecture\n${JSON.stringify(chapter, null, 2)}`);
|
|
62
|
+
addVolumePacing(chapter.volumeId);
|
|
47
63
|
}
|
|
48
64
|
}
|
|
49
65
|
if (input.chapterNumber > 1) {
|
|
@@ -67,6 +83,18 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
67
83
|
const formatted = formatHits(hits);
|
|
68
84
|
if (formatted) parts.push(`## Retrieved Relevant Snippets (lexical, BM25-style)\n${formatted}`);
|
|
69
85
|
}
|
|
86
|
+
|
|
87
|
+
const allThreads = await loadThreads(input.projectPath);
|
|
88
|
+
const active = activeThreads(allThreads);
|
|
89
|
+
if (active.length) {
|
|
90
|
+
const lines = active.map((t) => {
|
|
91
|
+
const flags: string[] = [`#${t.id}`, `status=${t.status}`, `planted=ch${t.plantedAt}`];
|
|
92
|
+
if (t.plannedPayoffAt) flags.push(`payoff=ch${t.plannedPayoffAt}`);
|
|
93
|
+
if (t.lastTouchedAt !== t.plantedAt) flags.push(`touched=ch${t.lastTouchedAt}`);
|
|
94
|
+
return `- ${t.description} (${flags.join(', ')})`;
|
|
95
|
+
});
|
|
96
|
+
parts.push(`## Active Foreshadow Threads (do not silently drop or contradict)\n${lines.join('\n')}`);
|
|
97
|
+
}
|
|
70
98
|
}
|
|
71
99
|
}
|
|
72
100
|
|
|
@@ -87,9 +115,12 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
87
115
|
|
|
88
116
|
if (input.purpose === 'chapter_review' && input.chapterNumber) {
|
|
89
117
|
if (chaptersJson) {
|
|
90
|
-
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[] }>;
|
|
118
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; title: string; summary: string; requiredBeats?: string[]; volumeId?: string }>;
|
|
91
119
|
const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
92
|
-
if (arch)
|
|
120
|
+
if (arch) {
|
|
121
|
+
parts.push(`## Target Chapter Architecture\n${JSON.stringify(arch, null, 2)}`);
|
|
122
|
+
addVolumePacing(arch.volumeId);
|
|
123
|
+
}
|
|
93
124
|
}
|
|
94
125
|
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
|
|
95
126
|
if (chapter) parts.push(`## Chapter ${input.chapterNumber} Text\n${chapter}`);
|
|
@@ -102,6 +133,15 @@ export async function buildContext(input: BuildContextInput): Promise<string> {
|
|
|
102
133
|
if (input.purpose === 'revision' && input.chapterNumber) {
|
|
103
134
|
const chapter = await readOptional(join(input.projectPath, 'chapters', chapterFileName(input.chapterNumber)));
|
|
104
135
|
if (chapter) parts.push(`## Current Chapter Text\n${chapter}`);
|
|
136
|
+
if (chaptersJson) {
|
|
137
|
+
try {
|
|
138
|
+
const chapters = JSON.parse(chaptersJson) as Array<{ chapterNumber: number; volumeId?: string }>;
|
|
139
|
+
const arch = chapters.find((item) => item.chapterNumber === input.chapterNumber);
|
|
140
|
+
addVolumePacing(arch?.volumeId);
|
|
141
|
+
} catch {
|
|
142
|
+
// ignore malformed architecture here; review feedback is still useful
|
|
143
|
+
}
|
|
144
|
+
}
|
|
105
145
|
const review = await readOptional(join(input.projectPath, 'reviews/chapter', chapterReviewFileName(input.chapterNumber)));
|
|
106
146
|
if (review) parts.push(`## Editor Review\n${review}`);
|
|
107
147
|
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';
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
architecture: ['architecture'],
|
|
168
|
+
continuity_review: ['continuityReview'],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const STEP_FILE_PATHS: Partial<Record<WorkflowStep, string[]>> = {
|
|
172
|
+
novel_metadata: ['novel.json'],
|
|
173
|
+
story_bible: ['story-bible.md'],
|
|
174
|
+
architecture: ['architecture/full.md', 'architecture/volumes.json', 'architecture/chapters.json'],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export async function redoStep(input: RedoStepInput): Promise<RedoStepResult> {
|
|
178
|
+
const state = await loadState(input.projectPath);
|
|
179
|
+
const removed: string[] = [];
|
|
180
|
+
|
|
181
|
+
if (input.step === 'chapter' || input.step === 'memory_card') {
|
|
182
|
+
const chapter = input.chapterNumber ?? state.currentChapter;
|
|
183
|
+
if (input.step === 'memory_card') {
|
|
184
|
+
const rel = join('memory', memoryFileName(chapter));
|
|
185
|
+
if (await tryUnlink(join(state.projectPath, rel))) removed.push(rel);
|
|
186
|
+
delete state.files[`memory-${chapter}`];
|
|
187
|
+
} else {
|
|
188
|
+
// chapter: also remove its memory + per-chapter review since they depend on it
|
|
189
|
+
const cRel = join('chapters', chapterFileName(chapter));
|
|
190
|
+
if (await tryUnlink(join(state.projectPath, cRel))) removed.push(cRel);
|
|
191
|
+
const mRel = join('memory', memoryFileName(chapter));
|
|
192
|
+
if (await tryUnlink(join(state.projectPath, mRel))) removed.push(mRel);
|
|
193
|
+
delete state.files[`chapter-${chapter}`];
|
|
194
|
+
delete state.files[`memory-${chapter}`];
|
|
195
|
+
await removeChapterFromIndex(state.projectPath, chapter);
|
|
196
|
+
await removeMemoryCardFromIndex(state.projectPath, chapter);
|
|
197
|
+
}
|
|
198
|
+
state.currentChapter = chapter;
|
|
199
|
+
state.currentStep = input.step;
|
|
200
|
+
state.pendingAction = undefined;
|
|
201
|
+
} else if (input.step === 'novel_metadata' || input.step === 'story_bible' || input.step === 'architecture' || input.step === 'continuity_review') {
|
|
202
|
+
const paths = STEP_FILE_PATHS[input.step] ?? [];
|
|
203
|
+
for (const p of paths) {
|
|
204
|
+
if (await tryUnlink(join(state.projectPath, p))) removed.push(p);
|
|
205
|
+
}
|
|
206
|
+
const keys = STEP_FILE_KEYS[input.step] ?? [];
|
|
207
|
+
for (const k of keys) {
|
|
208
|
+
delete state.files[k];
|
|
209
|
+
}
|
|
210
|
+
state.currentStep = input.step;
|
|
211
|
+
state.pendingAction = undefined;
|
|
212
|
+
if (input.step === 'novel_metadata') state.currentChapter = 1;
|
|
213
|
+
} else {
|
|
214
|
+
throw new Error(`redo_step does not support step: ${input.step}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Trim completedSteps after the redo target
|
|
218
|
+
const idx = state.completedSteps.lastIndexOf(input.step);
|
|
219
|
+
if (idx >= 0) state.completedSteps = state.completedSteps.slice(0, idx);
|
|
220
|
+
|
|
221
|
+
await saveState(state);
|
|
222
|
+
return {
|
|
223
|
+
removed,
|
|
224
|
+
currentStep: state.currentStep,
|
|
225
|
+
currentChapter: state.currentChapter,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// =============================================================================
|
|
230
|
+
// guards
|
|
231
|
+
// =============================================================================
|
|
232
|
+
|
|
233
|
+
export function assertProjectPath(workspaceRoot: string, projectPath: string): void {
|
|
234
|
+
const root = resolve(workspaceRoot);
|
|
235
|
+
const target = resolve(projectPath);
|
|
236
|
+
const rel = relative(root, target);
|
|
237
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
238
|
+
throw new Error(`Refusing to operate outside workspace: ${target}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// keep tsc happy if no other refs
|
|
243
|
+
void writeFile;
|
package/src/core/projectStore.ts
CHANGED
|
@@ -30,6 +30,7 @@ export async function ensureProjectDirectories(projectPath: string): Promise<voi
|
|
|
30
30
|
await mkdir(join(projectPath, 'architecture'), { recursive: true });
|
|
31
31
|
await mkdir(join(projectPath, 'chapters'), { recursive: true });
|
|
32
32
|
await mkdir(join(projectPath, 'chapters/.versions'), { recursive: true });
|
|
33
|
+
await mkdir(join(projectPath, 'story-bible-versions'), { recursive: true });
|
|
33
34
|
await mkdir(join(projectPath, 'memory'), { recursive: true });
|
|
34
35
|
await mkdir(join(projectPath, 'reviews'), { recursive: true });
|
|
35
36
|
await mkdir(join(projectPath, 'reviews/chapter'), { recursive: true });
|
|
@@ -47,6 +48,16 @@ export async function archiveChapterVersion(projectPath: string, chapterRelative
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
export async function archiveStoryBible(projectPath: string, versionRelative: string): Promise<string | undefined> {
|
|
52
|
+
const sourcePath = join(projectPath, 'story-bible.md');
|
|
53
|
+
try {
|
|
54
|
+
const existing = await readFile(sourcePath, 'utf8');
|
|
55
|
+
return saveMarkdownFile(projectPath, versionRelative, existing);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
export async function createProject(input: CreateProjectInput): Promise<CreateProjectResult> {
|
|
51
62
|
const workspaceRoot = resolve(input.workspaceRoot);
|
|
52
63
|
const baseDir = input.outputDir || 'novels';
|