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.
Files changed (46) hide show
  1. package/README.md +36 -13
  2. package/dist/src/cli/index.js +71 -2
  3. package/dist/src/core/bibleStore.js +36 -0
  4. package/dist/src/core/characterStore.js +74 -0
  5. package/dist/src/core/contextBuilder.js +44 -1
  6. package/dist/src/core/fileNames.js +4 -0
  7. package/dist/src/core/index.js +4 -0
  8. package/dist/src/core/projectOps.js +187 -0
  9. package/dist/src/core/projectStore.js +11 -0
  10. package/dist/src/core/prompts/en-US.js +117 -13
  11. package/dist/src/core/prompts/zh-CN.js +116 -12
  12. package/dist/src/core/retrieval/index.js +8 -0
  13. package/dist/src/core/schemas.js +98 -1
  14. package/dist/src/core/steps/architecture.js +7 -1
  15. package/dist/src/core/steps/chapter.js +11 -1
  16. package/dist/src/core/steps/chapterReview.js +25 -1
  17. package/dist/src/core/steps/chapterRevision.js +17 -0
  18. package/dist/src/core/steps/memoryCard.js +4 -0
  19. package/dist/src/core/steps/novelMetadata.js +4 -2
  20. package/dist/src/core/threadStore.js +150 -0
  21. package/dist/src/core/workflow.js +3 -3
  22. package/dist/src/mcp/tools.js +198 -18
  23. package/package.json +5 -1
  24. package/src/cli/index.ts +74 -1
  25. package/src/core/bibleStore.ts +57 -0
  26. package/src/core/characterStore.ts +93 -0
  27. package/src/core/contextBuilder.ts +44 -4
  28. package/src/core/fileNames.ts +5 -0
  29. package/src/core/index.ts +4 -0
  30. package/src/core/projectOps.ts +243 -0
  31. package/src/core/projectStore.ts +11 -0
  32. package/src/core/prompts/en-US.ts +126 -22
  33. package/src/core/prompts/types.ts +2 -1
  34. package/src/core/prompts/zh-CN.ts +118 -14
  35. package/src/core/retrieval/index.ts +10 -0
  36. package/src/core/schemas.ts +108 -1
  37. package/src/core/steps/architecture.ts +7 -1
  38. package/src/core/steps/chapter.ts +11 -1
  39. package/src/core/steps/chapterReview.ts +27 -1
  40. package/src/core/steps/chapterRevision.ts +18 -0
  41. package/src/core/steps/memoryCard.ts +4 -0
  42. package/src/core/steps/novelMetadata.ts +4 -2
  43. package/src/core/threadStore.ts +173 -0
  44. package/src/core/types.ts +102 -1
  45. package/src/core/workflow.ts +3 -3
  46. package/src/mcp/tools.ts +322 -19
@@ -9,9 +9,35 @@ export const chapterReviewHandler: StepHandler = async (state, content) => {
9
9
  const target = state.pendingAction?.chapterNumber ?? parsed.chapterNumber;
10
10
  const relative = join('reviews/chapter', chapterReviewFileName(target));
11
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
+
20
+ if (parsed.status === 'clean') {
21
+ return {
22
+ savedPaths: [path],
23
+ fileEntries: { [`review-chapter-${target}`]: relative },
24
+ next: { kind: 'linear', nextStep: 'memory_card' },
25
+ };
26
+ }
27
+
12
28
  return {
13
29
  savedPaths: [path],
14
30
  fileEntries: { [`review-chapter-${target}`]: relative },
15
- next: { kind: 'sideTrackReturn' },
31
+ next: {
32
+ kind: 'linear',
33
+ nextStep: 'chapter_revision',
34
+ statePatch: {
35
+ pendingAction: {
36
+ step: 'chapter_revision',
37
+ mode: 'gate',
38
+ chapterNumber: target,
39
+ },
40
+ },
41
+ },
16
42
  };
17
43
  };
@@ -13,6 +13,24 @@ export const chapterRevisionHandler: StepHandler = async (state, content) => {
13
13
  const savedPaths = archived ? [archived] : [];
14
14
  savedPaths.push(await saveMarkdownFile(state.projectPath, chapterRelative, content));
15
15
  await indexChapter(state.projectPath, target, content);
16
+ if (state.pendingAction?.mode === 'gate') {
17
+ return {
18
+ savedPaths,
19
+ fileEntries: { [`chapter-${target}`]: chapterRelative },
20
+ next: {
21
+ kind: 'linear',
22
+ nextStep: 'chapter_review',
23
+ statePatch: {
24
+ pendingAction: {
25
+ step: 'chapter_review',
26
+ mode: 'gate',
27
+ chapterNumber: target,
28
+ },
29
+ },
30
+ },
31
+ };
32
+ }
33
+
16
34
  return {
17
35
  savedPaths,
18
36
  fileEntries: { [`chapter-${target}`]: chapterRelative },
@@ -3,6 +3,8 @@ import { MemoryCardSchema } from '../schemas.js';
3
3
  import { saveJsonFile } from '../projectStore.js';
4
4
  import { memoryFileName } from '../fileNames.js';
5
5
  import { indexMemoryCard } from '../retrieval/index.js';
6
+ import { ingestMemoryCardThreads } from '../threadStore.js';
7
+ import { applyCharacterUpdates } from '../characterStore.js';
6
8
  import { StepHandler, parseJson } from './types.js';
7
9
 
8
10
  export const memoryCardHandler: StepHandler = async (state, content) => {
@@ -10,6 +12,8 @@ export const memoryCardHandler: StepHandler = async (state, content) => {
10
12
  const relative = join('memory', memoryFileName(state.currentChapter));
11
13
  const path = await saveJsonFile(state.projectPath, relative, parsed);
12
14
  await indexMemoryCard(state.projectPath, state.currentChapter, parsed);
15
+ await ingestMemoryCardThreads(state.projectPath, state.currentChapter, parsed.threadActions);
16
+ await applyCharacterUpdates(state.projectPath, state.currentChapter, parsed.characterUpdates);
13
17
  const nextChapter = state.currentChapter + 1;
14
18
  return {
15
19
  savedPaths: [path],
@@ -1,13 +1,15 @@
1
1
  import { NovelMetadataSchema } from '../schemas.js';
2
2
  import { saveJsonFile } from '../projectStore.js';
3
+ import { initializeCharacterStates } from '../characterStore.js';
3
4
  import { StepHandler, parseJson } from './types.js';
4
5
 
5
6
  export const novelMetadataHandler: StepHandler = async (state, content) => {
6
7
  const parsed = NovelMetadataSchema.parse(parseJson(content));
7
8
  const path = await saveJsonFile(state.projectPath, 'novel.json', parsed);
9
+ const charactersPath = await initializeCharacterStates(state.projectPath, parsed.coreCast);
8
10
  return {
9
- savedPaths: [path],
10
- fileEntries: { novel: 'novel.json' },
11
+ savedPaths: [path, charactersPath],
12
+ fileEntries: { novel: 'novel.json', characters: 'characters.json' },
11
13
  next: { kind: 'linear', nextStep: 'story_bible' },
12
14
  };
13
15
  };
@@ -0,0 +1,173 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { Thread, ThreadAction, ThreadStatus } from './types.js';
5
+
6
+ const THREADS_FILE = 'threads.json';
7
+
8
+ export interface ThreadsBundle {
9
+ threads: Thread[];
10
+ }
11
+
12
+ export async function loadThreads(projectPath: string): Promise<Thread[]> {
13
+ try {
14
+ const raw = await readFile(join(projectPath, THREADS_FILE), 'utf8');
15
+ const parsed = JSON.parse(raw) as ThreadsBundle;
16
+ return Array.isArray(parsed.threads) ? parsed.threads : [];
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+
22
+ export async function saveThreads(projectPath: string, threads: Thread[]): Promise<string> {
23
+ const fullPath = join(projectPath, THREADS_FILE);
24
+ const bundle: ThreadsBundle = { threads };
25
+ await writeFile(fullPath, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8');
26
+ return fullPath;
27
+ }
28
+
29
+ function newThreadId(existing: Set<string>): string {
30
+ let candidate = `t_${randomBytes(3).toString('hex')}`;
31
+ while (existing.has(candidate)) {
32
+ candidate = `t_${randomBytes(3).toString('hex')}`;
33
+ }
34
+ return candidate;
35
+ }
36
+
37
+ function findByDescription(threads: Thread[], description: string): Thread | undefined {
38
+ const target = description.trim();
39
+ return threads.find((t) => t.description.trim() === target);
40
+ }
41
+
42
+ /**
43
+ * Apply the threadActions emitted by a memory_card for chapter `chapterNumber`.
44
+ * Behavior:
45
+ * - 'plant' → create new thread (or reuse if identical description already planted)
46
+ * - 'build' → mark existing thread status = 'building', bump lastTouchedAt
47
+ * - 'pay' → mark existing thread status = 'paid', set paidOffAt
48
+ * - 'drop' → mark existing thread status = 'dropped', set droppedAt
49
+ * Unknown threadIds for non-plant actions are tolerated (a new thread is created and marked appropriately, so we never lose user intent).
50
+ */
51
+ export function applyThreadActions(
52
+ existing: Thread[],
53
+ chapterNumber: number,
54
+ actions: ThreadAction[]
55
+ ): Thread[] {
56
+ if (!actions || !actions.length) return existing;
57
+ const next: Thread[] = existing.map((t) => ({ ...t }));
58
+ const byId = new Map(next.map((t) => [t.id, t]));
59
+ const usedIds = new Set(next.map((t) => t.id));
60
+
61
+ for (const action of actions) {
62
+ if (action.kind === 'plant') {
63
+ const dup = findByDescription(next, action.description);
64
+ if (dup) {
65
+ dup.lastTouchedAt = Math.max(dup.lastTouchedAt, chapterNumber);
66
+ continue;
67
+ }
68
+ const id = action.threadId && !usedIds.has(action.threadId)
69
+ ? action.threadId
70
+ : newThreadId(usedIds);
71
+ usedIds.add(id);
72
+ const planted: Thread = {
73
+ id,
74
+ description: action.description.trim(),
75
+ status: 'planted',
76
+ plantedAt: chapterNumber,
77
+ lastTouchedAt: chapterNumber,
78
+ };
79
+ next.push(planted);
80
+ byId.set(id, planted);
81
+ continue;
82
+ }
83
+
84
+ // build / pay / drop need an existing thread
85
+ let target = action.threadId ? byId.get(action.threadId) : undefined;
86
+ if (!target) {
87
+ target = findByDescription(next, action.description);
88
+ }
89
+ if (!target) {
90
+ // Create a placeholder so the user intent is captured; mark planted+touched on this chapter
91
+ const id = newThreadId(usedIds);
92
+ usedIds.add(id);
93
+ target = {
94
+ id,
95
+ description: action.description.trim(),
96
+ status: 'planted',
97
+ plantedAt: chapterNumber,
98
+ lastTouchedAt: chapterNumber,
99
+ notes: `Auto-created from a ${action.kind} action without a known threadId.`,
100
+ };
101
+ next.push(target);
102
+ byId.set(id, target);
103
+ }
104
+
105
+ target.lastTouchedAt = chapterNumber;
106
+ if (action.kind === 'build') {
107
+ target.status = 'building';
108
+ } else if (action.kind === 'pay') {
109
+ target.status = 'paid';
110
+ target.paidOffAt = chapterNumber;
111
+ } else if (action.kind === 'drop') {
112
+ target.status = 'dropped';
113
+ target.droppedAt = chapterNumber;
114
+ }
115
+ }
116
+
117
+ return next;
118
+ }
119
+
120
+ export function activeThreads(threads: Thread[]): Thread[] {
121
+ return threads.filter((t) => t.status === 'planted' || t.status === 'building');
122
+ }
123
+
124
+ export async function ingestMemoryCardThreads(
125
+ projectPath: string,
126
+ chapterNumber: number,
127
+ actions: ThreadAction[] | undefined
128
+ ): Promise<Thread[]> {
129
+ if (!actions || !actions.length) return loadThreads(projectPath);
130
+ const existing = await loadThreads(projectPath);
131
+ const next = applyThreadActions(existing, chapterNumber, actions);
132
+ await saveThreads(projectPath, next);
133
+ return next;
134
+ }
135
+
136
+ export interface UpdateThreadPatch {
137
+ status?: ThreadStatus;
138
+ plannedPayoffAt?: number | null;
139
+ paidOffAt?: number | null;
140
+ droppedAt?: number | null;
141
+ description?: string;
142
+ notes?: string | null;
143
+ }
144
+
145
+ export async function updateThread(
146
+ projectPath: string,
147
+ id: string,
148
+ patch: UpdateThreadPatch
149
+ ): Promise<Thread> {
150
+ const existing = await loadThreads(projectPath);
151
+ const target = existing.find((t) => t.id === id);
152
+ if (!target) throw new Error(`Thread not found: ${id}`);
153
+ if (patch.status) target.status = patch.status;
154
+ if (patch.description) target.description = patch.description.trim();
155
+ if (Object.prototype.hasOwnProperty.call(patch, 'plannedPayoffAt')) {
156
+ if (patch.plannedPayoffAt === null) delete target.plannedPayoffAt;
157
+ else if (typeof patch.plannedPayoffAt === 'number') target.plannedPayoffAt = patch.plannedPayoffAt;
158
+ }
159
+ if (Object.prototype.hasOwnProperty.call(patch, 'paidOffAt')) {
160
+ if (patch.paidOffAt === null) delete target.paidOffAt;
161
+ else if (typeof patch.paidOffAt === 'number') target.paidOffAt = patch.paidOffAt;
162
+ }
163
+ if (Object.prototype.hasOwnProperty.call(patch, 'droppedAt')) {
164
+ if (patch.droppedAt === null) delete target.droppedAt;
165
+ else if (typeof patch.droppedAt === 'number') target.droppedAt = patch.droppedAt;
166
+ }
167
+ if (Object.prototype.hasOwnProperty.call(patch, 'notes')) {
168
+ if (patch.notes === null) delete target.notes;
169
+ else if (typeof patch.notes === 'string') target.notes = patch.notes;
170
+ }
171
+ await saveThreads(projectPath, existing);
172
+ return target;
173
+ }
package/src/core/types.ts CHANGED
@@ -8,21 +8,50 @@ export type WorkflowStep =
8
8
  | 'chapter_review'
9
9
  | 'chapter_revision'
10
10
  | 'cross_chapter_review'
11
+ | 'story_bible_amend'
11
12
  | 'complete';
12
13
 
13
14
  export type ReviewSeverity = 'low' | 'medium' | 'high';
14
15
 
15
16
  export interface ChapterReviewIssue {
16
17
  severity: ReviewSeverity;
17
- category: 'character' | 'world' | 'timeline' | 'item' | 'knowledge' | 'pacing' | 'style' | 'architecture';
18
+ category:
19
+ | 'character'
20
+ | 'world'
21
+ | 'timeline'
22
+ | 'item'
23
+ | 'knowledge'
24
+ | 'pacing'
25
+ | 'style'
26
+ | 'architecture'
27
+ | 'plot'
28
+ | 'foreshadow'
29
+ | 'hook'
30
+ | 'repetition';
18
31
  description: string;
19
32
  evidence: string;
20
33
  suggestion: string;
21
34
  }
22
35
 
36
+ export interface ChapterAcceptanceCheck {
37
+ status: 'pass' | 'fail';
38
+ evidence: string;
39
+ }
40
+
41
+ export interface ChapterAcceptanceGate {
42
+ requiredBeats: ChapterAcceptanceCheck & { missingBeats: string[] };
43
+ narrativeProgress: ChapterAcceptanceCheck;
44
+ characterProgress: ChapterAcceptanceCheck;
45
+ foreshadowProgress: ChapterAcceptanceCheck;
46
+ storyBibleConsistency: ChapterAcceptanceCheck;
47
+ endingHook: ChapterAcceptanceCheck;
48
+ repetition: ChapterAcceptanceCheck;
49
+ }
50
+
23
51
  export interface ChapterReview {
24
52
  chapterNumber: number;
25
53
  status: 'clean' | 'issues_found';
54
+ acceptance: ChapterAcceptanceGate;
26
55
  issues: ChapterReviewIssue[];
27
56
  }
28
57
 
@@ -60,20 +89,88 @@ export interface VolumeArchitecture {
60
89
  order: number;
61
90
  }
62
91
 
92
+ export interface VolumePacingBoard {
93
+ volumeId: string;
94
+ start: string;
95
+ promise: string;
96
+ keyTurns: string[];
97
+ midpoint: string;
98
+ climax: string;
99
+ payoffs: string[];
100
+ lingeringMysteries: string[];
101
+ }
102
+
103
+ export type EndHookFocus = 'cliffhanger' | 'mystery' | 'emotional' | 'reveal' | 'volume_close' | 'gentle';
104
+
63
105
  export interface ChapterArchitecture {
64
106
  chapterNumber: number;
65
107
  title: string;
66
108
  volumeId: string;
67
109
  summary: string;
68
110
  requiredBeats: string[];
111
+ targetWords?: number;
112
+ requireRecap?: boolean;
113
+ endHookFocus?: EndHookFocus;
114
+ povCharacter?: string;
69
115
  }
70
116
 
71
117
  export interface ArchitecturePayload {
72
118
  full: string;
73
119
  volumes: VolumeArchitecture[];
120
+ volumePacing?: VolumePacingBoard[];
74
121
  chapters: ChapterArchitecture[];
75
122
  }
76
123
 
124
+ export type ThreadStatus = 'planted' | 'building' | 'paid' | 'dropped';
125
+
126
+ export type ThreadActionKind = 'plant' | 'build' | 'pay' | 'drop';
127
+
128
+ export interface ThreadAction {
129
+ kind: ThreadActionKind;
130
+ threadId?: string; // existing thread id; required for build/pay/drop
131
+ description: string; // for plant: the new thread description; for others: how this chapter touched it
132
+ }
133
+
134
+ export interface Thread {
135
+ id: string;
136
+ description: string;
137
+ status: ThreadStatus;
138
+ plantedAt: number;
139
+ lastTouchedAt: number;
140
+ plannedPayoffAt?: number;
141
+ paidOffAt?: number;
142
+ droppedAt?: number;
143
+ notes?: string;
144
+ }
145
+
146
+ export interface CharacterRelationshipState {
147
+ name: string;
148
+ dynamic: string;
149
+ }
150
+
151
+ export interface CharacterState {
152
+ name: string;
153
+ role?: string;
154
+ goal: string;
155
+ belief: string;
156
+ relationships: CharacterRelationshipState[];
157
+ abilities: string[];
158
+ secrets: string[];
159
+ emotionalState: string;
160
+ lastUpdatedAt: number;
161
+ }
162
+
163
+ export interface CharacterStateUpdate {
164
+ name: string;
165
+ role?: string;
166
+ goal?: string;
167
+ belief?: string;
168
+ relationships?: CharacterRelationshipState[];
169
+ abilities?: string[];
170
+ secrets?: string[];
171
+ emotionalState?: string;
172
+ }
173
+
77
174
  export interface MemoryCard {
78
175
  summary: string;
79
176
  keyEvents: string[];
@@ -81,10 +178,14 @@ export interface MemoryCard {
81
178
  facts: Array<{ subject: string; predicate: string; object: string }>;
82
179
  stateChanges: Array<{ entity: string; before: string; after: string }>;
83
180
  openThreads: string[];
181
+ wordCount?: number;
182
+ threadActions?: ThreadAction[];
183
+ characterUpdates?: CharacterStateUpdate[];
84
184
  }
85
185
 
86
186
  export interface PendingAction {
87
187
  step: 'chapter_review' | 'chapter_revision' | 'cross_chapter_review';
188
+ mode?: 'side_track' | 'gate';
88
189
  chapterNumber?: number;
89
190
  range?: { start: number; end: number };
90
191
  feedback?: string;
@@ -132,17 +132,17 @@ function buildPendingAction(state: AgentState, input: RequestSideTrackInput): Pe
132
132
  switch (input.step) {
133
133
  case 'chapter_review': {
134
134
  if (!input.chapterNumber) throw new Error('chapter_review requires chapterNumber');
135
- return { step: 'chapter_review', chapterNumber: input.chapterNumber };
135
+ return { step: 'chapter_review', mode: 'side_track', chapterNumber: input.chapterNumber };
136
136
  }
137
137
  case 'chapter_revision': {
138
138
  if (!input.chapterNumber) throw new Error('chapter_revision requires chapterNumber');
139
- return { step: 'chapter_revision', chapterNumber: input.chapterNumber, feedback: input.feedback };
139
+ return { step: 'chapter_revision', mode: 'side_track', chapterNumber: input.chapterNumber, feedback: input.feedback };
140
140
  }
141
141
  case 'cross_chapter_review': {
142
142
  const max = maxExistingChapter(state);
143
143
  const range = input.range ?? { start: 1, end: max || state.currentChapter };
144
144
  if (range.start < 1 || range.end < range.start) throw new Error('Invalid range');
145
- return { step: 'cross_chapter_review', range };
145
+ return { step: 'cross_chapter_review', mode: 'side_track', range };
146
146
  }
147
147
  default:
148
148
  throw new Error(`Unknown side-track step: ${(input as RequestSideTrackInput).step}`);