@hanna84/mcp-writing 1.16.2 → 2.0.0

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,31 @@ 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
+ #### [v2.0.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.17.0...v2.0.0)
9
+
10
+ - feat(review-bundles)!: add PDF export via pdfkit [`#78`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/78)
12
+
13
+ #### [v1.17.0](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.16.2...v1.17.0)
15
+
16
+ > 25 April 2026
17
+
18
+ - feat: add beta_reader_personalized review bundle profile [`#77`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/77)
20
+ - Release 1.17.0 [`1cf0a19`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/1cf0a194bc73073655043072722a9c97cd17c923)
22
+
7
23
  #### [v1.16.2](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.16.1...v1.16.2)
9
25
 
26
+ > 25 April 2026
27
+
10
28
  - docs: close out review bundles M1 and add troubleshooting guidance [`#76`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/76)
30
+ - Release 1.16.2 [`557623a`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/557623afb18e4f5f5dbec565eee1cbd481051275)
12
32
 
13
33
  #### [v1.16.1](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.16.0...v1.16.1)
package/index.js CHANGED
@@ -1349,19 +1349,21 @@ function createMcpServer() {
1349
1349
  // ---- preview_review_bundle ----------------------------------------------
1350
1350
  s.tool(
1351
1351
  "preview_review_bundle",
1352
- "Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Note: include_scene_ids/include_metadata_sidebar/include_paragraph_anchors are advisory placeholders in Phase 4A.1 and do not alter planning semantics yet.",
1352
+ "Dry-run planning tool for review bundles. Resolves scene scope, deterministic ordering, warnings, and planned output filenames without writing files. Rendering options are accepted for API consistency and reflected in resolved_scope.options, but do not change planning output.",
1353
1353
  {
1354
1354
  project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1355
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion or editor_detailed."),
1355
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1356
1356
  part: z.number().int().optional().describe("Optional part filter."),
1357
1357
  chapter: z.number().int().optional().describe("Optional chapter filter."),
1358
1358
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
1359
1359
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
1360
1360
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
1361
- include_scene_ids: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default true). Included in preview output options, but does not change planning results in Phase 4A.1."),
1362
- include_metadata_sidebar: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default false). Included in preview output options, but does not change planning results in Phase 4A.1."),
1363
- include_paragraph_anchors: z.boolean().optional().describe("Advisory placeholder for later rendering behavior (default false). Included in preview output options, but does not change planning results in Phase 4A.1."),
1361
+ include_scene_ids: z.boolean().optional().describe("Rendering option (default true). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1362
+ include_metadata_sidebar: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1363
+ include_paragraph_anchors: z.boolean().optional().describe("Rendering option (default false). Echoed in resolved_scope.options for downstream rendering; does not change planning results."),
1364
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1364
1365
  bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in planned outputs)."),
1366
+ format: z.enum(["pdf", "markdown", "both"]).optional().describe("Planned output format: pdf (default), markdown, or both. Affects planned_outputs filenames only; preview_review_bundle does not render artifacts."),
1365
1367
  },
1366
1368
  async ({
1367
1369
  project_id,
@@ -1374,7 +1376,9 @@ function createMcpServer() {
1374
1376
  include_scene_ids = true,
1375
1377
  include_metadata_sidebar = false,
1376
1378
  include_paragraph_anchors = false,
1379
+ recipient_name,
1377
1380
  bundle_name,
1381
+ format = "pdf",
1378
1382
  }) => {
1379
1383
  const projectIdCheck = validateProjectId(project_id);
1380
1384
  if (!projectIdCheck.ok) {
@@ -1393,7 +1397,9 @@ function createMcpServer() {
1393
1397
  include_scene_ids,
1394
1398
  include_metadata_sidebar,
1395
1399
  include_paragraph_anchors,
1400
+ recipient_name,
1396
1401
  bundle_name,
1402
+ format,
1397
1403
  });
1398
1404
  return jsonResponse(plan);
1399
1405
  } catch (error) {
@@ -1411,21 +1417,23 @@ function createMcpServer() {
1411
1417
  // ---- create_review_bundle -----------------------------------------------
1412
1418
  s.tool(
1413
1419
  "create_review_bundle",
1414
- "Generate markdown review bundle artifacts from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
1420
+ "Generate review bundle artifacts (PDF/markdown) from planned scene scope. Writes files only under output_dir and returns manifest/provenance details.",
1415
1421
  {
1416
1422
  project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1417
- profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion or editor_detailed."),
1423
+ profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
1418
1424
  output_dir: z.string().describe("Directory path to write bundle artifacts into."),
1419
1425
  part: z.number().int().optional().describe("Optional part filter."),
1420
1426
  chapter: z.number().int().optional().describe("Optional chapter filter."),
1421
1427
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
1422
1428
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
1423
1429
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
1424
- include_scene_ids: z.boolean().optional().describe("Include scene IDs in markdown headings (default true)."),
1425
- include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false)."),
1426
- include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false)."),
1430
+ include_scene_ids: z.boolean().optional().describe("Include scene IDs in headings (default true). Applies to both PDF and markdown."),
1431
+ include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false). Markdown only — no effect on PDF."),
1432
+ include_paragraph_anchors: z.boolean().optional().describe("Include paragraph anchors in markdown output (default false). Markdown only — no effect on PDF."),
1433
+ recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1427
1434
  bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
1428
1435
  source_commit: z.string().optional().describe("Optional explicit source commit for provenance. Defaults to current HEAD when available."),
1436
+ format: z.enum(["pdf", "markdown", "both"]).optional().describe("Output format: pdf (default), markdown, or both."),
1429
1437
  },
1430
1438
  async ({
1431
1439
  project_id,
@@ -1439,8 +1447,10 @@ function createMcpServer() {
1439
1447
  include_scene_ids = true,
1440
1448
  include_metadata_sidebar = false,
1441
1449
  include_paragraph_anchors = false,
1450
+ recipient_name,
1442
1451
  bundle_name,
1443
1452
  source_commit,
1453
+ format = "pdf",
1444
1454
  }) => {
1445
1455
  const projectIdCheck = validateProjectId(project_id);
1446
1456
  if (!projectIdCheck.ok) {
@@ -1472,7 +1482,9 @@ function createMcpServer() {
1472
1482
  include_scene_ids,
1473
1483
  include_metadata_sidebar,
1474
1484
  include_paragraph_anchors,
1485
+ recipient_name,
1475
1486
  bundle_name,
1487
+ format,
1476
1488
  });
1477
1489
 
1478
1490
  if (!plan.strictness_result.can_proceed) {
@@ -1484,7 +1496,7 @@ function createMcpServer() {
1484
1496
  }
1485
1497
 
1486
1498
  const provenanceCommit = source_commit ?? (GIT_ENABLED ? getHeadCommitHash(SYNC_DIR) : null);
1487
- const artifacts = createReviewBundleArtifacts(db, {
1499
+ const artifacts = await createReviewBundleArtifacts(db, {
1488
1500
  plan,
1489
1501
  output_dir: resolvedOutputDir,
1490
1502
  source_commit: provenanceCommit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.16.2",
3
+ "version": "2.0.0",
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",
@@ -29,6 +29,7 @@
29
29
  "manual:test": "node scripts/manual/test.mjs",
30
30
  "manual:scenarios": "node scripts/manual/test-scenarios.mjs",
31
31
  "manual:merge-beta-test": "node scripts/manual/run_mcp_test.js",
32
+ "manual:review-bundle": "node scripts/manual/run_create_review_bundle.js",
32
33
  "setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
33
34
  "release": "release-it",
34
35
  "lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
@@ -55,6 +56,7 @@
55
56
  "@xmldom/xmldom": "^0.9.10",
56
57
  "gray-matter": "^4.0.3",
57
58
  "js-yaml": "^4.1.1",
59
+ "pdfkit": "^0.14.0",
58
60
  "zod": "^4.3.6"
59
61
  },
60
62
  "devDependencies": {
package/review-bundles.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import matter from "gray-matter";
4
+ import PDFDocument from "pdfkit";
4
5
 
5
6
  const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
6
7
 
7
- export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed"];
8
+ export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed", "beta_reader_personalized"];
8
9
  export const REVIEW_BUNDLE_STRICTNESS = ["warn", "fail"];
9
10
 
10
11
  export class ReviewBundlePlanError extends Error {
@@ -63,6 +64,62 @@ function escapeMarkdown(text) {
63
64
  .replace(/([*_`\[\]#])/g, "\\$1");
64
65
  }
65
66
 
67
+ function normalizeRecipientDisplayName(recipientName) {
68
+ const normalized = String(recipientName ?? "")
69
+ .replace(/[\x00-\x1f\x7f]+/g, " ")
70
+ .replace(/\s+/g, " ")
71
+ .trim()
72
+ .slice(0, 100);
73
+
74
+ return normalized || "Beta Reader";
75
+ }
76
+
77
+ function renderBetaNoticeMarkdown({ projectId, recipientName }) {
78
+ const displayName = normalizeRecipientDisplayName(recipientName);
79
+ return [
80
+ "# Non-Distribution Notice",
81
+ "",
82
+ `This review packet is prepared for ${escapeMarkdown(displayName)} for private beta-reading purposes only.`,
83
+ "",
84
+ "Please do not distribute, repost, or share this material without explicit author permission.",
85
+ "",
86
+ "This notice is informational only and is not legal advice.",
87
+ "",
88
+ `Project: ${escapeMarkdown(projectId)}`,
89
+ ].join("\n") + "\n";
90
+ }
91
+
92
+ function renderBetaFeedbackFormMarkdown({ projectId, recipientName, generatedAt }) {
93
+ const displayName = normalizeRecipientDisplayName(recipientName);
94
+ const feedbackDate = String(generatedAt ?? new Date().toISOString()).slice(0, 10);
95
+ return [
96
+ "# Beta Reader Feedback Form",
97
+ "",
98
+ `- Project: ${escapeMarkdown(projectId)}`,
99
+ `- Reader: ${escapeMarkdown(displayName)}`,
100
+ `- Date: ${feedbackDate}`,
101
+ "",
102
+ "## Big-Picture Questions",
103
+ "",
104
+ "1. Which sections felt most compelling, and why?",
105
+ "2. Where did pacing feel slow, rushed, or unclear?",
106
+ "3. Were any character motivations confusing or unconvincing?",
107
+ "",
108
+ "## Scene-Level Notes",
109
+ "",
110
+ "Use scene IDs when possible.",
111
+ "",
112
+ "- Scene ID:",
113
+ "- Comment:",
114
+ "- Severity (nit / moderate / major):",
115
+ "",
116
+ "## Final Thoughts",
117
+ "",
118
+ "- What should be prioritized in the next revision?",
119
+ "- Any continuity concerns to flag?",
120
+ ].join("\n") + "\n";
121
+ }
122
+
66
123
  function resolveOutputFilePath(outputDir, fileName) {
67
124
  const normalizedOutputDir = path.resolve(outputDir);
68
125
  const target = path.resolve(normalizedOutputDir, fileName);
@@ -97,6 +154,18 @@ function assertStrictness(strictness) {
97
154
  }
98
155
  }
99
156
 
157
+ export const REVIEW_BUNDLE_FORMATS = ["pdf", "markdown", "both"];
158
+
159
+ function assertFormat(format) {
160
+ if (!REVIEW_BUNDLE_FORMATS.includes(format)) {
161
+ throw new ReviewBundlePlanError(
162
+ "INVALID_FORMAT",
163
+ `Unsupported format '${format}'.`,
164
+ { supported_formats: REVIEW_BUNDLE_FORMATS }
165
+ );
166
+ }
167
+ }
168
+
100
169
  function resolveRequestedSceneIds(dbHandle, projectId, sceneIds) {
101
170
  if (!Array.isArray(sceneIds) || sceneIds.length === 0) {
102
171
  return { requested: [], existing: new Set() };
@@ -125,6 +194,8 @@ export function buildReviewBundlePlan(dbHandle, {
125
194
  include_metadata_sidebar = false,
126
195
  include_paragraph_anchors = false,
127
196
  bundle_name,
197
+ recipient_name,
198
+ format = "pdf",
128
199
  } = {}) {
129
200
  if (!project_id) {
130
201
  throw new ReviewBundlePlanError("INVALID_PROJECT_ID", "project_id is required.");
@@ -132,6 +203,7 @@ export function buildReviewBundlePlan(dbHandle, {
132
203
 
133
204
  assertProfile(profile);
134
205
  assertStrictness(strictness);
206
+ assertFormat(format);
135
207
 
136
208
  const projectRow = dbHandle.prepare(`SELECT project_id FROM projects WHERE project_id = ?`).get(project_id);
137
209
  if (!projectRow) {
@@ -264,6 +336,9 @@ export function buildReviewBundlePlan(dbHandle, {
264
336
  const count = Number(row.word_count);
265
337
  return sum + (Number.isFinite(count) ? count : 0);
266
338
  }, 0);
339
+ const resolvedRecipientName = profile === "beta_reader_personalized"
340
+ ? normalizeRecipientDisplayName(recipient_name)
341
+ : undefined;
267
342
 
268
343
  const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
269
344
  const appliedFilters = {
@@ -283,6 +358,7 @@ export function buildReviewBundlePlan(dbHandle, {
283
358
  include_scene_ids: Boolean(include_scene_ids),
284
359
  include_metadata_sidebar: Boolean(include_metadata_sidebar),
285
360
  include_paragraph_anchors: Boolean(include_paragraph_anchors),
361
+ ...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
286
362
  },
287
363
  },
288
364
  ordering: rows.map(row => ({
@@ -307,7 +383,14 @@ export function buildReviewBundlePlan(dbHandle, {
307
383
  blockers,
308
384
  },
309
385
  planned_outputs: [
310
- `${safeBundleName}.md`,
386
+ ...(format === "markdown" || format === "both" ? [`${safeBundleName}.md`] : []),
387
+ ...(format === "pdf" || format === "both" ? [`${safeBundleName}.pdf`] : []),
388
+ ...(profile === "beta_reader_personalized"
389
+ ? [
390
+ `${safeBundleName}.notice.md`,
391
+ `${safeBundleName}.feedback-form.md`,
392
+ ]
393
+ : []),
311
394
  `${safeBundleName}.manifest.json`,
312
395
  ],
313
396
  };
@@ -567,19 +650,35 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
567
650
  const sceneIds = plan.ordering.map(row => row.scene_id);
568
651
  const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
569
652
  const sections = [];
653
+ const recipientName = plan.resolved_scope?.options?.recipient_name;
654
+ const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
570
655
 
571
656
  const headerLines = [
572
657
  `# Review Bundle: ${escapeMarkdown(plan.resolved_scope.project_id)}`,
573
658
  "",
574
659
  `- Profile: ${profile}`,
660
+ ...(profile === "beta_reader_personalized"
661
+ ? [`- Recipient: ${escapeMarkdown(recipientDisplayName)}`]
662
+ : []),
575
663
  `- Generated at: ${generatedAt ?? new Date().toISOString()}`,
576
664
  `- Scene count: ${plan.summary.scene_count}`,
577
665
  ];
578
666
  sections.push(headerLines.join("\n"));
579
667
 
668
+ if (profile === "beta_reader_personalized") {
669
+ sections.push(
670
+ [
671
+ "## Usage Notice",
672
+ "",
673
+ "This beta-reader draft is intended for private review and feedback.",
674
+ "Please do not redistribute without explicit author permission.",
675
+ ].join("\n")
676
+ );
677
+ }
678
+
580
679
  for (const scene of rows) {
581
680
  let prose = "";
582
- if (profile === "editor_detailed") {
681
+ if (profile === "editor_detailed" || profile === "beta_reader_personalized") {
583
682
  const resolved = readProse(scene.file_path, { syncDir });
584
683
  if (resolved === null) {
585
684
  throw new ReviewBundlePlanError(
@@ -606,7 +705,150 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
606
705
  return sections.join("\n\n---\n\n").trim() + "\n";
607
706
  }
608
707
 
609
- export function createReviewBundleArtifacts(dbHandle, {
708
+ /**
709
+ * Render a review bundle plan to PDF format using pdfkit.
710
+ * Returns a buffer containing the PDF document.
711
+ */
712
+ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: syncDirOpt } = {}) {
713
+ const profile = plan.profile;
714
+ const includeSceneIds = Boolean(plan.resolved_scope?.options?.include_scene_ids);
715
+ const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
716
+
717
+ const sceneIds = plan.ordering.map(row => row.scene_id);
718
+ const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
719
+ const recipientName = plan.resolved_scope?.options?.recipient_name;
720
+ const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
721
+
722
+ // Create PDF document in memory (we'll pipe to buffer)
723
+ const doc = new PDFDocument({
724
+ size: "Letter",
725
+ margin: 50,
726
+ });
727
+
728
+ // Register listeners before any content is written so render-time errors
729
+ // always reject the returned Promise.
730
+ const chunks = [];
731
+ return new Promise((resolve, reject) => {
732
+ let settled = false;
733
+ const fail = (err) => {
734
+ if (settled) return;
735
+ settled = true;
736
+ reject(err);
737
+ };
738
+
739
+ doc.on("data", chunk => chunks.push(chunk));
740
+ doc.on("error", fail);
741
+ doc.on("end", () => {
742
+ if (settled) return;
743
+ settled = true;
744
+ resolve(Buffer.concat(chunks));
745
+ });
746
+
747
+ try {
748
+ // Title and metadata
749
+ doc.fontSize(24).font("Helvetica-Bold").text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
750
+ doc.moveDown(0.5);
751
+ doc.fontSize(11).font("Helvetica");
752
+ doc.text(`Profile: ${profile}`, { align: "left" });
753
+ if (profile === "beta_reader_personalized") {
754
+ doc.text(`Recipient: ${recipientDisplayName}`, { align: "left" });
755
+ }
756
+ doc.text(`Generated: ${generatedAt ?? new Date().toISOString()}`, { align: "left" });
757
+ doc.text(`Scenes: ${plan.summary.scene_count}`, { align: "left" });
758
+ doc.moveDown();
759
+
760
+ // Usage notice for beta profile
761
+ if (profile === "beta_reader_personalized") {
762
+ doc.fontSize(12).font("Helvetica-Bold").text("Usage Notice", { align: "left" });
763
+ doc.moveDown(0.3);
764
+ const noticeWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
765
+ doc.fontSize(10).font("Helvetica");
766
+ doc.text("This beta-reader draft is intended for private review and feedback. Please do not redistribute without explicit author permission.", {
767
+ align: "left",
768
+ width: noticeWidth,
769
+ });
770
+ doc.moveDown();
771
+ }
772
+
773
+ // Render scenes
774
+ for (const scene of rows) {
775
+ // Scene heading
776
+ doc.fontSize(14).font("Helvetica-Bold");
777
+ let heading = scene.title || scene.scene_id;
778
+ if (includeSceneIds) {
779
+ heading += ` [${scene.scene_id}]`;
780
+ }
781
+ doc.text(heading, { align: "left" });
782
+ doc.moveDown(0.2);
783
+
784
+ // Scene metadata (one-liner)
785
+ const metaParts = [];
786
+ if (scene.pov) metaParts.push(`POV: ${scene.pov}`);
787
+ if (scene.save_the_cat_beat) metaParts.push(`Beat: ${scene.save_the_cat_beat}`);
788
+ if (metaParts.length > 0) {
789
+ const metaWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
790
+ doc.fontSize(9).font("Helvetica-Oblique");
791
+ doc.text(metaParts.join(" • "), { align: "left", width: metaWidth });
792
+ doc.font("Helvetica");
793
+ doc.moveDown(0.2);
794
+ }
795
+
796
+ // Logline
797
+ if (scene.logline) {
798
+ doc.fontSize(10).font("Helvetica-Oblique");
799
+ const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
800
+ doc.text(`"${scene.logline}"`, { align: "left", width: textWidth });
801
+ doc.moveDown(0.3);
802
+ }
803
+
804
+ // Prose (only for detailed/beta profiles)
805
+ if (profile === "editor_detailed" || profile === "beta_reader_personalized") {
806
+ let prose = "";
807
+ const resolved = readProse(scene.file_path, { syncDir });
808
+ if (resolved === null) {
809
+ throw new ReviewBundlePlanError(
810
+ "SCENE_PROSE_READ_FAILED",
811
+ `Scene prose is unavailable for scene ${scene.scene_id}: file_path is null or could not be resolved within syncDir.`,
812
+ {
813
+ scene_id: scene.scene_id,
814
+ file_path: scene.file_path ?? null,
815
+ sync_dir: syncDir,
816
+ }
817
+ );
818
+ }
819
+ prose = resolved;
820
+
821
+ const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
822
+ doc.fontSize(10).font("Helvetica");
823
+ doc.text(prose, {
824
+ align: "left",
825
+ width: textWidth,
826
+ lineGap: 3,
827
+ });
828
+ }
829
+
830
+ doc.moveDown(0.5);
831
+ // Add page break between scenes only for prose-including profiles where
832
+ // clear scene separation matters. For outline_discussion, let content flow.
833
+ const includesProse = profile === "editor_detailed" || profile === "beta_reader_personalized";
834
+ if (includesProse && scene !== rows[rows.length - 1]) {
835
+ doc.addPage();
836
+ }
837
+ }
838
+
839
+ doc.end();
840
+ } catch (error) {
841
+ fail(error);
842
+ try {
843
+ doc.end();
844
+ } catch {
845
+ // Ignore errors from end() during failure cleanup.
846
+ }
847
+ }
848
+ });
849
+ }
850
+
851
+ export async function createReviewBundleArtifacts(dbHandle, {
610
852
  plan,
611
853
  output_dir,
612
854
  source_commit = null,
@@ -636,22 +878,59 @@ export function createReviewBundleArtifacts(dbHandle, {
636
878
  );
637
879
  }
638
880
 
639
- const markdownFileName = plan.planned_outputs.find(name => name.endsWith(".md"));
881
+ const noticeFileName = plan.planned_outputs.find(name => name.endsWith(".notice.md")) ?? null;
882
+ const feedbackFileName = plan.planned_outputs.find(name => name.endsWith(".feedback-form.md")) ?? null;
883
+ // Derive which outputs to write from the plan itself, not from the format param,
884
+ // so plan and artifacts always stay in sync.
885
+ const markdownFileName = plan.planned_outputs.find(
886
+ name => name.endsWith(".md") && !name.endsWith(".notice.md") && !name.endsWith(".feedback-form.md")
887
+ ) ?? null;
888
+ const pdfFileName = plan.planned_outputs.find(name => name.endsWith(".pdf")) ?? null;
640
889
  const manifestFileName = plan.planned_outputs.find(name => name.endsWith(".manifest.json"));
641
- if (!markdownFileName || !manifestFileName) {
890
+
891
+ if (!manifestFileName) {
892
+ throw new ReviewBundlePlanError(
893
+ "INVALID_PLAN_OUTPUTS",
894
+ "Plan is missing expected manifest filename."
895
+ );
896
+ }
897
+
898
+ if (!markdownFileName && !pdfFileName) {
642
899
  throw new ReviewBundlePlanError(
643
900
  "INVALID_PLAN_OUTPUTS",
644
- "Plan is missing expected markdown/manifest filenames."
901
+ "Plan has no primary bundle output (neither .md nor .pdf) in planned_outputs."
645
902
  );
646
903
  }
647
904
 
648
- const markdownPath = resolveOutputFilePath(normalizedOutputDir, markdownFileName);
905
+ const markdownPath = markdownFileName ? resolveOutputFilePath(normalizedOutputDir, markdownFileName) : null;
906
+ const pdfPath = pdfFileName ? resolveOutputFilePath(normalizedOutputDir, pdfFileName) : null;
649
907
  const manifestPath = resolveOutputFilePath(normalizedOutputDir, manifestFileName);
908
+ const noticePath = noticeFileName ? resolveOutputFilePath(normalizedOutputDir, noticeFileName) : null;
909
+ const feedbackPath = feedbackFileName ? resolveOutputFilePath(normalizedOutputDir, feedbackFileName) : null;
650
910
 
651
911
  const generatedAt = new Date().toISOString();
652
- const markdown = renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir });
912
+
913
+ // Render markdown if needed
914
+ const markdown = markdownPath ? renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDir }) : null;
915
+
916
+ // Render PDF if needed
917
+ let pdfBuffer = null;
918
+ if (pdfPath) {
919
+ pdfBuffer = await renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir });
920
+ }
921
+
922
+ const recipientName = plan.resolved_scope?.options?.recipient_name;
923
+ const betaNotice = plan.profile === "beta_reader_personalized"
924
+ ? renderBetaNoticeMarkdown({ projectId: plan.resolved_scope.project_id, recipientName })
925
+ : null;
926
+ const betaFeedbackForm = plan.profile === "beta_reader_personalized"
927
+ ? renderBetaFeedbackFormMarkdown({ projectId: plan.resolved_scope.project_id, recipientName, generatedAt })
928
+ : null;
929
+
930
+ // Use the bundle ID from whichever primary file exists
931
+ const bundleIdFileName = markdownFileName || pdfFileName;
653
932
  const manifest = {
654
- bundle_id: path.basename(markdownFileName, ".md"),
933
+ bundle_id: path.basename(bundleIdFileName, path.extname(bundleIdFileName)),
655
934
  profile: plan.profile,
656
935
  generated_at: generatedAt,
657
936
  provenance: {
@@ -665,7 +944,7 @@ export function createReviewBundleArtifacts(dbHandle, {
665
944
  scene_ids: plan.ordering.map(row => row.scene_id),
666
945
  };
667
946
 
668
- for (const outputPath of [markdownPath, manifestPath]) {
947
+ for (const outputPath of [markdownPath, pdfPath, manifestPath, noticePath, feedbackPath].filter(Boolean)) {
669
948
  try {
670
949
  const stat = fs.lstatSync(outputPath);
671
950
  if (stat.isSymbolicLink()) {
@@ -690,14 +969,28 @@ export function createReviewBundleArtifacts(dbHandle, {
690
969
  }
691
970
  }
692
971
 
693
- fs.writeFileSync(markdownPath, markdown, "utf8");
972
+ if (markdownPath && markdown != null) {
973
+ fs.writeFileSync(markdownPath, markdown, "utf8");
974
+ }
975
+ if (pdfPath && pdfBuffer != null) {
976
+ fs.writeFileSync(pdfPath, pdfBuffer);
977
+ }
694
978
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
979
+ if (noticePath && betaNotice != null) {
980
+ fs.writeFileSync(noticePath, betaNotice, "utf8");
981
+ }
982
+ if (feedbackPath && betaFeedbackForm != null) {
983
+ fs.writeFileSync(feedbackPath, betaFeedbackForm, "utf8");
984
+ }
695
985
 
696
986
  return {
697
987
  bundle_id: manifest.bundle_id,
698
988
  output_paths: {
699
- bundle_markdown: markdownPath,
989
+ ...(markdownPath ? { bundle_markdown: markdownPath } : {}),
990
+ ...(pdfPath ? { bundle_pdf: pdfPath } : {}),
700
991
  manifest_json: manifestPath,
992
+ ...(noticePath ? { notice_md: noticePath } : {}),
993
+ ...(feedbackPath ? { feedback_form_md: feedbackPath } : {}),
701
994
  },
702
995
  generated_at: generatedAt,
703
996
  };
@@ -2,11 +2,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
3
  import path from "path";
4
4
  import process from "process";
5
+ import fs from "node:fs";
5
6
 
6
7
  async function main() {
7
8
  const [projectId, profile, outputDir, ...flags] = process.argv.slice(2);
8
9
  if (!projectId || !profile || !outputDir) {
9
- console.error("Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_create_review_bundle.js <project_id> <profile> <output_dir> [--anchors] [--bundle-name <name>]");
10
+ console.error("Usage: WRITING_SYNC_DIR=/path/to/sync node scripts/manual/run_create_review_bundle.js <project_id> <profile> <output_dir> [--anchors] [--recipient <name>] [--bundle-name <name>] [--skip-preview] [--show-files]");
10
11
  process.exit(1);
11
12
  }
12
13
 
@@ -19,8 +20,28 @@ async function main() {
19
20
  }
20
21
 
21
22
  const includeParagraphAnchors = flags.includes("--anchors");
23
+ const skipPreview = flags.includes("--skip-preview");
24
+ const showFiles = flags.includes("--show-files");
22
25
  const bundleNameIdx = flags.indexOf("--bundle-name");
23
26
  const bundleName = bundleNameIdx !== -1 ? flags[bundleNameIdx + 1] : undefined;
27
+ const recipientIdx = flags.indexOf("--recipient");
28
+ const recipientName = recipientIdx !== -1 ? flags[recipientIdx + 1] : undefined;
29
+
30
+ const baseArguments = {
31
+ project_id: projectId,
32
+ profile,
33
+ ...(includeParagraphAnchors ? { include_paragraph_anchors: true } : {}),
34
+ ...(bundleName ? { bundle_name: bundleName } : {}),
35
+ ...(recipientName ? { recipient_name: recipientName } : {}),
36
+ };
37
+
38
+ function printArtifactExcerpt(label, filePath) {
39
+ if (!filePath || !fs.existsSync(filePath)) return;
40
+ const content = fs.readFileSync(filePath, "utf8");
41
+ const excerpt = content.split("\n").slice(0, 20).join("\n");
42
+ console.log(`\n--- ${label}: ${filePath} ---`);
43
+ console.log(excerpt);
44
+ }
24
45
 
25
46
  const transport = new StdioClientTransport({
26
47
  command: process.execPath,
@@ -41,18 +62,38 @@ async function main() {
41
62
 
42
63
  try {
43
64
  await client.connect(transport);
65
+
66
+ if (!skipPreview) {
67
+ const previewResult = await client.callTool({
68
+ name: "preview_review_bundle",
69
+ arguments: baseArguments,
70
+ });
71
+ const previewText = previewResult.content?.[0]?.text ?? "";
72
+ console.log("\n=== preview_review_bundle ===");
73
+ console.log(previewText);
74
+ }
75
+
44
76
  const result = await client.callTool({
45
77
  name: "create_review_bundle",
46
78
  arguments: {
47
- project_id: projectId,
48
- profile: profile,
79
+ ...baseArguments,
49
80
  output_dir: outputDir,
50
- ...(includeParagraphAnchors ? { include_paragraph_anchors: true } : {}),
51
- ...(bundleName ? { bundle_name: bundleName } : {})
52
81
  }
53
82
  });
54
83
 
55
- console.log(JSON.stringify(result, null, 2));
84
+ const resultText = result.content?.[0]?.text ?? "";
85
+ console.log("\n=== create_review_bundle ===");
86
+ console.log(resultText);
87
+
88
+ if (showFiles) {
89
+ const parsed = JSON.parse(resultText);
90
+ if (parsed.ok && parsed.output_paths) {
91
+ printArtifactExcerpt("Bundle Markdown", parsed.output_paths.bundle_markdown);
92
+ printArtifactExcerpt("Manifest JSON", parsed.output_paths.manifest_json);
93
+ printArtifactExcerpt("Notice Markdown", parsed.output_paths.notice_md);
94
+ printArtifactExcerpt("Feedback Form Markdown", parsed.output_paths.feedback_form_md);
95
+ }
96
+ }
56
97
  } catch (e) {
57
98
  console.error(e);
58
99
  process.exitCode = 1;
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Profile review-bundle generation performance
5
+ * Usage: node --experimental-sqlite scripts/profile-review-bundles.mjs
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+ import { openDb } from "../db.js";
12
+ import { syncAll } from "../sync.js";
13
+ import { isGitRepository, getHeadCommitHash } from "../git.js";
14
+ import {
15
+ buildReviewBundlePlan,
16
+ createReviewBundleArtifacts,
17
+ } from "../review-bundles.js";
18
+
19
+ const PROJECT_SYNC_DIR = process.env.WRITING_SYNC_DIR ?? process.argv[2] ?? null;
20
+ const DB_PATH = process.env.DB_PATH ?? (PROJECT_SYNC_DIR ? path.join(PROJECT_SYNC_DIR, ".mcp", "writing.db") : null);
21
+ const PROFILES = ["outline_discussion", "editor_detailed", "beta_reader_personalized"];
22
+
23
+ /**
24
+ * @typedef {Object} ProfileResult
25
+ * @property {string} scenario
26
+ * @property {string} profile
27
+ * @property {number} sceneCount
28
+ * @property {number} wordCount
29
+ * @property {number} durationMs
30
+ * @property {number} [outputSize]
31
+ */
32
+
33
+ /**
34
+ * @param {string} syncDir
35
+ * @param {string} projectId
36
+ * @param {string} profile
37
+ * @param {Record<string, any>} [filters={}]
38
+ * @returns {Promise<ProfileResult|null>}
39
+ */
40
+ async function profileScenario(
41
+ syncDir,
42
+ projectId,
43
+ profile,
44
+ filters = {}
45
+ ) {
46
+ console.log(
47
+ ` Profiling ${profile} with ${JSON.stringify(filters || "full project")}...`
48
+ );
49
+
50
+ const db = await openDb(DB_PATH);
51
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "profile-"));
52
+
53
+ try {
54
+ // Time the planning phase
55
+ const planStart = performance.now();
56
+ let plan;
57
+ try {
58
+ plan = await buildReviewBundlePlan(db, {
59
+ project_id: projectId,
60
+ profile,
61
+ ...filters,
62
+ });
63
+ } catch (err) {
64
+ console.error(` ✗ Planning failed: ${err.message}`);
65
+ return null;
66
+ }
67
+ const planMs = performance.now() - planStart;
68
+
69
+ // Time the artifact generation phase
70
+ const createStart = performance.now();
71
+ try {
72
+ await createReviewBundleArtifacts(db, {
73
+ plan,
74
+ output_dir: tmpDir,
75
+ syncDir,
76
+ source_commit: await getHeadCommitHash(syncDir),
77
+ });
78
+ } catch (err) {
79
+ console.error(` ✗ Creation failed: ${err.message}`);
80
+ return null;
81
+ }
82
+ const createMs = performance.now() - createStart;
83
+
84
+ // Measure output size
85
+ let outputSize = 0;
86
+ const files = fs.readdirSync(tmpDir);
87
+ for (const file of files) {
88
+ const filePath = path.join(tmpDir, file);
89
+ const stat = fs.statSync(filePath);
90
+ outputSize += stat.size;
91
+ }
92
+
93
+ const totalMs = planMs + createMs;
94
+ const sceneCount = plan.ordering?.length ?? 0;
95
+ const wordCount = plan.summary?.estimated_word_count ?? 0;
96
+
97
+ console.log(
98
+ ` ✓ ${sceneCount} scenes, ~${wordCount.toLocaleString()} words, ${outputSize.toLocaleString()} bytes`
99
+ );
100
+ console.log(` Planning: ${planMs.toFixed(2)}ms, Creation: ${createMs.toFixed(2)}ms, Total: ${totalMs.toFixed(2)}ms`);
101
+
102
+ return {
103
+ scenario: `${projectId} ${Object.keys(filters).length === 0 ? "full" : JSON.stringify(filters)}`,
104
+ profile,
105
+ sceneCount,
106
+ wordCount,
107
+ durationMs: totalMs,
108
+ outputSize,
109
+ };
110
+ } finally {
111
+ // Clean up temp directory
112
+ if (fs.existsSync(tmpDir)) {
113
+ fs.rmSync(tmpDir, { recursive: true, force: true });
114
+ }
115
+ db.close();
116
+ }
117
+ }
118
+
119
+ async function main() {
120
+ if (!PROJECT_SYNC_DIR) {
121
+ console.error("✗ WRITING_SYNC_DIR env var or a path argument is required.");
122
+ console.error(" Usage: WRITING_SYNC_DIR=/path/to/project node --experimental-sqlite scripts/profile-review-bundles.mjs");
123
+ process.exit(1);
124
+ }
125
+
126
+ if (!fs.existsSync(PROJECT_SYNC_DIR)) {
127
+ console.error(`✗ Project directory not found: ${PROJECT_SYNC_DIR}`);
128
+ process.exit(1);
129
+ }
130
+
131
+ if (!isGitRepository(PROJECT_SYNC_DIR)) {
132
+ console.error(`✗ Not a git repository: ${PROJECT_SYNC_DIR}`);
133
+ process.exit(1);
134
+ }
135
+
136
+ console.log("🔍 Review Bundles Performance Profile");
137
+ console.log(` Sync Dir: ${PROJECT_SYNC_DIR}`);
138
+ console.log(` Starting: ${new Date().toISOString()}\n`);
139
+
140
+ // Sync the database first
141
+ console.log("📇 Syncing project database...");
142
+ const syncDb = await openDb(DB_PATH);
143
+ try {
144
+ await syncAll(syncDb, PROJECT_SYNC_DIR);
145
+ } finally {
146
+ syncDb.close();
147
+ }
148
+ console.log(" ✓ Sync complete\n");
149
+
150
+ const results = [];
151
+
152
+ // Test scenarios for book-1-the-lamb (has 118 scenes)
153
+ const projectId = "universe-1/book-1-the-lamb";
154
+
155
+ console.log(`📖 Profiling ${projectId}:\n`);
156
+
157
+ // Scenario 1: Full project with all profiles
158
+ console.log("Scenario 1: Full project\n");
159
+ for (const profile of PROFILES) {
160
+ const result = await profileScenario(PROJECT_SYNC_DIR, projectId, profile);
161
+ if (result) results.push(result);
162
+ }
163
+
164
+ // Scenario 2: Single chapter (if available)
165
+ console.log("\nScenario 2: Single chapter (chapter 1)\n");
166
+ for (const profile of PROFILES) {
167
+ const result = await profileScenario(PROJECT_SYNC_DIR, projectId, profile, {
168
+ chapter: 1,
169
+ });
170
+ if (result) results.push(result);
171
+ }
172
+
173
+ // Scenario 3: Subset by scene_ids (first 10 scenes)
174
+ console.log("\nScenario 3: Subset by scene_ids (first 10 scenes)\n");
175
+ for (const profile of PROFILES) {
176
+ const result = await profileScenario(PROJECT_SYNC_DIR, projectId, profile, {
177
+ scene_ids: ["sc-001", "sc-002", "sc-003", "sc-004", "sc-005", "sc-006", "sc-007", "sc-008", "sc-009", "sc-010"],
178
+ });
179
+ if (result) results.push(result);
180
+ }
181
+
182
+ // Print summary
183
+ console.log("\n\n📊 Performance Summary\n");
184
+ console.log("Profile | Scenario | Scenes | Words | Output (KB) | Duration (ms)");
185
+ console.log(
186
+ "--------|----------|--------|-------|-------------|---------------"
187
+ );
188
+
189
+ for (const result of results) {
190
+ if (!result) continue;
191
+ const outputKb = (result.outputSize / 1024).toFixed(1);
192
+ const scenarioShort = result.scenario.replace(/universe-1\//, "").substring(0, 30);
193
+ console.log(
194
+ `${result.profile.padEnd(15)} | ${scenarioShort.padEnd(30)} | ${String(result.sceneCount).padEnd(6)} | ${String(result.wordCount).padEnd(5)} | ${outputKb.padEnd(11)} | ${result.durationMs.toFixed(2)}`
195
+ );
196
+ }
197
+
198
+ // Analysis
199
+ console.log("\n📈 Analysis:\n");
200
+
201
+ const fullProjectResults = results.filter(
202
+ (r) => r.scenario.includes("full")
203
+ );
204
+ const editorResults = fullProjectResults.filter(
205
+ (r) => r.profile === "editor_detailed"
206
+ );
207
+
208
+ if (editorResults.length > 0) {
209
+ const editorMs = editorResults[0].durationMs;
210
+ console.log(`• Full project (editor_detailed): ${editorMs.toFixed(2)}ms`);
211
+
212
+ if (editorMs > 5000) {
213
+ console.log(
214
+ " ⚠️ Generation takes > 5s. Async generation would be beneficial."
215
+ );
216
+ } else if (editorMs > 1000) {
217
+ console.log(
218
+ " ⚠️ Generation takes 1-5s. Async could improve UX for slow networks."
219
+ );
220
+ } else {
221
+ console.log(" ✓ Generation is fast (< 1s). Sync is sufficient for now.");
222
+ }
223
+ }
224
+
225
+ console.log(
226
+ "\n✓ Profile complete: " + new Date().toISOString()
227
+ );
228
+ }
229
+
230
+ main().catch((err) => {
231
+ console.error("✗ Profile failed:", err);
232
+ process.exit(1);
233
+ });