@hanna84/mcp-writing 3.5.2 → 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,16 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v3.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
+
7
11
  #### [v3.5.2](https://github.com/hannasdev/mcp-writing/compare/v3.5.1...v3.5.2)
8
12
 
13
+ > 8 May 2026
14
+
9
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)
10
17
 
11
18
  #### [v3.5.1](https://github.com/hannasdev/mcp-writing/compare/v3.5.0...v3.5.1)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.5.2",
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 {
@@ -355,14 +380,27 @@ function renderSceneBlock(scene, options) {
355
380
  includeSceneIds,
356
381
  includeMetadataSidebar,
357
382
  includeParagraphAnchors,
383
+ showChapterHeading,
358
384
  } = options;
359
385
 
360
- const title = scene.title || scene.scene_id;
361
- const sceneHeading = includeSceneIds
362
- ? `## ${escapeMarkdown(title)} (${escapeMarkdown(scene.scene_id)})`
363
- : `## ${escapeMarkdown(title)}`;
386
+ const isBetaProfile = profile === "beta_reader_personalized";
387
+ const isEpigraph = isBetaProfile && scene.tags?.includes("epigraph");
364
388
 
365
- const parts = [sceneHeading];
389
+ const parts = [];
390
+
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
+ }
366
404
 
367
405
  if (profile === "outline_discussion") {
368
406
  const summaryParts = [];
@@ -478,7 +516,7 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
478
516
  const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
479
517
 
480
518
  const sceneIds = plan.ordering.map(row => row.scene_id);
481
- const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
519
+ const rows = loadBundleSceneRowsWithTags(dbHandle, plan.resolved_scope.project_id, sceneIds);
482
520
  const sections = [];
483
521
  const recipientName = plan.resolved_scope?.options?.recipient_name;
484
522
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
@@ -507,7 +545,8 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
507
545
  );
508
546
  }
509
547
 
510
- for (const scene of rows) {
548
+ for (let sceneIndex = 0; sceneIndex < rows.length; sceneIndex += 1) {
549
+ const scene = rows[sceneIndex];
511
550
  let prose = "";
512
551
  if (profile === "editor_detailed" || profile === "beta_reader_personalized") {
513
552
  const resolved = readProse(scene.file_path, { syncDir });
@@ -525,11 +564,16 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
525
564
  prose = resolved;
526
565
  }
527
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);
528
571
  sections.push(renderSceneBlock(withProse, {
529
572
  profile,
530
573
  includeSceneIds,
531
574
  includeMetadataSidebar,
532
575
  includeParagraphAnchors,
576
+ showChapterHeading,
533
577
  }));
534
578
  }
535
579
 
@@ -558,7 +602,7 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
558
602
  const metaFont = italicFont;
559
603
 
560
604
  const sceneIds = plan.ordering.map(row => row.scene_id);
561
- const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
605
+ const rows = loadBundleSceneRowsWithTags(dbHandle, plan.resolved_scope.project_id, sceneIds);
562
606
  const recipientName = plan.resolved_scope?.options?.recipient_name;
563
607
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
564
608
  const footerRecipientDisplayName = sanitizeFooterRecipientDisplayName(recipientDisplayName);
@@ -674,19 +718,31 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
674
718
  doc.moveDown();
675
719
  }
676
720
 
677
- 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;
678
724
  if (isBetaProfile) {
679
725
  // Give chapter titles generous vertical breathing room for a
680
726
  // print-like opening feel before prose begins.
681
727
  doc.moveDown(2.0);
682
728
  }
683
- doc.fontSize(isBetaProfile ? 13 : 14).font(sceneHeadingFont);
684
- let heading = scene.title || scene.scene_id;
685
- if (includeSceneIds) {
686
- 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);
687
745
  }
688
- doc.text(heading, { align: isBetaProfile ? "center" : "left" });
689
- doc.moveDown(isBetaProfile ? 1.6 : 0.2);
690
746
 
691
747
  const metaParts = [];
692
748
  if (profile !== "beta_reader_personalized") {
@@ -758,7 +814,7 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
758
814
  // Add page break between scenes only for prose-including profiles where
759
815
  // clear scene separation matters. For outline_discussion, let content flow.
760
816
  const includesProse = profile === "editor_detailed" || profile === "beta_reader_personalized";
761
- if (includesProse && scene !== rows[rows.length - 1]) {
817
+ if (includesProse && sceneIndex < rows.length - 1) {
762
818
  doc.addPage();
763
819
  }
764
820
  }
@@ -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