mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
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:
committed by
Peter Steinberger
parent
fa9420069a
commit
6b3e0710f4
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (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<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>;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -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>();
|
||||
|
||||
173
src/memory/temporal-decay.test.ts
Normal file
173
src/memory/temporal-decay.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
166
src/memory/temporal-decay.ts
Normal file
166
src/memory/temporal-decay.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user