@hanna84/mcp-writing 2.9.0 → 2.9.4

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/tools/sync.js ADDED
@@ -0,0 +1,612 @@
1
+ import { z } from "zod";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import matter from "gray-matter";
5
+ import { syncAll, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync.js";
6
+ import { importScrivenerSync, validateProjectId } from "../importer.js";
7
+
8
+ export function registerSyncTools(s, {
9
+ db,
10
+ SYNC_DIR,
11
+ SYNC_DIR_ABS,
12
+ SYNC_DIR_REAL,
13
+ SYNC_DIR_WRITABLE,
14
+ asyncJobs,
15
+ errorResponse,
16
+ jsonResponse,
17
+ validateRegexPatterns,
18
+ startAsyncJob,
19
+ pruneAsyncJobs,
20
+ toPublicJob,
21
+ resolveProjectRoot,
22
+ resolveBatchTargetScenes,
23
+ maxScenesNextStep,
24
+ isPathInsideSyncDir,
25
+ deriveLoglineFromProse,
26
+ inferCharacterIdsFromProse,
27
+ }) {
28
+ s.tool("sync", "Re-scan the sync folder and update the scene/character/place index from disk. Call this after making edits in Scrivener or updating sidecar files outside the MCP.", {}, async () => {
29
+ const result = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
30
+ const parts = [`Sync complete. ${result.indexed} scenes indexed. ${result.staleMarked} scenes marked stale.`];
31
+ if (result.sidecarsMigrated) parts.push(`${result.sidecarsMigrated} sidecar(s) auto-generated from frontmatter.`);
32
+ if (result.skipped) {
33
+ parts.push(`${result.skipped} file(s) skipped (no scene_id).`);
34
+ parts.push(`Tip: for raw Scrivener Draft exports, run scripts/import.js first, then run sync again.`);
35
+ }
36
+ const summary = result.warningSummary;
37
+ const summaryEntries = Object.entries(summary);
38
+ if (summaryEntries.length) {
39
+ const lines = summaryEntries.map(([type, entry]) => `- ${type}: ${entry.count} (e.g. ${entry.examples[0]})`);
40
+ parts.push(`\n⚠️ Warning summary:\n` + lines.join("\n"));
41
+ }
42
+ return { content: [{ type: "text", text: parts.join(" ") }] };
43
+ });
44
+
45
+ s.tool(
46
+ "import_scrivener_sync",
47
+ "[STABLE] Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. This is the recommended default path for first-time setup before sync().",
48
+ {
49
+ source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
50
+ project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
51
+ dry_run: z.boolean().optional().describe("If true, reports planned writes without changing files."),
52
+ auto_sync: z.boolean().optional().describe("If true (default), runs sync() after import when not dry-run."),
53
+ preflight: z.boolean().optional().describe("If true, returns a list of files that would be processed without doing any work. Use to verify scope before a large import."),
54
+ ignore_patterns: z.array(z.string()).optional().describe("Array of regex patterns matched against filenames. Files matching any pattern are excluded from import. Useful to skip fragments, beat-sheet notes, or feedback files."),
55
+ },
56
+ async ({ source_dir, project_id, dry_run = false, auto_sync = true, preflight = false, ignore_patterns = [] }) => {
57
+ if (project_id !== undefined) {
58
+ const projectIdCheck = validateProjectId(project_id);
59
+ if (!projectIdCheck.ok) {
60
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
61
+ }
62
+ }
63
+
64
+ const ignorePatternCheck = validateRegexPatterns(ignore_patterns);
65
+ if (!ignorePatternCheck.ok) {
66
+ return errorResponse(
67
+ "INVALID_IGNORE_PATTERN",
68
+ `Invalid ignore pattern '${ignorePatternCheck.pattern}': ${ignorePatternCheck.reason}`,
69
+ {
70
+ source_dir,
71
+ sync_dir: SYNC_DIR_ABS,
72
+ project_id: project_id ?? null,
73
+ pattern: ignorePatternCheck.pattern,
74
+ }
75
+ );
76
+ }
77
+
78
+ if (!dry_run && !SYNC_DIR_WRITABLE) {
79
+ return errorResponse(
80
+ "SYNC_DIR_NOT_WRITABLE",
81
+ "Cannot import because WRITING_SYNC_DIR is not writable in this runtime.",
82
+ { sync_dir: SYNC_DIR_ABS }
83
+ );
84
+ }
85
+
86
+ let importResult;
87
+ try {
88
+ importResult = importScrivenerSync({
89
+ scrivenerDir: source_dir,
90
+ mcpSyncDir: SYNC_DIR,
91
+ projectId: project_id,
92
+ dryRun: Boolean(dry_run) || preflight,
93
+ preflight: Boolean(preflight),
94
+ ignorePatterns: ignore_patterns,
95
+ });
96
+ } catch (error) {
97
+ if (error && typeof error === "object" && error.code === "INVALID_IGNORE_PATTERN") {
98
+ return errorResponse(
99
+ "INVALID_IGNORE_PATTERN",
100
+ error instanceof Error ? error.message : "Invalid ignore pattern.",
101
+ {
102
+ source_dir,
103
+ sync_dir: SYNC_DIR_ABS,
104
+ project_id: project_id ?? null,
105
+ pattern: error.pattern ?? null,
106
+ }
107
+ );
108
+ }
109
+ return errorResponse(
110
+ "IMPORT_FAILED",
111
+ error instanceof Error ? error.message : "Import failed.",
112
+ {
113
+ source_dir,
114
+ sync_dir: SYNC_DIR_ABS,
115
+ project_id: project_id ?? null,
116
+ }
117
+ );
118
+ }
119
+
120
+ let syncResult = null;
121
+ if (!dry_run && !preflight && auto_sync) {
122
+ syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
123
+ }
124
+
125
+ return jsonResponse({
126
+ ok: true,
127
+ import: {
128
+ source_dir: importResult.scrivenerDir,
129
+ sync_dir: importResult.mcpSyncDir,
130
+ scenes_dir: importResult.scenesDir,
131
+ project_id: importResult.projectId,
132
+ preflight: importResult.preflight,
133
+ source_files: importResult.sourceFiles,
134
+ ignored_files: importResult.ignoredFiles,
135
+ ...(importResult.preflight ? {
136
+ files_to_process: importResult.filesToProcess,
137
+ file_previews: importResult.filePreviews,
138
+ existing_sidecars: importResult.existingSidecars,
139
+ } : {}),
140
+ created: importResult.created,
141
+ existing: importResult.existing,
142
+ skipped: importResult.skipped,
143
+ beat_markers_seen: importResult.beatMarkersSeen,
144
+ dry_run: importResult.dryRun,
145
+ },
146
+ sync: syncResult
147
+ ? {
148
+ indexed: syncResult.indexed,
149
+ stale_marked: syncResult.staleMarked,
150
+ sidecars_migrated: syncResult.sidecarsMigrated,
151
+ skipped: syncResult.skipped,
152
+ warning_summary: syncResult.warningSummary,
153
+ }
154
+ : null,
155
+ next_step: preflight
156
+ ? "Preflight complete. Review file_previews and ignored_files, then re-run without preflight=true."
157
+ : dry_run
158
+ ? "Dry run complete. Re-run with dry_run=false to write files."
159
+ : auto_sync
160
+ ? "Import and sync complete."
161
+ : "Import complete. Run sync() to index imported scenes.",
162
+ });
163
+ }
164
+ );
165
+
166
+ s.tool(
167
+ "import_scrivener_sync_async",
168
+ "[STABLE] Start an asynchronous Scrivener External Folder Sync import job. This is the recommended default import path when the sync tree is large. Returns immediately with a job_id to poll via get_async_job_status.",
169
+ {
170
+ source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
171
+ project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
172
+ dry_run: z.boolean().optional().describe("If true, reports planned writes without changing files."),
173
+ auto_sync: z.boolean().optional().describe("If true, runs sync() after a non-dry-run async import finishes."),
174
+ preflight: z.boolean().optional().describe("If true, returns a list of files that would be processed without doing any work."),
175
+ ignore_patterns: z.array(z.string()).optional().describe("Array of regex patterns matched against filenames. Files matching any pattern are excluded from import."),
176
+ },
177
+ async ({ source_dir, project_id, dry_run = false, auto_sync = false, preflight = false, ignore_patterns = [] }) => {
178
+ if (project_id !== undefined) {
179
+ const projectIdCheck = validateProjectId(project_id);
180
+ if (!projectIdCheck.ok) {
181
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
182
+ }
183
+ }
184
+
185
+ const ignorePatternCheck = validateRegexPatterns(ignore_patterns);
186
+ if (!ignorePatternCheck.ok) {
187
+ return errorResponse(
188
+ "INVALID_IGNORE_PATTERN",
189
+ `Invalid ignore pattern '${ignorePatternCheck.pattern}': ${ignorePatternCheck.reason}`,
190
+ {
191
+ source_dir,
192
+ sync_dir: SYNC_DIR_ABS,
193
+ project_id: project_id ?? null,
194
+ pattern: ignorePatternCheck.pattern,
195
+ }
196
+ );
197
+ }
198
+
199
+ if (!dry_run && !preflight && !SYNC_DIR_WRITABLE) {
200
+ return errorResponse(
201
+ "SYNC_DIR_NOT_WRITABLE",
202
+ "Cannot import because WRITING_SYNC_DIR is not writable in this runtime.",
203
+ { sync_dir: SYNC_DIR_ABS }
204
+ );
205
+ }
206
+
207
+ const job = startAsyncJob({
208
+ kind: "import_scrivener_sync",
209
+ requestPayload: {
210
+ kind: "import_scrivener_sync",
211
+ args: {
212
+ source_dir,
213
+ project_id,
214
+ dry_run: Boolean(dry_run),
215
+ preflight: Boolean(preflight),
216
+ ignore_patterns,
217
+ },
218
+ context: {
219
+ sync_dir: SYNC_DIR,
220
+ },
221
+ },
222
+ onComplete: (completedJob) => {
223
+ if (!auto_sync || dry_run || preflight || completedJob.status !== "completed") return;
224
+ const syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
225
+ if (completedJob.result && completedJob.result.ok) {
226
+ completedJob.result.sync = {
227
+ indexed: syncResult.indexed,
228
+ stale_marked: syncResult.staleMarked,
229
+ sidecars_migrated: syncResult.sidecarsMigrated,
230
+ skipped: syncResult.skipped,
231
+ warning_summary: syncResult.warningSummary,
232
+ };
233
+ }
234
+ },
235
+ });
236
+
237
+ return jsonResponse({
238
+ ok: true,
239
+ async: true,
240
+ job: toPublicJob(job, false),
241
+ next_step: "Call get_async_job_status with job_id until status is 'completed' or 'failed'.",
242
+ });
243
+ }
244
+ );
245
+
246
+ s.tool(
247
+ "merge_scrivener_project_beta",
248
+ "Merge metadata directly from a Scrivener .scriv project into existing scene sidecars by starting a background job. This path is opt-in and requires sidecars to already exist (for example, from import_scrivener_sync). Returns immediately with a job_id to poll via get_async_job_status.",
249
+ {
250
+ source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
251
+ project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
252
+ scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id."),
253
+ dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
254
+ auto_sync: z.boolean().optional().describe("If true, runs sync() after a non-dry-run async merge finishes."),
255
+ organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies. Chapter metadata is always extracted to sidecars."),
256
+ },
257
+ async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false, organize_by_chapters = false }) => {
258
+ if (project_id !== undefined) {
259
+ const projectIdCheck = validateProjectId(project_id);
260
+ if (!projectIdCheck.ok) {
261
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
262
+ }
263
+ }
264
+
265
+ if (!dry_run && !SYNC_DIR_WRITABLE) {
266
+ return errorResponse(
267
+ "SYNC_DIR_NOT_WRITABLE",
268
+ "Cannot merge Scrivener metadata because WRITING_SYNC_DIR is not writable in this runtime.",
269
+ { sync_dir: SYNC_DIR_ABS }
270
+ );
271
+ }
272
+
273
+ const resolvedScenesDir = scenes_dir
274
+ ?? (project_id ? path.join(resolveProjectRoot(project_id), "scenes") : undefined);
275
+ const normalizedScenesDir = resolvedScenesDir ? path.resolve(resolvedScenesDir) : undefined;
276
+
277
+ if (normalizedScenesDir) {
278
+ if (!isPathInsideSyncDir(normalizedScenesDir)) {
279
+ return errorResponse(
280
+ "INVALID_SCENES_DIR",
281
+ "scenes_dir must be inside WRITING_SYNC_DIR.",
282
+ { scenes_dir: normalizedScenesDir, sync_dir: SYNC_DIR_ABS, sync_dir_real: SYNC_DIR_REAL }
283
+ );
284
+ }
285
+ }
286
+
287
+ const job = startAsyncJob({
288
+ kind: "merge_scrivener_project_beta",
289
+ requestPayload: {
290
+ kind: "merge_scrivener_project_beta",
291
+ args: {
292
+ source_project_dir,
293
+ project_id,
294
+ scenes_dir: normalizedScenesDir,
295
+ dry_run: Boolean(dry_run),
296
+ organize_by_chapters: Boolean(organize_by_chapters),
297
+ },
298
+ context: {
299
+ sync_dir: SYNC_DIR,
300
+ },
301
+ },
302
+ onComplete: (completedJob) => {
303
+ if (!auto_sync || dry_run || completedJob.status !== "completed") return;
304
+ const syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
305
+ if (completedJob.result && completedJob.result.ok) {
306
+ completedJob.result.sync = {
307
+ indexed: syncResult.indexed,
308
+ stale_marked: syncResult.staleMarked,
309
+ sidecars_migrated: syncResult.sidecarsMigrated,
310
+ skipped: syncResult.skipped,
311
+ warning_summary: syncResult.warningSummary,
312
+ };
313
+ }
314
+ },
315
+ });
316
+
317
+ return jsonResponse({
318
+ ok: true,
319
+ async: true,
320
+ job: toPublicJob(job, false),
321
+ next_step: "Call get_async_job_status with job_id until status is 'completed' or 'failed'.",
322
+ });
323
+ }
324
+ );
325
+
326
+ s.tool(
327
+ "enrich_scene_characters_batch",
328
+ "Start an asynchronous batch job that infers scene character mentions and updates scene metadata links. Version 1 uses canonical character names only (no aliases). Defaults to dry_run=true.",
329
+ {
330
+ project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
331
+ scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
332
+ part: z.number().int().optional().describe("Optional part number filter."),
333
+ chapter: z.number().int().optional().describe("Optional chapter number filter."),
334
+ only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
335
+ dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
336
+ replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
337
+ max_scenes: z.number().int().positive().optional().describe("Hard guardrail for resolved scene count (default: 200)."),
338
+ include_match_details: z.boolean().optional().describe("If true, include extra match diagnostics per scene."),
339
+ confirm_replace: z.boolean().optional().describe("Must be true when replace_mode=replace."),
340
+ },
341
+ async ({
342
+ project_id,
343
+ scene_ids,
344
+ part,
345
+ chapter,
346
+ only_stale = false,
347
+ dry_run = true,
348
+ replace_mode = "merge",
349
+ max_scenes = 200,
350
+ include_match_details = false,
351
+ confirm_replace = false,
352
+ }) => {
353
+ const projectIdCheck = validateProjectId(project_id);
354
+ if (!projectIdCheck.ok) {
355
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
356
+ }
357
+
358
+ if (replace_mode === "replace" && !confirm_replace) {
359
+ return errorResponse(
360
+ "VALIDATION_ERROR",
361
+ "replace_mode=replace requires confirm_replace=true.",
362
+ { replace_mode, confirm_replace }
363
+ );
364
+ }
365
+
366
+ if (!dry_run && !SYNC_DIR_WRITABLE) {
367
+ return errorResponse(
368
+ "READ_ONLY",
369
+ "Cannot run batch character enrichment in write mode: sync dir is read-only.",
370
+ { sync_dir: SYNC_DIR_ABS }
371
+ );
372
+ }
373
+
374
+ const characterRows = db.prepare(`
375
+ SELECT character_id, name
376
+ FROM characters
377
+ WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
378
+ ORDER BY length(name) DESC
379
+ `).all(project_id, project_id);
380
+
381
+ const targetResolution = resolveBatchTargetScenes(db, {
382
+ projectId: project_id,
383
+ sceneIds: scene_ids,
384
+ part,
385
+ chapter,
386
+ onlyStale: Boolean(only_stale),
387
+ });
388
+ if (!targetResolution.ok) {
389
+ return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
390
+ }
391
+
392
+ const targetScenes = targetResolution.rows;
393
+ const projectExists = targetResolution.project_exists !== false;
394
+ if (targetScenes.length > max_scenes) {
395
+ return errorResponse(
396
+ "VALIDATION_ERROR",
397
+ `Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
398
+ {
399
+ matched_scenes: targetScenes.length,
400
+ max_scenes,
401
+ project_id,
402
+ next_step: maxScenesNextStep(targetScenes.length),
403
+ }
404
+ );
405
+ }
406
+
407
+ const job = startAsyncJob({
408
+ kind: "enrich_scene_characters_batch",
409
+ requestPayload: {
410
+ kind: "enrich_scene_characters_batch",
411
+ args: {
412
+ project_id,
413
+ dry_run: Boolean(dry_run),
414
+ replace_mode,
415
+ include_match_details: Boolean(include_match_details),
416
+ project_exists: projectExists,
417
+ target_scenes: targetScenes,
418
+ character_rows: characterRows,
419
+ },
420
+ context: { sync_dir: SYNC_DIR },
421
+ },
422
+ onComplete: (completedJob) => {
423
+ if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
424
+
425
+ syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
426
+
427
+ const changedScenes = (completedJob.result.results ?? [])
428
+ .filter(row => row.status === "changed")
429
+ .map(row => row.scene_id);
430
+
431
+ for (const sceneId of changedScenes) {
432
+ db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
433
+ .run(sceneId, project_id);
434
+ }
435
+ },
436
+ });
437
+
438
+ return jsonResponse({
439
+ ok: true,
440
+ async: true,
441
+ job: toPublicJob(job, false),
442
+ next_step: "Call get_async_job_status with job_id until status is 'completed', 'failed', or 'cancelled'.",
443
+ });
444
+ }
445
+ );
446
+
447
+ s.tool(
448
+ "get_async_job_status",
449
+ "Get status and result for an asynchronous job started by async tools such as import_scrivener_sync_async, merge_scrivener_project_beta, or enrich_scene_characters_batch. Use this to poll job progress after receiving a job_id. Common next step: if status is still running, call this tool again; if status is completed inspect result, and if status is failed or cancelled inspect job/result diagnostics.",
450
+ {
451
+ job_id: z.string().describe("Job ID returned by an async start tool."),
452
+ include_result: z.boolean().optional().describe("If true (default), includes completed result payload when available."),
453
+ },
454
+ async ({ job_id, include_result = true }) => {
455
+ pruneAsyncJobs();
456
+ const job = asyncJobs.get(job_id);
457
+ if (!job) {
458
+ return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired. Hint: call list_async_jobs to see currently tracked job IDs.`);
459
+ }
460
+ return jsonResponse({ ok: true, async: true, job: toPublicJob(job, include_result) });
461
+ }
462
+ );
463
+
464
+ s.tool(
465
+ "list_async_jobs",
466
+ "List asynchronous jobs currently known to this server. Use this when you lost a job_id or need a dashboard view of running/completed jobs. Returns an object envelope containing a jobs array of job objects sorted by newest first.",
467
+ {
468
+ include_results: z.boolean().optional().describe("If true, includes completed result payloads."),
469
+ },
470
+ async ({ include_results = false }) => {
471
+ pruneAsyncJobs();
472
+ const jobs = [...asyncJobs.values()]
473
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
474
+ .map(job => toPublicJob(job, include_results));
475
+ return jsonResponse({ ok: true, async: true, jobs });
476
+ }
477
+ );
478
+
479
+ s.tool(
480
+ "cancel_async_job",
481
+ "Cancel a running asynchronous job. Use this when an import/merge/batch run was started with overly broad scope or is no longer needed. Returns the updated job state; cancellation is cooperative and may transition through 'cancelling' before 'cancelled'.",
482
+ {
483
+ job_id: z.string().describe("Job ID returned by an async start tool."),
484
+ },
485
+ async ({ job_id }) => {
486
+ pruneAsyncJobs();
487
+ const job = asyncJobs.get(job_id);
488
+ if (!job) {
489
+ return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired. Hint: call list_async_jobs to find active IDs.`);
490
+ }
491
+
492
+ if (job.status !== "running") {
493
+ return jsonResponse({
494
+ ok: true,
495
+ async: true,
496
+ cancelled: false,
497
+ message: `Job is already ${job.status}.`,
498
+ job: toPublicJob(job, false),
499
+ });
500
+ }
501
+
502
+ // Guard: if the child has already exited, its exit handler will have
503
+ // set the terminal status. Don't overwrite it.
504
+ const childHasExited = job.child.exitCode !== null || job.child.signalCode !== null;
505
+ if (childHasExited) {
506
+ return jsonResponse({
507
+ ok: true,
508
+ async: true,
509
+ cancelled: false,
510
+ message: "Job is no longer running.",
511
+ job: toPublicJob(job, false),
512
+ });
513
+ }
514
+
515
+ let signalSent = false;
516
+ try {
517
+ signalSent = job.child.kill("SIGTERM");
518
+ } catch {
519
+ // kill() threw — treat as signal not sent
520
+ }
521
+
522
+ if (!signalSent) {
523
+ return jsonResponse({
524
+ ok: true,
525
+ async: true,
526
+ cancelled: false,
527
+ message: "Cancellation could not be requested; job may have already finished.",
528
+ job: toPublicJob(job, false),
529
+ });
530
+ }
531
+
532
+ // Transitional: signal sent but worker has not yet exited.
533
+ // Exit/error handlers will finalise status to "cancelled".
534
+ job.status = "cancelling";
535
+
536
+ return jsonResponse({
537
+ ok: true,
538
+ async: true,
539
+ cancelled: true,
540
+ message: "Cancellation requested. Poll get_async_job_status until status is 'cancelled'.",
541
+ job: toPublicJob(job, false),
542
+ });
543
+ }
544
+ );
545
+
546
+ // ---- enrichment ----------------------------------------------------------
547
+ s.tool(
548
+ "enrich_scene",
549
+ "Re-derive lightweight scene metadata from current prose (logline and character mentions) and clear metadata_stale for that scene. Only available when the sync dir is writable.",
550
+ {
551
+ scene_id: z.string().describe("Scene to enrich (e.g. 'sc-011-sebastian')."),
552
+ project_id: z.string().optional().describe("Project ID. Required when scene_id is duplicated across projects."),
553
+ },
554
+ async ({ scene_id, project_id }) => {
555
+ if (!SYNC_DIR_WRITABLE) {
556
+ return errorResponse("READ_ONLY", "Cannot enrich scene: sync dir is read-only.");
557
+ }
558
+
559
+ let scene;
560
+ if (project_id) {
561
+ scene = db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
562
+ .get(scene_id, project_id);
563
+ } else {
564
+ const matches = db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE scene_id = ?`).all(scene_id);
565
+ if (matches.length > 1) {
566
+ return errorResponse("VALIDATION_ERROR", `Scene '${scene_id}' exists in multiple projects. Provide project_id.`);
567
+ }
568
+ scene = matches[0];
569
+ }
570
+
571
+ if (!scene) {
572
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found${project_id ? ` in project '${project_id}'` : ""}.`);
573
+ }
574
+
575
+ try {
576
+ const raw = fs.readFileSync(scene.file_path, "utf8");
577
+ const { content: prose } = matter(raw);
578
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
579
+
580
+ const inferredLogline = deriveLoglineFromProse(prose);
581
+ const inferredCharacters = inferCharacterIdsFromProse(db, prose, scene.project_id);
582
+
583
+ const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, {
584
+ ...meta,
585
+ ...(inferredLogline ? { logline: inferredLogline } : {}),
586
+ ...((inferredCharacters.length > 0 || (meta.characters?.length ?? 0) > 0)
587
+ ? { characters: inferredCharacters.length > 0 ? inferredCharacters : meta.characters }
588
+ : {}),
589
+ }).meta;
590
+
591
+ writeMeta(scene.file_path, updatedMeta);
592
+ indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose);
593
+ db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
594
+ .run(scene.scene_id, scene.project_id);
595
+
596
+ return jsonResponse({
597
+ ok: true,
598
+ action: "enriched",
599
+ scene_id: scene.scene_id,
600
+ project_id: scene.project_id,
601
+ updated_fields: {
602
+ logline: Boolean(inferredLogline),
603
+ characters: inferredCharacters.length,
604
+ },
605
+ metadata_stale: false,
606
+ });
607
+ } catch (err) {
608
+ return errorResponse("IO_ERROR", `Failed to enrich scene '${scene.scene_id}': ${err.message}`);
609
+ }
610
+ }
611
+ );
612
+ }