@hanna84/mcp-writing 3.9.3 → 3.9.5
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/structure/structure-inference.js +32 -9
- package/src/sync/sync.js +263 -96
- package/src/tools/metadata.js +18 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.9.5](https://github.com/hannasdev/mcp-writing/compare/v3.9.4...v3.9.5)
|
|
8
|
+
|
|
9
|
+
- refactor(sync): complete target architecture M3 [`#201`](https://github.com/hannasdev/mcp-writing/pull/201)
|
|
10
|
+
|
|
11
|
+
#### [v3.9.4](https://github.com/hannasdev/mcp-writing/compare/v3.9.3...v3.9.4)
|
|
12
|
+
|
|
13
|
+
> 17 May 2026
|
|
14
|
+
|
|
15
|
+
- refactor(structure): centralize scene structure patches [`#200`](https://github.com/hannasdev/mcp-writing/pull/200)
|
|
16
|
+
- Release 3.9.4 [`ac77e80`](https://github.com/hannasdev/mcp-writing/commit/ac77e801d94ca576923a46a6d22c8f3230988d76)
|
|
17
|
+
|
|
7
18
|
#### [v3.9.3](https://github.com/hannasdev/mcp-writing/compare/v3.9.2...v3.9.3)
|
|
8
19
|
|
|
20
|
+
> 17 May 2026
|
|
21
|
+
|
|
9
22
|
- refactor(structure): extract target architecture M1 boundaries [`#199`](https://github.com/hannasdev/mcp-writing/pull/199)
|
|
23
|
+
- Release 3.9.3 [`55c87ea`](https://github.com/hannasdev/mcp-writing/commit/55c87ea405841153765144d32c57803b6139a103)
|
|
10
24
|
|
|
11
25
|
#### [v3.9.2](https://github.com/hannasdev/mcp-writing/compare/v3.9.1...v3.9.2)
|
|
12
26
|
|
package/package.json
CHANGED
|
@@ -109,24 +109,35 @@ export function inferChapterStructureFromPath(syncDir, filePath, meta = {}) {
|
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
export function
|
|
112
|
+
export function buildSceneStructurePatch(syncDir, filePath, meta = {}, { chapter } = {}) {
|
|
113
113
|
const derived = inferScenePositionFromPath(syncDir, filePath);
|
|
114
114
|
const chapterStructure = inferChapterStructureFromPath(syncDir, filePath, meta);
|
|
115
|
-
const
|
|
115
|
+
const patch = {};
|
|
116
116
|
|
|
117
|
-
if (derived.part !== null)
|
|
118
|
-
if (derived.chapter !== null)
|
|
117
|
+
if (derived.part !== null) patch.part = derived.part;
|
|
118
|
+
if (derived.chapter !== null) patch.chapter = derived.chapter;
|
|
119
119
|
if (chapterStructure.chapter?.chapter_id) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
patch.chapter_id = chapterStructure.chapter.chapter_id;
|
|
121
|
+
patch.chapter = chapterStructure.chapter.sort_index;
|
|
122
|
+
patch.chapter_title = chapterStructure.chapter.title;
|
|
123
123
|
}
|
|
124
124
|
if (chapterStructure.role) {
|
|
125
|
-
|
|
125
|
+
patch.scene_role = chapterStructure.role;
|
|
126
|
+
}
|
|
127
|
+
if (chapter !== undefined) {
|
|
128
|
+
if (chapter === null) {
|
|
129
|
+
patch.chapter_id = null;
|
|
130
|
+
patch.chapter = null;
|
|
131
|
+
patch.chapter_title = null;
|
|
132
|
+
} else {
|
|
133
|
+
patch.chapter_id = chapter.chapter_id;
|
|
134
|
+
patch.chapter = chapter.sort_index;
|
|
135
|
+
patch.chapter_title = chapter.title ?? null;
|
|
136
|
+
}
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
return {
|
|
129
|
-
|
|
140
|
+
patch,
|
|
130
141
|
derived,
|
|
131
142
|
chapterStructure,
|
|
132
143
|
mismatches: {
|
|
@@ -135,3 +146,15 @@ export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
|
|
|
135
146
|
},
|
|
136
147
|
};
|
|
137
148
|
}
|
|
149
|
+
|
|
150
|
+
export function applySceneStructurePatch(syncDir, filePath, meta = {}, options = {}) {
|
|
151
|
+
const plan = buildSceneStructurePatch(syncDir, filePath, meta, options);
|
|
152
|
+
return {
|
|
153
|
+
...plan,
|
|
154
|
+
meta: { ...meta, ...plan.patch },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
|
|
159
|
+
return applySceneStructurePatch(syncDir, filePath, meta);
|
|
160
|
+
}
|
package/src/sync/sync.js
CHANGED
|
@@ -3,6 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import matter from "gray-matter";
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
5
|
import {
|
|
6
|
+
applySceneStructurePatch,
|
|
7
|
+
buildSceneStructurePatch,
|
|
6
8
|
inferChapterStructureFromPath,
|
|
7
9
|
inferScenePositionFromPath,
|
|
8
10
|
normalizeSceneMetaForPath,
|
|
@@ -16,6 +18,8 @@ import { indexCanonicalEpigraph } from "../structure/epigraph-indexing.js";
|
|
|
16
18
|
const { load: parseYaml, dump: stringifyYaml } = yaml;
|
|
17
19
|
|
|
18
20
|
export {
|
|
21
|
+
applySceneStructurePatch,
|
|
22
|
+
buildSceneStructurePatch,
|
|
19
23
|
inferChapterStructureFromPath,
|
|
20
24
|
inferScenePositionFromPath,
|
|
21
25
|
normalizeSceneMetaForPath,
|
|
@@ -70,11 +74,34 @@ export function walkSidecars(dir, fileList = []) {
|
|
|
70
74
|
return fileList;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
export function buildSyncDiagnostic(message, { type = null, ...details } = {}) {
|
|
78
|
+
return { type, message, ...details };
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
function isNestedMirrorPath(syncDir, filePath) {
|
|
74
82
|
const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
|
|
75
83
|
return rel.includes("/scenes/projects/") || rel.includes("/scenes/universes/");
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
export function scanSyncFiles(syncDir) {
|
|
87
|
+
const files = [];
|
|
88
|
+
const diagnostics = [];
|
|
89
|
+
|
|
90
|
+
for (const file of walkFiles(syncDir)) {
|
|
91
|
+
if (isNestedMirrorPath(syncDir, file)) {
|
|
92
|
+
const relativePath = path.relative(syncDir, file);
|
|
93
|
+
diagnostics.push(buildSyncDiagnostic(`Ignored nested mirror path: ${relativePath}`, {
|
|
94
|
+
type: "nested_mirror",
|
|
95
|
+
relativePath,
|
|
96
|
+
}));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
files.push(file);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { files, diagnostics };
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
export function sidecarPath(filePath) {
|
|
79
106
|
return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
|
|
80
107
|
}
|
|
@@ -809,8 +836,14 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
|
|
|
809
836
|
return docId;
|
|
810
837
|
}
|
|
811
838
|
|
|
812
|
-
function pruneMissingReferenceDocs(db, seenDocIds) {
|
|
813
|
-
const
|
|
839
|
+
function pruneMissingReferenceDocs(db, seenDocIds, syncDir) {
|
|
840
|
+
const scope = inferReferenceScopeFromSyncDir(syncDir);
|
|
841
|
+
const rows = scope?.project_id
|
|
842
|
+
? db.prepare(`SELECT doc_id, project_id FROM reference_docs WHERE project_id = ?`).all(scope.project_id)
|
|
843
|
+
: scope?.universe_id
|
|
844
|
+
? db.prepare(`SELECT doc_id, project_id FROM reference_docs WHERE universe_id = ?`).all(scope.universe_id)
|
|
845
|
+
: db.prepare(`SELECT doc_id, project_id FROM reference_docs`).all();
|
|
846
|
+
|
|
814
847
|
for (const row of rows) {
|
|
815
848
|
if (seenDocIds.has(row.doc_id)) continue;
|
|
816
849
|
db.prepare(`
|
|
@@ -942,9 +975,157 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
|
|
|
942
975
|
}
|
|
943
976
|
}
|
|
944
977
|
|
|
945
|
-
export function
|
|
978
|
+
export function pruneSyncDerivedIndexes(db, syncDir, {
|
|
979
|
+
seenSceneKeys,
|
|
980
|
+
seenEpigraphKeys,
|
|
981
|
+
seenChapterKeys,
|
|
982
|
+
sceneIndexFailures,
|
|
983
|
+
}) {
|
|
984
|
+
if (!canPruneScenes(syncDir)) return { pruned: false, reason: "scope_not_prunable" };
|
|
985
|
+
if (sceneIndexFailures !== 0) return { pruned: false, reason: "scene_index_failures" };
|
|
986
|
+
|
|
987
|
+
pruneMissingScenes(db, seenSceneKeys, syncDir);
|
|
988
|
+
pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
|
|
989
|
+
pruneMissingChapters(db, seenChapterKeys, syncDir);
|
|
990
|
+
return { pruned: true, reason: null };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export function regenerateReferenceAndWorldIndexes(db, syncDir, files, {
|
|
994
|
+
writable = false,
|
|
995
|
+
pruneReferenceDocs = false,
|
|
996
|
+
} = {}) {
|
|
997
|
+
const indexedReferenceDocIds = new Set();
|
|
998
|
+
|
|
999
|
+
for (const file of files) {
|
|
1000
|
+
if (isReferenceFile(syncDir, file)) {
|
|
1001
|
+
try {
|
|
1002
|
+
const { data, content } = parseFile(file);
|
|
1003
|
+
const docId = indexReferenceFile(db, syncDir, file, data, content);
|
|
1004
|
+
indexedReferenceDocIds.add(docId);
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1007
|
+
}
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (!isWorldFile(syncDir, file)) continue;
|
|
1012
|
+
try {
|
|
1013
|
+
const { meta } = readMeta(file, syncDir, { writable });
|
|
1014
|
+
if (!Object.keys(meta).length) {
|
|
1015
|
+
const { data } = parseFile(file);
|
|
1016
|
+
indexWorldFile(db, syncDir, file, data);
|
|
1017
|
+
} else {
|
|
1018
|
+
indexWorldFile(db, syncDir, file, meta);
|
|
1019
|
+
}
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (pruneReferenceDocs && canPruneReferenceDocs(syncDir)) {
|
|
1026
|
+
pruneMissingReferenceDocs(db, indexedReferenceDocIds, syncDir);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
indexedReferenceDocIds,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export function buildStructureDiagnostic(message, { type = "chapter_structure", ...details } = {}) {
|
|
1035
|
+
return { type, message, ...details };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function observeStructureForFile(syncDir, file, {
|
|
1039
|
+
meta = {},
|
|
1040
|
+
sourceMeta = {},
|
|
1041
|
+
derived = {},
|
|
1042
|
+
mismatches = {},
|
|
1043
|
+
} = {}) {
|
|
946
1044
|
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
1045
|
+
const relativePath = path.relative(syncDir, file);
|
|
947
1046
|
const chapterStructure = inferChapterStructureFromPath(syncDir, file, meta);
|
|
1047
|
+
const diagnostics = [];
|
|
1048
|
+
|
|
1049
|
+
if (mismatches.part || mismatches.chapter) {
|
|
1050
|
+
const details = [];
|
|
1051
|
+
if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
|
|
1052
|
+
if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
|
|
1053
|
+
diagnostics.push(buildStructureDiagnostic(
|
|
1054
|
+
`Path/metadata mismatch for scene "${meta.scene_id}": ${relativePath} (${details.join(", ")}). Using path-derived values.`,
|
|
1055
|
+
{
|
|
1056
|
+
type: "path_metadata_mismatch",
|
|
1057
|
+
sceneId: meta.scene_id ?? null,
|
|
1058
|
+
relativePath,
|
|
1059
|
+
}
|
|
1060
|
+
));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return {
|
|
1064
|
+
universeId: universe_id,
|
|
1065
|
+
projectId: project_id,
|
|
1066
|
+
relativePath,
|
|
1067
|
+
chapterStructure,
|
|
1068
|
+
observedChapter: chapterStructure.chapter
|
|
1069
|
+
? {
|
|
1070
|
+
chapterId: chapterStructure.chapter.chapter_id,
|
|
1071
|
+
sortIndex: chapterStructure.chapter.sort_index,
|
|
1072
|
+
title: chapterStructure.chapter.title,
|
|
1073
|
+
folderKey: chapterStructure.chapter.folder_key,
|
|
1074
|
+
sourceKind: chapterStructure.chapter.source_kind,
|
|
1075
|
+
}
|
|
1076
|
+
: null,
|
|
1077
|
+
observedEpigraph: chapterStructure.isEpigraph
|
|
1078
|
+
? {
|
|
1079
|
+
epigraphId: meta.epigraph_id ?? null,
|
|
1080
|
+
chapterId: meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null,
|
|
1081
|
+
relativePath,
|
|
1082
|
+
}
|
|
1083
|
+
: null,
|
|
1084
|
+
diagnostics,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export function buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure = observeStructureForFile(syncDir, file, { meta })) {
|
|
1089
|
+
const chapterResolution = resolveIndexedChapterForFile(db, {
|
|
1090
|
+
syncDir,
|
|
1091
|
+
projectId: observedStructure.projectId,
|
|
1092
|
+
filePath: file,
|
|
1093
|
+
relativePath: observedStructure.relativePath,
|
|
1094
|
+
meta,
|
|
1095
|
+
chapterStructure: observedStructure.chapterStructure,
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
return {
|
|
1099
|
+
universeId: observedStructure.universeId,
|
|
1100
|
+
projectId: observedStructure.projectId,
|
|
1101
|
+
observedStructure,
|
|
1102
|
+
chapterResolution,
|
|
1103
|
+
canonicalChapter: chapterResolution.upsertChapter,
|
|
1104
|
+
diagnostics: chapterResolution.chapterWarning
|
|
1105
|
+
? [buildStructureDiagnostic(chapterResolution.chapterWarning)]
|
|
1106
|
+
: [],
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export function readSceneMetadataForSync(syncDir, file, { writable = false } = {}) {
|
|
1111
|
+
return readMeta(file, syncDir, { writable });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function readSceneFileForSync(syncDir, file, { writable = false } = {}) {
|
|
1115
|
+
const metadataRead = readSceneMetadataForSync(syncDir, file, { writable });
|
|
1116
|
+
const { data: frontmatter, content: prose } = parseFile(file);
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
...metadataRead,
|
|
1120
|
+
frontmatter,
|
|
1121
|
+
prose,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export function indexSceneFile(db, syncDir, file, meta, prose, { observedStructure } = {}) {
|
|
1126
|
+
const canonicalIndexPlan = buildCanonicalIndexPlan(db, syncDir, file, meta, observedStructure);
|
|
1127
|
+
const { universeId: universe_id, projectId: project_id } = canonicalIndexPlan;
|
|
1128
|
+
const { chapterStructure } = canonicalIndexPlan.observedStructure;
|
|
948
1129
|
const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
|
|
949
1130
|
const explicitSceneLinks = collectExplicitReferenceLinks(
|
|
950
1131
|
meta,
|
|
@@ -961,22 +1142,13 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
961
1142
|
project_id, universe_id ?? null, project_id
|
|
962
1143
|
);
|
|
963
1144
|
|
|
964
|
-
const relativePath = path.relative(syncDir, file);
|
|
965
|
-
const chapterResolution = resolveIndexedChapterForFile(db, {
|
|
966
|
-
syncDir,
|
|
967
|
-
projectId: project_id,
|
|
968
|
-
filePath: file,
|
|
969
|
-
relativePath,
|
|
970
|
-
meta,
|
|
971
|
-
chapterStructure,
|
|
972
|
-
});
|
|
973
1145
|
const {
|
|
974
1146
|
chapterId,
|
|
975
1147
|
chapterSortIndex,
|
|
976
1148
|
chapterTitle,
|
|
977
1149
|
chapterWarning,
|
|
978
1150
|
upsertChapter,
|
|
979
|
-
} = chapterResolution;
|
|
1151
|
+
} = canonicalIndexPlan.chapterResolution;
|
|
980
1152
|
|
|
981
1153
|
if (upsertChapter) {
|
|
982
1154
|
upsertCanonicalChapterRecord(db, {
|
|
@@ -987,7 +1159,7 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
987
1159
|
}
|
|
988
1160
|
|
|
989
1161
|
if (chapterStructure.isEpigraph) {
|
|
990
|
-
|
|
1162
|
+
const result = indexCanonicalEpigraph(db, {
|
|
991
1163
|
projectId: project_id,
|
|
992
1164
|
chapterId,
|
|
993
1165
|
chapterSortIndex,
|
|
@@ -995,11 +1167,12 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
995
1167
|
meta,
|
|
996
1168
|
prose,
|
|
997
1169
|
file,
|
|
998
|
-
relativePath,
|
|
1170
|
+
relativePath: canonicalIndexPlan.observedStructure.relativePath,
|
|
999
1171
|
chapterWarning,
|
|
1000
1172
|
buildProseChecksum: checksumProse,
|
|
1001
1173
|
buildDefaultEpigraphId: ({ projectId, chapterId }) => `epi-${slugifyChapterValue(`${projectId}-${chapterId}`)}`,
|
|
1002
1174
|
});
|
|
1175
|
+
return { ...result, canonicalIndexPlan };
|
|
1003
1176
|
}
|
|
1004
1177
|
|
|
1005
1178
|
const newChecksum = checksumProse(prose);
|
|
@@ -1137,7 +1310,48 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
1137
1310
|
relation: "informs",
|
|
1138
1311
|
});
|
|
1139
1312
|
|
|
1140
|
-
return { isStale, chapterId, warning: chapterWarning };
|
|
1313
|
+
return { isStale, chapterId, warning: chapterWarning, canonicalIndexPlan };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export function observeOrphanedSidecars(syncDir, { indexedSceneIds = new Set() } = {}) {
|
|
1317
|
+
const diagnostics = [];
|
|
1318
|
+
const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
|
|
1319
|
+
|
|
1320
|
+
for (const sidecar of sidecars) {
|
|
1321
|
+
const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
|
|
1322
|
+
const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
|
|
1323
|
+
if (fs.existsSync(prose) || fs.existsSync(proseTxt)) continue;
|
|
1324
|
+
|
|
1325
|
+
let orphanedSceneId = null;
|
|
1326
|
+
try {
|
|
1327
|
+
const raw = fs.readFileSync(sidecar, "utf8");
|
|
1328
|
+
orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
|
|
1329
|
+
} catch { /* empty */ }
|
|
1330
|
+
|
|
1331
|
+
const relativePath = path.relative(syncDir, sidecar);
|
|
1332
|
+
if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
|
|
1333
|
+
diagnostics.push(buildSyncDiagnostic(
|
|
1334
|
+
`Moved scene detected: sidecar for "${orphanedSceneId}" is at stale path ${relativePath} — prose file has moved. Consider relocating the sidecar alongside the prose file.`,
|
|
1335
|
+
{
|
|
1336
|
+
type: "moved_scene",
|
|
1337
|
+
sceneId: orphanedSceneId,
|
|
1338
|
+
relativePath,
|
|
1339
|
+
}
|
|
1340
|
+
));
|
|
1341
|
+
} else {
|
|
1342
|
+
const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
|
|
1343
|
+
diagnostics.push(buildSyncDiagnostic(
|
|
1344
|
+
`Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${relativePath}`,
|
|
1345
|
+
{
|
|
1346
|
+
type: "orphaned_sidecar",
|
|
1347
|
+
sceneId: orphanedSceneId,
|
|
1348
|
+
relativePath,
|
|
1349
|
+
}
|
|
1350
|
+
));
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return diagnostics;
|
|
1141
1355
|
}
|
|
1142
1356
|
|
|
1143
1357
|
const WARNING_TYPE_LABELS = {
|
|
@@ -1162,7 +1376,7 @@ const WARNING_PATTERNS = [
|
|
|
1162
1376
|
|
|
1163
1377
|
const MAX_WARNING_EXAMPLES = 5;
|
|
1164
1378
|
|
|
1165
|
-
function buildWarningSummary(warnings) {
|
|
1379
|
+
export function buildWarningSummary(warnings) {
|
|
1166
1380
|
const summary = {};
|
|
1167
1381
|
for (const w of warnings) {
|
|
1168
1382
|
const firstLine = w.split("\n")[0];
|
|
@@ -1182,7 +1396,6 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1182
1396
|
// (for example after imports or path repairs) are reflected immediately.
|
|
1183
1397
|
UNIVERSE_PROJECT_ROOT_CACHE.clear();
|
|
1184
1398
|
|
|
1185
|
-
const files = walkFiles(syncDir);
|
|
1186
1399
|
let indexed = 0;
|
|
1187
1400
|
let staleMarked = 0;
|
|
1188
1401
|
let epigraphsIndexed = 0;
|
|
@@ -1194,69 +1407,43 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1194
1407
|
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
1195
1408
|
const seenChapterKeys = new Set();
|
|
1196
1409
|
const seenEpigraphKeys = new Set();
|
|
1197
|
-
const indexedReferenceDocIds = new Set();
|
|
1198
1410
|
let sceneIndexFailures = 0;
|
|
1199
1411
|
const warnings = [];
|
|
1200
1412
|
const chapterFoldersByProject = new Map();
|
|
1201
1413
|
const roleFoldersByProject = new Map();
|
|
1202
1414
|
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
continue;
|
|
1208
|
-
}
|
|
1209
|
-
scanFiles.push(file);
|
|
1415
|
+
const syncScan = scanSyncFiles(syncDir);
|
|
1416
|
+
const scanFiles = syncScan.files;
|
|
1417
|
+
for (const diagnostic of syncScan.diagnostics) {
|
|
1418
|
+
warnings.push(diagnostic.message);
|
|
1210
1419
|
}
|
|
1211
1420
|
|
|
1212
1421
|
// --- Pass 1: world files and reference docs (characters/places must be indexed
|
|
1213
1422
|
// before scenes so that character name -> ID resolution in scene_characters works) ---
|
|
1214
|
-
|
|
1215
|
-
if (isReferenceFile(syncDir, file)) {
|
|
1216
|
-
try {
|
|
1217
|
-
const { data, content } = parseFile(file);
|
|
1218
|
-
const docId = indexReferenceFile(db, syncDir, file, data, content);
|
|
1219
|
-
indexedReferenceDocIds.add(docId);
|
|
1220
|
-
} catch (err) {
|
|
1221
|
-
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1222
|
-
}
|
|
1223
|
-
continue;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
if (!isWorldFile(syncDir, file)) continue;
|
|
1227
|
-
try {
|
|
1228
|
-
const { meta } = readMeta(file, syncDir, { writable });
|
|
1229
|
-
if (!Object.keys(meta).length) {
|
|
1230
|
-
const { data } = parseFile(file);
|
|
1231
|
-
indexWorldFile(db, syncDir, file, data);
|
|
1232
|
-
} else {
|
|
1233
|
-
indexWorldFile(db, syncDir, file, meta);
|
|
1234
|
-
}
|
|
1235
|
-
} catch (err) {
|
|
1236
|
-
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
if (canPruneReferenceDocs(syncDir)) {
|
|
1241
|
-
pruneMissingReferenceDocs(db, indexedReferenceDocIds);
|
|
1242
|
-
}
|
|
1423
|
+
regenerateReferenceAndWorldIndexes(db, syncDir, scanFiles, { writable, pruneReferenceDocs: true });
|
|
1243
1424
|
|
|
1244
1425
|
// --- Pass 2: scene files ---
|
|
1245
1426
|
for (const file of scanFiles) {
|
|
1246
1427
|
if (isWorldFile(syncDir, file) || isReferenceFile(syncDir, file)) continue;
|
|
1247
1428
|
try {
|
|
1248
|
-
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } =
|
|
1429
|
+
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readSceneMetadataForSync(syncDir, file, { writable });
|
|
1249
1430
|
if (sidecarGenerated) sidecarsMigrated++;
|
|
1250
|
-
const
|
|
1431
|
+
const structureObservation = observeStructureForFile(syncDir, file, {
|
|
1432
|
+
meta,
|
|
1433
|
+
sourceMeta,
|
|
1434
|
+
derived,
|
|
1435
|
+
mismatches,
|
|
1436
|
+
});
|
|
1437
|
+
const { chapterStructure } = structureObservation;
|
|
1251
1438
|
|
|
1252
1439
|
if (!meta.scene_id && !chapterStructure.isEpigraph) {
|
|
1253
1440
|
skipped++;
|
|
1254
|
-
if (!quiet) warnings.push(`Skipped (no scene_id): ${
|
|
1441
|
+
if (!quiet) warnings.push(`Skipped (no scene_id): ${structureObservation.relativePath}`);
|
|
1255
1442
|
continue;
|
|
1256
1443
|
}
|
|
1257
1444
|
|
|
1258
1445
|
// Duplicate scene_id detection
|
|
1259
|
-
const
|
|
1446
|
+
const project_id = structureObservation.projectId;
|
|
1260
1447
|
const key = `${meta.scene_id}::${project_id}`;
|
|
1261
1448
|
if (meta.scene_id && seenSceneIds.has(key)) {
|
|
1262
1449
|
warnings.push(
|
|
@@ -1280,18 +1467,17 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1280
1467
|
}
|
|
1281
1468
|
}
|
|
1282
1469
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
if (mismatches.part) details.push(`part metadata ${sourceMeta.part} != path part ${derived.part}`);
|
|
1286
|
-
if (mismatches.chapter) details.push(`chapter metadata ${sourceMeta.chapter} != path chapter ${derived.chapter}`);
|
|
1287
|
-
warnings.push(
|
|
1288
|
-
`Path/metadata mismatch for scene "${meta.scene_id}": ${path.relative(syncDir, file)} (${details.join(", ")}). Using path-derived values.`
|
|
1289
|
-
);
|
|
1470
|
+
for (const diagnostic of structureObservation.diagnostics) {
|
|
1471
|
+
warnings.push(diagnostic.message);
|
|
1290
1472
|
}
|
|
1291
1473
|
|
|
1292
|
-
const {
|
|
1293
|
-
|
|
1294
|
-
|
|
1474
|
+
const { content: prose } = parseFile(file);
|
|
1475
|
+
|
|
1476
|
+
const result = indexSceneFile(db, syncDir, file, meta, prose, { observedStructure: structureObservation });
|
|
1477
|
+
const canonicalDiagnostics = result.canonicalIndexPlan?.diagnostics ?? [];
|
|
1478
|
+
if (canonicalDiagnostics.length) {
|
|
1479
|
+
for (const diagnostic of canonicalDiagnostics) warnings.push(diagnostic.message);
|
|
1480
|
+
} else if (result.warning) {
|
|
1295
1481
|
warnings.push(result.warning);
|
|
1296
1482
|
}
|
|
1297
1483
|
if (result.chapterId) {
|
|
@@ -1333,35 +1519,16 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1333
1519
|
}
|
|
1334
1520
|
}
|
|
1335
1521
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1522
|
+
pruneSyncDerivedIndexes(db, syncDir, {
|
|
1523
|
+
seenSceneKeys,
|
|
1524
|
+
seenEpigraphKeys,
|
|
1525
|
+
seenChapterKeys,
|
|
1526
|
+
sceneIndexFailures,
|
|
1527
|
+
});
|
|
1341
1528
|
|
|
1342
1529
|
// --- Orphaned sidecar detection ---
|
|
1343
|
-
const
|
|
1344
|
-
|
|
1345
|
-
const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
|
|
1346
|
-
const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");
|
|
1347
|
-
if (!fs.existsSync(prose) && !fs.existsSync(proseTxt)) {
|
|
1348
|
-
let orphanedSceneId = null;
|
|
1349
|
-
try {
|
|
1350
|
-
const raw = fs.readFileSync(sidecar, "utf8");
|
|
1351
|
-
orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
|
|
1352
|
-
} catch { /* empty */ }
|
|
1353
|
-
|
|
1354
|
-
if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
|
|
1355
|
-
warnings.push(
|
|
1356
|
-
`Moved scene detected: sidecar for "${orphanedSceneId}" is at stale path ${path.relative(syncDir, sidecar)} — prose file has moved. Consider relocating the sidecar alongside the prose file.`
|
|
1357
|
-
);
|
|
1358
|
-
} else {
|
|
1359
|
-
const label = orphanedSceneId ? `scene "${orphanedSceneId}"` : "unknown scene";
|
|
1360
|
-
warnings.push(
|
|
1361
|
-
`Orphaned sidecar (${label}, no matching .md/.txt and not indexed): ${path.relative(syncDir, sidecar)}`
|
|
1362
|
-
);
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1530
|
+
for (const diagnostic of observeOrphanedSidecars(syncDir, { indexedSceneIds })) {
|
|
1531
|
+
warnings.push(diagnostic.message);
|
|
1365
1532
|
}
|
|
1366
1533
|
|
|
1367
1534
|
const warningSummary = buildWarningSummary(warnings);
|
package/src/tools/metadata.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import matter from "gray-matter";
|
|
4
|
-
import { readMeta, writeMeta, indexSceneFile,
|
|
4
|
+
import { readMeta, writeMeta, indexSceneFile, applySceneStructurePatch } from "../sync/sync.js";
|
|
5
5
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
6
6
|
import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
|
|
7
7
|
import {
|
|
@@ -545,6 +545,7 @@ export function registerMetadataTools(s, {
|
|
|
545
545
|
try {
|
|
546
546
|
const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
547
547
|
const nextFields = { ...fields };
|
|
548
|
+
let chapter = undefined;
|
|
548
549
|
|
|
549
550
|
if (fields.chapter_id === null && fields.chapter !== undefined) {
|
|
550
551
|
return errorResponse(
|
|
@@ -559,8 +560,20 @@ export function registerMetadataTools(s, {
|
|
|
559
560
|
}
|
|
560
561
|
|
|
561
562
|
if (fields.chapter_id === null) {
|
|
562
|
-
|
|
563
|
-
|
|
563
|
+
const structurePlan = applySceneStructurePatch(SYNC_DIR, scene.file_path, meta);
|
|
564
|
+
if (structurePlan.derived.chapter !== null || structurePlan.chapterStructure.chapter?.chapter_id) {
|
|
565
|
+
return errorResponse(
|
|
566
|
+
"VALIDATION_ERROR",
|
|
567
|
+
"chapter_id cannot be cleared for a scene whose file path implies a chapter.",
|
|
568
|
+
{
|
|
569
|
+
project_id,
|
|
570
|
+
scene_id,
|
|
571
|
+
chapter_id: null,
|
|
572
|
+
path_chapter: structurePlan.chapterStructure.chapter?.chapter_id ?? structurePlan.derived.chapter,
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
chapter = null;
|
|
564
577
|
} else if (fields.chapter_id !== undefined || fields.chapter !== undefined) {
|
|
565
578
|
const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
|
|
566
579
|
projectId: project_id,
|
|
@@ -594,12 +607,10 @@ export function registerMetadataTools(s, {
|
|
|
594
607
|
);
|
|
595
608
|
}
|
|
596
609
|
|
|
597
|
-
|
|
598
|
-
nextFields.chapter = resolvedChapter.sort_index;
|
|
599
|
-
nextFields.chapter_title = resolvedChapter.title ?? null;
|
|
610
|
+
chapter = resolvedChapter;
|
|
600
611
|
}
|
|
601
612
|
|
|
602
|
-
const updated =
|
|
613
|
+
const updated = applySceneStructurePatch(SYNC_DIR, scene.file_path, { ...meta, ...nextFields }, { chapter }).meta;
|
|
603
614
|
writeMeta(scene.file_path, updated);
|
|
604
615
|
|
|
605
616
|
const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
|