From 6b3e0710f4a05135fa57798dcc3ff9668e6a061a Mon Sep 17 00:00:00 2001 From: Rodrigo Uroz Date: Tue, 10 Feb 2026 08:42:22 -0300 Subject: [PATCH] feat(memory): Add opt-in temporal decay for hybrid search scoring Exponential decay (half-life configurable, default 30 days) applied before MMR re-ranking. Dated daily files (memory/YYYY-MM-DD.md) use filename date; evergreen files (MEMORY.md, topic files) are not decayed; other sources fall back to file mtime. Config: memorySearch.query.hybrid.temporalDecay.{enabled, halfLifeDays} Default: disabled (backwards compatible, opt-in). --- CHANGELOG.md | 1 + docs/concepts/memory.md | 138 ++++- src/agents/memory-search.ts | 50 ++ src/config/schema.ts | 751 +++++++++++++++++++++++++ src/config/types.tools.ts | 14 + src/config/zod-schema.agent-runtime.ts | 14 + src/memory/hybrid.test.ts | 8 +- src/memory/hybrid.ts | 40 +- src/memory/manager.ts | 16 +- src/memory/{__tests__ => }/mmr.test.ts | 2 +- src/memory/mmr.ts | 34 +- src/memory/temporal-decay.test.ts | 173 ++++++ src/memory/temporal-decay.ts | 166 ++++++ 13 files changed, 1372 insertions(+), 35 deletions(-) rename src/memory/{__tests__ => }/mmr.test.ts (99%) create mode 100644 src/memory/temporal-decay.test.ts create mode 100644 src/memory/temporal-decay.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a263eba6b9..0e69f825c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. - Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow. - Memory: add MMR (Maximal Marginal Relevance) re-ranking for hybrid search diversity. Configurable via `memorySearch.query.hybrid.mmr`. Thanks @rodrigouroz. +- Memory: add opt-in temporal decay for hybrid search scoring, with configurable half-life via `memorySearch.query.hybrid.temporalDecay`. Thanks @rodrigouroz. ### Fixes diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index cc9c76e761..a6c3ef2840 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -353,6 +353,7 @@ agents: { ``` Tools: + - `memory_search` — returns snippets with file + line ranges. - `memory_get` — read memory file content by path. @@ -428,23 +429,136 @@ This isn't "IR-theory perfect", but it's simple, fast, and tends to improve reca If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization (min/max or z-score) before mixing. +#### Post-processing pipeline + +After merging vector and keyword scores, two optional post-processing stages +refine the result list before it reaches the agent: + +``` +Vector + Keyword → Weighted Merge → Temporal Decay → Sort → MMR → Top-K Results +``` + +Both stages are **off by default** and can be enabled independently. + #### MMR re-ranking (diversity) When hybrid search returns results, multiple chunks may contain similar or overlapping content. +For example, searching for "home network setup" might return five nearly identical snippets +from different daily notes that all mention the same router configuration. + **MMR (Maximal Marginal Relevance)** re-ranks the results to balance relevance with diversity, -ensuring the top results aren't all saying the same thing. +ensuring the top results cover different aspects of the query instead of repeating the same information. How it works: + 1. Results are scored by their original relevance (vector + BM25 weighted score). -2. MMR iteratively selects results that maximize: `λ × relevance − (1−λ) × similarity_to_selected`. -3. Already-selected results are penalized via Jaccard text similarity. +2. MMR iteratively selects results that maximize: `λ × relevance − (1−λ) × max_similarity_to_selected`. +3. Similarity between results is measured using Jaccard text similarity on tokenized content. The `lambda` parameter controls the trade-off: + - `lambda = 1.0` → pure relevance (no diversity penalty) - `lambda = 0.0` → maximum diversity (ignores relevance) - Default: `0.7` (balanced, slight relevance bias) -Config: +**Example — query: "home network setup"** + +Given these memory files: + +``` +memory/2026-02-10.md → "Configured Omada router, set VLAN 10 for IoT devices" +memory/2026-02-08.md → "Configured Omada router, moved IoT to VLAN 10" +memory/2026-02-05.md → "Set up AdGuard DNS on 192.168.10.2" +memory/network.md → "Router: Omada ER605, AdGuard: 192.168.10.2, VLAN 10: IoT" +``` + +Without MMR — top 3 results: + +``` +1. memory/2026-02-10.md (score: 0.92) ← router + VLAN +2. memory/2026-02-08.md (score: 0.89) ← router + VLAN (near-duplicate!) +3. memory/network.md (score: 0.85) ← reference doc +``` + +With MMR (λ=0.7) — top 3 results: + +``` +1. memory/2026-02-10.md (score: 0.92) ← router + VLAN +2. memory/network.md (score: 0.85) ← reference doc (diverse!) +3. memory/2026-02-05.md (score: 0.78) ← AdGuard DNS (diverse!) +``` + +The near-duplicate from Feb 8 drops out, and the agent gets three distinct pieces of information. + +**When to enable:** If you notice `memory_search` returning redundant or near-duplicate snippets, +especially with daily notes that often repeat similar information across days. + +#### Temporal decay (recency boost) + +Agents with daily notes accumulate hundreds of dated files over time. Without decay, +a well-worded note from six months ago can outrank yesterday's update on the same topic. + +**Temporal decay** applies an exponential multiplier to scores based on the age of each result, +so recent memories naturally rank higher while old ones fade: + +``` +decayedScore = score × e^(-λ × ageInDays) +``` + +where `λ = ln(2) / halfLifeDays`. + +With the default half-life of 30 days: + +- Today's notes: **100%** of original score +- 7 days ago: **~84%** +- 30 days ago: **50%** +- 90 days ago: **12.5%** +- 180 days ago: **~1.6%** + +**Evergreen files are never decayed:** + +- `MEMORY.md` (root memory file) +- Non-dated files in `memory/` (e.g., `memory/projects.md`, `memory/network.md`) +- These contain durable reference information that should always rank normally. + +**Dated daily files** (`memory/YYYY-MM-DD.md`) use the date extracted from the filename. +Other sources (e.g., session transcripts) fall back to file modification time (`mtime`). + +**Example — query: "what's Rod's work schedule?"** + +Given these memory files (today is Feb 10): + +``` +memory/2025-09-15.md → "Rod works Mon-Fri, standup at 10am, pairing at 2pm" (148 days old) +memory/2026-02-10.md → "Rod has standup at 14:15, 1:1 with Zeb at 14:45" (today) +memory/2026-02-03.md → "Rod started new team, standup moved to 14:15" (7 days old) +``` + +Without decay: + +``` +1. memory/2025-09-15.md (score: 0.91) ← best semantic match, but stale! +2. memory/2026-02-10.md (score: 0.82) +3. memory/2026-02-03.md (score: 0.80) +``` + +With decay (halfLife=30): + +``` +1. memory/2026-02-10.md (score: 0.82 × 1.00 = 0.82) ← today, no decay +2. memory/2026-02-03.md (score: 0.80 × 0.85 = 0.68) ← 7 days, mild decay +3. memory/2025-09-15.md (score: 0.91 × 0.03 = 0.03) ← 148 days, nearly gone +``` + +The stale September note drops to the bottom despite having the best raw semantic match. + +**When to enable:** If your agent has months of daily notes and you find that old, +stale information outranks recent context. A half-life of 30 days works well for +daily-note-heavy workflows; increase it (e.g., 90 days) if you reference older notes frequently. + +#### Configuration + +Both features are configured under `memorySearch.query.hybrid`: ```json5 agents: { @@ -456,9 +570,15 @@ agents: { vectorWeight: 0.7, textWeight: 0.3, candidateMultiplier: 4, + // Diversity: reduce redundant results mmr: { - enabled: true, - lambda: 0.7 + enabled: true, // default: false + lambda: 0.7 // 0 = max diversity, 1 = max relevance + }, + // Recency: boost newer memories + temporalDecay: { + enabled: true, // default: false + halfLifeDays: 30 // score halves every 30 days } } } @@ -467,6 +587,12 @@ agents: { } ``` +You can enable either feature independently: + +- **MMR only** — useful when you have many similar notes but age doesn't matter. +- **Temporal decay only** — useful when recency matters but your results are already diverse. +- **Both** — recommended for agents with large, long-running daily note histories. + ### Embedding cache OpenClaw can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text. diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index df8e9f64b6..7c4445ab32 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -62,6 +62,14 @@ export type ResolvedMemorySearchConfig = { vectorWeight: number; textWeight: number; candidateMultiplier: number; + mmr: { + enabled: boolean; + lambda: number; + }; + temporalDecay: { + enabled: boolean; + halfLifeDays: number; + }; }; }; cache: { @@ -84,6 +92,10 @@ const DEFAULT_HYBRID_ENABLED = true; const DEFAULT_HYBRID_VECTOR_WEIGHT = 0.7; const DEFAULT_HYBRID_TEXT_WEIGHT = 0.3; const DEFAULT_HYBRID_CANDIDATE_MULTIPLIER = 4; +const DEFAULT_MMR_ENABLED = false; +const DEFAULT_MMR_LAMBDA = 0.7; +const DEFAULT_TEMPORAL_DECAY_ENABLED = false; +const DEFAULT_TEMPORAL_DECAY_HALF_LIFE_DAYS = 30; const DEFAULT_CACHE_ENABLED = true; const DEFAULT_SOURCES: Array<"memory" | "sessions"> = ["memory"]; @@ -236,6 +248,26 @@ function mergeConfig( overrides?.query?.hybrid?.candidateMultiplier ?? defaults?.query?.hybrid?.candidateMultiplier ?? DEFAULT_HYBRID_CANDIDATE_MULTIPLIER, + mmr: { + enabled: + overrides?.query?.hybrid?.mmr?.enabled ?? + defaults?.query?.hybrid?.mmr?.enabled ?? + DEFAULT_MMR_ENABLED, + lambda: + overrides?.query?.hybrid?.mmr?.lambda ?? + defaults?.query?.hybrid?.mmr?.lambda ?? + DEFAULT_MMR_LAMBDA, + }, + temporalDecay: { + enabled: + overrides?.query?.hybrid?.temporalDecay?.enabled ?? + defaults?.query?.hybrid?.temporalDecay?.enabled ?? + DEFAULT_TEMPORAL_DECAY_ENABLED, + halfLifeDays: + overrides?.query?.hybrid?.temporalDecay?.halfLifeDays ?? + defaults?.query?.hybrid?.temporalDecay?.halfLifeDays ?? + DEFAULT_TEMPORAL_DECAY_HALF_LIFE_DAYS, + }, }; const cache = { enabled: overrides?.cache?.enabled ?? defaults?.cache?.enabled ?? DEFAULT_CACHE_ENABLED, @@ -250,6 +282,14 @@ function mergeConfig( const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT; const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT; const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20); + const temporalDecayHalfLifeDays = Math.max( + 1, + Math.floor( + Number.isFinite(hybrid.temporalDecay.halfLifeDays) + ? hybrid.temporalDecay.halfLifeDays + : DEFAULT_TEMPORAL_DECAY_HALF_LIFE_DAYS, + ), + ); const deltaBytes = clampInt(sync.sessions.deltaBytes, 0, Number.MAX_SAFE_INTEGER); const deltaMessages = clampInt(sync.sessions.deltaMessages, 0, Number.MAX_SAFE_INTEGER); return { @@ -281,6 +321,16 @@ function mergeConfig( vectorWeight: normalizedVectorWeight, textWeight: normalizedTextWeight, candidateMultiplier, + mmr: { + enabled: Boolean(hybrid.mmr.enabled), + lambda: Number.isFinite(hybrid.mmr.lambda) + ? Math.max(0, Math.min(1, hybrid.mmr.lambda)) + : DEFAULT_MMR_LAMBDA, + }, + temporalDecay: { + enabled: Boolean(hybrid.temporalDecay.enabled), + halfLifeDays: temporalDecayHalfLifeDays, + }, }, }, cache: { diff --git a/src/config/schema.ts b/src/config/schema.ts index f3ae6bf2fa..f4aca4a265 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -10,6 +10,757 @@ export type ConfigSchema = ReturnType; type JsonSchemaNode = Record; +const GROUP_LABELS: Record = { + wizard: "Wizard", + update: "Update", + diagnostics: "Diagnostics", + logging: "Logging", + gateway: "Gateway", + nodeHost: "Node Host", + agents: "Agents", + tools: "Tools", + bindings: "Bindings", + audio: "Audio", + models: "Models", + messages: "Messages", + commands: "Commands", + session: "Session", + cron: "Cron", + hooks: "Hooks", + ui: "UI", + browser: "Browser", + talk: "Talk", + channels: "Messaging Channels", + skills: "Skills", + plugins: "Plugins", + discovery: "Discovery", + presence: "Presence", + voicewake: "Voice Wake", +}; + +const GROUP_ORDER: Record = { + wizard: 20, + update: 25, + diagnostics: 27, + gateway: 30, + nodeHost: 35, + agents: 40, + tools: 50, + bindings: 55, + audio: 60, + models: 70, + messages: 80, + commands: 85, + session: 90, + cron: 100, + hooks: 110, + ui: 120, + browser: 130, + talk: 140, + channels: 150, + skills: 200, + plugins: 205, + discovery: 210, + presence: 220, + voicewake: 230, + logging: 900, +}; + +const FIELD_LABELS: Record = { + "meta.lastTouchedVersion": "Config Last Touched Version", + "meta.lastTouchedAt": "Config Last Touched At", + "update.channel": "Update Channel", + "update.checkOnStart": "Update Check on Start", + "diagnostics.enabled": "Diagnostics Enabled", + "diagnostics.flags": "Diagnostics Flags", + "diagnostics.otel.enabled": "OpenTelemetry Enabled", + "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", + "diagnostics.otel.protocol": "OpenTelemetry Protocol", + "diagnostics.otel.headers": "OpenTelemetry Headers", + "diagnostics.otel.serviceName": "OpenTelemetry Service Name", + "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", + "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", + "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", + "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", + "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", + "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", + "diagnostics.cacheTrace.filePath": "Cache Trace File Path", + "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", + "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", + "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", + "agents.list.*.identity.avatar": "Identity Avatar", + "agents.list.*.skills": "Agent Skill Filter", + "gateway.remote.url": "Remote Gateway URL", + "gateway.remote.sshTarget": "Remote Gateway SSH Target", + "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", + "gateway.remote.token": "Remote Gateway Token", + "gateway.remote.password": "Remote Gateway Password", + "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", + "gateway.auth.token": "Gateway Token", + "gateway.auth.password": "Gateway Password", + "tools.media.image.enabled": "Enable Image Understanding", + "tools.media.image.maxBytes": "Image Understanding Max Bytes", + "tools.media.image.maxChars": "Image Understanding Max Chars", + "tools.media.image.prompt": "Image Understanding Prompt", + "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", + "tools.media.image.attachments": "Image Understanding Attachment Policy", + "tools.media.image.models": "Image Understanding Models", + "tools.media.image.scope": "Image Understanding Scope", + "tools.media.models": "Media Understanding Shared Models", + "tools.media.concurrency": "Media Understanding Concurrency", + "tools.media.audio.enabled": "Enable Audio Understanding", + "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", + "tools.media.audio.maxChars": "Audio Understanding Max Chars", + "tools.media.audio.prompt": "Audio Understanding Prompt", + "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", + "tools.media.audio.language": "Audio Understanding Language", + "tools.media.audio.attachments": "Audio Understanding Attachment Policy", + "tools.media.audio.models": "Audio Understanding Models", + "tools.media.audio.scope": "Audio Understanding Scope", + "tools.media.video.enabled": "Enable Video Understanding", + "tools.media.video.maxBytes": "Video Understanding Max Bytes", + "tools.media.video.maxChars": "Video Understanding Max Chars", + "tools.media.video.prompt": "Video Understanding Prompt", + "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", + "tools.media.video.attachments": "Video Understanding Attachment Policy", + "tools.media.video.models": "Video Understanding Models", + "tools.media.video.scope": "Video Understanding Scope", + "tools.links.enabled": "Enable Link Understanding", + "tools.links.maxLinks": "Link Understanding Max Links", + "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", + "tools.links.models": "Link Understanding Models", + "tools.links.scope": "Link Understanding Scope", + "tools.profile": "Tool Profile", + "tools.alsoAllow": "Tool Allowlist Additions", + "agents.list[].tools.profile": "Agent Tool Profile", + "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", + "tools.byProvider": "Tool Policy by Provider", + "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", + "tools.exec.applyPatch.enabled": "Enable apply_patch", + "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", + "tools.exec.notifyOnExit": "Exec Notify On Exit", + "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", + "tools.exec.host": "Exec Host", + "tools.exec.security": "Exec Security", + "tools.exec.ask": "Exec Ask", + "tools.exec.node": "Exec Node Binding", + "tools.exec.pathPrepend": "Exec PATH Prepend", + "tools.exec.safeBins": "Exec Safe Bins", + "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", + "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", + "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", + "tools.message.crossContext.marker.enabled": "Cross-Context Marker", + "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", + "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", + "tools.message.broadcast.enabled": "Enable Message Broadcast", + "tools.web.search.enabled": "Enable Web Search Tool", + "tools.web.search.provider": "Web Search Provider", + "tools.web.search.apiKey": "Brave Search API Key", + "tools.web.search.maxResults": "Web Search Max Results", + "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", + "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", + "tools.web.fetch.enabled": "Enable Web Fetch Tool", + "tools.web.fetch.maxChars": "Web Fetch Max Chars", + "tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)", + "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", + "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", + "tools.web.fetch.userAgent": "Web Fetch User-Agent", + "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.controlUi.root": "Control UI Assets Root", + "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", + "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", + "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", + "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", + "gateway.reload.mode": "Config Reload Mode", + "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.nodes.browser.mode": "Gateway Node Browser Mode", + "gateway.nodes.browser.node": "Gateway Node Browser Pin", + "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", + "gateway.nodes.denyCommands": "Gateway Node Denylist", + "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", + "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", + "skills.load.watch": "Watch Skills", + "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", + "agents.defaults.workspace": "Workspace", + "agents.defaults.repoRoot": "Repo Root", + "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", + "agents.defaults.envelopeTimezone": "Envelope Timezone", + "agents.defaults.envelopeTimestamp": "Envelope Timestamp", + "agents.defaults.envelopeElapsed": "Envelope Elapsed", + "agents.defaults.memorySearch": "Memory Search", + "agents.defaults.memorySearch.enabled": "Enable Memory Search", + "agents.defaults.memorySearch.sources": "Memory Search Sources", + "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Memory Search Session Index (Experimental)", + "agents.defaults.memorySearch.provider": "Memory Search Provider", + "agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL", + "agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key", + "agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers", + "agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency", + "agents.defaults.memorySearch.model": "Memory Search Model", + "agents.defaults.memorySearch.fallback": "Memory Search Fallback", + "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", + "agents.defaults.memorySearch.store.path": "Memory Search Index Path", + "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", + "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", + "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", + "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", + "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", + "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", + "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", + "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", + "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", + "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", + "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", + "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Memory Search Hybrid Candidate Multiplier", + "agents.defaults.memorySearch.query.hybrid.mmr.enabled": "Memory Search MMR Re-ranking", + "agents.defaults.memorySearch.query.hybrid.mmr.lambda": "Memory Search MMR Lambda", + "agents.defaults.memorySearch.query.hybrid.temporalDecay.enabled": "Memory Search Temporal Decay", + "agents.defaults.memorySearch.query.hybrid.temporalDecay.halfLifeDays": + "Memory Search Temporal Decay Half-life (Days)", + "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", + "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", + memory: "Memory", + "memory.backend": "Memory Backend", + "memory.citations": "Memory Citations Mode", + "memory.qmd.command": "QMD Binary", + "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", + "memory.qmd.paths": "QMD Extra Paths", + "memory.qmd.paths.path": "QMD Path", + "memory.qmd.paths.pattern": "QMD Path Pattern", + "memory.qmd.paths.name": "QMD Path Name", + "memory.qmd.sessions.enabled": "QMD Session Indexing", + "memory.qmd.sessions.exportDir": "QMD Session Export Directory", + "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", + "memory.qmd.update.interval": "QMD Update Interval", + "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", + "memory.qmd.update.onBoot": "QMD Update on Startup", + "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", + "memory.qmd.update.embedInterval": "QMD Embed Interval", + "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", + "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", + "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", + "memory.qmd.limits.maxResults": "QMD Max Results", + "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", + "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", + "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", + "memory.qmd.scope": "QMD Surface Scope", + "auth.profiles": "Auth Profiles", + "auth.order": "Auth Profile Order", + "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", + "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", + "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", + "auth.cooldowns.failureWindowHours": "Failover Window (hours)", + "agents.defaults.models": "Models", + "agents.defaults.model.primary": "Primary Model", + "agents.defaults.model.fallbacks": "Model Fallbacks", + "agents.defaults.imageModel.primary": "Image Model", + "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", + "agents.defaults.humanDelay.mode": "Human Delay Mode", + "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", + "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.cliBackends": "CLI Backends", + "commands.native": "Native Commands", + "commands.nativeSkills": "Native Skill Commands", + "commands.text": "Text Commands", + "commands.bash": "Allow Bash Chat Command", + "commands.bashForegroundMs": "Bash Foreground Window (ms)", + "commands.config": "Allow /config", + "commands.debug": "Allow /debug", + "commands.restart": "Allow Restart", + "commands.useAccessGroups": "Use Access Groups", + "commands.ownerAllowFrom": "Command Owners", + "commands.allowFrom": "Command Access Allowlist", + "ui.seamColor": "Accent Color", + "ui.assistant.name": "Assistant Name", + "ui.assistant.avatar": "Assistant Avatar", + "browser.evaluateEnabled": "Browser Evaluate Enabled", + "browser.snapshotDefaults": "Browser Snapshot Defaults", + "browser.snapshotDefaults.mode": "Browser Snapshot Mode", + "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", + "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", + "session.dmScope": "DM Session Scope", + "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", + "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", + "talk.apiKey": "Talk API Key", + "channels.whatsapp": "WhatsApp", + "channels.telegram": "Telegram", + "channels.telegram.customCommands": "Telegram Custom Commands", + "channels.discord": "Discord", + "channels.slack": "Slack", + "channels.mattermost": "Mattermost", + "channels.signal": "Signal", + "channels.imessage": "iMessage", + "channels.bluebubbles": "BlueBubbles", + "channels.msteams": "MS Teams", + "channels.telegram.botToken": "Telegram Bot Token", + "channels.telegram.dmPolicy": "Telegram DM Policy", + "channels.telegram.streamMode": "Telegram Draft Stream Mode", + "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", + "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", + "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", + "channels.telegram.retry.attempts": "Telegram Retry Attempts", + "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", + "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", + "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", + "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", + "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", + "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", + "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", + "channels.signal.dmPolicy": "Signal DM Policy", + "channels.imessage.dmPolicy": "iMessage DM Policy", + "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", + "channels.discord.dm.policy": "Discord DM Policy", + "channels.discord.retry.attempts": "Discord Retry Attempts", + "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", + "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", + "channels.discord.retry.jitter": "Discord Retry Jitter", + "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.intents.presence": "Discord Presence Intent", + "channels.discord.intents.guildMembers": "Discord Guild Members Intent", + "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", + "channels.discord.pluralkit.token": "Discord PluralKit Token", + "channels.slack.dm.policy": "Slack DM Policy", + "channels.slack.allowBots": "Slack Allow Bot Messages", + "channels.discord.token": "Discord Bot Token", + "channels.slack.botToken": "Slack Bot Token", + "channels.slack.appToken": "Slack App Token", + "channels.slack.userToken": "Slack User Token", + "channels.slack.userTokenReadOnly": "Slack User Token Read Only", + "channels.slack.thread.historyScope": "Slack Thread History Scope", + "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", + "channels.mattermost.botToken": "Mattermost Bot Token", + "channels.mattermost.baseUrl": "Mattermost Base URL", + "channels.mattermost.chatmode": "Mattermost Chat Mode", + "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", + "channels.mattermost.requireMention": "Mattermost Require Mention", + "channels.signal.account": "Signal Account", + "channels.imessage.cliPath": "iMessage CLI Path", + "agents.list[].skills": "Agent Skill Filter", + "agents.list[].identity.avatar": "Agent Avatar", + "discovery.mdns.mode": "mDNS Discovery Mode", + "plugins.enabled": "Enable Plugins", + "plugins.allow": "Plugin Allowlist", + "plugins.deny": "Plugin Denylist", + "plugins.load.paths": "Plugin Load Paths", + "plugins.slots": "Plugin Slots", + "plugins.slots.memory": "Memory Plugin", + "plugins.entries": "Plugin Entries", + "plugins.entries.*.enabled": "Plugin Enabled", + "plugins.entries.*.config": "Plugin Config", + "plugins.installs": "Plugin Install Records", + "plugins.installs.*.source": "Plugin Install Source", + "plugins.installs.*.spec": "Plugin Install Spec", + "plugins.installs.*.sourcePath": "Plugin Install Source Path", + "plugins.installs.*.installPath": "Plugin Install Path", + "plugins.installs.*.version": "Plugin Install Version", + "plugins.installs.*.installedAt": "Plugin Install Time", +}; + +const FIELD_HELP: Record = { + "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", + "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", + "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', + "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", + "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", + "gateway.remote.tlsFingerprint": + "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", + "gateway.remote.sshTarget": + "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", + "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "agents.list.*.skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].identity.avatar": + "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", + "discovery.mdns.mode": + 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', + "gateway.auth.token": + "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", + "gateway.auth.password": "Required for Tailscale funnel.", + "gateway.controlUi.basePath": + "Optional URL prefix where the Control UI is served (e.g. /openclaw).", + "gateway.controlUi.root": + "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", + "gateway.controlUi.allowedOrigins": + "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).", + "gateway.controlUi.allowInsecureAuth": + "Allow Control UI auth over insecure HTTP (token-only; not recommended).", + "gateway.controlUi.dangerouslyDisableDeviceAuth": + "DANGEROUS. Disable Control UI device identity checks (token/password only).", + "gateway.http.endpoints.chatCompletions.enabled": + "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", + "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', + "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "gateway.nodes.browser.mode": + 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', + "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", + "gateway.nodes.allowCommands": + "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", + "gateway.nodes.denyCommands": + "Commands to block even if present in node claims or default allowlist.", + "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", + "nodeHost.browserProxy.allowProfiles": + "Optional allowlist of browser profile names exposed via the node proxy.", + "diagnostics.flags": + 'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".', + "diagnostics.cacheTrace.enabled": + "Log cache trace snapshots for embedded agent runs (default: false).", + "diagnostics.cacheTrace.filePath": + "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", + "diagnostics.cacheTrace.includeMessages": + "Include full message payloads in trace output (default: true).", + "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", + "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", + "tools.exec.applyPatch.enabled": + "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", + "tools.exec.applyPatch.allowModels": + 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', + "tools.exec.notifyOnExit": + "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", + "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", + "tools.exec.safeBins": + "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "tools.message.allowCrossContextSend": + "Legacy override: allow cross-context sends across all providers.", + "tools.message.crossContext.allowWithinProvider": + "Allow sends to other channels within the same provider (default: true).", + "tools.message.crossContext.allowAcrossProviders": + "Allow sends across different providers (default: false).", + "tools.message.crossContext.marker.enabled": + "Add a visible origin marker when sending cross-context (default: true).", + "tools.message.crossContext.marker.prefix": + 'Text prefix for cross-context markers (supports "{channel}").', + "tools.message.crossContext.marker.suffix": + 'Text suffix for cross-context markers (supports "{channel}").', + "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", + "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", + "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', + "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", + "tools.web.search.maxResults": "Default number of results to return (1-10).", + "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", + "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.perplexity.apiKey": + "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", + "tools.web.search.perplexity.baseUrl": + "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", + "tools.web.search.perplexity.model": + 'Perplexity model override (default: "perplexity/sonar-pro").', + "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", + "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", + "tools.web.fetch.maxCharsCap": + "Hard cap for web_fetch maxChars (applies to config and tool calls).", + "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", + "tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.", + "tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).", + "tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.", + "tools.web.fetch.readability": + "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", + "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).", + "tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", + "tools.web.fetch.firecrawl.baseUrl": + "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", + "tools.web.fetch.firecrawl.onlyMainContent": + "When true, Firecrawl returns only the main content (default: true).", + "tools.web.fetch.firecrawl.maxAgeMs": + "Firecrawl maxAge (ms) for cached results when supported by the API.", + "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", + "channels.slack.allowBots": + "Allow bot-authored messages to trigger Slack replies (default: false).", + "channels.slack.thread.historyScope": + 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', + "channels.slack.thread.inheritParent": + "If true, Slack thread sessions inherit the parent channel transcript (default: false).", + "channels.mattermost.botToken": + "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", + "channels.mattermost.baseUrl": + "Base URL for your Mattermost server (e.g., https://chat.example.com).", + "channels.mattermost.chatmode": + 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").', + "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).', + "channels.mattermost.requireMention": + "Require @mention in channels before responding (default: true).", + "auth.profiles": "Named auth profiles (provider + mode + optional email).", + "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", + "auth.cooldowns.billingBackoffHours": + "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", + "auth.cooldowns.billingBackoffHoursByProvider": + "Optional per-provider overrides for billing backoff (hours).", + "auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).", + "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", + "agents.defaults.bootstrapMaxChars": + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "agents.defaults.repoRoot": + "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", + "agents.defaults.envelopeTimezone": + 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', + "agents.defaults.envelopeTimestamp": + 'Include absolute timestamps in message envelopes ("on" or "off").', + "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', + "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", + "agents.defaults.memorySearch": + "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", + "agents.defaults.memorySearch.sources": + 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', + "agents.defaults.memorySearch.extraPaths": + "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Enable experimental session transcript indexing for memory search (default: false).", + "agents.defaults.memorySearch.provider": + 'Embedding provider ("openai", "gemini", "voyage", or "local").', + "agents.defaults.memorySearch.remote.baseUrl": + "Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).", + "agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.", + "agents.defaults.memorySearch.remote.headers": + "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", + "agents.defaults.memorySearch.remote.batch.enabled": + "Enable batch API for memory embeddings (OpenAI/Gemini/Voyage; default: false).", + "agents.defaults.memorySearch.remote.batch.wait": + "Wait for batch completion when indexing (default: true).", + "agents.defaults.memorySearch.remote.batch.concurrency": + "Max concurrent embedding batch jobs for memory indexing (default: 2).", + "agents.defaults.memorySearch.remote.batch.pollIntervalMs": + "Polling interval in ms for batch status (default: 2000).", + "agents.defaults.memorySearch.remote.batch.timeoutMinutes": + "Timeout in minutes for batch indexing (default: 60).", + "agents.defaults.memorySearch.local.modelPath": + "Local GGUF model path or hf: URI (node-llama-cpp).", + "agents.defaults.memorySearch.fallback": + 'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").', + "agents.defaults.memorySearch.store.path": + "SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).", + "agents.defaults.memorySearch.store.vector.enabled": + "Enable sqlite-vec extension for vector search (default: true).", + "agents.defaults.memorySearch.store.vector.extensionPath": + "Optional override path to sqlite-vec extension library (.dylib/.so/.dll).", + "agents.defaults.memorySearch.query.hybrid.enabled": + "Enable hybrid BM25 + vector search for memory (default: true).", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": + "Weight for vector similarity when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.textWeight": + "Weight for BM25 text relevance when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Multiplier for candidate pool size (default: 4).", + "agents.defaults.memorySearch.query.hybrid.mmr.enabled": + "Enable MMR re-ranking for result diversity (default: false).", + "agents.defaults.memorySearch.query.hybrid.mmr.lambda": + "MMR lambda: 0 = max diversity, 1 = max relevance (default: 0.7).", + "agents.defaults.memorySearch.query.hybrid.temporalDecay.enabled": + "Apply exponential time decay to hybrid scores before MMR re-ranking (default: false).", + "agents.defaults.memorySearch.query.hybrid.temporalDecay.halfLifeDays": + "Temporal decay half-life in days (default: 30).", + "agents.defaults.memorySearch.cache.enabled": + "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", + memory: "Memory backend configuration (global).", + "memory.backend": 'Memory backend ("builtin" for OpenClaw embeddings, "qmd" for QMD sidecar).', + "memory.citations": 'Default citation behavior ("auto", "on", or "off").', + "memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).", + "memory.qmd.includeDefaultMemory": + "Whether to automatically index MEMORY.md + memory/**/*.md (default: true).", + "memory.qmd.paths": + "Additional directories/files to index with QMD (path + optional glob pattern).", + "memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.", + "memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).", + "memory.qmd.paths.name": + "Optional stable name for the QMD collection (default derived from path).", + "memory.qmd.sessions.enabled": + "Enable QMD session transcript indexing (experimental, default: false).", + "memory.qmd.sessions.exportDir": + "Override directory for sanitized session exports before indexing.", + "memory.qmd.sessions.retentionDays": + "Retention window for exported sessions before pruning (default: unlimited).", + "memory.qmd.update.interval": + "How often the QMD sidecar refreshes indexes (duration string, default: 5m).", + "memory.qmd.update.debounceMs": + "Minimum delay between successive QMD refresh runs (default: 15000).", + "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", + "memory.qmd.update.waitForBootSync": + "Block startup until the boot QMD refresh finishes (default: false).", + "memory.qmd.update.embedInterval": + "How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.", + "memory.qmd.update.commandTimeoutMs": + "Timeout for QMD maintenance commands like collection list/add (default: 30000).", + "memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).", + "memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).", + "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", + "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", + "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", + "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", + "memory.qmd.scope": + "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", + "agents.defaults.memorySearch.cache.maxEntries": + "Optional cap on cached embeddings (best-effort).", + "agents.defaults.memorySearch.sync.onSearch": + "Lazy sync: schedule a reindex on search after changes.", + "agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": + "Minimum appended bytes before session transcripts trigger reindex (default: 100000).", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": + "Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).", + "plugins.enabled": "Enable plugin/extension loading (default: true).", + "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", + "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", + "plugins.load.paths": "Additional plugin files or directories to load.", + "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", + "plugins.slots.memory": + 'Select the active memory plugin by id, or "none" to disable memory plugins.', + "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", + "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", + "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", + "plugins.installs": + "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", + "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', + "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", + "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", + "plugins.installs.*.installPath": + "Resolved install directory (usually ~/.openclaw/extensions/).", + "plugins.installs.*.version": "Version recorded at install time (if available).", + "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", + "agents.list.*.identity.avatar": + "Agent avatar (workspace-relative path, http(s) URL, or data URI).", + "agents.defaults.model.primary": "Primary model (provider/model).", + "agents.defaults.model.fallbacks": + "Ordered fallback models (provider/model). Used when the primary model fails.", + "agents.defaults.imageModel.primary": + "Optional image model (provider/model) used when the primary model lacks image input.", + "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", + "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", + "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', + "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", + "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", + "commands.native": + "Register native commands with channels that support it (Discord/Slack/Telegram).", + "commands.nativeSkills": + "Register native skill commands (user-invocable skills) with channels that support it.", + "commands.text": "Allow text command parsing (slash commands only).", + "commands.bash": + "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", + "commands.bashForegroundMs": + "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", + "commands.config": "Allow /config chat command to read/write config on disk (default: false).", + "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", + "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", + "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "commands.ownerAllowFrom": + "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", + "commands.allowFrom": + 'Per-provider allowlist restricting who can use slash commands. If set, overrides the channel\'s allowFrom for command authorization. Use \'*\' key for global default; provider-specific keys (e.g. \'discord\') override the global. Example: { "*": ["user1"], "discord": ["user:123"] }.', + "session.dmScope": + 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', + "session.identityLinks": + "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", + "channels.telegram.configWrites": + "Allow Telegram to write config in response to channel events/commands (default: true).", + "channels.slack.configWrites": + "Allow Slack to write config in response to channel events/commands (default: true).", + "channels.mattermost.configWrites": + "Allow Mattermost to write config in response to channel events/commands (default: true).", + "channels.discord.configWrites": + "Allow Discord to write config in response to channel events/commands (default: true).", + "channels.whatsapp.configWrites": + "Allow WhatsApp to write config in response to channel events/commands (default: true).", + "channels.signal.configWrites": + "Allow Signal to write config in response to channel events/commands (default: true).", + "channels.imessage.configWrites": + "Allow iMessage to write config in response to channel events/commands (default: true).", + "channels.msteams.configWrites": + "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", + "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', + "channels.discord.commands.nativeSkills": + 'Override native skill commands for Discord (bool or "auto").', + "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', + "channels.telegram.commands.nativeSkills": + 'Override native skill commands for Telegram (bool or "auto").', + "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', + "channels.slack.commands.nativeSkills": + 'Override native skill commands for Slack (bool or "auto").', + "session.agentToAgent.maxPingPongTurns": + "Max reply-back turns between requester and target (0–5).", + "channels.telegram.customCommands": + "Additional Telegram bot menu commands (merged with native; conflicts ignored).", + "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", + "messages.ackReactionScope": + 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', + "messages.inbound.debounceMs": + "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", + "channels.telegram.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', + "channels.telegram.streamMode": + "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", + "channels.telegram.draftChunk.minChars": + 'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).', + "channels.telegram.draftChunk.maxChars": + 'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).', + "channels.telegram.draftChunk.breakPreference": + "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", + "channels.telegram.retry.attempts": + "Max retry attempts for outbound Telegram API calls (default: 3).", + "channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.", + "channels.telegram.retry.maxDelayMs": + "Maximum retry delay cap in ms for Telegram outbound calls.", + "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", + "channels.telegram.network.autoSelectFamily": + "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", + "channels.telegram.timeoutSeconds": + "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", + "channels.whatsapp.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', + "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", + "channels.whatsapp.debounceMs": + "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", + "channels.signal.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', + "channels.imessage.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', + "channels.bluebubbles.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', + "channels.discord.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', + "channels.discord.retry.attempts": + "Max retry attempts for outbound Discord API calls (default: 3).", + "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", + "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", + "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", + "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.intents.presence": + "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", + "channels.discord.intents.guildMembers": + "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", + "channels.discord.pluralkit.enabled": + "Resolve PluralKit proxied messages and treat system members as distinct senders.", + "channels.discord.pluralkit.token": + "Optional PluralKit token for resolving private systems or members.", + "channels.slack.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', +}; + +const FIELD_PLACEHOLDERS: Record = { + "gateway.remote.url": "ws://host:18789", + "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", + "gateway.remote.sshTarget": "user@host", + "gateway.controlUi.basePath": "/openclaw", + "gateway.controlUi.root": "dist/control-ui", + "gateway.controlUi.allowedOrigins": "https://control.example.com", + "channels.mattermost.baseUrl": "https://chat.example.com", + "agents.list[].identity.avatar": "avatars/openclaw.png", +}; + +const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; + +function isSensitivePath(path: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); +} type JsonSchemaObject = JsonSchemaNode & { type?: string | string[]; properties?: Record; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index db8a2352ed..9bdecbdd55 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -334,6 +334,20 @@ export type MemorySearchConfig = { textWeight?: number; /** Multiplier for candidate pool size (default: 4). */ candidateMultiplier?: number; + /** Optional MMR re-ranking for result diversity. */ + mmr?: { + /** Enable MMR re-ranking (default: false). */ + enabled?: boolean; + /** Lambda: 0 = max diversity, 1 = max relevance (default: 0.7). */ + lambda?: number; + }; + /** Optional temporal decay to boost recency in hybrid scoring. */ + temporalDecay?: { + /** Enable temporal decay (default: false). */ + enabled?: boolean; + /** Half-life in days for exponential decay (default: 30). */ + halfLifeDays?: number; + }; }; }; /** Index cache behavior. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 91672ae6b9..3bbb61032a 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -503,6 +503,20 @@ export const MemorySearchSchema = z vectorWeight: z.number().min(0).max(1).optional(), textWeight: z.number().min(0).max(1).optional(), candidateMultiplier: z.number().int().positive().optional(), + mmr: z + .object({ + enabled: z.boolean().optional(), + lambda: z.number().min(0).max(1).optional(), + }) + .strict() + .optional(), + temporalDecay: z + .object({ + enabled: z.boolean().optional(), + halfLifeDays: z.number().int().positive().optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/memory/hybrid.test.ts b/src/memory/hybrid.test.ts index 6e9d8f4264..98e67f034b 100644 --- a/src/memory/hybrid.test.ts +++ b/src/memory/hybrid.test.ts @@ -17,8 +17,8 @@ describe("memory hybrid helpers", () => { expect(bm25RankToScore(-100)).toBeCloseTo(1); }); - it("mergeHybridResults unions by id and combines weighted scores", () => { - const merged = mergeHybridResults({ + it("mergeHybridResults unions by id and combines weighted scores", async () => { + const merged = await mergeHybridResults({ vectorWeight: 0.7, textWeight: 0.3, vector: [ @@ -52,8 +52,8 @@ describe("memory hybrid helpers", () => { expect(b?.score).toBeCloseTo(0.3 * 1.0); }); - it("mergeHybridResults prefers keyword snippet when ids overlap", () => { - const merged = mergeHybridResults({ + it("mergeHybridResults prefers keyword snippet when ids overlap", async () => { + const merged = await mergeHybridResults({ vectorWeight: 0.5, textWeight: 0.5, vector: [ diff --git a/src/memory/hybrid.ts b/src/memory/hybrid.ts index 1ea1c5e6f7..af045ade78 100644 --- a/src/memory/hybrid.ts +++ b/src/memory/hybrid.ts @@ -1,8 +1,14 @@ import { applyMMRToHybridResults, type MMRConfig, DEFAULT_MMR_CONFIG } from "./mmr.js"; +import { + applyTemporalDecayToHybridResults, + type TemporalDecayConfig, + DEFAULT_TEMPORAL_DECAY_CONFIG, +} from "./temporal-decay.js"; export type HybridSource = string; export { type MMRConfig, DEFAULT_MMR_CONFIG }; +export { type TemporalDecayConfig, DEFAULT_TEMPORAL_DECAY_CONFIG }; export type HybridVectorResult = { id: string; @@ -42,21 +48,28 @@ export function bm25RankToScore(rank: number): number { return 1 / (1 + normalized); } -export function mergeHybridResults(params: { +export async function mergeHybridResults(params: { vector: HybridVectorResult[]; keyword: HybridKeywordResult[]; vectorWeight: number; textWeight: number; + workspaceDir?: string; /** MMR configuration for diversity-aware re-ranking */ mmr?: Partial; -}): Array<{ - path: string; - startLine: number; - endLine: number; - score: number; - snippet: string; - source: HybridSource; -}> { + /** Temporal decay configuration for recency-aware scoring */ + temporalDecay?: Partial; + /** Test seam for deterministic time-dependent behavior */ + nowMs?: number; +}): Promise< + Array<{ + path: string; + startLine: number; + endLine: number; + score: number; + snippet: string; + source: HybridSource; + }> +> { const byId = new Map< string, { @@ -117,7 +130,14 @@ export function mergeHybridResults(params: { }; }); - const sorted = merged.toSorted((a, b) => b.score - a.score); + const temporalDecayConfig = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...params.temporalDecay }; + const decayed = await applyTemporalDecayToHybridResults({ + results: merged, + temporalDecay: temporalDecayConfig, + workspaceDir: params.workspaceDir, + nowMs: params.nowMs, + }); + const sorted = decayed.toSorted((a, b) => b.score - a.score); // Apply MMR re-ranking if enabled const mmrConfig = { ...DEFAULT_MMR_CONFIG, ...params.mmr }; diff --git a/src/memory/manager.ts b/src/memory/manager.ts index e9227aa58d..ea1d1be298 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -278,11 +278,13 @@ export class MemoryIndexManager implements MemorySearchManager { return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults); } - const merged = this.mergeHybridResults({ + const merged = await this.mergeHybridResults({ vector: vectorResults, keyword: keywordResults, vectorWeight: hybrid.vectorWeight, textWeight: hybrid.textWeight, + mmr: hybrid.mmr, + temporalDecay: hybrid.temporalDecay, }); return merged.filter((entry) => entry.score >= minScore).slice(0, maxResults); @@ -343,8 +345,10 @@ export class MemoryIndexManager implements MemorySearchManager { keyword: Array; vectorWeight: number; textWeight: number; - }): MemorySearchResult[] { - const merged = mergeHybridResults({ + mmr?: { enabled: boolean; lambda: number }; + temporalDecay?: { enabled: boolean; halfLifeDays: number }; + }): Promise { + return mergeHybridResults({ vector: params.vector.map((r) => ({ id: r.id, path: r.path, @@ -365,8 +369,10 @@ export class MemoryIndexManager implements MemorySearchManager { })), vectorWeight: params.vectorWeight, textWeight: params.textWeight, - }); - return merged.map((entry) => entry as MemorySearchResult); + mmr: params.mmr, + temporalDecay: params.temporalDecay, + workspaceDir: this.workspaceDir, + }).then((entries) => entries.map((entry) => entry as MemorySearchResult)); } async sync(params?: { diff --git a/src/memory/__tests__/mmr.test.ts b/src/memory/mmr.test.ts similarity index 99% rename from src/memory/__tests__/mmr.test.ts rename to src/memory/mmr.test.ts index 5931eb6610..0891133525 100644 --- a/src/memory/__tests__/mmr.test.ts +++ b/src/memory/mmr.test.ts @@ -8,7 +8,7 @@ import { applyMMRToHybridResults, DEFAULT_MMR_CONFIG, type MMRItem, -} from "../mmr.js"; +} from "./mmr.js"; describe("tokenize", () => { it("extracts alphanumeric tokens and lowercases", () => { diff --git a/src/memory/mmr.ts b/src/memory/mmr.ts index 3d6397184e..52b0d9e280 100644 --- a/src/memory/mmr.ts +++ b/src/memory/mmr.ts @@ -39,15 +39,21 @@ export function tokenize(text: string): Set { * Returns a value in [0, 1] where 1 means identical sets. */ export function jaccardSimilarity(setA: Set, setB: Set): number { - if (setA.size === 0 && setB.size === 0) return 1; - if (setA.size === 0 || setB.size === 0) return 0; + if (setA.size === 0 && setB.size === 0) { + return 1; + } + if (setA.size === 0 || setB.size === 0) { + return 0; + } let intersectionSize = 0; const smaller = setA.size <= setB.size ? setA : setB; const larger = setA.size <= setB.size ? setB : setA; for (const token of smaller) { - if (larger.has(token)) intersectionSize++; + if (larger.has(token)) { + intersectionSize++; + } } const unionSize = setA.size + setB.size - intersectionSize; @@ -69,7 +75,9 @@ function maxSimilarityToSelected( selectedItems: MMRItem[], tokenCache: Map>, ): number { - if (selectedItems.length === 0) return 0; + if (selectedItems.length === 0) { + return 0; + } let maxSim = 0; const itemTokens = tokenCache.get(item.id) ?? tokenize(item.content); @@ -77,7 +85,9 @@ function maxSimilarityToSelected( for (const selected of selectedItems) { const selectedTokens = tokenCache.get(selected.id) ?? tokenize(selected.content); const sim = jaccardSimilarity(itemTokens, selectedTokens); - if (sim > maxSim) maxSim = sim; + if (sim > maxSim) { + maxSim = sim; + } } return maxSim; @@ -107,14 +117,16 @@ export function mmrRerank(items: T[], config: Partial b.score - a.score); + return [...items].toSorted((a, b) => b.score - a.score); } // Pre-tokenize all items for efficiency @@ -129,7 +141,9 @@ export function mmrRerank(items: T[], config: Partial { - if (scoreRange === 0) return 1; // All scores equal + if (scoreRange === 0) { + return 1; // All scores equal + } return (score - minScore) / scoreRange; }; @@ -175,7 +189,9 @@ export function mmrRerank(items: T[], config: Partial(results: T[], config: Partial = {}): T[] { - if (results.length === 0) return results; + if (results.length === 0) { + return results; + } // Create a map from ID to original item for type-safe retrieval const itemById = new Map(); diff --git a/src/memory/temporal-decay.test.ts b/src/memory/temporal-decay.test.ts new file mode 100644 index 0000000000..1c01c16ea3 --- /dev/null +++ b/src/memory/temporal-decay.test.ts @@ -0,0 +1,173 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { mergeHybridResults } from "./hybrid.js"; +import { + applyTemporalDecayToHybridResults, + applyTemporalDecayToScore, + calculateTemporalDecayMultiplier, +} from "./temporal-decay.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW_MS = Date.UTC(2026, 1, 10, 0, 0, 0); + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-temporal-decay-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(async (dir) => { + await fs.rm(dir, { recursive: true, force: true }); + }), + ); +}); + +describe("temporal decay", () => { + it("matches exponential decay formula", () => { + const halfLifeDays = 30; + const ageInDays = 10; + const lambda = Math.LN2 / halfLifeDays; + const expectedMultiplier = Math.exp(-lambda * ageInDays); + + expect(calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays })).toBeCloseTo( + expectedMultiplier, + ); + expect(applyTemporalDecayToScore({ score: 0.8, ageInDays, halfLifeDays })).toBeCloseTo( + 0.8 * expectedMultiplier, + ); + }); + + it("is 0.5 exactly at half-life", () => { + expect(calculateTemporalDecayMultiplier({ ageInDays: 30, halfLifeDays: 30 })).toBeCloseTo(0.5); + }); + + it("does not decay evergreen memory files", async () => { + const dir = await makeTempDir(); + + const rootMemoryPath = path.join(dir, "MEMORY.md"); + const topicPath = path.join(dir, "memory", "projects.md"); + await fs.mkdir(path.dirname(topicPath), { recursive: true }); + await fs.writeFile(rootMemoryPath, "evergreen"); + await fs.writeFile(topicPath, "topic evergreen"); + + const veryOld = new Date(Date.UTC(2010, 0, 1)); + await fs.utimes(rootMemoryPath, veryOld, veryOld); + await fs.utimes(topicPath, veryOld, veryOld); + + const decayed = await applyTemporalDecayToHybridResults({ + results: [ + { path: "MEMORY.md", score: 1, source: "memory" }, + { path: "memory/projects.md", score: 0.75, source: "memory" }, + ], + workspaceDir: dir, + temporalDecay: { enabled: true, halfLifeDays: 30 }, + nowMs: NOW_MS, + }); + + expect(decayed[0]?.score).toBeCloseTo(1); + expect(decayed[1]?.score).toBeCloseTo(0.75); + }); + + it("applies decay in hybrid merging before ranking", async () => { + const merged = await mergeHybridResults({ + vectorWeight: 1, + textWeight: 0, + temporalDecay: { enabled: true, halfLifeDays: 30 }, + mmr: { enabled: false }, + nowMs: NOW_MS, + vector: [ + { + id: "old", + path: "memory/2025-01-01.md", + startLine: 1, + endLine: 1, + source: "memory", + snippet: "old but high", + vectorScore: 0.95, + }, + { + id: "new", + path: "memory/2026-02-10.md", + startLine: 1, + endLine: 1, + source: "memory", + snippet: "new and relevant", + vectorScore: 0.8, + }, + ], + keyword: [], + }); + + expect(merged[0]?.path).toBe("memory/2026-02-10.md"); + expect(merged[0]?.score ?? 0).toBeGreaterThan(merged[1]?.score ?? 0); + }); + + it("handles future dates, zero age, and very old memories", async () => { + const merged = await mergeHybridResults({ + vectorWeight: 1, + textWeight: 0, + temporalDecay: { enabled: true, halfLifeDays: 30 }, + mmr: { enabled: false }, + nowMs: NOW_MS, + vector: [ + { + id: "future", + path: "memory/2099-01-01.md", + startLine: 1, + endLine: 1, + source: "memory", + snippet: "future", + vectorScore: 0.9, + }, + { + id: "today", + path: "memory/2026-02-10.md", + startLine: 1, + endLine: 1, + source: "memory", + snippet: "today", + vectorScore: 0.8, + }, + { + id: "very-old", + path: "memory/2000-01-01.md", + startLine: 1, + endLine: 1, + source: "memory", + snippet: "ancient", + vectorScore: 1, + }, + ], + keyword: [], + }); + + const byPath = new Map(merged.map((entry) => [entry.path, entry])); + expect(byPath.get("memory/2099-01-01.md")?.score).toBeCloseTo(0.9); + expect(byPath.get("memory/2026-02-10.md")?.score).toBeCloseTo(0.8); + expect(byPath.get("memory/2000-01-01.md")?.score ?? 1).toBeLessThan(0.001); + }); + + it("uses file mtime fallback for non-memory sources", async () => { + const dir = await makeTempDir(); + const sessionPath = path.join(dir, "sessions", "thread.jsonl"); + await fs.mkdir(path.dirname(sessionPath), { recursive: true }); + await fs.writeFile(sessionPath, "{}\n"); + const oldMtime = new Date(NOW_MS - 30 * DAY_MS); + await fs.utimes(sessionPath, oldMtime, oldMtime); + + const decayed = await applyTemporalDecayToHybridResults({ + results: [{ path: "sessions/thread.jsonl", score: 1, source: "sessions" }], + workspaceDir: dir, + temporalDecay: { enabled: true, halfLifeDays: 30 }, + nowMs: NOW_MS, + }); + + expect(decayed[0]?.score).toBeCloseTo(0.5, 2); + }); +}); diff --git a/src/memory/temporal-decay.ts b/src/memory/temporal-decay.ts new file mode 100644 index 0000000000..adaf2ee4c3 --- /dev/null +++ b/src/memory/temporal-decay.ts @@ -0,0 +1,166 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export type TemporalDecayConfig = { + enabled: boolean; + halfLifeDays: number; +}; + +export const DEFAULT_TEMPORAL_DECAY_CONFIG: TemporalDecayConfig = { + enabled: false, + halfLifeDays: 30, +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DATED_MEMORY_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/; + +export function toDecayLambda(halfLifeDays: number): number { + if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) { + return 0; + } + return Math.LN2 / halfLifeDays; +} + +export function calculateTemporalDecayMultiplier(params: { + ageInDays: number; + halfLifeDays: number; +}): number { + const lambda = toDecayLambda(params.halfLifeDays); + const clampedAge = Math.max(0, params.ageInDays); + if (lambda <= 0 || !Number.isFinite(clampedAge)) { + return 1; + } + return Math.exp(-lambda * clampedAge); +} + +export function applyTemporalDecayToScore(params: { + score: number; + ageInDays: number; + halfLifeDays: number; +}): number { + return params.score * calculateTemporalDecayMultiplier(params); +} + +function parseMemoryDateFromPath(filePath: string): Date | null { + const normalized = filePath.replaceAll("\\", "/").replace(/^\.\//, ""); + const match = DATED_MEMORY_PATH_RE.exec(normalized); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + return null; + } + + const timestamp = Date.UTC(year, month - 1, day); + const parsed = new Date(timestamp); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) { + return null; + } + + return parsed; +} + +function isEvergreenMemoryPath(filePath: string): boolean { + const normalized = filePath.replaceAll("\\", "/").replace(/^\.\//, ""); + if (normalized === "MEMORY.md" || normalized === "memory.md") { + return true; + } + if (!normalized.startsWith("memory/")) { + return false; + } + return !DATED_MEMORY_PATH_RE.test(normalized); +} + +async function extractTimestamp(params: { + filePath: string; + source?: string; + workspaceDir?: string; +}): Promise { + const fromPath = parseMemoryDateFromPath(params.filePath); + if (fromPath) { + return fromPath; + } + + // Memory root/topic files are evergreen knowledge and should not decay. + if (params.source === "memory" && isEvergreenMemoryPath(params.filePath)) { + return null; + } + + if (!params.workspaceDir) { + return null; + } + + const absolutePath = path.isAbsolute(params.filePath) + ? params.filePath + : path.resolve(params.workspaceDir, params.filePath); + + try { + const stat = await fs.stat(absolutePath); + if (!Number.isFinite(stat.mtimeMs)) { + return null; + } + return new Date(stat.mtimeMs); + } catch { + return null; + } +} + +function ageInDaysFromTimestamp(timestamp: Date, nowMs: number): number { + const ageMs = Math.max(0, nowMs - timestamp.getTime()); + return ageMs / DAY_MS; +} + +export async function applyTemporalDecayToHybridResults< + T extends { path: string; score: number; source: string }, +>(params: { + results: T[]; + temporalDecay?: Partial; + workspaceDir?: string; + nowMs?: number; +}): Promise { + const config = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...params.temporalDecay }; + if (!config.enabled) { + return [...params.results]; + } + + const nowMs = params.nowMs ?? Date.now(); + const timestampCache = new Map(); + + return Promise.all( + params.results.map(async (entry) => { + const cacheKey = `${entry.source}:${entry.path}`; + if (!timestampCache.has(cacheKey)) { + const timestamp = await extractTimestamp({ + filePath: entry.path, + source: entry.source, + workspaceDir: params.workspaceDir, + }); + timestampCache.set(cacheKey, timestamp); + } + + const timestamp = timestampCache.get(cacheKey) ?? null; + if (!timestamp) { + return entry; + } + + const decayedScore = applyTemporalDecayToScore({ + score: entry.score, + ageInDays: ageInDaysFromTimestamp(timestamp, nowMs), + halfLifeDays: config.halfLifeDays, + }); + + return { + ...entry, + score: decayedScore, + }; + }), + ); +}