Skip to content

Commit 0717249

Browse files
authored
Merge pull request #183 from CortexReach/feat/temporal-facts-pr1
feat: add temporal supersede semantics for mutable facts
2 parents aa9a98d + d337523 commit 0717249

File tree

10 files changed

+1071
-54
lines changed

10 files changed

+1071
-54
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
]
3737
},
3838
"scripts": {
39-
"test": "node test/embedder-error-hints.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs",
39+
"test": "node test/embedder-error-hints.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs",
4040
"test:openclaw-host": "node test/openclaw-host-functional.mjs",
4141
"version": "node scripts/sync-plugin-version.mjs openclaw.plugin.json package.json && git add openclaw.plugin.json"
4242
},

src/extraction-prompts.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,25 +149,27 @@ Please decide:
149149
- SKIP: Candidate memory duplicates existing memories, no need to save. Also SKIP if the candidate contains LESS information than an existing memory on the same topic (information degradation — e.g., candidate says "programming language preference" but existing memory already says "programming language preference: Python, TypeScript")
150150
- CREATE: This is completely new information not covered by any existing memory, should be created
151151
- MERGE: Candidate memory adds genuinely NEW details to an existing memory and should be merged
152+
- SUPERSEDE: Candidate states that the same mutable fact has changed over time. Keep the old memory as historical but no longer current, and create a new current memory.
152153
- SUPPORT: Candidate reinforces/confirms an existing memory in a specific context (e.g. "still prefers tea in the evening")
153154
- CONTEXTUALIZE: Candidate adds a situational nuance to an existing memory (e.g. existing: "likes coffee", candidate: "prefers tea at night" — different context, same topic)
154155
- CONTRADICT: Candidate directly contradicts an existing memory in a specific context (e.g. existing: "runs on weekends", candidate: "stopped running on weekends")
155156
156157
IMPORTANT:
157-
- "events" and "cases" categories are independent records — they do NOT support MERGE/SUPPORT/CONTEXTUALIZE/CONTRADICT. For these categories, only use SKIP or CREATE.
158+
- "events" and "cases" categories are independent records — they do NOT support MERGE/SUPERSEDE/SUPPORT/CONTEXTUALIZE/CONTRADICT. For these categories, only use SKIP or CREATE.
158159
- If the candidate appears to be derived from a recall question (e.g., "Do you remember X?" / "你记得X吗?") and an existing memory already covers topic X with equal or more detail, you MUST choose SKIP.
159160
- A candidate with less information than an existing memory on the same topic should NEVER be CREATED or MERGED — always SKIP.
161+
- For "preferences" and "entities", use SUPERSEDE when the candidate replaces the current truth instead of adding detail or context. Example: existing "Preferred editor: VS Code", candidate "Preferred editor: Zed".
160162
- For SUPPORT/CONTEXTUALIZE/CONTRADICT, you MUST provide a context_label from this vocabulary: general, morning, evening, night, weekday, weekend, work, leisure, summer, winter, travel.
161163
162164
Return JSON format:
163165
{
164-
"decision": "skip|create|merge|support|contextualize|contradict",
166+
"decision": "skip|create|merge|supersede|support|contextualize|contradict",
165167
"match_index": 1,
166168
"reason": "Decision reason",
167169
"context_label": "evening"
168170
}
169171
170-
- If decision is "merge"/"support"/"contextualize"/"contradict", set "match_index" to the number of the existing memory (1-based).
172+
- If decision is "merge"/"supersede"/"support"/"contextualize"/"contradict", set "match_index" to the number of the existing memory (1-based).
171173
- Only include "context_label" for support/contextualize/contradict decisions.`;
172174
}
173175

src/memory-categories.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const MERGE_SUPPORTED_CATEGORIES = new Set<MemoryCategory>([
2626
"patterns",
2727
]);
2828

29+
/** Categories whose facts can be replaced over time without deleting history. */
30+
export const TEMPORAL_VERSIONED_CATEGORIES = new Set<MemoryCategory>([
31+
"preferences",
32+
"entities",
33+
]);
34+
2935
/** Categories that are append-only (CREATE or SKIP only, no MERGE). */
3036
export const APPEND_ONLY_CATEGORIES = new Set<MemoryCategory>([
3137
"events",
@@ -44,7 +50,14 @@ export type CandidateMemory = {
4450
};
4551

4652
/** Dedup decision from LLM. */
47-
export type DedupDecision = "create" | "merge" | "skip" | "support" | "contextualize" | "contradict";
53+
export type DedupDecision =
54+
| "create"
55+
| "merge"
56+
| "skip"
57+
| "support"
58+
| "contextualize"
59+
| "contradict"
60+
| "supersede";
4861

4962
export type DedupResult = {
5063
decision: DedupDecision;
@@ -58,6 +71,7 @@ export type ExtractionStats = {
5871
merged: number;
5972
skipped: number;
6073
supported?: number; // context-aware support count
74+
superseded?: number; // temporal fact replacements
6175
};
6276

6377
/** Validate and normalize a category string. */

src/retriever.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import {
1313
import { filterNoise } from "./noise-filter.js";
1414
import type { DecayEngine, DecayableMemory } from "./decay-engine.js";
1515
import type { TierManager } from "./tier-manager.js";
16-
import { toLifecycleMemory, getDecayableFromEntry } from "./smart-metadata.js";
16+
import {
17+
getDecayableFromEntry,
18+
isMemoryActiveAt,
19+
parseSmartMetadata,
20+
toLifecycleMemory,
21+
} from "./smart-metadata.js";
1722

1823
// ============================================================================
1924
// Types & Configuration
@@ -302,6 +307,12 @@ export class MemoryRetriever {
302307
this.accessTracker = tracker;
303308
}
304309

310+
private filterActiveResults<T extends MemorySearchResult>(results: T[]): T[] {
311+
return results.filter((result) =>
312+
isMemoryActiveAt(parseSmartMetadata(result.entry.metadata, result.entry)),
313+
);
314+
}
315+
305316
async retrieve(context: RetrievalContext): Promise<RetrievalResult[]> {
306317
const { query, limit, scopeFilter, category, source } = context;
307318
const safeLimit = clampInt(limit, 1, 20);
@@ -343,6 +354,7 @@ export class MemoryRetriever {
343354
limit,
344355
this.config.minScore,
345356
scopeFilter,
357+
{ excludeInactive: true },
346358
);
347359

348360
// Filter by category if specified
@@ -458,6 +470,7 @@ export class MemoryRetriever {
458470
limit,
459471
0.1,
460472
scopeFilter,
473+
{ excludeInactive: true },
461474
);
462475

463476
// Filter by category if specified
@@ -477,7 +490,7 @@ export class MemoryRetriever {
477490
scopeFilter?: string[],
478491
category?: string,
479492
): Promise<Array<MemorySearchResult & { rank: number }>> {
480-
const results = await this.store.bm25Search(query, limit, scopeFilter);
493+
const results = await this.store.bm25Search(query, limit, scopeFilter, { excludeInactive: true });
481494

482495
// Filter by category if specified
483496
const filtered = category

src/smart-extractor.ts

Lines changed: 161 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,21 @@ import {
2323
ALWAYS_MERGE_CATEGORIES,
2424
MERGE_SUPPORTED_CATEGORIES,
2525
MEMORY_CATEGORIES,
26+
TEMPORAL_VERSIONED_CATEGORIES,
2627
normalizeCategory,
2728
} from "./memory-categories.js";
2829
import { isNoise } from "./noise-filter.js";
2930
import type { NoisePrototypeBank } from "./noise-prototypes.js";
30-
import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata, parseSupportInfo, updateSupportStats } from "./smart-metadata.js";
31+
import {
32+
appendRelation,
33+
buildSmartMetadata,
34+
deriveFactKey,
35+
type MemoryRelation,
36+
parseSmartMetadata,
37+
stringifySmartMetadata,
38+
parseSupportInfo,
39+
updateSupportStats,
40+
} from "./smart-metadata.js";
3141

3242
// ============================================================================
3343
// Constants
@@ -36,7 +46,15 @@ import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata, parseSu
3646
const SIMILARITY_THRESHOLD = 0.7;
3747
const MAX_SIMILAR_FOR_PROMPT = 3;
3848
const MAX_MEMORIES_PER_EXTRACTION = 5;
39-
const VALID_DECISIONS = new Set<string>(["create", "merge", "skip", "support", "contextualize", "contradict"]);
49+
const VALID_DECISIONS = new Set<string>([
50+
"create",
51+
"merge",
52+
"skip",
53+
"support",
54+
"contextualize",
55+
"contradict",
56+
"supersede",
57+
]);
4058

4159
// ============================================================================
4260
// Smart Extractor
@@ -358,6 +376,27 @@ export class SmartExtractor {
358376
stats.skipped++;
359377
break;
360378

379+
case "supersede":
380+
if (
381+
dedupResult.matchId &&
382+
TEMPORAL_VERSIONED_CATEGORIES.has(candidate.category)
383+
) {
384+
await this.handleSupersede(
385+
candidate,
386+
vector,
387+
dedupResult.matchId,
388+
sessionKey,
389+
targetScope,
390+
scopeFilter,
391+
);
392+
stats.created++;
393+
stats.superseded = (stats.superseded ?? 0) + 1;
394+
} else {
395+
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
396+
stats.created++;
397+
}
398+
break;
399+
361400
case "support":
362401
if (dedupResult.matchId) {
363402
await this.handleSupport(dedupResult.matchId, scopeFilter, { session: sessionKey, timestamp: Date.now() }, dedupResult.reason, dedupResult.contextLabel);
@@ -380,8 +419,24 @@ export class SmartExtractor {
380419

381420
case "contradict":
382421
if (dedupResult.matchId) {
383-
await this.handleContradict(candidate, vector, dedupResult.matchId, sessionKey, targetScope, scopeFilter, dedupResult.contextLabel);
384-
stats.created++;
422+
if (
423+
TEMPORAL_VERSIONED_CATEGORIES.has(candidate.category) &&
424+
dedupResult.contextLabel === "general"
425+
) {
426+
await this.handleSupersede(
427+
candidate,
428+
vector,
429+
dedupResult.matchId,
430+
sessionKey,
431+
targetScope,
432+
scopeFilter,
433+
);
434+
stats.created++;
435+
stats.superseded = (stats.superseded ?? 0) + 1;
436+
} else {
437+
await this.handleContradict(candidate, vector, dedupResult.matchId, sessionKey, targetScope, scopeFilter, dedupResult.contextLabel);
438+
stats.created++;
439+
}
385440
} else {
386441
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
387442
stats.created++;
@@ -402,20 +457,23 @@ export class SmartExtractor {
402457
candidateVector: number[],
403458
scopeFilter: string[],
404459
): Promise<DedupResult> {
405-
// Stage 1: Vector pre-filter — find similar memories
406-
const similar = await this.store.vectorSearch(
460+
// Stage 1: Vector pre-filter — find similar active memories.
461+
// excludeInactive ensures the store over-fetches to fill N active slots,
462+
// preventing superseded history from crowding out the current fact.
463+
const activeSimilar = await this.store.vectorSearch(
407464
candidateVector,
408465
5,
409466
SIMILARITY_THRESHOLD,
410467
scopeFilter,
468+
{ excludeInactive: true },
411469
);
412470

413-
if (similar.length === 0) {
471+
if (activeSimilar.length === 0) {
414472
return { decision: "create", reason: "No similar memories found" };
415473
}
416474

417475
// Stage 2: LLM decision
418-
return this.llmDedupDecision(candidate, similar);
476+
return this.llmDedupDecision(candidate, activeSimilar);
419477
}
420478

421479
private async llmDedupDecision(
@@ -468,15 +526,28 @@ export class SmartExtractor {
468526

469527
// Resolve merge target from LLM's match_index (1-based)
470528
const idx = data.match_index;
471-
const matchEntry =
472-
typeof idx === "number" && idx >= 1 && idx <= topSimilar.length
473-
? topSimilar[idx - 1]
474-
: topSimilar[0];
529+
const hasValidIndex = typeof idx === "number" && idx >= 1 && idx <= topSimilar.length;
530+
const matchEntry = hasValidIndex
531+
? topSimilar[idx - 1]
532+
: topSimilar[0];
533+
534+
// For destructive decisions (supersede), missing match_index is
535+
// unsafe — we could invalidate the wrong memory. Degrade to create.
536+
const destructiveDecisions = new Set(["supersede", "contradict"]);
537+
if (destructiveDecisions.has(decision) && !hasValidIndex) {
538+
this.log(
539+
`memory-pro: smart-extractor: ${decision} decision has missing/invalid match_index (${idx}), degrading to create`,
540+
);
541+
return {
542+
decision: "create",
543+
reason: `${decision} degraded: missing match_index`,
544+
};
545+
}
475546

476547
return {
477548
decision,
478549
reason: data.reason ?? "",
479-
matchId: ["merge", "support", "contextualize", "contradict"].includes(decision) ? matchEntry?.entry.id : undefined,
550+
matchId: ["merge", "support", "contextualize", "contradict", "supersede"].includes(decision) ? matchEntry?.entry.id : undefined,
480551
contextLabel: typeof (data as any).context_label === "string" ? (data as any).context_label : undefined,
481552
};
482553
} catch (err) {
@@ -640,6 +711,83 @@ export class SmartExtractor {
640711
);
641712
}
642713

714+
/**
715+
* Handle SUPERSEDE: preserve the old record as historical but mark it as no
716+
* longer current, then create the new active fact.
717+
*/
718+
private async handleSupersede(
719+
candidate: CandidateMemory,
720+
vector: number[],
721+
matchId: string,
722+
sessionKey: string,
723+
targetScope: string,
724+
scopeFilter: string[],
725+
): Promise<void> {
726+
const existing = await this.store.getById(matchId, scopeFilter);
727+
if (!existing) {
728+
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
729+
return;
730+
}
731+
732+
const now = Date.now();
733+
const existingMeta = parseSmartMetadata(existing.metadata, existing);
734+
const factKey =
735+
existingMeta.fact_key ?? deriveFactKey(candidate.category, candidate.abstract);
736+
const storeCategory = this.mapToStoreCategory(candidate.category);
737+
const created = await this.store.store({
738+
text: candidate.abstract,
739+
vector,
740+
category: storeCategory,
741+
scope: targetScope,
742+
importance: this.getDefaultImportance(candidate.category),
743+
metadata: stringifySmartMetadata(
744+
buildSmartMetadata(
745+
{
746+
text: candidate.abstract,
747+
category: storeCategory,
748+
},
749+
{
750+
l0_abstract: candidate.abstract,
751+
l1_overview: candidate.overview,
752+
l2_content: candidate.content,
753+
memory_category: candidate.category,
754+
tier: "working",
755+
access_count: 0,
756+
confidence: 0.7,
757+
source_session: sessionKey,
758+
valid_from: now,
759+
fact_key: factKey,
760+
supersedes: matchId,
761+
relations: appendRelation([], {
762+
type: "supersedes",
763+
targetId: matchId,
764+
}),
765+
},
766+
),
767+
),
768+
});
769+
770+
const invalidatedMetadata = buildSmartMetadata(existing, {
771+
fact_key: factKey,
772+
invalidated_at: now,
773+
superseded_by: created.id,
774+
relations: appendRelation(existingMeta.relations, {
775+
type: "superseded_by",
776+
targetId: created.id,
777+
}),
778+
});
779+
780+
await this.store.update(
781+
matchId,
782+
{ metadata: stringifySmartMetadata(invalidatedMetadata) },
783+
scopeFilter,
784+
);
785+
786+
this.log(
787+
`memory-pro: smart-extractor: superseded [${candidate.category}] ${matchId.slice(0, 8)} -> ${created.id.slice(0, 8)}`,
788+
);
789+
}
790+
643791
// --------------------------------------------------------------------------
644792
// Context-Aware Handlers (support / contextualize / contradict)
645793
// --------------------------------------------------------------------------

0 commit comments

Comments
 (0)