@hanna84/mcp-writing 1.0.0 → 1.3.6

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/index.js CHANGED
@@ -4,12 +4,17 @@ import http from "node:http";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import matter from "gray-matter";
7
+ import yaml from "js-yaml";
7
8
  import { z } from "zod";
8
9
  import { openDb } from "./db.js";
9
- import { syncAll, isSyncDirWritable, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath } from "./sync.js";
10
+ import { syncAll, isSyncDirWritable, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
11
+ import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
12
+ import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
10
13
 
11
14
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
12
15
  const DB_PATH = process.env.DB_PATH ?? "./writing.db";
16
+ const SYNC_DIR_ABS = path.resolve(SYNC_DIR);
17
+ const DB_PATH_DISPLAY = DB_PATH === ":memory:" ? DB_PATH : path.resolve(DB_PATH);
13
18
  const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3000", 10);
14
19
  const MAX_CHAPTER_SCENES = parseInt(process.env.MAX_CHAPTER_SCENES ?? "10", 10);
15
20
  const DEFAULT_METADATA_PAGE_SIZE = parseInt(process.env.DEFAULT_METADATA_PAGE_SIZE ?? "20", 10);
@@ -92,17 +97,161 @@ function inferCharacterIdsFromProse(dbHandle, prose, projectId) {
92
97
  return [...new Set(found)].slice(0, 12);
93
98
  }
94
99
 
100
+ function readSupportingNotesForEntity(filePath) {
101
+ const ext = path.extname(filePath).toLowerCase();
102
+ const base = path.basename(filePath, ext).toLowerCase();
103
+ if (base !== "sheet") return [];
104
+
105
+ const dir = path.dirname(filePath);
106
+ let entries;
107
+ try {
108
+ entries = fs.readdirSync(dir, { withFileTypes: true });
109
+ } catch {
110
+ return [];
111
+ }
112
+
113
+ return entries
114
+ .filter(entry => entry.isFile())
115
+ .map(entry => entry.name)
116
+ .filter(name => /\.(md|txt)$/i.test(name))
117
+ .filter(name => !/^sheet\.(md|txt)$/i.test(name))
118
+ .sort((a, b) => a.localeCompare(b))
119
+ .map(name => {
120
+ const notePath = path.join(dir, name);
121
+ try {
122
+ const raw = fs.readFileSync(notePath, "utf8");
123
+ const { content } = matter(raw);
124
+ return {
125
+ file_name: name,
126
+ content: content.trim(),
127
+ };
128
+ } catch {
129
+ return null;
130
+ }
131
+ })
132
+ .filter(Boolean)
133
+ .filter(note => note.content);
134
+ }
135
+
136
+ function readEntityMetadata(filePath) {
137
+ const metaPath = sidecarPath(filePath);
138
+ if (fs.existsSync(metaPath)) {
139
+ try {
140
+ return yaml.load(fs.readFileSync(metaPath, "utf8")) ?? {};
141
+ } catch {
142
+ return {};
143
+ }
144
+ }
145
+
146
+ try {
147
+ return matter(fs.readFileSync(filePath, "utf8")).data ?? {};
148
+ } catch {
149
+ return {};
150
+ }
151
+ }
152
+
153
+ function resolveProjectRoot(projectId) {
154
+ if (projectId.includes("/")) {
155
+ const [universeId, projectSlug] = projectId.split("/");
156
+ return path.join(SYNC_DIR, "universes", universeId, projectSlug);
157
+ }
158
+ return path.join(SYNC_DIR, "projects", projectId);
159
+ }
160
+
161
+ function resolveWorldEntityDir({ kind, projectId, universeId, name }) {
162
+ const slug = slugifyEntityName(name);
163
+ const baseDir = projectId
164
+ ? path.join(resolveProjectRoot(projectId), "world")
165
+ : path.join(SYNC_DIR, "universes", universeId, "world");
166
+ const bucket = kind === "character" ? "characters" : "places";
167
+ return {
168
+ slug,
169
+ dir: path.join(baseDir, bucket, slug),
170
+ };
171
+ }
172
+
173
+ function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId, meta }) {
174
+ const prefix = kind === "character" ? "char" : "place";
175
+ const idKey = kind === "character" ? "character_id" : "place_id";
176
+ const slug = slugifyEntityName(name);
177
+ if (!slug) throw new Error("Name must contain at least one alphanumeric character.");
178
+
179
+ const { dir } = resolveWorldEntityDir({ kind, projectId, universeId, name });
180
+ const prosePath = path.join(dir, "sheet.md");
181
+ const metaPath = sidecarPath(prosePath);
182
+ if (fs.existsSync(prosePath) || fs.existsSync(metaPath)) {
183
+ throw new Error(`Canonical sheet already exists at ${dir}`);
184
+ }
185
+
186
+ fs.mkdirSync(dir, { recursive: true });
187
+ const defaultSheet = kind === "character"
188
+ ? renderCharacterSheetTemplate(name)
189
+ : renderPlaceSheetTemplate(name);
190
+ fs.writeFileSync(prosePath, `${notes?.trim() ?? defaultSheet}${(notes?.trim() ?? defaultSheet) ? "\n" : ""}`, "utf8");
191
+ if (kind === "character") {
192
+ fs.writeFileSync(path.join(dir, "arc.md"), `${renderCharacterArcTemplate(name)}\n`, "utf8");
193
+ }
194
+ const payload = {
195
+ [idKey]: `${prefix}-${slug}`,
196
+ name,
197
+ ...meta,
198
+ };
199
+ fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
200
+
201
+ syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
202
+
203
+ return {
204
+ id: payload[idKey],
205
+ prose_path: prosePath,
206
+ meta_path: metaPath,
207
+ project_id: projectId ?? null,
208
+ universe_id: universeId ?? null,
209
+ };
210
+ }
211
+
95
212
  // ---------------------------------------------------------------------------
96
213
  // Database setup
97
214
  // ---------------------------------------------------------------------------
98
215
  const db = openDb(DB_PATH);
99
216
 
217
+ process.stderr.write(`[mcp-writing] Sync dir: ${SYNC_DIR_ABS}\n`);
218
+ process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
219
+
100
220
  // Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
101
221
  const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
102
222
  if (!SYNC_DIR_WRITABLE) {
103
223
  process.stderr.write(`[mcp-writing] WARNING: sync dir is not writable — sidecar auto-migration and metadata write-back will be unavailable\n`);
104
224
  }
105
225
 
226
+ // Check git availability and initialize repository if needed (Phase 3)
227
+ const GIT_AVAILABLE = isGitAvailable();
228
+ let GIT_ENABLED = false;
229
+ if (GIT_AVAILABLE && SYNC_DIR_WRITABLE) {
230
+ if (!isGitRepository(SYNC_DIR)) {
231
+ try {
232
+ initGitRepository(SYNC_DIR);
233
+ process.stderr.write(`[mcp-writing] Initialized git repository at ${SYNC_DIR}\n`);
234
+ GIT_ENABLED = true;
235
+ } catch (err) {
236
+ process.stderr.write(`[mcp-writing] WARNING: Failed to initialize git repository: ${err.message}\n`);
237
+ }
238
+ } else {
239
+ GIT_ENABLED = true;
240
+ process.stderr.write(`[mcp-writing] Git repository detected at ${SYNC_DIR} — Phase 3 editing tools enabled\n`);
241
+ }
242
+ } else if (!GIT_AVAILABLE) {
243
+ process.stderr.write(`[mcp-writing] WARNING: git not found on PATH — Phase 3 editing tools will be unavailable\n`);
244
+ } else if (!SYNC_DIR_WRITABLE) {
245
+ process.stderr.write(`[mcp-writing] NOTE: sync dir is read-only — Phase 3 editing tools will be unavailable\n`);
246
+ }
247
+
248
+ // In-memory storage for pending edit proposals (Phase 3)
249
+ const pendingProposals = new Map();
250
+ let nextProposalId = 1;
251
+ function generateProposalId() {
252
+ return `proposal-${nextProposalId++}`;
253
+ }
254
+
106
255
  // Run sync on startup
107
256
  syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
108
257
 
@@ -122,6 +271,23 @@ function createMcpServer() {
122
271
  return { content: [{ type: "text", text: parts.join(" ") }] };
123
272
  });
124
273
 
274
+ // ---- get_runtime_config --------------------------------------------------
275
+ s.tool(
276
+ "get_runtime_config",
277
+ "Show the active runtime paths and capabilities for this server instance (sync dir, database path, writability, and git availability). Use this to verify which manuscript location is currently connected.",
278
+ {},
279
+ async () => {
280
+ return jsonResponse({
281
+ sync_dir: SYNC_DIR_ABS,
282
+ db_path: DB_PATH_DISPLAY,
283
+ sync_dir_writable: SYNC_DIR_WRITABLE,
284
+ git_available: GIT_AVAILABLE,
285
+ git_enabled: GIT_ENABLED,
286
+ http_port: HTTP_PORT,
287
+ });
288
+ }
289
+ );
290
+
125
291
  // ---- find_scenes ---------------------------------------------------------
126
292
  s.tool(
127
293
  "find_scenes",
@@ -203,22 +369,34 @@ function createMcpServer() {
203
369
  // ---- get_scene_prose -----------------------------------------------------
204
370
  s.tool(
205
371
  "get_scene_prose",
206
- "Load the full prose text of a single scene. Use this for close reading, continuity checks, or when you need the actual writing. For overview or filtering, use find_scenes instead — it is much cheaper.",
372
+ "Load the full prose text of a single scene. Use this for close reading, continuity checks, or when you need the actual writing. For overview or filtering, use find_scenes instead — it is much cheaper. Optionally retrieve a past version from git history.",
207
373
  {
208
374
  scene_id: z.string().describe("The scene_id to retrieve (e.g. 'sc-001-prologue'). Get this from find_scenes or get_arc."),
375
+ commit: z.string().optional().describe("Optional git commit hash to retrieve a past version. Use list_snapshots to find valid hashes. If omitted, returns the current prose."),
209
376
  },
210
- async ({ scene_id }) => {
377
+ async ({ scene_id, commit }) => {
211
378
  const scene = db.prepare(`SELECT file_path, metadata_stale FROM scenes WHERE scene_id = ?`).get(scene_id);
212
379
  if (!scene) {
213
380
  return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Run sync() if you just added it.`);
214
381
  }
215
382
  try {
216
- const raw = fs.readFileSync(scene.file_path, "utf8");
217
- const { content: prose } = matter(raw);
218
- const warning = scene.metadata_stale
383
+ let rawContent;
384
+ if (commit && GIT_ENABLED) {
385
+ // Retrieve from git history
386
+ rawContent = getSceneProseAtCommit(SYNC_DIR, scene.file_path, commit);
387
+ } else if (commit && !GIT_ENABLED) {
388
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — cannot retrieve historical versions.");
389
+ } else {
390
+ // Retrieve current version
391
+ rawContent = fs.readFileSync(scene.file_path, "utf8");
392
+ }
393
+
394
+ const { content: prose } = matter(rawContent);
395
+ const versionNote = commit ? `\n\n(Retrieved from commit: ${commit})` : "";
396
+ const warning = scene.metadata_stale && !commit
219
397
  ? `\n\n⚠️ Metadata for this scene may be stale — prose has changed since last enrichment.`
220
398
  : "";
221
- return { content: [{ type: "text", text: prose.trim() + warning }] };
399
+ return { content: [{ type: "text", text: prose.trim() + versionNote + warning }] };
222
400
  } catch (err) {
223
401
  if (err.code === "ENOENT") {
224
402
  return errorResponse(
@@ -352,7 +530,7 @@ function createMcpServer() {
352
530
  // ---- get_character_sheet -------------------------------------------------
353
531
  s.tool(
354
532
  "get_character_sheet",
355
- "Get full character details: role, arc_summary, traits, and the full content of the character notes file. Use list_characters first to get the character_id.",
533
+ "Get full character details: role, arc_summary, traits, the canonical sheet content, and any adjacent support notes when the character uses a folder-based layout. Use list_characters first to get the character_id.",
356
534
  {
357
535
  character_id: z.string().describe("The character_id to look up (e.g. 'char-sebastian'). Use list_characters to find valid IDs."),
358
536
  },
@@ -366,19 +544,67 @@ function createMcpServer() {
366
544
  .all(character_id).map(r => r.trait);
367
545
 
368
546
  let notes = "";
547
+ let supportingNotes = [];
369
548
  if (character.file_path) {
370
549
  try {
371
550
  const raw = fs.readFileSync(character.file_path, "utf8");
372
551
  const { content } = matter(raw);
373
552
  notes = content.trim();
374
- } catch {}
553
+ supportingNotes = readSupportingNotesForEntity(character.file_path);
554
+ } catch { /* empty */ }
375
555
  }
376
556
 
377
- const result = { ...character, traits, notes: notes || undefined };
557
+ const result = {
558
+ ...character,
559
+ traits,
560
+ notes: notes || undefined,
561
+ supporting_notes: supportingNotes.length ? supportingNotes : undefined,
562
+ };
378
563
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
379
564
  }
380
565
  );
381
566
 
567
+ // ---- create_character_sheet ---------------------------------------------
568
+ s.tool(
569
+ "create_character_sheet",
570
+ "Create a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately. Use this before migrating freeform notes into the new folder structure.",
571
+ {
572
+ name: z.string().describe("Display name of the character (e.g. 'Mira Nystrom')."),
573
+ project_id: z.string().optional().describe("Project scope for a book-local character (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
574
+ universe_id: z.string().optional().describe("Universe scope for a cross-book shared character (e.g. 'universe-1')."),
575
+ notes: z.string().optional().describe("Optional starter prose content for sheet.md."),
576
+ fields: z.object({
577
+ role: z.string().optional(),
578
+ arc_summary: z.string().optional(),
579
+ first_appearance: z.string().optional(),
580
+ traits: z.array(z.string()).optional(),
581
+ }).optional().describe("Optional starter metadata fields for the character sidecar."),
582
+ },
583
+ async ({ name, project_id, universe_id, notes, fields }) => {
584
+ if (!SYNC_DIR_WRITABLE) {
585
+ return errorResponse("READ_ONLY", "Cannot create character sheet: sync dir is read-only.");
586
+ }
587
+ if ((project_id && universe_id) || (!project_id && !universe_id)) {
588
+ return errorResponse("VALIDATION_ERROR", "Provide exactly one of project_id or universe_id.");
589
+ }
590
+
591
+ try {
592
+ const created = createCanonicalWorldEntity({
593
+ kind: "character",
594
+ name,
595
+ notes,
596
+ projectId: project_id,
597
+ universeId: universe_id,
598
+ meta: fields ?? {},
599
+ });
600
+
601
+ return jsonResponse({ ok: true, action: "created", kind: "character", ...created });
602
+ } catch (err) {
603
+ return errorResponse("IO_ERROR", `Failed to create character sheet: ${err.message}`);
604
+ }
605
+ }
606
+ );
607
+
382
608
  // ---- list_places ---------------------------------------------------------
383
609
  s.tool(
384
610
  "list_places",
@@ -404,6 +630,87 @@ function createMcpServer() {
404
630
  }
405
631
  );
406
632
 
633
+ // ---- create_place_sheet -------------------------------------------------
634
+ s.tool(
635
+ "create_place_sheet",
636
+ "Create a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately. Use this before migrating freeform notes into the new folder structure.",
637
+ {
638
+ name: z.string().describe("Display name of the place (e.g. 'University Hospital')."),
639
+ project_id: z.string().optional().describe("Project scope for a book-local place (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
640
+ universe_id: z.string().optional().describe("Universe scope for a cross-book shared place (e.g. 'universe-1')."),
641
+ notes: z.string().optional().describe("Optional starter prose content for sheet.md."),
642
+ fields: z.object({
643
+ associated_characters: z.array(z.string()).optional(),
644
+ tags: z.array(z.string()).optional(),
645
+ }).optional().describe("Optional starter metadata fields for the place sidecar."),
646
+ },
647
+ async ({ name, project_id, universe_id, notes, fields }) => {
648
+ if (!SYNC_DIR_WRITABLE) {
649
+ return errorResponse("READ_ONLY", "Cannot create place sheet: sync dir is read-only.");
650
+ }
651
+ if ((project_id && universe_id) || (!project_id && !universe_id)) {
652
+ return errorResponse("VALIDATION_ERROR", "Provide exactly one of project_id or universe_id.");
653
+ }
654
+
655
+ try {
656
+ const created = createCanonicalWorldEntity({
657
+ kind: "place",
658
+ name,
659
+ notes,
660
+ projectId: project_id,
661
+ universeId: universe_id,
662
+ meta: fields ?? {},
663
+ });
664
+
665
+ return jsonResponse({ ok: true, action: "created", kind: "place", ...created });
666
+ } catch (err) {
667
+ return errorResponse("IO_ERROR", `Failed to create place sheet: ${err.message}`);
668
+ }
669
+ }
670
+ );
671
+
672
+ // ---- get_place_sheet -----------------------------------------------------
673
+ s.tool(
674
+ "get_place_sheet",
675
+ "Get full place details: associated_characters, tags, the canonical sheet content, and any adjacent support notes when the place uses a folder-based layout. Use list_places first to get the place_id.",
676
+ {
677
+ place_id: z.string().describe("The place_id to look up (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
678
+ },
679
+ async ({ place_id }) => {
680
+ const place = db.prepare(`SELECT * FROM places WHERE place_id = ?`).get(place_id);
681
+ if (!place) {
682
+ return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
683
+ }
684
+
685
+ let notes = "";
686
+ let supportingNotes = [];
687
+ let associatedCharacters = [];
688
+ let tags = [];
689
+
690
+ if (place.file_path) {
691
+ try {
692
+ const raw = fs.readFileSync(place.file_path, "utf8");
693
+ const { content } = matter(raw);
694
+ notes = content.trim();
695
+ supportingNotes = readSupportingNotesForEntity(place.file_path);
696
+
697
+ const meta = readEntityMetadata(place.file_path);
698
+ associatedCharacters = Array.isArray(meta.associated_characters) ? meta.associated_characters : [];
699
+ tags = Array.isArray(meta.tags) ? meta.tags : [];
700
+ } catch { /* empty */ }
701
+ }
702
+
703
+ const result = {
704
+ ...place,
705
+ associated_characters: associatedCharacters.length ? associatedCharacters : undefined,
706
+ tags: tags.length ? tags : undefined,
707
+ notes: notes || undefined,
708
+ supporting_notes: supportingNotes.length ? supportingNotes : undefined,
709
+ };
710
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
711
+ }
712
+ );
713
+
407
714
  // ---- search_metadata -----------------------------------------------------
408
715
  s.tool(
409
716
  "search_metadata",
@@ -779,7 +1086,7 @@ function createMcpServer() {
779
1086
  {
780
1087
  scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
781
1088
  project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
782
- note: z.string().describe("The flag note (e.g. 'Victor knows Mira\'s name here, but they haven\'t been introduced yet contradicts sc-006')."),
1089
+ note: z.string().describe("The flag note (e.g. 'Victor knows Mira\u2019s name here, but they haven\u2019t been introduced yet \u2014 contradicts sc-006')."),
783
1090
  },
784
1091
  async ({ scene_id, project_id, note }) => {
785
1092
  if (!SYNC_DIR_WRITABLE) {
@@ -835,6 +1142,249 @@ function createMcpServer() {
835
1142
  }
836
1143
  );
837
1144
 
1145
+ // ---- PHASE 3: Prose Editing (git-backed) --------------------------------
1146
+
1147
+ // ---- propose_edit --------------------------------------------------------
1148
+ s.tool(
1149
+ "propose_edit",
1150
+ "Generate a proposed revision for a scene. Returns a proposal_id and a diff preview. Nothing is written yet — you must call commit_edit to apply the change. This tool requires git to be available.",
1151
+ {
1152
+ scene_id: z.string().describe("The scene_id to revise (e.g. 'sc-011-sebastian')."),
1153
+ instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
1154
+ revised_prose: z.string().describe("The complete revised prose text for the scene."),
1155
+ },
1156
+ async ({ scene_id, instruction, revised_prose }) => {
1157
+ if (!GIT_ENABLED) {
1158
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
1159
+ }
1160
+
1161
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
1162
+ if (!scene) {
1163
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
1164
+ }
1165
+
1166
+ try {
1167
+ // Read current prose
1168
+ const raw = fs.readFileSync(scene.file_path, "utf8");
1169
+ const { data: metadata, content: currentProse } = matter(raw);
1170
+
1171
+ // Generate a simple diff representation
1172
+ const currentLines = currentProse.trim().split("\n");
1173
+ const revisedLines = revised_prose.trim().split("\n");
1174
+ const diffLines = [];
1175
+ const maxLines = Math.max(currentLines.length, revisedLines.length);
1176
+
1177
+ // Simple line-by-line diff
1178
+ for (let i = 0; i < Math.min(3, maxLines); i++) {
1179
+ const curr = currentLines[i] || "(removed)";
1180
+ const rev = revisedLines[i] || "(removed)";
1181
+ if (curr !== rev) {
1182
+ diffLines.push(`- ${curr.substring(0, 80)}`);
1183
+ diffLines.push(`+ ${rev.substring(0, 80)}`);
1184
+ }
1185
+ }
1186
+ if (maxLines > 3) {
1187
+ diffLines.push(`... (${maxLines - 3} more lines)`);
1188
+ }
1189
+
1190
+ const proposalId = generateProposalId();
1191
+ pendingProposals.set(proposalId, {
1192
+ scene_id,
1193
+ scene_file_path: scene.file_path,
1194
+ instruction,
1195
+ revised_prose,
1196
+ original_prose: currentProse,
1197
+ metadata,
1198
+ created_at: new Date().toISOString(),
1199
+ });
1200
+
1201
+ const summary = {
1202
+ proposal_id: proposalId,
1203
+ scene_id,
1204
+ instruction,
1205
+ diff_preview: diffLines.join("\n"),
1206
+ note: "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
1207
+ };
1208
+
1209
+ return jsonResponse(summary);
1210
+ } catch (err) {
1211
+ if (err.code === "ENOENT") {
1212
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path.`, { indexed_path: scene.file_path });
1213
+ }
1214
+ return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
1215
+ }
1216
+ }
1217
+ );
1218
+
1219
+ // ---- commit_edit ---------------------------------------------------------
1220
+ s.tool(
1221
+ "commit_edit",
1222
+ "Apply a proposed edit and commit it to git. First creates a pre-edit snapshot, then writes the revised prose and metadata back to disk. The scene metadata stale flag is cleared.",
1223
+ {
1224
+ scene_id: z.string().describe("The scene_id being revised."),
1225
+ proposal_id: z.string().describe("The proposal_id returned by propose_edit."),
1226
+ },
1227
+ async ({ scene_id, proposal_id }) => {
1228
+ if (!GIT_ENABLED) {
1229
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported.");
1230
+ }
1231
+
1232
+ const proposal = pendingProposals.get(proposal_id);
1233
+ if (!proposal) {
1234
+ return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has expired.`);
1235
+ }
1236
+
1237
+ if (proposal.scene_id !== scene_id) {
1238
+ return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for scene '${proposal.scene_id}', not '${scene_id}'.`);
1239
+ }
1240
+
1241
+ try {
1242
+ // Reconstruct file content, preserving frontmatter only if the original had it
1243
+ const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
1244
+ const content = hasFrontmatter
1245
+ ? `---\n${yaml.dump(proposal.metadata)}---\n\n${proposal.revised_prose}\n`
1246
+ : `${proposal.revised_prose}\n`;
1247
+
1248
+ // Create pre-edit snapshot (commits current state before overwriting)
1249
+ const snapshot = createSnapshot(SYNC_DIR, proposal.scene_file_path, scene_id, proposal.instruction);
1250
+
1251
+ // Write the revised prose to disk
1252
+ fs.writeFileSync(proposal.scene_file_path, content, "utf8");
1253
+
1254
+ // Re-index using canonical metadata (sidecar takes precedence over inline frontmatter)
1255
+ const { meta: canonicalMeta } = readMeta(proposal.scene_file_path, SYNC_DIR, { writable: false });
1256
+ const { content: newProse } = matter(content);
1257
+ indexSceneFile(db, SYNC_DIR, proposal.scene_file_path, canonicalMeta, newProse);
1258
+
1259
+ // Clean up the proposal
1260
+ pendingProposals.delete(proposal_id);
1261
+
1262
+ const result = {
1263
+ ok: true,
1264
+ scene_id,
1265
+ proposal_id,
1266
+ snapshot_commit: snapshot.commit_hash,
1267
+ message: `Committed edit for scene '${scene_id}'${snapshot.commit_hash ? ` (snapshot: ${snapshot.commit_hash.substring(0, 7)})` : " (no changes to snapshot)"}`,
1268
+ };
1269
+
1270
+ return jsonResponse(result);
1271
+ } catch (err) {
1272
+ if (err.code === "ENOENT") {
1273
+ return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: proposal.scene_file_path });
1274
+ }
1275
+ return errorResponse("IO_ERROR", `Failed to commit edit: ${err.message}`);
1276
+ }
1277
+ }
1278
+ );
1279
+
1280
+ // ---- discard_edit --------------------------------------------------------
1281
+ s.tool(
1282
+ "discard_edit",
1283
+ "Discard a pending proposal without applying it. The proposal is deleted and the prose remains unchanged.",
1284
+ {
1285
+ proposal_id: z.string().describe("The proposal_id to discard (from propose_edit)."),
1286
+ },
1287
+ async ({ proposal_id }) => {
1288
+ const proposal = pendingProposals.get(proposal_id);
1289
+ if (!proposal) {
1290
+ return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has already been discarded.`);
1291
+ }
1292
+
1293
+ pendingProposals.delete(proposal_id);
1294
+ return jsonResponse({
1295
+ ok: true,
1296
+ proposal_id,
1297
+ message: `Discarded proposal '${proposal_id}' for scene '${proposal.scene_id}'.`,
1298
+ });
1299
+ }
1300
+ );
1301
+
1302
+ // ---- snapshot_scene -------------------------------------------------------
1303
+ s.tool(
1304
+ "snapshot_scene",
1305
+ "Manually create a git commit (snapshot) for the current state of a scene. Use this to mark important editing checkpoints outside of the propose/commit workflow.",
1306
+ {
1307
+ scene_id: z.string().describe("The scene_id to snapshot."),
1308
+ project_id: z.string().describe("Project the scene belongs to."),
1309
+ reason: z.string().describe("A brief reason for the snapshot (e.g. 'Character arc milestone reached')."),
1310
+ },
1311
+ async ({ scene_id, project_id, reason }) => {
1312
+ if (!GIT_ENABLED) {
1313
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be created.");
1314
+ }
1315
+
1316
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
1317
+ .get(scene_id, project_id);
1318
+ if (!scene) {
1319
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
1320
+ }
1321
+
1322
+ try {
1323
+ const snapshot = createSnapshot(SYNC_DIR, scene.file_path, scene_id, reason);
1324
+ if (!snapshot.commit_hash) {
1325
+ return jsonResponse({
1326
+ ok: true,
1327
+ scene_id,
1328
+ reason,
1329
+ message: "No changes to snapshot.",
1330
+ });
1331
+ }
1332
+
1333
+ return jsonResponse({
1334
+ ok: true,
1335
+ scene_id,
1336
+ reason,
1337
+ commit_hash: snapshot.commit_hash,
1338
+ message: `Created snapshot for scene '${scene_id}': ${reason}`,
1339
+ });
1340
+ } catch (err) {
1341
+ if (err.code === "ENOENT") {
1342
+ return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: scene.file_path });
1343
+ }
1344
+ return errorResponse("IO_ERROR", `Failed to create snapshot: ${err.message}`);
1345
+ }
1346
+ }
1347
+ );
1348
+
1349
+ // ---- list_snapshots -------------------------------------------------------
1350
+ s.tool(
1351
+ "list_snapshots",
1352
+ "List git commit history for a scene, with timestamps and commit messages. Use this to find commit hashes for get_scene_prose historical retrieval.",
1353
+ {
1354
+ scene_id: z.string().describe("The scene_id to list snapshots for."),
1355
+ },
1356
+ async ({ scene_id }) => {
1357
+ if (!GIT_ENABLED) {
1358
+ return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be retrieved.");
1359
+ }
1360
+
1361
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
1362
+ if (!scene) {
1363
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
1364
+ }
1365
+
1366
+ try {
1367
+ const snapshots = listSnapshots(SYNC_DIR, scene.file_path);
1368
+ if (!snapshots || snapshots.length === 0) {
1369
+ return errorResponse("NO_RESULTS", `No snapshots found for scene '${scene_id}'. Try editing and committing the scene first.`);
1370
+ }
1371
+
1372
+ return jsonResponse({
1373
+ scene_id,
1374
+ snapshots: snapshots.map(s => ({
1375
+ commit_hash: s.commit_hash,
1376
+ short_hash: s.commit_hash.substring(0, 7),
1377
+ timestamp: s.timestamp,
1378
+ message: s.message,
1379
+ })),
1380
+ note: "Use the commit_hash values with get_scene_prose(scene_id, commit) to retrieve a past version.",
1381
+ });
1382
+ } catch (err) {
1383
+ return errorResponse("IO_ERROR", `Failed to list snapshots: ${err.message}`);
1384
+ }
1385
+ }
1386
+ );
1387
+
838
1388
  return s;
839
1389
  }
840
1390
 
@@ -850,8 +1400,8 @@ const httpServer = http.createServer(async (req, res) => {
850
1400
 
851
1401
  const existing = activeSessions.get(sessionId);
852
1402
  if (existing) {
853
- try { await existing.transport.close(); } catch {}
854
- try { await existing.server.close(); } catch {}
1403
+ try { await existing.transport.close(); } catch { /* empty */ }
1404
+ try { await existing.server.close(); } catch { /* empty */ }
855
1405
  activeSessions.delete(sessionId);
856
1406
  }
857
1407