@sentry/junior-memory 0.133.0 → 0.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/recall.ts CHANGED
@@ -29,8 +29,6 @@ export interface MemoryRecallContext {
29
29
  embedder?: MemoryEmbeddingProvider;
30
30
  events?: PluginConversationEvents;
31
31
  log: PluginLogger;
32
- /** Maximum cosine distance for vector recall. Passed through to the memory store. */
33
- maxVectorDistance?: number;
34
32
  actor?: Actor;
35
33
  source: Source;
36
34
  text: string;
@@ -159,10 +157,7 @@ export async function createMemoryPromptContributions(
159
157
  }
160
158
  : undefined;
161
159
  const candidates = await createMemoryStore(context.db, runtimeContext, {
162
- ...(embedder ? { embedder } : {}),
163
- ...(context.maxVectorDistance !== undefined
164
- ? { maxVectorDistance: context.maxVectorDistance }
165
- : {}),
160
+ embedder,
166
161
  }).recallMemories({
167
162
  query: context.text,
168
163
  limit: RECALL_CANDIDATE_LIMIT,
package/src/store.ts CHANGED
@@ -51,11 +51,31 @@ const DEFAULT_SEARCH_LIMIT = 10;
51
51
  const DEFAULT_EXPIRED_ARCHIVE_LIMIT = 100;
52
52
  const PREFERENCE_ADJUDICATION_CANDIDATE_LIMIT = 10;
53
53
  const PREFERENCE_ADJUDICATION_VECTOR_LIMIT = 5;
54
- const VECTOR_SEARCH_OVERFETCH = 4;
55
- const LEXICAL_RANK_OVERFETCH = 4;
56
- const MAX_LEXICAL_RANK_CANDIDATES = 1_000;
54
+ /** Explicit search overfetch: keep a wider fusion window for tool/CLI search. */
55
+ const SEARCH_RETRIEVAL_OVERFETCH = 4;
56
+ /**
57
+ * Automatic recall overfetch. Recall already asks for ~20 candidates before the
58
+ * relevance gate, so each hybrid leg only needs a small top-k probe.
59
+ */
60
+ const RECALL_RETRIEVAL_OVERFETCH = 2;
61
+ /**
62
+ * Absolute ceiling per retrieval leg. Matches the store limit ceiling so a
63
+ * single healthy leg can still fill the caller's requested result window.
64
+ */
65
+ const MAX_RETRIEVAL_LEG_CANDIDATES = 200;
66
+ /** Cap ts_rank_cd work after GIN filtering; ranking is not indexable. */
67
+ const MAX_LEXICAL_RANK_CANDIDATES = 200;
68
+ /** Expand the GIN match window before ts_rank_cd, still under the hard cap. */
69
+ const LEXICAL_RANK_WINDOW_MULTIPLIER = 4;
70
+ /** Bound query text before embedding / FTS construction. */
71
+ const MAX_RETRIEVAL_QUERY_CHARS = 1_500;
57
72
  const MAX_MEMORY_CONTENT_CHARS = 4_000;
58
73
  const EMBEDDING_METRIC = "cosine";
74
+ /**
75
+ * Cosine distance cutoff for automatic recall only (not explicit search).
76
+ * Tuned for text-embedding-3-small; retune if the embedding model changes.
77
+ */
78
+ const RECALL_MAX_VECTOR_DISTANCE = 0.45;
59
79
 
60
80
  export type MemoryDb = PgDatabase<PgQueryResultHKT, typeof memorySqlSchema>;
61
81
 
@@ -301,8 +321,6 @@ export interface MemorySupersessionDecider {
301
321
 
302
322
  export interface MemoryStoreOptions {
303
323
  embedder?: MemoryEmbeddingProvider;
304
- /** Maximum cosine distance for vector recall candidates. Model-dependent; tune when changing AI_EMBEDDING_MODEL. */
305
- maxVectorDistance?: number;
306
324
  now?: () => number;
307
325
  supersessionDecider?: MemorySupersessionDecider;
308
326
  }
@@ -949,6 +967,25 @@ async function listVisibleMemories(args: {
949
967
  return rows.map(parseMemoryRow);
950
968
  }
951
969
 
970
+ function normalizeRetrievalQuery(query: string): string {
971
+ const normalized = query.replace(/\s+/g, " ").trim();
972
+ if (normalized.length <= MAX_RETRIEVAL_QUERY_CHARS) {
973
+ return normalized;
974
+ }
975
+ return normalized.slice(0, MAX_RETRIEVAL_QUERY_CHARS).trimEnd();
976
+ }
977
+
978
+ function retrievalLegLimit(limit: number, overfetch: number): number {
979
+ const requested = Math.max(1, limit);
980
+ const withOverfetch = requested * Math.max(1, overfetch);
981
+ // Never return fewer candidates than the caller asked for. A hard overfetch
982
+ // cap below `limit` under-fills when one modality is empty or both overlap.
983
+ return Math.min(
984
+ MAX_RETRIEVAL_LEG_CANDIDATES,
985
+ Math.max(requested, withOverfetch),
986
+ );
987
+ }
988
+
952
989
  /** Search a bounded active candidate set with PostgreSQL full-text ranking. */
953
990
  async function searchVisibleLexicalMemories(args: {
954
991
  db: MemoryDb;
@@ -961,7 +998,11 @@ async function searchVisibleLexicalMemories(args: {
961
998
  if (!predicate) {
962
999
  return [];
963
1000
  }
964
- const queryVector = sql`to_tsvector('english', ${args.query})`;
1001
+ const query = normalizeRetrievalQuery(args.query);
1002
+ if (!query) {
1003
+ return [];
1004
+ }
1005
+ const queryVector = sql`to_tsvector('english', ${query})`;
965
1006
  const tsquery = sql`(
966
1007
  SELECT COALESCE(
967
1008
  string_agg(quote_literal(term), ' | ')::tsquery,
@@ -969,9 +1010,10 @@ async function searchVisibleLexicalMemories(args: {
969
1010
  )
970
1011
  FROM unnest(tsvector_to_array(${queryVector})) AS query_terms(term)
971
1012
  )`;
1013
+ // GIN filter first, then rank only a bounded recent match window.
972
1014
  const candidateLimit = Math.min(
973
1015
  MAX_LEXICAL_RANK_CANDIDATES,
974
- args.limit * LEXICAL_RANK_OVERFETCH,
1016
+ args.limit * LEXICAL_RANK_WINDOW_MULTIPLIER,
975
1017
  );
976
1018
  const candidates = args.db
977
1019
  .select()
@@ -1021,7 +1063,7 @@ async function searchVisibleLexicalMemories(args: {
1021
1063
  }));
1022
1064
  }
1023
1065
 
1024
- /** Search active visible records with exact pgvector cosine distance. */
1066
+ /** Search active visible records with pgvector cosine distance. */
1025
1067
  async function searchVisibleVectorMemories(args: {
1026
1068
  db: MemoryDb;
1027
1069
  embedder: MemoryEmbeddingProvider | undefined;
@@ -1038,9 +1080,13 @@ async function searchVisibleVectorMemories(args: {
1038
1080
  if (!predicate) {
1039
1081
  return [];
1040
1082
  }
1083
+ const query = normalizeRetrievalQuery(args.query);
1084
+ if (!query) {
1085
+ return [];
1086
+ }
1041
1087
  let embedding: Awaited<ReturnType<typeof embedOne>>;
1042
1088
  try {
1043
- embedding = await embedOne(args.embedder, args.query);
1089
+ embedding = await embedOne(args.embedder, query);
1044
1090
  } catch {
1045
1091
  return [];
1046
1092
  }
@@ -1048,6 +1094,11 @@ async function searchVisibleVectorMemories(args: {
1048
1094
  juniorMemoryEmbeddings.embedding,
1049
1095
  embedding.vector,
1050
1096
  );
1097
+ // Push distance cutoff into SQL so recall does not overfetch weak neighbors.
1098
+ const distancePredicate =
1099
+ args.maxDistance === undefined
1100
+ ? undefined
1101
+ : sql`${distance} <= ${args.maxDistance}`;
1051
1102
  const rows = await args.db
1052
1103
  .select({
1053
1104
  contentHash: juniorMemoryEmbeddings.contentHash,
@@ -1066,6 +1117,7 @@ async function searchVisibleVectorMemories(args: {
1066
1117
  eq(juniorMemoryEmbeddings.model, embedding.model),
1067
1118
  eq(juniorMemoryEmbeddings.dimensions, MEMORY_EMBEDDING_DIMENSIONS),
1068
1119
  eq(juniorMemoryEmbeddings.metric, EMBEDDING_METRIC),
1120
+ ...(distancePredicate ? [distancePredicate] : []),
1069
1121
  ),
1070
1122
  )
1071
1123
  .orderBy(
@@ -1084,9 +1136,6 @@ async function searchVisibleVectorMemories(args: {
1084
1136
  ) {
1085
1137
  return [];
1086
1138
  }
1087
- if (args.maxDistance !== undefined && distanceValue > args.maxDistance) {
1088
- return [];
1089
- }
1090
1139
  return [
1091
1140
  {
1092
1141
  memory: parseMemoryRow(row.memory),
@@ -1108,7 +1157,6 @@ export function createMemoryStore(
1108
1157
  const runtimeContext = memoryRuntimeContextSchema.parse(context);
1109
1158
  const parsedOptions = memoryStoreOptionsSchema.parse({ now: options.now });
1110
1159
  const embedder = options.embedder;
1111
- const maxVectorDistance = options.maxVectorDistance;
1112
1160
  const supersessionDecider = options.supersessionDecider;
1113
1161
  const getNowMs = parsedOptions.now ?? Date.now;
1114
1162
 
@@ -1354,6 +1402,14 @@ export function createMemoryStore(
1354
1402
  : { created: false, memory: idempotent.memory };
1355
1403
  }
1356
1404
 
1405
+ /**
1406
+ * Hybrid retrieval for both automatic recall and explicit search.
1407
+ *
1408
+ * Keep both legs parallel and fuse ranks with RRF. Never skip lexical when
1409
+ * vectors already hit: that drops exact/token memories and serializes the
1410
+ * miss path. Each leg is a hard-capped top-k probe so Postgres work stays
1411
+ * bounded even on broad queries.
1412
+ */
1357
1413
  async function retrieveVisibleMemories(
1358
1414
  rawInput: SearchMemoriesInput,
1359
1415
  vectorMaxDistance: number | undefined,
@@ -1367,7 +1423,13 @@ export function createMemoryStore(
1367
1423
  scopes,
1368
1424
  });
1369
1425
  const limit = boundedLimit(input.limit, DEFAULT_SEARCH_LIMIT);
1370
- const candidateLimit = limit * VECTOR_SEARCH_OVERFETCH;
1426
+ const overfetch =
1427
+ vectorMaxDistance === undefined
1428
+ ? SEARCH_RETRIEVAL_OVERFETCH
1429
+ : RECALL_RETRIEVAL_OVERFETCH;
1430
+ const candidateLimit = retrievalLegLimit(limit, overfetch);
1431
+ // Always run both legs in parallel. Conditional lexical skip is unsafe:
1432
+ // one in-threshold vector distractor can hide a stronger lexical hit.
1371
1433
  const [vectorMatches, lexicalMatches] = await Promise.all([
1372
1434
  searchVisibleVectorMemories({
1373
1435
  db,
@@ -1391,6 +1453,10 @@ export function createMemoryStore(
1391
1453
  const channelPrefix = sourceChannelPrefix(runtimeContext);
1392
1454
  return rankMemoryMatches([...vectorMatches, ...lexicalMatches], {
1393
1455
  nowMs,
1456
+ // Slight lexical preference protects exact ids/names/timezones on ties.
1457
+ ...(vectorMaxDistance === undefined
1458
+ ? {}
1459
+ : { lexicalWeight: 1, vectorWeight: 0.85 }),
1394
1460
  ...(channelPrefix ? { channelPrefix } : {}),
1395
1461
  })
1396
1462
  .slice(0, limit)
@@ -1445,7 +1511,7 @@ export function createMemoryStore(
1445
1511
  },
1446
1512
 
1447
1513
  async recallMemories(input) {
1448
- return await retrieveVisibleMemories(input, maxVectorDistance);
1514
+ return await retrieveVisibleMemories(input, RECALL_MAX_VECTOR_DISTANCE);
1449
1515
  },
1450
1516
 
1451
1517
  async searchMemories(input) {