@hanna84/mcp-writing 3.27.0 → 3.29.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 +2 -2
- package/package.json +1 -1
- package/src/core/canonical-target-resolution.js +6 -46
- package/src/core/match-utils.js +41 -0
- package/src/core/vocabulary-resolution.js +269 -0
- package/src/structure/project-backup-restore.js +97 -3
- package/src/tools/metadata.js +22 -3
- package/src/tools/search.js +339 -17
- package/src/tools/sync.js +4 -2
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.0](https://github.com/hannasdev/mcp-writing/compare/v3.28.0...v3.29.0)
|
|
8
|
+
|
|
9
|
+
- feat: resolve scene vocabulary variants [`#239`](https://github.com/hannasdev/mcp-writing/pull/239)
|
|
10
|
+
|
|
11
|
+
#### [v3.28.0](https://github.com/hannasdev/mcp-writing/compare/v3.27.0...v3.28.0)
|
|
12
|
+
|
|
13
|
+
> 7 June 2026
|
|
14
|
+
|
|
15
|
+
- feat: make restore plans easier to scan [`#238`](https://github.com/hannasdev/mcp-writing/pull/238)
|
|
16
|
+
- Release 3.28.0 [`f0add03`](https://github.com/hannasdev/mcp-writing/commit/f0add03b915962d72dcac5c745b9000d50d6a819)
|
|
17
|
+
|
|
7
18
|
#### [v3.27.0](https://github.com/hannasdev/mcp-writing/compare/v3.26.0...v3.27.0)
|
|
8
19
|
|
|
20
|
+
> 6 June 2026
|
|
21
|
+
|
|
9
22
|
- feat(metadata): accept forgiving relationship evidence inputs [`#237`](https://github.com/hannasdev/mcp-writing/pull/237)
|
|
23
|
+
- Release 3.27.0 [`ef5423b`](https://github.com/hannasdev/mcp-writing/commit/ef5423b044274b75bf60327772c9a944b94123c1)
|
|
10
24
|
|
|
11
25
|
#### [v3.26.0](https://github.com/hannasdev/mcp-writing/compare/v3.25.0...v3.26.0)
|
|
12
26
|
|
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ const totalCount = parsed.total_count ?? items.length;
|
|
|
129
129
|
Goal: catch inconsistencies before sharing pages.
|
|
130
130
|
|
|
131
131
|
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`).
|
|
132
|
+
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
133
|
3. Use `get_arc` to review that character's ordered progression across the manuscript.
|
|
134
134
|
4. Load only the suspect scenes with `get_scene_prose`.
|
|
135
135
|
5. Attach follow-up notes with `flag_scene` where continuity needs a fix.
|
|
@@ -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. 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.
|
|
155
|
+
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
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
|
@@ -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 =
|
|
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 =
|
|
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 =>
|
|
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 =>
|
|
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
|
+
};
|
|
@@ -873,6 +873,88 @@ function buildRestorePlan(currentSnapshot, backupSnapshot, { projectId }) {
|
|
|
873
873
|
};
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
+
function buildPlanSummary(plan) {
|
|
877
|
+
return {
|
|
878
|
+
totals: { ...plan.totals },
|
|
879
|
+
by_domain: plan.by_domain,
|
|
880
|
+
destructive_change_count: plan.destructive_change_count,
|
|
881
|
+
cross_scope_change_count: plan.cross_scope_change_count,
|
|
882
|
+
has_destructive_changes: plan.destructive_change_count > 0,
|
|
883
|
+
has_cross_scope_changes: plan.cross_scope_change_count > 0,
|
|
884
|
+
requires_destructive_confirmation: plan.destructive_change_count > 0,
|
|
885
|
+
requires_cross_scope_confirmation: plan.cross_scope_change_count > 0,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function buildPlanDetailPolicy({ includeUnchanged, omittedUnchangedChangeCount }) {
|
|
890
|
+
return {
|
|
891
|
+
include_unchanged: includeUnchanged,
|
|
892
|
+
unchanged_rows_included: includeUnchanged,
|
|
893
|
+
omitted_unchanged_change_count: omittedUnchangedChangeCount,
|
|
894
|
+
full_plan_available: true,
|
|
895
|
+
full_plan_next_step: includeUnchanged
|
|
896
|
+
? "This response includes unchanged rows. Pass include_unchanged=false to suppress unchanged row details while keeping plan_summary counts."
|
|
897
|
+
: "Rerun the restore plan with include_unchanged=true or omit include_unchanged to retrieve unchanged row details.",
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function applyPlanDetailPolicy(plan, { includeUnchanged }) {
|
|
902
|
+
if (includeUnchanged) {
|
|
903
|
+
return {
|
|
904
|
+
plan,
|
|
905
|
+
planDetailPolicy: buildPlanDetailPolicy({
|
|
906
|
+
includeUnchanged,
|
|
907
|
+
omittedUnchangedChangeCount: 0,
|
|
908
|
+
}),
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const visibleChanges = [];
|
|
913
|
+
let omittedUnchangedChangeCount = 0;
|
|
914
|
+
for (const change of plan.changes) {
|
|
915
|
+
if (change.action === "unchanged") {
|
|
916
|
+
omittedUnchangedChangeCount += 1;
|
|
917
|
+
} else {
|
|
918
|
+
visibleChanges.push(change);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
plan: {
|
|
924
|
+
...plan,
|
|
925
|
+
changes: visibleChanges,
|
|
926
|
+
},
|
|
927
|
+
planDetailPolicy: buildPlanDetailPolicy({
|
|
928
|
+
includeUnchanged,
|
|
929
|
+
omittedUnchangedChangeCount,
|
|
930
|
+
}),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const BLOCKING_REQUIREMENT_PRIORITY = new Map([
|
|
935
|
+
["project_restore_current_snapshot_confirmation_required", 0],
|
|
936
|
+
["project_restore_current_snapshot_changed", 1],
|
|
937
|
+
["project_restore_destructive_confirmation_required", 2],
|
|
938
|
+
["project_restore_cross_scope_confirmation_required", 3],
|
|
939
|
+
]);
|
|
940
|
+
|
|
941
|
+
function buildBlockingRequirements(diagnostics) {
|
|
942
|
+
return [...diagnostics]
|
|
943
|
+
.sort((left, right) => {
|
|
944
|
+
const priorityDelta =
|
|
945
|
+
(BLOCKING_REQUIREMENT_PRIORITY.get(left.type) ?? 99) -
|
|
946
|
+
(BLOCKING_REQUIREMENT_PRIORITY.get(right.type) ?? 99);
|
|
947
|
+
if (priorityDelta !== 0) return priorityDelta;
|
|
948
|
+
return left.type.localeCompare(right.type);
|
|
949
|
+
})
|
|
950
|
+
.map(diagnostic => ({
|
|
951
|
+
type: diagnostic.type,
|
|
952
|
+
message: diagnostic.message,
|
|
953
|
+
next_step: diagnostic.next_step ?? null,
|
|
954
|
+
details: diagnostic.details,
|
|
955
|
+
}));
|
|
956
|
+
}
|
|
957
|
+
|
|
876
958
|
function placeholders(values) {
|
|
877
959
|
return values.map(() => "?").join(",") || "NULL";
|
|
878
960
|
}
|
|
@@ -1059,6 +1141,7 @@ export function restoreProjectFromBackup(db, {
|
|
|
1059
1141
|
confirmDestructive = false,
|
|
1060
1142
|
confirmCrossScope = false,
|
|
1061
1143
|
expectedCurrentSnapshotChecksum = null,
|
|
1144
|
+
includeUnchanged = true,
|
|
1062
1145
|
applicationVersion = "0.0.0",
|
|
1063
1146
|
} = {}) {
|
|
1064
1147
|
const resolvedBackupDir = resolveBackupDir(backupPath ?? path.join(syncDir, "project-backups", projectId));
|
|
@@ -1150,6 +1233,10 @@ export function restoreProjectFromBackup(db, {
|
|
|
1150
1233
|
}
|
|
1151
1234
|
|
|
1152
1235
|
const plan = buildRestorePlan(current.snapshot, snapshot, { projectId });
|
|
1236
|
+
const planSummary = buildPlanSummary(plan);
|
|
1237
|
+
const { plan: responsePlan, planDetailPolicy } = applyPlanDetailPolicy(plan, {
|
|
1238
|
+
includeUnchanged: includeUnchanged !== false,
|
|
1239
|
+
});
|
|
1153
1240
|
const applyDiagnostics = [];
|
|
1154
1241
|
if (dryRun === false) {
|
|
1155
1242
|
if (typeof expectedCurrentSnapshotChecksum !== "string" || expectedCurrentSnapshotChecksum === "") {
|
|
@@ -1222,8 +1309,11 @@ export function restoreProjectFromBackup(db, {
|
|
|
1222
1309
|
dry_run: Boolean(dryRun),
|
|
1223
1310
|
project_id: projectId,
|
|
1224
1311
|
backup_dir: resolvedBackupDir,
|
|
1312
|
+
blocking_requirements: buildBlockingRequirements(applyDiagnostics),
|
|
1225
1313
|
diagnostics: applyDiagnostics,
|
|
1226
|
-
|
|
1314
|
+
plan_summary: planSummary,
|
|
1315
|
+
plan_detail_policy: planDetailPolicy,
|
|
1316
|
+
plan: responsePlan,
|
|
1227
1317
|
next_step: "Resolve confirmation requirements before applying this trusted backup.",
|
|
1228
1318
|
};
|
|
1229
1319
|
}
|
|
@@ -1259,7 +1349,9 @@ export function restoreProjectFromBackup(db, {
|
|
|
1259
1349
|
},
|
|
1260
1350
|
{ severity: "error", nextStep: "Review the database error and retry after resolving conflicts." }
|
|
1261
1351
|
)],
|
|
1262
|
-
|
|
1352
|
+
plan_summary: planSummary,
|
|
1353
|
+
plan_detail_policy: planDetailPolicy,
|
|
1354
|
+
plan: responsePlan,
|
|
1263
1355
|
next_step: "Resolve restore write diagnostics before retrying.",
|
|
1264
1356
|
};
|
|
1265
1357
|
}
|
|
@@ -1279,7 +1371,9 @@ export function restoreProjectFromBackup(db, {
|
|
|
1279
1371
|
},
|
|
1280
1372
|
current_snapshot_checksum: current.checksum,
|
|
1281
1373
|
backup_snapshot_checksum: manifest.checksums.canonical_snapshot_sha256,
|
|
1282
|
-
|
|
1374
|
+
plan_summary: planSummary,
|
|
1375
|
+
plan_detail_policy: planDetailPolicy,
|
|
1376
|
+
plan: responsePlan,
|
|
1283
1377
|
applied: dryRun ? null : {
|
|
1284
1378
|
restored: true,
|
|
1285
1379
|
destructive_confirmed: Boolean(confirmDestructive),
|
package/src/tools/metadata.js
CHANGED
|
@@ -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) {
|
package/src/tools/search.js
CHANGED
|
@@ -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
|
|
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').
|
|
87
|
-
tag: z.string().optional().describe("Scene tag to filter by.
|
|
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
|
|
104
|
-
|
|
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 (
|
|
130
|
-
|
|
131
|
-
|
|
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 (
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
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 (
|
|
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(
|
|
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,
|
package/src/tools/sync.js
CHANGED
|
@@ -338,7 +338,7 @@ export function registerSyncTools(s, {
|
|
|
338
338
|
|
|
339
339
|
s.tool(
|
|
340
340
|
"restore_project_from_backup",
|
|
341
|
-
"Explicitly restore a project from a trusted generated project backup bundle. Defaults to dry-run planning; dry_run=false applies canonical SQLite changes transactionally after the reviewed current snapshot checksum and required destructive or cross-scope confirmations are provided.",
|
|
341
|
+
"Explicitly restore a project from a trusted generated project backup bundle. Defaults to dry-run planning; dry_run=false applies canonical SQLite changes transactionally after the reviewed current snapshot checksum and required destructive or cross-scope confirmations are provided. Restore plan and confirmation-refusal responses include compact plan_summary details; confirmation refusals include blocking_requirements. include_unchanged=false suppresses unchanged row details while preserving summary counts.",
|
|
342
342
|
{
|
|
343
343
|
project_id: z.string().describe("Project ID to restore (e.g. 'test-novel' or 'universe-1/book-1-the-lamb')."),
|
|
344
344
|
backup_path: z.string().optional().describe("Path under WRITING_SYNC_DIR to a project backup directory, manifest.json, or canonical.snapshot.json. Defaults to project-backups/<project_id>."),
|
|
@@ -346,8 +346,9 @@ export function registerSyncTools(s, {
|
|
|
346
346
|
confirm_destructive: z.boolean().optional().describe("Required with dry_run=false when the restore plan includes delete candidates."),
|
|
347
347
|
confirm_cross_scope: z.boolean().optional().describe("Required with dry_run=false when the restore plan changes universe-scoped records."),
|
|
348
348
|
expected_current_snapshot_checksum: z.string().optional().describe("Required with dry_run=false; pass the current_snapshot_checksum returned by the reviewed dry-run plan to guard against state changes before apply."),
|
|
349
|
+
include_unchanged: z.boolean().optional().describe("If false, suppress unchanged rows from plan.changes while preserving plan_summary counts. Defaults to true for compatibility."),
|
|
349
350
|
},
|
|
350
|
-
async ({ project_id, backup_path, dry_run = true, confirm_destructive = false, confirm_cross_scope = false, expected_current_snapshot_checksum = null } = {}) => {
|
|
351
|
+
async ({ project_id, backup_path, dry_run = true, confirm_destructive = false, confirm_cross_scope = false, expected_current_snapshot_checksum = null, include_unchanged = true } = {}) => {
|
|
351
352
|
if (!SYNC_DIR_WRITABLE && dry_run === false) {
|
|
352
353
|
return errorResponse("READ_ONLY", "Cannot restore project from backup: server is in read-only mode for canonical structure mutations.");
|
|
353
354
|
}
|
|
@@ -374,6 +375,7 @@ export function registerSyncTools(s, {
|
|
|
374
375
|
confirmDestructive: confirm_destructive,
|
|
375
376
|
confirmCrossScope: confirm_cross_scope,
|
|
376
377
|
expectedCurrentSnapshotChecksum: expected_current_snapshot_checksum,
|
|
378
|
+
includeUnchanged: include_unchanged,
|
|
377
379
|
applicationVersion: MCP_SERVER_VERSION,
|
|
378
380
|
}));
|
|
379
381
|
} catch (error) {
|