@sentry/junior-memory 0.119.0 → 0.120.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
@@ -2,9 +2,11 @@ import {
2
2
  definePromptContext,
3
3
  type UserPromptContribution,
4
4
  type Actor,
5
+ type PluginLogger,
5
6
  type Source,
6
7
  } from "@sentry/junior-plugin-api";
7
8
  import { z } from "zod";
9
+ import type { MemoryAgent } from "./agent";
8
10
  import {
9
11
  createMemoryStore,
10
12
  type MemoryDb,
@@ -14,13 +16,16 @@ import {
14
16
  import { memoryRuntimeContextSchema } from "./types";
15
17
 
16
18
  const DEFAULT_RECALL_LIMIT = 5;
19
+ const RECALL_CANDIDATE_LIMIT = 20;
17
20
  const MAX_PROMPT_CHARS = 4_000;
18
21
  const MAX_MEMORY_LINE_CHARS = 600;
19
22
 
20
23
  export interface MemoryRecallContext {
24
+ agent: Pick<MemoryAgent, "selectRelevantMemories">;
21
25
  conversationId?: string;
22
26
  db: MemoryDb;
23
27
  embedder?: MemoryEmbeddingProvider;
28
+ log: PluginLogger;
24
29
  /** Maximum cosine distance for vector recall. Passed through to the memory store. */
25
30
  maxVectorDistance?: number;
26
31
  actor?: Actor;
@@ -122,16 +127,38 @@ export async function createMemoryPromptContributions(
122
127
  ...(context.actor ? { actor: context.actor } : {}),
123
128
  source: context.source,
124
129
  });
125
- const memories = await createMemoryStore(context.db, runtimeContext, {
130
+ const candidates = await createMemoryStore(context.db, runtimeContext, {
126
131
  ...(context.embedder ? { embedder: context.embedder } : {}),
127
132
  ...(context.maxVectorDistance !== undefined
128
133
  ? { maxVectorDistance: context.maxVectorDistance }
129
134
  : {}),
130
- }).searchMemories({
135
+ }).recallMemories({
131
136
  query: context.text,
132
- limit: DEFAULT_RECALL_LIMIT,
137
+ limit: RECALL_CANDIDATE_LIMIT,
133
138
  });
134
- const selected = selectPromptMemories(memories);
139
+ if (candidates.length === 0) {
140
+ return undefined;
141
+ }
142
+ let relevantIds: string[];
143
+ try {
144
+ relevantIds = await context.agent.selectRelevantMemories({
145
+ candidates: candidates.map(({ content, id }) => ({ content, id })),
146
+ userRequest: context.text,
147
+ });
148
+ } catch {
149
+ // Automatic recall is optional context; a relevance-model failure must not
150
+ // prevent the user's turn from continuing without recalled memory.
151
+ context.log.warn("memory_recall_selection_failed");
152
+ return undefined;
153
+ }
154
+ const candidatesById = new Map(
155
+ candidates.map((memory) => [memory.id, memory]),
156
+ );
157
+ const relevant = relevantIds
158
+ .map((id) => candidatesById.get(id))
159
+ .filter((memory): memory is MemoryRecord => memory !== undefined)
160
+ .slice(0, DEFAULT_RECALL_LIMIT);
161
+ const selected = selectPromptMemories(relevant);
135
162
  if (selected.length === 0) {
136
163
  return undefined;
137
164
  }
package/src/store.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  desc,
13
13
  eq,
14
14
  gt,
15
- ilike,
16
15
  inArray,
17
16
  isNull,
18
17
  isNotNull,
@@ -28,6 +27,7 @@ import type { PgQueryResultHKT } from "drizzle-orm/pg-core/session";
28
27
  import { z } from "zod";
29
28
  import * as memorySqlSchema from "./db/schema";
30
29
  import { juniorMemoryEmbeddings, juniorMemoryMemories } from "./db/schema";
30
+ import { rankMemoryMatches, type MemoryMatch } from "./ranking";
31
31
  import {
32
32
  MEMORY_EMBEDDING_DIMENSIONS,
33
33
  MEMORY_SCOPES,
@@ -49,24 +49,14 @@ import {
49
49
  const DEFAULT_LIST_LIMIT = 50;
50
50
  const DEFAULT_SEARCH_LIMIT = 10;
51
51
  const DEFAULT_EXPIRED_ARCHIVE_LIMIT = 100;
52
- const HIGH_CONFIDENCE_DUPLICATE_DISTANCE = 0.015;
53
52
  const PREFERENCE_ADJUDICATION_CANDIDATE_LIMIT = 10;
54
53
  const PREFERENCE_ADJUDICATION_VECTOR_LIMIT = 5;
55
54
  const VECTOR_SEARCH_OVERFETCH = 4;
56
55
  const MAX_MEMORY_CONTENT_CHARS = 4_000;
57
56
  const EMBEDDING_METRIC = "cosine";
58
- const ONE_DAY_MS = 24 * 60 * 60 * 1000;
59
- const RELEVANCE_NEAR_TIE_DELTA = 0.01;
60
- const SOURCE_CHANNEL_BOOST = 0.05;
61
57
 
62
58
  export type MemoryDb = PgDatabase<PgQueryResultHKT, typeof memorySqlSchema>;
63
59
 
64
- interface SearchCandidate {
65
- memory: MemoryRecord;
66
- score: number;
67
- sourceKey: string;
68
- }
69
-
70
60
  interface MemoryEmbedding {
71
61
  model: string;
72
62
  provider: string;
@@ -326,6 +316,11 @@ export interface MemoryStore {
326
316
  listMemories(input: ListMemoriesInput): Promise<MemoryRecord[]>;
327
317
  /** List active personal memories owned by the current actor. */
328
318
  listPersonalMemories(input: ListMemoriesInput): Promise<MemoryRecord[]>;
319
+ /**
320
+ * Retrieve a broad relevance-ranked candidate window for automatic recall.
321
+ * Prompt admission remains owned by the recall boundary.
322
+ */
323
+ recallMemories(input: SearchMemoriesInput): Promise<MemoryRecord[]>;
329
324
  /** Search active memories visible in the current runtime context. */
330
325
  searchMemories(input: SearchMemoriesInput): Promise<MemoryRecord[]>;
331
326
  }
@@ -384,7 +379,8 @@ function sourceChannelPrefix(ctx: MemoryRuntimeContext): string | undefined {
384
379
  }
385
380
 
386
381
  /** Parse one SQL row into the public memory record projection. */
387
- function parseMemoryRow(row: unknown): MemoryRecord {
382
+ /** Parse one SQL row into the public memory projection. */
383
+ export function parseMemoryRow(row: unknown): MemoryRecord {
388
384
  const parsed = memoryRowSchema.parse(row);
389
385
  return memoryRecordSchema.parse({
390
386
  id: parsed.id,
@@ -423,7 +419,8 @@ function visibleScopePredicate(scopes: ResolvedMemoryScope[]): SQL | undefined {
423
419
  );
424
420
  }
425
421
 
426
- function activeVisiblePredicate(args: {
422
+ /** Build the active-row predicate for already-authorized memory scopes. */
423
+ export function activeVisiblePredicate(args: {
427
424
  nowMs: number;
428
425
  scopes: ResolvedMemoryScope[];
429
426
  }): SQL | undefined {
@@ -525,7 +522,7 @@ async function findByIdempotencyKey(args: {
525
522
  /**
526
523
  * Archive a bounded batch of expired active rows and remove their derived vectors.
527
524
  */
528
- async function archiveExpiredMemoryBatch(args: {
525
+ export async function archiveExpiredMemoryBatch(args: {
529
526
  db: MemoryDb;
530
527
  idempotencyKey?: string;
531
528
  limit?: number;
@@ -583,27 +580,38 @@ async function archiveExpiredMemoryBatch(args: {
583
580
  return { archivedCount: archivedIds.length };
584
581
  }
585
582
 
586
- function searchScore(memory: MemoryRecord, terms: string[]): number {
587
- const haystack = memory.content.toLowerCase();
588
- // Lexical score is a retrieval fallback signal, not a memory policy decision.
589
- return terms.reduce(
590
- (score, term) => score + (haystack.includes(term) ? 1 : 0),
591
- 0,
592
- );
593
- }
594
-
595
- function searchTerms(query: string): string[] {
583
+ /**
584
+ * Find path-like, dotted, underscored, and multi-part hyphenated identifiers.
585
+ * Two-part hyphenated prose such as "long-running" remains ordinary text.
586
+ */
587
+ function structuredQueryIdentifiers(query: string): string[] {
588
+ const matches =
589
+ query
590
+ .toLowerCase()
591
+ .match(
592
+ /(?:[a-z0-9][a-z0-9._-]*\/)+[a-z0-9][a-z0-9._-]*|[a-z0-9]+(?:[._][a-z0-9]+)+|[a-z0-9]+(?:-[a-z0-9]+){2,}/g,
593
+ ) ?? [];
596
594
  return [
597
- ...new Set(
598
- query
599
- .toLowerCase()
600
- .split(/[^a-z0-9_'-]+/)
601
- .map((term) => term.trim())
602
- .filter((term) => term.length >= 2),
603
- ),
595
+ ...new Set(matches.map((identifier) => identifier.replace(/\.+$/, ""))),
604
596
  ];
605
597
  }
606
598
 
599
+ function denseRanks<T>(
600
+ values: T[],
601
+ key: (value: T) => string | number,
602
+ ): number[] {
603
+ let previous: string | number | undefined;
604
+ let rank = 0;
605
+ return values.map((value, index) => {
606
+ const current = key(value);
607
+ if (index === 0 || current !== previous) {
608
+ rank = index + 1;
609
+ previous = current;
610
+ }
611
+ return rank;
612
+ });
613
+ }
614
+
607
615
  async function embedOne(
608
616
  embedder: MemoryEmbeddingProvider,
609
617
  text: string,
@@ -735,52 +743,6 @@ async function findExactDuplicateMemory(args: {
735
743
  return rows[0] ? parseMemoryRow(rows[0]) : undefined;
736
744
  }
737
745
 
738
- async function findVectorDuplicateMemory(args: {
739
- db: MemoryDb;
740
- embedding: MemoryEmbedding;
741
- kind: MemoryRecord["kind"];
742
- nowMs: number;
743
- scope: ResolvedMemoryScope;
744
- subject: ResolvedMemorySubject;
745
- }): Promise<MemoryRecord | undefined> {
746
- const distance = cosineDistance(
747
- juniorMemoryEmbeddings.embedding,
748
- args.embedding.vector,
749
- );
750
- const rows = await args.db
751
- .select({
752
- contentHash: juniorMemoryEmbeddings.contentHash,
753
- distance,
754
- memory: juniorMemoryMemories,
755
- })
756
- .from(juniorMemoryMemories)
757
- .innerJoin(
758
- juniorMemoryEmbeddings,
759
- eq(juniorMemoryEmbeddings.memoryId, juniorMemoryMemories.id),
760
- )
761
- .where(
762
- and(
763
- activeScopedSubjectPredicate(args),
764
- eq(juniorMemoryEmbeddings.provider, args.embedding.provider),
765
- eq(juniorMemoryEmbeddings.model, args.embedding.model),
766
- eq(juniorMemoryEmbeddings.dimensions, MEMORY_EMBEDDING_DIMENSIONS),
767
- eq(juniorMemoryEmbeddings.metric, EMBEDDING_METRIC),
768
- lte(distance, HIGH_CONFIDENCE_DUPLICATE_DISTANCE),
769
- ),
770
- )
771
- .orderBy(
772
- distance,
773
- desc(juniorMemoryMemories.createdAtMs),
774
- asc(juniorMemoryMemories.id),
775
- )
776
- .limit(1);
777
- const row = rows[0];
778
- if (!row || hashEmbeddedContent(row.memory.content) !== row.contentHash) {
779
- return undefined;
780
- }
781
- return parseMemoryRow(row.memory);
782
- }
783
-
784
746
  async function rememberDuplicateIdempotency(args: {
785
747
  content: string;
786
748
  db: MemoryDb;
@@ -989,38 +951,73 @@ async function listVisibleMemories(args: {
989
951
  return rows.map(parseMemoryRow);
990
952
  }
991
953
 
992
- /** Search active visible records with the V1 lexical matcher. */
993
- async function searchVisibleMemories(args: {
954
+ /** Search active visible records with PostgreSQL full-text ranking. */
955
+ async function searchVisibleLexicalMemories(args: {
994
956
  db: MemoryDb;
957
+ limit: number;
995
958
  nowMs: number;
996
959
  query: string;
997
960
  scopes: ResolvedMemoryScope[];
998
- }): Promise<SearchCandidate[]> {
999
- const terms = searchTerms(args.query);
1000
- if (terms.length === 0) {
1001
- return [];
1002
- }
961
+ }): Promise<MemoryMatch[]> {
1003
962
  const predicate = activeVisiblePredicate(args);
1004
963
  if (!predicate) {
1005
964
  return [];
1006
965
  }
966
+ const queryVector = sql`to_tsvector('english', ${args.query})`;
967
+ const tsquery = sql`(
968
+ SELECT COALESCE(
969
+ string_agg(quote_literal(term), ' | ')::tsquery,
970
+ ''::tsquery
971
+ )
972
+ FROM unnest(tsvector_to_array(${queryVector})) AS query_terms(term)
973
+ )`;
974
+ const textsearch = sql`to_tsvector('english', ${juniorMemoryMemories.content})`;
975
+ const textRank = sql<number>`ts_rank_cd(${textsearch}, ${tsquery})`;
976
+ const identifiers = structuredQueryIdentifiers(args.query);
977
+ const contentTokens = sql`regexp_split_to_array(lower(${juniorMemoryMemories.content}), '[^a-z0-9._/-]+')`;
978
+ const identifierArray =
979
+ identifiers.length > 0
980
+ ? sql`ARRAY[${sql.join(
981
+ identifiers.map((identifier) => sql`${identifier}`),
982
+ sql`, `,
983
+ )}]::text[]`
984
+ : undefined;
985
+ const exactIdentifierMatch =
986
+ identifierArray !== undefined
987
+ ? sql<boolean>`EXISTS (
988
+ SELECT 1
989
+ FROM unnest(${contentTokens}) AS content_token(value)
990
+ CROSS JOIN unnest(${identifierArray}) AS query_identifier(value)
991
+ WHERE strpos(
992
+ '/' || regexp_replace(content_token.value, '\\.+$', '') || '/',
993
+ '/' || query_identifier.value || '/'
994
+ ) > 0
995
+ )`
996
+ : sql<boolean>`false`;
1007
997
  const rows = await args.db
1008
- .select()
998
+ .select({
999
+ exactIdentifierMatch,
1000
+ memory: juniorMemoryMemories,
1001
+ textRank,
1002
+ })
1009
1003
  .from(juniorMemoryMemories)
1010
- .where(
1011
- and(
1012
- predicate,
1013
- or(
1014
- ...terms.map((term) =>
1015
- ilike(juniorMemoryMemories.content, `%${term}%`),
1016
- ),
1017
- ),
1018
- ),
1019
- );
1020
- return rows.map((row) => ({
1021
- memory: parseMemoryRow(row),
1022
- score: 0,
1023
- sourceKey: row.sourceKey,
1004
+ .where(and(predicate, sql`${textsearch} @@ ${tsquery}`))
1005
+ .orderBy(
1006
+ ...(identifiers.length > 0 ? [desc(exactIdentifierMatch)] : []),
1007
+ desc(textRank),
1008
+ desc(juniorMemoryMemories.observedAtMs),
1009
+ asc(juniorMemoryMemories.id),
1010
+ )
1011
+ .limit(args.limit);
1012
+ const ranks = denseRanks(
1013
+ rows,
1014
+ (row) => `${row.exactIdentifierMatch}:${Number(row.textRank)}`,
1015
+ );
1016
+ return rows.map((row, index) => ({
1017
+ exactIdentifier: row.exactIdentifierMatch,
1018
+ lexical: { rank: ranks[index] },
1019
+ memory: parseMemoryRow(row.memory),
1020
+ sourceKey: row.memory.sourceKey,
1024
1021
  }));
1025
1022
  }
1026
1023
 
@@ -1033,7 +1030,7 @@ async function searchVisibleVectorMemories(args: {
1033
1030
  nowMs: number;
1034
1031
  query: string;
1035
1032
  scopes: ResolvedMemoryScope[];
1036
- }): Promise<SearchCandidate[]> {
1033
+ }): Promise<MemoryMatch[]> {
1037
1034
  if (!args.embedder) {
1038
1035
  return [];
1039
1036
  }
@@ -1077,7 +1074,8 @@ async function searchVisibleVectorMemories(args: {
1077
1074
  asc(juniorMemoryMemories.id),
1078
1075
  )
1079
1076
  .limit(args.limit);
1080
- return rows.flatMap((row) => {
1077
+ const ranks = denseRanks(rows, (row) => Number(row.distance));
1078
+ return rows.flatMap((row, index) => {
1081
1079
  const distanceValue = Number(row.distance);
1082
1080
  if (
1083
1081
  row.distance === null ||
@@ -1091,89 +1089,17 @@ async function searchVisibleVectorMemories(args: {
1091
1089
  }
1092
1090
  return [
1093
1091
  {
1092
+ exactIdentifier: false,
1094
1093
  memory: parseMemoryRow(row.memory),
1095
- score: 1 / (1 + Math.max(0, distanceValue)),
1096
1094
  sourceKey: row.memory.sourceKey,
1095
+ vector: {
1096
+ rank: ranks[index],
1097
+ },
1097
1098
  },
1098
1099
  ];
1099
1100
  });
1100
1101
  }
1101
1102
 
1102
- function sourceBoost(
1103
- candidate: Pick<SearchCandidate, "sourceKey">,
1104
- currentChannelPrefix: string | undefined,
1105
- ): number {
1106
- if (!currentChannelPrefix) {
1107
- return 0;
1108
- }
1109
- return candidate.sourceKey.startsWith(currentChannelPrefix)
1110
- ? SOURCE_CHANNEL_BOOST
1111
- : 0;
1112
- }
1113
-
1114
- /** Score only observation age for near-tie reranking; this is not lifecycle decay. */
1115
- function observedAgeBoost(memory: MemoryRecord, nowMs: number): number {
1116
- const ageMs = Math.max(0, nowMs - memory.observedAtMs);
1117
- if (ageMs <= 7 * ONE_DAY_MS) {
1118
- return 0.15;
1119
- }
1120
- if (ageMs <= 30 * ONE_DAY_MS) {
1121
- return 0.1;
1122
- }
1123
- if (ageMs <= 90 * ONE_DAY_MS) {
1124
- return 0.05;
1125
- }
1126
- return 0;
1127
- }
1128
-
1129
- /** Fuse retrieval candidates while keeping observed age to near-tie reranking. */
1130
- function mergeSearchCandidates(
1131
- candidates: SearchCandidate[],
1132
- nowMs: number,
1133
- currentChannelKey?: string,
1134
- ): MemoryRecord[] {
1135
- const byId = new Map<
1136
- string,
1137
- {
1138
- memory: MemoryRecord;
1139
- score: number;
1140
- sourceKey: string;
1141
- }
1142
- >();
1143
- for (const candidate of candidates) {
1144
- const existing = byId.get(candidate.memory.id);
1145
- if (existing) {
1146
- existing.score += candidate.score;
1147
- continue;
1148
- }
1149
- byId.set(candidate.memory.id, {
1150
- memory: candidate.memory,
1151
- score: candidate.score,
1152
- sourceKey: candidate.sourceKey,
1153
- });
1154
- }
1155
- return [...byId.values()]
1156
- .sort((left, right) => {
1157
- const scoreDelta = right.score - left.score;
1158
- if (Math.abs(scoreDelta) > RELEVANCE_NEAR_TIE_DELTA) {
1159
- return scoreDelta;
1160
- }
1161
- const sourceDelta =
1162
- sourceBoost(right, currentChannelKey) -
1163
- sourceBoost(left, currentChannelKey);
1164
- if (sourceDelta !== 0) {
1165
- return sourceDelta;
1166
- }
1167
- return (
1168
- observedAgeBoost(right.memory, nowMs) -
1169
- observedAgeBoost(left.memory, nowMs) ||
1170
- right.memory.observedAtMs - left.memory.observedAtMs ||
1171
- left.memory.id.localeCompare(right.memory.id)
1172
- );
1173
- })
1174
- .map((candidate) => candidate.memory);
1175
- }
1176
-
1177
1103
  /** Create a context-bound SQL-backed store for explicit memory operations. */
1178
1104
  export function createMemoryStore(
1179
1105
  db: MemoryDb,
@@ -1294,27 +1220,6 @@ export function createMemoryStore(
1294
1220
  candidateEmbedding = undefined;
1295
1221
  }
1296
1222
  }
1297
- const vectorDuplicate = candidateEmbedding
1298
- ? await findVectorDuplicateMemory({
1299
- db,
1300
- embedding: candidateEmbedding,
1301
- kind: input.kind,
1302
- nowMs,
1303
- scope,
1304
- subject,
1305
- })
1306
- : undefined;
1307
- if (vectorDuplicate) {
1308
- return await reuseDuplicateMemory({
1309
- content,
1310
- duplicate: vectorDuplicate,
1311
- idempotencyKey: input.idempotencyKey,
1312
- nowMs,
1313
- scope,
1314
- subject,
1315
- });
1316
- }
1317
-
1318
1223
  let supersededIds: string[] = [];
1319
1224
  if (
1320
1225
  scopeKind === "personal" &&
@@ -1440,6 +1345,49 @@ export function createMemoryStore(
1440
1345
  return { created: false, memory: idempotent };
1441
1346
  }
1442
1347
 
1348
+ async function retrieveVisibleMemories(
1349
+ rawInput: SearchMemoriesInput,
1350
+ vectorMaxDistance: number | undefined,
1351
+ ): Promise<MemoryRecord[]> {
1352
+ const input = searchMemoriesInputSchema.parse(rawInput);
1353
+ const nowMs = getNowMs();
1354
+ const scopes = deriveVisibleMemoryScopes(runtimeContext);
1355
+ await archiveExpiredMemoryBatch({
1356
+ db,
1357
+ nowMs,
1358
+ scopes,
1359
+ });
1360
+ const limit = boundedLimit(input.limit, DEFAULT_SEARCH_LIMIT);
1361
+ const candidateLimit = limit * VECTOR_SEARCH_OVERFETCH;
1362
+ const [vectorMatches, lexicalMatches] = await Promise.all([
1363
+ searchVisibleVectorMemories({
1364
+ db,
1365
+ embedder,
1366
+ limit: candidateLimit,
1367
+ ...(vectorMaxDistance !== undefined
1368
+ ? { maxDistance: vectorMaxDistance }
1369
+ : {}),
1370
+ nowMs,
1371
+ query: input.query,
1372
+ scopes,
1373
+ }),
1374
+ searchVisibleLexicalMemories({
1375
+ db,
1376
+ limit: candidateLimit,
1377
+ nowMs,
1378
+ query: input.query,
1379
+ scopes,
1380
+ }),
1381
+ ]);
1382
+ const channelPrefix = sourceChannelPrefix(runtimeContext);
1383
+ return rankMemoryMatches([...vectorMatches, ...lexicalMatches], {
1384
+ nowMs,
1385
+ ...(channelPrefix ? { channelPrefix } : {}),
1386
+ })
1387
+ .slice(0, limit)
1388
+ .map(({ memory }) => memory);
1389
+ }
1390
+
1443
1391
  return {
1444
1392
  async archiveExpiredMemories(input) {
1445
1393
  return await archiveExpiredVisibleMemories(input, getNowMs());
@@ -1487,45 +1435,12 @@ export function createMemoryStore(
1487
1435
  });
1488
1436
  },
1489
1437
 
1438
+ async recallMemories(input) {
1439
+ return await retrieveVisibleMemories(input, maxVectorDistance);
1440
+ },
1441
+
1490
1442
  async searchMemories(input) {
1491
- input = searchMemoriesInputSchema.parse(input);
1492
- const nowMs = getNowMs();
1493
- const scopes = deriveVisibleMemoryScopes(runtimeContext);
1494
- await archiveExpiredMemoryBatch({
1495
- db,
1496
- nowMs,
1497
- scopes,
1498
- });
1499
- const limit = boundedLimit(input.limit, DEFAULT_SEARCH_LIMIT);
1500
- const vectorCandidates = await searchVisibleVectorMemories({
1501
- db,
1502
- embedder,
1503
- limit: limit * VECTOR_SEARCH_OVERFETCH,
1504
- ...(maxVectorDistance !== undefined
1505
- ? { maxDistance: maxVectorDistance }
1506
- : {}),
1507
- nowMs,
1508
- query: input.query,
1509
- scopes,
1510
- });
1511
- const candidates = await searchVisibleMemories({
1512
- db,
1513
- nowMs,
1514
- query: input.query,
1515
- scopes,
1516
- });
1517
- const terms = searchTerms(input.query);
1518
- const lexicalCandidates = candidates
1519
- .map((candidate) => ({
1520
- ...candidate,
1521
- score: searchScore(candidate.memory, terms),
1522
- }))
1523
- .filter((item) => item.score > 0);
1524
- return mergeSearchCandidates(
1525
- [...vectorCandidates, ...lexicalCandidates],
1526
- nowMs,
1527
- sourceChannelPrefix(runtimeContext),
1528
- ).slice(0, limit);
1443
+ return await retrieveVisibleMemories(input, undefined);
1529
1444
  },
1530
1445
 
1531
1446
  async archiveMemory(input) {
package/src/user-pages.ts CHANGED
@@ -1,36 +1,7 @@
1
- /** Project actor-owned memories into Junior's core-rendered user page. */
2
- import type {
3
- PluginUserPageActor,
4
- PluginUserPageDefinition,
5
- } from "@sentry/junior-plugin-api";
6
- import { createMemoryStore, type MemoryDb, type MemoryRecord } from "./store";
7
- import type { MemoryRuntimeContext } from "./types";
8
-
9
- const MEMORY_PAGE_LIMIT = 100;
10
-
11
- function runtimeContext(
12
- actor: PluginUserPageActor,
13
- ): MemoryRuntimeContext | undefined {
14
- if (actor.platform === "slack") {
15
- return {
16
- actor,
17
- source: {
18
- platform: "slack",
19
- type: "priv",
20
- teamId: actor.teamId,
21
- channelId: "DDASHBOARD",
22
- },
23
- };
24
- }
25
- return {
26
- actor,
27
- source: {
28
- platform: "local",
29
- type: "priv",
30
- conversationId: "local:dashboard:memories",
31
- },
32
- };
33
- }
1
+ /** Project viewer-owned memories into Junior's core-rendered user page. */
2
+ import type { PluginUserPageDefinition } from "@sentry/junior-plugin-api";
3
+ import { createViewerMemories } from "./personal";
4
+ import type { MemoryDb, MemoryRecord } from "./store";
34
5
 
35
6
  function memoryKindLabel(kind: MemoryRecord["kind"]): string {
36
7
  return kind.charAt(0).toUpperCase() + kind.slice(1);
@@ -43,44 +14,38 @@ function rememberedDate(createdAtMs: number): string {
43
14
  }).format(new Date(createdAtMs));
44
15
  }
45
16
 
46
- async function readPersonalMemories(
47
- db: MemoryDb,
48
- actors: PluginUserPageActor[],
49
- ): Promise<MemoryRecord[]> {
50
- const memories = await Promise.all(
51
- actors.flatMap((actor) => {
52
- const context = runtimeContext(actor);
53
- return context
54
- ? [
55
- createMemoryStore(db, context).listPersonalMemories({
56
- limit: MEMORY_PAGE_LIMIT,
57
- }),
58
- ]
59
- : [];
60
- }),
61
- );
62
- return [
63
- ...new Map(memories.flat().map((memory) => [memory.id, memory])).values(),
64
- ]
65
- .sort((left, right) => right.createdAtMs - left.createdAtMs)
66
- .slice(0, MEMORY_PAGE_LIMIT);
67
- }
68
-
69
- /** Create the read-only personal Memories dashboard page. */
17
+ /** Create the interactive personal Memories dashboard page. */
70
18
  export function createMemoryUserPage(): PluginUserPageDefinition {
71
19
  return {
72
20
  id: "memories",
73
21
  label: "Memories",
74
22
  description: "Personal facts and preferences Junior remembers about you.",
75
- async read(ctx) {
76
- const memories = await readPersonalMemories(
23
+ async read(ctx, input) {
24
+ const page = await createViewerMemories(
77
25
  ctx.db as MemoryDb,
78
26
  ctx.viewer.actors,
79
- );
27
+ ).list({
28
+ cursor: input.cursor,
29
+ limit: input.limit,
30
+ ...(input.query ? { query: input.query } : {}),
31
+ });
80
32
  return {
81
33
  type: "list",
82
- emptyText: "No personal memories yet.",
83
- records: memories.map((memory) => ({
34
+ emptyText: input.query
35
+ ? "No memories matched your search."
36
+ : "No personal memories yet.",
37
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
38
+ searchPlaceholder: "Search memories",
39
+ records: page.memories.map((memory) => ({
40
+ actions: [
41
+ {
42
+ confirmation: "Forget this memory?",
43
+ href: `/api/plugins/memory/memories/${encodeURIComponent(memory.id)}`,
44
+ label: "Forget",
45
+ method: "DELETE" as const,
46
+ tone: "danger" as const,
47
+ },
48
+ ],
84
49
  id: memory.id,
85
50
  title: memory.content,
86
51
  metadata: [