mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:41:38 +00: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.
|
- 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.
|
- 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 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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ agents: {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Tools:
|
Tools:
|
||||||
|
|
||||||
- `memory_search` — returns snippets with file + line ranges.
|
- `memory_search` — returns snippets with file + line ranges.
|
||||||
- `memory_get` — read memory file content by path.
|
- `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
|
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.
|
(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)
|
#### MMR re-ranking (diversity)
|
||||||
|
|
||||||
When hybrid search returns results, multiple chunks may contain similar or overlapping content.
|
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,
|
**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:
|
How it works:
|
||||||
|
|
||||||
1. Results are scored by their original relevance (vector + BM25 weighted score).
|
1. Results are scored by their original relevance (vector + BM25 weighted score).
|
||||||
2. MMR iteratively selects results that maximize: `λ × relevance − (1−λ) × similarity_to_selected`.
|
2. MMR iteratively selects results that maximize: `λ × relevance − (1−λ) × max_similarity_to_selected`.
|
||||||
3. Already-selected results are penalized via Jaccard text similarity.
|
3. Similarity between results is measured using Jaccard text similarity on tokenized content.
|
||||||
|
|
||||||
The `lambda` parameter controls the trade-off:
|
The `lambda` parameter controls the trade-off:
|
||||||
|
|
||||||
- `lambda = 1.0` → pure relevance (no diversity penalty)
|
- `lambda = 1.0` → pure relevance (no diversity penalty)
|
||||||
- `lambda = 0.0` → maximum diversity (ignores relevance)
|
- `lambda = 0.0` → maximum diversity (ignores relevance)
|
||||||
- Default: `0.7` (balanced, slight relevance bias)
|
- 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
|
```json5
|
||||||
agents: {
|
agents: {
|
||||||
@@ -456,9 +570,15 @@ agents: {
|
|||||||
vectorWeight: 0.7,
|
vectorWeight: 0.7,
|
||||||
textWeight: 0.3,
|
textWeight: 0.3,
|
||||||
candidateMultiplier: 4,
|
candidateMultiplier: 4,
|
||||||
|
// Diversity: reduce redundant results
|
||||||
mmr: {
|
mmr: {
|
||||||
enabled: true,
|
enabled: true, // default: false
|
||||||
lambda: 0.7
|
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
|
### Embedding cache
|
||||||
|
|
||||||
OpenClaw can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
|
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;
|
vectorWeight: number;
|
||||||
textWeight: number;
|
textWeight: number;
|
||||||
candidateMultiplier: number;
|
candidateMultiplier: number;
|
||||||
|
mmr: {
|
||||||
|
enabled: boolean;
|
||||||
|
lambda: number;
|
||||||
|
};
|
||||||
|
temporalDecay: {
|
||||||
|
enabled: boolean;
|
||||||
|
halfLifeDays: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
cache: {
|
cache: {
|
||||||
@@ -84,6 +92,10 @@ const DEFAULT_HYBRID_ENABLED = true;
|
|||||||
const DEFAULT_HYBRID_VECTOR_WEIGHT = 0.7;
|
const DEFAULT_HYBRID_VECTOR_WEIGHT = 0.7;
|
||||||
const DEFAULT_HYBRID_TEXT_WEIGHT = 0.3;
|
const DEFAULT_HYBRID_TEXT_WEIGHT = 0.3;
|
||||||
const DEFAULT_HYBRID_CANDIDATE_MULTIPLIER = 4;
|
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_CACHE_ENABLED = true;
|
||||||
const DEFAULT_SOURCES: Array<"memory" | "sessions"> = ["memory"];
|
const DEFAULT_SOURCES: Array<"memory" | "sessions"> = ["memory"];
|
||||||
|
|
||||||
@@ -236,6 +248,26 @@ function mergeConfig(
|
|||||||
overrides?.query?.hybrid?.candidateMultiplier ??
|
overrides?.query?.hybrid?.candidateMultiplier ??
|
||||||
defaults?.query?.hybrid?.candidateMultiplier ??
|
defaults?.query?.hybrid?.candidateMultiplier ??
|
||||||
DEFAULT_HYBRID_CANDIDATE_MULTIPLIER,
|
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 = {
|
const cache = {
|
||||||
enabled: overrides?.cache?.enabled ?? defaults?.cache?.enabled ?? DEFAULT_CACHE_ENABLED,
|
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 normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
|
||||||
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
||||||
const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20);
|
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 deltaBytes = clampInt(sync.sessions.deltaBytes, 0, Number.MAX_SAFE_INTEGER);
|
||||||
const deltaMessages = clampInt(sync.sessions.deltaMessages, 0, Number.MAX_SAFE_INTEGER);
|
const deltaMessages = clampInt(sync.sessions.deltaMessages, 0, Number.MAX_SAFE_INTEGER);
|
||||||
return {
|
return {
|
||||||
@@ -281,6 +321,16 @@ function mergeConfig(
|
|||||||
vectorWeight: normalizedVectorWeight,
|
vectorWeight: normalizedVectorWeight,
|
||||||
textWeight: normalizedTextWeight,
|
textWeight: normalizedTextWeight,
|
||||||
candidateMultiplier,
|
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: {
|
cache: {
|
||||||
|
|||||||
@@ -10,6 +10,757 @@ export type ConfigSchema = ReturnType<typeof OpenClawSchema.toJSONSchema>;
|
|||||||
|
|
||||||
type JsonSchemaNode = Record<string, unknown>;
|
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 JsonSchemaObject = JsonSchemaNode & {
|
||||||
type?: string | string[];
|
type?: string | string[];
|
||||||
properties?: Record<string, JsonSchemaObject>;
|
properties?: Record<string, JsonSchemaObject>;
|
||||||
|
|||||||
@@ -334,6 +334,20 @@ export type MemorySearchConfig = {
|
|||||||
textWeight?: number;
|
textWeight?: number;
|
||||||
/** Multiplier for candidate pool size (default: 4). */
|
/** Multiplier for candidate pool size (default: 4). */
|
||||||
candidateMultiplier?: number;
|
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. */
|
/** Index cache behavior. */
|
||||||
|
|||||||
@@ -503,6 +503,20 @@ export const MemorySearchSchema = z
|
|||||||
vectorWeight: z.number().min(0).max(1).optional(),
|
vectorWeight: z.number().min(0).max(1).optional(),
|
||||||
textWeight: z.number().min(0).max(1).optional(),
|
textWeight: z.number().min(0).max(1).optional(),
|
||||||
candidateMultiplier: z.number().int().positive().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()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ describe("memory hybrid helpers", () => {
|
|||||||
expect(bm25RankToScore(-100)).toBeCloseTo(1);
|
expect(bm25RankToScore(-100)).toBeCloseTo(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mergeHybridResults unions by id and combines weighted scores", () => {
|
it("mergeHybridResults unions by id and combines weighted scores", async () => {
|
||||||
const merged = mergeHybridResults({
|
const merged = await mergeHybridResults({
|
||||||
vectorWeight: 0.7,
|
vectorWeight: 0.7,
|
||||||
textWeight: 0.3,
|
textWeight: 0.3,
|
||||||
vector: [
|
vector: [
|
||||||
@@ -52,8 +52,8 @@ describe("memory hybrid helpers", () => {
|
|||||||
expect(b?.score).toBeCloseTo(0.3 * 1.0);
|
expect(b?.score).toBeCloseTo(0.3 * 1.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mergeHybridResults prefers keyword snippet when ids overlap", () => {
|
it("mergeHybridResults prefers keyword snippet when ids overlap", async () => {
|
||||||
const merged = mergeHybridResults({
|
const merged = await mergeHybridResults({
|
||||||
vectorWeight: 0.5,
|
vectorWeight: 0.5,
|
||||||
textWeight: 0.5,
|
textWeight: 0.5,
|
||||||
vector: [
|
vector: [
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { applyMMRToHybridResults, type MMRConfig, DEFAULT_MMR_CONFIG } from "./mmr.js";
|
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 HybridSource = string;
|
||||||
|
|
||||||
export { type MMRConfig, DEFAULT_MMR_CONFIG };
|
export { type MMRConfig, DEFAULT_MMR_CONFIG };
|
||||||
|
export { type TemporalDecayConfig, DEFAULT_TEMPORAL_DECAY_CONFIG };
|
||||||
|
|
||||||
export type HybridVectorResult = {
|
export type HybridVectorResult = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,21 +48,28 @@ export function bm25RankToScore(rank: number): number {
|
|||||||
return 1 / (1 + normalized);
|
return 1 / (1 + normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeHybridResults(params: {
|
export async function mergeHybridResults(params: {
|
||||||
vector: HybridVectorResult[];
|
vector: HybridVectorResult[];
|
||||||
keyword: HybridKeywordResult[];
|
keyword: HybridKeywordResult[];
|
||||||
vectorWeight: number;
|
vectorWeight: number;
|
||||||
textWeight: number;
|
textWeight: number;
|
||||||
|
workspaceDir?: string;
|
||||||
/** MMR configuration for diversity-aware re-ranking */
|
/** MMR configuration for diversity-aware re-ranking */
|
||||||
mmr?: Partial<MMRConfig>;
|
mmr?: Partial<MMRConfig>;
|
||||||
}): Array<{
|
/** Temporal decay configuration for recency-aware scoring */
|
||||||
|
temporalDecay?: Partial<TemporalDecayConfig>;
|
||||||
|
/** Test seam for deterministic time-dependent behavior */
|
||||||
|
nowMs?: number;
|
||||||
|
}): Promise<
|
||||||
|
Array<{
|
||||||
path: string;
|
path: string;
|
||||||
startLine: number;
|
startLine: number;
|
||||||
endLine: number;
|
endLine: number;
|
||||||
score: number;
|
score: number;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
source: HybridSource;
|
source: HybridSource;
|
||||||
}> {
|
}>
|
||||||
|
> {
|
||||||
const byId = new Map<
|
const byId = new Map<
|
||||||
string,
|
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
|
// Apply MMR re-ranking if enabled
|
||||||
const mmrConfig = { ...DEFAULT_MMR_CONFIG, ...params.mmr };
|
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);
|
return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = this.mergeHybridResults({
|
const merged = await this.mergeHybridResults({
|
||||||
vector: vectorResults,
|
vector: vectorResults,
|
||||||
keyword: keywordResults,
|
keyword: keywordResults,
|
||||||
vectorWeight: hybrid.vectorWeight,
|
vectorWeight: hybrid.vectorWeight,
|
||||||
textWeight: hybrid.textWeight,
|
textWeight: hybrid.textWeight,
|
||||||
|
mmr: hybrid.mmr,
|
||||||
|
temporalDecay: hybrid.temporalDecay,
|
||||||
});
|
});
|
||||||
|
|
||||||
return merged.filter((entry) => entry.score >= minScore).slice(0, maxResults);
|
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 }>;
|
keyword: Array<MemorySearchResult & { id: string; textScore: number }>;
|
||||||
vectorWeight: number;
|
vectorWeight: number;
|
||||||
textWeight: number;
|
textWeight: number;
|
||||||
}): MemorySearchResult[] {
|
mmr?: { enabled: boolean; lambda: number };
|
||||||
const merged = mergeHybridResults({
|
temporalDecay?: { enabled: boolean; halfLifeDays: number };
|
||||||
|
}): Promise<MemorySearchResult[]> {
|
||||||
|
return mergeHybridResults({
|
||||||
vector: params.vector.map((r) => ({
|
vector: params.vector.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
path: r.path,
|
path: r.path,
|
||||||
@@ -365,8 +369,10 @@ export class MemoryIndexManager implements MemorySearchManager {
|
|||||||
})),
|
})),
|
||||||
vectorWeight: params.vectorWeight,
|
vectorWeight: params.vectorWeight,
|
||||||
textWeight: params.textWeight,
|
textWeight: params.textWeight,
|
||||||
});
|
mmr: params.mmr,
|
||||||
return merged.map((entry) => entry as MemorySearchResult);
|
temporalDecay: params.temporalDecay,
|
||||||
|
workspaceDir: this.workspaceDir,
|
||||||
|
}).then((entries) => entries.map((entry) => entry as MemorySearchResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
async sync(params?: {
|
async sync(params?: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
applyMMRToHybridResults,
|
applyMMRToHybridResults,
|
||||||
DEFAULT_MMR_CONFIG,
|
DEFAULT_MMR_CONFIG,
|
||||||
type MMRItem,
|
type MMRItem,
|
||||||
} from "../mmr.js";
|
} from "./mmr.js";
|
||||||
|
|
||||||
describe("tokenize", () => {
|
describe("tokenize", () => {
|
||||||
it("extracts alphanumeric tokens and lowercases", () => {
|
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.
|
* Returns a value in [0, 1] where 1 means identical sets.
|
||||||
*/
|
*/
|
||||||
export function jaccardSimilarity(setA: Set<string>, setB: Set<string>): number {
|
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) {
|
||||||
if (setA.size === 0 || setB.size === 0) return 0;
|
return 1;
|
||||||
|
}
|
||||||
|
if (setA.size === 0 || setB.size === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
let intersectionSize = 0;
|
let intersectionSize = 0;
|
||||||
const smaller = setA.size <= setB.size ? setA : setB;
|
const smaller = setA.size <= setB.size ? setA : setB;
|
||||||
const larger = setA.size <= setB.size ? setB : setA;
|
const larger = setA.size <= setB.size ? setB : setA;
|
||||||
|
|
||||||
for (const token of smaller) {
|
for (const token of smaller) {
|
||||||
if (larger.has(token)) intersectionSize++;
|
if (larger.has(token)) {
|
||||||
|
intersectionSize++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unionSize = setA.size + setB.size - intersectionSize;
|
const unionSize = setA.size + setB.size - intersectionSize;
|
||||||
@@ -69,7 +75,9 @@ function maxSimilarityToSelected(
|
|||||||
selectedItems: MMRItem[],
|
selectedItems: MMRItem[],
|
||||||
tokenCache: Map<string, Set<string>>,
|
tokenCache: Map<string, Set<string>>,
|
||||||
): number {
|
): number {
|
||||||
if (selectedItems.length === 0) return 0;
|
if (selectedItems.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
let maxSim = 0;
|
let maxSim = 0;
|
||||||
const itemTokens = tokenCache.get(item.id) ?? tokenize(item.content);
|
const itemTokens = tokenCache.get(item.id) ?? tokenize(item.content);
|
||||||
@@ -77,7 +85,9 @@ function maxSimilarityToSelected(
|
|||||||
for (const selected of selectedItems) {
|
for (const selected of selectedItems) {
|
||||||
const selectedTokens = tokenCache.get(selected.id) ?? tokenize(selected.content);
|
const selectedTokens = tokenCache.get(selected.id) ?? tokenize(selected.content);
|
||||||
const sim = jaccardSimilarity(itemTokens, selectedTokens);
|
const sim = jaccardSimilarity(itemTokens, selectedTokens);
|
||||||
if (sim > maxSim) maxSim = sim;
|
if (sim > maxSim) {
|
||||||
|
maxSim = sim;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return maxSim;
|
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;
|
const { enabled = DEFAULT_MMR_CONFIG.enabled, lambda = DEFAULT_MMR_CONFIG.lambda } = config;
|
||||||
|
|
||||||
// Early exits
|
// Early exits
|
||||||
if (!enabled || items.length <= 1) return [...items];
|
if (!enabled || items.length <= 1) {
|
||||||
|
return [...items];
|
||||||
|
}
|
||||||
|
|
||||||
// Clamp lambda to valid range
|
// Clamp lambda to valid range
|
||||||
const clampedLambda = Math.max(0, Math.min(1, lambda));
|
const clampedLambda = Math.max(0, Math.min(1, lambda));
|
||||||
|
|
||||||
// If lambda is 1, just return sorted by relevance (no diversity penalty)
|
// If lambda is 1, just return sorted by relevance (no diversity penalty)
|
||||||
if (clampedLambda === 1) {
|
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
|
// 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 scoreRange = maxScore - minScore;
|
||||||
|
|
||||||
const normalizeScore = (score: number): number => {
|
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;
|
return (score - minScore) / scoreRange;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +189,9 @@ export function mmrRerank<T extends MMRItem>(items: T[], config: Partial<MMRConf
|
|||||||
export function applyMMRToHybridResults<
|
export function applyMMRToHybridResults<
|
||||||
T extends { score: number; snippet: string; path: string; startLine: number },
|
T extends { score: number; snippet: string; path: string; startLine: number },
|
||||||
>(results: T[], config: Partial<MMRConfig> = {}): T[] {
|
>(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
|
// Create a map from ID to original item for type-safe retrieval
|
||||||
const itemById = new Map<string, T>();
|
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