@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 +20 -0
- package/index.js +15 -9
- package/package.json +3 -1
- package/review-bundles.js +201 -15
- package/scene-character-batch.js +221 -0
- 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.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.
|
|
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("
|
|
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
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
|
|
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
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|