@hanna84/mcp-writing 2.12.22 → 2.13.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/core/db.js +27 -0
- package/src/sync/sync.js +207 -3
- package/src/tools/search.js +61 -0
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.13.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.12.22...v2.13.0)
|
|
9
|
+
|
|
10
|
+
- feat: add reference document search [`#146`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/146)
|
|
12
|
+
|
|
7
13
|
#### [v2.12.22](https://github.com/hannasdev/mcp-writing.git
|
|
8
14
|
/compare/v2.12.21...v2.12.22)
|
|
9
15
|
|
|
16
|
+
> 30 April 2026
|
|
17
|
+
|
|
10
18
|
- docs(prd): refine reference docs into reference graph model [`#143`](https://github.com/hannasdev/mcp-writing.git
|
|
11
19
|
/pull/143)
|
|
20
|
+
- Release 2.12.22 [`d363c63`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/d363c63ddcaf6de694f24d9ed66e2f703f439bf4)
|
|
12
22
|
|
|
13
23
|
#### [v2.12.21](https://github.com/hannasdev/mcp-writing.git
|
|
14
24
|
/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
|
|
|
@@ -120,6 +122,10 @@ export const SCHEMA = `
|
|
|
120
122
|
scene_id, project_id, logline, title, keywords
|
|
121
123
|
);
|
|
122
124
|
|
|
125
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS reference_docs_fts USING fts5(
|
|
126
|
+
doc_id, project_id, title, summary, tags
|
|
127
|
+
);
|
|
128
|
+
|
|
123
129
|
CREATE TABLE IF NOT EXISTS schema_version (
|
|
124
130
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
125
131
|
version INTEGER NOT NULL
|
|
@@ -169,6 +175,27 @@ const MIGRATIONS = [
|
|
|
169
175
|
db.exec(`ALTER TABLE scenes_fts_migrating RENAME TO scenes_fts;`);
|
|
170
176
|
}
|
|
171
177
|
},
|
|
178
|
+
// 3: add lightweight reference-doc metadata columns and FTS table
|
|
179
|
+
(db) => {
|
|
180
|
+
const referenceDocColumns = db.prepare(`PRAGMA table_info(reference_docs)`).all();
|
|
181
|
+
if (!referenceDocColumns.some(c => c.name === "type")) {
|
|
182
|
+
db.exec(`ALTER TABLE reference_docs ADD COLUMN type TEXT;`);
|
|
183
|
+
}
|
|
184
|
+
if (!referenceDocColumns.some(c => c.name === "summary")) {
|
|
185
|
+
db.exec(`ALTER TABLE reference_docs ADD COLUMN summary TEXT;`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ftsSql = db.prepare(`
|
|
189
|
+
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'reference_docs_fts'
|
|
190
|
+
`).get()?.sql;
|
|
191
|
+
if (typeof ftsSql !== "string") {
|
|
192
|
+
db.exec(`
|
|
193
|
+
CREATE VIRTUAL TABLE reference_docs_fts USING fts5(
|
|
194
|
+
doc_id, project_id, title, summary, tags
|
|
195
|
+
);
|
|
196
|
+
`);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
172
199
|
];
|
|
173
200
|
|
|
174
201
|
// The version every database should reach after openDb. Not the current DB value —
|
package/src/sync/sync.js
CHANGED
|
@@ -170,6 +170,118 @@ 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 deriveReferenceSummary(meta = {}, content = "") {
|
|
271
|
+
if (typeof meta.summary === "string" && meta.summary.trim()) return meta.summary.trim();
|
|
272
|
+
|
|
273
|
+
const body = content
|
|
274
|
+
.split("\n")
|
|
275
|
+
.map(line => line.trim())
|
|
276
|
+
.filter(line => line && !line.startsWith("#"))
|
|
277
|
+
.join(" ")
|
|
278
|
+
.replace(/\s+/g, " ")
|
|
279
|
+
.trim();
|
|
280
|
+
|
|
281
|
+
if (!body) return null;
|
|
282
|
+
return body.length <= 240 ? body : `${body.slice(0, 237).trimEnd()}...`;
|
|
283
|
+
}
|
|
284
|
+
|
|
173
285
|
export function worldEntityKindForPath(syncDir, filePath) {
|
|
174
286
|
const rel = path.relative(syncDir, filePath);
|
|
175
287
|
if (rel.includes(`${path.sep}characters${path.sep}`) || rel.includes("/characters/")) return "character";
|
|
@@ -463,6 +575,82 @@ export function indexWorldFile(db, syncDir, file, meta) {
|
|
|
463
575
|
}
|
|
464
576
|
}
|
|
465
577
|
|
|
578
|
+
export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
|
|
579
|
+
const { universe_id, project_id } = inferReferenceProjectAndUniverse(syncDir, file);
|
|
580
|
+
const docId = deriveReferenceDocId(syncDir, file, meta);
|
|
581
|
+
const type = inferReferenceDocType(syncDir, file);
|
|
582
|
+
const title = deriveReferenceTitle(file, meta, content);
|
|
583
|
+
const summary = deriveReferenceSummary(meta, content);
|
|
584
|
+
const tags = normalizeReferenceTags(meta.tags);
|
|
585
|
+
|
|
586
|
+
db.prepare(`
|
|
587
|
+
INSERT INTO reference_docs (doc_id, project_id, universe_id, type, title, summary, file_path)
|
|
588
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
589
|
+
ON CONFLICT (doc_id) DO UPDATE SET
|
|
590
|
+
project_id = excluded.project_id,
|
|
591
|
+
universe_id = excluded.universe_id,
|
|
592
|
+
type = excluded.type,
|
|
593
|
+
title = excluded.title,
|
|
594
|
+
summary = excluded.summary,
|
|
595
|
+
file_path = excluded.file_path
|
|
596
|
+
`).run(
|
|
597
|
+
docId,
|
|
598
|
+
project_id ?? null,
|
|
599
|
+
universe_id ?? null,
|
|
600
|
+
type,
|
|
601
|
+
title,
|
|
602
|
+
summary ?? null,
|
|
603
|
+
file
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id = ?`).run(docId);
|
|
607
|
+
for (const tag of tags) {
|
|
608
|
+
db.prepare(`INSERT OR IGNORE INTO reference_doc_tags (doc_id, tag) VALUES (?, ?)`).run(docId, tag);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
db.prepare(`DELETE FROM reference_docs_fts WHERE doc_id = ?`).run(docId);
|
|
612
|
+
db.prepare(`
|
|
613
|
+
INSERT INTO reference_docs_fts (doc_id, project_id, title, summary, tags)
|
|
614
|
+
VALUES (?, ?, ?, ?, ?)
|
|
615
|
+
`).run(
|
|
616
|
+
docId,
|
|
617
|
+
project_id ?? "",
|
|
618
|
+
title,
|
|
619
|
+
summary ?? "",
|
|
620
|
+
tags.join(" ")
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
return docId;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function pruneMissingReferenceDocs(db, seenDocIds) {
|
|
627
|
+
const rows = db.prepare(`SELECT doc_id FROM reference_docs`).all();
|
|
628
|
+
for (const row of rows) {
|
|
629
|
+
if (seenDocIds.has(row.doc_id)) continue;
|
|
630
|
+
db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id = ?`).run(row.doc_id);
|
|
631
|
+
db.prepare(`DELETE FROM reference_docs_fts WHERE doc_id = ?`).run(row.doc_id);
|
|
632
|
+
db.prepare(`DELETE FROM reference_docs WHERE doc_id = ?`).run(row.doc_id);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function canPruneReferenceDocs(syncDir) {
|
|
637
|
+
const resolvedSyncDir = path.resolve(syncDir);
|
|
638
|
+
const scopedRoot = inferReferenceScopeFromSyncDir(resolvedSyncDir);
|
|
639
|
+
if (scopedRoot) return true;
|
|
640
|
+
|
|
641
|
+
// Flat project roots and broad workspace roots can safely prune because
|
|
642
|
+
// they can observe the full set of reference docs in their scope.
|
|
643
|
+
const hasBroadRootChild = ["projects", "universes", "scenes"].some((name) => {
|
|
644
|
+
try {
|
|
645
|
+
return fs.statSync(path.join(resolvedSyncDir, name)).isDirectory();
|
|
646
|
+
} catch {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
return hasBroadRootChild;
|
|
652
|
+
}
|
|
653
|
+
|
|
466
654
|
export function indexSceneFile(db, syncDir, file, meta, prose) {
|
|
467
655
|
const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
|
|
468
656
|
|
|
@@ -639,6 +827,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
639
827
|
let sidecarsMigrated = 0;
|
|
640
828
|
const seenSceneIds = new Map(); // scene_id+project_id → file path, for duplicate detection
|
|
641
829
|
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
830
|
+
const indexedReferenceDocIds = new Set();
|
|
642
831
|
const warnings = [];
|
|
643
832
|
|
|
644
833
|
const scanFiles = [];
|
|
@@ -650,9 +839,20 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
650
839
|
scanFiles.push(file);
|
|
651
840
|
}
|
|
652
841
|
|
|
653
|
-
// --- Pass 1: world files (characters/places must be indexed
|
|
654
|
-
// so that character name
|
|
842
|
+
// --- Pass 1: world files and reference docs (characters/places must be indexed
|
|
843
|
+
// before scenes so that character name -> ID resolution in scene_characters works) ---
|
|
655
844
|
for (const file of scanFiles) {
|
|
845
|
+
if (isReferenceFile(syncDir, file)) {
|
|
846
|
+
try {
|
|
847
|
+
const { data, content } = parseFile(file);
|
|
848
|
+
const docId = indexReferenceFile(db, syncDir, file, data, content);
|
|
849
|
+
indexedReferenceDocIds.add(docId);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
|
|
852
|
+
}
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
656
856
|
if (!isWorldFile(syncDir, file)) continue;
|
|
657
857
|
try {
|
|
658
858
|
const { meta } = readMeta(file, syncDir, { writable });
|
|
@@ -667,9 +867,13 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
667
867
|
}
|
|
668
868
|
}
|
|
669
869
|
|
|
870
|
+
if (canPruneReferenceDocs(syncDir)) {
|
|
871
|
+
pruneMissingReferenceDocs(db, indexedReferenceDocIds);
|
|
872
|
+
}
|
|
873
|
+
|
|
670
874
|
// --- Pass 2: scene files ---
|
|
671
875
|
for (const file of scanFiles) {
|
|
672
|
-
if (isWorldFile(syncDir, file)) continue;
|
|
876
|
+
if (isWorldFile(syncDir, file) || isReferenceFile(syncDir, file)) continue;
|
|
673
877
|
try {
|
|
674
878
|
const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
|
|
675
879
|
if (sidecarGenerated) sidecarsMigrated++;
|
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",
|