@hanna84/mcp-writing 3.8.2 → 3.9.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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/core/chapter-resolution.js +61 -0
- package/src/core/helpers.js +24 -5
- package/src/sync/metadata-lint.js +26 -4
- package/src/tools/metadata.js +59 -2
- package/src/tools/search.js +1 -61
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.9.0](https://github.com/hannasdev/mcp-writing/compare/v3.8.2...v3.9.0)
|
|
8
|
+
|
|
9
|
+
- feat(chapters): align metadata tooling with chapter ids [`#196`](https://github.com/hannasdev/mcp-writing/pull/196)
|
|
10
|
+
|
|
7
11
|
#### [v3.8.2](https://github.com/hannasdev/mcp-writing/compare/v3.8.1...v3.8.2)
|
|
8
12
|
|
|
13
|
+
> 16 May 2026
|
|
14
|
+
|
|
9
15
|
- docs(skills): align release-log path guidance [`#195`](https://github.com/hannasdev/mcp-writing/pull/195)
|
|
16
|
+
- Release 3.8.2 [`b5284f1`](https://github.com/hannasdev/mcp-writing/commit/b5284f144702c3532f330cfb1dbdf85f83289131)
|
|
10
17
|
|
|
11
18
|
#### [v3.8.1](https://github.com/hannasdev/mcp-writing/compare/v3.8.0...v3.8.1)
|
|
12
19
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function resolveChapterByCompatibilityKey(db, { projectId, chapterNumber, chapterId }) {
|
|
2
|
+
if (!projectId) return null;
|
|
3
|
+
if (chapterId) {
|
|
4
|
+
return db.prepare(`
|
|
5
|
+
SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
|
|
6
|
+
FROM chapters
|
|
7
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
8
|
+
`).get(projectId, chapterId);
|
|
9
|
+
}
|
|
10
|
+
if (chapterNumber == null) return null;
|
|
11
|
+
|
|
12
|
+
const canonicalChapter = db.prepare(`
|
|
13
|
+
SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
|
|
14
|
+
FROM chapters
|
|
15
|
+
WHERE project_id = ? AND sort_index = ?
|
|
16
|
+
`).get(projectId, chapterNumber);
|
|
17
|
+
if (canonicalChapter) return canonicalChapter;
|
|
18
|
+
|
|
19
|
+
return db.prepare(`
|
|
20
|
+
SELECT chapter_id, project_id, chapter_title AS title, chapter AS sort_index, NULL AS logline, MAX(metadata_stale) AS metadata_stale
|
|
21
|
+
FROM scenes
|
|
22
|
+
WHERE project_id = ? AND chapter = ? AND chapter_id IS NOT NULL
|
|
23
|
+
GROUP BY chapter_id, project_id, chapter_title, chapter
|
|
24
|
+
ORDER BY chapter_id
|
|
25
|
+
LIMIT 1
|
|
26
|
+
`).get(projectId, chapterNumber);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveValidatedChapterFilter(db, { projectId, chapterNumber, chapterId }) {
|
|
30
|
+
if (!projectId) return { chapter: null };
|
|
31
|
+
if (!chapterId && chapterNumber == null) return { chapter: null };
|
|
32
|
+
|
|
33
|
+
const resolvedById = chapterId
|
|
34
|
+
? resolveChapterByCompatibilityKey(db, { projectId, chapterId })
|
|
35
|
+
: null;
|
|
36
|
+
const resolvedByNumber = chapterNumber != null
|
|
37
|
+
? resolveChapterByCompatibilityKey(db, { projectId, chapterNumber })
|
|
38
|
+
: null;
|
|
39
|
+
|
|
40
|
+
if (chapterId && chapterNumber != null) {
|
|
41
|
+
if (!resolvedById || !resolvedByNumber) {
|
|
42
|
+
return {
|
|
43
|
+
error: {
|
|
44
|
+
code: "NOT_FOUND",
|
|
45
|
+
message: "Chapter not found for the provided project and identifier.",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (resolvedById.chapter_id !== resolvedByNumber.chapter_id) {
|
|
50
|
+
return {
|
|
51
|
+
error: {
|
|
52
|
+
code: "VALIDATION_ERROR",
|
|
53
|
+
message: "chapter_id and chapter must refer to the same canonical chapter when both are provided.",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { chapter: resolvedById };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { chapter: resolvedById ?? resolvedByNumber ?? null };
|
|
61
|
+
}
|
package/src/core/helpers.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import matter from "gray-matter";
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
5
|
import { sidecarPath, syncAll } from "../sync/sync.js";
|
|
6
|
+
import { resolveValidatedChapterFilter } from "./chapter-resolution.js";
|
|
6
7
|
import {
|
|
7
8
|
slugifyEntityName,
|
|
8
9
|
renderCharacterSheetTemplate,
|
|
@@ -126,6 +127,22 @@ export function resolveBatchTargetScenes(dbHandle, {
|
|
|
126
127
|
|
|
127
128
|
const conditions = ["project_id = ?"];
|
|
128
129
|
const params = [projectId];
|
|
130
|
+
const resolvedChapterFilter = (chapter !== undefined || chapterId !== undefined)
|
|
131
|
+
? resolveValidatedChapterFilter(dbHandle, { projectId, chapterNumber: chapter, chapterId })
|
|
132
|
+
: { chapter: null };
|
|
133
|
+
|
|
134
|
+
if (resolvedChapterFilter.error) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
code: resolvedChapterFilter.error.code,
|
|
138
|
+
message: resolvedChapterFilter.error.message,
|
|
139
|
+
details: {
|
|
140
|
+
project_id: projectId,
|
|
141
|
+
chapter: chapter ?? null,
|
|
142
|
+
chapter_id: chapterId ?? null,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
129
146
|
|
|
130
147
|
if (sceneIds?.length) {
|
|
131
148
|
const placeholders = sceneIds.map(() => "?").join(",");
|
|
@@ -136,13 +153,15 @@ export function resolveBatchTargetScenes(dbHandle, {
|
|
|
136
153
|
conditions.push("part = ?");
|
|
137
154
|
params.push(part);
|
|
138
155
|
}
|
|
139
|
-
if (chapter
|
|
140
|
-
conditions.push("
|
|
141
|
-
params.push(chapter);
|
|
142
|
-
}
|
|
143
|
-
if (chapterId !== undefined) {
|
|
156
|
+
if (resolvedChapterFilter.chapter) {
|
|
157
|
+
conditions.push("chapter_id = ?");
|
|
158
|
+
params.push(resolvedChapterFilter.chapter.chapter_id);
|
|
159
|
+
} else if (chapterId !== undefined) {
|
|
144
160
|
conditions.push("chapter_id = ?");
|
|
145
161
|
params.push(chapterId);
|
|
162
|
+
} else if (chapter !== undefined) {
|
|
163
|
+
conditions.push("chapter = ?");
|
|
164
|
+
params.push(chapter);
|
|
146
165
|
}
|
|
147
166
|
if (onlyStale) {
|
|
148
167
|
conditions.push("metadata_stale = 1");
|
|
@@ -16,7 +16,7 @@ import yaml from "js-yaml";
|
|
|
16
16
|
|
|
17
17
|
const { load: parseYaml } = yaml;
|
|
18
18
|
|
|
19
|
-
const metadataKindSchema = z.enum(["scene", "character", "place"]);
|
|
19
|
+
const metadataKindSchema = z.enum(["scene", "character", "place", "epigraph"]);
|
|
20
20
|
|
|
21
21
|
const threadLinkSchema = z.object({
|
|
22
22
|
thread_id: z.string().min(1),
|
|
@@ -71,9 +71,22 @@ const placeSchema = z.object({
|
|
|
71
71
|
tags: z.array(z.string().min(1)).optional(),
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
const epigraphSchema = z.object({
|
|
75
|
+
kind: z.literal("epigraph").optional(),
|
|
76
|
+
type: z.literal("epigraph").optional(),
|
|
77
|
+
epigraph_id: z.string().min(1).optional(),
|
|
78
|
+
scene_id: z.string().min(1).optional(),
|
|
79
|
+
chapter: z.number().int().positive().optional(),
|
|
80
|
+
chapter_id: z.string().min(1).optional(),
|
|
81
|
+
chapter_title: z.string().min(1).optional(),
|
|
82
|
+
characters: z.array(z.string().min(1)).optional(),
|
|
83
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
84
|
+
});
|
|
85
|
+
|
|
74
86
|
const sceneAllowedKeys = new Set(Object.keys(sceneSchema.shape));
|
|
75
87
|
const characterAllowedKeys = new Set(Object.keys(characterSchema.shape));
|
|
76
88
|
const placeAllowedKeys = new Set(Object.keys(placeSchema.shape));
|
|
89
|
+
const epigraphAllowedKeys = new Set(Object.keys(epigraphSchema.shape));
|
|
77
90
|
const sceneLegacyKeys = new Set(["synopsis", "save_the_cat", "change"]);
|
|
78
91
|
|
|
79
92
|
function uniqueItems(items = []) {
|
|
@@ -82,6 +95,7 @@ function uniqueItems(items = []) {
|
|
|
82
95
|
|
|
83
96
|
export function detectMetadataKind(meta) {
|
|
84
97
|
if (meta && typeof meta === "object") {
|
|
98
|
+
if (meta.kind === "epigraph" || meta.type === "epigraph" || typeof meta.epigraph_id === "string") return "epigraph";
|
|
85
99
|
if (typeof meta.character_id === "string") return "character";
|
|
86
100
|
if (typeof meta.place_id === "string") return "place";
|
|
87
101
|
return "scene";
|
|
@@ -92,18 +106,22 @@ export function detectMetadataKind(meta) {
|
|
|
92
106
|
function allowedKeysFor(kind) {
|
|
93
107
|
if (kind === "character") return characterAllowedKeys;
|
|
94
108
|
if (kind === "place") return placeAllowedKeys;
|
|
109
|
+
if (kind === "epigraph") return epigraphAllowedKeys;
|
|
95
110
|
return sceneAllowedKeys;
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
function schemaFor(kind) {
|
|
99
114
|
if (kind === "character") return characterSchema;
|
|
100
115
|
if (kind === "place") return placeSchema;
|
|
116
|
+
if (kind === "epigraph") return epigraphSchema;
|
|
101
117
|
return sceneSchema;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
function validateUniqueArrays(meta, kind, issues) {
|
|
105
121
|
const fields = kind === "scene"
|
|
106
122
|
? ["characters", "places", "tags", "scene_functions", "versions"]
|
|
123
|
+
: kind === "epigraph"
|
|
124
|
+
? ["characters", "tags"]
|
|
107
125
|
: kind === "character"
|
|
108
126
|
? ["traits", "tags"]
|
|
109
127
|
: ["associated_characters", "tags"];
|
|
@@ -119,7 +137,7 @@ function validateUniqueArrays(meta, kind, issues) {
|
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
|
|
122
|
-
function
|
|
140
|
+
function validateCanonicalCharacterReferenceStyle(meta, issues, { entityLabel }) {
|
|
123
141
|
if (!Array.isArray(meta.characters) || meta.characters.length === 0) return;
|
|
124
142
|
|
|
125
143
|
if (!meta.characters.every(value => typeof value === "string")) return;
|
|
@@ -132,7 +150,7 @@ function validateSceneCharacterReferenceStyle(meta, issues) {
|
|
|
132
150
|
issues.push({
|
|
133
151
|
level: "warning",
|
|
134
152
|
code: "MIXED_CHARACTER_REFERENCE_STYLE",
|
|
135
|
-
message:
|
|
153
|
+
message: `${entityLabel} characters contain mixed canonical and non-canonical references. Prefer canonical character_id values only.`,
|
|
136
154
|
});
|
|
137
155
|
}
|
|
138
156
|
|
|
@@ -186,7 +204,11 @@ export function validateMetadataObject(meta, { sourcePath, kindHint } = {}) {
|
|
|
186
204
|
validateUniqueArrays(meta, kind, issues);
|
|
187
205
|
|
|
188
206
|
if (kind === "scene") {
|
|
189
|
-
|
|
207
|
+
validateCanonicalCharacterReferenceStyle(meta, issues, { entityLabel: "Scene" });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (kind === "epigraph") {
|
|
211
|
+
validateCanonicalCharacterReferenceStyle(meta, issues, { entityLabel: "Epigraph" });
|
|
190
212
|
}
|
|
191
213
|
|
|
192
214
|
if (kind === "scene" && sourcePath) {
|
package/src/tools/metadata.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import matter from "gray-matter";
|
|
4
4
|
import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
|
|
5
5
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
6
|
+
import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
|
|
6
7
|
import {
|
|
7
8
|
persistSceneReferenceLink,
|
|
8
9
|
upsertExplicitReferenceLinkRow,
|
|
@@ -523,7 +524,8 @@ export function registerMetadataTools(s, {
|
|
|
523
524
|
save_the_cat_beat: z.string().optional(),
|
|
524
525
|
pov: z.string().optional(),
|
|
525
526
|
part: z.number().int().optional(),
|
|
526
|
-
chapter: z.number().int().optional(),
|
|
527
|
+
chapter: z.number().int().optional().describe("Compatibility chapter number. When it resolves to a canonical chapter, update_scene_metadata also persists the matching chapter_id."),
|
|
528
|
+
chapter_id: z.string().nullable().optional().describe("Canonical chapter identifier. Use list_chapters to find valid values. Pass null to clear an explicit chapter link on an unchaptered scene."),
|
|
527
529
|
timeline_position: z.number().int().optional(),
|
|
528
530
|
story_time: z.string().optional(),
|
|
529
531
|
tags: z.array(z.string()).optional(),
|
|
@@ -542,7 +544,62 @@ export function registerMetadataTools(s, {
|
|
|
542
544
|
}
|
|
543
545
|
try {
|
|
544
546
|
const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
545
|
-
const
|
|
547
|
+
const nextFields = { ...fields };
|
|
548
|
+
|
|
549
|
+
if (fields.chapter_id === null && fields.chapter !== undefined) {
|
|
550
|
+
return errorResponse(
|
|
551
|
+
"VALIDATION_ERROR",
|
|
552
|
+
"chapter_id cannot be null when chapter is also provided.",
|
|
553
|
+
{
|
|
554
|
+
project_id,
|
|
555
|
+
chapter_id: null,
|
|
556
|
+
chapter: fields.chapter,
|
|
557
|
+
}
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (fields.chapter_id === null) {
|
|
562
|
+
nextFields.chapter = null;
|
|
563
|
+
nextFields.chapter_title = null;
|
|
564
|
+
} else if (fields.chapter_id !== undefined || fields.chapter !== undefined) {
|
|
565
|
+
const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
|
|
566
|
+
projectId: project_id,
|
|
567
|
+
chapterNumber: fields.chapter,
|
|
568
|
+
chapterId: fields.chapter_id,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (resolvedChapterFilter.error) {
|
|
572
|
+
return errorResponse(
|
|
573
|
+
resolvedChapterFilter.error.code,
|
|
574
|
+
resolvedChapterFilter.error.message,
|
|
575
|
+
{
|
|
576
|
+
project_id,
|
|
577
|
+
chapter_id: fields.chapter_id ?? null,
|
|
578
|
+
chapter: fields.chapter ?? null,
|
|
579
|
+
}
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const resolvedChapter = resolvedChapterFilter.chapter;
|
|
584
|
+
|
|
585
|
+
if (!resolvedChapter) {
|
|
586
|
+
return errorResponse(
|
|
587
|
+
"NOT_FOUND",
|
|
588
|
+
"Chapter not found for the provided project and identifier.",
|
|
589
|
+
{
|
|
590
|
+
project_id,
|
|
591
|
+
chapter_id: fields.chapter_id ?? null,
|
|
592
|
+
chapter: fields.chapter ?? null,
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
nextFields.chapter_id = resolvedChapter.chapter_id;
|
|
598
|
+
nextFields.chapter = resolvedChapter.sort_index;
|
|
599
|
+
nextFields.chapter_title = resolvedChapter.title ?? null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...nextFields }).meta;
|
|
546
603
|
writeMeta(scene.file_path, updated);
|
|
547
604
|
|
|
548
605
|
const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
|
package/src/tools/search.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import matter from "gray-matter";
|
|
4
4
|
import { readMeta } from "../sync/sync.js";
|
|
5
5
|
import { persistSceneReferenceLink, upsertExplicitReferenceLinkRow } from "./reference-link-persistence.js";
|
|
6
|
+
import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
|
|
6
7
|
|
|
7
8
|
function accumulateSuggestionScore(scoreMap, rows, sourceLabel) {
|
|
8
9
|
for (const row of rows) {
|
|
@@ -39,67 +40,6 @@ function readSceneEntityIdsFromMetadata({ scenePath, syncDir }) {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function resolveChapterByCompatibilityKey(db, { projectId, chapterNumber, chapterId }) {
|
|
43
|
-
if (!projectId) return null;
|
|
44
|
-
if (chapterId) {
|
|
45
|
-
return db.prepare(`
|
|
46
|
-
SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
|
|
47
|
-
FROM chapters
|
|
48
|
-
WHERE project_id = ? AND chapter_id = ?
|
|
49
|
-
`).get(projectId, chapterId);
|
|
50
|
-
}
|
|
51
|
-
if (chapterNumber == null) return null;
|
|
52
|
-
const canonicalChapter = db.prepare(`
|
|
53
|
-
SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
|
|
54
|
-
FROM chapters
|
|
55
|
-
WHERE project_id = ? AND sort_index = ?
|
|
56
|
-
`).get(projectId, chapterNumber);
|
|
57
|
-
if (canonicalChapter) return canonicalChapter;
|
|
58
|
-
|
|
59
|
-
return db.prepare(`
|
|
60
|
-
SELECT chapter_id, project_id, chapter_title AS title, chapter AS sort_index, NULL AS logline, MAX(metadata_stale) AS metadata_stale
|
|
61
|
-
FROM scenes
|
|
62
|
-
WHERE project_id = ? AND chapter = ? AND chapter_id IS NOT NULL
|
|
63
|
-
GROUP BY chapter_id, project_id, chapter_title, chapter
|
|
64
|
-
ORDER BY chapter_id
|
|
65
|
-
LIMIT 1
|
|
66
|
-
`).get(projectId, chapterNumber);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function resolveValidatedChapterFilter(db, { projectId, chapterNumber, chapterId }) {
|
|
70
|
-
if (!projectId) return { chapter: null };
|
|
71
|
-
if (!chapterId && chapterNumber == null) return { chapter: null };
|
|
72
|
-
|
|
73
|
-
const resolvedById = chapterId
|
|
74
|
-
? resolveChapterByCompatibilityKey(db, { projectId, chapterId })
|
|
75
|
-
: null;
|
|
76
|
-
const resolvedByNumber = chapterNumber != null
|
|
77
|
-
? resolveChapterByCompatibilityKey(db, { projectId, chapterNumber })
|
|
78
|
-
: null;
|
|
79
|
-
|
|
80
|
-
if (chapterId && chapterNumber != null) {
|
|
81
|
-
if (!resolvedById || !resolvedByNumber) {
|
|
82
|
-
return {
|
|
83
|
-
error: {
|
|
84
|
-
code: "NOT_FOUND",
|
|
85
|
-
message: "Chapter not found for the provided project and identifier.",
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
if (resolvedById.chapter_id !== resolvedByNumber.chapter_id) {
|
|
90
|
-
return {
|
|
91
|
-
error: {
|
|
92
|
-
code: "VALIDATION_ERROR",
|
|
93
|
-
message: "chapter_id and chapter must refer to the same canonical chapter when both are provided.",
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
return { chapter: resolvedById };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return { chapter: resolvedById ?? resolvedByNumber ?? null };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
43
|
function selectApplyCandidates(enrichedCandidates, selectedDocIds, maxApply) {
|
|
104
44
|
const selectedSet = selectedDocIds ? new Set(selectedDocIds) : null;
|
|
105
45
|
const chosenByDocId = new Map();
|