novelforge-agent 0.1.0 → 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 +82 -14
- package/dist/src/cli/index.js +92 -2
- package/dist/src/cli/install.js +224 -0
- 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 +94 -1
- package/src/cli/install.ts +275 -0
- 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,275 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export type InstallHost = 'claude-code' | 'codex' | 'cursor';
|
|
10
|
+
|
|
11
|
+
export interface InstallOptions {
|
|
12
|
+
host?: InstallHost;
|
|
13
|
+
workspace?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
printOnly?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface InstallResult {
|
|
19
|
+
host: InstallHost;
|
|
20
|
+
name: string;
|
|
21
|
+
workspace: string;
|
|
22
|
+
applied: boolean;
|
|
23
|
+
method: 'cli' | 'config-edit' | 'print-only';
|
|
24
|
+
message: string;
|
|
25
|
+
manualSnippet?: string;
|
|
26
|
+
verificationHint: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PACKAGE_NAME = 'novelforge-agent';
|
|
30
|
+
const MCP_BIN = 'novelforge-agent-mcp';
|
|
31
|
+
|
|
32
|
+
function defaultWorkspace(): string {
|
|
33
|
+
return join(homedir(), 'novelforge');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultName(): string {
|
|
37
|
+
return 'novelforge';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function claudeJsonSnippet(name: string, workspace: string): string {
|
|
41
|
+
return JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
mcpServers: {
|
|
44
|
+
[name]: {
|
|
45
|
+
command: 'npx',
|
|
46
|
+
args: ['-y', MCP_BIN],
|
|
47
|
+
env: { NOVELFORGE_WORKSPACE: workspace },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
null,
|
|
52
|
+
2
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function codexTomlSnippet(name: string, workspace: string): string {
|
|
57
|
+
return [
|
|
58
|
+
`[mcp_servers.${name}]`,
|
|
59
|
+
`command = "npx"`,
|
|
60
|
+
`args = ["-y", "${MCP_BIN}"]`,
|
|
61
|
+
``,
|
|
62
|
+
`[mcp_servers.${name}.env]`,
|
|
63
|
+
`NOVELFORGE_WORKSPACE = "${workspace}"`,
|
|
64
|
+
``,
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cursorSnippet(name: string, workspace: string): string {
|
|
69
|
+
return JSON.stringify(
|
|
70
|
+
{
|
|
71
|
+
mcpServers: {
|
|
72
|
+
[name]: {
|
|
73
|
+
command: 'npx',
|
|
74
|
+
args: ['-y', MCP_BIN],
|
|
75
|
+
env: { NOVELFORGE_WORKSPACE: workspace },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
null,
|
|
80
|
+
2
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function ensureWorkspaceDir(workspace: string): Promise<void> {
|
|
85
|
+
await mkdir(workspace, { recursive: true });
|
|
86
|
+
await mkdir(join(workspace, 'novels'), { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function tryClaudeCli(name: string, workspace: string): Promise<{ ok: boolean; output: string; error?: string }> {
|
|
90
|
+
const args = [
|
|
91
|
+
'mcp',
|
|
92
|
+
'add',
|
|
93
|
+
'-s',
|
|
94
|
+
'user',
|
|
95
|
+
'-e',
|
|
96
|
+
`NOVELFORGE_WORKSPACE=${workspace}`,
|
|
97
|
+
name,
|
|
98
|
+
'--',
|
|
99
|
+
'npx',
|
|
100
|
+
'-y',
|
|
101
|
+
MCP_BIN,
|
|
102
|
+
];
|
|
103
|
+
try {
|
|
104
|
+
const { stdout, stderr } = await execFileAsync('claude', args);
|
|
105
|
+
return { ok: true, output: `${stdout}\n${stderr}`.trim() };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const err = error as Error & { code?: string };
|
|
108
|
+
return { ok: false, output: '', error: err.message };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function applyClaudeCode(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
|
|
113
|
+
const snippet = claudeJsonSnippet(name, workspace);
|
|
114
|
+
const verificationHint =
|
|
115
|
+
'In Claude Code, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
|
|
116
|
+
|
|
117
|
+
if (printOnly) {
|
|
118
|
+
return {
|
|
119
|
+
host: 'claude-code',
|
|
120
|
+
name,
|
|
121
|
+
workspace,
|
|
122
|
+
applied: false,
|
|
123
|
+
method: 'print-only',
|
|
124
|
+
message:
|
|
125
|
+
'Print-only mode. Run the snippet below or invoke without --print-only to apply automatically.',
|
|
126
|
+
manualSnippet: snippet,
|
|
127
|
+
verificationHint,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cli = await tryClaudeCli(name, workspace);
|
|
132
|
+
if (cli.ok) {
|
|
133
|
+
return {
|
|
134
|
+
host: 'claude-code',
|
|
135
|
+
name,
|
|
136
|
+
workspace,
|
|
137
|
+
applied: true,
|
|
138
|
+
method: 'cli',
|
|
139
|
+
message: `Registered via \`claude mcp add\`.\n${cli.output}`,
|
|
140
|
+
verificationHint,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
host: 'claude-code',
|
|
145
|
+
name,
|
|
146
|
+
workspace,
|
|
147
|
+
applied: false,
|
|
148
|
+
method: 'print-only',
|
|
149
|
+
message:
|
|
150
|
+
`\`claude\` CLI not available (${cli.error ?? 'unknown error'}). Paste the snippet below into ~/.claude.json under "mcpServers", or run \`claude mcp add -s user -e NOVELFORGE_WORKSPACE=${workspace} ${name} -- npx -y ${MCP_BIN}\` after installing Claude Code.`,
|
|
151
|
+
manualSnippet: snippet,
|
|
152
|
+
verificationHint,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function applyCodex(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
|
|
157
|
+
const snippet = codexTomlSnippet(name, workspace);
|
|
158
|
+
const configPath = join(homedir(), '.codex', 'config.toml');
|
|
159
|
+
const verificationHint =
|
|
160
|
+
'In Codex CLI, ask the assistant: "list_projects 现在能用吗?". The host should call the list_projects tool and return an empty array.';
|
|
161
|
+
|
|
162
|
+
if (printOnly) {
|
|
163
|
+
return {
|
|
164
|
+
host: 'codex',
|
|
165
|
+
name,
|
|
166
|
+
workspace,
|
|
167
|
+
applied: false,
|
|
168
|
+
method: 'print-only',
|
|
169
|
+
message: `Print-only mode. Append the snippet below to ${configPath}.`,
|
|
170
|
+
manualSnippet: snippet,
|
|
171
|
+
verificationHint,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
177
|
+
let existing = '';
|
|
178
|
+
try {
|
|
179
|
+
existing = await readFile(configPath, 'utf8');
|
|
180
|
+
} catch {
|
|
181
|
+
existing = '';
|
|
182
|
+
}
|
|
183
|
+
if (existing.includes(`[mcp_servers.${name}]`)) {
|
|
184
|
+
return {
|
|
185
|
+
host: 'codex',
|
|
186
|
+
name,
|
|
187
|
+
workspace,
|
|
188
|
+
applied: false,
|
|
189
|
+
method: 'config-edit',
|
|
190
|
+
message: `Section [mcp_servers.${name}] already exists in ${configPath}. Edit it manually if you want to change settings.`,
|
|
191
|
+
manualSnippet: snippet,
|
|
192
|
+
verificationHint,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const next = existing.endsWith('\n') || existing === '' ? existing : `${existing}\n`;
|
|
196
|
+
await writeFile(configPath, `${next}\n${snippet}`, 'utf8');
|
|
197
|
+
return {
|
|
198
|
+
host: 'codex',
|
|
199
|
+
name,
|
|
200
|
+
workspace,
|
|
201
|
+
applied: true,
|
|
202
|
+
method: 'config-edit',
|
|
203
|
+
message: `Appended [mcp_servers.${name}] to ${configPath}.`,
|
|
204
|
+
verificationHint,
|
|
205
|
+
};
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return {
|
|
208
|
+
host: 'codex',
|
|
209
|
+
name,
|
|
210
|
+
workspace,
|
|
211
|
+
applied: false,
|
|
212
|
+
method: 'print-only',
|
|
213
|
+
message: `Could not write ${configPath}: ${(error as Error).message}. Append the snippet manually.`,
|
|
214
|
+
manualSnippet: snippet,
|
|
215
|
+
verificationHint,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function applyCursor(name: string, workspace: string, printOnly: boolean): Promise<InstallResult> {
|
|
221
|
+
const snippet = cursorSnippet(name, workspace);
|
|
222
|
+
return {
|
|
223
|
+
host: 'cursor',
|
|
224
|
+
name,
|
|
225
|
+
workspace,
|
|
226
|
+
applied: false,
|
|
227
|
+
method: 'print-only',
|
|
228
|
+
message:
|
|
229
|
+
'Cursor: open Settings → Tools & Integrations → MCP, click "Add new MCP server", and paste the snippet below.',
|
|
230
|
+
manualSnippet: snippet,
|
|
231
|
+
verificationHint:
|
|
232
|
+
'In Cursor, the agent should be able to call the list_projects tool from the novelforge MCP server.',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function runInstall(options: InstallOptions): Promise<InstallResult> {
|
|
237
|
+
const host: InstallHost = options.host ?? 'claude-code';
|
|
238
|
+
const workspace = resolve(options.workspace ?? defaultWorkspace());
|
|
239
|
+
const name = options.name ?? defaultName();
|
|
240
|
+
const printOnly = options.printOnly ?? false;
|
|
241
|
+
|
|
242
|
+
if (!printOnly) {
|
|
243
|
+
await ensureWorkspaceDir(workspace);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
switch (host) {
|
|
247
|
+
case 'claude-code':
|
|
248
|
+
return applyClaudeCode(name, workspace, printOnly);
|
|
249
|
+
case 'codex':
|
|
250
|
+
return applyCodex(name, workspace, printOnly);
|
|
251
|
+
case 'cursor':
|
|
252
|
+
return applyCursor(name, workspace, printOnly);
|
|
253
|
+
default:
|
|
254
|
+
throw new Error(`Unknown host: ${host}. Use claude-code | codex | cursor.`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function formatInstallResult(result: InstallResult): string {
|
|
259
|
+
const parts: string[] = [];
|
|
260
|
+
parts.push(`Host: ${result.host}`);
|
|
261
|
+
parts.push(`MCP name: ${result.name}`);
|
|
262
|
+
parts.push(`Workspace: ${result.workspace}`);
|
|
263
|
+
parts.push(`Applied: ${result.applied ? 'yes' : 'no'} (${result.method})`);
|
|
264
|
+
parts.push('');
|
|
265
|
+
parts.push(result.message);
|
|
266
|
+
if (result.manualSnippet) {
|
|
267
|
+
parts.push('');
|
|
268
|
+
parts.push('--- Snippet ---');
|
|
269
|
+
parts.push(result.manualSnippet);
|
|
270
|
+
parts.push('---------------');
|
|
271
|
+
}
|
|
272
|
+
parts.push('');
|
|
273
|
+
parts.push(`Verify: ${result.verificationHint}`);
|
|
274
|
+
return parts.join('\n');
|
|
275
|
+
}
|
|
@@ -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';
|