@memrosetta/core 0.2.18 → 0.2.20

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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import Database from 'better-sqlite3';
2
- import { Memory, MemoryInput, RelationType, MemoryRelation, SearchResult, SearchFilters, SearchQuery, SearchResponse, IMemoryEngine, CompressResult, MaintenanceResult, MemoryTier, TierConfig } from '@memrosetta/types';
2
+ import { Memory, MemoryState, MemoryInput, RelationType, MemoryRelation, SearchResult, SearchFilters, SearchQuery, SearchResponse, IMemoryEngine, CompressResult, MaintenanceResult, MemoryTier, MemoryQuality, TierConfig } from '@memrosetta/types';
3
3
  import { Embedder, ContradictionDetector } from '@memrosetta/embeddings';
4
4
 
5
5
  interface SchemaOptions {
@@ -12,6 +12,13 @@ declare function generateMemoryId(): string;
12
12
  declare function nowIso(): string;
13
13
  declare function keywordsToString(keywords: readonly string[] | undefined): string | null;
14
14
  declare function stringToKeywords(str: string | null): readonly string[];
15
+ /**
16
+ * Derive the logical state of a memory from its existing fields.
17
+ * - invalidated: invalidatedAt is set (takes precedence)
18
+ * - superseded: isLatest is false
19
+ * - current: isLatest is true and not invalidated
20
+ */
21
+ declare function deriveMemoryState(memory: Memory): MemoryState;
15
22
 
16
23
  interface MemoryRow {
17
24
  readonly id: number;
@@ -160,6 +167,21 @@ declare function rrfMergeWeighted(ftsResults: readonly {
160
167
  readonly memory: Memory;
161
168
  readonly rank: number;
162
169
  }[], k?: number, limit?: number, ftsWeight?: number, vecWeight?: number): readonly SearchResult[];
170
+ /**
171
+ * Generative Agents-inspired 3-factor reranking.
172
+ * final_score = w_recency * recency + w_importance * importance + w_relevance * relevance
173
+ *
174
+ * - recency: exponential decay from learnedAt (0.995^hours)
175
+ * - importance: memory.salience (0-1)
176
+ * - relevance: original search score (from FTS/vector/RRF)
177
+ *
178
+ * All three are min-max normalized before combining.
179
+ */
180
+ declare function applyThreeFactorReranking(results: readonly SearchResult[], weights?: {
181
+ recency?: number;
182
+ importance?: number;
183
+ relevance?: number;
184
+ }): readonly SearchResult[];
163
185
  /**
164
186
  * Remove duplicate search results based on content identity.
165
187
  * Keeps the first (highest-scored) occurrence when duplicates exist.
@@ -189,7 +211,7 @@ declare function updateAccessTracking(db: Database.Database, memoryIds: readonly
189
211
  *
190
212
  * Results are weighted by activation score and access tracking is updated.
191
213
  */
192
- declare function searchMemories(db: Database.Database, query: SearchQuery, queryVec?: Float32Array, useVecTable?: boolean): SearchResponse;
214
+ declare function searchMemories(db: Database.Database, query: SearchQuery, queryVec?: Float32Array, useVecTable?: boolean, skipAccessTracking?: boolean): SearchResponse;
193
215
 
194
216
  interface SqliteEngineOptions {
195
217
  readonly dbPath: string;
@@ -220,6 +242,7 @@ declare class SqliteMemoryEngine implements IMemoryEngine {
220
242
  compress(userId: string): Promise<CompressResult>;
221
243
  maintain(userId: string): Promise<MaintenanceResult>;
222
244
  setTier(memoryId: string, tier: MemoryTier): Promise<void>;
245
+ quality(userId: string): Promise<MemoryQuality>;
223
246
  /**
224
247
  * Check the newly stored memory against existing similar memories
225
248
  * for contradictions using the NLI model.
@@ -231,6 +254,23 @@ declare class SqliteMemoryEngine implements IMemoryEngine {
231
254
  * swallowed so that memory storage is never blocked.
232
255
  */
233
256
  private checkContradictions;
257
+ /**
258
+ * Check for near-duplicate memories after storing.
259
+ * Uses direct cosine similarity (not the combined search score) to avoid
260
+ * false positives from the multi-factor reranking.
261
+ * If cosine similarity > 0.95, auto-create an 'updates' relation (new supersedes old).
262
+ * Only runs for single store() calls, not storeBatch() (too slow for bulk).
263
+ */
264
+ private checkDuplicates;
265
+ /**
266
+ * Auto-create 'extends' relations when a new memory shares keywords
267
+ * with existing memories. Builds graph density for future graph-based retrieval.
268
+ *
269
+ * Only runs for individual store() calls, not storeBatch().
270
+ * Requires at least 2 overlapping keywords to create a relation.
271
+ * Graceful: errors are silently swallowed.
272
+ */
273
+ private autoRelate;
234
274
  private ensureInitialized;
235
275
  }
236
276
  declare function createEngine(options: SqliteEngineOptions): SqliteMemoryEngine;
@@ -257,19 +297,30 @@ declare function createEngine(options: SqliteEngineOptions): SqliteMemoryEngine;
257
297
  * @returns activation score in [0, 1]
258
298
  */
259
299
  declare function computeActivation(accessTimestamps: readonly string[], salience: number, now?: Date): number;
300
+ /**
301
+ * Ebbinghaus forgetting curve: R = e^(-t/S)
302
+ * R = retention (0-1)
303
+ * t = days since last access
304
+ * S = strength (access_count, minimum 1)
305
+ *
306
+ * Works with existing fields (access_count, last_accessed_at) without
307
+ * needing an access history table like ACT-R does.
308
+ */
309
+ declare function computeEbbinghaus(accessCount: number, lastAccessedAt: string | null, now?: Date): number;
260
310
 
261
311
  declare const DEFAULT_TIER_CONFIG: TierConfig;
262
312
  /**
263
313
  * Determine the appropriate tier for a memory based on its properties.
264
314
  *
265
- * - hot: manually promoted memories stay hot
266
- * - warm: memories within warmDays of creation
267
- * - cold: older than warmDays
315
+ * - hot: manually promoted memories stay hot, OR auto-promoted via high access count (>= 10)
316
+ * - warm: memories within warmDays of creation, OR old but with high activation
317
+ * - cold: older than warmDays AND low activation
268
318
  */
269
319
  declare function determineTier(memory: {
270
320
  readonly learnedAt: string;
271
321
  readonly activationScore: number;
272
322
  readonly tier: string;
323
+ readonly accessCount?: number;
273
324
  }, config?: TierConfig, now?: Date): MemoryTier;
274
325
  /**
275
326
  * Estimate token count from content length.
@@ -277,4 +328,4 @@ declare function determineTier(memory: {
277
328
  */
278
329
  declare function estimateTokens(content: string): number;
279
330
 
280
- export { DEFAULT_TIER_CONFIG, type MemoryRow, type PreparedStatements, type RelationStatements, type SchemaOptions, type SearchSqlResult, type SqliteEngineOptions, SqliteMemoryEngine, type VectorSearchResult, applyKeywordBoost, bruteForceVectorSearch, buildFtsQuery, buildSearchSql, computeActivation, createEngine, createPreparedStatements, createRelation, createRelationStatements, deduplicateResults, determineTier, ensureSchema, estimateTokens, extractQueryTokens, ftsSearch, generateMemoryId, getRelationsByMemory, keywordsToString, normalizeScores, nowIso, rowToMemory, rrfMerge, rrfMergeWeighted, searchMemories, serializeEmbedding, storeBatchAsync, storeBatchInTransaction, storeMemory, storeMemoryAsync, stringToKeywords, updateAccessTracking, vectorSearch };
331
+ export { DEFAULT_TIER_CONFIG, type MemoryRow, type PreparedStatements, type RelationStatements, type SchemaOptions, type SearchSqlResult, type SqliteEngineOptions, SqliteMemoryEngine, type VectorSearchResult, applyKeywordBoost, applyThreeFactorReranking, bruteForceVectorSearch, buildFtsQuery, buildSearchSql, computeActivation, computeEbbinghaus, createEngine, createPreparedStatements, createRelation, createRelationStatements, deduplicateResults, deriveMemoryState, determineTier, ensureSchema, estimateTokens, extractQueryTokens, ftsSearch, generateMemoryId, getRelationsByMemory, keywordsToString, normalizeScores, nowIso, rowToMemory, rrfMerge, rrfMergeWeighted, searchMemories, serializeEmbedding, storeBatchAsync, storeBatchInTransaction, storeMemory, storeMemoryAsync, stringToKeywords, updateAccessTracking, vectorSearch };
package/dist/index.js CHANGED
@@ -183,6 +183,11 @@ function stringToKeywords(str) {
183
183
  if (!str || str.trim() === "") return [];
184
184
  return str.split(" ").filter((s) => s.length > 0);
185
185
  }
186
+ function deriveMemoryState(memory) {
187
+ if (memory.invalidatedAt) return "invalidated";
188
+ if (!memory.isLatest) return "superseded";
189
+ return "current";
190
+ }
186
191
 
187
192
  // src/mapper.ts
188
193
  function rowToMemory(row) {
@@ -608,9 +613,29 @@ function buildSearchSql(query) {
608
613
  whereClauses.push("m.confidence >= ?");
609
614
  params.push(filters.minConfidence);
610
615
  }
611
- const onlyLatest = filters?.onlyLatest ?? true;
612
- if (onlyLatest) {
613
- whereClauses.push("m.is_latest = 1");
616
+ if (filters?.states && filters.states.length > 0) {
617
+ const stateConditions = [];
618
+ if (filters.states.includes("current")) {
619
+ stateConditions.push("(m.is_latest = 1 AND m.invalidated_at IS NULL)");
620
+ }
621
+ if (filters.states.includes("superseded")) {
622
+ stateConditions.push("(m.is_latest = 0)");
623
+ }
624
+ if (filters.states.includes("invalidated")) {
625
+ stateConditions.push("(m.invalidated_at IS NOT NULL)");
626
+ }
627
+ if (stateConditions.length > 0) {
628
+ whereClauses.push(`(${stateConditions.join(" OR ")})`);
629
+ }
630
+ } else {
631
+ const onlyLatest = filters?.onlyLatest ?? true;
632
+ if (onlyLatest) {
633
+ whereClauses.push("m.is_latest = 1");
634
+ }
635
+ const excludeInvalidated = filters?.excludeInvalidated ?? true;
636
+ if (excludeInvalidated) {
637
+ whereClauses.push("m.invalidated_at IS NULL");
638
+ }
614
639
  }
615
640
  if (filters?.eventDateRange?.start) {
616
641
  whereClauses.push("m.event_date_start >= ?");
@@ -620,10 +645,6 @@ function buildSearchSql(query) {
620
645
  whereClauses.push("m.event_date_end <= ?");
621
646
  params.push(filters.eventDateRange.end);
622
647
  }
623
- const excludeInvalidated = filters?.excludeInvalidated ?? true;
624
- if (excludeInvalidated) {
625
- whereClauses.push("m.invalidated_at IS NULL");
626
- }
627
648
  const limit = query.limit ?? DEFAULT_LIMIT;
628
649
  params.push(limit);
629
650
  const sql = [
@@ -676,9 +697,29 @@ function ftsSearch(db, query) {
676
697
  function bruteForceVectorSearch(db, queryVec, userId, limit, filters) {
677
698
  const whereClauses = ["user_id = ?", "embedding IS NOT NULL"];
678
699
  const params = [userId];
679
- const onlyLatest = filters?.onlyLatest ?? true;
680
- if (onlyLatest) {
681
- whereClauses.push("is_latest = 1");
700
+ if (filters?.states && filters.states.length > 0) {
701
+ const stateConditions = [];
702
+ if (filters.states.includes("current")) {
703
+ stateConditions.push("(is_latest = 1 AND invalidated_at IS NULL)");
704
+ }
705
+ if (filters.states.includes("superseded")) {
706
+ stateConditions.push("(is_latest = 0)");
707
+ }
708
+ if (filters.states.includes("invalidated")) {
709
+ stateConditions.push("(invalidated_at IS NOT NULL)");
710
+ }
711
+ if (stateConditions.length > 0) {
712
+ whereClauses.push(`(${stateConditions.join(" OR ")})`);
713
+ }
714
+ } else {
715
+ const onlyLatest = filters?.onlyLatest ?? true;
716
+ if (onlyLatest) {
717
+ whereClauses.push("is_latest = 1");
718
+ }
719
+ const excludeInvalidated = filters?.excludeInvalidated ?? true;
720
+ if (excludeInvalidated) {
721
+ whereClauses.push("invalidated_at IS NULL");
722
+ }
682
723
  }
683
724
  if (filters?.memoryTypes && filters.memoryTypes.length > 0) {
684
725
  const mtPlaceholders = filters.memoryTypes.map(() => "?").join(",");
@@ -707,10 +748,6 @@ function bruteForceVectorSearch(db, queryVec, userId, limit, filters) {
707
748
  whereClauses.push("event_date_end <= ?");
708
749
  params.push(filters.eventDateRange.end);
709
750
  }
710
- const excludeInvalidated = filters?.excludeInvalidated ?? true;
711
- if (excludeInvalidated) {
712
- whereClauses.push("invalidated_at IS NULL");
713
- }
714
751
  const sql = `SELECT * FROM memories WHERE ${whereClauses.join(" AND ")}`;
715
752
  const rows = db.prepare(sql).all(...params);
716
753
  if (rows.length === 0) {
@@ -748,9 +785,29 @@ function vectorSearch(db, queryVec, userId, limit, filters, useVecTable = true)
748
785
  const placeholders = rowids.map(() => "?").join(",");
749
786
  let sql = `SELECT * FROM memories WHERE id IN (${placeholders}) AND user_id = ?`;
750
787
  const params = [...rowids, userId];
751
- const onlyLatest = filters?.onlyLatest ?? true;
752
- if (onlyLatest) {
753
- sql += " AND is_latest = 1";
788
+ if (filters?.states && filters.states.length > 0) {
789
+ const stateConditions = [];
790
+ if (filters.states.includes("current")) {
791
+ stateConditions.push("(is_latest = 1 AND invalidated_at IS NULL)");
792
+ }
793
+ if (filters.states.includes("superseded")) {
794
+ stateConditions.push("(is_latest = 0)");
795
+ }
796
+ if (filters.states.includes("invalidated")) {
797
+ stateConditions.push("(invalidated_at IS NOT NULL)");
798
+ }
799
+ if (stateConditions.length > 0) {
800
+ sql += ` AND (${stateConditions.join(" OR ")})`;
801
+ }
802
+ } else {
803
+ const onlyLatest = filters?.onlyLatest ?? true;
804
+ if (onlyLatest) {
805
+ sql += " AND is_latest = 1";
806
+ }
807
+ const excludeInvalidated = filters?.excludeInvalidated ?? true;
808
+ if (excludeInvalidated) {
809
+ sql += " AND invalidated_at IS NULL";
810
+ }
754
811
  }
755
812
  if (filters?.memoryTypes && filters.memoryTypes.length > 0) {
756
813
  const mtPlaceholders = filters.memoryTypes.map(() => "?").join(",");
@@ -779,10 +836,6 @@ function vectorSearch(db, queryVec, userId, limit, filters, useVecTable = true)
779
836
  sql += " AND event_date_end <= ?";
780
837
  params.push(filters.eventDateRange.end);
781
838
  }
782
- const excludeInvalidated = filters?.excludeInvalidated ?? true;
783
- if (excludeInvalidated) {
784
- sql += " AND invalidated_at IS NULL";
785
- }
786
839
  const rows = db.prepare(sql).all(...params);
787
840
  return rows.map((row) => ({
788
841
  memory: rowToMemory(row),
@@ -871,14 +924,41 @@ function cosineSimilarity(a, b) {
871
924
  const denom = Math.sqrt(normA) * Math.sqrt(normB);
872
925
  return denom === 0 ? 0 : dot / denom;
873
926
  }
874
- function applyActivationWeighting(results) {
875
- return results.map((r) => {
876
- const activationWeight = 0.5 + 0.5 * r.memory.activationScore;
877
- return {
878
- ...r,
879
- score: r.score * activationWeight
880
- };
881
- }).sort((a, b) => b.score - a.score);
927
+ function applyThreeFactorReranking(results, weights) {
928
+ if (results.length === 0) return results;
929
+ const w = {
930
+ recency: weights?.recency ?? 1,
931
+ importance: weights?.importance ?? 1,
932
+ relevance: weights?.relevance ?? 1
933
+ };
934
+ const now = Date.now();
935
+ const scored = results.map((r) => {
936
+ const hoursSince = (now - new Date(r.memory.learnedAt).getTime()) / (1e3 * 60 * 60);
937
+ const recency = Math.pow(0.995, Math.max(0, hoursSince));
938
+ const importance = r.memory.salience ?? 1;
939
+ const relevance = r.score;
940
+ return { ...r, recency, importance, relevance };
941
+ });
942
+ const NORM_EPSILON = 0.01;
943
+ const safeNormalize = (values) => {
944
+ let min = Infinity;
945
+ let max = -Infinity;
946
+ for (const v of values) {
947
+ if (v < min) min = v;
948
+ if (v > max) max = v;
949
+ }
950
+ const range = max - min;
951
+ if (range < NORM_EPSILON) return values.map(() => 1);
952
+ return values.map((v) => (v - min) / range);
953
+ };
954
+ const recencies = safeNormalize(scored.map((s) => s.recency));
955
+ const importances = safeNormalize(scored.map((s) => s.importance));
956
+ const relevances = safeNormalize(scored.map((s) => s.relevance));
957
+ return scored.map((s, i) => ({
958
+ memory: s.memory,
959
+ score: w.recency * recencies[i] + w.importance * importances[i] + w.relevance * relevances[i],
960
+ matchType: s.matchType
961
+ })).sort((a, b) => b.score - a.score);
882
962
  }
883
963
  function deduplicateResults(results) {
884
964
  const seen = /* @__PURE__ */ new Set();
@@ -923,16 +1003,18 @@ function updateAccessTracking(db, memoryIds) {
923
1003
  });
924
1004
  updateAll(memoryIds);
925
1005
  }
926
- function searchMemories(db, query, queryVec, useVecTable = true) {
1006
+ function searchMemories(db, query, queryVec, useVecTable = true, skipAccessTracking = false) {
927
1007
  const startTime = performance.now();
928
1008
  const queryTokens = extractQueryTokens(query.query);
929
1009
  const ftsResults = ftsSearch(db, query);
930
1010
  let finalResults;
931
1011
  if (!queryVec) {
932
- const weighted = applyActivationWeighting(ftsResults);
1012
+ const weighted = applyThreeFactorReranking(ftsResults);
933
1013
  const boosted = applyKeywordBoost(weighted, queryTokens);
934
1014
  finalResults = deduplicateResults(boosted);
935
- updateAccessTracking(db, finalResults.map((r) => r.memory.memoryId));
1015
+ if (!skipAccessTracking) {
1016
+ updateAccessTracking(db, finalResults.map((r) => r.memory.memoryId));
1017
+ }
936
1018
  return {
937
1019
  results: finalResults,
938
1020
  totalCount: finalResults.length,
@@ -955,11 +1037,11 @@ function searchMemories(db, query, queryVec, useVecTable = true) {
955
1037
  // Convert distance back to similarity
956
1038
  matchType: "vector"
957
1039
  }));
958
- const weighted = applyActivationWeighting(vecOnly);
1040
+ const weighted = applyThreeFactorReranking(vecOnly);
959
1041
  const boosted = applyKeywordBoost(weighted, queryTokens);
960
1042
  finalResults = deduplicateResults(boosted);
961
1043
  } else if (vecResults.length === 0) {
962
- const weighted = applyActivationWeighting(ftsResults);
1044
+ const weighted = applyThreeFactorReranking(ftsResults);
963
1045
  const boosted = applyKeywordBoost(weighted, queryTokens);
964
1046
  finalResults = deduplicateResults(boosted);
965
1047
  } else {
@@ -972,7 +1054,7 @@ function searchMemories(db, query, queryVec, useVecTable = true) {
972
1054
  score: r.score * (vecIds.has(r.memory.memoryId) ? 1.3 : 1)
973
1055
  }));
974
1056
  reranked.sort((a, b) => b.score - a.score);
975
- const weighted = applyActivationWeighting(reranked);
1057
+ const weighted = applyThreeFactorReranking(reranked);
976
1058
  const boosted = applyKeywordBoost(weighted, queryTokens);
977
1059
  finalResults = deduplicateResults(boosted);
978
1060
  } else {
@@ -988,7 +1070,7 @@ function searchMemories(db, query, queryVec, useVecTable = true) {
988
1070
  matchType: "hybrid"
989
1071
  });
990
1072
  }
991
- const weighted = applyActivationWeighting(ftsItems);
1073
+ const weighted = applyThreeFactorReranking(ftsItems);
992
1074
  const boosted = applyKeywordBoost(weighted, queryTokens);
993
1075
  finalResults = deduplicateResults(boosted);
994
1076
  }
@@ -1024,6 +1106,16 @@ function computeActivation(accessTimestamps, salience, now) {
1024
1106
  const activation = sum > 0 ? Math.log(sum) + beta : beta * 0.1;
1025
1107
  return sigmoid(activation);
1026
1108
  }
1109
+ function computeEbbinghaus(accessCount, lastAccessedAt, now) {
1110
+ const currentTime = now ?? /* @__PURE__ */ new Date();
1111
+ if (!lastAccessedAt) {
1112
+ return 0.1;
1113
+ }
1114
+ const S = Math.max(1, accessCount);
1115
+ const t = (currentTime.getTime() - new Date(lastAccessedAt).getTime()) / MS_PER_DAY;
1116
+ if (t <= 0) return 1;
1117
+ return Math.exp(-t / S);
1118
+ }
1027
1119
  function sigmoid(x) {
1028
1120
  return 1 / (1 + Math.exp(-x));
1029
1121
  }
@@ -1038,9 +1130,12 @@ var DEFAULT_TIER_CONFIG = {
1038
1130
  function determineTier(memory, config, now) {
1039
1131
  const cfg = config ?? DEFAULT_TIER_CONFIG;
1040
1132
  if (memory.tier === "hot") return "hot";
1133
+ const accessCount = memory.accessCount ?? 0;
1134
+ if (accessCount >= 10) return "hot";
1041
1135
  const currentTime = now ?? /* @__PURE__ */ new Date();
1042
1136
  const age = (currentTime.getTime() - new Date(memory.learnedAt).getTime()) / MS_PER_DAY2;
1043
1137
  if (age <= cfg.warmDays) return "warm";
1138
+ if (memory.activationScore >= cfg.coldActivationThreshold) return "warm";
1044
1139
  return "cold";
1045
1140
  }
1046
1141
  function estimateTokens(content) {
@@ -1102,14 +1197,27 @@ var SqliteMemoryEngine = class {
1102
1197
  if (this.options.contradictionDetector && this.options.embedder) {
1103
1198
  await this.checkContradictions(memory);
1104
1199
  }
1200
+ await this.checkDuplicates(memory);
1201
+ await this.autoRelate(memory);
1105
1202
  return memory;
1106
1203
  }
1107
1204
  async storeBatch(inputs) {
1108
1205
  this.ensureInitialized();
1206
+ let memories;
1109
1207
  if (this.options.embedder) {
1110
- return storeBatchAsync(this.db, this.stmts, inputs, this.options.embedder);
1208
+ memories = await storeBatchAsync(this.db, this.stmts, inputs, this.options.embedder);
1209
+ } else {
1210
+ memories = storeBatchInTransaction(this.db, this.stmts, inputs);
1211
+ }
1212
+ if (this.options.contradictionDetector && this.options.embedder && memories.length <= 50) {
1213
+ for (const memory of memories) {
1214
+ try {
1215
+ await this.checkContradictions(memory);
1216
+ } catch {
1217
+ }
1218
+ }
1111
1219
  }
1112
- return storeBatchInTransaction(this.db, this.stmts, inputs);
1220
+ return memories;
1113
1221
  }
1114
1222
  async getById(memoryId) {
1115
1223
  this.ensureInitialized();
@@ -1315,10 +1423,11 @@ var SqliteMemoryEngine = class {
1315
1423
  );
1316
1424
  const activationTransaction = db.transaction(() => {
1317
1425
  for (const mem of memories) {
1318
- const timestamps = [mem.learned_at, mem.last_accessed_at].filter(
1319
- (t) => t != null
1426
+ const ebbinghaus = computeEbbinghaus(
1427
+ mem.access_count ?? 0,
1428
+ mem.last_accessed_at ?? null
1320
1429
  );
1321
- const score = computeActivation(timestamps, mem.salience);
1430
+ const score = ebbinghaus * 0.8 + (mem.salience ?? 1) * 0.2;
1322
1431
  updateActivation.run(score, mem.memory_id);
1323
1432
  activationUpdated++;
1324
1433
  }
@@ -1336,7 +1445,8 @@ var SqliteMemoryEngine = class {
1336
1445
  const newTier = determineTier({
1337
1446
  learnedAt: mem.learned_at,
1338
1447
  activationScore: mem.activation_score ?? 1,
1339
- tier: mem.tier ?? "warm"
1448
+ tier: mem.tier ?? "warm",
1449
+ accessCount: mem.access_count ?? 0
1340
1450
  });
1341
1451
  if (newTier !== (mem.tier ?? "warm")) {
1342
1452
  updateTier.run(newTier, mem.memory_id);
@@ -1357,6 +1467,41 @@ var SqliteMemoryEngine = class {
1357
1467
  this.ensureInitialized();
1358
1468
  this.db.prepare("UPDATE memories SET tier = ? WHERE memory_id = ?").run(tier, memoryId);
1359
1469
  }
1470
+ async quality(userId) {
1471
+ this.ensureInitialized();
1472
+ const db = this.db;
1473
+ const total = db.prepare("SELECT COUNT(*) as c FROM memories WHERE user_id = ?").get(userId).c;
1474
+ const fresh = db.prepare(
1475
+ "SELECT COUNT(*) as c FROM memories WHERE user_id = ? AND is_latest = 1 AND invalidated_at IS NULL"
1476
+ ).get(userId).c;
1477
+ const invalidated = db.prepare(
1478
+ "SELECT COUNT(*) as c FROM memories WHERE user_id = ? AND invalidated_at IS NOT NULL"
1479
+ ).get(userId).c;
1480
+ const superseded = db.prepare(
1481
+ "SELECT COUNT(*) as c FROM memories WHERE user_id = ? AND is_latest = 0"
1482
+ ).get(userId).c;
1483
+ const withRelations = db.prepare(`
1484
+ SELECT COUNT(DISTINCT mid) as c FROM (
1485
+ SELECT src_memory_id as mid FROM memory_relations
1486
+ WHERE src_memory_id IN (SELECT memory_id FROM memories WHERE user_id = ?)
1487
+ UNION
1488
+ SELECT dst_memory_id as mid FROM memory_relations
1489
+ WHERE dst_memory_id IN (SELECT memory_id FROM memories WHERE user_id = ?)
1490
+ )
1491
+ `).get(userId, userId).c;
1492
+ const avgRow = db.prepare(
1493
+ "SELECT AVG(activation_score) as avg FROM memories WHERE user_id = ? AND is_latest = 1"
1494
+ ).get(userId);
1495
+ const avgActivation = avgRow.avg ?? 0;
1496
+ return {
1497
+ total,
1498
+ fresh,
1499
+ invalidated,
1500
+ superseded,
1501
+ withRelations,
1502
+ avgActivation
1503
+ };
1504
+ }
1360
1505
  /**
1361
1506
  * Check the newly stored memory against existing similar memories
1362
1507
  * for contradictions using the NLI model.
@@ -1382,7 +1527,9 @@ var SqliteMemoryEngine = class {
1382
1527
  filters: { onlyLatest: true }
1383
1528
  },
1384
1529
  queryVec,
1385
- this.vectorEnabled
1530
+ this.vectorEnabled,
1531
+ true
1532
+ // skipAccessTracking
1386
1533
  );
1387
1534
  for (const result of similar.results) {
1388
1535
  if (result.memory.memoryId === newMemory.memoryId) continue;
@@ -1405,6 +1552,91 @@ var SqliteMemoryEngine = class {
1405
1552
  } catch {
1406
1553
  }
1407
1554
  }
1555
+ /**
1556
+ * Check for near-duplicate memories after storing.
1557
+ * Uses direct cosine similarity (not the combined search score) to avoid
1558
+ * false positives from the multi-factor reranking.
1559
+ * If cosine similarity > 0.95, auto-create an 'updates' relation (new supersedes old).
1560
+ * Only runs for single store() calls, not storeBatch() (too slow for bulk).
1561
+ */
1562
+ async checkDuplicates(newMemory) {
1563
+ if (!this.options.embedder) return;
1564
+ try {
1565
+ const queryVec = await this.options.embedder.embed(newMemory.content);
1566
+ const candidates = bruteForceVectorSearch(
1567
+ this.db,
1568
+ queryVec,
1569
+ newMemory.userId,
1570
+ 10,
1571
+ { onlyLatest: true }
1572
+ );
1573
+ for (const candidate of candidates) {
1574
+ if (candidate.memory.memoryId === newMemory.memoryId) continue;
1575
+ const similarity = 1 - candidate.distance;
1576
+ if (similarity > 0.95) {
1577
+ try {
1578
+ await this.relate(
1579
+ newMemory.memoryId,
1580
+ candidate.memory.memoryId,
1581
+ "updates",
1582
+ `Auto-detected duplicate: cosine similarity ${similarity.toFixed(3)}`
1583
+ );
1584
+ } catch {
1585
+ }
1586
+ }
1587
+ }
1588
+ } catch {
1589
+ }
1590
+ }
1591
+ /**
1592
+ * Auto-create 'extends' relations when a new memory shares keywords
1593
+ * with existing memories. Builds graph density for future graph-based retrieval.
1594
+ *
1595
+ * Only runs for individual store() calls, not storeBatch().
1596
+ * Requires at least 2 overlapping keywords to create a relation.
1597
+ * Graceful: errors are silently swallowed.
1598
+ */
1599
+ async autoRelate(newMemory) {
1600
+ if (!newMemory.keywords || newMemory.keywords.length === 0) return;
1601
+ try {
1602
+ const keywordList = newMemory.keywords;
1603
+ const existing = this.db.prepare(`
1604
+ SELECT memory_id, keywords FROM memories
1605
+ WHERE user_id = ? AND is_latest = 1 AND memory_id != ?
1606
+ AND invalidated_at IS NULL
1607
+ ORDER BY learned_at DESC LIMIT 10
1608
+ `).all(newMemory.userId, newMemory.memoryId);
1609
+ for (const row of existing) {
1610
+ if (!row.keywords) continue;
1611
+ const existingKeywords = row.keywords.split(" ").map((k) => k.trim().toLowerCase());
1612
+ const overlap = keywordList.filter((k) => existingKeywords.includes(k.toLowerCase()));
1613
+ if (overlap.length >= 3) {
1614
+ const existingRelation = this.db.prepare(
1615
+ `SELECT 1 FROM memory_relations
1616
+ WHERE (src_memory_id = ? AND dst_memory_id = ?)
1617
+ OR (src_memory_id = ? AND dst_memory_id = ?)
1618
+ LIMIT 1`
1619
+ ).get(
1620
+ newMemory.memoryId,
1621
+ row.memory_id,
1622
+ row.memory_id,
1623
+ newMemory.memoryId
1624
+ );
1625
+ if (existingRelation) continue;
1626
+ try {
1627
+ await this.relate(
1628
+ newMemory.memoryId,
1629
+ row.memory_id,
1630
+ "extends",
1631
+ `Auto: ${overlap.length} shared keywords (${overlap.join(", ")})`
1632
+ );
1633
+ } catch {
1634
+ }
1635
+ }
1636
+ }
1637
+ } catch {
1638
+ }
1639
+ }
1408
1640
  ensureInitialized() {
1409
1641
  if (!this.db || !this.stmts || !this.relStmts) {
1410
1642
  throw new Error("Engine not initialized. Call initialize() first.");
@@ -1418,15 +1650,18 @@ export {
1418
1650
  DEFAULT_TIER_CONFIG,
1419
1651
  SqliteMemoryEngine,
1420
1652
  applyKeywordBoost,
1653
+ applyThreeFactorReranking,
1421
1654
  bruteForceVectorSearch,
1422
1655
  buildFtsQuery,
1423
1656
  buildSearchSql,
1424
1657
  computeActivation,
1658
+ computeEbbinghaus,
1425
1659
  createEngine,
1426
1660
  createPreparedStatements,
1427
1661
  createRelation,
1428
1662
  createRelationStatements,
1429
1663
  deduplicateResults,
1664
+ deriveMemoryState,
1430
1665
  determineTier,
1431
1666
  ensureSchema,
1432
1667
  estimateTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memrosetta/core",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -20,10 +20,10 @@
20
20
  "better-sqlite3": "^11.0.0",
21
21
  "nanoid": "^5.0.0",
22
22
  "sqlite-vec": "^0.1.7",
23
- "@memrosetta/types": "0.2.18"
23
+ "@memrosetta/types": "0.2.20"
24
24
  },
25
25
  "optionalDependencies": {
26
- "@memrosetta/embeddings": "0.2.18"
26
+ "@memrosetta/embeddings": "0.2.20"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/better-sqlite3": "^7.6.0",