@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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",
@@ -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
- character_id: row.character_id,
14
- name: String(row.name).trim(),
15
- tokens: [...new Set(String(row.name).toLowerCase().split(/\s+/).filter(Boolean))],
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
- for (const token of row.tokens) {
22
- if (!token || token.length < 3) continue;
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.tokens.length > 1) {
39
- const pattern = row.tokens.map(escapeRegex).join("\\s+");
40
- const regex = new RegExp(`\\b${pattern}\\b`, "i");
41
- if (regex.test(prose)) {
42
- inferred.add(row.character_id);
43
- continue;
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.tokens) {
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(before_characters);
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 = [...afterSet];
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);