@hanna84/mcp-writing 2.13.0 → 2.15.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 +78 -0
- package/src/sync/sync.js +142 -1
- package/src/tools/search.js +154 -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.15.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.14.0...v2.15.0)
|
|
9
|
+
|
|
10
|
+
- feat(reference): add reference link query tools [`#148`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/148)
|
|
12
|
+
|
|
13
|
+
#### [v2.14.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.13.0...v2.14.0)
|
|
15
|
+
|
|
16
|
+
> 30 April 2026
|
|
17
|
+
|
|
18
|
+
- feat(reference): add reference link indexing for Phase 4B [`#147`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/147)
|
|
20
|
+
- Release 2.14.0 [`0b82946`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/0b829469448cb86008113dbf1b05f92e700b61a1)
|
|
22
|
+
|
|
7
23
|
#### [v2.13.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v2.12.22...v2.13.0)
|
|
9
25
|
|
|
26
|
+
> 30 April 2026
|
|
27
|
+
|
|
10
28
|
- feat: add reference document search [`#146`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/146)
|
|
30
|
+
- Release 2.13.0 [`0df673a`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/0df673a183f180a16cd5a9490c2829932b4aa744)
|
|
12
32
|
|
|
13
33
|
#### [v2.12.22](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v2.12.21...v2.12.22)
|
package/package.json
CHANGED
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) {
|
package/src/tools/search.js
CHANGED
|
@@ -485,6 +485,160 @@ export function registerSearchTools(s, {
|
|
|
485
485
|
}
|
|
486
486
|
);
|
|
487
487
|
|
|
488
|
+
// ---- list_scene_references -----------------------------------------------
|
|
489
|
+
s.tool(
|
|
490
|
+
"list_scene_references",
|
|
491
|
+
"List direct reference documents linked from a scene via metadata (for example, reference_ids). Returns only one-hop scene -> reference links and does not recursively traverse related references. If scene IDs are reused across projects, omitting project_id returns CONFLICT with candidate project_ids.",
|
|
492
|
+
{
|
|
493
|
+
scene_id: z.string().describe("Scene ID to inspect."),
|
|
494
|
+
project_id: z.string().optional().describe("Optional project ID to disambiguate duplicate scene IDs across projects."),
|
|
495
|
+
},
|
|
496
|
+
async ({ scene_id, project_id }) => {
|
|
497
|
+
let scene;
|
|
498
|
+
if (project_id) {
|
|
499
|
+
scene = db.prepare(`
|
|
500
|
+
SELECT scene_id, project_id
|
|
501
|
+
FROM scenes
|
|
502
|
+
WHERE scene_id = ? AND project_id = ?
|
|
503
|
+
LIMIT 1
|
|
504
|
+
`).get(scene_id, project_id);
|
|
505
|
+
if (!scene) {
|
|
506
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
const matches = db.prepare(`
|
|
510
|
+
SELECT scene_id, project_id
|
|
511
|
+
FROM scenes
|
|
512
|
+
WHERE scene_id = ?
|
|
513
|
+
ORDER BY project_id
|
|
514
|
+
`).all(scene_id);
|
|
515
|
+
if (matches.length === 0) {
|
|
516
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
|
|
517
|
+
}
|
|
518
|
+
if (matches.length > 1) {
|
|
519
|
+
return errorResponse(
|
|
520
|
+
"CONFLICT",
|
|
521
|
+
`Scene ID '${scene_id}' exists in multiple projects. Provide project_id to disambiguate.`,
|
|
522
|
+
{ scene_id, project_ids: matches.map(row => row.project_id) }
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
scene = matches[0];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const links = db.prepare(`
|
|
529
|
+
SELECT
|
|
530
|
+
rl.target_doc_id,
|
|
531
|
+
rl.relation,
|
|
532
|
+
rd.project_id AS target_project_id,
|
|
533
|
+
rd.universe_id AS target_universe_id,
|
|
534
|
+
rd.type,
|
|
535
|
+
rd.title,
|
|
536
|
+
rd.summary,
|
|
537
|
+
rd.file_path
|
|
538
|
+
FROM reference_links rl
|
|
539
|
+
LEFT JOIN reference_docs rd ON rd.doc_id = rl.target_doc_id
|
|
540
|
+
WHERE rl.source_kind = 'scene' AND rl.source_project_id = ? AND rl.source_id = ?
|
|
541
|
+
ORDER BY rl.target_doc_id
|
|
542
|
+
`).all(scene.project_id ?? "", scene.scene_id);
|
|
543
|
+
|
|
544
|
+
if (links.length === 0) {
|
|
545
|
+
return errorResponse("NO_RESULTS", `No reference links found for scene '${scene.scene_id}' in project '${scene.project_id}'.`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const tagsStmt = db.prepare(`
|
|
549
|
+
SELECT tag
|
|
550
|
+
FROM reference_doc_tags
|
|
551
|
+
WHERE doc_id = ?
|
|
552
|
+
ORDER BY tag
|
|
553
|
+
`);
|
|
554
|
+
const references = links.map((row) => ({
|
|
555
|
+
doc_id: row.target_doc_id,
|
|
556
|
+
relation: row.relation,
|
|
557
|
+
project_id: row.target_project_id,
|
|
558
|
+
universe_id: row.target_universe_id,
|
|
559
|
+
type: row.type,
|
|
560
|
+
title: row.title,
|
|
561
|
+
summary: row.summary,
|
|
562
|
+
file_path: row.file_path,
|
|
563
|
+
tags: tagsStmt.all(row.target_doc_id).map(tagRow => tagRow.tag),
|
|
564
|
+
}));
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
content: [{
|
|
568
|
+
type: "text",
|
|
569
|
+
text: JSON.stringify({
|
|
570
|
+
scene_id: scene.scene_id,
|
|
571
|
+
project_id: scene.project_id,
|
|
572
|
+
references,
|
|
573
|
+
}, null, 2),
|
|
574
|
+
}],
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
// ---- get_reference_doc ----------------------------------------------------
|
|
580
|
+
s.tool(
|
|
581
|
+
"get_reference_doc",
|
|
582
|
+
"Get metadata for a reference document by doc_id. Optionally includes exactly one hop of related reference docs.",
|
|
583
|
+
{
|
|
584
|
+
doc_id: z.string().describe("Reference document ID."),
|
|
585
|
+
include_related: z.boolean().optional().describe("If true, include one-hop related reference docs."),
|
|
586
|
+
},
|
|
587
|
+
async ({ doc_id, include_related = false }) => {
|
|
588
|
+
const doc = db.prepare(`
|
|
589
|
+
SELECT doc_id, project_id, universe_id, type, title, summary, file_path
|
|
590
|
+
FROM reference_docs
|
|
591
|
+
WHERE doc_id = ?
|
|
592
|
+
`).get(doc_id);
|
|
593
|
+
if (!doc) {
|
|
594
|
+
return errorResponse("NOT_FOUND", `Reference document '${doc_id}' not found.`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const tagsStmt = db.prepare(`
|
|
598
|
+
SELECT tag
|
|
599
|
+
FROM reference_doc_tags
|
|
600
|
+
WHERE doc_id = ?
|
|
601
|
+
ORDER BY tag
|
|
602
|
+
`);
|
|
603
|
+
const payload = {
|
|
604
|
+
...doc,
|
|
605
|
+
tags: tagsStmt.all(doc.doc_id).map(row => row.tag),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
if (include_related) {
|
|
609
|
+
const relatedRows = db.prepare(`
|
|
610
|
+
SELECT
|
|
611
|
+
rl.target_doc_id,
|
|
612
|
+
rl.relation,
|
|
613
|
+
rd.project_id,
|
|
614
|
+
rd.universe_id,
|
|
615
|
+
rd.type,
|
|
616
|
+
rd.title,
|
|
617
|
+
rd.summary,
|
|
618
|
+
rd.file_path
|
|
619
|
+
FROM reference_links rl
|
|
620
|
+
LEFT JOIN reference_docs rd ON rd.doc_id = rl.target_doc_id
|
|
621
|
+
WHERE rl.source_kind = 'reference' AND rl.source_project_id = ? AND rl.source_id = ?
|
|
622
|
+
ORDER BY rl.target_doc_id
|
|
623
|
+
`).all(doc.project_id ?? "", doc.doc_id);
|
|
624
|
+
|
|
625
|
+
payload.related = relatedRows.map((row) => ({
|
|
626
|
+
doc_id: row.target_doc_id,
|
|
627
|
+
relation: row.relation,
|
|
628
|
+
project_id: row.project_id,
|
|
629
|
+
universe_id: row.universe_id,
|
|
630
|
+
type: row.type,
|
|
631
|
+
title: row.title,
|
|
632
|
+
summary: row.summary,
|
|
633
|
+
file_path: row.file_path,
|
|
634
|
+
tags: tagsStmt.all(row.target_doc_id).map(tagRow => tagRow.tag),
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
639
|
+
}
|
|
640
|
+
);
|
|
641
|
+
|
|
488
642
|
// ---- list_threads --------------------------------------------------------
|
|
489
643
|
s.tool(
|
|
490
644
|
"list_threads",
|