@hanna84/mcp-writing 3.28.0 → 3.29.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 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.29.1](https://github.com/hannasdev/mcp-writing/compare/v3.29.0...v3.29.1)
8
+
9
+ - docs: close Human Input Forgiveness initiative [`#240`](https://github.com/hannasdev/mcp-writing/pull/240)
10
+
11
+ #### [v3.29.0](https://github.com/hannasdev/mcp-writing/compare/v3.28.0...v3.29.0)
12
+
13
+ > 7 June 2026
14
+
15
+ - feat: resolve scene vocabulary variants [`#239`](https://github.com/hannasdev/mcp-writing/pull/239)
16
+ - Release 3.29.0 [`bb81824`](https://github.com/hannasdev/mcp-writing/commit/bb81824fcb04640b3e62fae2cee6bd39bcd8af14)
17
+
7
18
  #### [v3.28.0](https://github.com/hannasdev/mcp-writing/compare/v3.27.0...v3.28.0)
8
19
 
20
+ > 7 June 2026
21
+
9
22
  - feat: make restore plans easier to scan [`#238`](https://github.com/hannasdev/mcp-writing/pull/238)
23
+ - Release 3.28.0 [`f0add03`](https://github.com/hannasdev/mcp-writing/commit/f0add03b915962d72dcac5c745b9000d50d6a819)
10
24
 
11
25
  #### [v3.27.0](https://github.com/hannasdev/mcp-writing/compare/v3.26.0...v3.27.0)
12
26
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
28
28
 
29
29
  **Current status:**
30
30
  - **Core platform complete:** Metadata-first analysis, SQLite-canonical structural and relationship metadata, compatibility sidecar maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
31
- - **Recently completed:** Relationship Metadata Boundary closed the sidecar-first scene relationship mutation path, added SQLite-first paired and one-sided evidence workflows, and preserved legacy sidecar/frontmatter compatibility for sync and import.
31
+ - **Recently completed:** Human Input Forgiveness made selected request-boundary inputs more forgiving, clarified keyword metadata search boundaries, and recorded temp-fixture replay validation while preserving stable canonical IDs.
32
32
  - **Active development:** No initiative is currently selected.
33
33
  - **Deferred backlog:** OpenClaw integration, client-agnostic setup, divisions, and embeddings search.
34
34
  - **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
@@ -122,6 +122,16 @@ const items = parsed.results ?? [];
122
122
  const totalCount = parsed.total_count ?? items.length;
123
123
  ```
124
124
 
125
+ ## Canonical IDs and forgiving inputs
126
+
127
+ Stable IDs remain the canonical identity for projects, chapters, scenes, characters, places, and relationship writes. When you already know the ID, pass it exactly.
128
+
129
+ Some request-boundary fields now accept unambiguous human-shaped inputs, such as scene titles, character names, place names, or case variants. Successful tools still write and return canonical IDs, with `resolved_from` details when the input was resolved from a non-canonical value. Ambiguous matches, near matches, or suggested-only values fail or return advisory suggestions without mutating canonical state.
130
+
131
+ Tags and Save the Cat beats remain freeform editorial vocabulary. `find_scenes` can match existing tag and beat casing variants, and metadata updates can suggest nearby existing vocabulary, but supplied tag and beat text is preserved unless you intentionally change it.
132
+
133
+ `search_metadata` is keyword/FTS metadata search over indexed titles, loglines, tags, characters, places, and versions. It is not semantic search and does not search prose text; use `get_scene_prose` after metadata search or structured filters identify likely scenes. Semantic/prose search remains deferred to the Embedding-Based Search backlog.
134
+
125
135
  ## Usage scenarios
126
136
 
127
137
  ### 1) Continuity pass before sending chapters to beta readers
@@ -129,7 +139,7 @@ const totalCount = parsed.total_count ?? items.length;
129
139
  Goal: catch inconsistencies before sharing pages.
130
140
 
131
141
  1. Run `sync` after your latest writing session.
132
- 2. Ask `find_scenes` for scenes involving a specific character or tag (for example, all scenes tagged `injury` or `promise`).
142
+ 2. Ask `find_scenes` for scenes involving a specific character or tag (for example, all scenes tagged `injury` or `promise`). Canonical IDs remain preferred, but character/POV filters can resolve unambiguous project-scoped character names, tag and beat filters can suggest near matches, and `chapter_id` accepts exact IDs or unambiguous case variants.
133
143
  3. Use `get_arc` to review that character's ordered progression across the manuscript.
134
144
  4. Load only the suspect scenes with `get_scene_prose`.
135
145
  5. Attach follow-up notes with `flag_scene` where continuity needs a fix.
@@ -152,7 +162,7 @@ Outcome: subplot structure stays visible and auditable, which reduces dropped th
152
162
  Goal: keep indexes accurate without manually re-tagging everything.
153
163
 
154
164
  1. After rewriting scenes, call `enrich_scene` to re-derive lightweight metadata from current prose.
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. Those relationship evidence tools prefer canonical IDs but also accept unambiguous scene titles, character names, place names, and case variants; ambiguous or suggested-only matches fail without mutating state. 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.
165
+ 2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, status, and tags). Tags and beats remain freeform; the tool preserves supplied text while returning suggestions when a value resembles existing vocabulary. 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. Those relationship evidence tools prefer canonical IDs but also accept unambiguous scene titles, character names, place names, and case variants; ambiguous or suggested-only matches fail without mutating state. 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
166
  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
167
 
158
168
  Outcome: your AI assistant can reliably find the right scenes without drifting from the manuscript.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.28.0",
3
+ "version": "3.29.1",
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",
@@ -1,3 +1,5 @@
1
+ import { isNearMatch, normalizeMatchValue } from "./match-utils.js";
2
+
1
3
  const DEFAULT_CANDIDATE_LIMIT = 5;
2
4
  const HARD_CANDIDATE_LIMIT = 10;
3
5
 
@@ -9,10 +11,6 @@ const MATCH_GROUP_ORDER = new Map([
9
11
  ["near_match_suggestion", 3],
10
12
  ]);
11
13
 
12
- function normalizeValue(value) {
13
- return String(value ?? "").trim().toLowerCase();
14
- }
15
-
16
14
  function clampCandidateLimit(candidateLimit) {
17
15
  if (!Number.isInteger(candidateLimit) || candidateLimit <= 0) {
18
16
  return DEFAULT_CANDIDATE_LIMIT;
@@ -24,50 +22,12 @@ function getProjectUniverseId(db, projectId) {
24
22
  return db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(projectId)?.universe_id ?? null;
25
23
  }
26
24
 
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
25
  function sortCandidates(candidates) {
66
26
  return [...candidates].sort((left, right) => {
67
27
  const groupDelta = (MATCH_GROUP_ORDER.get(left.match_type) ?? 99) - (MATCH_GROUP_ORDER.get(right.match_type) ?? 99);
68
28
  if (groupDelta !== 0) return groupDelta;
69
29
 
70
- const labelDelta = normalizeValue(left.label).localeCompare(normalizeValue(right.label));
30
+ const labelDelta = normalizeMatchValue(left.label).localeCompare(normalizeMatchValue(right.label));
71
31
  if (labelDelta !== 0) return labelDelta;
72
32
 
73
33
  return left.id.localeCompare(right.id);
@@ -210,7 +170,7 @@ function resolveFromRows({
210
170
  candidateLimit,
211
171
  formatCandidate,
212
172
  }) {
213
- const normalizedInput = normalizeValue(input);
173
+ const normalizedInput = normalizeMatchValue(input);
214
174
  if (!normalizedInput) {
215
175
  return buildResolutionFailure({
216
176
  targetKind,
@@ -236,7 +196,7 @@ function resolveFromRows({
236
196
  });
237
197
  }
238
198
 
239
- const caseInsensitiveIdRows = rows.filter(row => normalizeValue(row[idField]) === normalizedInput);
199
+ const caseInsensitiveIdRows = rows.filter(row => normalizeMatchValue(row[idField]) === normalizedInput);
240
200
  if (caseInsensitiveIdRows.length === 1) {
241
201
  const candidate = formatCandidate(caseInsensitiveIdRows[0], { matchedField: idField, matchType: "case_insensitive_id" });
242
202
  return buildSuccess({
@@ -263,7 +223,7 @@ function resolveFromRows({
263
223
  });
264
224
  }
265
225
 
266
- const caseInsensitiveNameRows = rows.filter(row => normalizeValue(row[nameField]) === normalizedInput);
226
+ const caseInsensitiveNameRows = rows.filter(row => normalizeMatchValue(row[nameField]) === normalizedInput);
267
227
  if (caseInsensitiveNameRows.length === 1) {
268
228
  const candidate = formatCandidate(caseInsensitiveNameRows[0], { matchedField: nameField, matchType: nameMatchType });
269
229
  return buildSuccess({
@@ -0,0 +1,41 @@
1
+ export function normalizeMatchValue(value) {
2
+ return String(value ?? "").trim().toLowerCase();
3
+ }
4
+
5
+ export function levenshteinDistance(left, right) {
6
+ if (left === right) return 0;
7
+ if (left.length === 0) return right.length;
8
+ if (right.length === 0) return left.length;
9
+
10
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
11
+ const current = Array.from({ length: right.length + 1 }, () => 0);
12
+
13
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
14
+ current[0] = leftIndex;
15
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
16
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
17
+ current[rightIndex] = Math.min(
18
+ current[rightIndex - 1] + 1,
19
+ previous[rightIndex] + 1,
20
+ previous[rightIndex - 1] + cost
21
+ );
22
+ }
23
+ for (let index = 0; index < previous.length; index += 1) {
24
+ previous[index] = current[index];
25
+ }
26
+ }
27
+
28
+ return previous[right.length];
29
+ }
30
+
31
+ export function isNearMatch(input, value) {
32
+ const normalizedInput = normalizeMatchValue(input);
33
+ const normalizedValue = normalizeMatchValue(value);
34
+ if (!normalizedInput || !normalizedValue) return false;
35
+ if (normalizedInput.length < 3 || normalizedValue.length < 3) return false;
36
+ if (normalizedValue.includes(normalizedInput) || normalizedInput.includes(normalizedValue)) return true;
37
+
38
+ const distance = levenshteinDistance(normalizedInput, normalizedValue);
39
+ const threshold = Math.max(1, Math.floor(Math.max(normalizedInput.length, normalizedValue.length) / 4));
40
+ return distance <= threshold;
41
+ }
@@ -0,0 +1,269 @@
1
+ import { isNearMatch, normalizeMatchValue } from "./match-utils.js";
2
+
3
+ const DEFAULT_VOCABULARY_CANDIDATE_LIMIT = 5;
4
+ const HARD_VOCABULARY_CANDIDATE_LIMIT = 10;
5
+
6
+ function normalizeVocabularyValue(value) {
7
+ return normalizeMatchValue(value);
8
+ }
9
+
10
+ function clampCandidateLimit(candidateLimit) {
11
+ if (!Number.isInteger(candidateLimit) || candidateLimit <= 0) {
12
+ return DEFAULT_VOCABULARY_CANDIDATE_LIMIT;
13
+ }
14
+ return Math.min(candidateLimit, HARD_VOCABULARY_CANDIDATE_LIMIT);
15
+ }
16
+
17
+ function cleanVocabularyValues(values) {
18
+ const seen = new Set();
19
+ const cleaned = [];
20
+ for (const value of Array.isArray(values) ? values : []) {
21
+ if (typeof value !== "string") continue;
22
+ const normalized = normalizeVocabularyValue(value);
23
+ if (!normalized) continue;
24
+ if (seen.has(value)) continue;
25
+ seen.add(value);
26
+ cleaned.push(value);
27
+ }
28
+ return cleaned.sort((left, right) => (
29
+ normalizeVocabularyValue(left).localeCompare(normalizeVocabularyValue(right)) || left.localeCompare(right)
30
+ ));
31
+ }
32
+
33
+ function formatVocabularyCandidate({ targetKind, value, matchedField, matchType }) {
34
+ return {
35
+ target_kind: targetKind,
36
+ id: value,
37
+ value,
38
+ label: value,
39
+ matched_field: matchedField,
40
+ match_type: matchType,
41
+ };
42
+ }
43
+
44
+ function capVocabularyCandidates(candidates, candidateLimit) {
45
+ return candidates
46
+ .sort((left, right) => (
47
+ normalizeVocabularyValue(left.label).localeCompare(normalizeVocabularyValue(right.label)) ||
48
+ left.id.localeCompare(right.id)
49
+ ))
50
+ .slice(0, clampCandidateLimit(candidateLimit));
51
+ }
52
+
53
+ export function resolveVocabularyValue({
54
+ input,
55
+ values,
56
+ targetKind,
57
+ matchedField = "value",
58
+ candidateLimit = DEFAULT_VOCABULARY_CANDIDATE_LIMIT,
59
+ } = {}) {
60
+ const normalizedInput = normalizeVocabularyValue(input);
61
+ const vocabularyValues = cleanVocabularyValues(values);
62
+ if (!normalizedInput) {
63
+ return {
64
+ ok: false,
65
+ input,
66
+ target_kind: targetKind,
67
+ candidate_matches: [],
68
+ };
69
+ }
70
+
71
+ const caseInsensitiveMatches = vocabularyValues.filter(value => normalizeVocabularyValue(value) === normalizedInput);
72
+ if (caseInsensitiveMatches.length === 1) {
73
+ const canonicalValue = caseInsensitiveMatches[0];
74
+ return {
75
+ ok: true,
76
+ input,
77
+ target_kind: targetKind,
78
+ value: canonicalValue,
79
+ canonical: canonicalValue === input,
80
+ ...(canonicalValue === input ? {} : {
81
+ resolved_from: {
82
+ input,
83
+ matched_field: matchedField,
84
+ match_type: "case_insensitive_value",
85
+ value: canonicalValue,
86
+ },
87
+ }),
88
+ };
89
+ }
90
+ if (caseInsensitiveMatches.length > 1) {
91
+ return {
92
+ ok: true,
93
+ input,
94
+ target_kind: targetKind,
95
+ value: input,
96
+ canonical: false,
97
+ case_variants: caseInsensitiveMatches.map(value => formatVocabularyCandidate({
98
+ targetKind,
99
+ value,
100
+ matchedField,
101
+ matchType: "case_insensitive_value",
102
+ })),
103
+ };
104
+ }
105
+
106
+ const suggestions = vocabularyValues
107
+ .filter(value => isNearMatch(input, value))
108
+ .map(value => formatVocabularyCandidate({
109
+ targetKind,
110
+ value,
111
+ matchedField,
112
+ matchType: "near_match_suggestion",
113
+ }));
114
+
115
+ return {
116
+ ok: false,
117
+ input,
118
+ target_kind: targetKind,
119
+ candidate_matches: capVocabularyCandidates(suggestions, candidateLimit),
120
+ };
121
+ }
122
+
123
+ export function selectSceneTagVocabulary(db, { projectId } = {}) {
124
+ const rows = projectId
125
+ ? db.prepare(`
126
+ SELECT DISTINCT tag
127
+ FROM scene_tags
128
+ WHERE project_id = ? AND tag IS NOT NULL AND tag != ''
129
+ ORDER BY tag COLLATE NOCASE, tag
130
+ `).all(projectId)
131
+ : db.prepare(`
132
+ SELECT DISTINCT tag
133
+ FROM scene_tags
134
+ WHERE tag IS NOT NULL AND tag != ''
135
+ ORDER BY tag COLLATE NOCASE, tag
136
+ `).all();
137
+ return rows.map(row => row.tag);
138
+ }
139
+
140
+ export function selectSceneBeatVocabulary(db, { projectId } = {}) {
141
+ const rows = projectId
142
+ ? db.prepare(`
143
+ SELECT DISTINCT save_the_cat_beat AS beat
144
+ FROM scenes
145
+ WHERE project_id = ? AND save_the_cat_beat IS NOT NULL AND save_the_cat_beat != ''
146
+ ORDER BY save_the_cat_beat COLLATE NOCASE, save_the_cat_beat
147
+ `).all(projectId)
148
+ : db.prepare(`
149
+ SELECT DISTINCT save_the_cat_beat AS beat
150
+ FROM scenes
151
+ WHERE save_the_cat_beat IS NOT NULL AND save_the_cat_beat != ''
152
+ ORDER BY save_the_cat_beat COLLATE NOCASE, save_the_cat_beat
153
+ `).all();
154
+ return rows.map(row => row.beat);
155
+ }
156
+
157
+ export function selectSceneTagCaseVariants(db, { projectId, input } = {}) {
158
+ if (!input) return [];
159
+ const rows = projectId
160
+ ? db.prepare(`
161
+ SELECT DISTINCT tag
162
+ FROM scene_tags
163
+ WHERE project_id = ? AND tag IS NOT NULL AND tag != ''
164
+ AND lower(tag) = lower(?)
165
+ ORDER BY tag COLLATE NOCASE, tag
166
+ `).all(projectId, input)
167
+ : db.prepare(`
168
+ SELECT DISTINCT tag
169
+ FROM scene_tags
170
+ WHERE tag IS NOT NULL AND tag != ''
171
+ AND lower(tag) = lower(?)
172
+ ORDER BY tag COLLATE NOCASE, tag
173
+ `).all(input);
174
+ return rows.map(row => row.tag);
175
+ }
176
+
177
+ export function selectSceneBeatCaseVariants(db, { projectId, input } = {}) {
178
+ if (!input) return [];
179
+ const rows = projectId
180
+ ? db.prepare(`
181
+ SELECT DISTINCT save_the_cat_beat AS beat
182
+ FROM scenes
183
+ WHERE project_id = ? AND save_the_cat_beat IS NOT NULL AND save_the_cat_beat != ''
184
+ AND lower(save_the_cat_beat) = lower(?)
185
+ ORDER BY save_the_cat_beat COLLATE NOCASE, save_the_cat_beat
186
+ `).all(projectId, input)
187
+ : db.prepare(`
188
+ SELECT DISTINCT save_the_cat_beat AS beat
189
+ FROM scenes
190
+ WHERE save_the_cat_beat IS NOT NULL AND save_the_cat_beat != ''
191
+ AND lower(save_the_cat_beat) = lower(?)
192
+ ORDER BY save_the_cat_beat COLLATE NOCASE, save_the_cat_beat
193
+ `).all(input);
194
+ return rows.map(row => row.beat);
195
+ }
196
+
197
+ export function buildVocabularyNoResultsDetails({
198
+ filters,
199
+ resolvedFilters,
200
+ suggestions = [],
201
+ nextStep = "Broaden filters, choose a candidate value, or call search_metadata with exact metadata keywords.",
202
+ } = {}) {
203
+ return {
204
+ lookup_kind: "scene_metadata_filters",
205
+ filters,
206
+ ...(resolvedFilters ? { resolved_filters: resolvedFilters } : {}),
207
+ candidate_matches: suggestions.flatMap(suggestion => suggestion.candidate_matches ?? []),
208
+ filter_suggestions: suggestions,
209
+ next_step: nextStep,
210
+ };
211
+ }
212
+
213
+ export function buildFreeformFieldSuggestions({
214
+ fields,
215
+ vocabulary,
216
+ } = {}) {
217
+ const suggestions = {};
218
+ if (Array.isArray(fields?.tags) && Array.isArray(vocabulary?.tags)) {
219
+ const tagSuggestions = fields.tags
220
+ .map(tag => resolveVocabularyValue({
221
+ input: tag,
222
+ values: vocabulary.tags,
223
+ targetKind: "tag",
224
+ matchedField: "tag",
225
+ }))
226
+ .filter(result => result.ok && result.resolved_from)
227
+ .map(result => ({
228
+ field: "tags",
229
+ input: result.input,
230
+ existing_value: result.value,
231
+ match_type: result.resolved_from.match_type,
232
+ note: "Existing tag differs only by case; supplied casing was preserved because tags remain freeform metadata.",
233
+ }));
234
+ if (tagSuggestions.length > 0) suggestions.tags = tagSuggestions;
235
+ }
236
+
237
+ if (typeof fields?.save_the_cat_beat === "string" && Array.isArray(vocabulary?.beats)) {
238
+ const beatSuggestion = resolveVocabularyValue({
239
+ input: fields.save_the_cat_beat,
240
+ values: vocabulary.beats,
241
+ targetKind: "beat",
242
+ matchedField: "save_the_cat_beat",
243
+ });
244
+ if (beatSuggestion.ok && beatSuggestion.resolved_from) {
245
+ suggestions.save_the_cat_beat = [{
246
+ field: "save_the_cat_beat",
247
+ input: beatSuggestion.input,
248
+ existing_value: beatSuggestion.value,
249
+ match_type: beatSuggestion.resolved_from.match_type,
250
+ note: "Existing beat differs only by case; supplied value was preserved because Save the Cat beat remains freeform metadata.",
251
+ }];
252
+ } else if (!beatSuggestion.ok && beatSuggestion.candidate_matches.length > 0) {
253
+ suggestions.save_the_cat_beat = beatSuggestion.candidate_matches.map(candidate => ({
254
+ field: "save_the_cat_beat",
255
+ input: fields.save_the_cat_beat,
256
+ suggested_value: candidate.value,
257
+ match_type: candidate.match_type,
258
+ note: "Beat remains freeform; choose an existing value only if it matches author intent.",
259
+ }));
260
+ }
261
+ }
262
+
263
+ return Object.keys(suggestions).length > 0 ? suggestions : undefined;
264
+ }
265
+
266
+ export const VOCABULARY_CANDIDATE_LIMITS = {
267
+ default: DEFAULT_VOCABULARY_CANDIDATE_LIMIT,
268
+ hard: HARD_VOCABULARY_CANDIDATE_LIMIT,
269
+ };
@@ -10,6 +10,11 @@ import {
10
10
  resolvePlaceTargetForProject,
11
11
  resolveSceneTarget,
12
12
  } from "../core/canonical-target-resolution.js";
13
+ import {
14
+ buildFreeformFieldSuggestions,
15
+ selectSceneBeatVocabulary,
16
+ selectSceneTagVocabulary,
17
+ } from "../core/vocabulary-resolution.js";
13
18
  import {
14
19
  FILESYSTEM_ARTIFACT_CLASSES,
15
20
  assertRegularFileReadTarget,
@@ -2636,7 +2641,7 @@ export function registerMetadataTools(s, {
2636
2641
  // ---- update_scene_metadata -----------------------------------------------
2637
2642
  s.tool(
2638
2643
  "update_scene_metadata",
2639
- "Update one or more non-structural, non-relationship metadata fields for a scene. Writes only supplied allowed fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose, mirrors path-derived structure, or changes scene character/place relationship authority. Structural fields (part, chapter, chapter_id, chapter_title, timeline_position) are rejected here; use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structure changes. Relationship fields (characters, places) are rejected here; use discovery workflows plus connect_character_place_evidence when evidence is paired, connect_scene_character_evidence for character-only evidence, connect_scene_place_evidence for place-only evidence, and audit_relationship_metadata for legacy sidecar/frontmatter relationship review. Allowed changes are immediately reflected in the index. Only available when the sync dir is writable.",
2644
+ "Update one or more non-structural, non-relationship metadata fields for a scene. Writes only supplied allowed fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose, mirrors path-derived structure, or changes scene character/place relationship authority. Structural fields (part, chapter, chapter_id, chapter_title, timeline_position) are rejected here; use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structure changes. Relationship fields (characters, places) are rejected here; use discovery workflows plus connect_character_place_evidence when evidence is paired, connect_scene_character_evidence for character-only evidence, connect_scene_place_evidence for place-only evidence, and audit_relationship_metadata for legacy sidecar/frontmatter relationship review. Tags and Save the Cat beat remain freeform; responses may include field_suggestions when supplied values resemble existing vocabulary, but supplied casing/text is preserved. Allowed changes are immediately reflected in the index. Only available when the sync dir is writable.",
2640
2645
  {
2641
2646
  scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
2642
2647
  project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
@@ -2644,7 +2649,7 @@ export function registerMetadataTools(s, {
2644
2649
  title: z.string().optional(),
2645
2650
  logline: z.string().optional(),
2646
2651
  status: z.string().optional().describe("Workflow status (e.g. 'draft', 'revision', 'complete'). Free text — no fixed vocabulary."),
2647
- save_the_cat_beat: z.string().optional(),
2652
+ save_the_cat_beat: z.string().optional().describe("Freeform Save the Cat beat. Suggestions may be returned when the value resembles existing indexed beats, but the supplied value is preserved."),
2648
2653
  pov: z.string().optional(),
2649
2654
  part: z.number().int().optional().describe("Rejected by update_scene_metadata. Structural placement must use explicit structure workflows."),
2650
2655
  chapter: z.number().int().optional().describe("Rejected by update_scene_metadata. Use assign_scene_to_chapter or move_scene with canonical chapter_id."),
@@ -2652,7 +2657,7 @@ export function registerMetadataTools(s, {
2652
2657
  chapter_title: z.string().nullable().optional().describe("Rejected by update_scene_metadata. Use rename_chapter for canonical chapter title changes."),
2653
2658
  timeline_position: z.number().int().optional().describe("Rejected by update_scene_metadata. Use move_scene for ordering changes."),
2654
2659
  story_time: z.string().optional(),
2655
- tags: z.array(z.string()).optional(),
2660
+ tags: z.array(z.string()).optional().describe("Freeform scene tags. Suggestions may be returned when supplied values differ only by case from existing tags, but supplied casing is preserved."),
2656
2661
  characters: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence when evidence is paired, connect_scene_character_evidence for character-only evidence, and audit_relationship_metadata for compatibility review."),
2657
2662
  places: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence when evidence is paired, connect_scene_place_evidence for place-only evidence, and audit_relationship_metadata for compatibility review."),
2658
2663
  }).describe("Fields to update. Only supplied keys are changed."),
@@ -2693,6 +2698,19 @@ export function registerMetadataTools(s, {
2693
2698
  }
2694
2699
  try {
2695
2700
  const relationshipSnapshot = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
2701
+ const needsTagSuggestions = Array.isArray(fields.tags)
2702
+ && fields.tags.some(tag => typeof tag === "string" && Boolean(tag.trim()));
2703
+ const needsBeatSuggestions = typeof fields.save_the_cat_beat === "string"
2704
+ && Boolean(fields.save_the_cat_beat.trim());
2705
+ const fieldSuggestions = needsTagSuggestions || needsBeatSuggestions
2706
+ ? buildFreeformFieldSuggestions({
2707
+ fields,
2708
+ vocabulary: {
2709
+ ...(needsTagSuggestions ? { tags: selectSceneTagVocabulary(db, { projectId: project_id }) } : {}),
2710
+ ...(needsBeatSuggestions ? { beats: selectSceneBeatVocabulary(db, { projectId: project_id }) } : {}),
2711
+ },
2712
+ })
2713
+ : undefined;
2696
2714
  const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
2697
2715
  const updated = { ...sourceMeta, ...fields };
2698
2716
  writeMeta(scene.file_path, updated, { syncDir: SYNC_DIR });
@@ -2751,6 +2769,7 @@ export function registerMetadataTools(s, {
2751
2769
  message: `Updated metadata for scene '${scene_id}'.`,
2752
2770
  scene_id,
2753
2771
  project_id,
2772
+ ...(fieldSuggestions ? { field_suggestions: fieldSuggestions } : {}),
2754
2773
  ...backupMutationFields(backupResult),
2755
2774
  });
2756
2775
  } catch (err) {
@@ -7,6 +7,15 @@ import {
7
7
  upsertExplicitReferenceLinkRow,
8
8
  } from "./reference-link-persistence.js";
9
9
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
10
+ import { resolveCharacterTargetForProject } from "../core/canonical-target-resolution.js";
11
+ import {
12
+ buildVocabularyNoResultsDetails,
13
+ resolveVocabularyValue,
14
+ selectSceneBeatCaseVariants,
15
+ selectSceneBeatVocabulary,
16
+ selectSceneTagCaseVariants,
17
+ selectSceneTagVocabulary,
18
+ } from "../core/vocabulary-resolution.js";
10
19
  import {
11
20
  createToolActor,
12
21
  refreshProjectBackupAfterMutation,
@@ -62,6 +71,141 @@ function selectApplyCandidates(enrichedCandidates, selectedDocIds, maxApply) {
62
71
  return uniqueCandidates.slice(0, maxApply ?? uniqueCandidates.length);
63
72
  }
64
73
 
74
+ function resolveCaseInsensitiveChapterId(db, { projectId, chapterId }) {
75
+ if (!projectId || !chapterId) return { chapterId, resolvedFrom: undefined };
76
+ const rows = db.prepare(`
77
+ SELECT chapter_id, title
78
+ FROM chapters
79
+ WHERE project_id = ? AND lower(chapter_id) = lower(?)
80
+ ORDER BY chapter_id
81
+ `).all(projectId, chapterId);
82
+ const exactMatch = rows.find(row => row.chapter_id === chapterId);
83
+ if (exactMatch) {
84
+ return { chapterId, resolvedFrom: undefined };
85
+ }
86
+ if (rows.length > 1) {
87
+ return {
88
+ error: {
89
+ code: "AMBIGUOUS_TARGET",
90
+ message: `Chapter ID '${chapterId}' resolves to multiple case variants in project '${projectId}'. Use an exact canonical chapter_id.`,
91
+ details: {
92
+ lookup_kind: "chapter",
93
+ target_kind: "chapter",
94
+ input: chapterId,
95
+ project_id: projectId,
96
+ candidate_matches: rows.map(row => ({
97
+ target_kind: "chapter",
98
+ id: row.chapter_id,
99
+ label: row.title || row.chapter_id,
100
+ matched_field: "chapter_id",
101
+ match_type: "case_insensitive_id",
102
+ project_id: projectId,
103
+ })),
104
+ next_step: "Use list_chapters to inspect exact chapter_id values, then retry with the intended canonical chapter_id casing.",
105
+ },
106
+ },
107
+ };
108
+ }
109
+ if (rows.length !== 1) return { chapterId, resolvedFrom: undefined };
110
+ return {
111
+ chapterId: rows[0].chapter_id,
112
+ resolvedFrom: {
113
+ chapter_id: {
114
+ input: chapterId,
115
+ matched_field: "chapter_id",
116
+ match_type: "case_insensitive_id",
117
+ id: rows[0].chapter_id,
118
+ },
119
+ },
120
+ };
121
+ }
122
+
123
+ function buildIndexedSceneCharacterResolution(rows, { input, argumentName }) {
124
+ if (rows.length === 0) return undefined;
125
+ if (rows.length === 1 && rows[0].value !== input) {
126
+ return {
127
+ value: rows[0].value,
128
+ resolved_from: {
129
+ [argumentName]: {
130
+ input,
131
+ matched_field: "character_id",
132
+ match_type: "case_insensitive_id",
133
+ id: rows[0].value,
134
+ },
135
+ },
136
+ };
137
+ }
138
+ return { value: input, values: rows.map(row => row.value), resolved_from: undefined };
139
+ }
140
+
141
+ function resolveIndexedSceneCharacterId(db, { projectId, input, argumentName }) {
142
+ if (!projectId || !input) return undefined;
143
+ const rows = db.prepare(`
144
+ SELECT DISTINCT character_id AS value
145
+ FROM scene_characters
146
+ WHERE project_id = ? AND character_id IS NOT NULL AND character_id != ''
147
+ AND lower(character_id) = lower(?)
148
+ ORDER BY character_id
149
+ `).all(projectId, input);
150
+ return buildIndexedSceneCharacterResolution(rows, { input, argumentName });
151
+ }
152
+
153
+ function resolveIndexedScenePovId(db, { projectId, input, argumentName }) {
154
+ if (!projectId || !input) return undefined;
155
+ const rows = db.prepare(`
156
+ SELECT DISTINCT pov AS value
157
+ FROM scenes
158
+ WHERE project_id = ? AND pov IS NOT NULL AND pov != ''
159
+ AND lower(pov) = lower(?)
160
+ ORDER BY pov
161
+ `).all(projectId, input);
162
+ return buildIndexedSceneCharacterResolution(rows, { input, argumentName });
163
+ }
164
+
165
+ function mapCharacterResolutionError(errorResponse, resolution, { argumentName, input, projectId }) {
166
+ return errorResponse(resolution.error.code, resolution.error.message, {
167
+ ...(resolution.error.details ?? {}),
168
+ argument: argumentName,
169
+ input,
170
+ project_id: projectId,
171
+ });
172
+ }
173
+
174
+ function mergeResolvedFilters(...filters) {
175
+ const merged = {};
176
+ for (const filter of filters) {
177
+ if (filter) Object.assign(merged, filter);
178
+ }
179
+ return Object.keys(merged).length > 0 ? merged : undefined;
180
+ }
181
+
182
+ function valuesForVocabularyResolution(resolution, fallback) {
183
+ if (!resolution.ok) return [fallback];
184
+ if (Array.isArray(resolution.case_variants) && resolution.case_variants.length > 0) {
185
+ return resolution.case_variants.map(candidate => candidate.value);
186
+ }
187
+ return [resolution.value];
188
+ }
189
+
190
+ function valuesForIndexedResolution(resolution, fallback) {
191
+ if (Array.isArray(resolution?.values) && resolution.values.length > 0) {
192
+ return resolution.values;
193
+ }
194
+ if (resolution?.value) return [resolution.value];
195
+ return fallback ? [fallback] : [];
196
+ }
197
+
198
+ function addExactValueCondition({ conditions, params, column, values }) {
199
+ if (!Array.isArray(values) || values.length === 0) return;
200
+ if (values.length === 1) {
201
+ conditions.push(`${column} = ?`);
202
+ params.push(values[0]);
203
+ return;
204
+ }
205
+ conditions.push(`${column} IN (${values.map(() => "?").join(", ")})`);
206
+ params.push(...values);
207
+ }
208
+
65
209
  export function registerSearchTools(s, {
66
210
  db,
67
211
  SYNC_DIR,
@@ -79,16 +223,16 @@ export function registerSearchTools(s, {
79
223
  // ---- find_scenes ---------------------------------------------------------
80
224
  s.tool(
81
225
  "find_scenes",
82
- "Find scenes by filtering on character, Save the Cat beat, tags, canonical chapter identity, numeric chapter alias, or POV. Returns ordered scene metadata only — no prose. Most filters are optional and combinable. `chapter_id` requires `project_id`; numeric `chapter` is a compatibility alias for read scopes only, resolved through canonical chapter identity, and must agree with `chapter_id` when both are provided. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Warns if any matching scenes have stale metadata. Response shape note: always returns a structured envelope (`results`, `total_count`, with pagination fields when paging is active).",
226
+ "Find scenes by filtering on character, Save the Cat beat, tags, canonical chapter identity, numeric chapter alias, or POV. Returns ordered scene metadata only — no prose. Most filters are optional and combinable. `character` and `pov` prefer character_id values and may resolve unambiguous project-scoped character names when project_id is provided. `tag` and `beat` match case-insensitively when unambiguous; near matches are suggestion-only. `chapter_id` requires `project_id` and accepts exact canonical IDs or unambiguous case variants; ambiguous case variants return candidate IDs. Numeric `chapter` is a compatibility alias for read scopes only, resolved through canonical chapter identity, and must agree with `chapter_id` when both are provided. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Warns if any matching scenes have stale metadata. Response shape note: always returns a structured envelope (`results`, `total_count`, with pagination fields when paging is active).",
83
227
  {
84
228
  project_id: z.string().optional().describe("Project ID (e.g. 'the-lamb'). Use to scope results to one project."),
85
- 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."),
86
- beat: z.string().optional().describe("Save the Cat beat name (e.g. 'Opening Image'). Exact match."),
87
- tag: z.string().optional().describe("Scene tag to filter by. Exact match."),
229
+ character: z.string().optional().describe("A character_id (e.g. 'char-mira-nystrom'), or an unambiguous project-scoped character name when project_id is provided. Returns only scenes that character appears in. Use list_characters first to find valid IDs."),
230
+ beat: z.string().optional().describe("Save the Cat beat name (e.g. 'Opening Image'). Matches existing indexed beat values case-insensitively; near matches are suggestions only."),
231
+ tag: z.string().optional().describe("Scene tag to filter by. Matches existing indexed tag values case-insensitively; near matches are suggestions only."),
88
232
  part: z.number().int().optional().describe("Part number (integer, e.g. 1). Chapters are numbered globally across the whole project."),
89
233
  chapter: z.number().int().optional().describe("Read-scope compatibility alias resolved from canonical chapter sort order. Not a structural mutation target."),
90
- chapter_id: z.string().optional().describe("Canonical chapter identifier. Requires project_id. Use list_chapters to find valid values."),
91
- pov: z.string().optional().describe("POV character_id. Use list_characters first to find valid IDs."),
234
+ chapter_id: z.string().optional().describe("Canonical chapter identifier. Requires project_id. Case variants of existing chapter_id values are accepted. Use list_chapters to find valid values."),
235
+ pov: z.string().optional().describe("POV character_id, or an unambiguous project-scoped character name when project_id is provided. Use list_characters first to find valid IDs."),
92
236
  page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
93
237
  page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
94
238
  },
@@ -100,8 +244,143 @@ export function registerSearchTools(s, {
100
244
  { chapter_id }
101
245
  );
102
246
  }
103
- const resolvedChapterFilter = project_id && (chapter_id || chapter != null)
104
- ? resolveValidatedChapterFilter(db, { projectId: project_id, chapterNumber: chapter, chapterId: chapter_id })
247
+ const resolvedFilters = {};
248
+ const vocabularySuggestions = [];
249
+
250
+ let resolvedCharacter = character;
251
+ let resolvedCharacterValues = character ? [character] : [];
252
+ if (project_id && character) {
253
+ const indexedResolution = resolveIndexedSceneCharacterId(db, {
254
+ projectId: project_id,
255
+ input: character,
256
+ argumentName: "character",
257
+ });
258
+ if (indexedResolution) {
259
+ resolvedCharacter = indexedResolution.value;
260
+ resolvedCharacterValues = valuesForIndexedResolution(indexedResolution, character);
261
+ if (indexedResolution.resolved_from?.character) {
262
+ resolvedFilters.character = indexedResolution.resolved_from.character;
263
+ }
264
+ } else {
265
+ const resolution = resolveCharacterTargetForProject(db, {
266
+ projectId: project_id,
267
+ input: character,
268
+ argumentName: "character",
269
+ });
270
+ if (!resolution.ok) {
271
+ return mapCharacterResolutionError(errorResponse, resolution, {
272
+ argumentName: "character",
273
+ input: character,
274
+ projectId: project_id,
275
+ });
276
+ }
277
+ resolvedCharacter = resolution.character_id;
278
+ resolvedCharacterValues = [resolution.character_id];
279
+ if (resolution.resolved_from?.character) {
280
+ resolvedFilters.character = resolution.resolved_from.character;
281
+ }
282
+ }
283
+ }
284
+
285
+ let resolvedPov = pov;
286
+ let resolvedPovValues = pov ? [pov] : [];
287
+ if (project_id && pov) {
288
+ const indexedResolution = resolveIndexedScenePovId(db, {
289
+ projectId: project_id,
290
+ input: pov,
291
+ argumentName: "pov",
292
+ });
293
+ if (indexedResolution) {
294
+ resolvedPov = indexedResolution.value;
295
+ resolvedPovValues = valuesForIndexedResolution(indexedResolution, pov);
296
+ if (indexedResolution.resolved_from?.pov) {
297
+ resolvedFilters.pov = indexedResolution.resolved_from.pov;
298
+ }
299
+ } else {
300
+ const resolution = resolveCharacterTargetForProject(db, {
301
+ projectId: project_id,
302
+ input: pov,
303
+ argumentName: "pov",
304
+ });
305
+ if (!resolution.ok) {
306
+ return mapCharacterResolutionError(errorResponse, resolution, {
307
+ argumentName: "pov",
308
+ input: pov,
309
+ projectId: project_id,
310
+ });
311
+ }
312
+ resolvedPov = resolution.character_id;
313
+ resolvedPovValues = [resolution.character_id];
314
+ if (resolution.resolved_from?.pov) {
315
+ resolvedFilters.pov = resolution.resolved_from.pov;
316
+ }
317
+ }
318
+ }
319
+
320
+ let resolvedTag = tag;
321
+ let resolvedTagValues = tag ? [tag] : [];
322
+ if (tag) {
323
+ const tagCaseVariants = selectSceneTagCaseVariants(db, { projectId: project_id, input: tag });
324
+ const resolution = resolveVocabularyValue({
325
+ input: tag,
326
+ values: tagCaseVariants.length > 0
327
+ ? tagCaseVariants
328
+ : selectSceneTagVocabulary(db, { projectId: project_id }),
329
+ targetKind: "tag",
330
+ matchedField: "tag",
331
+ });
332
+ if (resolution.ok) {
333
+ resolvedTag = resolution.value;
334
+ resolvedTagValues = valuesForVocabularyResolution(resolution, tag);
335
+ if (resolution.resolved_from) resolvedFilters.tag = resolution.resolved_from;
336
+ } else if (resolution.candidate_matches.length > 0) {
337
+ vocabularySuggestions.push({
338
+ filter: "tag",
339
+ input: tag,
340
+ candidate_matches: resolution.candidate_matches,
341
+ });
342
+ }
343
+ }
344
+
345
+ let resolvedBeat = beat;
346
+ let resolvedBeatValues = beat ? [beat] : [];
347
+ if (beat) {
348
+ const beatCaseVariants = selectSceneBeatCaseVariants(db, { projectId: project_id, input: beat });
349
+ const resolution = resolveVocabularyValue({
350
+ input: beat,
351
+ values: beatCaseVariants.length > 0
352
+ ? beatCaseVariants
353
+ : selectSceneBeatVocabulary(db, { projectId: project_id }),
354
+ targetKind: "beat",
355
+ matchedField: "save_the_cat_beat",
356
+ });
357
+ if (resolution.ok) {
358
+ resolvedBeat = resolution.value;
359
+ resolvedBeatValues = valuesForVocabularyResolution(resolution, beat);
360
+ if (resolution.resolved_from) resolvedFilters.beat = resolution.resolved_from;
361
+ } else if (resolution.candidate_matches.length > 0) {
362
+ vocabularySuggestions.push({
363
+ filter: "beat",
364
+ input: beat,
365
+ candidate_matches: resolution.candidate_matches,
366
+ });
367
+ }
368
+ }
369
+
370
+ const chapterResolution = resolveCaseInsensitiveChapterId(db, { projectId: project_id, chapterId: chapter_id });
371
+ if (chapterResolution.error) {
372
+ return errorResponse(
373
+ chapterResolution.error.code,
374
+ chapterResolution.error.message,
375
+ chapterResolution.error.details
376
+ );
377
+ }
378
+ if (chapterResolution.resolvedFrom?.chapter_id) {
379
+ resolvedFilters.chapter_id = chapterResolution.resolvedFrom.chapter_id;
380
+ }
381
+
382
+ const resolvedChapterFilter = project_id && (chapterResolution.chapterId || chapter != null)
383
+ ? resolveValidatedChapterFilter(db, { projectId: project_id, chapterNumber: chapter, chapterId: chapterResolution.chapterId })
105
384
  : { chapter: null };
106
385
  if (resolvedChapterFilter.error) {
107
386
  return errorResponse(
@@ -126,16 +405,33 @@ export function registerSearchTools(s, {
126
405
  const conditions = [];
127
406
  const params = [];
128
407
 
129
- if (character) {
130
- joins.push(`JOIN scene_characters sc ON sc.scene_id = s.scene_id AND sc.project_id = s.project_id AND sc.character_id = ?`);
131
- params.push(character);
408
+ if (resolvedCharacter) {
409
+ if (resolvedCharacterValues.length === 1) {
410
+ joins.push(`JOIN scene_characters sc ON sc.scene_id = s.scene_id AND sc.project_id = s.project_id AND sc.character_id = ?`);
411
+ params.push(resolvedCharacterValues[0]);
412
+ } else {
413
+ joins.push(`JOIN scene_characters sc ON sc.scene_id = s.scene_id AND sc.project_id = s.project_id AND sc.character_id IN (${resolvedCharacterValues.map(() => "?").join(", ")})`);
414
+ params.push(...resolvedCharacterValues);
415
+ }
132
416
  }
133
- if (tag) {
134
- joins.push(`JOIN scene_tags st ON st.scene_id = s.scene_id AND st.project_id = s.project_id AND st.tag = ?`);
135
- params.push(tag);
417
+ if (resolvedTag) {
418
+ if (resolvedTagValues.length === 1) {
419
+ joins.push(`JOIN scene_tags st ON st.scene_id = s.scene_id AND st.project_id = s.project_id AND st.tag = ?`);
420
+ params.push(resolvedTagValues[0]);
421
+ } else {
422
+ joins.push(`JOIN scene_tags st ON st.scene_id = s.scene_id AND st.project_id = s.project_id AND st.tag IN (${resolvedTagValues.map(() => "?").join(", ")})`);
423
+ params.push(...resolvedTagValues);
424
+ }
136
425
  }
137
426
  if (project_id) { conditions.push(`s.project_id = ?`); params.push(project_id); }
138
- if (beat) { conditions.push(`s.save_the_cat_beat = ?`); params.push(beat); }
427
+ if (resolvedBeat) {
428
+ addExactValueCondition({
429
+ conditions,
430
+ params,
431
+ column: "s.save_the_cat_beat",
432
+ values: resolvedBeatValues,
433
+ });
434
+ }
139
435
  if (part) { conditions.push(`s.part = ?`); params.push(part); }
140
436
  if (resolvedChapterFilter.chapter) {
141
437
  conditions.push(`s.chapter_id = ?`);
@@ -144,7 +440,14 @@ export function registerSearchTools(s, {
144
440
  conditions.push(`s.chapter = ?`);
145
441
  params.push(chapter);
146
442
  }
147
- if (pov) { conditions.push(`s.pov = ?`); params.push(pov); }
443
+ if (resolvedPov) {
444
+ addExactValueCondition({
445
+ conditions,
446
+ params,
447
+ column: "s.pov",
448
+ values: resolvedPovValues,
449
+ });
450
+ }
148
451
 
149
452
  if (joins.length) query += " " + joins.join(" ");
150
453
  if (conditions.length) query += " WHERE " + conditions.join(" AND ");
@@ -152,7 +455,24 @@ export function registerSearchTools(s, {
152
455
 
153
456
  const rows = db.prepare(query).all(...params);
154
457
  if (rows.length === 0) {
155
- return errorResponse("NO_RESULTS", "No scenes match the given filters. Hint: broaden filters or call search_metadata with a keyword first.");
458
+ return errorResponse(
459
+ "NO_RESULTS",
460
+ "No scenes match the given filters. Hint: broaden filters, choose a suggested candidate, or call search_metadata with a keyword first.",
461
+ buildVocabularyNoResultsDetails({
462
+ filters: {
463
+ project_id: project_id ?? null,
464
+ character: character ?? null,
465
+ beat: beat ?? null,
466
+ tag: tag ?? null,
467
+ part: part ?? null,
468
+ chapter: chapter ?? null,
469
+ chapter_id: chapter_id ?? null,
470
+ pov: pov ?? null,
471
+ },
472
+ resolvedFilters: mergeResolvedFilters(resolvedFilters),
473
+ suggestions: vocabularySuggestions,
474
+ })
475
+ );
156
476
  }
157
477
 
158
478
  const staleCount = rows.filter(r => r.metadata_stale).length;
@@ -171,6 +491,7 @@ export function registerSearchTools(s, {
171
491
  results: paged.rows,
172
492
  ...paged.meta,
173
493
  warning,
494
+ resolved_filters: mergeResolvedFilters(resolvedFilters),
174
495
  next_step: staleCount > 0
175
496
  ? "Touch stale scenes as you work and run enrich_scene(scene_id, project_id) to recover metadata parity incrementally."
176
497
  : undefined,
@@ -179,6 +500,7 @@ export function registerSearchTools(s, {
179
500
  results: rows,
180
501
  total_count: rows.length,
181
502
  warning,
503
+ resolved_filters: mergeResolvedFilters(resolvedFilters),
182
504
  next_step: staleCount > 0
183
505
  ? "Touch stale scenes as you work and run enrich_scene(scene_id, project_id) to recover metadata parity incrementally."
184
506
  : undefined,