@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 +20 -0
- package/index.js +23 -11
- package/package.json +3 -1
- package/review-bundles.js +306 -13
- package/scripts/manual/run_create_review_bundle.js +47 -6
- package/scripts/profile-review-bundles.mjs +233 -0
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.
|
|
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
|
|
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("
|
|
1362
|
-
include_metadata_sidebar: z.boolean().optional().describe("
|
|
1363
|
-
include_paragraph_anchors: z.boolean().optional().describe("
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|