@hanna84/mcp-writing 2.12.7 → 2.12.9

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Generates docs/tools.md from tool definitions in the runtime entrypoint
4
- * (src/index.js when present, otherwise index.js) and tools/*.js.
4
+ * (src/index.js when present, otherwise index.js) and src/tools/*.js.
5
5
  *
6
6
  * Run: node scripts/generate-tool-docs.mjs
7
7
  * or: npm run docs
@@ -18,15 +18,19 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
18
  const OUT = path.join(ROOT, 'docs', 'tools.md');
19
19
 
20
20
  // Build a map from each registration function name to its module source.
21
- // e.g. "registerSyncTools" -> contents of tools/sync.js
21
+ // e.g. "registerSyncTools" -> contents of src/tools/sync.js
22
22
  const toolModuleMap = new Map();
23
- try {
24
- for (const f of readdirSync(path.join(ROOT, 'tools')).filter(f => f.endsWith('.js')).sort()) {
25
- const content = readFileSync(path.join(ROOT, 'tools', f), 'utf8');
26
- const fnName = (content.match(/export function (\w+)\s*\(/) ?? [])[1];
27
- if (fnName) toolModuleMap.set(fnName, content);
23
+ for (const dir of [path.join(ROOT, 'src', 'tools'), path.join(ROOT, 'tools')]) {
24
+ try {
25
+ for (const f of readdirSync(dir).filter(f => f.endsWith('.js')).sort()) {
26
+ const content = readFileSync(path.join(dir, f), 'utf8');
27
+ const fnName = (content.match(/export function (\w+)\s*\(/) ?? [])[1];
28
+ if (fnName && !toolModuleMap.has(fnName)) toolModuleMap.set(fnName, content);
29
+ }
30
+ } catch {
31
+ // Directory may not exist during early migration phases.
28
32
  }
29
- } catch { /* tools/ not yet created */ }
33
+ }
30
34
 
31
35
  // Inline each register*Tools(s, ...) call with the module source so that
32
36
  // s.tool() blocks appear in registration order (matching createMcpServer()).
@@ -14,7 +14,7 @@ import { isGitRepository, getHeadCommitHash } from "../src/core/git.js";
14
14
  import {
15
15
  buildReviewBundlePlan,
16
16
  createReviewBundleArtifacts,
17
- } from "../review-bundles.js";
17
+ } from "../src/review-bundles/review-bundles.js";
18
18
 
19
19
  const PROJECT_SYNC_DIR = process.env.WRITING_SYNC_DIR ?? process.argv[2] ?? null;
20
20
  const DB_PATH = process.env.DB_PATH ?? (PROJECT_SYNC_DIR ? path.join(PROJECT_SYNC_DIR, ".mcp", "writing.db") : null);
package/src/index.js CHANGED
@@ -18,14 +18,14 @@ import {
18
18
  readEntityMetadata,
19
19
  resolveBatchTargetScenes,
20
20
  } from "../helpers.js";
21
- import { STYLEGUIDE_CONFIG_BASENAME } from "../prose-styleguide.js";
22
- import { registerSyncTools } from "../tools/sync.js";
23
- import { registerSearchTools } from "../tools/search.js";
24
- import { registerMetadataTools } from "../tools/metadata.js";
25
- import { registerReviewBundleTools } from "../tools/review-bundles.js";
26
- import { registerStyleguideTools } from "../tools/styleguide.js";
27
- import { registerEditingTools } from "../tools/editing.js";
28
- import { WORKFLOW_CATALOGUE } from "../workflow-catalogue.js";
21
+ import { STYLEGUIDE_CONFIG_BASENAME } from "./styleguide/prose-styleguide.js";
22
+ import { registerSyncTools } from "./tools/sync.js";
23
+ import { registerSearchTools } from "./tools/search.js";
24
+ import { registerMetadataTools } from "./tools/metadata.js";
25
+ import { registerReviewBundleTools } from "./tools/review-bundles.js";
26
+ import { registerStyleguideTools } from "./tools/styleguide.js";
27
+ import { registerEditingTools } from "./tools/editing.js";
28
+ import { WORKFLOW_CATALOGUE } from "./workflows/workflow-catalogue.js";
29
29
  import { getRuntimeDiagnostics } from "./runtime/runtime-diagnostics.js";
30
30
 
31
31
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
@@ -365,7 +365,7 @@ function createMcpServer() {
365
365
  }
366
366
  );
367
367
 
368
- // Passed to each tool registration module (tools/*.js) to thread state and
368
+ // Passed to each tool registration module (src/tools/*.js) to thread state and
369
369
  // shared helpers without circular imports. Grows as groups are extracted.
370
370
  const toolContext = {
371
371
  db,
@@ -0,0 +1,345 @@
1
+ const MAX_SORT_VALUE = Number.MAX_SAFE_INTEGER;
2
+ const MAX_SCENE_ID_FILTER_PARAMS = 900;
3
+
4
+ export const REVIEW_BUNDLE_PROFILES = ["outline_discussion", "editor_detailed", "beta_reader_personalized"];
5
+ export const REVIEW_BUNDLE_STRICTNESS = ["warn", "fail"];
6
+ export const REVIEW_BUNDLE_FORMATS = ["pdf", "markdown", "both"];
7
+
8
+ export class ReviewBundlePlanError extends Error {
9
+ constructor(code, message, details) {
10
+ super(message);
11
+ this.name = "ReviewBundlePlanError";
12
+ this.code = code;
13
+ this.details = details;
14
+ }
15
+ }
16
+
17
+ export function normalizeRecipientDisplayName(recipientName) {
18
+ const normalized = String(recipientName ?? "")
19
+ .replace(/[\x00-\x1f\x7f]+/g, " ") // eslint-disable-line no-control-regex
20
+ .replace(/\s+/g, " ")
21
+ .trim()
22
+ .slice(0, 100);
23
+
24
+ return normalized || "Beta Reader";
25
+ }
26
+
27
+ function normalizeSortNumber(value) {
28
+ return Number.isInteger(value) ? value : MAX_SORT_VALUE;
29
+ }
30
+
31
+ function sceneSort(a, b) {
32
+ const partDiff = normalizeSortNumber(a.part) - normalizeSortNumber(b.part);
33
+ if (partDiff !== 0) return partDiff;
34
+
35
+ const chapterDiff = normalizeSortNumber(a.chapter) - normalizeSortNumber(b.chapter);
36
+ if (chapterDiff !== 0) return chapterDiff;
37
+
38
+ const timelineDiff = normalizeSortNumber(a.timeline_position) - normalizeSortNumber(b.timeline_position);
39
+ if (timelineDiff !== 0) return timelineDiff;
40
+
41
+ return String(a.scene_id).localeCompare(String(b.scene_id));
42
+ }
43
+
44
+ function buildWarningSummary(warnings) {
45
+ const summary = {};
46
+ for (const warning of warnings) {
47
+ const type = warning.type ?? "unknown";
48
+ if (!summary[type]) {
49
+ summary[type] = { count: 0, examples: [] };
50
+ }
51
+ summary[type].count += 1;
52
+ if (summary[type].examples.length < 5) {
53
+ summary[type].examples.push(warning.message);
54
+ }
55
+ }
56
+ return summary;
57
+ }
58
+
59
+ function slugifyBundleName(value) {
60
+ const slug = String(value ?? "")
61
+ .trim()
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, "-")
64
+ .replace(/^-+|-+$/g, "");
65
+ return slug || "review-bundle";
66
+ }
67
+
68
+ function assertProfile(profile) {
69
+ if (!REVIEW_BUNDLE_PROFILES.includes(profile)) {
70
+ throw new ReviewBundlePlanError(
71
+ "INVALID_PROFILE",
72
+ `Unsupported review bundle profile '${profile}'.`,
73
+ { supported_profiles: REVIEW_BUNDLE_PROFILES }
74
+ );
75
+ }
76
+ }
77
+
78
+ function assertStrictness(strictness) {
79
+ if (!REVIEW_BUNDLE_STRICTNESS.includes(strictness)) {
80
+ throw new ReviewBundlePlanError(
81
+ "INVALID_STRICTNESS",
82
+ `Unsupported strictness '${strictness}'.`,
83
+ { supported_strictness: REVIEW_BUNDLE_STRICTNESS }
84
+ );
85
+ }
86
+ }
87
+
88
+ function assertFormat(format) {
89
+ if (!REVIEW_BUNDLE_FORMATS.includes(format)) {
90
+ throw new ReviewBundlePlanError(
91
+ "INVALID_FORMAT",
92
+ `Unsupported format '${format}'.`,
93
+ { supported_formats: REVIEW_BUNDLE_FORMATS }
94
+ );
95
+ }
96
+ }
97
+
98
+ function resolveRequestedSceneIds(dbHandle, projectId, sceneIds) {
99
+ if (!Array.isArray(sceneIds) || sceneIds.length === 0) {
100
+ return { requested: [], existing: new Set() };
101
+ }
102
+
103
+ const existing = new Set();
104
+
105
+ for (let i = 0; i < sceneIds.length; i += MAX_SCENE_ID_FILTER_PARAMS) {
106
+ const chunk = sceneIds.slice(i, i + MAX_SCENE_ID_FILTER_PARAMS);
107
+ const placeholders = chunk.map(() => "?").join(",");
108
+ const rows = dbHandle.prepare(
109
+ `SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
110
+ ).all(projectId, ...chunk);
111
+ for (const row of rows) {
112
+ existing.add(row.scene_id);
113
+ }
114
+ }
115
+
116
+ return {
117
+ requested: sceneIds,
118
+ existing,
119
+ };
120
+ }
121
+
122
+ export function buildReviewBundlePlan(dbHandle, {
123
+ project_id,
124
+ profile,
125
+ part,
126
+ chapter,
127
+ tag,
128
+ scene_ids,
129
+ strictness = "warn",
130
+ include_scene_ids = true,
131
+ include_metadata_sidebar = false,
132
+ include_paragraph_anchors = false,
133
+ bundle_name,
134
+ recipient_name,
135
+ format = "pdf",
136
+ } = {}) {
137
+ if (!project_id) {
138
+ throw new ReviewBundlePlanError("INVALID_PROJECT_ID", "project_id is required.");
139
+ }
140
+
141
+ if (Array.isArray(scene_ids) && scene_ids.length > MAX_SCENE_ID_FILTER_PARAMS) {
142
+ throw new ReviewBundlePlanError(
143
+ "SCENE_IDS_TOO_LARGE",
144
+ `scene_ids supports at most ${MAX_SCENE_ID_FILTER_PARAMS} entries per request.`,
145
+ {
146
+ max_scene_ids: MAX_SCENE_ID_FILTER_PARAMS,
147
+ received_scene_ids: scene_ids.length,
148
+ }
149
+ );
150
+ }
151
+
152
+ assertProfile(profile);
153
+ assertStrictness(strictness);
154
+ assertFormat(format);
155
+
156
+ const projectRow = dbHandle.prepare(`SELECT project_id FROM projects WHERE project_id = ?`).get(project_id);
157
+ if (!projectRow) {
158
+ throw new ReviewBundlePlanError("NOT_FOUND", `Project '${project_id}' not found.`);
159
+ }
160
+
161
+ const requestedSceneIds = resolveRequestedSceneIds(dbHandle, project_id, scene_ids);
162
+ const conditions = ["s.project_id = ?"];
163
+ const params = [project_id];
164
+ const joins = [];
165
+
166
+ if (tag) {
167
+ joins.push("JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?");
168
+ params.push(tag);
169
+ }
170
+ if (Array.isArray(scene_ids) && scene_ids.length > 0) {
171
+ const placeholders = scene_ids.map(() => "?").join(",");
172
+ conditions.push(`s.scene_id IN (${placeholders})`);
173
+ params.push(...scene_ids);
174
+ }
175
+ if (part !== undefined) {
176
+ conditions.push("s.part = ?");
177
+ params.push(part);
178
+ }
179
+ if (chapter !== undefined) {
180
+ conditions.push("s.chapter = ?");
181
+ params.push(chapter);
182
+ }
183
+
184
+ let query = `
185
+ SELECT DISTINCT
186
+ s.scene_id,
187
+ s.project_id,
188
+ s.title,
189
+ s.part,
190
+ s.chapter,
191
+ s.timeline_position,
192
+ s.word_count,
193
+ s.logline,
194
+ s.pov,
195
+ s.save_the_cat_beat,
196
+ s.metadata_stale
197
+ FROM scenes s
198
+ `;
199
+
200
+ if (joins.length > 0) {
201
+ query += ` ${joins.join(" ")}`;
202
+ }
203
+ query += ` WHERE ${conditions.join(" AND ")}`;
204
+
205
+ const rows = dbHandle.prepare(query).all(...params).sort(sceneSort);
206
+ if (rows.length === 0) {
207
+ throw new ReviewBundlePlanError(
208
+ "NO_RESULTS",
209
+ "No scenes matched the requested review bundle scope.",
210
+ {
211
+ project_id,
212
+ filters: {
213
+ ...(part !== undefined ? { part } : {}),
214
+ ...(chapter !== undefined ? { chapter } : {}),
215
+ ...(tag ? { tag } : {}),
216
+ ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
217
+ },
218
+ }
219
+ );
220
+ }
221
+
222
+ const includedSceneIds = new Set(rows.map(row => row.scene_id));
223
+ const excludedSceneIds = requestedSceneIds.requested.filter(sceneId => !includedSceneIds.has(sceneId));
224
+ const notFoundSceneIds = requestedSceneIds.requested.filter(sceneId => !requestedSceneIds.existing.has(sceneId));
225
+ const filteredOutSceneIds = excludedSceneIds.filter(sceneId => requestedSceneIds.existing.has(sceneId));
226
+
227
+ const warnings = [];
228
+
229
+ if (notFoundSceneIds.length > 0) {
230
+ warnings.push({
231
+ type: "requested_scene_ids_not_found",
232
+ message: `${notFoundSceneIds.length} requested scene_id value(s) do not exist in project '${project_id}'.`,
233
+ scene_ids: notFoundSceneIds,
234
+ });
235
+ }
236
+
237
+ if (filteredOutSceneIds.length > 0) {
238
+ warnings.push({
239
+ type: "requested_scene_ids_filtered_out",
240
+ message: `${filteredOutSceneIds.length} requested scene_id value(s) were excluded by additional filters.`,
241
+ scene_ids: filteredOutSceneIds,
242
+ });
243
+ }
244
+
245
+ const staleRows = rows.filter(row => Number(row.metadata_stale) === 1);
246
+ if (staleRows.length > 0) {
247
+ warnings.push({
248
+ type: "metadata_stale",
249
+ message: `${staleRows.length} scene(s) have stale metadata and may need re-enrichment before editorial use.`,
250
+ count: staleRows.length,
251
+ });
252
+ }
253
+
254
+ const missingOrderingRows = rows.filter(
255
+ row => row.part == null || row.chapter == null || row.timeline_position == null
256
+ );
257
+ if (missingOrderingRows.length > 0) {
258
+ warnings.push({
259
+ type: "missing_ordering_fields",
260
+ message: `${missingOrderingRows.length} scene(s) are missing part/chapter/timeline_position metadata; fallback ordering was applied.`,
261
+ count: missingOrderingRows.length,
262
+ });
263
+ }
264
+
265
+ const missingWordCountRows = rows.filter(row => row.word_count == null);
266
+ if (missingWordCountRows.length > 0) {
267
+ warnings.push({
268
+ type: "missing_word_count",
269
+ message: `${missingWordCountRows.length} scene(s) are missing word_count; estimated_word_count may be low.`,
270
+ count: missingWordCountRows.length,
271
+ });
272
+ }
273
+
274
+ const blockers = [];
275
+ if (strictness === "fail" && staleRows.length > 0) {
276
+ blockers.push({
277
+ code: "STALE_METADATA",
278
+ message: `${staleRows.length} scene(s) are marked metadata_stale.`,
279
+ scene_ids: staleRows.map(row => row.scene_id),
280
+ });
281
+ }
282
+
283
+ const estimatedWordCount = rows.reduce((sum, row) => {
284
+ const count = Number(row.word_count);
285
+ return sum + (Number.isFinite(count) ? count : 0);
286
+ }, 0);
287
+ const resolvedRecipientName = profile === "beta_reader_personalized"
288
+ ? normalizeRecipientDisplayName(recipient_name)
289
+ : undefined;
290
+
291
+ const safeBundleName = slugifyBundleName(bundle_name || `${project_id}-${profile}`);
292
+ const appliedFilters = {
293
+ ...(part !== undefined ? { part } : {}),
294
+ ...(chapter !== undefined ? { chapter } : {}),
295
+ ...(tag ? { tag } : {}),
296
+ ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
297
+ };
298
+
299
+ return {
300
+ ok: true,
301
+ profile,
302
+ resolved_scope: {
303
+ project_id,
304
+ filters: appliedFilters,
305
+ options: {
306
+ include_scene_ids: Boolean(include_scene_ids),
307
+ include_metadata_sidebar: Boolean(include_metadata_sidebar),
308
+ include_paragraph_anchors: Boolean(include_paragraph_anchors),
309
+ ...(resolvedRecipientName ? { recipient_name: resolvedRecipientName } : {}),
310
+ },
311
+ },
312
+ ordering: rows.map(row => ({
313
+ scene_id: row.scene_id,
314
+ project_id: row.project_id,
315
+ title: row.title,
316
+ part: row.part,
317
+ chapter: row.chapter,
318
+ timeline_position: row.timeline_position,
319
+ metadata_stale: Number(row.metadata_stale) === 1,
320
+ })),
321
+ summary: {
322
+ scene_count: rows.length,
323
+ estimated_word_count: estimatedWordCount,
324
+ excluded_scene_ids: excludedSceneIds,
325
+ },
326
+ warnings,
327
+ warning_summary: buildWarningSummary(warnings),
328
+ strictness_result: {
329
+ strictness,
330
+ can_proceed: blockers.length === 0,
331
+ blockers,
332
+ },
333
+ planned_outputs: [
334
+ ...(format === "markdown" || format === "both" ? [`${safeBundleName}.md`] : []),
335
+ ...(format === "pdf" || format === "both" ? [`${safeBundleName}.pdf`] : []),
336
+ ...(profile === "beta_reader_personalized"
337
+ ? [
338
+ `${safeBundleName}.notice.md`,
339
+ `${safeBundleName}.feedback-form.md`,
340
+ ]
341
+ : []),
342
+ `${safeBundleName}.manifest.json`,
343
+ ],
344
+ };
345
+ }