@knowall-ai/reverie 0.5.3 → 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 CHANGED
@@ -11,7 +11,7 @@ Reverie turns an agent's memory from a pile of facts into a **map of the entitie
11
11
  ## Why Reverie
12
12
 
13
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.
14
- - **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.
15
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.
16
16
  - **One graph, any agent.** KnowAll runs Sallie (OpenClaw) and Poppie (Hermes) against the same conventions; Reverie is how they share what they know.
17
17
  - **LLM-driven, transparent tools.** Simple atomic operations; the model does the entity recognition and conflict resolution, and every action is explicit.
@@ -66,22 +66,23 @@ Unlike traditional approaches that embed complex logic in tools, this server pro
66
66
  - **Transparent operations**: Every action is explicit and predictable
67
67
  - **Maximum flexibility**: The LLM can implement any strategy without tool limitations
68
68
 
69
- ### Search Behavior
70
- `search_memories` is hybrid: keyword hits (any word of the query as a substring of any searchable content property) rank first, then semantic matches above a similarity threshold. See **Search** below.
69
+ ### Search Behaviour
70
+ `search_memories` is hybrid: each result ranks by its best keyword, semantic or fuzzy name signal. See **Search** below.
71
71
 
72
72
  This approach makes the system more powerful and adaptable, as improvements in LLM capabilities directly translate to better memory management.
73
73
 
74
74
  ## Search
75
75
 
76
- `search_memories` now supports three modes:
77
- - `hybrid` (default): keyword hits score `1`, then semantic matches add close variants such as `Benjamin Weeks` for `Ben Weeks`
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
78
78
  - `keyword`: any word of the query as a substring of any searchable content property (timestamps, `status` and embedding fields are never matched)
79
- - `semantic`: uses embeddings only when available, with graceful fallback to keyword behavior if embeddings are unavailable
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
80
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
81
82
 
82
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.
83
84
 
84
- 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` and `_match` on each returned `memory` object so callers can explain why a memory was returned.
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`.
85
86
 
86
87
  ### Neo4j Enterprise Support
87
88
 
@@ -90,10 +91,10 @@ This server now supports connecting to specific databases in Neo4j Enterprise Ed
90
91
  ### Memory Tools
91
92
 
92
93
  - `search_memories`: Search and retrieve memories from the knowledge graph
93
- - **Hybrid search**: Blend keyword and semantic search; `Ben Weeks` can also find `Benjamin Weeks`
94
- - Choose `search_mode` = `hybrid`, `keyword`, or `semantic`
95
- - Tune semantic strictness with `similarity_threshold` (default `0.4`)
96
- - Returned memories include `_score` and `_match` metadata
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
97
98
  - Filter by memory type (case-insensitive, so `person` and `Person` both work), date, depth, result limit, and sort order
98
99
 
99
100
  - `create_memory`: Create a new memory in the knowledge graph
@@ -220,7 +221,7 @@ Set `REVERIE_EMBEDDINGS` to choose the embedding provider used by hybrid and sem
220
221
 
221
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.
222
223
 
223
- 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 degrades to keyword matching for that call and the error is logged to stderr. Set `REVERIE_EMBEDDINGS=none` to turn embeddings off entirely.
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.
224
225
 
225
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.
226
227
 
@@ -374,7 +375,7 @@ I found several people matching your search:
374
375
  The most likely match appears to be Ben Weeks, the Software Engineer.
375
376
  ```
376
377
 
377
- Note: The search finds ANY word from your query, allowing the LLM to present all potentially relevant results and identify the best match based on context.
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.
378
379
 
379
380
  #### Recent Memories (Last 7 Days)
380
381
  ```
@@ -508,6 +509,12 @@ CI runs the same suite against a Neo4j service container on every pull request a
508
509
  build if coverage drops below the gate. The first semantic search downloads the local embedding
509
510
  model (about 23 MB); CI caches it.
510
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
+
511
518
  ### Interactive Testing with MCP Inspector
512
519
 
513
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
+ }
@@ -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 mode never needs the stored vectors, so leave them out of the wire payload.
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 !== 'keyword' && requestedMode !== 'exact') {
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 true;
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
- if (words.some((word) => haystack.includes(word))) {
27
- return true;
28
- }
33
+ words.forEach((word, index) => {
34
+ if (haystack.includes(word))
35
+ found.add(index);
36
+ });
29
37
  }
30
- return false;
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
- if (opts.mode === 'hybrid' || opts.mode === 'keyword') {
50
- for (const candidate of candidates) {
51
- if (keywordMatches(trimmedQuery, candidate.props)) {
52
- results.push({ id: candidate.id, score: 1, match: 'keyword' });
53
- seen.add(candidate.id);
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
- if ((opts.mode === 'hybrid' || opts.mode === 'semantic') && opts.queryEmbedding && opts.modelId) {
58
- for (const candidate of candidates) {
59
- if (seen.has(candidate.id)) {
60
- continue;
61
- }
62
- if (candidate.props.embedding_model !== opts.modelId) {
63
- continue;
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
- results.push({ id: candidate.id, score, match: 'semantic' });
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 Weeks" can also find "Benjamin Weeks" and each result includes _score and _match.',
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 return keyword matches first, then semantic matches above the threshold
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
- - Results include _score and _match so you can explain why something was returned
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) {
@@ -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",
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"