@sentry/junior-memory 0.80.0 → 0.81.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,9 +49,24 @@ 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 SUPERSESSION_STOP_TERMS = new Set([
60
+ "and",
61
+ "for",
62
+ "prefer",
63
+ "prefers",
64
+ "preference",
65
+ "the",
66
+ "use",
67
+ "uses",
68
+ "with",
69
+ ]);
53
70
 
54
71
  export type MemoryDb = PgDatabase<PgQueryResultHKT, typeof memorySqlSchema>;
55
72
 
@@ -58,6 +75,12 @@ interface SearchCandidate {
58
75
  score: number;
59
76
  }
60
77
 
78
+ interface MemoryEmbedding {
79
+ model: string;
80
+ provider: string;
81
+ vector: number[];
82
+ }
83
+
61
84
  const nonEmptyStringSchema = z.string().min(1);
62
85
  const memoryContentSchema = z
63
86
  .string()
@@ -215,9 +238,44 @@ export interface MemoryEmbeddingProvider {
215
238
  }>;
216
239
  }
217
240
 
241
+ export interface MemorySupersessionInput {
242
+ candidate: {
243
+ content: string;
244
+ kind: "preference";
245
+ };
246
+ existingMemories: [
247
+ {
248
+ content: string;
249
+ id: string;
250
+ },
251
+ ...Array<{
252
+ content: string;
253
+ id: string;
254
+ }>,
255
+ ];
256
+ runtimeContext: MemoryRuntimeContext;
257
+ }
258
+
259
+ export type MemorySupersessionDecision =
260
+ | {
261
+ decision: "supersedes_old";
262
+ supersededIds: [string, ...string[]];
263
+ }
264
+ | {
265
+ decision: "distinct" | "uncertain";
266
+ };
267
+
268
+ export interface MemorySupersessionDecider {
269
+ /** Decide whether a new preference clearly replaces active old preferences. */
270
+ adjudicateSupersession(
271
+ input: MemorySupersessionInput,
272
+ ): Promise<MemorySupersessionDecision> | MemorySupersessionDecision;
273
+ }
274
+
218
275
  export interface MemoryStoreOptions {
219
276
  embedder?: MemoryEmbeddingProvider;
220
277
  now?: () => number;
278
+ supersessionDecider?: MemorySupersessionDecider;
221
279
  }
222
280
 
223
281
  /** Context-bound storage operations for visible long-term memories. */
@@ -248,6 +306,22 @@ function hashEmbeddedContent(content: string): string {
248
306
  return createHash("sha256").update(content, "utf8").digest("hex");
249
307
  }
250
308
 
309
+ function idempotencyAliasId(args: {
310
+ idempotencyKey: string;
311
+ scope: ResolvedMemoryScope;
312
+ targetId: string;
313
+ }): string {
314
+ return `alias:${createHash("sha256")
315
+ .update(args.scope.scope)
316
+ .update("\0")
317
+ .update(args.scope.scopeKey)
318
+ .update("\0")
319
+ .update(args.idempotencyKey)
320
+ .update("\0")
321
+ .update(args.targetId)
322
+ .digest("hex")}`;
323
+ }
324
+
251
325
  function boundedLimit(value: number | undefined, fallback: number): number {
252
326
  if (typeof value !== "number" || !Number.isFinite(value)) {
253
327
  return fallback;
@@ -336,7 +410,7 @@ async function findByIdempotencyKey(args: {
336
410
  nowMs: number;
337
411
  scope: ResolvedMemoryScope;
338
412
  }): Promise<MemoryRecord | undefined> {
339
- const rows = await args.db
413
+ const activeRows = await args.db
340
414
  .select()
341
415
  .from(juniorMemoryMemories)
342
416
  .where(
@@ -354,7 +428,58 @@ async function findByIdempotencyKey(args: {
354
428
  ),
355
429
  )
356
430
  .limit(1);
357
- return rows[0] ? parseMemoryRow(rows[0]) : undefined;
431
+ if (activeRows[0]) {
432
+ return parseMemoryRow(activeRows[0]);
433
+ }
434
+
435
+ const aliasRows = await args.db
436
+ .select({ supersededById: juniorMemoryMemories.supersededById })
437
+ .from(juniorMemoryMemories)
438
+ .where(
439
+ and(
440
+ eq(juniorMemoryMemories.scope, args.scope.scope),
441
+ eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey),
442
+ eq(juniorMemoryMemories.idempotencyKey, args.idempotencyKey),
443
+ isNull(juniorMemoryMemories.archivedAtMs),
444
+ isNotNull(juniorMemoryMemories.supersededAtMs),
445
+ isNotNull(juniorMemoryMemories.supersededById),
446
+ or(
447
+ isNull(juniorMemoryMemories.expiresAtMs),
448
+ gt(juniorMemoryMemories.expiresAtMs, args.nowMs),
449
+ ),
450
+ ),
451
+ )
452
+ .orderBy(
453
+ desc(juniorMemoryMemories.createdAtMs),
454
+ asc(juniorMemoryMemories.id),
455
+ );
456
+ for (const alias of aliasRows) {
457
+ if (!alias.supersededById) {
458
+ continue;
459
+ }
460
+ const rows = await args.db
461
+ .select()
462
+ .from(juniorMemoryMemories)
463
+ .where(
464
+ and(
465
+ eq(juniorMemoryMemories.id, alias.supersededById),
466
+ eq(juniorMemoryMemories.scope, args.scope.scope),
467
+ eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey),
468
+ isNull(juniorMemoryMemories.archivedAtMs),
469
+ isNull(juniorMemoryMemories.supersededAtMs),
470
+ isNull(juniorMemoryMemories.supersededById),
471
+ or(
472
+ isNull(juniorMemoryMemories.expiresAtMs),
473
+ gt(juniorMemoryMemories.expiresAtMs, args.nowMs),
474
+ ),
475
+ ),
476
+ )
477
+ .limit(1);
478
+ if (rows[0]) {
479
+ return parseMemoryRow(rows[0]);
480
+ }
481
+ }
482
+ return undefined;
358
483
  }
359
484
 
360
485
  /**
@@ -442,11 +567,7 @@ function searchTerms(query: string): string[] {
442
567
  async function embedOne(
443
568
  embedder: MemoryEmbeddingProvider,
444
569
  text: string,
445
- ): Promise<{
446
- model: string;
447
- provider: string;
448
- vector: number[];
449
- }> {
570
+ ): Promise<MemoryEmbedding> {
450
571
  const normalized = normalizeContent(text);
451
572
  if (!normalized) {
452
573
  throw new Error("Embedding text is required.");
@@ -469,10 +590,11 @@ async function storeEmbedding(args: {
469
590
  content: string;
470
591
  db: MemoryDb;
471
592
  embedder: MemoryEmbeddingProvider | undefined;
593
+ embedding?: MemoryEmbedding;
472
594
  memoryId: string;
473
595
  nowMs: number;
474
596
  }): Promise<void> {
475
- if (!args.embedder) {
597
+ if (!args.embedder && !args.embedding) {
476
598
  return;
477
599
  }
478
600
  try {
@@ -488,10 +610,18 @@ async function storeEmbedding(args: {
488
610
  return;
489
611
  }
490
612
  let embedding: Awaited<ReturnType<typeof embedOne>>;
491
- try {
492
- embedding = await embedOne(args.embedder, args.content);
493
- } catch {
494
- return;
613
+ if (args.embedding) {
614
+ embedding = args.embedding;
615
+ } else {
616
+ const embedder = args.embedder;
617
+ if (!embedder) {
618
+ return;
619
+ }
620
+ try {
621
+ embedding = await embedOne(embedder, args.content);
622
+ } catch {
623
+ return;
624
+ }
495
625
  }
496
626
  try {
497
627
  await args.db
@@ -512,6 +642,293 @@ async function storeEmbedding(args: {
512
642
  }
513
643
  }
514
644
 
645
+ function activeScopedSubjectPredicate(args: {
646
+ kind: MemoryRecord["kind"];
647
+ nowMs: number;
648
+ scope: ResolvedMemoryScope;
649
+ subject: ResolvedMemorySubject;
650
+ }): SQL {
651
+ const predicate = and(
652
+ eq(juniorMemoryMemories.scope, args.scope.scope),
653
+ eq(juniorMemoryMemories.scopeKey, args.scope.scopeKey),
654
+ eq(juniorMemoryMemories.kind, args.kind),
655
+ eq(juniorMemoryMemories.subjectType, args.subject.subjectType),
656
+ args.subject.subjectKey === undefined
657
+ ? isNull(juniorMemoryMemories.subjectKey)
658
+ : eq(juniorMemoryMemories.subjectKey, args.subject.subjectKey),
659
+ isNull(juniorMemoryMemories.archivedAtMs),
660
+ isNull(juniorMemoryMemories.supersededAtMs),
661
+ isNull(juniorMemoryMemories.supersededById),
662
+ or(
663
+ isNull(juniorMemoryMemories.expiresAtMs),
664
+ gt(juniorMemoryMemories.expiresAtMs, args.nowMs),
665
+ ),
666
+ );
667
+ if (!predicate) {
668
+ throw new Error("Memory duplicate predicate is empty.");
669
+ }
670
+ return predicate;
671
+ }
672
+
673
+ async function findExactDuplicateMemory(args: {
674
+ content: string;
675
+ db: MemoryDb;
676
+ kind: MemoryRecord["kind"];
677
+ nowMs: number;
678
+ scope: ResolvedMemoryScope;
679
+ subject: ResolvedMemorySubject;
680
+ }): Promise<MemoryRecord | undefined> {
681
+ const rows = await args.db
682
+ .select()
683
+ .from(juniorMemoryMemories)
684
+ .where(
685
+ and(
686
+ activeScopedSubjectPredicate(args),
687
+ eq(juniorMemoryMemories.content, args.content),
688
+ ),
689
+ )
690
+ .orderBy(
691
+ desc(juniorMemoryMemories.createdAtMs),
692
+ asc(juniorMemoryMemories.id),
693
+ )
694
+ .limit(1);
695
+ return rows[0] ? parseMemoryRow(rows[0]) : undefined;
696
+ }
697
+
698
+ async function findVectorDuplicateMemory(args: {
699
+ db: MemoryDb;
700
+ embedding: MemoryEmbedding;
701
+ kind: MemoryRecord["kind"];
702
+ nowMs: number;
703
+ scope: ResolvedMemoryScope;
704
+ subject: ResolvedMemorySubject;
705
+ }): Promise<MemoryRecord | undefined> {
706
+ const distance = cosineDistance(
707
+ juniorMemoryEmbeddings.embedding,
708
+ args.embedding.vector,
709
+ );
710
+ const rows = await args.db
711
+ .select({
712
+ contentHash: juniorMemoryEmbeddings.contentHash,
713
+ distance,
714
+ memory: juniorMemoryMemories,
715
+ })
716
+ .from(juniorMemoryMemories)
717
+ .innerJoin(
718
+ juniorMemoryEmbeddings,
719
+ eq(juniorMemoryEmbeddings.memoryId, juniorMemoryMemories.id),
720
+ )
721
+ .where(
722
+ and(
723
+ activeScopedSubjectPredicate(args),
724
+ eq(juniorMemoryEmbeddings.provider, args.embedding.provider),
725
+ eq(juniorMemoryEmbeddings.model, args.embedding.model),
726
+ eq(juniorMemoryEmbeddings.dimensions, MEMORY_EMBEDDING_DIMENSIONS),
727
+ eq(juniorMemoryEmbeddings.metric, EMBEDDING_METRIC),
728
+ lte(distance, HIGH_CONFIDENCE_DUPLICATE_DISTANCE),
729
+ ),
730
+ )
731
+ .orderBy(
732
+ distance,
733
+ desc(juniorMemoryMemories.createdAtMs),
734
+ asc(juniorMemoryMemories.id),
735
+ )
736
+ .limit(1);
737
+ const row = rows[0];
738
+ if (!row || hashEmbeddedContent(row.memory.content) !== row.contentHash) {
739
+ return undefined;
740
+ }
741
+ return parseMemoryRow(row.memory);
742
+ }
743
+
744
+ async function rememberDuplicateIdempotency(args: {
745
+ content: string;
746
+ db: MemoryDb;
747
+ duplicate: MemoryRecord;
748
+ idempotencyKey?: string;
749
+ nowMs: number;
750
+ runtimeContext: MemoryRuntimeContext;
751
+ scope: ResolvedMemoryScope;
752
+ subject: ResolvedMemorySubject;
753
+ }): Promise<void> {
754
+ if (args.idempotencyKey === undefined) {
755
+ return;
756
+ }
757
+ await args.db
758
+ .insert(juniorMemoryMemories)
759
+ .values({
760
+ content: args.content,
761
+ createdAtMs: args.nowMs,
762
+ expiresAtMs: args.duplicate.expiresAtMs,
763
+ id: idempotencyAliasId({
764
+ idempotencyKey: args.idempotencyKey,
765
+ scope: args.scope,
766
+ targetId: args.duplicate.id,
767
+ }),
768
+ idempotencyKey: args.idempotencyKey,
769
+ observedAtMs: args.nowMs,
770
+ scope: args.scope.scope,
771
+ scopeKey: args.scope.scopeKey,
772
+ sourceKey: sourceKey(args.runtimeContext),
773
+ sourcePlatform: args.runtimeContext.source.platform,
774
+ subjectKey: args.subject.subjectKey,
775
+ subjectType: args.subject.subjectType,
776
+ supersededAtMs: args.nowMs,
777
+ supersededById: args.duplicate.id,
778
+ kind: args.duplicate.kind,
779
+ })
780
+ .onConflictDoNothing();
781
+ }
782
+
783
+ async function listSupersessionCandidates(args: {
784
+ content: string;
785
+ db: MemoryDb;
786
+ embedding?: MemoryEmbedding;
787
+ kind: MemoryRecord["kind"];
788
+ nowMs: number;
789
+ scope: ResolvedMemoryScope;
790
+ subject: ResolvedMemorySubject;
791
+ }): Promise<MemoryRecord[]> {
792
+ const predicate = activeScopedSubjectPredicate(args);
793
+ const terms = searchTerms(args.content).filter(
794
+ (term) => !SUPERSESSION_STOP_TERMS.has(term),
795
+ );
796
+ const lexicalRows =
797
+ terms.length > 0
798
+ ? await args.db
799
+ .select()
800
+ .from(juniorMemoryMemories)
801
+ .where(
802
+ and(
803
+ predicate,
804
+ or(
805
+ ...terms.map((term) =>
806
+ ilike(juniorMemoryMemories.content, `%${term}%`),
807
+ ),
808
+ ),
809
+ ),
810
+ )
811
+ : [];
812
+ const lexicalCandidates = lexicalRows
813
+ .map(parseMemoryRow)
814
+ .map((memory) => ({
815
+ memory,
816
+ score: searchScore(memory, terms),
817
+ }))
818
+ .filter((candidate) => candidate.score > 1)
819
+ .sort(
820
+ (left, right) =>
821
+ right.score - left.score ||
822
+ right.memory.createdAtMs - left.memory.createdAtMs ||
823
+ left.memory.id.localeCompare(right.memory.id),
824
+ )
825
+ .map((candidate) => candidate.memory);
826
+
827
+ const vectorCandidates = args.embedding
828
+ ? await listVectorSupersessionCandidates({
829
+ db: args.db,
830
+ embedding: args.embedding,
831
+ kind: args.kind,
832
+ nowMs: args.nowMs,
833
+ scope: args.scope,
834
+ subject: args.subject,
835
+ })
836
+ : [];
837
+ const byId = new Map<string, MemoryRecord>();
838
+ for (const memory of [...lexicalCandidates, ...vectorCandidates]) {
839
+ if (byId.size >= SUPERSESSION_CANDIDATE_LIMIT) {
840
+ break;
841
+ }
842
+ byId.set(memory.id, memory);
843
+ }
844
+ return [...byId.values()];
845
+ }
846
+
847
+ async function listVectorSupersessionCandidates(args: {
848
+ db: MemoryDb;
849
+ embedding: MemoryEmbedding;
850
+ kind: MemoryRecord["kind"];
851
+ nowMs: number;
852
+ scope: ResolvedMemoryScope;
853
+ subject: ResolvedMemorySubject;
854
+ }): Promise<MemoryRecord[]> {
855
+ const distance = cosineDistance(
856
+ juniorMemoryEmbeddings.embedding,
857
+ args.embedding.vector,
858
+ );
859
+ const rows = await args.db
860
+ .select({
861
+ contentHash: juniorMemoryEmbeddings.contentHash,
862
+ distance,
863
+ memory: juniorMemoryMemories,
864
+ })
865
+ .from(juniorMemoryMemories)
866
+ .innerJoin(
867
+ juniorMemoryEmbeddings,
868
+ eq(juniorMemoryEmbeddings.memoryId, juniorMemoryMemories.id),
869
+ )
870
+ .where(
871
+ and(
872
+ activeScopedSubjectPredicate(args),
873
+ eq(juniorMemoryEmbeddings.provider, args.embedding.provider),
874
+ eq(juniorMemoryEmbeddings.model, args.embedding.model),
875
+ eq(juniorMemoryEmbeddings.dimensions, MEMORY_EMBEDDING_DIMENSIONS),
876
+ eq(juniorMemoryEmbeddings.metric, EMBEDDING_METRIC),
877
+ ),
878
+ )
879
+ .orderBy(
880
+ distance,
881
+ desc(juniorMemoryMemories.createdAtMs),
882
+ asc(juniorMemoryMemories.id),
883
+ )
884
+ .limit(SUPERSESSION_CANDIDATE_LIMIT);
885
+ return rows.flatMap((row) => {
886
+ if (hashEmbeddedContent(row.memory.content) !== row.contentHash) {
887
+ return [];
888
+ }
889
+ return [parseMemoryRow(row.memory)];
890
+ });
891
+ }
892
+
893
+ async function decideSupersededIds(args: {
894
+ candidates: MemoryRecord[];
895
+ content: string;
896
+ decider: MemorySupersessionDecider | undefined;
897
+ kind: MemoryRecord["kind"];
898
+ runtimeContext: MemoryRuntimeContext;
899
+ }): Promise<string[]> {
900
+ // Supersession is conservative: model uncertainty or decider failure leaves
901
+ // both memories active rather than hiding a possibly valid old preference.
902
+ if (
903
+ args.kind !== "preference" ||
904
+ !args.decider ||
905
+ args.candidates.length === 0
906
+ ) {
907
+ return [];
908
+ }
909
+ const existingMemories = args.candidates.map((memory) => ({
910
+ content: memory.content,
911
+ id: memory.id,
912
+ })) as MemorySupersessionInput["existingMemories"];
913
+ const candidateIds = new Set(args.candidates.map((memory) => memory.id));
914
+ try {
915
+ const decision = await args.decider.adjudicateSupersession({
916
+ candidate: {
917
+ content: args.content,
918
+ kind: "preference",
919
+ },
920
+ existingMemories,
921
+ runtimeContext: args.runtimeContext,
922
+ });
923
+ if (decision.decision !== "supersedes_old") {
924
+ return [];
925
+ }
926
+ return decision.supersededIds.filter((id) => candidateIds.has(id));
927
+ } catch {
928
+ return [];
929
+ }
930
+ }
931
+
515
932
  /** List active records for the runtime-derived visible scopes. */
516
933
  async function listVisibleMemories(args: {
517
934
  db: MemoryDb;
@@ -637,8 +1054,26 @@ async function searchVisibleVectorMemories(args: {
637
1054
  });
638
1055
  }
639
1056
 
640
- /** Fuse higher-is-better retrieval candidates before applying the final limit. */
641
- function mergeSearchCandidates(candidates: SearchCandidate[]): MemoryRecord[] {
1057
+ /** Score only observation age for near-tie reranking; this is not lifecycle decay. */
1058
+ function observedAgeBoost(memory: MemoryRecord, nowMs: number): number {
1059
+ const ageMs = Math.max(0, nowMs - memory.observedAtMs);
1060
+ if (ageMs <= 7 * ONE_DAY_MS) {
1061
+ return 0.15;
1062
+ }
1063
+ if (ageMs <= 30 * ONE_DAY_MS) {
1064
+ return 0.1;
1065
+ }
1066
+ if (ageMs <= 90 * ONE_DAY_MS) {
1067
+ return 0.05;
1068
+ }
1069
+ return 0;
1070
+ }
1071
+
1072
+ /** Fuse retrieval candidates while keeping observed age to near-tie reranking. */
1073
+ function mergeSearchCandidates(
1074
+ candidates: SearchCandidate[],
1075
+ nowMs: number,
1076
+ ): MemoryRecord[] {
642
1077
  const byId = new Map<
643
1078
  string,
644
1079
  {
@@ -658,12 +1093,18 @@ function mergeSearchCandidates(candidates: SearchCandidate[]): MemoryRecord[] {
658
1093
  });
659
1094
  }
660
1095
  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
- )
1096
+ .sort((left, right) => {
1097
+ const scoreDelta = right.score - left.score;
1098
+ if (Math.abs(scoreDelta) > RELEVANCE_NEAR_TIE_DELTA) {
1099
+ return scoreDelta;
1100
+ }
1101
+ return (
1102
+ observedAgeBoost(right.memory, nowMs) -
1103
+ observedAgeBoost(left.memory, nowMs) ||
1104
+ right.memory.observedAtMs - left.memory.observedAtMs ||
1105
+ left.memory.id.localeCompare(right.memory.id)
1106
+ );
1107
+ })
667
1108
  .map((candidate) => candidate.memory);
668
1109
  }
669
1110
 
@@ -676,6 +1117,7 @@ export function createMemoryStore(
676
1117
  const runtimeContext = memoryRuntimeContextSchema.parse(context);
677
1118
  const parsedOptions = memoryStoreOptionsSchema.parse({ now: options.now });
678
1119
  const embedder = options.embedder;
1120
+ const supersessionDecider = options.supersessionDecider;
679
1121
  const getNowMs = parsedOptions.now ?? Date.now;
680
1122
 
681
1123
  async function archiveExpiredVisibleMemories(
@@ -716,40 +1158,183 @@ export function createMemoryStore(
716
1158
  nowMs,
717
1159
  scopes: [scope],
718
1160
  });
1161
+ if (input.idempotencyKey !== undefined) {
1162
+ const idempotent = await findByIdempotencyKey({
1163
+ db,
1164
+ idempotencyKey: input.idempotencyKey,
1165
+ nowMs,
1166
+ scope,
1167
+ });
1168
+ if (idempotent) {
1169
+ await storeEmbedding({
1170
+ content: idempotent.content,
1171
+ db,
1172
+ embedder,
1173
+ memoryId: idempotent.id,
1174
+ nowMs,
1175
+ });
1176
+ return { created: false, memory: idempotent };
1177
+ }
1178
+ }
719
1179
 
720
- const id = randomUUID();
721
- const rows = await db
722
- .insert(juniorMemoryMemories)
723
- .values({
1180
+ const exactDuplicate = await findExactDuplicateMemory({
1181
+ content,
1182
+ db,
1183
+ kind: input.kind,
1184
+ nowMs,
1185
+ scope,
1186
+ subject,
1187
+ });
1188
+ if (exactDuplicate) {
1189
+ await rememberDuplicateIdempotency({
724
1190
  content,
725
- createdAtMs: nowMs,
726
- expiresAtMs: input.expiresAtMs,
727
- id,
1191
+ db,
1192
+ duplicate: exactDuplicate,
728
1193
  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,
1194
+ nowMs,
1195
+ runtimeContext,
1196
+ scope,
1197
+ subject,
1198
+ });
1199
+ await storeEmbedding({
1200
+ content: exactDuplicate.content,
1201
+ db,
1202
+ embedder,
1203
+ memoryId: exactDuplicate.id,
1204
+ nowMs,
1205
+ });
1206
+ return { created: false, memory: exactDuplicate };
1207
+ }
1208
+
1209
+ let candidateEmbedding: MemoryEmbedding | undefined;
1210
+ if (embedder) {
1211
+ try {
1212
+ candidateEmbedding = await embedOne(embedder, content);
1213
+ } catch {
1214
+ candidateEmbedding = undefined;
1215
+ }
1216
+ }
1217
+ const vectorDuplicate = candidateEmbedding
1218
+ ? await findVectorDuplicateMemory({
1219
+ db,
1220
+ embedding: candidateEmbedding,
1221
+ kind: input.kind,
1222
+ nowMs,
1223
+ scope,
1224
+ subject,
1225
+ })
1226
+ : undefined;
1227
+ if (vectorDuplicate) {
1228
+ await rememberDuplicateIdempotency({
1229
+ content,
1230
+ db,
1231
+ duplicate: vectorDuplicate,
1232
+ idempotencyKey: input.idempotencyKey,
1233
+ nowMs,
1234
+ runtimeContext,
1235
+ scope,
1236
+ subject,
1237
+ });
1238
+ await storeEmbedding({
1239
+ content: vectorDuplicate.content,
1240
+ db,
1241
+ embedder,
1242
+ memoryId: vectorDuplicate.id,
1243
+ nowMs,
1244
+ });
1245
+ return { created: false, memory: vectorDuplicate };
1246
+ }
1247
+
1248
+ let supersededIds: string[] = [];
1249
+ if (
1250
+ scopeKind === "personal" &&
1251
+ input.kind === "preference" &&
1252
+ supersessionDecider &&
1253
+ (input.expiresAtMs === undefined || input.expiresAtMs > nowMs)
1254
+ ) {
1255
+ const supersessionCandidates = await listSupersessionCandidates({
1256
+ content,
1257
+ db,
1258
+ ...(candidateEmbedding ? { embedding: candidateEmbedding } : {}),
736
1259
  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();
1260
+ nowMs,
1261
+ scope,
1262
+ subject,
1263
+ });
1264
+ supersededIds = await decideSupersededIds({
1265
+ candidates: supersessionCandidates,
1266
+ content,
1267
+ decider: supersessionDecider,
1268
+ kind: input.kind,
1269
+ runtimeContext,
1270
+ });
1271
+ }
1272
+
1273
+ const id = randomUUID();
1274
+ const rows = await db.transaction(async (tx) => {
1275
+ const inserted = await tx
1276
+ .insert(juniorMemoryMemories)
1277
+ .values({
1278
+ content,
1279
+ createdAtMs: nowMs,
1280
+ expiresAtMs: input.expiresAtMs,
1281
+ id,
1282
+ idempotencyKey: input.idempotencyKey,
1283
+ observedAtMs: nowMs,
1284
+ scope: scope.scope,
1285
+ scopeKey: scope.scopeKey,
1286
+ sourceKey: sourceKey(runtimeContext),
1287
+ sourcePlatform: runtimeContext.source.platform,
1288
+ subjectKey: subject.subjectKey,
1289
+ subjectType: subject.subjectType,
1290
+ kind: input.kind,
1291
+ })
1292
+ .onConflictDoNothing({
1293
+ target: [
1294
+ juniorMemoryMemories.scope,
1295
+ juniorMemoryMemories.scopeKey,
1296
+ juniorMemoryMemories.idempotencyKey,
1297
+ ],
1298
+ where: sql`${juniorMemoryMemories.idempotencyKey} IS NOT NULL AND ${juniorMemoryMemories.archivedAtMs} IS NULL AND ${juniorMemoryMemories.supersededAtMs} IS NULL AND ${juniorMemoryMemories.supersededById} IS NULL`,
1299
+ })
1300
+ .returning();
1301
+ const insertedMemory = inserted[0];
1302
+ if (!insertedMemory || supersededIds.length === 0) {
1303
+ return inserted;
1304
+ }
1305
+ const superseded = await tx
1306
+ .update(juniorMemoryMemories)
1307
+ .set({
1308
+ supersededAtMs: nowMs,
1309
+ supersededById: insertedMemory.id,
1310
+ })
1311
+ .where(
1312
+ and(
1313
+ inArray(juniorMemoryMemories.id, supersededIds),
1314
+ activeScopedSubjectPredicate({
1315
+ kind: input.kind,
1316
+ nowMs,
1317
+ scope,
1318
+ subject,
1319
+ }),
1320
+ ),
1321
+ )
1322
+ .returning({ id: juniorMemoryMemories.id });
1323
+ const idsToClean = superseded.map((row) => row.id);
1324
+ if (idsToClean.length > 0) {
1325
+ await tx
1326
+ .delete(juniorMemoryEmbeddings)
1327
+ .where(inArray(juniorMemoryEmbeddings.memoryId, idsToClean));
1328
+ }
1329
+ return inserted;
1330
+ });
747
1331
  if (rows[0]) {
748
1332
  const memory = parseMemoryRow(rows[0]);
749
1333
  await storeEmbedding({
750
1334
  content: memory.content,
751
1335
  db,
752
1336
  embedder,
1337
+ embedding: candidateEmbedding,
753
1338
  memoryId: memory.id,
754
1339
  nowMs,
755
1340
  });
@@ -833,10 +1418,10 @@ export function createMemoryStore(
833
1418
  const lexicalCandidates = candidates
834
1419
  .map((memory) => ({ memory, score: searchScore(memory, terms) }))
835
1420
  .filter((item) => item.score > 0);
836
- return mergeSearchCandidates([
837
- ...vectorCandidates,
838
- ...lexicalCandidates,
839
- ]).slice(0, limit);
1421
+ return mergeSearchCandidates(
1422
+ [...vectorCandidates, ...lexicalCandidates],
1423
+ nowMs,
1424
+ ).slice(0, limit);
840
1425
  },
841
1426
 
842
1427
  async archiveMemory(input) {