novelforge-agent 0.3.0 → 0.4.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
CHANGED
|
@@ -171,7 +171,7 @@ NOVELFORGE_WORKSPACE = "/absolute/path/where/projects/should/live"
|
|
|
171
171
|
## Tool reference
|
|
172
172
|
|
|
173
173
|
### Project lifecycle
|
|
174
|
-
- **`start_novel_project`** `(prompt, language?, outputDir?, targetChapters?, plannedTotalChapters?)` — create a new project under `<workspaceRoot>/<outputDir>/<slug>-<rand6>/` and return the first step's instruction. `targetChapters` is the per-batch planning size; MCP defaults to 5. `plannedTotalChapters` is the whole-book target; MCP defaults to 12.
|
|
174
|
+
- **`start_novel_project`** `(prompt, language?, outputDir?, targetChapters?, plannedTotalChapters?)` — create a new project under `<workspaceRoot>/<outputDir>/<prompt-slug>-<rand6>/` and return the first step's instruction. After `novel_metadata` is accepted, the directory is renamed to `<title-slug>-<same-rand6>/`; callers must continue with the returned `state.projectPath`. `targetChapters` is the per-batch planning size; MCP defaults to 5. `plannedTotalChapters` is the whole-book target; MCP defaults to 12.
|
|
175
175
|
- **`list_projects`** `(outputDir?)` — list all projects in the workspace, newest first.
|
|
176
176
|
- **`get_project_status`** `(projectPath)` — compact summary: current step, chapters written, open threads, latest review verdict.
|
|
177
177
|
- **`get_next_step`** `(projectPath)` — return the prompt + packed context for whatever the workflow expects next.
|
|
@@ -207,7 +207,7 @@ Dynamic planning is built into the state machine: after each accepted chapter an
|
|
|
207
207
|
A project on disk:
|
|
208
208
|
|
|
209
209
|
```
|
|
210
|
-
novels/<slug>-<rand6>/
|
|
210
|
+
novels/<title-slug>-<rand6>/
|
|
211
211
|
├── agent-state.json # workflow state (currentStep, currentChapter, files map, …)
|
|
212
212
|
├── novel.json # metadata (NovelMetadataSchema)
|
|
213
213
|
├── characters.json # independent character state table
|
|
@@ -8,12 +8,12 @@ export function makeProjectSlug(title) {
|
|
|
8
8
|
const replaced = title
|
|
9
9
|
.trim()
|
|
10
10
|
.split('')
|
|
11
|
-
.map((char) => PINYIN_FALLBACK[char]
|
|
12
|
-
.join('
|
|
11
|
+
.map((char) => PINYIN_FALLBACK[char] ? `-${PINYIN_FALLBACK[char]}-` : char)
|
|
12
|
+
.join('')
|
|
13
13
|
.normalize('NFKD')
|
|
14
14
|
.replace(/[\u0300-\u036f]/g, '')
|
|
15
15
|
.toLowerCase()
|
|
16
|
-
.replace(/[
|
|
16
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
|
|
17
17
|
.replace(/^-+|-+$/g, '');
|
|
18
18
|
return replaced || `novel-${Date.now()}`;
|
|
19
19
|
}
|
|
@@ -1,14 +1,59 @@
|
|
|
1
|
+
import { access, rename } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
1
3
|
import { NovelMetadataSchema } from '../schemas.js';
|
|
2
4
|
import { saveJsonFile } from '../projectStore.js';
|
|
3
5
|
import { initializeCharacterStates } from '../characterStore.js';
|
|
6
|
+
import { makeProjectSlug } from '../fileNames.js';
|
|
4
7
|
import { parseJson } from './types.js';
|
|
8
|
+
async function pathExists(path) {
|
|
9
|
+
try {
|
|
10
|
+
await access(path);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function uniqueProjectPath(parentDir, baseName, currentPath) {
|
|
18
|
+
let candidate = join(parentDir, baseName);
|
|
19
|
+
if (candidate === currentPath || !(await pathExists(candidate)))
|
|
20
|
+
return candidate;
|
|
21
|
+
for (let index = 2; index < 100; index += 1) {
|
|
22
|
+
candidate = join(parentDir, `${baseName}-${index}`);
|
|
23
|
+
if (candidate === currentPath || !(await pathExists(candidate)))
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`Unable to find available project directory for ${baseName}`);
|
|
27
|
+
}
|
|
28
|
+
async function renameProjectForTitle(projectPath, title) {
|
|
29
|
+
const parentDir = dirname(projectPath);
|
|
30
|
+
const currentName = basename(projectPath);
|
|
31
|
+
const suffix = currentName.match(/-([a-f0-9]{6})$/i)?.[1];
|
|
32
|
+
const titleSlug = makeProjectSlug(title);
|
|
33
|
+
const nextName = suffix ? `${titleSlug}-${suffix}` : titleSlug;
|
|
34
|
+
if (nextName === currentName)
|
|
35
|
+
return projectPath;
|
|
36
|
+
const nextPath = await uniqueProjectPath(parentDir, nextName, projectPath);
|
|
37
|
+
if (nextPath === projectPath)
|
|
38
|
+
return projectPath;
|
|
39
|
+
await rename(projectPath, nextPath);
|
|
40
|
+
return nextPath;
|
|
41
|
+
}
|
|
5
42
|
export const novelMetadataHandler = async (state, content) => {
|
|
6
43
|
const parsed = NovelMetadataSchema.parse(parseJson(content));
|
|
7
44
|
const path = await saveJsonFile(state.projectPath, 'novel.json', parsed);
|
|
8
45
|
const charactersPath = await initializeCharacterStates(state.projectPath, parsed.coreCast);
|
|
46
|
+
const projectPath = await renameProjectForTitle(state.projectPath, parsed.title);
|
|
9
47
|
return {
|
|
10
|
-
savedPaths: [
|
|
48
|
+
savedPaths: [
|
|
49
|
+
projectPath === state.projectPath ? path : join(projectPath, 'novel.json'),
|
|
50
|
+
projectPath === state.projectPath ? charactersPath : join(projectPath, 'characters.json'),
|
|
51
|
+
],
|
|
11
52
|
fileEntries: { novel: 'novel.json', characters: 'characters.json' },
|
|
12
|
-
next: {
|
|
53
|
+
next: {
|
|
54
|
+
kind: 'linear',
|
|
55
|
+
nextStep: 'story_bible',
|
|
56
|
+
statePatch: { projectPath },
|
|
57
|
+
},
|
|
13
58
|
};
|
|
14
59
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "novelforge-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Local-first long-form novel workflow engine for any MCP host (Claude Code, Codex CLI, …) or CLI. State machine + zod schemas + BM25 retrieval + persistent project state. No LLM dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
package/src/core/fileNames.ts
CHANGED
|
@@ -9,12 +9,12 @@ export function makeProjectSlug(title: string): string {
|
|
|
9
9
|
const replaced = title
|
|
10
10
|
.trim()
|
|
11
11
|
.split('')
|
|
12
|
-
.map((char) => PINYIN_FALLBACK[char]
|
|
13
|
-
.join('
|
|
12
|
+
.map((char) => PINYIN_FALLBACK[char] ? `-${PINYIN_FALLBACK[char]}-` : char)
|
|
13
|
+
.join('')
|
|
14
14
|
.normalize('NFKD')
|
|
15
15
|
.replace(/[\u0300-\u036f]/g, '')
|
|
16
16
|
.toLowerCase()
|
|
17
|
-
.replace(/[
|
|
17
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
|
|
18
18
|
.replace(/^-+|-+$/g, '');
|
|
19
19
|
return replaced || `novel-${Date.now()}`;
|
|
20
20
|
}
|
|
@@ -1,15 +1,61 @@
|
|
|
1
|
+
import { access, rename } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
1
3
|
import { NovelMetadataSchema } from '../schemas.js';
|
|
2
4
|
import { saveJsonFile } from '../projectStore.js';
|
|
3
5
|
import { initializeCharacterStates } from '../characterStore.js';
|
|
6
|
+
import { makeProjectSlug } from '../fileNames.js';
|
|
4
7
|
import { StepHandler, parseJson } from './types.js';
|
|
5
8
|
|
|
9
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
await access(path);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function uniqueProjectPath(parentDir: string, baseName: string, currentPath: string): Promise<string> {
|
|
19
|
+
let candidate = join(parentDir, baseName);
|
|
20
|
+
if (candidate === currentPath || !(await pathExists(candidate))) return candidate;
|
|
21
|
+
|
|
22
|
+
for (let index = 2; index < 100; index += 1) {
|
|
23
|
+
candidate = join(parentDir, `${baseName}-${index}`);
|
|
24
|
+
if (candidate === currentPath || !(await pathExists(candidate))) return candidate;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
throw new Error(`Unable to find available project directory for ${baseName}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function renameProjectForTitle(projectPath: string, title: string): Promise<string> {
|
|
31
|
+
const parentDir = dirname(projectPath);
|
|
32
|
+
const currentName = basename(projectPath);
|
|
33
|
+
const suffix = currentName.match(/-([a-f0-9]{6})$/i)?.[1];
|
|
34
|
+
const titleSlug = makeProjectSlug(title);
|
|
35
|
+
const nextName = suffix ? `${titleSlug}-${suffix}` : titleSlug;
|
|
36
|
+
if (nextName === currentName) return projectPath;
|
|
37
|
+
|
|
38
|
+
const nextPath = await uniqueProjectPath(parentDir, nextName, projectPath);
|
|
39
|
+
if (nextPath === projectPath) return projectPath;
|
|
40
|
+
await rename(projectPath, nextPath);
|
|
41
|
+
return nextPath;
|
|
42
|
+
}
|
|
43
|
+
|
|
6
44
|
export const novelMetadataHandler: StepHandler = async (state, content) => {
|
|
7
45
|
const parsed = NovelMetadataSchema.parse(parseJson(content));
|
|
8
46
|
const path = await saveJsonFile(state.projectPath, 'novel.json', parsed);
|
|
9
47
|
const charactersPath = await initializeCharacterStates(state.projectPath, parsed.coreCast);
|
|
48
|
+
const projectPath = await renameProjectForTitle(state.projectPath, parsed.title);
|
|
10
49
|
return {
|
|
11
|
-
savedPaths: [
|
|
50
|
+
savedPaths: [
|
|
51
|
+
projectPath === state.projectPath ? path : join(projectPath, 'novel.json'),
|
|
52
|
+
projectPath === state.projectPath ? charactersPath : join(projectPath, 'characters.json'),
|
|
53
|
+
],
|
|
12
54
|
fileEntries: { novel: 'novel.json', characters: 'characters.json' },
|
|
13
|
-
next: {
|
|
55
|
+
next: {
|
|
56
|
+
kind: 'linear',
|
|
57
|
+
nextStep: 'story_bible',
|
|
58
|
+
statePatch: { projectPath },
|
|
59
|
+
},
|
|
14
60
|
};
|
|
15
61
|
};
|