@hanna84/mcp-writing 2.13.0 → 2.14.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 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.14.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.13.0...v2.14.0)
9
+
10
+ - feat(reference): add reference link indexing for Phase 4B [`#147`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/147)
12
+
7
13
  #### [v2.13.0](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.12.22...v2.13.0)
9
15
 
16
+ > 30 April 2026
17
+
10
18
  - feat: add reference document search [`#146`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/146)
20
+ - Release 2.13.0 [`0df673a`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/0df673a183f180a16cd5a9490c2829932b4aa744)
12
22
 
13
23
  #### [v2.12.22](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.12.21...v2.12.22)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
package/src/core/db.js CHANGED
@@ -118,6 +118,18 @@ export const SCHEMA = `
118
118
  PRIMARY KEY (doc_id, tag)
119
119
  );
120
120
 
121
+ CREATE TABLE IF NOT EXISTS reference_links (
122
+ source_kind TEXT NOT NULL,
123
+ source_project_id TEXT NOT NULL DEFAULT '',
124
+ source_id TEXT NOT NULL,
125
+ target_doc_id TEXT NOT NULL,
126
+ relation TEXT NOT NULL,
127
+ PRIMARY KEY (source_kind, source_project_id, source_id, target_doc_id, relation)
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_reference_links_target_doc_id
131
+ ON reference_links(target_doc_id);
132
+
121
133
  CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5(
122
134
  scene_id, project_id, logline, title, keywords
123
135
  );
@@ -196,6 +208,72 @@ const MIGRATIONS = [
196
208
  `);
197
209
  }
198
210
  },
211
+ // 4: add explicit reference links table
212
+ (db) => {
213
+ db.exec(`
214
+ CREATE TABLE IF NOT EXISTS reference_links (
215
+ source_kind TEXT NOT NULL,
216
+ source_project_id TEXT NOT NULL DEFAULT '',
217
+ source_id TEXT NOT NULL,
218
+ target_doc_id TEXT NOT NULL,
219
+ relation TEXT NOT NULL,
220
+ PRIMARY KEY (source_kind, source_project_id, source_id, target_doc_id, relation)
221
+ );
222
+ `);
223
+
224
+ db.exec(`
225
+ CREATE INDEX IF NOT EXISTS idx_reference_links_target_doc_id
226
+ ON reference_links(target_doc_id);
227
+ `);
228
+ },
229
+ // 5: ensure reference_links has project-scoped source key and target_doc_id index
230
+ (db) => {
231
+ const tables = db.prepare(`
232
+ SELECT name
233
+ FROM sqlite_master
234
+ WHERE type = 'table' AND name = 'reference_links'
235
+ `).all();
236
+
237
+ if (tables.length === 0) {
238
+ db.exec(`
239
+ CREATE TABLE reference_links (
240
+ source_kind TEXT NOT NULL,
241
+ source_project_id TEXT NOT NULL DEFAULT '',
242
+ source_id TEXT NOT NULL,
243
+ target_doc_id TEXT NOT NULL,
244
+ relation TEXT NOT NULL,
245
+ PRIMARY KEY (source_kind, source_project_id, source_id, target_doc_id, relation)
246
+ );
247
+ `);
248
+ } else {
249
+ const columns = db.prepare(`PRAGMA table_info(reference_links)`).all();
250
+ if (!columns.some(c => c.name === "source_project_id")) {
251
+ db.exec(`
252
+ CREATE TABLE reference_links_migrating (
253
+ source_kind TEXT NOT NULL,
254
+ source_project_id TEXT NOT NULL DEFAULT '',
255
+ source_id TEXT NOT NULL,
256
+ target_doc_id TEXT NOT NULL,
257
+ relation TEXT NOT NULL,
258
+ PRIMARY KEY (source_kind, source_project_id, source_id, target_doc_id, relation)
259
+ );
260
+ `);
261
+ db.exec(`
262
+ INSERT OR IGNORE INTO reference_links_migrating
263
+ (source_kind, source_project_id, source_id, target_doc_id, relation)
264
+ SELECT source_kind, '', source_id, target_doc_id, relation
265
+ FROM reference_links;
266
+ `);
267
+ db.exec(`DROP TABLE reference_links;`);
268
+ db.exec(`ALTER TABLE reference_links_migrating RENAME TO reference_links;`);
269
+ }
270
+ }
271
+
272
+ db.exec(`
273
+ CREATE INDEX IF NOT EXISTS idx_reference_links_target_doc_id
274
+ ON reference_links(target_doc_id);
275
+ `);
276
+ },
199
277
  ];
200
278
 
201
279
  // The version every database should reach after openDb. Not the current DB value —
package/src/sync/sync.js CHANGED
@@ -267,6 +267,20 @@ export function normalizeReferenceTags(tags) {
267
267
  )];
268
268
  }
269
269
 
270
+ export function normalizeReferenceIdList(values) {
271
+ const rawValues = Array.isArray(values)
272
+ ? values
273
+ : typeof values === "string"
274
+ ? values.split(",")
275
+ : [];
276
+
277
+ return [...new Set(
278
+ rawValues
279
+ .map(value => String(value).trim())
280
+ .filter(Boolean)
281
+ )];
282
+ }
283
+
270
284
  export function deriveReferenceSummary(meta = {}, content = "") {
271
285
  if (typeof meta.summary === "string" && meta.summary.trim()) return meta.summary.trim();
272
286
 
@@ -282,6 +296,30 @@ export function deriveReferenceSummary(meta = {}, content = "") {
282
296
  return body.length <= 240 ? body : `${body.slice(0, 237).trimEnd()}...`;
283
297
  }
284
298
 
299
+ function indexReferenceLinksForSource(db, {
300
+ sourceKind,
301
+ sourceProjectId = "",
302
+ sourceId,
303
+ targetDocIds,
304
+ relation,
305
+ }) {
306
+ db.prepare(`
307
+ DELETE FROM reference_links
308
+ WHERE source_kind = ? AND source_project_id = ? AND source_id = ?
309
+ `).run(sourceKind, sourceProjectId, sourceId);
310
+
311
+ const insertReferenceLink = db.prepare(`
312
+ INSERT OR IGNORE INTO reference_links
313
+ (source_kind, source_project_id, source_id, target_doc_id, relation)
314
+ VALUES (?, ?, ?, ?, ?)
315
+ `);
316
+
317
+ for (const targetDocId of targetDocIds) {
318
+ if (sourceKind === "reference" && sourceId === targetDocId) continue;
319
+ insertReferenceLink.run(sourceKind, sourceProjectId, sourceId, targetDocId, relation);
320
+ }
321
+ }
322
+
285
323
  export function worldEntityKindForPath(syncDir, filePath) {
286
324
  const rel = path.relative(syncDir, filePath);
287
325
  if (rel.includes(`${path.sep}characters${path.sep}`) || rel.includes("/characters/")) return "character";
@@ -582,6 +620,9 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
582
620
  const title = deriveReferenceTitle(file, meta, content);
583
621
  const summary = deriveReferenceSummary(meta, content);
584
622
  const tags = normalizeReferenceTags(meta.tags);
623
+ const relatedReferenceIds = normalizeReferenceIdList(
624
+ meta.related_reference_ids ?? meta.related_references ?? meta.related_docs ?? meta.related
625
+ );
585
626
 
586
627
  db.prepare(`
587
628
  INSERT INTO reference_docs (doc_id, project_id, universe_id, type, title, summary, file_path)
@@ -620,13 +661,26 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
620
661
  tags.join(" ")
621
662
  );
622
663
 
664
+ indexReferenceLinksForSource(db, {
665
+ sourceKind: "reference",
666
+ sourceProjectId: project_id ?? "",
667
+ sourceId: docId,
668
+ targetDocIds: relatedReferenceIds,
669
+ relation: "related",
670
+ });
671
+
623
672
  return docId;
624
673
  }
625
674
 
626
675
  function pruneMissingReferenceDocs(db, seenDocIds) {
627
- const rows = db.prepare(`SELECT doc_id FROM reference_docs`).all();
676
+ const rows = db.prepare(`SELECT doc_id, project_id FROM reference_docs`).all();
628
677
  for (const row of rows) {
629
678
  if (seenDocIds.has(row.doc_id)) continue;
679
+ db.prepare(`
680
+ DELETE FROM reference_links
681
+ WHERE source_kind = 'reference' AND source_project_id = ? AND source_id = ?
682
+ `).run(row.project_id ?? "", row.doc_id);
683
+ db.prepare(`DELETE FROM reference_links WHERE target_doc_id = ?`).run(row.doc_id);
630
684
  db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id = ?`).run(row.doc_id);
631
685
  db.prepare(`DELETE FROM reference_docs_fts WHERE doc_id = ?`).run(row.doc_id);
632
686
  db.prepare(`DELETE FROM reference_docs WHERE doc_id = ?`).run(row.doc_id);
@@ -651,8 +705,79 @@ function canPruneReferenceDocs(syncDir) {
651
705
  return hasBroadRootChild;
652
706
  }
653
707
 
708
+ function inferSceneProjectScopeFromSyncDir(syncDir) {
709
+ const parts = path.resolve(syncDir).split(path.sep).filter(Boolean);
710
+ if (parts.length < 2) return null;
711
+
712
+ const tail = parts.at(-1);
713
+ const parent = parts.at(-2);
714
+
715
+ if (parent === "projects" && tail) {
716
+ return tail;
717
+ }
718
+
719
+ if (tail === "scenes" && parts.length >= 3 && parts.at(-3) === "projects") {
720
+ return parts.at(-2);
721
+ }
722
+
723
+ if (parts.length >= 3 && parts.at(-3) === "universes") {
724
+ return `${parts.at(-2)}/${parts.at(-1)}`;
725
+ }
726
+
727
+ if (tail === "scenes" && parts.length >= 4 && parts.at(-4) === "universes") {
728
+ return `${parts.at(-3)}/${parts.at(-2)}`;
729
+ }
730
+
731
+ return null;
732
+ }
733
+
734
+ function canPruneScenes(syncDir) {
735
+ const resolvedSyncDir = path.resolve(syncDir);
736
+
737
+ if (inferSceneProjectScopeFromSyncDir(resolvedSyncDir)) {
738
+ return true;
739
+ }
740
+
741
+ const hasBroadRootChild = ["projects", "universes", "scenes"].some((name) => {
742
+ try {
743
+ return fs.statSync(path.join(resolvedSyncDir, name)).isDirectory();
744
+ } catch {
745
+ return false;
746
+ }
747
+ });
748
+
749
+ return hasBroadRootChild;
750
+ }
751
+
752
+ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
753
+ const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
754
+ const rows = projectScope
755
+ ? db.prepare(`SELECT scene_id, project_id FROM scenes WHERE project_id = ?`).all(projectScope)
756
+ : db.prepare(`SELECT scene_id, project_id FROM scenes`).all();
757
+
758
+ for (const row of rows) {
759
+ const key = `${row.scene_id}::${row.project_id}`;
760
+ if (seenSceneKeys.has(key)) continue;
761
+
762
+ db.prepare(`DELETE FROM scenes_fts WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
763
+ db.prepare(`
764
+ DELETE FROM reference_links
765
+ WHERE source_kind = 'scene' AND source_project_id = ? AND source_id = ?
766
+ `).run(row.project_id ?? "", row.scene_id);
767
+ db.prepare(`DELETE FROM scenes WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
768
+
769
+ const remainingScene = db.prepare(`SELECT 1 FROM scenes WHERE scene_id = ? LIMIT 1`).get(row.scene_id);
770
+ if (!remainingScene) {
771
+ db.prepare(`DELETE FROM scene_characters WHERE scene_id = ?`).run(row.scene_id);
772
+ db.prepare(`DELETE FROM scene_places WHERE scene_id = ?`).run(row.scene_id);
773
+ db.prepare(`DELETE FROM scene_tags WHERE scene_id = ?`).run(row.scene_id);
774
+ }
775
+ }
776
+ }
777
+
654
778
  export function indexSceneFile(db, syncDir, file, meta, prose) {
655
779
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
780
+ const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
656
781
 
657
782
  if (universe_id) {
658
783
  db.prepare(`INSERT OR IGNORE INTO universes (universe_id, name) VALUES (?, ?)`).run(
@@ -777,6 +902,14 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
777
902
  keywordTokens,
778
903
  );
779
904
 
905
+ indexReferenceLinksForSource(db, {
906
+ sourceKind: "scene",
907
+ sourceProjectId: project_id ?? "",
908
+ sourceId: meta.scene_id,
909
+ targetDocIds: referenceIds,
910
+ relation: "informs",
911
+ });
912
+
780
913
  return { isStale };
781
914
  }
782
915
 
@@ -826,8 +959,10 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
826
959
  let skipped = 0;
827
960
  let sidecarsMigrated = 0;
828
961
  const seenSceneIds = new Map(); // scene_id+project_id → file path, for duplicate detection
962
+ const seenSceneKeys = new Set();
829
963
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
830
964
  const indexedReferenceDocIds = new Set();
965
+ let sceneIndexFailures = 0;
831
966
  const warnings = [];
832
967
 
833
968
  const scanFiles = [];
@@ -896,6 +1031,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
896
1031
  } else {
897
1032
  seenSceneIds.set(key, file);
898
1033
  }
1034
+ seenSceneKeys.add(key);
899
1035
 
900
1036
  if (mismatches.part || mismatches.chapter) {
901
1037
  const details = [];
@@ -912,10 +1048,15 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
912
1048
  if (isStale) staleMarked++;
913
1049
  indexed++;
914
1050
  } catch (err) {
1051
+ sceneIndexFailures++;
915
1052
  process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
916
1053
  }
917
1054
  }
918
1055
 
1056
+ if (canPruneScenes(syncDir) && sceneIndexFailures === 0) {
1057
+ pruneMissingScenes(db, seenSceneKeys, syncDir);
1058
+ }
1059
+
919
1060
  // --- Orphaned sidecar detection ---
920
1061
  const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
921
1062
  for (const sidecar of sidecars) {