@hanna84/mcp-writing 2.0.0 → 2.0.2
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 +21 -1
- package/package.json +2 -1
- package/scene-character-batch.js +330 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,31 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
-
#### [v2.0.
|
|
7
|
+
#### [v2.0.2](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.0.1...v2.0.2)
|
|
9
|
+
|
|
10
|
+
- fix(scene-character-batch): tighten precision-first character linking [`#80`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/80)
|
|
12
|
+
|
|
13
|
+
#### [v2.0.1](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.0.0...v2.0.1)
|
|
15
|
+
|
|
16
|
+
> 25 April 2026
|
|
17
|
+
|
|
18
|
+
- fix(package): include scene-character-batch in published files [`#79`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/79)
|
|
20
|
+
- Release 2.0.1 [`ec34e21`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/ec34e21c6c4d9472c937d5e549f55ddea4b57186)
|
|
22
|
+
|
|
23
|
+
### [v2.0.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.17.0...v2.0.0)
|
|
9
25
|
|
|
26
|
+
> 25 April 2026
|
|
27
|
+
|
|
10
28
|
- feat(review-bundles)!: add PDF export via pdfkit [`#78`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/78)
|
|
30
|
+
- Release 2.0.0 [`ddccb28`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/ddccb2869c6787b0cb64897d067886d59f7cb6e6)
|
|
12
32
|
|
|
13
33
|
#### [v1.17.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.16.2...v1.17.0)
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
|
8
8
|
"index.js",
|
|
9
9
|
"async-progress.js",
|
|
10
|
+
"scene-character-batch.js",
|
|
10
11
|
"scrivener-direct.js",
|
|
11
12
|
"importer.js",
|
|
12
13
|
"db.js",
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import matter from "gray-matter";
|
|
3
|
+
import { normalizeSceneMetaForPath, readMeta, writeMeta } from "./sync.js";
|
|
4
|
+
|
|
5
|
+
const NON_DISTINCTIVE_TOKENS = new Set([
|
|
6
|
+
"the",
|
|
7
|
+
"and",
|
|
8
|
+
"for",
|
|
9
|
+
"with",
|
|
10
|
+
"from",
|
|
11
|
+
"into",
|
|
12
|
+
"onto",
|
|
13
|
+
"over",
|
|
14
|
+
"under",
|
|
15
|
+
"after",
|
|
16
|
+
"before",
|
|
17
|
+
"about",
|
|
18
|
+
"around",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function escapeRegex(text) {
|
|
22
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isDistinctiveToken(token) {
|
|
26
|
+
return Boolean(token) && token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeCharacterRows(rows) {
|
|
30
|
+
const clean = rows
|
|
31
|
+
.filter(row => row?.character_id && row?.name)
|
|
32
|
+
.map(row => {
|
|
33
|
+
const phrase_tokens = String(row.name).toLowerCase().split(/\s+/).filter(Boolean);
|
|
34
|
+
const tokens = [...new Set(phrase_tokens)];
|
|
35
|
+
return {
|
|
36
|
+
character_id: row.character_id,
|
|
37
|
+
name: String(row.name).trim(),
|
|
38
|
+
phrase_tokens,
|
|
39
|
+
tokens,
|
|
40
|
+
informative_tokens: tokens.filter(isDistinctiveToken),
|
|
41
|
+
full_name_regex: phrase_tokens.length > 1
|
|
42
|
+
? new RegExp(`\\b${phrase_tokens.map(escapeRegex).join("\\s+")}\\b`, "i")
|
|
43
|
+
: null,
|
|
44
|
+
};
|
|
45
|
+
})
|
|
46
|
+
.filter(row => row.name.length > 0);
|
|
47
|
+
|
|
48
|
+
const tokenMap = new Map();
|
|
49
|
+
const byId = new Map();
|
|
50
|
+
const nameMap = new Map();
|
|
51
|
+
for (const row of clean) {
|
|
52
|
+
byId.set(row.character_id, row);
|
|
53
|
+
|
|
54
|
+
const normalizedName = row.name.toLowerCase();
|
|
55
|
+
const exactNameIds = nameMap.get(normalizedName) ?? [];
|
|
56
|
+
exactNameIds.push(row.character_id);
|
|
57
|
+
nameMap.set(normalizedName, exactNameIds);
|
|
58
|
+
|
|
59
|
+
for (const token of row.informative_tokens) {
|
|
60
|
+
const ids = tokenMap.get(token) ?? [];
|
|
61
|
+
ids.push(row.character_id);
|
|
62
|
+
tokenMap.set(token, ids);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { clean, tokenMap, byId, nameMap };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inferCharactersFromProse(prose, characterRows) {
|
|
70
|
+
const { clean, tokenMap } = characterRows;
|
|
71
|
+
const inferred = new Set();
|
|
72
|
+
const full_name_matches = new Set();
|
|
73
|
+
const ambiguous_tokens = [];
|
|
74
|
+
|
|
75
|
+
for (const row of clean) {
|
|
76
|
+
if (row.full_name_regex?.test(prose)) {
|
|
77
|
+
inferred.add(row.character_id);
|
|
78
|
+
full_name_matches.add(row.character_id);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Precision-first v1 policy: multi-token names require a full phrase match.
|
|
83
|
+
if (row.phrase_tokens.length !== 1) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const token of row.informative_tokens) {
|
|
88
|
+
const tokenRegex = new RegExp(`\\b${escapeRegex(token)}\\b`, "i");
|
|
89
|
+
if (!tokenRegex.test(prose)) continue;
|
|
90
|
+
|
|
91
|
+
const tokenIds = tokenMap.get(token) ?? [];
|
|
92
|
+
if (tokenIds.length === 1) {
|
|
93
|
+
inferred.add(row.character_id);
|
|
94
|
+
} else if (!ambiguous_tokens.includes(token)) {
|
|
95
|
+
ambiguous_tokens.push(token);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const [token, tokenIds] of tokenMap.entries()) {
|
|
101
|
+
if (tokenIds.length < 2) continue;
|
|
102
|
+
const tokenRegex = new RegExp(`\\b${escapeRegex(token)}\\b`, "i");
|
|
103
|
+
if (tokenRegex.test(prose) && !ambiguous_tokens.includes(token)) {
|
|
104
|
+
ambiguous_tokens.push(token);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
inferred_characters: [...inferred],
|
|
110
|
+
full_name_matches: [...full_name_matches],
|
|
111
|
+
ambiguous_tokens,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveCharacterEntry(entry, characterRows) {
|
|
116
|
+
const value = String(entry ?? "").trim();
|
|
117
|
+
if (!value) return null;
|
|
118
|
+
|
|
119
|
+
if (characterRows.byId.has(value)) {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const exactNameIds = characterRows.nameMap.get(value.toLowerCase());
|
|
124
|
+
if (exactNameIds?.length === 1) {
|
|
125
|
+
return exactNameIds[0];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const words = value.toLowerCase().split(/\s+/).filter(isDistinctiveToken);
|
|
129
|
+
if (words.length === 0) {
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const matches = characterRows.clean.filter(row =>
|
|
134
|
+
words.every(word => row.informative_tokens.includes(word))
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (matches.length === 1) {
|
|
138
|
+
return matches[0].character_id;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pruneLessSpecificCharacters(characterIds, fullNameMatches, characterRows) {
|
|
145
|
+
const kept = new Set(characterIds);
|
|
146
|
+
|
|
147
|
+
for (const candidateId of [...kept]) {
|
|
148
|
+
const candidate = characterRows.byId.get(candidateId);
|
|
149
|
+
if (!candidate || candidate.informative_tokens.length < 2) continue;
|
|
150
|
+
|
|
151
|
+
for (const dominantId of fullNameMatches) {
|
|
152
|
+
if (candidateId === dominantId) continue;
|
|
153
|
+
const dominant = characterRows.byId.get(dominantId);
|
|
154
|
+
if (!dominant) continue;
|
|
155
|
+
if (candidate.informative_tokens.length >= dominant.informative_tokens.length) continue;
|
|
156
|
+
|
|
157
|
+
if (candidate.informative_tokens.every(token => dominant.informative_tokens.includes(token))) {
|
|
158
|
+
kept.delete(candidateId);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return [...kept];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function nextTurn() {
|
|
168
|
+
return new Promise(resolve => setImmediate(resolve));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getInterSceneDelayMs() {
|
|
172
|
+
const raw = Number(process.env.MCP_WRITING_SCENE_CHARACTER_BATCH_DELAY_MS ?? 0);
|
|
173
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function delay(ms) {
|
|
177
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function runSceneCharacterBatch({ syncDir, args, onProgress, shouldCancel }) {
|
|
181
|
+
const {
|
|
182
|
+
project_id,
|
|
183
|
+
dry_run = true,
|
|
184
|
+
replace_mode = "merge",
|
|
185
|
+
include_match_details = false,
|
|
186
|
+
project_exists = true,
|
|
187
|
+
target_scenes = [],
|
|
188
|
+
character_rows = [],
|
|
189
|
+
} = args;
|
|
190
|
+
|
|
191
|
+
const targetScenes = Array.isArray(target_scenes) ? target_scenes : [];
|
|
192
|
+
const characterRows = Array.isArray(character_rows) ? character_rows : [];
|
|
193
|
+
const normalizedCharacterRows = normalizeCharacterRows(characterRows);
|
|
194
|
+
|
|
195
|
+
const results = [];
|
|
196
|
+
let processed_scenes = 0;
|
|
197
|
+
let scenes_changed = 0;
|
|
198
|
+
let failed_scenes = 0;
|
|
199
|
+
let links_added = 0;
|
|
200
|
+
let links_removed = 0;
|
|
201
|
+
const interSceneDelayMs = getInterSceneDelayMs();
|
|
202
|
+
|
|
203
|
+
const emitProgress = () => {
|
|
204
|
+
if (typeof onProgress !== "function") return;
|
|
205
|
+
onProgress({
|
|
206
|
+
total_scenes: targetScenes.length,
|
|
207
|
+
processed_scenes,
|
|
208
|
+
scenes_changed,
|
|
209
|
+
failed_scenes,
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
emitProgress();
|
|
214
|
+
|
|
215
|
+
for (const scene of targetScenes) {
|
|
216
|
+
await nextTurn();
|
|
217
|
+
|
|
218
|
+
if (typeof shouldCancel === "function" && shouldCancel()) {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
224
|
+
const { content: prose } = matter(raw);
|
|
225
|
+
const { meta } = readMeta(scene.file_path, syncDir, { writable: !dry_run });
|
|
226
|
+
|
|
227
|
+
const before_characters = [...new Set((meta.characters ?? []).map(String).filter(Boolean))];
|
|
228
|
+
const normalized_before_characters = [...new Set(
|
|
229
|
+
before_characters
|
|
230
|
+
.map(character => resolveCharacterEntry(character, normalizedCharacterRows))
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
)];
|
|
233
|
+
const inference = inferCharactersFromProse(prose, normalizedCharacterRows);
|
|
234
|
+
const inferred_characters = inference.inferred_characters;
|
|
235
|
+
|
|
236
|
+
const afterSet = new Set(normalized_before_characters);
|
|
237
|
+
if (replace_mode === "replace") {
|
|
238
|
+
afterSet.clear();
|
|
239
|
+
}
|
|
240
|
+
for (const characterId of inferred_characters) {
|
|
241
|
+
afterSet.add(characterId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const after_characters = pruneLessSpecificCharacters(
|
|
245
|
+
[...afterSet],
|
|
246
|
+
inference.full_name_matches,
|
|
247
|
+
normalizedCharacterRows
|
|
248
|
+
);
|
|
249
|
+
const beforeSet = new Set(before_characters);
|
|
250
|
+
const added = after_characters.filter(id => !beforeSet.has(id));
|
|
251
|
+
const afterSetLookup = new Set(after_characters);
|
|
252
|
+
const removed = before_characters.filter(id => !afterSetLookup.has(id));
|
|
253
|
+
const changed = added.length > 0 || removed.length > 0;
|
|
254
|
+
|
|
255
|
+
if (!dry_run && changed) {
|
|
256
|
+
const updatedMeta = normalizeSceneMetaForPath(syncDir, scene.file_path, {
|
|
257
|
+
...meta,
|
|
258
|
+
characters: after_characters,
|
|
259
|
+
}).meta;
|
|
260
|
+
|
|
261
|
+
writeMeta(scene.file_path, updatedMeta);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
scenes_changed += changed ? 1 : 0;
|
|
265
|
+
links_added += added.length;
|
|
266
|
+
links_removed += removed.length;
|
|
267
|
+
|
|
268
|
+
const hasInferredMatches = inferred_characters.length > 0;
|
|
269
|
+
const sceneStatus = changed
|
|
270
|
+
? "changed"
|
|
271
|
+
: (!hasInferredMatches && inference.ambiguous_tokens.length > 0 ? "skipped_ambiguous" : "unchanged");
|
|
272
|
+
|
|
273
|
+
results.push({
|
|
274
|
+
scene_id: scene.scene_id,
|
|
275
|
+
file_path: scene.file_path,
|
|
276
|
+
before_characters,
|
|
277
|
+
inferred_characters,
|
|
278
|
+
after_characters,
|
|
279
|
+
added,
|
|
280
|
+
removed,
|
|
281
|
+
changed,
|
|
282
|
+
status: sceneStatus,
|
|
283
|
+
...(include_match_details ? { match_details: { ambiguous_tokens: inference.ambiguous_tokens } } : {}),
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
failed_scenes += 1;
|
|
287
|
+
results.push({
|
|
288
|
+
scene_id: scene.scene_id,
|
|
289
|
+
file_path: scene.file_path,
|
|
290
|
+
before_characters: [],
|
|
291
|
+
inferred_characters: [],
|
|
292
|
+
after_characters: [],
|
|
293
|
+
added: [],
|
|
294
|
+
removed: [],
|
|
295
|
+
changed: false,
|
|
296
|
+
status: "failed",
|
|
297
|
+
error: error instanceof Error ? error.message : String(error),
|
|
298
|
+
});
|
|
299
|
+
} finally {
|
|
300
|
+
processed_scenes += 1;
|
|
301
|
+
emitProgress();
|
|
302
|
+
if (interSceneDelayMs > 0) {
|
|
303
|
+
await delay(interSceneDelayMs);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const warnings = [];
|
|
309
|
+
if (failed_scenes > 0) {
|
|
310
|
+
warnings.push("PARTIAL_SUCCESS: one or more scenes failed to process.");
|
|
311
|
+
}
|
|
312
|
+
if (!project_exists && targetScenes.length === 0) {
|
|
313
|
+
warnings.push(`PROJECT_NOT_FOUND_WARNING: project '${project_id}' was not found; nothing to process.`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
cancelled: Boolean(typeof shouldCancel === "function" && shouldCancel() && processed_scenes < targetScenes.length),
|
|
319
|
+
project_id,
|
|
320
|
+
dry_run: Boolean(dry_run),
|
|
321
|
+
total_scenes: targetScenes.length,
|
|
322
|
+
processed_scenes,
|
|
323
|
+
scenes_changed,
|
|
324
|
+
failed_scenes,
|
|
325
|
+
links_added,
|
|
326
|
+
links_removed,
|
|
327
|
+
results,
|
|
328
|
+
...(warnings.length > 0 ? { warning: warnings.join(" ") } : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|