@hanna84/mcp-writing 3.25.0 → 3.27.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 +14 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/canonical-target-resolution.js +416 -0
- package/src/tools/metadata.js +118 -145
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.27.0](https://github.com/hannasdev/mcp-writing/compare/v3.26.0...v3.27.0)
|
|
8
|
+
|
|
9
|
+
- feat(metadata): accept forgiving relationship evidence inputs [`#237`](https://github.com/hannasdev/mcp-writing/pull/237)
|
|
10
|
+
|
|
11
|
+
#### [v3.26.0](https://github.com/hannasdev/mcp-writing/compare/v3.25.0...v3.26.0)
|
|
12
|
+
|
|
13
|
+
> 6 June 2026
|
|
14
|
+
|
|
15
|
+
- feat: add canonical target resolver [`#236`](https://github.com/hannasdev/mcp-writing/pull/236)
|
|
16
|
+
- Release 3.26.0 [`bb01109`](https://github.com/hannasdev/mcp-writing/commit/bb01109bdef98ad7020ffabc4fd249017b51998c)
|
|
17
|
+
|
|
7
18
|
#### [v3.25.0](https://github.com/hannasdev/mcp-writing/compare/v3.24.1...v3.25.0)
|
|
8
19
|
|
|
20
|
+
> 6 June 2026
|
|
21
|
+
|
|
9
22
|
- feat: clarify M1 workflow and relationship responses [`#235`](https://github.com/hannasdev/mcp-writing/pull/235)
|
|
23
|
+
- Release 3.25.0 [`45b7c47`](https://github.com/hannasdev/mcp-writing/commit/45b7c4782ca6d6e54d66a49bcee5ae40d3f1cd69)
|
|
10
24
|
|
|
11
25
|
#### [v3.24.1](https://github.com/hannasdev/mcp-writing/compare/v3.24.0...v3.24.1)
|
|
12
26
|
|
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ Outcome: subplot structure stays visible and auditable, which reduces dropped th
|
|
|
152
152
|
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
|
-
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.
|
|
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.
|
|
156
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.
|
package/package.json
CHANGED
|
@@ -0,0 +1,416 @@
|
|
|
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
|
+
if (!normalizedInput) {
|
|
215
|
+
return buildResolutionFailure({
|
|
216
|
+
targetKind,
|
|
217
|
+
input,
|
|
218
|
+
projectId,
|
|
219
|
+
universeId,
|
|
220
|
+
candidateLimit,
|
|
221
|
+
candidates: [],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const exactIdRows = rows.filter(row => row[idField] === input);
|
|
226
|
+
if (exactIdRows.length === 1) {
|
|
227
|
+
const candidate = formatCandidate(exactIdRows[0], { matchedField: idField, matchType: "exact_id" });
|
|
228
|
+
return buildSuccess({
|
|
229
|
+
targetKind,
|
|
230
|
+
input,
|
|
231
|
+
argumentName,
|
|
232
|
+
row: exactIdRows[0],
|
|
233
|
+
candidate,
|
|
234
|
+
idField,
|
|
235
|
+
rowField: targetKind,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const caseInsensitiveIdRows = rows.filter(row => normalizeValue(row[idField]) === normalizedInput);
|
|
240
|
+
if (caseInsensitiveIdRows.length === 1) {
|
|
241
|
+
const candidate = formatCandidate(caseInsensitiveIdRows[0], { matchedField: idField, matchType: "case_insensitive_id" });
|
|
242
|
+
return buildSuccess({
|
|
243
|
+
targetKind,
|
|
244
|
+
input,
|
|
245
|
+
argumentName,
|
|
246
|
+
row: caseInsensitiveIdRows[0],
|
|
247
|
+
candidate,
|
|
248
|
+
idField,
|
|
249
|
+
rowField: targetKind,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (caseInsensitiveIdRows.length > 1) {
|
|
253
|
+
return buildResolutionFailure({
|
|
254
|
+
targetKind,
|
|
255
|
+
input,
|
|
256
|
+
projectId,
|
|
257
|
+
universeId,
|
|
258
|
+
candidateLimit,
|
|
259
|
+
candidates: caseInsensitiveIdRows.map(row => formatCandidate(row, {
|
|
260
|
+
matchedField: idField,
|
|
261
|
+
matchType: "case_insensitive_id",
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const caseInsensitiveNameRows = rows.filter(row => normalizeValue(row[nameField]) === normalizedInput);
|
|
267
|
+
if (caseInsensitiveNameRows.length === 1) {
|
|
268
|
+
const candidate = formatCandidate(caseInsensitiveNameRows[0], { matchedField: nameField, matchType: nameMatchType });
|
|
269
|
+
return buildSuccess({
|
|
270
|
+
targetKind,
|
|
271
|
+
input,
|
|
272
|
+
argumentName,
|
|
273
|
+
row: caseInsensitiveNameRows[0],
|
|
274
|
+
candidate,
|
|
275
|
+
idField,
|
|
276
|
+
rowField: targetKind,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (caseInsensitiveNameRows.length > 1) {
|
|
280
|
+
return buildResolutionFailure({
|
|
281
|
+
targetKind,
|
|
282
|
+
input,
|
|
283
|
+
projectId,
|
|
284
|
+
universeId,
|
|
285
|
+
candidateLimit,
|
|
286
|
+
candidates: caseInsensitiveNameRows.map(row => formatCandidate(row, {
|
|
287
|
+
matchedField: nameField,
|
|
288
|
+
matchType: nameMatchType,
|
|
289
|
+
})),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const suggestionsById = rows
|
|
294
|
+
.filter(row => isNearMatch(input, row[idField]))
|
|
295
|
+
.map(row => formatCandidate(row, { matchedField: idField, matchType: "near_match_suggestion" }));
|
|
296
|
+
const suggestedIds = new Set(suggestionsById.map(candidate => candidate.id));
|
|
297
|
+
const suggestionsByName = rows
|
|
298
|
+
.filter(row => !suggestedIds.has(row[idField]) && isNearMatch(input, row[nameField]))
|
|
299
|
+
.map(row => formatCandidate(row, { matchedField: nameField, matchType: "near_match_suggestion" }));
|
|
300
|
+
|
|
301
|
+
return buildResolutionFailure({
|
|
302
|
+
targetKind,
|
|
303
|
+
input,
|
|
304
|
+
projectId,
|
|
305
|
+
universeId,
|
|
306
|
+
candidateLimit,
|
|
307
|
+
candidates: [...suggestionsById, ...suggestionsByName],
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function resolveSceneTarget(db, {
|
|
312
|
+
projectId,
|
|
313
|
+
input,
|
|
314
|
+
argumentName = "scene_id",
|
|
315
|
+
candidateLimit = DEFAULT_CANDIDATE_LIMIT,
|
|
316
|
+
} = {}) {
|
|
317
|
+
const rows = db.prepare(`
|
|
318
|
+
SELECT scene_id, project_id, title, chapter_id, chapter_title, timeline_position
|
|
319
|
+
FROM scenes
|
|
320
|
+
WHERE project_id = ?
|
|
321
|
+
ORDER BY title COLLATE NOCASE, scene_id
|
|
322
|
+
`).all(projectId);
|
|
323
|
+
|
|
324
|
+
return resolveFromRows({
|
|
325
|
+
rows,
|
|
326
|
+
input,
|
|
327
|
+
targetKind: "scene",
|
|
328
|
+
idField: "scene_id",
|
|
329
|
+
nameField: "title",
|
|
330
|
+
nameMatchType: "case_insensitive_title",
|
|
331
|
+
argumentName,
|
|
332
|
+
projectId,
|
|
333
|
+
universeId: undefined,
|
|
334
|
+
candidateLimit,
|
|
335
|
+
formatCandidate: formatSceneCandidate,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function selectRelationshipScopedCharacters(db, { projectId }) {
|
|
340
|
+
const universeId = getProjectUniverseId(db, projectId);
|
|
341
|
+
return {
|
|
342
|
+
universeId,
|
|
343
|
+
rows: db.prepare(`
|
|
344
|
+
SELECT character_id, project_id, universe_id, name, role, first_appearance
|
|
345
|
+
FROM characters
|
|
346
|
+
WHERE project_id = ?
|
|
347
|
+
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
348
|
+
OR (project_id IS NULL AND universe_id IS NULL)
|
|
349
|
+
ORDER BY name COLLATE NOCASE, character_id
|
|
350
|
+
`).all(projectId, universeId),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function selectRelationshipScopedPlaces(db, { projectId }) {
|
|
355
|
+
const universeId = getProjectUniverseId(db, projectId);
|
|
356
|
+
return {
|
|
357
|
+
universeId,
|
|
358
|
+
rows: db.prepare(`
|
|
359
|
+
SELECT place_id, project_id, universe_id, name
|
|
360
|
+
FROM places
|
|
361
|
+
WHERE project_id = ?
|
|
362
|
+
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
363
|
+
OR (project_id IS NULL AND universe_id IS NULL)
|
|
364
|
+
ORDER BY name COLLATE NOCASE, place_id
|
|
365
|
+
`).all(projectId, universeId),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function resolveCharacterTargetForProject(db, {
|
|
370
|
+
projectId,
|
|
371
|
+
input,
|
|
372
|
+
argumentName = "character_id",
|
|
373
|
+
candidateLimit = DEFAULT_CANDIDATE_LIMIT,
|
|
374
|
+
} = {}) {
|
|
375
|
+
const { universeId, rows } = selectRelationshipScopedCharacters(db, { projectId });
|
|
376
|
+
return resolveFromRows({
|
|
377
|
+
rows,
|
|
378
|
+
input,
|
|
379
|
+
targetKind: "character",
|
|
380
|
+
idField: "character_id",
|
|
381
|
+
nameField: "name",
|
|
382
|
+
nameMatchType: "case_insensitive_name",
|
|
383
|
+
argumentName,
|
|
384
|
+
projectId,
|
|
385
|
+
universeId,
|
|
386
|
+
candidateLimit,
|
|
387
|
+
formatCandidate: formatCharacterCandidate,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function resolvePlaceTargetForProject(db, {
|
|
392
|
+
projectId,
|
|
393
|
+
input,
|
|
394
|
+
argumentName = "place_id",
|
|
395
|
+
candidateLimit = DEFAULT_CANDIDATE_LIMIT,
|
|
396
|
+
} = {}) {
|
|
397
|
+
const { universeId, rows } = selectRelationshipScopedPlaces(db, { projectId });
|
|
398
|
+
return resolveFromRows({
|
|
399
|
+
rows,
|
|
400
|
+
input,
|
|
401
|
+
targetKind: "place",
|
|
402
|
+
idField: "place_id",
|
|
403
|
+
nameField: "name",
|
|
404
|
+
nameMatchType: "case_insensitive_name",
|
|
405
|
+
argumentName,
|
|
406
|
+
projectId,
|
|
407
|
+
universeId,
|
|
408
|
+
candidateLimit,
|
|
409
|
+
formatCandidate: formatPlaceCandidate,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export const CANONICAL_TARGET_CANDIDATE_LIMITS = {
|
|
414
|
+
default: DEFAULT_CANDIDATE_LIMIT,
|
|
415
|
+
hard: HARD_CANDIDATE_LIMIT,
|
|
416
|
+
};
|
package/src/tools/metadata.js
CHANGED
|
@@ -5,6 +5,11 @@ import matter from "gray-matter";
|
|
|
5
5
|
import { readMeta, readSourceMeta, writeMeta, indexSceneFile, isManagedStructureProject, normalizeSceneMetaForPath, resolveSceneCharacterCompatibilityId } from "../sync/sync.js";
|
|
6
6
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
7
7
|
import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
|
|
8
|
+
import {
|
|
9
|
+
resolveCharacterTargetForProject,
|
|
10
|
+
resolvePlaceTargetForProject,
|
|
11
|
+
resolveSceneTarget,
|
|
12
|
+
} from "../core/canonical-target-resolution.js";
|
|
8
13
|
import {
|
|
9
14
|
FILESYSTEM_ARTIFACT_CLASSES,
|
|
10
15
|
assertRegularFileReadTarget,
|
|
@@ -130,38 +135,26 @@ function buildRelationshipMetadataBoundaryDetails({ projectId, sceneId, blockedF
|
|
|
130
135
|
};
|
|
131
136
|
}
|
|
132
137
|
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
}
|
|
138
|
+
function alreadyLinkedRelationshipNextStep({ entityKind }) {
|
|
139
|
+
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.`;
|
|
140
|
+
}
|
|
149
141
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
142
|
+
function mergeResolvedFrom(...resolutions) {
|
|
143
|
+
const merged = {};
|
|
144
|
+
for (const resolution of resolutions) {
|
|
145
|
+
if (resolution?.resolved_from) {
|
|
146
|
+
Object.assign(merged, resolution.resolved_from);
|
|
147
|
+
}
|
|
155
148
|
}
|
|
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
|
-
};
|
|
149
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
161
150
|
}
|
|
162
151
|
|
|
163
|
-
function
|
|
164
|
-
|
|
152
|
+
function relationshipEvidenceResolutionError(errorResponse, resolution, { sceneId } = {}) {
|
|
153
|
+
const details = {
|
|
154
|
+
...(resolution.error.details ?? {}),
|
|
155
|
+
...(sceneId !== undefined ? { scene_id: sceneId } : {}),
|
|
156
|
+
};
|
|
157
|
+
return errorResponse(resolution.error.code, resolution.error.message, details);
|
|
165
158
|
}
|
|
166
159
|
|
|
167
160
|
function persistReferenceDocLink({ filePath, syncDir, targetDocId, relation }) {
|
|
@@ -461,21 +454,6 @@ function resolveCharacterForProject(db, { characterId, projectId }) {
|
|
|
461
454
|
`).get(characterId, projectId, universeId);
|
|
462
455
|
}
|
|
463
456
|
|
|
464
|
-
function resolvePlaceForProject(db, { placeId, projectId }) {
|
|
465
|
-
const universeId = getProjectUniverseId(db, projectId);
|
|
466
|
-
return db.prepare(`
|
|
467
|
-
SELECT place_id, project_id, universe_id, name
|
|
468
|
-
FROM places
|
|
469
|
-
WHERE place_id = ?
|
|
470
|
-
AND (
|
|
471
|
-
project_id = ?
|
|
472
|
-
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
473
|
-
OR (project_id IS NULL AND universe_id IS NULL)
|
|
474
|
-
)
|
|
475
|
-
LIMIT 1
|
|
476
|
-
`).get(placeId, projectId, universeId);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
457
|
function querySceneRelationshipSnapshot(db, { sceneId, projectId }) {
|
|
480
458
|
return {
|
|
481
459
|
characters: db.prepare(`
|
|
@@ -890,61 +868,53 @@ export function registerMetadataTools(s, {
|
|
|
890
868
|
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
891
869
|
}
|
|
892
870
|
|
|
871
|
+
const sceneResolution = resolveSceneTarget(db, {
|
|
872
|
+
projectId: project_id,
|
|
873
|
+
input: scene_id,
|
|
874
|
+
argumentName: "scene_id",
|
|
875
|
+
});
|
|
876
|
+
if (!sceneResolution.ok) {
|
|
877
|
+
return relationshipEvidenceResolutionError(errorResponse, sceneResolution);
|
|
878
|
+
}
|
|
879
|
+
const canonicalSceneId = sceneResolution.scene_id;
|
|
880
|
+
|
|
893
881
|
const scene = db.prepare(`
|
|
894
882
|
SELECT scene_id, project_id, file_path
|
|
895
883
|
FROM scenes
|
|
896
884
|
WHERE scene_id = ? AND project_id = ?
|
|
897
|
-
`).get(
|
|
898
|
-
if (!scene) {
|
|
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
|
-
);
|
|
908
|
-
}
|
|
885
|
+
`).get(canonicalSceneId, project_id);
|
|
909
886
|
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
input: character_id,
|
|
918
|
-
projectId: project_id,
|
|
919
|
-
sceneId: scene_id,
|
|
920
|
-
})
|
|
921
|
-
);
|
|
887
|
+
const characterResolution = resolveCharacterTargetForProject(db, {
|
|
888
|
+
projectId: project_id,
|
|
889
|
+
input: character_id,
|
|
890
|
+
argumentName: "character_id",
|
|
891
|
+
});
|
|
892
|
+
if (!characterResolution.ok) {
|
|
893
|
+
return relationshipEvidenceResolutionError(errorResponse, characterResolution, { sceneId: canonicalSceneId });
|
|
922
894
|
}
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
sceneId: scene_id,
|
|
933
|
-
})
|
|
934
|
-
);
|
|
895
|
+
const canonicalCharacterId = characterResolution.character_id;
|
|
896
|
+
|
|
897
|
+
const placeResolution = resolvePlaceTargetForProject(db, {
|
|
898
|
+
projectId: project_id,
|
|
899
|
+
input: place_id,
|
|
900
|
+
argumentName: "place_id",
|
|
901
|
+
});
|
|
902
|
+
if (!placeResolution.ok) {
|
|
903
|
+
return relationshipEvidenceResolutionError(errorResponse, placeResolution, { sceneId: canonicalSceneId });
|
|
935
904
|
}
|
|
905
|
+
const canonicalPlaceId = placeResolution.place_id;
|
|
936
906
|
|
|
937
|
-
const before = querySceneRelationshipSnapshot(db, { sceneId:
|
|
907
|
+
const before = querySceneRelationshipSnapshot(db, { sceneId: canonicalSceneId, projectId: project_id });
|
|
938
908
|
try {
|
|
939
909
|
db.exec("BEGIN");
|
|
940
910
|
db.prepare(`
|
|
941
911
|
INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id)
|
|
942
912
|
VALUES (?, ?, ?)
|
|
943
|
-
`).run(
|
|
913
|
+
`).run(canonicalSceneId, project_id, canonicalCharacterId);
|
|
944
914
|
db.prepare(`
|
|
945
915
|
INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id)
|
|
946
916
|
VALUES (?, ?, ?)
|
|
947
|
-
`).run(
|
|
917
|
+
`).run(canonicalSceneId, project_id, canonicalPlaceId);
|
|
948
918
|
db.exec("COMMIT");
|
|
949
919
|
} catch (err) {
|
|
950
920
|
try {
|
|
@@ -952,10 +922,10 @@ export function registerMetadataTools(s, {
|
|
|
952
922
|
} catch (rollbackErr) {
|
|
953
923
|
void rollbackErr;
|
|
954
924
|
}
|
|
955
|
-
return errorResponse("IO_ERROR", `Failed to connect character/place evidence for scene '${
|
|
925
|
+
return errorResponse("IO_ERROR", `Failed to connect character/place evidence for scene '${canonicalSceneId}': ${err.message}`);
|
|
956
926
|
}
|
|
957
927
|
|
|
958
|
-
const after = querySceneRelationshipSnapshot(db, { sceneId:
|
|
928
|
+
const after = querySceneRelationshipSnapshot(db, { sceneId: canonicalSceneId, projectId: project_id });
|
|
959
929
|
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
960
930
|
syncDir: SYNC_DIR,
|
|
961
931
|
projectId: project_id,
|
|
@@ -963,11 +933,11 @@ export function registerMetadataTools(s, {
|
|
|
963
933
|
operation: "connect_character_place_evidence",
|
|
964
934
|
actor: createToolActor("connect_character_place_evidence"),
|
|
965
935
|
affected: {
|
|
966
|
-
scenes: [
|
|
967
|
-
characters: [
|
|
968
|
-
places: [
|
|
936
|
+
scenes: [canonicalSceneId],
|
|
937
|
+
characters: [canonicalCharacterId],
|
|
938
|
+
places: [canonicalPlaceId],
|
|
969
939
|
},
|
|
970
|
-
summary: `Connected character "${
|
|
940
|
+
summary: `Connected character "${canonicalCharacterId}" and place "${canonicalPlaceId}" as evidence in scene "${canonicalSceneId}".`,
|
|
971
941
|
before: {
|
|
972
942
|
scene_relationships: before,
|
|
973
943
|
},
|
|
@@ -984,10 +954,10 @@ export function registerMetadataTools(s, {
|
|
|
984
954
|
compatibilityDiagnostics.push({
|
|
985
955
|
code: "STALE_PATH",
|
|
986
956
|
severity: "warning",
|
|
987
|
-
message: `Canonical scene relationship evidence was committed, but scene '${
|
|
957
|
+
message: `Canonical scene relationship evidence was committed, but scene '${canonicalSceneId}' has no indexed file path for generated compatibility output.`,
|
|
988
958
|
next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
|
|
989
959
|
details: {
|
|
990
|
-
scene_id,
|
|
960
|
+
scene_id: canonicalSceneId,
|
|
991
961
|
project_id,
|
|
992
962
|
indexed_path: null,
|
|
993
963
|
},
|
|
@@ -996,7 +966,7 @@ export function registerMetadataTools(s, {
|
|
|
996
966
|
try {
|
|
997
967
|
writeSceneRelationshipCompatibilityOutput({
|
|
998
968
|
db,
|
|
999
|
-
sceneId:
|
|
969
|
+
sceneId: canonicalSceneId,
|
|
1000
970
|
projectId: project_id,
|
|
1001
971
|
scenePath: scene.file_path,
|
|
1002
972
|
syncDir: SYNC_DIR,
|
|
@@ -1009,7 +979,7 @@ export function registerMetadataTools(s, {
|
|
|
1009
979
|
message: `Canonical scene relationship evidence was committed, but generated scene metadata compatibility output could not be refreshed: ${err.message}`,
|
|
1010
980
|
next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
|
|
1011
981
|
details: {
|
|
1012
|
-
scene_id,
|
|
982
|
+
scene_id: canonicalSceneId,
|
|
1013
983
|
project_id,
|
|
1014
984
|
indexed_path: scene.file_path,
|
|
1015
985
|
},
|
|
@@ -1017,13 +987,15 @@ export function registerMetadataTools(s, {
|
|
|
1017
987
|
}
|
|
1018
988
|
}
|
|
1019
989
|
|
|
990
|
+
const resolvedFrom = mergeResolvedFrom(sceneResolution, characterResolution, placeResolution);
|
|
1020
991
|
return jsonResponse({
|
|
1021
992
|
ok: true,
|
|
1022
993
|
action: "connected",
|
|
1023
|
-
scene_id,
|
|
994
|
+
scene_id: canonicalSceneId,
|
|
1024
995
|
project_id,
|
|
1025
|
-
character_id,
|
|
1026
|
-
place_id,
|
|
996
|
+
character_id: canonicalCharacterId,
|
|
997
|
+
place_id: canonicalPlaceId,
|
|
998
|
+
...(resolvedFrom ? { resolved_from: resolvedFrom } : {}),
|
|
1027
999
|
note: note ?? null,
|
|
1028
1000
|
mutation_order: [
|
|
1029
1001
|
"validated_request",
|
|
@@ -1059,45 +1031,36 @@ export function registerMetadataTools(s, {
|
|
|
1059
1031
|
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1060
1032
|
}
|
|
1061
1033
|
|
|
1034
|
+
const sceneResolution = resolveSceneTarget(db, {
|
|
1035
|
+
projectId: project_id,
|
|
1036
|
+
input: scene_id,
|
|
1037
|
+
argumentName: "scene_id",
|
|
1038
|
+
});
|
|
1039
|
+
if (!sceneResolution.ok) {
|
|
1040
|
+
return relationshipEvidenceResolutionError(errorResponse, sceneResolution);
|
|
1041
|
+
}
|
|
1042
|
+
const canonicalSceneId = sceneResolution.scene_id;
|
|
1043
|
+
|
|
1062
1044
|
const scene = db.prepare(`
|
|
1063
1045
|
SELECT scene_id, project_id, file_path
|
|
1064
1046
|
FROM scenes
|
|
1065
1047
|
WHERE scene_id = ? AND project_id = ?
|
|
1066
|
-
`).get(
|
|
1067
|
-
if (!scene) {
|
|
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
|
-
);
|
|
1077
|
-
}
|
|
1048
|
+
`).get(canonicalSceneId, project_id);
|
|
1078
1049
|
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
);
|
|
1050
|
+
const entityResolution = resolveEntity(entity_id);
|
|
1051
|
+
if (!entityResolution.ok) {
|
|
1052
|
+
return relationshipEvidenceResolutionError(errorResponse, entityResolution, { sceneId: canonicalSceneId });
|
|
1091
1053
|
}
|
|
1054
|
+
const canonicalEntityId = entityResolution[idField];
|
|
1092
1055
|
|
|
1093
|
-
const before = querySceneRelationshipSnapshot(db, { sceneId:
|
|
1094
|
-
const alreadyLinked = before[entityKind === "character" ? "characters" : "places"].includes(
|
|
1056
|
+
const before = querySceneRelationshipSnapshot(db, { sceneId: canonicalSceneId, projectId: project_id });
|
|
1057
|
+
const alreadyLinked = before[entityKind === "character" ? "characters" : "places"].includes(canonicalEntityId);
|
|
1095
1058
|
try {
|
|
1096
1059
|
db.exec("BEGIN");
|
|
1097
1060
|
db.prepare(`
|
|
1098
1061
|
INSERT OR IGNORE INTO ${tableName} (scene_id, project_id, ${idField})
|
|
1099
1062
|
VALUES (?, ?, ?)
|
|
1100
|
-
`).run(
|
|
1063
|
+
`).run(canonicalSceneId, project_id, canonicalEntityId);
|
|
1101
1064
|
db.exec("COMMIT");
|
|
1102
1065
|
} catch (err) {
|
|
1103
1066
|
try {
|
|
@@ -1105,10 +1068,10 @@ export function registerMetadataTools(s, {
|
|
|
1105
1068
|
} catch (rollbackErr) {
|
|
1106
1069
|
void rollbackErr;
|
|
1107
1070
|
}
|
|
1108
|
-
return errorResponse("IO_ERROR", `Failed to connect scene ${entityKind} evidence for scene '${
|
|
1071
|
+
return errorResponse("IO_ERROR", `Failed to connect scene ${entityKind} evidence for scene '${canonicalSceneId}': ${err.message}`);
|
|
1109
1072
|
}
|
|
1110
1073
|
|
|
1111
|
-
const after = querySceneRelationshipSnapshot(db, { sceneId:
|
|
1074
|
+
const after = querySceneRelationshipSnapshot(db, { sceneId: canonicalSceneId, projectId: project_id });
|
|
1112
1075
|
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
1113
1076
|
syncDir: SYNC_DIR,
|
|
1114
1077
|
projectId: project_id,
|
|
@@ -1116,10 +1079,10 @@ export function registerMetadataTools(s, {
|
|
|
1116
1079
|
operation,
|
|
1117
1080
|
actor: createToolActor(operation),
|
|
1118
1081
|
affected: {
|
|
1119
|
-
scenes: [
|
|
1120
|
-
[`${entityKind}s`]: [
|
|
1082
|
+
scenes: [canonicalSceneId],
|
|
1083
|
+
[`${entityKind}s`]: [canonicalEntityId],
|
|
1121
1084
|
},
|
|
1122
|
-
summary: `Connected ${entityKind} "${
|
|
1085
|
+
summary: `Connected ${entityKind} "${canonicalEntityId}" as evidence in scene "${canonicalSceneId}".`,
|
|
1123
1086
|
before: {
|
|
1124
1087
|
scene_relationships: before,
|
|
1125
1088
|
},
|
|
@@ -1136,10 +1099,10 @@ export function registerMetadataTools(s, {
|
|
|
1136
1099
|
compatibilityDiagnostics.push({
|
|
1137
1100
|
code: "STALE_PATH",
|
|
1138
1101
|
severity: "warning",
|
|
1139
|
-
message: `Canonical scene ${entityKind} evidence was committed, but scene '${
|
|
1102
|
+
message: `Canonical scene ${entityKind} evidence was committed, but scene '${canonicalSceneId}' has no indexed file path for generated compatibility output.`,
|
|
1140
1103
|
next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
|
|
1141
1104
|
details: {
|
|
1142
|
-
scene_id,
|
|
1105
|
+
scene_id: canonicalSceneId,
|
|
1143
1106
|
project_id,
|
|
1144
1107
|
indexed_path: null,
|
|
1145
1108
|
},
|
|
@@ -1148,7 +1111,7 @@ export function registerMetadataTools(s, {
|
|
|
1148
1111
|
try {
|
|
1149
1112
|
writeSceneRelationshipCompatibilityOutput({
|
|
1150
1113
|
db,
|
|
1151
|
-
sceneId:
|
|
1114
|
+
sceneId: canonicalSceneId,
|
|
1152
1115
|
projectId: project_id,
|
|
1153
1116
|
scenePath: scene.file_path,
|
|
1154
1117
|
syncDir: SYNC_DIR,
|
|
@@ -1161,7 +1124,7 @@ export function registerMetadataTools(s, {
|
|
|
1161
1124
|
message: `Canonical scene ${entityKind} evidence was committed, but generated scene metadata compatibility output could not be refreshed: ${err.message}`,
|
|
1162
1125
|
next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
|
|
1163
1126
|
details: {
|
|
1164
|
-
scene_id,
|
|
1127
|
+
scene_id: canonicalSceneId,
|
|
1165
1128
|
project_id,
|
|
1166
1129
|
indexed_path: scene.file_path,
|
|
1167
1130
|
},
|
|
@@ -1169,6 +1132,7 @@ export function registerMetadataTools(s, {
|
|
|
1169
1132
|
}
|
|
1170
1133
|
}
|
|
1171
1134
|
|
|
1135
|
+
const resolvedFrom = mergeResolvedFrom(sceneResolution, entityResolution);
|
|
1172
1136
|
return jsonResponse({
|
|
1173
1137
|
ok: true,
|
|
1174
1138
|
action: "connected",
|
|
@@ -1177,9 +1141,10 @@ export function registerMetadataTools(s, {
|
|
|
1177
1141
|
...(alreadyLinked
|
|
1178
1142
|
? { next_step: alreadyLinkedRelationshipNextStep({ entityKind }) }
|
|
1179
1143
|
: {}),
|
|
1180
|
-
scene_id,
|
|
1144
|
+
scene_id: canonicalSceneId,
|
|
1181
1145
|
project_id,
|
|
1182
|
-
[idField]:
|
|
1146
|
+
[idField]: canonicalEntityId,
|
|
1147
|
+
...(resolvedFrom ? { resolved_from: resolvedFrom } : {}),
|
|
1183
1148
|
note: note ?? null,
|
|
1184
1149
|
scene_relationships: after,
|
|
1185
1150
|
mutation_order: [
|
|
@@ -1207,7 +1172,11 @@ export function registerMetadataTools(s, {
|
|
|
1207
1172
|
entityKind: "character",
|
|
1208
1173
|
idField: "character_id",
|
|
1209
1174
|
tableName: "scene_characters",
|
|
1210
|
-
resolveEntity: (characterId) =>
|
|
1175
|
+
resolveEntity: (characterId) => resolveCharacterTargetForProject(db, {
|
|
1176
|
+
projectId: args.project_id,
|
|
1177
|
+
input: characterId,
|
|
1178
|
+
argumentName: "character_id",
|
|
1179
|
+
}),
|
|
1211
1180
|
});
|
|
1212
1181
|
}
|
|
1213
1182
|
|
|
@@ -1222,7 +1191,11 @@ export function registerMetadataTools(s, {
|
|
|
1222
1191
|
entityKind: "place",
|
|
1223
1192
|
idField: "place_id",
|
|
1224
1193
|
tableName: "scene_places",
|
|
1225
|
-
resolveEntity: (placeId) =>
|
|
1194
|
+
resolveEntity: (placeId) => resolvePlaceTargetForProject(db, {
|
|
1195
|
+
projectId: args.project_id,
|
|
1196
|
+
input: placeId,
|
|
1197
|
+
argumentName: "place_id",
|
|
1198
|
+
}),
|
|
1226
1199
|
});
|
|
1227
1200
|
}
|
|
1228
1201
|
|
|
@@ -1733,12 +1706,12 @@ export function registerMetadataTools(s, {
|
|
|
1733
1706
|
// ---- connect_character_place_evidence ------------------------------------
|
|
1734
1707
|
s.tool(
|
|
1735
1708
|
"connect_character_place_evidence",
|
|
1736
|
-
"Connect a character and place as paired scene-backed story evidence. This outcome-level workflow covers sheet-backed character/place associations: SQLite scene relationship indexes commit first, project backups refresh after commit, and scene sidecar characters/places are regenerated only as generated compatibility output from canonical indexes. Use connect_scene_character_evidence or connect_scene_place_evidence for one-sided scene evidence.",
|
|
1709
|
+
"Connect a character and place as paired scene-backed story evidence. This outcome-level workflow covers sheet-backed character/place associations: SQLite scene relationship indexes commit first, project backups refresh after commit, and scene sidecar characters/places are regenerated only as generated compatibility output from canonical indexes. Canonical IDs are preferred; unambiguous case-insensitive scene titles, character names, place names, and ID variants are resolved to canonical IDs before mutation. Ambiguous or suggested-only matches fail without mutating state. Use connect_scene_character_evidence or connect_scene_place_evidence for one-sided scene evidence.",
|
|
1737
1710
|
{
|
|
1738
1711
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
1739
|
-
scene_id: z.string().describe("Scene that provides the evidence
|
|
1740
|
-
character_id: z.string().describe("Character present in the scene.
|
|
1741
|
-
place_id: z.string().describe("Place present in the scene.
|
|
1712
|
+
scene_id: z.string().describe("Scene that provides the evidence. Prefer canonical scene_id; an unambiguous case-insensitive scene_id or unique scene title in this project is also accepted."),
|
|
1713
|
+
character_id: z.string().describe("Character present in the scene. Prefer canonical character_id; an unambiguous case-insensitive character_id or character name in this project/universe scope is also accepted."),
|
|
1714
|
+
place_id: z.string().describe("Place present in the scene. Prefer canonical place_id; an unambiguous case-insensitive place_id or place name in this project/universe scope is also accepted."),
|
|
1742
1715
|
note: z.string().optional().describe("Optional review note explaining the evidence. Stored in operation history, not in compatibility sidecars."),
|
|
1743
1716
|
},
|
|
1744
1717
|
async (args) => connectCharacterPlaceEvidence(args)
|
|
@@ -1747,11 +1720,11 @@ export function registerMetadataTools(s, {
|
|
|
1747
1720
|
// ---- connect_scene_character_evidence -----------------------------------
|
|
1748
1721
|
s.tool(
|
|
1749
1722
|
"connect_scene_character_evidence",
|
|
1750
|
-
"Connect a sheet-backed character to a scene without requiring paired place evidence. This outcome-level workflow records character-only scene evidence in SQLite first, refreshes project backups after commit, and regenerates scene sidecar characters/places only as generated compatibility output from canonical indexes.",
|
|
1723
|
+
"Connect a sheet-backed character to a scene without requiring paired place evidence. This outcome-level workflow records character-only scene evidence in SQLite first, refreshes project backups after commit, and regenerates scene sidecar characters/places only as generated compatibility output from canonical indexes. Canonical IDs are preferred; unambiguous case-insensitive scene titles, character names, and ID variants are resolved to canonical IDs before mutation. Ambiguous or suggested-only matches fail without mutating state.",
|
|
1751
1724
|
{
|
|
1752
1725
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
1753
|
-
scene_id: z.string().describe("Scene that provides the evidence
|
|
1754
|
-
character_id: z.string().describe("Sheet-backed
|
|
1726
|
+
scene_id: z.string().describe("Scene that provides the evidence. Prefer canonical scene_id; an unambiguous case-insensitive scene_id or unique scene title in this project is also accepted."),
|
|
1727
|
+
character_id: z.string().describe("Sheet-backed character present in the scene. Prefer canonical character_id; an unambiguous case-insensitive character_id or character name in this project/universe scope is also accepted."),
|
|
1755
1728
|
note: z.string().optional().describe("Optional review note explaining the evidence. Stored in operation history, not in compatibility sidecars."),
|
|
1756
1729
|
},
|
|
1757
1730
|
async (args) => connectSceneCharacterEvidence(args)
|
|
@@ -1760,11 +1733,11 @@ export function registerMetadataTools(s, {
|
|
|
1760
1733
|
// ---- connect_scene_place_evidence ---------------------------------------
|
|
1761
1734
|
s.tool(
|
|
1762
1735
|
"connect_scene_place_evidence",
|
|
1763
|
-
"Connect a sheet-backed place to a scene without requiring paired character evidence. This outcome-level workflow records place-only scene evidence in SQLite first, refreshes project backups after commit, and regenerates scene sidecar characters/places only as generated compatibility output from canonical indexes.",
|
|
1736
|
+
"Connect a sheet-backed place to a scene without requiring paired character evidence. This outcome-level workflow records place-only scene evidence in SQLite first, refreshes project backups after commit, and regenerates scene sidecar characters/places only as generated compatibility output from canonical indexes. Canonical IDs are preferred; unambiguous case-insensitive scene titles, place names, and ID variants are resolved to canonical IDs before mutation. Ambiguous or suggested-only matches fail without mutating state.",
|
|
1764
1737
|
{
|
|
1765
1738
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
1766
|
-
scene_id: z.string().describe("Scene that provides the evidence
|
|
1767
|
-
place_id: z.string().describe("Sheet-backed
|
|
1739
|
+
scene_id: z.string().describe("Scene that provides the evidence. Prefer canonical scene_id; an unambiguous case-insensitive scene_id or unique scene title in this project is also accepted."),
|
|
1740
|
+
place_id: z.string().describe("Sheet-backed place present in the scene. Prefer canonical place_id; an unambiguous case-insensitive place_id or place name in this project/universe scope is also accepted."),
|
|
1768
1741
|
note: z.string().optional().describe("Optional review note explaining the evidence. Stored in operation history, not in compatibility sidecars."),
|
|
1769
1742
|
},
|
|
1770
1743
|
async (args) => connectScenePlaceEvidence(args)
|