@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 +20 -0
- package/package.json +1 -1
- package/src/core/db.js +105 -0
- package/src/sync/sync.js +348 -3
- package/src/tools/search.js +61 -0
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
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
|
|
654
|
-
// so that character name
|
|
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) {
|
package/src/tools/search.js
CHANGED
|
@@ -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",
|