@hanna84/mcp-writing 3.5.1 → 3.5.3

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,9 +4,23 @@ 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.5.3](https://github.com/hannasdev/mcp-writing/compare/v3.5.2...v3.5.3)
8
+
9
+ - fix(review-bundles): keep epigraph prose without epigraph titles in beta exports [`#185`](https://github.com/hannasdev/mcp-writing/pull/185)
10
+
11
+ #### [v3.5.2](https://github.com/hannasdev/mcp-writing/compare/v3.5.1...v3.5.2)
12
+
13
+ > 8 May 2026
14
+
15
+ - Improve beta prose flow and centered scene dateline [`#184`](https://github.com/hannasdev/mcp-writing/pull/184)
16
+ - Release 3.5.2 [`5fb224a`](https://github.com/hannasdev/mcp-writing/commit/5fb224af5a66111c9b8d3d32e17acbb908fa5f97)
17
+
7
18
  #### [v3.5.1](https://github.com/hannasdev/mcp-writing/compare/v3.5.0...v3.5.1)
8
19
 
20
+ > 8 May 2026
21
+
9
22
  - Fix beta accountability follow-ups and test coverage [`#183`](https://github.com/hannasdev/mcp-writing/pull/183)
23
+ - Release 3.5.1 [`28d77f7`](https://github.com/hannasdev/mcp-writing/commit/28d77f755708dc8e987caa69dfcb6f3c5408b916)
10
24
 
11
25
  #### [v3.5.0](https://github.com/hannasdev/mcp-writing/compare/v3.4.4...v3.5.0)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -57,7 +57,7 @@ function renderBetaFeedbackFormMarkdown({ projectId, recipientName, generatedAt
57
57
  ].join("\n") + "\n";
58
58
  }
59
59
 
60
- function loadBundleSceneRows(dbHandle, projectId, sceneIds) {
60
+ function loadBundleSceneRowsWithTags(dbHandle, projectId, sceneIds) {
61
61
  if (!Array.isArray(sceneIds) || sceneIds.length === 0) return [];
62
62
  const rows = [];
63
63
  // 900 is safely below SQLite's per-query bound of 999 host parameters
@@ -74,6 +74,7 @@ function loadBundleSceneRows(dbHandle, projectId, sceneIds) {
74
74
  title,
75
75
  part,
76
76
  chapter,
77
+ chapter_title,
77
78
  timeline_position,
78
79
  logline,
79
80
  pov,
@@ -85,12 +86,36 @@ function loadBundleSceneRows(dbHandle, projectId, sceneIds) {
85
86
  rows.push(...chunkRows);
86
87
  }
87
88
 
88
- const rowMap = new Map(rows.map(row => [row.scene_id, row]));
89
89
  const orderedRows = [];
90
90
  const missingSceneIds = [];
91
91
 
92
+ // Load tags in batches to avoid N+1 queries for large bundles.
93
+ const tagsBySceneId = new Map(rows.map(row => [row.scene_id, []]));
94
+ const tagChunkSize = 900;
95
+ for (let offset = 0; offset < rows.length; offset += tagChunkSize) {
96
+ const chunk = rows.slice(offset, offset + tagChunkSize);
97
+ const chunkSceneIds = chunk.map(row => row.scene_id);
98
+ if (chunkSceneIds.length === 0) continue;
99
+ const placeholders = chunkSceneIds.map(() => "?").join(",");
100
+ const tagRows = dbHandle.prepare(`
101
+ SELECT scene_id, tag
102
+ FROM scene_tags
103
+ WHERE project_id = ? AND scene_id IN (${placeholders})
104
+ `).all(projectId, ...chunkSceneIds);
105
+ for (const tagRow of tagRows) {
106
+ const tags = tagsBySceneId.get(tagRow.scene_id);
107
+ if (tags) tags.push(tagRow.tag);
108
+ }
109
+ }
110
+
111
+ const rowsWithTags = rows.map(row => ({
112
+ ...row,
113
+ tags: tagsBySceneId.get(row.scene_id) ?? [],
114
+ }));
115
+ const rowMapWithTags = new Map(rowsWithTags.map(row => [row.scene_id, row]));
116
+
92
117
  for (const sceneId of sceneIds) {
93
- const row = rowMap.get(sceneId);
118
+ const row = rowMapWithTags.get(sceneId);
94
119
  if (row) {
95
120
  orderedRows.push(row);
96
121
  } else {
@@ -233,20 +258,149 @@ function readProse(filePath, { syncDir } = {}) {
233
258
  }
234
259
  }
235
260
 
261
+ function normalizeHardWrappedProse(rawProse) {
262
+ const prose = String(rawProse ?? "").replace(/\r\n?/g, "\n").trim();
263
+ if (!prose) return "";
264
+ const paragraphs = prose
265
+ .split(/\n\s*\n/g)
266
+ .map(paragraph => paragraph.replace(/\s*\n\s*/g, " ").trim())
267
+ .filter(Boolean);
268
+ return paragraphs.join("\n\n");
269
+ }
270
+
271
+ function extractSceneDateline(prose) {
272
+ const normalized = String(prose ?? "").replace(/\r\n?/g, "\n").trim();
273
+ if (!normalized) {
274
+ return { dateline: null, body: "" };
275
+ }
276
+
277
+ const lines = normalized
278
+ .split("\n")
279
+ .map(line => line.trim())
280
+ .filter(Boolean);
281
+ if (lines.length === 0) {
282
+ return { dateline: null, body: "" };
283
+ }
284
+
285
+ const firstParagraph = lines[0];
286
+ const dashMatch = firstParagraph.match(/^(.+?)\s*[–-]\s*(.+)$/);
287
+ const left = dashMatch?.[1]?.trim() ?? "";
288
+ const right = dashMatch?.[2]?.trim() ?? "";
289
+ const totalWords = firstParagraph.split(/\s+/).filter(Boolean).length;
290
+ const looksLikeDateline = (
291
+ firstParagraph.length >= 6
292
+ && firstParagraph.length <= 90
293
+ && Boolean(dashMatch)
294
+ && left.length >= 2
295
+ && right.length >= 2
296
+ && totalWords <= 14
297
+ && !/[!?]/.test(firstParagraph)
298
+ && !/[“”"']/.test(firstParagraph)
299
+ );
300
+
301
+ if (!looksLikeDateline) {
302
+ return { dateline: null, body: normalized };
303
+ }
304
+
305
+ return {
306
+ dateline: firstParagraph,
307
+ body: lines.slice(1).join("\n"),
308
+ };
309
+ }
310
+
311
+ function normalizeBetaProseFlow(prose) {
312
+ const normalized = String(prose ?? "").replace(/\r\n?/g, "\n").trim();
313
+ if (!normalized) return "";
314
+ const paragraphs = normalized
315
+ .split(/\n\s*\n/g)
316
+ .map(paragraph => paragraph
317
+ .split("\n")
318
+ .map(line => line.trim())
319
+ .filter(Boolean)
320
+ .join("\n"))
321
+ .filter(Boolean);
322
+ // For beta exports, convert paragraph blocks into regular line breaks so the
323
+ // reading flow stays continuous without large section gaps.
324
+ return paragraphs.join("\n");
325
+ }
326
+
327
+ function normalizeBetaTypography(prose) {
328
+ return String(prose ?? "")
329
+ .replace(/(^|\s)--(\s|$)/g, "$1—$2");
330
+ }
331
+
332
+ function renderProseWithInlineEmphasis(doc, prose, {
333
+ bodyFont,
334
+ italicFont,
335
+ fontSize,
336
+ width,
337
+ align = "left",
338
+ lineGap = 0,
339
+ paragraphGap = 0,
340
+ blankLineMoveDown = 0.15,
341
+ }) {
342
+ const lines = String(prose ?? "").split("\n");
343
+ for (const line of lines) {
344
+ if (line.length === 0) {
345
+ doc.moveDown(blankLineMoveDown);
346
+ continue;
347
+ }
348
+
349
+ if (line.trim() === "***") {
350
+ doc.moveDown(0.5);
351
+ doc.fontSize(fontSize).font(bodyFont);
352
+ doc.text("***", { align: "center", width, lineGap, paragraphGap: 0 });
353
+ doc.moveDown(0.5);
354
+ continue;
355
+ }
356
+
357
+ const segments = line.split(/(\*[^*\n]+\*)/g).filter(Boolean);
358
+
359
+ for (let index = 0; index < segments.length; index += 1) {
360
+ const segment = segments[index];
361
+ const isItalic = /^\*[^*\n]+\*$/.test(segment);
362
+ const text = isItalic ? segment.slice(1, -1) : segment;
363
+ if (!text) continue;
364
+ doc.fontSize(fontSize).font(isItalic ? italicFont : bodyFont);
365
+ doc.text(text, {
366
+ align,
367
+ width,
368
+ lineGap,
369
+ paragraphGap,
370
+ continued: index < segments.length - 1,
371
+ });
372
+ }
373
+ }
374
+ doc.font(bodyFont).fontSize(fontSize);
375
+ }
376
+
236
377
  function renderSceneBlock(scene, options) {
237
378
  const {
238
379
  profile,
239
380
  includeSceneIds,
240
381
  includeMetadataSidebar,
241
382
  includeParagraphAnchors,
383
+ showChapterHeading,
242
384
  } = options;
243
385
 
244
- const title = scene.title || scene.scene_id;
245
- const sceneHeading = includeSceneIds
246
- ? `## ${escapeMarkdown(title)} (${escapeMarkdown(scene.scene_id)})`
247
- : `## ${escapeMarkdown(title)}`;
386
+ const isBetaProfile = profile === "beta_reader_personalized";
387
+ const isEpigraph = isBetaProfile && scene.tags?.includes("epigraph");
388
+
389
+ const parts = [];
248
390
 
249
- const parts = [sceneHeading];
391
+ // Render chapter heading only when the caller detects a chapter transition.
392
+ if (isBetaProfile && scene.chapter_title && showChapterHeading) {
393
+ parts.push(`## ${escapeMarkdown(scene.chapter_title)}`);
394
+ }
395
+
396
+ // Only render heading if not an epigraph
397
+ if (!isEpigraph) {
398
+ const title = scene.title || scene.scene_id;
399
+ const sceneHeading = includeSceneIds
400
+ ? `## ${escapeMarkdown(title)} (${escapeMarkdown(scene.scene_id)})`
401
+ : `## ${escapeMarkdown(title)}`;
402
+ parts.push(sceneHeading);
403
+ }
250
404
 
251
405
  if (profile === "outline_discussion") {
252
406
  const summaryParts = [];
@@ -278,11 +432,11 @@ function renderSceneBlock(scene, options) {
278
432
 
279
433
  const prose = scene.prose ?? "";
280
434
  if (!includeParagraphAnchors || prose.length === 0) {
281
- parts.push(prose);
435
+ parts.push(normalizeHardWrappedProse(prose));
282
436
  return parts.join("\n\n");
283
437
  }
284
438
 
285
- const paragraphs = prose
439
+ const paragraphs = normalizeHardWrappedProse(prose)
286
440
  .split(/\n\s*\n/g)
287
441
  .map(p => p.trim())
288
442
  .filter(Boolean);
@@ -362,7 +516,7 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
362
516
  const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
363
517
 
364
518
  const sceneIds = plan.ordering.map(row => row.scene_id);
365
- const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
519
+ const rows = loadBundleSceneRowsWithTags(dbHandle, plan.resolved_scope.project_id, sceneIds);
366
520
  const sections = [];
367
521
  const recipientName = plan.resolved_scope?.options?.recipient_name;
368
522
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
@@ -391,7 +545,8 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
391
545
  );
392
546
  }
393
547
 
394
- for (const scene of rows) {
548
+ for (let sceneIndex = 0; sceneIndex < rows.length; sceneIndex += 1) {
549
+ const scene = rows[sceneIndex];
395
550
  let prose = "";
396
551
  if (profile === "editor_detailed" || profile === "beta_reader_personalized") {
397
552
  const resolved = readProse(scene.file_path, { syncDir });
@@ -409,11 +564,16 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
409
564
  prose = resolved;
410
565
  }
411
566
  const withProse = { ...scene, prose };
567
+ const prevScene = sceneIndex > 0 ? rows[sceneIndex - 1] : null;
568
+ const showChapterHeading = isBetaProfile
569
+ && Boolean(scene.chapter_title)
570
+ && (!prevScene || prevScene.chapter !== scene.chapter);
412
571
  sections.push(renderSceneBlock(withProse, {
413
572
  profile,
414
573
  includeSceneIds,
415
574
  includeMetadataSidebar,
416
575
  includeParagraphAnchors,
576
+ showChapterHeading,
417
577
  }));
418
578
  }
419
579
 
@@ -433,15 +593,16 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
433
593
  const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
434
594
  const isBetaProfile = profile === "beta_reader_personalized";
435
595
  const proseFontSize = isBetaProfile ? 8 : 10;
436
- const proseLineGap = isBetaProfile ? 3.2 : 3;
596
+ const proseLineGap = isBetaProfile ? 1.6 : 3;
437
597
  const bodyFont = profile === "beta_reader_personalized" ? "Times-Roman" : "Helvetica";
438
598
  const coverHeadingFont = profile === "beta_reader_personalized" ? "Times-Bold" : "Helvetica-Bold";
439
599
  // Beta scene headings intentionally use body font (non-bold) per product direction.
440
600
  const sceneHeadingFont = isBetaProfile ? bodyFont : coverHeadingFont;
441
- const metaFont = profile === "beta_reader_personalized" ? "Times-Italic" : "Helvetica-Oblique";
601
+ const italicFont = profile === "beta_reader_personalized" ? "Times-Italic" : "Helvetica-Oblique";
602
+ const metaFont = italicFont;
442
603
 
443
604
  const sceneIds = plan.ordering.map(row => row.scene_id);
444
- const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
605
+ const rows = loadBundleSceneRowsWithTags(dbHandle, plan.resolved_scope.project_id, sceneIds);
445
606
  const recipientName = plan.resolved_scope?.options?.recipient_name;
446
607
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
447
608
  const footerRecipientDisplayName = sanitizeFooterRecipientDisplayName(recipientDisplayName);
@@ -494,7 +655,7 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
494
655
  doc.restore();
495
656
  // Restore prose style so auto-flowed text keeps consistent typography
496
657
  // on pages added during long text rendering.
497
- doc.font(bodyFont).fontSize(proseFontSize);
658
+ doc.font(bodyFont).fontSize(proseFontSize).fillColor("#000000");
498
659
  doc.x = previousX;
499
660
  doc.y = previousY;
500
661
  };
@@ -530,8 +691,9 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
530
691
 
531
692
  try {
532
693
  doc.addPage();
533
- doc.fontSize(24).font(coverHeadingFont).text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
534
- doc.moveDown(0.5);
694
+ const coverLabel = `Review Bundle: ${plan.resolved_scope.project_id}`;
695
+ doc.fontSize(isBetaProfile ? 11 : 24).font(coverHeadingFont).text(coverLabel, { align: "left" });
696
+ doc.moveDown(isBetaProfile ? 0.2 : 0.5);
535
697
  doc.fontSize(11).font(bodyFont);
536
698
  if (profile !== "beta_reader_personalized") {
537
699
  doc.text(`Profile: ${profile}`, { align: "left" });
@@ -556,19 +718,31 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
556
718
  doc.moveDown();
557
719
  }
558
720
 
559
- for (const scene of rows) {
721
+ for (let sceneIndex = 0; sceneIndex < rows.length; sceneIndex += 1) {
722
+ const scene = rows[sceneIndex];
723
+ const prevScene = sceneIndex > 0 ? rows[sceneIndex - 1] : null;
560
724
  if (isBetaProfile) {
561
725
  // Give chapter titles generous vertical breathing room for a
562
726
  // print-like opening feel before prose begins.
563
727
  doc.moveDown(2.0);
564
728
  }
565
- doc.fontSize(isBetaProfile ? 13 : 14).font(sceneHeadingFont);
566
- let heading = scene.title || scene.scene_id;
567
- if (includeSceneIds) {
568
- heading += ` [${scene.scene_id}]`;
729
+ if (isBetaProfile && scene.chapter_title && (!prevScene || prevScene.chapter !== scene.chapter)) {
730
+ doc.fontSize(16).font(coverHeadingFont);
731
+ doc.text(scene.chapter_title, { align: "center" });
732
+ doc.moveDown(1.0);
733
+ }
734
+
735
+ // Skip title rendering for epigraphs in beta profile
736
+ const isEpigraph = isBetaProfile && scene.tags?.includes("epigraph");
737
+ if (!isEpigraph) {
738
+ doc.fontSize(isBetaProfile ? 13 : 14).font(sceneHeadingFont);
739
+ let heading = scene.title || scene.scene_id;
740
+ if (includeSceneIds) {
741
+ heading += ` [${scene.scene_id}]`;
742
+ }
743
+ doc.text(heading, { align: isBetaProfile ? "center" : "left" });
744
+ doc.moveDown(isBetaProfile ? 1.6 : 0.2);
569
745
  }
570
- doc.text(heading, { align: isBetaProfile ? "center" : "left" });
571
- doc.moveDown(isBetaProfile ? 1.6 : 0.2);
572
746
 
573
747
  const metaParts = [];
574
748
  if (profile !== "beta_reader_personalized") {
@@ -604,14 +778,35 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
604
778
  }
605
779
  );
606
780
  }
607
- prose = resolved;
781
+ let sceneDateline = null;
782
+ if (isBetaProfile) {
783
+ prose = normalizeBetaProseFlow(resolved);
784
+ const extracted = extractSceneDateline(prose);
785
+ sceneDateline = extracted.dateline ? normalizeBetaTypography(extracted.dateline) : null;
786
+ prose = normalizeBetaTypography(extracted.body);
787
+ } else {
788
+ prose = normalizeHardWrappedProse(resolved);
789
+ }
790
+
791
+ if (sceneDateline) {
792
+ doc.fontSize(10).font(metaFont);
793
+ doc.text(sceneDateline, {
794
+ align: "center",
795
+ width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
796
+ });
797
+ doc.moveDown(1.0);
798
+ }
608
799
 
609
800
  const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
610
- doc.fontSize(proseFontSize).font(bodyFont);
611
- doc.text(prose, {
801
+ renderProseWithInlineEmphasis(doc, prose, {
802
+ bodyFont,
803
+ italicFont,
804
+ fontSize: proseFontSize,
612
805
  align: "left",
613
806
  width: textWidth,
614
807
  lineGap: proseLineGap,
808
+ paragraphGap: 0,
809
+ blankLineMoveDown: isBetaProfile ? 0.15 : 0.65,
615
810
  });
616
811
  }
617
812
 
@@ -619,7 +814,7 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
619
814
  // Add page break between scenes only for prose-including profiles where
620
815
  // clear scene separation matters. For outline_discussion, let content flow.
621
816
  const includesProse = profile === "editor_detailed" || profile === "beta_reader_personalized";
622
- if (includesProse && scene !== rows[rows.length - 1]) {
817
+ if (includesProse && sceneIndex < rows.length - 1) {
623
818
  doc.addPage();
624
819
  }
625
820
  }
@@ -642,4 +837,5 @@ export {
642
837
  buildPageFingerprintToken,
643
838
  buildFingerprintSeed,
644
839
  buildFingerprintSeedHash,
840
+ extractSceneDateline,
645
841
  };
@@ -13,6 +13,7 @@ export {
13
13
  buildPageFingerprintToken,
14
14
  buildFingerprintSeed,
15
15
  buildFingerprintSeedHash,
16
+ extractSceneDateline,
16
17
  } from "./review-bundles-renderer.js";
17
18
 
18
19
  export { createReviewBundleArtifacts } from "./review-bundles-writer.js";
@@ -358,12 +358,7 @@ export function importScrivenerSync({
358
358
  continue;
359
359
  }
360
360
 
361
- if (isEpigraph(rawTitle)) {
362
- logger(` SKIP (epigraph) ${filename}`);
363
- skipped++;
364
- continue;
365
- }
366
-
361
+ const isEpigraphScene = isEpigraph(rawTitle);
367
362
  const title = cleanTitle(rawTitle);
368
363
  const existingScene = existingScenes.get(String(binderId)) ?? null;
369
364
  const sceneId = existingScene?.meta?.scene_id ?? makeSceneId(binderId, title);
@@ -375,6 +370,10 @@ export function importScrivenerSync({
375
370
  const destFile = path.join(targetDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
376
371
  const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
377
372
 
373
+ const existingTags = Array.isArray(existingScene?.meta?.tags)
374
+ ? existingScene.meta.tags
375
+ : [];
376
+
378
377
  const meta = {
379
378
  ...(existingScene?.meta ?? {}),
380
379
  scene_id: sceneId,
@@ -383,6 +382,7 @@ export function importScrivenerSync({
383
382
  title,
384
383
  timeline_position: seq,
385
384
  ...(beatCarry ? { save_the_cat_beat: beatCarry } : {}),
385
+ ...(isEpigraphScene ? { tags: [...existingTags, "epigraph"].filter((v, i, a) => a.indexOf(v) === i) } : {}),
386
386
  };
387
387
 
388
388
  if (!beatCarry && existingScene?.meta && Object.hasOwn(existingScene.meta, "save_the_cat_beat")) {
@@ -424,7 +424,7 @@ export function importScrivenerSync({
424
424
  logger("");
425
425
  logger(`${"-".repeat(50)}`);
426
426
  logger(`Created: ${created} sidecars${dryRun ? " (dry run)" : ""}`);
427
- logger(`Skipped: ${skipped} (empty / epigraph / pattern)`);
427
+ logger(`Skipped: ${skipped} (empty / pattern)`);
428
428
  if (existing) logger(`Existing: ${existing} already had sidecars`);
429
429
  logger(`Beat markers seen: ${beatMarkersSeen}`);
430
430