@squirrelnotes.app/mcp-embeddings 2.1.1-test.341 → 2.1.1-test.343

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
@@ -8,7 +8,8 @@ flat white with oat milk"*.
8
8
  It is the same server as [`@squirrelnotes.app/mcp`](https://www.npmjs.com/package/@squirrelnotes.app/mcp):
9
9
  the same tools, the same inputs and the same output shape. It is built from the
10
10
  same source and published from the same commit, at the same version. The
11
- only difference is the order `search_memory` returns. If you do not install it,
11
+ only differences are the order `search_memory` returns, its `ranking` (`hybrid`) and each hit's
12
+ `semanticScore`, which the base package sends as `null`. If you do not install it,
12
13
  nothing changes for you.
13
14
 
14
15
  ## What stays on your machine
@@ -96,8 +97,18 @@ The vector file can be deleted at any time; it is rebuilt when needed.
96
97
  ranks above one in only one.
97
98
  - A semantic match must reach a minimum similarity of 0.35. Below that, a
98
99
  memory is returned only if its words match.
99
- - `score` is on a 0–1 scale here, and unbounded BM25 in the base package. The
100
- keys are the same in both.
100
+ - `score` is on a 0–1 scale here, and unbounded BM25 in the base package. It
101
+ orders one response only: a memory found by one order alone scores about 0.5
102
+ whatever its relevance. The keys are the same in both.
103
+ - To judge a hit, use the absolute scores: `semanticScore` (the cosine
104
+ similarity, reported for every hit, also those below 0.35 found by words) and
105
+ `lexicalScore` (the share of the query's content words, stopwords aside).
106
+ As a guide, from the recall gate's data (ADR-0444): below `semanticScore`
107
+ 0.45 and `lexicalScore` 0.67 a hit is rarely the answer (about 1 in 20); at
108
+ `semanticScore` ≥ 0.45 about half are, and at `lexicalScore` ≥ 0.67 about
109
+ 9 in 10. These were measured on the delivered (rounded) scores. `semanticScore` is a cosine, −1 to 1
110
+ (in practice about −0.1 to 0.9). `ranking` is `hybrid` when the semantic
111
+ order ran, `lexical` while the model loads or after it fails.
101
112
  - Words outside ASCII letters and digits never match lexically, in either
102
113
  package. Embeddings can find some of these memories (see *Recall*).
103
114
  - A query with no ASCII letters or digits always comes back with a `note`.
package/dist/cli.js CHANGED
@@ -22613,8 +22613,15 @@ function intersectSets(a, b) {
22613
22613
  function searchableText(p) {
22614
22614
  return normalizeText([p.text, p.entity, ...p.tags].join(" "));
22615
22615
  }
22616
+ function hasSearchableText(query) {
22617
+ return normalizeText(query).length > 0;
22618
+ }
22616
22619
  function queryTerms(query) {
22617
- return [...new Set(normalizeText(query).split(" ").filter(Boolean))];
22620
+ const tokens = normalizeText(query).split(" ").filter(Boolean);
22621
+ const isStop = (w, i) => STOPWORDS.has(w) || NT_STEMS.has(w) && tokens[i + 1] === "t";
22622
+ const content = [...new Set(tokens.filter((w, i) => !isStop(w, i)))];
22623
+ const words = [...new Set(tokens)];
22624
+ return content.length > 0 ? content : words;
22618
22625
  }
22619
22626
  function recencyWeight(category, lastValidatedAt, now2) {
22620
22627
  const at = Date.parse(lastValidatedAt);
@@ -22700,7 +22707,7 @@ function termCoverage(text, p) {
22700
22707
  }
22701
22708
  return total / terms.length;
22702
22709
  }
22703
- var DECAY_HALF_LIFE_DAYS, BM25_K1, BM25_B, PREFIX_WEIGHT, DAY_MS;
22710
+ var DECAY_HALF_LIFE_DAYS, BM25_K1, BM25_B, PREFIX_WEIGHT, DAY_MS, STOPWORDS, NT_STEMS;
22704
22711
  var init_memory_search = __esm({
22705
22712
  "../mcp/src/memory-search.ts"() {
22706
22713
  "use strict";
@@ -22714,6 +22721,158 @@ var init_memory_search = __esm({
22714
22721
  BM25_B = 0.75;
22715
22722
  PREFIX_WEIGHT = 0.5;
22716
22723
  DAY_MS = 864e5;
22724
+ STOPWORDS = /* @__PURE__ */ new Set([
22725
+ "a",
22726
+ "about",
22727
+ "above",
22728
+ "after",
22729
+ "again",
22730
+ "all",
22731
+ "am",
22732
+ "an",
22733
+ "and",
22734
+ "any",
22735
+ "are",
22736
+ "as",
22737
+ "at",
22738
+ "be",
22739
+ "because",
22740
+ "been",
22741
+ "before",
22742
+ "being",
22743
+ "below",
22744
+ "between",
22745
+ "both",
22746
+ "but",
22747
+ "by",
22748
+ "can",
22749
+ "could",
22750
+ "did",
22751
+ "do",
22752
+ "does",
22753
+ "doing",
22754
+ "during",
22755
+ "each",
22756
+ "few",
22757
+ "for",
22758
+ "from",
22759
+ "further",
22760
+ "had",
22761
+ "has",
22762
+ "have",
22763
+ "having",
22764
+ "he",
22765
+ "her",
22766
+ "here",
22767
+ "hers",
22768
+ "herself",
22769
+ "him",
22770
+ "himself",
22771
+ "his",
22772
+ "how",
22773
+ "i",
22774
+ "if",
22775
+ "in",
22776
+ "into",
22777
+ "is",
22778
+ "it",
22779
+ "its",
22780
+ "itself",
22781
+ "just",
22782
+ "me",
22783
+ "more",
22784
+ "most",
22785
+ "my",
22786
+ "myself",
22787
+ "no",
22788
+ "nor",
22789
+ "not",
22790
+ "of",
22791
+ "off",
22792
+ "on",
22793
+ "once",
22794
+ "only",
22795
+ "or",
22796
+ "other",
22797
+ "our",
22798
+ "ours",
22799
+ "ourselves",
22800
+ "out",
22801
+ "over",
22802
+ "own",
22803
+ "same",
22804
+ "she",
22805
+ "should",
22806
+ "so",
22807
+ "some",
22808
+ "such",
22809
+ "than",
22810
+ "that",
22811
+ "the",
22812
+ "their",
22813
+ "theirs",
22814
+ "them",
22815
+ "themselves",
22816
+ "then",
22817
+ "there",
22818
+ "these",
22819
+ "they",
22820
+ "this",
22821
+ "those",
22822
+ "through",
22823
+ "to",
22824
+ "too",
22825
+ "under",
22826
+ "until",
22827
+ "up",
22828
+ "very",
22829
+ "was",
22830
+ "we",
22831
+ "were",
22832
+ "what",
22833
+ "when",
22834
+ "where",
22835
+ "which",
22836
+ "while",
22837
+ "who",
22838
+ "whom",
22839
+ "whose",
22840
+ "why",
22841
+ "will",
22842
+ "with",
22843
+ "would",
22844
+ "you",
22845
+ "your",
22846
+ "yours",
22847
+ "yourself",
22848
+ "yourselves",
22849
+ // What contractions leave once normalizeText turns the apostrophe into a
22850
+ // space ("what's" → "what s", "doesn't" → "doesn t"). A one-letter term
22851
+ // would prefix-match every word starting with that letter. "don", "won" and
22852
+ // "haven" are also words or names, so they are not here: queryTerms drops
22853
+ // them only before a "t" (NT_STEMS).
22854
+ "s",
22855
+ "t",
22856
+ "d",
22857
+ "ll",
22858
+ "m",
22859
+ "re",
22860
+ "ve",
22861
+ "aren",
22862
+ "couldn",
22863
+ "didn",
22864
+ "doesn",
22865
+ "hadn",
22866
+ "hasn",
22867
+ "isn",
22868
+ "mustn",
22869
+ "needn",
22870
+ "shouldn",
22871
+ "wasn",
22872
+ "weren",
22873
+ "wouldn"
22874
+ ]);
22875
+ NT_STEMS = /* @__PURE__ */ new Set(["don", "won", "haven"]);
22717
22876
  }
22718
22877
  });
22719
22878
 
@@ -22730,6 +22889,9 @@ function blendRanks(lexical, dense) {
22730
22889
  for (const [id, s] of scores) scores.set(id, s / max);
22731
22890
  return scores;
22732
22891
  }
22892
+ function aboveFloor(similarities, minSimilarity) {
22893
+ return similarities.filter((m) => m.similarity >= minSimilarity);
22894
+ }
22733
22895
  var RRF_K;
22734
22896
  var init_memory_rank = __esm({
22735
22897
  "../mcp/src/memory-rank.ts"() {
@@ -22785,24 +22947,66 @@ async function listAllMemories(client, key) {
22785
22947
  return { memories, unverifiable };
22786
22948
  }
22787
22949
  async function rankForSearch(query, all, eligible, semantic, key, now2) {
22950
+ const byId = new Map(eligible.map((m) => [m.id, m]));
22951
+ const hit = (id, score, similarity2) => ({
22952
+ id,
22953
+ score,
22954
+ lexicalScore: termCoverage(query, byId.get(id).payload),
22955
+ semanticScore: similarity2 ? similarity2.get(id) ?? null : null
22956
+ });
22788
22957
  const lexical = scoreMemories(query, eligible, now2);
22789
- if (!semantic) return { ranked: lexical, fused: false };
22790
- const matches = await semantic.rank(
22958
+ const lexicalOnly = () => ({ ranked: lexical.map((x) => hit(x.id, x.score, null)), fused: false });
22959
+ if (!semantic) return lexicalOnly();
22960
+ const scored = await semantic.score(
22791
22961
  query,
22792
22962
  all.map((m) => ({ id: m.id, revision: m.revision, text: memoryEmbeddingText(m.payload) })),
22793
22963
  new Set(eligible.map((m) => m.id)),
22794
22964
  key
22795
22965
  );
22796
- if (!matches) return { ranked: lexical, fused: false };
22797
- const byId = new Map(eligible.map((m) => [m.id, m]));
22966
+ if (!scored) return lexicalOnly();
22967
+ const similarity = new Map(scored.similarities.map((m) => [m.id, m.similarity]));
22798
22968
  const decay = (id) => {
22799
22969
  const p = byId.get(id).payload;
22800
22970
  return recencyWeight(p.category, p.lastValidatedAt, now2);
22801
22971
  };
22802
- const dense = matches.map(({ id, similarity }) => ({ id, weighed: similarity * decay(id) })).sort((a, b) => b.weighed - a.weighed || (a.id < b.id ? -1 : 1)).map((x) => x.id);
22803
- const ranked = [...blendRanks(lexical.map((x) => x.id), dense)].map(([id, score]) => ({ id, score, updatedAt: byId.get(id).payload.updatedAt })).sort((a, b) => b.score - a.score || b.updatedAt.localeCompare(a.updatedAt) || (a.id < b.id ? -1 : 1)).map(({ id, score }) => ({ id, score }));
22972
+ const dense = aboveFloor(scored.similarities, scored.minSimilarity).map(({ id, similarity: sim }) => ({ id, weighed: sim * decay(id) })).sort((a, b) => b.weighed - a.weighed || (a.id < b.id ? -1 : 1)).map((x) => x.id);
22973
+ const ranked = [...blendRanks(lexical.map((x) => x.id), dense)].map(([id, score]) => ({ id, score, updatedAt: byId.get(id).payload.updatedAt })).sort((a, b) => b.score - a.score || b.updatedAt.localeCompare(a.updatedAt) || (a.id < b.id ? -1 : 1)).map(({ id, score }) => hit(id, score, similarity));
22804
22974
  return { ranked, fused: true };
22805
22975
  }
22976
+ function listByRecency(eligible) {
22977
+ return [...eligible].sort(compareRecency);
22978
+ }
22979
+ function compareRecency(a, b) {
22980
+ return b.payload.updatedAt.localeCompare(a.payload.updatedAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
22981
+ }
22982
+ function encodeListCursor(m) {
22983
+ const position = { u: m.payload.updatedAt, i: m.id };
22984
+ return Buffer.from(JSON.stringify(position)).toString("base64url");
22985
+ }
22986
+ function decodeListCursor(cursor) {
22987
+ try {
22988
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
22989
+ if (typeof parsed !== "object" || parsed === null) return null;
22990
+ const { u, i } = parsed;
22991
+ return typeof u === "string" && typeof i === "string" && u.length <= 64 && i.length <= 64 ? { u, i } : null;
22992
+ } catch {
22993
+ return null;
22994
+ }
22995
+ }
22996
+ function listPage(eligible, after, limit) {
22997
+ const ordered = listByRecency(eligible);
22998
+ const start = after === null ? ordered : ordered.filter((m) => compareRecency(m, { id: after.i, payload: { updatedAt: after.u } }) > 0);
22999
+ const page = start.slice(0, limit);
23000
+ const last = page[page.length - 1];
23001
+ return { page, nextCursor: start.length > limit && last ? encodeListCursor(last) : null };
23002
+ }
23003
+ function hitScores(h) {
23004
+ return {
23005
+ score: round(h.score, 2),
23006
+ lexicalScore: round(h.lexicalScore, 2),
23007
+ semanticScore: h.semanticScore === null ? null : round(h.semanticScore, 3)
23008
+ };
23009
+ }
22806
23010
  function view(m, score) {
22807
23011
  const p = m.payload;
22808
23012
  return {
@@ -22823,7 +23027,7 @@ function view(m, score) {
22823
23027
  reviewedAt: p.reviewedAt,
22824
23028
  flaggedAt: p.flaggedAt ?? null,
22825
23029
  updatedAt: p.updatedAt,
22826
- ...score !== void 0 ? { score: Math.round(score * 100) / 100 } : {}
23030
+ ...typeof score === "number" ? { score: round(score, 2) } : score ?? {}
22827
23031
  };
22828
23032
  }
22829
23033
  function chunkNote(title, markdown, category) {
@@ -23060,33 +23264,45 @@ function registerMemoryTools(server, context, options = {}) {
23060
23264
  };
23061
23265
  server.tool(
23062
23266
  "search_memory",
23063
- `Search the user's long-term memories (short facts and preferences saved by agents or the user). Ranks active memories by their relevance to the query: matching words in the text, entity and tags, and, when the user has installed local embeddings, similar meaning. Word matching uses ASCII letters and digits only (as in the app's search): other letters, such as accented or non-Latin ones, are ignored by it, so without local embeddings a query or memory written only in them cannot be found. ${NEEDS_REVIEW_HELP} Superseded and invalidated memories are left out unless asked for.`,
23267
+ SEARCH_MEMORY_DESCRIPTION,
23064
23268
  {
23065
- query: external_exports.string().min(1).max(500).describe("Words to look for"),
23269
+ query: external_exports.string().max(500).optional().describe("Words to look for. Optional when category or entity is given: then the matching memories are listed, newest first"),
23066
23270
  category: external_exports.enum(MEMORY_CATEGORIES).optional().describe("Only this category"),
23067
23271
  entity: external_exports.string().max(MAX_ENTITY).optional().describe("Only memories about this entity (case-insensitive)"),
23068
23272
  includeSuperseded: external_exports.boolean().default(false),
23069
23273
  includeInvalidated: external_exports.boolean().default(false),
23070
- limit: external_exports.number().int().min(1).max(20).default(5)
23274
+ limit: external_exports.number().int().min(1).max(20).default(5),
23275
+ cursor: external_exports.string().max(200).optional().describe("A listing only (no query): the nextCursor of the previous page, to get the next one")
23071
23276
  },
23072
- async ({ query, category, entity, includeSuperseded, includeInvalidated, limit }) => {
23277
+ async ({ query, category, entity, includeSuperseded, includeInvalidated, limit, cursor }) => {
23278
+ const wantEntity = entity?.trim().toLowerCase() || void 0;
23279
+ const wantQuery = query?.trim() ? query : void 0;
23280
+ if (wantQuery === void 0 && !category && !wantEntity) return refuse("search_memory", NO_QUERY_OR_FILTER);
23281
+ if (cursor !== void 0 && wantQuery !== void 0) return refuse("search_memory", CURSOR_WITH_QUERY);
23282
+ const after = cursor === void 0 ? null : decodeListCursor(cursor);
23283
+ if (cursor !== void 0 && after === null) return refuse("search_memory", BAD_CURSOR);
23073
23284
  try {
23074
23285
  const ctx = await unlock();
23075
23286
  const { client, key } = ctx;
23076
23287
  const { memories, unverifiable } = await loadAll(client, key);
23077
- const wantEntity = entity?.trim().toLowerCase();
23078
23288
  const eligible = memories.filter(
23079
23289
  ({ payload: p }) => p.state === "active" || p.state === "superseded" && includeSuperseded || p.state === "invalidated" && includeInvalidated
23080
23290
  ).filter(({ payload: p }) => !category || p.category === category).filter(({ payload: p }) => !wantEntity || p.entity.trim().toLowerCase() === wantEntity);
23291
+ if (wantQuery === void 0) {
23292
+ const { page, nextCursor } = listPage(eligible, after, limit);
23293
+ const results2 = page.map((m) => view(m, NO_SCORES));
23294
+ mcpLog(`memory: search_memory listed=${eligible.length} results=${results2.length} unverifiable=${unverifiable}`);
23295
+ const ranking2 = "none";
23296
+ return ok({ ranking: ranking2, results: results2, total: eligible.length, nextCursor, searched: memories.length, unverifiable });
23297
+ }
23081
23298
  const byId = new Map(eligible.map((m) => [m.id, m]));
23082
- const { ranked, fused } = await rankForSearch(query, memories, eligible, options.semantic ?? null, key, Date.now());
23083
- const results = ranked.slice(0, limit).map((x) => view(byId.get(x.id), x.score));
23299
+ const { ranked, fused } = await rankForSearch(wantQuery, memories, eligible, options.semantic ?? null, key, Date.now());
23300
+ const results = ranked.slice(0, limit).map((x) => view(byId.get(x.id), hitScores(x)));
23301
+ const ranking = fused ? "hybrid" : "lexical";
23084
23302
  mcpLog(`memory: search_memory searched=${memories.length} results=${results.length} unverifiable=${unverifiable}`);
23085
- if (queryTerms(query).length === 0) {
23086
- const note = fused ? MEANING_ONLY_NOTE : NO_SEARCHABLE_TERMS;
23087
- return ok({ results, searched: memories.length, unverifiable, note });
23088
- }
23089
- return ok({ results, searched: memories.length, unverifiable });
23303
+ const body = { ranking, results, total: ranked.length, nextCursor: null, searched: memories.length, unverifiable };
23304
+ if (!hasSearchableText(wantQuery)) return ok({ ...body, note: fused ? MEANING_ONLY_NOTE : NO_SEARCHABLE_TERMS });
23305
+ return ok(body);
23090
23306
  } catch (err) {
23091
23307
  return fail("search_memory", err);
23092
23308
  }
@@ -23317,7 +23533,7 @@ async function supersedeLanded(client, key, newId, oldId) {
23317
23533
  return false;
23318
23534
  }
23319
23535
  }
23320
- var MEMORY_CATEGORIES, MEMORY_STATES, VIAS, MAX_TEXT, MAX_ENTITY, MAX_REASON, MAX_TAGS, MAX_TAG, MAX_CLIENT_NAME, uuidArg, MAX_IMPORT, CREATED_REVISION, MAX_ENCRYPTED_PAYLOAD_LENGTH, NoteVerificationError, MemoryTooLongError, isStr, isStrOrNull, oneOf, NEAR_DUPLICATE_SCORE, NEAR_DUPLICATE_LIMIT, STORAGE_MESSAGES, KeyContextError, isRetryable, USER_DATA_TEXT, NEEDS_REVIEW_TEXT, PROFILE_TOKEN_BUDGET, CHARS_PER_TOKEN, PROFILE_URI, NO_SEARCHABLE_TERMS, MEANING_ONLY_NOTE, NO_PINNED_MESSAGE, MEMORY_INSTRUCTIONS, NEEDS_REVIEW_HELP, now, MEMORY_CACHE_TTL_MS;
23536
+ var MEMORY_CATEGORIES, MEMORY_STATES, VIAS, MAX_TEXT, MAX_ENTITY, MAX_REASON, MAX_TAGS, MAX_TAG, MAX_CLIENT_NAME, uuidArg, MAX_IMPORT, CREATED_REVISION, MAX_ENCRYPTED_PAYLOAD_LENGTH, NoteVerificationError, MemoryTooLongError, isStr, isStrOrNull, oneOf, NEAR_DUPLICATE_SCORE, NEAR_DUPLICATE_LIMIT, round, NO_SCORES, STORAGE_MESSAGES, KeyContextError, isRetryable, USER_DATA_TEXT, NEEDS_REVIEW_TEXT, PROFILE_TOKEN_BUDGET, CHARS_PER_TOKEN, PROFILE_URI, NO_SEARCHABLE_TERMS, MEANING_ONLY_NOTE, CURSOR_WITH_QUERY, BAD_CURSOR, NO_QUERY_OR_FILTER, NO_PINNED_MESSAGE, MEMORY_INSTRUCTIONS, NEEDS_REVIEW_HELP, SEARCH_MEMORY_DESCRIPTION, now, MEMORY_CACHE_TTL_MS;
23321
23537
  var init_memory = __esm({
23322
23538
  "../mcp/src/memory.ts"() {
23323
23539
  "use strict";
@@ -23357,6 +23573,8 @@ var init_memory = __esm({
23357
23573
  oneOf = (xs, x) => isStr(x) && xs.includes(x);
23358
23574
  NEAR_DUPLICATE_SCORE = 0.6;
23359
23575
  NEAR_DUPLICATE_LIMIT = 3;
23576
+ round = (x, places) => Math.round(x * 10 ** places) / 10 ** places;
23577
+ NO_SCORES = { score: null, lexicalScore: null, semanticScore: null };
23360
23578
  STORAGE_MESSAGES = ["Memory limit reached", "Memory storage limit reached"];
23361
23579
  KeyContextError = class extends Error {
23362
23580
  constructor(reason) {
@@ -23374,9 +23592,13 @@ var init_memory = __esm({
23374
23592
  PROFILE_URI = "memory://profile";
23375
23593
  NO_SEARCHABLE_TERMS = "The query has no searchable characters: memory search matches ASCII letters and digits only, so accented or non-Latin text cannot be searched.";
23376
23594
  MEANING_ONLY_NOTE = "The query has no ASCII letters or digits, which word matching ignores: these results were found only by meaning, through local embeddings.";
23595
+ CURSOR_WITH_QUERY = "A cursor pages a listing (a category or entity with no query); a search does not page. Leave out the cursor, or the query.";
23596
+ BAD_CURSOR = "That cursor is not one search_memory returned. Use the nextCursor of the previous page, or leave it out to start again.";
23597
+ NO_QUERY_OR_FILTER = "Nothing to search for: give a query, or a category or entity to list the memories that match it.";
23377
23598
  NO_PINNED_MESSAGE = "No pinned memories yet. Only the user pins memories, in the Squirrel Notes app (Memory); agents cannot pin. Pinned memories appear here. Use search_memory to look for anything else.";
23378
23599
  MEMORY_INSTRUCTIONS = "Squirrel Notes keeps the user's encrypted notes and long-term memories. At the start of a session, call get_memory_profile (or read the resource memory://profile) to load the memories the user has pinned as always relevant (only the user pins, in the app). Use search_memory before asking the user something they may already have told you, and remember_memory, supersede_memory and invalidate_memory to keep memories current. " + NEEDS_REVIEW_TEXT;
23379
23600
  NEEDS_REVIEW_HELP = "`needsReview: true` means the memory was written by an agent and the user has not confirmed it yet; it is still in effect. " + USER_DATA_TEXT;
23601
+ SEARCH_MEMORY_DESCRIPTION = `Search the user's long-term memories (short facts and preferences saved by agents or the user). Ranks active memories by their relevance to the query: matching words in the text, entity and tags, and, when the user has installed local embeddings, similar meaning. Common English words (stopwords such as "what", "does", "he", "is") are ignored by word matching unless the query has nothing else. Word matching uses ASCII letters and digits only (as in the app's search): other letters, such as accented or non-Latin ones, are ignored by it, so without local embeddings a query or memory written only in them cannot be found. The response says which ranking ran: "hybrid" (words and meaning), "lexical" (words only) or "none" (a listing). Each hit has \`score\`, which orders this response only and cannot be compared across searches, and two scores that can: \`lexicalScore\` (0\u20131, the share of the query's words, stopwords aside, that the memory contains; a query word that only begins a memory word counts half) and \`semanticScore\` (the cosine similarity of meaning, null unless the ranking is hybrid). As a guide, measured on a synthetic test set: a hit with semanticScore below 0.45 and lexicalScore below 0.67 is rarely the answer (about 1 in 20); at semanticScore 0.45 or more about half are, and at lexicalScore 0.67 or more (two thirds of the query's words, as delivered) about 9 in 10 are. semanticScore is a cosine, from -1 to 1 (in practice about -0.1 to 0.9). Without a query, a category or entity filter lists the matching memories, newest first, \`limit\` (at most 20) at a time: pass the response's \`nextCursor\` as \`cursor\` for the next page (null on the last). \`total\` is how many match in all; a memory edited during a walk may be missed, so compare against \`total\` or start again. ${NEEDS_REVIEW_HELP} Superseded and invalidated memories are left out unless asked for.`;
23380
23602
  now = () => (/* @__PURE__ */ new Date()).toISOString();
23381
23603
  MEMORY_CACHE_TTL_MS = 6e4;
23382
23604
  }
@@ -24610,6 +24832,7 @@ var memory_semantic_exports = {};
24610
24832
  __export(memory_semantic_exports, {
24611
24833
  RRF_K: () => RRF_K,
24612
24834
  SemanticMemoryIndex: () => SemanticMemoryIndex,
24835
+ aboveFloor: () => aboveFloor,
24613
24836
  blendRanks: () => blendRanks,
24614
24837
  memoryEmbeddingText: () => memoryEmbeddingText
24615
24838
  });
@@ -24648,15 +24871,18 @@ var init_memory_semantic = __esm({
24648
24871
  batchSize;
24649
24872
  settled;
24650
24873
  /**
24651
- * The `candidates` whose cosine similarity to `query` reaches the model's
24652
- * minimum, with that similarity, best first; or null, meaning "rank
24653
- * lexically". The caller weighs them (recency, ADR-0444) before fusing.
24874
+ * Every candidate that has a vector, with its cosine similarity to `query`,
24875
+ * best first, and the model's minimum alongside, from one embedding of the
24876
+ * query; or null, meaning "rank lexically". search_memory fuses those at or
24877
+ * above the minimum (`aboveFloor`), weighed by recency, and reports each
24878
+ * hit's similarity as `semanticScore` (ADR-0444, amended). A candidate not
24879
+ * embedded within the time budget is absent.
24654
24880
  *
24655
24881
  * `all` is every memory the list returned: it decides which vectors are kept
24656
24882
  * on file. `candidates` is the subset search_memory has already filtered
24657
24883
  * (state, category, entity).
24658
24884
  */
24659
- async rank(query, all, candidates2, dek) {
24885
+ async score(query, all, candidates2, dek) {
24660
24886
  const epoch = this.epoch;
24661
24887
  const state = await this.settledState();
24662
24888
  if (state.kind !== "ready") return null;
@@ -24669,12 +24895,11 @@ var init_memory_semantic = __esm({
24669
24895
  const scored = [];
24670
24896
  for (const id of candidates2) {
24671
24897
  const e = vectors.get(id);
24672
- if (!e) continue;
24673
- const similarity = dot(q, e.vector);
24674
- if (similarity >= embedder.minSimilarity) scored.push({ id, similarity });
24898
+ if (e) scored.push({ id, similarity: dot(q, e.vector) });
24675
24899
  }
24676
24900
  await this.writeChain;
24677
- return scored.sort((a, b) => b.similarity - a.similarity || (a.id < b.id ? -1 : 1));
24901
+ scored.sort((a, b) => b.similarity - a.similarity || (a.id < b.id ? -1 : 1));
24902
+ return { similarities: scored, minSimilarity: embedder.minSimilarity };
24678
24903
  } catch {
24679
24904
  this.state = { kind: "off" };
24680
24905
  this.options.log("memory: semantic search failed in the embedding runtime; using lexical search for the rest of this session");
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@squirrelnotes.app/mcp-embeddings",
3
- "version": "2.1.1-test.341",
3
+ "version": "2.1.1-test.343",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@squirrelnotes.app/mcp-embeddings",
9
- "version": "2.1.1-test.341",
9
+ "version": "2.1.1-test.343",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "onnxruntime-node": "1.30.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squirrelnotes.app/mcp-embeddings",
3
- "version": "2.1.1-test.341",
3
+ "version": "2.1.1-test.343",
4
4
  "description": "Opt-in local semantic memory search for the Squirrel Notes MCP (stdio). The embedding model runs on your machine; no memory text is sent anywhere to be embedded.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "onnxruntime-node": "1.30.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@modelcontextprotocol/sdk": "1.27.1",
48
+ "@modelcontextprotocol/sdk": "1.31.0",
49
49
  "@types/jest": "^30.0.0",
50
50
  "@types/node": "^20.0.0",
51
51
  "esbuild": "^0.28.1",