Skip to content

Commit d868fe3

Browse files
安闲静雅安闲静雅
authored andcommitted
feat: add temporal supersede semantics for mutable facts
1 parent 6e9fa4a commit d868fe3

File tree

7 files changed

+536
-22
lines changed

7 files changed

+536
-22
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
]
3636
},
3737
"scripts": {
38-
"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",
38+
"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",
3939
"test:openclaw-host": "node test/openclaw-host-functional.mjs",
4040
"version": "node scripts/sync-plugin-version.mjs openclaw.plugin.json package.json && git add openclaw.plugin.json"
4141
},

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: 18 additions & 4 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);
@@ -349,8 +360,9 @@ export class MemoryRetriever {
349360
const filtered = category
350361
? results.filter((r) => r.entry.category === category)
351362
: results;
363+
const active = this.filterActiveResults(filtered);
352364

353-
const mapped = filtered.map(
365+
const mapped = active.map(
354366
(result, index) =>
355367
({
356368
...result,
@@ -464,8 +476,9 @@ export class MemoryRetriever {
464476
const filtered = category
465477
? results.filter((r) => r.entry.category === category)
466478
: results;
479+
const active = this.filterActiveResults(filtered);
467480

468-
return filtered.map((result, index) => ({
481+
return active.map((result, index) => ({
469482
...result,
470483
rank: index + 1,
471484
}));
@@ -483,8 +496,9 @@ export class MemoryRetriever {
483496
const filtered = category
484497
? results.filter((r) => r.entry.category === category)
485498
: results;
499+
const active = this.filterActiveResults(filtered);
486500

487-
return filtered.map((result, index) => ({
501+
return active.map((result, index) => ({
488502
...result,
489503
rank: index + 1,
490504
}));

src/smart-extractor.ts

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,20 @@ 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+
buildSmartMetadata,
33+
deriveFactKey,
34+
isMemoryActiveAt,
35+
parseSmartMetadata,
36+
stringifySmartMetadata,
37+
parseSupportInfo,
38+
updateSupportStats,
39+
} from "./smart-metadata.js";
3140

3241
// ============================================================================
3342
// Constants
@@ -36,7 +45,36 @@ import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata, parseSu
3645
const SIMILARITY_THRESHOLD = 0.7;
3746
const MAX_SIMILAR_FOR_PROMPT = 3;
3847
const MAX_MEMORIES_PER_EXTRACTION = 5;
39-
const VALID_DECISIONS = new Set<string>(["create", "merge", "skip", "support", "contextualize", "contradict"]);
48+
const VALID_DECISIONS = new Set<string>([
49+
"create",
50+
"merge",
51+
"skip",
52+
"support",
53+
"contextualize",
54+
"contradict",
55+
"supersede",
56+
]);
57+
58+
function appendRelation(
59+
existing: unknown,
60+
relation: { type: string; targetId: string },
61+
): Array<{ type: string; targetId: string }> {
62+
const rows = Array.isArray(existing)
63+
? existing.filter(
64+
(item): item is { type: string; targetId: string } =>
65+
!!item &&
66+
typeof item === "object" &&
67+
typeof (item as { type?: unknown }).type === "string" &&
68+
typeof (item as { targetId?: unknown }).targetId === "string",
69+
)
70+
: [];
71+
72+
if (rows.some((item) => item.type === relation.type && item.targetId === relation.targetId)) {
73+
return rows;
74+
}
75+
76+
return [...rows, relation];
77+
}
4078

4179
// ============================================================================
4280
// Smart Extractor
@@ -358,6 +396,27 @@ export class SmartExtractor {
358396
stats.skipped++;
359397
break;
360398

399+
case "supersede":
400+
if (
401+
dedupResult.matchId &&
402+
TEMPORAL_VERSIONED_CATEGORIES.has(candidate.category)
403+
) {
404+
await this.handleSupersede(
405+
candidate,
406+
vector,
407+
dedupResult.matchId,
408+
sessionKey,
409+
targetScope,
410+
scopeFilter,
411+
);
412+
stats.created++;
413+
stats.superseded = (stats.superseded ?? 0) + 1;
414+
} else {
415+
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
416+
stats.created++;
417+
}
418+
break;
419+
361420
case "support":
362421
if (dedupResult.matchId) {
363422
await this.handleSupport(dedupResult.matchId, scopeFilter, { session: sessionKey, timestamp: Date.now() }, dedupResult.reason, dedupResult.contextLabel);
@@ -380,8 +439,24 @@ export class SmartExtractor {
380439

381440
case "contradict":
382441
if (dedupResult.matchId) {
383-
await this.handleContradict(candidate, vector, dedupResult.matchId, sessionKey, targetScope, scopeFilter, dedupResult.contextLabel);
384-
stats.created++;
442+
if (
443+
TEMPORAL_VERSIONED_CATEGORIES.has(candidate.category) &&
444+
(!dedupResult.contextLabel || dedupResult.contextLabel === "general")
445+
) {
446+
await this.handleSupersede(
447+
candidate,
448+
vector,
449+
dedupResult.matchId,
450+
sessionKey,
451+
targetScope,
452+
scopeFilter,
453+
);
454+
stats.created++;
455+
stats.superseded = (stats.superseded ?? 0) + 1;
456+
} else {
457+
await this.handleContradict(candidate, vector, dedupResult.matchId, sessionKey, targetScope, scopeFilter, dedupResult.contextLabel);
458+
stats.created++;
459+
}
385460
} else {
386461
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
387462
stats.created++;
@@ -409,13 +484,16 @@ export class SmartExtractor {
409484
SIMILARITY_THRESHOLD,
410485
scopeFilter,
411486
);
487+
const activeSimilar = similar.filter((result) =>
488+
isMemoryActiveAt(parseSmartMetadata(result.entry.metadata, result.entry)),
489+
);
412490

413-
if (similar.length === 0) {
491+
if (activeSimilar.length === 0) {
414492
return { decision: "create", reason: "No similar memories found" };
415493
}
416494

417495
// Stage 2: LLM decision
418-
return this.llmDedupDecision(candidate, similar);
496+
return this.llmDedupDecision(candidate, activeSimilar);
419497
}
420498

421499
private async llmDedupDecision(
@@ -476,7 +554,7 @@ export class SmartExtractor {
476554
return {
477555
decision,
478556
reason: data.reason ?? "",
479-
matchId: ["merge", "support", "contextualize", "contradict"].includes(decision) ? matchEntry?.entry.id : undefined,
557+
matchId: ["merge", "support", "contextualize", "contradict", "supersede"].includes(decision) ? matchEntry?.entry.id : undefined,
480558
contextLabel: typeof (data as any).context_label === "string" ? (data as any).context_label : undefined,
481559
};
482560
} catch (err) {
@@ -640,6 +718,83 @@ export class SmartExtractor {
640718
);
641719
}
642720

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

0 commit comments

Comments
 (0)