@hanna84/mcp-writing 3.22.5 → 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 +14 -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 +158 -4
- package/src/tools/sync.js +17 -4
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.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
|
+
|
|
11
|
+
#### [v3.23.0](https://github.com/hannasdev/mcp-writing/compare/v3.22.5...v3.23.0)
|
|
12
|
+
|
|
13
|
+
> 30 May 2026
|
|
14
|
+
|
|
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)
|
|
17
|
+
|
|
7
18
|
#### [v3.22.5](https://github.com/hannasdev/mcp-writing/compare/v3.22.4...v3.22.5)
|
|
8
19
|
|
|
20
|
+
> 30 May 2026
|
|
21
|
+
|
|
9
22
|
- chore(deps): Bump qs in the npm_and_yarn group across 1 directory [`#214`](https://github.com/hannasdev/mcp-writing/pull/214)
|
|
23
|
+
- Release 3.22.5 [`a4146a0`](https://github.com/hannasdev/mcp-writing/commit/a4146a03a9976d022afb75842bee3ce50742fe65)
|
|
10
24
|
|
|
11
25
|
#### [v3.22.4](https://github.com/hannasdev/mcp-writing/compare/v3.22.3...v3.22.4)
|
|
12
26
|
|
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 {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "../structure/project-backup-refresh.js";
|
|
35
35
|
|
|
36
36
|
const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "chapter_title", "timeline_position"];
|
|
37
|
+
const RELATIONSHIP_SCENE_METADATA_FIELDS = ["characters", "places"];
|
|
37
38
|
|
|
38
39
|
function emptyBackupMutationResult() {
|
|
39
40
|
return {
|
|
@@ -108,6 +109,22 @@ function getProvidedStructuralSceneMetadataFields(fields) {
|
|
|
108
109
|
return STRUCTURAL_SCENE_METADATA_FIELDS.filter((field) => Object.hasOwn(fields, field));
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
function getProvidedRelationshipSceneMetadataFields(fields) {
|
|
113
|
+
return RELATIONSHIP_SCENE_METADATA_FIELDS.filter((field) => Object.hasOwn(fields, field));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildRelationshipMetadataBoundaryDetails({ projectId, sceneId, blockedFields }) {
|
|
117
|
+
return {
|
|
118
|
+
project_id: projectId,
|
|
119
|
+
scene_id: sceneId,
|
|
120
|
+
blocked_fields: blockedFields,
|
|
121
|
+
boundary: "scene_relationship_metadata",
|
|
122
|
+
relationship_tools: ["connect_character_place_evidence", "audit_relationship_metadata"],
|
|
123
|
+
discovery_workflows: ["describe_workflows", "find_scenes", "list_characters", "list_places"],
|
|
124
|
+
next_step: "Use find_scenes, list_characters, and list_places to identify stable IDs. Use connect_character_place_evidence only when the scene proves paired sheet-backed character/place evidence; use audit_relationship_metadata to review legacy sidecar/frontmatter relationship fields. Character-only or place-only scene evidence is not changed through update_scene_metadata.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
111
128
|
function persistReferenceDocLink({ filePath, syncDir, targetDocId, relation }) {
|
|
112
129
|
const syncDirAbs = path.resolve(syncDir);
|
|
113
130
|
const syncDirReal = resolveBoundaryRootReal(syncDirAbs);
|
|
@@ -437,6 +454,85 @@ function querySceneRelationshipSnapshot(db, { sceneId, projectId }) {
|
|
|
437
454
|
};
|
|
438
455
|
}
|
|
439
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
|
+
|
|
487
|
+
function restoreSceneRelationshipSnapshot(db, { sceneId, projectId, snapshot }) {
|
|
488
|
+
db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
|
|
489
|
+
db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
|
|
490
|
+
|
|
491
|
+
const insertCharacter = db.prepare(`
|
|
492
|
+
INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id)
|
|
493
|
+
VALUES (?, ?, ?)
|
|
494
|
+
`);
|
|
495
|
+
for (const characterId of snapshot.characters) {
|
|
496
|
+
insertCharacter.run(sceneId, projectId, characterId);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const insertPlace = db.prepare(`
|
|
500
|
+
INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id)
|
|
501
|
+
VALUES (?, ?, ?)
|
|
502
|
+
`);
|
|
503
|
+
for (const placeId of snapshot.places) {
|
|
504
|
+
insertPlace.run(sceneId, projectId, placeId);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isVersionContinuityMarker(value) {
|
|
509
|
+
return /^v\d[\d.a-z]*$/i.test(String(value).trim());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function buildSceneMetadataSearchKeywords(meta, relationshipSnapshot) {
|
|
513
|
+
const compatibilityVersionMarkers = normalizeStringList(meta.characters).filter(isVersionContinuityMarker);
|
|
514
|
+
return [
|
|
515
|
+
...normalizeStringList(meta.tags),
|
|
516
|
+
...compatibilityVersionMarkers,
|
|
517
|
+
...relationshipSnapshot.characters,
|
|
518
|
+
...relationshipSnapshot.places,
|
|
519
|
+
...normalizeStringList(meta.versions),
|
|
520
|
+
]
|
|
521
|
+
.filter(Boolean)
|
|
522
|
+
.map(String)
|
|
523
|
+
.map((value) => value.trim())
|
|
524
|
+
.filter(Boolean)
|
|
525
|
+
.join(" ");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function restoreSceneRelationshipSearchKeywords(db, { sceneId, projectId, meta, snapshot }) {
|
|
529
|
+
db.prepare(`
|
|
530
|
+
UPDATE scenes_fts
|
|
531
|
+
SET keywords = ?
|
|
532
|
+
WHERE scene_id = ? AND project_id = ?
|
|
533
|
+
`).run(buildSceneMetadataSearchKeywords(meta, snapshot), sceneId, projectId);
|
|
534
|
+
}
|
|
535
|
+
|
|
440
536
|
export function registerMetadataTools(s, {
|
|
441
537
|
db,
|
|
442
538
|
SYNC_DIR,
|
|
@@ -995,6 +1091,39 @@ export function registerMetadataTools(s, {
|
|
|
995
1091
|
}
|
|
996
1092
|
try {
|
|
997
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
|
+
}
|
|
998
1127
|
if (sourceMeta.threads) {
|
|
999
1128
|
diagnostics.push({
|
|
1000
1129
|
type: "sidecar_threads_compatibility_input",
|
|
@@ -1080,6 +1209,7 @@ export function registerMetadataTools(s, {
|
|
|
1080
1209
|
summary: {
|
|
1081
1210
|
diagnostics_count: diagnostics.length,
|
|
1082
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,
|
|
1083
1213
|
compatibility_note_count: diagnostics.filter(diagnostic => diagnostic.severity === "info").length,
|
|
1084
1214
|
},
|
|
1085
1215
|
next_steps: [
|
|
@@ -2214,7 +2344,7 @@ export function registerMetadataTools(s, {
|
|
|
2214
2344
|
// ---- update_scene_metadata -----------------------------------------------
|
|
2215
2345
|
s.tool(
|
|
2216
2346
|
"update_scene_metadata",
|
|
2217
|
-
"Update one or more non-structural metadata fields for a scene. Writes only supplied
|
|
2347
|
+
"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 for paired sheet-backed character/place 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.",
|
|
2218
2348
|
{
|
|
2219
2349
|
scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
|
|
2220
2350
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
@@ -2231,8 +2361,8 @@ export function registerMetadataTools(s, {
|
|
|
2231
2361
|
timeline_position: z.number().int().optional().describe("Rejected by update_scene_metadata. Use move_scene for ordering changes."),
|
|
2232
2362
|
story_time: z.string().optional(),
|
|
2233
2363
|
tags: z.array(z.string()).optional(),
|
|
2234
|
-
characters: z.array(z.string()).optional(),
|
|
2235
|
-
places: z.array(z.string()).optional(),
|
|
2364
|
+
characters: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence for paired sheet-backed evidence, and audit_relationship_metadata for compatibility review."),
|
|
2365
|
+
places: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence for paired sheet-backed evidence, and audit_relationship_metadata for compatibility review."),
|
|
2236
2366
|
}).describe("Fields to update. Only supplied keys are changed."),
|
|
2237
2367
|
},
|
|
2238
2368
|
async ({ scene_id, project_id, fields }) => {
|
|
@@ -2244,6 +2374,18 @@ export function registerMetadataTools(s, {
|
|
|
2244
2374
|
if (!scene) {
|
|
2245
2375
|
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
2246
2376
|
}
|
|
2377
|
+
const relationshipFields = getProvidedRelationshipSceneMetadataFields(fields);
|
|
2378
|
+
if (relationshipFields.length > 0) {
|
|
2379
|
+
return errorResponse(
|
|
2380
|
+
"VALIDATION_ERROR",
|
|
2381
|
+
"update_scene_metadata cannot change relationship-boundary fields characters or places. Scene relationship metadata is sheet-backed and must use outcome-level relationship workflows, not generic sidecar metadata writes.",
|
|
2382
|
+
buildRelationshipMetadataBoundaryDetails({
|
|
2383
|
+
projectId: project_id,
|
|
2384
|
+
sceneId: scene_id,
|
|
2385
|
+
blockedFields: relationshipFields,
|
|
2386
|
+
})
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2247
2389
|
const structuralFields = getProvidedStructuralSceneMetadataFields(fields);
|
|
2248
2390
|
if (structuralFields.length > 0) {
|
|
2249
2391
|
return errorResponse(
|
|
@@ -2258,6 +2400,7 @@ export function registerMetadataTools(s, {
|
|
|
2258
2400
|
);
|
|
2259
2401
|
}
|
|
2260
2402
|
try {
|
|
2403
|
+
const relationshipSnapshot = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
|
|
2261
2404
|
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
2262
2405
|
const updated = { ...sourceMeta, ...fields };
|
|
2263
2406
|
writeMeta(scene.file_path, updated, { syncDir: SYNC_DIR });
|
|
@@ -2267,6 +2410,17 @@ export function registerMetadataTools(s, {
|
|
|
2267
2410
|
indexSceneFile(db, SYNC_DIR, scene.file_path, normalizedUpdated, prose, {
|
|
2268
2411
|
managedStructure: isManagedStructureProject(db, project_id),
|
|
2269
2412
|
});
|
|
2413
|
+
restoreSceneRelationshipSnapshot(db, {
|
|
2414
|
+
sceneId: scene_id,
|
|
2415
|
+
projectId: project_id,
|
|
2416
|
+
snapshot: relationshipSnapshot,
|
|
2417
|
+
});
|
|
2418
|
+
restoreSceneRelationshipSearchKeywords(db, {
|
|
2419
|
+
sceneId: scene_id,
|
|
2420
|
+
projectId: project_id,
|
|
2421
|
+
meta: normalizedUpdated,
|
|
2422
|
+
snapshot: relationshipSnapshot,
|
|
2423
|
+
});
|
|
2270
2424
|
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
2271
2425
|
syncDir: SYNC_DIR,
|
|
2272
2426
|
projectId: project_id,
|
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);
|