@knowall-ai/reverie 0.5.2 → 0.5.4
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/README.md +27 -18
- package/build/fuzzy.js +103 -0
- package/build/handlers/index.js +13 -8
- package/build/search.js +48 -23
- package/build/server.js +2 -1
- package/build/tools/definitions.js +10 -3
- package/build/tools/guidance-tool.js +7 -2
- package/build/types.js +7 -2
- package/build/version.js +17 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Reverie — graph memory that dreams
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://coderabbit.ai)
|
|
4
|
+
|
|
5
|
+
**Reverie** is KnowAll AI's Neo4j knowledge-graph memory for AI agents, served over MCP. It is published on npm as [`@knowall-ai/reverie`](https://www.npmjs.com/package/@knowall-ai/reverie) (successor to the legacy package `@knowall-ai/mcp-neo4j-agent-memory`). The GitHub repository is now `knowall-ai/mcp-reverie`, and old URLs redirect.
|
|
4
6
|
|
|
5
7
|

|
|
6
8
|
|
|
@@ -9,7 +11,7 @@ Reverie turns an agent's memory from a pile of facts into a **map of the entitie
|
|
|
9
11
|
## Why Reverie
|
|
10
12
|
|
|
11
13
|
- **A typed entity graph, not a fact store.** People, organisations, projects, places, concepts, meetings and decisions are nodes with typed relationships. "Who at the Irish FA have we talked to about Winnie?" is a graph walk, not a similarity search.
|
|
12
|
-
- **Search that finds "Ben" when you say "Benjamin".** Hybrid keyword + semantic search, with local embeddings by default (no API key) and OpenAI, Azure OpenAI, Ollama or Voyage a config switch away.
|
|
14
|
+
- **Search that finds "Ben" when you say "Benjamin".** Hybrid keyword + semantic + fuzzy name search, with local embeddings by default (no API key) and OpenAI, Azure OpenAI, Ollama or Voyage a config switch away.
|
|
13
15
|
- **It dreams.** A `dream` tool merges duplicates safely, canonicalises labels, re-embeds, counts orphans and flags nodes that have become property dumps, so a nightly job can keep the graph clean.
|
|
14
16
|
- **One graph, any agent.** KnowAll runs Sallie (OpenClaw) and Poppie (Hermes) against the same conventions; Reverie is how they share what they know.
|
|
15
17
|
- **LLM-driven, transparent tools.** Simple atomic operations; the model does the entity recognition and conflict resolution, and every action is explicit.
|
|
@@ -20,7 +22,7 @@ Reverie turns an agent's memory from a pile of facts into a **map of the entitie
|
|
|
20
22
|
You can run this MCP server directly using npx:
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
|
-
npx
|
|
25
|
+
npx -y @knowall-ai/reverie
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
Or add it to your Claude Desktop configuration:
|
|
@@ -28,9 +30,9 @@ Or add it to your Claude Desktop configuration:
|
|
|
28
30
|
```json
|
|
29
31
|
{
|
|
30
32
|
"mcpServers": {
|
|
31
|
-
"
|
|
33
|
+
"reverie": {
|
|
32
34
|
"command": "npx",
|
|
33
|
-
"args": ["@knowall-ai/
|
|
35
|
+
"args": ["-y", "@knowall-ai/reverie"],
|
|
34
36
|
"env": {
|
|
35
37
|
"NEO4J_URI": "bolt://localhost:7687",
|
|
36
38
|
"NEO4J_USERNAME": "neo4j",
|
|
@@ -64,22 +66,23 @@ Unlike traditional approaches that embed complex logic in tools, this server pro
|
|
|
64
66
|
- **Transparent operations**: Every action is explicit and predictable
|
|
65
67
|
- **Maximum flexibility**: The LLM can implement any strategy without tool limitations
|
|
66
68
|
|
|
67
|
-
### Search
|
|
68
|
-
`search_memories` is hybrid:
|
|
69
|
+
### Search Behaviour
|
|
70
|
+
`search_memories` is hybrid: each result ranks by its best keyword, semantic or fuzzy name signal. See **Search** below.
|
|
69
71
|
|
|
70
72
|
This approach makes the system more powerful and adaptable, as improvements in LLM capabilities directly translate to better memory management.
|
|
71
73
|
|
|
72
74
|
## Search
|
|
73
75
|
|
|
74
|
-
`search_memories`
|
|
75
|
-
- `hybrid` (default):
|
|
76
|
+
`search_memories` supports four matching signals — keyword, semantic, fuzzy and exact — through these modes:
|
|
77
|
+
- `hybrid` (default): rank by the best signal — keyword share of query words, semantic cosine or fuzzy name similarity
|
|
76
78
|
- `keyword`: any word of the query as a substring of any searchable content property (timestamps, `status` and embedding fields are never matched)
|
|
77
|
-
- `semantic`:
|
|
79
|
+
- `semantic`: embeddings only; if embeddings are unavailable it falls back to keyword matching (and `hybrid` keeps its keyword and fuzzy signals)
|
|
80
|
+
- `fuzzy`: match misspelled or misheard names and aliases using spelling, prefixes and phonetics; "Ben Wicks" can find "Benjamin Weeks" without embeddings
|
|
78
81
|
- `exact`: case-insensitive equality on `name`, `aliases` or `email`; the precise lookup to run before creating a memory, so "ben weeks" finds exactly "Ben Weeks" and nothing else
|
|
79
82
|
|
|
80
83
|
Archived memories (`status = 'archived'`) are left out of results and of `list_memory_labels` unless `include_archived: true` is passed. Returned relationships carry `_start` and `_end` node ids, so a connection's direction is always recoverable.
|
|
81
84
|
|
|
82
|
-
Use `similarity_threshold` (default `0.4`; must be between `0` and `1`, other values are rejected) to control how strict semantic matches are. Results include `_score
|
|
85
|
+
Use `similarity_threshold` (default `0.4`; must be between `0` and `1`, other values are rejected) to control how strict semantic matches are. Use `fuzzy_threshold` (default `0.85`, range `0`–`1`) for fuzzy strictness. Results include `_score`, `_match` and, for fuzzy hits, `_matched` (the stored name or alias). Confirm differing names with the user; nicknames such as Bill/William need `aliases`.
|
|
83
86
|
|
|
84
87
|
### Neo4j Enterprise Support
|
|
85
88
|
|
|
@@ -88,10 +91,10 @@ This server now supports connecting to specific databases in Neo4j Enterprise Ed
|
|
|
88
91
|
### Memory Tools
|
|
89
92
|
|
|
90
93
|
- `search_memories`: Search and retrieve memories from the knowledge graph
|
|
91
|
-
- **Hybrid search**: Blend keyword and
|
|
92
|
-
- Choose `search_mode` = `hybrid`, `keyword`, or `
|
|
93
|
-
- Tune
|
|
94
|
-
- Returned memories include `_score` and `
|
|
94
|
+
- **Hybrid search**: Blend keyword, semantic and fuzzy name search; `Ben Wicks` can find `Benjamin Weeks`
|
|
95
|
+
- Choose `search_mode` = `hybrid`, `keyword`, `semantic`, `fuzzy` or `exact`
|
|
96
|
+
- Tune `similarity_threshold` (default `0.4`) and `fuzzy_threshold` (default `0.85`)
|
|
97
|
+
- Returned memories include `_score`, `_match` and `_matched` for fuzzy hits
|
|
95
98
|
- Filter by memory type (case-insensitive, so `person` and `Person` both work), date, depth, result limit, and sort order
|
|
96
99
|
|
|
97
100
|
- `create_memory`: Create a new memory in the knowledge graph
|
|
@@ -168,7 +171,7 @@ Reverie is listed in the [MCP Registry](https://registry.modelcontextprotocol.io
|
|
|
168
171
|
### Installing via Smithery
|
|
169
172
|
|
|
170
173
|
|
|
171
|
-
To install Reverie for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@knowall-ai/mcp-neo4j-agent-memory):
|
|
174
|
+
To install Reverie for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@knowall-ai/mcp-neo4j-agent-memory) (currently indexed under its legacy package name):
|
|
172
175
|
|
|
173
176
|
```bash
|
|
174
177
|
npx -y @smithery/cli install @knowall-ai/mcp-neo4j-agent-memory --client claude
|
|
@@ -218,7 +221,7 @@ Set `REVERIE_EMBEDDINGS` to choose the embedding provider used by hybrid and sem
|
|
|
218
221
|
|
|
219
222
|
Use `REVERIE_EMBEDDING_MODEL` to override the model name for any provider. Remote providers batch up to 64 texts per request, time out after `REVERIE_EMBED_TIMEOUT_MS` (default 30000, max 120000), and must be reached over https; plain http is only accepted for localhost.
|
|
220
223
|
|
|
221
|
-
The `local` provider downloads its model (about 23 MB) from Hugging Face on first use and caches it. If that download fails, or any provider errors, search
|
|
224
|
+
The `local` provider downloads its model (about 23 MB) from Hugging Face on first use and caches it. If that download fails, or any provider errors, semantic search falls back to keyword matching and hybrid retains keyword + fuzzy matching for that call and the error is logged to stderr. Set `REVERIE_EMBEDDINGS=none` to turn embeddings off entirely.
|
|
222
225
|
|
|
223
226
|
Each node stores two vectors: `embedding` (label, name and every text property) and `name_embedding` (label, name and aliases only), plus `embedding_model` and `embedded_at`. A semantic score is the better of the two, so a short query like `Ben Weeks` still matches a richly described `Benjamin Weeks`. None of these fields are ever returned by the tools.
|
|
224
227
|
|
|
@@ -372,7 +375,7 @@ I found several people matching your search:
|
|
|
372
375
|
The most likely match appears to be Ben Weeks, the Software Engineer.
|
|
373
376
|
```
|
|
374
377
|
|
|
375
|
-
|
|
378
|
+
Keyword matching finds ANY word from your query and scores the fraction found. Hybrid search also considers semantic and fuzzy name matches, ranking each result by its best signal.
|
|
376
379
|
|
|
377
380
|
#### Recent Memories (Last 7 Days)
|
|
378
381
|
```
|
|
@@ -506,6 +509,12 @@ CI runs the same suite against a Neo4j service container on every pull request a
|
|
|
506
509
|
build if coverage drops below the gate. The first semantic search downloads the local embedding
|
|
507
510
|
model (about 23 MB); CI caches it.
|
|
508
511
|
|
|
512
|
+
A second CI check, `Version bumped with src/` (`scripts/check-version.mjs`), keeps the version
|
|
513
|
+
honest: `server.json`'s `version` and each `packages[].version` must equal `package.json`'s, and a
|
|
514
|
+
pull request that changes `src/` must raise `package.json`'s version above the base branch's
|
|
515
|
+
(SemVer). To fix it, run `npm version patch --no-git-tag-version` and set the same version in both
|
|
516
|
+
places in `server.json`.
|
|
517
|
+
|
|
509
518
|
### Interactive Testing with MCP Inspector
|
|
510
519
|
|
|
511
520
|
For interactive testing and debugging, use the MCP Inspector:
|
package/build/fuzzy.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { doubleMetaphone } from 'double-metaphone';
|
|
2
|
+
/** Default cut-off for a fuzzy name match, calibrated so Wicks≈Weeks and Grimsby≈Grimshaw match but Jones≠James. */
|
|
3
|
+
export const FUZZY_DEFAULT_THRESHOLD = 0.85;
|
|
4
|
+
/** Phonetic codes by token; cleared when it reaches 50,000 entries. */
|
|
5
|
+
const phoneticCache = new Map();
|
|
6
|
+
/** Lower-case name tokens with diacritics stripped: "Zoë-Smith" → ["zoe", "smith"]. */
|
|
7
|
+
export function nameTokens(value) {
|
|
8
|
+
return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
/** Jaro-Winkler similarity (0..1), favouring strings that share a prefix; good for short names. */
|
|
11
|
+
export function jaroWinkler(a, b) {
|
|
12
|
+
if (a === b)
|
|
13
|
+
return 1;
|
|
14
|
+
if (!a || !b)
|
|
15
|
+
return 0;
|
|
16
|
+
const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
|
|
17
|
+
const aMatches = new Array(a.length).fill(false);
|
|
18
|
+
const bMatches = new Array(b.length).fill(false);
|
|
19
|
+
let matches = 0;
|
|
20
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
21
|
+
for (let j = Math.max(0, i - window); j < Math.min(b.length, i + window + 1); j += 1) {
|
|
22
|
+
if (!bMatches[j] && a[i] === b[j]) {
|
|
23
|
+
aMatches[i] = true;
|
|
24
|
+
bMatches[j] = true;
|
|
25
|
+
matches += 1;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!matches)
|
|
31
|
+
return 0;
|
|
32
|
+
let differences = 0;
|
|
33
|
+
let j = 0;
|
|
34
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
35
|
+
if (!aMatches[i])
|
|
36
|
+
continue;
|
|
37
|
+
while (!bMatches[j])
|
|
38
|
+
j += 1;
|
|
39
|
+
if (a[i] !== b[j])
|
|
40
|
+
differences += 1;
|
|
41
|
+
j += 1;
|
|
42
|
+
}
|
|
43
|
+
const jaro = (matches / a.length + matches / b.length + (matches - differences / 2) / matches) / 3;
|
|
44
|
+
let prefix = 0;
|
|
45
|
+
while (prefix < Math.min(4, a.length, b.length) && a[prefix] === b[prefix])
|
|
46
|
+
prefix += 1;
|
|
47
|
+
return jaro > 0.7 ? jaro + prefix * 0.1 * (1 - jaro) : jaro;
|
|
48
|
+
}
|
|
49
|
+
/** Double Metaphone codes for a token (memoised); sound-alikes such as Wicks and Weeks share a code. */
|
|
50
|
+
export function phoneticCodes(token) {
|
|
51
|
+
const cached = phoneticCache.get(token);
|
|
52
|
+
if (cached)
|
|
53
|
+
return cached;
|
|
54
|
+
const codes = [...new Set(doubleMetaphone(token))].filter((code) => code.length >= 2);
|
|
55
|
+
if (phoneticCache.size >= 50_000)
|
|
56
|
+
phoneticCache.clear();
|
|
57
|
+
phoneticCache.set(token, codes);
|
|
58
|
+
return codes;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Similarity of two normalised tokens: 1 when equal, at least 0.9 for a prefix (ben → benjamin) or shared phonetic
|
|
62
|
+
* code, otherwise Jaro-Winkler. Tokens under three characters only match exactly.
|
|
63
|
+
*/
|
|
64
|
+
export function tokenSimilarity(query, candidate) {
|
|
65
|
+
if (query === candidate)
|
|
66
|
+
return 1;
|
|
67
|
+
if (query.length < 3 || candidate.length < 3)
|
|
68
|
+
return 0;
|
|
69
|
+
const jw = jaroWinkler(query, candidate);
|
|
70
|
+
if (query.startsWith(candidate) || candidate.startsWith(query))
|
|
71
|
+
return Math.max(0.9, jw);
|
|
72
|
+
const candidateCodes = phoneticCodes(candidate);
|
|
73
|
+
if (phoneticCodes(query).some((code) => candidateCodes.includes(code)))
|
|
74
|
+
return Math.max(0.9, jw);
|
|
75
|
+
return jw;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Best fuzzy match of a query against name/alias values: the mean over query tokens of each token's best similarity
|
|
79
|
+
* (tokens below the threshold count as 0). Returns the score and matched value, or null below the threshold.
|
|
80
|
+
*/
|
|
81
|
+
export function fuzzyNameScore(query, values, threshold) {
|
|
82
|
+
const queryTokens = nameTokens(query);
|
|
83
|
+
if (!queryTokens.length)
|
|
84
|
+
return null;
|
|
85
|
+
let best = null;
|
|
86
|
+
for (const value of values) {
|
|
87
|
+
const tokens = nameTokens(value);
|
|
88
|
+
if (!tokens.length)
|
|
89
|
+
continue;
|
|
90
|
+
let total = 0;
|
|
91
|
+
for (const queryToken of queryTokens) {
|
|
92
|
+
let tokenScore = 0;
|
|
93
|
+
for (const token of tokens)
|
|
94
|
+
tokenScore = Math.max(tokenScore, tokenSimilarity(queryToken, token));
|
|
95
|
+
if (tokenScore >= threshold)
|
|
96
|
+
total += tokenScore;
|
|
97
|
+
}
|
|
98
|
+
const score = total / queryTokens.length;
|
|
99
|
+
if (score >= threshold && (!best || score > best.score))
|
|
100
|
+
best = { score, matched: value };
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
package/build/handlers/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import neo4j from 'neo4j-driver';
|
|
3
3
|
import { embedNodes, scrub } from '../embeddings.js';
|
|
4
|
+
import { FUZZY_DEFAULT_THRESHOLD } from '../fuzzy.js';
|
|
4
5
|
import { readOnlyViolation } from '../cypher-guard.js';
|
|
5
6
|
import { emit } from '../events.js';
|
|
6
7
|
import { bloatHint, contentKeys, factLikeKeys, lazyEmbedBatch, maxProperties } from '../hygiene.js';
|
|
@@ -41,6 +42,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
|
|
|
41
42
|
const query = args.query ?? '';
|
|
42
43
|
const requestedMode = args.search_mode ?? 'hybrid';
|
|
43
44
|
const similarityThreshold = args.similarity_threshold ?? 0.4;
|
|
45
|
+
const fuzzyThreshold = args.fuzzy_threshold ?? FUZZY_DEFAULT_THRESHOLD;
|
|
44
46
|
let orderBy = 'memory.created_at DESC';
|
|
45
47
|
let hasCustomOrder = false;
|
|
46
48
|
if (args.order_by) {
|
|
@@ -67,8 +69,8 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
|
|
|
67
69
|
if (conditions.length > 0) {
|
|
68
70
|
baseQuery += ` WHERE ${conditions.join(' AND ')}`;
|
|
69
71
|
}
|
|
70
|
-
// Keyword
|
|
71
|
-
const propsProjection = requestedMode === 'keyword' || requestedMode === 'exact'
|
|
72
|
+
// Keyword, exact and fuzzy modes never need the stored vectors, so leave them out of the wire payload.
|
|
73
|
+
const propsProjection = requestedMode === 'keyword' || requestedMode === 'exact' || requestedMode === 'fuzzy'
|
|
72
74
|
? 'memory {.*, embedding: null, name_embedding: null}'
|
|
73
75
|
: 'properties(memory)';
|
|
74
76
|
baseQuery += ` RETURN id(memory) AS id, labels(memory)[0] AS label, ${propsProjection} AS props ORDER BY id(memory)`;
|
|
@@ -78,7 +80,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
|
|
|
78
80
|
label: candidate.label || 'Memory',
|
|
79
81
|
props: candidate.props || {}
|
|
80
82
|
}));
|
|
81
|
-
const ranking = await rankCandidates(neo4jClient, candidates, query, requestedMode, similarityThreshold, embedder);
|
|
83
|
+
const ranking = await rankCandidates(neo4jClient, candidates, query, requestedMode, similarityThreshold, fuzzyThreshold, embedder);
|
|
82
84
|
const topRanked = ranking.slice(0, limit);
|
|
83
85
|
const rankedIds = topRanked.map((item) => item.id);
|
|
84
86
|
if (rankedIds.length === 0) {
|
|
@@ -86,7 +88,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
|
|
|
86
88
|
return jsonResult([]);
|
|
87
89
|
}
|
|
88
90
|
const result = await fetchMemories(neo4jClient, rankedIds, depth, orderBy, limit, Boolean(args.include_archived));
|
|
89
|
-
const scoring = new Map(topRanked.map((item) => [item.id, { score: Number(item.score.toFixed(2)), match: item.match }]));
|
|
91
|
+
const scoring = new Map(topRanked.map((item) => [item.id, { score: Number(item.score.toFixed(2)), match: item.match, matched: item.matched }]));
|
|
90
92
|
for (const row of result) {
|
|
91
93
|
const memoryId = row?.memory?._id;
|
|
92
94
|
if (typeof memoryId !== 'number') {
|
|
@@ -96,6 +98,8 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
|
|
|
96
98
|
if (meta) {
|
|
97
99
|
row.memory._score = meta.score;
|
|
98
100
|
row.memory._match = meta.match;
|
|
101
|
+
if (meta.matched !== undefined)
|
|
102
|
+
row.memory._matched = meta.matched;
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
const orderedResult = hasCustomOrder
|
|
@@ -454,13 +458,13 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
|
|
|
454
458
|
};
|
|
455
459
|
}
|
|
456
460
|
}
|
|
457
|
-
async function rankCandidates(neo4jClient, candidates, query, requestedMode, threshold, embedder) {
|
|
461
|
+
async function rankCandidates(neo4jClient, candidates, query, requestedMode, threshold, fuzzyThreshold, embedder) {
|
|
458
462
|
const trimmedQuery = query.trim();
|
|
459
463
|
let effectiveMode = requestedMode;
|
|
460
464
|
let queryEmbedding;
|
|
461
|
-
if (trimmedQuery && requestedMode
|
|
465
|
+
if (trimmedQuery && (requestedMode === 'hybrid' || requestedMode === 'semantic')) {
|
|
462
466
|
if (!embedder) {
|
|
463
|
-
effectiveMode = 'keyword';
|
|
467
|
+
effectiveMode = requestedMode === 'semantic' ? 'keyword' : 'hybrid';
|
|
464
468
|
}
|
|
465
469
|
else {
|
|
466
470
|
try {
|
|
@@ -477,7 +481,7 @@ async function rankCandidates(neo4jClient, candidates, query, requestedMode, thr
|
|
|
477
481
|
}
|
|
478
482
|
catch (error) {
|
|
479
483
|
console.error('Embedding unavailable for search_memories:', error);
|
|
480
|
-
effectiveMode = 'keyword';
|
|
484
|
+
effectiveMode = requestedMode === 'semantic' ? 'keyword' : 'hybrid';
|
|
481
485
|
queryEmbedding = undefined;
|
|
482
486
|
}
|
|
483
487
|
}
|
|
@@ -487,6 +491,7 @@ async function rankCandidates(neo4jClient, candidates, query, requestedMode, thr
|
|
|
487
491
|
mode: effectiveMode,
|
|
488
492
|
queryEmbedding,
|
|
489
493
|
threshold,
|
|
494
|
+
fuzzyThreshold,
|
|
490
495
|
modelId: embedder?.id
|
|
491
496
|
});
|
|
492
497
|
}
|
package/build/search.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cosine } from './embeddings.js';
|
|
2
|
+
import { FUZZY_DEFAULT_THRESHOLD, fuzzyNameScore } from './fuzzy.js';
|
|
2
3
|
import { contentKeys } from './hygiene.js';
|
|
3
4
|
/** Case-insensitive equality on name, aliases or email: the lookup to run before creating a memory. */
|
|
4
5
|
export function exactMatches(query, props) {
|
|
@@ -8,12 +9,18 @@ export function exactMatches(query, props) {
|
|
|
8
9
|
const values = [props.name, props.email, ...(Array.isArray(props.aliases) ? props.aliases : [])];
|
|
9
10
|
return values.some((v) => typeof v === 'string' && v.trim().toLowerCase() === wanted);
|
|
10
11
|
}
|
|
12
|
+
/** True when any query word is a substring of a content property; an empty query matches everything. */
|
|
11
13
|
export function keywordMatches(query, props) {
|
|
14
|
+
return !query.trim() || keywordScore(query, props) > 0;
|
|
15
|
+
}
|
|
16
|
+
/** Share of query words (0..1) found as substrings of any content property; timestamps, status and vectors never match. */
|
|
17
|
+
export function keywordScore(query, props) {
|
|
12
18
|
const trimmedQuery = query.trim().toLowerCase();
|
|
13
19
|
if (!trimmedQuery) {
|
|
14
|
-
return
|
|
20
|
+
return 0;
|
|
15
21
|
}
|
|
16
22
|
const words = trimmedQuery.split(/\s+/);
|
|
23
|
+
const found = new Set();
|
|
17
24
|
// Only user content is searchable: timestamps, status and embedding fields never match.
|
|
18
25
|
for (const key of contentKeys(props)) {
|
|
19
26
|
const value = props[key];
|
|
@@ -23,12 +30,17 @@ export function keywordMatches(query, props) {
|
|
|
23
30
|
const haystack = Array.isArray(value)
|
|
24
31
|
? value.map((item) => item?.toString() || '').join(' ').toLowerCase()
|
|
25
32
|
: value.toString().toLowerCase();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
words.forEach((word, index) => {
|
|
34
|
+
if (haystack.includes(word))
|
|
35
|
+
found.add(index);
|
|
36
|
+
});
|
|
29
37
|
}
|
|
30
|
-
return
|
|
38
|
+
return found.size / words.length;
|
|
31
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Rank candidates for a query. `exact` keeps only equality hits; every other mode scores each candidate by its
|
|
42
|
+
* best allowed signal (exact in hybrid, keyword share, semantic cosine, fuzzy name similarity).
|
|
43
|
+
*/
|
|
32
44
|
export function rank(candidates, opts) {
|
|
33
45
|
const trimmedQuery = opts.query.trim();
|
|
34
46
|
if (!trimmedQuery) {
|
|
@@ -37,7 +49,6 @@ export function rank(candidates, opts) {
|
|
|
37
49
|
.sort(compareRankedBy(candidates));
|
|
38
50
|
}
|
|
39
51
|
const results = [];
|
|
40
|
-
const seen = new Set();
|
|
41
52
|
if (opts.mode === 'exact') {
|
|
42
53
|
for (const candidate of candidates) {
|
|
43
54
|
if (exactMatches(trimmedQuery, candidate.props)) {
|
|
@@ -46,33 +57,44 @@ export function rank(candidates, opts) {
|
|
|
46
57
|
}
|
|
47
58
|
return results.sort(compareRankedBy(candidates));
|
|
48
59
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
60
|
+
for (const candidate of candidates) {
|
|
61
|
+
// In hybrid, an exact name, alias or email hit is the strongest signal there is.
|
|
62
|
+
if (opts.mode === 'hybrid' && exactMatches(trimmedQuery, candidate.props)) {
|
|
63
|
+
results.push({ id: candidate.id, score: 1, match: 'exact' });
|
|
64
|
+
continue;
|
|
55
65
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
let best;
|
|
67
|
+
// Strictly greater comparisons preserve keyword > semantic > fuzzy on ties.
|
|
68
|
+
if (opts.mode === 'hybrid' || opts.mode === 'keyword') {
|
|
69
|
+
const score = keywordScore(trimmedQuery, candidate.props);
|
|
70
|
+
if (score > 0)
|
|
71
|
+
best = { id: candidate.id, score, match: 'keyword' };
|
|
72
|
+
}
|
|
73
|
+
if ((opts.mode === 'hybrid' || opts.mode === 'semantic') && opts.queryEmbedding && opts.modelId &&
|
|
74
|
+
candidate.props.embedding_model === opts.modelId) {
|
|
65
75
|
const score = Math.max(similarity(opts.queryEmbedding, candidate.props.embedding), similarity(opts.queryEmbedding, candidate.props.name_embedding));
|
|
66
|
-
if (score >= opts.threshold) {
|
|
67
|
-
|
|
76
|
+
if (score >= opts.threshold && (!best || score > best.score)) {
|
|
77
|
+
best = { id: candidate.id, score, match: 'semantic' };
|
|
68
78
|
}
|
|
69
79
|
}
|
|
80
|
+
if (opts.mode === 'hybrid' || opts.mode === 'fuzzy') {
|
|
81
|
+
const values = [candidate.props.name, ...(Array.isArray(candidate.props.aliases) ? candidate.props.aliases : [])]
|
|
82
|
+
.filter((value) => typeof value === 'string');
|
|
83
|
+
const fuzzy = fuzzyNameScore(trimmedQuery, values, opts.fuzzyThreshold ?? FUZZY_DEFAULT_THRESHOLD);
|
|
84
|
+
if (fuzzy && (!best || fuzzy.score > best.score)) {
|
|
85
|
+
best = { id: candidate.id, score: fuzzy.score, match: 'fuzzy', matched: fuzzy.matched };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (best)
|
|
89
|
+
results.push(best);
|
|
70
90
|
}
|
|
71
91
|
return results.sort(compareRankedBy(candidates));
|
|
72
92
|
}
|
|
73
93
|
function similarity(query, vector) {
|
|
74
94
|
return Array.isArray(vector) ? cosine(query, vector.map((item) => Number(item))) : 0;
|
|
75
95
|
}
|
|
96
|
+
/** On equal scores, the more precise signal ranks first. */
|
|
97
|
+
const MATCH_PRIORITY = { exact: 0, keyword: 1, semantic: 2, fuzzy: 3 };
|
|
76
98
|
function compareRankedBy(candidates) {
|
|
77
99
|
const createdAt = new Map(candidates.map((candidate) => [
|
|
78
100
|
candidate.id,
|
|
@@ -82,6 +104,9 @@ function compareRankedBy(candidates) {
|
|
|
82
104
|
if (right.score !== left.score) {
|
|
83
105
|
return right.score - left.score;
|
|
84
106
|
}
|
|
107
|
+
if (left.match !== right.match) {
|
|
108
|
+
return MATCH_PRIORITY[left.match] - MATCH_PRIORITY[right.match];
|
|
109
|
+
}
|
|
85
110
|
const leftCreatedAt = createdAt.get(left.id) ?? '';
|
|
86
111
|
const rightCreatedAt = createdAt.get(right.id) ?? '';
|
|
87
112
|
if (rightCreatedAt !== leftCreatedAt) {
|
package/build/server.js
CHANGED
|
@@ -5,6 +5,8 @@ import { createEmbedder } from './embeddings.js';
|
|
|
5
5
|
import { handleToolCall } from './handlers/index.js';
|
|
6
6
|
import { Neo4jClient } from './neo4j-client.js';
|
|
7
7
|
import { tools } from './tools/definitions.js';
|
|
8
|
+
import { VERSION } from './version.js';
|
|
9
|
+
export { VERSION };
|
|
8
10
|
/** A misconfigured provider (missing key, http endpoint…) must not stop the server; search degrades to keyword. */
|
|
9
11
|
export function safeCreateEmbedder() {
|
|
10
12
|
try {
|
|
@@ -15,7 +17,6 @@ export function safeCreateEmbedder() {
|
|
|
15
17
|
return null;
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
|
-
export const VERSION = '0.5.0';
|
|
19
20
|
/** The MCP protocol server with Reverie's tools, bound to a (possibly absent) Neo4j client and embedder. */
|
|
20
21
|
export function createMcpServer(neo4j, embedder) {
|
|
21
22
|
const server = new Server({ name: 'reverie', version: VERSION }, { capabilities: { tools: {} } });
|
|
@@ -8,7 +8,7 @@ import { guidanceTool } from './guidance-tool.js';
|
|
|
8
8
|
export const tools = [
|
|
9
9
|
{
|
|
10
10
|
name: 'search_memories',
|
|
11
|
-
description: 'Hybrid keyword + semantic search across the knowledge graph. "Ben
|
|
11
|
+
description: 'Hybrid keyword + semantic + fuzzy name search across the knowledge graph. "Ben Wicks" can find "Benjamin Weeks". Each result\'s memory carries _score (0-1: exact = 1, keyword = share of query words found, semantic = cosine similarity, fuzzy = name similarity), _match (exact | keyword | semantic | fuzzy) and, for fuzzy hits, _matched (the stored name or alias that matched: "did you mean <_matched>?"). A semantic-only hit below about 0.5 is weak and often unrelated.',
|
|
12
12
|
inputSchema: {
|
|
13
13
|
type: 'object',
|
|
14
14
|
properties: {
|
|
@@ -42,8 +42,15 @@ export const tools = [
|
|
|
42
42
|
},
|
|
43
43
|
search_mode: {
|
|
44
44
|
type: 'string',
|
|
45
|
-
enum: ['hybrid', 'keyword', 'semantic', 'exact'],
|
|
46
|
-
description: 'Search mode: hybrid (default), keyword-only, semantic-only, or exact (case-insensitive equality on name/aliases/email: use before creating a memory).',
|
|
45
|
+
enum: ['hybrid', 'keyword', 'semantic', 'exact', 'fuzzy'],
|
|
46
|
+
description: 'Search mode: hybrid (default), keyword-only, semantic-only, fuzzy name matching, or exact (case-insensitive equality on name/aliases/email: use before creating a memory).',
|
|
47
|
+
},
|
|
48
|
+
fuzzy_threshold: {
|
|
49
|
+
type: 'number',
|
|
50
|
+
minimum: 0,
|
|
51
|
+
maximum: 1,
|
|
52
|
+
default: 0.85,
|
|
53
|
+
description: 'Fuzzy name-match cut-off for misspelled or misheard names',
|
|
47
54
|
},
|
|
48
55
|
similarity_threshold: {
|
|
49
56
|
type: 'number',
|
|
@@ -149,12 +149,17 @@ Use UPPERCASE for relationship types:
|
|
|
149
149
|
|
|
150
150
|
**Searching**
|
|
151
151
|
- Empty query string returns all memories
|
|
152
|
-
- Use search_mode: "hybrid" (the default) to
|
|
152
|
+
- Use search_mode: "hybrid" (the default) to rank every result by its best signal: keyword share of query words, semantic cosine, or fuzzy name similarity
|
|
153
153
|
- Use search_mode: "keyword" to match any word of the query as a substring of any searchable content property (not timestamps, status or embedding fields), or search_mode: "semantic" for meaning-first recall
|
|
154
|
+
- Use search_mode: "fuzzy" for name lookups when spelling may be wrong (for example speech-to-text: "Ben Wicks" can find "Benjamin Weeks")
|
|
155
|
+
- Tune fuzzy_threshold (default 0.85, range 0..1) for misspelled or misheard names
|
|
156
|
+
- When _match is fuzzy and the name differs, confirm with the user ("Did you mean Grimshaw?") rather than silently swapping
|
|
157
|
+
- First-name nicknames that do not sound alike (Bill/William) need aliases; store full-name aliases such as "Bill Weeks" for full-name queries
|
|
154
158
|
- Use search_mode: "exact" before creating: it matches only a memory whose name, alias or email equals the query, case-insensitively
|
|
155
159
|
- Archived memories are excluded unless include_archived: true
|
|
156
160
|
- Lower similarity_threshold (for example 0.35) to widen semantic matches, or raise it (for example 0.7) to be stricter
|
|
157
|
-
-
|
|
161
|
+
- Each result's memory carries _score (0..1) and _match (exact | keyword | semantic | fuzzy), plus _matched (the stored name or alias) for fuzzy hits, so you can explain why something was returned
|
|
162
|
+
- Reading _score: exact is 1; keyword is the share of query words found; semantic is the cosine similarity; fuzzy is the name similarity (at least fuzzy_threshold). A semantic-only hit below about 0.5 is weak and often unrelated, so do not present it as a match
|
|
158
163
|
- Use label parameter to filter by type
|
|
159
164
|
- Increase depth to include more relationships (depth=2 or 3 for rich context)
|
|
160
165
|
- Default limit is 10, max is 200
|
package/build/types.js
CHANGED
|
@@ -28,7 +28,7 @@ export function isCreateMemoryArgs(args) {
|
|
|
28
28
|
isIdentifier(args.label) &&
|
|
29
29
|
isPlainObject(args.properties);
|
|
30
30
|
}
|
|
31
|
-
const SEARCH_KEYS = ['query', 'label', 'depth', 'order_by', 'limit', 'since_date', 'search_mode', 'similarity_threshold', 'include_archived'];
|
|
31
|
+
const SEARCH_KEYS = ['query', 'label', 'depth', 'order_by', 'limit', 'since_date', 'search_mode', 'similarity_threshold', 'fuzzy_threshold', 'include_archived'];
|
|
32
32
|
export function isSearchMemoriesArgs(args) {
|
|
33
33
|
if (!isPlainObject(args) || !hasOnlyKeys(args, SEARCH_KEYS))
|
|
34
34
|
return false;
|
|
@@ -45,7 +45,7 @@ export function isSearchMemoriesArgs(args) {
|
|
|
45
45
|
return false;
|
|
46
46
|
if (searchArgs.since_date !== undefined && typeof searchArgs.since_date !== 'string')
|
|
47
47
|
return false;
|
|
48
|
-
if (searchArgs.search_mode !== undefined && !['hybrid', 'keyword', 'semantic', 'exact'].includes(searchArgs.search_mode))
|
|
48
|
+
if (searchArgs.search_mode !== undefined && !['hybrid', 'keyword', 'semantic', 'exact', 'fuzzy'].includes(searchArgs.search_mode))
|
|
49
49
|
return false;
|
|
50
50
|
if (searchArgs.include_archived !== undefined && typeof searchArgs.include_archived !== 'boolean')
|
|
51
51
|
return false;
|
|
@@ -54,6 +54,11 @@ export function isSearchMemoriesArgs(args) {
|
|
|
54
54
|
if (typeof threshold !== 'number' || !Number.isFinite(threshold) || threshold < 0 || threshold > 1)
|
|
55
55
|
return false;
|
|
56
56
|
}
|
|
57
|
+
if (searchArgs.fuzzy_threshold !== undefined) {
|
|
58
|
+
const threshold = searchArgs.fuzzy_threshold;
|
|
59
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold) || threshold < 0 || threshold > 1)
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
57
62
|
return true;
|
|
58
63
|
}
|
|
59
64
|
export function isCreateConnectionArgs(args) {
|
package/build/version.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
/**
|
|
3
|
+
* The package version, read from package.json at run time (build/version.js → ../package.json), so the
|
|
4
|
+
* serverInfo a client sees can never drift from the release again: until 0.5.4 it was hard-coded as 0.5.0.
|
|
5
|
+
* npm always ships package.json and the Docker image copies it; anything else reports "unknown" rather
|
|
6
|
+
* than a wrong number.
|
|
7
|
+
*/
|
|
8
|
+
export function readVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const pkg = createRequire(import.meta.url)('../package.json');
|
|
11
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'unknown';
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return 'unknown';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export const VERSION = readVersion();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knowall-ai/reverie",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Reverie — graph memory that dreams. Graph-based memory system for AI agents. Store people, places, organizations as nodes. Build semantic relationships (KNOWS, WORKS_AT, CREATED). Hybrid keyword + semantic search finds partial matches and close variants. Filter by date, traverse relationships, maintain temporal context. Simple tools, LLM handles complexity. 12 tools for complete memory management.",
|
|
5
5
|
"mcpName": "ai.knowall/reverie",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"@huggingface/transformers": "^3.8.1",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
29
29
|
"dotenv": "^17.2.0",
|
|
30
|
+
"double-metaphone": "^2.0.1",
|
|
30
31
|
"neo4j-driver": "^5.27.0",
|
|
31
32
|
"uuid": "^11.0.4",
|
|
32
33
|
"zod": "^3.25.76"
|