@hanna84/mcp-writing 1.11.0 → 1.11.1

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 CHANGED
@@ -4,11 +4,21 @@ 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
+ #### [v1.11.1](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.11.0...v1.11.1)
9
+
10
+ - Implement graceful batch cancellation and complete Phase D coverage [`#59`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/59)
12
+
7
13
  #### [v1.11.0](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v1.10.0...v1.11.0)
9
15
 
16
+ > 21 April 2026
17
+
10
18
  - feat: add reusable manual real-data Scrivener test runner [`#58`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/58)
20
+ - Release 1.11.0 [`5938d51`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/5938d51b277dcce2f5fcfe728ff2112d3af644e5)
12
22
 
13
23
  #### [v1.10.0](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v1.9.4...v1.10.0)
package/README.md CHANGED
@@ -76,5 +76,17 @@ Goal: let AI propose prose edits without losing control of your draft.
76
76
 
77
77
  Outcome: you get AI speed with explicit approval and recoverable history for every applied change.
78
78
 
79
+ ### 5) Refreshing scene-character links after imports or major rewrites
80
+
81
+ Goal: rebuild scene-to-character links in a controlled way after imported prose changes or metadata drift.
82
+
83
+ 1. Start with `enrich_scene_characters_batch` using the default `dry_run=true` to preview inferred links for a project, chapter, or explicit scene list.
84
+ 2. Poll `get_async_job_status` until the batch job completes, then review `job.result.results` for changed scenes, ambiguous matches, and partial failures.
85
+ 3. Spot-check a few affected scenes with `get_scene_prose` if the changes touch important continuity or cast-heavy chapters.
86
+ 4. Re-run `enrich_scene_characters_batch` with `dry_run=false` once the preview looks correct.
87
+ 5. If you want a destructive overwrite instead of additive merge behavior, use `replace_mode=replace` with `confirm_replace=true` deliberately.
88
+
89
+ Outcome: character-link maintenance becomes a preview-first batch operation instead of a one-off regex script or manual sidecar cleanup.
90
+
79
91
  ## License
80
92
  AGPL-3.0-only
package/index.js CHANGED
@@ -17,6 +17,7 @@ import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, lis
17
17
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
18
18
  import { importScrivenerSync, validateProjectId } from "./importer.js";
19
19
  import { mergeScrivenerProjectMetadata } from "./scrivener-direct.js";
20
+ import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
20
21
 
21
22
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
22
23
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
@@ -119,12 +120,14 @@ function toPublicJob(job, includeResult = true) {
119
120
  finished_at: job.finishedAt,
120
121
  pid: job.pid,
121
122
  error: job.error,
123
+ ...(job.progress ? { progress: job.progress } : {}),
122
124
  ...(includeResult ? { result: job.result } : {}),
123
125
  };
124
126
  }
125
127
 
126
128
  function startAsyncJob({ kind, requestPayload, onComplete }) {
127
129
  pruneAsyncJobs();
130
+ const progressPrefix = ASYNC_PROGRESS_PREFIX;
128
131
 
129
132
  const id = randomUUID();
130
133
  const tmpPrefix = path.join(os.tmpdir(), "mcp-writing-job-");
@@ -156,14 +159,38 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
156
159
  requestPath,
157
160
  resultPath,
158
161
  result: null,
162
+ progress: null,
159
163
  error: null,
160
164
  onComplete,
161
165
  child,
162
166
  };
163
167
  asyncJobs.set(id, job);
164
168
 
165
- child.stdout.on("data", () => {
166
- // worker writes structured output to resultPath; stdout is ignored here
169
+ let stdoutBuffer = "";
170
+ child.stdout.on("data", (chunk) => {
171
+ stdoutBuffer += chunk.toString("utf8");
172
+ const lines = stdoutBuffer.split("\n");
173
+ stdoutBuffer = lines.pop() ?? "";
174
+
175
+ for (const line of lines) {
176
+ const trimmed = line.trim();
177
+ if (!trimmed.startsWith(progressPrefix)) continue;
178
+ const payload = trimmed.slice(progressPrefix.length);
179
+ try {
180
+ const progress = JSON.parse(payload);
181
+ if (progress && typeof progress === "object") {
182
+ const nextProgress = {
183
+ total_scenes: Number(progress.total_scenes ?? 0),
184
+ processed_scenes: Number(progress.processed_scenes ?? 0),
185
+ scenes_changed: Number(progress.scenes_changed ?? 0),
186
+ failed_scenes: Number(progress.failed_scenes ?? 0),
187
+ };
188
+ job.progress = nextProgress;
189
+ }
190
+ } catch {
191
+ // Ignore malformed progress lines; they are best-effort telemetry.
192
+ }
193
+ }
167
194
  });
168
195
  child.stderr.on("data", () => {
169
196
  // avoid crashing on stderr backpressure for noisy runs
@@ -187,12 +214,32 @@ function startAsyncJob({ kind, requestPayload, onComplete }) {
187
214
  const payload = readJsonIfExists(resultPath);
188
215
  const successful = payload?.ok === true;
189
216
  const cancelledBySignal = signal === "SIGTERM" || signal === "SIGKILL";
217
+ const cancelledByPayload = payload?.cancelled === true;
190
218
 
191
219
  job.finishedAt = new Date().toISOString();
192
220
  job.result = payload;
193
221
 
222
+ const hasProgressFields = payload && (
223
+ payload.total_scenes !== undefined
224
+ || payload.processed_scenes !== undefined
225
+ || payload.scenes_changed !== undefined
226
+ || payload.failed_scenes !== undefined
227
+ );
228
+
229
+ if (payload && payload.ok === true && hasProgressFields) {
230
+ job.progress = {
231
+ total_scenes: Number(payload.total_scenes ?? job.progress?.total_scenes ?? 0),
232
+ processed_scenes: Number(payload.processed_scenes ?? job.progress?.processed_scenes ?? 0),
233
+ scenes_changed: Number(payload.scenes_changed ?? job.progress?.scenes_changed ?? 0),
234
+ failed_scenes: Number(payload.failed_scenes ?? job.progress?.failed_scenes ?? 0),
235
+ };
236
+ }
237
+
194
238
  if (job.status === "cancelling") {
195
- if (successful && !cancelledBySignal) {
239
+ if (cancelledByPayload) {
240
+ job.status = "cancelled";
241
+ job.error = "Async job cancelled after returning partial results.";
242
+ } else if (successful && !cancelledBySignal) {
196
243
  // Race: cancellation was requested as work completed successfully.
197
244
  job.status = "completed";
198
245
  } else {
@@ -379,6 +426,63 @@ function resolveWorldEntityDir({ kind, projectId, universeId, name }) {
379
426
  };
380
427
  }
381
428
 
429
+ function resolveBatchTargetScenes(dbHandle, {
430
+ projectId,
431
+ sceneIds,
432
+ part,
433
+ chapter,
434
+ onlyStale,
435
+ }) {
436
+ const projectExists = Boolean(
437
+ dbHandle.prepare(`SELECT 1 FROM projects WHERE project_id = ? LIMIT 1`).get(projectId)
438
+ );
439
+
440
+ if (sceneIds?.length) {
441
+ const placeholders = sceneIds.map(() => "?").join(",");
442
+ const existingRows = dbHandle.prepare(
443
+ `SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
444
+ ).all(projectId, ...sceneIds);
445
+ const existing = new Set(existingRows.map(row => row.scene_id));
446
+ const missing = sceneIds.filter(sceneId => !existing.has(sceneId));
447
+ if (missing.length > 0) {
448
+ return { ok: false, code: "NOT_FOUND", message: `Requested scene IDs were not found in project '${projectId}'.`, details: { missing_scene_ids: missing, project_id: projectId } };
449
+ }
450
+ }
451
+
452
+ const conditions = ["project_id = ?"];
453
+ const params = [projectId];
454
+
455
+ if (sceneIds?.length) {
456
+ const placeholders = sceneIds.map(() => "?").join(",");
457
+ conditions.push(`scene_id IN (${placeholders})`);
458
+ params.push(...sceneIds);
459
+ }
460
+ if (part !== undefined) {
461
+ conditions.push("part = ?");
462
+ params.push(part);
463
+ }
464
+ if (chapter !== undefined) {
465
+ conditions.push("chapter = ?");
466
+ params.push(chapter);
467
+ }
468
+ if (onlyStale) {
469
+ conditions.push("metadata_stale = 1");
470
+ }
471
+
472
+ const query = `
473
+ SELECT scene_id, project_id, file_path
474
+ FROM scenes
475
+ WHERE ${conditions.join(" AND ")}
476
+ ORDER BY part, chapter, timeline_position
477
+ `;
478
+
479
+ return {
480
+ ok: true,
481
+ rows: dbHandle.prepare(query).all(...params),
482
+ project_exists: projectExists,
483
+ };
484
+ }
485
+
382
486
  function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId, meta }) {
383
487
  const prefix = kind === "character" ? "char" : "place";
384
488
  const idKey = kind === "character" ? "character_id" : "place_id";
@@ -1062,9 +1166,129 @@ function createMcpServer() {
1062
1166
  }
1063
1167
  );
1064
1168
 
1169
+ s.tool(
1170
+ "enrich_scene_characters_batch",
1171
+ "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.",
1172
+ {
1173
+ project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
1174
+ scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
1175
+ part: z.number().int().optional().describe("Optional part number filter."),
1176
+ chapter: z.number().int().optional().describe("Optional chapter number filter."),
1177
+ only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
1178
+ dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
1179
+ replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
1180
+ max_scenes: z.number().int().positive().optional().describe("Hard guardrail for resolved scene count (default: 200)."),
1181
+ include_match_details: z.boolean().optional().describe("If true, include extra match diagnostics per scene."),
1182
+ confirm_replace: z.boolean().optional().describe("Must be true when replace_mode=replace."),
1183
+ },
1184
+ async ({
1185
+ project_id,
1186
+ scene_ids,
1187
+ part,
1188
+ chapter,
1189
+ only_stale = false,
1190
+ dry_run = true,
1191
+ replace_mode = "merge",
1192
+ max_scenes = 200,
1193
+ include_match_details = false,
1194
+ confirm_replace = false,
1195
+ }) => {
1196
+ const projectIdCheck = validateProjectId(project_id);
1197
+ if (!projectIdCheck.ok) {
1198
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
1199
+ }
1200
+
1201
+ if (replace_mode === "replace" && !confirm_replace) {
1202
+ return errorResponse(
1203
+ "VALIDATION_ERROR",
1204
+ "replace_mode=replace requires confirm_replace=true.",
1205
+ { replace_mode, confirm_replace }
1206
+ );
1207
+ }
1208
+
1209
+ if (!dry_run && !SYNC_DIR_WRITABLE) {
1210
+ return errorResponse(
1211
+ "READ_ONLY",
1212
+ "Cannot run batch character enrichment in write mode: sync dir is read-only.",
1213
+ { sync_dir: SYNC_DIR_ABS }
1214
+ );
1215
+ }
1216
+
1217
+ const characterRows = db.prepare(`
1218
+ SELECT character_id, name
1219
+ FROM characters
1220
+ WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
1221
+ ORDER BY length(name) DESC
1222
+ `).all(project_id, project_id);
1223
+
1224
+ const targetResolution = resolveBatchTargetScenes(db, {
1225
+ projectId: project_id,
1226
+ sceneIds: scene_ids,
1227
+ part,
1228
+ chapter,
1229
+ onlyStale: Boolean(only_stale),
1230
+ });
1231
+ if (!targetResolution.ok) {
1232
+ return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
1233
+ }
1234
+
1235
+ const targetScenes = targetResolution.rows;
1236
+ const projectExists = targetResolution.project_exists !== false;
1237
+ if (targetScenes.length > max_scenes) {
1238
+ return errorResponse(
1239
+ "VALIDATION_ERROR",
1240
+ `Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
1241
+ {
1242
+ matched_scenes: targetScenes.length,
1243
+ max_scenes,
1244
+ project_id,
1245
+ }
1246
+ );
1247
+ }
1248
+
1249
+ const job = startAsyncJob({
1250
+ kind: "enrich_scene_characters_batch",
1251
+ requestPayload: {
1252
+ kind: "enrich_scene_characters_batch",
1253
+ args: {
1254
+ project_id,
1255
+ dry_run: Boolean(dry_run),
1256
+ replace_mode,
1257
+ include_match_details: Boolean(include_match_details),
1258
+ project_exists: projectExists,
1259
+ target_scenes: targetScenes,
1260
+ character_rows: characterRows,
1261
+ },
1262
+ context: { sync_dir: SYNC_DIR },
1263
+ },
1264
+ onComplete: (completedJob) => {
1265
+ if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
1266
+
1267
+ syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
1268
+
1269
+ const changedScenes = (completedJob.result.results ?? [])
1270
+ .filter(row => row.status === "changed")
1271
+ .map(row => row.scene_id);
1272
+
1273
+ for (const sceneId of changedScenes) {
1274
+ db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
1275
+ .run(sceneId, project_id);
1276
+ }
1277
+ },
1278
+ });
1279
+
1280
+ return jsonResponse({
1281
+ ok: true,
1282
+ async: true,
1283
+ job: toPublicJob(job, false),
1284
+ next_step: "Call get_async_job_status with job_id until status is 'completed', 'failed', or 'cancelled'.",
1285
+ });
1286
+ }
1287
+ );
1288
+
1065
1289
  s.tool(
1066
1290
  "get_async_job_status",
1067
- "Get status and result for an asynchronous job started by import_scrivener_sync_async or merge_scrivener_project_beta_async.",
1291
+ "Get status and result for an asynchronous job started by async tools such as import_scrivener_sync_async, merge_scrivener_project_beta_async, or enrich_scene_characters_batch.",
1068
1292
  {
1069
1293
  job_id: z.string().describe("Job ID returned by an async start tool."),
1070
1294
  include_result: z.boolean().optional().describe("If true (default), includes completed result payload when available."),
@@ -1081,7 +1305,7 @@ function createMcpServer() {
1081
1305
 
1082
1306
  s.tool(
1083
1307
  "list_async_jobs",
1084
- "List asynchronous import/merge jobs currently known to this server.",
1308
+ "List asynchronous jobs currently known to this server.",
1085
1309
  {
1086
1310
  include_results: z.boolean().optional().describe("If true, includes completed result payloads."),
1087
1311
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -2,12 +2,24 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { importScrivenerSync } from "../importer.js";
4
4
  import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
5
+ import { runSceneCharacterBatch } from "../scene-character-batch.js";
6
+ import { ASYNC_PROGRESS_PREFIX } from "../async-progress.js";
7
+
8
+ const PROGRESS_PREFIX = ASYNC_PROGRESS_PREFIX;
5
9
 
6
10
  function writeResult(resultPath, payload) {
7
11
  fs.mkdirSync(path.dirname(resultPath), { recursive: true });
8
12
  fs.writeFileSync(resultPath, JSON.stringify(payload, null, 2), "utf8");
9
13
  }
10
14
 
15
+ function writeProgress(payload) {
16
+ try {
17
+ process.stdout.write(`${PROGRESS_PREFIX}${JSON.stringify(payload)}\n`);
18
+ } catch {
19
+ // Best-effort only; never fail the job due to progress telemetry.
20
+ }
21
+ }
22
+
11
23
  function normalizeImportResult(importResult) {
12
24
  const importPayload = {
13
25
  source_dir: importResult.scrivenerDir,
@@ -68,7 +80,14 @@ function normalizeMergeResult(mergeResult) {
68
80
  };
69
81
  }
70
82
 
71
- function main() {
83
+ function normalizeSceneCharacterBatchResult(batchResult) {
84
+ return {
85
+ ok: true,
86
+ ...batchResult,
87
+ };
88
+ }
89
+
90
+ async function main() {
72
91
  const requestPath = process.argv[2];
73
92
  const resultPath = process.argv[3];
74
93
 
@@ -107,11 +126,37 @@ function main() {
107
126
  return;
108
127
  }
109
128
 
129
+ if (request.kind === "enrich_scene_characters_batch") {
130
+ let cancellationRequested = false;
131
+ const handleSigterm = () => {
132
+ cancellationRequested = true;
133
+ };
134
+ process.on("SIGTERM", handleSigterm);
135
+
136
+ const result = await runSceneCharacterBatch({
137
+ syncDir,
138
+ args: {
139
+ project_id: request.args?.project_id,
140
+ dry_run: Boolean(request.args?.dry_run),
141
+ replace_mode: request.args?.replace_mode ?? "merge",
142
+ include_match_details: Boolean(request.args?.include_match_details),
143
+ project_exists: request.args?.project_exists !== false,
144
+ target_scenes: request.args?.target_scenes ?? [],
145
+ character_rows: request.args?.character_rows ?? [],
146
+ },
147
+ onProgress: progress => writeProgress({ kind: request.kind, ...progress }),
148
+ shouldCancel: () => cancellationRequested,
149
+ });
150
+ process.off("SIGTERM", handleSigterm);
151
+ writeResult(resultPath, normalizeSceneCharacterBatchResult(result));
152
+ return;
153
+ }
154
+
110
155
  throw new Error(`Unsupported async job kind '${request.kind}'.`);
111
156
  }
112
157
 
113
158
  try {
114
- main();
159
+ await main();
115
160
  } catch (error) {
116
161
  const resultPath = process.argv[3];
117
162
  if (resultPath) {