@hanna84/mcp-writing 3.21.3 → 3.22.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/README.md +5 -4
- package/package.json +1 -1
- package/src/index.js +2 -1
- package/src/sync/scene-character-batch.js +9 -0
- package/src/tools/metadata.js +972 -300
- package/src/tools/reference-link-persistence.js +5 -0
- package/src/tools/search.js +84 -44
- package/src/tools/sync.js +14 -2
- package/src/workflows/workflow-catalogue.js +34 -1
package/src/tools/metadata.js
CHANGED
|
@@ -80,6 +80,30 @@ function backupMutationFields(backupResult) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function normalizeStringList(value) {
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === "string") {
|
|
88
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function uniqueSorted(values) {
|
|
94
|
+
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildCompatibilityOutput({ refreshed, diagnostics = [], role = "generated_transparency" } = {}) {
|
|
98
|
+
return {
|
|
99
|
+
role,
|
|
100
|
+
generated_transparency: true,
|
|
101
|
+
mutation_surface: false,
|
|
102
|
+
refreshed: Boolean(refreshed),
|
|
103
|
+
diagnostics,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
function getProvidedStructuralSceneMetadataFields(fields) {
|
|
84
108
|
return STRUCTURAL_SCENE_METADATA_FIELDS.filter((field) => Object.hasOwn(fields, field));
|
|
85
109
|
}
|
|
@@ -362,6 +386,57 @@ function resolveReferenceLinkSource({
|
|
|
362
386
|
});
|
|
363
387
|
}
|
|
364
388
|
|
|
389
|
+
function getProjectUniverseId(db, projectId) {
|
|
390
|
+
return db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(projectId)?.universe_id ?? null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function resolveCharacterForProject(db, { characterId, projectId }) {
|
|
394
|
+
const universeId = getProjectUniverseId(db, projectId);
|
|
395
|
+
return db.prepare(`
|
|
396
|
+
SELECT character_id, project_id, universe_id, name
|
|
397
|
+
FROM characters
|
|
398
|
+
WHERE character_id = ?
|
|
399
|
+
AND (
|
|
400
|
+
project_id = ?
|
|
401
|
+
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
402
|
+
OR (project_id IS NULL AND universe_id IS NULL)
|
|
403
|
+
)
|
|
404
|
+
LIMIT 1
|
|
405
|
+
`).get(characterId, projectId, universeId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function resolvePlaceForProject(db, { placeId, projectId }) {
|
|
409
|
+
const universeId = getProjectUniverseId(db, projectId);
|
|
410
|
+
return db.prepare(`
|
|
411
|
+
SELECT place_id, project_id, universe_id, name
|
|
412
|
+
FROM places
|
|
413
|
+
WHERE place_id = ?
|
|
414
|
+
AND (
|
|
415
|
+
project_id = ?
|
|
416
|
+
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
417
|
+
OR (project_id IS NULL AND universe_id IS NULL)
|
|
418
|
+
)
|
|
419
|
+
LIMIT 1
|
|
420
|
+
`).get(placeId, projectId, universeId);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function querySceneRelationshipSnapshot(db, { sceneId, projectId }) {
|
|
424
|
+
return {
|
|
425
|
+
characters: db.prepare(`
|
|
426
|
+
SELECT character_id
|
|
427
|
+
FROM scene_characters
|
|
428
|
+
WHERE scene_id = ? AND project_id = ?
|
|
429
|
+
ORDER BY character_id
|
|
430
|
+
`).all(sceneId, projectId).map(row => row.character_id),
|
|
431
|
+
places: db.prepare(`
|
|
432
|
+
SELECT place_id
|
|
433
|
+
FROM scene_places
|
|
434
|
+
WHERE scene_id = ? AND project_id = ?
|
|
435
|
+
ORDER BY place_id
|
|
436
|
+
`).all(sceneId, projectId).map(row => row.place_id),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
365
440
|
export function registerMetadataTools(s, {
|
|
366
441
|
db,
|
|
367
442
|
SYNC_DIR,
|
|
@@ -371,6 +446,651 @@ export function registerMetadataTools(s, {
|
|
|
371
446
|
jsonResponse,
|
|
372
447
|
createCanonicalWorldEntity,
|
|
373
448
|
}) {
|
|
449
|
+
async function trackThreadArcLink({ project_id, thread_id, thread_name, scene_id, beat, status }, { operation }) {
|
|
450
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
451
|
+
return errorResponse("READ_ONLY", "Cannot write thread links: sync dir is read-only.");
|
|
452
|
+
}
|
|
453
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
454
|
+
if (!projectIdCheck.ok) {
|
|
455
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const existingThread = db.prepare(`SELECT thread_id, project_id FROM threads WHERE thread_id = ?`).get(thread_id);
|
|
459
|
+
if (existingThread && existingThread.project_id !== project_id) {
|
|
460
|
+
return errorResponse(
|
|
461
|
+
"CONFLICT",
|
|
462
|
+
`Thread '${thread_id}' already exists in project '${existingThread.project_id}', cannot reuse it for project '${project_id}'.`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const scene = db.prepare(`SELECT scene_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
|
|
467
|
+
if (!scene) {
|
|
468
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
db.exec("BEGIN");
|
|
473
|
+
db.prepare(`
|
|
474
|
+
INSERT INTO threads (thread_id, project_id, name, status)
|
|
475
|
+
VALUES (?, ?, ?, ?)
|
|
476
|
+
ON CONFLICT (thread_id) DO UPDATE SET
|
|
477
|
+
name = excluded.name,
|
|
478
|
+
status = excluded.status
|
|
479
|
+
`).run(thread_id, project_id, thread_name, status ?? "active");
|
|
480
|
+
|
|
481
|
+
db.prepare(`
|
|
482
|
+
INSERT INTO scene_threads (scene_id, project_id, thread_id, beat)
|
|
483
|
+
VALUES (?, ?, ?, ?)
|
|
484
|
+
ON CONFLICT (scene_id, project_id, thread_id) DO UPDATE SET
|
|
485
|
+
beat = excluded.beat
|
|
486
|
+
`).run(scene_id, project_id, thread_id, beat ?? null);
|
|
487
|
+
db.exec("COMMIT");
|
|
488
|
+
} catch (err) {
|
|
489
|
+
try {
|
|
490
|
+
db.exec("ROLLBACK");
|
|
491
|
+
} catch (rollbackErr) {
|
|
492
|
+
void rollbackErr;
|
|
493
|
+
}
|
|
494
|
+
return errorResponse("IO_ERROR", `Failed to track thread arc '${thread_id}' for scene '${scene_id}': ${err.message}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
|
|
498
|
+
const link = db.prepare(`SELECT scene_id, project_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND project_id = ? AND thread_id = ?`)
|
|
499
|
+
.get(scene_id, project_id, thread_id);
|
|
500
|
+
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
501
|
+
syncDir: SYNC_DIR,
|
|
502
|
+
projectId: project_id,
|
|
503
|
+
applicationVersion: MCP_SERVER_VERSION,
|
|
504
|
+
operation,
|
|
505
|
+
actor: createToolActor(operation),
|
|
506
|
+
affected: {
|
|
507
|
+
threads: [thread_id],
|
|
508
|
+
scenes: [scene_id],
|
|
509
|
+
},
|
|
510
|
+
summary: `Tracked thread "${thread_id}" for scene "${scene_id}".`,
|
|
511
|
+
before: null,
|
|
512
|
+
after: {
|
|
513
|
+
thread,
|
|
514
|
+
link,
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return jsonResponse({
|
|
519
|
+
ok: true,
|
|
520
|
+
action: operation === "upsert_thread_link" ? "upserted" : "tracked",
|
|
521
|
+
thread,
|
|
522
|
+
link,
|
|
523
|
+
mutation_order: ["validated_request", "sqlite_commit", "project_backup_refresh"],
|
|
524
|
+
...backupMutationFields(backupResult),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function writeReferenceLinkCompatibilityOutput({ sourceKind, sourceFilePath, targetDocId, relation }) {
|
|
529
|
+
if (sourceKind === "scene") {
|
|
530
|
+
persistSceneReferenceLink({
|
|
531
|
+
scenePath: sourceFilePath,
|
|
532
|
+
syncDir: SYNC_DIR,
|
|
533
|
+
targetDocId,
|
|
534
|
+
relation,
|
|
535
|
+
});
|
|
536
|
+
} else if (sourceKind === "character") {
|
|
537
|
+
persistCharacterReferenceLink({
|
|
538
|
+
characterPath: sourceFilePath,
|
|
539
|
+
syncDir: SYNC_DIR,
|
|
540
|
+
targetDocId,
|
|
541
|
+
relation,
|
|
542
|
+
});
|
|
543
|
+
} else if (sourceKind === "place") {
|
|
544
|
+
persistPlaceReferenceLink({
|
|
545
|
+
placePath: sourceFilePath,
|
|
546
|
+
syncDir: SYNC_DIR,
|
|
547
|
+
targetDocId,
|
|
548
|
+
relation,
|
|
549
|
+
});
|
|
550
|
+
} else {
|
|
551
|
+
persistReferenceDocLink({
|
|
552
|
+
filePath: sourceFilePath,
|
|
553
|
+
syncDir: SYNC_DIR,
|
|
554
|
+
targetDocId,
|
|
555
|
+
relation,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function linkReferenceEvidence({
|
|
561
|
+
source_kind,
|
|
562
|
+
source_id,
|
|
563
|
+
source_project_id,
|
|
564
|
+
target_doc_id,
|
|
565
|
+
relation,
|
|
566
|
+
}, { operation }) {
|
|
567
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
568
|
+
return errorResponse("READ_ONLY", "Cannot write reference links: sync dir is read-only.");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const normalizedRelation = relation.trim().toLowerCase();
|
|
572
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(normalizedRelation)) {
|
|
573
|
+
return errorResponse(
|
|
574
|
+
"VALIDATION_ERROR",
|
|
575
|
+
"Relation is normalized to lowercase and must match [a-z][a-z0-9_-]* after normalization (for example: 'informs' or 'history_of').",
|
|
576
|
+
{ relation }
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const targetDoc = db.prepare(`
|
|
581
|
+
SELECT doc_id, project_id
|
|
582
|
+
FROM reference_docs
|
|
583
|
+
WHERE doc_id = ?
|
|
584
|
+
`).get(target_doc_id);
|
|
585
|
+
if (!targetDoc) {
|
|
586
|
+
return errorResponse("NOT_FOUND", `Target reference doc '${target_doc_id}' not found.`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const sourceResolution = resolveReferenceLinkSource({
|
|
590
|
+
db,
|
|
591
|
+
errorResponse,
|
|
592
|
+
sourceKind: source_kind,
|
|
593
|
+
sourceId: source_id,
|
|
594
|
+
sourceProjectId: source_project_id,
|
|
595
|
+
targetDocId: target_doc_id,
|
|
596
|
+
});
|
|
597
|
+
if (sourceResolution.error) {
|
|
598
|
+
return sourceResolution.error;
|
|
599
|
+
}
|
|
600
|
+
const { resolvedSourceProjectId, sourceFilePath } = sourceResolution.value;
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
db.exec("BEGIN");
|
|
604
|
+
upsertExplicitReferenceLinkRow(db, {
|
|
605
|
+
sourceKind: source_kind,
|
|
606
|
+
sourceProjectId: resolvedSourceProjectId,
|
|
607
|
+
sourceId: source_id,
|
|
608
|
+
targetDocId: target_doc_id,
|
|
609
|
+
relation: normalizedRelation,
|
|
610
|
+
});
|
|
611
|
+
db.exec("COMMIT");
|
|
612
|
+
} catch (err) {
|
|
613
|
+
try {
|
|
614
|
+
db.exec("ROLLBACK");
|
|
615
|
+
} catch (rollbackErr) {
|
|
616
|
+
void rollbackErr;
|
|
617
|
+
}
|
|
618
|
+
return errorResponse("IO_ERROR", `Failed to link reference evidence in SQLite: ${err.message}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const link = db.prepare(`
|
|
622
|
+
SELECT source_kind, source_project_id, source_id, target_doc_id, relation, origin
|
|
623
|
+
FROM reference_links
|
|
624
|
+
WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND target_doc_id = ? AND relation = ?
|
|
625
|
+
`).get(source_kind, resolvedSourceProjectId, source_id, target_doc_id, normalizedRelation);
|
|
626
|
+
const backupProjectId = resolvedSourceProjectId || targetDoc.project_id || null;
|
|
627
|
+
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
628
|
+
syncDir: SYNC_DIR,
|
|
629
|
+
projectId: backupProjectId,
|
|
630
|
+
applicationVersion: MCP_SERVER_VERSION,
|
|
631
|
+
operation,
|
|
632
|
+
actor: createToolActor(operation),
|
|
633
|
+
affected: {
|
|
634
|
+
reference_docs: [target_doc_id],
|
|
635
|
+
sources: [`${source_kind}:${source_id}`],
|
|
636
|
+
},
|
|
637
|
+
summary: `Linked ${source_kind} evidence from "${source_id}" to "${target_doc_id}".`,
|
|
638
|
+
before: null,
|
|
639
|
+
after: {
|
|
640
|
+
link,
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const compatibilityDiagnostics = [];
|
|
645
|
+
if (!sourceFilePath) {
|
|
646
|
+
compatibilityDiagnostics.push({
|
|
647
|
+
code: "STALE_PATH",
|
|
648
|
+
severity: "warning",
|
|
649
|
+
message: `Canonical reference link was committed, but ${source_kind} '${source_id}' has no indexed file path for generated compatibility output.`,
|
|
650
|
+
next_step: "Treat SQLite and project backup artifacts as current. Run sync, inspect the indexed source path, then retry link_reference_evidence if compatibility output is still needed.",
|
|
651
|
+
details: {
|
|
652
|
+
source_kind,
|
|
653
|
+
source_id,
|
|
654
|
+
source_project_id: resolvedSourceProjectId,
|
|
655
|
+
target_doc_id,
|
|
656
|
+
indexed_path: null,
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
} else {
|
|
660
|
+
try {
|
|
661
|
+
writeReferenceLinkCompatibilityOutput({
|
|
662
|
+
sourceKind: source_kind,
|
|
663
|
+
sourceFilePath,
|
|
664
|
+
targetDocId: target_doc_id,
|
|
665
|
+
relation: normalizedRelation,
|
|
666
|
+
});
|
|
667
|
+
} catch (err) {
|
|
668
|
+
compatibilityDiagnostics.push({
|
|
669
|
+
code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
|
|
670
|
+
severity: "warning",
|
|
671
|
+
message: `Canonical reference link was committed, but generated compatibility metadata for ${source_kind} '${source_id}' could not be refreshed: ${err.message}`,
|
|
672
|
+
next_step: "Treat SQLite and project backup artifacts as current. Run sync, inspect the indexed source path, then retry link_reference_evidence if compatibility output is still needed.",
|
|
673
|
+
details: {
|
|
674
|
+
source_kind,
|
|
675
|
+
source_id,
|
|
676
|
+
source_project_id: resolvedSourceProjectId,
|
|
677
|
+
target_doc_id,
|
|
678
|
+
indexed_path: sourceFilePath,
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return jsonResponse({
|
|
685
|
+
ok: true,
|
|
686
|
+
action: operation === "upsert_reference_link" ? "upserted" : "linked",
|
|
687
|
+
link,
|
|
688
|
+
mutation_order: [
|
|
689
|
+
"validated_request",
|
|
690
|
+
"sqlite_commit",
|
|
691
|
+
"project_backup_refresh",
|
|
692
|
+
"compatibility_output_refresh",
|
|
693
|
+
],
|
|
694
|
+
compatibility_output: {
|
|
695
|
+
generated_transparency: true,
|
|
696
|
+
mutation_surface: false,
|
|
697
|
+
refreshed: compatibilityDiagnostics.length === 0,
|
|
698
|
+
},
|
|
699
|
+
compatibility_diagnostics: compatibilityDiagnostics,
|
|
700
|
+
...backupMutationFields(backupResult),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function connectCharacterPlaceEvidence({
|
|
705
|
+
project_id,
|
|
706
|
+
scene_id,
|
|
707
|
+
character_id,
|
|
708
|
+
place_id,
|
|
709
|
+
note,
|
|
710
|
+
}) {
|
|
711
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
712
|
+
return errorResponse("READ_ONLY", "Cannot connect character/place evidence: sync dir is read-only.");
|
|
713
|
+
}
|
|
714
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
715
|
+
if (!projectIdCheck.ok) {
|
|
716
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const scene = db.prepare(`
|
|
720
|
+
SELECT scene_id, project_id, file_path
|
|
721
|
+
FROM scenes
|
|
722
|
+
WHERE scene_id = ? AND project_id = ?
|
|
723
|
+
`).get(scene_id, project_id);
|
|
724
|
+
if (!scene) {
|
|
725
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const character = resolveCharacterForProject(db, { characterId: character_id, projectId: project_id });
|
|
729
|
+
if (!character) {
|
|
730
|
+
return errorResponse("NOT_FOUND", `Character '${character_id}' is not indexed for project '${project_id}' or its universe.`);
|
|
731
|
+
}
|
|
732
|
+
const place = resolvePlaceForProject(db, { placeId: place_id, projectId: project_id });
|
|
733
|
+
if (!place) {
|
|
734
|
+
return errorResponse("NOT_FOUND", `Place '${place_id}' is not indexed for project '${project_id}' or its universe.`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const before = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
|
|
738
|
+
try {
|
|
739
|
+
db.exec("BEGIN");
|
|
740
|
+
db.prepare(`
|
|
741
|
+
INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id)
|
|
742
|
+
VALUES (?, ?, ?)
|
|
743
|
+
`).run(scene_id, project_id, character_id);
|
|
744
|
+
db.prepare(`
|
|
745
|
+
INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id)
|
|
746
|
+
VALUES (?, ?, ?)
|
|
747
|
+
`).run(scene_id, project_id, place_id);
|
|
748
|
+
db.exec("COMMIT");
|
|
749
|
+
} catch (err) {
|
|
750
|
+
try {
|
|
751
|
+
db.exec("ROLLBACK");
|
|
752
|
+
} catch (rollbackErr) {
|
|
753
|
+
void rollbackErr;
|
|
754
|
+
}
|
|
755
|
+
return errorResponse("IO_ERROR", `Failed to connect character/place evidence for scene '${scene_id}': ${err.message}`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const after = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
|
|
759
|
+
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
760
|
+
syncDir: SYNC_DIR,
|
|
761
|
+
projectId: project_id,
|
|
762
|
+
applicationVersion: MCP_SERVER_VERSION,
|
|
763
|
+
operation: "connect_character_place_evidence",
|
|
764
|
+
actor: createToolActor("connect_character_place_evidence"),
|
|
765
|
+
affected: {
|
|
766
|
+
scenes: [scene_id],
|
|
767
|
+
characters: [character_id],
|
|
768
|
+
places: [place_id],
|
|
769
|
+
},
|
|
770
|
+
summary: `Connected character "${character_id}" and place "${place_id}" as evidence in scene "${scene_id}".`,
|
|
771
|
+
before: {
|
|
772
|
+
scene_relationships: before,
|
|
773
|
+
},
|
|
774
|
+
after: {
|
|
775
|
+
scene_relationships: after,
|
|
776
|
+
},
|
|
777
|
+
metadata: {
|
|
778
|
+
note: note ?? null,
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const compatibilityDiagnostics = [];
|
|
783
|
+
if (!scene.file_path) {
|
|
784
|
+
compatibilityDiagnostics.push({
|
|
785
|
+
code: "STALE_PATH",
|
|
786
|
+
severity: "warning",
|
|
787
|
+
message: `Canonical scene relationship evidence was committed, but scene '${scene_id}' has no indexed file path for generated compatibility output.`,
|
|
788
|
+
next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
|
|
789
|
+
details: {
|
|
790
|
+
scene_id,
|
|
791
|
+
project_id,
|
|
792
|
+
indexed_path: null,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
} else {
|
|
796
|
+
try {
|
|
797
|
+
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
798
|
+
writeMeta(scene.file_path, {
|
|
799
|
+
...sourceMeta,
|
|
800
|
+
characters: uniqueSorted([...normalizeStringList(sourceMeta.characters), character_id]),
|
|
801
|
+
places: uniqueSorted([...normalizeStringList(sourceMeta.places), place_id]),
|
|
802
|
+
}, { syncDir: SYNC_DIR });
|
|
803
|
+
} catch (err) {
|
|
804
|
+
compatibilityDiagnostics.push({
|
|
805
|
+
code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
|
|
806
|
+
severity: "warning",
|
|
807
|
+
message: `Canonical scene relationship evidence was committed, but generated scene metadata compatibility output could not be refreshed: ${err.message}`,
|
|
808
|
+
next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed scene path before retrying compatibility output.",
|
|
809
|
+
details: {
|
|
810
|
+
scene_id,
|
|
811
|
+
project_id,
|
|
812
|
+
indexed_path: scene.file_path,
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return jsonResponse({
|
|
819
|
+
ok: true,
|
|
820
|
+
action: "connected",
|
|
821
|
+
scene_id,
|
|
822
|
+
project_id,
|
|
823
|
+
character_id,
|
|
824
|
+
place_id,
|
|
825
|
+
note: note ?? null,
|
|
826
|
+
mutation_order: [
|
|
827
|
+
"validated_request",
|
|
828
|
+
"sqlite_commit",
|
|
829
|
+
"project_backup_refresh",
|
|
830
|
+
"compatibility_output_refresh",
|
|
831
|
+
],
|
|
832
|
+
compatibility_output: buildCompatibilityOutput({
|
|
833
|
+
refreshed: compatibilityDiagnostics.length === 0,
|
|
834
|
+
diagnostics: compatibilityDiagnostics,
|
|
835
|
+
}),
|
|
836
|
+
...backupMutationFields(backupResult),
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function recordCharacterRelationshipBeat({
|
|
841
|
+
project_id,
|
|
842
|
+
from_character,
|
|
843
|
+
to_character,
|
|
844
|
+
relationship_type,
|
|
845
|
+
strength,
|
|
846
|
+
scene_id,
|
|
847
|
+
note,
|
|
848
|
+
}) {
|
|
849
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
850
|
+
return errorResponse("READ_ONLY", "Cannot record character relationship beats: sync dir is read-only.");
|
|
851
|
+
}
|
|
852
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
853
|
+
if (!projectIdCheck.ok) {
|
|
854
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
855
|
+
}
|
|
856
|
+
if (from_character === to_character) {
|
|
857
|
+
return errorResponse("VALIDATION_ERROR", "from_character and to_character must be different characters.");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const fromCharacter = resolveCharacterForProject(db, { characterId: from_character, projectId: project_id });
|
|
861
|
+
if (!fromCharacter) {
|
|
862
|
+
return errorResponse("NOT_FOUND", `Character '${from_character}' is not indexed for project '${project_id}' or its universe.`);
|
|
863
|
+
}
|
|
864
|
+
const toCharacter = resolveCharacterForProject(db, { characterId: to_character, projectId: project_id });
|
|
865
|
+
if (!toCharacter) {
|
|
866
|
+
return errorResponse("NOT_FOUND", `Character '${to_character}' is not indexed for project '${project_id}' or its universe.`);
|
|
867
|
+
}
|
|
868
|
+
const scene = db.prepare(`
|
|
869
|
+
SELECT scene_id, project_id
|
|
870
|
+
FROM scenes
|
|
871
|
+
WHERE scene_id = ? AND project_id = ?
|
|
872
|
+
`).get(scene_id, project_id);
|
|
873
|
+
if (!scene) {
|
|
874
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const normalizedRelationshipType = relationship_type.trim().toLowerCase();
|
|
878
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(normalizedRelationshipType)) {
|
|
879
|
+
return errorResponse(
|
|
880
|
+
"VALIDATION_ERROR",
|
|
881
|
+
"relationship_type is normalized to lowercase and must match [a-z][a-z0-9_-]* after normalization.",
|
|
882
|
+
{ relationship_type }
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
const normalizedNote = note?.trim() || null;
|
|
886
|
+
const before = db.prepare(`
|
|
887
|
+
SELECT from_character, to_character, relationship_type, strength, scene_id, note
|
|
888
|
+
FROM character_relationships
|
|
889
|
+
WHERE from_character = ? AND to_character = ? AND relationship_type = ? AND scene_id = ? AND COALESCE(note, '') = COALESCE(?, '')
|
|
890
|
+
`).all(from_character, to_character, normalizedRelationshipType, scene_id, normalizedNote);
|
|
891
|
+
|
|
892
|
+
try {
|
|
893
|
+
db.exec("BEGIN");
|
|
894
|
+
db.prepare(`
|
|
895
|
+
DELETE FROM character_relationships
|
|
896
|
+
WHERE from_character = ? AND to_character = ? AND relationship_type = ? AND scene_id = ? AND COALESCE(note, '') = COALESCE(?, '')
|
|
897
|
+
`).run(from_character, to_character, normalizedRelationshipType, scene_id, normalizedNote);
|
|
898
|
+
db.prepare(`
|
|
899
|
+
INSERT INTO character_relationships (from_character, to_character, relationship_type, strength, scene_id, note)
|
|
900
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
901
|
+
`).run(from_character, to_character, normalizedRelationshipType, strength ?? null, scene_id, normalizedNote);
|
|
902
|
+
db.exec("COMMIT");
|
|
903
|
+
} catch (err) {
|
|
904
|
+
try {
|
|
905
|
+
db.exec("ROLLBACK");
|
|
906
|
+
} catch (rollbackErr) {
|
|
907
|
+
void rollbackErr;
|
|
908
|
+
}
|
|
909
|
+
return errorResponse("IO_ERROR", `Failed to record relationship beat for '${from_character}' and '${to_character}': ${err.message}`);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const relationship = db.prepare(`
|
|
913
|
+
SELECT from_character, to_character, relationship_type, strength, scene_id, note
|
|
914
|
+
FROM character_relationships
|
|
915
|
+
WHERE from_character = ? AND to_character = ? AND relationship_type = ? AND scene_id = ? AND COALESCE(note, '') = COALESCE(?, '')
|
|
916
|
+
`).get(from_character, to_character, normalizedRelationshipType, scene_id, normalizedNote);
|
|
917
|
+
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
918
|
+
syncDir: SYNC_DIR,
|
|
919
|
+
projectId: project_id,
|
|
920
|
+
applicationVersion: MCP_SERVER_VERSION,
|
|
921
|
+
operation: "record_character_relationship_beat",
|
|
922
|
+
actor: createToolActor("record_character_relationship_beat"),
|
|
923
|
+
affected: {
|
|
924
|
+
scenes: [scene_id],
|
|
925
|
+
characters: [from_character, to_character],
|
|
926
|
+
},
|
|
927
|
+
summary: `Recorded "${normalizedRelationshipType}" beat for "${from_character}" and "${to_character}" in scene "${scene_id}".`,
|
|
928
|
+
before: {
|
|
929
|
+
relationships: before,
|
|
930
|
+
},
|
|
931
|
+
after: {
|
|
932
|
+
relationship,
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
return jsonResponse({
|
|
937
|
+
ok: true,
|
|
938
|
+
action: "recorded",
|
|
939
|
+
relationship,
|
|
940
|
+
mutation_order: ["validated_request", "sqlite_commit", "project_backup_refresh"],
|
|
941
|
+
compatibility_output: {
|
|
942
|
+
role: "none",
|
|
943
|
+
reason: "Character relationship beats are canonical SQLite state and have no sidecar compatibility output in M4.",
|
|
944
|
+
},
|
|
945
|
+
...backupMutationFields(backupResult),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function auditRelationshipMetadata({ project_id }) {
|
|
950
|
+
const projectFilter = project_id ? `WHERE project_id = ?` : "";
|
|
951
|
+
const projectParams = project_id ? [project_id] : [];
|
|
952
|
+
const projectScope = project_id
|
|
953
|
+
? db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(project_id)
|
|
954
|
+
: null;
|
|
955
|
+
const entityFilter = project_id
|
|
956
|
+
? projectScope
|
|
957
|
+
? `WHERE project_id = ?
|
|
958
|
+
OR (universe_id IS NOT NULL AND universe_id = ?)
|
|
959
|
+
OR (project_id IS NULL AND universe_id IS NULL)`
|
|
960
|
+
: "WHERE 0"
|
|
961
|
+
: "";
|
|
962
|
+
const entityParams = project_id && projectScope
|
|
963
|
+
? [project_id, projectScope.universe_id]
|
|
964
|
+
: [];
|
|
965
|
+
const scenes = db.prepare(`
|
|
966
|
+
SELECT scene_id, project_id, file_path, metadata_stale
|
|
967
|
+
FROM scenes
|
|
968
|
+
${projectFilter}
|
|
969
|
+
ORDER BY project_id, scene_id
|
|
970
|
+
`).all(...projectParams);
|
|
971
|
+
const characters = db.prepare(`
|
|
972
|
+
SELECT character_id, project_id, universe_id, file_path
|
|
973
|
+
FROM characters
|
|
974
|
+
${entityFilter}
|
|
975
|
+
ORDER BY character_id
|
|
976
|
+
`).all(...entityParams);
|
|
977
|
+
const places = db.prepare(`
|
|
978
|
+
SELECT place_id, project_id, universe_id, file_path
|
|
979
|
+
FROM places
|
|
980
|
+
${entityFilter}
|
|
981
|
+
ORDER BY place_id
|
|
982
|
+
`).all(...entityParams);
|
|
983
|
+
|
|
984
|
+
const diagnostics = [];
|
|
985
|
+
for (const scene of scenes) {
|
|
986
|
+
if (scene.metadata_stale) {
|
|
987
|
+
diagnostics.push({
|
|
988
|
+
type: "stale_scene_relationship_index",
|
|
989
|
+
severity: "warning",
|
|
990
|
+
message: `Scene '${scene.scene_id}' has stale metadata; relationship indexes may lag prose.`,
|
|
991
|
+
scene_id: scene.scene_id,
|
|
992
|
+
project_id: scene.project_id,
|
|
993
|
+
next_step: "Use enrich_scene or enrich_scene_characters_batch dry_run to review prose-derived repairs before applying.",
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: false });
|
|
998
|
+
if (sourceMeta.threads) {
|
|
999
|
+
diagnostics.push({
|
|
1000
|
+
type: "sidecar_threads_compatibility_input",
|
|
1001
|
+
severity: "info",
|
|
1002
|
+
message: `Scene '${scene.scene_id}' still has sidecar thread metadata; use track_thread_arc for current thread authority.`,
|
|
1003
|
+
scene_id: scene.scene_id,
|
|
1004
|
+
project_id: scene.project_id,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
diagnostics.push({
|
|
1009
|
+
type: "scene_relationship_metadata_unreadable",
|
|
1010
|
+
severity: "warning",
|
|
1011
|
+
message: `Could not read scene metadata for '${scene.scene_id}': ${err.message}`,
|
|
1012
|
+
scene_id: scene.scene_id,
|
|
1013
|
+
project_id: scene.project_id,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
for (const character of characters) {
|
|
1019
|
+
if (!character.file_path) continue;
|
|
1020
|
+
try {
|
|
1021
|
+
const { sourceMeta } = readSourceMeta(character.file_path, SYNC_DIR, { writable: false });
|
|
1022
|
+
if (normalizeStringList(sourceMeta.tags).length > 0) {
|
|
1023
|
+
diagnostics.push({
|
|
1024
|
+
type: "character_tags_review_note",
|
|
1025
|
+
severity: "info",
|
|
1026
|
+
message: `Character '${character.character_id}' has sidecar tags; M4 treats these as compatibility/review notes, not relationship authority.`,
|
|
1027
|
+
character_id: character.character_id,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
} catch {
|
|
1031
|
+
// Character prose/metadata files are compatibility output for this audit.
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
for (const place of places) {
|
|
1036
|
+
if (!place.file_path) continue;
|
|
1037
|
+
try {
|
|
1038
|
+
const { sourceMeta } = readSourceMeta(place.file_path, SYNC_DIR, { writable: false });
|
|
1039
|
+
if (normalizeStringList(sourceMeta.associated_characters).length > 0) {
|
|
1040
|
+
diagnostics.push({
|
|
1041
|
+
type: "place_associated_characters_review_note",
|
|
1042
|
+
severity: "info",
|
|
1043
|
+
message: `Place '${place.place_id}' has associated_characters sidecar metadata; use connect_character_place_evidence for current scene-backed authority.`,
|
|
1044
|
+
place_id: place.place_id,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
if (normalizeStringList(sourceMeta.tags).length > 0) {
|
|
1048
|
+
diagnostics.push({
|
|
1049
|
+
type: "place_tags_review_note",
|
|
1050
|
+
severity: "info",
|
|
1051
|
+
message: `Place '${place.place_id}' has sidecar tags; M4 treats these as compatibility/review notes, not relationship authority.`,
|
|
1052
|
+
place_id: place.place_id,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
} catch {
|
|
1056
|
+
// Place prose/metadata files are compatibility output for this audit.
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return jsonResponse({
|
|
1061
|
+
ok: true,
|
|
1062
|
+
project_id: project_id ?? null,
|
|
1063
|
+
audit_authority: {
|
|
1064
|
+
canonical_relationship_sources: [
|
|
1065
|
+
"scene_characters",
|
|
1066
|
+
"scene_places",
|
|
1067
|
+
"threads",
|
|
1068
|
+
"scene_threads",
|
|
1069
|
+
"character_relationships",
|
|
1070
|
+
"reference_links",
|
|
1071
|
+
],
|
|
1072
|
+
compatibility_sources: [
|
|
1073
|
+
"scene sidecar characters/places/tags/threads",
|
|
1074
|
+
"character sidecar tags",
|
|
1075
|
+
"place sidecar associated_characters/tags",
|
|
1076
|
+
],
|
|
1077
|
+
compatibility_mutation_surface: false,
|
|
1078
|
+
},
|
|
1079
|
+
diagnostics,
|
|
1080
|
+
summary: {
|
|
1081
|
+
diagnostics_count: diagnostics.length,
|
|
1082
|
+
stale_scene_count: diagnostics.filter(diagnostic => diagnostic.type === "stale_scene_relationship_index").length,
|
|
1083
|
+
compatibility_note_count: diagnostics.filter(diagnostic => diagnostic.severity === "info").length,
|
|
1084
|
+
},
|
|
1085
|
+
next_steps: [
|
|
1086
|
+
"Use connect_character_place_evidence for scene-backed character/place relationships.",
|
|
1087
|
+
"Use record_character_relationship_beat for relationship arcs between characters.",
|
|
1088
|
+
"Use link_reference_evidence for explicit reference evidence.",
|
|
1089
|
+
"Use export_project_backup when a fresh recovery snapshot is needed.",
|
|
1090
|
+
],
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
374
1094
|
// ---- create_character_sheet ---------------------------------------------
|
|
375
1095
|
s.tool(
|
|
376
1096
|
"create_character_sheet",
|
|
@@ -529,249 +1249,102 @@ export function registerMetadataTools(s, {
|
|
|
529
1249
|
}
|
|
530
1250
|
);
|
|
531
1251
|
|
|
1252
|
+
// ---- track_thread_arc ----------------------------------------------------
|
|
1253
|
+
s.tool(
|
|
1254
|
+
"track_thread_arc",
|
|
1255
|
+
"Track a storyline, subplot, or recurring arc through a scene by recording the scene's thread beat. This is the outcome-level workflow for thread relationship changes: callers provide story intent, while SQLite thread tables, backup refresh, and rollback stay implementation details.",
|
|
1256
|
+
{
|
|
1257
|
+
project_id: z.string().describe("Project the thread belongs to (e.g. 'the-lamb')."),
|
|
1258
|
+
thread_id: z.string().describe("Stable thread ID for the arc being tracked (e.g. 'thread-reconciliation')."),
|
|
1259
|
+
thread_name: z.string().describe("Human-readable thread or arc name."),
|
|
1260
|
+
scene_id: z.string().describe("Scene that carries this thread beat (e.g. 'sc-011-sebastian')."),
|
|
1261
|
+
beat: z.string().optional().describe("Optional story beat for this scene in the thread, such as setup, escalation, reveal, reversal, or payoff."),
|
|
1262
|
+
status: z.string().optional().describe("Thread status (e.g. 'active', 'resolved'). Defaults to 'active'."),
|
|
1263
|
+
},
|
|
1264
|
+
async (args) => trackThreadArcLink(args, { operation: "track_thread_arc" })
|
|
1265
|
+
);
|
|
1266
|
+
|
|
532
1267
|
// ---- upsert_thread_link --------------------------------------------------
|
|
533
1268
|
s.tool(
|
|
534
1269
|
"upsert_thread_link",
|
|
535
|
-
"
|
|
1270
|
+
"Compatibility name for track_thread_arc. Prefer track_thread_arc when recording story intent; this retained alias still validates, writes SQLite first, refreshes project backups, and rolls back failed canonical writes.",
|
|
536
1271
|
{
|
|
537
1272
|
project_id: z.string().describe("Project the thread belongs to (e.g. 'the-lamb')."),
|
|
538
|
-
thread_id: z.string().describe("
|
|
539
|
-
thread_name: z.string().describe("
|
|
540
|
-
scene_id: z.string().describe("Scene
|
|
541
|
-
beat: z.string().optional().describe("Optional
|
|
1273
|
+
thread_id: z.string().describe("Stable thread ID for the arc being tracked (e.g. 'thread-reconciliation')."),
|
|
1274
|
+
thread_name: z.string().describe("Human-readable thread or arc name."),
|
|
1275
|
+
scene_id: z.string().describe("Scene that carries this thread beat (e.g. 'sc-011-sebastian')."),
|
|
1276
|
+
beat: z.string().optional().describe("Optional story beat for this scene in the thread, such as setup, escalation, reveal, reversal, or payoff."),
|
|
542
1277
|
status: z.string().optional().describe("Thread status (e.g. 'active', 'resolved'). Defaults to 'active'."),
|
|
543
1278
|
},
|
|
544
|
-
async (
|
|
545
|
-
|
|
546
|
-
return errorResponse("READ_ONLY", "Cannot write thread links: sync dir is read-only.");
|
|
547
|
-
}
|
|
548
|
-
const projectIdCheck = validateProjectId(project_id);
|
|
549
|
-
if (!projectIdCheck.ok) {
|
|
550
|
-
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const existingThread = db.prepare(`SELECT thread_id, project_id FROM threads WHERE thread_id = ?`).get(thread_id);
|
|
554
|
-
if (existingThread && existingThread.project_id !== project_id) {
|
|
555
|
-
return errorResponse(
|
|
556
|
-
"CONFLICT",
|
|
557
|
-
`Thread '${thread_id}' already exists in project '${existingThread.project_id}', cannot reuse it for project '${project_id}'.`
|
|
558
|
-
);
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
const scene = db.prepare(`SELECT scene_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
|
|
562
|
-
if (!scene) {
|
|
563
|
-
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
db.prepare(`
|
|
567
|
-
INSERT INTO threads (thread_id, project_id, name, status)
|
|
568
|
-
VALUES (?, ?, ?, ?)
|
|
569
|
-
ON CONFLICT (thread_id) DO UPDATE SET
|
|
570
|
-
name = excluded.name,
|
|
571
|
-
status = excluded.status
|
|
572
|
-
`).run(thread_id, project_id, thread_name, status ?? "active");
|
|
573
|
-
|
|
574
|
-
db.prepare(`
|
|
575
|
-
INSERT INTO scene_threads (scene_id, project_id, thread_id, beat)
|
|
576
|
-
VALUES (?, ?, ?, ?)
|
|
577
|
-
ON CONFLICT (scene_id, project_id, thread_id) DO UPDATE SET
|
|
578
|
-
beat = excluded.beat
|
|
579
|
-
`).run(scene_id, project_id, thread_id, beat ?? null);
|
|
580
|
-
|
|
581
|
-
const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
|
|
582
|
-
const link = db.prepare(`SELECT scene_id, project_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND project_id = ? AND thread_id = ?`)
|
|
583
|
-
.get(scene_id, project_id, thread_id);
|
|
584
|
-
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
585
|
-
syncDir: SYNC_DIR,
|
|
586
|
-
projectId: project_id,
|
|
587
|
-
applicationVersion: MCP_SERVER_VERSION,
|
|
588
|
-
operation: "upsert_thread_link",
|
|
589
|
-
actor: createToolActor("upsert_thread_link"),
|
|
590
|
-
affected: {
|
|
591
|
-
threads: [thread_id],
|
|
592
|
-
scenes: [scene_id],
|
|
593
|
-
},
|
|
594
|
-
summary: `Upserted thread "${thread_id}" link for scene "${scene_id}".`,
|
|
595
|
-
before: null,
|
|
596
|
-
after: {
|
|
597
|
-
thread,
|
|
598
|
-
link,
|
|
599
|
-
},
|
|
600
|
-
});
|
|
1279
|
+
async (args) => trackThreadArcLink(args, { operation: "upsert_thread_link" })
|
|
1280
|
+
);
|
|
601
1281
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1282
|
+
// ---- link_reference_evidence --------------------------------------------
|
|
1283
|
+
s.tool(
|
|
1284
|
+
"link_reference_evidence",
|
|
1285
|
+
"Link scene, character, place, or reference evidence to a reference document. This is the outcome-level workflow for evidence relationships: SQLite commits first, project backup artifacts refresh after commit, and sidecar/frontmatter compatibility output is refreshed only as generated transparency.",
|
|
1286
|
+
{
|
|
1287
|
+
source_kind: z.enum(["scene", "character", "place", "reference"]).describe("Evidence source kind."),
|
|
1288
|
+
source_id: z.string().describe("Source scene_id, character_id, place_id, or reference doc_id."),
|
|
1289
|
+
source_project_id: z.string().optional().describe("Optional project scope for the source. For scene/character/place sources, use this to disambiguate an ambiguous source_id across projects. For reference sources, when provided, it is treated as an ownership check and must match the source reference doc's project."),
|
|
1290
|
+
target_doc_id: z.string().describe("Target reference doc_id."),
|
|
1291
|
+
relation: z.string().describe("Evidence relationship label (for example: 'informs', 'related', 'history_of'). The value is trimmed and lowercased before validation."),
|
|
1292
|
+
},
|
|
1293
|
+
async (args) => linkReferenceEvidence(args, { operation: "link_reference_evidence" })
|
|
610
1294
|
);
|
|
611
1295
|
|
|
612
1296
|
// ---- upsert_reference_link -----------------------------------------------
|
|
613
1297
|
s.tool(
|
|
614
1298
|
"upsert_reference_link",
|
|
615
|
-
"
|
|
1299
|
+
"Compatibility name for link_reference_evidence. Prefer link_reference_evidence when recording story evidence; this retained alias still validates, commits SQLite first, refreshes project backups, and treats sidecar/frontmatter output as generated compatibility.",
|
|
616
1300
|
{
|
|
617
|
-
source_kind: z.enum(["scene", "character", "place", "reference"]).describe("
|
|
1301
|
+
source_kind: z.enum(["scene", "character", "place", "reference"]).describe("Evidence source kind."),
|
|
618
1302
|
source_id: z.string().describe("Source scene_id, character_id, place_id, or reference doc_id."),
|
|
619
1303
|
source_project_id: z.string().optional().describe("Optional project scope for the source. For scene/character/place sources, use this to disambiguate an ambiguous source_id across projects. For reference sources, when provided, it is treated as an ownership check and must match the source reference doc's project."),
|
|
620
1304
|
target_doc_id: z.string().describe("Target reference doc_id."),
|
|
621
|
-
relation: z.string().describe("
|
|
1305
|
+
relation: z.string().describe("Evidence relationship label (for example: 'informs', 'related', 'history_of'). The value is trimmed and lowercased before validation."),
|
|
622
1306
|
},
|
|
623
|
-
async (
|
|
624
|
-
|
|
625
|
-
return errorResponse("READ_ONLY", "Cannot write reference links: sync dir is read-only.");
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const normalizedRelation = relation.trim().toLowerCase();
|
|
629
|
-
if (!/^[a-z][a-z0-9_-]*$/.test(normalizedRelation)) {
|
|
630
|
-
return errorResponse(
|
|
631
|
-
"VALIDATION_ERROR",
|
|
632
|
-
"Relation is normalized to lowercase and must match [a-z][a-z0-9_-]* after normalization (for example: 'informs' or 'history_of').",
|
|
633
|
-
{ relation }
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const targetDoc = db.prepare(`
|
|
638
|
-
SELECT doc_id, project_id
|
|
639
|
-
FROM reference_docs
|
|
640
|
-
WHERE doc_id = ?
|
|
641
|
-
`).get(target_doc_id);
|
|
642
|
-
if (!targetDoc) {
|
|
643
|
-
return errorResponse("NOT_FOUND", `Target reference doc '${target_doc_id}' not found.`);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const sourceResolution = resolveReferenceLinkSource({
|
|
647
|
-
db,
|
|
648
|
-
errorResponse,
|
|
649
|
-
sourceKind: source_kind,
|
|
650
|
-
sourceId: source_id,
|
|
651
|
-
sourceProjectId: source_project_id,
|
|
652
|
-
targetDocId: target_doc_id,
|
|
653
|
-
});
|
|
654
|
-
if (sourceResolution.error) {
|
|
655
|
-
return sourceResolution.error;
|
|
656
|
-
}
|
|
657
|
-
const { resolvedSourceProjectId, sourceFilePath } = sourceResolution.value;
|
|
658
|
-
|
|
659
|
-
try {
|
|
660
|
-
if (source_kind === "scene") {
|
|
661
|
-
if (!sourceFilePath) {
|
|
662
|
-
return errorResponse("STALE_PATH", `Scene '${source_id}' has no indexed file path. Run sync() to refresh.`, {
|
|
663
|
-
source_id,
|
|
664
|
-
source_project_id: resolvedSourceProjectId,
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
persistSceneReferenceLink({
|
|
668
|
-
scenePath: sourceFilePath,
|
|
669
|
-
syncDir: SYNC_DIR,
|
|
670
|
-
targetDocId: target_doc_id,
|
|
671
|
-
relation: normalizedRelation,
|
|
672
|
-
});
|
|
673
|
-
} else if (source_kind === "character") {
|
|
674
|
-
if (!sourceFilePath) {
|
|
675
|
-
return errorResponse("STALE_PATH", `Character '${source_id}' has no indexed file path. Run sync() to refresh.`, {
|
|
676
|
-
source_id,
|
|
677
|
-
source_project_id: resolvedSourceProjectId,
|
|
678
|
-
});
|
|
679
|
-
}
|
|
680
|
-
persistCharacterReferenceLink({
|
|
681
|
-
characterPath: sourceFilePath,
|
|
682
|
-
syncDir: SYNC_DIR,
|
|
683
|
-
targetDocId: target_doc_id,
|
|
684
|
-
relation: normalizedRelation,
|
|
685
|
-
});
|
|
686
|
-
} else if (source_kind === "place") {
|
|
687
|
-
if (!sourceFilePath) {
|
|
688
|
-
return errorResponse("STALE_PATH", `Place '${source_id}' has no indexed file path. Run sync() to refresh.`, {
|
|
689
|
-
source_id,
|
|
690
|
-
source_project_id: resolvedSourceProjectId,
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
persistPlaceReferenceLink({
|
|
694
|
-
placePath: sourceFilePath,
|
|
695
|
-
syncDir: SYNC_DIR,
|
|
696
|
-
targetDocId: target_doc_id,
|
|
697
|
-
relation: normalizedRelation,
|
|
698
|
-
});
|
|
699
|
-
} else {
|
|
700
|
-
if (!sourceFilePath) {
|
|
701
|
-
return errorResponse("STALE_PATH", `Reference doc '${source_id}' has no indexed file path. Run sync() to refresh.`, {
|
|
702
|
-
source_id,
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
persistReferenceDocLink({
|
|
706
|
-
filePath: sourceFilePath,
|
|
707
|
-
syncDir: SYNC_DIR,
|
|
708
|
-
targetDocId: target_doc_id,
|
|
709
|
-
relation: normalizedRelation,
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
} catch (err) {
|
|
713
|
-
if (err?.code === "ENOENT") {
|
|
714
|
-
return errorResponse(
|
|
715
|
-
"STALE_PATH",
|
|
716
|
-
`Source file for ${source_kind} '${source_id}' not found at indexed path — run sync() to refresh.`,
|
|
717
|
-
{ indexed_path: sourceFilePath }
|
|
718
|
-
);
|
|
719
|
-
}
|
|
720
|
-
if (err?.name === "CoreValidationError") {
|
|
721
|
-
return errorResponse(err.code, err.message, err.details);
|
|
722
|
-
}
|
|
723
|
-
return errorResponse("IO_ERROR", `Failed to persist link metadata: ${err.message}`);
|
|
724
|
-
}
|
|
1307
|
+
async (args) => linkReferenceEvidence(args, { operation: "upsert_reference_link" })
|
|
1308
|
+
);
|
|
725
1309
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
} catch (rollbackErr) {
|
|
740
|
-
void rollbackErr;
|
|
741
|
-
}
|
|
742
|
-
throw err;
|
|
743
|
-
}
|
|
1310
|
+
// ---- connect_character_place_evidence ------------------------------------
|
|
1311
|
+
s.tool(
|
|
1312
|
+
"connect_character_place_evidence",
|
|
1313
|
+
"Connect a character and place as scene-backed story evidence. This is the outcome-level workflow for character/place association: SQLite scene relationship indexes commit first, project backups refresh after commit, and scene sidecar characters/places are refreshed only as generated compatibility output.",
|
|
1314
|
+
{
|
|
1315
|
+
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
1316
|
+
scene_id: z.string().describe("Scene that provides the evidence for this character/place association."),
|
|
1317
|
+
character_id: z.string().describe("Character present in the scene. Use list_characters to find valid IDs."),
|
|
1318
|
+
place_id: z.string().describe("Place present in the scene. Use list_places to find valid IDs."),
|
|
1319
|
+
note: z.string().optional().describe("Optional review note explaining the evidence. Stored in operation history, not in compatibility sidecars."),
|
|
1320
|
+
},
|
|
1321
|
+
async (args) => connectCharacterPlaceEvidence(args)
|
|
1322
|
+
);
|
|
744
1323
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
},
|
|
761
|
-
summary: `Upserted ${source_kind} reference link from "${source_id}" to "${target_doc_id}".`,
|
|
762
|
-
before: null,
|
|
763
|
-
after: {
|
|
764
|
-
link,
|
|
765
|
-
},
|
|
766
|
-
});
|
|
1324
|
+
// ---- record_character_relationship_beat ----------------------------------
|
|
1325
|
+
s.tool(
|
|
1326
|
+
"record_character_relationship_beat",
|
|
1327
|
+
"Record how two characters relate in a specific scene. This outcome-level workflow writes character relationship beats directly to SQLite, validates scene evidence, refreshes project backups after commit, and does not require callers to know the character_relationships table.",
|
|
1328
|
+
{
|
|
1329
|
+
project_id: z.string().describe("Project the scene evidence belongs to (e.g. 'the-lamb')."),
|
|
1330
|
+
from_character: z.string().describe("First character_id in the relationship beat."),
|
|
1331
|
+
to_character: z.string().describe("Second character_id in the relationship beat."),
|
|
1332
|
+
relationship_type: z.string().describe("Relationship label such as trusts, protects, fears, betrays, or reconciles. Trimmed and lowercased before validation."),
|
|
1333
|
+
strength: z.string().optional().describe("Optional qualitative strength or direction for this scene beat."),
|
|
1334
|
+
scene_id: z.string().describe("Scene that provides the evidence for this relationship beat."),
|
|
1335
|
+
note: z.string().optional().describe("Optional short evidence note for this beat."),
|
|
1336
|
+
},
|
|
1337
|
+
async (args) => recordCharacterRelationshipBeat(args)
|
|
1338
|
+
);
|
|
767
1339
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
1340
|
+
// ---- audit_relationship_metadata -----------------------------------------
|
|
1341
|
+
s.tool(
|
|
1342
|
+
"audit_relationship_metadata",
|
|
1343
|
+
"Review relationship metadata authority, stale indexes, and retained compatibility notes without mutating SQLite or files. Use this before repair work when character/place associations, sidecar tags, scene threads, or recovery readiness look stale or ambiguous.",
|
|
1344
|
+
{
|
|
1345
|
+
project_id: z.string().optional().describe("Optional project scope for the audit."),
|
|
1346
|
+
},
|
|
1347
|
+
async (args) => auditRelationshipMetadata(args)
|
|
775
1348
|
);
|
|
776
1349
|
|
|
777
1350
|
// ---- create_chapter ------------------------------------------------------
|
|
@@ -1746,7 +2319,7 @@ export function registerMetadataTools(s, {
|
|
|
1746
2319
|
// ---- update_character_sheet ----------------------------------------------
|
|
1747
2320
|
s.tool(
|
|
1748
2321
|
"update_character_sheet",
|
|
1749
|
-
"Update
|
|
2322
|
+
"Update canonical character profile fields such as name, role, arc_summary, first_appearance, and traits. SQLite commits first, project backups refresh after commit, and the .meta.yaml file is refreshed only as generated compatibility output; prose notes are never modified.",
|
|
1750
2323
|
{
|
|
1751
2324
|
character_id: z.string().describe("The character_id to update (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
|
|
1752
2325
|
fields: z.object({
|
|
@@ -1765,82 +2338,122 @@ export function registerMetadataTools(s, {
|
|
|
1765
2338
|
if (!char) {
|
|
1766
2339
|
return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
|
|
1767
2340
|
}
|
|
2341
|
+
const beforeTraits = db.prepare(`SELECT trait FROM character_traits WHERE character_id = ? ORDER BY trait`)
|
|
2342
|
+
.all(character_id).map(row => row.trait);
|
|
2343
|
+
const nextCharacter = {
|
|
2344
|
+
name: fields.name ?? char.name,
|
|
2345
|
+
role: Object.hasOwn(fields, "role") ? fields.role ?? null : char.role,
|
|
2346
|
+
arc_summary: Object.hasOwn(fields, "arc_summary") ? fields.arc_summary ?? null : char.arc_summary,
|
|
2347
|
+
first_appearance: Object.hasOwn(fields, "first_appearance") ? fields.first_appearance ?? null : char.first_appearance,
|
|
2348
|
+
};
|
|
2349
|
+
const nextTraits = fields.traits ? uniqueSorted(fields.traits) : beforeTraits;
|
|
1768
2350
|
try {
|
|
1769
|
-
|
|
1770
|
-
const updated = { ...meta, ...fields };
|
|
1771
|
-
writeMeta(char.file_path, updated, { syncDir: SYNC_DIR });
|
|
1772
|
-
|
|
2351
|
+
db.exec("BEGIN");
|
|
1773
2352
|
db.prepare(`
|
|
1774
2353
|
UPDATE characters SET name = ?, role = ?, arc_summary = ?, first_appearance = ?
|
|
1775
2354
|
WHERE character_id = ?
|
|
1776
2355
|
`).run(
|
|
1777
|
-
|
|
1778
|
-
|
|
2356
|
+
nextCharacter.name, nextCharacter.role,
|
|
2357
|
+
nextCharacter.arc_summary, nextCharacter.first_appearance,
|
|
1779
2358
|
character_id
|
|
1780
2359
|
);
|
|
1781
2360
|
if (fields.traits) {
|
|
1782
2361
|
db.prepare(`DELETE FROM character_traits WHERE character_id = ?`).run(character_id);
|
|
1783
|
-
for (const t of
|
|
2362
|
+
for (const t of nextTraits) {
|
|
1784
2363
|
db.prepare(`INSERT OR IGNORE INTO character_traits (character_id, trait) VALUES (?, ?)`).run(character_id, t);
|
|
1785
2364
|
}
|
|
1786
2365
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2366
|
+
db.exec("COMMIT");
|
|
2367
|
+
} catch (err) {
|
|
2368
|
+
try {
|
|
2369
|
+
db.exec("ROLLBACK");
|
|
2370
|
+
} catch (rollbackErr) {
|
|
2371
|
+
void rollbackErr;
|
|
2372
|
+
}
|
|
2373
|
+
return errorResponse("IO_ERROR", `Failed to update canonical character metadata for '${character_id}': ${err.message}`);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
2377
|
+
syncDir: SYNC_DIR,
|
|
2378
|
+
projectId: char.project_id,
|
|
2379
|
+
applicationVersion: MCP_SERVER_VERSION,
|
|
2380
|
+
operation: "update_character_sheet",
|
|
2381
|
+
actor: createToolActor("update_character_sheet"),
|
|
2382
|
+
affected: {
|
|
2383
|
+
characters: [character_id],
|
|
2384
|
+
},
|
|
2385
|
+
summary: `Updated character sheet "${character_id}".`,
|
|
2386
|
+
before: {
|
|
2387
|
+
character: {
|
|
2388
|
+
character_id,
|
|
2389
|
+
project_id: char.project_id,
|
|
2390
|
+
universe_id: char.universe_id,
|
|
2391
|
+
name: char.name,
|
|
2392
|
+
role: char.role,
|
|
2393
|
+
arc_summary: char.arc_summary,
|
|
2394
|
+
first_appearance: char.first_appearance,
|
|
2395
|
+
traits: beforeTraits,
|
|
1807
2396
|
},
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
first_appearance: updated.first_appearance ?? null,
|
|
1817
|
-
},
|
|
2397
|
+
},
|
|
2398
|
+
after: {
|
|
2399
|
+
character: {
|
|
2400
|
+
character_id,
|
|
2401
|
+
project_id: char.project_id,
|
|
2402
|
+
universe_id: char.universe_id,
|
|
2403
|
+
...nextCharacter,
|
|
2404
|
+
traits: nextTraits,
|
|
1818
2405
|
},
|
|
1819
|
-
}
|
|
2406
|
+
},
|
|
2407
|
+
});
|
|
1820
2408
|
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
character_id,
|
|
1826
|
-
...backupMutationFields(backupResult),
|
|
1827
|
-
});
|
|
1828
|
-
} catch (err) {
|
|
1829
|
-
if (err?.name === "CoreValidationError") {
|
|
1830
|
-
return errorResponse(err.code, err.message, err.details);
|
|
1831
|
-
}
|
|
1832
|
-
if (err.code === "ENOENT") {
|
|
1833
|
-
return errorResponse("STALE_PATH", `Character file for '${character_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: char.file_path });
|
|
2409
|
+
const compatibilityDiagnostics = [];
|
|
2410
|
+
try {
|
|
2411
|
+
if (!char.file_path) {
|
|
2412
|
+
throw Object.assign(new Error("character has no indexed file path"), { code: "STALE_PATH" });
|
|
1834
2413
|
}
|
|
1835
|
-
|
|
2414
|
+
const { meta } = readMeta(char.file_path, SYNC_DIR, { writable: true });
|
|
2415
|
+
writeMeta(char.file_path, {
|
|
2416
|
+
...meta,
|
|
2417
|
+
...nextCharacter,
|
|
2418
|
+
traits: nextTraits,
|
|
2419
|
+
}, { syncDir: SYNC_DIR });
|
|
2420
|
+
} catch (err) {
|
|
2421
|
+
compatibilityDiagnostics.push({
|
|
2422
|
+
code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
|
|
2423
|
+
severity: "warning",
|
|
2424
|
+
message: `Canonical character metadata was committed, but generated compatibility output for '${character_id}' could not be refreshed: ${err.message}`,
|
|
2425
|
+
next_step: "Treat SQLite and project backup artifacts as current. Run sync and inspect the indexed character path before retrying compatibility output.",
|
|
2426
|
+
details: {
|
|
2427
|
+
character_id,
|
|
2428
|
+
indexed_path: char.file_path,
|
|
2429
|
+
},
|
|
2430
|
+
});
|
|
1836
2431
|
}
|
|
2432
|
+
|
|
2433
|
+
return jsonResponse({
|
|
2434
|
+
ok: true,
|
|
2435
|
+
action: "updated",
|
|
2436
|
+
message: `Updated character sheet for '${character_id}'.`,
|
|
2437
|
+
character_id,
|
|
2438
|
+
mutation_order: [
|
|
2439
|
+
"validated_request",
|
|
2440
|
+
"sqlite_commit",
|
|
2441
|
+
"project_backup_refresh",
|
|
2442
|
+
"compatibility_output_refresh",
|
|
2443
|
+
],
|
|
2444
|
+
compatibility_output: buildCompatibilityOutput({
|
|
2445
|
+
refreshed: compatibilityDiagnostics.length === 0,
|
|
2446
|
+
diagnostics: compatibilityDiagnostics,
|
|
2447
|
+
}),
|
|
2448
|
+
...backupMutationFields(backupResult),
|
|
2449
|
+
});
|
|
1837
2450
|
}
|
|
1838
2451
|
);
|
|
1839
2452
|
|
|
1840
2453
|
// ---- update_place_sheet --------------------------------------------------
|
|
1841
2454
|
s.tool(
|
|
1842
2455
|
"update_place_sheet",
|
|
1843
|
-
"Update
|
|
2456
|
+
"Update canonical place profile fields and retained compatibility notes. The place name commits to SQLite first and refreshes project backups; associated_characters and tags are compatibility/review metadata only. For current character/place relationship authority, use connect_character_place_evidence.",
|
|
1844
2457
|
{
|
|
1845
2458
|
place_id: z.string().describe("The place_id to update (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
|
|
1846
2459
|
fields: z.object({
|
|
@@ -1857,14 +2470,25 @@ export function registerMetadataTools(s, {
|
|
|
1857
2470
|
if (!place) {
|
|
1858
2471
|
return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
|
|
1859
2472
|
}
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
writeMeta(place.file_path, updated, { syncDir: SYNC_DIR });
|
|
2473
|
+
const hasCanonicalNameUpdate = Object.hasOwn(fields, "name");
|
|
2474
|
+
const nextName = fields.name ?? place.name;
|
|
2475
|
+
let backupResult = emptyBackupMutationResult();
|
|
1864
2476
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2477
|
+
if (hasCanonicalNameUpdate) {
|
|
2478
|
+
try {
|
|
2479
|
+
db.exec("BEGIN");
|
|
2480
|
+
db.prepare(`UPDATE places SET name = ? WHERE place_id = ?`)
|
|
2481
|
+
.run(nextName, place_id);
|
|
2482
|
+
db.exec("COMMIT");
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
try {
|
|
2485
|
+
db.exec("ROLLBACK");
|
|
2486
|
+
} catch (rollbackErr) {
|
|
2487
|
+
void rollbackErr;
|
|
2488
|
+
}
|
|
2489
|
+
return errorResponse("IO_ERROR", `Failed to update canonical place metadata for '${place_id}': ${err.message}`);
|
|
2490
|
+
}
|
|
2491
|
+
backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
1868
2492
|
syncDir: SYNC_DIR,
|
|
1869
2493
|
projectId: place.project_id,
|
|
1870
2494
|
applicationVersion: MCP_SERVER_VERSION,
|
|
@@ -1887,34 +2511,68 @@ export function registerMetadataTools(s, {
|
|
|
1887
2511
|
place_id,
|
|
1888
2512
|
project_id: place.project_id,
|
|
1889
2513
|
universe_id: place.universe_id,
|
|
1890
|
-
name:
|
|
2514
|
+
name: nextName,
|
|
1891
2515
|
},
|
|
1892
2516
|
},
|
|
1893
2517
|
});
|
|
2518
|
+
}
|
|
1894
2519
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
place_id,
|
|
1900
|
-
...backupMutationFields(backupResult),
|
|
1901
|
-
});
|
|
1902
|
-
} catch (err) {
|
|
1903
|
-
if (err?.name === "CoreValidationError") {
|
|
1904
|
-
return errorResponse(err.code, err.message, err.details);
|
|
1905
|
-
}
|
|
1906
|
-
if (err.code === "ENOENT") {
|
|
1907
|
-
return errorResponse("STALE_PATH", `Place file for '${place_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: place.file_path });
|
|
2520
|
+
const compatibilityDiagnostics = [];
|
|
2521
|
+
try {
|
|
2522
|
+
if (!place.file_path) {
|
|
2523
|
+
throw Object.assign(new Error("place has no indexed file path"), { code: "STALE_PATH" });
|
|
1908
2524
|
}
|
|
1909
|
-
|
|
2525
|
+
const { meta } = readMeta(place.file_path, SYNC_DIR, { writable: true });
|
|
2526
|
+
const updated = { ...meta, ...fields, name: nextName };
|
|
2527
|
+
writeMeta(place.file_path, updated, { syncDir: SYNC_DIR });
|
|
2528
|
+
} catch (err) {
|
|
2529
|
+
compatibilityDiagnostics.push({
|
|
2530
|
+
code: err?.code ?? "COMPATIBILITY_OUTPUT_FAILED",
|
|
2531
|
+
severity: "warning",
|
|
2532
|
+
message: `Place metadata was updated, but generated compatibility output for '${place_id}' could not be refreshed: ${err.message}`,
|
|
2533
|
+
next_step: "Treat SQLite and project backup artifacts as current for canonical place fields. Use connect_character_place_evidence for relationship authority.",
|
|
2534
|
+
details: {
|
|
2535
|
+
place_id,
|
|
2536
|
+
indexed_path: place.file_path,
|
|
2537
|
+
},
|
|
2538
|
+
});
|
|
1910
2539
|
}
|
|
2540
|
+
|
|
2541
|
+
return jsonResponse({
|
|
2542
|
+
ok: true,
|
|
2543
|
+
action: "updated",
|
|
2544
|
+
message: `Updated place sheet for '${place_id}'.`,
|
|
2545
|
+
place_id,
|
|
2546
|
+
canonical_mutation: hasCanonicalNameUpdate,
|
|
2547
|
+
mutation_order: hasCanonicalNameUpdate
|
|
2548
|
+
? [
|
|
2549
|
+
"validated_request",
|
|
2550
|
+
"sqlite_commit",
|
|
2551
|
+
"project_backup_refresh",
|
|
2552
|
+
"compatibility_output_refresh",
|
|
2553
|
+
]
|
|
2554
|
+
: [
|
|
2555
|
+
"validated_request",
|
|
2556
|
+
"compatibility_review_note_refresh",
|
|
2557
|
+
],
|
|
2558
|
+
compatibility_output: buildCompatibilityOutput({
|
|
2559
|
+
refreshed: compatibilityDiagnostics.length === 0,
|
|
2560
|
+
diagnostics: compatibilityDiagnostics,
|
|
2561
|
+
role: hasCanonicalNameUpdate ? "generated_transparency" : "review_note",
|
|
2562
|
+
}),
|
|
2563
|
+
non_canonical_fields: ["associated_characters", "tags"].filter(field => Object.hasOwn(fields, field)),
|
|
2564
|
+
next_step: Object.hasOwn(fields, "associated_characters")
|
|
2565
|
+
? "Use connect_character_place_evidence to make scene-backed character/place associations authoritative."
|
|
2566
|
+
: undefined,
|
|
2567
|
+
...backupMutationFields(backupResult),
|
|
2568
|
+
});
|
|
1911
2569
|
}
|
|
1912
2570
|
);
|
|
1913
2571
|
|
|
1914
2572
|
// ---- flag_scene ----------------------------------------------------------
|
|
1915
2573
|
s.tool(
|
|
1916
2574
|
"flag_scene",
|
|
1917
|
-
"Attach a continuity or review note to a scene. Flags are
|
|
2575
|
+
"Attach a continuity or review note to a scene as compatibility review metadata. Flags are not canonical relationship authority and do not mutate SQLite; use audit_relationship_metadata, connect_character_place_evidence, record_character_relationship_beat, or link_reference_evidence when the note identifies relationship repair work.",
|
|
1918
2576
|
{
|
|
1919
2577
|
scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
|
|
1920
2578
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
@@ -1934,7 +2592,21 @@ export function registerMetadataTools(s, {
|
|
|
1934
2592
|
const flags = sourceMeta.flags ?? [];
|
|
1935
2593
|
flags.push({ note, flagged_at: new Date().toISOString() });
|
|
1936
2594
|
writeMeta(scene.file_path, { ...sourceMeta, flags }, { syncDir: SYNC_DIR });
|
|
1937
|
-
return {
|
|
2595
|
+
return jsonResponse({
|
|
2596
|
+
ok: true,
|
|
2597
|
+
action: "flagged",
|
|
2598
|
+
message: `Flagged scene '${scene_id}': ${note}`,
|
|
2599
|
+
scene_id,
|
|
2600
|
+
project_id,
|
|
2601
|
+
compatibility_output: {
|
|
2602
|
+
role: "review_note",
|
|
2603
|
+
generated_transparency: false,
|
|
2604
|
+
mutation_surface: false,
|
|
2605
|
+
canonical_mutation: false,
|
|
2606
|
+
refreshed: true,
|
|
2607
|
+
},
|
|
2608
|
+
next_step: "If this flag identifies relationship drift, use audit_relationship_metadata before applying an outcome-level repair.",
|
|
2609
|
+
});
|
|
1938
2610
|
} catch (err) {
|
|
1939
2611
|
if (err?.name === "CoreValidationError") {
|
|
1940
2612
|
return errorResponse(err.code, err.message, err.details);
|