@hanna84/mcp-writing 2.9.6 → 2.9.8
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 +20 -0
- package/index.js +10 -963
- package/package.json +1 -1
- package/tools/editing.js +293 -0
- package/tools/styleguide.js +687 -0
package/package.json
CHANGED
package/tools/editing.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
import { createSnapshot, listSnapshots } from "../git.js";
|
|
6
|
+
import { getFileWriteDiagnostics, readMeta, indexSceneFile } from "../sync.js";
|
|
7
|
+
|
|
8
|
+
export function registerEditingTools(s, {
|
|
9
|
+
db,
|
|
10
|
+
SYNC_DIR,
|
|
11
|
+
GIT_ENABLED,
|
|
12
|
+
errorResponse,
|
|
13
|
+
jsonResponse,
|
|
14
|
+
pendingProposals,
|
|
15
|
+
generateProposalId,
|
|
16
|
+
}) {
|
|
17
|
+
// ---- propose_edit --------------------------------------------------------
|
|
18
|
+
s.tool(
|
|
19
|
+
"propose_edit",
|
|
20
|
+
"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.",
|
|
21
|
+
{
|
|
22
|
+
scene_id: z.string().describe("The scene_id to revise (e.g. 'sc-011-sebastian')."),
|
|
23
|
+
instruction: z.string().describe("A brief instruction for the edit (e.g. 'Tighten the opening paragraph'). Used in the git commit message."),
|
|
24
|
+
revised_prose: z.string().describe("The complete revised prose text for the scene."),
|
|
25
|
+
},
|
|
26
|
+
async ({ scene_id, instruction, revised_prose }) => {
|
|
27
|
+
if (!GIT_ENABLED) {
|
|
28
|
+
return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported. Ensure git is installed and the sync directory is writable.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
|
|
32
|
+
if (!scene) {
|
|
33
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Hint: call find_scenes to get valid scene IDs.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
38
|
+
const { data: metadata, content: currentProse } = matter(raw);
|
|
39
|
+
|
|
40
|
+
const currentLines = currentProse.trim().split("\n");
|
|
41
|
+
const revisedLines = revised_prose.trim().split("\n");
|
|
42
|
+
const diffLines = [];
|
|
43
|
+
const maxLines = Math.max(currentLines.length, revisedLines.length);
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < Math.min(3, maxLines); i++) {
|
|
46
|
+
const curr = currentLines[i] || "(removed)";
|
|
47
|
+
const rev = revisedLines[i] || "(removed)";
|
|
48
|
+
if (curr !== rev) {
|
|
49
|
+
diffLines.push(`- ${curr.substring(0, 80)}`);
|
|
50
|
+
diffLines.push(`+ ${rev.substring(0, 80)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (maxLines > 3) {
|
|
54
|
+
diffLines.push(`... (${maxLines - 3} more lines)`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const proposalId = generateProposalId();
|
|
58
|
+
pendingProposals.set(proposalId, {
|
|
59
|
+
scene_id,
|
|
60
|
+
scene_file_path: scene.file_path,
|
|
61
|
+
instruction,
|
|
62
|
+
revised_prose,
|
|
63
|
+
original_prose: currentProse,
|
|
64
|
+
metadata,
|
|
65
|
+
created_at: new Date().toISOString(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return jsonResponse({
|
|
69
|
+
proposal_id: proposalId,
|
|
70
|
+
scene_id,
|
|
71
|
+
instruction,
|
|
72
|
+
diff_preview: diffLines.join("\n"),
|
|
73
|
+
note: "Review the diff above. Call commit_edit with this proposal_id to apply the change.",
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.code === "ENOENT") {
|
|
77
|
+
return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path.`, { indexed_path: scene.file_path });
|
|
78
|
+
}
|
|
79
|
+
return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// ---- commit_edit ---------------------------------------------------------
|
|
85
|
+
s.tool(
|
|
86
|
+
"commit_edit",
|
|
87
|
+
"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.",
|
|
88
|
+
{
|
|
89
|
+
scene_id: z.string().describe("The scene_id being revised."),
|
|
90
|
+
proposal_id: z.string().describe("The proposal_id returned by propose_edit."),
|
|
91
|
+
},
|
|
92
|
+
async ({ scene_id, proposal_id }) => {
|
|
93
|
+
if (!GIT_ENABLED) {
|
|
94
|
+
return errorResponse("GIT_UNAVAILABLE", "Git is not available — prose editing is not supported.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const proposal = pendingProposals.get(proposal_id);
|
|
98
|
+
if (!proposal) {
|
|
99
|
+
return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has expired. Hint: call propose_edit again to create a fresh proposal_id.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (proposal.scene_id !== scene_id) {
|
|
103
|
+
return errorResponse("INVALID_EDIT", `Proposal '${proposal_id}' is for scene '${proposal.scene_id}', not '${scene_id}'.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
|
|
108
|
+
if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
|
|
109
|
+
return errorResponse(
|
|
110
|
+
"PROSE_FILE_NOT_WRITABLE",
|
|
111
|
+
"Scene prose file cannot be accessed by the current runtime user.",
|
|
112
|
+
{
|
|
113
|
+
indexed_path: proposal.scene_file_path,
|
|
114
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (proseWriteDiagnostics.stat_error_code && proseWriteDiagnostics.stat_error_code !== "ENOENT" && proseWriteDiagnostics.stat_error_code !== "ENOTDIR") {
|
|
120
|
+
return errorResponse(
|
|
121
|
+
"IO_ERROR",
|
|
122
|
+
"Failed to inspect scene prose path before writing.",
|
|
123
|
+
{
|
|
124
|
+
indexed_path: proposal.scene_file_path,
|
|
125
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!proseWriteDiagnostics.exists) {
|
|
131
|
+
return errorResponse("STALE_PATH", "Prose file not found at indexed path.", {
|
|
132
|
+
indexed_path: proposal.scene_file_path,
|
|
133
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!proseWriteDiagnostics.is_file) {
|
|
138
|
+
return errorResponse("INVALID_PROSE_PATH", "Indexed prose path is not a regular file.", {
|
|
139
|
+
indexed_path: proposal.scene_file_path,
|
|
140
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!proseWriteDiagnostics.writable) {
|
|
145
|
+
return errorResponse(
|
|
146
|
+
"PROSE_FILE_NOT_WRITABLE",
|
|
147
|
+
"Scene prose file is not writable by the current runtime user.",
|
|
148
|
+
{
|
|
149
|
+
indexed_path: proposal.scene_file_path,
|
|
150
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
|
|
156
|
+
const content = hasFrontmatter
|
|
157
|
+
? `---\n${yaml.dump(proposal.metadata)}---\n\n${proposal.revised_prose}\n`
|
|
158
|
+
: `${proposal.revised_prose}\n`;
|
|
159
|
+
|
|
160
|
+
const snapshot = createSnapshot(SYNC_DIR, proposal.scene_file_path, scene_id, proposal.instruction);
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(proposal.scene_file_path, content, "utf8");
|
|
163
|
+
|
|
164
|
+
const { meta: canonicalMeta } = readMeta(proposal.scene_file_path, SYNC_DIR, { writable: false });
|
|
165
|
+
const { content: newProse } = matter(content);
|
|
166
|
+
indexSceneFile(db, SYNC_DIR, proposal.scene_file_path, canonicalMeta, newProse);
|
|
167
|
+
|
|
168
|
+
pendingProposals.delete(proposal_id);
|
|
169
|
+
|
|
170
|
+
return jsonResponse({
|
|
171
|
+
ok: true,
|
|
172
|
+
scene_id,
|
|
173
|
+
proposal_id,
|
|
174
|
+
snapshot_commit: snapshot.commit_hash,
|
|
175
|
+
message: `Committed edit for scene '${scene_id}'${snapshot.commit_hash ? ` (snapshot: ${snapshot.commit_hash.substring(0, 7)})` : " (no changes to snapshot)"}`,
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err.code === "ENOENT") {
|
|
179
|
+
return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: proposal.scene_file_path });
|
|
180
|
+
}
|
|
181
|
+
return errorResponse("IO_ERROR", `Failed to commit edit: ${err.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// ---- discard_edit --------------------------------------------------------
|
|
187
|
+
s.tool(
|
|
188
|
+
"discard_edit",
|
|
189
|
+
"Discard a pending proposal without applying it. The proposal is deleted and the prose remains unchanged.",
|
|
190
|
+
{
|
|
191
|
+
proposal_id: z.string().describe("The proposal_id to discard (from propose_edit)."),
|
|
192
|
+
},
|
|
193
|
+
async ({ proposal_id }) => {
|
|
194
|
+
const proposal = pendingProposals.get(proposal_id);
|
|
195
|
+
if (!proposal) {
|
|
196
|
+
return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has already been discarded.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
pendingProposals.delete(proposal_id);
|
|
200
|
+
return jsonResponse({
|
|
201
|
+
ok: true,
|
|
202
|
+
proposal_id,
|
|
203
|
+
message: `Discarded proposal '${proposal_id}' for scene '${proposal.scene_id}'.`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// ---- snapshot_scene -------------------------------------------------------
|
|
209
|
+
s.tool(
|
|
210
|
+
"snapshot_scene",
|
|
211
|
+
"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.",
|
|
212
|
+
{
|
|
213
|
+
scene_id: z.string().describe("The scene_id to snapshot."),
|
|
214
|
+
project_id: z.string().describe("Project the scene belongs to."),
|
|
215
|
+
reason: z.string().describe("A brief reason for the snapshot (e.g. 'Character arc milestone reached')."),
|
|
216
|
+
},
|
|
217
|
+
async ({ scene_id, project_id, reason }) => {
|
|
218
|
+
if (!GIT_ENABLED) {
|
|
219
|
+
return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be created.");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
|
|
223
|
+
.get(scene_id, project_id);
|
|
224
|
+
if (!scene) {
|
|
225
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const snapshot = createSnapshot(SYNC_DIR, scene.file_path, scene_id, reason);
|
|
230
|
+
if (!snapshot.commit_hash) {
|
|
231
|
+
return jsonResponse({
|
|
232
|
+
ok: true,
|
|
233
|
+
scene_id,
|
|
234
|
+
reason,
|
|
235
|
+
message: "No changes to snapshot.",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return jsonResponse({
|
|
240
|
+
ok: true,
|
|
241
|
+
scene_id,
|
|
242
|
+
reason,
|
|
243
|
+
commit_hash: snapshot.commit_hash,
|
|
244
|
+
message: `Created snapshot for scene '${scene_id}': ${reason}`,
|
|
245
|
+
});
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err.code === "ENOENT") {
|
|
248
|
+
return errorResponse("STALE_PATH", `Prose file not found at indexed path.`, { indexed_path: scene.file_path });
|
|
249
|
+
}
|
|
250
|
+
return errorResponse("IO_ERROR", `Failed to create snapshot: ${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// ---- list_snapshots -------------------------------------------------------
|
|
256
|
+
s.tool(
|
|
257
|
+
"list_snapshots",
|
|
258
|
+
"List git commit history for a scene, with timestamps and commit messages. Use this to find commit hashes for get_scene_prose historical retrieval.",
|
|
259
|
+
{
|
|
260
|
+
scene_id: z.string().describe("The scene_id to list snapshots for."),
|
|
261
|
+
},
|
|
262
|
+
async ({ scene_id }) => {
|
|
263
|
+
if (!GIT_ENABLED) {
|
|
264
|
+
return errorResponse("GIT_UNAVAILABLE", "Git is not available — snapshots cannot be retrieved.");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
|
|
268
|
+
if (!scene) {
|
|
269
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const snapshots = listSnapshots(SYNC_DIR, scene.file_path);
|
|
274
|
+
if (!snapshots || snapshots.length === 0) {
|
|
275
|
+
return errorResponse("NO_RESULTS", `No snapshots found for scene '${scene_id}'. Try editing and committing the scene first.`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return jsonResponse({
|
|
279
|
+
scene_id,
|
|
280
|
+
snapshots: snapshots.map(s => ({
|
|
281
|
+
commit_hash: s.commit_hash,
|
|
282
|
+
short_hash: s.commit_hash.substring(0, 7),
|
|
283
|
+
timestamp: s.timestamp,
|
|
284
|
+
message: s.message,
|
|
285
|
+
})),
|
|
286
|
+
note: "Use the commit_hash values with get_scene_prose(scene_id, commit) to retrieve a past version.",
|
|
287
|
+
});
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return errorResponse("IO_ERROR", `Failed to list snapshots: ${err.message}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
}
|