@hanna84/mcp-writing 2.12.4 → 2.12.6
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 +20 -0
- package/async-jobs.js +1 -218
- package/async-progress.js +1 -1
- package/helpers.js +2 -2
- package/importer.js +1 -448
- package/index.js +1 -501
- package/metadata-lint.js +1 -468
- package/package.json +32 -2
- package/runtime-diagnostics.js +1 -97
- package/scene-character-batch.js +1 -246
- package/scene-character-normalization.js +1 -199
- package/scripts/async-job-runner.mjs +3 -3
- package/scripts/generate-tool-docs.mjs +21 -3
- package/scripts/import.js +1 -1
- package/scripts/lint-metadata.mjs +1 -1
- package/scripts/manual-scrivener-realtest.mjs +3 -3
- package/scripts/merge-scrivx.js +2 -2
- package/scripts/new-world-entity.js +2 -2
- package/scripts/normalize-scene-characters.mjs +3 -3
- package/scripts/profile-review-bundles.mjs +1 -1
- package/scrivener-direct.js +1 -843
- package/src/index.js +502 -0
- package/src/runtime/async-jobs.js +218 -0
- package/src/runtime/async-progress.js +1 -0
- package/src/runtime/runtime-diagnostics.js +97 -0
- package/src/sync/importer.js +448 -0
- package/src/sync/metadata-lint.js +468 -0
- package/src/sync/scene-character-batch.js +246 -0
- package/src/sync/scene-character-normalization.js +199 -0
- package/src/sync/scrivener-direct.js +843 -0
- package/src/sync/sync.js +755 -0
- package/src/world/world-entity-templates.js +116 -0
- package/sync.js +1 -755
- package/tools/editing.js +1 -1
- package/tools/metadata.js +2 -2
- package/tools/review-bundles.js +1 -1
- package/tools/styleguide.js +1 -1
- package/tools/sync.js +2 -2
- package/world-entity-templates.js +1 -116
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
inferProjectAndUniverse,
|
|
6
|
+
isCanonicalWorldEntityFile,
|
|
7
|
+
isWorldFile,
|
|
8
|
+
parseFile,
|
|
9
|
+
sidecarPath,
|
|
10
|
+
walkFiles,
|
|
11
|
+
walkSidecars,
|
|
12
|
+
worldEntityFolderKey,
|
|
13
|
+
worldEntityKindForPath,
|
|
14
|
+
} from "./sync.js";
|
|
15
|
+
import yaml from "js-yaml";
|
|
16
|
+
|
|
17
|
+
const { load: parseYaml } = yaml;
|
|
18
|
+
|
|
19
|
+
const metadataKindSchema = z.enum(["scene", "character", "place"]);
|
|
20
|
+
|
|
21
|
+
const threadLinkSchema = z.object({
|
|
22
|
+
thread_id: z.string().min(1),
|
|
23
|
+
beat: z.string().min(1).optional(),
|
|
24
|
+
thread_name: z.string().min(1).optional(),
|
|
25
|
+
status: z.string().min(1).optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const sceneSchema = z.object({
|
|
29
|
+
scene_id: z.string().min(1),
|
|
30
|
+
external_source: z.string().min(1).optional(),
|
|
31
|
+
external_id: z.string().min(1).optional(),
|
|
32
|
+
title: z.string().min(1).optional(),
|
|
33
|
+
part: z.number().int().positive().optional(),
|
|
34
|
+
chapter: z.number().int().positive().optional(),
|
|
35
|
+
chapter_title: z.string().min(1).optional(),
|
|
36
|
+
pov: z.string().min(1).optional(),
|
|
37
|
+
logline: z.string().min(1).optional(),
|
|
38
|
+
save_the_cat_beat: z.string().min(1).optional(),
|
|
39
|
+
status: z.string().min(1).optional(),
|
|
40
|
+
timeline_position: z.number().int().optional(),
|
|
41
|
+
story_time: z.string().min(1).optional(),
|
|
42
|
+
word_count: z.number().int().nonnegative().optional(),
|
|
43
|
+
scene_change: z.string().min(1).optional(),
|
|
44
|
+
causality: z.string().min(1).optional(),
|
|
45
|
+
stakes: z.string().min(1).optional(),
|
|
46
|
+
scene_functions: z.array(z.string().min(1)).optional(),
|
|
47
|
+
characters: z.array(z.string().min(1)).optional(),
|
|
48
|
+
places: z.array(z.string().min(1)).optional(),
|
|
49
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
50
|
+
versions: z.array(z.string().min(1)).optional(),
|
|
51
|
+
threads: z.array(threadLinkSchema).optional(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const characterSchema = z.object({
|
|
55
|
+
character_id: z.string().min(1),
|
|
56
|
+
canonical: z.boolean().optional(),
|
|
57
|
+
name: z.string().min(1).optional(),
|
|
58
|
+
role: z.string().min(1).optional(),
|
|
59
|
+
group: z.string().min(1).optional(),
|
|
60
|
+
arc_summary: z.string().min(1).optional(),
|
|
61
|
+
first_appearance: z.string().min(1).optional(),
|
|
62
|
+
traits: z.array(z.string().min(1)).optional(),
|
|
63
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const placeSchema = z.object({
|
|
67
|
+
place_id: z.string().min(1),
|
|
68
|
+
canonical: z.boolean().optional(),
|
|
69
|
+
name: z.string().min(1).optional(),
|
|
70
|
+
associated_characters: z.array(z.string().min(1)).optional(),
|
|
71
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const sceneAllowedKeys = new Set(Object.keys(sceneSchema.shape));
|
|
75
|
+
const characterAllowedKeys = new Set(Object.keys(characterSchema.shape));
|
|
76
|
+
const placeAllowedKeys = new Set(Object.keys(placeSchema.shape));
|
|
77
|
+
const sceneLegacyKeys = new Set(["synopsis", "save_the_cat", "change"]);
|
|
78
|
+
|
|
79
|
+
function uniqueItems(items = []) {
|
|
80
|
+
return new Set(items).size === items.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function detectMetadataKind(meta) {
|
|
84
|
+
if (meta && typeof meta === "object") {
|
|
85
|
+
if (typeof meta.character_id === "string") return "character";
|
|
86
|
+
if (typeof meta.place_id === "string") return "place";
|
|
87
|
+
return "scene";
|
|
88
|
+
}
|
|
89
|
+
return "scene";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function allowedKeysFor(kind) {
|
|
93
|
+
if (kind === "character") return characterAllowedKeys;
|
|
94
|
+
if (kind === "place") return placeAllowedKeys;
|
|
95
|
+
return sceneAllowedKeys;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function schemaFor(kind) {
|
|
99
|
+
if (kind === "character") return characterSchema;
|
|
100
|
+
if (kind === "place") return placeSchema;
|
|
101
|
+
return sceneSchema;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateUniqueArrays(meta, kind, issues) {
|
|
105
|
+
const fields = kind === "scene"
|
|
106
|
+
? ["characters", "places", "tags", "scene_functions", "versions"]
|
|
107
|
+
: kind === "character"
|
|
108
|
+
? ["traits", "tags"]
|
|
109
|
+
: ["associated_characters", "tags"];
|
|
110
|
+
|
|
111
|
+
for (const key of fields) {
|
|
112
|
+
if (Array.isArray(meta[key]) && !uniqueItems(meta[key])) {
|
|
113
|
+
issues.push({
|
|
114
|
+
level: "warning",
|
|
115
|
+
code: "DUPLICATE_ARRAY_ITEMS",
|
|
116
|
+
message: `Array '${key}' contains duplicate values.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateSceneCharacterReferenceStyle(meta, issues) {
|
|
123
|
+
if (!Array.isArray(meta.characters) || meta.characters.length === 0) return;
|
|
124
|
+
|
|
125
|
+
if (!meta.characters.every(value => typeof value === "string")) return;
|
|
126
|
+
|
|
127
|
+
const hasCanonicalIds = meta.characters.some(value => /^char-/.test(String(value).trim()));
|
|
128
|
+
const hasNonCanonicalEntries = meta.characters.some(value => !/^char-/.test(String(value).trim()));
|
|
129
|
+
|
|
130
|
+
if (!hasCanonicalIds || !hasNonCanonicalEntries) return;
|
|
131
|
+
|
|
132
|
+
issues.push({
|
|
133
|
+
level: "warning",
|
|
134
|
+
code: "MIXED_CHARACTER_REFERENCE_STYLE",
|
|
135
|
+
message: "Scene characters contain mixed canonical and non-canonical references. Prefer canonical character_id values only.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function validateMetadataObject(meta, { sourcePath, kindHint } = {}) {
|
|
140
|
+
const issues = [];
|
|
141
|
+
const kind = metadataKindSchema.parse(kindHint ?? detectMetadataKind(meta));
|
|
142
|
+
const schema = schemaFor(kind);
|
|
143
|
+
const allowed = allowedKeysFor(kind);
|
|
144
|
+
|
|
145
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
kind,
|
|
149
|
+
issues: [{
|
|
150
|
+
level: "error",
|
|
151
|
+
code: "INVALID_METADATA_OBJECT",
|
|
152
|
+
message: "Metadata must be a YAML mapping/object.",
|
|
153
|
+
}],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parsed = schema.safeParse(meta);
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
for (const issue of parsed.error.issues) {
|
|
160
|
+
issues.push({
|
|
161
|
+
level: "error",
|
|
162
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
163
|
+
message: `${issue.path.join(".") || "metadata"}: ${issue.message}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const key of Object.keys(meta)) {
|
|
169
|
+
if (kind === "scene" && sceneLegacyKeys.has(key)) {
|
|
170
|
+
issues.push({
|
|
171
|
+
level: "warning",
|
|
172
|
+
code: "LEGACY_SCENE_KEY",
|
|
173
|
+
message: `Legacy scene key '${key}' found. Prefer canonical sidecar keys.`,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!allowed.has(key)) {
|
|
178
|
+
issues.push({
|
|
179
|
+
level: "warning",
|
|
180
|
+
code: "UNKNOWN_KEY",
|
|
181
|
+
message: `Unknown key '${key}' for ${kind} metadata.`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
validateUniqueArrays(meta, kind, issues);
|
|
187
|
+
|
|
188
|
+
if (kind === "scene") {
|
|
189
|
+
validateSceneCharacterReferenceStyle(meta, issues);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (kind === "scene" && sourcePath) {
|
|
193
|
+
const sidecar = sourcePath.endsWith(".meta.yaml");
|
|
194
|
+
if (sidecar && !meta.scene_id) {
|
|
195
|
+
issues.push({
|
|
196
|
+
level: "error",
|
|
197
|
+
code: "MISSING_SCENE_ID",
|
|
198
|
+
message: "Scene sidecar is missing required 'scene_id'.",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const hasErrors = issues.some(i => i.level === "error");
|
|
204
|
+
return { ok: !hasErrors, kind, issues };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadYamlFile(filePath) {
|
|
208
|
+
try {
|
|
209
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
210
|
+
const parsed = parseYaml(raw);
|
|
211
|
+
return { ok: true, value: parsed ?? {} };
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
error: {
|
|
216
|
+
level: "error",
|
|
217
|
+
code: "YAML_PARSE_ERROR",
|
|
218
|
+
message: `Failed to parse YAML: ${err.message}`,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function lintSidecar(filePath) {
|
|
225
|
+
const loaded = loadYamlFile(filePath);
|
|
226
|
+
if (!loaded.ok) {
|
|
227
|
+
return {
|
|
228
|
+
file: filePath,
|
|
229
|
+
kind: "scene",
|
|
230
|
+
issues: [loaded.error],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const result = validateMetadataObject(loaded.value, { sourcePath: filePath });
|
|
234
|
+
return { file: filePath, kind: result.kind, issues: result.issues };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function lintFrontmatter(filePath) {
|
|
238
|
+
try {
|
|
239
|
+
const { data } = parseFile(filePath);
|
|
240
|
+
if (!data || !Object.keys(data).length) return null;
|
|
241
|
+
const result = validateMetadataObject(data, { sourcePath: filePath });
|
|
242
|
+
return { file: filePath, kind: result.kind, issues: result.issues };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return {
|
|
245
|
+
file: filePath,
|
|
246
|
+
kind: "scene",
|
|
247
|
+
issues: [{
|
|
248
|
+
level: "error",
|
|
249
|
+
code: "FRONTMATTER_PARSE_ERROR",
|
|
250
|
+
message: `Failed to parse frontmatter: ${err.message}`,
|
|
251
|
+
}],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function loadMetadataForFile(filePath) {
|
|
257
|
+
const sidecar = sidecarPath(filePath);
|
|
258
|
+
if (fs.existsSync(sidecar)) {
|
|
259
|
+
const loaded = loadYamlFile(sidecar);
|
|
260
|
+
return loaded.ok ? loaded.value : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const { data } = parseFile(filePath);
|
|
265
|
+
return data && Object.keys(data).length ? data : {};
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function reportPathForFile(filePath) {
|
|
272
|
+
const sidecar = sidecarPath(filePath);
|
|
273
|
+
return fs.existsSync(sidecar) ? sidecar : filePath;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function addIssueToReport(reports, filePath, kind, issue) {
|
|
277
|
+
const reportFile = reportPathForFile(filePath);
|
|
278
|
+
const report = reports.find(r => r.file === reportFile);
|
|
279
|
+
if (report) {
|
|
280
|
+
report.issues.push(issue);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
reports.push({ file: reportFile, kind, issues: [issue] });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function shouldWarnNoMetadata(syncDir, filePath) {
|
|
287
|
+
if (!isWorldFile(syncDir, filePath)) return true;
|
|
288
|
+
|
|
289
|
+
const kind = worldEntityKindForPath(syncDir, filePath);
|
|
290
|
+
if (!kind) return false;
|
|
291
|
+
|
|
292
|
+
return isCanonicalWorldEntityFile(syncDir, filePath, {});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function compareSidecarAndFrontmatter(filePath, reports) {
|
|
296
|
+
const sidecar = sidecarPath(filePath);
|
|
297
|
+
if (!fs.existsSync(sidecar)) return;
|
|
298
|
+
const sc = reports.find(r => r.file === sidecar);
|
|
299
|
+
if (!sc) return;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const { data } = parseFile(filePath);
|
|
303
|
+
if (!data || !Object.keys(data).length) return;
|
|
304
|
+
const sidecarData = parseYaml(fs.readFileSync(sidecar, "utf8")) ?? {};
|
|
305
|
+
|
|
306
|
+
if (typeof data.scene_id === "string" && typeof sidecarData.scene_id === "string" && data.scene_id !== sidecarData.scene_id) {
|
|
307
|
+
sc.issues.push({
|
|
308
|
+
level: "warning",
|
|
309
|
+
code: "SCENE_ID_MISMATCH",
|
|
310
|
+
message: `scene_id mismatch between frontmatter ('${data.scene_id}') and sidecar ('${sidecarData.scene_id}').`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// Parsing failures are already surfaced by individual report entries.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function lintMetadataInSyncDir(syncDir) {
|
|
319
|
+
const reports = [];
|
|
320
|
+
const files = walkFiles(syncDir);
|
|
321
|
+
|
|
322
|
+
for (const sidecar of walkSidecars(syncDir)) {
|
|
323
|
+
reports.push(lintSidecar(sidecar));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
if (fs.existsSync(sidecarPath(file))) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const frontmatterReport = lintFrontmatter(file);
|
|
331
|
+
if (frontmatterReport) {
|
|
332
|
+
reports.push(frontmatterReport);
|
|
333
|
+
} else if (shouldWarnNoMetadata(syncDir, file)) {
|
|
334
|
+
// No sidecar and no frontmatter — file will be silently skipped during sync
|
|
335
|
+
reports.push({
|
|
336
|
+
file,
|
|
337
|
+
kind: "scene",
|
|
338
|
+
issues: [{
|
|
339
|
+
level: "warning",
|
|
340
|
+
code: "NO_METADATA",
|
|
341
|
+
message: "File has no sidecar and no frontmatter — will be skipped during sync (no scene_id).",
|
|
342
|
+
}],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
compareSidecarAndFrontmatter(file, reports);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- Duplicate scene_id detection (cross-file, errors) ---
|
|
352
|
+
const sceneIdToFiles = new Map(); // scene_id → [filePath, ...]
|
|
353
|
+
|
|
354
|
+
for (const sidecar of walkSidecars(syncDir)) {
|
|
355
|
+
try {
|
|
356
|
+
const raw = fs.readFileSync(sidecar, "utf8");
|
|
357
|
+
const meta = parseYaml(raw) ?? {};
|
|
358
|
+
if (typeof meta.scene_id === "string" && meta.scene_id) {
|
|
359
|
+
const arr = sceneIdToFiles.get(meta.scene_id) ?? [];
|
|
360
|
+
arr.push(sidecar);
|
|
361
|
+
sceneIdToFiles.set(meta.scene_id, arr);
|
|
362
|
+
}
|
|
363
|
+
} catch { /* empty */ }
|
|
364
|
+
}
|
|
365
|
+
for (const file of files) {
|
|
366
|
+
if (fs.existsSync(sidecarPath(file))) continue; // already counted via sidecar
|
|
367
|
+
try {
|
|
368
|
+
const { data } = parseFile(file);
|
|
369
|
+
if (typeof data.scene_id === "string" && data.scene_id) {
|
|
370
|
+
const arr = sceneIdToFiles.get(data.scene_id) ?? [];
|
|
371
|
+
arr.push(file);
|
|
372
|
+
sceneIdToFiles.set(data.scene_id, arr);
|
|
373
|
+
}
|
|
374
|
+
} catch { /* empty */ }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const [sceneId, dupeFiles] of sceneIdToFiles) {
|
|
378
|
+
if (dupeFiles.length < 2) continue;
|
|
379
|
+
const relPaths = dupeFiles.map(f => path.relative(syncDir, f)).join(", ");
|
|
380
|
+
for (const f of dupeFiles) {
|
|
381
|
+
const report = reports.find(r => r.file === f);
|
|
382
|
+
const issue = {
|
|
383
|
+
level: "error",
|
|
384
|
+
code: "DUPLICATE_SCENE_ID",
|
|
385
|
+
message: `scene_id "${sceneId}" is used by ${dupeFiles.length} files: ${relPaths}`,
|
|
386
|
+
};
|
|
387
|
+
if (report) {
|
|
388
|
+
report.issues.push(issue);
|
|
389
|
+
} else {
|
|
390
|
+
reports.push({ file: f, kind: "scene", issues: [issue] });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const entityIdToFiles = new Map();
|
|
396
|
+
const canonicalFilesByFolder = new Map();
|
|
397
|
+
|
|
398
|
+
for (const file of files) {
|
|
399
|
+
const kind = worldEntityKindForPath(syncDir, file);
|
|
400
|
+
if (!kind) continue;
|
|
401
|
+
|
|
402
|
+
const meta = loadMetadataForFile(file);
|
|
403
|
+
if (!meta) continue;
|
|
404
|
+
if (!isCanonicalWorldEntityFile(syncDir, file, meta)) continue;
|
|
405
|
+
|
|
406
|
+
const folderKey = worldEntityFolderKey(syncDir, file, kind);
|
|
407
|
+
if (folderKey) {
|
|
408
|
+
const current = canonicalFilesByFolder.get(folderKey) ?? [];
|
|
409
|
+
current.push(file);
|
|
410
|
+
canonicalFilesByFolder.set(folderKey, current);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const entityId = kind === "character" ? meta.character_id : meta.place_id;
|
|
414
|
+
if (typeof entityId === "string" && entityId) {
|
|
415
|
+
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
416
|
+
const scopeKey = `${kind}:${universe_id ?? "-"}:${project_id ?? "-"}:${entityId}`;
|
|
417
|
+
const current = entityIdToFiles.get(scopeKey) ?? [];
|
|
418
|
+
current.push(file);
|
|
419
|
+
entityIdToFiles.set(scopeKey, current);
|
|
420
|
+
} else {
|
|
421
|
+
addIssueToReport(reports, file, kind, {
|
|
422
|
+
level: "warning",
|
|
423
|
+
code: kind === "character" ? "MISSING_CHARACTER_ID" : "MISSING_PLACE_ID",
|
|
424
|
+
message: `Canonical ${kind} file is missing required '${kind}_id'.`,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const [folderKey, canonicalFiles] of canonicalFilesByFolder) {
|
|
430
|
+
if (canonicalFiles.length < 2) continue;
|
|
431
|
+
const relPaths = canonicalFiles.map(f => path.relative(syncDir, f)).join(", ");
|
|
432
|
+
for (const file of canonicalFiles) {
|
|
433
|
+
addIssueToReport(reports, file, worldEntityKindForPath(syncDir, file), {
|
|
434
|
+
level: "error",
|
|
435
|
+
code: "MULTIPLE_CANONICAL_FILES",
|
|
436
|
+
message: `Multiple canonical files found in entity folder '${folderKey}': ${relPaths}`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const [scopeKey, dupeFiles] of entityIdToFiles) {
|
|
442
|
+
if (dupeFiles.length < 2) continue;
|
|
443
|
+
const entityId = scopeKey.split(":").at(-1);
|
|
444
|
+
const kind = scopeKey.split(":")[0];
|
|
445
|
+
const relPaths = dupeFiles.map(f => path.relative(syncDir, f)).join(", ");
|
|
446
|
+
for (const file of dupeFiles) {
|
|
447
|
+
addIssueToReport(reports, file, kind, {
|
|
448
|
+
level: "error",
|
|
449
|
+
code: kind === "character" ? "DUPLICATE_CHARACTER_ID" : "DUPLICATE_PLACE_ID",
|
|
450
|
+
message: `${kind}_id '${entityId}' is used by ${dupeFiles.length} canonical files: ${relPaths}`,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const errors = reports.flatMap(r => r.issues.map(i => ({ ...i, file: r.file, kind: r.kind }))).filter(i => i.level === "error");
|
|
456
|
+
const warnings = reports.flatMap(r => r.issues.map(i => ({ ...i, file: r.file, kind: r.kind }))).filter(i => i.level === "warning");
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
ok: errors.length === 0,
|
|
460
|
+
syncDir: path.resolve(syncDir),
|
|
461
|
+
files_checked: reports.length,
|
|
462
|
+
error_count: errors.length,
|
|
463
|
+
warning_count: warnings.length,
|
|
464
|
+
errors,
|
|
465
|
+
warnings,
|
|
466
|
+
reports,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import matter from "gray-matter";
|
|
3
|
+
import { buildCharacterNormalizationContext, escapeRegex, resolveCharacterReference } from "./scene-character-normalization.js";
|
|
4
|
+
import { normalizeSceneMetaForPath, readMeta, writeMeta } from "./sync.js";
|
|
5
|
+
|
|
6
|
+
function normalizeCharacterRows(rows) {
|
|
7
|
+
return buildCharacterNormalizationContext(rows);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function inferCharactersFromProse(prose, characterRows) {
|
|
11
|
+
const { clean, tokenMap } = characterRows;
|
|
12
|
+
const inferred = new Set();
|
|
13
|
+
const full_name_matches = new Set();
|
|
14
|
+
const ambiguous_tokens = [];
|
|
15
|
+
|
|
16
|
+
for (const row of clean) {
|
|
17
|
+
if (row.full_name_regex?.test(prose)) {
|
|
18
|
+
inferred.add(row.character_id);
|
|
19
|
+
full_name_matches.add(row.character_id);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Precision-first v1 policy: multi-token names require a full phrase match.
|
|
24
|
+
if (row.phrase_tokens.length !== 1) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const token of row.informative_tokens) {
|
|
29
|
+
const tokenRegex = new RegExp(`\\b${escapeRegex(token)}\\b`, "i");
|
|
30
|
+
if (!tokenRegex.test(prose)) continue;
|
|
31
|
+
|
|
32
|
+
const tokenIds = tokenMap.get(token) ?? [];
|
|
33
|
+
if (tokenIds.length === 1) {
|
|
34
|
+
inferred.add(row.character_id);
|
|
35
|
+
} else if (!ambiguous_tokens.includes(token)) {
|
|
36
|
+
ambiguous_tokens.push(token);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const [token, tokenIds] of tokenMap.entries()) {
|
|
42
|
+
if (tokenIds.length < 2) continue;
|
|
43
|
+
const tokenRegex = new RegExp(`\\b${escapeRegex(token)}\\b`, "i");
|
|
44
|
+
if (tokenRegex.test(prose) && !ambiguous_tokens.includes(token)) {
|
|
45
|
+
ambiguous_tokens.push(token);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
inferred_characters: [...inferred],
|
|
51
|
+
full_name_matches: [...full_name_matches],
|
|
52
|
+
ambiguous_tokens,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveCharacterEntry(entry, characterRows) {
|
|
57
|
+
return resolveCharacterReference(entry, characterRows);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pruneLessSpecificCharacters(characterIds, fullNameMatches, characterRows) {
|
|
61
|
+
const kept = new Set(characterIds);
|
|
62
|
+
|
|
63
|
+
for (const candidateId of [...kept]) {
|
|
64
|
+
const candidate = characterRows.byId.get(candidateId);
|
|
65
|
+
if (!candidate || candidate.informative_tokens.length < 2) continue;
|
|
66
|
+
|
|
67
|
+
for (const dominantId of fullNameMatches) {
|
|
68
|
+
if (candidateId === dominantId) continue;
|
|
69
|
+
const dominant = characterRows.byId.get(dominantId);
|
|
70
|
+
if (!dominant) continue;
|
|
71
|
+
if (candidate.informative_tokens.length >= dominant.informative_tokens.length) continue;
|
|
72
|
+
|
|
73
|
+
if (candidate.informative_tokens.every(token => dominant.informative_tokens.includes(token))) {
|
|
74
|
+
kept.delete(candidateId);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [...kept];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function nextTurn() {
|
|
84
|
+
return new Promise(resolve => setImmediate(resolve));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getInterSceneDelayMs() {
|
|
88
|
+
const raw = Number(process.env.MCP_WRITING_SCENE_CHARACTER_BATCH_DELAY_MS ?? 0);
|
|
89
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function delay(ms) {
|
|
93
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function runSceneCharacterBatch({ syncDir, args, onProgress, shouldCancel }) {
|
|
97
|
+
const {
|
|
98
|
+
project_id,
|
|
99
|
+
dry_run = true,
|
|
100
|
+
replace_mode = "merge",
|
|
101
|
+
include_match_details = false,
|
|
102
|
+
project_exists = true,
|
|
103
|
+
target_scenes = [],
|
|
104
|
+
character_rows = [],
|
|
105
|
+
} = args;
|
|
106
|
+
|
|
107
|
+
const targetScenes = Array.isArray(target_scenes) ? target_scenes : [];
|
|
108
|
+
const characterRows = Array.isArray(character_rows) ? character_rows : [];
|
|
109
|
+
const normalizedCharacterRows = normalizeCharacterRows(characterRows);
|
|
110
|
+
|
|
111
|
+
const results = [];
|
|
112
|
+
let processed_scenes = 0;
|
|
113
|
+
let scenes_changed = 0;
|
|
114
|
+
let failed_scenes = 0;
|
|
115
|
+
let links_added = 0;
|
|
116
|
+
let links_removed = 0;
|
|
117
|
+
const interSceneDelayMs = getInterSceneDelayMs();
|
|
118
|
+
|
|
119
|
+
const emitProgress = () => {
|
|
120
|
+
if (typeof onProgress !== "function") return;
|
|
121
|
+
onProgress({
|
|
122
|
+
total_scenes: targetScenes.length,
|
|
123
|
+
processed_scenes,
|
|
124
|
+
scenes_changed,
|
|
125
|
+
failed_scenes,
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
emitProgress();
|
|
130
|
+
|
|
131
|
+
for (const scene of targetScenes) {
|
|
132
|
+
await nextTurn();
|
|
133
|
+
|
|
134
|
+
if (typeof shouldCancel === "function" && shouldCancel()) {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
140
|
+
const { content: prose } = matter(raw);
|
|
141
|
+
const { meta } = readMeta(scene.file_path, syncDir, { writable: !dry_run });
|
|
142
|
+
|
|
143
|
+
const before_characters = [...new Set((meta.characters ?? []).map(String).filter(Boolean))];
|
|
144
|
+
const normalized_before_characters = [...new Set(
|
|
145
|
+
before_characters
|
|
146
|
+
.map(character => resolveCharacterEntry(character, normalizedCharacterRows))
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
)];
|
|
149
|
+
const inference = inferCharactersFromProse(prose, normalizedCharacterRows);
|
|
150
|
+
const inferred_characters = inference.inferred_characters;
|
|
151
|
+
|
|
152
|
+
const afterSet = new Set(normalized_before_characters);
|
|
153
|
+
if (replace_mode === "replace") {
|
|
154
|
+
afterSet.clear();
|
|
155
|
+
}
|
|
156
|
+
for (const characterId of inferred_characters) {
|
|
157
|
+
afterSet.add(characterId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const after_characters = pruneLessSpecificCharacters(
|
|
161
|
+
[...afterSet],
|
|
162
|
+
inference.full_name_matches,
|
|
163
|
+
normalizedCharacterRows
|
|
164
|
+
);
|
|
165
|
+
const beforeSet = new Set(before_characters);
|
|
166
|
+
const added = after_characters.filter(id => !beforeSet.has(id));
|
|
167
|
+
const afterSetLookup = new Set(after_characters);
|
|
168
|
+
const removed = before_characters.filter(id => !afterSetLookup.has(id));
|
|
169
|
+
const changed = added.length > 0 || removed.length > 0;
|
|
170
|
+
|
|
171
|
+
if (!dry_run && changed) {
|
|
172
|
+
const updatedMeta = normalizeSceneMetaForPath(syncDir, scene.file_path, {
|
|
173
|
+
...meta,
|
|
174
|
+
characters: after_characters,
|
|
175
|
+
}).meta;
|
|
176
|
+
|
|
177
|
+
writeMeta(scene.file_path, updatedMeta);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
scenes_changed += changed ? 1 : 0;
|
|
181
|
+
links_added += added.length;
|
|
182
|
+
links_removed += removed.length;
|
|
183
|
+
|
|
184
|
+
const hasInferredMatches = inferred_characters.length > 0;
|
|
185
|
+
const sceneStatus = changed
|
|
186
|
+
? "changed"
|
|
187
|
+
: (!hasInferredMatches && inference.ambiguous_tokens.length > 0 ? "skipped_ambiguous" : "unchanged");
|
|
188
|
+
|
|
189
|
+
results.push({
|
|
190
|
+
scene_id: scene.scene_id,
|
|
191
|
+
file_path: scene.file_path,
|
|
192
|
+
before_characters,
|
|
193
|
+
inferred_characters,
|
|
194
|
+
after_characters,
|
|
195
|
+
added,
|
|
196
|
+
removed,
|
|
197
|
+
changed,
|
|
198
|
+
status: sceneStatus,
|
|
199
|
+
...(include_match_details ? { match_details: { ambiguous_tokens: inference.ambiguous_tokens } } : {}),
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
failed_scenes += 1;
|
|
203
|
+
results.push({
|
|
204
|
+
scene_id: scene.scene_id,
|
|
205
|
+
file_path: scene.file_path,
|
|
206
|
+
before_characters: [],
|
|
207
|
+
inferred_characters: [],
|
|
208
|
+
after_characters: [],
|
|
209
|
+
added: [],
|
|
210
|
+
removed: [],
|
|
211
|
+
changed: false,
|
|
212
|
+
status: "failed",
|
|
213
|
+
error: error instanceof Error ? error.message : String(error),
|
|
214
|
+
});
|
|
215
|
+
} finally {
|
|
216
|
+
processed_scenes += 1;
|
|
217
|
+
emitProgress();
|
|
218
|
+
if (interSceneDelayMs > 0) {
|
|
219
|
+
await delay(interSceneDelayMs);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const warnings = [];
|
|
225
|
+
if (failed_scenes > 0) {
|
|
226
|
+
warnings.push("PARTIAL_SUCCESS: one or more scenes failed to process.");
|
|
227
|
+
}
|
|
228
|
+
if (!project_exists && targetScenes.length === 0) {
|
|
229
|
+
warnings.push(`PROJECT_NOT_FOUND_WARNING: project '${project_id}' was not found; nothing to process.`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
cancelled: Boolean(typeof shouldCancel === "function" && shouldCancel() && processed_scenes < targetScenes.length),
|
|
235
|
+
project_id,
|
|
236
|
+
dry_run: Boolean(dry_run),
|
|
237
|
+
total_scenes: targetScenes.length,
|
|
238
|
+
processed_scenes,
|
|
239
|
+
scenes_changed,
|
|
240
|
+
failed_scenes,
|
|
241
|
+
links_added,
|
|
242
|
+
links_removed,
|
|
243
|
+
results,
|
|
244
|
+
...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|