@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.
@@ -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
+ }
@@ -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") {