@hanna84/mcp-writing 2.0.1 → 2.0.3
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/metadata-lint.js +21 -0
- package/package.json +1 -1
- package/scene-character-batch.js +128 -19
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.3](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.0.2...v2.0.3)
|
|
9
|
+
|
|
10
|
+
- fix(metadata-lint): warn on mixed scene character reference styles [`#81`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/81)
|
|
12
|
+
|
|
13
|
+
#### [v2.0.2](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.0.1...v2.0.2)
|
|
15
|
+
|
|
16
|
+
> 25 April 2026
|
|
17
|
+
|
|
18
|
+
- fix(scene-character-batch): tighten precision-first character linking [`#80`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/80)
|
|
20
|
+
- Release 2.0.2 [`0ca5898`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/0ca58982799b64f115681948b19b4dc795095951)
|
|
22
|
+
|
|
7
23
|
#### [v2.0.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v2.0.0...v2.0.1)
|
|
9
25
|
|
|
26
|
+
> 25 April 2026
|
|
27
|
+
|
|
10
28
|
- fix(package): include scene-character-batch in published files [`#79`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/79)
|
|
30
|
+
- Release 2.0.1 [`ec34e21`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/ec34e21c6c4d9472c937d5e549f55ddea4b57186)
|
|
12
32
|
|
|
13
33
|
### [v2.0.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.17.0...v2.0.0)
|
package/metadata-lint.js
CHANGED
|
@@ -119,6 +119,23 @@ function validateUniqueArrays(meta, kind, issues) {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function validateSceneCharacterReferenceStyle(meta, issues) {
|
|
123
|
+
if (!Array.isArray(meta.characters) || meta.characters.length === 0) return;
|
|
124
|
+
|
|
125
|
+
if (!meta.characters.every(value => typeof value === "string")) return;
|
|
126
|
+
|
|
127
|
+
const hasCanonicalIds = meta.characters.some(value => /^char-/.test(String(value).trim()));
|
|
128
|
+
const hasNonCanonicalEntries = meta.characters.some(value => !/^char-/.test(String(value).trim()));
|
|
129
|
+
|
|
130
|
+
if (!hasCanonicalIds || !hasNonCanonicalEntries) return;
|
|
131
|
+
|
|
132
|
+
issues.push({
|
|
133
|
+
level: "warning",
|
|
134
|
+
code: "MIXED_CHARACTER_REFERENCE_STYLE",
|
|
135
|
+
message: "Scene characters contain mixed canonical and non-canonical references. Prefer canonical character_id values only.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
export function validateMetadataObject(meta, { sourcePath, kindHint } = {}) {
|
|
123
140
|
const issues = [];
|
|
124
141
|
const kind = metadataKindSchema.parse(kindHint ?? detectMetadataKind(meta));
|
|
@@ -168,6 +185,10 @@ export function validateMetadataObject(meta, { sourcePath, kindHint } = {}) {
|
|
|
168
185
|
|
|
169
186
|
validateUniqueArrays(meta, kind, issues);
|
|
170
187
|
|
|
188
|
+
if (kind === "scene") {
|
|
189
|
+
validateSceneCharacterReferenceStyle(meta, issues);
|
|
190
|
+
}
|
|
191
|
+
|
|
171
192
|
if (kind === "scene" && sourcePath) {
|
|
172
193
|
const sidecar = sourcePath.endsWith(".meta.yaml");
|
|
173
194
|
if (sidecar && !meta.scene_id) {
|
package/package.json
CHANGED
package/scene-character-batch.js
CHANGED
|
@@ -2,50 +2,89 @@ import fs from "node:fs";
|
|
|
2
2
|
import matter from "gray-matter";
|
|
3
3
|
import { normalizeSceneMetaForPath, readMeta, writeMeta } from "./sync.js";
|
|
4
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
|
+
|
|
5
21
|
function escapeRegex(text) {
|
|
6
22
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
23
|
}
|
|
8
24
|
|
|
25
|
+
function isDistinctiveToken(token) {
|
|
26
|
+
return Boolean(token) && token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token);
|
|
27
|
+
}
|
|
28
|
+
|
|
9
29
|
function normalizeCharacterRows(rows) {
|
|
10
30
|
const clean = rows
|
|
11
31
|
.filter(row => row?.character_id && row?.name)
|
|
12
|
-
.map(row =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
})
|
|
17
46
|
.filter(row => row.name.length > 0);
|
|
18
47
|
|
|
19
48
|
const tokenMap = new Map();
|
|
49
|
+
const byId = new Map();
|
|
50
|
+
const nameMap = new Map();
|
|
20
51
|
for (const row of clean) {
|
|
21
|
-
|
|
22
|
-
|
|
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) {
|
|
23
60
|
const ids = tokenMap.get(token) ?? [];
|
|
24
61
|
ids.push(row.character_id);
|
|
25
62
|
tokenMap.set(token, ids);
|
|
26
63
|
}
|
|
27
64
|
}
|
|
28
65
|
|
|
29
|
-
return { clean, tokenMap };
|
|
66
|
+
return { clean, tokenMap, byId, nameMap };
|
|
30
67
|
}
|
|
31
68
|
|
|
32
69
|
function inferCharactersFromProse(prose, characterRows) {
|
|
33
70
|
const { clean, tokenMap } = characterRows;
|
|
34
71
|
const inferred = new Set();
|
|
72
|
+
const full_name_matches = new Set();
|
|
35
73
|
const ambiguous_tokens = [];
|
|
36
74
|
|
|
37
75
|
for (const row of clean) {
|
|
38
|
-
if (row.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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;
|
|
45
85
|
}
|
|
46
86
|
|
|
47
|
-
for (const token of row.
|
|
48
|
-
if (!token || token.length < 3) continue;
|
|
87
|
+
for (const token of row.informative_tokens) {
|
|
49
88
|
const tokenRegex = new RegExp(`\\b${escapeRegex(token)}\\b`, "i");
|
|
50
89
|
if (!tokenRegex.test(prose)) continue;
|
|
51
90
|
|
|
@@ -58,12 +97,73 @@ function inferCharactersFromProse(prose, characterRows) {
|
|
|
58
97
|
}
|
|
59
98
|
}
|
|
60
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
|
+
|
|
61
108
|
return {
|
|
62
109
|
inferred_characters: [...inferred],
|
|
110
|
+
full_name_matches: [...full_name_matches],
|
|
63
111
|
ambiguous_tokens,
|
|
64
112
|
};
|
|
65
113
|
}
|
|
66
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
|
+
|
|
67
167
|
function nextTurn() {
|
|
68
168
|
return new Promise(resolve => setImmediate(resolve));
|
|
69
169
|
}
|
|
@@ -125,10 +225,15 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
|
|
|
125
225
|
const { meta } = readMeta(scene.file_path, syncDir, { writable: !dry_run });
|
|
126
226
|
|
|
127
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
|
+
)];
|
|
128
233
|
const inference = inferCharactersFromProse(prose, normalizedCharacterRows);
|
|
129
234
|
const inferred_characters = inference.inferred_characters;
|
|
130
235
|
|
|
131
|
-
const afterSet = new Set(
|
|
236
|
+
const afterSet = new Set(normalized_before_characters);
|
|
132
237
|
if (replace_mode === "replace") {
|
|
133
238
|
afterSet.clear();
|
|
134
239
|
}
|
|
@@ -136,7 +241,11 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
|
|
|
136
241
|
afterSet.add(characterId);
|
|
137
242
|
}
|
|
138
243
|
|
|
139
|
-
const after_characters =
|
|
244
|
+
const after_characters = pruneLessSpecificCharacters(
|
|
245
|
+
[...afterSet],
|
|
246
|
+
inference.full_name_matches,
|
|
247
|
+
normalizedCharacterRows
|
|
248
|
+
);
|
|
140
249
|
const beforeSet = new Set(before_characters);
|
|
141
250
|
const added = after_characters.filter(id => !beforeSet.has(id));
|
|
142
251
|
const afterSetLookup = new Set(after_characters);
|