@remind_ai/remind 0.1.0 → 0.1.1

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.
@@ -13,17 +13,25 @@ const clamp01 = (n) => Math.max(0, Math.min(1, n));
13
13
  export async function consolidate(agentId, stores, gate, llm = defaultLlm) {
14
14
  const report = { promoted: [], blocked: [], merged: 0 };
15
15
  const cache = new Map(); // embeddings reused across the whole cycle
16
- // 1. Fresh episodic memories are the raw material of a sleep cycle.
17
- const episodic = (await stores.doc.getRecords(agentId, { type: 'episodic' })).filter((r) => r.tier === 'hot' && !r.quarantined);
16
+ // Every currently-valid memory (beliefs + episodics) in one pass, so the newest statement
17
+ // can retire any older record it contradicts fact-vs-fact, fact-vs-event, or event-vs-event.
18
+ const valid = currentlyValid(await stores.doc.getRecords(agentId, {}));
19
+ // Latest-wins supersession across the freshly-ingested records: a changed preference or a
20
+ // corrected detail retires the stale version even when no episodic belief is distilled this
21
+ // cycle, keeping the bi-temporal record collection — the single source of retrieval truth —
22
+ // free of superseded content.
23
+ const retired = await supersedeRecent(valid, stores, llm, cache);
24
+ // Beliefs still on the books after supersession (for corroboration + salience), embedded once.
25
+ let existingBeliefs = valid.filter((r) => r.type === 'factual' && !retired.has(r.id));
26
+ await embedAll(existingBeliefs, cache, llm.embed);
27
+ // 1. Fresh episodic memories are the raw material of a sleep cycle — minus any just retired.
28
+ const episodic = valid.filter((r) => r.type === 'episodic' && r.tier === 'hot' && !r.quarantined && !retired.has(r.id));
18
29
  if (!episodic.length)
19
30
  return report;
20
31
  // 2. Drop exact duplicates, then cluster what remains into belief candidates.
21
32
  const { canonical } = await dedupe(episodic, cache, llm.embed);
22
33
  const clusters = cluster(canonical, cache, MERGE_THRESHOLD);
23
34
  report.merged = episodic.length - clusters.length;
24
- // 3. Beliefs currently on the books (for supersession + corroboration), embedded once.
25
- const existingBeliefs = currentlyValid(await stores.doc.getRecords(agentId, { type: 'factual' }));
26
- await embedAll(existingBeliefs, cache, llm.embed);
27
35
  // 4. Distill each cluster into a semantic gist, gate it, persist if promoted.
28
36
  for (const group of clusters) {
29
37
  try {
@@ -56,6 +64,33 @@ export async function consolidate(agentId, stores, gate, llm = defaultLlm) {
56
64
  }
57
65
  return report;
58
66
  }
67
+ /**
68
+ * Latest-wins supersession across recently-ingested ("hot") memories. Each fresh record,
69
+ * newest first, retires older still-valid records it contradicts — fact, event, or
70
+ * preference alike — so a changed preference or corrected detail closes out the stale
71
+ * version even when no episodic belief is distilled this cycle. The embedding prefilter
72
+ * inside `supersede` keeps this to O(related): an LLM contradiction check only fires for
73
+ * topically-similar records. Returns the retired record ids.
74
+ */
75
+ async function supersedeRecent(records, stores, llm, cache) {
76
+ const retired = new Set();
77
+ const fresh = records
78
+ .filter((r) => r.tier === 'hot')
79
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); // newest first
80
+ for (const rec of fresh) {
81
+ if (retired.has(rec.id))
82
+ continue;
83
+ const older = records.filter((r) => r.id !== rec.id && !retired.has(r.id) && Date.parse(r.createdAt) < Date.parse(rec.createdAt));
84
+ if (!older.length)
85
+ continue;
86
+ // These are the user's own recent statements, so widen recall (lower sim gate): a
87
+ // reworded update ("phone is a Pixel" → "owns an iPhone") should still be compared.
88
+ const closed = await supersede(rec, older, stores, llm, cache, 0.3);
89
+ for (const c of closed)
90
+ retired.add(c.id);
91
+ }
92
+ return retired;
93
+ }
59
94
  async function offer(candidate, sources, gate, stores, existingBeliefs, report, llm, cache) {
60
95
  const verdict = await gate(candidate, sources);
61
96
  if (!verdict.promote) {
@@ -6,4 +6,4 @@ export declare function currentlyValid(records: MemoryRecord[]): MemoryRecord[];
6
6
  /** Records whose valid-time window contains the instant `at` (ISO). */
7
7
  export declare function activeAt(records: MemoryRecord[], at: string): MemoryRecord[];
8
8
  /** Close the valid-time of active beliefs that `newRecord` contradicts. Returns the retired records. */
9
- export declare function supersede(newRecord: MemoryRecord, existing: MemoryRecord[], stores: Stores, llm?: LlmDeps, cache?: Map<string, Vec>): Promise<MemoryRecord[]>;
9
+ export declare function supersede(newRecord: MemoryRecord, existing: MemoryRecord[], stores: Stores, llm?: LlmDeps, cache?: Map<string, Vec>, minSim?: number): Promise<MemoryRecord[]>;
@@ -20,24 +20,29 @@ export function activeAt(records, at) {
20
20
  });
21
21
  }
22
22
  /** Close the valid-time of active beliefs that `newRecord` contradicts. Returns the retired records. */
23
- export async function supersede(newRecord, existing, stores, llm = defaultLlm, cache = new Map()) {
23
+ export async function supersede(newRecord, existing, stores, llm = defaultLlm, cache = new Map(), minSim = SUPERSEDE_SIM_THRESHOLD) {
24
24
  if (!newRecord.validFrom)
25
25
  newRecord.validFrom = nowIso();
26
26
  const candidates = currentlyValid(existing).filter((r) => r.id !== newRecord.id);
27
27
  if (!candidates.length)
28
28
  return [];
29
29
  // Embedding prefilter: only spend an LLM call on beliefs that are topically related,
30
- // so this stays O(related) instead of O(every belief).
30
+ // so this stays O(related) instead of O(every belief). Callers comparing the user's own
31
+ // handful of recent statements can widen recall by lowering `minSim`.
31
32
  await embedAll([newRecord, ...candidates], cache, llm.embed);
32
33
  const nv = cache.get(keyOf(newRecord));
33
34
  const retired = [];
34
35
  for (const old of candidates) {
35
36
  const ov = cache.get(keyOf(old));
36
37
  const sim = nv && ov ? cosine(nv, ov) : 1; // missing vectors ⇒ fail safe and check
37
- if (!sharesSubject(newRecord, old) && sim < SUPERSEDE_SIM_THRESHOLD)
38
+ if (!sharesSubject(newRecord, old) && sim < minSim)
38
39
  continue;
39
- const ans = await llm.classify(`OLD belief: "${old.content}"\nNEW belief: "${newRecord.content}"\n` +
40
- `Does NEW contradict or replace OLD about the same subject? Answer 'yes' or 'no'.`);
40
+ const ans = await llm.classify(`You maintain the user's profile, where each attribute (employer, location, phone, ` +
41
+ `manager, preference, device, etc.) holds a single CURRENT value.\n` +
42
+ `OLD: "${old.content}"\nNEW: "${newRecord.content}"\n` +
43
+ `Does NEW contradict or replace OLD — i.e. give a new value for the SAME attribute, so ` +
44
+ `OLD is no longer the user's current state? Answer 'yes' if NEW updates, replaces, or ` +
45
+ `contradicts OLD; answer 'no' only if they are independent facts that can both be true at once.`);
41
46
  if (ans.startsWith('yes')) {
42
47
  const closed = { ...old, validTo: newRecord.validFrom };
43
48
  await stores.doc.saveRecord(closed);
@@ -15,8 +15,15 @@ Query: ${query}`);
15
15
  Rewritten query: ${refined}
16
16
  Chunks: ${JSON.stringify(first.map((r) => r.content))}`);
17
17
  const second = await stores.vector.search(agentId, refined2, 3, sourceId);
18
- // 4. Frequency fusion — chunks seen in both passes rank higher.
19
- const priority = fuse([...first, ...second]).slice(0, 3);
18
+ // 4. Frequency fusion — chunks seen in both passes rank higher. A bi-temporal guard
19
+ // drops any chunk whose record has been superseded (validTo closed), so a stale value a
20
+ // newer statement replaced never reaches the context even though it lingers in the index.
21
+ const all = await stores.doc.getRecords(agentId, {});
22
+ const now = Date.now();
23
+ const superseded = new Set(all.filter((r) => r.validTo && Date.parse(r.validTo) <= now).map((r) => r.id));
24
+ const priority = fuse([...first, ...second])
25
+ .filter((r) => !superseded.has(r.id))
26
+ .slice(0, 3);
20
27
  // 5. Knowledge-graph relations, filtered to what's relevant.
21
28
  const graphMap = await stores.graph.fetchGraph(agentId);
22
29
  let relations = '';
@@ -59,8 +59,29 @@ export const docStore = {
59
59
  },
60
60
  async getFacts(agentId) {
61
61
  await connect();
62
- const a = await AgentModel.findOne({ agentId }).lean();
63
- return a?.facts ?? [];
62
+ // Single source of truth: derive the user's factual context from the bi-temporal
63
+ // record collection, excluding any belief whose valid-time window has been closed
64
+ // by supersession. The validity filter runs in the DB query (indexed on agentId),
65
+ // so superseded facts never reach retrieval. ISO-8601 UTC strings compare
66
+ // lexicographically in chronological order, so $gt on `validTo` is correct.
67
+ const now = new Date().toISOString();
68
+ const docs = await RecordModel.find({
69
+ agentId,
70
+ type: 'factual',
71
+ $or: [{ validTo: { $exists: false } }, { validTo: null }, { validTo: { $gt: now } }],
72
+ })
73
+ .sort({ createdAt: 1 })
74
+ .lean();
75
+ const seen = new Set();
76
+ const facts = [];
77
+ for (const d of docs) {
78
+ const c = d.content?.trim();
79
+ if (c && !seen.has(c.toLowerCase())) {
80
+ seen.add(c.toLowerCase());
81
+ facts.push(c);
82
+ }
83
+ }
84
+ return facts;
64
85
  },
65
86
  async saveRecord(record) {
66
87
  await connect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remind_ai/remind",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Brain-inspired AI-agent memory SDK with the MemGuard security layer.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",