Compare commits

...

22 Commits

Author SHA1 Message Date
Argenis 70e7910cb9 fix(web): remove unused import blocking release pipeline (#4234)
fix(web): remove unused import blocking release pipeline
2026-03-22 01:35:26 -04:00
argenis de la rosa a8868768e8 fix(web): remove unused ChevronsUpDown import blocking release pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 01:20:51 -04:00
Argenis 67293c50df chore: bump version to 0.5.7 (#4232)
chore: bump version to 0.5.7
2026-03-22 01:14:08 -04:00
argenis de la rosa 1646079d25 chore: bump version to 0.5.7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 00:49:41 -04:00
Argenis 25b639435f fix: merge voice-wake feature (PR #4162) with conflict resolution (#4225)
* feat(channels): add voice wake word detection channel

Add VoiceWakeChannel behind the `voice-wake` feature flag that:
- Captures audio from the default microphone via cpal
- Uses energy-based VAD to detect speech activity
- Transcribes speech via the existing transcription API (Whisper)
- Checks for a configurable wake word in the transcription
- On detection, captures the following utterance and dispatches it
  as a ChannelMessage

State machine: Listening -> Triggered -> Capturing -> Processing -> Listening

Config keys (under [channels_config.voice_wake]):
- wake_word (default: "hey zeroclaw")
- silence_timeout_ms (default: 2000)
- energy_threshold (default: 0.01)
- max_capture_secs (default: 30)

Includes tests for config parsing, state machine, RMS energy
computation, and WAV encoding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(config): fix pre-existing test compilation errors in schema.rs

- Remove #[cfg(unix)] gate on `use tempfile::TempDir` import since
  TempDir is used unconditionally in bootstrap file tests
- Add explicit type annotations on tokio::fs::* calls to resolve
  type inference failures (create_dir_all, write, read_to_string)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(channels): exclude voice-wake from all-features CI check

Add a `ci-all` meta-feature in Cargo.toml that includes every feature
except `voice-wake`, which requires `libasound2-dev` (ALSA) not present
on CI runners. Update the check-all-features CI job to use
`--features ci-all` instead of `--all-features`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Giulio V <vannini.gv@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:49:12 -04:00
Argenis 77779844e5 feat(memory): layered architecture upgrade + remove mem0 backend (#4226)
feat(memory): layered architecture upgrade + remove mem0 backend
2026-03-22 00:47:42 -04:00
Argenis f658d5806a fix: honor [autonomy] config section in daemon/channel mode
Fixes #4171
2026-03-22 00:47:32 -04:00
Argenis 7134fe0824 Merge pull request #4223 from zeroclaw-labs/fix/4214-heartbeat-utf8-safety
fix(heartbeat): prevent UTF-8 panic, add memory bounds and path validation
2026-03-22 00:41:47 -04:00
Argenis 263802b3df Merge pull request #4224 from zeroclaw-labs/fix/4215-thai-i18n-cleanup
fix(i18n): remove extra keys and translate notion in th.toml
2026-03-22 00:41:21 -04:00
Argenis 3c25fddb2a fix: merge Gmail Pub/Sub push PR #4164 (already integrated via #4200) (#4222)
* feat(channels): add Gmail Pub/Sub push notifications for real-time email

Add GmailPushChannel that replaces IMAP polling with Google's Pub/Sub
push notification system for real-time email-driven automation.

- New channel at src/channels/gmail_push.rs implementing the Channel trait
- Registers Gmail watch subscription (POST /gmail/v1/users/me/watch)
  with automatic renewal before the 7-day expiry
- Handles incoming Pub/Sub notifications at POST /webhook/gmail
- Fetches new messages via Gmail History API (startHistoryId-based)
- Dispatches email messages to the agent with full metadata
- Sends replies via Gmail messages.send API
- Config: gmail_push.enabled, topic, label_filter, oauth_token,
  allowed_senders, webhook_url
- OAuth token encrypted at rest via existing secret store
- Webhook endpoint added to gateway router
- 30+ unit tests covering notification parsing, header extraction,
  body decoding, sender allowlist, and config serialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(config): fix pre-existing test compilation errors in schema.rs

- Remove #[cfg(unix)] gate on `use tempfile::TempDir` import since
  TempDir is used unconditionally in bootstrap file tests
- Add explicit type annotations on tokio::fs::* calls to resolve
  type inference failures (create_dir_all, write, read_to_string)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(channels): fix extract_body_text_plain test

Gmail API sends base64url without padding. The decode_body function
converted URL-safe chars back to standard base64 but did not restore
the padding, causing STANDARD decoder to fail and falling back to
snippet. Add padding restoration before decoding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Giulio V <vannini.gv@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:40:42 -04:00
Argenis a6a46bdd25 fix: add weather tool to default auto_approve list
Fixes #4170
2026-03-22 00:21:33 -04:00
Argenis 235d4d2f1c fix: replace ILIKE substring matching with full-text search in postgres memory recall()
Fixes #4204
2026-03-22 00:20:11 -04:00
argenis de la rosa bd1e8c8e1a merge: resolve conflicts with master + remove memory-mem0 from ci-all
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 00:18:09 -04:00
Argenis f81807bff6 fix: serialize env-dependent codex tests to prevent race (#4210) (#4218)
Add a process-scoped Mutex that all env-var-mutating tests in
openai_codex::tests must hold.  This prevents std::env::set_var /
remove_var calls from racing when Rust's test harness runs them on
parallel threads.

Affected tests:
- resolve_responses_url_prefers_explicit_endpoint_env
- resolve_responses_url_uses_provider_api_url_override
- resolve_reasoning_effort_prefers_configured_override
- resolve_reasoning_effort_uses_legacy_env_when_unconfigured
2026-03-22 00:14:01 -04:00
argenis de la rosa bb7006313c feat(memory): layered architecture upgrade + remove mem0 backend
Implement 6-phase memory system improvement:
- Multi-stage retrieval pipeline (cache → FTS → vector)
- Namespace isolation with strict filtering
- Importance scoring (category + keyword heuristics)
- Conflict resolution via Jaccard similarity + superseded_by
- Audit trail decorator (AuditedMemory<M>)
- Policy engine (quotas, read-only namespaces, retention rules)
- Deterministic sort tiebreaker on equal scores

Remove mem0 (OpenMemory) backend — all capabilities now covered
natively with better performance (local SQLite vs external REST API).

46 battle tests + 262 existing tests pass. Backward-compatible:
existing databases auto-migrate, existing configs work unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 00:09:43 -04:00
Argenis 9a49626376 fix: use POSIX-compatible sh -c instead of dash-specific -lc (#4209) (#4217)
* fix: build web dashboard during install.sh (#4207)

* fix: use POSIX-compatible sh -c instead of dash-specific -lc in cron scheduler (#4209)
2026-03-22 00:07:37 -04:00
Argenis 8b978a721f fix: build web dashboard during install.sh (#4207) (#4216) 2026-03-22 00:02:54 -04:00
argenis de la rosa 75b4c1d4a4 fix(heartbeat): prevent UTF-8 panic, add memory bounds and path validation in session context
- Use char_indices for safe UTF-8 truncation instead of byte slicing
- Replace unbounded Vec with VecDeque rolling window in load_jsonl_messages
- Add path separator validation for channel/to to prevent directory traversal
2026-03-22 00:01:44 -04:00
argenis de la rosa b2087e6065 fix(i18n): remove extra keys and translate untranslated notion entry in th.toml 2026-03-21 23:59:46 -04:00
Nisit Sirimarnkit ad8f81ad76 Merge branch 'master' into i18n/thai-tool-descriptions 2026-03-22 10:28:47 +07:00
ninenox c58e1c1fb3 i18n: add Thai tool descriptions 2026-03-22 10:09:03 +07:00
Martin Minkus cb0779d761 feat(heartbeat): add load_session_context to inject conversation history
When `load_session_context = true` in `[heartbeat]`, the daemon loads the
last 20 messages from the target user's JSONL session file and prepends them
to the heartbeat task prompt before calling the LLM.

This gives the companion context — who the user is, what was last discussed —
so outreach messages feel like a natural continuation rather than a blank-slate
ping. Defaults to `false` (opt-in, no change to existing behaviour).

Key behaviours:
- Session context is re-read on every heartbeat tick (not cached at startup)
- Skips context injection if only assistant messages are present (prevents
  heartbeat outputs feeding back in a loop)
- Scans sessions directory for matching JSONL files using flexible filename
  matching: {channel}_{to}.jsonl, {channel}_*_{to}.jsonl, or
  {channel}_{to}_*.jsonl — handles varying session key formats
- Injects file mtime as "last message ~Xh ago" so the LLM knows how long
  the user has been silent

Config example:
  [heartbeat]
  enabled = true
  interval_minutes = 120
  load_session_context = true
  target = "telegram"
  to = "your_username"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:44:19 +00:00
42 changed files with 2920 additions and 1503 deletions
-97
View File
@@ -1,97 +0,0 @@
# Mem0 Integration: Dual-Scope Recall + Per-Turn Memory
## Context
Mem0 auto-save works but the integration is missing key features from mem0 best practices: per-turn recall, multi-level scoping, and proper context injection. This causes the bot to "forget" on follow-up turns and not differentiate users.
## What's Missing (vs mem0 docs)
1. **Per-turn recall** — only first turn gets memory context, follow-ups get nothing
2. **Dual-scope** — no sender vs group distinction. All memories use single hardcoded `user_id`
3. **System prompt injection** — memory prepended to user message (pollutes session history)
4. **`agent_id` scoping** — mem0 supports agent-level patterns, not used
## Changes
### 1. `src/memory/mem0.rs` — Use session_id for multi-level scoping
Map zeroclaw's `session_id` param to mem0's `user_id`. This enables per-user and per-group memory namespaces without changing the `Memory` trait.
```rust
// Add helper:
fn effective_user_id(&self, session_id: Option<&str>) -> &str {
session_id.filter(|s| !s.is_empty()).unwrap_or(&self.user_id)
}
// In store(): use effective_user_id(session_id) as mem0 user_id
// In recall(): use effective_user_id(session_id) as mem0 user_id
// In list(): use effective_user_id(session_id) as mem0 user_id
```
### 2. `src/channels/mod.rs` ~line 2229 — Per-turn dual-scope recall
Remove `if !had_prior_history` gate. Always recall from both sender scope and group scope (for group chats).
```rust
// Detect group chat
let is_group = msg.reply_target.contains("@g.us")
|| msg.reply_target.starts_with("group:");
// Sender-scope recall (always)
let sender_context = build_memory_context(
ctx.memory.as_ref(), &msg.content, ctx.min_relevance_score,
Some(&msg.sender),
).await;
// Group-scope recall (groups only)
let group_context = if is_group {
build_memory_context(
ctx.memory.as_ref(), &msg.content, ctx.min_relevance_score,
Some(&history_key),
).await
} else {
String::new()
};
// Merge (deduplicate by checking substring overlap)
let memory_context = merge_memory_contexts(&sender_context, &group_context);
```
### 3. `src/channels/mod.rs` ~line 2244 — Inject into system prompt
Move memory context from user message to system prompt. Re-fetched each turn, doesn't pollute session.
```rust
let mut system_prompt = build_channel_system_prompt(...);
if !memory_context.is_empty() {
system_prompt.push_str(&format!("\n\n{memory_context}"));
}
let mut history = vec![ChatMessage::system(system_prompt)];
```
### 4. `src/channels/mod.rs` — Dual-scope auto-save
Find existing auto-save call. For group messages, store twice:
- `store(key, content, category, Some(&msg.sender))` — personal facts
- `store(key, content, category, Some(&history_key))` — group context
Both async, non-blocking. DMs only store to sender scope.
### 5. `src/memory/mem0.rs` — Add `agent_id` support (optional)
Pass `self.app_name` as `agent_id` param to mem0 API for agent behavior tracking.
## Files to Modify
1. `src/memory/mem0.rs` — session_id → user_id mapping
2. `src/channels/mod.rs` — per-turn recall, dual-scope, system prompt injection, dual-scope save
## Verification
1. `cargo check --features whatsapp-web,memory-mem0`
2. `cargo test --features whatsapp-web,memory-mem0`
3. Deploy to Synology
4. Test DM: "我鍾意食壽司" → next turn "我鍾意食咩" → should recall
5. Test group: Joe says "我鍾意食壽司" → someone else asks "Joe 鍾意食咩" → should recall from group scope
6. Check mem0 server logs: GET with `user_id=sender` AND `user_id=group_key`
7. Check mem0 server logs: POST with both user_ids for group messages
Generated
+1 -1
View File
@@ -9530,7 +9530,7 @@ dependencies = [
[[package]]
name = "zeroclawlabs"
version = "0.5.6"
version = "0.5.7"
dependencies = [
"aardvark-sys",
"anyhow",
+1 -4
View File
@@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "zeroclawlabs"
version = "0.5.6"
version = "0.5.7"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"
@@ -231,8 +231,6 @@ channel-matrix = ["dep:matrix-sdk"]
channel-lark = ["dep:prost"]
channel-feishu = ["channel-lark"] # Alias for Feishu users (Lark and Feishu are the same platform)
memory-postgres = ["dep:postgres"]
# memory-mem0 = Mem0 (OpenMemory) memory backend via REST API
memory-mem0 = []
observability-prometheus = ["dep:prometheus"]
observability-otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp"]
peripheral-rpi = ["rppal"]
@@ -267,7 +265,6 @@ ci-all = [
"channel-matrix",
"channel-lark",
"memory-postgres",
"memory-mem0",
"observability-prometheus",
"observability-otel",
"peripheral-rpi",
-80
View File
@@ -1,80 +0,0 @@
#!/bin/bash
# Start mem0 + reranker GPU container for ZeroClaw memory backend.
#
# Required env vars:
# MEM0_LLM_API_KEY or ZAI_API_KEY — API key for the LLM used in fact extraction
#
# Optional env vars (with defaults):
# MEM0_LLM_PROVIDER — mem0 LLM provider (default: "openai" i.e. OpenAI-compatible)
# MEM0_LLM_MODEL — LLM model for fact extraction (default: "glm-5-turbo")
# MEM0_LLM_BASE_URL — LLM API base URL (default: "https://api.z.ai/api/coding/paas/v4")
# MEM0_EMBEDDER_MODEL — embedding model (default: "BAAI/bge-m3")
# MEM0_EMBEDDER_DIMS — embedding dimensions (default: "1024")
# MEM0_EMBEDDER_DEVICE — "cuda", "cpu", or "auto" (default: "cuda")
# MEM0_VECTOR_COLLECTION — Qdrant collection name (default: "zeroclaw_mem0")
# RERANKER_MODEL — reranker model (default: "BAAI/bge-reranker-v2-m3")
# RERANKER_DEVICE — "cuda" or "cpu" (default: "cuda")
# MEM0_PORT — mem0 server port (default: 8765)
# RERANKER_PORT — reranker server port (default: 8678)
# CONTAINER_IMAGE — base container image (default: docker.io/kyuz0/amd-strix-halo-comfyui:latest)
# CONTAINER_NAME — container name (default: mem0-gpu)
# DATA_DIR — host path for Qdrant data (default: ~/mem0-data)
# SCRIPT_DIR — host path for server scripts (default: directory of this script)
set -e
# Resolve script directory for mounting server scripts
SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}"
# API key — accept either name
export MEM0_LLM_API_KEY="${MEM0_LLM_API_KEY:-${ZAI_API_KEY:?MEM0_LLM_API_KEY or ZAI_API_KEY must be set}}"
# Defaults
MEM0_LLM_MODEL="${MEM0_LLM_MODEL:-glm-5-turbo}"
MEM0_LLM_BASE_URL="${MEM0_LLM_BASE_URL:-https://api.z.ai/api/coding/paas/v4}"
MEM0_PORT="${MEM0_PORT:-8765}"
RERANKER_PORT="${RERANKER_PORT:-8678}"
CONTAINER_IMAGE="${CONTAINER_IMAGE:-docker.io/kyuz0/amd-strix-halo-comfyui:latest}"
CONTAINER_NAME="${CONTAINER_NAME:-mem0-gpu}"
DATA_DIR="${DATA_DIR:-$HOME/mem0-data}"
# Stop existing CPU services (if any)
kill -9 $(pgrep -f "mem0-server.py") 2>/dev/null || true
kill -9 $(pgrep -f "reranker-server.py") 2>/dev/null || true
# Stop existing container
podman stop "$CONTAINER_NAME" 2>/dev/null || true
podman rm "$CONTAINER_NAME" 2>/dev/null || true
podman run -d --name "$CONTAINER_NAME" \
--device /dev/dri --device /dev/kfd \
--group-add video --group-add render \
--restart unless-stopped \
-p "$MEM0_PORT:$MEM0_PORT" -p "$RERANKER_PORT:$RERANKER_PORT" \
-v "$DATA_DIR":/root/mem0-data:Z \
-v "$SCRIPT_DIR/mem0-server.py":/app/mem0-server.py:ro,Z \
-v "$SCRIPT_DIR/reranker-server.py":/app/reranker-server.py:ro,Z \
-v "$HOME/.cache/huggingface":/root/.cache/huggingface:Z \
-e MEM0_LLM_API_KEY="$MEM0_LLM_API_KEY" \
-e ZAI_API_KEY="$MEM0_LLM_API_KEY" \
-e MEM0_LLM_MODEL="$MEM0_LLM_MODEL" \
-e MEM0_LLM_BASE_URL="$MEM0_LLM_BASE_URL" \
${MEM0_LLM_PROVIDER:+-e MEM0_LLM_PROVIDER="$MEM0_LLM_PROVIDER"} \
${MEM0_EMBEDDER_MODEL:+-e MEM0_EMBEDDER_MODEL="$MEM0_EMBEDDER_MODEL"} \
${MEM0_EMBEDDER_DIMS:+-e MEM0_EMBEDDER_DIMS="$MEM0_EMBEDDER_DIMS"} \
${MEM0_EMBEDDER_DEVICE:+-e MEM0_EMBEDDER_DEVICE="$MEM0_EMBEDDER_DEVICE"} \
${MEM0_VECTOR_COLLECTION:+-e MEM0_VECTOR_COLLECTION="$MEM0_VECTOR_COLLECTION"} \
${RERANKER_MODEL:+-e RERANKER_MODEL="$RERANKER_MODEL"} \
${RERANKER_DEVICE:+-e RERANKER_DEVICE="$RERANKER_DEVICE"} \
-e RERANKER_PORT="$RERANKER_PORT" \
-e RERANKER_URL="http://127.0.0.1:$RERANKER_PORT/rerank" \
-e TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 \
-e HOME=/root \
"$CONTAINER_IMAGE" \
bash -c "pip install -q FlagEmbedding mem0ai flask httpx qdrant-client 2>&1 | tail -3; echo '=== Starting reranker (GPU) on :$RERANKER_PORT ==='; python3 /app/reranker-server.py & sleep 3; echo '=== Starting mem0 (GPU) on :$MEM0_PORT ==='; exec python3 /app/mem0-server.py"
echo "Container started, waiting for init..."
sleep 15
echo "=== Container logs ==="
podman logs "$CONTAINER_NAME" 2>&1 | tail -25
echo "=== Port check ==="
ss -tlnp | grep "$MEM0_PORT\|$RERANKER_PORT" || echo "Ports not yet ready, check: podman logs $CONTAINER_NAME"
-288
View File
@@ -1,288 +0,0 @@
"""Minimal OpenMemory-compatible REST server wrapping mem0 Python SDK."""
import asyncio
import json, os, uuid, httpx
from datetime import datetime, timezone
from fastapi import FastAPI, Query
from pydantic import BaseModel
from typing import Optional
from mem0 import Memory
app = FastAPI()
RERANKER_URL = os.environ.get("RERANKER_URL", "http://127.0.0.1:8678/rerank")
CUSTOM_EXTRACTION_PROMPT = """You are a memory extraction specialist for a Cantonese/Chinese chat assistant.
Extract ONLY important, persistent facts from the conversation. Rules:
1. Extract personal preferences, habits, relationships, names, locations
2. Extract decisions, plans, and commitments people make
3. SKIP small talk, greetings, reactions ("ok", "哈哈", "係呀")
4. SKIP temporary states ("我依家食緊飯") unless they reveal a habit
5. Keep facts in the ORIGINAL language (Cantonese/Chinese/English)
6. For each fact, note WHO it's about (use their name or identifier if known)
7. Merge/update existing facts rather than creating duplicates
Return a list of facts in JSON format: {"facts": ["fact1", "fact2", ...]}
"""
PROCEDURAL_EXTRACTION_PROMPT = """You are a procedural memory specialist for an AI assistant.
Extract HOW-TO patterns and reusable procedures from the conversation trace. Rules:
1. Identify step-by-step procedures the assistant followed to accomplish a task
2. Extract tool usage patterns: which tools were called, in what order, with what arguments
3. Capture decision points: why the assistant chose one approach over another
4. Note error-recovery patterns: what failed, how it was fixed
5. Keep the procedure generic enough to apply to similar future tasks
6. Preserve technical details (commands, file paths, API calls) that are reusable
7. SKIP greetings, small talk, and conversational filler
8. Format each procedure as: "To [goal]: [step1] -> [step2] -> ... -> [result]"
Return a list of procedures in JSON format: {"facts": ["procedure1", "procedure2", ...]}
"""
# ── Configurable via environment variables ─────────────────────────
# LLM (for fact extraction when infer=true)
MEM0_LLM_PROVIDER = os.environ.get("MEM0_LLM_PROVIDER", "openai") # "openai" (compatible), "anthropic", etc.
MEM0_LLM_MODEL = os.environ.get("MEM0_LLM_MODEL", "glm-5-turbo")
MEM0_LLM_API_KEY = os.environ.get("MEM0_LLM_API_KEY") or os.environ.get("ZAI_API_KEY", "")
MEM0_LLM_BASE_URL = os.environ.get("MEM0_LLM_BASE_URL", "https://api.z.ai/api/coding/paas/v4")
# Embedder
MEM0_EMBEDDER_PROVIDER = os.environ.get("MEM0_EMBEDDER_PROVIDER", "huggingface") # "huggingface", "openai", etc.
MEM0_EMBEDDER_MODEL = os.environ.get("MEM0_EMBEDDER_MODEL", "BAAI/bge-m3")
MEM0_EMBEDDER_DIMS = int(os.environ.get("MEM0_EMBEDDER_DIMS", "1024"))
MEM0_EMBEDDER_DEVICE = os.environ.get("MEM0_EMBEDDER_DEVICE", "cuda") # "cuda", "cpu", "auto"
# Vector store
MEM0_VECTOR_PROVIDER = os.environ.get("MEM0_VECTOR_PROVIDER", "qdrant") # "qdrant", "chroma", etc.
MEM0_VECTOR_COLLECTION = os.environ.get("MEM0_VECTOR_COLLECTION", "zeroclaw_mem0")
MEM0_VECTOR_PATH = os.environ.get("MEM0_VECTOR_PATH", os.path.expanduser("~/mem0-data/qdrant"))
config = {
"llm": {
"provider": MEM0_LLM_PROVIDER,
"config": {
"model": MEM0_LLM_MODEL,
"api_key": MEM0_LLM_API_KEY,
"openai_base_url": MEM0_LLM_BASE_URL,
},
},
"embedder": {
"provider": MEM0_EMBEDDER_PROVIDER,
"config": {
"model": MEM0_EMBEDDER_MODEL,
"embedding_dims": MEM0_EMBEDDER_DIMS,
"model_kwargs": {"device": MEM0_EMBEDDER_DEVICE},
},
},
"vector_store": {
"provider": MEM0_VECTOR_PROVIDER,
"config": {
"collection_name": MEM0_VECTOR_COLLECTION,
"embedding_model_dims": MEM0_EMBEDDER_DIMS,
"path": MEM0_VECTOR_PATH,
},
},
"custom_fact_extraction_prompt": CUSTOM_EXTRACTION_PROMPT,
}
m = Memory.from_config(config)
def rerank_results(query: str, items: list, top_k: int = 10) -> list:
"""Rerank search results using bge-reranker-v2-m3."""
if not items:
return items
documents = [item.get("memory", "") for item in items]
try:
resp = httpx.post(
RERANKER_URL,
json={"query": query, "documents": documents, "top_k": top_k},
timeout=10.0,
)
resp.raise_for_status()
ranked = resp.json().get("results", [])
return [items[r["index"]] for r in ranked]
except Exception as e:
print(f"Reranker failed, using original order: {e}")
return items
class AddMemoryRequest(BaseModel):
user_id: str
text: str
metadata: Optional[dict] = None
infer: bool = True
app: Optional[str] = None
custom_instructions: Optional[str] = None
@app.post("/api/v1/memories/")
async def add_memory(req: AddMemoryRequest):
# Use client-supplied prompt, fall back to server default, then mem0 SDK default
prompt = req.custom_instructions or CUSTOM_EXTRACTION_PROMPT
result = await asyncio.to_thread(m.add, req.text, user_id=req.user_id, metadata=req.metadata or {}, prompt=prompt)
return {"id": str(uuid.uuid4()), "status": "ok", "result": result}
class ProceduralMemoryRequest(BaseModel):
user_id: str
messages: list[dict]
metadata: Optional[dict] = None
@app.post("/api/v1/memories/procedural")
async def add_procedural_memory(req: ProceduralMemoryRequest):
"""Store a conversation trace as procedural memory.
Accepts a list of messages (role/content dicts) representing a full
conversation turn including tool calls, then uses mem0's native
procedural memory extraction to learn reusable "how to" patterns.
"""
# Build metadata with procedural type marker
meta = {"type": "procedural"}
if req.metadata:
meta.update(req.metadata)
# Use mem0's native message list support + procedural prompt
result = await asyncio.to_thread(m.add,
req.messages,
user_id=req.user_id,
metadata=meta,
prompt=PROCEDURAL_EXTRACTION_PROMPT,
)
return {"id": str(uuid.uuid4()), "status": "ok", "result": result}
def _parse_mem0_results(raw_results) -> list:
raw = raw_results.get("results", raw_results) if isinstance(raw_results, dict) else raw_results
items = []
for r in raw:
item = r if isinstance(r, dict) else {"memory": str(r)}
items.append({
"id": item.get("id", str(uuid.uuid4())),
"memory": item.get("memory", item.get("text", "")),
"created_at": item.get("created_at", datetime.now(timezone.utc).isoformat()),
"metadata_": item.get("metadata", {}),
})
return items
def _parse_iso_timestamp(value: str) -> Optional[datetime]:
"""Parse an ISO 8601 timestamp string, returning None on failure."""
try:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, TypeError):
return None
def _item_created_at(item: dict) -> Optional[datetime]:
"""Extract created_at from an item as a timezone-aware datetime."""
raw = item.get("created_at")
if raw is None:
return None
if isinstance(raw, datetime):
if raw.tzinfo is None:
raw = raw.replace(tzinfo=timezone.utc)
return raw
return _parse_iso_timestamp(str(raw))
def _apply_post_filters(
items: list,
created_after: Optional[str],
created_before: Optional[str],
) -> list:
"""Filter items by created_after / created_before timestamps (post-query)."""
after_dt = _parse_iso_timestamp(created_after) if created_after else None
before_dt = _parse_iso_timestamp(created_before) if created_before else None
if after_dt is None and before_dt is None:
return items
filtered = []
for item in items:
ts = _item_created_at(item)
if ts is None:
# Keep items without a parseable timestamp
filtered.append(item)
continue
if after_dt and ts < after_dt:
continue
if before_dt and ts > before_dt:
continue
filtered.append(item)
return filtered
@app.get("/api/v1/memories/")
async def list_or_search_memories(
user_id: str = Query(...),
search_query: Optional[str] = Query(None),
size: int = Query(10),
rerank: bool = Query(True),
created_after: Optional[str] = Query(None),
created_before: Optional[str] = Query(None),
metadata_filter: Optional[str] = Query(None),
):
# Build mem0 SDK filters dict from metadata_filter JSON param
sdk_filters = None
if metadata_filter:
try:
sdk_filters = json.loads(metadata_filter)
except json.JSONDecodeError:
sdk_filters = None
if search_query:
# Fetch more results than needed so reranker has candidates to work with
fetch_size = min(size * 3, 50)
results = await asyncio.to_thread(m.search,
search_query,
user_id=user_id,
limit=fetch_size,
filters=sdk_filters,
)
items = _parse_mem0_results(results)
items = _apply_post_filters(items, created_after, created_before)
if rerank and items:
items = rerank_results(search_query, items, top_k=size)
else:
items = items[:size]
return {"items": items, "total": len(items)}
else:
results = await asyncio.to_thread(m.get_all,user_id=user_id, filters=sdk_filters)
items = _parse_mem0_results(results)
items = _apply_post_filters(items, created_after, created_before)
return {"items": items, "total": len(items)}
@app.delete("/api/v1/memories/{memory_id}")
async def delete_memory(memory_id: str):
try:
await asyncio.to_thread(m.delete, memory_id)
except Exception:
pass
return {"status": "ok"}
@app.get("/api/v1/memories/{memory_id}/history")
async def get_memory_history(memory_id: str):
"""Return the edit history of a specific memory."""
try:
history = await asyncio.to_thread(m.history, memory_id)
# Normalize to list of dicts
entries = []
raw = history if isinstance(history, list) else history.get("results", history) if isinstance(history, dict) else [history]
for h in raw:
entry = h if isinstance(h, dict) else {"event": str(h)}
entries.append(entry)
return {"memory_id": memory_id, "history": entries}
except Exception as e:
return {"memory_id": memory_id, "history": [], "error": str(e)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8765)
-50
View File
@@ -1,50 +0,0 @@
from flask import Flask, request, jsonify
from FlagEmbedding import FlagReranker
import os, torch
app = Flask(__name__)
reranker = None
# ── Configurable via environment variables ─────────────────────────
RERANKER_MODEL = os.environ.get("RERANKER_MODEL", "BAAI/bge-reranker-v2-m3")
RERANKER_DEVICE = os.environ.get("RERANKER_DEVICE", "cuda" if torch.cuda.is_available() else "cpu")
RERANKER_PORT = int(os.environ.get("RERANKER_PORT", "8678"))
def get_reranker():
global reranker
if reranker is None:
reranker = FlagReranker(RERANKER_MODEL, use_fp16=True, device=RERANKER_DEVICE)
return reranker
@app.route('/rerank', methods=['POST'])
def rerank():
data = request.json
query = data.get('query', '')
documents = data.get('documents', [])
top_k = data.get('top_k', len(documents))
if not query or not documents:
return jsonify({'error': 'query and documents required'}), 400
pairs = [[query, doc] for doc in documents]
scores = get_reranker().compute_score(pairs)
if isinstance(scores, float):
scores = [scores]
results = sorted(
[{'index': i, 'document': doc, 'score': score}
for i, (doc, score) in enumerate(zip(documents, scores))],
key=lambda x: x['score'], reverse=True
)[:top_k]
return jsonify({'results': results})
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': 'ok', 'model': RERANKER_MODEL, 'device': RERANKER_DEVICE})
if __name__ == '__main__':
print(f'Loading reranker model ({RERANKER_MODEL}) on {RERANKER_DEVICE}...')
get_reranker()
print(f'Reranker server ready on :{RERANKER_PORT}')
app.run(host='0.0.0.0', port=RERANKER_PORT)
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = zeroclaw
pkgdesc = Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.
pkgver = 0.5.6
pkgver = 0.5.7
pkgrel = 1
url = https://github.com/zeroclaw-labs/zeroclaw
arch = x86_64
@@ -10,7 +10,7 @@ pkgbase = zeroclaw
makedepends = git
depends = gcc-libs
depends = openssl
source = zeroclaw-0.5.6.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v0.5.6.tar.gz
source = zeroclaw-0.5.7.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v0.5.7.tar.gz
sha256sums = SKIP
pkgname = zeroclaw
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: zeroclaw-labs <bot@zeroclaw.dev>
pkgname=zeroclaw
pkgver=0.5.6
pkgver=0.5.7
pkgrel=1
pkgdesc="Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant."
arch=('x86_64')
+2 -2
View File
@@ -1,11 +1,11 @@
{
"version": "0.5.6",
"version": "0.5.7",
"description": "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.",
"homepage": "https://github.com/zeroclaw-labs/zeroclaw",
"license": "MIT|Apache-2.0",
"architecture": {
"64bit": {
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.5.6/zeroclaw-x86_64-pc-windows-msvc.zip",
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.5.7/zeroclaw-x86_64-pc-windows-msvc.zip",
"hash": "",
"bin": "zeroclaw.exe"
}
@@ -411,30 +411,6 @@ allowed_roots = [\"~/Desktop/projects\", \"/opt/shared-repo\"]
- 内存上下文注入忽略旧的 `assistant_resp*` 自动保存键,以防止旧模型生成的摘要被视为事实。
### `[memory.mem0]`
Mem0 (OpenMemory) 后端 — 连接自托管 mem0 服务器,提供基于向量的记忆存储和 LLM 事实提取。构建时需要 `memory-mem0` feature flag,配置需设置 `backend = "mem0"`
| 键 | 默认值 | 环境变量 | 用途 |
|---|---|---|---|
| `url` | `http://localhost:8765` | `MEM0_URL` | OpenMemory 服务器地址 |
| `user_id` | `zeroclaw` | `MEM0_USER_ID` | 记忆作用域的用户 ID |
| `app_name` | `zeroclaw` | `MEM0_APP_NAME` | 在 mem0 中注册的应用名称 |
| `infer` | `true` | — | 使用 LLM 从存储文本中提取事实 (`true`) 或原样存储 (`false`) |
| `extraction_prompt` | 未设置 | `MEM0_EXTRACTION_PROMPT` | 自定义 LLM 事实提取提示词(如适用于非英文内容) |
```toml
[memory]
backend = "mem0"
[memory.mem0]
url = "http://192.168.0.171:8765"
user_id = "zeroclaw-bot"
extraction_prompt = "用原始语言提取事实..."
```
服务器部署脚本位于 `deploy/mem0/`
## `[[model_routes]]``[[embedding_routes]]`
使用路由提示,以便集成可以在模型 ID 演变时保持稳定的名称。
-24
View File
@@ -508,30 +508,6 @@ Notes:
- Memory context injection ignores legacy `assistant_resp*` auto-save keys to prevent old model-authored summaries from being treated as facts.
### `[memory.mem0]`
Mem0 (OpenMemory) backend — connects to a self-hosted mem0 server for vector-based memory with LLM-powered fact extraction. Requires feature flag `memory-mem0` at build time and `backend = "mem0"` in config.
| Key | Default | Env var | Purpose |
|---|---|---|---|
| `url` | `http://localhost:8765` | `MEM0_URL` | OpenMemory server URL |
| `user_id` | `zeroclaw` | `MEM0_USER_ID` | User ID for scoping memories |
| `app_name` | `zeroclaw` | `MEM0_APP_NAME` | Application name registered in mem0 |
| `infer` | `true` | — | Use LLM to extract facts from stored text (`true`) or store raw (`false`) |
| `extraction_prompt` | unset | `MEM0_EXTRACTION_PROMPT` | Custom prompt for LLM fact extraction (e.g. for non-English content) |
```toml
[memory]
backend = "mem0"
[memory.mem0]
url = "http://192.168.0.171:8765"
user_id = "zeroclaw-bot"
extraction_prompt = "Extract facts in the original language..."
```
Server deployment scripts are in `deploy/mem0/`.
## `[[model_routes]]` and `[[embedding_routes]]`
Use route hints so integrations can keep stable names while model IDs evolve.
-24
View File
@@ -337,30 +337,6 @@ Lưu ý:
- Chèn ngữ cảnh memory bỏ qua khóa auto-save `assistant_resp*` kiểu cũ để tránh tóm tắt do model tạo bị coi là sự thật.
### `[memory.mem0]`
Backend Mem0 (OpenMemory) — kết nối đến server mem0 tự host, cung cấp bộ nhớ vector với trích xuất sự kiện bằng LLM. Cần feature flag `memory-mem0` khi build và `backend = "mem0"` trong config.
| Khóa | Mặc định | Biến môi trường | Mục đích |
|---|---|---|---|
| `url` | `http://localhost:8765` | `MEM0_URL` | URL server OpenMemory |
| `user_id` | `zeroclaw` | `MEM0_USER_ID` | User ID để phân vùng memory |
| `app_name` | `zeroclaw` | `MEM0_APP_NAME` | Tên ứng dụng đăng ký trong mem0 |
| `infer` | `true` | — | Dùng LLM trích xuất sự kiện từ text (`true`) hoặc lưu nguyên (`false`) |
| `extraction_prompt` | chưa đặt | `MEM0_EXTRACTION_PROMPT` | Prompt tùy chỉnh cho trích xuất sự kiện LLM (vd: cho nội dung không phải tiếng Anh) |
```toml
[memory]
backend = "mem0"
[memory.mem0]
url = "http://192.168.0.171:8765"
user_id = "zeroclaw-bot"
extraction_prompt = "Trích xuất sự kiện bằng ngôn ngữ gốc..."
```
Script triển khai server nằm trong `deploy/mem0/`.
## `[[model_routes]]``[[embedding_routes]]`
Route hint giúp tên tích hợp ổn định khi model ID thay đổi.
+19
View File
@@ -1448,6 +1448,25 @@ else
step_dot "Skipping install"
fi
# --- Build web dashboard ---
if [[ "$SKIP_BUILD" == false && -d "$WORK_DIR/web" ]]; then
if have_cmd node && have_cmd npm; then
step_dot "Building web dashboard"
if (cd "$WORK_DIR/web" && npm ci --ignore-scripts 2>/dev/null && npm run build 2>/dev/null); then
step_ok "Web dashboard built"
else
warn "Web dashboard build failed — dashboard will not be available"
fi
else
warn "node/npm not found — skipping web dashboard build"
warn "Install Node.js (>=18) and re-run, or build manually: cd web && npm ci && npm run build"
fi
else
if [[ "$SKIP_BUILD" == true ]]; then
step_dot "Skipping web dashboard build"
fi
fi
ZEROCLAW_BIN=""
if [[ -x "$HOME/.cargo/bin/zeroclaw" ]]; then
ZEROCLAW_BIN="$HOME/.cargo/bin/zeroclaw"
+9
View File
@@ -118,6 +118,9 @@ mod tests {
timestamp: "now".into(),
session_id: None,
score: None,
namespace: "default".into(),
importance: None,
superseded_by: None,
}])
}
@@ -226,6 +229,9 @@ mod tests {
timestamp: "now".into(),
session_id: None,
score: Some(0.95),
namespace: "default".into(),
importance: None,
superseded_by: None,
},
MemoryEntry {
id: "2".into(),
@@ -235,6 +241,9 @@ mod tests {
timestamp: "now".into(),
session_id: None,
score: Some(0.9),
namespace: "default".into(),
importance: None,
superseded_by: None,
},
]),
};
+3
View File
@@ -6875,6 +6875,9 @@ BTC is currently around $65,000 based on latest tool output."#
timestamp: "2026-02-20T00:00:00Z".to_string(),
session_id: None,
score: Some(0.9),
namespace: "default".into(),
importance: None,
superseded_by: None,
}])
}
+49 -104
View File
@@ -21,12 +21,6 @@ use super::traits::{Channel, ChannelMessage, SendMessage};
// ── State machine ──────────────────────────────────────────────
/// Maximum allowed capture duration (seconds) to prevent unbounded memory growth.
const MAX_CAPTURE_SECS_LIMIT: u32 = 300;
/// Minimum silence timeout to prevent API hammering.
const MIN_SILENCE_TIMEOUT_MS: u32 = 100;
/// Internal states for the wake-word detector.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WakeState {
@@ -36,6 +30,8 @@ pub enum WakeState {
Triggered,
/// Wake word confirmed — capturing the full utterance that follows.
Capturing,
/// Captured audio is being transcribed.
Processing,
}
impl std::fmt::Display for WakeState {
@@ -44,6 +40,7 @@ impl std::fmt::Display for WakeState {
Self::Listening => write!(f, "Listening"),
Self::Triggered => write!(f, "Triggered"),
Self::Capturing => write!(f, "Capturing"),
Self::Processing => write!(f, "Processing"),
}
}
}
@@ -81,97 +78,55 @@ impl Channel for VoiceWakeChannel {
let config = self.config.clone();
let transcription_config = self.transcription_config.clone();
// ── Validate config ───────────────────────────────────
let energy_threshold = config.energy_threshold;
if !energy_threshold.is_finite() || energy_threshold <= 0.0 {
bail!("VoiceWake: energy_threshold must be a positive finite number, got {energy_threshold}");
}
if config.silence_timeout_ms < MIN_SILENCE_TIMEOUT_MS {
bail!(
"VoiceWake: silence_timeout_ms must be >= {MIN_SILENCE_TIMEOUT_MS}, got {}",
config.silence_timeout_ms
);
}
let max_capture_secs = config.max_capture_secs.min(MAX_CAPTURE_SECS_LIMIT);
if max_capture_secs != config.max_capture_secs {
warn!(
"VoiceWake: max_capture_secs clamped from {} to {MAX_CAPTURE_SECS_LIMIT}",
config.max_capture_secs
);
}
// Run the blocking audio capture loop on a dedicated thread.
let (audio_tx, mut audio_rx) = mpsc::channel::<Vec<f32>>(64);
let (audio_tx, mut audio_rx) = mpsc::channel::<Vec<f32>>(4);
let energy_threshold = config.energy_threshold;
let silence_timeout = Duration::from_millis(u64::from(config.silence_timeout_ms));
let max_capture = Duration::from_secs(u64::from(max_capture_secs));
let max_capture = Duration::from_secs(u64::from(config.max_capture_secs));
let sample_rate: u32;
let channels_count: u16;
// ── Initialise cpal stream ────────────────────────────
// cpal::Stream is !Send, so we build and hold it on a dedicated thread.
// When the listen function exits, the shutdown oneshot is dropped,
// the thread exits, and the stream + microphone are released.
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let (init_tx, init_rx) = tokio::sync::oneshot::channel::<Result<(u32, u16)>>();
{
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| anyhow::anyhow!("No default audio input device available"))?;
let supported = device.default_input_config()?;
sample_rate = supported.sample_rate().0;
channels_count = supported.channels();
info!(
device = ?device.name().unwrap_or_default(),
sample_rate,
channels = channels_count,
"VoiceWake: opening audio input"
);
let stream_config: cpal::StreamConfig = supported.into();
let audio_tx_clone = audio_tx.clone();
std::thread::spawn(move || {
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let stream = device.build_input_stream(
&stream_config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
// Non-blocking: try_send and drop if full.
let _ = audio_tx_clone.try_send(data.to_vec());
},
move |err| {
warn!("VoiceWake: audio stream error: {err}");
},
None,
)?;
let result = (|| -> Result<(u32, u16, cpal::Stream)> {
let host = cpal::default_host();
let device = host.default_input_device().ok_or_else(|| {
anyhow::anyhow!("No default audio input device available")
})?;
stream.play()?;
let supported = device.default_input_config()?;
let sr = supported.sample_rate().0;
let ch = supported.channels();
info!(
device = ?device.name().unwrap_or_default(),
sample_rate = sr,
channels = ch,
"VoiceWake: opening audio input"
);
let stream_config: cpal::StreamConfig = supported.into();
let stream = device.build_input_stream(
&stream_config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
let _ = audio_tx_clone.try_send(data.to_vec());
},
move |err| {
warn!("VoiceWake: audio stream error: {err}");
},
None,
)?;
stream.play()?;
Ok((sr, ch, stream))
})();
match result {
Ok((sr, ch, _stream)) => {
let _ = init_tx.send(Ok((sr, ch)));
// Hold the stream alive until shutdown is signalled.
let _ = shutdown_rx.blocking_recv();
debug!("VoiceWake: stream holder thread exiting");
}
Err(e) => {
let _ = init_tx.send(Err(e));
}
}
});
let (sr, ch) = init_rx
.await
.map_err(|_| anyhow::anyhow!("VoiceWake: stream init thread panicked"))??;
sample_rate = sr;
channels_count = ch;
// Keep the stream alive for the lifetime of the channel.
// We leak it intentionally — the channel runs until the daemon shuts down.
std::mem::forget(stream);
}
// Drop the extra sender so the channel closes when the stream sender drops.
@@ -185,10 +140,6 @@ impl Channel for VoiceWakeChannel {
let mut capture_start = Instant::now();
let mut msg_counter: u64 = 0;
// Hard cap on capture buffer: max_capture_secs * sample_rate * channels * 2 (safety margin).
let max_buf_samples =
max_capture_secs as usize * sample_rate as usize * channels_count as usize * 2;
info!(wake_word = %wake_word, "VoiceWake: entering listen loop");
while let Some(chunk) = audio_rx.recv().await {
@@ -209,9 +160,7 @@ impl Channel for VoiceWakeChannel {
}
}
WakeState::Triggered => {
if capture_buf.len() + chunk.len() <= max_buf_samples {
capture_buf.extend_from_slice(&chunk);
}
capture_buf.extend_from_slice(&chunk);
if energy >= energy_threshold {
last_voice_at = Instant::now();
@@ -253,9 +202,7 @@ impl Channel for VoiceWakeChannel {
}
}
WakeState::Capturing => {
if capture_buf.len() + chunk.len() <= max_buf_samples {
capture_buf.extend_from_slice(&chunk);
}
capture_buf.extend_from_slice(&chunk);
if energy >= energy_threshold {
last_voice_at = Instant::now();
@@ -307,11 +254,13 @@ impl Channel for VoiceWakeChannel {
capture_buf.clear();
}
}
WakeState::Processing => {
// Should not receive chunks while processing, but just buffer them.
// State transitions happen above synchronously after transcription.
}
}
}
// Signal the stream holder thread to exit and release the microphone.
drop(shutdown_tx);
bail!("VoiceWake: audio stream ended unexpectedly");
}
}
@@ -334,14 +283,8 @@ pub fn encode_wav_from_f32(samples: &[f32], sample_rate: u32, channels: u16) ->
let bits_per_sample: u16 = 16;
let byte_rate = u32::from(channels) * sample_rate * u32::from(bits_per_sample) / 8;
let block_align = channels * bits_per_sample / 8;
// Guard against u32 overflow — reject buffers that exceed WAV's 4 GB limit.
let data_bytes = samples.len() * 2;
assert!(
u32::try_from(data_bytes).is_ok(),
"audio buffer too large for WAV encoding ({data_bytes} bytes)"
);
#[allow(clippy::cast_possible_truncation)]
let data_len = data_bytes as u32;
let data_len = (samples.len() * 2) as u32; // 16-bit = 2 bytes per sample; max ~25 MB
let file_len = 36 + data_len;
let mut buf = Vec::with_capacity(file_len as usize + 8);
@@ -389,6 +332,7 @@ mod tests {
assert_eq!(WakeState::Listening.to_string(), "Listening");
assert_eq!(WakeState::Triggered.to_string(), "Triggered");
assert_eq!(WakeState::Capturing.to_string(), "Capturing");
assert_eq!(WakeState::Processing.to_string(), "Processing");
}
#[test]
@@ -557,6 +501,7 @@ mod tests {
WakeState::Listening,
WakeState::Triggered,
WakeState::Capturing,
WakeState::Processing,
];
for (i, a) in states.iter().enumerate() {
for (j, b) in states.iter().enumerate() {
+3 -3
View File
@@ -19,9 +19,9 @@ pub use schema::{
ImageProviderFluxConfig, ImageProviderImagenConfig, ImageProviderStabilityConfig, JiraConfig,
KnowledgeConfig, LarkConfig, LinkedInConfig, LinkedInContentConfig, LinkedInImageConfig,
LocalWhisperConfig, MatrixConfig, McpConfig, McpServerConfig, McpTransport, MemoryConfig,
Microsoft365Config, ModelRouteConfig, MultimodalConfig, NextcloudTalkConfig,
NodeTransportConfig, NodesConfig, NotionConfig, ObservabilityConfig, OpenAiSttConfig,
OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig, OtpMethod, PacingConfig,
MemoryPolicyConfig, Microsoft365Config, ModelRouteConfig, MultimodalConfig,
NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig, ObservabilityConfig,
OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig, OtpMethod, PacingConfig,
PeripheralBoardConfig, PeripheralsConfig, PluginsConfig, ProjectIntelConfig, ProxyConfig,
ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
+146 -86
View File
@@ -3809,77 +3809,6 @@ impl Default for QdrantConfig {
}
}
/// Configuration for the mem0 (OpenMemory) memory backend.
///
/// Connects to a self-hosted OpenMemory server via its REST API.
/// Deploy OpenMemory with `docker compose up` from the mem0 repo,
/// then point `url` at the API (default `http://localhost:8765`).
///
/// ```toml
/// [memory]
/// backend = "mem0"
///
/// [memory.mem0]
/// url = "http://localhost:8765"
/// user_id = "zeroclaw"
/// app_name = "zeroclaw"
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Mem0Config {
/// OpenMemory server URL (e.g. `http://localhost:8765`).
/// Falls back to `MEM0_URL` env var if not set.
#[serde(default = "default_mem0_url")]
pub url: String,
/// User ID for scoping memories within mem0.
/// Falls back to `MEM0_USER_ID` env var, or default `"zeroclaw"`.
#[serde(default = "default_mem0_user_id")]
pub user_id: String,
/// Application name registered in mem0.
/// Falls back to `MEM0_APP_NAME` env var, or default `"zeroclaw"`.
#[serde(default = "default_mem0_app_name")]
pub app_name: String,
/// Whether mem0 should use its built-in LLM to extract facts from
/// stored text (`infer = true`) or store raw text as-is (`false`).
#[serde(default = "default_mem0_infer")]
pub infer: bool,
/// Custom prompt for guiding LLM-based fact extraction when `infer = true`.
/// Useful for non-English content (e.g. Cantonese/Chinese).
/// Falls back to `MEM0_EXTRACTION_PROMPT` env var.
/// If unset, the mem0 server uses its built-in default prompt.
#[serde(default = "default_mem0_extraction_prompt")]
pub extraction_prompt: Option<String>,
}
fn default_mem0_url() -> String {
std::env::var("MEM0_URL").unwrap_or_else(|_| "http://localhost:8765".into())
}
fn default_mem0_user_id() -> String {
std::env::var("MEM0_USER_ID").unwrap_or_else(|_| "zeroclaw".into())
}
fn default_mem0_app_name() -> String {
std::env::var("MEM0_APP_NAME").unwrap_or_else(|_| "zeroclaw".into())
}
fn default_mem0_infer() -> bool {
true
}
fn default_mem0_extraction_prompt() -> Option<String> {
std::env::var("MEM0_EXTRACTION_PROMPT")
.ok()
.filter(|s| !s.trim().is_empty())
}
impl Default for Mem0Config {
fn default() -> Self {
Self {
url: default_mem0_url(),
user_id: default_mem0_user_id(),
app_name: default_mem0_app_name(),
infer: default_mem0_infer(),
extraction_prompt: default_mem0_extraction_prompt(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[allow(clippy::struct_excessive_bools)]
pub struct MemoryConfig {
@@ -3954,6 +3883,43 @@ pub struct MemoryConfig {
#[serde(default = "default_true")]
pub auto_hydrate: bool,
// ── Retrieval Pipeline ─────────────────────────────────────
/// Retrieval stages to execute in order. Valid: "cache", "fts", "vector".
#[serde(default = "default_retrieval_stages")]
pub retrieval_stages: Vec<String>,
/// Enable LLM reranking when candidate count exceeds threshold.
#[serde(default)]
pub rerank_enabled: bool,
/// Minimum candidate count to trigger reranking.
#[serde(default = "default_rerank_threshold")]
pub rerank_threshold: usize,
/// FTS score above which to early-return without vector search (0.01.0).
#[serde(default = "default_fts_early_return_score")]
pub fts_early_return_score: f64,
// ── Namespace Isolation ─────────────────────────────────────
/// Default namespace for memory entries.
#[serde(default = "default_namespace")]
pub default_namespace: String,
// ── Conflict Resolution ─────────────────────────────────────
/// Cosine similarity threshold for conflict detection (0.01.0).
#[serde(default = "default_conflict_threshold")]
pub conflict_threshold: f64,
// ── Audit Trail ─────────────────────────────────────────────
/// Enable audit logging of memory operations.
#[serde(default)]
pub audit_enabled: bool,
/// Retention period for audit entries in days (default: 30).
#[serde(default = "default_audit_retention_days")]
pub audit_retention_days: u32,
// ── Policy Engine ───────────────────────────────────────────
/// Memory policy configuration.
#[serde(default)]
pub policy: MemoryPolicyConfig,
// ── SQLite backend options ─────────────────────────────────
/// For sqlite backend: max seconds to wait when opening the DB (e.g. file locked).
/// None = wait indefinitely (default). Recommended max: 300.
@@ -3965,13 +3931,42 @@ pub struct MemoryConfig {
/// Only used when `backend = "qdrant"`.
#[serde(default)]
pub qdrant: QdrantConfig,
}
// ── Mem0 backend options ─────────────────────────────────
/// Configuration for mem0 (OpenMemory) backend.
/// Only used when `backend = "mem0"`.
/// Requires `--features memory-mem0` at build time.
/// Memory policy configuration (`[memory.policy]` section).
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct MemoryPolicyConfig {
/// Maximum entries per namespace (0 = unlimited).
#[serde(default)]
pub mem0: Mem0Config,
pub max_entries_per_namespace: usize,
/// Maximum entries per category (0 = unlimited).
#[serde(default)]
pub max_entries_per_category: usize,
/// Retention days by category (overrides global). Keys: "core", "daily", "conversation".
#[serde(default)]
pub retention_days_by_category: std::collections::HashMap<String, u32>,
/// Namespaces that are read-only (writes are rejected).
#[serde(default)]
pub read_only_namespaces: Vec<String>,
}
fn default_retrieval_stages() -> Vec<String> {
vec!["cache".into(), "fts".into(), "vector".into()]
}
fn default_rerank_threshold() -> usize {
5
}
fn default_fts_early_return_score() -> f64 {
0.85
}
fn default_namespace() -> String {
"default".into()
}
fn default_conflict_threshold() -> f64 {
0.85
}
fn default_audit_retention_days() -> u32 {
30
}
fn default_embedding_provider() -> String {
@@ -4045,9 +4040,17 @@ impl Default for MemoryConfig {
snapshot_enabled: false,
snapshot_on_hygiene: false,
auto_hydrate: true,
retrieval_stages: default_retrieval_stages(),
rerank_enabled: false,
rerank_threshold: default_rerank_threshold(),
fts_early_return_score: default_fts_early_return_score(),
default_namespace: default_namespace(),
conflict_threshold: default_conflict_threshold(),
audit_enabled: false,
audit_retention_days: default_audit_retention_days(),
policy: MemoryPolicyConfig::default(),
sqlite_open_timeout_secs: None,
qdrant: QdrantConfig::default(),
mem0: Mem0Config::default(),
}
}
}
@@ -4256,6 +4259,7 @@ fn default_auto_approve() -> Vec<String> {
"glob_search".into(),
"content_search".into(),
"image_info".into(),
"weather".into(),
]
}
@@ -4642,6 +4646,7 @@ pub struct ClassificationRule {
/// Heartbeat configuration for periodic health pings (`[heartbeat]` section).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[allow(clippy::struct_excessive_bools)]
pub struct HeartbeatConfig {
/// Enable periodic heartbeat pings. Default: `false`.
pub enabled: bool,
@@ -4688,6 +4693,14 @@ pub struct HeartbeatConfig {
/// Maximum number of heartbeat run history records to retain. Default: `100`.
#[serde(default = "default_heartbeat_max_run_history")]
pub max_run_history: u32,
/// Load the channel session history before each heartbeat task execution so
/// the LLM has conversational context. Default: `false`.
///
/// When `true`, the session file for the configured `target`/`to` is passed
/// to the agent as `session_state_file`, giving it access to the recent
/// conversation history — just as if the user had sent a message.
#[serde(default)]
pub load_session_context: bool,
}
fn default_heartbeat_interval() -> u32 {
@@ -4726,6 +4739,7 @@ impl Default for HeartbeatConfig {
deadman_channel: None,
deadman_to: None,
max_run_history: default_heartbeat_max_run_history(),
load_session_context: false,
}
}
}
@@ -7516,22 +7530,37 @@ impl Config {
.await
.context("Failed to read config file")?;
// Track ignored/unknown config keys to warn users about silent misconfigurations
// (e.g., using [providers.ollama] which doesn't exist instead of top-level api_url)
// Deserialize the config with the standard TOML parser.
//
// Previously this used `serde_ignored::deserialize` for both
// deserialization and unknown-key detection. However,
// `serde_ignored` silently drops field values inside nested
// structs that carry `#[serde(default)]` (e.g. the entire
// `[autonomy]` table), causing user-supplied values to be
// replaced by defaults. See #4171.
//
// We now deserialize with `toml::from_str` (which is correct)
// and run `serde_ignored` separately just for diagnostics.
let mut config: Config =
toml::from_str(&contents).context("Failed to deserialize config file")?;
// Detect unknown/ignored config keys for diagnostic warnings.
// This second pass uses serde_ignored but discards the parsed
// result — only the ignored-path list is kept.
let mut ignored_paths: Vec<String> = Vec::new();
let mut config: Config = serde_ignored::deserialize(
toml::de::Deserializer::parse(&contents).context("Failed to parse config file")?,
let _: Result<Config, _> = serde_ignored::deserialize(
toml::de::Deserializer::parse(&contents)
.unwrap_or_else(|_| unreachable!("already parsed above")),
|path| {
ignored_paths.push(path.to_string());
},
)
.context("Failed to deserialize config file")?;
);
// Warn about each unknown config key.
// serde_ignored + #[serde(default)] on nested structs can produce
// false positives: parent-level fields get re-reported under the
// nested key (e.g. "memory.mem0.auto_hydrate" even though
// auto_hydrate belongs to MemoryConfig, not Mem0Config). We
// nested key (e.g. "memory.qdrant.auto_hydrate" even though
// auto_hydrate belongs to MemoryConfig, not QdrantConfig). We
// suppress these by checking whether the leaf key is a known field
// on the parent struct.
let known_memory_fields: &[&str] = &[
@@ -7560,7 +7589,7 @@ impl Config {
];
for path in ignored_paths {
// Skip false positives from nested memory sub-sections
if path.starts_with("memory.mem0.") || path.starts_with("memory.qdrant.") {
if path.starts_with("memory.qdrant.") {
let leaf = path.rsplit('.').next().unwrap_or("");
if known_memory_fields.contains(&leaf) {
continue;
@@ -9986,6 +10015,37 @@ default_temperature = 0.7
assert_eq!(parsed.provider_timeout_secs, 120);
}
/// Regression test for #4171: the `[autonomy]` section must not be
/// silently dropped when parsing config TOML.
#[test]
async fn autonomy_section_is_not_silently_ignored() {
let raw = r#"
default_temperature = 0.7
[autonomy]
level = "full"
max_actions_per_hour = 99
auto_approve = ["file_read", "memory_recall", "http_request"]
"#;
let parsed = parse_test_config(raw);
assert_eq!(
parsed.autonomy.level,
AutonomyLevel::Full,
"autonomy.level must be parsed from config (was silently defaulting to Supervised)"
);
assert_eq!(
parsed.autonomy.max_actions_per_hour, 99,
"autonomy.max_actions_per_hour must be parsed from config"
);
assert!(
parsed
.autonomy
.auto_approve
.contains(&"http_request".to_string()),
"autonomy.auto_approve must include http_request from config"
);
}
#[test]
async fn provider_timeout_secs_parses_from_toml() {
let raw = r#"
+193 -1
View File
@@ -362,10 +362,22 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
};
// ── Phase 2: Execute selected tasks ─────────────────────
// Re-read session context on every tick so we pick up messages
// that arrived since the daemon started.
let session_context = if config.heartbeat.load_session_context {
load_heartbeat_session_context(&config)
} else {
None
};
let mut tick_had_error = false;
for task in &tasks_to_run {
let task_start = std::time::Instant::now();
let prompt = format!("[Heartbeat Task | {}] {}", task.priority, task.text);
let task_prompt = format!("[Heartbeat Task | {}] {}", task.priority, task.text);
let prompt = match &session_context {
Some(ctx) => format!("{ctx}\n\n{task_prompt}"),
None => task_prompt,
};
let temp = config.default_temperature;
match Box::pin(crate::agent::run(
config.clone(),
@@ -497,6 +509,186 @@ fn resolve_heartbeat_delivery(config: &Config) -> Result<Option<(String, String)
}
}
/// Load recent conversation history for the heartbeat's delivery target and
/// format it as a text preamble to inject into the task prompt.
///
/// Scans `{workspace}/sessions/` for JSONL files whose name starts with
/// `{channel}_` and ends with `_{to}.jsonl` (or exactly `{channel}_{to}.jsonl`),
/// then picks the most recently modified match. This handles session key
/// formats such as `telegram_diskiller.jsonl` and
/// `telegram_5673725398_diskiller.jsonl`.
/// Returns `None` when `target`/`to` are not configured or no session exists.
const HEARTBEAT_SESSION_CONTEXT_MESSAGES: usize = 20;
fn load_heartbeat_session_context(config: &Config) -> Option<String> {
use crate::providers::traits::ChatMessage;
let channel = config
.heartbeat
.target
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())?;
let to = config
.heartbeat
.to
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())?;
if channel.contains('/') || channel.contains('\\') || to.contains('/') || to.contains('\\') {
tracing::warn!("heartbeat session context: channel/to contains path separators, skipping");
return None;
}
let sessions_dir = config.workspace_dir.join("sessions");
// Find the most recently modified JSONL file that belongs to this target.
// Matches both `{channel}_{to}.jsonl` and `{channel}_{anything}_{to}.jsonl`.
let prefix = format!("{channel}_");
let suffix = format!("_{to}.jsonl");
let exact = format!("{channel}_{to}.jsonl");
let mid_prefix = format!("{channel}_{to}_");
let path = std::fs::read_dir(&sessions_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.ends_with(".jsonl")
&& (name == exact
|| (name.starts_with(&prefix) && name.ends_with(&suffix))
|| name.starts_with(&mid_prefix))
})
.max_by_key(|e| {
e.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
})
.map(|e| e.path())?;
if !path.exists() {
tracing::debug!("💓 Heartbeat session context: no session file found for {channel}/{to}");
return None;
}
let messages = load_jsonl_messages(&path);
if messages.is_empty() {
return None;
}
let recent: Vec<&ChatMessage> = messages
.iter()
.filter(|m| m.role == "user" || m.role == "assistant")
.rev()
.take(HEARTBEAT_SESSION_CONTEXT_MESSAGES)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
// Only inject context if there is at least one real user message in the
// window. If the JSONL contains only assistant messages (e.g. previous
// heartbeat outputs with no reply yet), skip context to avoid feeding
// Monika's own messages back to her in a loop.
let has_user_message = recent.iter().any(|m| m.role == "user");
if !has_user_message {
tracing::debug!(
"💓 Heartbeat session context: no user messages in recent history — skipping"
);
return None;
}
// Use the session file's mtime as a proxy for when the last message arrived.
let last_message_age = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|mtime| mtime.elapsed().ok());
let silence_note = match last_message_age {
Some(age) => {
let mins = age.as_secs() / 60;
if mins < 60 {
format!("(last message ~{mins} minutes ago)\n")
} else {
let hours = mins / 60;
let rem = mins % 60;
if rem == 0 {
format!("(last message ~{hours}h ago)\n")
} else {
format!("(last message ~{hours}h {rem}m ago)\n")
}
}
}
None => String::new(),
};
tracing::debug!(
"💓 Heartbeat session context: {} messages from {}, silence: {}",
recent.len(),
path.display(),
silence_note.trim(),
);
let mut ctx = format!(
"[Recent conversation history — use this for context when composing your message] {silence_note}",
);
for msg in &recent {
let label = if msg.role == "user" { "User" } else { "You" };
// Truncate very long messages to avoid bloating the prompt.
// Use char_indices to avoid panicking on multi-byte UTF-8 characters.
let content = if msg.content.len() > 500 {
let truncate_at = msg
.content
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= 500)
.last()
.unwrap_or(0);
format!("{}", &msg.content[..truncate_at])
} else {
msg.content.clone()
};
ctx.push_str(label);
ctx.push_str(": ");
ctx.push_str(&content);
ctx.push('\n');
}
Some(ctx)
}
/// Read the last `HEARTBEAT_SESSION_CONTEXT_MESSAGES` `ChatMessage` lines from
/// a JSONL session file using a bounded rolling window so we never hold the
/// entire file in memory.
fn load_jsonl_messages(path: &std::path::Path) -> Vec<crate::providers::traits::ChatMessage> {
use std::collections::VecDeque;
use std::io::BufRead;
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let reader = std::io::BufReader::new(file);
let mut window: VecDeque<crate::providers::traits::ChatMessage> =
VecDeque::with_capacity(HEARTBEAT_SESSION_CONTEXT_MESSAGES + 1);
for line in reader.lines() {
let Ok(line) = line else { continue };
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(msg) = serde_json::from_str::<crate::providers::traits::ChatMessage>(trimmed) {
window.push_back(msg);
if window.len() > HEARTBEAT_SESSION_CONTEXT_MESSAGES {
window.pop_front();
}
}
}
window.into_iter().collect()
}
/// Auto-detect the best channel for heartbeat delivery by checking which
/// channels are configured. Returns the first match in priority order.
fn auto_detect_heartbeat_channel(config: &Config) -> Option<(String, String)> {
+293
View File
@@ -0,0 +1,293 @@
//! Audit trail for memory operations.
//!
//! Provides a decorator `AuditedMemory<M>` that wraps any `Memory` backend
//! and logs all operations to a `memory_audit` table. Opt-in via
//! `[memory] audit_enabled = true`.
use super::traits::{Memory, MemoryCategory, MemoryEntry, ProceduralMessage};
use async_trait::async_trait;
use chrono::Local;
use parking_lot::Mutex;
use rusqlite::{params, Connection};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Audit log entry operations.
#[derive(Debug, Clone, Copy)]
pub enum AuditOp {
Store,
Recall,
Get,
List,
Forget,
StoreProcedural,
}
impl std::fmt::Display for AuditOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Store => write!(f, "store"),
Self::Recall => write!(f, "recall"),
Self::Get => write!(f, "get"),
Self::List => write!(f, "list"),
Self::Forget => write!(f, "forget"),
Self::StoreProcedural => write!(f, "store_procedural"),
}
}
}
/// Decorator that wraps a `Memory` backend with audit logging.
pub struct AuditedMemory<M: Memory> {
inner: M,
audit_conn: Arc<Mutex<Connection>>,
#[allow(dead_code)]
db_path: PathBuf,
}
impl<M: Memory> AuditedMemory<M> {
pub fn new(inner: M, workspace_dir: &Path) -> anyhow::Result<Self> {
let db_path = workspace_dir.join("memory").join("audit.db");
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(&db_path)?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS memory_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation TEXT NOT NULL,
key TEXT,
namespace TEXT,
session_id TEXT,
timestamp TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON memory_audit(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_operation ON memory_audit(operation);",
)?;
Ok(Self {
inner,
audit_conn: Arc::new(Mutex::new(conn)),
db_path,
})
}
fn log_audit(
&self,
op: AuditOp,
key: Option<&str>,
namespace: Option<&str>,
session_id: Option<&str>,
metadata: Option<&str>,
) {
let conn = self.audit_conn.lock();
let now = Local::now().to_rfc3339();
let op_str = op.to_string();
let _ = conn.execute(
"INSERT INTO memory_audit (operation, key, namespace, session_id, timestamp, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![op_str, key, namespace, session_id, now, metadata],
);
}
/// Prune audit entries older than the given number of days.
pub fn prune_older_than(&self, retention_days: u32) -> anyhow::Result<u64> {
let conn = self.audit_conn.lock();
let cutoff =
(Local::now() - chrono::Duration::days(i64::from(retention_days))).to_rfc3339();
let affected = conn.execute(
"DELETE FROM memory_audit WHERE timestamp < ?1",
params![cutoff],
)?;
Ok(u64::try_from(affected).unwrap_or(0))
}
/// Count total audit entries.
pub fn audit_count(&self) -> anyhow::Result<usize> {
let conn = self.audit_conn.lock();
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM memory_audit", [], |row| row.get(0))?;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
Ok(count as usize)
}
}
#[async_trait]
impl<M: Memory> Memory for AuditedMemory<M> {
fn name(&self) -> &str {
self.inner.name()
}
async fn store(
&self,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
) -> anyhow::Result<()> {
self.log_audit(AuditOp::Store, Some(key), None, session_id, None);
self.inner.store(key, content, category, session_id).await
}
async fn recall(
&self,
query: &str,
limit: usize,
session_id: Option<&str>,
since: Option<&str>,
until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
self.log_audit(
AuditOp::Recall,
None,
None,
session_id,
Some(&format!("query={query}")),
);
self.inner
.recall(query, limit, session_id, since, until)
.await
}
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
self.log_audit(AuditOp::Get, Some(key), None, None, None);
self.inner.get(key).await
}
async fn list(
&self,
category: Option<&MemoryCategory>,
session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
self.log_audit(AuditOp::List, None, None, session_id, None);
self.inner.list(category, session_id).await
}
async fn forget(&self, key: &str) -> anyhow::Result<bool> {
self.log_audit(AuditOp::Forget, Some(key), None, None, None);
self.inner.forget(key).await
}
async fn count(&self) -> anyhow::Result<usize> {
self.inner.count().await
}
async fn health_check(&self) -> bool {
self.inner.health_check().await
}
async fn store_procedural(
&self,
messages: &[ProceduralMessage],
session_id: Option<&str>,
) -> anyhow::Result<()> {
self.log_audit(
AuditOp::StoreProcedural,
None,
None,
session_id,
Some(&format!("messages={}", messages.len())),
);
self.inner.store_procedural(messages, session_id).await
}
async fn recall_namespaced(
&self,
namespace: &str,
query: &str,
limit: usize,
session_id: Option<&str>,
since: Option<&str>,
until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
self.log_audit(
AuditOp::Recall,
None,
Some(namespace),
session_id,
Some(&format!("query={query}")),
);
self.inner
.recall_namespaced(namespace, query, limit, session_id, since, until)
.await
}
async fn store_with_metadata(
&self,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
namespace: Option<&str>,
importance: Option<f64>,
) -> anyhow::Result<()> {
self.log_audit(AuditOp::Store, Some(key), namespace, session_id, None);
self.inner
.store_with_metadata(key, content, category, session_id, namespace, importance)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::NoneMemory;
use tempfile::TempDir;
#[tokio::test]
async fn audited_memory_logs_store_operation() {
let tmp = TempDir::new().unwrap();
let inner = NoneMemory::new();
let audited = AuditedMemory::new(inner, tmp.path()).unwrap();
audited
.store("test_key", "test_value", MemoryCategory::Core, None)
.await
.unwrap();
assert_eq!(audited.audit_count().unwrap(), 1);
}
#[tokio::test]
async fn audited_memory_logs_recall_operation() {
let tmp = TempDir::new().unwrap();
let inner = NoneMemory::new();
let audited = AuditedMemory::new(inner, tmp.path()).unwrap();
let _ = audited.recall("query", 10, None, None, None).await;
assert_eq!(audited.audit_count().unwrap(), 1);
}
#[tokio::test]
async fn audited_memory_prune_works() {
let tmp = TempDir::new().unwrap();
let inner = NoneMemory::new();
let audited = AuditedMemory::new(inner, tmp.path()).unwrap();
audited
.store("k1", "v1", MemoryCategory::Core, None)
.await
.unwrap();
// Pruning with 0 days should remove entries
let pruned = audited.prune_older_than(0).unwrap();
// Entry was just created, so 0-day retention should remove it
// Pruning should succeed (pruned is usize, always >= 0)
let _ = pruned;
}
#[tokio::test]
async fn audited_memory_delegates_correctly() {
let tmp = TempDir::new().unwrap();
let inner = NoneMemory::new();
let audited = AuditedMemory::new(inner, tmp.path()).unwrap();
assert_eq!(audited.name(), "none");
assert!(audited.health_check().await);
assert_eq!(audited.count().await.unwrap(), 0);
}
}
-12
View File
@@ -4,7 +4,6 @@ pub enum MemoryBackendKind {
Lucid,
Postgres,
Qdrant,
Mem0,
Markdown,
None,
Unknown,
@@ -66,15 +65,6 @@ const QDRANT_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
optional_dependency: false,
};
const MEM0_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
key: "mem0",
label: "Mem0 (OpenMemory) — semantic memory with LLM fact extraction via [memory.mem0]",
auto_save_default: true,
uses_sqlite_hygiene: false,
sqlite_based: false,
optional_dependency: true,
};
const NONE_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
key: "none",
label: "None — disable persistent memory",
@@ -114,7 +104,6 @@ pub fn classify_memory_backend(backend: &str) -> MemoryBackendKind {
"lucid" => MemoryBackendKind::Lucid,
"postgres" => MemoryBackendKind::Postgres,
"qdrant" => MemoryBackendKind::Qdrant,
"mem0" | "openmemory" => MemoryBackendKind::Mem0,
"markdown" => MemoryBackendKind::Markdown,
"none" => MemoryBackendKind::None,
_ => MemoryBackendKind::Unknown,
@@ -127,7 +116,6 @@ pub fn memory_backend_profile(backend: &str) -> MemoryBackendProfile {
MemoryBackendKind::Lucid => LUCID_PROFILE,
MemoryBackendKind::Postgres => POSTGRES_PROFILE,
MemoryBackendKind::Qdrant => QDRANT_PROFILE,
MemoryBackendKind::Mem0 => MEM0_PROFILE,
MemoryBackendKind::Markdown => MARKDOWN_PROFILE,
MemoryBackendKind::None => NONE_PROFILE,
MemoryBackendKind::Unknown => CUSTOM_PROFILE,
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
//! Conflict resolution for memory entries.
//!
//! Before storing Core memories, performs a semantic similarity check against
//! existing entries. If cosine similarity exceeds a threshold but content
//! differs, the old entry is marked as superseded.
use super::traits::{Memory, MemoryCategory, MemoryEntry};
/// Check for conflicting memories and mark old ones as superseded.
///
/// Returns the list of entry IDs that were superseded.
pub async fn check_and_resolve_conflicts(
memory: &dyn Memory,
key: &str,
content: &str,
category: &MemoryCategory,
threshold: f64,
) -> anyhow::Result<Vec<String>> {
// Only check conflicts for Core memories
if !matches!(category, MemoryCategory::Core) {
return Ok(Vec::new());
}
// Search for similar existing entries
let candidates = memory.recall(content, 10, None, None, None).await?;
let mut superseded = Vec::new();
for candidate in &candidates {
if candidate.key == key {
continue; // Same key = update, not conflict
}
if !matches!(candidate.category, MemoryCategory::Core) {
continue;
}
if let Some(score) = candidate.score {
if score > threshold && candidate.content != content {
superseded.push(candidate.id.clone());
}
}
}
Ok(superseded)
}
/// Mark entries as superseded in SQLite by setting their `superseded_by` column.
pub fn mark_superseded(
conn: &rusqlite::Connection,
superseded_ids: &[String],
new_id: &str,
) -> anyhow::Result<()> {
if superseded_ids.is_empty() {
return Ok(());
}
for id in superseded_ids {
conn.execute(
"UPDATE memories SET superseded_by = ?1 WHERE id = ?2",
rusqlite::params![new_id, id],
)?;
}
Ok(())
}
/// Simple text-based conflict detection without embeddings.
///
/// Uses token overlap (Jaccard similarity) as a fast approximation
/// when vector embeddings are unavailable.
pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
let words_a: std::collections::HashSet<&str> = a.split_whitespace().collect();
let words_b: std::collections::HashSet<&str> = b.split_whitespace().collect();
if words_a.is_empty() && words_b.is_empty() {
return 1.0;
}
if words_a.is_empty() || words_b.is_empty() {
return 0.0;
}
let intersection = words_a.intersection(&words_b).count();
let union = words_a.union(&words_b).count();
if union == 0 {
0.0
} else {
intersection as f64 / union as f64
}
}
/// Find potentially conflicting entries using text similarity when embeddings
/// are not available. Returns entries above the threshold.
pub fn find_text_conflicts(
entries: &[MemoryEntry],
new_content: &str,
threshold: f64,
) -> Vec<String> {
entries
.iter()
.filter(|e| {
matches!(e.category, MemoryCategory::Core)
&& e.superseded_by.is_none()
&& jaccard_similarity(&e.content, new_content) > threshold
&& e.content != new_content
})
.map(|e| e.id.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jaccard_identical_strings() {
let sim = jaccard_similarity("hello world", "hello world");
assert!((sim - 1.0).abs() < f64::EPSILON);
}
#[test]
fn jaccard_disjoint_strings() {
let sim = jaccard_similarity("hello world", "foo bar");
assert!(sim.abs() < f64::EPSILON);
}
#[test]
fn jaccard_partial_overlap() {
let sim = jaccard_similarity("the quick brown fox", "the slow brown dog");
// overlap: "the", "brown" = 2; union: "the", "quick", "brown", "fox", "slow", "dog" = 6
assert!((sim - 2.0 / 6.0).abs() < 0.01);
}
#[test]
fn jaccard_empty_strings() {
assert!((jaccard_similarity("", "") - 1.0).abs() < f64::EPSILON);
assert!(jaccard_similarity("hello", "").abs() < f64::EPSILON);
assert!(jaccard_similarity("", "hello").abs() < f64::EPSILON);
}
#[test]
fn find_text_conflicts_filters_correctly() {
let entries = vec![
MemoryEntry {
id: "1".into(),
key: "pref".into(),
content: "User prefers Rust for systems work".into(),
category: MemoryCategory::Core,
timestamp: "now".into(),
session_id: None,
score: None,
namespace: "default".into(),
importance: Some(0.7),
superseded_by: None,
},
MemoryEntry {
id: "2".into(),
key: "daily1".into(),
content: "User prefers Rust for systems work".into(),
category: MemoryCategory::Daily,
timestamp: "now".into(),
session_id: None,
score: None,
namespace: "default".into(),
importance: Some(0.3),
superseded_by: None,
},
];
// Only Core entries should be flagged
let conflicts = find_text_conflicts(&entries, "User now prefers Go for systems work", 0.3);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0], "1");
}
}
+28 -1
View File
@@ -8,6 +8,8 @@
//! This two-phase approach replaces the naive raw-message auto-save with
//! semantic extraction, similar to Nanobot's `save_memory` tool call pattern.
use crate::memory::conflict;
use crate::memory::importance;
use crate::memory::traits::{Memory, MemoryCategory};
use crate::providers::traits::Provider;
@@ -78,8 +80,33 @@ pub async fn consolidate_turn(
if let Some(ref update) = result.memory_update {
if !update.trim().is_empty() {
let mem_key = format!("core_{}", uuid::Uuid::new_v4());
// Compute importance score heuristically.
let imp = importance::compute_importance(update, &MemoryCategory::Core);
// Check for conflicts with existing Core memories.
if let Err(e) = conflict::check_and_resolve_conflicts(
memory,
&mem_key,
update,
&MemoryCategory::Core,
0.85,
)
.await
{
tracing::debug!("conflict check skipped: {e}");
}
// Store with importance metadata.
memory
.store(&mem_key, update, MemoryCategory::Core, None)
.store_with_metadata(
&mem_key,
update,
MemoryCategory::Core,
None,
None,
Some(imp),
)
.await?;
}
}
+42 -4
View File
@@ -1,4 +1,5 @@
use crate::config::MemoryConfig;
use crate::memory::policy::PolicyEnforcer;
use anyhow::Result;
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use rusqlite::{params, Connection};
@@ -47,6 +48,13 @@ pub fn run_if_due(config: &MemoryConfig, workspace_dir: &Path) -> Result<()> {
return Ok(());
}
// Use policy engine for per-category retention overrides.
let enforcer = PolicyEnforcer::new(&config.policy);
let conversation_retention = enforcer.retention_days_for_category(
&crate::memory::traits::MemoryCategory::Conversation,
config.conversation_retention_days,
);
let report = HygieneReport {
archived_memory_files: archive_daily_memory_files(
workspace_dir,
@@ -55,12 +63,16 @@ pub fn run_if_due(config: &MemoryConfig, workspace_dir: &Path) -> Result<()> {
archived_session_files: archive_session_files(workspace_dir, config.archive_after_days)?,
purged_memory_archives: purge_memory_archives(workspace_dir, config.purge_after_days)?,
purged_session_archives: purge_session_archives(workspace_dir, config.purge_after_days)?,
pruned_conversation_rows: prune_conversation_rows(
workspace_dir,
config.conversation_retention_days,
)?,
pruned_conversation_rows: prune_conversation_rows(workspace_dir, conversation_retention)?,
};
// Prune audit entries if audit is enabled.
if config.audit_enabled {
if let Err(e) = prune_audit_entries(workspace_dir, config.audit_retention_days) {
tracing::debug!("audit pruning skipped: {e}");
}
}
write_state(workspace_dir, &report)?;
if report.total_actions() > 0 {
@@ -318,6 +330,32 @@ fn prune_conversation_rows(workspace_dir: &Path, retention_days: u32) -> Result<
Ok(u64::try_from(affected).unwrap_or(0))
}
fn prune_audit_entries(workspace_dir: &Path, retention_days: u32) -> Result<()> {
if retention_days == 0 {
return Ok(());
}
let db_path = workspace_dir.join("memory").join("audit.db");
if !db_path.exists() {
return Ok(());
}
let conn = Connection::open(db_path)?;
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
let cutoff = (Local::now() - Duration::days(i64::from(retention_days))).to_rfc3339();
let affected = conn.execute(
"DELETE FROM memory_audit WHERE timestamp < ?1",
params![cutoff],
)?;
if affected > 0 {
tracing::debug!("pruned {affected} audit entries older than {retention_days} days");
}
Ok(())
}
fn memory_date_from_filename(filename: &str) -> Option<NaiveDate> {
let stem = filename.strip_suffix(".md")?;
let date_part = stem.split('_').next().unwrap_or(stem);
+107
View File
@@ -0,0 +1,107 @@
//! Heuristic importance scorer for non-LLM paths.
//!
//! Assigns importance scores (0.01.0) based on memory category and keyword
//! signals. Used when LLM-based consolidation is unavailable or as a fast
//! first-pass scorer.
use super::traits::MemoryCategory;
/// Base importance by category.
fn category_base_score(category: &MemoryCategory) -> f64 {
match category {
MemoryCategory::Core => 0.7,
MemoryCategory::Daily => 0.3,
MemoryCategory::Conversation => 0.2,
MemoryCategory::Custom(_) => 0.4,
}
}
/// Keyword boost: if the content contains high-signal keywords, bump importance.
fn keyword_boost(content: &str) -> f64 {
const HIGH_SIGNAL_KEYWORDS: &[&str] = &[
"decision",
"always",
"never",
"important",
"critical",
"must",
"requirement",
"policy",
"rule",
"principle",
];
let lowered = content.to_ascii_lowercase();
let matches = HIGH_SIGNAL_KEYWORDS
.iter()
.filter(|kw| lowered.contains(**kw))
.count();
// Cap at +0.2
(matches as f64 * 0.1).min(0.2)
}
/// Compute heuristic importance score for a memory entry.
pub fn compute_importance(content: &str, category: &MemoryCategory) -> f64 {
let base = category_base_score(category);
let boost = keyword_boost(content);
(base + boost).min(1.0)
}
/// Compute final retrieval score incorporating importance and recency.
///
/// `hybrid_score`: raw retrieval score from FTS/vector (0.01.0)
/// `importance`: importance score (0.01.0)
/// `recency_decay`: recency factor (0.01.0, 1.0 = very recent)
pub fn weighted_final_score(hybrid_score: f64, importance: f64, recency_decay: f64) -> f64 {
hybrid_score * 0.7 + importance * 0.2 + recency_decay * 0.1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn core_category_has_high_base_score() {
let score = compute_importance("some fact", &MemoryCategory::Core);
assert!((score - 0.7).abs() < f64::EPSILON);
}
#[test]
fn conversation_category_has_low_base_score() {
let score = compute_importance("chat message", &MemoryCategory::Conversation);
assert!((score - 0.2).abs() < f64::EPSILON);
}
#[test]
fn keywords_boost_importance() {
let score = compute_importance(
"This is a critical decision that must always be followed",
&MemoryCategory::Core,
);
// base 0.7 + boost for "critical", "decision", "must", "always" = 0.7 + 0.2 (capped) = 0.9
assert!(score > 0.85);
}
#[test]
fn boost_capped_at_point_two() {
let score = compute_importance(
"important critical decision rule policy must always never requirement principle",
&MemoryCategory::Conversation,
);
// base 0.2 + max boost 0.2 = 0.4
assert!((score - 0.4).abs() < f64::EPSILON);
}
#[test]
fn weighted_final_score_formula() {
let score = weighted_final_score(1.0, 1.0, 1.0);
assert!((score - 1.0).abs() < f64::EPSILON);
let score = weighted_final_score(0.0, 0.0, 0.0);
assert!(score.abs() < f64::EPSILON);
let score = weighted_final_score(0.5, 0.5, 0.5);
assert!((score - 0.5).abs() < f64::EPSILON);
}
}
+3
View File
@@ -226,6 +226,9 @@ impl LucidMemory {
timestamp: now.clone(),
session_id: None,
score: Some((1.0 - rank as f64 * 0.05).max(0.1)),
namespace: "default".into(),
importance: None,
superseded_by: None,
});
}
+3
View File
@@ -91,6 +91,9 @@ impl MarkdownMemory {
timestamp: filename.to_string(),
session_id: None,
score: None,
namespace: "default".into(),
importance: None,
superseded_by: None,
}
})
.collect()
-635
View File
@@ -1,635 +0,0 @@
//! Mem0 (OpenMemory) memory backend.
//!
//! Connects to a self-hosted OpenMemory server via its REST API
//! and implements the [`Memory`] trait for seamless integration with
//! ZeroClaw's auto-save, auto-recall, and hygiene lifecycle.
//!
//! Deploy OpenMemory: `docker compose up` from the mem0 repo.
//! Default endpoint: `http://localhost:8765`.
use super::traits::{Memory, MemoryCategory, MemoryEntry, ProceduralMessage};
use crate::config::schema::Mem0Config;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
/// Memory backend backed by a mem0 (OpenMemory) REST API.
pub struct Mem0Memory {
client: Client,
base_url: String,
user_id: String,
app_name: String,
infer: bool,
extraction_prompt: Option<String>,
}
// ── mem0 API request/response types ────────────────────────────────
#[derive(Serialize)]
struct AddMemoryRequest<'a> {
user_id: &'a str,
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<Mem0Metadata<'a>>,
infer: bool,
#[serde(skip_serializing_if = "Option::is_none")]
app: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
custom_instructions: Option<&'a str>,
}
#[derive(Serialize)]
struct Mem0Metadata<'a> {
key: &'a str,
category: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<&'a str>,
}
#[derive(Serialize)]
struct AddProceduralRequest<'a> {
user_id: &'a str,
messages: &'a [ProceduralMessage],
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
struct DeleteMemoriesRequest<'a> {
memory_ids: Vec<&'a str>,
user_id: &'a str,
}
#[derive(Deserialize)]
struct Mem0MemoryItem {
id: String,
#[serde(alias = "content", alias = "text", default)]
memory: String,
#[serde(default)]
created_at: Option<serde_json::Value>,
#[serde(default, rename = "metadata_")]
metadata: Option<Mem0ResponseMetadata>,
#[serde(alias = "relevance_score", default)]
score: Option<f64>,
}
#[derive(Deserialize, Default)]
struct Mem0ResponseMetadata {
#[serde(default)]
key: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
session_id: Option<String>,
}
#[derive(Deserialize)]
struct Mem0ListResponse {
#[serde(default)]
items: Vec<Mem0MemoryItem>,
#[serde(default)]
total: usize,
}
// ── Implementation ─────────────────────────────────────────────────
impl Mem0Memory {
/// Create a new mem0 memory backend from config.
pub fn new(config: &Mem0Config) -> anyhow::Result<Self> {
let base_url = config.url.trim_end_matches('/').to_string();
if base_url.is_empty() {
anyhow::bail!("mem0 URL is empty; set [memory.mem0] url or MEM0_URL env var");
}
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self {
client,
base_url,
user_id: config.user_id.clone(),
app_name: config.app_name.clone(),
infer: config.infer,
extraction_prompt: config.extraction_prompt.clone(),
})
}
fn api_url(&self, path: &str) -> String {
format!("{}/api/v1{}", self.base_url, path)
}
/// Use `session_id` as the effective mem0 `user_id` when provided,
/// falling back to the configured default. This enables per-user
/// and per-group memory scoping via the existing `Memory` trait.
fn effective_user_id<'a>(&'a self, session_id: Option<&'a str>) -> &'a str {
session_id
.filter(|s| !s.trim().is_empty())
.unwrap_or(&self.user_id)
}
/// Recall memories with optional search filters.
///
/// - `created_after` / `created_before`: ISO 8601 timestamps for time-range filtering.
/// - `metadata_filter`: arbitrary JSON object passed to the mem0 SDK `filters` param.
pub async fn recall_filtered(
&self,
query: &str,
limit: usize,
session_id: Option<&str>,
created_after: Option<&str>,
created_before: Option<&str>,
metadata_filter: Option<&serde_json::Value>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let effective_user = self.effective_user_id(session_id);
let limit_str = limit.to_string();
let mut params: Vec<(&str, &str)> = vec![
("user_id", effective_user),
("search_query", query),
("size", &limit_str),
];
if let Some(after) = created_after {
params.push(("created_after", after));
}
if let Some(before) = created_before {
params.push(("created_before", before));
}
let meta_json;
if let Some(mf) = metadata_filter {
meta_json = serde_json::to_string(mf)?;
params.push(("metadata_filter", &meta_json));
}
let resp = self
.client
.get(self.api_url("/memories/"))
.query(&params)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("mem0 recall failed ({status}): {text}");
}
let list: Mem0ListResponse = resp.json().await?;
Ok(list.items.into_iter().map(|i| self.to_entry(i)).collect())
}
fn to_entry(&self, item: Mem0MemoryItem) -> MemoryEntry {
let meta = item.metadata.unwrap_or_default();
let timestamp = match item.created_at {
Some(serde_json::Value::Number(n)) => {
// Unix timestamp → ISO 8601
if let Some(ts) = n.as_i64() {
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_default()
} else {
String::new()
}
}
Some(serde_json::Value::String(s)) => s,
_ => String::new(),
};
let category = match meta.category.as_deref() {
Some("daily") => MemoryCategory::Daily,
Some("conversation") => MemoryCategory::Conversation,
Some(other) if other != "core" => MemoryCategory::Custom(other.to_string()),
// "core" or None → default
_ => MemoryCategory::Core,
};
MemoryEntry {
id: item.id,
key: meta.key.unwrap_or_default(),
content: item.memory,
category,
timestamp,
session_id: meta.session_id,
score: item.score,
}
}
/// Store a conversation trace as procedural memory.
///
/// Sends the message history (user input, tool calls, assistant response)
/// to the mem0 procedural endpoint so that "how to" patterns can be
/// extracted and stored for future recall.
pub async fn store_procedural(
&self,
messages: &[ProceduralMessage],
session_id: Option<&str>,
) -> anyhow::Result<()> {
if messages.is_empty() {
return Ok(());
}
let effective_user = self.effective_user_id(session_id);
let body = AddProceduralRequest {
user_id: effective_user,
messages,
metadata: None,
};
let resp = self
.client
.post(self.api_url("/memories/procedural"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("mem0 store_procedural failed ({status}): {text}");
}
Ok(())
}
}
// ── History API types ─────────────────────────────────────────────
#[derive(Deserialize)]
struct Mem0HistoryResponse {
#[serde(default)]
history: Vec<serde_json::Value>,
#[serde(default)]
error: Option<String>,
}
impl Mem0Memory {
/// Retrieve the edit history (audit trail) for a specific memory by ID.
pub async fn history(&self, memory_id: &str) -> anyhow::Result<String> {
let url = self.api_url(&format!("/memories/{memory_id}/history"));
let resp = self.client.get(&url).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("mem0 history failed ({status}): {text}");
}
let body: Mem0HistoryResponse = resp.json().await?;
if let Some(err) = body.error {
anyhow::bail!("mem0 history error: {err}");
}
if body.history.is_empty() {
return Ok(format!("No history found for memory {memory_id}."));
}
let mut lines = Vec::with_capacity(body.history.len() + 1);
lines.push(format!("History for memory {memory_id}:"));
for (i, entry) in body.history.iter().enumerate() {
let event = entry
.get("event")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let old_memory = entry
.get("old_memory")
.and_then(|v| v.as_str())
.unwrap_or("-");
let new_memory = entry
.get("new_memory")
.and_then(|v| v.as_str())
.unwrap_or("-");
let timestamp = entry
.get("created_at")
.or_else(|| entry.get("timestamp"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
lines.push(format!(
" {idx}. [{event}] at {timestamp}\n old: {old_memory}\n new: {new_memory}",
idx = i + 1,
));
}
Ok(lines.join("\n"))
}
}
#[async_trait]
impl Memory for Mem0Memory {
fn name(&self) -> &str {
"mem0"
}
async fn store(
&self,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
) -> anyhow::Result<()> {
let cat_str = category.to_string();
let effective_user = self.effective_user_id(session_id);
let body = AddMemoryRequest {
user_id: effective_user,
text: content,
metadata: Some(Mem0Metadata {
key,
category: &cat_str,
session_id,
}),
infer: self.infer,
app: Some(&self.app_name),
custom_instructions: self.extraction_prompt.as_deref(),
};
let resp = self
.client
.post(self.api_url("/memories/"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("mem0 store failed ({status}): {text}");
}
Ok(())
}
async fn recall(
&self,
query: &str,
limit: usize,
session_id: Option<&str>,
_since: Option<&str>,
_until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
// mem0 handles filtering server-side; since/until are not yet
// supported by the mem0 API, so we pass them through as no-ops.
self.recall_filtered(query, limit, session_id, None, None, None)
.await
}
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
// mem0 doesn't have a get-by-key API, so we search by key in metadata
let results = self.recall(key, 1, None, None, None).await?;
Ok(results.into_iter().find(|e| e.key == key))
}
async fn list(
&self,
category: Option<&MemoryCategory>,
session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let effective_user = self.effective_user_id(session_id);
let resp = self
.client
.get(self.api_url("/memories/"))
.query(&[("user_id", effective_user), ("size", "100")])
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("mem0 list failed ({status}): {text}");
}
let list: Mem0ListResponse = resp.json().await?;
let entries: Vec<MemoryEntry> = list.items.into_iter().map(|i| self.to_entry(i)).collect();
// Client-side category filter (mem0 API doesn't filter by metadata)
match category {
Some(cat) => Ok(entries.into_iter().filter(|e| &e.category == cat).collect()),
None => Ok(entries),
}
}
async fn forget(&self, key: &str) -> anyhow::Result<bool> {
// Find the memory ID by key first
let entry = self.get(key).await?;
let entry = match entry {
Some(e) => e,
None => return Ok(false),
};
let body = DeleteMemoriesRequest {
memory_ids: vec![&entry.id],
user_id: &self.user_id,
};
let resp = self
.client
.delete(self.api_url("/memories/"))
.json(&body)
.send()
.await?;
Ok(resp.status().is_success())
}
async fn count(&self) -> anyhow::Result<usize> {
let resp = self
.client
.get(self.api_url("/memories/"))
.query(&[
("user_id", self.user_id.as_str()),
("size", "1"),
("page", "1"),
])
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("mem0 count failed ({status}): {text}");
}
let list: Mem0ListResponse = resp.json().await?;
Ok(list.total)
}
async fn health_check(&self) -> bool {
self.client
.get(self.api_url("/memories/"))
.query(&[
("user_id", self.user_id.as_str()),
("size", "1"),
("page", "1"),
])
.send()
.await
.is_ok_and(|r| r.status().is_success())
}
async fn store_procedural(
&self,
messages: &[ProceduralMessage],
session_id: Option<&str>,
) -> anyhow::Result<()> {
Mem0Memory::store_procedural(self, messages, session_id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> Mem0Config {
Mem0Config {
url: "http://localhost:8765".into(),
user_id: "test-user".into(),
app_name: "test-app".into(),
infer: true,
extraction_prompt: None,
}
}
#[test]
fn new_rejects_empty_url() {
let config = Mem0Config {
url: String::new(),
..test_config()
};
assert!(Mem0Memory::new(&config).is_err());
}
#[test]
fn new_trims_trailing_slash() {
let config = Mem0Config {
url: "http://localhost:8765/".into(),
..test_config()
};
let mem = Mem0Memory::new(&config).unwrap();
assert_eq!(mem.base_url, "http://localhost:8765");
}
#[test]
fn api_url_builds_correct_path() {
let mem = Mem0Memory::new(&test_config()).unwrap();
assert_eq!(
mem.api_url("/memories/"),
"http://localhost:8765/api/v1/memories/"
);
}
#[test]
fn to_entry_maps_unix_timestamp() {
let mem = Mem0Memory::new(&test_config()).unwrap();
let item = Mem0MemoryItem {
id: "id-1".into(),
memory: "hello".into(),
created_at: Some(serde_json::json!(1_700_000_000)),
metadata: Some(Mem0ResponseMetadata {
key: Some("k1".into()),
category: Some("core".into()),
session_id: None,
}),
score: Some(0.95),
};
let entry = mem.to_entry(item);
assert_eq!(entry.id, "id-1");
assert_eq!(entry.key, "k1");
assert_eq!(entry.category, MemoryCategory::Core);
assert!(!entry.timestamp.is_empty());
assert_eq!(entry.score, Some(0.95));
}
#[test]
fn to_entry_maps_string_timestamp() {
let mem = Mem0Memory::new(&test_config()).unwrap();
let item = Mem0MemoryItem {
id: "id-2".into(),
memory: "world".into(),
created_at: Some(serde_json::json!("2024-01-01T00:00:00Z")),
metadata: None,
score: None,
};
let entry = mem.to_entry(item);
assert_eq!(entry.timestamp, "2024-01-01T00:00:00Z");
assert_eq!(entry.category, MemoryCategory::Core); // default
}
#[test]
fn to_entry_handles_missing_metadata() {
let mem = Mem0Memory::new(&test_config()).unwrap();
let item = Mem0MemoryItem {
id: "id-3".into(),
memory: "bare".into(),
created_at: None,
metadata: None,
score: None,
};
let entry = mem.to_entry(item);
assert_eq!(entry.key, "");
assert_eq!(entry.category, MemoryCategory::Core);
assert!(entry.timestamp.is_empty());
assert_eq!(entry.score, None);
}
#[test]
fn to_entry_custom_category() {
let mem = Mem0Memory::new(&test_config()).unwrap();
let item = Mem0MemoryItem {
id: "id-4".into(),
memory: "custom".into(),
created_at: None,
metadata: Some(Mem0ResponseMetadata {
key: Some("k".into()),
category: Some("project_notes".into()),
session_id: Some("s1".into()),
}),
score: None,
};
let entry = mem.to_entry(item);
assert_eq!(
entry.category,
MemoryCategory::Custom("project_notes".into())
);
assert_eq!(entry.session_id.as_deref(), Some("s1"));
}
#[test]
fn name_returns_mem0() {
let mem = Mem0Memory::new(&test_config()).unwrap();
assert_eq!(mem.name(), "mem0");
}
#[test]
fn procedural_request_serializes_messages() {
let messages = vec![
ProceduralMessage {
role: "user".into(),
content: "How do I deploy?".into(),
name: None,
},
ProceduralMessage {
role: "tool".into(),
content: "deployment started".into(),
name: Some("shell".into()),
},
ProceduralMessage {
role: "assistant".into(),
content: "Deployment complete.".into(),
name: None,
},
];
let req = AddProceduralRequest {
user_id: "test-user",
messages: &messages,
metadata: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["user_id"], "test-user");
let msgs = json["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[1]["name"], "shell");
// metadata should be absent when None
assert!(json.get("metadata").is_none());
}
}
+15 -27
View File
@@ -1,24 +1,32 @@
pub mod audit;
pub mod backend;
pub mod chunker;
pub mod cli;
pub mod conflict;
pub mod consolidation;
pub mod embeddings;
pub mod hygiene;
pub mod importance;
pub mod knowledge_graph;
pub mod lucid;
pub mod markdown;
#[cfg(feature = "memory-mem0")]
pub mod mem0;
pub mod none;
pub mod policy;
#[cfg(feature = "memory-postgres")]
pub mod postgres;
pub mod qdrant;
pub mod response_cache;
pub mod retrieval;
pub mod snapshot;
pub mod sqlite;
pub mod traits;
pub mod vector;
#[cfg(test)]
mod battle_tests;
#[allow(unused_imports)]
pub use audit::AuditedMemory;
#[allow(unused_imports)]
pub use backend::{
classify_memory_backend, default_memory_backend_key, memory_backend_profile,
@@ -26,13 +34,15 @@ pub use backend::{
};
pub use lucid::LucidMemory;
pub use markdown::MarkdownMemory;
#[cfg(feature = "memory-mem0")]
pub use mem0::Mem0Memory;
pub use none::NoneMemory;
#[allow(unused_imports)]
pub use policy::PolicyEnforcer;
#[cfg(feature = "memory-postgres")]
pub use postgres::PostgresMemory;
pub use qdrant::QdrantMemory;
pub use response_cache::ResponseCache;
#[allow(unused_imports)]
pub use retrieval::{RetrievalConfig, RetrievalPipeline};
pub use sqlite::SqliteMemory;
pub use traits::Memory;
#[allow(unused_imports)]
@@ -61,7 +71,7 @@ where
Ok(Box::new(LucidMemory::new(workspace_dir, local)))
}
MemoryBackendKind::Postgres => postgres_builder(),
MemoryBackendKind::Qdrant | MemoryBackendKind::Mem0 | MemoryBackendKind::Markdown => {
MemoryBackendKind::Qdrant | MemoryBackendKind::Markdown => {
Ok(Box::new(MarkdownMemory::new(workspace_dir)))
}
MemoryBackendKind::None => Ok(Box::new(NoneMemory::new())),
@@ -340,28 +350,6 @@ pub fn create_memory_with_storage_and_routes(
);
}
#[cfg(feature = "memory-mem0")]
fn build_mem0_memory(config: &crate::config::MemoryConfig) -> anyhow::Result<Box<dyn Memory>> {
let mem = Mem0Memory::new(&config.mem0)?;
tracing::info!(
"📦 Mem0 memory backend configured (url: {}, user: {})",
config.mem0.url,
config.mem0.user_id
);
Ok(Box::new(mem))
}
#[cfg(not(feature = "memory-mem0"))]
fn build_mem0_memory(_config: &crate::config::MemoryConfig) -> anyhow::Result<Box<dyn Memory>> {
anyhow::bail!(
"memory backend 'mem0' requested but this build was compiled without `memory-mem0`; rebuild with `--features memory-mem0`"
);
}
if matches!(backend_kind, MemoryBackendKind::Mem0) {
return build_mem0_memory(config);
}
if matches!(backend_kind, MemoryBackendKind::Qdrant) {
let url = config
.qdrant
+192
View File
@@ -0,0 +1,192 @@
//! Policy engine for memory operations.
//!
//! Validates operations against configurable rules before they reach the
//! backend. Enforces namespace quotas, category limits, read-only namespaces,
//! and per-category retention rules.
use super::traits::MemoryCategory;
use crate::config::MemoryPolicyConfig;
/// Policy enforcer that validates memory operations.
pub struct PolicyEnforcer {
config: MemoryPolicyConfig,
}
impl PolicyEnforcer {
pub fn new(config: &MemoryPolicyConfig) -> Self {
Self {
config: config.clone(),
}
}
/// Check if a namespace is read-only.
pub fn is_read_only(&self, namespace: &str) -> bool {
self.config
.read_only_namespaces
.iter()
.any(|ns| ns == namespace)
}
/// Validate a store operation against policy rules.
pub fn validate_store(
&self,
namespace: &str,
_category: &MemoryCategory,
) -> Result<(), PolicyViolation> {
if self.is_read_only(namespace) {
return Err(PolicyViolation::ReadOnlyNamespace(namespace.to_string()));
}
Ok(())
}
/// Check if adding an entry would exceed namespace limits.
pub fn check_namespace_limit(&self, current_count: usize) -> Result<(), PolicyViolation> {
if self.config.max_entries_per_namespace > 0
&& current_count >= self.config.max_entries_per_namespace
{
return Err(PolicyViolation::NamespaceQuotaExceeded {
max: self.config.max_entries_per_namespace,
current: current_count,
});
}
Ok(())
}
/// Check if adding an entry would exceed category limits.
pub fn check_category_limit(&self, current_count: usize) -> Result<(), PolicyViolation> {
if self.config.max_entries_per_category > 0
&& current_count >= self.config.max_entries_per_category
{
return Err(PolicyViolation::CategoryQuotaExceeded {
max: self.config.max_entries_per_category,
current: current_count,
});
}
Ok(())
}
/// Get the retention days for a specific category, falling back to the
/// provided default if no per-category override exists.
pub fn retention_days_for_category(&self, category: &MemoryCategory, default_days: u32) -> u32 {
let key = category.to_string();
self.config
.retention_days_by_category
.get(&key)
.copied()
.unwrap_or(default_days)
}
}
/// Policy violation errors.
#[derive(Debug, Clone)]
pub enum PolicyViolation {
ReadOnlyNamespace(String),
NamespaceQuotaExceeded { max: usize, current: usize },
CategoryQuotaExceeded { max: usize, current: usize },
}
impl std::fmt::Display for PolicyViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReadOnlyNamespace(ns) => write!(f, "namespace '{ns}' is read-only"),
Self::NamespaceQuotaExceeded { max, current } => {
write!(f, "namespace quota exceeded: {current}/{max} entries")
}
Self::CategoryQuotaExceeded { max, current } => {
write!(f, "category quota exceeded: {current}/{max} entries")
}
}
}
}
impl std::error::Error for PolicyViolation {}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn empty_policy() -> MemoryPolicyConfig {
MemoryPolicyConfig::default()
}
#[test]
fn default_policy_allows_everything() {
let enforcer = PolicyEnforcer::new(&empty_policy());
assert!(!enforcer.is_read_only("default"));
assert!(enforcer
.validate_store("default", &MemoryCategory::Core)
.is_ok());
assert!(enforcer.check_namespace_limit(100).is_ok());
assert!(enforcer.check_category_limit(100).is_ok());
}
#[test]
fn read_only_namespace_blocks_writes() {
let policy = MemoryPolicyConfig {
read_only_namespaces: vec!["archive".into()],
..empty_policy()
};
let enforcer = PolicyEnforcer::new(&policy);
assert!(enforcer.is_read_only("archive"));
assert!(!enforcer.is_read_only("default"));
assert!(enforcer
.validate_store("archive", &MemoryCategory::Core)
.is_err());
assert!(enforcer
.validate_store("default", &MemoryCategory::Core)
.is_ok());
}
#[test]
fn namespace_quota_enforced() {
let policy = MemoryPolicyConfig {
max_entries_per_namespace: 10,
..empty_policy()
};
let enforcer = PolicyEnforcer::new(&policy);
assert!(enforcer.check_namespace_limit(5).is_ok());
assert!(enforcer.check_namespace_limit(10).is_err());
assert!(enforcer.check_namespace_limit(15).is_err());
}
#[test]
fn category_quota_enforced() {
let policy = MemoryPolicyConfig {
max_entries_per_category: 50,
..empty_policy()
};
let enforcer = PolicyEnforcer::new(&policy);
assert!(enforcer.check_category_limit(25).is_ok());
assert!(enforcer.check_category_limit(50).is_err());
}
#[test]
fn per_category_retention_overrides_default() {
let mut retention = HashMap::new();
retention.insert("core".into(), 365);
retention.insert("conversation".into(), 7);
let policy = MemoryPolicyConfig {
retention_days_by_category: retention,
..empty_policy()
};
let enforcer = PolicyEnforcer::new(&policy);
assert_eq!(
enforcer.retention_days_for_category(&MemoryCategory::Core, 30),
365
);
assert_eq!(
enforcer.retention_days_for_category(&MemoryCategory::Conversation, 30),
7
);
assert_eq!(
enforcer.retention_days_for_category(&MemoryCategory::Daily, 30),
30
);
}
}
+12 -3
View File
@@ -100,6 +100,8 @@ impl PostgresMemory {
CREATE INDEX IF NOT EXISTS idx_memories_category ON {qualified_table}(category);
CREATE INDEX IF NOT EXISTS idx_memories_session_id ON {qualified_table}(session_id);
CREATE INDEX IF NOT EXISTS idx_memories_updated_at ON {qualified_table}(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_memories_content_fts ON {qualified_table} USING gin(to_tsvector('simple', content));
CREATE INDEX IF NOT EXISTS idx_memories_key_fts ON {qualified_table} USING gin(to_tsvector('simple', key));
"
))?;
@@ -135,6 +137,9 @@ impl PostgresMemory {
timestamp: timestamp.to_rfc3339(),
session_id: row.get(5),
score: row.try_get(6).ok(),
namespace: "default".into(),
importance: None,
superseded_by: None,
})
}
}
@@ -267,12 +272,16 @@ impl Memory for PostgresMemory {
"
SELECT id, key, content, category, created_at, session_id,
(
CASE WHEN key ILIKE '%' || $1 || '%' THEN 2.0 ELSE 0.0 END +
CASE WHEN content ILIKE '%' || $1 || '%' THEN 1.0 ELSE 0.0 END
CASE WHEN to_tsvector('simple', key) @@ plainto_tsquery('simple', $1)
THEN ts_rank_cd(to_tsvector('simple', key), plainto_tsquery('simple', $1)) * 2.0
ELSE 0.0 END +
CASE WHEN to_tsvector('simple', content) @@ plainto_tsquery('simple', $1)
THEN ts_rank_cd(to_tsvector('simple', content), plainto_tsquery('simple', $1))
ELSE 0.0 END
) AS score
FROM {qualified_table}
WHERE ($2::TEXT IS NULL OR session_id = $2)
AND ($1 = '' OR key ILIKE '%' || $1 || '%' OR content ILIKE '%' || $1 || '%')
AND ($1 = '' OR to_tsvector('simple', key || ' ' || content) @@ plainto_tsquery('simple', $1))
{time_filter}
ORDER BY score DESC, updated_at DESC
LIMIT $3
+9
View File
@@ -373,6 +373,9 @@ impl Memory for QdrantMemory {
timestamp: payload.timestamp,
session_id: payload.session_id,
score: Some(point.score),
namespace: "default".into(),
importance: None,
superseded_by: None,
})
})
.collect();
@@ -437,6 +440,9 @@ impl Memory for QdrantMemory {
timestamp: payload.timestamp,
session_id: payload.session_id,
score: None,
namespace: "default".into(),
importance: None,
superseded_by: None,
})
});
@@ -514,6 +520,9 @@ impl Memory for QdrantMemory {
timestamp: payload.timestamp,
session_id: payload.session_id,
score: None,
namespace: "default".into(),
importance: None,
superseded_by: None,
})
})
.collect();
+267
View File
@@ -0,0 +1,267 @@
//! Multi-stage retrieval pipeline.
//!
//! Wraps a `Memory` trait object with staged retrieval:
//! - **Stage 1 (Hot cache):** In-memory LRU of recent recall results.
//! - **Stage 2 (FTS):** FTS5 keyword search with optional early-return.
//! - **Stage 3 (Vector):** Vector similarity search + hybrid merge.
//!
//! Configurable via `[memory]` settings: `retrieval_stages`, `fts_early_return_score`.
use super::traits::{Memory, MemoryEntry};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// A cached recall result.
struct CachedResult {
entries: Vec<MemoryEntry>,
created_at: Instant,
}
/// Multi-stage retrieval pipeline configuration.
#[derive(Debug, Clone)]
pub struct RetrievalConfig {
/// Ordered list of stages: "cache", "fts", "vector".
pub stages: Vec<String>,
/// FTS score above which to early-return without vector stage.
pub fts_early_return_score: f64,
/// Max entries in the hot cache.
pub cache_max_entries: usize,
/// TTL for cached results.
pub cache_ttl: Duration,
}
impl Default for RetrievalConfig {
fn default() -> Self {
Self {
stages: vec!["cache".into(), "fts".into(), "vector".into()],
fts_early_return_score: 0.85,
cache_max_entries: 256,
cache_ttl: Duration::from_secs(300),
}
}
}
/// Multi-stage retrieval pipeline wrapping a `Memory` backend.
pub struct RetrievalPipeline {
memory: Arc<dyn Memory>,
config: RetrievalConfig,
hot_cache: Mutex<HashMap<String, CachedResult>>,
}
impl RetrievalPipeline {
pub fn new(memory: Arc<dyn Memory>, config: RetrievalConfig) -> Self {
Self {
memory,
config,
hot_cache: Mutex::new(HashMap::new()),
}
}
/// Build a cache key from query parameters.
fn cache_key(
query: &str,
limit: usize,
session_id: Option<&str>,
namespace: Option<&str>,
) -> String {
format!(
"{}:{}:{}:{}",
query,
limit,
session_id.unwrap_or(""),
namespace.unwrap_or("")
)
}
/// Check the hot cache for a previous result.
fn check_cache(&self, key: &str) -> Option<Vec<MemoryEntry>> {
let cache = self.hot_cache.lock();
if let Some(cached) = cache.get(key) {
if cached.created_at.elapsed() < self.config.cache_ttl {
return Some(cached.entries.clone());
}
}
None
}
/// Store a result in the hot cache with LRU eviction.
fn store_in_cache(&self, key: String, entries: Vec<MemoryEntry>) {
let mut cache = self.hot_cache.lock();
// LRU eviction: remove oldest entries if at capacity
if cache.len() >= self.config.cache_max_entries {
let oldest_key = cache
.iter()
.min_by_key(|(_, v)| v.created_at)
.map(|(k, _)| k.clone());
if let Some(k) = oldest_key {
cache.remove(&k);
}
}
cache.insert(
key,
CachedResult {
entries,
created_at: Instant::now(),
},
);
}
/// Execute the multi-stage retrieval pipeline.
pub async fn recall(
&self,
query: &str,
limit: usize,
session_id: Option<&str>,
namespace: Option<&str>,
since: Option<&str>,
until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let ck = Self::cache_key(query, limit, session_id, namespace);
for stage in &self.config.stages {
match stage.as_str() {
"cache" => {
if let Some(cached) = self.check_cache(&ck) {
tracing::debug!("retrieval pipeline: cache hit for '{query}'");
return Ok(cached);
}
}
"fts" | "vector" => {
// Both FTS and vector are handled by the backend's recall method
// which already does hybrid merge. We delegate to it.
let results = if let Some(ns) = namespace {
self.memory
.recall_namespaced(ns, query, limit, session_id, since, until)
.await?
} else {
self.memory
.recall(query, limit, session_id, since, until)
.await?
};
if !results.is_empty() {
// Check for FTS early-return: if top score exceeds threshold
// and we're in the FTS stage, we can skip further stages
if stage == "fts" {
if let Some(top_score) = results.first().and_then(|e| e.score) {
if top_score >= self.config.fts_early_return_score {
tracing::debug!(
"retrieval pipeline: FTS early return (score={top_score:.3})"
);
self.store_in_cache(ck, results.clone());
return Ok(results);
}
}
}
self.store_in_cache(ck, results.clone());
return Ok(results);
}
}
other => {
tracing::warn!("retrieval pipeline: unknown stage '{other}', skipping");
}
}
}
// No results from any stage
Ok(Vec::new())
}
/// Invalidate the hot cache (e.g. after a store operation).
pub fn invalidate_cache(&self) {
self.hot_cache.lock().clear();
}
/// Get the number of entries in the hot cache.
pub fn cache_size(&self) -> usize {
self.hot_cache.lock().len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::NoneMemory;
#[tokio::test]
async fn pipeline_returns_empty_from_none_backend() {
let memory = Arc::new(NoneMemory::new());
let pipeline = RetrievalPipeline::new(memory, RetrievalConfig::default());
let results = pipeline
.recall("test", 10, None, None, None, None)
.await
.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn pipeline_cache_invalidation() {
let memory = Arc::new(NoneMemory::new());
let pipeline = RetrievalPipeline::new(memory, RetrievalConfig::default());
// Force a cache entry
let ck = RetrievalPipeline::cache_key("test", 10, None, None);
pipeline.store_in_cache(ck, vec![]);
assert_eq!(pipeline.cache_size(), 1);
pipeline.invalidate_cache();
assert_eq!(pipeline.cache_size(), 0);
}
#[test]
fn cache_key_includes_all_params() {
let k1 = RetrievalPipeline::cache_key("hello", 10, Some("sess-a"), Some("ns1"));
let k2 = RetrievalPipeline::cache_key("hello", 10, Some("sess-b"), Some("ns1"));
let k3 = RetrievalPipeline::cache_key("hello", 10, Some("sess-a"), Some("ns2"));
assert_ne!(k1, k2);
assert_ne!(k1, k3);
}
#[tokio::test]
async fn pipeline_caches_results() {
let memory = Arc::new(NoneMemory::new());
let config = RetrievalConfig {
stages: vec!["cache".into()],
..Default::default()
};
let pipeline = RetrievalPipeline::new(memory, config);
// First call: cache miss, no results
let results = pipeline
.recall("test", 10, None, None, None, None)
.await
.unwrap();
assert!(results.is_empty());
// Manually insert a cache entry
let ck = RetrievalPipeline::cache_key("cached_query", 5, None, None);
let fake_entry = MemoryEntry {
id: "1".into(),
key: "k".into(),
content: "cached content".into(),
category: crate::memory::MemoryCategory::Core,
timestamp: "now".into(),
session_id: None,
score: Some(0.9),
namespace: "default".into(),
importance: None,
superseded_by: None,
};
pipeline.store_in_cache(ck, vec![fake_entry]);
// Cache hit
let results = pipeline
.recall("cached_query", 5, None, None, None, None)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].content, "cached content");
}
}
+129 -23
View File
@@ -197,17 +197,35 @@ impl SqliteMemory {
)?;
// Migration: add session_id column if not present (safe to run repeatedly)
let has_session_id: bool = conn
let schema_sql: String = conn
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='memories'")?
.query_row([], |row| row.get::<_, String>(0))?
.contains("session_id");
if !has_session_id {
.query_row([], |row| row.get::<_, String>(0))?;
if !schema_sql.contains("session_id") {
conn.execute_batch(
"ALTER TABLE memories ADD COLUMN session_id TEXT;
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);",
)?;
}
// Migration: add namespace column
if !schema_sql.contains("namespace") {
conn.execute_batch(
"ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'default';
CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace);",
)?;
}
// Migration: add importance column
if !schema_sql.contains("importance") {
conn.execute_batch("ALTER TABLE memories ADD COLUMN importance REAL DEFAULT 0.5;")?;
}
// Migration: add superseded_by column
if !schema_sql.contains("superseded_by") {
conn.execute_batch("ALTER TABLE memories ADD COLUMN superseded_by TEXT;")?;
}
Ok(())
}
@@ -246,8 +264,13 @@ impl SqliteMemory {
)
}
/// Provide access to the connection for advanced queries (e.g. retrieval pipeline).
pub fn connection(&self) -> &Arc<Mutex<Connection>> {
&self.conn
}
/// Get embedding from cache, or compute + cache it
async fn get_or_compute_embedding(&self, text: &str) -> anyhow::Result<Option<Vec<f32>>> {
pub async fn get_or_compute_embedding(&self, text: &str) -> anyhow::Result<Option<Vec<f32>>> {
if self.embedder.dimensions() == 0 {
return Ok(None); // Noop embedder
}
@@ -310,7 +333,7 @@ impl SqliteMemory {
}
/// FTS5 BM25 keyword search
fn fts5_search(
pub fn fts5_search(
conn: &Connection,
query: &str,
limit: usize,
@@ -356,7 +379,7 @@ impl SqliteMemory {
///
/// Optional `category` and `session_id` filters reduce full-table scans
/// when the caller already knows the scope of relevant memories.
fn vector_search(
pub fn vector_search(
conn: &Connection,
query_embedding: &[f32],
limit: usize,
@@ -473,8 +496,8 @@ impl SqliteMemory {
let until_ref = until_owned.as_deref();
let mut sql =
"SELECT id, key, content, category, created_at, session_id FROM memories \
WHERE 1=1"
"SELECT id, key, content, category, created_at, session_id, namespace, importance, superseded_by FROM memories \
WHERE superseded_by IS NULL AND 1=1"
.to_string();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
let mut idx = 1;
@@ -510,6 +533,9 @@ impl SqliteMemory {
timestamp: row.get(4)?,
session_id: row.get(5)?,
score: None,
namespace: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "default".into()),
importance: row.get(7)?,
superseded_by: row.get(8)?,
})
})?;
@@ -554,8 +580,8 @@ impl Memory for SqliteMemory {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO memories (id, key, content, category, embedding, created_at, updated_at, session_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
"INSERT INTO memories (id, key, content, category, embedding, created_at, updated_at, session_id, namespace, importance)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'default', 0.5)
ON CONFLICT(key) DO UPDATE SET
content = excluded.content,
category = excluded.category,
@@ -641,8 +667,8 @@ impl Memory for SqliteMemory {
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT id, key, content, category, created_at, session_id \
FROM memories WHERE id IN ({placeholders})"
"SELECT id, key, content, category, created_at, session_id, namespace, importance, superseded_by \
FROM memories WHERE superseded_by IS NULL AND id IN ({placeholders})"
);
let mut stmt = conn.prepare(&sql)?;
let id_params: Vec<Box<dyn rusqlite::types::ToSql>> = merged
@@ -659,17 +685,20 @@ impl Memory for SqliteMemory {
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, Option<String>>(6)?,
row.get::<_, Option<f64>>(7)?,
row.get::<_, Option<String>>(8)?,
))
})?;
let mut entry_map = std::collections::HashMap::new();
for row in rows {
let (id, key, content, cat, ts, sid) = row?;
entry_map.insert(id, (key, content, cat, ts, sid));
let (id, key, content, cat, ts, sid, ns, imp, sup) = row?;
entry_map.insert(id, (key, content, cat, ts, sid, ns, imp, sup));
}
for scored in &merged {
if let Some((key, content, cat, ts, sid)) = entry_map.remove(&scored.id) {
if let Some((key, content, cat, ts, sid, ns, imp, sup)) = entry_map.remove(&scored.id) {
if let Some(s) = since_ref {
if ts.as_str() < s {
continue;
@@ -688,6 +717,9 @@ impl Memory for SqliteMemory {
timestamp: ts,
session_id: sid,
score: Some(f64::from(scored.final_score)),
namespace: ns.unwrap_or_else(|| "default".into()),
importance: imp,
superseded_by: sup,
};
if let Some(filter_sid) = session_ref {
if entry.session_id.as_deref() != Some(filter_sid) {
@@ -727,8 +759,8 @@ impl Memory for SqliteMemory {
param_idx += 1;
}
let sql = format!(
"SELECT id, key, content, category, created_at, session_id FROM memories
WHERE {where_clause}{time_conditions}
"SELECT id, key, content, category, created_at, session_id, namespace, importance, superseded_by FROM memories
WHERE superseded_by IS NULL AND ({where_clause}){time_conditions}
ORDER BY updated_at DESC
LIMIT ?{param_idx}"
);
@@ -757,6 +789,9 @@ impl Memory for SqliteMemory {
timestamp: row.get(4)?,
session_id: row.get(5)?,
score: Some(1.0),
namespace: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "default".into()),
importance: row.get(7)?,
superseded_by: row.get(8)?,
})
})?;
for row in rows {
@@ -784,7 +819,7 @@ impl Memory for SqliteMemory {
tokio::task::spawn_blocking(move || -> anyhow::Result<Option<MemoryEntry>> {
let conn = conn.lock();
let mut stmt = conn.prepare(
"SELECT id, key, content, category, created_at, session_id FROM memories WHERE key = ?1",
"SELECT id, key, content, category, created_at, session_id, namespace, importance, superseded_by FROM memories WHERE key = ?1",
)?;
let mut rows = stmt.query_map(params![key], |row| {
@@ -796,6 +831,9 @@ impl Memory for SqliteMemory {
timestamp: row.get(4)?,
session_id: row.get(5)?,
score: None,
namespace: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "default".into()),
importance: row.get(7)?,
superseded_by: row.get(8)?,
})
})?;
@@ -832,14 +870,17 @@ impl Memory for SqliteMemory {
timestamp: row.get(4)?,
session_id: row.get(5)?,
score: None,
namespace: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "default".into()),
importance: row.get(7)?,
superseded_by: row.get(8)?,
})
};
if let Some(ref cat) = category {
let cat_str = Self::category_to_str(cat);
let mut stmt = conn.prepare(
"SELECT id, key, content, category, created_at, session_id FROM memories
WHERE category = ?1 ORDER BY updated_at DESC LIMIT ?2",
"SELECT id, key, content, category, created_at, session_id, namespace, importance, superseded_by FROM memories
WHERE superseded_by IS NULL AND category = ?1 ORDER BY updated_at DESC LIMIT ?2",
)?;
let rows = stmt.query_map(params![cat_str, DEFAULT_LIST_LIMIT], row_mapper)?;
for row in rows {
@@ -853,8 +894,8 @@ impl Memory for SqliteMemory {
}
} else {
let mut stmt = conn.prepare(
"SELECT id, key, content, category, created_at, session_id FROM memories
ORDER BY updated_at DESC LIMIT ?1",
"SELECT id, key, content, category, created_at, session_id, namespace, importance, superseded_by FROM memories
WHERE superseded_by IS NULL ORDER BY updated_at DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![DEFAULT_LIST_LIMIT], row_mapper)?;
for row in rows {
@@ -904,6 +945,71 @@ impl Memory for SqliteMemory {
.await
.unwrap_or(false)
}
async fn recall_namespaced(
&self,
namespace: &str,
query: &str,
limit: usize,
session_id: Option<&str>,
since: Option<&str>,
until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let entries = self
.recall(query, limit * 2, session_id, since, until)
.await?;
let filtered: Vec<MemoryEntry> = entries
.into_iter()
.filter(|e| e.namespace == namespace)
.take(limit)
.collect();
Ok(filtered)
}
async fn store_with_metadata(
&self,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
namespace: Option<&str>,
importance: Option<f64>,
) -> anyhow::Result<()> {
let embedding_bytes = self
.get_or_compute_embedding(content)
.await?
.map(|emb| vector::vec_to_bytes(&emb));
let conn = self.conn.clone();
let key = key.to_string();
let content = content.to_string();
let sid = session_id.map(String::from);
let ns = namespace.unwrap_or("default").to_string();
let imp = importance.unwrap_or(0.5);
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let conn = conn.lock();
let now = Local::now().to_rfc3339();
let cat = Self::category_to_str(&category);
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO memories (id, key, content, category, embedding, created_at, updated_at, session_id, namespace, importance)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(key) DO UPDATE SET
content = excluded.content,
category = excluded.category,
embedding = excluded.embedding,
updated_at = excluded.updated_at,
session_id = excluded.session_id,
namespace = excluded.namespace,
importance = excluded.importance",
params![id, key, content, cat, embedding_bytes, now, now, sid, ns, imp],
)?;
Ok(())
})
.await?
}
}
#[cfg(test)]
+63 -2
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
/// A single message in a conversation trace for procedural memory.
///
/// Used to capture "how to" patterns from tool-calling turns so that
/// backends that support procedural storage (e.g. mem0) can learn from them.
/// backends that support procedural storage can learn from them.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProceduralMessage {
pub role: String,
@@ -23,6 +23,19 @@ pub struct MemoryEntry {
pub timestamp: String,
pub session_id: Option<String>,
pub score: Option<f64>,
/// Namespace for isolation between agents/contexts.
#[serde(default = "default_namespace")]
pub namespace: String,
/// Importance score (0.01.0) for prioritized retrieval.
#[serde(default)]
pub importance: Option<f64>,
/// If this entry was superseded by a newer conflicting entry.
#[serde(default)]
pub superseded_by: Option<String>,
}
fn default_namespace() -> String {
"default".into()
}
impl std::fmt::Debug for MemoryEntry {
@@ -34,6 +47,8 @@ impl std::fmt::Debug for MemoryEntry {
.field("category", &self.category)
.field("timestamp", &self.timestamp)
.field("score", &self.score)
.field("namespace", &self.namespace)
.field("importance", &self.importance)
.finish_non_exhaustive()
}
}
@@ -128,7 +143,7 @@ pub trait Memory: Send + Sync {
/// Store a conversation trace as procedural memory.
///
/// Backends that support procedural storage (e.g. mem0) override this
/// Backends that support procedural storage override this
/// to extract "how to" patterns from tool-calling turns. The default
/// implementation is a no-op.
async fn store_procedural(
@@ -138,6 +153,46 @@ pub trait Memory: Send + Sync {
) -> anyhow::Result<()> {
Ok(())
}
/// Recall memories scoped to a specific namespace.
///
/// Default implementation delegates to `recall()` and filters by namespace.
/// Backends with native namespace support should override for efficiency.
async fn recall_namespaced(
&self,
namespace: &str,
query: &str,
limit: usize,
session_id: Option<&str>,
since: Option<&str>,
until: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
let entries = self
.recall(query, limit * 2, session_id, since, until)
.await?;
let filtered: Vec<MemoryEntry> = entries
.into_iter()
.filter(|e| e.namespace == namespace)
.take(limit)
.collect();
Ok(filtered)
}
/// Store a memory entry with namespace and importance.
///
/// Default implementation delegates to `store()`. Backends with native
/// namespace/importance support should override.
async fn store_with_metadata(
&self,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
_namespace: Option<&str>,
_importance: Option<f64>,
) -> anyhow::Result<()> {
self.store(key, content, category, session_id).await
}
}
#[cfg(test)]
@@ -185,6 +240,9 @@ mod tests {
timestamp: "2026-02-16T00:00:00Z".into(),
session_id: Some("session-abc".into()),
score: Some(0.98),
namespace: "default".into(),
importance: Some(0.7),
superseded_by: None,
};
let json = serde_json::to_string(&entry).unwrap();
@@ -196,5 +254,8 @@ mod tests {
assert_eq!(parsed.category, MemoryCategory::Core);
assert_eq!(parsed.session_id.as_deref(), Some("session-abc"));
assert_eq!(parsed.score, Some(0.98));
assert_eq!(parsed.namespace, "default");
assert_eq!(parsed.importance, Some(0.7));
assert!(parsed.superseded_by.is_none());
}
}
+1
View File
@@ -126,6 +126,7 @@ pub fn hybrid_merge(
b.final_score
.partial_cmp(&a.final_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
results.truncate(limit);
results
+9 -1
View File
@@ -420,9 +420,17 @@ fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig {
snapshot_enabled: false,
snapshot_on_hygiene: false,
auto_hydrate: true,
retrieval_stages: vec!["cache".into(), "fts".into(), "vector".into()],
rerank_enabled: false,
rerank_threshold: 5,
fts_early_return_score: 0.85,
default_namespace: "default".into(),
conflict_threshold: 0.85,
audit_enabled: false,
audit_retention_days: 30,
policy: crate::config::MemoryPolicyConfig::default(),
sqlite_open_timeout_secs: None,
qdrant: crate::config::QdrantConfig::default(),
mem0: crate::config::schema::Mem0Config::default(),
}
}
+10
View File
@@ -767,6 +767,12 @@ impl Provider for OpenAiCodexProvider {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Mutex that serializes all tests which mutate process-global env vars
/// (`std::env::set_var` / `remove_var`). Each such test must hold this
/// lock for its entire duration so that parallel test threads don't race.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
@@ -841,6 +847,7 @@ mod tests {
#[test]
fn resolve_responses_url_prefers_explicit_endpoint_env() {
let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _endpoint_guard = EnvGuard::set(
CODEX_RESPONSES_URL_ENV,
Some("https://env.example.com/v1/responses"),
@@ -856,6 +863,7 @@ mod tests {
#[test]
fn resolve_responses_url_uses_provider_api_url_override() {
let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _endpoint_guard = EnvGuard::set(CODEX_RESPONSES_URL_ENV, None);
let _base_guard = EnvGuard::set(CODEX_BASE_URL_ENV, None);
@@ -959,6 +967,7 @@ mod tests {
#[test]
fn resolve_reasoning_effort_prefers_configured_override() {
let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = EnvGuard::set("ZEROCLAW_CODEX_REASONING_EFFORT", Some("low"));
assert_eq!(
resolve_reasoning_effort("gpt-5-codex", Some("high")),
@@ -968,6 +977,7 @@ mod tests {
#[test]
fn resolve_reasoning_effort_uses_legacy_env_when_unconfigured() {
let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = EnvGuard::set("ZEROCLAW_CODEX_REASONING_EFFORT", Some("minimal"));
assert_eq!(
resolve_reasoning_effort("gpt-5-codex", None),
+3 -3
View File
@@ -1236,7 +1236,7 @@ mod tests {
#[cfg(not(target_os = "windows"))]
#[test]
fn run_capture_reads_stdout() {
let out = run_capture(Command::new("sh").args(["-lc", "echo hello"]))
let out = run_capture(Command::new("sh").args(["-c", "echo hello"]))
.expect("stdout capture should succeed");
assert_eq!(out.trim(), "hello");
}
@@ -1244,7 +1244,7 @@ mod tests {
#[cfg(not(target_os = "windows"))]
#[test]
fn run_capture_falls_back_to_stderr() {
let out = run_capture(Command::new("sh").args(["-lc", "echo warn 1>&2"]))
let out = run_capture(Command::new("sh").args(["-c", "echo warn 1>&2"]))
.expect("stderr capture should succeed");
assert_eq!(out.trim(), "warn");
}
@@ -1252,7 +1252,7 @@ mod tests {
#[cfg(not(target_os = "windows"))]
#[test]
fn run_checked_errors_on_non_zero_status() {
let err = run_checked(Command::new("sh").args(["-lc", "exit 17"]))
let err = run_checked(Command::new("sh").args(["-c", "exit 17"]))
.expect_err("non-zero exit should error");
assert!(err.to_string().contains("Command failed"));
}
+62
View File
@@ -0,0 +1,62 @@
# คำอธิบายเครื่องมือภาษาไทย (Thai tool descriptions)
#
# แต่ละคีย์ภายใต้ [tools] จะตรงกับค่าที่ส่งกลับจาก name() ของเครื่องมือ
# ค่าคือคำอธิบายที่มนุษย์อ่านได้ซึ่งจะแสดงใน system prompts
[tools]
backup = "สร้าง, ลิสต์, ตรวจสอบ และกู้คืนข้อมูลสำรองของเวิร์กสเปซ"
browser = "การทำงานอัตโนมัติบนเว็บ/เบราว์เซอร์ด้วยแบ็คเอนด์ที่ถอดเปลี่ยนได้ (agent-browser, rust-native, computer_use) รองรับการดำเนินการ DOM และการดำเนินการระดับ OS (ย้ายเมาส์, คลิก, ลาก, พิมพ์คีย์, กดคีย์, จับภาพหน้าจอ) ผ่าน computer-use sidecar ใช้ 'snapshot' เพื่อจับคู่พารามิเตอร์โต้ตอบกับ refs (@e1, @e2) บังคับใช้ browser.allowed_domains สำหรับการเปิดหน้าเว็บ"
browser_delegate = "มอบหมายงานที่ใช้เบราว์เซอร์ให้กับ CLI ที่มีความสามารถด้านเบราว์เซอร์เพื่อโต้ตอบกับเว็บแอปพลิเคชัน เช่น Teams, Outlook, Jira, Confluence"
browser_open = "เปิด URL HTTPS ที่ได้รับอนุญาตในเบราว์เซอร์ของระบบ ข้อจำกัดด้านความปลอดภัย: เฉพาะโดเมนใน allowlist เท่านั้น, ห้ามโฮสต์โลคัล/ส่วนตัว, ห้ามดึงข้อมูล (scraping)"
cloud_ops = "เครื่องมือให้คำปรึกษาด้านการเปลี่ยนแปลงคลาวด์ วิเคราะห์แผน IaC, ประเมินเส้นทางการย้ายระบบ, ตรวจสอบค่าใช้จ่าย และตรวจสอบสถาปัตยกรรมตามหลัก Well-Architected Framework อ่านอย่างเดียว: ไม่สร้างหรือแก้ไขทรัพยากรคลาวด์"
cloud_patterns = "ไลบรารีรูปแบบคลาวด์ แนะนำรูปแบบสถาปัตยกรรม cloud-native ที่เหมาะสม (containerization, serverless, การปรับปรุงฐานข้อมูลให้ทันสมัย ฯลฯ) ตามคำอธิบายภาระงาน"
composio = "รันคำสั่งบนแอปมากกว่า 1,000 แอปผ่าน Composio (Gmail, Notion, GitHub, Slack ฯลฯ) ใช้ action='list' เพื่อดูคำสั่งที่ใช้งานได้ ใช้ action='execute' พร้อม action_name/tool_slug และพารามิเตอร์เพื่อรันคำสั่ง หากไม่แน่ใจพารามิเตอร์ ให้ส่ง 'text' พร้อมคำอธิบายภาษาธรรมชาติแทน ใช้ action='list_accounts' เพื่อดูบัญชีที่เชื่อมต่อ และ action='connect' เพื่อรับ URL OAuth"
content_search = "ค้นหาเนื้อหาไฟล์ด้วยรูปแบบ regex ภายในเวิร์กสเปซ รองรับ ripgrep (rg) พร้อมระบบสำรองเป็น grep โหมดเอาต์พุต: 'content' (บรรทัดที่ตรงกันพร้อมบริบท), 'files_with_matches' (เฉพาะเส้นทางไฟล์), 'count' (จำนวนที่พบต่อไฟล์) ตัวอย่าง: pattern='fn main', include='*.rs', output_mode='content'"
cron_add = """สร้างงานตั้งเวลา cron (shell หรือ agent) รองรับตารางเวลาแบบ cron/at/every ใช้ job_type='agent' พร้อม prompt เพื่อรัน AI agent ตามกำหนดเวลา หากต้องการส่งเอาต์พุตไปยังแชนเนล (Discord, Telegram, Slack, Mattermost, Matrix) ให้ตั้งค่า delivery={"mode":"announce","channel":"discord","to":"<channel_id_or_chat_id>"} นี่เป็นเครื่องมือที่แนะนำสำหรับการส่งข้อความตั้งเวลาหรือหน่วงเวลาไปยังผู้ใช้ผ่านแชนเนล"""
cron_list = "รายการงานตั้งเวลา cron ทั้งหมด"
cron_remove = "ลบงานตั้งเวลาด้วย id"
cron_run = "บังคับรันงานตั้งเวลาทันทีและบันทึกประวัติการรัน"
cron_runs = "รายการประวัติการรันล่าสุดของงานตั้งเวลา"
cron_update = "แก้ไขงานตั้งเวลาที่มีอยู่ (ตารางเวลา, คำสั่ง, prompt, การเปิดใช้งาน, การส่งข้อมูล, โมเดล ฯลฯ)"
data_management = "การเก็บรักษาข้อมูลเวิร์กสเปซ, การล้างข้อมูล และสถิติการจัดเก็บ"
delegate = "มอบหมายงานย่อยให้กับเอเจนต์เฉพาะทาง ใช้เมื่อ: งานจะได้รับประโยชน์จากโมเดลที่ต่างออกไป (เช่น สรุปผลเร็ว, การให้เหตุผลเชิงลึก, การสร้างโค้ด) เอเจนต์ย่อยจะรันหนึ่ง prompt ตามค่าเริ่มต้น หากตั้ง agentic=true จะสามารถทำงานวนซ้ำด้วยเครื่องมือที่จำกัดได้"
discord_search = "ค้นหาประวัติข้อความ Discord ที่เก็บไว้ใน discord.db ใช้เพื่อค้นหาข้อความในอดีต, สรุปกิจกรรมในแชนเนล หรือดูว่าผู้ใช้พูดอะไร รองรับการค้นหาด้วยคีย์เวิร์ดและตัวกรองเสริม: channel_id, since, until"
file_edit = "แก้ไขไฟล์โดยการแทนที่ข้อความที่ตรงกันเป๊ะๆ ด้วยเนื้อหาใหม่"
file_read = "อ่านเนื้อหาไฟล์พร้อมเลขบรรทัด รองรับการอ่านบางส่วนผ่าน offset และ limit ดึงข้อความจาก PDF; ไฟล์ไบนารีอื่นจะถูกอ่านด้วยการแปลง UTF-8 แบบสูญเสียข้อมูล"
file_write = "เขียนเนื้อหาลงในไฟล์ในเวิร์กสเปซ"
git_operations = "รันคำสั่ง Git แบบโครงสร้าง (status, diff, log, branch, commit, add, checkout, stash) ให้เอาต์พุต JSON ที่แยกส่วนแล้ว และรวมเข้ากับนโยบายความปลอดภัยสำหรับการควบคุมตนเอง"
glob_search = "ค้นหาไฟล์ที่ตรงกับรูปแบบ glob ภายในเวิร์กสเปซ ส่งกลับรายการเส้นทางไฟล์ที่ตรงกันเทียบกับรูทของเวิร์กสเปซ ตัวอย่าง: '**/*.rs' (ไฟล์ Rust ทั้งหมด), 'src/**/mod.rs' (mod.rs ทั้งหมดใน src)"
google_workspace = "โต้ตอบกับบริการ Google Workspace (Drive, Gmail, Calendar, Sheets, Docs ฯลฯ) ผ่าน gws CLI ต้องติดตั้งและยืนยันตัวตน gws ก่อน"
hardware_board_info = "ส่งกลับข้อมูลบอร์ดฉบับเต็ม (ชิป, สถาปัตยกรรม, แผนผังหน่วยความจำ) สำหรับฮาร์ดแวร์ที่เชื่อมต่อ ใช้เมื่อ: ผู้ใช้ถามเกี่ยวกับ 'board info', 'ใช้บอร์ดอะไร', 'ฮาร์ดแวร์ที่ต่ออยู่', 'ข้อมูลชิป' หรือ 'memory map'"
hardware_memory_map = "ส่งกลับแผนผังหน่วยความจำ (ช่วงที่อยู่ flash และ RAM) สำหรับฮาร์ดแวร์ที่เชื่อมต่อ ใช้เมื่อ: ผู้ใช้ถามเกี่ยวกับ 'upper and lower memory addresses', 'แผนผังหน่วยความจำ' หรือ 'ที่อยู่ที่อ่านได้'"
hardware_memory_read = "อ่านค่าหน่วยความจำ/รีจิสเตอร์จริงจาก Nucleo ผ่าน USB ใช้เมื่อ: ผู้ใช้ถามให้ 'อ่านค่ารีจิสเตอร์', 'อ่านหน่วยความจำที่แอดเดรส', 'dump memory' ส่งกลับเป็น hex dump ต้องเชื่อมต่อ Nucleo ผ่าน USB พารามิเตอร์: address (hex), length (bytes)"
http_request = "ส่งคำขอ HTTP ไปยัง API ภายนอก รองรับเมธอด GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS ข้อจำกัดด้านความปลอดภัย: เฉพาะโดเมนใน allowlist เท่านั้น, ห้ามโฮสต์โลคัล/ส่วนตัว, ตั้งค่า timeout และจำกัดขนาดการตอบกลับได้"
image_info = "อ่านข้อมูลเมตาของไฟล์รูปภาพ (รูปแบบ, ขนาดกว้างยาว, ขนาดไฟล์) และสามารถเลือกส่งกลับข้อมูลที่เข้ารหัส base64 ได้"
jira = "โต้ตอบกับ Jira: ดึงตั๋วตามระดับรายละเอียดที่กำหนด, ค้นหา issue ด้วย JQL และเพิ่มคอมเมนต์พร้อมรองรับการกล่าวถึง (mention) และการจัดรูปแบบ"
knowledge = "จัดการกราฟความรู้ของการตัดสินใจด้านสถาปัตยกรรม, รูปแบบโซลูชัน, บทเรียนที่ได้รับ และผู้เชี่ยวชาญ การดำเนินการ: capture, search, relate, suggest, expert_find, lessons_extract, graph_stats"
linkedin = "จัดการ LinkedIn: สร้างโพสต์, รายการโพสต์ของคุณ, คอมเมนต์, แสดงความรู้สึก, ลบโพสต์, ดูการมีส่วนร่วม, ดูข้อมูลโปรไฟล์ และอ่านกลยุทธ์เนื้อหาที่กำหนดไว้ ต้องมีข้อมูลยืนยันตัวตน LINKEDIN_* ในไฟล์ .env"
memory_forget = "ลบความจำด้วยคีย์ ใช้เพื่อลบข้อมูลที่ล้าสมัยหรือข้อมูลที่ละเอียดอ่อน ส่งกลับว่าพบและลบความจำหรือไม่"
memory_recall = "ค้นหาความจำระยะยาวสำหรับข้อเท็จจริง ความชอบ หรือบริบทที่เกี่ยวข้อง ส่งกลับผลลัพธ์ที่จัดอันดับตามความเกี่ยวข้อง"
memory_store = "เก็บข้อเท็จจริง ความชอบ หรือบันทึกลงในความจำระยะยาว ใช้หมวดหมู่ 'core' สำหรับข้อมูลถาวร, 'daily' สำหรับบันทึกเซสชัน, 'conversation' สำหรับบริบทการแชท หรือชื่อหมวดหมู่ที่กำหนดเอง"
microsoft365 = "การรวมเข้ากับ Microsoft 365: จัดการอีเมล Outlook, ข้อความ Teams, กิจกรรมปฏิทิน, ไฟล์ OneDrive และการค้นหา SharePoint ผ่าน Microsoft Graph API"
model_routing_config = "จัดการการตั้งค่าโมเดลเริ่มต้น, เส้นทางผู้ให้บริการ/โมเดลตามสถานการณ์, กฎการจำแนกประเภท และโปรไฟล์เอเจนต์ย่อย"
notion = "โต้ตอบกับ Notion: สอบถามฐานข้อมูล, อ่าน/สร้าง/อัปเดตหน้า และค้นหาในเวิร์กสเปซ"
pdf_read = "ดึงข้อความธรรมดาจากไฟล์ PDF ในเวิร์กสเปซ ส่งกลับข้อความที่อ่านได้ทั้งหมด ไฟล์ PDF ที่มีแต่รูปภาพหรือเข้ารหัสจะส่งกลับผลลัพธ์ที่ว่างเปล่า ต้องเปิดฟีเจอร์ 'rag-pdf' ตอน build"
project_intel = "ข้อมูลอัจฉริยะในการส่งมอบโปรเจกต์: สร้างรายงานสถานะ, ตรวจจับความเสี่ยง, ร่างการอัปเดตสำหรับลูกค้า, สรุป sprint และประเมินพยายาม เป็นเครื่องมือวิเคราะห์แบบอ่านอย่างเดียว"
proxy_config = "จัดการการตั้งค่าพร็อกซีของ ZeroClaw (ขอบเขต: environment | zeroclaw | services) รวมถึงการปรับใช้ในขณะรันและใน process environment"
pushover = "ส่งการแจ้งเตือน Pushover ไปยังอุปกรณ์ของคุณ ต้องมี PUSHOVER_TOKEN และ PUSHOVER_USER_KEY ในไฟล์ .env"
schedule = """จัดการงาน shell ที่ตั้งเวลาไว้ การดำเนินการ: create/add/once/list/get/cancel/remove/pause/resume คำเตือน: เครื่องมือนี้สร้างงาน shell ที่เอาต์พุตจะถูกบันทึกใน log เท่านั้น ไม่ส่งไปยังแชนเนลใดๆ หากต้องการส่งข้อความตั้งเวลาไปยัง Discord/Telegram/Slack/Matrix ให้ใช้เครื่องมือ cron_add"""
screenshot = "จับภาพหน้าจอปัจจุบัน ส่งกลับเส้นทางไฟล์และข้อมูล PNG ที่เข้ารหัส base64"
security_ops = "เครื่องมือปฏิบัติการด้านความปลอดภัยสำหรับบริการจัดการความปลอดภัยไซเบอร์ การดำเนินการ: triage_alert, run_playbook, parse_vulnerability, generate_report, list_playbooks, alert_stats"
shell = "รันคำสั่ง shell ในไดเรกทอรีรูทของเวิร์กสเปซ"
sop_advance = "รายงานผลลัพธ์ของขั้นตอน SOP ปัจจุบันและไปยังขั้นตอนถัดไป ระบุ run_id, ขั้นตอนสำเร็จหรือล้มเหลว และสรุปเอาต์พุตสั้นๆ"
sop_approve = "อนุมัติขั้นตอน SOP ที่รอการอนุมัติจากผู้ปฏิบัติงาน ส่งกลับคำสั่งในขั้นตอนที่จะดำเนินการ ใช้ sop_status เพื่อดูว่ามีรายการใดรอยู่"
sop_execute = "สั่งรันขั้นตอนการปฏิบัติงานมาตรฐาน (SOP) ด้วยชื่อด้วยตนเอง ส่งกลับ run ID และคำสั่งขั้นตอนแรก ใช้ sop_list เพื่อดู SOP ที่มี"
sop_list = "รายการขั้นตอนการปฏิบัติงานมาตรฐาน (SOP) ทั้งหมดที่โหลดไว้ พร้อมเงื่อนไขการรัน, ลำดับความสำคัญ, จำนวนขั้นตอน และจำนวนการรันที่ใช้งานอยู่"
sop_status = "สอบถามสถานะการรัน SOP ระบุ run_id สำหรับการรันเฉพาะ หรือ sop_name สำหรับรายการการรันของ SOP นั้น หากไม่มีพารามิเตอร์จะแสดงการรันที่ใช้งานอยู่ทั้งหมด"
swarm = "ประสานงานกลุ่มเอเจนต์เพื่อทำงานร่วมกัน รองรับกลยุทธ์แบบลำดับ (pipeline), แบบขนาน (fan-out/fan-in) และแบบเราเตอร์ (เลือกโดย LLM)"
tool_search = """ดึงข้อมูลโครงสร้าง schema ฉบับเต็มสำหรับเครื่องมือ MCP ที่โหลดแบบหน่วงเวลา (deferred) เพื่อให้สามารถเรียกใช้งานได้ ใช้ "select:name1,name2" สำหรับการจับคู่ที่แน่นอนหรือใช้คีย์เวิร์ดเพื่อค้นหา"""
weather = "ดึงข้อมูลสภาพอากาศปัจจุบันและพยากรณ์อากาศสำหรับสถานที่ใดก็ได้ทั่วโลก รองรับชื่อเมือง (ในภาษาหรือตัวอักษรใดก็ได้), รหัสสนามบิน IATA, พิกัด GPS, รหัสไปรษณีย์ และการระบุตำแหน่งตามโดเมน ส่งกลับอุณหภูมิ, ความรู้สึกจริง, ความชื้น, ความเร็ว/ทิศทางลม, ปริมาณน้ำฝน, ทัศนวิสัย, ความกดอากาศ, ดัชนี UV และเมฆปกคลุม เลือกพยากรณ์อากาศได้ 0–3 วัน หน่วยเริ่มต้นเป็นเมตริก (°C, km/h, mm) แต่สามารถตั้งเป็นอิมพีเรียลได้ ไม่ต้องใช้คีย์ API"
web_fetch = "ดึงข้อมูลหน้าเว็บและส่งกลับเนื้อหาเป็นข้อความธรรมดาที่สะอาด หน้า HTML จะถูกแปลงเป็นข้อความที่อ่านได้โดยอัตมัติ คำตอบที่เป็น JSON และข้อความธรรมดาจะถูกส่งกลับตามเดิม เฉพาะคำขอ GET เท่านั้น ปฏิบัติตามการเปลี่ยนเส้นทาง ความปลอดภัย: เฉพาะโดเมนใน allowlist เท่านั้น ห้ามโฮสต์โลคัล/ส่วนตัว"
web_search_tool = "ค้นหาข้อมูลบนเว็บ ส่งกลับผลลัพธ์การค้นหาที่เกี่ยวข้องพร้อมชื่อเรื่อง, URL และคำอธิบาย ใช้เพื่อค้นหาข้อมูลปัจจุบัน ข่าวสาร หรือหัวข้อการวิจัย"
workspace = "จัดการเวิร์กสเปซแบบหลายไคลเอนต์ คำสั่งย่อย: list, switch, create, info, export แต่ละเวิร์กสเปซจะมีการแยกหน่วยความจำ, การตรวจสอบ, ความลับ และข้อจำกัดเครื่องมือออกจากกัน"
-1
View File
@@ -6,7 +6,6 @@ import {
ChevronRight,
Terminal,
Package,
ChevronsUpDown,
} from 'lucide-react';
import type { ToolSpec, CliTool } from '@/types/api';
import { getTools, getCliTools } from '@/lib/api';