@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/CHANGELOG.md +87 -0
- package/README.md +126 -12
- package/git.js +190 -0
- package/index.js +563 -13
- package/metadata-lint.js +117 -4
- package/package.json +15 -3
- package/scripts/import.js +95 -166
- package/scripts/manual-validation.mjs +273 -0
- package/scripts/mcp-debug-client.mjs +43 -0
- package/scripts/new-world-entity.js +160 -0
- package/sync.js +43 -5
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
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
|
-
|
|
553
|
+
supportingNotes = readSupportingNotesForEntity(character.file_path);
|
|
554
|
+
} catch { /* empty */ }
|
|
375
555
|
}
|
|
376
556
|
|
|
377
|
-
const result = {
|
|
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\
|
|
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
|
|