@hanna84/mcp-writing 2.17.1 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,11 +4,21 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v2.18.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.17.1...v2.18.0)
9
+
10
+ - feat(reference): complete Phase 4D scene reference suggestion flow [`#163`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/163)
12
+
7
13
  #### [v2.17.1](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.17.0...v2.17.1)
9
15
 
16
+ > 1 May 2026
17
+
10
18
  - docs: mark reference-docs Phase 4C complete [`#162`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/162)
20
+ - Release 2.17.1 [`65ba9a1`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/65ba9a118afc4c1d484a5f3f714d00a021eac48e)
12
22
 
13
23
  #### [v2.17.0](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.16.2...v2.17.0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.17.1",
3
+ "version": "2.18.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/sync/sync.js CHANGED
@@ -698,8 +698,27 @@ export function indexWorldFile(db, syncDir, file, meta) {
698
698
 
699
699
  if (!kind || !isCanonicalWorldEntityFile(syncDir, file, meta)) return;
700
700
 
701
+ const indexWorldEntityReferenceLinks = ({ sourceKind, sourceId }) => {
702
+ const explicitReferenceLinks = collectExplicitReferenceLinks(
703
+ meta,
704
+ ["reference_links", "explicit_reference_links"],
705
+ { defaultRelation: "informs" }
706
+ );
707
+
708
+ if (explicitReferenceLinks.hasField) {
709
+ indexExplicitReferenceLinksForSource(db, {
710
+ sourceKind,
711
+ sourceProjectId: project_id ?? "",
712
+ sourceId,
713
+ links: explicitReferenceLinks.links,
714
+ defaultRelation: "informs",
715
+ });
716
+ }
717
+ };
718
+
701
719
  if (kind === "character") {
702
720
  if (!meta.character_id) return;
721
+
703
722
  db.prepare(`
704
723
  INSERT INTO characters (character_id, project_id, universe_id, name, role, arc_summary, first_appearance, file_path)
705
724
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -717,8 +736,10 @@ export function indexWorldFile(db, syncDir, file, meta) {
717
736
  meta.character_id, t
718
737
  );
719
738
  }
739
+ indexWorldEntityReferenceLinks({ sourceKind: "character", sourceId: meta.character_id });
720
740
  } else if (kind === "place") {
721
741
  if (!meta.place_id) return;
742
+
722
743
  db.prepare(`
723
744
  INSERT INTO places (place_id, project_id, universe_id, name, file_path)
724
745
  VALUES (?, ?, ?, ?, ?)
@@ -727,6 +748,7 @@ export function indexWorldFile(db, syncDir, file, meta) {
727
748
  meta.place_id, project_id ?? null, universe_id ?? null,
728
749
  meta.name ?? meta.place_id, file
729
750
  );
751
+ indexWorldEntityReferenceLinks({ sourceKind: "place", sourceId: meta.place_id });
730
752
  }
731
753
  }
732
754
 
@@ -1,46 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
- import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath, normalizeReferenceLinkList } from "../sync/sync.js";
4
+ import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
5
5
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
6
-
7
- function upsertSerializedReferenceLinks(existing, targetDocId, relation, { defaultRelation }) {
8
- const normalized = normalizeReferenceLinkList(existing ?? [], { defaultRelation });
9
- const filtered = normalized.filter((entry) => entry.targetDocId !== targetDocId);
10
- filtered.push({ targetDocId, relation });
11
- return filtered.map((entry) => ({
12
- target_doc_id: entry.targetDocId,
13
- relation: entry.relation,
14
- }));
15
- }
16
-
17
- function persistSceneReferenceLink({ scenePath, syncDir, targetDocId, relation }) {
18
- const { meta } = readMeta(scenePath, syncDir, { writable: true });
19
- const existingExplicit = [
20
- ...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
21
- ...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
22
- ];
23
- const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
24
- defaultRelation: "informs",
25
- });
26
-
27
- const nextMeta = {
28
- ...meta,
29
- reference_links: nextReferenceLinks,
30
- };
31
- delete nextMeta.explicit_reference_links;
32
-
33
- if (relation === "informs") {
34
- const existingIds = Array.isArray(meta.reference_ids)
35
- ? meta.reference_ids
36
- : typeof meta.reference_ids === "string"
37
- ? meta.reference_ids.split(",")
38
- : [];
39
- nextMeta.reference_ids = [...new Set([...existingIds.map((value) => String(value).trim()).filter(Boolean), targetDocId])];
40
- }
41
-
42
- writeMeta(scenePath, nextMeta);
43
- }
6
+ import {
7
+ persistSceneReferenceLink,
8
+ upsertExplicitReferenceLinkRow,
9
+ upsertSerializedReferenceLinks,
10
+ } from "./reference-link-persistence.js";
44
11
 
45
12
  function persistReferenceDocLink({ filePath, targetDocId, relation }) {
46
13
  const raw = fs.readFileSync(filePath, "utf8");
@@ -74,6 +41,165 @@ function persistReferenceDocLink({ filePath, targetDocId, relation }) {
74
41
  fs.writeFileSync(filePath, matter.stringify(parsed.content, nextData), "utf8");
75
42
  }
76
43
 
44
+ function persistCharacterReferenceLink({ characterPath, syncDir, targetDocId, relation }) {
45
+ const { meta } = readMeta(characterPath, syncDir, { writable: true });
46
+ const existingExplicit = [
47
+ ...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
48
+ ...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
49
+ ];
50
+ const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
51
+ defaultRelation: "informs",
52
+ });
53
+
54
+ const nextMeta = {
55
+ ...meta,
56
+ reference_links: nextReferenceLinks,
57
+ };
58
+ delete nextMeta.explicit_reference_links;
59
+
60
+ writeMeta(characterPath, nextMeta);
61
+ }
62
+
63
+ function persistPlaceReferenceLink({ placePath, syncDir, targetDocId, relation }) {
64
+ const { meta } = readMeta(placePath, syncDir, { writable: true });
65
+ const existingExplicit = [
66
+ ...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
67
+ ...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
68
+ ];
69
+ const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
70
+ defaultRelation: "informs",
71
+ });
72
+
73
+ const nextMeta = {
74
+ ...meta,
75
+ reference_links: nextReferenceLinks,
76
+ };
77
+ delete nextMeta.explicit_reference_links;
78
+
79
+ writeMeta(placePath, nextMeta);
80
+ }
81
+
82
+ function resolveProjectScopedSource({
83
+ db,
84
+ errorResponse,
85
+ sourceId,
86
+ sourceProjectId,
87
+ table,
88
+ idColumn,
89
+ label,
90
+ }) {
91
+ if (sourceProjectId) {
92
+ const scoped = db.prepare(`
93
+ SELECT ${idColumn} AS source_id, project_id, file_path
94
+ FROM ${table}
95
+ WHERE ${idColumn} = ? AND project_id = ?
96
+ LIMIT 1
97
+ `).get(sourceId, sourceProjectId);
98
+ if (!scoped) {
99
+ return { error: errorResponse("NOT_FOUND", `${label} '${sourceId}' not found in project '${sourceProjectId}'.`) };
100
+ }
101
+ return {
102
+ value: {
103
+ resolvedSourceProjectId: scoped.project_id ?? "",
104
+ sourceFilePath: scoped.file_path,
105
+ },
106
+ };
107
+ }
108
+
109
+ const matches = db.prepare(`
110
+ SELECT ${idColumn} AS source_id, project_id, file_path
111
+ FROM ${table}
112
+ WHERE ${idColumn} = ?
113
+ ORDER BY project_id
114
+ `).all(sourceId);
115
+
116
+ if (matches.length === 0) {
117
+ return { error: errorResponse("NOT_FOUND", `${label} '${sourceId}' not found.`) };
118
+ }
119
+ if (matches.length > 1) {
120
+ return {
121
+ error: errorResponse(
122
+ "CONFLICT",
123
+ `${label} ID '${sourceId}' exists in multiple projects. Provide source_project_id to disambiguate.`,
124
+ { source_id: sourceId, project_ids: matches.map((row) => row.project_id) }
125
+ ),
126
+ };
127
+ }
128
+
129
+ return {
130
+ value: {
131
+ resolvedSourceProjectId: matches[0].project_id ?? "",
132
+ sourceFilePath: matches[0].file_path,
133
+ },
134
+ };
135
+ }
136
+
137
+ function resolveReferenceLinkSource({
138
+ db,
139
+ errorResponse,
140
+ sourceKind,
141
+ sourceId,
142
+ sourceProjectId,
143
+ targetDocId,
144
+ }) {
145
+ if (sourceKind === "reference") {
146
+ const sourceDoc = db.prepare(`
147
+ SELECT doc_id, project_id, file_path
148
+ FROM reference_docs
149
+ WHERE doc_id = ?
150
+ LIMIT 1
151
+ `).get(sourceId);
152
+ if (!sourceDoc) {
153
+ return { error: errorResponse("NOT_FOUND", `Source reference doc '${sourceId}' not found.`) };
154
+ }
155
+ if (sourceId === targetDocId) {
156
+ return { error: errorResponse("VALIDATION_ERROR", "Self-links are not allowed for reference sources.") };
157
+ }
158
+ const resolvedSourceProjectId = sourceDoc.project_id ?? "";
159
+ if ((sourceProjectId ?? "") !== "" && sourceProjectId !== resolvedSourceProjectId) {
160
+ const resolvedSourceProjectLabel = resolvedSourceProjectId === ""
161
+ ? "unscoped/no project"
162
+ : `project '${resolvedSourceProjectId}'`;
163
+ const requestedSourceProjectLabel = sourceProjectId === ""
164
+ ? "unscoped/no project"
165
+ : `project '${sourceProjectId}'`;
166
+ return {
167
+ error: errorResponse(
168
+ "CONFLICT",
169
+ `Source reference doc '${sourceId}' belongs to ${resolvedSourceProjectLabel}, not ${requestedSourceProjectLabel}.`,
170
+ {
171
+ source_id: sourceId,
172
+ source_project_id: sourceProjectId,
173
+ resolved_source_project_id: resolvedSourceProjectId,
174
+ }
175
+ ),
176
+ };
177
+ }
178
+ return {
179
+ value: {
180
+ resolvedSourceProjectId,
181
+ sourceFilePath: sourceDoc.file_path,
182
+ },
183
+ };
184
+ }
185
+
186
+ const sourceConfigByKind = {
187
+ scene: { table: "scenes", idColumn: "scene_id", label: "Scene" },
188
+ character: { table: "characters", idColumn: "character_id", label: "Character" },
189
+ place: { table: "places", idColumn: "place_id", label: "Place" },
190
+ };
191
+ const config = sourceConfigByKind[sourceKind];
192
+ return resolveProjectScopedSource({
193
+ db,
194
+ errorResponse,
195
+ sourceId,
196
+ sourceProjectId,
197
+ table: config.table,
198
+ idColumn: config.idColumn,
199
+ label: config.label,
200
+ });
201
+ }
202
+
77
203
  export function registerMetadataTools(s, {
78
204
  db,
79
205
  SYNC_DIR,
@@ -243,11 +369,11 @@ export function registerMetadataTools(s, {
243
369
  // ---- upsert_reference_link -----------------------------------------------
244
370
  s.tool(
245
371
  "upsert_reference_link",
246
- "Create or update an explicit reference link from a scene or reference doc to a target reference doc. If a link already exists between the same source and target, this updates the relation. Only available when the sync dir is writable.",
372
+ "Create or update an explicit reference link from a scene, character, place, or reference doc to a target reference doc. If a link already exists between the same source and target, this updates the relation. Only available when the sync dir is writable.",
247
373
  {
248
- source_kind: z.enum(["scene", "reference"]).describe("Link source kind."),
249
- source_id: z.string().describe("Source scene_id or reference doc_id."),
250
- source_project_id: z.string().optional().describe("Optional project scope for the source. For scene sources, use this to disambiguate an ambiguous scene_id across projects. For reference sources, when provided, it is treated as an ownership check and must match the source reference doc's project."),
374
+ source_kind: z.enum(["scene", "character", "place", "reference"]).describe("Link source kind."),
375
+ source_id: z.string().describe("Source scene_id, character_id, place_id, or reference doc_id."),
376
+ source_project_id: z.string().optional().describe("Optional project scope for the source. For scene/character/place sources, use this to disambiguate an ambiguous source_id across projects. For reference sources, when provided, it is treated as an ownership check and must match the source reference doc's project."),
251
377
  target_doc_id: z.string().describe("Target reference doc_id."),
252
378
  relation: z.string().describe("Relationship label (for example: 'informs', 'related', 'history_of'). The value is trimmed and lowercased before validation."),
253
379
  },
@@ -274,109 +400,77 @@ export function registerMetadataTools(s, {
274
400
  return errorResponse("NOT_FOUND", `Target reference doc '${target_doc_id}' not found.`);
275
401
  }
276
402
 
277
- let resolvedSourceProjectId;
278
- let sourceScenePath = null;
279
- let sourceReferencePath = null;
280
- if (source_kind === "scene") {
281
- if (source_project_id) {
282
- const scene = db.prepare(`
283
- SELECT scene_id, project_id, file_path
284
- FROM scenes
285
- WHERE scene_id = ? AND project_id = ?
286
- LIMIT 1
287
- `).get(source_id, source_project_id);
288
- if (!scene) {
289
- return errorResponse("NOT_FOUND", `Scene '${source_id}' not found in project '${source_project_id}'.`);
290
- }
291
- resolvedSourceProjectId = scene.project_id ?? "";
292
- sourceScenePath = scene.file_path;
293
- } else {
294
- const matches = db.prepare(`
295
- SELECT scene_id, project_id, file_path
296
- FROM scenes
297
- WHERE scene_id = ?
298
- ORDER BY project_id
299
- `).all(source_id);
300
- if (matches.length === 0) {
301
- return errorResponse("NOT_FOUND", `Scene '${source_id}' not found.`);
302
- }
303
- if (matches.length > 1) {
304
- return errorResponse(
305
- "CONFLICT",
306
- `Scene ID '${source_id}' exists in multiple projects. Provide source_project_id to disambiguate.`,
307
- { source_id, project_ids: matches.map(row => row.project_id) }
308
- );
309
- }
310
- resolvedSourceProjectId = matches[0].project_id ?? "";
311
- sourceScenePath = matches[0].file_path;
312
- }
313
- } else {
314
- const sourceDoc = db.prepare(`
315
- SELECT doc_id, project_id, file_path
316
- FROM reference_docs
317
- WHERE doc_id = ?
318
- LIMIT 1
319
- `).get(source_id);
320
- if (!sourceDoc) {
321
- return errorResponse("NOT_FOUND", `Source reference doc '${source_id}' not found.`);
322
- }
323
- if (source_id === target_doc_id) {
324
- return errorResponse("VALIDATION_ERROR", "Self-links are not allowed for reference sources.");
325
- }
326
- resolvedSourceProjectId = sourceDoc.project_id ?? "";
327
- if ((source_project_id ?? "") !== "" && source_project_id !== resolvedSourceProjectId) {
328
- const resolvedSourceProjectLabel = resolvedSourceProjectId === ""
329
- ? "unscoped/no project"
330
- : `project '${resolvedSourceProjectId}'`;
331
- const requestedSourceProjectLabel = source_project_id === ""
332
- ? "unscoped/no project"
333
- : `project '${source_project_id}'`;
334
- return errorResponse(
335
- "CONFLICT",
336
- `Source reference doc '${source_id}' belongs to ${resolvedSourceProjectLabel}, not ${requestedSourceProjectLabel}.`,
337
- {
338
- source_id,
339
- source_project_id,
340
- resolved_source_project_id: resolvedSourceProjectId,
341
- }
342
- );
343
- }
344
- sourceReferencePath = sourceDoc.file_path;
403
+ const sourceResolution = resolveReferenceLinkSource({
404
+ db,
405
+ errorResponse,
406
+ sourceKind: source_kind,
407
+ sourceId: source_id,
408
+ sourceProjectId: source_project_id,
409
+ targetDocId: target_doc_id,
410
+ });
411
+ if (sourceResolution.error) {
412
+ return sourceResolution.error;
345
413
  }
414
+ const { resolvedSourceProjectId, sourceFilePath } = sourceResolution.value;
346
415
 
347
416
  try {
348
417
  if (source_kind === "scene") {
349
- if (!sourceScenePath) {
418
+ if (!sourceFilePath) {
350
419
  return errorResponse("STALE_PATH", `Scene '${source_id}' has no indexed file path. Run sync() to refresh.`, {
351
420
  source_id,
352
421
  source_project_id: resolvedSourceProjectId,
353
422
  });
354
423
  }
355
424
  persistSceneReferenceLink({
356
- scenePath: sourceScenePath,
425
+ scenePath: sourceFilePath,
426
+ syncDir: SYNC_DIR,
427
+ targetDocId: target_doc_id,
428
+ relation: normalizedRelation,
429
+ });
430
+ } else if (source_kind === "character") {
431
+ if (!sourceFilePath) {
432
+ return errorResponse("STALE_PATH", `Character '${source_id}' has no indexed file path. Run sync() to refresh.`, {
433
+ source_id,
434
+ source_project_id: resolvedSourceProjectId,
435
+ });
436
+ }
437
+ persistCharacterReferenceLink({
438
+ characterPath: sourceFilePath,
439
+ syncDir: SYNC_DIR,
440
+ targetDocId: target_doc_id,
441
+ relation: normalizedRelation,
442
+ });
443
+ } else if (source_kind === "place") {
444
+ if (!sourceFilePath) {
445
+ return errorResponse("STALE_PATH", `Place '${source_id}' has no indexed file path. Run sync() to refresh.`, {
446
+ source_id,
447
+ source_project_id: resolvedSourceProjectId,
448
+ });
449
+ }
450
+ persistPlaceReferenceLink({
451
+ placePath: sourceFilePath,
357
452
  syncDir: SYNC_DIR,
358
453
  targetDocId: target_doc_id,
359
454
  relation: normalizedRelation,
360
455
  });
361
456
  } else {
362
- if (!sourceReferencePath) {
457
+ if (!sourceFilePath) {
363
458
  return errorResponse("STALE_PATH", `Reference doc '${source_id}' has no indexed file path. Run sync() to refresh.`, {
364
459
  source_id,
365
460
  });
366
461
  }
367
462
  persistReferenceDocLink({
368
- filePath: sourceReferencePath,
463
+ filePath: sourceFilePath,
369
464
  targetDocId: target_doc_id,
370
465
  relation: normalizedRelation,
371
466
  });
372
467
  }
373
468
  } catch (err) {
374
469
  if (err?.code === "ENOENT") {
375
- const indexedPath = source_kind === "scene" ? sourceScenePath : sourceReferencePath;
376
470
  return errorResponse(
377
471
  "STALE_PATH",
378
472
  `Source file for ${source_kind} '${source_id}' not found at indexed path — run sync() to refresh.`,
379
- { indexed_path: indexedPath }
473
+ { indexed_path: sourceFilePath }
380
474
  );
381
475
  }
382
476
  return errorResponse("IO_ERROR", `Failed to persist link metadata: ${err.message}`);
@@ -384,16 +478,13 @@ export function registerMetadataTools(s, {
384
478
 
385
479
  try {
386
480
  db.exec("BEGIN");
387
- db.prepare(`
388
- DELETE FROM reference_links
389
- WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND target_doc_id = ?
390
- `).run(source_kind, resolvedSourceProjectId, source_id, target_doc_id);
391
-
392
- db.prepare(`
393
- INSERT INTO reference_links (
394
- source_kind, source_project_id, source_id, target_doc_id, relation, origin
395
- ) VALUES (?, ?, ?, ?, ?, 'explicit')
396
- `).run(source_kind, resolvedSourceProjectId, source_id, target_doc_id, normalizedRelation);
481
+ upsertExplicitReferenceLinkRow(db, {
482
+ sourceKind: source_kind,
483
+ sourceProjectId: resolvedSourceProjectId,
484
+ sourceId: source_id,
485
+ targetDocId: target_doc_id,
486
+ relation: normalizedRelation,
487
+ });
397
488
  db.exec("COMMIT");
398
489
  } catch (err) {
399
490
  try {
@@ -0,0 +1,77 @@
1
+ import { readMeta, writeMeta, normalizeReferenceLinkList } from "../sync/sync.js";
2
+
3
+ let savepointCounter = 0;
4
+
5
+ export function upsertSerializedReferenceLinks(existing, targetDocId, relation, { defaultRelation }) {
6
+ const normalized = normalizeReferenceLinkList(existing ?? [], { defaultRelation });
7
+ const filtered = normalized.filter((entry) => entry.targetDocId !== targetDocId);
8
+ filtered.push({ targetDocId, relation });
9
+ return filtered.map((entry) => ({
10
+ target_doc_id: entry.targetDocId,
11
+ relation: entry.relation,
12
+ }));
13
+ }
14
+
15
+ export function persistSceneReferenceLink({ scenePath, syncDir, targetDocId, relation }) {
16
+ const { meta } = readMeta(scenePath, syncDir, { writable: true });
17
+ const existingExplicit = [
18
+ ...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
19
+ ...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
20
+ ];
21
+ const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
22
+ defaultRelation: "informs",
23
+ });
24
+
25
+ const nextMeta = {
26
+ ...meta,
27
+ reference_links: nextReferenceLinks,
28
+ };
29
+ delete nextMeta.explicit_reference_links;
30
+
31
+ if (relation === "informs") {
32
+ const existingIds = Array.isArray(meta.reference_ids)
33
+ ? meta.reference_ids
34
+ : typeof meta.reference_ids === "string"
35
+ ? meta.reference_ids.split(",")
36
+ : [];
37
+ nextMeta.reference_ids = [...new Set([...existingIds.map((value) => String(value).trim()).filter(Boolean), targetDocId])];
38
+ }
39
+
40
+ writeMeta(scenePath, nextMeta);
41
+ }
42
+
43
+ export function upsertExplicitReferenceLinkRow(
44
+ db,
45
+ { sourceKind, sourceProjectId, sourceId, targetDocId, relation }
46
+ ) {
47
+ const deleteStmt = db.prepare(`
48
+ DELETE FROM reference_links
49
+ WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND target_doc_id = ?
50
+ `);
51
+ const insertStmt = db.prepare(`
52
+ INSERT INTO reference_links (
53
+ source_kind, source_project_id, source_id, target_doc_id, relation, origin
54
+ ) VALUES (?, ?, ?, ?, ?, 'explicit')
55
+ `);
56
+
57
+ const runUpsertBody = () => {
58
+ deleteStmt.run(sourceKind, sourceProjectId, sourceId, targetDocId);
59
+ insertStmt.run(sourceKind, sourceProjectId, sourceId, targetDocId, relation);
60
+ };
61
+
62
+ if (typeof db.transaction === "function") {
63
+ db.transaction(runUpsertBody)();
64
+ return;
65
+ }
66
+
67
+ const savepointName = `reference_link_upsert_${savepointCounter += 1}`;
68
+ db.exec(`SAVEPOINT ${savepointName};`);
69
+ try {
70
+ runUpsertBody();
71
+ db.exec(`RELEASE SAVEPOINT ${savepointName};`);
72
+ } catch (err) {
73
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepointName};`);
74
+ db.exec(`RELEASE SAVEPOINT ${savepointName};`);
75
+ throw err;
76
+ }
77
+ }
@@ -1,10 +1,63 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
+ import { readMeta } from "../sync/sync.js";
5
+ import { persistSceneReferenceLink, upsertExplicitReferenceLinkRow } from "./reference-link-persistence.js";
6
+
7
+ function accumulateSuggestionScore(scoreMap, rows, sourceLabel) {
8
+ for (const row of rows) {
9
+ const key = `${row.target_doc_id}:${row.relation}`;
10
+ if (!scoreMap.has(key)) {
11
+ scoreMap.set(key, {
12
+ doc_id: row.target_doc_id,
13
+ relation: row.relation,
14
+ score: 0,
15
+ sources: [],
16
+ });
17
+ }
18
+ const entry = scoreMap.get(key);
19
+ entry.score += 1;
20
+ entry.sources.push(`${sourceLabel}: ${row.source_name ?? row.source_id}`);
21
+ }
22
+ }
23
+
24
+ function normalizeEntityIdList(value) {
25
+ if (Array.isArray(value)) {
26
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
27
+ }
28
+ if (typeof value === "string") {
29
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
30
+ }
31
+ return [];
32
+ }
33
+
34
+ function readSceneEntityIdsFromMetadata({ scenePath, syncDir }) {
35
+ const { meta } = readMeta(scenePath, syncDir, { writable: false });
36
+ return {
37
+ characterIds: normalizeEntityIdList(meta.characters),
38
+ placeIds: normalizeEntityIdList(meta.places),
39
+ };
40
+ }
41
+
42
+ function selectApplyCandidates(enrichedCandidates, selectedDocIds, maxApply) {
43
+ const selectedSet = selectedDocIds ? new Set(selectedDocIds) : null;
44
+ const chosenByDocId = new Map();
45
+
46
+ for (const candidate of enrichedCandidates) {
47
+ if (selectedSet && !selectedSet.has(candidate.doc_id)) continue;
48
+ if (!chosenByDocId.has(candidate.doc_id)) {
49
+ chosenByDocId.set(candidate.doc_id, candidate);
50
+ }
51
+ }
52
+
53
+ const uniqueCandidates = Array.from(chosenByDocId.values());
54
+ return uniqueCandidates.slice(0, maxApply ?? uniqueCandidates.length);
55
+ }
4
56
 
5
57
  export function registerSearchTools(s, {
6
58
  db,
7
59
  SYNC_DIR,
60
+ SYNC_DIR_WRITABLE,
8
61
  GIT_ENABLED,
9
62
  errorResponse,
10
63
  paginateRows,
@@ -740,4 +793,264 @@ export function registerSearchTools(s, {
740
793
  return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
741
794
  }
742
795
  );
796
+
797
+ // ---- suggest_scene_references --------------------------------------------
798
+ s.tool(
799
+ "suggest_scene_references",
800
+ "Suggest reference documents for a scene by aggregating links from the scene's characters and places. Returns weighted candidates ranked by how many entities in the scene link to each reference. Excludes any explicit scene → reference links already present. In apply mode, can persist selected suggestions as explicit scene links in one call.",
801
+ {
802
+ scene_id: z.string().describe("Scene ID (e.g. 'sc-011-sebastian')."),
803
+ project_id: z.string().optional().describe("Optional project scope to disambiguate an ambiguous scene_id across projects."),
804
+ mode: z.enum(["preview", "apply"]).optional().describe("Use 'preview' (default) to list candidates only, or 'apply' to persist selected suggestions as explicit scene links."),
805
+ selected_doc_ids: z.array(z.string()).optional().describe("Optional allowlist of doc_ids to apply when mode='apply'. If omitted, applies top-ranked candidates."),
806
+ max_apply: z.number().int().min(1).optional().describe("Optional cap for how many candidates to apply when mode='apply'."),
807
+ min_score: z.number().int().min(1).optional().describe("Optional minimum candidate score. Candidates below this are excluded from preview/apply. Defaults to 1."),
808
+ },
809
+ async ({ scene_id, project_id, mode = "preview", selected_doc_ids, max_apply, min_score = 1 }) => {
810
+ // Resolve scene
811
+ let sceneQuery = `SELECT scene_id, project_id, file_path FROM scenes WHERE scene_id = ?`;
812
+ const sceneParams = [scene_id];
813
+ if (project_id) {
814
+ sceneQuery += ` AND project_id = ?`;
815
+ sceneParams.push(project_id);
816
+ }
817
+
818
+ const scenes = db.prepare(sceneQuery).all(...sceneParams);
819
+ if (scenes.length === 0) {
820
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found${project_id ? ` in project '${project_id}'` : ""}.`);
821
+ }
822
+ if (scenes.length > 1) {
823
+ return errorResponse(
824
+ "CONFLICT",
825
+ `Scene ID '${scene_id}' exists in multiple projects. Provide project_id to disambiguate.`,
826
+ { scene_id, project_ids: scenes.map(s => s.project_id) }
827
+ );
828
+ }
829
+
830
+ const resolvedScene = scenes[0];
831
+ const resolvedProjectId = resolvedScene.project_id ?? "";
832
+
833
+ if (mode === "apply" && !SYNC_DIR_WRITABLE) {
834
+ return errorResponse("READ_ONLY", "Cannot apply suggestions: sync dir is read-only.");
835
+ }
836
+
837
+ let characterIds = [];
838
+ let placeIds = [];
839
+ let loadedSceneEntitiesFromMetadata = false;
840
+
841
+ if (resolvedScene.file_path) {
842
+ try {
843
+ const entities = readSceneEntityIdsFromMetadata({
844
+ scenePath: resolvedScene.file_path,
845
+ syncDir: SYNC_DIR,
846
+ });
847
+ characterIds = entities.characterIds;
848
+ placeIds = entities.placeIds;
849
+ loadedSceneEntitiesFromMetadata = true;
850
+ } catch (err) {
851
+ void err;
852
+ }
853
+ }
854
+
855
+ if (!loadedSceneEntitiesFromMetadata) {
856
+ // Fallback for scenes without readable indexed file paths.
857
+ characterIds = db.prepare(`
858
+ SELECT character_id FROM scene_characters WHERE scene_id = ?
859
+ `).all(scene_id).map((row) => row.character_id);
860
+
861
+ placeIds = db.prepare(`
862
+ SELECT place_id FROM scene_places WHERE scene_id = ?
863
+ `).all(scene_id).map((row) => row.place_id);
864
+ }
865
+
866
+ // Get explicit scene → reference links already present
867
+ const existingSceneLinks = db.prepare(`
868
+ SELECT target_doc_id
869
+ FROM reference_links
870
+ WHERE source_kind = 'scene' AND source_project_id = ? AND source_id = ? AND origin = 'explicit'
871
+ `).all(resolvedProjectId, scene_id);
872
+ const existingSceneDocIds = new Set(existingSceneLinks.map((link) => link.target_doc_id));
873
+
874
+ // Load all character/place source links in project scope and aggregate in memory.
875
+ const characterReferenceLinks = characterIds.length > 0
876
+ ? db.prepare(`
877
+ SELECT rl.target_doc_id, rl.relation, rl.source_id AS source_id, c.name AS source_name
878
+ FROM reference_links rl
879
+ LEFT JOIN characters c
880
+ ON c.character_id = rl.source_id
881
+ AND c.project_id = rl.source_project_id
882
+ WHERE rl.source_kind = 'character'
883
+ AND rl.source_project_id = ?
884
+ AND rl.source_id IN (${characterIds.map(() => "?").join(",")})
885
+ `).all(resolvedProjectId, ...characterIds)
886
+ : [];
887
+
888
+ const placeReferenceLinks = placeIds.length > 0
889
+ ? db.prepare(`
890
+ SELECT rl.target_doc_id, rl.relation, rl.source_id AS source_id, p.name AS source_name
891
+ FROM reference_links rl
892
+ LEFT JOIN places p
893
+ ON p.place_id = rl.source_id
894
+ AND p.project_id = rl.source_project_id
895
+ WHERE rl.source_kind = 'place'
896
+ AND rl.source_project_id = ?
897
+ AND rl.source_id IN (${placeIds.map(() => "?").join(",")})
898
+ `).all(resolvedProjectId, ...placeIds)
899
+ : [];
900
+
901
+ // Merge and score
902
+ const scoreMap = new Map(); // key: "doc_id:relation" → { doc_id, relation, score, sources: [...] }
903
+ accumulateSuggestionScore(scoreMap, characterReferenceLinks, "character");
904
+ accumulateSuggestionScore(scoreMap, placeReferenceLinks, "place");
905
+
906
+ // Filter out already explicit scene links and deduplicate sources
907
+ const candidates = Array.from(scoreMap.values())
908
+ .filter(entry => !existingSceneDocIds.has(entry.doc_id))
909
+ .filter(entry => entry.score >= min_score)
910
+ .map(entry => ({
911
+ ...entry,
912
+ sources: [...new Set(entry.sources)], // deduplicate
913
+ }))
914
+ .sort((a, b) => b.score - a.score || a.doc_id.localeCompare(b.doc_id) || a.relation.localeCompare(b.relation));
915
+
916
+ const candidateDocIds = [...new Set(candidates.map(candidate => candidate.doc_id))];
917
+ const docsById = candidateDocIds.length > 0
918
+ ? new Map(
919
+ db.prepare(`
920
+ SELECT doc_id, type, title, summary, project_id, universe_id
921
+ FROM reference_docs
922
+ WHERE doc_id IN (${candidateDocIds.map(() => "?").join(",")})
923
+ `)
924
+ .all(...candidateDocIds)
925
+ .map(row => [row.doc_id, row])
926
+ )
927
+ : new Map();
928
+
929
+ // Enrich with reference doc metadata
930
+ const enriched = candidates
931
+ .filter(candidate => docsById.has(candidate.doc_id))
932
+ .map(candidate => {
933
+ const doc = docsById.get(candidate.doc_id);
934
+ return {
935
+ ...candidate,
936
+ title: doc.title,
937
+ type: doc.type,
938
+ summary: doc.summary,
939
+ };
940
+ });
941
+
942
+ const skippedMissingDocIds = candidateDocIds.filter((docId) => !docsById.has(docId));
943
+
944
+ if (enriched.length === 0) {
945
+ return {
946
+ content: [{
947
+ type: "text",
948
+ text: JSON.stringify({
949
+ scene_id,
950
+ project_id: resolvedProjectId,
951
+ total_candidates: 0,
952
+ message: "No reference suggestions found. Scene characters and places have no linked references.",
953
+ skipped_missing_doc_ids: skippedMissingDocIds,
954
+ candidates: [],
955
+ }, null, 2),
956
+ }],
957
+ };
958
+ }
959
+
960
+
961
+ if (mode === "apply") {
962
+ if (!resolvedScene.file_path) {
963
+ return errorResponse("STALE_PATH", `Scene '${scene_id}' has no indexed file path. Run sync() to refresh.`, {
964
+ scene_id,
965
+ project_id: resolvedProjectId,
966
+ });
967
+ }
968
+
969
+ const toApply = selectApplyCandidates(enriched, selected_doc_ids, max_apply);
970
+
971
+ const appliedLinks = [];
972
+ const failedLinks = [];
973
+
974
+ for (const candidate of toApply) {
975
+ try {
976
+ persistSceneReferenceLink({
977
+ scenePath: resolvedScene.file_path,
978
+ syncDir: SYNC_DIR,
979
+ targetDocId: candidate.doc_id,
980
+ relation: candidate.relation,
981
+ });
982
+ } catch (err) {
983
+ failedLinks.push({
984
+ target_doc_id: candidate.doc_id,
985
+ relation: candidate.relation,
986
+ stage: "metadata",
987
+ code: err?.code ?? "IO_ERROR",
988
+ message: err?.code === "ENOENT"
989
+ ? `Scene file for '${scene_id}' not found at indexed path — run sync() to refresh.`
990
+ : `Failed to persist scene reference link metadata: ${err.message}`,
991
+ });
992
+ continue;
993
+ }
994
+
995
+ try {
996
+ upsertExplicitReferenceLinkRow(db, {
997
+ sourceKind: "scene",
998
+ sourceProjectId: resolvedProjectId,
999
+ sourceId: scene_id,
1000
+ targetDocId: candidate.doc_id,
1001
+ relation: candidate.relation,
1002
+ });
1003
+ } catch (err) {
1004
+ failedLinks.push({
1005
+ target_doc_id: candidate.doc_id,
1006
+ relation: candidate.relation,
1007
+ stage: "index",
1008
+ code: err?.code ?? "IO_ERROR",
1009
+ message: `Failed to persist scene reference link index row: ${err.message}`,
1010
+ });
1011
+ continue;
1012
+ }
1013
+
1014
+ appliedLinks.push({
1015
+ source_kind: "scene",
1016
+ source_project_id: resolvedProjectId,
1017
+ source_id: scene_id,
1018
+ target_doc_id: candidate.doc_id,
1019
+ relation: candidate.relation,
1020
+ origin: "explicit",
1021
+ });
1022
+ }
1023
+
1024
+ return {
1025
+ content: [{
1026
+ type: "text",
1027
+ text: JSON.stringify({
1028
+ scene_id,
1029
+ project_id: resolvedProjectId,
1030
+ mode,
1031
+ total_candidates: enriched.length,
1032
+ skipped_missing_doc_ids: skippedMissingDocIds,
1033
+ applied_count: appliedLinks.length,
1034
+ applied_links: appliedLinks,
1035
+ failed_count: failedLinks.length,
1036
+ failed_links: failedLinks,
1037
+ candidates: enriched,
1038
+ }, null, 2),
1039
+ }],
1040
+ };
1041
+ }
1042
+ return {
1043
+ content: [{
1044
+ type: "text",
1045
+ text: JSON.stringify({
1046
+ scene_id,
1047
+ project_id: resolvedProjectId,
1048
+ total_candidates: enriched.length,
1049
+ skipped_missing_doc_ids: skippedMissingDocIds,
1050
+ candidates: enriched,
1051
+ }, null, 2),
1052
+ }],
1053
+ };
1054
+ }
1055
+ );
743
1056
  }