Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import {
type AdmissionControlConfig,
type AdmissionRejectionAuditEntry,
} from "./src/admission-control.js";
import { analyzeIntent, applyCategoryBoost } from "./src/intent-analyzer.js";
import { analyzeIntent, applyCategoryBoost, applyMemoryTypeBoost } from "./src/intent-analyzer.js";

// ============================================================================
// Configuration & Types
Expand Down Expand Up @@ -156,6 +156,8 @@ interface PluginConfig {
coreDecayFloor?: number;
workingDecayFloor?: number;
peripheralDecayFloor?: number;
knowledgeHalfLifeMultiplier?: number;
experienceHalfLifeMultiplier?: number;
};
tier?: {
coreAccessThreshold?: number;
Expand Down Expand Up @@ -2377,8 +2379,16 @@ const memoryLanceDBProPlugin = {
return;
}

// Apply intent-based category boost for adaptive mode
const rankedResults = intent ? applyCategoryBoost(results, intent) : results;
// Apply intent-based category boost for adaptive mode, then the
// knowledge/experience type boost (arxiv:2602.05665 §V-E).
const categoryBoosted = intent ? applyCategoryBoost(results, intent) : results;
const rankedResults = intent
? applyMemoryTypeBoost(
categoryBoosted,
intent,
(entry) => parseSmartMetadata(entry.metadata, entry).memory_type,
)
: categoryBoosted;

// Filter out redundant memories based on session history
const minRepeated = config.autoRecallMinRepeated ?? 8;
Expand Down
24 changes: 24 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,20 @@
"minimum": 0,
"maximum": 1,
"default": 0.5
},
"knowledgeHalfLifeMultiplier": {
"type": "number",
"minimum": 0.1,
"maximum": 10,
"default": 3.0,
"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)."
},
"experienceHalfLifeMultiplier": {
"type": "number",
"minimum": 0.1,
"maximum": 10,
"default": 0.7,
"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."
}
}
},
Expand Down Expand Up @@ -1162,6 +1176,16 @@
"help": "Weibull beta for peripheral memories.",
"advanced": true
},
"decay.knowledgeHalfLifeMultiplier": {
"label": "Knowledge Half-Life Multiplier",
"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.",
"advanced": true
},
"decay.experienceHalfLifeMultiplier": {
"label": "Experience Half-Life Multiplier",
"help": "Multiplier applied to the half-life of experience-type memories (events, cases). <1 makes trajectory logs fade faster.",
"advanced": true
},
"tier.coreAccessThreshold": {
"label": "Core Access Threshold",
"help": "Minimum recall count before promoting to core.",
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-test-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const CI_TEST_MANIFEST = [
{ group: "core-regression", runner: "node", file: "test/store-serialization.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/access-tracker-retry.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/embedder-cache.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/knowledge-experience-decoupling.test.mjs", args: ["--test"] },
];

export function getEntriesForGroup(group) {
Expand Down
2 changes: 2 additions & 0 deletions scripts/verify-ci-test-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ const EXPECTED_BASELINE = [
{ group: "storage-and-schema", runner: "node", file: "test/cross-process-lock.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/preference-slots.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/is-latest-auto-supersede.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/hook-dedup-phase1.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/temporal-awareness.test.mjs", args: ["--test"] },
// Issue #598 regression tests
{ group: "core-regression", runner: "node", file: "test/store-serialization.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/access-tracker-retry.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/embedder-cache.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/knowledge-experience-decoupling.test.mjs", args: ["--test"] },
];

function fail(message) {
Expand Down
25 changes: 23 additions & 2 deletions src/decay-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - Intrinsic: importance × confidence
*/

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

// ============================================================================
// Types
Expand Down Expand Up @@ -43,6 +43,14 @@ export interface DecayConfig {
workingDecayFloor: number;
/** Decay floor for Peripheral memories (default: 0.5) */
peripheralDecayFloor: number;
/**
* Knowledge/experience half-life multipliers (arxiv:2602.05665 §V-E).
* Knowledge is static reference data — decays slower (3x).
* Experience is trajectory data — decays faster (0.7x), letting old logs fade.
* Set both to 1.0 to disable K/E decoupling.
*/
knowledgeHalfLifeMultiplier: number;
experienceHalfLifeMultiplier: number;
}

export const DEFAULT_DECAY_CONFIG: DecayConfig = {
Expand All @@ -59,6 +67,8 @@ export const DEFAULT_DECAY_CONFIG: DecayConfig = {
coreDecayFloor: 0.9,
workingDecayFloor: 0.7,
peripheralDecayFloor: 0.5,
knowledgeHalfLifeMultiplier: 3.0,
experienceHalfLifeMultiplier: 0.7,
};

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

export interface DecayEngine {
Expand Down Expand Up @@ -120,6 +132,8 @@ export function createDecayEngine(
coreDecayFloor,
workingDecayFloor,
peripheralDecayFloor,
knowledgeHalfLifeMultiplier,
experienceHalfLifeMultiplier,
} = config;

function getTierBeta(tier: MemoryTier): number {
Expand Down Expand Up @@ -155,7 +169,14 @@ export function createDecayEngine(
memory.accessCount > 0 ? memory.lastAccessedAt : memory.createdAt;
const daysSince = Math.max(0, (now - lastActive) / MS_PER_DAY);
// Dynamic memories decay 3x faster (1/3 half-life)
const baseHL = memory.temporalType === "dynamic" ? halfLife / 3 : halfLife;
const temporalHL = memory.temporalType === "dynamic" ? halfLife / 3 : halfLife;
const typeMultiplier =
memory.memoryType === "knowledge"
? knowledgeHalfLifeMultiplier
: memory.memoryType === "experience"
? experienceHalfLifeMultiplier
: 1.0;
const baseHL = temporalHL * typeMultiplier;
const effectiveHL = baseHL * Math.exp(mu * memory.importance);
const lambda = Math.LN2 / effectiveHL;
const beta = getTierBeta(memory.tier);
Expand Down
67 changes: 65 additions & 2 deletions src/intent-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export type MemoryCategoryIntent =

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

/**
* Knowledge vs Experience routing (arxiv:2602.05665 §V-E).
* Set from per-intent-rule hints; consumed by applyMemoryTypeBoost to
* promote the matching side of the split.
*/
export type MemoryTypeIntent = "knowledge" | "experience";

export interface IntentSignal {
/** Categories to prioritize (ordered by relevance). */
categories: MemoryCategoryIntent[];
Expand All @@ -38,6 +45,8 @@ export interface IntentSignal {
confidence: "high" | "medium" | "low";
/** Short label for logging. */
label: string;
/** Preferred memory type (knowledge vs experience), if the query leans one way. */
memoryType?: MemoryTypeIntent;
}

// ============================================================================
Expand All @@ -49,6 +58,7 @@ interface IntentRule {
patterns: RegExp[];
categories: MemoryCategoryIntent[];
depth: RecallDepth;
memoryType?: MemoryTypeIntent;
}

/**
Expand All @@ -66,6 +76,22 @@ const INTENT_RULES: IntentRule[] = [
],
categories: ["preference", "decision"],
depth: "l0",
memoryType: "knowledge",
},

// --- Experience / Trajectory queries (K-E decoupling, §V-E) ---
// Kept above "decision" so queries like "last time we decided" route to
// experience rather than generic decision-rationale.
{
label: "experience",
patterns: [
/\b(last time|remember when|recall when|when we (tried|did|built|shipped|deployed))\b/i,
/\b(previously|earlier (we|i)|we used to|ran into|encountered)\b/i,
/(上次|上回|之前|以前|记得|还记得|那次|那会|先前)/,
],
categories: ["decision", "fact"],
depth: "full",
memoryType: "experience",
},

// --- Decision / Rationale queries ---
Expand All @@ -78,6 +104,7 @@ const INTENT_RULES: IntentRule[] = [
],
categories: ["decision", "fact"],
depth: "l1",
memoryType: "experience",
},

// --- Entity / People / Project queries ---
Expand All @@ -92,6 +119,7 @@ const INTENT_RULES: IntentRule[] = [
],
categories: ["entity", "fact"],
depth: "l1",
memoryType: "knowledge",
},

// --- Event / Timeline queries ---
Expand All @@ -102,10 +130,11 @@ const INTENT_RULES: IntentRule[] = [
patterns: [
/\b(when did|what happened|timeline|incident|outage|deploy|release|shipped)\b/i,
/\b(last (week|month|time|sprint)|recently|yesterday|today)\b/i,
/(什么时候|发生了什么|时间线|事件|上线|部署|发布|上次|最近)/,
/(什么时候|发生了什么|时间线|事件|上线|部署|发布|最近)/,
],
categories: ["entity", "decision"],
depth: "full",
memoryType: "experience",
},

// --- Fact / Knowledge queries ---
Expand All @@ -114,10 +143,11 @@ const INTENT_RULES: IntentRule[] = [
patterns: [
/\b(how (does|do|to)|what (does|do|is)|explain|documentation|spec)\b/i,
/\b(config|configuration|setup|install|architecture|api|endpoint)\b/i,
/(怎么|如何|是什么|解释|文档|规范|配置|安装|架构|接口)/,
/(怎么|如何|是什么|解释|文档|规范|配置|安装|架构|接口|原理|规则)/,
],
categories: ["fact", "entity"],
depth: "l1",
memoryType: "knowledge",
},
];

Expand Down Expand Up @@ -150,6 +180,7 @@ export function analyzeIntent(query: string): IntentSignal {
depth: rule.depth,
confidence: "high",
label: rule.label,
memoryType: rule.memoryType,
};
}
}
Expand Down Expand Up @@ -195,6 +226,38 @@ export function applyCategoryBoost<
return boosted.sort((a, b) => b.score - a.score);
}

/**
* Boost results whose stored memory_type matches the intent's memoryType.
*
* Implements the retrieval half of knowledge-experience decoupling
* (arxiv:2602.05665 §V-E). Call AFTER applyCategoryBoost so both signals
* compound on the same result set.
*
* `getMemoryType` reads the stored type from entry metadata — accepting a
* callback keeps this module free of JSON-parsing and smart-metadata imports.
*/
export function applyMemoryTypeBoost<
T extends { entry: { metadata?: string }; score: number },
>(
results: T[],
intent: IntentSignal,
getMemoryType: (entry: T["entry"]) => "knowledge" | "experience" | undefined,
boostFactor = 1.15,
): T[] {
if (!intent.memoryType || intent.confidence === "low") return results;

const want = intent.memoryType;
const boosted = results.map((r) => {
const type = getMemoryType(r.entry);
if (type === want) {
return { ...r, score: Math.min(1, r.score * boostFactor) };
}
return r;
});

return boosted.sort((a, b) => b.score - a.score);
}

/**
* Format a memory entry for context injection at the specified depth level.
*
Expand Down
45 changes: 45 additions & 0 deletions src/memory-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,51 @@ export const APPEND_ONLY_CATEGORIES = new Set<MemoryCategory>([
/** Memory tier levels for lifecycle management. */
export type MemoryTier = "core" | "working" | "peripheral";

/**
* Knowledge vs Experience decoupling (see arxiv:2602.05665 §III-C, §V-E).
*
* - knowledge: passive, static, verifiable reference (profile / preferences / entities / patterns)
* - experience: trajectory log of interactions and outcomes (events / cases)
*/
export type MemoryType = "knowledge" | "experience";

const KNOWLEDGE_CATEGORIES = new Set<MemoryCategory>([
"profile",
"preferences",
"entities",
"patterns",
]);

const EXPERIENCE_CATEGORIES = new Set<MemoryCategory>([
"events",
"cases",
]);

const KNOWLEDGE_LEGACY = new Set(["preference", "fact", "entity"]);
const EXPERIENCE_LEGACY = new Set(["decision", "reflection"]);

/**
* Classify a memory as knowledge or experience.
* Prefers the 6-category `memory_category`; falls back to the legacy top-level category.
* Defaults to "knowledge" when neither is informative (conservative for decay: knowledge decays slower).
*/
export function classifyMemoryType(
memoryCategory: MemoryCategory | string | undefined,
legacyCategory?: string,
): MemoryType {
if (typeof memoryCategory === "string") {
const mc = memoryCategory as MemoryCategory;
if (KNOWLEDGE_CATEGORIES.has(mc)) return "knowledge";
if (EXPERIENCE_CATEGORIES.has(mc)) return "experience";
}
if (legacyCategory) {
const lc = legacyCategory.toLowerCase();
if (KNOWLEDGE_LEGACY.has(lc)) return "knowledge";
if (EXPERIENCE_LEGACY.has(lc)) return "experience";
}
return "knowledge";
}

/** A candidate memory extracted from conversation by LLM. */
export type CandidateMemory = {
category: MemoryCategory;
Expand Down
Loading
Loading