@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 +10 -0
- package/package.json +1 -1
- package/src/sync/sync.js +22 -0
- package/src/tools/metadata.js +218 -127
- package/src/tools/reference-link-persistence.js +77 -0
- package/src/tools/search.js +313 -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.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
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
|
|
package/src/tools/metadata.js
CHANGED
|
@@ -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
|
|
4
|
+
import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
|
|
5
5
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 (!
|
|
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:
|
|
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 (!
|
|
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:
|
|
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:
|
|
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
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
}
|
package/src/tools/search.js
CHANGED
|
@@ -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
|
}
|