@hanna84/mcp-writing 2.16.2 → 2.17.0
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 +10 -0
- package/package.json +1 -1
- package/src/sync/sync.js +127 -0
- package/src/tools/metadata.js +117 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,21 @@ 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.17.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.16.2...v2.17.0)
|
|
9
|
+
|
|
10
|
+
- feat(reference): persist explicit links to source metadata [`#161`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/161)
|
|
12
|
+
|
|
7
13
|
#### [v2.16.2](https://github.com/hannasdev/mcp-writing.git
|
|
8
14
|
/compare/v2.16.1...v2.16.2)
|
|
9
15
|
|
|
16
|
+
> 1 May 2026
|
|
17
|
+
|
|
10
18
|
- docs(prd): refresh reference-doc status and Phase 4C plan [`#160`](https://github.com/hannasdev/mcp-writing.git
|
|
11
19
|
/pull/160)
|
|
20
|
+
- Release 2.16.2 [`db02dcf`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/db02dcfe3d854ba2c79c072ab93f73d8ab69527c)
|
|
12
22
|
|
|
13
23
|
#### [v2.16.1](https://github.com/hannasdev/mcp-writing.git
|
|
14
24
|
/compare/v2.16.0...v2.16.1)
|
package/package.json
CHANGED
package/src/sync/sync.js
CHANGED
|
@@ -281,6 +281,50 @@ export function normalizeReferenceIdList(values) {
|
|
|
281
281
|
)];
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
function normalizeReferenceRelation(value, fallbackRelation) {
|
|
285
|
+
const normalized = String(value ?? fallbackRelation ?? "").trim().toLowerCase();
|
|
286
|
+
if (/^[a-z][a-z0-9_-]*$/.test(normalized)) return normalized;
|
|
287
|
+
return String(fallbackRelation ?? "related").trim().toLowerCase();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function normalizeReferenceLinkList(values, { defaultRelation = "related" } = {}) {
|
|
291
|
+
const rawValues = Array.isArray(values)
|
|
292
|
+
? values
|
|
293
|
+
: typeof values === "string"
|
|
294
|
+
? values.split(",")
|
|
295
|
+
: [];
|
|
296
|
+
|
|
297
|
+
const links = [];
|
|
298
|
+
for (const value of rawValues) {
|
|
299
|
+
if (typeof value === "string") {
|
|
300
|
+
const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
301
|
+
for (const targetDocId of parts) {
|
|
302
|
+
links.push({ targetDocId, relation: normalizeReferenceRelation(defaultRelation, defaultRelation) });
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!value || typeof value !== "object") continue;
|
|
308
|
+
const targetDocId = String(value.target_doc_id ?? value.doc_id ?? value.id ?? "").trim();
|
|
309
|
+
if (!targetDocId) continue;
|
|
310
|
+
links.push({
|
|
311
|
+
targetDocId,
|
|
312
|
+
relation: normalizeReferenceRelation(value.relation, defaultRelation),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const deduped = [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
for (const link of links) {
|
|
319
|
+
const key = `${link.targetDocId}::${link.relation}`;
|
|
320
|
+
if (seen.has(key)) continue;
|
|
321
|
+
seen.add(key);
|
|
322
|
+
deduped.push(link);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return deduped;
|
|
326
|
+
}
|
|
327
|
+
|
|
284
328
|
export function deriveReferenceSummary(meta = {}, content = "") {
|
|
285
329
|
if (typeof meta.summary === "string" && meta.summary.trim()) return meta.summary.trim();
|
|
286
330
|
|
|
@@ -340,6 +384,59 @@ function indexReferenceLinksForSource(db, {
|
|
|
340
384
|
}
|
|
341
385
|
}
|
|
342
386
|
|
|
387
|
+
function indexExplicitReferenceLinksForSource(db, {
|
|
388
|
+
sourceKind,
|
|
389
|
+
sourceProjectId = "",
|
|
390
|
+
sourceId,
|
|
391
|
+
links,
|
|
392
|
+
defaultRelation,
|
|
393
|
+
}) {
|
|
394
|
+
db.prepare(`
|
|
395
|
+
DELETE FROM reference_links
|
|
396
|
+
WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND origin = 'explicit'
|
|
397
|
+
`).run(sourceKind, sourceProjectId, sourceId);
|
|
398
|
+
|
|
399
|
+
const insertReferenceLink = db.prepare(`
|
|
400
|
+
INSERT OR IGNORE INTO reference_links (
|
|
401
|
+
source_kind, source_project_id, source_id, target_doc_id, relation, origin
|
|
402
|
+
) VALUES (?, ?, ?, ?, ?, 'explicit')
|
|
403
|
+
`);
|
|
404
|
+
|
|
405
|
+
for (const link of links) {
|
|
406
|
+
if (sourceKind === "reference" && sourceId === link.targetDocId) continue;
|
|
407
|
+
insertReferenceLink.run(
|
|
408
|
+
sourceKind,
|
|
409
|
+
sourceProjectId,
|
|
410
|
+
sourceId,
|
|
411
|
+
link.targetDocId,
|
|
412
|
+
normalizeReferenceRelation(link.relation, defaultRelation)
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function collectExplicitReferenceLinks(meta, fields, { defaultRelation }) {
|
|
418
|
+
const hasField = fields.some((field) => Object.prototype.hasOwnProperty.call(meta, field));
|
|
419
|
+
if (!hasField) {
|
|
420
|
+
return { hasField: false, links: [] };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const rawValues = [];
|
|
424
|
+
for (const field of fields) {
|
|
425
|
+
if (!Object.prototype.hasOwnProperty.call(meta, field)) continue;
|
|
426
|
+
const value = meta[field];
|
|
427
|
+
if (Array.isArray(value)) {
|
|
428
|
+
rawValues.push(...value);
|
|
429
|
+
} else if (value !== undefined && value !== null) {
|
|
430
|
+
rawValues.push(value);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
hasField: true,
|
|
436
|
+
links: normalizeReferenceLinkList(rawValues, { defaultRelation }),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
343
440
|
export function worldEntityKindForPath(syncDir, filePath) {
|
|
344
441
|
const rel = path.relative(syncDir, filePath);
|
|
345
442
|
if (rel.includes(`${path.sep}characters${path.sep}`) || rel.includes("/characters/")) return "character";
|
|
@@ -643,6 +740,11 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
|
|
|
643
740
|
const relatedReferenceIds = normalizeReferenceIdList(
|
|
644
741
|
meta.related_reference_ids ?? meta.related_references ?? meta.related_docs ?? meta.related
|
|
645
742
|
);
|
|
743
|
+
const explicitReferenceLinks = collectExplicitReferenceLinks(
|
|
744
|
+
meta,
|
|
745
|
+
["reference_links", "related_reference_links", "explicit_reference_links"],
|
|
746
|
+
{ defaultRelation: "related" }
|
|
747
|
+
);
|
|
646
748
|
|
|
647
749
|
db.prepare(`
|
|
648
750
|
INSERT INTO reference_docs (doc_id, project_id, universe_id, type, title, summary, file_path)
|
|
@@ -681,6 +783,16 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
|
|
|
681
783
|
tags.join(" ")
|
|
682
784
|
);
|
|
683
785
|
|
|
786
|
+
if (explicitReferenceLinks.hasField) {
|
|
787
|
+
indexExplicitReferenceLinksForSource(db, {
|
|
788
|
+
sourceKind: "reference",
|
|
789
|
+
sourceProjectId: project_id ?? "",
|
|
790
|
+
sourceId: docId,
|
|
791
|
+
links: explicitReferenceLinks.links,
|
|
792
|
+
defaultRelation: "related",
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
684
796
|
indexReferenceLinksForSource(db, {
|
|
685
797
|
sourceKind: "reference",
|
|
686
798
|
sourceProjectId: project_id ?? "",
|
|
@@ -798,6 +910,11 @@ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
|
|
|
798
910
|
export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
799
911
|
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
800
912
|
const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
|
|
913
|
+
const explicitSceneLinks = collectExplicitReferenceLinks(
|
|
914
|
+
meta,
|
|
915
|
+
["reference_links", "explicit_reference_links"],
|
|
916
|
+
{ defaultRelation: "informs" }
|
|
917
|
+
);
|
|
801
918
|
|
|
802
919
|
if (universe_id) {
|
|
803
920
|
db.prepare(`INSERT OR IGNORE INTO universes (universe_id, name) VALUES (?, ?)`).run(
|
|
@@ -922,6 +1039,16 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
|
922
1039
|
keywordTokens,
|
|
923
1040
|
);
|
|
924
1041
|
|
|
1042
|
+
if (explicitSceneLinks.hasField) {
|
|
1043
|
+
indexExplicitReferenceLinksForSource(db, {
|
|
1044
|
+
sourceKind: "scene",
|
|
1045
|
+
sourceProjectId: project_id ?? "",
|
|
1046
|
+
sourceId: meta.scene_id,
|
|
1047
|
+
links: explicitSceneLinks.links,
|
|
1048
|
+
defaultRelation: "informs",
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
925
1052
|
indexReferenceLinksForSource(db, {
|
|
926
1053
|
sourceKind: "scene",
|
|
927
1054
|
sourceProjectId: project_id ?? "",
|
package/src/tools/metadata.js
CHANGED
|
@@ -1,9 +1,79 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import matter from "gray-matter";
|
|
4
|
-
import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
|
|
4
|
+
import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath, normalizeReferenceLinkList } from "../sync/sync.js";
|
|
5
5
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
6
6
|
|
|
7
|
+
function upsertSerializedReferenceLinks(existing, targetDocId, relation, { defaultRelation }) {
|
|
8
|
+
const normalized = normalizeReferenceLinkList(existing ?? [], { defaultRelation });
|
|
9
|
+
const filtered = normalized.filter((entry) => entry.targetDocId !== targetDocId);
|
|
10
|
+
filtered.push({ targetDocId, relation });
|
|
11
|
+
return filtered.map((entry) => ({
|
|
12
|
+
target_doc_id: entry.targetDocId,
|
|
13
|
+
relation: entry.relation,
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function persistSceneReferenceLink({ scenePath, syncDir, targetDocId, relation }) {
|
|
18
|
+
const { meta } = readMeta(scenePath, syncDir, { writable: true });
|
|
19
|
+
const existingExplicit = [
|
|
20
|
+
...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
|
|
21
|
+
...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
|
|
22
|
+
];
|
|
23
|
+
const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
|
|
24
|
+
defaultRelation: "informs",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const nextMeta = {
|
|
28
|
+
...meta,
|
|
29
|
+
reference_links: nextReferenceLinks,
|
|
30
|
+
};
|
|
31
|
+
delete nextMeta.explicit_reference_links;
|
|
32
|
+
|
|
33
|
+
if (relation === "informs") {
|
|
34
|
+
const existingIds = Array.isArray(meta.reference_ids)
|
|
35
|
+
? meta.reference_ids
|
|
36
|
+
: typeof meta.reference_ids === "string"
|
|
37
|
+
? meta.reference_ids.split(",")
|
|
38
|
+
: [];
|
|
39
|
+
nextMeta.reference_ids = [...new Set([...existingIds.map((value) => String(value).trim()).filter(Boolean), targetDocId])];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
writeMeta(scenePath, nextMeta);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function persistReferenceDocLink({ filePath, targetDocId, relation }) {
|
|
46
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
47
|
+
const parsed = matter(raw);
|
|
48
|
+
const data = parsed.data ?? {};
|
|
49
|
+
const existingExplicit = [
|
|
50
|
+
...(Array.isArray(data.reference_links) ? data.reference_links : data.reference_links ? [data.reference_links] : []),
|
|
51
|
+
...(Array.isArray(data.related_reference_links) ? data.related_reference_links : data.related_reference_links ? [data.related_reference_links] : []),
|
|
52
|
+
...(Array.isArray(data.explicit_reference_links) ? data.explicit_reference_links : data.explicit_reference_links ? [data.explicit_reference_links] : []),
|
|
53
|
+
];
|
|
54
|
+
const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
|
|
55
|
+
defaultRelation: "related",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const nextData = {
|
|
59
|
+
...data,
|
|
60
|
+
reference_links: nextReferenceLinks,
|
|
61
|
+
};
|
|
62
|
+
delete nextData.related_reference_links;
|
|
63
|
+
delete nextData.explicit_reference_links;
|
|
64
|
+
|
|
65
|
+
if (relation === "related") {
|
|
66
|
+
const existingIds = Array.isArray(data.related_reference_ids)
|
|
67
|
+
? data.related_reference_ids
|
|
68
|
+
: typeof data.related_reference_ids === "string"
|
|
69
|
+
? data.related_reference_ids.split(",")
|
|
70
|
+
: [];
|
|
71
|
+
nextData.related_reference_ids = [...new Set([...existingIds.map((value) => String(value).trim()).filter(Boolean), targetDocId])];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fs.writeFileSync(filePath, matter.stringify(parsed.content, nextData), "utf8");
|
|
75
|
+
}
|
|
76
|
+
|
|
7
77
|
export function registerMetadataTools(s, {
|
|
8
78
|
db,
|
|
9
79
|
SYNC_DIR,
|
|
@@ -205,10 +275,12 @@ export function registerMetadataTools(s, {
|
|
|
205
275
|
}
|
|
206
276
|
|
|
207
277
|
let resolvedSourceProjectId;
|
|
278
|
+
let sourceScenePath = null;
|
|
279
|
+
let sourceReferencePath = null;
|
|
208
280
|
if (source_kind === "scene") {
|
|
209
281
|
if (source_project_id) {
|
|
210
282
|
const scene = db.prepare(`
|
|
211
|
-
SELECT scene_id, project_id
|
|
283
|
+
SELECT scene_id, project_id, file_path
|
|
212
284
|
FROM scenes
|
|
213
285
|
WHERE scene_id = ? AND project_id = ?
|
|
214
286
|
LIMIT 1
|
|
@@ -217,9 +289,10 @@ export function registerMetadataTools(s, {
|
|
|
217
289
|
return errorResponse("NOT_FOUND", `Scene '${source_id}' not found in project '${source_project_id}'.`);
|
|
218
290
|
}
|
|
219
291
|
resolvedSourceProjectId = scene.project_id ?? "";
|
|
292
|
+
sourceScenePath = scene.file_path;
|
|
220
293
|
} else {
|
|
221
294
|
const matches = db.prepare(`
|
|
222
|
-
SELECT scene_id, project_id
|
|
295
|
+
SELECT scene_id, project_id, file_path
|
|
223
296
|
FROM scenes
|
|
224
297
|
WHERE scene_id = ?
|
|
225
298
|
ORDER BY project_id
|
|
@@ -235,10 +308,11 @@ export function registerMetadataTools(s, {
|
|
|
235
308
|
);
|
|
236
309
|
}
|
|
237
310
|
resolvedSourceProjectId = matches[0].project_id ?? "";
|
|
311
|
+
sourceScenePath = matches[0].file_path;
|
|
238
312
|
}
|
|
239
313
|
} else {
|
|
240
314
|
const sourceDoc = db.prepare(`
|
|
241
|
-
SELECT doc_id, project_id
|
|
315
|
+
SELECT doc_id, project_id, file_path
|
|
242
316
|
FROM reference_docs
|
|
243
317
|
WHERE doc_id = ?
|
|
244
318
|
LIMIT 1
|
|
@@ -267,6 +341,45 @@ export function registerMetadataTools(s, {
|
|
|
267
341
|
}
|
|
268
342
|
);
|
|
269
343
|
}
|
|
344
|
+
sourceReferencePath = sourceDoc.file_path;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
if (source_kind === "scene") {
|
|
349
|
+
if (!sourceScenePath) {
|
|
350
|
+
return errorResponse("STALE_PATH", `Scene '${source_id}' has no indexed file path. Run sync() to refresh.`, {
|
|
351
|
+
source_id,
|
|
352
|
+
source_project_id: resolvedSourceProjectId,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
persistSceneReferenceLink({
|
|
356
|
+
scenePath: sourceScenePath,
|
|
357
|
+
syncDir: SYNC_DIR,
|
|
358
|
+
targetDocId: target_doc_id,
|
|
359
|
+
relation: normalizedRelation,
|
|
360
|
+
});
|
|
361
|
+
} else {
|
|
362
|
+
if (!sourceReferencePath) {
|
|
363
|
+
return errorResponse("STALE_PATH", `Reference doc '${source_id}' has no indexed file path. Run sync() to refresh.`, {
|
|
364
|
+
source_id,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
persistReferenceDocLink({
|
|
368
|
+
filePath: sourceReferencePath,
|
|
369
|
+
targetDocId: target_doc_id,
|
|
370
|
+
relation: normalizedRelation,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (err?.code === "ENOENT") {
|
|
375
|
+
const indexedPath = source_kind === "scene" ? sourceScenePath : sourceReferencePath;
|
|
376
|
+
return errorResponse(
|
|
377
|
+
"STALE_PATH",
|
|
378
|
+
`Source file for ${source_kind} '${source_id}' not found at indexed path — run sync() to refresh.`,
|
|
379
|
+
{ indexed_path: indexedPath }
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return errorResponse("IO_ERROR", `Failed to persist link metadata: ${err.message}`);
|
|
270
383
|
}
|
|
271
384
|
|
|
272
385
|
try {
|