@sentry/junior-memory 0.80.1 → 0.82.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/store.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  ilike,
16
16
  inArray,
17
17
  isNull,
18
+ isNotNull,
18
19
  like,
19
20
  lte,
20
21
  or,
@@ -40,6 +41,7 @@ import {
40
41
  import {
41
42
  deriveMemoryScope,
42
43
  deriveMemorySubject,
44
+ type ResolvedMemorySubject,
43
45
  deriveVisibleMemoryScopes,
44
46
  type ResolvedMemoryScope,
45
47
  } from "./scope";
@@ -47,15 +49,38 @@ import {
47
49
  const DEFAULT_LIST_LIMIT = 50;
48
50
  const DEFAULT_SEARCH_LIMIT = 10;
49
51
  const DEFAULT_EXPIRED_ARCHIVE_LIMIT = 100;
52
+ const HIGH_CONFIDENCE_DUPLICATE_DISTANCE = 0.015;
53
+ const SUPERSESSION_CANDIDATE_LIMIT = 10;
50
54
  const VECTOR_SEARCH_OVERFETCH = 4;
51
55
  const MAX_MEMORY_CONTENT_CHARS = 4_000;
52
56
  const EMBEDDING_METRIC = "cosine";
57
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
58
+ const RELEVANCE_NEAR_TIE_DELTA = 0.01;
59
+ const SOURCE_CHANNEL_BOOST = 0.05;
60
+ const SUPERSESSION_STOP_TERMS = new Set([
61
+ "and",
62
+ "for",
63
+ "prefer",
64
+ "prefers",
65
+ "preference",
66
+ "the",
67
+ "use",
68
+ "uses",
69
+ "with",
70
+ ]);
53
71
 
54
72
  export type MemoryDb = PgDatabase<PgQueryResultHKT, typeof memorySqlSchema>;
55
73
 
56
74
  interface SearchCandidate {
57
75
  memory: MemoryRecord;
58
76
  score: number;
77
+ sourceKey: string;
78
+ }
79
+
80
+ interface MemoryEmbedding {
81
+ model: string;
82
+ provider: string;
83
+ vector: number[];
59
84
  }
60
85
 
61
86
  const nonEmptyStringSchema = z.string().min(1);
@@ -215,9 +240,46 @@ export interface MemoryEmbeddingProvider {
215
240
  }>;
216
241
  }
217
242
 
243
+ export interface MemorySupersessionInput {
244
+ candidate: {
245
+ content: string;
246
+ kind: "preference";
247
+ };
248
+ existingMemories: [
249
+ {
250
+ content: string;
251
+ id: string;
252
+ },
253
+ ...Array<{
254
+ content: string;
255
+ id: string;
256
+ }>,
257
+ ];
258
+ runtimeContext: MemoryRuntimeContext;
259
+ }
260
+
261
+ export type MemorySupersessionDecision =
262
+ | {
263
+ decision: "supersedes_old";
264
+ supersededIds: [string, ...string[]];
265
+ }
266
+ | {
267
+ decision: "distinct" | "uncertain";
268
+ };
269
+
270
+ export interface MemorySupersessionDecider {
271
+ /** Decide whether a new preference clearly replaces active old preferences. */
272
+ adjudicateSupersession(
273
+ input: MemorySupersessionInput,
274
+ ): Promise<MemorySupersessionDecision> | MemorySupersessionDecision;
275
+ }
276
+
218
277
  export interface MemoryStoreOptions {
219
278
  embedder?: MemoryEmbeddingProvider;
279
+ /** Maximum cosine distance for vector recall candidates. Model-dependent; tune when changing AI_EMBEDDING_MODEL. */
280
+ maxVectorDistance?: number;
220
281
  now?: () => number;
282
+ supersessionDecider?: MemorySupersessionDecider;
221
283
  }
222
284
 
223
285
  /** Context-bound storage operations for visible long-term memories. */
@@ -248,6 +310,22 @@ function hashEmbeddedContent(content: string): string {
248
310
  return createHash("sha256").update(content, "utf8").digest("hex");
249
311
  }
250
312
 
313
+ function idempotencyAliasId(args: {
314
+ idempotencyKey: string;
315
+ scope: ResolvedMemoryScope;
316
+ targetId: string;
317
+ }): string {
318
+ return `alias:${createHash("sha256")
319
+ .update(args.scope.scope)
320
+ .update("\0")
321
+ .update(args.scope.scopeKey)
322
+ .update("\0")
323
+ .update(args.idempotencyKey)
324
+ .update("\0")
325
+ .update(args.targetId)
326
+ .digest("hex")}`;
327
+ }
328
+
251
329
  function boundedLimit(value: number | undefined, fallback: number): number {
252
330
  if (typeof value !== "number" || !Number.isFinite(value)) {
253
331
  return fallback;
@@ -269,6 +347,14 @@ function sourceKey(ctx: MemoryRuntimeContext): string {
269
347
  return `slack:${ctx.source.teamId}:${ctx.source.channelId}:${threadKey}`;
270
348
  }
271
349
 
350
+ function sourceChannelPrefix(ctx: MemoryRuntimeContext): string | undefined {
351
+ if (ctx.source.platform !== "slack") {
352
+ return undefined;
353
+ }
354
+ // TODO(v0.82.0): Replace Slack source-key prefix matching with typed source proximity metadata.
355
+ return `slack:${ctx.source.teamId}:${ctx.source.channelId}:`;
356
+ }
357
+
272
358
  /** Parse one SQL row into the public memory record projection. */
273
359
  function parseMemoryRow(row: unknown): MemoryRecord {
274
360
  const parsed = memoryRowSchema.parse(row);
@@ -336,7 +422,7 @@ async function findByIdempotencyKey(args: {
336
422
  nowMs: number;
337
423
  scope: ResolvedMemoryScope;
338
424
  }): Promise<MemoryRecord | undefined> {
339
- const rows = await args.db
425
+ const activeRows = await args.db
340
426
  .select()
341
427
  .from(juniorMemoryMemories)
342
428
  .where(
@@ -354,7 +440,58 @@ async function findByIdempotencyKey(args: {
354
440
  ),
355
441
  )
356
442
  .limit(1);
357
- return rows[0] ? parseMemoryRow(rows[0]) : undefined;
443
+ if (activeRows[0]) {
444
+ return parseMemoryRow(activeRows[0]);
445
+ }
446
+
447
+ const aliasRows = await args.db
448
+ .select({ supersededById: juniorMemoryMemories.supersededById })
449
+ .from(juniorMemoryMemories)
450
+ .where(
451
+ and(
452
+ eq(juniorMemoryMemories.scope, args.scope.scope),
453
+ eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey),
454
+ eq(juniorMemoryMemories.idempotencyKey, args.idempotencyKey),
455
+ isNull(juniorMemoryMemories.archivedAtMs),
456
+ isNotNull(juniorMemoryMemories.supersededAtMs),
457
+ isNotNull(juniorMemoryMemories.supersededById),
458
+ or(
459
+ isNull(juniorMemoryMemories.expiresAtMs),
460
+ gt(juniorMemoryMemories.expiresAtMs, args.nowMs),
461
+ ),
462
+ ),
463
+ )
464
+ .orderBy(
465
+ desc(juniorMemoryMemories.createdAtMs),
466
+ asc(juniorMemoryMemories.id),
467
+ );
468
+ for (const alias of aliasRows) {
469
+ if (!alias.supersededById) {
470
+ continue;
471
+ }
472
+ const rows = await args.db
473
+ .select()
474
+ .from(juniorMemoryMemories)
475
+ .where(
476
+ and(
477
+ eq(juniorMemoryMemories.id, alias.supersededById),
478
+ eq(juniorMemoryMemories.scope, args.scope.scope),
479
+ eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey),
480
+ isNull(juniorMemoryMemories.archivedAtMs),
481
+ isNull(juniorMemoryMemories.supersededAtMs),
482
+ isNull(juniorMemoryMemories.supersededById),
483
+ or(
484
+ isNull(juniorMemoryMemories.expiresAtMs),
485
+ gt(juniorMemoryMemories.expiresAtMs, args.nowMs),
486
+ ),
487
+ ),
488
+ )
489
+ .limit(1);
490
+ if (rows[0]) {
491
+ return parseMemoryRow(rows[0]);
492
+ }
493
+ }
494
+ return undefined;
358
495
  }
359
496
 
360
497
  /**
@@ -442,11 +579,7 @@ function searchTerms(query: string): string[] {
442
579
  async function embedOne(
443
580
  embedder: MemoryEmbeddingProvider,
444
581
  text: string,
445
- ): Promise<{
446
- model: string;
447
- provider: string;
448
- vector: number[];
449
- }> {
582
+ ): Promise<MemoryEmbedding> {
450
583
  const normalized = normalizeContent(text);
451
584
  if (!normalized) {
452
585
  throw new Error("Embedding text is required.");
@@ -469,10 +602,11 @@ async function storeEmbedding(args: {
469
602
  content: string;
470
603
  db: MemoryDb;
471
604
  embedder: MemoryEmbeddingProvider | undefined;
605
+ embedding?: MemoryEmbedding;
472
606
  memoryId: string;
473
607
  nowMs: number;
474
608
  }): Promise<void> {
475
- if (!args.embedder) {
609
+ if (!args.embedder && !args.embedding) {
476
610
  return;
477
611
  }
478
612
  try {
@@ -488,10 +622,18 @@ async function storeEmbedding(args: {
488
622
  return;
489
623
  }
490
624
  let embedding: Awaited<ReturnType<typeof embedOne>>;
491
- try {
492
- embedding = await embedOne(args.embedder, args.content);
493
- } catch {
494
- return;
625
+ if (args.embedding) {
626
+ embedding = args.embedding;
627
+ } else {
628
+ const embedder = args.embedder;
629
+ if (!embedder) {
630
+ return;
631
+ }
632
+ try {
633
+ embedding = await embedOne(embedder, args.content);
634
+ } catch {
635
+ return;
636
+ }
495
637
  }
496
638
  try {
497
639
  await args.db
@@ -512,6 +654,293 @@ async function storeEmbedding(args: {
512
654
  }
513
655
  }
514
656
 
657
+ function activeScopedSubjectPredicate(args: {
658
+ kind: MemoryRecord["kind"];
659
+ nowMs: number;
660
+ scope: ResolvedMemoryScope;
661
+ subject: ResolvedMemorySubject;
662
+ }): SQL {
663
+ const predicate = and(
664
+ eq(juniorMemoryMemories.scope, args.scope.scope),
665
+ eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey),
666
+ eq(juniorMemoryMemories.kind, args.kind),
667
+ eq(juniorMemoryMemories.subjectType, args.subject.subjectType),
668
+ args.subject.subjectKey === undefined
669
+ ? isNull(juniorMemoryMemories.subjectKey)
670
+ : eq(juniorMemoryMemories.subjectKey, args.subject.subjectKey),
671
+ isNull(juniorMemoryMemories.archivedAtMs),
672
+ isNull(juniorMemoryMemories.supersededAtMs),
673
+ isNull(juniorMemoryMemories.supersededById),
674
+ or(
675
+ isNull(juniorMemoryMemories.expiresAtMs),
676
+ gt(juniorMemoryMemories.expiresAtMs, args.nowMs),
677
+ ),
678
+ );
679
+ if (!predicate) {
680
+ throw new Error("Memory duplicate predicate is empty.");
681
+ }
682
+ return predicate;
683
+ }
684
+
685
+ async function findExactDuplicateMemory(args: {
686
+ content: string;
687
+ db: MemoryDb;
688
+ kind: MemoryRecord["kind"];
689
+ nowMs: number;
690
+ scope: ResolvedMemoryScope;
691
+ subject: ResolvedMemorySubject;
692
+ }): Promise<MemoryRecord | undefined> {
693
+ const rows = await args.db
694
+ .select()
695
+ .from(juniorMemoryMemories)
696
+ .where(
697
+ and(
698
+ activeScopedSubjectPredicate(args),
699
+ eq(juniorMemoryMemories.content, args.content),
700
+ ),
701
+ )
702
+ .orderBy(
703
+ desc(juniorMemoryMemories.createdAtMs),
704
+ asc(juniorMemoryMemories.id),
705
+ )
706
+ .limit(1);
707
+ return rows[0] ? parseMemoryRow(rows[0]) : undefined;
708
+ }
709
+
710
+ async function findVectorDuplicateMemory(args: {
711
+ db: MemoryDb;
712
+ embedding: MemoryEmbedding;
713
+ kind: MemoryRecord["kind"];
714
+ nowMs: number;
715
+ scope: ResolvedMemoryScope;
716
+ subject: ResolvedMemorySubject;
717
+ }): Promise<MemoryRecord | undefined> {
718
+ const distance = cosineDistance(
719
+ juniorMemoryEmbeddings.embedding,
720
+ args.embedding.vector,
721
+ );
722
+ const rows = await args.db
723
+ .select({
724
+ contentHash: juniorMemoryEmbeddings.contentHash,
725
+ distance,
726
+ memory: juniorMemoryMemories,
727
+ })
728
+ .from(juniorMemoryMemories)
729
+ .innerJoin(
730
+ juniorMemoryEmbeddings,
731
+ eq(juniorMemoryEmbeddings.memoryId, juniorMemoryMemories.id),
732
+ )
733
+ .where(
734
+ and(
735
+ activeScopedSubjectPredicate(args),
736
+ eq(juniorMemoryEmbeddings.provider, args.embedding.provider),
737
+ eq(juniorMemoryEmbeddings.model, args.embedding.model),
738
+ eq(juniorMemoryEmbeddings.dimensions, MEMORY_EMBEDDING_DIMENSIONS),
739
+ eq(juniorMemoryEmbeddings.metric, EMBEDDING_METRIC),
740
+ lte(distance, HIGH_CONFIDENCE_DUPLICATE_DISTANCE),
741
+ ),
742
+ )
743
+ .orderBy(
744
+ distance,
745
+ desc(juniorMemoryMemories.createdAtMs),
746
+ asc(juniorMemoryMemories.id),
747
+ )
748
+ .limit(1);
749
+ const row = rows[0];
750
+ if (!row || hashEmbeddedContent(row.memory.content) !== row.contentHash) {
751
+ return undefined;
752
+ }
753
+ return parseMemoryRow(row.memory);
754
+ }
755
+
756
+ async function rememberDuplicateIdempotency(args: {
757
+ content: string;
758
+ db: MemoryDb;
759
+ duplicate: MemoryRecord;
760
+ idempotencyKey?: string;
761
+ nowMs: number;
762
+ runtimeContext: MemoryRuntimeContext;
763
+ scope: ResolvedMemoryScope;
764
+ subject: ResolvedMemorySubject;
765
+ }): Promise<void> {
766
+ if (args.idempotencyKey === undefined) {
767
+ return;
768
+ }
769
+ await args.db
770
+ .insert(juniorMemoryMemories)
771
+ .values({
772
+ content: args.content,
773
+ createdAtMs: args.nowMs,
774
+ expiresAtMs: args.duplicate.expiresAtMs,
775
+ id: idempotencyAliasId({
776
+ idempotencyKey: args.idempotencyKey,
777
+ scope: args.scope,
778
+ targetId: args.duplicate.id,
779
+ }),
780
+ idempotencyKey: args.idempotencyKey,
781
+ observedAtMs: args.nowMs,
782
+ scope: args.scope.scope,
783
+ scopeKey: args.scope.scopeKey,
784
+ sourceKey: sourceKey(args.runtimeContext),
785
+ sourcePlatform: args.runtimeContext.source.platform,
786
+ subjectKey: args.subject.subjectKey,
787
+ subjectType: args.subject.subjectType,
788
+ supersededAtMs: args.nowMs,
789
+ supersededById: args.duplicate.id,
790
+ kind: args.duplicate.kind,
791
+ })
792
+ .onConflictDoNothing();
793
+ }
794
+
795
+ async function listSupersessionCandidates(args: {
796
+ content: string;
797
+ db: MemoryDb;
798
+ embedding?: MemoryEmbedding;
799
+ kind: MemoryRecord["kind"];
800
+ nowMs: number;
801
+ scope: ResolvedMemoryScope;
802
+ subject: ResolvedMemorySubject;
803
+ }): Promise<MemoryRecord[]> {
804
+ const predicate = activeScopedSubjectPredicate(args);
805
+ const terms = searchTerms(args.content).filter(
806
+ (term) => !SUPERSESSION_STOP_TERMS.has(term),
807
+ );
808
+ const lexicalRows =
809
+ terms.length > 0
810
+ ? await args.db
811
+ .select()
812
+ .from(juniorMemoryMemories)
813
+ .where(
814
+ and(
815
+ predicate,
816
+ or(
817
+ ...terms.map((term) =>
818
+ ilike(juniorMemoryMemories.content, `%${term}%`),
819
+ ),
820
+ ),
821
+ ),
822
+ )
823
+ : [];
824
+ const lexicalCandidates = lexicalRows
825
+ .map(parseMemoryRow)
826
+ .map((memory) => ({
827
+ memory,
828
+ score: searchScore(memory, terms),
829
+ }))
830
+ .filter((candidate) => candidate.score > 1)
831
+ .sort(
832
+ (left, right) =>
833
+ right.score - left.score ||
834
+ right.memory.createdAtMs - left.memory.createdAtMs ||
835
+ left.memory.id.localeCompare(right.memory.id),
836
+ )
837
+ .map((candidate) => candidate.memory);
838
+
839
+ const vectorCandidates = args.embedding
840
+ ? await listVectorSupersessionCandidates({
841
+ db: args.db,
842
+ embedding: args.embedding,
843
+ kind: args.kind,
844
+ nowMs: args.nowMs,
845
+ scope: args.scope,
846
+ subject: args.subject,
847
+ })
848
+ : [];
849
+ const byId = new Map<string, MemoryRecord>();
850
+ for (const memory of [...lexicalCandidates, ...vectorCandidates]) {
851
+ if (byId.size >= SUPERSESSION_CANDIDATE_LIMIT) {
852
+ break;
853
+ }
854
+ byId.set(memory.id, memory);
855
+ }
856
+ return [...byId.values()];
857
+ }
858
+
859
+ async function listVectorSupersessionCandidates(args: {
860
+ db: MemoryDb;
861
+ embedding: MemoryEmbedding;
862
+ kind: MemoryRecord["kind"];
863
+ nowMs: number;
864
+ scope: ResolvedMemoryScope;
865
+ subject: ResolvedMemorySubject;
866
+ }): Promise<MemoryRecord[]> {
867
+ const distance = cosineDistance(
868
+ juniorMemoryEmbeddings.embedding,
869
+ args.embedding.vector,
870
+ );
871
+ const rows = await args.db
872
+ .select({
873
+ contentHash: juniorMemoryEmbeddings.contentHash,
874
+ distance,
875
+ memory: juniorMemoryMemories,
876
+ })
877
+ .from(juniorMemoryMemories)
878
+ .innerJoin(
879
+ juniorMemoryEmbeddings,
880
+ eq(juniorMemoryEmbeddings.memoryId, juniorMemoryMemories.id),
881
+ )
882
+ .where(
883
+ and(
884
+ activeScopedSubjectPredicate(args),
885
+ eq(juniorMemoryEmbeddings.provider, args.embedding.provider),
886
+ eq(juniorMemoryEmbeddings.model, args.embedding.model),
887
+ eq(juniorMemoryEmbeddings.dimensions, MEMORY_EMBEDDING_DIMENSIONS),
888
+ eq(juniorMemoryEmbeddings.metric, EMBEDDING_METRIC),
889
+ ),
890
+ )
891
+ .orderBy(
892
+ distance,
893
+ desc(juniorMemoryMemories.createdAtMs),
894
+ asc(juniorMemoryMemories.id),
895
+ )
896
+ .limit(SUPERSESSION_CANDIDATE_LIMIT);
897
+ return rows.flatMap((row) => {
898
+ if (hashEmbeddedContent(row.memory.content) !== row.contentHash) {
899
+ return [];
900
+ }
901
+ return [parseMemoryRow(row.memory)];
902
+ });
903
+ }
904
+
905
+ async function decideSupersededIds(args: {
906
+ candidates: MemoryRecord[];
907
+ content: string;
908
+ decider: MemorySupersessionDecider | undefined;
909
+ kind: MemoryRecord["kind"];
910
+ runtimeContext: MemoryRuntimeContext;
911
+ }): Promise<string[]> {
912
+ // Supersession is conservative: model uncertainty or decider failure leaves
913
+ // both memories active rather than hiding a possibly valid old preference.
914
+ if (
915
+ args.kind !== "preference" ||
916
+ !args.decider ||
917
+ args.candidates.length === 0
918
+ ) {
919
+ return [];
920
+ }
921
+ const existingMemories = args.candidates.map((memory) => ({
922
+ content: memory.content,
923
+ id: memory.id,
924
+ })) as MemorySupersessionInput["existingMemories"];
925
+ const candidateIds = new Set(args.candidates.map((memory) => memory.id));
926
+ try {
927
+ const decision = await args.decider.adjudicateSupersession({
928
+ candidate: {
929
+ content: args.content,
930
+ kind: "preference",
931
+ },
932
+ existingMemories,
933
+ runtimeContext: args.runtimeContext,
934
+ });
935
+ if (decision.decision !== "supersedes_old") {
936
+ return [];
937
+ }
938
+ return decision.supersededIds.filter((id) => candidateIds.has(id));
939
+ } catch {
940
+ return [];
941
+ }
942
+ }
943
+
515
944
  /** List active records for the runtime-derived visible scopes. */
516
945
  async function listVisibleMemories(args: {
517
946
  db: MemoryDb;
@@ -542,7 +971,7 @@ async function searchVisibleMemories(args: {
542
971
  nowMs: number;
543
972
  query: string;
544
973
  scopes: ResolvedMemoryScope[];
545
- }): Promise<MemoryRecord[]> {
974
+ }): Promise<SearchCandidate[]> {
546
975
  const terms = searchTerms(args.query);
547
976
  if (terms.length === 0) {
548
977
  return [];
@@ -564,7 +993,11 @@ async function searchVisibleMemories(args: {
564
993
  ),
565
994
  ),
566
995
  );
567
- return rows.map(parseMemoryRow);
996
+ return rows.map((row) => ({
997
+ memory: parseMemoryRow(row),
998
+ score: 0,
999
+ sourceKey: row.sourceKey,
1000
+ }));
568
1001
  }
569
1002
 
570
1003
  /** Search active visible records with exact pgvector cosine distance. */
@@ -572,6 +1005,7 @@ async function searchVisibleVectorMemories(args: {
572
1005
  db: MemoryDb;
573
1006
  embedder: MemoryEmbeddingProvider | undefined;
574
1007
  limit: number;
1008
+ maxDistance?: number;
575
1009
  nowMs: number;
576
1010
  query: string;
577
1011
  scopes: ResolvedMemoryScope[];
@@ -628,22 +1062,58 @@ async function searchVisibleVectorMemories(args: {
628
1062
  ) {
629
1063
  return [];
630
1064
  }
1065
+ if (args.maxDistance !== undefined && distanceValue > args.maxDistance) {
1066
+ return [];
1067
+ }
631
1068
  return [
632
1069
  {
633
1070
  memory: parseMemoryRow(row.memory),
634
1071
  score: 1 / (1 + Math.max(0, distanceValue)),
1072
+ sourceKey: row.memory.sourceKey,
635
1073
  },
636
1074
  ];
637
1075
  });
638
1076
  }
639
1077
 
640
- /** Fuse higher-is-better retrieval candidates before applying the final limit. */
641
- function mergeSearchCandidates(candidates: SearchCandidate[]): MemoryRecord[] {
1078
+ function sourceBoost(
1079
+ candidate: Pick<SearchCandidate, "sourceKey">,
1080
+ currentChannelPrefix: string | undefined,
1081
+ ): number {
1082
+ if (!currentChannelPrefix) {
1083
+ return 0;
1084
+ }
1085
+ return candidate.sourceKey.startsWith(currentChannelPrefix)
1086
+ ? SOURCE_CHANNEL_BOOST
1087
+ : 0;
1088
+ }
1089
+
1090
+ /** Score only observation age for near-tie reranking; this is not lifecycle decay. */
1091
+ function observedAgeBoost(memory: MemoryRecord, nowMs: number): number {
1092
+ const ageMs = Math.max(0, nowMs - memory.observedAtMs);
1093
+ if (ageMs <= 7 * ONE_DAY_MS) {
1094
+ return 0.15;
1095
+ }
1096
+ if (ageMs <= 30 * ONE_DAY_MS) {
1097
+ return 0.1;
1098
+ }
1099
+ if (ageMs <= 90 * ONE_DAY_MS) {
1100
+ return 0.05;
1101
+ }
1102
+ return 0;
1103
+ }
1104
+
1105
+ /** Fuse retrieval candidates while keeping observed age to near-tie reranking. */
1106
+ function mergeSearchCandidates(
1107
+ candidates: SearchCandidate[],
1108
+ nowMs: number,
1109
+ currentChannelKey?: string,
1110
+ ): MemoryRecord[] {
642
1111
  const byId = new Map<
643
1112
  string,
644
1113
  {
645
1114
  memory: MemoryRecord;
646
1115
  score: number;
1116
+ sourceKey: string;
647
1117
  }
648
1118
  >();
649
1119
  for (const candidate of candidates) {
@@ -655,15 +1125,28 @@ function mergeSearchCandidates(candidates: SearchCandidate[]): MemoryRecord[] {
655
1125
  byId.set(candidate.memory.id, {
656
1126
  memory: candidate.memory,
657
1127
  score: candidate.score,
1128
+ sourceKey: candidate.sourceKey,
658
1129
  });
659
1130
  }
660
1131
  return [...byId.values()]
661
- .sort(
662
- (left, right) =>
663
- right.score - left.score ||
664
- right.memory.createdAtMs - left.memory.createdAtMs ||
665
- left.memory.id.localeCompare(right.memory.id),
666
- )
1132
+ .sort((left, right) => {
1133
+ const scoreDelta = right.score - left.score;
1134
+ if (Math.abs(scoreDelta) > RELEVANCE_NEAR_TIE_DELTA) {
1135
+ return scoreDelta;
1136
+ }
1137
+ const sourceDelta =
1138
+ sourceBoost(right, currentChannelKey) -
1139
+ sourceBoost(left, currentChannelKey);
1140
+ if (sourceDelta !== 0) {
1141
+ return sourceDelta;
1142
+ }
1143
+ return (
1144
+ observedAgeBoost(right.memory, nowMs) -
1145
+ observedAgeBoost(left.memory, nowMs) ||
1146
+ right.memory.observedAtMs - left.memory.observedAtMs ||
1147
+ left.memory.id.localeCompare(right.memory.id)
1148
+ );
1149
+ })
667
1150
  .map((candidate) => candidate.memory);
668
1151
  }
669
1152
 
@@ -676,6 +1159,8 @@ export function createMemoryStore(
676
1159
  const runtimeContext = memoryRuntimeContextSchema.parse(context);
677
1160
  const parsedOptions = memoryStoreOptionsSchema.parse({ now: options.now });
678
1161
  const embedder = options.embedder;
1162
+ const maxVectorDistance = options.maxVectorDistance;
1163
+ const supersessionDecider = options.supersessionDecider;
679
1164
  const getNowMs = parsedOptions.now ?? Date.now;
680
1165
 
681
1166
  async function archiveExpiredVisibleMemories(
@@ -716,40 +1201,183 @@ export function createMemoryStore(
716
1201
  nowMs,
717
1202
  scopes: [scope],
718
1203
  });
1204
+ if (input.idempotencyKey !== undefined) {
1205
+ const idempotent = await findByIdempotencyKey({
1206
+ db,
1207
+ idempotencyKey: input.idempotencyKey,
1208
+ nowMs,
1209
+ scope,
1210
+ });
1211
+ if (idempotent) {
1212
+ await storeEmbedding({
1213
+ content: idempotent.content,
1214
+ db,
1215
+ embedder,
1216
+ memoryId: idempotent.id,
1217
+ nowMs,
1218
+ });
1219
+ return { created: false, memory: idempotent };
1220
+ }
1221
+ }
719
1222
 
720
- const id = randomUUID();
721
- const rows = await db
722
- .insert(juniorMemoryMemories)
723
- .values({
1223
+ const exactDuplicate = await findExactDuplicateMemory({
1224
+ content,
1225
+ db,
1226
+ kind: input.kind,
1227
+ nowMs,
1228
+ scope,
1229
+ subject,
1230
+ });
1231
+ if (exactDuplicate) {
1232
+ await rememberDuplicateIdempotency({
1233
+ content,
1234
+ db,
1235
+ duplicate: exactDuplicate,
1236
+ idempotencyKey: input.idempotencyKey,
1237
+ nowMs,
1238
+ runtimeContext,
1239
+ scope,
1240
+ subject,
1241
+ });
1242
+ await storeEmbedding({
1243
+ content: exactDuplicate.content,
1244
+ db,
1245
+ embedder,
1246
+ memoryId: exactDuplicate.id,
1247
+ nowMs,
1248
+ });
1249
+ return { created: false, memory: exactDuplicate };
1250
+ }
1251
+
1252
+ let candidateEmbedding: MemoryEmbedding | undefined;
1253
+ if (embedder) {
1254
+ try {
1255
+ candidateEmbedding = await embedOne(embedder, content);
1256
+ } catch {
1257
+ candidateEmbedding = undefined;
1258
+ }
1259
+ }
1260
+ const vectorDuplicate = candidateEmbedding
1261
+ ? await findVectorDuplicateMemory({
1262
+ db,
1263
+ embedding: candidateEmbedding,
1264
+ kind: input.kind,
1265
+ nowMs,
1266
+ scope,
1267
+ subject,
1268
+ })
1269
+ : undefined;
1270
+ if (vectorDuplicate) {
1271
+ await rememberDuplicateIdempotency({
724
1272
  content,
725
- createdAtMs: nowMs,
726
- expiresAtMs: input.expiresAtMs,
727
- id,
1273
+ db,
1274
+ duplicate: vectorDuplicate,
728
1275
  idempotencyKey: input.idempotencyKey,
729
- observedAtMs: nowMs,
730
- scope: scope.scope,
731
- scopeKey: scope.scopeKey,
732
- sourceKey: sourceKey(runtimeContext),
733
- sourcePlatform: runtimeContext.source.platform,
734
- subjectKey: subject.subjectKey,
735
- subjectType: subject.subjectType,
1276
+ nowMs,
1277
+ runtimeContext,
1278
+ scope,
1279
+ subject,
1280
+ });
1281
+ await storeEmbedding({
1282
+ content: vectorDuplicate.content,
1283
+ db,
1284
+ embedder,
1285
+ memoryId: vectorDuplicate.id,
1286
+ nowMs,
1287
+ });
1288
+ return { created: false, memory: vectorDuplicate };
1289
+ }
1290
+
1291
+ let supersededIds: string[] = [];
1292
+ if (
1293
+ scopeKind === "personal" &&
1294
+ input.kind === "preference" &&
1295
+ supersessionDecider &&
1296
+ (input.expiresAtMs === undefined || input.expiresAtMs > nowMs)
1297
+ ) {
1298
+ const supersessionCandidates = await listSupersessionCandidates({
1299
+ content,
1300
+ db,
1301
+ ...(candidateEmbedding ? { embedding: candidateEmbedding } : {}),
736
1302
  kind: input.kind,
737
- })
738
- .onConflictDoNothing({
739
- target: [
740
- juniorMemoryMemories.scope,
741
- juniorMemoryMemories.scopeKey,
742
- juniorMemoryMemories.idempotencyKey,
743
- ],
744
- where: sql`${juniorMemoryMemories.idempotencyKey} IS NOT NULL AND ${juniorMemoryMemories.archivedAtMs} IS NULL AND ${juniorMemoryMemories.supersededAtMs} IS NULL AND ${juniorMemoryMemories.supersededById} IS NULL`,
745
- })
746
- .returning();
1303
+ nowMs,
1304
+ scope,
1305
+ subject,
1306
+ });
1307
+ supersededIds = await decideSupersededIds({
1308
+ candidates: supersessionCandidates,
1309
+ content,
1310
+ decider: supersessionDecider,
1311
+ kind: input.kind,
1312
+ runtimeContext,
1313
+ });
1314
+ }
1315
+
1316
+ const id = randomUUID();
1317
+ const rows = await db.transaction(async (tx) => {
1318
+ const inserted = await tx
1319
+ .insert(juniorMemoryMemories)
1320
+ .values({
1321
+ content,
1322
+ createdAtMs: nowMs,
1323
+ expiresAtMs: input.expiresAtMs,
1324
+ id,
1325
+ idempotencyKey: input.idempotencyKey,
1326
+ observedAtMs: nowMs,
1327
+ scope: scope.scope,
1328
+ scopeKey: scope.scopeKey,
1329
+ sourceKey: sourceKey(runtimeContext),
1330
+ sourcePlatform: runtimeContext.source.platform,
1331
+ subjectKey: subject.subjectKey,
1332
+ subjectType: subject.subjectType,
1333
+ kind: input.kind,
1334
+ })
1335
+ .onConflictDoNothing({
1336
+ target: [
1337
+ juniorMemoryMemories.scope,
1338
+ juniorMemoryMemories.scopeKey,
1339
+ juniorMemoryMemories.idempotencyKey,
1340
+ ],
1341
+ where: sql`${juniorMemoryMemories.idempotencyKey} IS NOT NULL AND ${juniorMemoryMemories.archivedAtMs} IS NULL AND ${juniorMemoryMemories.supersededAtMs} IS NULL AND ${juniorMemoryMemories.supersededById} IS NULL`,
1342
+ })
1343
+ .returning();
1344
+ const insertedMemory = inserted[0];
1345
+ if (!insertedMemory || supersededIds.length === 0) {
1346
+ return inserted;
1347
+ }
1348
+ const superseded = await tx
1349
+ .update(juniorMemoryMemories)
1350
+ .set({
1351
+ supersededAtMs: nowMs,
1352
+ supersededById: insertedMemory.id,
1353
+ })
1354
+ .where(
1355
+ and(
1356
+ inArray(juniorMemoryMemories.id, supersededIds),
1357
+ activeScopedSubjectPredicate({
1358
+ kind: input.kind,
1359
+ nowMs,
1360
+ scope,
1361
+ subject,
1362
+ }),
1363
+ ),
1364
+ )
1365
+ .returning({ id: juniorMemoryMemories.id });
1366
+ const idsToClean = superseded.map((row) => row.id);
1367
+ if (idsToClean.length > 0) {
1368
+ await tx
1369
+ .delete(juniorMemoryEmbeddings)
1370
+ .where(inArray(juniorMemoryEmbeddings.memoryId, idsToClean));
1371
+ }
1372
+ return inserted;
1373
+ });
747
1374
  if (rows[0]) {
748
1375
  const memory = parseMemoryRow(rows[0]);
749
1376
  await storeEmbedding({
750
1377
  content: memory.content,
751
1378
  db,
752
1379
  embedder,
1380
+ embedding: candidateEmbedding,
753
1381
  memoryId: memory.id,
754
1382
  nowMs,
755
1383
  });
@@ -819,6 +1447,9 @@ export function createMemoryStore(
819
1447
  db,
820
1448
  embedder,
821
1449
  limit: limit * VECTOR_SEARCH_OVERFETCH,
1450
+ ...(maxVectorDistance !== undefined
1451
+ ? { maxDistance: maxVectorDistance }
1452
+ : {}),
822
1453
  nowMs,
823
1454
  query: input.query,
824
1455
  scopes,
@@ -831,12 +1462,16 @@ export function createMemoryStore(
831
1462
  });
832
1463
  const terms = searchTerms(input.query);
833
1464
  const lexicalCandidates = candidates
834
- .map((memory) => ({ memory, score: searchScore(memory, terms) }))
1465
+ .map((candidate) => ({
1466
+ ...candidate,
1467
+ score: searchScore(candidate.memory, terms),
1468
+ }))
835
1469
  .filter((item) => item.score > 0);
836
- return mergeSearchCandidates([
837
- ...vectorCandidates,
838
- ...lexicalCandidates,
839
- ]).slice(0, limit);
1470
+ return mergeSearchCandidates(
1471
+ [...vectorCandidates, ...lexicalCandidates],
1472
+ nowMs,
1473
+ sourceChannelPrefix(runtimeContext),
1474
+ ).slice(0, limit);
840
1475
  },
841
1476
 
842
1477
  async archiveMemory(input) {