@hanna84/mcp-writing 3.24.1 → 3.26.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 CHANGED
@@ -4,9 +4,23 @@ 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
+ #### [v3.26.0](https://github.com/hannasdev/mcp-writing/compare/v3.25.0...v3.26.0)
8
+
9
+ - feat: add canonical target resolver [`#236`](https://github.com/hannasdev/mcp-writing/pull/236)
10
+
11
+ #### [v3.25.0](https://github.com/hannasdev/mcp-writing/compare/v3.24.1...v3.25.0)
12
+
13
+ > 6 June 2026
14
+
15
+ - feat: clarify M1 workflow and relationship responses [`#235`](https://github.com/hannasdev/mcp-writing/pull/235)
16
+ - Release 3.25.0 [`45b7c47`](https://github.com/hannasdev/mcp-writing/commit/45b7c4782ca6d6e54d66a49bcee5ae40d3f1cd69)
17
+
7
18
  #### [v3.24.1](https://github.com/hannasdev/mcp-writing/compare/v3.24.0...v3.24.1)
8
19
 
20
+ > 6 June 2026
21
+
9
22
  - test: add relationship boundary release readiness coverage [`#234`](https://github.com/hannasdev/mcp-writing/pull/234)
23
+ - Release 3.24.1 [`158ff88`](https://github.com/hannasdev/mcp-writing/commit/158ff8817e7bb3aefc92b2c4c05c8c2b36c83ef3)
10
24
 
11
25
  #### [v3.24.0](https://github.com/hannasdev/mcp-writing/compare/v3.23.2...v3.24.0)
12
26
 
package/README.md CHANGED
@@ -58,7 +58,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
58
58
 
59
59
  ### `describe_workflows` surface redesign
60
60
 
61
- `describe_workflows` now exposes an outcome-first, discovery-first workflow map. This is a breaking change if your prompts or automation depend on previous workflow IDs or ordering.
61
+ `describe_workflows` now exposes an outcome-first, discovery-first workflow map. This was a breaking change if your prompts or automation depend on previous workflow IDs or ordering; the newer `recommended_next_actions` tier is additive and appears before the full catalogue.
62
62
 
63
63
  Update integrations using this mapping:
64
64
 
@@ -153,7 +153,7 @@ Goal: keep indexes accurate without manually re-tagging everything.
153
153
 
154
154
  1. After rewriting scenes, call `enrich_scene` to re-derive lightweight metadata from current prose.
155
155
  2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, status, and tags). It rejects scene `characters` and `places`; use `connect_character_place_evidence` when a scene proves paired sheet-backed character/place evidence, `connect_scene_character_evidence` for character-only evidence, and `connect_scene_place_evidence` for place-only evidence. Use `audit_relationship_metadata` for retained sidecar/frontmatter relationship fields. Use `list_chapters` plus `assign_scene_to_chapter` or `move_scene` for chapter placement and ordering.
156
- 3. Use `search_metadata` and `find_scenes` to verify scenes are discoverable under the expected filters.
156
+ 3. Use `search_metadata` for keyword/FTS metadata searches across indexed titles, loglines, tags, characters, places, and versions, and use `find_scenes` to verify scenes are discoverable under structured filters. After identifying likely scenes, use `get_scene_prose` for prose context.
157
157
 
158
158
  Outcome: your AI assistant can reliably find the right scenes without drifting from the manuscript.
159
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.24.1",
3
+ "version": "3.26.0",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -0,0 +1,405 @@
1
+ const DEFAULT_CANDIDATE_LIMIT = 5;
2
+ const HARD_CANDIDATE_LIMIT = 10;
3
+
4
+ const MATCH_GROUP_ORDER = new Map([
5
+ ["exact_id", 0],
6
+ ["case_insensitive_id", 1],
7
+ ["case_insensitive_name", 2],
8
+ ["case_insensitive_title", 2],
9
+ ["near_match_suggestion", 3],
10
+ ]);
11
+
12
+ function normalizeValue(value) {
13
+ return String(value ?? "").trim().toLowerCase();
14
+ }
15
+
16
+ function clampCandidateLimit(candidateLimit) {
17
+ if (!Number.isInteger(candidateLimit) || candidateLimit <= 0) {
18
+ return DEFAULT_CANDIDATE_LIMIT;
19
+ }
20
+ return Math.min(candidateLimit, HARD_CANDIDATE_LIMIT);
21
+ }
22
+
23
+ function getProjectUniverseId(db, projectId) {
24
+ return db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(projectId)?.universe_id ?? null;
25
+ }
26
+
27
+ function levenshteinDistance(left, right) {
28
+ if (left === right) return 0;
29
+ if (left.length === 0) return right.length;
30
+ if (right.length === 0) return left.length;
31
+
32
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
33
+ const current = Array.from({ length: right.length + 1 }, () => 0);
34
+
35
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
36
+ current[0] = leftIndex;
37
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
38
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
39
+ current[rightIndex] = Math.min(
40
+ current[rightIndex - 1] + 1,
41
+ previous[rightIndex] + 1,
42
+ previous[rightIndex - 1] + cost
43
+ );
44
+ }
45
+ for (let index = 0; index < previous.length; index += 1) {
46
+ previous[index] = current[index];
47
+ }
48
+ }
49
+
50
+ return previous[right.length];
51
+ }
52
+
53
+ function isNearMatch(input, value) {
54
+ const normalizedInput = normalizeValue(input);
55
+ const normalizedValue = normalizeValue(value);
56
+ if (!normalizedInput || !normalizedValue) return false;
57
+ if (normalizedInput.length < 3 || normalizedValue.length < 3) return false;
58
+ if (normalizedValue.includes(normalizedInput) || normalizedInput.includes(normalizedValue)) return true;
59
+
60
+ const distance = levenshteinDistance(normalizedInput, normalizedValue);
61
+ const threshold = Math.max(1, Math.floor(Math.max(normalizedInput.length, normalizedValue.length) / 4));
62
+ return distance <= threshold;
63
+ }
64
+
65
+ function sortCandidates(candidates) {
66
+ return [...candidates].sort((left, right) => {
67
+ const groupDelta = (MATCH_GROUP_ORDER.get(left.match_type) ?? 99) - (MATCH_GROUP_ORDER.get(right.match_type) ?? 99);
68
+ if (groupDelta !== 0) return groupDelta;
69
+
70
+ const labelDelta = normalizeValue(left.label).localeCompare(normalizeValue(right.label));
71
+ if (labelDelta !== 0) return labelDelta;
72
+
73
+ return left.id.localeCompare(right.id);
74
+ });
75
+ }
76
+
77
+ function capCandidates(candidates, candidateLimit) {
78
+ return sortCandidates(candidates).slice(0, clampCandidateLimit(candidateLimit));
79
+ }
80
+
81
+ function formatSceneCandidate(row, { matchedField, matchType }) {
82
+ return {
83
+ target_kind: "scene",
84
+ id: row.scene_id,
85
+ label: row.title || row.scene_id,
86
+ matched_field: matchedField,
87
+ match_type: matchType,
88
+ project_id: row.project_id,
89
+ context: {
90
+ project_id: row.project_id,
91
+ title: row.title ?? null,
92
+ chapter_id: row.chapter_id ?? null,
93
+ chapter_title: row.chapter_title ?? null,
94
+ timeline_position: row.timeline_position ?? null,
95
+ },
96
+ };
97
+ }
98
+
99
+ function formatCharacterCandidate(row, { matchedField, matchType }) {
100
+ return {
101
+ target_kind: "character",
102
+ id: row.character_id,
103
+ label: row.name || row.character_id,
104
+ matched_field: matchedField,
105
+ match_type: matchType,
106
+ project_id: row.project_id ?? null,
107
+ universe_id: row.universe_id ?? null,
108
+ context: {
109
+ role: row.role ?? null,
110
+ first_appearance: row.first_appearance ?? null,
111
+ },
112
+ };
113
+ }
114
+
115
+ function formatPlaceCandidate(row, { matchedField, matchType }) {
116
+ return {
117
+ target_kind: "place",
118
+ id: row.place_id,
119
+ label: row.name || row.place_id,
120
+ matched_field: matchedField,
121
+ match_type: matchType,
122
+ project_id: row.project_id ?? null,
123
+ universe_id: row.universe_id ?? null,
124
+ };
125
+ }
126
+
127
+ function buildResolvedFrom(argumentName, input, candidate) {
128
+ if (candidate.match_type === "exact_id") return undefined;
129
+ return {
130
+ [argumentName]: {
131
+ input,
132
+ matched_field: candidate.matched_field,
133
+ match_type: candidate.match_type,
134
+ id: candidate.id,
135
+ },
136
+ };
137
+ }
138
+
139
+ function nextStepForTargetKind(targetKind) {
140
+ if (targetKind === "scene") {
141
+ return "Use find_scenes with project_id to identify the canonical scene_id, then retry with the stable ID.";
142
+ }
143
+ if (targetKind === "character") {
144
+ return "Use list_characters to inspect candidate character_id values for this project or universe, then retry with the stable ID.";
145
+ }
146
+ return "Use list_places to inspect candidate place_id values for this project or universe, then retry with the stable ID.";
147
+ }
148
+
149
+ function buildResolutionFailure({ targetKind, input, projectId, universeId, candidates, candidateLimit }) {
150
+ const cappedCandidates = capCandidates(candidates, candidateLimit);
151
+ const details = {
152
+ lookup_kind: targetKind,
153
+ target_kind: targetKind,
154
+ input,
155
+ project_id: projectId,
156
+ ...(universeId !== undefined ? { universe_id: universeId } : {}),
157
+ candidate_matches: cappedCandidates,
158
+ next_step: nextStepForTargetKind(targetKind),
159
+ };
160
+
161
+ if (candidates.some(candidate => candidate.match_type !== "near_match_suggestion")) {
162
+ return {
163
+ ok: false,
164
+ error: {
165
+ code: "AMBIGUOUS_TARGET",
166
+ message: `${targetKind} '${input}' resolves to multiple canonical targets. Use a stable canonical ID.`,
167
+ details,
168
+ },
169
+ };
170
+ }
171
+
172
+ return {
173
+ ok: false,
174
+ error: {
175
+ code: "NOT_FOUND",
176
+ message: `${targetKind} '${input}' was not found in the provided scope.`,
177
+ details,
178
+ },
179
+ };
180
+ }
181
+
182
+ function buildSuccess({ targetKind, input, argumentName, row, candidate, idField, rowField }) {
183
+ const resolvedFrom = buildResolvedFrom(argumentName, input, candidate);
184
+ return {
185
+ ok: true,
186
+ target_kind: targetKind,
187
+ id: candidate.id,
188
+ [idField]: candidate.id,
189
+ [rowField]: row,
190
+ canonical: candidate.match_type === "exact_id",
191
+ match: {
192
+ matched_field: candidate.matched_field,
193
+ match_type: candidate.match_type,
194
+ id: candidate.id,
195
+ },
196
+ ...(resolvedFrom ? { resolved_from: resolvedFrom } : {}),
197
+ };
198
+ }
199
+
200
+ function resolveFromRows({
201
+ rows,
202
+ input,
203
+ targetKind,
204
+ idField,
205
+ nameField,
206
+ nameMatchType,
207
+ argumentName,
208
+ projectId,
209
+ universeId,
210
+ candidateLimit,
211
+ formatCandidate,
212
+ }) {
213
+ const normalizedInput = normalizeValue(input);
214
+ const exactIdRows = rows.filter(row => row[idField] === input);
215
+ if (exactIdRows.length === 1) {
216
+ const candidate = formatCandidate(exactIdRows[0], { matchedField: idField, matchType: "exact_id" });
217
+ return buildSuccess({
218
+ targetKind,
219
+ input,
220
+ argumentName,
221
+ row: exactIdRows[0],
222
+ candidate,
223
+ idField,
224
+ rowField: targetKind,
225
+ });
226
+ }
227
+
228
+ const caseInsensitiveIdRows = rows.filter(row => normalizeValue(row[idField]) === normalizedInput);
229
+ if (caseInsensitiveIdRows.length === 1) {
230
+ const candidate = formatCandidate(caseInsensitiveIdRows[0], { matchedField: idField, matchType: "case_insensitive_id" });
231
+ return buildSuccess({
232
+ targetKind,
233
+ input,
234
+ argumentName,
235
+ row: caseInsensitiveIdRows[0],
236
+ candidate,
237
+ idField,
238
+ rowField: targetKind,
239
+ });
240
+ }
241
+ if (caseInsensitiveIdRows.length > 1) {
242
+ return buildResolutionFailure({
243
+ targetKind,
244
+ input,
245
+ projectId,
246
+ universeId,
247
+ candidateLimit,
248
+ candidates: caseInsensitiveIdRows.map(row => formatCandidate(row, {
249
+ matchedField: idField,
250
+ matchType: "case_insensitive_id",
251
+ })),
252
+ });
253
+ }
254
+
255
+ const caseInsensitiveNameRows = rows.filter(row => normalizeValue(row[nameField]) === normalizedInput);
256
+ if (caseInsensitiveNameRows.length === 1) {
257
+ const candidate = formatCandidate(caseInsensitiveNameRows[0], { matchedField: nameField, matchType: nameMatchType });
258
+ return buildSuccess({
259
+ targetKind,
260
+ input,
261
+ argumentName,
262
+ row: caseInsensitiveNameRows[0],
263
+ candidate,
264
+ idField,
265
+ rowField: targetKind,
266
+ });
267
+ }
268
+ if (caseInsensitiveNameRows.length > 1) {
269
+ return buildResolutionFailure({
270
+ targetKind,
271
+ input,
272
+ projectId,
273
+ universeId,
274
+ candidateLimit,
275
+ candidates: caseInsensitiveNameRows.map(row => formatCandidate(row, {
276
+ matchedField: nameField,
277
+ matchType: nameMatchType,
278
+ })),
279
+ });
280
+ }
281
+
282
+ const suggestionsById = rows
283
+ .filter(row => isNearMatch(input, row[idField]))
284
+ .map(row => formatCandidate(row, { matchedField: idField, matchType: "near_match_suggestion" }));
285
+ const suggestedIds = new Set(suggestionsById.map(candidate => candidate.id));
286
+ const suggestionsByName = rows
287
+ .filter(row => !suggestedIds.has(row[idField]) && isNearMatch(input, row[nameField]))
288
+ .map(row => formatCandidate(row, { matchedField: nameField, matchType: "near_match_suggestion" }));
289
+
290
+ return buildResolutionFailure({
291
+ targetKind,
292
+ input,
293
+ projectId,
294
+ universeId,
295
+ candidateLimit,
296
+ candidates: [...suggestionsById, ...suggestionsByName],
297
+ });
298
+ }
299
+
300
+ export function resolveSceneTarget(db, {
301
+ projectId,
302
+ input,
303
+ argumentName = "scene_id",
304
+ candidateLimit = DEFAULT_CANDIDATE_LIMIT,
305
+ } = {}) {
306
+ const rows = db.prepare(`
307
+ SELECT scene_id, project_id, title, chapter_id, chapter_title, timeline_position
308
+ FROM scenes
309
+ WHERE project_id = ?
310
+ ORDER BY title COLLATE NOCASE, scene_id
311
+ `).all(projectId);
312
+
313
+ return resolveFromRows({
314
+ rows,
315
+ input,
316
+ targetKind: "scene",
317
+ idField: "scene_id",
318
+ nameField: "title",
319
+ nameMatchType: "case_insensitive_title",
320
+ argumentName,
321
+ projectId,
322
+ universeId: undefined,
323
+ candidateLimit,
324
+ formatCandidate: formatSceneCandidate,
325
+ });
326
+ }
327
+
328
+ function selectRelationshipScopedCharacters(db, { projectId }) {
329
+ const universeId = getProjectUniverseId(db, projectId);
330
+ return {
331
+ universeId,
332
+ rows: db.prepare(`
333
+ SELECT character_id, project_id, universe_id, name, role, first_appearance
334
+ FROM characters
335
+ WHERE project_id = ?
336
+ OR (universe_id IS NOT NULL AND universe_id = ?)
337
+ OR (project_id IS NULL AND universe_id IS NULL)
338
+ ORDER BY name COLLATE NOCASE, character_id
339
+ `).all(projectId, universeId),
340
+ };
341
+ }
342
+
343
+ function selectRelationshipScopedPlaces(db, { projectId }) {
344
+ const universeId = getProjectUniverseId(db, projectId);
345
+ return {
346
+ universeId,
347
+ rows: db.prepare(`
348
+ SELECT place_id, project_id, universe_id, name
349
+ FROM places
350
+ WHERE project_id = ?
351
+ OR (universe_id IS NOT NULL AND universe_id = ?)
352
+ OR (project_id IS NULL AND universe_id IS NULL)
353
+ ORDER BY name COLLATE NOCASE, place_id
354
+ `).all(projectId, universeId),
355
+ };
356
+ }
357
+
358
+ export function resolveCharacterTargetForProject(db, {
359
+ projectId,
360
+ input,
361
+ argumentName = "character_id",
362
+ candidateLimit = DEFAULT_CANDIDATE_LIMIT,
363
+ } = {}) {
364
+ const { universeId, rows } = selectRelationshipScopedCharacters(db, { projectId });
365
+ return resolveFromRows({
366
+ rows,
367
+ input,
368
+ targetKind: "character",
369
+ idField: "character_id",
370
+ nameField: "name",
371
+ nameMatchType: "case_insensitive_name",
372
+ argumentName,
373
+ projectId,
374
+ universeId,
375
+ candidateLimit,
376
+ formatCandidate: formatCharacterCandidate,
377
+ });
378
+ }
379
+
380
+ export function resolvePlaceTargetForProject(db, {
381
+ projectId,
382
+ input,
383
+ argumentName = "place_id",
384
+ candidateLimit = DEFAULT_CANDIDATE_LIMIT,
385
+ } = {}) {
386
+ const { universeId, rows } = selectRelationshipScopedPlaces(db, { projectId });
387
+ return resolveFromRows({
388
+ rows,
389
+ input,
390
+ targetKind: "place",
391
+ idField: "place_id",
392
+ nameField: "name",
393
+ nameMatchType: "case_insensitive_name",
394
+ argumentName,
395
+ projectId,
396
+ universeId,
397
+ candidateLimit,
398
+ formatCandidate: formatPlaceCandidate,
399
+ });
400
+ }
401
+
402
+ export const CANONICAL_TARGET_CANDIDATE_LIMITS = {
403
+ default: DEFAULT_CANDIDATE_LIMIT,
404
+ hard: HARD_CANDIDATE_LIMIT,
405
+ };
package/src/index.js CHANGED
@@ -327,6 +327,100 @@ function maxScenesNextStep(matchedCount) {
327
327
  return `Re-run with max_scenes set to at least ${matchedCount}.`;
328
328
  }
329
329
 
330
+ function buildRecommendedNextActions({
331
+ sceneCount,
332
+ setupContract,
333
+ dbMigrationWarnings,
334
+ }) {
335
+ const recommendations = [];
336
+
337
+ if (dbMigrationWarnings.length > 0) {
338
+ recommendations.push({
339
+ id: "refresh_index_after_migration_warning",
340
+ label: "Refresh indexed state",
341
+ tool: "sync",
342
+ workflow_id: "parity_recovery",
343
+ priority: 10,
344
+ reason: "Database migration warnings are present, so indexed metadata may need a sync before other work.",
345
+ next_step: "Call sync, then re-run describe_workflows before mutating project state.",
346
+ });
347
+ }
348
+
349
+ if (sceneCount === 0) {
350
+ recommendations.push({
351
+ id: "index_project_content",
352
+ label: "Index project content",
353
+ tool: "sync",
354
+ workflow_id: "first_time_setup",
355
+ priority: 20,
356
+ reason: "No scenes are indexed yet, so discovery and scene-reading tools have no project content to inspect.",
357
+ next_step: "Call sync to index the configured writing folder, then use find_scenes without filters to confirm project_ids.",
358
+ });
359
+ recommendations.push({
360
+ id: "inspect_runtime_configuration",
361
+ label: "Inspect runtime configuration",
362
+ tool: "get_runtime_config",
363
+ workflow_id: "first_time_setup",
364
+ priority: 30,
365
+ reason: "Runtime paths and writable state determine whether sync can index the intended manuscript folder.",
366
+ next_step: "Call get_runtime_config if sync still indexes no scenes or the configured writing folder looks wrong.",
367
+ });
368
+ recommendations.push({
369
+ id: "diagnose_empty_index",
370
+ label: "Diagnose indexed structure",
371
+ tool: "diagnose_structure",
372
+ workflow_id: "parity_recovery",
373
+ priority: 35,
374
+ reason: "If sync does not discover scenes, structure diagnostics can explain missing or ambiguous manuscript representations.",
375
+ next_step: "Call diagnose_structure after sync if the index is still empty or project structure looks unexpected.",
376
+ });
377
+ } else {
378
+ recommendations.push({
379
+ id: "discover_indexed_scenes",
380
+ label: "Discover indexed scenes",
381
+ tool: "find_scenes",
382
+ workflow_id: "question_driven_discovery",
383
+ priority: 20,
384
+ reason: "Scenes are indexed; metadata-first discovery is the safest starting point for most manuscript questions.",
385
+ next_step: "Call find_scenes with project_id or lightweight filters before loading prose.",
386
+ });
387
+ recommendations.push({
388
+ id: "keyword_metadata_search",
389
+ label: "Search metadata keywords",
390
+ tool: "search_metadata",
391
+ workflow_id: "question_driven_discovery",
392
+ priority: 30,
393
+ reason: "Use keyword/FTS metadata search when you know likely titles, logline words, tags, characters, places, or versions.",
394
+ next_step: "Search exact metadata keywords, then open likely scenes with get_scene_prose if prose context is needed.",
395
+ });
396
+ recommendations.push({
397
+ id: "read_likely_scene",
398
+ label: "Read a likely scene",
399
+ tool: "get_scene_prose",
400
+ workflow_id: "targeted_scene_reading",
401
+ priority: 35,
402
+ reason: "Prose should be loaded only after metadata has identified a likely scene.",
403
+ next_step: "Call get_scene_prose with a specific scene_id and project_id once discovery has narrowed the target.",
404
+ });
405
+ }
406
+
407
+ if (setupContract?.setup_recommended) {
408
+ recommendations.push({
409
+ id: "review_styleguide_setup",
410
+ label: "Review styleguide setup",
411
+ tool: "setup_prose_styleguide_config",
412
+ workflow_id: "styleguide_setup_new",
413
+ priority: 40,
414
+ reason: `Styleguide setup status is ${setupContract.styleguide_setup_status}.`,
415
+ next_step: "Follow context.setup_contract.plan_preview before running styleguide drift checks or enforcement workflows.",
416
+ });
417
+ }
418
+
419
+ return recommendations
420
+ .sort((a, b) => a.priority - b.priority)
421
+ .slice(0, 5);
422
+ }
423
+
330
424
  // ---------------------------------------------------------------------------
331
425
  // MCP server factory
332
426
  // ---------------------------------------------------------------------------
@@ -444,6 +538,11 @@ function createMcpServer() {
444
538
  pending_proposals: pendingProposals.size,
445
539
  db_migration_warnings: DB_STARTUP_WARNINGS,
446
540
  },
541
+ recommended_next_actions: buildRecommendedNextActions({
542
+ sceneCount: scene_count,
543
+ setupContract: setupContractContextCheck.value,
544
+ dbMigrationWarnings: DB_STARTUP_WARNINGS,
545
+ }),
447
546
  workflows: WORKFLOW_CATALOGUE,
448
547
  notes: [
449
548
  "Never write JavaScript or shell scripts to invoke tools. Call them directly.",
@@ -130,6 +130,40 @@ function buildRelationshipMetadataBoundaryDetails({ projectId, sceneId, blockedF
130
130
  };
131
131
  }
132
132
 
133
+ function relationshipEvidenceNotFoundDetails({ lookupKind, input, projectId, sceneId }) {
134
+ const details = {
135
+ lookup_kind: lookupKind,
136
+ input,
137
+ project_id: projectId,
138
+ };
139
+ if (sceneId !== undefined) {
140
+ details.scene_id = sceneId;
141
+ }
142
+
143
+ if (lookupKind === "scene") {
144
+ return {
145
+ ...details,
146
+ next_step: "Use find_scenes with the project_id to confirm the canonical scene_id, then retry the relationship evidence tool.",
147
+ };
148
+ }
149
+
150
+ if (lookupKind === "character") {
151
+ return {
152
+ ...details,
153
+ next_step: "Use list_characters to find the stable character_id for this project or universe, then retry the relationship evidence tool.",
154
+ };
155
+ }
156
+
157
+ return {
158
+ ...details,
159
+ next_step: "Use list_places to find the stable place_id for this project or universe, then retry the relationship evidence tool.",
160
+ };
161
+ }
162
+
163
+ function alreadyLinkedRelationshipNextStep({ entityKind }) {
164
+ return `This ${entityKind} was already linked to the scene, so no canonical relationship rows changed. Use the scene_relationships field in this response to inspect current links; call get_scene_prose with the scene_id and project_id if prose context is needed.`;
165
+ }
166
+
133
167
  function persistReferenceDocLink({ filePath, syncDir, targetDocId, relation }) {
134
168
  const syncDirAbs = path.resolve(syncDir);
135
169
  const syncDirReal = resolveBoundaryRootReal(syncDirAbs);
@@ -862,16 +896,42 @@ export function registerMetadataTools(s, {
862
896
  WHERE scene_id = ? AND project_id = ?
863
897
  `).get(scene_id, project_id);
864
898
  if (!scene) {
865
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
899
+ return errorResponse(
900
+ "NOT_FOUND",
901
+ `Scene '${scene_id}' not found in project '${project_id}'.`,
902
+ relationshipEvidenceNotFoundDetails({
903
+ lookupKind: "scene",
904
+ input: scene_id,
905
+ projectId: project_id,
906
+ })
907
+ );
866
908
  }
867
909
 
868
910
  const character = resolveCharacterForProject(db, { characterId: character_id, projectId: project_id });
869
911
  if (!character) {
870
- return errorResponse("NOT_FOUND", `Character '${character_id}' is not indexed for project '${project_id}' or its universe.`);
912
+ return errorResponse(
913
+ "NOT_FOUND",
914
+ `Character '${character_id}' is not indexed for project '${project_id}' or its universe.`,
915
+ relationshipEvidenceNotFoundDetails({
916
+ lookupKind: "character",
917
+ input: character_id,
918
+ projectId: project_id,
919
+ sceneId: scene_id,
920
+ })
921
+ );
871
922
  }
872
923
  const place = resolvePlaceForProject(db, { placeId: place_id, projectId: project_id });
873
924
  if (!place) {
874
- return errorResponse("NOT_FOUND", `Place '${place_id}' is not indexed for project '${project_id}' or its universe.`);
925
+ return errorResponse(
926
+ "NOT_FOUND",
927
+ `Place '${place_id}' is not indexed for project '${project_id}' or its universe.`,
928
+ relationshipEvidenceNotFoundDetails({
929
+ lookupKind: "place",
930
+ input: place_id,
931
+ projectId: project_id,
932
+ sceneId: scene_id,
933
+ })
934
+ );
875
935
  }
876
936
 
877
937
  const before = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
@@ -1005,12 +1065,29 @@ export function registerMetadataTools(s, {
1005
1065
  WHERE scene_id = ? AND project_id = ?
1006
1066
  `).get(scene_id, project_id);
1007
1067
  if (!scene) {
1008
- return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
1068
+ return errorResponse(
1069
+ "NOT_FOUND",
1070
+ `Scene '${scene_id}' not found in project '${project_id}'.`,
1071
+ relationshipEvidenceNotFoundDetails({
1072
+ lookupKind: "scene",
1073
+ input: scene_id,
1074
+ projectId: project_id,
1075
+ })
1076
+ );
1009
1077
  }
1010
1078
 
1011
1079
  if (!resolveEntity(entity_id)) {
1012
1080
  const label = entityKind[0].toUpperCase() + entityKind.slice(1);
1013
- return errorResponse("NOT_FOUND", `${label} '${entity_id}' is not indexed for project '${project_id}' or its universe.`);
1081
+ return errorResponse(
1082
+ "NOT_FOUND",
1083
+ `${label} '${entity_id}' is not indexed for project '${project_id}' or its universe.`,
1084
+ relationshipEvidenceNotFoundDetails({
1085
+ lookupKind: entityKind,
1086
+ input: entity_id,
1087
+ projectId: project_id,
1088
+ sceneId: scene_id,
1089
+ })
1090
+ );
1014
1091
  }
1015
1092
 
1016
1093
  const before = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
@@ -1095,7 +1172,11 @@ export function registerMetadataTools(s, {
1095
1172
  return jsonResponse({
1096
1173
  ok: true,
1097
1174
  action: "connected",
1175
+ outcome: alreadyLinked ? "no_op" : "connected",
1098
1176
  already_linked: alreadyLinked,
1177
+ ...(alreadyLinked
1178
+ ? { next_step: alreadyLinkedRelationshipNextStep({ entityKind }) }
1179
+ : {}),
1099
1180
  scene_id,
1100
1181
  project_id,
1101
1182
  [idField]: entity_id,
@@ -662,9 +662,9 @@ export function registerSearchTools(s, {
662
662
  // ---- search_metadata -----------------------------------------------------
663
663
  s.tool(
664
664
  "search_metadata",
665
- "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.",
665
+ "Keyword/FTS metadata search across scene titles, loglines (synopsis/logline text fields), and indexed metadata keywords (tags/characters/places/versions). Use this when you know likely words or exact metadata values but do not know the scene_id or chapter. This is not semantic search and does not search prose text; use find_scenes for structured filters and get_scene_prose after identifying likely scenes. Supports pagination via page/page_size and auto-paginates large result sets with total_count.",
666
666
  {
667
- query: z.string().describe("Search terms (e.g. 'hospital' or 'Sebastian feeding'). FTS5 syntax supported."),
667
+ query: z.string().describe("Keyword search terms from indexed metadata (e.g. 'hospital' or a quoted character/place/tag phrase). FTS5 syntax supported."),
668
668
  page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
669
669
  page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
670
670
  },
@@ -682,7 +682,11 @@ export function registerSearchTools(s, {
682
682
  }
683
683
 
684
684
  if (totalCount === 0) {
685
- return errorResponse("NO_RESULTS", "No scenes matched the search query.");
685
+ return errorResponse("NO_RESULTS", "No scenes matched the keyword metadata search query.", {
686
+ search_type: "keyword_metadata_fts",
687
+ searched_fields: ["scene.title", "scene.logline", "tags", "characters", "places", "versions"],
688
+ next_step: "Try exact metadata keywords, quoted character/place/tag names, or find_scenes structured filters. After finding likely scenes, use get_scene_prose for prose context; search_metadata is not semantic or prose search.",
689
+ });
686
690
  }
687
691
 
688
692
  const shouldPaginate = totalCount > DEFAULT_METADATA_PAGE_SIZE || page !== undefined || page_size !== undefined;
@@ -5,7 +5,7 @@ export const WORKFLOW_CATALOGUE = [
5
5
  use_when: "Start here for most sessions: when the user has a manuscript question, you need to narrow scope, or you are not yet sure which scene matters.",
6
6
  steps: [
7
7
  { tool: "find_scenes", note: "Use structured metadata filters first when the question already suggests characters, beats, tags, parts, chapters, or POV; numeric chapter values are read-scope aliases, while structure changes require canonical chapter_id workflows." },
8
- { tool: "search_metadata", note: "Use this when the question is thematic, fuzzy, or keyword-driven rather than cleanly filterable." },
8
+ { tool: "search_metadata", note: "Use this when likely title, logline, tag, character, place, or version keywords are known but structured filters are not cleanly available; it is not semantic or prose search." },
9
9
  { tool: "get_scene_prose", note: "Escalate to prose only after likely scenes have been identified and metadata is no longer enough." },
10
10
  { tool: "flag_scene", note: "Use only when the current task naturally leads to recording a follow-up note for later editorial attention." },
11
11
  ],