@hanna84/mcp-writing 2.9.1 → 2.9.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.9.1",
3
+ "version": "2.9.4",
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",
@@ -21,6 +21,7 @@
21
21
  "prose-styleguide-drift.js",
22
22
  "prose-styleguide-skill.js",
23
23
  "scripts/",
24
+ "tools/",
24
25
  "README.md",
25
26
  "CHANGELOG.md"
26
27
  ],
@@ -38,12 +39,12 @@
38
39
  "normalize:scene-characters": "node --experimental-sqlite scripts/normalize-scene-characters.mjs",
39
40
  "setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
40
41
  "release": "release-it",
41
- "lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
42
+ "lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/ tools/",
42
43
  "docs": "node scripts/generate-tool-docs.mjs",
43
44
  "lint:metadata": "node scripts/lint-metadata.mjs",
44
- "test:unit": "node --experimental-sqlite --test test/unit.test.mjs",
45
- "test:integration": "node --experimental-sqlite --test test/integration.test.mjs",
46
- "test": "node --experimental-sqlite --test test/unit.test.mjs test/integration.test.mjs"
45
+ "test:unit": "node --experimental-sqlite --test test/unit/*.test.mjs",
46
+ "test:integration": "node --experimental-sqlite --test --test-concurrency=1 test/integration/*.test.mjs",
47
+ "test": "npm run test:unit && npm run test:integration"
47
48
  },
48
49
  "keywords": [
49
50
  "mcp",
@@ -1,23 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Generates docs/tools.md from tool definitions in index.js.
3
+ * Generates docs/tools.md from tool definitions in index.js and tools/*.js.
4
4
  *
5
5
  * Run: node scripts/generate-tool-docs.mjs
6
6
  * or: npm run docs
7
7
  *
8
8
  * The output is the single source of truth for the tool reference.
9
- * Re-run after editing tool names, descriptions, or parameters in index.js.
9
+ * Re-run after editing tool names, descriptions, or parameters.
10
10
  */
11
11
 
12
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
12
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
16
16
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
- const SRC = path.join(ROOT, 'index.js');
18
17
  const OUT = path.join(ROOT, 'docs', 'tools.md');
19
18
 
20
- const source = readFileSync(SRC, 'utf8');
19
+ // Build a map from each registration function name to its module source.
20
+ // e.g. "registerSyncTools" -> contents of tools/sync.js
21
+ const toolModuleMap = new Map();
22
+ try {
23
+ for (const f of readdirSync(path.join(ROOT, 'tools')).filter(f => f.endsWith('.js')).sort()) {
24
+ const content = readFileSync(path.join(ROOT, 'tools', f), 'utf8');
25
+ const fnName = (content.match(/export function (\w+)\s*\(/) ?? [])[1];
26
+ if (fnName) toolModuleMap.set(fnName, content);
27
+ }
28
+ } catch { /* tools/ not yet created */ }
29
+
30
+ // Inline each register*Tools(s, ...) call with the module source so that
31
+ // s.tool() blocks appear in registration order (matching createMcpServer()).
32
+ let source = readFileSync(path.join(ROOT, 'index.js'), 'utf8');
33
+ for (const [fnName, content] of toolModuleMap) {
34
+ source = source.replace(new RegExp(`[ \\t]*${fnName}\\s*\\([\\s\\S]*?\\);`), content);
35
+ }
21
36
 
22
37
  function decodeEscape(src, i) {
23
38
  const esc = src[i];
@@ -0,0 +1,528 @@
1
+ import { z } from "zod";
2
+ import fs from "node:fs";
3
+ import matter from "gray-matter";
4
+
5
+ export function registerSearchTools(s, {
6
+ db,
7
+ SYNC_DIR,
8
+ GIT_ENABLED,
9
+ errorResponse,
10
+ paginateRows,
11
+ DEFAULT_METADATA_PAGE_SIZE,
12
+ MAX_CHAPTER_SCENES,
13
+ getSceneProseAtCommit,
14
+ readSupportingNotesForEntity,
15
+ readEntityMetadata,
16
+ }) {
17
+ // ---- find_scenes ---------------------------------------------------------
18
+ s.tool(
19
+ "find_scenes",
20
+ "Find scenes by filtering on character, Save the Cat beat, tags, part, chapter, or POV. Returns ordered scene metadata only — no prose. All filters are optional and combinable. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Warns if any matching scenes have stale metadata.",
21
+ {
22
+ project_id: z.string().optional().describe("Project ID (e.g. 'the-lamb'). Use to scope results to one project."),
23
+ character: z.string().optional().describe("A character_id (e.g. 'char-mira-nystrom'). Returns only scenes that character appears in. Use list_characters first to find valid IDs."),
24
+ beat: z.string().optional().describe("Save the Cat beat name (e.g. 'Opening Image'). Exact match."),
25
+ tag: z.string().optional().describe("Scene tag to filter by. Exact match."),
26
+ part: z.number().int().optional().describe("Part number (integer, e.g. 1). Chapters are numbered globally across the whole project."),
27
+ chapter: z.number().int().optional().describe("Chapter number (integer, e.g. 3). Chapters are numbered globally across the whole project — do not reset per part."),
28
+ pov: z.string().optional().describe("POV character_id. Use list_characters first to find valid IDs."),
29
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
30
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
31
+ },
32
+ async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
33
+ let query = `
34
+ SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.chapter_title, s.pov,
35
+ s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
36
+ s.save_the_cat_beat, s.timeline_position, s.story_time,
37
+ s.word_count, s.metadata_stale
38
+ FROM scenes s
39
+ `;
40
+ const joins = [];
41
+ const conditions = [];
42
+ const params = [];
43
+
44
+ if (character) {
45
+ joins.push(`JOIN scene_characters sc ON sc.scene_id = s.scene_id AND sc.character_id = ?`);
46
+ params.push(character);
47
+ }
48
+ if (tag) {
49
+ joins.push(`JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?`);
50
+ params.push(tag);
51
+ }
52
+ if (project_id) { conditions.push(`s.project_id = ?`); params.push(project_id); }
53
+ if (beat) { conditions.push(`s.save_the_cat_beat = ?`); params.push(beat); }
54
+ if (part) { conditions.push(`s.part = ?`); params.push(part); }
55
+ if (chapter) { conditions.push(`s.chapter = ?`); params.push(chapter); }
56
+ if (pov) { conditions.push(`s.pov = ?`); params.push(pov); }
57
+
58
+ if (joins.length) query += " " + joins.join(" ");
59
+ if (conditions.length) query += " WHERE " + conditions.join(" AND ");
60
+ query += " ORDER BY s.part, s.chapter, s.timeline_position";
61
+
62
+ const rows = db.prepare(query).all(...params);
63
+ if (rows.length === 0) {
64
+ return errorResponse("NO_RESULTS", "No scenes match the given filters. Hint: broaden filters or call search_metadata with a keyword first.");
65
+ }
66
+
67
+ const staleCount = rows.filter(r => r.metadata_stale).length;
68
+ const warning = staleCount > 0
69
+ ? `${staleCount} scene(s) have stale metadata — prose has changed since last enrichment. Consider running enrich_scene() before relying on this data for analysis.`
70
+ : undefined;
71
+
72
+ const paged = paginateRows(rows, {
73
+ page,
74
+ pageSize: page_size,
75
+ forcePagination: rows.length > DEFAULT_METADATA_PAGE_SIZE,
76
+ });
77
+
78
+ const payload = paged.paginated
79
+ ? {
80
+ results: paged.rows,
81
+ ...paged.meta,
82
+ warning,
83
+ }
84
+ : rows;
85
+
86
+ return {
87
+ content: [{
88
+ type: "text",
89
+ text: JSON.stringify(payload, null, 2),
90
+ }],
91
+ };
92
+ }
93
+ );
94
+
95
+ // ---- get_scene_prose -----------------------------------------------------
96
+ s.tool(
97
+ "get_scene_prose",
98
+ "Load the full prose text of a single scene. Use this for close reading, continuity checks, or when you need the actual writing. For overview or filtering, use find_scenes instead — it is much cheaper. Optionally retrieve a past version from git history.",
99
+ {
100
+ scene_id: z.string().describe("The scene_id to retrieve (e.g. 'sc-001-prologue'). Get this from find_scenes or get_arc."),
101
+ commit: z.string().optional().describe("Optional git commit hash to retrieve a past version. Use list_snapshots to find valid hashes. If omitted, returns the current prose."),
102
+ },
103
+ async ({ scene_id, commit }) => {
104
+ const scene = db.prepare(`SELECT file_path, metadata_stale FROM scenes WHERE scene_id = ?`).get(scene_id);
105
+ if (!scene) {
106
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Run sync() if you just added it.`);
107
+ }
108
+ try {
109
+ let rawContent;
110
+ if (commit && GIT_ENABLED) {
111
+ rawContent = getSceneProseAtCommit(SYNC_DIR, scene.file_path, commit);
112
+ } else if (commit && !GIT_ENABLED) {
113
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — cannot retrieve historical versions.");
114
+ } else {
115
+ rawContent = fs.readFileSync(scene.file_path, "utf8");
116
+ }
117
+
118
+ const { content: prose } = matter(rawContent);
119
+ const versionNote = commit ? `\n\n(Retrieved from commit: ${commit})` : "";
120
+ const warning = scene.metadata_stale && !commit
121
+ ? `\n\n⚠️ Metadata for this scene may be stale — prose has changed since last enrichment.`
122
+ : "";
123
+ return { content: [{ type: "text", text: prose.trim() + versionNote + warning }] };
124
+ } catch (err) {
125
+ if (err.code === "ENOENT") {
126
+ return errorResponse(
127
+ "STALE_PATH",
128
+ `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved since the last sync. Run sync() to refresh the index.`,
129
+ { indexed_path: scene.file_path }
130
+ );
131
+ }
132
+ return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
133
+ }
134
+ }
135
+ );
136
+
137
+ // ---- get_chapter_prose ---------------------------------------------------
138
+ s.tool(
139
+ "get_chapter_prose",
140
+ `Load the full prose for every scene in a chapter, concatenated in order. Expensive — only use when you need to read an entire chapter. Capped at ${MAX_CHAPTER_SCENES} scenes. Use find_scenes first to confirm the chapter exists.`,
141
+ {
142
+ project_id: z.string().describe("Project ID (e.g. 'the-lamb')."),
143
+ part: z.number().int().describe("Part number (integer)."),
144
+ chapter: z.number().int().describe("Chapter number (integer, globally numbered across the whole project)."),
145
+ },
146
+ async ({ project_id, part, chapter }) => {
147
+ const allScenes = db.prepare(`
148
+ SELECT scene_id, title, file_path FROM scenes
149
+ WHERE project_id = ? AND part = ? AND chapter = ?
150
+ ORDER BY timeline_position
151
+ `).all(project_id, part, chapter);
152
+
153
+ if (allScenes.length === 0) {
154
+ return errorResponse("NO_RESULTS", `No scenes found for Part ${part}, Chapter ${chapter}.`);
155
+ }
156
+
157
+ const truncated = allScenes.length > MAX_CHAPTER_SCENES;
158
+ const scenes = truncated ? allScenes.slice(0, MAX_CHAPTER_SCENES) : allScenes;
159
+
160
+ const parts = [];
161
+ for (const scene of scenes) {
162
+ try {
163
+ const raw = fs.readFileSync(scene.file_path, "utf8");
164
+ const { content: prose } = matter(raw);
165
+ parts.push(`## ${scene.title ?? scene.scene_id}\n\n${prose.trim()}`);
166
+ } catch (err) {
167
+ parts.push(`## ${scene.scene_id}\n\n[Error reading file: ${err.message}]`);
168
+ }
169
+ }
170
+
171
+ const warning = truncated
172
+ ? `\n\n⚠️ Chapter has ${allScenes.length} scenes — only the first ${MAX_CHAPTER_SCENES} were loaded. Set MAX_CHAPTER_SCENES to increase this limit.`
173
+ : "";
174
+ return { content: [{ type: "text", text: parts.join("\n\n---\n\n") + warning }] };
175
+ }
176
+ );
177
+
178
+ // ---- get_arc -------------------------------------------------------------
179
+ s.tool(
180
+ "get_arc",
181
+ "Get every scene a character appears in, ordered by part/chapter/position. Returns scene metadata only — no prose. Use this to trace a character's arc through the story. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Call list_characters first to get the character_id.",
182
+ {
183
+ character_id: z.string().describe("The character_id to trace (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
184
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
185
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
186
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
187
+ },
188
+ async ({ character_id, project_id, page, page_size }) => {
189
+ let query = `
190
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
191
+ s.scene_change, s.causality, s.stakes, s.scene_functions,
192
+ s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
193
+ FROM scenes s
194
+ JOIN scene_characters sc ON sc.scene_id = s.scene_id
195
+ WHERE sc.character_id = ?
196
+ `;
197
+ const params = [character_id];
198
+ if (project_id) { query += ` AND s.project_id = ?`; params.push(project_id); }
199
+ query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
200
+
201
+ const rows = db.prepare(query).all(...params);
202
+ if (rows.length === 0) {
203
+ return errorResponse("NO_RESULTS", `No scenes found for character '${character_id}'.`);
204
+ }
205
+
206
+ const staleCount = rows.filter(r => r.metadata_stale).length;
207
+ const warning = staleCount > 0
208
+ ? `${staleCount} scene(s) have stale metadata.`
209
+ : undefined;
210
+
211
+ const paged = paginateRows(rows, {
212
+ page,
213
+ pageSize: page_size,
214
+ forcePagination: rows.length > DEFAULT_METADATA_PAGE_SIZE,
215
+ });
216
+
217
+ const payload = paged.paginated
218
+ ? {
219
+ results: paged.rows,
220
+ ...paged.meta,
221
+ warning,
222
+ }
223
+ : rows;
224
+
225
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
226
+ }
227
+ );
228
+
229
+ // ---- list_characters -----------------------------------------------------
230
+ s.tool(
231
+ "list_characters",
232
+ "List all indexed characters with their character_id, name, role, and arc_summary. Call this first whenever you need to filter scenes by character or look up a character sheet — it gives you the character_id values required by other tools.",
233
+ {
234
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
235
+ universe_id: z.string().optional().describe("Limit to a specific universe (if using cross-project world-building)."),
236
+ },
237
+ async ({ project_id, universe_id }) => {
238
+ let query = `SELECT character_id, name, role, arc_summary, project_id, universe_id FROM characters`;
239
+ const conditions = [];
240
+ const params = [];
241
+ if (project_id) { conditions.push(`project_id = ?`); params.push(project_id); }
242
+ if (universe_id) { conditions.push(`universe_id = ?`); params.push(universe_id); }
243
+ if (conditions.length) query += " WHERE " + conditions.join(" AND ");
244
+ query += " ORDER BY name";
245
+
246
+ const rows = db.prepare(query).all(...params);
247
+ if (rows.length === 0) {
248
+ return errorResponse("NO_RESULTS", "No characters found.");
249
+ }
250
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
251
+ }
252
+ );
253
+
254
+ // ---- get_character_sheet -------------------------------------------------
255
+ s.tool(
256
+ "get_character_sheet",
257
+ "Get full character details: role, arc_summary, traits, the canonical sheet content, and any adjacent support notes when the character uses a folder-based layout. Use list_characters first to get the character_id.",
258
+ {
259
+ character_id: z.string().describe("The character_id to look up (e.g. 'char-sebastian'). Use list_characters to find valid IDs."),
260
+ },
261
+ async ({ character_id }) => {
262
+ const character = db.prepare(`SELECT * FROM characters WHERE character_id = ?`).get(character_id);
263
+ if (!character) {
264
+ return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
265
+ }
266
+
267
+ const traits = db.prepare(`SELECT trait FROM character_traits WHERE character_id = ?`)
268
+ .all(character_id).map(r => r.trait);
269
+
270
+ let notes = "";
271
+ let supportingNotes = [];
272
+ if (character.file_path) {
273
+ try {
274
+ const raw = fs.readFileSync(character.file_path, "utf8");
275
+ const { content } = matter(raw);
276
+ notes = content.trim();
277
+ supportingNotes = readSupportingNotesForEntity(character.file_path);
278
+ } catch { /* empty */ }
279
+ }
280
+
281
+ const result = {
282
+ ...character,
283
+ traits,
284
+ notes: notes || undefined,
285
+ supporting_notes: supportingNotes.length ? supportingNotes : undefined,
286
+ };
287
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
288
+ }
289
+ );
290
+
291
+ // ---- list_places ---------------------------------------------------------
292
+ s.tool(
293
+ "list_places",
294
+ "List all indexed places with their place_id and name. Use this to find place_id values for scene filtering or to get an overview of the story's locations.",
295
+ {
296
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
297
+ universe_id: z.string().optional().describe("Limit to a specific universe."),
298
+ },
299
+ async ({ project_id, universe_id }) => {
300
+ let query = `SELECT place_id, name, project_id, universe_id FROM places`;
301
+ const conditions = [];
302
+ const params = [];
303
+ if (project_id) { conditions.push(`project_id = ?`); params.push(project_id); }
304
+ if (universe_id) { conditions.push(`universe_id = ?`); params.push(universe_id); }
305
+ if (conditions.length) query += " WHERE " + conditions.join(" AND ");
306
+ query += " ORDER BY name";
307
+
308
+ const rows = db.prepare(query).all(...params);
309
+ if (rows.length === 0) {
310
+ return errorResponse("NO_RESULTS", "No places found.");
311
+ }
312
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
313
+ }
314
+ );
315
+
316
+ // ---- get_place_sheet -----------------------------------------------------
317
+ s.tool(
318
+ "get_place_sheet",
319
+ "Get full place details: associated_characters, tags, the canonical sheet content, and any adjacent support notes when the place uses a folder-based layout. Use list_places first to get the place_id.",
320
+ {
321
+ place_id: z.string().describe("The place_id to look up (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
322
+ },
323
+ async ({ place_id }) => {
324
+ const place = db.prepare(`SELECT * FROM places WHERE place_id = ?`).get(place_id);
325
+ if (!place) {
326
+ return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
327
+ }
328
+
329
+ let notes = "";
330
+ let supportingNotes = [];
331
+ let associatedCharacters = [];
332
+ let tags = [];
333
+
334
+ if (place.file_path) {
335
+ try {
336
+ const raw = fs.readFileSync(place.file_path, "utf8");
337
+ const { content } = matter(raw);
338
+ notes = content.trim();
339
+ supportingNotes = readSupportingNotesForEntity(place.file_path);
340
+
341
+ const meta = readEntityMetadata(place.file_path);
342
+ associatedCharacters = Array.isArray(meta.associated_characters) ? meta.associated_characters : [];
343
+ tags = Array.isArray(meta.tags) ? meta.tags : [];
344
+ } catch { /* empty */ }
345
+ }
346
+
347
+ const result = {
348
+ ...place,
349
+ associated_characters: associatedCharacters.length ? associatedCharacters : undefined,
350
+ tags: tags.length ? tags : undefined,
351
+ notes: notes || undefined,
352
+ supporting_notes: supportingNotes.length ? supportingNotes : undefined,
353
+ };
354
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
355
+ }
356
+ );
357
+
358
+ // ---- search_metadata -----------------------------------------------------
359
+ s.tool(
360
+ "search_metadata",
361
+ "Full-text search across scene titles, loglines (synopsis/logline text fields), and metadata keywords (tags/characters/places/versions). Use this when you don't know the exact scene_id or chapter but want to find scenes by topic, theme, or metadata keyword. Not a prose search — use get_scene_prose to read actual text. Supports pagination via page/page_size and auto-paginates large result sets with total_count.",
362
+ {
363
+ query: z.string().describe("Search terms (e.g. 'hospital' or 'Sebastian feeding'). FTS5 syntax supported."),
364
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
365
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
366
+ },
367
+ async ({ query, page, page_size }) => {
368
+ let totalCount;
369
+ try {
370
+ totalCount = db.prepare(`
371
+ SELECT COUNT(*) AS count
372
+ FROM scenes_fts f
373
+ JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
374
+ WHERE scenes_fts MATCH ?
375
+ `).get(query)?.count ?? 0;
376
+ } catch (err) {
377
+ return errorResponse("INVALID_QUERY", "Invalid search query syntax. Use plain keywords or quoted phrases.", { detail: err.message });
378
+ }
379
+
380
+ if (totalCount === 0) {
381
+ return errorResponse("NO_RESULTS", "No scenes matched the search query.");
382
+ }
383
+
384
+ const shouldPaginate = totalCount > DEFAULT_METADATA_PAGE_SIZE || page !== undefined || page_size !== undefined;
385
+
386
+ if (!shouldPaginate) {
387
+ const rows = db.prepare(`
388
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
389
+ FROM scenes_fts f
390
+ JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
391
+ WHERE scenes_fts MATCH ?
392
+ ORDER BY rank
393
+ `).all(query);
394
+
395
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
396
+ }
397
+
398
+ const safePageSize = Math.max(1, page_size ?? DEFAULT_METADATA_PAGE_SIZE);
399
+ const safePage = Math.max(1, page ?? 1);
400
+ const totalPages = Math.max(1, Math.ceil(totalCount / safePageSize));
401
+ const normalizedPage = Math.min(safePage, totalPages);
402
+ const offset = (normalizedPage - 1) * safePageSize;
403
+
404
+ const rows = db.prepare(`
405
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
406
+ FROM scenes_fts f
407
+ JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
408
+ WHERE scenes_fts MATCH ?
409
+ ORDER BY rank
410
+ LIMIT ? OFFSET ?
411
+ `).all(query, safePageSize, offset);
412
+
413
+ const payload = {
414
+ results: rows,
415
+ total_count: totalCount,
416
+ page: normalizedPage,
417
+ page_size: safePageSize,
418
+ total_pages: totalPages,
419
+ has_next_page: normalizedPage < totalPages,
420
+ has_prev_page: normalizedPage > 1,
421
+ };
422
+
423
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
424
+ }
425
+ );
426
+
427
+ // ---- list_threads --------------------------------------------------------
428
+ s.tool(
429
+ "list_threads",
430
+ "List all subplot/storyline threads for a project. Returns a structured JSON envelope with results and total_count. Use this to discover valid thread_id values before calling get_thread_arc or upsert_thread_link. Supports pagination via page/page_size.",
431
+ {
432
+ project_id: z.string().describe("Project ID."),
433
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
434
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
435
+ },
436
+ async ({ project_id, page, page_size }) => {
437
+ const rows = db.prepare(`SELECT * FROM threads WHERE project_id = ? ORDER BY name`).all(project_id);
438
+ const paged = paginateRows(rows, { page, pageSize: page_size, forcePagination: false });
439
+ const payload = paged.paginated
440
+ ? {
441
+ project_id,
442
+ results: paged.rows,
443
+ ...paged.meta,
444
+ }
445
+ : {
446
+ project_id,
447
+ results: rows,
448
+ total_count: rows.length,
449
+ };
450
+
451
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
452
+ }
453
+ );
454
+
455
+ // ---- get_thread_arc ------------------------------------------------------
456
+ s.tool(
457
+ "get_thread_arc",
458
+ "Get ordered scene metadata for all scenes belonging to a thread, including the per-thread beat. Returns a structured JSON envelope with thread metadata, results, and total_count. Use list_threads first to find a valid thread_id, then call get_scene_prose for close reading of specific scenes. Supports pagination via page/page_size.",
459
+ {
460
+ thread_id: z.string().describe("Thread ID."),
461
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
462
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
463
+ },
464
+ async ({ thread_id, page, page_size }) => {
465
+ const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
466
+ if (!thread) {
467
+ return errorResponse("NOT_FOUND", `Thread '${thread_id}' not found. Hint: call list_threads with project_id to get valid thread IDs.`);
468
+ }
469
+
470
+ const rows = db.prepare(`
471
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
472
+ st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
473
+ FROM scenes s
474
+ JOIN scene_threads st ON st.scene_id = s.scene_id AND st.thread_id = ?
475
+ ORDER BY s.part, s.chapter, s.timeline_position
476
+ `).all(thread_id);
477
+ const staleCount = rows.filter(r => r.metadata_stale).length;
478
+ const warning = staleCount > 0 ? `${staleCount} scene(s) have stale metadata.` : undefined;
479
+ const paged = paginateRows(rows, { page, pageSize: page_size, forcePagination: false });
480
+
481
+ const payload = paged.paginated
482
+ ? {
483
+ thread,
484
+ results: paged.rows,
485
+ ...paged.meta,
486
+ warning,
487
+ }
488
+ : {
489
+ thread,
490
+ results: rows,
491
+ total_count: rows.length,
492
+ warning,
493
+ };
494
+
495
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
496
+ }
497
+ );
498
+
499
+ // ---- get_relationship_arc ------------------------------------------------
500
+ s.tool(
501
+ "get_relationship_arc",
502
+ "Show how the relationship between two characters evolves across scenes, in order. Uses explicitly recorded relationship entries — returns nothing if no entries exist yet. Use list_characters to get character_id values.",
503
+ {
504
+ from_character: z.string().describe("character_id of the first character (e.g. 'char-sebastian')."),
505
+ to_character: z.string().describe("character_id of the second character (e.g. 'char-mira-nystrom')."),
506
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
507
+ },
508
+ async ({ from_character, to_character, project_id }) => {
509
+ let query = `
510
+ SELECT r.from_character, r.to_character, r.relationship_type, r.strength,
511
+ r.scene_id, r.note,
512
+ s.part, s.chapter, s.chapter_title, s.timeline_position, s.title AS scene_title
513
+ FROM character_relationships r
514
+ LEFT JOIN scenes s ON s.scene_id = r.scene_id
515
+ WHERE r.from_character = ? AND r.to_character = ?
516
+ `;
517
+ const params = [from_character, to_character];
518
+ if (project_id) { query += ` AND (s.project_id = ? OR r.scene_id IS NULL)`; params.push(project_id); }
519
+ query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
520
+
521
+ const rows = db.prepare(query).all(...params);
522
+ if (rows.length === 0) {
523
+ return errorResponse("NO_RESULTS", `No relationship data found between '${from_character}' and '${to_character}'.`);
524
+ }
525
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
526
+ }
527
+ );
528
+ }