@hanna84/mcp-writing 3.25.0 → 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/core/canonical-target-resolution.js +405 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.26.0](https://github.com/hannasdev/mcp-writing/compare/v3.25.0...v3.26.0)
|
|
8
|
+
|
|
9
|
+
- feat: add canonical target resolver [`#236`](https://github.com/hannasdev/mcp-writing/pull/236)
|
|
10
|
+
|
|
7
11
|
#### [v3.25.0](https://github.com/hannasdev/mcp-writing/compare/v3.24.1...v3.25.0)
|
|
8
12
|
|
|
13
|
+
> 6 June 2026
|
|
14
|
+
|
|
9
15
|
- feat: clarify M1 workflow and relationship responses [`#235`](https://github.com/hannasdev/mcp-writing/pull/235)
|
|
16
|
+
- Release 3.25.0 [`45b7c47`](https://github.com/hannasdev/mcp-writing/commit/45b7c4782ca6d6e54d66a49bcee5ae40d3f1cd69)
|
|
10
17
|
|
|
11
18
|
#### [v3.24.1](https://github.com/hannasdev/mcp-writing/compare/v3.24.0...v3.24.1)
|
|
12
19
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
const DEFAULT_CANDIDATE_LIMIT = 5;
|
|
2
|
+
const HARD_CANDIDATE_LIMIT = 10;
|
|
3
|
+
|
|
4
|
+
const MATCH_GROUP_ORDER = new Map([
|
|
5
|
+
["exact_id", 0],
|
|
6
|
+
["case_insensitive_id", 1],
|
|
7
|
+
["case_insensitive_name", 2],
|
|
8
|
+
["case_insensitive_title", 2],
|
|
9
|
+
["near_match_suggestion", 3],
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function normalizeValue(value) {
|
|
13
|
+
return String(value ?? "").trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clampCandidateLimit(candidateLimit) {
|
|
17
|
+
if (!Number.isInteger(candidateLimit) || candidateLimit <= 0) {
|
|
18
|
+
return DEFAULT_CANDIDATE_LIMIT;
|
|
19
|
+
}
|
|
20
|
+
return Math.min(candidateLimit, HARD_CANDIDATE_LIMIT);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getProjectUniverseId(db, projectId) {
|
|
24
|
+
return db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(projectId)?.universe_id ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function levenshteinDistance(left, right) {
|
|
28
|
+
if (left === right) return 0;
|
|
29
|
+
if (left.length === 0) return right.length;
|
|
30
|
+
if (right.length === 0) return left.length;
|
|
31
|
+
|
|
32
|
+
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
33
|
+
const current = Array.from({ length: right.length + 1 }, () => 0);
|
|
34
|
+
|
|
35
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
36
|
+
current[0] = leftIndex;
|
|
37
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
|
38
|
+
const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
|
|
39
|
+
current[rightIndex] = Math.min(
|
|
40
|
+
current[rightIndex - 1] + 1,
|
|
41
|
+
previous[rightIndex] + 1,
|
|
42
|
+
previous[rightIndex - 1] + cost
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
for (let index = 0; index < previous.length; index += 1) {
|
|
46
|
+
previous[index] = current[index];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return previous[right.length];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isNearMatch(input, value) {
|
|
54
|
+
const normalizedInput = normalizeValue(input);
|
|
55
|
+
const normalizedValue = normalizeValue(value);
|
|
56
|
+
if (!normalizedInput || !normalizedValue) return false;
|
|
57
|
+
if (normalizedInput.length < 3 || normalizedValue.length < 3) return false;
|
|
58
|
+
if (normalizedValue.includes(normalizedInput) || normalizedInput.includes(normalizedValue)) return true;
|
|
59
|
+
|
|
60
|
+
const distance = levenshteinDistance(normalizedInput, normalizedValue);
|
|
61
|
+
const threshold = Math.max(1, Math.floor(Math.max(normalizedInput.length, normalizedValue.length) / 4));
|
|
62
|
+
return distance <= threshold;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sortCandidates(candidates) {
|
|
66
|
+
return [...candidates].sort((left, right) => {
|
|
67
|
+
const groupDelta = (MATCH_GROUP_ORDER.get(left.match_type) ?? 99) - (MATCH_GROUP_ORDER.get(right.match_type) ?? 99);
|
|
68
|
+
if (groupDelta !== 0) return groupDelta;
|
|
69
|
+
|
|
70
|
+
const labelDelta = normalizeValue(left.label).localeCompare(normalizeValue(right.label));
|
|
71
|
+
if (labelDelta !== 0) return labelDelta;
|
|
72
|
+
|
|
73
|
+
return left.id.localeCompare(right.id);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function capCandidates(candidates, candidateLimit) {
|
|
78
|
+
return sortCandidates(candidates).slice(0, clampCandidateLimit(candidateLimit));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatSceneCandidate(row, { matchedField, matchType }) {
|
|
82
|
+
return {
|
|
83
|
+
target_kind: "scene",
|
|
84
|
+
id: row.scene_id,
|
|
85
|
+
label: row.title || row.scene_id,
|
|
86
|
+
matched_field: matchedField,
|
|
87
|
+
match_type: matchType,
|
|
88
|
+
project_id: row.project_id,
|
|
89
|
+
context: {
|
|
90
|
+
project_id: row.project_id,
|
|
91
|
+
title: row.title ?? null,
|
|
92
|
+
chapter_id: row.chapter_id ?? null,
|
|
93
|
+
chapter_title: row.chapter_title ?? null,
|
|
94
|
+
timeline_position: row.timeline_position ?? null,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatCharacterCandidate(row, { matchedField, matchType }) {
|
|
100
|
+
return {
|
|
101
|
+
target_kind: "character",
|
|
102
|
+
id: row.character_id,
|
|
103
|
+
label: row.name || row.character_id,
|
|
104
|
+
matched_field: matchedField,
|
|
105
|
+
match_type: matchType,
|
|
106
|
+
project_id: row.project_id ?? null,
|
|
107
|
+
universe_id: row.universe_id ?? null,
|
|
108
|
+
context: {
|
|
109
|
+
role: row.role ?? null,
|
|
110
|
+
first_appearance: row.first_appearance ?? null,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatPlaceCandidate(row, { matchedField, matchType }) {
|
|
116
|
+
return {
|
|
117
|
+
target_kind: "place",
|
|
118
|
+
id: row.place_id,
|
|
119
|
+
label: row.name || row.place_id,
|
|
120
|
+
matched_field: matchedField,
|
|
121
|
+
match_type: matchType,
|
|
122
|
+
project_id: row.project_id ?? null,
|
|
123
|
+
universe_id: row.universe_id ?? null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildResolvedFrom(argumentName, input, candidate) {
|
|
128
|
+
if (candidate.match_type === "exact_id") return undefined;
|
|
129
|
+
return {
|
|
130
|
+
[argumentName]: {
|
|
131
|
+
input,
|
|
132
|
+
matched_field: candidate.matched_field,
|
|
133
|
+
match_type: candidate.match_type,
|
|
134
|
+
id: candidate.id,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function nextStepForTargetKind(targetKind) {
|
|
140
|
+
if (targetKind === "scene") {
|
|
141
|
+
return "Use find_scenes with project_id to identify the canonical scene_id, then retry with the stable ID.";
|
|
142
|
+
}
|
|
143
|
+
if (targetKind === "character") {
|
|
144
|
+
return "Use list_characters to inspect candidate character_id values for this project or universe, then retry with the stable ID.";
|
|
145
|
+
}
|
|
146
|
+
return "Use list_places to inspect candidate place_id values for this project or universe, then retry with the stable ID.";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildResolutionFailure({ targetKind, input, projectId, universeId, candidates, candidateLimit }) {
|
|
150
|
+
const cappedCandidates = capCandidates(candidates, candidateLimit);
|
|
151
|
+
const details = {
|
|
152
|
+
lookup_kind: targetKind,
|
|
153
|
+
target_kind: targetKind,
|
|
154
|
+
input,
|
|
155
|
+
project_id: projectId,
|
|
156
|
+
...(universeId !== undefined ? { universe_id: universeId } : {}),
|
|
157
|
+
candidate_matches: cappedCandidates,
|
|
158
|
+
next_step: nextStepForTargetKind(targetKind),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (candidates.some(candidate => candidate.match_type !== "near_match_suggestion")) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: {
|
|
165
|
+
code: "AMBIGUOUS_TARGET",
|
|
166
|
+
message: `${targetKind} '${input}' resolves to multiple canonical targets. Use a stable canonical ID.`,
|
|
167
|
+
details,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
error: {
|
|
175
|
+
code: "NOT_FOUND",
|
|
176
|
+
message: `${targetKind} '${input}' was not found in the provided scope.`,
|
|
177
|
+
details,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildSuccess({ targetKind, input, argumentName, row, candidate, idField, rowField }) {
|
|
183
|
+
const resolvedFrom = buildResolvedFrom(argumentName, input, candidate);
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
target_kind: targetKind,
|
|
187
|
+
id: candidate.id,
|
|
188
|
+
[idField]: candidate.id,
|
|
189
|
+
[rowField]: row,
|
|
190
|
+
canonical: candidate.match_type === "exact_id",
|
|
191
|
+
match: {
|
|
192
|
+
matched_field: candidate.matched_field,
|
|
193
|
+
match_type: candidate.match_type,
|
|
194
|
+
id: candidate.id,
|
|
195
|
+
},
|
|
196
|
+
...(resolvedFrom ? { resolved_from: resolvedFrom } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveFromRows({
|
|
201
|
+
rows,
|
|
202
|
+
input,
|
|
203
|
+
targetKind,
|
|
204
|
+
idField,
|
|
205
|
+
nameField,
|
|
206
|
+
nameMatchType,
|
|
207
|
+
argumentName,
|
|
208
|
+
projectId,
|
|
209
|
+
universeId,
|
|
210
|
+
candidateLimit,
|
|
211
|
+
formatCandidate,
|
|
212
|
+
}) {
|
|
213
|
+
const normalizedInput = normalizeValue(input);
|
|
214
|
+
const exactIdRows = rows.filter(row => row[idField] === input);
|
|
215
|
+
if (exactIdRows.length === 1) {
|
|
216
|
+
const candidate = formatCandidate(exactIdRows[0], { matchedField: idField, matchType: "exact_id" });
|
|
217
|
+
return buildSuccess({
|
|
218
|
+
targetKind,
|
|
219
|
+
input,
|
|
220
|
+
argumentName,
|
|
221
|
+
row: exactIdRows[0],
|
|
222
|
+
candidate,
|
|
223
|
+
idField,
|
|
224
|
+
rowField: targetKind,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const caseInsensitiveIdRows = rows.filter(row => normalizeValue(row[idField]) === normalizedInput);
|
|
229
|
+
if (caseInsensitiveIdRows.length === 1) {
|
|
230
|
+
const candidate = formatCandidate(caseInsensitiveIdRows[0], { matchedField: idField, matchType: "case_insensitive_id" });
|
|
231
|
+
return buildSuccess({
|
|
232
|
+
targetKind,
|
|
233
|
+
input,
|
|
234
|
+
argumentName,
|
|
235
|
+
row: caseInsensitiveIdRows[0],
|
|
236
|
+
candidate,
|
|
237
|
+
idField,
|
|
238
|
+
rowField: targetKind,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (caseInsensitiveIdRows.length > 1) {
|
|
242
|
+
return buildResolutionFailure({
|
|
243
|
+
targetKind,
|
|
244
|
+
input,
|
|
245
|
+
projectId,
|
|
246
|
+
universeId,
|
|
247
|
+
candidateLimit,
|
|
248
|
+
candidates: caseInsensitiveIdRows.map(row => formatCandidate(row, {
|
|
249
|
+
matchedField: idField,
|
|
250
|
+
matchType: "case_insensitive_id",
|
|
251
|
+
})),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const caseInsensitiveNameRows = rows.filter(row => normalizeValue(row[nameField]) === normalizedInput);
|
|
256
|
+
if (caseInsensitiveNameRows.length === 1) {
|
|
257
|
+
const candidate = formatCandidate(caseInsensitiveNameRows[0], { matchedField: nameField, matchType: nameMatchType });
|
|
258
|
+
return buildSuccess({
|
|
259
|
+
targetKind,
|
|
260
|
+
input,
|
|
261
|
+
argumentName,
|
|
262
|
+
row: caseInsensitiveNameRows[0],
|
|
263
|
+
candidate,
|
|
264
|
+
idField,
|
|
265
|
+
rowField: targetKind,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (caseInsensitiveNameRows.length > 1) {
|
|
269
|
+
return buildResolutionFailure({
|
|
270
|
+
targetKind,
|
|
271
|
+
input,
|
|
272
|
+
projectId,
|
|
273
|
+
universeId,
|
|
274
|
+
candidateLimit,
|
|
275
|
+
candidates: caseInsensitiveNameRows.map(row => formatCandidate(row, {
|
|
276
|
+
matchedField: nameField,
|
|
277
|
+
matchType: nameMatchType,
|
|
278
|
+
})),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const suggestionsById = rows
|
|
283
|
+
.filter(row => isNearMatch(input, row[idField]))
|
|
284
|
+
.map(row => formatCandidate(row, { matchedField: idField, matchType: "near_match_suggestion" }));
|
|
285
|
+
const suggestedIds = new Set(suggestionsById.map(candidate => candidate.id));
|
|
286
|
+
const suggestionsByName = rows
|
|
287
|
+
.filter(row => !suggestedIds.has(row[idField]) && isNearMatch(input, row[nameField]))
|
|
288
|
+
.map(row => formatCandidate(row, { matchedField: nameField, matchType: "near_match_suggestion" }));
|
|
289
|
+
|
|
290
|
+
return buildResolutionFailure({
|
|
291
|
+
targetKind,
|
|
292
|
+
input,
|
|
293
|
+
projectId,
|
|
294
|
+
universeId,
|
|
295
|
+
candidateLimit,
|
|
296
|
+
candidates: [...suggestionsById, ...suggestionsByName],
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function resolveSceneTarget(db, {
|
|
301
|
+
projectId,
|
|
302
|
+
input,
|
|
303
|
+
argumentName = "scene_id",
|
|
304
|
+
candidateLimit = DEFAULT_CANDIDATE_LIMIT,
|
|
305
|
+
} = {}) {
|
|
306
|
+
const rows = db.prepare(`
|
|
307
|
+
SELECT scene_id, project_id, title, chapter_id, chapter_title, timeline_position
|
|
308
|
+
FROM scenes
|
|
309
|
+
WHERE project_id = ?
|
|
310
|
+
ORDER BY title COLLATE NOCASE, scene_id
|
|
311
|
+
`).all(projectId);
|
|
312
|
+
|
|
313
|
+
return resolveFromRows({
|
|
314
|
+
rows,
|
|
315
|
+
input,
|
|
316
|
+
targetKind: "scene",
|
|
317
|
+
idField: "scene_id",
|
|
318
|
+
nameField: "title",
|
|
319
|
+
nameMatchType: "case_insensitive_title",
|
|
320
|
+
argumentName,
|
|
321
|
+
projectId,
|
|
322
|
+
universeId: undefined,
|
|
323
|
+
candidateLimit,
|
|
324
|
+
formatCandidate: formatSceneCandidate,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function selectRelationshipScopedCharacters(db, { projectId }) {
|
|
329
|
+
const universeId = getProjectUniverseId(db, projectId);
|
|
330
|
+
return {
|
|
331
|
+
universeId,
|
|
332
|
+
rows: db.prepare(`
|
|
333
|
+
SELECT character_id, project_id, universe_id, name, role, first_appearance
|
|
334
|
+
FROM characters
|
|
335
|
+
WHERE project_id = ?
|
|
336
|
+
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
337
|
+
OR (project_id IS NULL AND universe_id IS NULL)
|
|
338
|
+
ORDER BY name COLLATE NOCASE, character_id
|
|
339
|
+
`).all(projectId, universeId),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function selectRelationshipScopedPlaces(db, { projectId }) {
|
|
344
|
+
const universeId = getProjectUniverseId(db, projectId);
|
|
345
|
+
return {
|
|
346
|
+
universeId,
|
|
347
|
+
rows: db.prepare(`
|
|
348
|
+
SELECT place_id, project_id, universe_id, name
|
|
349
|
+
FROM places
|
|
350
|
+
WHERE project_id = ?
|
|
351
|
+
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
352
|
+
OR (project_id IS NULL AND universe_id IS NULL)
|
|
353
|
+
ORDER BY name COLLATE NOCASE, place_id
|
|
354
|
+
`).all(projectId, universeId),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function resolveCharacterTargetForProject(db, {
|
|
359
|
+
projectId,
|
|
360
|
+
input,
|
|
361
|
+
argumentName = "character_id",
|
|
362
|
+
candidateLimit = DEFAULT_CANDIDATE_LIMIT,
|
|
363
|
+
} = {}) {
|
|
364
|
+
const { universeId, rows } = selectRelationshipScopedCharacters(db, { projectId });
|
|
365
|
+
return resolveFromRows({
|
|
366
|
+
rows,
|
|
367
|
+
input,
|
|
368
|
+
targetKind: "character",
|
|
369
|
+
idField: "character_id",
|
|
370
|
+
nameField: "name",
|
|
371
|
+
nameMatchType: "case_insensitive_name",
|
|
372
|
+
argumentName,
|
|
373
|
+
projectId,
|
|
374
|
+
universeId,
|
|
375
|
+
candidateLimit,
|
|
376
|
+
formatCandidate: formatCharacterCandidate,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function resolvePlaceTargetForProject(db, {
|
|
381
|
+
projectId,
|
|
382
|
+
input,
|
|
383
|
+
argumentName = "place_id",
|
|
384
|
+
candidateLimit = DEFAULT_CANDIDATE_LIMIT,
|
|
385
|
+
} = {}) {
|
|
386
|
+
const { universeId, rows } = selectRelationshipScopedPlaces(db, { projectId });
|
|
387
|
+
return resolveFromRows({
|
|
388
|
+
rows,
|
|
389
|
+
input,
|
|
390
|
+
targetKind: "place",
|
|
391
|
+
idField: "place_id",
|
|
392
|
+
nameField: "name",
|
|
393
|
+
nameMatchType: "case_insensitive_name",
|
|
394
|
+
argumentName,
|
|
395
|
+
projectId,
|
|
396
|
+
universeId,
|
|
397
|
+
candidateLimit,
|
|
398
|
+
formatCandidate: formatPlaceCandidate,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export const CANONICAL_TARGET_CANDIDATE_LIMITS = {
|
|
403
|
+
default: DEFAULT_CANDIDATE_LIMIT,
|
|
404
|
+
hard: HARD_CANDIDATE_LIMIT,
|
|
405
|
+
};
|