@hanna84/mcp-writing 3.23.0 → 3.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/sync/scene-character-batch.js +11 -1
- package/src/sync/sync.js +179 -38
- package/src/tools/metadata.js +65 -1
- package/src/tools/sync.js +17 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.23.1](https://github.com/hannasdev/mcp-writing/compare/v3.23.0...v3.23.1)
|
|
8
|
+
|
|
9
|
+
- fix: align relationship compatibility sync and audit [`#231`](https://github.com/hannasdev/mcp-writing/pull/231)
|
|
10
|
+
|
|
7
11
|
#### [v3.23.0](https://github.com/hannasdev/mcp-writing/compare/v3.22.5...v3.23.0)
|
|
8
12
|
|
|
13
|
+
> 30 May 2026
|
|
14
|
+
|
|
9
15
|
- feat: guard scene relationship metadata updates [`#230`](https://github.com/hannasdev/mcp-writing/pull/230)
|
|
16
|
+
- Release 3.23.0 [`93c359f`](https://github.com/hannasdev/mcp-writing/commit/93c359f71dde69afed34e7b024aa21729810df2b)
|
|
10
17
|
|
|
11
18
|
#### [v3.22.5](https://github.com/hannasdev/mcp-writing/compare/v3.22.4...v3.22.5)
|
|
12
19
|
|
package/package.json
CHANGED
|
@@ -57,6 +57,16 @@ function resolveCharacterEntry(entry, characterRows) {
|
|
|
57
57
|
return resolveCharacterReference(entry, characterRows);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function normalizeCompatibilityCharacters(value) {
|
|
61
|
+
const rawValues = Array.isArray(value)
|
|
62
|
+
? value
|
|
63
|
+
: typeof value === "string"
|
|
64
|
+
? value.split(",")
|
|
65
|
+
: [];
|
|
66
|
+
|
|
67
|
+
return [...new Set(rawValues.map(String).map(character => character.trim()).filter(Boolean))];
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
function pruneLessSpecificCharacters(characterIds, fullNameMatches, characterRows) {
|
|
61
71
|
const kept = new Set(characterIds);
|
|
62
72
|
|
|
@@ -140,7 +150,7 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
|
|
|
140
150
|
const { content: prose } = matter(raw);
|
|
141
151
|
const { sourceMeta: meta } = readSourceMeta(scene.file_path, syncDir, { writable: !dry_run });
|
|
142
152
|
|
|
143
|
-
const before_characters =
|
|
153
|
+
const before_characters = normalizeCompatibilityCharacters(meta.characters);
|
|
144
154
|
const normalized_before_characters = [...new Set(
|
|
145
155
|
before_characters
|
|
146
156
|
.map(character => resolveCharacterEntry(character, normalizedCharacterRows))
|
package/src/sync/sync.js
CHANGED
|
@@ -299,6 +299,24 @@ export function normalizeReferenceIdList(values) {
|
|
|
299
299
|
)];
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
function normalizeMetadataList(values) {
|
|
303
|
+
const rawValues = Array.isArray(values)
|
|
304
|
+
? values
|
|
305
|
+
: typeof values === "string"
|
|
306
|
+
? values.split(",")
|
|
307
|
+
: [];
|
|
308
|
+
|
|
309
|
+
return [...new Set(
|
|
310
|
+
rawValues
|
|
311
|
+
.map(value => String(value).trim())
|
|
312
|
+
.filter(Boolean)
|
|
313
|
+
)];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isVersionContinuityMarker(value) {
|
|
317
|
+
return /^v\d[\d.a-z]*$/i.test(String(value).trim());
|
|
318
|
+
}
|
|
319
|
+
|
|
302
320
|
function normalizeReferenceRelation(value, fallbackRelation) {
|
|
303
321
|
const normalized = String(value ?? fallbackRelation ?? "").trim().toLowerCase();
|
|
304
322
|
if (/^[a-z][a-z0-9_-]*$/.test(normalized)) return normalized;
|
|
@@ -1272,7 +1290,131 @@ export function readSceneFileForSync(syncDir, file, { writable = false } = {}) {
|
|
|
1272
1290
|
};
|
|
1273
1291
|
}
|
|
1274
1292
|
|
|
1275
|
-
export function
|
|
1293
|
+
export function resolveSceneCharacterCompatibilityId(db, value) {
|
|
1294
|
+
let characterId = value;
|
|
1295
|
+
if (!/^char-/.test(value)) {
|
|
1296
|
+
let row = db.prepare(`SELECT character_id FROM characters WHERE lower(name) = lower(?)`).get(value);
|
|
1297
|
+
if (!row) {
|
|
1298
|
+
const words = value.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1299
|
+
const all = db.prepare(`SELECT character_id, name FROM characters`).all();
|
|
1300
|
+
const match = all.find(r =>
|
|
1301
|
+
words.every(w => r.name.toLowerCase().includes(w))
|
|
1302
|
+
);
|
|
1303
|
+
if (match) row = match;
|
|
1304
|
+
}
|
|
1305
|
+
if (row) characterId = row.character_id;
|
|
1306
|
+
}
|
|
1307
|
+
return characterId;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function querySceneRelationshipIds(db, { sceneId, projectId, table, idColumn }) {
|
|
1311
|
+
return db.prepare(`
|
|
1312
|
+
SELECT ${idColumn} AS id
|
|
1313
|
+
FROM ${table}
|
|
1314
|
+
WHERE scene_id = ? AND project_id = ?
|
|
1315
|
+
ORDER BY ${idColumn}
|
|
1316
|
+
`).all(sceneId, projectId).map((row) => row.id);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function sameStringSet(a, b) {
|
|
1320
|
+
const left = [...new Set(a.map(String))].sort();
|
|
1321
|
+
const right = [...new Set(b.map(String))].sort();
|
|
1322
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function selectRelationshipIndexValues({ sceneId, projectId, relationshipKind, existing, compatibility, compatibilityMode, hasCompatibilityField, hasExistingScene }) {
|
|
1326
|
+
if (!hasCompatibilityField) {
|
|
1327
|
+
return {
|
|
1328
|
+
values: existing,
|
|
1329
|
+
diagnostic: null,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
if (compatibilityMode === "adopt_compatibility") {
|
|
1333
|
+
return {
|
|
1334
|
+
values: compatibility,
|
|
1335
|
+
diagnostic: null,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
if (hasExistingScene && !sameStringSet(existing, compatibility)) {
|
|
1339
|
+
return {
|
|
1340
|
+
values: existing,
|
|
1341
|
+
diagnostic: {
|
|
1342
|
+
type: "relationship_compatibility_drift",
|
|
1343
|
+
relationship_kind: relationshipKind,
|
|
1344
|
+
scene_id: sceneId,
|
|
1345
|
+
project_id: projectId,
|
|
1346
|
+
canonical_values: existing,
|
|
1347
|
+
compatibility_values: compatibility,
|
|
1348
|
+
message: `Relationship compatibility drift for scene "${sceneId}" in project "${projectId}": sidecar ${relationshipKind} differ from SQLite ${relationshipKind}; preserved SQLite relationship rows. Run audit_relationship_metadata before applying an outcome-level repair.`,
|
|
1349
|
+
},
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
values: compatibility,
|
|
1354
|
+
diagnostic: null,
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function relationshipCompatibilityFieldsFromMeta(meta = {}) {
|
|
1359
|
+
return {
|
|
1360
|
+
characters: Object.hasOwn(meta, "characters"),
|
|
1361
|
+
places: Object.hasOwn(meta, "places"),
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function buildSceneRelationshipIndexPlan(db, { meta, projectId, compatibilityMode = "preserve_existing", compatibilityFields = relationshipCompatibilityFieldsFromMeta(meta), hasExistingScene = false }) {
|
|
1366
|
+
const compatibilityCharacters = [];
|
|
1367
|
+
const compatibilityVersionMarkers = [];
|
|
1368
|
+
for (const value of normalizeMetadataList(meta.characters)) {
|
|
1369
|
+
if (isVersionContinuityMarker(value)) {
|
|
1370
|
+
compatibilityVersionMarkers.push(value);
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
compatibilityCharacters.push(resolveSceneCharacterCompatibilityId(db, value));
|
|
1374
|
+
}
|
|
1375
|
+
const compatibilityPlaces = normalizeMetadataList(meta.places);
|
|
1376
|
+
const existingCharacters = querySceneRelationshipIds(db, {
|
|
1377
|
+
sceneId: meta.scene_id,
|
|
1378
|
+
projectId,
|
|
1379
|
+
table: "scene_characters",
|
|
1380
|
+
idColumn: "character_id",
|
|
1381
|
+
});
|
|
1382
|
+
const existingPlaces = querySceneRelationshipIds(db, {
|
|
1383
|
+
sceneId: meta.scene_id,
|
|
1384
|
+
projectId,
|
|
1385
|
+
table: "scene_places",
|
|
1386
|
+
idColumn: "place_id",
|
|
1387
|
+
});
|
|
1388
|
+
const characterSelection = selectRelationshipIndexValues({
|
|
1389
|
+
sceneId: meta.scene_id,
|
|
1390
|
+
projectId,
|
|
1391
|
+
relationshipKind: "characters",
|
|
1392
|
+
existing: existingCharacters,
|
|
1393
|
+
compatibility: compatibilityCharacters,
|
|
1394
|
+
compatibilityMode,
|
|
1395
|
+
hasCompatibilityField: compatibilityFields.characters,
|
|
1396
|
+
hasExistingScene,
|
|
1397
|
+
});
|
|
1398
|
+
const placeSelection = selectRelationshipIndexValues({
|
|
1399
|
+
sceneId: meta.scene_id,
|
|
1400
|
+
projectId,
|
|
1401
|
+
relationshipKind: "places",
|
|
1402
|
+
existing: existingPlaces,
|
|
1403
|
+
compatibility: compatibilityPlaces,
|
|
1404
|
+
compatibilityMode,
|
|
1405
|
+
hasCompatibilityField: compatibilityFields.places,
|
|
1406
|
+
hasExistingScene,
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
return {
|
|
1410
|
+
characters: characterSelection.values,
|
|
1411
|
+
places: placeSelection.values,
|
|
1412
|
+
compatibilityVersionMarkers,
|
|
1413
|
+
diagnostics: [characterSelection.diagnostic, placeSelection.diagnostic].filter(Boolean),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructure, managedStructure = false, relationshipCompatibilityMode = "preserve_existing", relationshipCompatibilityFields } = {}) {
|
|
1276
1418
|
const canonicalIndexPlan = buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure, { managedStructure });
|
|
1277
1419
|
const { universeId: universe_id, projectId: project_id } = canonicalIndexPlan;
|
|
1278
1420
|
const { chapterStructure } = canonicalIndexPlan.observedStructure;
|
|
@@ -1408,58 +1550,44 @@ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructu
|
|
|
1408
1550
|
new Date().toISOString()
|
|
1409
1551
|
);
|
|
1410
1552
|
|
|
1553
|
+
const relationshipIndexPlan = buildSceneRelationshipIndexPlan(db, {
|
|
1554
|
+
meta,
|
|
1555
|
+
projectId: project_id,
|
|
1556
|
+
compatibilityMode: relationshipCompatibilityMode,
|
|
1557
|
+
compatibilityFields: relationshipCompatibilityFields,
|
|
1558
|
+
hasExistingScene: Boolean(existing),
|
|
1559
|
+
});
|
|
1560
|
+
const tagValues = [
|
|
1561
|
+
...normalizeMetadataList(meta.tags),
|
|
1562
|
+
...relationshipIndexPlan.compatibilityVersionMarkers,
|
|
1563
|
+
...normalizeMetadataList(meta.versions),
|
|
1564
|
+
];
|
|
1565
|
+
|
|
1411
1566
|
db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
|
|
1412
1567
|
db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
|
|
1413
1568
|
db.prepare(`DELETE FROM scene_tags WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
|
|
1569
|
+
db.prepare(`DELETE FROM scenes_fts WHERE scene_id = ? AND project_id = ?`).run(meta.scene_id, project_id);
|
|
1414
1570
|
|
|
1415
|
-
for (const
|
|
1416
|
-
// Version continuity markers (e.g. v7.3, v3.3b) are tracked as tags, not characters
|
|
1417
|
-
if (/^v\d[\d.a-z]*$/i.test(c)) {
|
|
1418
|
-
db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(meta.scene_id, project_id, c);
|
|
1419
|
-
continue;
|
|
1420
|
-
}
|
|
1421
|
-
let cid = c;
|
|
1422
|
-
// If the value looks like a name rather than an ID, try to resolve it
|
|
1423
|
-
if (!/^char-/.test(c)) {
|
|
1424
|
-
// 1. Exact name match (case-insensitive)
|
|
1425
|
-
let row = db.prepare(`SELECT character_id FROM characters WHERE lower(name) = lower(?)`).get(c);
|
|
1426
|
-
// 2. Word-overlap: all words in the keyword appear in the stored name
|
|
1427
|
-
// Handles "Victor Sidorin" → "Victor Alexeyvich Sidorin"
|
|
1428
|
-
if (!row) {
|
|
1429
|
-
const words = c.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1430
|
-
const all = db.prepare(`SELECT character_id, name FROM characters`).all();
|
|
1431
|
-
const match = all.find(r =>
|
|
1432
|
-
words.every(w => r.name.toLowerCase().includes(w))
|
|
1433
|
-
);
|
|
1434
|
-
if (match) row = match;
|
|
1435
|
-
}
|
|
1436
|
-
if (row) cid = row.character_id;
|
|
1437
|
-
}
|
|
1571
|
+
for (const cid of relationshipIndexPlan.characters) {
|
|
1438
1572
|
db.prepare(`INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id) VALUES (?, ?, ?)`).run(
|
|
1439
1573
|
meta.scene_id, project_id, cid
|
|
1440
1574
|
);
|
|
1441
1575
|
}
|
|
1442
|
-
for (const p of
|
|
1576
|
+
for (const p of relationshipIndexPlan.places) {
|
|
1443
1577
|
db.prepare(`INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id) VALUES (?, ?, ?)`).run(
|
|
1444
1578
|
meta.scene_id, project_id, p
|
|
1445
1579
|
);
|
|
1446
1580
|
}
|
|
1447
|
-
for (const t of
|
|
1581
|
+
for (const t of tagValues) {
|
|
1448
1582
|
db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(
|
|
1449
1583
|
meta.scene_id, project_id, t
|
|
1450
1584
|
);
|
|
1451
1585
|
}
|
|
1452
|
-
for (const v of (meta.versions ?? [])) {
|
|
1453
|
-
db.prepare(`INSERT OR IGNORE INTO scene_tags (scene_id, project_id, tag) VALUES (?, ?, ?)`).run(
|
|
1454
|
-
meta.scene_id, project_id, v
|
|
1455
|
-
);
|
|
1456
|
-
}
|
|
1457
1586
|
|
|
1458
1587
|
const keywordTokens = [
|
|
1459
|
-
...
|
|
1460
|
-
...
|
|
1461
|
-
...
|
|
1462
|
-
...(meta.versions ?? []),
|
|
1588
|
+
...tagValues,
|
|
1589
|
+
...relationshipIndexPlan.characters,
|
|
1590
|
+
...relationshipIndexPlan.places,
|
|
1463
1591
|
]
|
|
1464
1592
|
.filter(Boolean)
|
|
1465
1593
|
.map(String)
|
|
@@ -1467,7 +1595,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructu
|
|
|
1467
1595
|
.filter(Boolean)
|
|
1468
1596
|
.join(" ");
|
|
1469
1597
|
|
|
1470
|
-
db.prepare(`INSERT
|
|
1598
|
+
db.prepare(`INSERT INTO scenes_fts (scene_id, project_id, logline, title, keywords) VALUES (?, ?, ?, ?, ?)`).run(
|
|
1471
1599
|
meta.scene_id,
|
|
1472
1600
|
project_id,
|
|
1473
1601
|
meta.logline ?? meta.synopsis ?? "",
|
|
@@ -1493,7 +1621,13 @@ export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructu
|
|
|
1493
1621
|
relation: "informs",
|
|
1494
1622
|
});
|
|
1495
1623
|
|
|
1496
|
-
return {
|
|
1624
|
+
return {
|
|
1625
|
+
isStale,
|
|
1626
|
+
chapterId,
|
|
1627
|
+
warning: chapterWarning,
|
|
1628
|
+
canonicalIndexPlan,
|
|
1629
|
+
relationshipCompatibilityDiagnostics: relationshipIndexPlan.diagnostics,
|
|
1630
|
+
};
|
|
1497
1631
|
}
|
|
1498
1632
|
|
|
1499
1633
|
export function isManagedStructureProject(db, projectId) {
|
|
@@ -1564,6 +1698,7 @@ const WARNING_TYPE_LABELS = {
|
|
|
1564
1698
|
unobserved_canonical_scene: "Unobserved canonical scene",
|
|
1565
1699
|
missing_canonical_chapter: "Missing canonical chapter",
|
|
1566
1700
|
missing_canonical_epigraph: "Missing canonical epigraph",
|
|
1701
|
+
relationship_compatibility_drift: "Relationship compatibility drift",
|
|
1567
1702
|
nested_mirror: "Ignored nested mirror path",
|
|
1568
1703
|
};
|
|
1569
1704
|
|
|
@@ -1578,6 +1713,7 @@ const WARNING_PATTERNS = [
|
|
|
1578
1713
|
{ type: "unobserved_canonical_scene", re: /^Managed sync preserved canonical scene not observed during sync scan:/ },
|
|
1579
1714
|
{ type: "missing_canonical_chapter", re: /^Managed sync preserved canonical chapter missing from filesystem:/ },
|
|
1580
1715
|
{ type: "missing_canonical_epigraph", re: /^Managed sync preserved canonical epigraph missing from filesystem:/ },
|
|
1716
|
+
{ type: "relationship_compatibility_drift", re: /^Relationship compatibility drift/ },
|
|
1581
1717
|
{ type: "nested_mirror", re: /^Ignored nested mirror path:/ },
|
|
1582
1718
|
];
|
|
1583
1719
|
|
|
@@ -1598,7 +1734,7 @@ export function buildWarningSummary(warnings) {
|
|
|
1598
1734
|
return summary;
|
|
1599
1735
|
}
|
|
1600
1736
|
|
|
1601
|
-
export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
1737
|
+
export function syncAll(db, syncDir, { quiet = false, writable = false, relationshipCompatibilityMode = "preserve_existing" } = {}) {
|
|
1602
1738
|
// Reset per-run inference cache so filesystem changes between sync calls
|
|
1603
1739
|
// (for example after imports or path repairs) are reflected immediately.
|
|
1604
1740
|
UNIVERSE_PROJECT_ROOT_CACHE.clear();
|
|
@@ -1701,6 +1837,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1701
1837
|
const result = indexSceneFile(db, syncDir, file, meta, prose, {
|
|
1702
1838
|
observedStructure: structureObservation,
|
|
1703
1839
|
managedStructure: managedProjectIds.has(project_id),
|
|
1840
|
+
relationshipCompatibilityMode,
|
|
1841
|
+
relationshipCompatibilityFields: relationshipCompatibilityFieldsFromMeta(sourceMeta),
|
|
1704
1842
|
});
|
|
1705
1843
|
const canonicalDiagnostics = result.canonicalIndexPlan?.diagnostics ?? [];
|
|
1706
1844
|
if (canonicalDiagnostics.length) {
|
|
@@ -1708,6 +1846,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1708
1846
|
} else if (result.warning) {
|
|
1709
1847
|
warnings.push(result.warning);
|
|
1710
1848
|
}
|
|
1849
|
+
for (const diagnostic of result.relationshipCompatibilityDiagnostics ?? []) {
|
|
1850
|
+
warnings.push(diagnostic.message);
|
|
1851
|
+
}
|
|
1711
1852
|
if (result.chapterId) {
|
|
1712
1853
|
const chapterKey = `${result.chapterId}::${project_id}`;
|
|
1713
1854
|
seenChapterKeys.add(chapterKey);
|
package/src/tools/metadata.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import matter from "gray-matter";
|
|
5
|
-
import { readMeta, readSourceMeta, writeMeta, indexSceneFile, isManagedStructureProject, normalizeSceneMetaForPath } from "../sync/sync.js";
|
|
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
8
|
import {
|
|
@@ -454,6 +454,36 @@ function querySceneRelationshipSnapshot(db, { sceneId, projectId }) {
|
|
|
454
454
|
};
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
function sameStringSet(a, b) {
|
|
458
|
+
const left = [...new Set(a.map(String))].sort();
|
|
459
|
+
const right = [...new Set(b.map(String))].sort();
|
|
460
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function normalizeSceneRelationshipCompatibilityFields(db, sourceMeta) {
|
|
464
|
+
const hasCharactersField = Object.hasOwn(sourceMeta, "characters");
|
|
465
|
+
const hasPlacesField = Object.hasOwn(sourceMeta, "places");
|
|
466
|
+
return {
|
|
467
|
+
has_relationship_fields: hasCharactersField || hasPlacesField,
|
|
468
|
+
has_characters_field: hasCharactersField,
|
|
469
|
+
has_places_field: hasPlacesField,
|
|
470
|
+
characters: normalizeStringList(sourceMeta.characters)
|
|
471
|
+
.filter((value) => !isVersionContinuityMarker(value))
|
|
472
|
+
.map((value) => resolveSceneCharacterCompatibilityId(db, value)),
|
|
473
|
+
places: normalizeStringList(sourceMeta.places),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function sceneRelationshipCompatibilityHasDrift(canonicalRelationships, compatibilityRelationships) {
|
|
478
|
+
return (
|
|
479
|
+
compatibilityRelationships.has_characters_field &&
|
|
480
|
+
!sameStringSet(canonicalRelationships.characters, compatibilityRelationships.characters)
|
|
481
|
+
) || (
|
|
482
|
+
compatibilityRelationships.has_places_field &&
|
|
483
|
+
!sameStringSet(canonicalRelationships.places, compatibilityRelationships.places)
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
457
487
|
function restoreSceneRelationshipSnapshot(db, { sceneId, projectId, snapshot }) {
|
|
458
488
|
db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
|
|
459
489
|
db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
|
|
@@ -1061,6 +1091,39 @@ export function registerMetadataTools(s, {
|
|
|
1061
1091
|
}
|
|
1062
1092
|
try {
|
|
1063
1093
|
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: false });
|
|
1094
|
+
const compatibilityRelationships = normalizeSceneRelationshipCompatibilityFields(db, sourceMeta);
|
|
1095
|
+
if (compatibilityRelationships.has_relationship_fields) {
|
|
1096
|
+
const canonicalRelationships = querySceneRelationshipSnapshot(db, {
|
|
1097
|
+
sceneId: scene.scene_id,
|
|
1098
|
+
projectId: scene.project_id,
|
|
1099
|
+
});
|
|
1100
|
+
diagnostics.push({
|
|
1101
|
+
type: "scene_relationship_compatibility_input",
|
|
1102
|
+
severity: "info",
|
|
1103
|
+
message: `Scene '${scene.scene_id}' retains sidecar/frontmatter character/place relationship fields as compatibility input; SQLite scene relationship rows remain canonical.`,
|
|
1104
|
+
scene_id: scene.scene_id,
|
|
1105
|
+
project_id: scene.project_id,
|
|
1106
|
+
compatibility: compatibilityRelationships,
|
|
1107
|
+
canonical: canonicalRelationships,
|
|
1108
|
+
authority: {
|
|
1109
|
+
canonical_owner: "SQLite scene_characters/scene_places",
|
|
1110
|
+
compatibility_mutation_surface: false,
|
|
1111
|
+
},
|
|
1112
|
+
next_step: "Use this as migration or review evidence only. Use outcome-level relationship tools for current repairs.",
|
|
1113
|
+
});
|
|
1114
|
+
if (sceneRelationshipCompatibilityHasDrift(canonicalRelationships, compatibilityRelationships)) {
|
|
1115
|
+
diagnostics.push({
|
|
1116
|
+
type: "scene_relationship_compatibility_drift",
|
|
1117
|
+
severity: "warning",
|
|
1118
|
+
message: `Scene '${scene.scene_id}' sidecar/frontmatter relationship fields disagree with canonical SQLite relationship rows.`,
|
|
1119
|
+
scene_id: scene.scene_id,
|
|
1120
|
+
project_id: scene.project_id,
|
|
1121
|
+
compatibility: compatibilityRelationships,
|
|
1122
|
+
canonical: canonicalRelationships,
|
|
1123
|
+
next_step: "Treat SQLite relationship rows as canonical. Use find_scenes, list_characters, and list_places to inspect stable IDs; use connect_character_place_evidence for paired sheet-backed evidence or leave character-only/place-only repairs to a deliberately named future workflow.",
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1064
1127
|
if (sourceMeta.threads) {
|
|
1065
1128
|
diagnostics.push({
|
|
1066
1129
|
type: "sidecar_threads_compatibility_input",
|
|
@@ -1146,6 +1209,7 @@ export function registerMetadataTools(s, {
|
|
|
1146
1209
|
summary: {
|
|
1147
1210
|
diagnostics_count: diagnostics.length,
|
|
1148
1211
|
stale_scene_count: diagnostics.filter(diagnostic => diagnostic.type === "stale_scene_relationship_index").length,
|
|
1212
|
+
compatibility_drift_count: diagnostics.filter(diagnostic => diagnostic.type === "scene_relationship_compatibility_drift").length,
|
|
1149
1213
|
compatibility_note_count: diagnostics.filter(diagnostic => diagnostic.severity === "info").length,
|
|
1150
1214
|
},
|
|
1151
1215
|
next_steps: [
|
package/src/tools/sync.js
CHANGED
|
@@ -470,7 +470,10 @@ export function registerSyncTools(s, {
|
|
|
470
470
|
|
|
471
471
|
let syncResult = null;
|
|
472
472
|
if (!dry_run && !preflight && auto_sync) {
|
|
473
|
-
syncResult = syncAll(db, SYNC_DIR, {
|
|
473
|
+
syncResult = syncAll(db, SYNC_DIR, {
|
|
474
|
+
writable: SYNC_DIR_WRITABLE,
|
|
475
|
+
relationshipCompatibilityMode: "adopt_compatibility",
|
|
476
|
+
});
|
|
474
477
|
}
|
|
475
478
|
|
|
476
479
|
return jsonResponse({
|
|
@@ -572,7 +575,10 @@ export function registerSyncTools(s, {
|
|
|
572
575
|
},
|
|
573
576
|
onComplete: (completedJob) => {
|
|
574
577
|
if (!auto_sync || dry_run || preflight || completedJob.status !== "completed") return;
|
|
575
|
-
const syncResult = syncAll(db, SYNC_DIR, {
|
|
578
|
+
const syncResult = syncAll(db, SYNC_DIR, {
|
|
579
|
+
writable: SYNC_DIR_WRITABLE,
|
|
580
|
+
relationshipCompatibilityMode: "adopt_compatibility",
|
|
581
|
+
});
|
|
576
582
|
if (completedJob.result && completedJob.result.ok) {
|
|
577
583
|
completedJob.result.sync = {
|
|
578
584
|
indexed: syncResult.indexed,
|
|
@@ -652,7 +658,10 @@ export function registerSyncTools(s, {
|
|
|
652
658
|
},
|
|
653
659
|
onComplete: (completedJob) => {
|
|
654
660
|
if (!auto_sync || dry_run || completedJob.status !== "completed") return;
|
|
655
|
-
const syncResult = syncAll(db, SYNC_DIR, {
|
|
661
|
+
const syncResult = syncAll(db, SYNC_DIR, {
|
|
662
|
+
writable: SYNC_DIR_WRITABLE,
|
|
663
|
+
relationshipCompatibilityMode: "adopt_compatibility",
|
|
664
|
+
});
|
|
656
665
|
if (completedJob.result && completedJob.result.ok) {
|
|
657
666
|
completedJob.result.sync = {
|
|
658
667
|
indexed: syncResult.indexed,
|
|
@@ -776,7 +785,10 @@ export function registerSyncTools(s, {
|
|
|
776
785
|
onComplete: (completedJob) => {
|
|
777
786
|
if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
|
|
778
787
|
|
|
779
|
-
syncAll(db, SYNC_DIR, {
|
|
788
|
+
syncAll(db, SYNC_DIR, {
|
|
789
|
+
writable: SYNC_DIR_WRITABLE,
|
|
790
|
+
relationshipCompatibilityMode: "adopt_compatibility",
|
|
791
|
+
});
|
|
780
792
|
|
|
781
793
|
const changedScenes = (completedJob.result.results ?? [])
|
|
782
794
|
.filter(row => row.status === "changed")
|
|
@@ -980,6 +992,7 @@ export function registerSyncTools(s, {
|
|
|
980
992
|
writeMeta(scene.file_path, sourceUpdatedMeta, { syncDir: SYNC_DIR });
|
|
981
993
|
indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose, {
|
|
982
994
|
managedStructure: isManagedStructureProject(db, scene.project_id),
|
|
995
|
+
relationshipCompatibilityMode: "adopt_compatibility",
|
|
983
996
|
});
|
|
984
997
|
db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
|
|
985
998
|
.run(scene.scene_id, scene.project_id);
|