@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
|
-
//
|
|
17
|
-
|
|
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
|
|
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 <
|
|
38
|
+
if (!sharesSubject(newRecord, old) && sim < minSim)
|
|
38
39
|
continue;
|
|
39
|
-
const ans = await llm.classify(`
|
|
40
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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();
|