@hanna84/mcp-writing 3.26.0 → 3.28.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 +11 -0
- package/src/structure/project-backup-restore.js +97 -3
- package/src/tools/metadata.js +118 -145
- 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.28.0](https://github.com/hannasdev/mcp-writing/compare/v3.27.0...v3.28.0)
|
|
8
|
+
|
|
9
|
+
- feat: make restore plans easier to scan [`#238`](https://github.com/hannasdev/mcp-writing/pull/238)
|
|
10
|
+
|
|
11
|
+
#### [v3.27.0](https://github.com/hannasdev/mcp-writing/compare/v3.26.0...v3.27.0)
|
|
12
|
+
|
|
13
|
+
> 6 June 2026
|
|
14
|
+
|
|
15
|
+
- feat(metadata): accept forgiving relationship evidence inputs [`#237`](https://github.com/hannasdev/mcp-writing/pull/237)
|
|
16
|
+
- Release 3.27.0 [`ef5423b`](https://github.com/hannasdev/mcp-writing/commit/ef5423b044274b75bf60327772c9a944b94123c1)
|
|
17
|
+
|
|
7
18
|
#### [v3.26.0](https://github.com/hannasdev/mcp-writing/compare/v3.25.0...v3.26.0)
|
|
8
19
|
|
|
20
|
+
> 6 June 2026
|
|
21
|
+
|
|
9
22
|
- feat: add canonical target resolver [`#236`](https://github.com/hannasdev/mcp-writing/pull/236)
|
|
23
|
+
- Release 3.26.0 [`bb01109`](https://github.com/hannasdev/mcp-writing/commit/bb01109bdef98ad7020ffabc4fd249017b51998c)
|
|
10
24
|
|
|
11
25
|
#### [v3.25.0](https://github.com/hannasdev/mcp-writing/compare/v3.24.1...v3.25.0)
|
|
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
|
@@ -211,6 +211,17 @@ function resolveFromRows({
|
|
|
211
211
|
formatCandidate,
|
|
212
212
|
}) {
|
|
213
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
|
+
|
|
214
225
|
const exactIdRows = rows.filter(row => row[idField] === input);
|
|
215
226
|
if (exactIdRows.length === 1) {
|
|
216
227
|
const candidate = formatCandidate(exactIdRows[0], { matchedField: idField, matchType: "exact_id" });
|
|
@@ -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
|
@@ -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)
|
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) {
|