@hanna84/mcp-writing 3.18.1 → 3.19.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 +7 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/filesystem-boundary.js +12 -0
- package/src/index.js +1 -0
- package/src/structure/project-backup-diagnostics.js +346 -0
- package/src/structure/project-backup-operations.js +116 -0
- package/src/structure/project-backup-refresh.js +160 -0
- package/src/structure/project-backup.js +476 -0
- package/src/tools/editing.js +55 -0
- package/src/tools/metadata.js +469 -8
- package/src/tools/search.js +34 -0
- package/src/tools/sync.js +200 -0
- package/src/workflows/workflow-catalogue.js +2 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
assertRegularFileWriteTarget,
|
|
6
|
+
ensureDirectoryInsideBoundary,
|
|
7
|
+
resolveGeneratedOutputPath,
|
|
8
|
+
writeGeneratedOutputFile,
|
|
9
|
+
} from "../core/filesystem-boundary.js";
|
|
10
|
+
import { CURRENT_SCHEMA_VERSION } from "../core/db.js";
|
|
11
|
+
import {
|
|
12
|
+
ensureProjectBackupOperationLog,
|
|
13
|
+
PROJECT_BACKUP_OPERATION_LOG_FILE,
|
|
14
|
+
} from "./project-backup-operations.js";
|
|
15
|
+
|
|
16
|
+
export const PROJECT_BACKUP_SCHEMA_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
const INCLUDED_TABLES = [
|
|
19
|
+
"projects",
|
|
20
|
+
"universes",
|
|
21
|
+
"scenes",
|
|
22
|
+
"chapters",
|
|
23
|
+
"epigraphs",
|
|
24
|
+
"epigraph_characters",
|
|
25
|
+
"epigraph_tags",
|
|
26
|
+
"scene_characters",
|
|
27
|
+
"scene_places",
|
|
28
|
+
"scene_tags",
|
|
29
|
+
"scene_threads",
|
|
30
|
+
"characters",
|
|
31
|
+
"character_traits",
|
|
32
|
+
"character_relationships",
|
|
33
|
+
"places",
|
|
34
|
+
"threads",
|
|
35
|
+
"reference_docs",
|
|
36
|
+
"reference_doc_tags",
|
|
37
|
+
"reference_links",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const EXCLUDED_TABLES = [
|
|
41
|
+
"scenes_fts",
|
|
42
|
+
"reference_docs_fts",
|
|
43
|
+
"async_jobs",
|
|
44
|
+
"schema_version",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function stableStringify(value, indent = 2) {
|
|
48
|
+
const seen = new WeakSet();
|
|
49
|
+
function normalize(input) {
|
|
50
|
+
if (input === null || typeof input !== "object") return input;
|
|
51
|
+
if (seen.has(input)) {
|
|
52
|
+
throw new TypeError("Cannot stable-stringify circular structure.");
|
|
53
|
+
}
|
|
54
|
+
seen.add(input);
|
|
55
|
+
if (Array.isArray(input)) {
|
|
56
|
+
const array = input.map(normalize);
|
|
57
|
+
seen.delete(input);
|
|
58
|
+
return array;
|
|
59
|
+
}
|
|
60
|
+
const object = {};
|
|
61
|
+
for (const key of Object.keys(input).sort()) {
|
|
62
|
+
object[key] = normalize(input[key]);
|
|
63
|
+
}
|
|
64
|
+
seen.delete(input);
|
|
65
|
+
return object;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return JSON.stringify(normalize(value), null, indent);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sha256(value) {
|
|
72
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizePathForBackup(syncDir, filePath) {
|
|
76
|
+
if (!filePath) return null;
|
|
77
|
+
if (!syncDir) return filePath;
|
|
78
|
+
const syncRoot = path.resolve(syncDir);
|
|
79
|
+
const resolvedPath = path.isAbsolute(filePath)
|
|
80
|
+
? path.resolve(filePath)
|
|
81
|
+
: path.resolve(syncRoot, filePath);
|
|
82
|
+
const normalized = path.relative(syncRoot, resolvedPath);
|
|
83
|
+
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
|
84
|
+
throw new Error(`Cannot back up path outside sync_dir: ${filePath}`);
|
|
85
|
+
}
|
|
86
|
+
return normalized.split(path.sep).join("/");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function querySchemaVersion(db) {
|
|
90
|
+
return db.prepare(`SELECT version FROM schema_version WHERE id = 1`).get()?.version ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sortedUnique(values) {
|
|
94
|
+
return [...new Set(values.filter(value => value !== null && value !== undefined && value !== ""))].sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mapPath(row, key, syncDir) {
|
|
98
|
+
return {
|
|
99
|
+
...row,
|
|
100
|
+
[key]: normalizePathForBackup(syncDir, row[key]),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function collectProjectScopedRows(db, table, projectId, orderBy) {
|
|
105
|
+
return db.prepare(`
|
|
106
|
+
SELECT *
|
|
107
|
+
FROM ${table}
|
|
108
|
+
WHERE project_id = ?
|
|
109
|
+
ORDER BY ${orderBy}
|
|
110
|
+
`).all(projectId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function collectScopedWorldRows(db, table, {
|
|
114
|
+
projectId,
|
|
115
|
+
universeId,
|
|
116
|
+
idColumn,
|
|
117
|
+
orderBy,
|
|
118
|
+
includeUniverseIds = new Set(),
|
|
119
|
+
}) {
|
|
120
|
+
const universeIds = [...includeUniverseIds];
|
|
121
|
+
const rows = db.prepare(`
|
|
122
|
+
SELECT *
|
|
123
|
+
FROM ${table}
|
|
124
|
+
WHERE project_id = ?
|
|
125
|
+
OR (
|
|
126
|
+
? IS NOT NULL
|
|
127
|
+
AND project_id IS NULL
|
|
128
|
+
AND universe_id = ?
|
|
129
|
+
AND ${idColumn} IN (${universeIds.map(() => "?").join(",") || "NULL"})
|
|
130
|
+
)
|
|
131
|
+
ORDER BY ${orderBy}
|
|
132
|
+
`).all(projectId, universeId, universeId, ...universeIds);
|
|
133
|
+
return {
|
|
134
|
+
rows,
|
|
135
|
+
ids: new Set(rows.map(row => row[idColumn])),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function collectReferenceDocsForBackup(db, {
|
|
140
|
+
projectId,
|
|
141
|
+
universeId,
|
|
142
|
+
}) {
|
|
143
|
+
let rows = collectProjectScopedRows(db, "reference_docs", projectId, "doc_id");
|
|
144
|
+
const ids = new Set(rows.map(row => row.doc_id));
|
|
145
|
+
|
|
146
|
+
let changed = true;
|
|
147
|
+
while (changed) {
|
|
148
|
+
changed = false;
|
|
149
|
+
const sourceIds = [...ids];
|
|
150
|
+
const links = db.prepare(`
|
|
151
|
+
SELECT *
|
|
152
|
+
FROM reference_links
|
|
153
|
+
WHERE source_project_id = ?
|
|
154
|
+
OR (
|
|
155
|
+
source_project_id = ''
|
|
156
|
+
AND source_kind = 'reference'
|
|
157
|
+
AND source_id IN (${sourceIds.map(() => "?").join(",") || "NULL"})
|
|
158
|
+
)
|
|
159
|
+
ORDER BY source_kind, source_project_id, source_id, target_doc_id, relation
|
|
160
|
+
`).all(projectId, ...sourceIds);
|
|
161
|
+
|
|
162
|
+
const targetIds = sortedUnique(links.map(row => row.target_doc_id).filter(id => !ids.has(id)));
|
|
163
|
+
if (!targetIds.length) continue;
|
|
164
|
+
|
|
165
|
+
const universeRows = universeId
|
|
166
|
+
? db.prepare(`
|
|
167
|
+
SELECT *
|
|
168
|
+
FROM reference_docs
|
|
169
|
+
WHERE project_id IS NULL
|
|
170
|
+
AND universe_id = ?
|
|
171
|
+
AND doc_id IN (${targetIds.map(() => "?").join(",")})
|
|
172
|
+
ORDER BY doc_id
|
|
173
|
+
`).all(universeId, ...targetIds)
|
|
174
|
+
: [];
|
|
175
|
+
|
|
176
|
+
for (const row of universeRows) {
|
|
177
|
+
if (ids.has(row.doc_id)) continue;
|
|
178
|
+
ids.add(row.doc_id);
|
|
179
|
+
rows.push(row);
|
|
180
|
+
changed = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
rows = rows.sort((a, b) => {
|
|
185
|
+
const projectOrder = Number(a.project_id === null) - Number(b.project_id === null);
|
|
186
|
+
if (projectOrder !== 0) return projectOrder;
|
|
187
|
+
return a.doc_id.localeCompare(b.doc_id);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
rows,
|
|
192
|
+
ids,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function collectProjectBackupSnapshot(db, { project, syncDir }) {
|
|
197
|
+
const projectId = project.project_id;
|
|
198
|
+
const universe = project.universe_id
|
|
199
|
+
? db.prepare(`
|
|
200
|
+
SELECT universe_id, name
|
|
201
|
+
FROM universes
|
|
202
|
+
WHERE universe_id = ?
|
|
203
|
+
`).get(project.universe_id) ?? null
|
|
204
|
+
: null;
|
|
205
|
+
|
|
206
|
+
const chapters = collectProjectScopedRows(db, "chapters", projectId, "sort_index, chapter_id")
|
|
207
|
+
.map(row => mapPath(row, "source_path", syncDir));
|
|
208
|
+
const scenes = collectProjectScopedRows(db, "scenes", projectId, "part, chapter, timeline_position, scene_id")
|
|
209
|
+
.map(row => mapPath(row, "file_path", syncDir));
|
|
210
|
+
const epigraphs = db.prepare(`
|
|
211
|
+
SELECT epigraph_id, project_id, chapter_id, file_path, prose_checksum, metadata_stale, updated_at
|
|
212
|
+
FROM epigraphs
|
|
213
|
+
WHERE project_id = ?
|
|
214
|
+
ORDER BY chapter_id, epigraph_id
|
|
215
|
+
`).all(projectId).map(row => mapPath(row, "file_path", syncDir));
|
|
216
|
+
|
|
217
|
+
const epigraphCharacters = collectProjectScopedRows(db, "epigraph_characters", projectId, "epigraph_id, character_id");
|
|
218
|
+
const epigraphTags = collectProjectScopedRows(db, "epigraph_tags", projectId, "epigraph_id, tag");
|
|
219
|
+
const sceneCharacters = collectProjectScopedRows(db, "scene_characters", projectId, "scene_id, character_id");
|
|
220
|
+
const scenePlaces = collectProjectScopedRows(db, "scene_places", projectId, "scene_id, place_id");
|
|
221
|
+
const sceneTags = collectProjectScopedRows(db, "scene_tags", projectId, "scene_id, tag");
|
|
222
|
+
const sceneThreads = collectProjectScopedRows(db, "scene_threads", projectId, "scene_id, thread_id");
|
|
223
|
+
const threads = collectProjectScopedRows(db, "threads", projectId, "thread_id");
|
|
224
|
+
const directlyReferencedCharacterIds = new Set([
|
|
225
|
+
...sceneCharacters.map(row => row.character_id),
|
|
226
|
+
...epigraphCharacters.map(row => row.character_id),
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const characters = collectScopedWorldRows(db, "characters", {
|
|
230
|
+
projectId,
|
|
231
|
+
universeId: project.universe_id,
|
|
232
|
+
idColumn: "character_id",
|
|
233
|
+
orderBy: "project_id IS NULL, character_id",
|
|
234
|
+
includeUniverseIds: directlyReferencedCharacterIds,
|
|
235
|
+
});
|
|
236
|
+
const characterTraits = characters.ids.size
|
|
237
|
+
? db.prepare(`
|
|
238
|
+
SELECT *
|
|
239
|
+
FROM character_traits
|
|
240
|
+
WHERE character_id IN (${[...characters.ids].map(() => "?").join(",")})
|
|
241
|
+
ORDER BY character_id, trait
|
|
242
|
+
`).all(...characters.ids)
|
|
243
|
+
: [];
|
|
244
|
+
|
|
245
|
+
const characterRelationships = db.prepare(`
|
|
246
|
+
SELECT *
|
|
247
|
+
FROM character_relationships
|
|
248
|
+
WHERE from_character IN (${[...characters.ids].map(() => "?").join(",") || "NULL"})
|
|
249
|
+
OR to_character IN (${[...characters.ids].map(() => "?").join(",") || "NULL"})
|
|
250
|
+
OR scene_id IN (${scenes.map(() => "?").join(",") || "NULL"})
|
|
251
|
+
ORDER BY from_character, to_character, relationship_type, scene_id, note
|
|
252
|
+
`).all(...characters.ids, ...characters.ids, ...scenes.map(row => row.scene_id));
|
|
253
|
+
|
|
254
|
+
const places = collectScopedWorldRows(db, "places", {
|
|
255
|
+
projectId,
|
|
256
|
+
universeId: project.universe_id,
|
|
257
|
+
idColumn: "place_id",
|
|
258
|
+
orderBy: "project_id IS NULL, place_id",
|
|
259
|
+
includeUniverseIds: new Set(scenePlaces.map(row => row.place_id)),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const referenceDocs = collectReferenceDocsForBackup(db, {
|
|
263
|
+
projectId,
|
|
264
|
+
universeId: project.universe_id,
|
|
265
|
+
});
|
|
266
|
+
const referenceDocTags = referenceDocs.ids.size
|
|
267
|
+
? db.prepare(`
|
|
268
|
+
SELECT *
|
|
269
|
+
FROM reference_doc_tags
|
|
270
|
+
WHERE doc_id IN (${[...referenceDocs.ids].map(() => "?").join(",")})
|
|
271
|
+
ORDER BY doc_id, tag
|
|
272
|
+
`).all(...referenceDocs.ids)
|
|
273
|
+
: [];
|
|
274
|
+
const referenceLinks = db.prepare(`
|
|
275
|
+
SELECT *
|
|
276
|
+
FROM reference_links
|
|
277
|
+
WHERE source_project_id = ?
|
|
278
|
+
OR (
|
|
279
|
+
source_project_id = ''
|
|
280
|
+
AND source_kind = 'reference'
|
|
281
|
+
AND source_id IN (${[...referenceDocs.ids].map(() => "?").join(",") || "NULL"})
|
|
282
|
+
)
|
|
283
|
+
ORDER BY source_kind, source_project_id, source_id, target_doc_id, relation
|
|
284
|
+
`).all(projectId, ...referenceDocs.ids);
|
|
285
|
+
|
|
286
|
+
const includedCharacterIds = characters.ids;
|
|
287
|
+
const includedPlaceIds = places.ids;
|
|
288
|
+
const includedReferenceDocIds = referenceDocs.ids;
|
|
289
|
+
const externalReferences = {
|
|
290
|
+
character_ids: sortedUnique([
|
|
291
|
+
...sceneCharacters.map(row => row.character_id),
|
|
292
|
+
...epigraphCharacters.map(row => row.character_id),
|
|
293
|
+
...characterRelationships.flatMap(row => [row.from_character, row.to_character]),
|
|
294
|
+
].filter(id => !includedCharacterIds.has(id))),
|
|
295
|
+
place_ids: sortedUnique(scenePlaces.map(row => row.place_id).filter(id => !includedPlaceIds.has(id))),
|
|
296
|
+
reference_doc_ids: sortedUnique(referenceLinks.map(row => row.target_doc_id).filter(id => !includedReferenceDocIds.has(id))),
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
project: {
|
|
301
|
+
project_id: project.project_id,
|
|
302
|
+
universe_id: project.universe_id ?? null,
|
|
303
|
+
name: project.name,
|
|
304
|
+
},
|
|
305
|
+
universe: universe
|
|
306
|
+
? {
|
|
307
|
+
universe_id: universe.universe_id,
|
|
308
|
+
name: universe.name,
|
|
309
|
+
}
|
|
310
|
+
: null,
|
|
311
|
+
chapters,
|
|
312
|
+
scenes,
|
|
313
|
+
epigraphs,
|
|
314
|
+
epigraph_characters: epigraphCharacters,
|
|
315
|
+
epigraph_tags: epigraphTags,
|
|
316
|
+
scene_characters: sceneCharacters,
|
|
317
|
+
scene_places: scenePlaces,
|
|
318
|
+
scene_tags: sceneTags,
|
|
319
|
+
scene_threads: sceneThreads,
|
|
320
|
+
characters: characters.rows.map(row => mapPath(row, "file_path", syncDir)),
|
|
321
|
+
character_traits: characterTraits,
|
|
322
|
+
character_relationships: characterRelationships,
|
|
323
|
+
places: places.rows.map(row => mapPath(row, "file_path", syncDir)),
|
|
324
|
+
threads,
|
|
325
|
+
reference_docs: referenceDocs.rows.map(row => mapPath(row, "file_path", syncDir)),
|
|
326
|
+
reference_doc_tags: referenceDocTags,
|
|
327
|
+
reference_links: referenceLinks,
|
|
328
|
+
external_references: externalReferences,
|
|
329
|
+
operation_history: {
|
|
330
|
+
supported: true,
|
|
331
|
+
authority: false,
|
|
332
|
+
advisory: true,
|
|
333
|
+
artifact: PROJECT_BACKUP_OPERATION_LOG_FILE,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function computeProjectBackupSnapshotChecksum(snapshot) {
|
|
339
|
+
return sha256(stableStringify(snapshot, 0));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function computeProjectBackupBundleChecksum(bundle) {
|
|
343
|
+
const { manifest = {}, snapshot = {} } = bundle ?? {};
|
|
344
|
+
const { checksums: _checksums, ...manifestWithoutChecksums } = manifest;
|
|
345
|
+
return sha256(stableStringify({
|
|
346
|
+
manifest: manifestWithoutChecksums,
|
|
347
|
+
snapshot,
|
|
348
|
+
}, 0));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function renderProjectBackupArtifact(value) {
|
|
352
|
+
return `${stableStringify(value, 2)}\n`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function writeGeneratedOutputFileIfChanged(filePath, rendered) {
|
|
356
|
+
assertRegularFileWriteTarget(filePath);
|
|
357
|
+
if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === rendered) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
writeGeneratedOutputFile(filePath, rendered, { encoding: "utf8" });
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function writeProjectBackupFiles(bundle, { outputDir }) {
|
|
365
|
+
const normalizedOutputDir = path.resolve(outputDir);
|
|
366
|
+
ensureDirectoryInsideBoundary(normalizedOutputDir, { label: "backup output_dir" });
|
|
367
|
+
|
|
368
|
+
const manifestPath = resolveGeneratedOutputPath(normalizedOutputDir, "manifest.json");
|
|
369
|
+
const snapshotPath = resolveGeneratedOutputPath(normalizedOutputDir, "canonical.snapshot.json");
|
|
370
|
+
const renderedManifest = renderProjectBackupArtifact(bundle.manifest);
|
|
371
|
+
const renderedSnapshot = renderProjectBackupArtifact(bundle.snapshot);
|
|
372
|
+
|
|
373
|
+
const manifestWritten = writeGeneratedOutputFileIfChanged(manifestPath, renderedManifest);
|
|
374
|
+
const snapshotWritten = writeGeneratedOutputFileIfChanged(snapshotPath, renderedSnapshot);
|
|
375
|
+
const operationLog = ensureProjectBackupOperationLog({ outputDir: normalizedOutputDir });
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
manifestPath,
|
|
379
|
+
snapshotPath,
|
|
380
|
+
operationLogPath: operationLog.operationLogPath,
|
|
381
|
+
written: {
|
|
382
|
+
manifest: manifestWritten,
|
|
383
|
+
canonical_snapshot: snapshotWritten,
|
|
384
|
+
operations: operationLog.written,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function buildProjectBackup(db, {
|
|
390
|
+
projectId,
|
|
391
|
+
syncDir = null,
|
|
392
|
+
applicationVersion = "0.0.0",
|
|
393
|
+
backupLocation = null,
|
|
394
|
+
} = {}) {
|
|
395
|
+
const project = db.prepare(`
|
|
396
|
+
SELECT project_id, universe_id, name
|
|
397
|
+
FROM projects
|
|
398
|
+
WHERE project_id = ?
|
|
399
|
+
`).get(projectId);
|
|
400
|
+
if (!project) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
error: {
|
|
404
|
+
code: "NOT_FOUND",
|
|
405
|
+
message: `Project '${projectId}' not found.`,
|
|
406
|
+
details: { project_id: projectId },
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const snapshot = collectProjectBackupSnapshot(db, { project, syncDir });
|
|
412
|
+
const snapshotChecksum = computeProjectBackupSnapshotChecksum(snapshot);
|
|
413
|
+
const manifestBase = {
|
|
414
|
+
artifact_kind: "project_backup",
|
|
415
|
+
schema_version: PROJECT_BACKUP_SCHEMA_VERSION,
|
|
416
|
+
canonical_source: "sqlite",
|
|
417
|
+
generated_transparency: true,
|
|
418
|
+
mutation_surface: false,
|
|
419
|
+
project_id: project.project_id,
|
|
420
|
+
backup_location: backupLocation ?? `project-backups/${project.project_id}/`,
|
|
421
|
+
compatibility: {
|
|
422
|
+
application_version: applicationVersion,
|
|
423
|
+
sqlite_schema_version: querySchemaVersion(db),
|
|
424
|
+
current_sqlite_schema_version: CURRENT_SCHEMA_VERSION,
|
|
425
|
+
},
|
|
426
|
+
restore_policy: {
|
|
427
|
+
authority: "full_snapshot",
|
|
428
|
+
custom_delta_chains: false,
|
|
429
|
+
event_replay_required: false,
|
|
430
|
+
operation_history_authority: false,
|
|
431
|
+
},
|
|
432
|
+
operation_history: {
|
|
433
|
+
supported: true,
|
|
434
|
+
advisory: true,
|
|
435
|
+
artifact: PROJECT_BACKUP_OPERATION_LOG_FILE,
|
|
436
|
+
authority: false,
|
|
437
|
+
purpose: "future audit, provenance, progress analytics, and tool accountability",
|
|
438
|
+
},
|
|
439
|
+
privacy: {
|
|
440
|
+
git_trackable: true,
|
|
441
|
+
manuscript_sensitive: true,
|
|
442
|
+
includes_authored_prose_bodies: false,
|
|
443
|
+
note: "Backup artifacts may include titles, summaries, tags, relationship notes, and structural metadata.",
|
|
444
|
+
},
|
|
445
|
+
coverage: {
|
|
446
|
+
included_tables: INCLUDED_TABLES,
|
|
447
|
+
excluded_tables: EXCLUDED_TABLES,
|
|
448
|
+
split_snapshot_supported: false,
|
|
449
|
+
counts: {
|
|
450
|
+
chapters: snapshot.chapters.length,
|
|
451
|
+
scenes: snapshot.scenes.length,
|
|
452
|
+
epigraphs: snapshot.epigraphs.length,
|
|
453
|
+
characters: snapshot.characters.length,
|
|
454
|
+
places: snapshot.places.length,
|
|
455
|
+
threads: snapshot.threads.length,
|
|
456
|
+
reference_docs: snapshot.reference_docs.length,
|
|
457
|
+
external_character_references: snapshot.external_references.character_ids.length,
|
|
458
|
+
external_place_references: snapshot.external_references.place_ids.length,
|
|
459
|
+
external_reference_doc_references: snapshot.external_references.reference_doc_ids.length,
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
const manifest = {
|
|
464
|
+
...manifestBase,
|
|
465
|
+
checksums: {
|
|
466
|
+
canonical_snapshot_sha256: snapshotChecksum,
|
|
467
|
+
bundle_sha256: computeProjectBackupBundleChecksum({ manifest: manifestBase, snapshot }),
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
ok: true,
|
|
473
|
+
manifest,
|
|
474
|
+
snapshot,
|
|
475
|
+
};
|
|
476
|
+
}
|
package/src/tools/editing.js
CHANGED
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
PROSE_STYLEGUIDE_SKILL_DIRNAME,
|
|
20
20
|
} from "../styleguide/prose-styleguide-skill.js";
|
|
21
21
|
import { analyzeSceneStyleguideDrift } from "../styleguide/prose-styleguide-drift.js";
|
|
22
|
+
import {
|
|
23
|
+
createToolActor,
|
|
24
|
+
refreshProjectBackupAfterMutation,
|
|
25
|
+
} from "../structure/project-backup-refresh.js";
|
|
22
26
|
|
|
23
27
|
function renderSceneContent(metadata, revisedProse) {
|
|
24
28
|
const hasFrontmatter = metadata && Object.keys(metadata).length > 0;
|
|
@@ -217,6 +221,7 @@ function evaluateStyleguidePolicy({
|
|
|
217
221
|
export function registerEditingTools(s, {
|
|
218
222
|
db,
|
|
219
223
|
SYNC_DIR,
|
|
224
|
+
MCP_SERVER_VERSION = "0.0.0",
|
|
220
225
|
GIT_ENABLED,
|
|
221
226
|
STYLEGUIDE_ENFORCEMENT_MODE,
|
|
222
227
|
errorResponse,
|
|
@@ -224,6 +229,14 @@ export function registerEditingTools(s, {
|
|
|
224
229
|
pendingProposals,
|
|
225
230
|
generateProposalId,
|
|
226
231
|
}) {
|
|
232
|
+
function backupMutationFields(backupResult) {
|
|
233
|
+
return {
|
|
234
|
+
operation_history: backupResult.operation_history,
|
|
235
|
+
backup_refresh: backupResult.backup_refresh,
|
|
236
|
+
backup_warnings: backupResult.backup_warnings,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
227
240
|
// ---- propose_edit --------------------------------------------------------
|
|
228
241
|
s.tool(
|
|
229
242
|
"propose_edit",
|
|
@@ -501,6 +514,25 @@ export function registerEditingTools(s, {
|
|
|
501
514
|
managedStructure: isManagedStructureProject(db, proposal.project_id ?? project_id),
|
|
502
515
|
});
|
|
503
516
|
pendingProposals.delete(proposal_id);
|
|
517
|
+
const backupResult = refreshProjectBackupAfterMutation(db, {
|
|
518
|
+
syncDir: SYNC_DIR,
|
|
519
|
+
projectId: proposal.project_id ?? project_id,
|
|
520
|
+
applicationVersion: MCP_SERVER_VERSION,
|
|
521
|
+
operation: "commit_edit",
|
|
522
|
+
actor: createToolActor("commit_edit"),
|
|
523
|
+
affected: {
|
|
524
|
+
scenes: [scene_id],
|
|
525
|
+
},
|
|
526
|
+
summary: `Refreshed scene "${scene_id}" after no-op edit commit.`,
|
|
527
|
+
before: null,
|
|
528
|
+
after: {
|
|
529
|
+
scene: {
|
|
530
|
+
scene_id,
|
|
531
|
+
project_id: proposal.project_id ?? project_id,
|
|
532
|
+
noop: true,
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
});
|
|
504
536
|
|
|
505
537
|
return jsonResponse({
|
|
506
538
|
ok: true,
|
|
@@ -511,6 +543,7 @@ export function registerEditingTools(s, {
|
|
|
511
543
|
noop: true,
|
|
512
544
|
message: `Proposal for scene '${scene_id}' matches the current file. Nothing was written.`,
|
|
513
545
|
next_step: "No changes were applied. If you still need edits, call propose_edit with revised prose.",
|
|
546
|
+
...backupMutationFields(backupResult),
|
|
514
547
|
});
|
|
515
548
|
}
|
|
516
549
|
|
|
@@ -530,6 +563,27 @@ export function registerEditingTools(s, {
|
|
|
530
563
|
});
|
|
531
564
|
|
|
532
565
|
pendingProposals.delete(proposal_id);
|
|
566
|
+
const backupResult = refreshProjectBackupAfterMutation(db, {
|
|
567
|
+
syncDir: SYNC_DIR,
|
|
568
|
+
projectId: proposal.project_id ?? project_id,
|
|
569
|
+
applicationVersion: MCP_SERVER_VERSION,
|
|
570
|
+
operation: "commit_edit",
|
|
571
|
+
actor: createToolActor("commit_edit"),
|
|
572
|
+
affected: {
|
|
573
|
+
scenes: [scene_id],
|
|
574
|
+
},
|
|
575
|
+
summary: `Committed prose edit for scene "${scene_id}".`,
|
|
576
|
+
before: {
|
|
577
|
+
prose_digest: digestFor(proposal.original_prose),
|
|
578
|
+
},
|
|
579
|
+
after: {
|
|
580
|
+
scene: {
|
|
581
|
+
scene_id,
|
|
582
|
+
project_id: proposal.project_id ?? project_id,
|
|
583
|
+
snapshot_commit: snapshot.commit_hash,
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
});
|
|
533
587
|
|
|
534
588
|
return jsonResponse({
|
|
535
589
|
ok: true,
|
|
@@ -540,6 +594,7 @@ export function registerEditingTools(s, {
|
|
|
540
594
|
noop: false,
|
|
541
595
|
message: `Applied edit to scene '${scene_id}'${snapshot.commit_hash ? ` (snapshot: ${snapshot.commit_hash.substring(0, 7)})` : " (no pre-edit snapshot needed)"}`,
|
|
542
596
|
next_step: "Edit applied. Run get_scene_prose to verify prose, then continue with additional targeted edits if needed.",
|
|
597
|
+
...backupMutationFields(backupResult),
|
|
543
598
|
});
|
|
544
599
|
} catch (err) {
|
|
545
600
|
if (err.code === "ENOENT") {
|