@hanna84/mcp-writing 3.18.0 → 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.
@@ -4,6 +4,10 @@ import matter from "gray-matter";
4
4
  import { readMeta } from "../sync/sync.js";
5
5
  import { persistSceneReferenceLink, upsertExplicitReferenceLinkRow } from "./reference-link-persistence.js";
6
6
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
7
+ import {
8
+ createToolActor,
9
+ refreshProjectBackupAfterMutation,
10
+ } from "../structure/project-backup-refresh.js";
7
11
 
8
12
  function accumulateSuggestionScore(scoreMap, rows, sourceLabel) {
9
13
  for (const row of rows) {
@@ -59,6 +63,7 @@ export function registerSearchTools(s, {
59
63
  db,
60
64
  SYNC_DIR,
61
65
  SYNC_DIR_WRITABLE,
66
+ MCP_SERVER_VERSION = "0.0.0",
62
67
  GIT_ENABLED,
63
68
  errorResponse,
64
69
  paginateRows,
@@ -1292,6 +1297,32 @@ export function registerSearchTools(s, {
1292
1297
  });
1293
1298
  }
1294
1299
 
1300
+ const backupResult = appliedLinks.length
1301
+ ? refreshProjectBackupAfterMutation(db, {
1302
+ syncDir: SYNC_DIR,
1303
+ projectId: resolvedProjectId,
1304
+ applicationVersion: MCP_SERVER_VERSION,
1305
+ operation: "suggest_scene_references",
1306
+ actor: createToolActor("suggest_scene_references"),
1307
+ affected: {
1308
+ scenes: [scene_id],
1309
+ reference_docs: appliedLinks.map(link => link.target_doc_id),
1310
+ },
1311
+ summary: `Applied ${appliedLinks.length} suggested reference link(s) for scene "${scene_id}".`,
1312
+ before: null,
1313
+ after: {
1314
+ applied_links: appliedLinks,
1315
+ },
1316
+ metadata: {
1317
+ failed_count: failedLinks.length,
1318
+ },
1319
+ })
1320
+ : {
1321
+ operation_history: null,
1322
+ backup_refresh: null,
1323
+ backup_warnings: [],
1324
+ };
1325
+
1295
1326
  return {
1296
1327
  content: [{
1297
1328
  type: "text",
@@ -1305,6 +1336,9 @@ export function registerSearchTools(s, {
1305
1336
  applied_links: appliedLinks,
1306
1337
  failed_count: failedLinks.length,
1307
1338
  failed_links: failedLinks,
1339
+ operation_history: backupResult.operation_history,
1340
+ backup_refresh: backupResult.backup_refresh,
1341
+ backup_warnings: backupResult.backup_warnings,
1308
1342
  candidates: enriched,
1309
1343
  }, null, 2),
1310
1344
  }],
package/src/tools/sync.js CHANGED
@@ -10,6 +10,15 @@ import {
10
10
  defaultStructureExportFileName,
11
11
  writeStructureExportFile,
12
12
  } from "../structure/structure-export.js";
13
+ import {
14
+ buildProjectBackup,
15
+ writeProjectBackupFiles,
16
+ } from "../structure/project-backup.js";
17
+ import { runProjectBackupDiagnostics } from "../structure/project-backup-diagnostics.js";
18
+ import {
19
+ createToolActor,
20
+ refreshProjectBackupAfterMutation,
21
+ } from "../structure/project-backup-refresh.js";
13
22
  import { restoreStructureFromExport } from "../structure/structure-restore.js";
14
23
 
15
24
  export function registerSyncTools(s, {
@@ -18,6 +27,7 @@ export function registerSyncTools(s, {
18
27
  SYNC_DIR_ABS,
19
28
  SYNC_DIR_REAL,
20
29
  SYNC_DIR_WRITABLE,
30
+ MCP_SERVER_VERSION = "0.0.0",
21
31
  asyncJobs,
22
32
  errorResponse,
23
33
  jsonResponse,
@@ -33,6 +43,14 @@ export function registerSyncTools(s, {
33
43
  deriveLoglineFromProse,
34
44
  inferCharacterIdsFromProse,
35
45
  }) {
46
+ function backupMutationFields(backupResult) {
47
+ return {
48
+ operation_history: backupResult.operation_history,
49
+ backup_refresh: backupResult.backup_refresh,
50
+ backup_warnings: backupResult.backup_warnings,
51
+ };
52
+ }
53
+
36
54
  s.tool("sync", "Re-scan the sync folder and update derived scene/character/place indexes from disk. For already managed projects, sync reports file-derived chapter or epigraph drift without adopting it as canonical structure; use explicit import, repair, or structure tools for structural changes.", {}, async () => {
37
55
  const result = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
38
56
  const parts = [`Sync complete. ${result.indexed} scenes indexed. ${result.staleMarked} scenes marked stale.`];
@@ -151,6 +169,143 @@ export function registerSyncTools(s, {
151
169
  }
152
170
  );
153
171
 
172
+ s.tool(
173
+ "export_project_backup",
174
+ "Generate a deterministic project backup bundle from SQLite canonical state. Writes manifest.json, canonical.snapshot.json, and operations.jsonl under WRITING_SYNC_DIR for explicit review and future restore workflows; this is generated transparency only and does not mutate canonical state.",
175
+ {
176
+ project_id: z.string().describe("Project ID to back up (e.g. 'test-novel' or 'universe-1/book-1-the-lamb')."),
177
+ output_dir: z.string().optional().describe("Directory under WRITING_SYNC_DIR where manifest.json, canonical.snapshot.json, and operations.jsonl should be written. Defaults to project-backups/<project_id>."),
178
+ },
179
+ async ({ project_id, output_dir }) => {
180
+ if (!SYNC_DIR_WRITABLE) {
181
+ return errorResponse("READ_ONLY", "Cannot export project backup: sync dir is read-only.");
182
+ }
183
+
184
+ const projectIdCheck = validateProjectId(project_id);
185
+ if (!projectIdCheck.ok) {
186
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
187
+ }
188
+
189
+ try {
190
+ const requestedOutputDir = output_dir
191
+ ? (path.isAbsolute(output_dir) ? output_dir : path.join(SYNC_DIR_ABS, output_dir))
192
+ : path.join(SYNC_DIR_ABS, "project-backups", project_id);
193
+ const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(requestedOutputDir);
194
+ const relativeBase = relativeToSyncDir.split(path.sep).filter(Boolean).join("/");
195
+ const outputDirSegments = relativeToSyncDir
196
+ .split(path.sep)
197
+ .filter(Boolean)
198
+ .map(segment => segment.toLowerCase());
199
+ if (outputDirSegments.includes("scenes")) {
200
+ return errorResponse(
201
+ "INVALID_OUTPUT_DIR",
202
+ "output_dir cannot be inside a scenes directory. Choose a dedicated generated backup folder under WRITING_SYNC_DIR.",
203
+ { output_dir: resolvedOutputDir }
204
+ );
205
+ }
206
+
207
+ const built = buildProjectBackup(db, {
208
+ projectId: project_id,
209
+ syncDir: SYNC_DIR_ABS,
210
+ applicationVersion: MCP_SERVER_VERSION,
211
+ backupLocation: relativeBase ? `${relativeBase}/` : "./",
212
+ });
213
+ if (!built.ok) {
214
+ return errorResponse(built.error.code, built.error.message, built.error.details);
215
+ }
216
+
217
+ const written = writeProjectBackupFiles(built, { outputDir: resolvedOutputDir });
218
+ const relativePath = fileName => (relativeBase ? `${relativeBase}/${fileName}` : fileName);
219
+
220
+ return jsonResponse({
221
+ ok: true,
222
+ action: "exported",
223
+ project_id,
224
+ output_dir: resolvedOutputDir,
225
+ relative_output_dir: relativeBase,
226
+ files: {
227
+ manifest: written.manifestPath,
228
+ canonical_snapshot: written.snapshotPath,
229
+ operations: written.operationLogPath,
230
+ },
231
+ written: written.written,
232
+ relative_files: {
233
+ manifest: relativePath("manifest.json"),
234
+ canonical_snapshot: relativePath("canonical.snapshot.json"),
235
+ operations: relativePath("operations.jsonl"),
236
+ },
237
+ manifest: {
238
+ schema_version: built.manifest.schema_version,
239
+ canonical_source: built.manifest.canonical_source,
240
+ generated_transparency: built.manifest.generated_transparency,
241
+ mutation_surface: built.manifest.mutation_surface,
242
+ backup_location: built.manifest.backup_location,
243
+ restore_policy: built.manifest.restore_policy,
244
+ privacy: built.manifest.privacy,
245
+ coverage: built.manifest.coverage,
246
+ checksums: built.manifest.checksums,
247
+ },
248
+ next_step: "Review or commit the generated project backup bundle. Do not edit it as a mutation surface; future restore tools will treat it as explicit recovery input.",
249
+ });
250
+ } catch (error) {
251
+ if (
252
+ error &&
253
+ typeof error === "object" &&
254
+ error.name === "CoreValidationError" &&
255
+ typeof error.code === "string"
256
+ ) {
257
+ return errorResponse(error.code, error.message ?? "Request failed.", error.details);
258
+ }
259
+ return errorResponse(
260
+ "EXPORT_PROJECT_BACKUP_FAILED",
261
+ error instanceof Error ? error.message : "Failed to export project backup."
262
+ );
263
+ }
264
+ }
265
+ );
266
+
267
+ s.tool(
268
+ "diagnose_project_backups",
269
+ "Run read-only diagnostics for a generated project backup bundle. Reports missing, partial, wrong-project, incompatible-schema, tampered, unreadable, and stale backups without mutating SQLite or generated files.",
270
+ {
271
+ project_id: z.string().describe("Project ID whose backup bundle should be diagnosed (e.g. 'test-novel')."),
272
+ backup_dir: z.string().optional().describe("Directory under WRITING_SYNC_DIR containing manifest.json and canonical.snapshot.json. Defaults to project-backups/<project_id>."),
273
+ },
274
+ async ({ project_id, backup_dir } = {}) => {
275
+ const projectIdCheck = validateProjectId(project_id);
276
+ if (!projectIdCheck.ok) {
277
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
278
+ }
279
+
280
+ try {
281
+ const requestedBackupDir = backup_dir
282
+ ? (path.isAbsolute(backup_dir) ? backup_dir : path.join(SYNC_DIR_ABS, backup_dir))
283
+ : path.join(SYNC_DIR_ABS, "project-backups", project_id);
284
+ const { resolvedOutputDir } = resolveOutputDirWithinSync(requestedBackupDir);
285
+
286
+ return jsonResponse(runProjectBackupDiagnostics(db, {
287
+ syncDir: SYNC_DIR_ABS,
288
+ backupDir: resolvedOutputDir,
289
+ projectId: project_id,
290
+ applicationVersion: MCP_SERVER_VERSION,
291
+ }));
292
+ } catch (error) {
293
+ if (
294
+ error &&
295
+ typeof error === "object" &&
296
+ error.name === "CoreValidationError" &&
297
+ typeof error.code === "string"
298
+ ) {
299
+ return errorResponse(error.code, error.message ?? "Request failed.", error.details);
300
+ }
301
+ return errorResponse(
302
+ "DIAGNOSE_PROJECT_BACKUPS_FAILED",
303
+ error instanceof Error ? error.message : "Failed to diagnose project backup."
304
+ );
305
+ }
306
+ }
307
+ );
308
+
154
309
  s.tool(
155
310
  "restore_structure_from_export",
156
311
  "Explicitly restore canonical SQLite chapter, scene-placement, and epigraph structure from a trusted generated structure export. This is a repair workflow, never a sync side effect; it validates project identity, schema, checksum, file presence, and conflicts before applying a transaction.",
@@ -573,6 +728,28 @@ export function registerSyncTools(s, {
573
728
  db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
574
729
  .run(sceneId, project_id);
575
730
  }
731
+
732
+ if (changedScenes.length > 0) {
733
+ const backupResult = refreshProjectBackupAfterMutation(db, {
734
+ syncDir: SYNC_DIR,
735
+ projectId: project_id,
736
+ applicationVersion: MCP_SERVER_VERSION,
737
+ operation: "enrich_scene_characters_batch",
738
+ actor: createToolActor("enrich_scene_characters_batch"),
739
+ affected: {
740
+ scenes: changedScenes,
741
+ },
742
+ summary: `Applied batch character enrichment to ${changedScenes.length} scene(s).`,
743
+ before: null,
744
+ after: {
745
+ scenes_changed: changedScenes.length,
746
+ },
747
+ });
748
+ completedJob.result = {
749
+ ...completedJob.result,
750
+ ...backupMutationFields(backupResult),
751
+ };
752
+ }
576
753
  },
577
754
  });
578
755
 
@@ -735,6 +912,28 @@ export function registerSyncTools(s, {
735
912
  });
736
913
  db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
737
914
  .run(scene.scene_id, scene.project_id);
915
+ const backupResult = refreshProjectBackupAfterMutation(db, {
916
+ syncDir: SYNC_DIR,
917
+ projectId: scene.project_id,
918
+ applicationVersion: MCP_SERVER_VERSION,
919
+ operation: "enrich_scene",
920
+ actor: createToolActor("enrich_scene"),
921
+ affected: {
922
+ scenes: [scene.scene_id],
923
+ },
924
+ summary: `Enriched scene "${scene.scene_id}".`,
925
+ before: null,
926
+ after: {
927
+ scene: {
928
+ scene_id: scene.scene_id,
929
+ project_id: scene.project_id,
930
+ updated_fields: {
931
+ logline: Boolean(inferredLogline),
932
+ characters: inferredCharacters.length,
933
+ },
934
+ },
935
+ },
936
+ });
738
937
 
739
938
  return jsonResponse({
740
939
  ok: true,
@@ -746,6 +945,7 @@ export function registerSyncTools(s, {
746
945
  characters: inferredCharacters.length,
747
946
  },
748
947
  metadata_stale: false,
948
+ ...backupMutationFields(backupResult),
749
949
  });
750
950
  } catch (err) {
751
951
  if (err?.name === "CoreValidationError") {
@@ -89,8 +89,10 @@ export const WORKFLOW_CATALOGUE = [
89
89
  { tool: "move_scene", note: "Use when a scene should move to another canonical chapter and/or unused timeline_position; this does not move the scene source file." },
90
90
  { tool: "assign_scene_to_chapter", note: "Use this named structure workflow for chapter assignment or clearing instead of editing chapter fields through generic metadata updates." },
91
91
  { tool: "diagnose_structure", note: "Run when the assignment is part of a drift repair workflow, when folder-derived structure may disagree with the requested link, or before trusting an export for recovery." },
92
+ { tool: "diagnose_project_backups", note: "Structure mutation tools normally refresh the project backup automatically; run this if backup_warnings were returned or you need to verify backup freshness before recovery work." },
92
93
  { tool: "restore_structure_from_export", note: "Use only as an explicit repair workflow after diagnostics show a trusted generated export can recover canonical SQLite structure." },
93
94
  { tool: "export_structure_snapshot", note: "Generate a deterministic SQLite-derived structure export for Git review after canonical structure changes; editing the export does not mutate structure." },
95
+ { tool: "export_project_backup", note: "Generate or repair the broader deterministic project backup bundle for Git review and future recovery input; editing it does not mutate canonical state." },
94
96
  ],
95
97
  },
96
98
  {