@hanna84/mcp-writing 3.5.0 → 3.5.2

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.2](https://github.com/hannasdev/mcp-writing/compare/v3.5.1...v3.5.2)
8
+
9
+ - Improve beta prose flow and centered scene dateline [`#184`](https://github.com/hannasdev/mcp-writing/pull/184)
10
+
11
+ #### [v3.5.1](https://github.com/hannasdev/mcp-writing/compare/v3.5.0...v3.5.1)
12
+
13
+ > 8 May 2026
14
+
15
+ - Fix beta accountability follow-ups and test coverage [`#183`](https://github.com/hannasdev/mcp-writing/pull/183)
16
+ - Release 3.5.1 [`28d77f7`](https://github.com/hannasdev/mcp-writing/commit/28d77f755708dc8e987caa69dfcb6f3c5408b916)
17
+
7
18
  #### [v3.5.0](https://github.com/hannasdev/mcp-writing/compare/v3.4.4...v3.5.0)
8
19
 
20
+ > 8 May 2026
21
+
9
22
  - feat: add accountable beta-reader bundle fingerprinting [`#182`](https://github.com/hannasdev/mcp-writing/pull/182)
23
+ - Release 3.5.0 [`6134dfc`](https://github.com/hannasdev/mcp-writing/commit/6134dfc17e998c118f5fdbb68f1a26e3ba673f05)
10
24
 
11
25
  #### [v3.4.4](https://github.com/hannasdev/mcp-writing/compare/v3.4.3...v3.4.4)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
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",
@@ -335,16 +335,23 @@ export function buildReviewBundlePlan(dbHandle, {
335
335
  : undefined;
336
336
 
337
337
  const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
338
+ const normalizedSceneIds = Array.isArray(scene_ids)
339
+ ? Array.from(new Set(scene_ids.map(sceneId => String(sceneId)))).sort()
340
+ : undefined;
338
341
  const appliedFilters = {
339
342
  ...(part !== undefined ? { part } : {}),
340
343
  ...(chapter !== undefined ? { chapter } : {}),
341
344
  ...(Array.isArray(normalizedChapters) ? { chapters: normalizedChapters } : {}),
342
345
  ...(tag ? { tag } : {}),
343
- ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
346
+ ...(Array.isArray(normalizedSceneIds) ? { scene_ids: normalizedSceneIds } : {}),
344
347
  };
345
348
  const resolvedBetaAccountability = profile === "beta_reader_personalized"
346
349
  ? Boolean(beta_accountability ?? true)
347
350
  : false;
351
+ const isBetaProfile = profile === "beta_reader_personalized";
352
+ const resolvedIncludeSceneIds = isBetaProfile ? false : Boolean(include_scene_ids);
353
+ const resolvedIncludeMetadataSidebar = isBetaProfile ? false : Boolean(include_metadata_sidebar);
354
+ const resolvedIncludeParagraphAnchors = isBetaProfile ? false : Boolean(include_paragraph_anchors);
348
355
 
349
356
  return {
350
357
  ok: true,
@@ -353,9 +360,9 @@ export function buildReviewBundlePlan(dbHandle, {
353
360
  project_id,
354
361
  filters: appliedFilters,
355
362
  options: {
356
- include_scene_ids: Boolean(include_scene_ids),
357
- include_metadata_sidebar: Boolean(include_metadata_sidebar),
358
- include_paragraph_anchors: Boolean(include_paragraph_anchors),
363
+ include_scene_ids: resolvedIncludeSceneIds,
364
+ include_metadata_sidebar: resolvedIncludeMetadataSidebar,
365
+ include_paragraph_anchors: resolvedIncludeParagraphAnchors,
359
366
  beta_accountability: resolvedBetaAccountability,
360
367
  ...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
361
368
  },
@@ -233,6 +233,122 @@ function readProse(filePath, { syncDir } = {}) {
233
233
  }
234
234
  }
235
235
 
236
+ function normalizeHardWrappedProse(rawProse) {
237
+ const prose = String(rawProse ?? "").replace(/\r\n?/g, "\n").trim();
238
+ if (!prose) return "";
239
+ const paragraphs = prose
240
+ .split(/\n\s*\n/g)
241
+ .map(paragraph => paragraph.replace(/\s*\n\s*/g, " ").trim())
242
+ .filter(Boolean);
243
+ return paragraphs.join("\n\n");
244
+ }
245
+
246
+ function extractSceneDateline(prose) {
247
+ const normalized = String(prose ?? "").replace(/\r\n?/g, "\n").trim();
248
+ if (!normalized) {
249
+ return { dateline: null, body: "" };
250
+ }
251
+
252
+ const lines = normalized
253
+ .split("\n")
254
+ .map(line => line.trim())
255
+ .filter(Boolean);
256
+ if (lines.length === 0) {
257
+ return { dateline: null, body: "" };
258
+ }
259
+
260
+ const firstParagraph = lines[0];
261
+ const dashMatch = firstParagraph.match(/^(.+?)\s*[–-]\s*(.+)$/);
262
+ const left = dashMatch?.[1]?.trim() ?? "";
263
+ const right = dashMatch?.[2]?.trim() ?? "";
264
+ const totalWords = firstParagraph.split(/\s+/).filter(Boolean).length;
265
+ const looksLikeDateline = (
266
+ firstParagraph.length >= 6
267
+ && firstParagraph.length <= 90
268
+ && Boolean(dashMatch)
269
+ && left.length >= 2
270
+ && right.length >= 2
271
+ && totalWords <= 14
272
+ && !/[!?]/.test(firstParagraph)
273
+ && !/[“”"']/.test(firstParagraph)
274
+ );
275
+
276
+ if (!looksLikeDateline) {
277
+ return { dateline: null, body: normalized };
278
+ }
279
+
280
+ return {
281
+ dateline: firstParagraph,
282
+ body: lines.slice(1).join("\n"),
283
+ };
284
+ }
285
+
286
+ function normalizeBetaProseFlow(prose) {
287
+ const normalized = String(prose ?? "").replace(/\r\n?/g, "\n").trim();
288
+ if (!normalized) return "";
289
+ const paragraphs = normalized
290
+ .split(/\n\s*\n/g)
291
+ .map(paragraph => paragraph
292
+ .split("\n")
293
+ .map(line => line.trim())
294
+ .filter(Boolean)
295
+ .join("\n"))
296
+ .filter(Boolean);
297
+ // For beta exports, convert paragraph blocks into regular line breaks so the
298
+ // reading flow stays continuous without large section gaps.
299
+ return paragraphs.join("\n");
300
+ }
301
+
302
+ function normalizeBetaTypography(prose) {
303
+ return String(prose ?? "")
304
+ .replace(/(^|\s)--(\s|$)/g, "$1—$2");
305
+ }
306
+
307
+ function renderProseWithInlineEmphasis(doc, prose, {
308
+ bodyFont,
309
+ italicFont,
310
+ fontSize,
311
+ width,
312
+ align = "left",
313
+ lineGap = 0,
314
+ paragraphGap = 0,
315
+ blankLineMoveDown = 0.15,
316
+ }) {
317
+ const lines = String(prose ?? "").split("\n");
318
+ for (const line of lines) {
319
+ if (line.length === 0) {
320
+ doc.moveDown(blankLineMoveDown);
321
+ continue;
322
+ }
323
+
324
+ if (line.trim() === "***") {
325
+ doc.moveDown(0.5);
326
+ doc.fontSize(fontSize).font(bodyFont);
327
+ doc.text("***", { align: "center", width, lineGap, paragraphGap: 0 });
328
+ doc.moveDown(0.5);
329
+ continue;
330
+ }
331
+
332
+ const segments = line.split(/(\*[^*\n]+\*)/g).filter(Boolean);
333
+
334
+ for (let index = 0; index < segments.length; index += 1) {
335
+ const segment = segments[index];
336
+ const isItalic = /^\*[^*\n]+\*$/.test(segment);
337
+ const text = isItalic ? segment.slice(1, -1) : segment;
338
+ if (!text) continue;
339
+ doc.fontSize(fontSize).font(isItalic ? italicFont : bodyFont);
340
+ doc.text(text, {
341
+ align,
342
+ width,
343
+ lineGap,
344
+ paragraphGap,
345
+ continued: index < segments.length - 1,
346
+ });
347
+ }
348
+ }
349
+ doc.font(bodyFont).fontSize(fontSize);
350
+ }
351
+
236
352
  function renderSceneBlock(scene, options) {
237
353
  const {
238
354
  profile,
@@ -278,11 +394,11 @@ function renderSceneBlock(scene, options) {
278
394
 
279
395
  const prose = scene.prose ?? "";
280
396
  if (!includeParagraphAnchors || prose.length === 0) {
281
- parts.push(prose);
397
+ parts.push(normalizeHardWrappedProse(prose));
282
398
  return parts.join("\n\n");
283
399
  }
284
400
 
285
- const paragraphs = prose
401
+ const paragraphs = normalizeHardWrappedProse(prose)
286
402
  .split(/\n\s*\n/g)
287
403
  .map(p => p.trim())
288
404
  .filter(Boolean);
@@ -346,11 +462,16 @@ function buildPageFingerprintToken({ seedHash, pageNumber }) {
346
462
  return `BR-${digest}-P${String(pageNumber).padStart(3, "0")}`;
347
463
  }
348
464
 
465
+ function sanitizeFooterRecipientDisplayName(recipientDisplayName) {
466
+ return String(recipientDisplayName ?? "").replaceAll("|", "/");
467
+ }
468
+
349
469
  export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
350
470
  const profile = plan.profile;
351
- const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
352
- const includeMetadataSidebar = Boolean(plan.resolved_scope?.options?.include_metadata_sidebar);
353
- const includeParagraphAnchors = Boolean(plan.resolved_scope?.options?.include_paragraph_anchors);
471
+ const isBetaProfile = profile === "beta_reader_personalized";
472
+ const includeSceneIds = isBetaProfile ? false : Boolean(plan.resolved_scope?.options?.include_scene_ids);
473
+ const includeMetadataSidebar = isBetaProfile ? false : Boolean(plan.resolved_scope?.options?.include_metadata_sidebar);
474
+ const includeParagraphAnchors = isBetaProfile ? false : Boolean(plan.resolved_scope?.options?.include_paragraph_anchors);
354
475
  // Prefer explicitly threaded syncDir; fall back to env.
355
476
  // No further fallback: if syncDir is null, resolveSceneFilePath returns null
356
477
  // and SCENE_PROSE_READ_FAILED is thrown, making misconfiguration explicit.
@@ -365,12 +486,13 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
365
486
  const headerLines = [
366
487
  `# Review Bundle: ${escapeMarkdown(plan.resolved_scope.project_id)}`,
367
488
  "",
368
- `- Profile: ${profile}`,
489
+ ...(profile !== "beta_reader_personalized" ? [`- Profile: ${profile}`] : []),
369
490
  ...(profile === "beta_reader_personalized"
370
491
  ? [`- Recipient: ${escapeMarkdown(recipientDisplayName)}`]
371
492
  : []),
372
- `- Generated at: ${generatedAt ?? new Date().toISOString()}`,
373
- `- Scene count: ${plan.summary.scene_count}`,
493
+ ...(profile !== "beta_reader_personalized"
494
+ ? [`- Generated at: ${generatedAt ?? new Date().toISOString()}`, `- Scene count: ${plan.summary.scene_count}`]
495
+ : []),
374
496
  ];
375
497
  sections.push(headerLines.join("\n"));
376
498
 
@@ -421,13 +543,25 @@ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: sy
421
543
 
422
544
  export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
423
545
  const profile = plan.profile;
424
- const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
546
+ const includeSceneIds = profile === "beta_reader_personalized"
547
+ ? false
548
+ : Boolean(plan.resolved_scope?.options?.include_scene_ids);
425
549
  const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
550
+ const isBetaProfile = profile === "beta_reader_personalized";
551
+ const proseFontSize = isBetaProfile ? 8 : 10;
552
+ const proseLineGap = isBetaProfile ? 1.6 : 3;
553
+ const bodyFont = profile === "beta_reader_personalized" ? "Times-Roman" : "Helvetica";
554
+ const coverHeadingFont = profile === "beta_reader_personalized" ? "Times-Bold" : "Helvetica-Bold";
555
+ // Beta scene headings intentionally use body font (non-bold) per product direction.
556
+ const sceneHeadingFont = isBetaProfile ? bodyFont : coverHeadingFont;
557
+ const italicFont = profile === "beta_reader_personalized" ? "Times-Italic" : "Helvetica-Oblique";
558
+ const metaFont = italicFont;
426
559
 
427
560
  const sceneIds = plan.ordering.map(row => row.scene_id);
428
561
  const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
429
562
  const recipientName = plan.resolved_scope?.options?.recipient_name;
430
563
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
564
+ const footerRecipientDisplayName = sanitizeFooterRecipientDisplayName(recipientDisplayName);
431
565
  const betaAccountabilityEnabled = profile === "beta_reader_personalized"
432
566
  && Boolean(plan.resolved_scope?.options?.beta_accountability);
433
567
  const effectiveGeneratedAt = generatedAt ?? new Date().toISOString();
@@ -441,7 +575,8 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
441
575
  const pdfOptions = profile === "beta_reader_personalized"
442
576
  ? {
443
577
  size: [432, 648], // 6x9in in PDF points
444
- margins: { top: 64, right: 58, bottom: 72, left: 58 },
578
+ // Extra bottom margin reserves clear space above the accountability footer.
579
+ margins: { top: 64, right: 58, bottom: 96, left: 58 },
445
580
  autoFirstPage: false,
446
581
  }
447
582
  : {
@@ -455,23 +590,30 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
455
590
 
456
591
  const drawAccountabilityFooter = () => {
457
592
  if (!betaAccountabilityEnabled || !fingerprintSeedHash) return;
593
+ const previousX = doc.x;
594
+ const previousY = doc.y;
458
595
  pageNumber += 1;
459
596
  const token = buildPageFingerprintToken({
460
597
  seedHash: fingerprintSeedHash,
461
598
  pageNumber,
462
599
  });
463
600
  pageTokens.push({ page: pageNumber, token });
464
- const footerY = doc.page.height - doc.page.margins.bottom - 12;
465
- const footerWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
466
- const footerText = `For: ${recipientDisplayName} | Fingerprint: ${token} | Page ${pageNumber}`;
601
+ const footerY = doc.page.height - 42;
602
+ const footerText = `For: ${footerRecipientDisplayName} | Fingerprint: ${token}`;
603
+ const pageNumberText = String(pageNumber);
467
604
  doc.save();
468
- doc.font("Helvetica").fontSize(8).fillColor("#555555");
469
- doc.text(footerText, doc.page.margins.left, footerY, {
470
- width: footerWidth,
471
- align: "left",
472
- lineBreak: false,
473
- });
605
+ doc.font("Times-Roman").fontSize(8).fillColor("#555555");
606
+ // Draw footer in no-wrap mode to avoid layout flow side effects.
607
+ doc.text(footerText, doc.page.margins.left, footerY, { lineBreak: false });
608
+ const pageNumberWidth = doc.widthOfString(pageNumberText);
609
+ const pageNumberX = (doc.page.width - pageNumberWidth) / 2;
610
+ doc.text(pageNumberText, pageNumberX, doc.page.height - 24, { lineBreak: false });
474
611
  doc.restore();
612
+ // Restore prose style so auto-flowed text keeps consistent typography
613
+ // on pages added during long text rendering.
614
+ doc.font(bodyFont).fontSize(proseFontSize).fillColor("#000000");
615
+ doc.x = previousX;
616
+ doc.y = previousY;
475
617
  };
476
618
  doc.on("pageAdded", drawAccountabilityFooter);
477
619
 
@@ -505,22 +647,26 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
505
647
 
506
648
  try {
507
649
  doc.addPage();
508
- doc.fontSize(24).font("Helvetica-Bold").text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
509
- doc.moveDown(0.5);
510
- doc.fontSize(11).font("Helvetica");
511
- doc.text(`Profile: ${profile}`, { align: "left" });
650
+ const coverLabel = `Review Bundle: ${plan.resolved_scope.project_id}`;
651
+ doc.fontSize(isBetaProfile ? 11 : 24).font(coverHeadingFont).text(coverLabel, { align: "left" });
652
+ doc.moveDown(isBetaProfile ? 0.2 : 0.5);
653
+ doc.fontSize(11).font(bodyFont);
654
+ if (profile !== "beta_reader_personalized") {
655
+ doc.text(`Profile: ${profile}`, { align: "left" });
656
+ }
512
657
  if (profile === "beta_reader_personalized") {
513
658
  doc.text(`Recipient: ${recipientDisplayName}`, { align: "left" });
659
+ } else {
660
+ doc.text(`Generated: ${effectiveGeneratedAt}`, { align: "left" });
661
+ doc.text(`Scenes: ${plan.summary.scene_count}`, { align: "left" });
514
662
  }
515
- doc.text(`Generated: ${effectiveGeneratedAt}`, { align: "left" });
516
- doc.text(`Scenes: ${plan.summary.scene_count}`, { align: "left" });
517
663
  doc.moveDown();
518
664
 
519
665
  if (profile === "beta_reader_personalized") {
520
- doc.fontSize(12).font("Helvetica-Bold").text("Usage Notice", { align: "left" });
666
+ doc.fontSize(12).font("Times-Bold").text("Usage Notice", { align: "left" });
521
667
  doc.moveDown(0.3);
522
668
  const noticeWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
523
- doc.fontSize(10).font("Helvetica");
669
+ doc.fontSize(10).font("Times-Roman");
524
670
  doc.text("This beta-reader draft is intended for private review and feedback. Please do not redistribute without explicit author permission.", {
525
671
  align: "left",
526
672
  width: noticeWidth,
@@ -529,22 +675,29 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
529
675
  }
530
676
 
531
677
  for (const scene of rows) {
532
- doc.fontSize(14).font("Helvetica-Bold");
678
+ if (isBetaProfile) {
679
+ // Give chapter titles generous vertical breathing room for a
680
+ // print-like opening feel before prose begins.
681
+ doc.moveDown(2.0);
682
+ }
683
+ doc.fontSize(isBetaProfile ? 13 : 14).font(sceneHeadingFont);
533
684
  let heading = scene.title || scene.scene_id;
534
685
  if (includeSceneIds) {
535
686
  heading += ` [${scene.scene_id}]`;
536
687
  }
537
- doc.text(heading, { align: "left" });
538
- doc.moveDown(0.2);
688
+ doc.text(heading, { align: isBetaProfile ? "center" : "left" });
689
+ doc.moveDown(isBetaProfile ? 1.6 : 0.2);
539
690
 
540
691
  const metaParts = [];
541
- if (scene.pov) metaParts.push(`POV: ${scene.pov}`);
542
- if (scene.save_the_cat_beat) metaParts.push(`Beat: ${scene.save_the_cat_beat}`);
692
+ if (profile !== "beta_reader_personalized") {
693
+ if (scene.pov) metaParts.push(`POV: ${scene.pov}`);
694
+ if (scene.save_the_cat_beat) metaParts.push(`Beat: ${scene.save_the_cat_beat}`);
695
+ }
543
696
  if (metaParts.length > 0) {
544
697
  const metaWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
545
- doc.fontSize(9).font("Helvetica-Oblique");
698
+ doc.fontSize(9).font(metaFont);
546
699
  doc.text(metaParts.join(" • "), { align: "left", width: metaWidth });
547
- doc.font("Helvetica");
700
+ doc.font(bodyFont);
548
701
  doc.moveDown(0.2);
549
702
  }
550
703
 
@@ -569,14 +722,35 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
569
722
  }
570
723
  );
571
724
  }
572
- prose = resolved;
725
+ let sceneDateline = null;
726
+ if (isBetaProfile) {
727
+ prose = normalizeBetaProseFlow(resolved);
728
+ const extracted = extractSceneDateline(prose);
729
+ sceneDateline = extracted.dateline ? normalizeBetaTypography(extracted.dateline) : null;
730
+ prose = normalizeBetaTypography(extracted.body);
731
+ } else {
732
+ prose = normalizeHardWrappedProse(resolved);
733
+ }
734
+
735
+ if (sceneDateline) {
736
+ doc.fontSize(10).font(metaFont);
737
+ doc.text(sceneDateline, {
738
+ align: "center",
739
+ width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
740
+ });
741
+ doc.moveDown(1.0);
742
+ }
573
743
 
574
744
  const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
575
- doc.fontSize(10).font("Helvetica");
576
- doc.text(prose, {
745
+ renderProseWithInlineEmphasis(doc, prose, {
746
+ bodyFont,
747
+ italicFont,
748
+ fontSize: proseFontSize,
577
749
  align: "left",
578
750
  width: textWidth,
579
- lineGap: profile === "beta_reader_personalized" ? 4.5 : 3,
751
+ lineGap: proseLineGap,
752
+ paragraphGap: 0,
753
+ blankLineMoveDown: isBetaProfile ? 0.15 : 0.65,
580
754
  });
581
755
  }
582
756
 
@@ -607,4 +781,5 @@ export {
607
781
  buildPageFingerprintToken,
608
782
  buildFingerprintSeed,
609
783
  buildFingerprintSeedHash,
784
+ extractSceneDateline,
610
785
  };
@@ -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";