@knowall-ai/reverie 0.4.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/LICENSE +21 -0
- package/README.md +528 -0
- package/build/cypher-guard.js +92 -0
- package/build/embeddings.js +303 -0
- package/build/handlers/index.js +654 -0
- package/build/hygiene.js +43 -0
- package/build/index.js +65 -0
- package/build/neo4j-client.js +182 -0
- package/build/search.js +92 -0
- package/build/server.js +71 -0
- package/build/tools/definitions.js +251 -0
- package/build/tools/guidance-tool.js +284 -0
- package/build/types.js +115 -0
- package/package.json +63 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import neo4j from 'neo4j-driver';
|
|
3
|
+
import { embedNodes, scrub } from '../embeddings.js';
|
|
4
|
+
import { readOnlyViolation } from '../cypher-guard.js';
|
|
5
|
+
import { bloatHint, contentKeys, factLikeKeys, lazyEmbedBatch, maxProperties } from '../hygiene.js';
|
|
6
|
+
import { rank } from '../search.js';
|
|
7
|
+
import { isCreateConnectionArgs, isCreateMemoryArgs, isDeleteConnectionArgs, isDeleteMemoryArgs, isDreamArgs, isListMemoryLabelsArgs, isMemoryStatsArgs, isQueryMemoriesArgs, isSearchMemoriesArgs, isUpdateConnectionArgs, isUpdateMemoryArgs } from '../types.js';
|
|
8
|
+
import { getGuidanceContent, isGetGuidanceArgs } from '../tools/guidance-tool.js';
|
|
9
|
+
const QUERY_ROW_LIMIT = 200;
|
|
10
|
+
const QUERY_TIMEOUT_MS = 10_000;
|
|
11
|
+
/** Property keys that, when both present and different, mark two same-named nodes as distinct entities. */
|
|
12
|
+
const IDENTITY_KEYS = ['email', 'phone', 'website', 'company', 'organisation', 'organization'];
|
|
13
|
+
/** apoc.refactor.mergeNodes property strategy: survivor wins for name, timestamps and vectors; the rest combine. */
|
|
14
|
+
const MERGE_PROPERTY_STRATEGY = {
|
|
15
|
+
name: 'discard',
|
|
16
|
+
created_at: 'discard',
|
|
17
|
+
embedding: 'discard',
|
|
18
|
+
name_embedding: 'discard',
|
|
19
|
+
embedding_model: 'discard',
|
|
20
|
+
embedded_at: 'discard',
|
|
21
|
+
'.*': 'combine'
|
|
22
|
+
};
|
|
23
|
+
const EMBED_NODE_CYPHER = `
|
|
24
|
+
MATCH (n)
|
|
25
|
+
WHERE id(n) = $id
|
|
26
|
+
SET n.embedding = $embedding,
|
|
27
|
+
n.name_embedding = $nameEmbedding,
|
|
28
|
+
n.embedding_model = $model,
|
|
29
|
+
n.embedded_at = $embeddedAt`;
|
|
30
|
+
export async function handleToolCall(name, args, neo4jClient, embedder) {
|
|
31
|
+
try {
|
|
32
|
+
switch (name) {
|
|
33
|
+
case 'search_memories': {
|
|
34
|
+
if (!isSearchMemoriesArgs(args)) {
|
|
35
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid search_memories arguments');
|
|
36
|
+
}
|
|
37
|
+
const depth = args.depth ?? 1;
|
|
38
|
+
const limit = Math.min(args.limit ?? 10, 200);
|
|
39
|
+
const query = args.query ?? '';
|
|
40
|
+
const requestedMode = args.search_mode ?? 'hybrid';
|
|
41
|
+
const similarityThreshold = args.similarity_threshold ?? 0.4;
|
|
42
|
+
let orderBy = 'memory.created_at DESC';
|
|
43
|
+
let hasCustomOrder = false;
|
|
44
|
+
if (args.order_by) {
|
|
45
|
+
const orderMatch = args.order_by.match(/^(memory\.|n\.)?([a-zA-Z_]+)\s+(ASC|DESC)$/i);
|
|
46
|
+
if (orderMatch) {
|
|
47
|
+
orderBy = `memory.${orderMatch[2]} ${orderMatch[3].toUpperCase()}`;
|
|
48
|
+
hasCustomOrder = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
let baseQuery = 'MATCH (memory)';
|
|
52
|
+
const conditions = [];
|
|
53
|
+
const queryParams = {};
|
|
54
|
+
if (!args.include_archived) {
|
|
55
|
+
conditions.push("coalesce(memory.status, '') <> 'archived'");
|
|
56
|
+
}
|
|
57
|
+
if (args.label) {
|
|
58
|
+
conditions.push('toLower(labels(memory)[0]) = toLower($label)');
|
|
59
|
+
queryParams.label = args.label;
|
|
60
|
+
}
|
|
61
|
+
if (args.since_date) {
|
|
62
|
+
conditions.push('memory.created_at >= $since_date');
|
|
63
|
+
queryParams.since_date = args.since_date;
|
|
64
|
+
}
|
|
65
|
+
if (conditions.length > 0) {
|
|
66
|
+
baseQuery += ` WHERE ${conditions.join(' AND ')}`;
|
|
67
|
+
}
|
|
68
|
+
// Keyword mode never needs the stored vectors, so leave them out of the wire payload.
|
|
69
|
+
const propsProjection = requestedMode === 'keyword' || requestedMode === 'exact'
|
|
70
|
+
? 'memory {.*, embedding: null, name_embedding: null}'
|
|
71
|
+
: 'properties(memory)';
|
|
72
|
+
baseQuery += ` RETURN id(memory) AS id, labels(memory)[0] AS label, ${propsProjection} AS props ORDER BY id(memory)`;
|
|
73
|
+
const rawCandidates = await neo4jClient.executeQuery(baseQuery, queryParams);
|
|
74
|
+
const candidates = rawCandidates.map((candidate) => ({
|
|
75
|
+
id: candidate.id,
|
|
76
|
+
label: candidate.label || 'Memory',
|
|
77
|
+
props: candidate.props || {}
|
|
78
|
+
}));
|
|
79
|
+
const ranking = await rankCandidates(neo4jClient, candidates, query, requestedMode, similarityThreshold, embedder);
|
|
80
|
+
const topRanked = ranking.slice(0, limit);
|
|
81
|
+
const rankedIds = topRanked.map((item) => item.id);
|
|
82
|
+
if (rankedIds.length === 0) {
|
|
83
|
+
return jsonResult([]);
|
|
84
|
+
}
|
|
85
|
+
const result = await fetchMemories(neo4jClient, rankedIds, depth, orderBy, limit, Boolean(args.include_archived));
|
|
86
|
+
const scoring = new Map(topRanked.map((item) => [item.id, { score: Number(item.score.toFixed(2)), match: item.match }]));
|
|
87
|
+
for (const row of result) {
|
|
88
|
+
const memoryId = row?.memory?._id;
|
|
89
|
+
if (typeof memoryId !== 'number') {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const meta = scoring.get(memoryId);
|
|
93
|
+
if (meta) {
|
|
94
|
+
row.memory._score = meta.score;
|
|
95
|
+
row.memory._match = meta.match;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const orderedResult = hasCustomOrder
|
|
99
|
+
? result
|
|
100
|
+
: rankedIds
|
|
101
|
+
.map((id) => result.find((row) => row?.memory?._id === id))
|
|
102
|
+
.filter((row) => Boolean(row));
|
|
103
|
+
return jsonResult(orderedResult);
|
|
104
|
+
}
|
|
105
|
+
case 'create_memory': {
|
|
106
|
+
if (!isCreateMemoryArgs(args)) {
|
|
107
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid create_memory arguments');
|
|
108
|
+
}
|
|
109
|
+
const properties = {
|
|
110
|
+
...args.properties,
|
|
111
|
+
created_at: args.properties.created_at || new Date().toISOString()
|
|
112
|
+
};
|
|
113
|
+
const result = await neo4jClient.createNode(args.label, properties);
|
|
114
|
+
await embedNodeIfPossible(neo4jClient, embedder, result?.memory, args.label, 'create_memory');
|
|
115
|
+
return jsonResult(result);
|
|
116
|
+
}
|
|
117
|
+
case 'create_connection': {
|
|
118
|
+
if (!isCreateConnectionArgs(args)) {
|
|
119
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid create_connection arguments');
|
|
120
|
+
}
|
|
121
|
+
const result = await neo4jClient.createRelationship(args.fromMemoryId, args.toMemoryId, args.type, args.properties || { created_at: new Date().toISOString() });
|
|
122
|
+
return jsonResult(result);
|
|
123
|
+
}
|
|
124
|
+
case 'update_memory': {
|
|
125
|
+
if (!isUpdateMemoryArgs(args)) {
|
|
126
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid update_memory arguments');
|
|
127
|
+
}
|
|
128
|
+
const result = await neo4jClient.updateNode(args.nodeId, args.properties);
|
|
129
|
+
const label = Array.isArray(result?.memory?._labels) && typeof result.memory._labels[0] === 'string'
|
|
130
|
+
? result.memory._labels[0]
|
|
131
|
+
: 'Memory';
|
|
132
|
+
await embedNodeIfPossible(neo4jClient, embedder, result?.memory, label, 'update_memory');
|
|
133
|
+
if (result?.memory) {
|
|
134
|
+
const count = contentKeys(result.memory).length;
|
|
135
|
+
const limit = maxProperties();
|
|
136
|
+
if (count > limit) {
|
|
137
|
+
result.memory._hint = bloatHint(String(result.memory.name ?? args.nodeId), count, limit);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return jsonResult(result);
|
|
141
|
+
}
|
|
142
|
+
case 'update_connection': {
|
|
143
|
+
if (!isUpdateConnectionArgs(args)) {
|
|
144
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid update_connection arguments');
|
|
145
|
+
}
|
|
146
|
+
const result = await neo4jClient.updateRelationship(args.fromMemoryId, args.toMemoryId, args.type, args.properties);
|
|
147
|
+
return jsonResult(result);
|
|
148
|
+
}
|
|
149
|
+
case 'delete_memory': {
|
|
150
|
+
if (!isDeleteMemoryArgs(args)) {
|
|
151
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid delete_memory arguments');
|
|
152
|
+
}
|
|
153
|
+
const result = await neo4jClient.deleteNode(args.nodeId);
|
|
154
|
+
return jsonResult(result);
|
|
155
|
+
}
|
|
156
|
+
case 'delete_connection': {
|
|
157
|
+
if (!isDeleteConnectionArgs(args)) {
|
|
158
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid delete_connection arguments');
|
|
159
|
+
}
|
|
160
|
+
const result = await neo4jClient.deleteRelationship(args.fromMemoryId, args.toMemoryId, args.type);
|
|
161
|
+
return jsonResult(result);
|
|
162
|
+
}
|
|
163
|
+
case 'list_memory_labels': {
|
|
164
|
+
if (!isListMemoryLabelsArgs(args)) {
|
|
165
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid list_memory_labels arguments');
|
|
166
|
+
}
|
|
167
|
+
const query = `
|
|
168
|
+
MATCH (memory)
|
|
169
|
+
WHERE $includeArchived OR coalesce(memory.status, '') <> 'archived'
|
|
170
|
+
WITH labels(memory) AS nodeLabels
|
|
171
|
+
UNWIND nodeLabels AS label
|
|
172
|
+
WITH label, count(*) AS count
|
|
173
|
+
ORDER BY count DESC, label
|
|
174
|
+
RETURN collect({label: label, count: count}) AS labels, sum(count) AS totalMemories
|
|
175
|
+
`;
|
|
176
|
+
const result = await neo4jClient.executeQuery(query, { includeArchived: Boolean(args.include_archived) });
|
|
177
|
+
return jsonResult(result);
|
|
178
|
+
}
|
|
179
|
+
case 'query_memories': {
|
|
180
|
+
if (!isQueryMemoriesArgs(args)) {
|
|
181
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid query_memories arguments');
|
|
182
|
+
}
|
|
183
|
+
const violation = readOnlyViolation(args.cypher);
|
|
184
|
+
if (violation) {
|
|
185
|
+
throw new McpError(ErrorCode.InvalidParams, `query_memories only supports read-only Cypher: ${violation}`);
|
|
186
|
+
}
|
|
187
|
+
const result = await neo4jClient.executeReadQuery(args.cypher, args.params ?? {}, {
|
|
188
|
+
limit: QUERY_ROW_LIMIT,
|
|
189
|
+
timeoutMs: QUERY_TIMEOUT_MS
|
|
190
|
+
});
|
|
191
|
+
return jsonResult(result);
|
|
192
|
+
}
|
|
193
|
+
case 'memory_stats': {
|
|
194
|
+
if (!isMemoryStatsArgs(args)) {
|
|
195
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid memory_stats arguments');
|
|
196
|
+
}
|
|
197
|
+
const [nodeRow] = await neo4jClient.executeQuery(`MATCH (n)
|
|
198
|
+
WHERE coalesce(n.status, '') <> 'archived'
|
|
199
|
+
RETURN count(n) AS count`);
|
|
200
|
+
const [relationshipRow] = await neo4jClient.executeQuery('MATCH ()-[r]->() RETURN count(r) AS count');
|
|
201
|
+
const labelRows = await neo4jClient.executeQuery(`MATCH (n)
|
|
202
|
+
WHERE coalesce(n.status, '') <> 'archived'
|
|
203
|
+
UNWIND labels(n) AS label
|
|
204
|
+
RETURN label, count(*) AS count
|
|
205
|
+
ORDER BY label`);
|
|
206
|
+
const relationshipTypeRows = await neo4jClient.executeQuery('MATCH ()-[r]->() RETURN type(r) AS type, count(r) AS count ORDER BY type');
|
|
207
|
+
const [orphanRow] = await neo4jClient.executeQuery(`MATCH (n)
|
|
208
|
+
WHERE coalesce(n.status, '') <> 'archived'
|
|
209
|
+
AND NOT (n)--()
|
|
210
|
+
RETURN count(n) AS count`);
|
|
211
|
+
let embedded = 0;
|
|
212
|
+
let embedderId = null;
|
|
213
|
+
if (embedder) {
|
|
214
|
+
embedderId = embedder.id;
|
|
215
|
+
const [embeddedRow] = await neo4jClient.executeQuery(`MATCH (n)
|
|
216
|
+
WHERE coalesce(n.status, '') <> 'archived'
|
|
217
|
+
AND n.embedding_model = $model
|
|
218
|
+
RETURN count(n) AS count`, { model: embedder.id });
|
|
219
|
+
embedded = embeddedRow?.count ?? 0;
|
|
220
|
+
}
|
|
221
|
+
return jsonResult({
|
|
222
|
+
nodes: nodeRow?.count ?? 0,
|
|
223
|
+
relationships: relationshipRow?.count ?? 0,
|
|
224
|
+
labels: Object.fromEntries(labelRows.map((row) => [row.label, row.count])),
|
|
225
|
+
relationship_types: Object.fromEntries(relationshipTypeRows.map((row) => [row.type, row.count])),
|
|
226
|
+
embedded,
|
|
227
|
+
embedder: embedderId,
|
|
228
|
+
orphans: orphanRow?.count ?? 0
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
case 'dream': {
|
|
232
|
+
if (!isDreamArgs(args)) {
|
|
233
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid dream arguments');
|
|
234
|
+
}
|
|
235
|
+
const dryRun = args.dry_run ?? false;
|
|
236
|
+
const notes = [];
|
|
237
|
+
const labelRows = await neo4jClient.executeQuery('CALL db.labels() YIELD label RETURN label ORDER BY label');
|
|
238
|
+
let relabelled = 0;
|
|
239
|
+
for (const row of labelRows) {
|
|
240
|
+
const sourceLabel = row.label;
|
|
241
|
+
if (!isAllLowercase(sourceLabel)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const targetLabel = capitalizeLabel(sourceLabel);
|
|
245
|
+
if (targetLabel === sourceLabel) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const escapedSource = escapeIdentifier(sourceLabel);
|
|
249
|
+
const escapedTarget = escapeIdentifier(targetLabel);
|
|
250
|
+
if (dryRun) {
|
|
251
|
+
const [countRow] = await neo4jClient.executeQuery(`MATCH (n:\`${escapedSource}\`) RETURN count(n) AS count`);
|
|
252
|
+
relabelled += countRow?.count ?? 0;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const [countRow] = await neo4jClient.executeQuery(`MATCH (n:\`${escapedSource}\`)
|
|
256
|
+
REMOVE n:\`${escapedSource}\`
|
|
257
|
+
SET n:\`${escapedTarget}\`, n.embedding_model = null
|
|
258
|
+
RETURN count(n) AS count`);
|
|
259
|
+
relabelled += countRow?.count ?? 0;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const duplicateRows = await neo4jClient.executeQuery(`MATCH (n)
|
|
263
|
+
WHERE coalesce(n.status, '') <> 'archived' AND n.name IS :: STRING
|
|
264
|
+
RETURN id(n) AS id,
|
|
265
|
+
labels(n)[0] AS label,
|
|
266
|
+
n.name AS name,
|
|
267
|
+
n.created_at AS created_at,
|
|
268
|
+
COUNT { (n)--() } AS rel_count
|
|
269
|
+
ORDER BY labels(n)[0], toLower(n.name), n.created_at, id(n)`);
|
|
270
|
+
const duplicates = groupDuplicates(duplicateRows);
|
|
271
|
+
const identity = await loadIdentityFields(neo4jClient, duplicates.flat().map((node) => node.id));
|
|
272
|
+
const duplicateReport = [];
|
|
273
|
+
let apocAvailable = true;
|
|
274
|
+
try {
|
|
275
|
+
await neo4jClient.executeQuery('RETURN apoc.version() AS v');
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
apocAvailable = false;
|
|
279
|
+
}
|
|
280
|
+
let merged = 0;
|
|
281
|
+
if (!apocAvailable && duplicates.length > 0) {
|
|
282
|
+
notes.push('APOC unavailable; duplicates were listed but not merged');
|
|
283
|
+
}
|
|
284
|
+
for (const group of duplicates) {
|
|
285
|
+
const [keep, ...rest] = group;
|
|
286
|
+
const report = {
|
|
287
|
+
label: keep.effectiveLabel,
|
|
288
|
+
name: String(keep.name),
|
|
289
|
+
keep: keep.id,
|
|
290
|
+
merged: [],
|
|
291
|
+
skipped: []
|
|
292
|
+
};
|
|
293
|
+
// The survivor absorbs the identity fields of everything merged into it, so later
|
|
294
|
+
// duplicates are compared against the accumulated set, not the pre-merge snapshot.
|
|
295
|
+
const keepIdentity = { ...(identity.get(keep.id) ?? {}) };
|
|
296
|
+
for (const duplicate of rest) {
|
|
297
|
+
const duplicateIdentity = identity.get(duplicate.id);
|
|
298
|
+
const conflict = identityConflict(keepIdentity, duplicateIdentity);
|
|
299
|
+
if (conflict) {
|
|
300
|
+
report.skipped.push({ id: duplicate.id, reason: `different ${conflict}` });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!apocAvailable) {
|
|
304
|
+
report.skipped.push({ id: duplicate.id, reason: 'APOC unavailable' });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (!dryRun) {
|
|
308
|
+
// Keep the survivor's identity and vector fields; combine everything else so no value is
|
|
309
|
+
// dropped (conflicts become lists). Clearing embedding_model makes the re-embed step below
|
|
310
|
+
// refresh the merged node.
|
|
311
|
+
await neo4jClient.executeQuery(`MATCH (keep) WHERE id(keep) = $keepId
|
|
312
|
+
MATCH (dup) WHERE id(dup) = $dupId
|
|
313
|
+
CALL apoc.refactor.mergeNodes([keep, dup], {properties: $strategy, mergeRels: true})
|
|
314
|
+
YIELD node
|
|
315
|
+
SET node.embedding_model = null
|
|
316
|
+
RETURN count(node) AS count`, {
|
|
317
|
+
keepId: neo4j.int(keep.id),
|
|
318
|
+
dupId: neo4j.int(duplicate.id),
|
|
319
|
+
strategy: MERGE_PROPERTY_STRATEGY
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
for (const key of IDENTITY_KEYS) {
|
|
323
|
+
const value = duplicateIdentity?.[key];
|
|
324
|
+
if (value !== undefined && keepIdentity[key] === undefined) {
|
|
325
|
+
keepIdentity[key] = value;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
report.merged.push(duplicate.id);
|
|
329
|
+
merged += 1;
|
|
330
|
+
}
|
|
331
|
+
duplicateReport.push(report);
|
|
332
|
+
}
|
|
333
|
+
let reembedded = 0;
|
|
334
|
+
if (embedder) {
|
|
335
|
+
const staleWhere = '(n.embedding_model IS NULL OR n.embedding_model <> $model OR n.name_embedding IS NULL)';
|
|
336
|
+
if (dryRun) {
|
|
337
|
+
const [staleRow] = await neo4jClient.executeQuery(`MATCH (n) WHERE ${staleWhere} RETURN count(n) AS count`, { model: embedder.id });
|
|
338
|
+
reembedded = staleRow?.count ?? 0;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// Page through stale nodes by id so a large graph is never materialised at once, and
|
|
342
|
+
// leave the stored vectors out of the wire payload.
|
|
343
|
+
let cursor = -1;
|
|
344
|
+
for (;;) {
|
|
345
|
+
const page = await neo4jClient.executeQuery(`MATCH (n)
|
|
346
|
+
WHERE id(n) > $cursor AND ${staleWhere}
|
|
347
|
+
RETURN id(n) AS id, labels(n)[0] AS label, n {.*, embedding: null, name_embedding: null} AS props
|
|
348
|
+
ORDER BY id(n) LIMIT 50`, { model: embedder.id, cursor: neo4j.int(cursor) });
|
|
349
|
+
if (page.length === 0) {
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
cursor = page[page.length - 1].id;
|
|
353
|
+
const batch = page.map((candidate) => ({
|
|
354
|
+
id: candidate.id,
|
|
355
|
+
label: candidate.label || 'Memory',
|
|
356
|
+
props: candidate.props || {}
|
|
357
|
+
}));
|
|
358
|
+
try {
|
|
359
|
+
reembedded += await persistCandidateEmbeddings(neo4jClient, embedder, batch);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
console.error('Embedding unavailable for dream:', error);
|
|
363
|
+
notes.push(`re-embedding stopped after ${reembedded} nodes: ${error instanceof Error ? error.message : String(error)}`);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const [currentOrphanRow] = await neo4jClient.executeQuery(`MATCH (n)
|
|
370
|
+
WHERE coalesce(n.status, '') <> 'archived'
|
|
371
|
+
AND NOT (n)--()
|
|
372
|
+
RETURN count(n) AS count`);
|
|
373
|
+
// Simulate only what would actually merge: the survivor plus the merged ids, never the skipped ones.
|
|
374
|
+
const mergedGroups = duplicateReport
|
|
375
|
+
.filter((report) => report.merged.length > 0)
|
|
376
|
+
.map((report) => {
|
|
377
|
+
const group = duplicates.find((candidate) => candidate[0].id === report.keep) ?? [];
|
|
378
|
+
const retained = new Set([report.keep, ...report.merged]);
|
|
379
|
+
return group.filter((node) => retained.has(node.id));
|
|
380
|
+
});
|
|
381
|
+
const orphans = dryRun
|
|
382
|
+
? simulateOrphansAfterMerge(currentOrphanRow?.count ?? 0, mergedGroups)
|
|
383
|
+
: currentOrphanRow?.count ?? 0;
|
|
384
|
+
const bloated = await findBloatedNodes(neo4jClient, maxProperties());
|
|
385
|
+
if (bloated.length > 0) {
|
|
386
|
+
notes.push(`${bloated.length} node(s) exceed ${maxProperties()} properties; split their fact-like keys into their own memories (see bloated)`);
|
|
387
|
+
}
|
|
388
|
+
return jsonResult({
|
|
389
|
+
dry_run: dryRun,
|
|
390
|
+
relabelled,
|
|
391
|
+
merged,
|
|
392
|
+
reembedded,
|
|
393
|
+
orphans,
|
|
394
|
+
duplicates: duplicateReport,
|
|
395
|
+
bloated,
|
|
396
|
+
apoc_available: apocAvailable,
|
|
397
|
+
notes
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
case 'get_guidance': {
|
|
401
|
+
if (!isGetGuidanceArgs(args)) {
|
|
402
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid get_guidance arguments');
|
|
403
|
+
}
|
|
404
|
+
const topic = typeof args === 'object' && args !== null ? args.topic : undefined;
|
|
405
|
+
return {
|
|
406
|
+
content: [
|
|
407
|
+
{
|
|
408
|
+
type: 'text',
|
|
409
|
+
text: getGuidanceContent(topic),
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
default:
|
|
415
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
console.error('Error executing tool:', error);
|
|
420
|
+
return {
|
|
421
|
+
content: [
|
|
422
|
+
{
|
|
423
|
+
type: 'text',
|
|
424
|
+
text: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
isError: true,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async function rankCandidates(neo4jClient, candidates, query, requestedMode, threshold, embedder) {
|
|
432
|
+
const trimmedQuery = query.trim();
|
|
433
|
+
let effectiveMode = requestedMode;
|
|
434
|
+
let queryEmbedding;
|
|
435
|
+
if (trimmedQuery && requestedMode !== 'keyword' && requestedMode !== 'exact') {
|
|
436
|
+
if (!embedder) {
|
|
437
|
+
effectiveMode = 'keyword';
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
try {
|
|
441
|
+
const lazyBatch = lazyEmbedBatch();
|
|
442
|
+
const candidatesToEmbed = candidates.filter((candidate) => needsEmbedding(candidate.props, embedder.id)).slice(0, lazyBatch);
|
|
443
|
+
if (candidatesToEmbed.length > 0) {
|
|
444
|
+
await persistCandidateEmbeddings(neo4jClient, embedder, candidatesToEmbed);
|
|
445
|
+
}
|
|
446
|
+
const [vector] = await embedder.embed([trimmedQuery]);
|
|
447
|
+
if (!Array.isArray(vector) || vector.length === 0) {
|
|
448
|
+
throw new Error('Embedding provider returned no vector for the query');
|
|
449
|
+
}
|
|
450
|
+
queryEmbedding = vector;
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
console.error('Embedding unavailable for search_memories:', error);
|
|
454
|
+
effectiveMode = 'keyword';
|
|
455
|
+
queryEmbedding = undefined;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return rank(candidates, {
|
|
460
|
+
query,
|
|
461
|
+
mode: effectiveMode,
|
|
462
|
+
queryEmbedding,
|
|
463
|
+
threshold,
|
|
464
|
+
modelId: embedder?.id
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async function persistCandidateEmbeddings(neo4jClient, embedder, candidates) {
|
|
468
|
+
const vectors = await embedNodes(embedder, candidates);
|
|
469
|
+
const embeddedAt = new Date().toISOString();
|
|
470
|
+
let persisted = 0;
|
|
471
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
472
|
+
const candidate = candidates[index];
|
|
473
|
+
const { embedding, name_embedding } = vectors[index];
|
|
474
|
+
if (embedding.length === 0) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
await neo4jClient.executeQuery(EMBED_NODE_CYPHER, {
|
|
478
|
+
id: neo4j.int(candidate.id),
|
|
479
|
+
embedding,
|
|
480
|
+
nameEmbedding: name_embedding,
|
|
481
|
+
model: embedder.id,
|
|
482
|
+
embeddedAt
|
|
483
|
+
});
|
|
484
|
+
Object.assign(candidate.props, { embedding, name_embedding, embedding_model: embedder.id, embedded_at: embeddedAt });
|
|
485
|
+
persisted += 1;
|
|
486
|
+
}
|
|
487
|
+
return persisted;
|
|
488
|
+
}
|
|
489
|
+
async function embedNodeIfPossible(neo4jClient, embedder, memory, label, toolName) {
|
|
490
|
+
if (!embedder || !memory || typeof memory._id !== 'number') {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const [{ embedding, name_embedding }] = await embedNodes(embedder, [{ label, props: toNodeProperties(memory) }]);
|
|
495
|
+
if (embedding.length === 0) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const embeddedAt = new Date().toISOString();
|
|
499
|
+
await neo4jClient.executeQuery(EMBED_NODE_CYPHER, {
|
|
500
|
+
id: neo4j.int(memory._id),
|
|
501
|
+
embedding,
|
|
502
|
+
nameEmbedding: name_embedding,
|
|
503
|
+
model: embedder.id,
|
|
504
|
+
embeddedAt
|
|
505
|
+
});
|
|
506
|
+
Object.assign(memory, { embedding, name_embedding, embedding_model: embedder.id, embedded_at: embeddedAt });
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
console.error(`Embedding unavailable for ${toolName}:`, error);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function fetchMemories(neo4jClient, memoryIds, depth, orderBy, limit, includeArchived) {
|
|
513
|
+
let finalQuery = `
|
|
514
|
+
MATCH (memory)
|
|
515
|
+
WHERE id(memory) IN $memoryIds
|
|
516
|
+
`;
|
|
517
|
+
if (depth > 0) {
|
|
518
|
+
// Archived nodes are hidden from the neighbourhood too, and a path through one is not followed.
|
|
519
|
+
finalQuery += `
|
|
520
|
+
OPTIONAL MATCH path = (memory)-[*1..${depth}]-(related)
|
|
521
|
+
WHERE $includeArchived OR all(x IN nodes(path) WHERE coalesce(x.status, '') <> 'archived')
|
|
522
|
+
RETURN memory, collect(DISTINCT {
|
|
523
|
+
memory: related,
|
|
524
|
+
relationship: relationships(path)[0],
|
|
525
|
+
distance: length(path)
|
|
526
|
+
}) AS connections
|
|
527
|
+
ORDER BY ${orderBy}
|
|
528
|
+
LIMIT ${limit}
|
|
529
|
+
`;
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
finalQuery += `
|
|
533
|
+
RETURN memory, [] AS connections
|
|
534
|
+
ORDER BY ${orderBy}
|
|
535
|
+
LIMIT ${limit}
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
return neo4jClient.executeQuery(finalQuery, { memoryIds, includeArchived });
|
|
539
|
+
}
|
|
540
|
+
/** Identity-bearing properties for the nodes in duplicate groups, lower-cased for comparison. */
|
|
541
|
+
async function loadIdentityFields(neo4jClient, ids) {
|
|
542
|
+
const fields = new Map();
|
|
543
|
+
if (ids.length === 0) {
|
|
544
|
+
return fields;
|
|
545
|
+
}
|
|
546
|
+
const rows = await neo4jClient.executeQuery('MATCH (n) WHERE id(n) IN $ids RETURN id(n) AS id, properties(n) AS props', { ids: ids.map((id) => neo4j.int(id)) });
|
|
547
|
+
for (const row of rows) {
|
|
548
|
+
const entry = {};
|
|
549
|
+
for (const key of IDENTITY_KEYS) {
|
|
550
|
+
const value = row.props[key];
|
|
551
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
552
|
+
entry[key] = value.trim().toLowerCase();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
fields.set(row.id, entry);
|
|
556
|
+
}
|
|
557
|
+
return fields;
|
|
558
|
+
}
|
|
559
|
+
/** The first identity key both nodes carry with different values, or null when nothing distinguishes them. */
|
|
560
|
+
function identityConflict(left, right) {
|
|
561
|
+
for (const key of IDENTITY_KEYS) {
|
|
562
|
+
const a = left?.[key];
|
|
563
|
+
const b = right?.[key];
|
|
564
|
+
if (a !== undefined && b !== undefined && a !== b) {
|
|
565
|
+
return key;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
/** Nodes carrying more than `limit` real properties, worst first, with the keys that should become their own memories. */
|
|
571
|
+
async function findBloatedNodes(neo4jClient, limit) {
|
|
572
|
+
const rows = await neo4jClient.executeQuery(`MATCH (n)
|
|
573
|
+
WHERE coalesce(n.status, '') <> 'archived' AND size(keys(n)) > $limit
|
|
574
|
+
RETURN id(n) AS id, labels(n)[0] AS label, properties(n) AS props`, { limit: neo4j.int(limit) });
|
|
575
|
+
return rows
|
|
576
|
+
.map((row) => ({
|
|
577
|
+
id: row.id,
|
|
578
|
+
label: row.label || 'Memory',
|
|
579
|
+
name: String(row.props.name ?? row.id),
|
|
580
|
+
properties: contentKeys(row.props).length,
|
|
581
|
+
fact_like_keys: factLikeKeys(row.props).slice(0, 25)
|
|
582
|
+
}))
|
|
583
|
+
.filter((node) => node.properties > limit)
|
|
584
|
+
.sort((left, right) => right.properties - left.properties)
|
|
585
|
+
.slice(0, 20);
|
|
586
|
+
}
|
|
587
|
+
function jsonResult(value) {
|
|
588
|
+
return {
|
|
589
|
+
content: [
|
|
590
|
+
{
|
|
591
|
+
type: 'text',
|
|
592
|
+
text: JSON.stringify(scrub(value), null, 2),
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
/** Stale when embedded by another model/provider, or before the name vector was introduced. */
|
|
598
|
+
function needsEmbedding(props, modelId) {
|
|
599
|
+
return props.embedding_model !== modelId || !Array.isArray(props.name_embedding);
|
|
600
|
+
}
|
|
601
|
+
function toNodeProperties(memory) {
|
|
602
|
+
return Object.fromEntries(Object.entries(memory).filter(([key]) => !key.startsWith('_')));
|
|
603
|
+
}
|
|
604
|
+
function isAllLowercase(value) {
|
|
605
|
+
return value.length > 0 && value === value.toLowerCase() && value !== value.toUpperCase();
|
|
606
|
+
}
|
|
607
|
+
function capitalizeLabel(value) {
|
|
608
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
609
|
+
}
|
|
610
|
+
function escapeIdentifier(value) {
|
|
611
|
+
return value.replace(/`/g, '``');
|
|
612
|
+
}
|
|
613
|
+
function groupDuplicates(rows) {
|
|
614
|
+
const byGroup = new Map();
|
|
615
|
+
for (const row of rows) {
|
|
616
|
+
const effectiveLabel = isAllLowercase(row.label) ? capitalizeLabel(row.label) : row.label;
|
|
617
|
+
const node = {
|
|
618
|
+
...row,
|
|
619
|
+
effectiveLabel
|
|
620
|
+
};
|
|
621
|
+
const key = `${effectiveLabel}\u0000${String(row.name).toLowerCase()}`;
|
|
622
|
+
const group = byGroup.get(key) ?? [];
|
|
623
|
+
group.push(node);
|
|
624
|
+
byGroup.set(key, group);
|
|
625
|
+
}
|
|
626
|
+
return [...byGroup.values()]
|
|
627
|
+
.filter((group) => group.length > 1)
|
|
628
|
+
.map((group) => [...group].sort(compareDuplicateNodes))
|
|
629
|
+
.sort((left, right) => {
|
|
630
|
+
const leftKey = `${left[0].effectiveLabel}/${String(left[0].name).toLowerCase()}`;
|
|
631
|
+
const rightKey = `${right[0].effectiveLabel}/${String(right[0].name).toLowerCase()}`;
|
|
632
|
+
return leftKey.localeCompare(rightKey);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
function compareDuplicateNodes(left, right) {
|
|
636
|
+
const leftCreatedAt = String(left.created_at ?? '');
|
|
637
|
+
const rightCreatedAt = String(right.created_at ?? '');
|
|
638
|
+
if (leftCreatedAt !== rightCreatedAt) {
|
|
639
|
+
return leftCreatedAt.localeCompare(rightCreatedAt);
|
|
640
|
+
}
|
|
641
|
+
return left.id - right.id;
|
|
642
|
+
}
|
|
643
|
+
function simulateOrphansAfterMerge(currentOrphans, duplicates) {
|
|
644
|
+
let orphans = currentOrphans;
|
|
645
|
+
for (const group of duplicates) {
|
|
646
|
+
const orphanCount = group.filter((node) => node.rel_count === 0).length;
|
|
647
|
+
if (orphanCount === 0) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const allOrphans = orphanCount === group.length;
|
|
651
|
+
orphans -= allOrphans ? orphanCount - 1 : orphanCount;
|
|
652
|
+
}
|
|
653
|
+
return orphans;
|
|
654
|
+
}
|