@hanna84/mcp-writing 3.21.2 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/scripts/normalize-scene-characters.mjs +5 -5
- package/src/sync/scene-character-batch.js +13 -4
- package/src/sync/sync.js +29 -9
- package/src/tools/metadata.js +987 -313
- package/src/tools/reference-link-persistence.js +7 -2
- package/src/tools/search.js +84 -44
- package/src/tools/sync.js +20 -7
- package/src/workflows/workflow-catalogue.js +20 -1
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, writeMeta, indexSceneFile, isManagedStructureProject } from "../sync/sync.js";
|
|
5
|
+
import { readMeta, readSourceMeta, writeMeta, indexSceneFile, isManagedStructureProject, normalizeSceneMetaForPath } 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 {
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
refreshProjectBackupAfterMutation,
|
|
34
34
|
} from "../structure/project-backup-refresh.js";
|
|
35
35
|
|
|
36
|
-
const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "timeline_position"];
|
|
36
|
+
const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "chapter_title", "timeline_position"];
|
|
37
37
|
|
|
38
38
|
function emptyBackupMutationResult() {
|
|
39
39
|
return {
|
|
@@ -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 ------------------------------------------------------
|
|
@@ -1641,7 +2214,7 @@ export function registerMetadataTools(s, {
|
|
|
1641
2214
|
// ---- update_scene_metadata -----------------------------------------------
|
|
1642
2215
|
s.tool(
|
|
1643
2216
|
"update_scene_metadata",
|
|
1644
|
-
"Update one or more non-structural metadata fields for a scene. Writes to the .meta.yaml sidecar
|
|
2217
|
+
"Update one or more non-structural metadata fields for a scene. Writes only supplied non-structural fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose or mirrors path-derived structure. 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. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
|
|
1645
2218
|
{
|
|
1646
2219
|
scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
|
|
1647
2220
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
@@ -1654,6 +2227,7 @@ export function registerMetadataTools(s, {
|
|
|
1654
2227
|
part: z.number().int().optional().describe("Rejected by update_scene_metadata. Structural placement must use explicit structure workflows."),
|
|
1655
2228
|
chapter: z.number().int().optional().describe("Rejected by update_scene_metadata. Use assign_scene_to_chapter or move_scene with canonical chapter_id."),
|
|
1656
2229
|
chapter_id: z.string().nullable().optional().describe("Rejected by update_scene_metadata. Use list_chapters, then assign_scene_to_chapter or move_scene."),
|
|
2230
|
+
chapter_title: z.string().nullable().optional().describe("Rejected by update_scene_metadata. Use rename_chapter for canonical chapter title changes."),
|
|
1657
2231
|
timeline_position: z.number().int().optional().describe("Rejected by update_scene_metadata. Use move_scene for ordering changes."),
|
|
1658
2232
|
story_time: z.string().optional(),
|
|
1659
2233
|
tags: z.array(z.string()).optional(),
|
|
@@ -1674,22 +2248,23 @@ export function registerMetadataTools(s, {
|
|
|
1674
2248
|
if (structuralFields.length > 0) {
|
|
1675
2249
|
return errorResponse(
|
|
1676
2250
|
"VALIDATION_ERROR",
|
|
1677
|
-
"update_scene_metadata cannot change structural fields. Use assign_scene_to_chapter
|
|
2251
|
+
"update_scene_metadata cannot change structural fields. Use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structural changes.",
|
|
1678
2252
|
{
|
|
1679
2253
|
project_id,
|
|
1680
2254
|
scene_id,
|
|
1681
2255
|
blocked_fields: structuralFields,
|
|
1682
|
-
allowed_structure_tools: ["assign_scene_to_chapter", "move_scene"],
|
|
2256
|
+
allowed_structure_tools: ["list_chapters", "assign_scene_to_chapter", "move_scene", "rename_chapter", "reorder_chapter"],
|
|
1683
2257
|
}
|
|
1684
2258
|
);
|
|
1685
2259
|
}
|
|
1686
2260
|
try {
|
|
1687
|
-
const {
|
|
1688
|
-
const updated = { ...
|
|
2261
|
+
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
2262
|
+
const updated = { ...sourceMeta, ...fields };
|
|
1689
2263
|
writeMeta(scene.file_path, updated, { syncDir: SYNC_DIR });
|
|
2264
|
+
const normalizedUpdated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, updated).meta;
|
|
1690
2265
|
|
|
1691
2266
|
const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
|
|
1692
|
-
indexSceneFile(db, SYNC_DIR, scene.file_path,
|
|
2267
|
+
indexSceneFile(db, SYNC_DIR, scene.file_path, normalizedUpdated, prose, {
|
|
1693
2268
|
managedStructure: isManagedStructureProject(db, project_id),
|
|
1694
2269
|
});
|
|
1695
2270
|
const backupResult = refreshProjectScopedBackupAfterMutation(db, {
|
|
@@ -1707,7 +2282,7 @@ export function registerMetadataTools(s, {
|
|
|
1707
2282
|
scene_id,
|
|
1708
2283
|
project_id,
|
|
1709
2284
|
fields: Object.keys(fields).sort().reduce((acc, key) => {
|
|
1710
|
-
acc[key] =
|
|
2285
|
+
acc[key] = sourceMeta[key] ?? null;
|
|
1711
2286
|
return acc;
|
|
1712
2287
|
}, {}),
|
|
1713
2288
|
},
|
|
@@ -1717,7 +2292,7 @@ export function registerMetadataTools(s, {
|
|
|
1717
2292
|
scene_id,
|
|
1718
2293
|
project_id,
|
|
1719
2294
|
fields: Object.keys(fields).sort().reduce((acc, key) => {
|
|
1720
|
-
acc[key] =
|
|
2295
|
+
acc[key] = normalizedUpdated[key] ?? null;
|
|
1721
2296
|
return acc;
|
|
1722
2297
|
}, {}),
|
|
1723
2298
|
},
|
|
@@ -1744,7 +2319,7 @@ export function registerMetadataTools(s, {
|
|
|
1744
2319
|
// ---- update_character_sheet ----------------------------------------------
|
|
1745
2320
|
s.tool(
|
|
1746
2321
|
"update_character_sheet",
|
|
1747
|
-
"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.",
|
|
1748
2323
|
{
|
|
1749
2324
|
character_id: z.string().describe("The character_id to update (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
|
|
1750
2325
|
fields: z.object({
|
|
@@ -1763,82 +2338,122 @@ export function registerMetadataTools(s, {
|
|
|
1763
2338
|
if (!char) {
|
|
1764
2339
|
return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
|
|
1765
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;
|
|
1766
2350
|
try {
|
|
1767
|
-
|
|
1768
|
-
const updated = { ...meta, ...fields };
|
|
1769
|
-
writeMeta(char.file_path, updated, { syncDir: SYNC_DIR });
|
|
1770
|
-
|
|
2351
|
+
db.exec("BEGIN");
|
|
1771
2352
|
db.prepare(`
|
|
1772
2353
|
UPDATE characters SET name = ?, role = ?, arc_summary = ?, first_appearance = ?
|
|
1773
2354
|
WHERE character_id = ?
|
|
1774
2355
|
`).run(
|
|
1775
|
-
|
|
1776
|
-
|
|
2356
|
+
nextCharacter.name, nextCharacter.role,
|
|
2357
|
+
nextCharacter.arc_summary, nextCharacter.first_appearance,
|
|
1777
2358
|
character_id
|
|
1778
2359
|
);
|
|
1779
2360
|
if (fields.traits) {
|
|
1780
2361
|
db.prepare(`DELETE FROM character_traits WHERE character_id = ?`).run(character_id);
|
|
1781
|
-
for (const t of
|
|
2362
|
+
for (const t of nextTraits) {
|
|
1782
2363
|
db.prepare(`INSERT OR IGNORE INTO character_traits (character_id, trait) VALUES (?, ?)`).run(character_id, t);
|
|
1783
2364
|
}
|
|
1784
2365
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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,
|
|
1805
2396
|
},
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
first_appearance: updated.first_appearance ?? null,
|
|
1815
|
-
},
|
|
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,
|
|
1816
2405
|
},
|
|
1817
|
-
}
|
|
2406
|
+
},
|
|
2407
|
+
});
|
|
1818
2408
|
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
character_id,
|
|
1824
|
-
...backupMutationFields(backupResult),
|
|
1825
|
-
});
|
|
1826
|
-
} catch (err) {
|
|
1827
|
-
if (err?.name === "CoreValidationError") {
|
|
1828
|
-
return errorResponse(err.code, err.message, err.details);
|
|
1829
|
-
}
|
|
1830
|
-
if (err.code === "ENOENT") {
|
|
1831
|
-
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" });
|
|
1832
2413
|
}
|
|
1833
|
-
|
|
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
|
+
});
|
|
1834
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
|
+
});
|
|
1835
2450
|
}
|
|
1836
2451
|
);
|
|
1837
2452
|
|
|
1838
2453
|
// ---- update_place_sheet --------------------------------------------------
|
|
1839
2454
|
s.tool(
|
|
1840
2455
|
"update_place_sheet",
|
|
1841
|
-
"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.",
|
|
1842
2457
|
{
|
|
1843
2458
|
place_id: z.string().describe("The place_id to update (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
|
|
1844
2459
|
fields: z.object({
|
|
@@ -1855,14 +2470,25 @@ export function registerMetadataTools(s, {
|
|
|
1855
2470
|
if (!place) {
|
|
1856
2471
|
return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
|
|
1857
2472
|
}
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
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();
|
|
1862
2476
|
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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, {
|
|
1866
2492
|
syncDir: SYNC_DIR,
|
|
1867
2493
|
projectId: place.project_id,
|
|
1868
2494
|
applicationVersion: MCP_SERVER_VERSION,
|
|
@@ -1885,34 +2511,68 @@ export function registerMetadataTools(s, {
|
|
|
1885
2511
|
place_id,
|
|
1886
2512
|
project_id: place.project_id,
|
|
1887
2513
|
universe_id: place.universe_id,
|
|
1888
|
-
name:
|
|
2514
|
+
name: nextName,
|
|
1889
2515
|
},
|
|
1890
2516
|
},
|
|
1891
2517
|
});
|
|
2518
|
+
}
|
|
1892
2519
|
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
place_id,
|
|
1898
|
-
...backupMutationFields(backupResult),
|
|
1899
|
-
});
|
|
1900
|
-
} catch (err) {
|
|
1901
|
-
if (err?.name === "CoreValidationError") {
|
|
1902
|
-
return errorResponse(err.code, err.message, err.details);
|
|
1903
|
-
}
|
|
1904
|
-
if (err.code === "ENOENT") {
|
|
1905
|
-
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" });
|
|
1906
2524
|
}
|
|
1907
|
-
|
|
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
|
+
});
|
|
1908
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
|
+
});
|
|
1909
2569
|
}
|
|
1910
2570
|
);
|
|
1911
2571
|
|
|
1912
2572
|
// ---- flag_scene ----------------------------------------------------------
|
|
1913
2573
|
s.tool(
|
|
1914
2574
|
"flag_scene",
|
|
1915
|
-
"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.",
|
|
1916
2576
|
{
|
|
1917
2577
|
scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
|
|
1918
2578
|
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
@@ -1928,11 +2588,25 @@ export function registerMetadataTools(s, {
|
|
|
1928
2588
|
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
1929
2589
|
}
|
|
1930
2590
|
try {
|
|
1931
|
-
const {
|
|
1932
|
-
const flags =
|
|
2591
|
+
const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
2592
|
+
const flags = sourceMeta.flags ?? [];
|
|
1933
2593
|
flags.push({ note, flagged_at: new Date().toISOString() });
|
|
1934
|
-
writeMeta(scene.file_path, { ...
|
|
1935
|
-
return {
|
|
2594
|
+
writeMeta(scene.file_path, { ...sourceMeta, flags }, { syncDir: SYNC_DIR });
|
|
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
|
+
});
|
|
1936
2610
|
} catch (err) {
|
|
1937
2611
|
if (err?.name === "CoreValidationError") {
|
|
1938
2612
|
return errorResponse(err.code, err.message, err.details);
|