@hanna84/mcp-writing 2.12.22 → 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,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.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
+
13
+ #### [v2.13.0](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v2.12.22...v2.13.0)
15
+
16
+ > 30 April 2026
17
+
18
+ - feat: add reference document search [`#146`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/146)
20
+ - Release 2.13.0 [`0df673a`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/0df673a183f180a16cd5a9490c2829932b4aa744)
22
+
7
23
  #### [v2.12.22](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v2.12.21...v2.12.22)
9
25
 
26
+ > 30 April 2026
27
+
10
28
  - docs(prd): refine reference docs into reference graph model [`#143`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/143)
30
+ - Release 2.12.22 [`d363c63`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/d363c63ddcaf6de694f24d9ed66e2f703f439bf4)
12
32
 
13
33
  #### [v2.12.21](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v2.12.20...v2.12.21)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.12.22",
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
@@ -106,7 +106,9 @@ export const SCHEMA = `
106
106
  doc_id TEXT NOT NULL PRIMARY KEY,
107
107
  project_id TEXT,
108
108
  universe_id TEXT,
109
+ type TEXT,
109
110
  title TEXT NOT NULL,
111
+ summary TEXT,
110
112
  file_path TEXT NOT NULL
111
113
  );
112
114
 
@@ -116,10 +118,26 @@ export const SCHEMA = `
116
118
  PRIMARY KEY (doc_id, tag)
117
119
  );
118
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
+
119
133
  CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5(
120
134
  scene_id, project_id, logline, title, keywords
121
135
  );
122
136
 
137
+ CREATE VIRTUAL TABLE IF NOT EXISTS reference_docs_fts USING fts5(
138
+ doc_id, project_id, title, summary, tags
139
+ );
140
+
123
141
  CREATE TABLE IF NOT EXISTS schema_version (
124
142
  id INTEGER PRIMARY KEY CHECK (id = 1),
125
143
  version INTEGER NOT NULL
@@ -169,6 +187,93 @@ const MIGRATIONS = [
169
187
  db.exec(`ALTER TABLE scenes_fts_migrating RENAME TO scenes_fts;`);
170
188
  }
171
189
  },
190
+ // 3: add lightweight reference-doc metadata columns and FTS table
191
+ (db) => {
192
+ const referenceDocColumns = db.prepare(`PRAGMA table_info(reference_docs)`).all();
193
+ if (!referenceDocColumns.some(c => c.name === "type")) {
194
+ db.exec(`ALTER TABLE reference_docs ADD COLUMN type TEXT;`);
195
+ }
196
+ if (!referenceDocColumns.some(c => c.name === "summary")) {
197
+ db.exec(`ALTER TABLE reference_docs ADD COLUMN summary TEXT;`);
198
+ }
199
+
200
+ const ftsSql = db.prepare(`
201
+ SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'reference_docs_fts'
202
+ `).get()?.sql;
203
+ if (typeof ftsSql !== "string") {
204
+ db.exec(`
205
+ CREATE VIRTUAL TABLE reference_docs_fts USING fts5(
206
+ doc_id, project_id, title, summary, tags
207
+ );
208
+ `);
209
+ }
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
+ },
172
277
  ];
173
278
 
174
279
  // The version every database should reach after openDb. Not the current DB value —
package/src/sync/sync.js CHANGED
@@ -170,6 +170,156 @@ export function isWorldFile(syncDir, filePath) {
170
170
  return rel.includes(`${path.sep}world${path.sep}`) || rel.includes("/world/");
171
171
  }
172
172
 
173
+ export function isReferenceFile(syncDir, filePath) {
174
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/").toLowerCase();
175
+ return rel.startsWith("world/reference/") || rel.includes("/world/reference/") || rel.startsWith("notes/") || rel.includes("/notes/");
176
+ }
177
+
178
+ export function inferReferenceDocType(syncDir, filePath) {
179
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/").toLowerCase();
180
+ if (rel.startsWith("world/reference/") || rel.includes("/world/reference/")) return "world";
181
+ if (rel.startsWith("notes/continuity/") || rel.includes("/notes/continuity/")) return "continuity";
182
+ if (rel.startsWith("notes/research/") || rel.includes("/notes/research/")) return "research";
183
+ if (rel.startsWith("notes/style/") || rel.includes("/notes/style/")) return "style";
184
+ return "reference";
185
+ }
186
+
187
+ function inferReferenceScopeFromSyncDir(syncDir) {
188
+ const parts = path.resolve(syncDir).split(path.sep).filter(Boolean);
189
+ const projectSlug = parts.at(-1);
190
+ const parent = parts.at(-2);
191
+ const universeId = parts.at(-2);
192
+ const universeProjectSlug = parts.at(-1);
193
+ const universeMarker = parts.at(-3);
194
+
195
+ if (parent === "projects" && projectSlug) {
196
+ return { universe_id: null, project_id: projectSlug };
197
+ }
198
+
199
+ if (parent === "universes" && projectSlug) {
200
+ return { universe_id: projectSlug, project_id: null };
201
+ }
202
+
203
+ if (universeMarker === "universes" && universeId && universeProjectSlug) {
204
+ return { universe_id: universeId, project_id: `${universeId}/${universeProjectSlug}` };
205
+ }
206
+
207
+ return null;
208
+ }
209
+
210
+ function inferReferenceProjectAndUniverse(syncDir, filePath) {
211
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/").toLowerCase();
212
+ const scoped = inferReferenceScopeFromSyncDir(syncDir);
213
+ if (
214
+ scoped
215
+ && (rel.startsWith("world/reference/") || rel.startsWith("notes/"))
216
+ ) {
217
+ return scoped;
218
+ }
219
+
220
+ return inferProjectAndUniverse(syncDir, filePath);
221
+ }
222
+
223
+ function slugifyReferencePart(value) {
224
+ return String(value ?? "")
225
+ .toLowerCase()
226
+ .replace(/\.(md|txt)$/i, "")
227
+ .replace(/[^a-z0-9]+/g, "-")
228
+ .replace(/^-+|-+$/g, "");
229
+ }
230
+
231
+ export function deriveReferenceDocId(syncDir, filePath, meta = {}) {
232
+ if (typeof meta.doc_id === "string" && meta.doc_id.trim()) return meta.doc_id.trim();
233
+
234
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
235
+ const slug = rel
236
+ .split("/")
237
+ .map(slugifyReferencePart)
238
+ .filter(Boolean)
239
+ .join("-");
240
+ return `ref-${slug}`;
241
+ }
242
+
243
+ export function deriveReferenceTitle(filePath, meta = {}, content = "") {
244
+ if (typeof meta.title === "string" && meta.title.trim()) return meta.title.trim();
245
+
246
+ const heading = content.match(/^\s*#\s+(.+?)\s*$/m)?.[1]?.trim();
247
+ if (heading) return heading;
248
+
249
+ const base = path.basename(filePath, path.extname(filePath));
250
+ return base
251
+ .replace(/[-_]+/g, " ")
252
+ .replace(/\s+/g, " ")
253
+ .trim();
254
+ }
255
+
256
+ export function normalizeReferenceTags(tags) {
257
+ const values = Array.isArray(tags)
258
+ ? tags
259
+ : typeof tags === "string"
260
+ ? tags.split(",")
261
+ : [];
262
+
263
+ return [...new Set(
264
+ values
265
+ .map(tag => String(tag).trim())
266
+ .filter(Boolean)
267
+ )];
268
+ }
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
+
284
+ export function deriveReferenceSummary(meta = {}, content = "") {
285
+ if (typeof meta.summary === "string" && meta.summary.trim()) return meta.summary.trim();
286
+
287
+ const body = content
288
+ .split("\n")
289
+ .map(line => line.trim())
290
+ .filter(line => line && !line.startsWith("#"))
291
+ .join(" ")
292
+ .replace(/\s+/g, " ")
293
+ .trim();
294
+
295
+ if (!body) return null;
296
+ return body.length <= 240 ? body : `${body.slice(0, 237).trimEnd()}...`;
297
+ }
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
+
173
323
  export function worldEntityKindForPath(syncDir, filePath) {
174
324
  const rel = path.relative(syncDir, filePath);
175
325
  if (rel.includes(`${path.sep}characters${path.sep}`) || rel.includes("/characters/")) return "character";
@@ -463,8 +613,171 @@ export function indexWorldFile(db, syncDir, file, meta) {
463
613
  }
464
614
  }
465
615
 
616
+ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
617
+ const { universe_id, project_id } = inferReferenceProjectAndUniverse(syncDir, file);
618
+ const docId = deriveReferenceDocId(syncDir, file, meta);
619
+ const type = inferReferenceDocType(syncDir, file);
620
+ const title = deriveReferenceTitle(file, meta, content);
621
+ const summary = deriveReferenceSummary(meta, content);
622
+ const tags = normalizeReferenceTags(meta.tags);
623
+ const relatedReferenceIds = normalizeReferenceIdList(
624
+ meta.related_reference_ids ?? meta.related_references ?? meta.related_docs ?? meta.related
625
+ );
626
+
627
+ db.prepare(`
628
+ INSERT INTO reference_docs (doc_id, project_id, universe_id, type, title, summary, file_path)
629
+ VALUES (?, ?, ?, ?, ?, ?, ?)
630
+ ON CONFLICT (doc_id) DO UPDATE SET
631
+ project_id = excluded.project_id,
632
+ universe_id = excluded.universe_id,
633
+ type = excluded.type,
634
+ title = excluded.title,
635
+ summary = excluded.summary,
636
+ file_path = excluded.file_path
637
+ `).run(
638
+ docId,
639
+ project_id ?? null,
640
+ universe_id ?? null,
641
+ type,
642
+ title,
643
+ summary ?? null,
644
+ file
645
+ );
646
+
647
+ db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id = ?`).run(docId);
648
+ for (const tag of tags) {
649
+ db.prepare(`INSERT OR IGNORE INTO reference_doc_tags (doc_id, tag) VALUES (?, ?)`).run(docId, tag);
650
+ }
651
+
652
+ db.prepare(`DELETE FROM reference_docs_fts WHERE doc_id = ?`).run(docId);
653
+ db.prepare(`
654
+ INSERT INTO reference_docs_fts (doc_id, project_id, title, summary, tags)
655
+ VALUES (?, ?, ?, ?, ?)
656
+ `).run(
657
+ docId,
658
+ project_id ?? "",
659
+ title,
660
+ summary ?? "",
661
+ tags.join(" ")
662
+ );
663
+
664
+ indexReferenceLinksForSource(db, {
665
+ sourceKind: "reference",
666
+ sourceProjectId: project_id ?? "",
667
+ sourceId: docId,
668
+ targetDocIds: relatedReferenceIds,
669
+ relation: "related",
670
+ });
671
+
672
+ return docId;
673
+ }
674
+
675
+ function pruneMissingReferenceDocs(db, seenDocIds) {
676
+ const rows = db.prepare(`SELECT doc_id, project_id FROM reference_docs`).all();
677
+ for (const row of rows) {
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);
684
+ db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id = ?`).run(row.doc_id);
685
+ db.prepare(`DELETE FROM reference_docs_fts WHERE doc_id = ?`).run(row.doc_id);
686
+ db.prepare(`DELETE FROM reference_docs WHERE doc_id = ?`).run(row.doc_id);
687
+ }
688
+ }
689
+
690
+ function canPruneReferenceDocs(syncDir) {
691
+ const resolvedSyncDir = path.resolve(syncDir);
692
+ const scopedRoot = inferReferenceScopeFromSyncDir(resolvedSyncDir);
693
+ if (scopedRoot) return true;
694
+
695
+ // Flat project roots and broad workspace roots can safely prune because
696
+ // they can observe the full set of reference docs in their scope.
697
+ const hasBroadRootChild = ["projects", "universes", "scenes"].some((name) => {
698
+ try {
699
+ return fs.statSync(path.join(resolvedSyncDir, name)).isDirectory();
700
+ } catch {
701
+ return false;
702
+ }
703
+ });
704
+
705
+ return hasBroadRootChild;
706
+ }
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
+
466
778
  export function indexSceneFile(db, syncDir, file, meta, prose) {
467
779
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
780
+ const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
468
781
 
469
782
  if (universe_id) {
470
783
  db.prepare(`INSERT OR IGNORE INTO universes (universe_id, name) VALUES (?, ?)`).run(
@@ -589,6 +902,14 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
589
902
  keywordTokens,
590
903
  );
591
904
 
905
+ indexReferenceLinksForSource(db, {
906
+ sourceKind: "scene",
907
+ sourceProjectId: project_id ?? "",
908
+ sourceId: meta.scene_id,
909
+ targetDocIds: referenceIds,
910
+ relation: "informs",
911
+ });
912
+
592
913
  return { isStale };
593
914
  }
594
915
 
@@ -638,7 +959,10 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
638
959
  let skipped = 0;
639
960
  let sidecarsMigrated = 0;
640
961
  const seenSceneIds = new Map(); // scene_id+project_id → file path, for duplicate detection
962
+ const seenSceneKeys = new Set();
641
963
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
964
+ const indexedReferenceDocIds = new Set();
965
+ let sceneIndexFailures = 0;
642
966
  const warnings = [];
643
967
 
644
968
  const scanFiles = [];
@@ -650,9 +974,20 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
650
974
  scanFiles.push(file);
651
975
  }
652
976
 
653
- // --- Pass 1: world files (characters/places must be indexed before scenes
654
- // so that character name ID resolution in scene_characters works) ---
977
+ // --- Pass 1: world files and reference docs (characters/places must be indexed
978
+ // before scenes so that character name -> ID resolution in scene_characters works) ---
655
979
  for (const file of scanFiles) {
980
+ if (isReferenceFile(syncDir, file)) {
981
+ try {
982
+ const { data, content } = parseFile(file);
983
+ const docId = indexReferenceFile(db, syncDir, file, data, content);
984
+ indexedReferenceDocIds.add(docId);
985
+ } catch (err) {
986
+ process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
987
+ }
988
+ continue;
989
+ }
990
+
656
991
  if (!isWorldFile(syncDir, file)) continue;
657
992
  try {
658
993
  const { meta } = readMeta(file, syncDir, { writable });
@@ -667,9 +1002,13 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
667
1002
  }
668
1003
  }
669
1004
 
1005
+ if (canPruneReferenceDocs(syncDir)) {
1006
+ pruneMissingReferenceDocs(db, indexedReferenceDocIds);
1007
+ }
1008
+
670
1009
  // --- Pass 2: scene files ---
671
1010
  for (const file of scanFiles) {
672
- if (isWorldFile(syncDir, file)) continue;
1011
+ if (isWorldFile(syncDir, file) || isReferenceFile(syncDir, file)) continue;
673
1012
  try {
674
1013
  const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
675
1014
  if (sidecarGenerated) sidecarsMigrated++;
@@ -692,6 +1031,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
692
1031
  } else {
693
1032
  seenSceneIds.set(key, file);
694
1033
  }
1034
+ seenSceneKeys.add(key);
695
1035
 
696
1036
  if (mismatches.part || mismatches.chapter) {
697
1037
  const details = [];
@@ -708,10 +1048,15 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
708
1048
  if (isStale) staleMarked++;
709
1049
  indexed++;
710
1050
  } catch (err) {
1051
+ sceneIndexFailures++;
711
1052
  process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
712
1053
  }
713
1054
  }
714
1055
 
1056
+ if (canPruneScenes(syncDir) && sceneIndexFailures === 0) {
1057
+ pruneMissingScenes(db, seenSceneKeys, syncDir);
1058
+ }
1059
+
715
1060
  // --- Orphaned sidecar detection ---
716
1061
  const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
717
1062
  for (const sidecar of sidecars) {
@@ -424,6 +424,67 @@ export function registerSearchTools(s, {
424
424
  }
425
425
  );
426
426
 
427
+ // ---- search_reference ----------------------------------------------------
428
+ s.tool(
429
+ "search_reference",
430
+ "Full-text search across indexed reference document titles, summaries, and tags. Use this to discover world-building notes, continuity references, research docs, and other reference material without loading full file contents.",
431
+ {
432
+ query: z.string().describe("Search terms (e.g. 'vampirism' or 'blood replacement'). FTS5 syntax supported."),
433
+ type: z.string().optional().describe("Optional reference type filter (for example: 'world', 'continuity', 'research', 'style')."),
434
+ tag: z.string().optional().describe("Optional exact tag filter."),
435
+ },
436
+ async ({ query, type, tag }) => {
437
+ let matchRows;
438
+ try {
439
+ matchRows = db.prepare(`
440
+ SELECT doc_id, rank
441
+ FROM reference_docs_fts
442
+ WHERE reference_docs_fts MATCH ?
443
+ ORDER BY rank
444
+ `).all(query);
445
+ } catch (err) {
446
+ return errorResponse("INVALID_QUERY", "Invalid reference search query syntax. Use plain keywords or quoted phrases.", { detail: err.message });
447
+ }
448
+
449
+ if (matchRows.length === 0) {
450
+ return errorResponse("NO_RESULTS", "No reference documents matched the search query.");
451
+ }
452
+
453
+ const rows = [];
454
+ const docStmt = db.prepare(`
455
+ SELECT doc_id, project_id, universe_id, type, title, summary, file_path
456
+ FROM reference_docs
457
+ WHERE doc_id = ?
458
+ `);
459
+ const tagsStmt = db.prepare(`
460
+ SELECT tag
461
+ FROM reference_doc_tags
462
+ WHERE doc_id = ?
463
+ ORDER BY tag
464
+ `);
465
+
466
+ for (const match of matchRows) {
467
+ const doc = docStmt.get(match.doc_id);
468
+ if (!doc) continue;
469
+
470
+ const tags = tagsStmt.all(match.doc_id).map(row => row.tag);
471
+ if (type && doc.type !== type) continue;
472
+ if (tag && !tags.includes(tag)) continue;
473
+
474
+ rows.push({
475
+ ...doc,
476
+ tags,
477
+ });
478
+ }
479
+
480
+ if (rows.length === 0) {
481
+ return errorResponse("NO_RESULTS", "No reference documents matched the provided filters.");
482
+ }
483
+
484
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
485
+ }
486
+ );
487
+
427
488
  // ---- list_threads --------------------------------------------------------
428
489
  s.tool(
429
490
  "list_threads",