diff --git a/index.ts b/index.ts index 141bf2c8..91734f2a 100644 --- a/index.ts +++ b/index.ts @@ -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 @@ -156,6 +156,8 @@ interface PluginConfig { coreDecayFloor?: number; workingDecayFloor?: number; peripheralDecayFloor?: number; + knowledgeHalfLifeMultiplier?: number; + experienceHalfLifeMultiplier?: number; }; tier?: { coreAccessThreshold?: number; @@ -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; diff --git a/openclaw.plugin.json b/openclaw.plugin.json index bf274e03..9a7c9b9f 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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." } } }, @@ -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.", diff --git a/scripts/ci-test-manifest.mjs b/scripts/ci-test-manifest.mjs index 52594d41..12150de1 100644 --- a/scripts/ci-test-manifest.mjs +++ b/scripts/ci-test-manifest.mjs @@ -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) { diff --git a/scripts/verify-ci-test-manifest.mjs b/scripts/verify-ci-test-manifest.mjs index 35c2c167..fcb9c8c4 100644 --- a/scripts/verify-ci-test-manifest.mjs +++ b/scripts/verify-ci-test-manifest.mjs @@ -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) { diff --git a/src/decay-engine.ts b/src/decay-engine.ts index 9738ed8b..82eed1d9 100644 --- a/src/decay-engine.ts +++ b/src/decay-engine.ts @@ -8,7 +8,7 @@ * - Intrinsic: importance × confidence */ -import type { MemoryTier } from "./memory-categories.js"; +import type { MemoryTier, MemoryType } from "./memory-categories.js"; // ============================================================================ // Types @@ -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 = { @@ -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 { @@ -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 { @@ -120,6 +132,8 @@ export function createDecayEngine( coreDecayFloor, workingDecayFloor, peripheralDecayFloor, + knowledgeHalfLifeMultiplier, + experienceHalfLifeMultiplier, } = config; function getTierBeta(tier: MemoryTier): number { @@ -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); diff --git a/src/intent-analyzer.ts b/src/intent-analyzer.ts index 58c34281..0371a0b6 100644 --- a/src/intent-analyzer.ts +++ b/src/intent-analyzer.ts @@ -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[]; @@ -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; } // ============================================================================ @@ -49,6 +58,7 @@ interface IntentRule { patterns: RegExp[]; categories: MemoryCategoryIntent[]; depth: RecallDepth; + memoryType?: MemoryTypeIntent; } /** @@ -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 --- @@ -78,6 +104,7 @@ const INTENT_RULES: IntentRule[] = [ ], categories: ["decision", "fact"], depth: "l1", + memoryType: "experience", }, // --- Entity / People / Project queries --- @@ -92,6 +119,7 @@ const INTENT_RULES: IntentRule[] = [ ], categories: ["entity", "fact"], depth: "l1", + memoryType: "knowledge", }, // --- Event / Timeline queries --- @@ -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 --- @@ -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", }, ]; @@ -150,6 +180,7 @@ export function analyzeIntent(query: string): IntentSignal { depth: rule.depth, confidence: "high", label: rule.label, + memoryType: rule.memoryType, }; } } @@ -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. * diff --git a/src/memory-categories.ts b/src/memory-categories.ts index 7edc7f53..bd31a05f 100644 --- a/src/memory-categories.ts +++ b/src/memory-categories.ts @@ -41,6 +41,51 @@ export const APPEND_ONLY_CATEGORIES = new Set([ /** 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([ + "profile", + "preferences", + "entities", + "patterns", +]); + +const EXPERIENCE_CATEGORIES = new Set([ + "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; diff --git a/src/smart-metadata.ts b/src/smart-metadata.ts index da7e79cd..65be16f0 100644 --- a/src/smart-metadata.ts +++ b/src/smart-metadata.ts @@ -1,7 +1,9 @@ import { TEMPORAL_VERSIONED_CATEGORIES, + classifyMemoryType, type MemoryCategory, type MemoryTier, + type MemoryType, } from "./memory-categories.js"; import type { DecayableMemory } from "./decay-engine.js"; @@ -40,6 +42,7 @@ export interface SmartMemoryMetadata { l1_overview: string; l2_content: string; memory_category: MemoryCategory; + memory_type: MemoryType; tier: MemoryTier; access_count: number; confidence: number; @@ -74,6 +77,11 @@ export interface LifecycleMemory { createdAt: number; lastAccessedAt: number; temporalType?: "static" | "dynamic"; + memoryType?: MemoryType; +} + +function normalizeMemoryType(value: unknown): MemoryType | undefined { + return value === "knowledge" || value === "experience" ? value : undefined; } function clamp01(value: unknown, fallback: number): number { @@ -289,15 +297,21 @@ export function parseSmartMetadata( const memoryLayer = normalizeLayer( parsed.memory_layer ?? deriveDefaultLayer(source, memoryCategory, state), ); + const resolvedMemoryCategory: MemoryCategory = + typeof parsed.memory_category === "string" + ? (parsed.memory_category as MemoryCategory) + : memoryCategory; + const resolvedMemoryType: MemoryType = + normalizeMemoryType(parsed.memory_type) ?? + classifyMemoryType(resolvedMemoryCategory, entry.category); + const normalized: SmartMemoryMetadata = { ...parsed, l0_abstract: l0, l1_overview: normalizeText(parsed.l1_overview, defaultOverview(l0)), l2_content: l2, - memory_category: - typeof parsed.memory_category === "string" - ? (parsed.memory_category as MemoryCategory) - : memoryCategory, + memory_category: resolvedMemoryCategory, + memory_type: resolvedMemoryType, tier: normalizeTier(parsed.tier), access_count: clampCount(parsed.access_count, 0), confidence: clamp01(parsed.confidence, 0.7), @@ -346,6 +360,11 @@ export function buildSmartMetadata( typeof patch.memory_category === "string" ? patch.memory_category : base.memory_category; + const nextMemoryType: MemoryType = + normalizeMemoryType(patch.memory_type) ?? + (typeof patch.memory_category === "string" + ? classifyMemoryType(nextCategory, entry.category) + : base.memory_type); const nextSource = patch.source !== undefined ? normalizeSource(patch.source) : base.source; const nextState = @@ -366,6 +385,7 @@ export function buildSmartMetadata( l1_overview: normalizeText(patch.l1_overview, base.l1_overview), l2_content: normalizeText(patch.l2_content, base.l2_content), memory_category: nextCategory, + memory_type: nextMemoryType, tier: normalizeTier(patch.tier ?? base.tier), access_count: clampCount(patch.access_count, base.access_count), confidence: clamp01(patch.confidence, base.confidence), @@ -498,6 +518,7 @@ export function toLifecycleMemory( temporalType: metadata.memory_temporal_type === "dynamic" ? "dynamic" : metadata.memory_temporal_type === "static" ? "static" : undefined, + memoryType: metadata.memory_type, }; } @@ -528,6 +549,7 @@ export function getDecayableFromEntry( temporalType: meta.memory_temporal_type === "dynamic" ? "dynamic" : meta.memory_temporal_type === "static" ? "static" : undefined, + memoryType: meta.memory_type, }; return { memory, meta }; diff --git a/src/tools.ts b/src/tools.ts index 6b6e1beb..0e8187a2 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -21,6 +21,7 @@ import { parseSmartMetadata, stringifySmartMetadata, } from "./smart-metadata.js"; +import type { MemoryType } from "./memory-categories.js"; import { classifyTemporal, inferExpiry } from "./temporal-classifier.js"; import { TEMPORAL_VERSIONED_CATEGORIES } from "./memory-categories.js"; import { appendSelfImprovementEntry, ensureSelfImprovementLearningFiles } from "./self-improvement-files.js"; @@ -506,7 +507,7 @@ export function registerMemoryRecallTool( name: "memory_recall", label: "Memory Recall", description: - "Search through long-term memories using hybrid retrieval (vector + keyword search). Use when you need context about user preferences, past decisions, or previously discussed topics.", + "Search through long-term memories using hybrid retrieval (vector + keyword search). Use when you need context about user preferences, past decisions, or previously discussed topics. Pass type=\"knowledge\" for static/reference facts (profile, preferences, entities, patterns) or type=\"experience\" for past interactions and outcomes (events, cases).", parameters: Type.Object({ query: Type.String({ description: "Search query for finding relevant memories", @@ -532,6 +533,9 @@ export function registerMemoryRecallTool( }), ), category: Type.Optional(stringEnum(MEMORY_CATEGORIES)), + type: Type.Optional( + stringEnum(["knowledge", "experience", "both"] as const), + ), }), async execute(_toolCallId, params) { const { @@ -541,6 +545,7 @@ export function registerMemoryRecallTool( maxCharsPerItem = 180, scope, category, + type, } = params as { query: string; limit?: number; @@ -548,6 +553,7 @@ export function registerMemoryRecallTool( maxCharsPerItem?: number; scope?: string; category?: string; + type?: "knowledge" | "experience" | "both"; }; try { @@ -575,7 +581,7 @@ export function registerMemoryRecallTool( } } - const results = filterUserMdExclusiveRecallResults(await retrieveWithRetry(runtimeContext.retriever, { + const rawResults = filterUserMdExclusiveRecallResults(await retrieveWithRetry(runtimeContext.retriever, { query, limit: safeLimit, scopeFilter, @@ -583,10 +589,19 @@ export function registerMemoryRecallTool( source: "manual", }, () => runtimeContext.store.count()), runtimeContext.workspaceBoundary); + const typeFilter: MemoryType | undefined = + type === "knowledge" || type === "experience" ? type : undefined; + const results = typeFilter + ? rawResults.filter( + (r) => + parseSmartMetadata(r.entry.metadata, r.entry).memory_type === typeFilter, + ) + : rawResults; + if (results.length === 0) { return { content: [{ type: "text", text: "No relevant memories found." }], - details: { count: 0, query, scopes: scopeFilter }, + details: { count: 0, query, scopes: scopeFilter, type: typeFilter ?? "both" }, }; } @@ -646,6 +661,7 @@ export function registerMemoryRecallTool( scopes: scopeFilter, retrievalMode: runtimeContext.retriever.getConfig().mode, recallMode: includeFullText ? "full" : "summary", + type: typeFilter ?? "both", }, }; } catch (error) { diff --git a/test/knowledge-experience-decoupling.test.mjs b/test/knowledge-experience-decoupling.test.mjs new file mode 100644 index 00000000..80b8dbcf --- /dev/null +++ b/test/knowledge-experience-decoupling.test.mjs @@ -0,0 +1,230 @@ +/** + * Regression: knowledge vs experience decoupling (arxiv:2602.05665 §III-C, §V-E). + * + * Covers the four layers of the MVP: + * 1. classifyMemoryType maps 6-category + legacy inputs to K/E + * 2. parseSmartMetadata lazy-backfills memory_type on legacy entries + * 3. DecayEngine applies per-type half-life multipliers + * 4. analyzeIntent + applyMemoryTypeBoost route K/E queries correctly + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import Module from "node:module"; + +import jitiFactory from "jiti"; + +process.env.NODE_PATH = [ + process.env.NODE_PATH, + "/opt/homebrew/lib/node_modules/openclaw/node_modules", + "/opt/homebrew/lib/node_modules", +].filter(Boolean).join(":"); +Module._initPaths(); + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); + +const { classifyMemoryType } = jiti("../src/memory-categories.ts"); +const { parseSmartMetadata, buildSmartMetadata, stringifySmartMetadata } = + jiti("../src/smart-metadata.ts"); +const { createDecayEngine, DEFAULT_DECAY_CONFIG } = + jiti("../src/decay-engine.ts"); +const { analyzeIntent, applyMemoryTypeBoost } = + jiti("../src/intent-analyzer.ts"); + +// --------------------------------------------------------------------------- +// 1. classifyMemoryType +// --------------------------------------------------------------------------- + +describe("classifyMemoryType", () => { + it("maps the 6 semantic categories correctly", () => { + assert.equal(classifyMemoryType("profile"), "knowledge"); + assert.equal(classifyMemoryType("preferences"), "knowledge"); + assert.equal(classifyMemoryType("entities"), "knowledge"); + assert.equal(classifyMemoryType("patterns"), "knowledge"); + assert.equal(classifyMemoryType("events"), "experience"); + assert.equal(classifyMemoryType("cases"), "experience"); + }); + + it("falls back to legacy category when memory_category is missing", () => { + assert.equal(classifyMemoryType(undefined, "preference"), "knowledge"); + assert.equal(classifyMemoryType(undefined, "fact"), "knowledge"); + assert.equal(classifyMemoryType(undefined, "entity"), "knowledge"); + assert.equal(classifyMemoryType(undefined, "decision"), "experience"); + assert.equal(classifyMemoryType(undefined, "reflection"), "experience"); + }); + + it("defaults to knowledge when neither is informative", () => { + assert.equal(classifyMemoryType(undefined, undefined), "knowledge"); + assert.equal(classifyMemoryType("totally-unknown"), "knowledge"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. parseSmartMetadata backfill +// --------------------------------------------------------------------------- + +describe("parseSmartMetadata memory_type backfill", () => { + it("derives memory_type from memory_category when not stored", () => { + const raw = JSON.stringify({ + memory_category: "events", + l0_abstract: "shipped v1 last Tuesday", + }); + const meta = parseSmartMetadata(raw, { text: "shipped v1 last Tuesday" }); + assert.equal(meta.memory_type, "experience"); + }); + + it("derives memory_type from legacy category when memory_category also missing", () => { + const meta = parseSmartMetadata(undefined, { + text: "the user prefers tabs", + category: "preference", + }); + assert.equal(meta.memory_type, "knowledge"); + }); + + it("respects an explicit stored memory_type", () => { + const raw = JSON.stringify({ + memory_category: "profile", + memory_type: "experience", // deliberately mismatched + }); + const meta = parseSmartMetadata(raw, { text: "x" }); + assert.equal(meta.memory_type, "experience"); + }); + + it("buildSmartMetadata updates memory_type when memory_category changes", () => { + const before = buildSmartMetadata( + { text: "x", category: "preference" }, + { memory_category: "preferences" }, + ); + assert.equal(before.memory_type, "knowledge"); + + const after = buildSmartMetadata( + { text: "x", metadata: stringifySmartMetadata(before) }, + { memory_category: "cases" }, + ); + assert.equal(after.memory_type, "experience"); + }); +}); + +// --------------------------------------------------------------------------- +// 3. DecayEngine half-life multipliers +// --------------------------------------------------------------------------- + +describe("DecayEngine type-aware half-life", () => { + const now = Date.now(); + const dayMs = 86_400_000; + + const baseMemory = { + id: "m1", + importance: 0.5, + confidence: 1.0, + tier: "working", + accessCount: 0, + createdAt: now - 30 * dayMs, // 30 days old — exactly at base half-life + lastAccessedAt: now - 30 * dayMs, + }; + + it("knowledge memories decay slower than untyped", () => { + const engine = createDecayEngine(DEFAULT_DECAY_CONFIG); + const untyped = engine.score(baseMemory, now); + const knowledge = engine.score({ ...baseMemory, memoryType: "knowledge" }, now); + assert.ok( + knowledge.recency > untyped.recency, + `knowledge recency ${knowledge.recency} should exceed untyped ${untyped.recency}`, + ); + }); + + it("experience memories decay faster than untyped", () => { + const engine = createDecayEngine(DEFAULT_DECAY_CONFIG); + const untyped = engine.score(baseMemory, now); + const experience = engine.score({ ...baseMemory, memoryType: "experience" }, now); + assert.ok( + experience.recency < untyped.recency, + `experience recency ${experience.recency} should be below untyped ${untyped.recency}`, + ); + }); + + it("disabling multipliers (1.0/1.0) restores legacy behavior", () => { + const engine = createDecayEngine({ + ...DEFAULT_DECAY_CONFIG, + knowledgeHalfLifeMultiplier: 1.0, + experienceHalfLifeMultiplier: 1.0, + }); + const untyped = engine.score(baseMemory, now); + const knowledge = engine.score({ ...baseMemory, memoryType: "knowledge" }, now); + const experience = engine.score({ ...baseMemory, memoryType: "experience" }, now); + assert.equal(knowledge.recency, untyped.recency); + assert.equal(experience.recency, untyped.recency); + }); +}); + +// --------------------------------------------------------------------------- +// 4. analyzeIntent + applyMemoryTypeBoost +// --------------------------------------------------------------------------- + +describe("analyzeIntent memoryType routing", () => { + it("classifies English experience queries", () => { + const signal = analyzeIntent("last time we deployed to prod, what broke?"); + assert.equal(signal.memoryType, "experience"); + }); + + it("classifies Chinese experience queries", () => { + const signal = analyzeIntent("上次我们是怎么处理这个 bug 的?"); + assert.equal(signal.memoryType, "experience"); + }); + + it("classifies English knowledge queries", () => { + const signal = analyzeIntent("what is the auth API endpoint?"); + assert.equal(signal.memoryType, "knowledge"); + }); + + it("classifies Chinese knowledge queries", () => { + const signal = analyzeIntent("这个接口是怎么配置的?"); + assert.equal(signal.memoryType, "knowledge"); + }); + + it("returns undefined memoryType for broad queries", () => { + const signal = analyzeIntent("write a function to sort arrays"); + assert.equal(signal.memoryType, undefined); + }); +}); + +describe("applyMemoryTypeBoost", () => { + function buildEntry(type) { + return { + category: "fact", + metadata: JSON.stringify({ memory_type: type }), + }; + } + + it("boosts matching type and re-sorts", () => { + const results = [ + { entry: buildEntry("knowledge"), score: 0.80 }, + { entry: buildEntry("experience"), score: 0.75 }, + ]; + const signal = { categories: [], depth: "full", confidence: "high", label: "experience", memoryType: "experience" }; + const getType = (entry) => parseSmartMetadata(entry.metadata, entry).memory_type; + const boosted = applyMemoryTypeBoost(results, signal, getType); + assert.equal(boosted[0].entry.metadata.includes("experience"), true); + assert.ok(boosted[0].score > 0.75); + }); + + it("returns unchanged when intent has no memoryType", () => { + const results = [ + { entry: buildEntry("experience"), score: 0.80 }, + { entry: buildEntry("knowledge"), score: 0.60 }, + ]; + const signal = { categories: [], depth: "l0", confidence: "low", label: "broad" }; + const getType = (entry) => parseSmartMetadata(entry.metadata, entry).memory_type; + const out = applyMemoryTypeBoost(results, signal, getType); + assert.equal(out[0].score, 0.80); + assert.equal(out[1].score, 0.60); + }); + + it("caps boosted score at 1.0", () => { + const results = [{ entry: buildEntry("knowledge"), score: 0.98 }]; + const signal = { categories: [], depth: "l1", confidence: "high", label: "fact", memoryType: "knowledge" }; + const getType = (entry) => parseSmartMetadata(entry.metadata, entry).memory_type; + const boosted = applyMemoryTypeBoost(results, signal, getType); + assert.ok(boosted[0].score <= 1.0); + }); +});