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).
This commit is contained in:
Rodrigo Uroz
2026-02-10 08:42:22 -03:00
committed by Peter Steinberger
parent fa9420069a
commit 6b3e0710f4
13 changed files with 1372 additions and 35 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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: {

View File

@@ -10,6 +10,757 @@ export type ConfigSchema = ReturnType<typeof OpenClawSchema.toJSONSchema>;
type JsonSchemaNode = Record<string, unknown>;
const GROUP_LABELS: Record<string, string> = {
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<string, number> = {
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<string, string> = {
"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<string, string> = {
"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/<id>).",
"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 (05).",
"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<string, string> = {
"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<string, JsonSchemaObject>;

View File

@@ -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. */

View File

@@ -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(),

View File

@@ -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: [

View File

@@ -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<MMRConfig>;
}): Array<{
path: string;
startLine: number;
endLine: number;
score: number;
snippet: string;
source: HybridSource;
}> {
/** Temporal decay configuration for recency-aware scoring */
temporalDecay?: Partial<TemporalDecayConfig>;
/** 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 };

View File

@@ -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<MemorySearchResult & { id: string; textScore: number }>;
vectorWeight: number;
textWeight: number;
}): MemorySearchResult[] {
const merged = mergeHybridResults({
mmr?: { enabled: boolean; lambda: number };
temporalDecay?: { enabled: boolean; halfLifeDays: number };
}): Promise<MemorySearchResult[]> {
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?: {

View File

@@ -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", () => {

View File

@@ -39,15 +39,21 @@ export function tokenize(text: string): Set<string> {
* Returns a value in [0, 1] where 1 means identical sets.
*/
export function jaccardSimilarity(setA: Set<string>, setB: Set<string>): 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<string, Set<string>>,
): 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<T extends MMRItem>(items: T[], config: Partial<MMRConf
const { enabled = DEFAULT_MMR_CONFIG.enabled, lambda = DEFAULT_MMR_CONFIG.lambda } = config;
// Early exits
if (!enabled || items.length <= 1) return [...items];
if (!enabled || items.length <= 1) {
return [...items];
}
// Clamp lambda to valid range
const clampedLambda = Math.max(0, Math.min(1, lambda));
// If lambda is 1, just return sorted by relevance (no diversity penalty)
if (clampedLambda === 1) {
return [...items].sort((a, b) => 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<T extends MMRItem>(items: T[], config: Partial<MMRConf
const scoreRange = maxScore - minScore;
const normalizeScore = (score: number): number => {
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<T extends MMRItem>(items: T[], config: Partial<MMRConf
export function applyMMRToHybridResults<
T extends { score: number; snippet: string; path: string; startLine: number },
>(results: T[], config: Partial<MMRConfig> = {}): 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<string, T>();

View File

@@ -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<string> {
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);
});
});

View File

@@ -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<Date | null> {
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<TemporalDecayConfig>;
workspaceDir?: string;
nowMs?: number;
}): Promise<T[]> {
const config = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...params.temporalDecay };
if (!config.enabled) {
return [...params.results];
}
const nowMs = params.nowMs ?? Date.now();
const timestampCache = new Map<string, Date | null>();
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,
};
}),
);
}