Skip to content

Commit 73e4b66

Browse files
win4rclaude
andcommitted
feat(memory): knowledge-experience decoupling
Implements the K/E decoupling pattern from arxiv:2602.05665 §III-C and §V-E: static reference data (profile, preferences, entities, patterns) and trajectory data (events, cases) now travel separately through decay and retrieval without splitting storage. Changes: - classifyMemoryType() in src/memory-categories.ts — deterministic 6-cat (+ legacy) → knowledge/experience mapping - memory_type threaded through SmartMemoryMetadata with lazy-backfill on legacy entries in parseSmartMetadata - DecayEngine applies per-type half-life multipliers (knowledge 3.0x, experience 0.7x by default; set both to 1.0 to disable) - analyzeIntent returns memoryType hint; new "experience" rule (last time / 上次 / 之前) routes trajectory queries - applyMemoryTypeBoost() promotes matching-type results after the existing category boost in the auto-recall path - memory_recall tool accepts type: "knowledge" | "experience" | "both" - openclaw.plugin.json schema exposes the two multipliers Backward-compat: old memories backfill on read; unconfigured callers keep working (type defaults to knowledge, which decays slower — safer than losing data). Also registers the pre-existing test/hook-dedup-phase1.test.mjs in verify-ci-test-manifest.mjs EXPECTED_BASELINE (was in the runtime manifest but missing from the baseline check, so `npm test` was failing the manifest-verify step on master). Verified: 197/197 tests pass (npm test, full suite). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 16ee3e4 commit 73e4b66

File tree

10 files changed

+448
-14
lines changed

10 files changed

+448
-14
lines changed

index.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ import {
7878
type AdmissionControlConfig,
7979
type AdmissionRejectionAuditEntry,
8080
} from "./src/admission-control.js";
81-
import { analyzeIntent, applyCategoryBoost } from "./src/intent-analyzer.js";
81+
import { analyzeIntent, applyCategoryBoost, applyMemoryTypeBoost } from "./src/intent-analyzer.js";
8282

8383
// ============================================================================
8484
// Configuration & Types
@@ -156,6 +156,8 @@ interface PluginConfig {
156156
coreDecayFloor?: number;
157157
workingDecayFloor?: number;
158158
peripheralDecayFloor?: number;
159+
knowledgeHalfLifeMultiplier?: number;
160+
experienceHalfLifeMultiplier?: number;
159161
};
160162
tier?: {
161163
coreAccessThreshold?: number;
@@ -2377,8 +2379,16 @@ const memoryLanceDBProPlugin = {
23772379
return;
23782380
}
23792381

2380-
// Apply intent-based category boost for adaptive mode
2381-
const rankedResults = intent ? applyCategoryBoost(results, intent) : results;
2382+
// Apply intent-based category boost for adaptive mode, then the
2383+
// knowledge/experience type boost (arxiv:2602.05665 §V-E).
2384+
const categoryBoosted = intent ? applyCategoryBoost(results, intent) : results;
2385+
const rankedResults = intent
2386+
? applyMemoryTypeBoost(
2387+
categoryBoosted,
2388+
intent,
2389+
(entry) => parseSmartMetadata(entry.metadata, entry).memory_type,
2390+
)
2391+
: categoryBoosted;
23822392

23832393
// Filter out redundant memories based on session history
23842394
const minRepeated = config.autoRecallMinRepeated ?? 8;

openclaw.plugin.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,20 @@
496496
"minimum": 0,
497497
"maximum": 1,
498498
"default": 0.5
499+
},
500+
"knowledgeHalfLifeMultiplier": {
501+
"type": "number",
502+
"minimum": 0.1,
503+
"maximum": 10,
504+
"default": 3.0,
505+
"description": "Half-life multiplier for knowledge-type memories (profile / preferences / entities / patterns). >1 makes knowledge decay slower. Set both multipliers to 1.0 to disable K/E decoupling (arxiv:2602.05665 §V-E)."
506+
},
507+
"experienceHalfLifeMultiplier": {
508+
"type": "number",
509+
"minimum": 0.1,
510+
"maximum": 10,
511+
"default": 0.7,
512+
"description": "Half-life multiplier for experience-type memories (events / cases). <1 makes trajectory logs decay faster. Set both multipliers to 1.0 to disable K/E decoupling."
499513
}
500514
}
501515
},
@@ -1162,6 +1176,16 @@
11621176
"help": "Weibull beta for peripheral memories.",
11631177
"advanced": true
11641178
},
1179+
"decay.knowledgeHalfLifeMultiplier": {
1180+
"label": "Knowledge Half-Life Multiplier",
1181+
"help": "Multiplier applied to the half-life of knowledge-type memories (profile, preferences, entities, patterns). >1 makes them decay slower. Set to 1.0 to disable K/E decoupling.",
1182+
"advanced": true
1183+
},
1184+
"decay.experienceHalfLifeMultiplier": {
1185+
"label": "Experience Half-Life Multiplier",
1186+
"help": "Multiplier applied to the half-life of experience-type memories (events, cases). <1 makes trajectory logs fade faster.",
1187+
"advanced": true
1188+
},
11651189
"tier.coreAccessThreshold": {
11661190
"label": "Core Access Threshold",
11671191
"help": "Minimum recall count before promoting to core.",

scripts/ci-test-manifest.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const CI_TEST_MANIFEST = [
4747
{ group: "core-regression", runner: "node", file: "test/store-serialization.test.mjs" },
4848
{ group: "core-regression", runner: "node", file: "test/access-tracker-retry.test.mjs" },
4949
{ group: "core-regression", runner: "node", file: "test/embedder-cache.test.mjs" },
50+
{ group: "core-regression", runner: "node", file: "test/knowledge-experience-decoupling.test.mjs", args: ["--test"] },
5051
];
5152

5253
export function getEntriesForGroup(group) {

scripts/verify-ci-test-manifest.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ const EXPECTED_BASELINE = [
4242
{ group: "storage-and-schema", runner: "node", file: "test/cross-process-lock.test.mjs", args: ["--test"] },
4343
{ group: "core-regression", runner: "node", file: "test/preference-slots.test.mjs", args: ["--test"] },
4444
{ group: "core-regression", runner: "node", file: "test/is-latest-auto-supersede.test.mjs" },
45+
{ group: "core-regression", runner: "node", file: "test/hook-dedup-phase1.test.mjs", args: ["--test"] },
4546
{ group: "core-regression", runner: "node", file: "test/temporal-awareness.test.mjs", args: ["--test"] },
4647
// Issue #598 regression tests
4748
{ group: "core-regression", runner: "node", file: "test/store-serialization.test.mjs" },
4849
{ group: "core-regression", runner: "node", file: "test/access-tracker-retry.test.mjs" },
4950
{ group: "core-regression", runner: "node", file: "test/embedder-cache.test.mjs" },
51+
{ group: "core-regression", runner: "node", file: "test/knowledge-experience-decoupling.test.mjs", args: ["--test"] },
5052
];
5153

5254
function fail(message) {

src/decay-engine.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* - Intrinsic: importance × confidence
99
*/
1010

11-
import type { MemoryTier } from "./memory-categories.js";
11+
import type { MemoryTier, MemoryType } from "./memory-categories.js";
1212

1313
// ============================================================================
1414
// Types
@@ -43,6 +43,14 @@ export interface DecayConfig {
4343
workingDecayFloor: number;
4444
/** Decay floor for Peripheral memories (default: 0.5) */
4545
peripheralDecayFloor: number;
46+
/**
47+
* Knowledge/experience half-life multipliers (arxiv:2602.05665 §V-E).
48+
* Knowledge is static reference data — decays slower (3x).
49+
* Experience is trajectory data — decays faster (0.7x), letting old logs fade.
50+
* Set both to 1.0 to disable K/E decoupling.
51+
*/
52+
knowledgeHalfLifeMultiplier: number;
53+
experienceHalfLifeMultiplier: number;
4654
}
4755

4856
export const DEFAULT_DECAY_CONFIG: DecayConfig = {
@@ -59,6 +67,8 @@ export const DEFAULT_DECAY_CONFIG: DecayConfig = {
5967
coreDecayFloor: 0.9,
6068
workingDecayFloor: 0.7,
6169
peripheralDecayFloor: 0.5,
70+
knowledgeHalfLifeMultiplier: 3.0,
71+
experienceHalfLifeMultiplier: 0.7,
6272
};
6373

6474
export interface DecayScore {
@@ -80,6 +90,8 @@ export interface DecayableMemory {
8090
lastAccessedAt: number;
8191
/** Temporal classification: "dynamic" memories decay 3x faster. */
8292
temporalType?: "static" | "dynamic";
93+
/** Knowledge-vs-experience classification; applies a half-life multiplier when set. */
94+
memoryType?: MemoryType;
8395
}
8496

8597
export interface DecayEngine {
@@ -120,6 +132,8 @@ export function createDecayEngine(
120132
coreDecayFloor,
121133
workingDecayFloor,
122134
peripheralDecayFloor,
135+
knowledgeHalfLifeMultiplier,
136+
experienceHalfLifeMultiplier,
123137
} = config;
124138

125139
function getTierBeta(tier: MemoryTier): number {
@@ -155,7 +169,14 @@ export function createDecayEngine(
155169
memory.accessCount > 0 ? memory.lastAccessedAt : memory.createdAt;
156170
const daysSince = Math.max(0, (now - lastActive) / MS_PER_DAY);
157171
// Dynamic memories decay 3x faster (1/3 half-life)
158-
const baseHL = memory.temporalType === "dynamic" ? halfLife / 3 : halfLife;
172+
const temporalHL = memory.temporalType === "dynamic" ? halfLife / 3 : halfLife;
173+
const typeMultiplier =
174+
memory.memoryType === "knowledge"
175+
? knowledgeHalfLifeMultiplier
176+
: memory.memoryType === "experience"
177+
? experienceHalfLifeMultiplier
178+
: 1.0;
179+
const baseHL = temporalHL * typeMultiplier;
159180
const effectiveHL = baseHL * Math.exp(mu * memory.importance);
160181
const lambda = Math.LN2 / effectiveHL;
161182
const beta = getTierBeta(memory.tier);

src/intent-analyzer.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export type MemoryCategoryIntent =
2929

3030
export type RecallDepth = "l0" | "l1" | "full";
3131

32+
/**
33+
* Knowledge vs Experience routing (arxiv:2602.05665 §V-E).
34+
* Set from per-intent-rule hints; consumed by applyMemoryTypeBoost to
35+
* promote the matching side of the split.
36+
*/
37+
export type MemoryTypeIntent = "knowledge" | "experience";
38+
3239
export interface IntentSignal {
3340
/** Categories to prioritize (ordered by relevance). */
3441
categories: MemoryCategoryIntent[];
@@ -38,6 +45,8 @@ export interface IntentSignal {
3845
confidence: "high" | "medium" | "low";
3946
/** Short label for logging. */
4047
label: string;
48+
/** Preferred memory type (knowledge vs experience), if the query leans one way. */
49+
memoryType?: MemoryTypeIntent;
4150
}
4251

4352
// ============================================================================
@@ -49,6 +58,7 @@ interface IntentRule {
4958
patterns: RegExp[];
5059
categories: MemoryCategoryIntent[];
5160
depth: RecallDepth;
61+
memoryType?: MemoryTypeIntent;
5262
}
5363

5464
/**
@@ -66,6 +76,22 @@ const INTENT_RULES: IntentRule[] = [
6676
],
6777
categories: ["preference", "decision"],
6878
depth: "l0",
79+
memoryType: "knowledge",
80+
},
81+
82+
// --- Experience / Trajectory queries (K-E decoupling, §V-E) ---
83+
// Kept above "decision" so queries like "last time we decided" route to
84+
// experience rather than generic decision-rationale.
85+
{
86+
label: "experience",
87+
patterns: [
88+
/\b(last time|remember when|recall when|when we (tried|did|built|shipped|deployed))\b/i,
89+
/\b(previously|earlier (we|i)|we used to|ran into|encountered)\b/i,
90+
/(||||||||)/,
91+
],
92+
categories: ["decision", "fact"],
93+
depth: "full",
94+
memoryType: "experience",
6995
},
7096

7197
// --- Decision / Rationale queries ---
@@ -78,6 +104,7 @@ const INTENT_RULES: IntentRule[] = [
78104
],
79105
categories: ["decision", "fact"],
80106
depth: "l1",
107+
memoryType: "experience",
81108
},
82109

83110
// --- Entity / People / Project queries ---
@@ -92,6 +119,7 @@ const INTENT_RULES: IntentRule[] = [
92119
],
93120
categories: ["entity", "fact"],
94121
depth: "l1",
122+
memoryType: "knowledge",
95123
},
96124

97125
// --- Event / Timeline queries ---
@@ -102,10 +130,11 @@ const INTENT_RULES: IntentRule[] = [
102130
patterns: [
103131
/\b(when did|what happened|timeline|incident|outage|deploy|release|shipped)\b/i,
104132
/\b(last (week|month|time|sprint)|recently|yesterday|today)\b/i,
105-
/(||线||线||||)/,
133+
/(||线||线|||)/,
106134
],
107135
categories: ["entity", "decision"],
108136
depth: "full",
137+
memoryType: "experience",
109138
},
110139

111140
// --- Fact / Knowledge queries ---
@@ -114,10 +143,11 @@ const INTENT_RULES: IntentRule[] = [
114143
patterns: [
115144
/\b(how (does|do|to)|what (does|do|is)|explain|documentation|spec)\b/i,
116145
/\b(config|configuration|setup|install|architecture|api|endpoint)\b/i,
117-
/(|||||||||)/,
146+
/(|||||||||||)/,
118147
],
119148
categories: ["fact", "entity"],
120149
depth: "l1",
150+
memoryType: "knowledge",
121151
},
122152
];
123153

@@ -150,6 +180,7 @@ export function analyzeIntent(query: string): IntentSignal {
150180
depth: rule.depth,
151181
confidence: "high",
152182
label: rule.label,
183+
memoryType: rule.memoryType,
153184
};
154185
}
155186
}
@@ -195,6 +226,38 @@ export function applyCategoryBoost<
195226
return boosted.sort((a, b) => b.score - a.score);
196227
}
197228

229+
/**
230+
* Boost results whose stored memory_type matches the intent's memoryType.
231+
*
232+
* Implements the retrieval half of knowledge-experience decoupling
233+
* (arxiv:2602.05665 §V-E). Call AFTER applyCategoryBoost so both signals
234+
* compound on the same result set.
235+
*
236+
* `getMemoryType` reads the stored type from entry metadata — accepting a
237+
* callback keeps this module free of JSON-parsing and smart-metadata imports.
238+
*/
239+
export function applyMemoryTypeBoost<
240+
T extends { entry: { metadata?: string }; score: number },
241+
>(
242+
results: T[],
243+
intent: IntentSignal,
244+
getMemoryType: (entry: T["entry"]) => "knowledge" | "experience" | undefined,
245+
boostFactor = 1.15,
246+
): T[] {
247+
if (!intent.memoryType || intent.confidence === "low") return results;
248+
249+
const want = intent.memoryType;
250+
const boosted = results.map((r) => {
251+
const type = getMemoryType(r.entry);
252+
if (type === want) {
253+
return { ...r, score: Math.min(1, r.score * boostFactor) };
254+
}
255+
return r;
256+
});
257+
258+
return boosted.sort((a, b) => b.score - a.score);
259+
}
260+
198261
/**
199262
* Format a memory entry for context injection at the specified depth level.
200263
*

src/memory-categories.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,51 @@ export const APPEND_ONLY_CATEGORIES = new Set<MemoryCategory>([
4141
/** Memory tier levels for lifecycle management. */
4242
export type MemoryTier = "core" | "working" | "peripheral";
4343

44+
/**
45+
* Knowledge vs Experience decoupling (see arxiv:2602.05665 §III-C, §V-E).
46+
*
47+
* - knowledge: passive, static, verifiable reference (profile / preferences / entities / patterns)
48+
* - experience: trajectory log of interactions and outcomes (events / cases)
49+
*/
50+
export type MemoryType = "knowledge" | "experience";
51+
52+
const KNOWLEDGE_CATEGORIES = new Set<MemoryCategory>([
53+
"profile",
54+
"preferences",
55+
"entities",
56+
"patterns",
57+
]);
58+
59+
const EXPERIENCE_CATEGORIES = new Set<MemoryCategory>([
60+
"events",
61+
"cases",
62+
]);
63+
64+
const KNOWLEDGE_LEGACY = new Set(["preference", "fact", "entity"]);
65+
const EXPERIENCE_LEGACY = new Set(["decision", "reflection"]);
66+
67+
/**
68+
* Classify a memory as knowledge or experience.
69+
* Prefers the 6-category `memory_category`; falls back to the legacy top-level category.
70+
* Defaults to "knowledge" when neither is informative (conservative for decay: knowledge decays slower).
71+
*/
72+
export function classifyMemoryType(
73+
memoryCategory: MemoryCategory | string | undefined,
74+
legacyCategory?: string,
75+
): MemoryType {
76+
if (typeof memoryCategory === "string") {
77+
const mc = memoryCategory as MemoryCategory;
78+
if (KNOWLEDGE_CATEGORIES.has(mc)) return "knowledge";
79+
if (EXPERIENCE_CATEGORIES.has(mc)) return "experience";
80+
}
81+
if (legacyCategory) {
82+
const lc = legacyCategory.toLowerCase();
83+
if (KNOWLEDGE_LEGACY.has(lc)) return "knowledge";
84+
if (EXPERIENCE_LEGACY.has(lc)) return "experience";
85+
}
86+
return "knowledge";
87+
}
88+
4489
/** A candidate memory extracted from conversation by LLM. */
4590
export type CandidateMemory = {
4691
category: MemoryCategory;

0 commit comments

Comments
 (0)