@hanna84/mcp-writing 1.17.0 → 2.0.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,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.1](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.0.0...v2.0.1)
9
+
10
+ - fix(package): include scene-character-batch in published files [`#79`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/79)
12
+
13
+ ### [v2.0.0](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.17.0...v2.0.0)
15
+
16
+ > 25 April 2026
17
+
18
+ - feat(review-bundles)!: add PDF export via pdfkit [`#78`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/78)
20
+ - Release 2.0.0 [`ddccb28`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/ddccb2869c6787b0cb64897d067886d59f7cb6e6)
22
+
7
23
  #### [v1.17.0](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.16.2...v1.17.0)
9
25
 
26
+ > 25 April 2026
27
+
10
28
  - feat: add beta_reader_personalized review bundle profile [`#77`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/77)
30
+ - Release 1.17.0 [`1cf0a19`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/1cf0a194bc73073655043072722a9c97cd17c923)
12
32
 
13
33
  #### [v1.16.2](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.16.1...v1.16.2)
package/index.js CHANGED
@@ -1349,7 +1349,7 @@ 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
1355
  profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
@@ -1358,11 +1358,12 @@ function createMcpServer() {
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
1364
  recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1365
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."),
1366
1367
  },
1367
1368
  async ({
1368
1369
  project_id,
@@ -1377,6 +1378,7 @@ function createMcpServer() {
1377
1378
  include_paragraph_anchors = false,
1378
1379
  recipient_name,
1379
1380
  bundle_name,
1381
+ format = "pdf",
1380
1382
  }) => {
1381
1383
  const projectIdCheck = validateProjectId(project_id);
1382
1384
  if (!projectIdCheck.ok) {
@@ -1397,6 +1399,7 @@ function createMcpServer() {
1397
1399
  include_paragraph_anchors,
1398
1400
  recipient_name,
1399
1401
  bundle_name,
1402
+ format,
1400
1403
  });
1401
1404
  return jsonResponse(plan);
1402
1405
  } catch (error) {
@@ -1414,7 +1417,7 @@ function createMcpServer() {
1414
1417
  // ---- create_review_bundle -----------------------------------------------
1415
1418
  s.tool(
1416
1419
  "create_review_bundle",
1417
- "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.",
1418
1421
  {
1419
1422
  project_id: z.string().describe("Project ID to scope the review bundle (e.g. 'test-novel')."),
1420
1423
  profile: z.enum(REVIEW_BUNDLE_PROFILES).describe("Bundle profile: outline_discussion, editor_detailed, or beta_reader_personalized."),
@@ -1424,12 +1427,13 @@ function createMcpServer() {
1424
1427
  tag: z.string().optional().describe("Optional tag filter (exact match)."),
1425
1428
  scene_ids: z.array(z.string()).optional().describe("Optional explicit scene_id allowlist. Intersects with other filters."),
1426
1429
  strictness: z.enum(REVIEW_BUNDLE_STRICTNESS).optional().describe("Strictness mode: warn (default) or fail."),
1427
- include_scene_ids: z.boolean().optional().describe("Include scene IDs in markdown headings (default true)."),
1428
- include_metadata_sidebar: z.boolean().optional().describe("Include metadata sidebar in markdown output (default false)."),
1429
- 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."),
1430
1433
  recipient_name: z.string().optional().describe("Optional recipient display name for beta_reader_personalized profile."),
1431
1434
  bundle_name: z.string().optional().describe("Optional output bundle base name override (slugified in filenames)."),
1432
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."),
1433
1437
  },
1434
1438
  async ({
1435
1439
  project_id,
@@ -1446,6 +1450,7 @@ function createMcpServer() {
1446
1450
  recipient_name,
1447
1451
  bundle_name,
1448
1452
  source_commit,
1453
+ format = "pdf",
1449
1454
  }) => {
1450
1455
  const projectIdCheck = validateProjectId(project_id);
1451
1456
  if (!projectIdCheck.ok) {
@@ -1479,6 +1484,7 @@ function createMcpServer() {
1479
1484
  include_paragraph_anchors,
1480
1485
  recipient_name,
1481
1486
  bundle_name,
1487
+ format,
1482
1488
  });
1483
1489
 
1484
1490
  if (!plan.strictness_result.can_proceed) {
@@ -1490,7 +1496,7 @@ function createMcpServer() {
1490
1496
  }
1491
1497
 
1492
1498
  const provenanceCommit = source_commit ?? (GIT_ENABLED ? getHeadCommitHash(SYNC_DIR) : null);
1493
- const artifacts = createReviewBundleArtifacts(db, {
1499
+ const artifacts = await createReviewBundleArtifacts(db, {
1494
1500
  plan,
1495
1501
  output_dir: resolvedOutputDir,
1496
1502
  source_commit: provenanceCommit,
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.17.0",
3
+ "version": "2.0.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",
7
7
  "files": [
8
8
  "index.js",
9
9
  "async-progress.js",
10
+ "scene-character-batch.js",
10
11
  "scrivener-direct.js",
11
12
  "importer.js",
12
13
  "db.js",
@@ -56,6 +57,7 @@
56
57
  "@xmldom/xmldom": "^0.9.10",
57
58
  "gray-matter": "^4.0.3",
58
59
  "js-yaml": "^4.1.1",
60
+ "pdfkit": "^0.14.0",
59
61
  "zod": "^4.3.6"
60
62
  },
61
63
  "devDependencies": {
package/review-bundles.js CHANGED
@@ -1,6 +1,7 @@
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
 
@@ -153,6 +154,18 @@ function assertStrictness(strictness) {
153
154
  }
154
155
  }
155
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
+
156
169
  function resolveRequestedSceneIds(dbHandle, projectId, sceneIds) {
157
170
  if (!Array.isArray(sceneIds) || sceneIds.length === 0) {
158
171
  return { requested: [], existing: new Set() };
@@ -182,6 +195,7 @@ export function buildReviewBundlePlan(dbHandle, {
182
195
  include_paragraph_anchors = false,
183
196
  bundle_name,
184
197
  recipient_name,
198
+ format = "pdf",
185
199
  } = {}) {
186
200
  if (!project_id) {
187
201
  throw new ReviewBundlePlanError("INVALID_PROJECT_ID", "project_id is required.");
@@ -189,6 +203,7 @@ export function buildReviewBundlePlan(dbHandle, {
189
203
 
190
204
  assertProfile(profile);
191
205
  assertStrictness(strictness);
206
+ assertFormat(format);
192
207
 
193
208
  const projectRow = dbHandle.prepare(`SELECT project_id FROM projects WHERE project_id = ?`).get(project_id);
194
209
  if (!projectRow) {
@@ -368,7 +383,8 @@ export function buildReviewBundlePlan(dbHandle, {
368
383
  blockers,
369
384
  },
370
385
  planned_outputs: [
371
- `${safeBundleName}.md`,
386
+ ...(format === "markdown" || format === "both" ? [`${safeBundleName}.md`] : []),
387
+ ...(format === "pdf" || format === "both" ? [`${safeBundleName}.pdf`] : []),
372
388
  ...(profile === "beta_reader_personalized"
373
389
  ? [
374
390
  `${safeBundleName}.notice.md`,
@@ -689,7 +705,150 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
689
705
  return sections.join("\n\n---\n\n").trim() + "\n";
690
706
  }
691
707
 
692
- 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, {
693
852
  plan,
694
853
  output_dir,
695
854
  source_commit = null,
@@ -721,27 +880,45 @@ export function createReviewBundleArtifacts(dbHandle, {
721
880
 
722
881
  const noticeFileName = plan.planned_outputs.find(name => name.endsWith(".notice.md")) ?? null;
723
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.
724
885
  const markdownFileName = plan.planned_outputs.find(
725
- name =>
726
- name.endsWith(".md") &&
727
- !name.endsWith(".notice.md") &&
728
- !name.endsWith(".feedback-form.md")
729
- );
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;
730
889
  const manifestFileName = plan.planned_outputs.find(name => name.endsWith(".manifest.json"));
731
- 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) {
732
899
  throw new ReviewBundlePlanError(
733
900
  "INVALID_PLAN_OUTPUTS",
734
- "Plan is missing expected markdown/manifest filenames."
901
+ "Plan has no primary bundle output (neither .md nor .pdf) in planned_outputs."
735
902
  );
736
903
  }
737
904
 
738
- const markdownPath = resolveOutputFilePath(normalizedOutputDir, markdownFileName);
905
+ const markdownPath = markdownFileName ? resolveOutputFilePath(normalizedOutputDir, markdownFileName) : null;
906
+ const pdfPath = pdfFileName ? resolveOutputFilePath(normalizedOutputDir, pdfFileName) : null;
739
907
  const manifestPath = resolveOutputFilePath(normalizedOutputDir, manifestFileName);
740
908
  const noticePath = noticeFileName ? resolveOutputFilePath(normalizedOutputDir, noticeFileName) : null;
741
909
  const feedbackPath = feedbackFileName ? resolveOutputFilePath(normalizedOutputDir, feedbackFileName) : null;
742
910
 
743
911
  const generatedAt = new Date().toISOString();
744
- 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
+
745
922
  const recipientName = plan.resolved_scope?.options?.recipient_name;
746
923
  const betaNotice = plan.profile === "beta_reader_personalized"
747
924
  ? renderBetaNoticeMarkdown({ projectId: plan.resolved_scope.project_id, recipientName })
@@ -749,8 +926,11 @@ export function createReviewBundleArtifacts(dbHandle, {
749
926
  const betaFeedbackForm = plan.profile === "beta_reader_personalized"
750
927
  ? renderBetaFeedbackFormMarkdown({ projectId: plan.resolved_scope.project_id, recipientName, generatedAt })
751
928
  : null;
929
+
930
+ // Use the bundle ID from whichever primary file exists
931
+ const bundleIdFileName = markdownFileName || pdfFileName;
752
932
  const manifest = {
753
- bundle_id: path.basename(markdownFileName, ".md"),
933
+ bundle_id: path.basename(bundleIdFileName, path.extname(bundleIdFileName)),
754
934
  profile: plan.profile,
755
935
  generated_at: generatedAt,
756
936
  provenance: {
@@ -764,7 +944,7 @@ export function createReviewBundleArtifacts(dbHandle, {
764
944
  scene_ids: plan.ordering.map(row => row.scene_id),
765
945
  };
766
946
 
767
- for (const outputPath of [markdownPath, manifestPath, noticePath, feedbackPath].filter(Boolean)) {
947
+ for (const outputPath of [markdownPath, pdfPath, manifestPath, noticePath, feedbackPath].filter(Boolean)) {
768
948
  try {
769
949
  const stat = fs.lstatSync(outputPath);
770
950
  if (stat.isSymbolicLink()) {
@@ -789,7 +969,12 @@ export function createReviewBundleArtifacts(dbHandle, {
789
969
  }
790
970
  }
791
971
 
792
- 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
+ }
793
978
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
794
979
  if (noticePath && betaNotice != null) {
795
980
  fs.writeFileSync(noticePath, betaNotice, "utf8");
@@ -801,7 +986,8 @@ export function createReviewBundleArtifacts(dbHandle, {
801
986
  return {
802
987
  bundle_id: manifest.bundle_id,
803
988
  output_paths: {
804
- bundle_markdown: markdownPath,
989
+ ...(markdownPath ? { bundle_markdown: markdownPath } : {}),
990
+ ...(pdfPath ? { bundle_pdf: pdfPath } : {}),
805
991
  manifest_json: manifestPath,
806
992
  ...(noticePath ? { notice_md: noticePath } : {}),
807
993
  ...(feedbackPath ? { feedback_form_md: feedbackPath } : {}),
@@ -0,0 +1,221 @@
1
+ import fs from "node:fs";
2
+ import matter from "gray-matter";
3
+ import { normalizeSceneMetaForPath, readMeta, writeMeta } from "./sync.js";
4
+
5
+ function escapeRegex(text) {
6
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ }
8
+
9
+ function normalizeCharacterRows(rows) {
10
+ const clean = rows
11
+ .filter(row => row?.character_id && row?.name)
12
+ .map(row => ({
13
+ character_id: row.character_id,
14
+ name: String(row.name).trim(),
15
+ tokens: [...new Set(String(row.name).toLowerCase().split(/\s+/).filter(Boolean))],
16
+ }))
17
+ .filter(row => row.name.length > 0);
18
+
19
+ const tokenMap = new Map();
20
+ for (const row of clean) {
21
+ for (const token of row.tokens) {
22
+ if (!token || token.length < 3) continue;
23
+ const ids = tokenMap.get(token) ?? [];
24
+ ids.push(row.character_id);
25
+ tokenMap.set(token, ids);
26
+ }
27
+ }
28
+
29
+ return { clean, tokenMap };
30
+ }
31
+
32
+ function inferCharactersFromProse(prose, characterRows) {
33
+ const { clean, tokenMap } = characterRows;
34
+ const inferred = new Set();
35
+ const ambiguous_tokens = [];
36
+
37
+ for (const row of clean) {
38
+ if (row.tokens.length > 1) {
39
+ const pattern = row.tokens.map(escapeRegex).join("\\s+");
40
+ const regex = new RegExp(`\\b${pattern}\\b`, "i");
41
+ if (regex.test(prose)) {
42
+ inferred.add(row.character_id);
43
+ continue;
44
+ }
45
+ }
46
+
47
+ for (const token of row.tokens) {
48
+ if (!token || token.length < 3) continue;
49
+ const tokenRegex = new RegExp(`\\b${escapeRegex(token)}\\b`, "i");
50
+ if (!tokenRegex.test(prose)) continue;
51
+
52
+ const tokenIds = tokenMap.get(token) ?? [];
53
+ if (tokenIds.length === 1) {
54
+ inferred.add(row.character_id);
55
+ } else if (!ambiguous_tokens.includes(token)) {
56
+ ambiguous_tokens.push(token);
57
+ }
58
+ }
59
+ }
60
+
61
+ return {
62
+ inferred_characters: [...inferred],
63
+ ambiguous_tokens,
64
+ };
65
+ }
66
+
67
+ function nextTurn() {
68
+ return new Promise(resolve => setImmediate(resolve));
69
+ }
70
+
71
+ function getInterSceneDelayMs() {
72
+ const raw = Number(process.env.MCP_WRITING_SCENE_CHARACTER_BATCH_DELAY_MS ?? 0);
73
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
74
+ }
75
+
76
+ function delay(ms) {
77
+ return new Promise(resolve => setTimeout(resolve, ms));
78
+ }
79
+
80
+ export async function runSceneCharacterBatch({ syncDir, args, onProgress, shouldCancel }) {
81
+ const {
82
+ project_id,
83
+ dry_run = true,
84
+ replace_mode = "merge",
85
+ include_match_details = false,
86
+ project_exists = true,
87
+ target_scenes = [],
88
+ character_rows = [],
89
+ } = args;
90
+
91
+ const targetScenes = Array.isArray(target_scenes) ? target_scenes : [];
92
+ const characterRows = Array.isArray(character_rows) ? character_rows : [];
93
+ const normalizedCharacterRows = normalizeCharacterRows(characterRows);
94
+
95
+ const results = [];
96
+ let processed_scenes = 0;
97
+ let scenes_changed = 0;
98
+ let failed_scenes = 0;
99
+ let links_added = 0;
100
+ let links_removed = 0;
101
+ const interSceneDelayMs = getInterSceneDelayMs();
102
+
103
+ const emitProgress = () => {
104
+ if (typeof onProgress !== "function") return;
105
+ onProgress({
106
+ total_scenes: targetScenes.length,
107
+ processed_scenes,
108
+ scenes_changed,
109
+ failed_scenes,
110
+ });
111
+ };
112
+
113
+ emitProgress();
114
+
115
+ for (const scene of targetScenes) {
116
+ await nextTurn();
117
+
118
+ if (typeof shouldCancel === "function" && shouldCancel()) {
119
+ break;
120
+ }
121
+
122
+ try {
123
+ const raw = fs.readFileSync(scene.file_path, "utf8");
124
+ const { content: prose } = matter(raw);
125
+ const { meta } = readMeta(scene.file_path, syncDir, { writable: !dry_run });
126
+
127
+ const before_characters = [...new Set((meta.characters ?? []).map(String).filter(Boolean))];
128
+ const inference = inferCharactersFromProse(prose, normalizedCharacterRows);
129
+ const inferred_characters = inference.inferred_characters;
130
+
131
+ const afterSet = new Set(before_characters);
132
+ if (replace_mode === "replace") {
133
+ afterSet.clear();
134
+ }
135
+ for (const characterId of inferred_characters) {
136
+ afterSet.add(characterId);
137
+ }
138
+
139
+ const after_characters = [...afterSet];
140
+ const beforeSet = new Set(before_characters);
141
+ const added = after_characters.filter(id => !beforeSet.has(id));
142
+ const afterSetLookup = new Set(after_characters);
143
+ const removed = before_characters.filter(id => !afterSetLookup.has(id));
144
+ const changed = added.length > 0 || removed.length > 0;
145
+
146
+ if (!dry_run && changed) {
147
+ const updatedMeta = normalizeSceneMetaForPath(syncDir, scene.file_path, {
148
+ ...meta,
149
+ characters: after_characters,
150
+ }).meta;
151
+
152
+ writeMeta(scene.file_path, updatedMeta);
153
+ }
154
+
155
+ scenes_changed += changed ? 1 : 0;
156
+ links_added += added.length;
157
+ links_removed += removed.length;
158
+
159
+ const hasInferredMatches = inferred_characters.length > 0;
160
+ const sceneStatus = changed
161
+ ? "changed"
162
+ : (!hasInferredMatches && inference.ambiguous_tokens.length > 0 ? "skipped_ambiguous" : "unchanged");
163
+
164
+ results.push({
165
+ scene_id: scene.scene_id,
166
+ file_path: scene.file_path,
167
+ before_characters,
168
+ inferred_characters,
169
+ after_characters,
170
+ added,
171
+ removed,
172
+ changed,
173
+ status: sceneStatus,
174
+ ...(include_match_details ? { match_details: { ambiguous_tokens: inference.ambiguous_tokens } } : {}),
175
+ });
176
+ } catch (error) {
177
+ failed_scenes += 1;
178
+ results.push({
179
+ scene_id: scene.scene_id,
180
+ file_path: scene.file_path,
181
+ before_characters: [],
182
+ inferred_characters: [],
183
+ after_characters: [],
184
+ added: [],
185
+ removed: [],
186
+ changed: false,
187
+ status: "failed",
188
+ error: error instanceof Error ? error.message : String(error),
189
+ });
190
+ } finally {
191
+ processed_scenes += 1;
192
+ emitProgress();
193
+ if (interSceneDelayMs > 0) {
194
+ await delay(interSceneDelayMs);
195
+ }
196
+ }
197
+ }
198
+
199
+ const warnings = [];
200
+ if (failed_scenes > 0) {
201
+ warnings.push("PARTIAL_SUCCESS: one or more scenes failed to process.");
202
+ }
203
+ if (!project_exists && targetScenes.length === 0) {
204
+ warnings.push(`PROJECT_NOT_FOUND_WARNING: project '${project_id}' was not found; nothing to process.`);
205
+ }
206
+
207
+ return {
208
+ ok: true,
209
+ cancelled: Boolean(typeof shouldCancel === "function" && shouldCancel() && processed_scenes < targetScenes.length),
210
+ project_id,
211
+ dry_run: Boolean(dry_run),
212
+ total_scenes: targetScenes.length,
213
+ processed_scenes,
214
+ scenes_changed,
215
+ failed_scenes,
216
+ links_added,
217
+ links_removed,
218
+ results,
219
+ ...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}),
220
+ };
221
+ }
@@ -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
+ });