Compare commits
1 Commits
claude/iss
...
burn/842-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3238cf4eb1 |
44
docs/plans/awesome-ai-tools-integration.md
Normal file
44
docs/plans/awesome-ai-tools-integration.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# awesome-ai-tools Integration Plan
|
||||
|
||||
**Tracking:** #842
|
||||
**Source report:** docs/tool-investigation-2026-04-15.md
|
||||
**Date:** 2026-04-16
|
||||
|
||||
---
|
||||
|
||||
## Status Dashboard
|
||||
|
||||
| # | Tool | Category | Impact | Effort | Status | Issue |
|
||||
|---|------|----------|--------|--------|--------|-------|
|
||||
| 1 | Mem0 | Memory | 5/5 | 3/5 | Cloud + Local done | #842 |
|
||||
| 2 | LightRAG | RAG | 4/5 | 3/5 | Not started | #857 |
|
||||
| 3 | n8n | Orchestration | 5/5 | 4/5 | Not started | #858 |
|
||||
| 4 | RAGFlow | RAG | 4/5 | 4/5 | Not started | #859 |
|
||||
| 5 | tensorzero | LLMOps | 4/5 | 3/5 | Not started | #860 |
|
||||
|
||||
---
|
||||
|
||||
## #1: Mem0 — DONE
|
||||
|
||||
Cloud: `plugins/memory/mem0/` (MEM0_API_KEY required)
|
||||
Local: `plugins/memory/mem0_local/` (ChromaDB, no API key)
|
||||
|
||||
## #2: LightRAG (P2)
|
||||
|
||||
Create `plugins/rag/lightrag/` plugin. Index skill docs. Use local Ollama embeddings.
|
||||
|
||||
## #3: n8n (P3)
|
||||
|
||||
Deploy as Docker service. Create workflow templates for Hermes patterns.
|
||||
|
||||
## #4: RAGFlow (P4)
|
||||
|
||||
Deploy as Docker service. Integrate via HTTP API for document understanding.
|
||||
|
||||
## #5: tensorzero (P3)
|
||||
|
||||
Evaluate as provider routing replacement. Canary migration (10% traffic first).
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-16*
|
||||
151
docs/tool-investigation-2026-04-15.md
Normal file
151
docs/tool-investigation-2026-04-15.md
Normal file
@@ -0,0 +1,151 @@
|
||||
## Tool Investigation Report: Top 5 Recommendations from awesome-ai-tools
|
||||
|
||||
**Source:** [formatho/awesome-ai-tools](https://github.com/formatho/awesome-ai-tools)
|
||||
**Date:** 2026-04-15
|
||||
**Tools Analyzed:** 414 across 9 categories
|
||||
**Agent:** Timmy
|
||||
|
||||
---
|
||||
|
||||
## Analysis Summary
|
||||
|
||||
Scanned 414 tools from the awesome-ai-tools repository. Evaluated each against Hermes integration potential across five categories: Memory/Context, Inference Optimization, Agent Orchestration, Workflow Automation, and Retrieval/RAG.
|
||||
|
||||
### Evaluation Criteria
|
||||
- **Stars:** GitHub community validation (stability signal)
|
||||
- **Freshness:** Active development (Fresh = updated <=7 days)
|
||||
- **Integration Fit:** How well it complements Hermes' existing architecture (skills, memory, tools)
|
||||
- **Integration Effort:** 1 (trivial drop-in) to 5 (major refactor required)
|
||||
- **Impact:** 1 (incremental) to 5 (transformative)
|
||||
|
||||
---
|
||||
|
||||
## Top 5 Recommended Tools
|
||||
|
||||
### #1: Mem0 — Universal Memory Layer for AI Agents
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Category** | Memory/Context |
|
||||
| **GitHub** | [mem0ai/mem0](https://github.com/mem0ai/mem0) |
|
||||
| **Stars** | 53.1k |
|
||||
| **Freshness** | Fresh |
|
||||
| **Integration Effort** | 3/5 |
|
||||
| **Impact** | 5/5 |
|
||||
| **Hermes Status** | IMPLEMENTED (plugins/memory/mem0/) + LOCAL MODE (plugins/memory/mem0_local/) |
|
||||
|
||||
**Why it fits Hermes:**
|
||||
Hermes currently has session_search (transcript recall) and memory (persistent facts), but lacks a unified memory layer that bridges sessions with semantic understanding. Mem0 provides exactly this: automatic memory extraction from conversations, deduplication, and cross-session retrieval with semantic search.
|
||||
|
||||
**Integration path:**
|
||||
- Cloud: plugins/memory/mem0/ (requires MEM0_API_KEY)
|
||||
- Local: plugins/memory/mem0_local/ (ChromaDB-backed, no API key)
|
||||
- Auto-extract facts from session transcripts
|
||||
- Query before session_search for richer contextual recall
|
||||
|
||||
**Key risk:** Mem0 is freemium — core is open-source but advanced features require paid tier. Local mode mitigates this entirely.
|
||||
|
||||
---
|
||||
|
||||
### #2: LightRAG — Simple and Fast Retrieval-Augmented Generation
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Category** | Retrieval/RAG |
|
||||
| **GitHub** | [HKUDS/LightRAG](https://github.com/HKUDS/LightRAG) |
|
||||
| **Stars** | 33.1k |
|
||||
| **Freshness** | Fresh |
|
||||
| **Integration Effort** | 3/5 |
|
||||
| **Impact** | 4/5 |
|
||||
| **Hermes Status** | NOT IMPLEMENTED — Issue #857 |
|
||||
|
||||
**Why it fits Hermes:**
|
||||
Hermes has 190+ skills but no unified knowledge retrieval system. LightRAG adds graph-based RAG that understands relationships between concepts, not just keyword matches. It's lightweight, runs locally, and has a simple API.
|
||||
|
||||
**Integration path:**
|
||||
- LightRAG as a local knowledge base for skill references
|
||||
- Index GENOME.md files, README.md, and key codebase files
|
||||
- Use local Ollama models for embeddings
|
||||
- Complements existing search_files without replacing it
|
||||
|
||||
---
|
||||
|
||||
### #3: n8n — Workflow Automation Platform
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Category** | Workflow Automation / Agent Orchestration |
|
||||
| **GitHub** | [n8n-io/n8n](https://github.com/n8n-io/n8n) |
|
||||
| **Stars** | 183.9k |
|
||||
| **Freshness** | Fresh |
|
||||
| **Integration Effort** | 4/5 |
|
||||
| **Impact** | 5/5 |
|
||||
| **Hermes Status** | NOT IMPLEMENTED — Issue #858 |
|
||||
|
||||
**Why it fits Hermes:**
|
||||
n8n provides a self-hosted, fair-code workflow platform with 400+ integrations. Rather than replacing Hermes' agent loop, n8n sits above it: trigger Hermes agents from external events, chain multi-agent workflows, and visualize execution.
|
||||
|
||||
---
|
||||
|
||||
### #4: RAGFlow — Open-Source RAG Engine
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Category** | Retrieval/RAG |
|
||||
| **GitHub** | [infiniflow/ragflow](https://github.com/infiniflow/ragflow) |
|
||||
| **Stars** | 77.9k |
|
||||
| **Freshness** | Fresh |
|
||||
| **Integration Effort** | 4/5 |
|
||||
| **Impact** | 4/5 |
|
||||
| **Hermes Status** | NOT IMPLEMENTED — Issue #859 |
|
||||
|
||||
**Why it fits Hermes:**
|
||||
RAGFlow handles document parsing (PDF, Word, images via OCR), chunking, embedding, and retrieval with a web UI. Enables "document understanding" as a first-class capability.
|
||||
|
||||
---
|
||||
|
||||
### #5: tensorzero — LLMOps Platform
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Category** | Inference Optimization / LLMOps |
|
||||
| **GitHub** | [tensorzero/tensorzero](https://github.com/tensorzero/tensorzero) |
|
||||
| **Stars** | 11.2k |
|
||||
| **Freshness** | Fresh |
|
||||
| **Integration Effort** | 3/5 |
|
||||
| **Impact** | 4/5 |
|
||||
| **Hermes Status** | NOT IMPLEMENTED — Issue #860 |
|
||||
|
||||
**Why it fits Hermes:**
|
||||
TensorZero unifies LLM gateway, observability, evaluation, and optimization. Replaces custom provider routing with a maintained, battle-tested platform.
|
||||
|
||||
---
|
||||
|
||||
## Honorable Mentions
|
||||
|
||||
| Tool | Stars | Category | Why Not Top 5 |
|
||||
|------|-------|----------|---------------|
|
||||
| memvid | 14.9k | Memory | Newer; Mem0 is more mature |
|
||||
| mempalace | 44.8k | Memory | Already evaluated; Mem0 has broader API |
|
||||
| Everything Claude Code | 154.3k | Agent | Too Claude-specific |
|
||||
| Portkey AI Gateway | 11.3k | Gateway | TensorZero is OSS; Portkey is freemium |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
| Priority | Tool | Action | Status | Issue |
|
||||
|----------|------|--------|--------|-------|
|
||||
| P1 | Mem0 | Local-only mode (ChromaDB) | DONE | #842 |
|
||||
| P2 | LightRAG | Set up local instance, index skills | Not started | #857 |
|
||||
| P3 | tensorzero | Evaluate as provider routing | Not started | #860 |
|
||||
| P4 | RAGFlow | Deploy Docker, test docs | Not started | #859 |
|
||||
| P5 | n8n | Deploy for workflow viz | Not started | #858 |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
- Source: https://github.com/formatho/awesome-ai-tools
|
||||
- Total tools: 414 across 9 categories
|
||||
- Last updated: April 16, 2026
|
||||
- Tracking issue: Timmy_Foundation/hermes-agent#842
|
||||
60
plugins/memory/mem0_local/README.md
Normal file
60
plugins/memory/mem0_local/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Mem0 Local - Sovereign Memory Provider
|
||||
|
||||
Local-only memory provider using ChromaDB. No API key required - all data stays on your machine.
|
||||
|
||||
## How It Differs from Cloud Mem0
|
||||
|
||||
| Feature | Cloud Mem0 | Local Mem0 |
|
||||
|---------|-----------|------------|
|
||||
| API key | Required | Not needed |
|
||||
| Data location | Mem0 servers | Your machine |
|
||||
| Fact extraction | Server-side LLM | Pattern-based heuristics |
|
||||
| Reranking | Yes | No |
|
||||
| Cost | Freemium | Free forever |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install chromadb
|
||||
hermes config set memory.provider mem0-local
|
||||
```
|
||||
|
||||
Or manually in ~/.hermes/config.yaml:
|
||||
```yaml
|
||||
memory:
|
||||
provider: mem0-local
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Config file: $HERMES_HOME/mem0-local.json
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| storage_path | ~/.hermes/mem0-local/ | ChromaDB storage directory |
|
||||
| collection_prefix | mem0 | Collection name prefix |
|
||||
| max_memories | 10000 | Maximum stored memories |
|
||||
|
||||
## Tools
|
||||
|
||||
Same interface as cloud Mem0:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| mem0_profile | All stored memories about the user |
|
||||
| mem0_search | Semantic search by meaning |
|
||||
| mem0_conclude | Store a fact verbatim |
|
||||
|
||||
## Data Sovereignty
|
||||
|
||||
All data is stored in $HERMES_HOME/mem0-local/ as a ChromaDB persistent database. No network calls are made.
|
||||
|
||||
To back up: copy the mem0-local/ directory.
|
||||
To reset: delete the mem0-local/ directory.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Fact extraction is pattern-based (not LLM-powered)
|
||||
- No reranking - results ranked by embedding similarity only
|
||||
- No cross-device sync (by design)
|
||||
- Requires chromadb pip dependency (~50MB)
|
||||
381
plugins/memory/mem0_local/__init__.py
Normal file
381
plugins/memory/mem0_local/__init__.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Mem0 Local memory provider - ChromaDB-backed, no API key required.
|
||||
|
||||
Sovereign deployment: all data stays on the user's machine. Uses ChromaDB
|
||||
for vector storage and simple heuristic fact extraction (no server-side LLM).
|
||||
|
||||
Compatible tool schemas with the cloud Mem0 provider:
|
||||
mem0_profile - retrieve all stored memories
|
||||
mem0_search - semantic search by meaning
|
||||
mem0_conclude - store a fact verbatim
|
||||
|
||||
Config via $HERMES_HOME/mem0-local.json or environment variables:
|
||||
MEM0_LOCAL_PATH - storage directory (default: $HERMES_HOME/mem0-local/)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Circuit breaker
|
||||
_BREAKER_THRESHOLD = 5
|
||||
_BREAKER_COOLDOWN_SECS = 120
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load local config from env vars, with $HERMES_HOME/mem0-local.json overrides."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
config = {
|
||||
"storage_path": os.environ.get("MEM0_LOCAL_PATH", ""),
|
||||
"collection_prefix": "mem0",
|
||||
"max_memories": 10000,
|
||||
}
|
||||
|
||||
config_path = get_hermes_home() / "mem0-local.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
file_cfg = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config.update({k: v for k, v in file_cfg.items()
|
||||
if v is not None and v != ""})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not config["storage_path"]:
|
||||
config["storage_path"] = str(get_hermes_home() / "mem0-local")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Simple fact extraction patterns (no LLM required)
|
||||
_FACT_PATTERNS = [
|
||||
(r"(?:my|the user'?s?)\s+(?:name|username)\s+(?:is|=)\s+(.+?)(?:\.|$)", "user.name"),
|
||||
(r"(?:i|user)\s+(?:prefer|like|use|want|need)s?\s+(.+?)(?:\.|$)", "preference"),
|
||||
(r"(?:i|user)\s+(?:work|am)\s+(?:at|as|on|with)\s+(.+?)(?:\.|$)", "context"),
|
||||
(r"(?:remember|note|save|store)[:\s]+(.+?)(?:\.|$)", "explicit"),
|
||||
(r"(?:my|the)\s+(?:timezone|tz)\s+(?:is|=)\s+(.+?)(?:\.|$)", "user.timezone"),
|
||||
(r"(?:my|the)\s+(?:project|repo|codebase)\s+(?:is|=|called)\s+(.+?)(?:\.|$)", "project"),
|
||||
(r"(?:actually|correction|instead)[:\s]+(.+?)(?:\.|$)", "correction"),
|
||||
]
|
||||
|
||||
|
||||
def _extract_facts(text: str) -> List[Dict[str, str]]:
|
||||
"""Extract structured facts from conversation text using pattern matching."""
|
||||
facts = []
|
||||
if not text or len(text) < 10:
|
||||
return facts
|
||||
text_lower = text.lower().strip()
|
||||
|
||||
for pattern, category in _FACT_PATTERNS:
|
||||
matches = re.findall(pattern, text_lower, re.IGNORECASE)
|
||||
for match in matches:
|
||||
fact_text = match.strip() if isinstance(match, str) else match[0].strip()
|
||||
if len(fact_text) > 3 and len(fact_text) < 500:
|
||||
facts.append({
|
||||
"content": fact_text,
|
||||
"category": category,
|
||||
"source_text": text[:200],
|
||||
})
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
# Tool schemas (compatible with cloud Mem0)
|
||||
PROFILE_SCHEMA = {
|
||||
"name": "mem0_profile",
|
||||
"description": (
|
||||
"Retrieve all stored memories about the user - preferences, facts, "
|
||||
"project context. Fast, no reranking. Use at conversation start."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "mem0_search",
|
||||
"description": (
|
||||
"Search memories by meaning. Returns relevant facts ranked by similarity. "
|
||||
"Local-only - no API calls."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "What to search for."},
|
||||
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CONCLUDE_SCHEMA = {
|
||||
"name": "mem0_conclude",
|
||||
"description": (
|
||||
"Store a durable fact about the user. Stored verbatim (no LLM extraction). "
|
||||
"Use for explicit preferences, corrections, or decisions. Local-only."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"conclusion": {"type": "string", "description": "The fact to store."},
|
||||
},
|
||||
"required": ["conclusion"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Mem0LocalProvider(MemoryProvider):
|
||||
"""Local ChromaDB-backed memory provider. No API key required."""
|
||||
|
||||
def __init__(self):
|
||||
self._config = None
|
||||
self._client = None
|
||||
self._collection = None
|
||||
self._client_lock = threading.Lock()
|
||||
self._user_id = "hermes-user"
|
||||
self._storage_path = ""
|
||||
self._max_memories = 10000
|
||||
self._consecutive_failures = 0
|
||||
self._breaker_open_until = 0.0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "mem0-local"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
import chromadb
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def save_config(self, values, hermes_home):
|
||||
config_path = Path(hermes_home) / "mem0-local.json"
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing = json.loads(config_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2))
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
{"key": "storage_path", "description": "Storage directory for ChromaDB", "default": "~/.hermes/mem0-local/"},
|
||||
{"key": "collection_prefix", "description": "Collection name prefix", "default": "mem0"},
|
||||
{"key": "max_memories", "description": "Maximum stored memories", "default": "10000"},
|
||||
]
|
||||
|
||||
def _get_collection(self):
|
||||
"""Thread-safe ChromaDB collection accessor with lazy init."""
|
||||
with self._client_lock:
|
||||
if self._collection is not None:
|
||||
return self._collection
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
except ImportError:
|
||||
raise RuntimeError("chromadb package not installed. Run: pip install chromadb")
|
||||
|
||||
Path(self._storage_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._client = chromadb.PersistentClient(
|
||||
path=self._storage_path,
|
||||
settings=Settings(anonymized_telemetry=False),
|
||||
)
|
||||
|
||||
collection_name = f"{self._config.get('collection_prefix', 'mem0')}_memories"
|
||||
self._collection = self._client.get_or_create_collection(
|
||||
name=collection_name,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Mem0 local: ChromaDB collection '%s' at %s (%d docs)",
|
||||
collection_name, self._storage_path, self._collection.count(),
|
||||
)
|
||||
|
||||
return self._collection
|
||||
|
||||
def _doc_id(self, content: str) -> str:
|
||||
"""Deterministic ID from content hash (for dedup)."""
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def _is_breaker_open(self) -> bool:
|
||||
if self._consecutive_failures < _BREAKER_THRESHOLD:
|
||||
return False
|
||||
if time.monotonic() >= self._breaker_open_until:
|
||||
self._consecutive_failures = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _record_success(self):
|
||||
self._consecutive_failures = 0
|
||||
|
||||
def _record_failure(self):
|
||||
self._consecutive_failures += 1
|
||||
if self._consecutive_failures >= _BREAKER_THRESHOLD:
|
||||
self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._config = _load_config()
|
||||
self._storage_path = self._config.get("storage_path", "")
|
||||
self._max_memories = int(self._config.get("max_memories", 10000))
|
||||
self._user_id = kwargs.get("user_id") or self._config.get("user_id", "hermes-user")
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
count = 0
|
||||
try:
|
||||
col = self._get_collection()
|
||||
count = col.count()
|
||||
except Exception:
|
||||
pass
|
||||
return (
|
||||
"# Mem0 Local Memory\n"
|
||||
f"Active. {count} memories stored locally. "
|
||||
"Use mem0_search to find memories, mem0_conclude to store facts, "
|
||||
"mem0_profile for a full overview."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
return ""
|
||||
|
||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||
pass
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Extract and store facts from the conversation turn."""
|
||||
if self._is_breaker_open():
|
||||
return
|
||||
try:
|
||||
col = self._get_collection()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for content in [user_content, assistant_content]:
|
||||
if not content or len(content) < 10:
|
||||
continue
|
||||
facts = _extract_facts(content)
|
||||
for fact in facts:
|
||||
doc_id = self._doc_id(fact["content"])
|
||||
try:
|
||||
col.upsert(
|
||||
ids=[doc_id],
|
||||
documents=[fact["content"]],
|
||||
metadatas=[{
|
||||
"category": fact["category"],
|
||||
"user_id": self._user_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "extracted",
|
||||
}],
|
||||
)
|
||||
self._record_success()
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
logger.debug("Mem0 local: failed to upsert fact: %s", e)
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [PROFILE_SCHEMA, SEARCH_SCHEMA, CONCLUDE_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if self._is_breaker_open():
|
||||
return json.dumps({"error": "Local memory temporarily unavailable. Will retry automatically."})
|
||||
|
||||
try:
|
||||
col = self._get_collection()
|
||||
except Exception as e:
|
||||
return tool_error(f"ChromaDB not available: {e}")
|
||||
|
||||
if tool_name == "mem0_profile":
|
||||
try:
|
||||
results = col.get(
|
||||
where={"user_id": self._user_id} if self._user_id else None,
|
||||
limit=500,
|
||||
)
|
||||
documents = results.get("documents", [])
|
||||
if not documents:
|
||||
return json.dumps({"result": "No memories stored yet."})
|
||||
lines = [d for d in documents if d]
|
||||
self._record_success()
|
||||
return json.dumps({"result": "\n".join(f"- {l}" for l in lines), "count": len(lines)})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
return tool_error(f"Failed to fetch profile: {e}")
|
||||
|
||||
elif tool_name == "mem0_search":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
top_k = min(int(args.get("top_k", 10)), 50)
|
||||
|
||||
try:
|
||||
results = col.query(
|
||||
query_texts=[query],
|
||||
n_results=top_k,
|
||||
where={"user_id": self._user_id} if self._user_id else None,
|
||||
)
|
||||
|
||||
documents = results.get("documents", [[]])[0]
|
||||
distances = results.get("distances", [[]])[0]
|
||||
|
||||
if not documents:
|
||||
return json.dumps({"result": "No relevant memories found."})
|
||||
|
||||
items = []
|
||||
for doc, dist in zip(documents, distances):
|
||||
score = max(0, 1 - (dist / 2))
|
||||
items.append({"memory": doc, "score": round(score, 3)})
|
||||
|
||||
self._record_success()
|
||||
return json.dumps({"results": items, "count": len(items)})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
return tool_error(f"Search failed: {e}")
|
||||
|
||||
elif tool_name == "mem0_conclude":
|
||||
conclusion = args.get("conclusion", "")
|
||||
if not conclusion:
|
||||
return tool_error("Missing required parameter: conclusion")
|
||||
|
||||
try:
|
||||
doc_id = self._doc_id(conclusion)
|
||||
col.upsert(
|
||||
ids=[doc_id],
|
||||
documents=[conclusion],
|
||||
metadatas=[{
|
||||
"category": "explicit",
|
||||
"user_id": self._user_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "conclude",
|
||||
}],
|
||||
)
|
||||
self._record_success()
|
||||
return json.dumps({"result": "Fact stored locally.", "id": doc_id})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
return tool_error(f"Failed to store: {e}")
|
||||
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
with self._client_lock:
|
||||
self._collection = None
|
||||
self._client = None
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register Mem0 Local as a memory provider plugin."""
|
||||
ctx.register_memory_provider(Mem0LocalProvider())
|
||||
5
plugins/memory/mem0_local/plugin.yaml
Normal file
5
plugins/memory/mem0_local/plugin.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
name: mem0_local
|
||||
version: 1.0.0
|
||||
description: "Mem0 local mode — ChromaDB-backed memory with no API key required. Sovereign deployment."
|
||||
pip_dependencies:
|
||||
- chromadb
|
||||
173
tests/plugins/memory/test_mem0_local.py
Normal file
173
tests/plugins/memory/test_mem0_local.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Tests for Mem0 Local memory provider - ChromaDB-backed, no API key."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Fact extraction tests
|
||||
|
||||
class TestFactExtraction:
|
||||
"""Test the regex-based fact extraction."""
|
||||
|
||||
def _extract(self, text):
|
||||
from plugins.memory.mem0_local import _extract_facts
|
||||
return _extract_facts(text)
|
||||
|
||||
def test_name_extraction(self):
|
||||
facts = self._extract("My name is Alexander Whitestone.")
|
||||
assert any("alexander whitestone" in f["content"].lower() for f in facts)
|
||||
|
||||
def test_preference_extraction(self):
|
||||
facts = self._extract("I prefer using vim for editing.")
|
||||
assert any("vim" in f["content"].lower() for f in facts)
|
||||
|
||||
def test_timezone_extraction(self):
|
||||
facts = self._extract("My timezone is America/New_York.")
|
||||
assert any("america/new_york" in f["content"].lower() for f in facts)
|
||||
|
||||
def test_explicit_remember(self):
|
||||
facts = self._extract("Remember: always use f-strings in Python.")
|
||||
assert len(facts) > 0
|
||||
|
||||
def test_correction_extraction(self):
|
||||
facts = self._extract("Actually: the port is 8080, not 3000.")
|
||||
assert len(facts) > 0
|
||||
|
||||
def test_empty_input(self):
|
||||
facts = self._extract("")
|
||||
assert facts == []
|
||||
|
||||
def test_short_input_ignored(self):
|
||||
facts = self._extract("Hi")
|
||||
assert facts == []
|
||||
|
||||
def test_no_crash_on_random_text(self):
|
||||
facts = self._extract("The quick brown fox jumps over the lazy dog. " * 10)
|
||||
assert isinstance(facts, list)
|
||||
|
||||
|
||||
# Config tests
|
||||
|
||||
class TestConfig:
|
||||
"""Test configuration loading."""
|
||||
|
||||
def test_default_storage_path(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
from plugins.memory.mem0_local import _load_config
|
||||
config = _load_config()
|
||||
assert "mem0-local" in config["storage_path"]
|
||||
|
||||
def test_env_override(self, tmp_path, monkeypatch):
|
||||
custom_path = str(tmp_path / "custom-mem0")
|
||||
monkeypatch.setenv("MEM0_LOCAL_PATH", custom_path)
|
||||
from plugins.memory.mem0_local import _load_config
|
||||
config = _load_config()
|
||||
assert config["storage_path"] == custom_path
|
||||
|
||||
|
||||
# Provider interface tests
|
||||
|
||||
class TestProviderInterface:
|
||||
"""Test provider interface methods."""
|
||||
|
||||
def test_name(self):
|
||||
from plugins.memory.mem0_local import Mem0LocalProvider
|
||||
provider = Mem0LocalProvider()
|
||||
assert provider.name == "mem0-local"
|
||||
|
||||
def test_tool_schemas(self):
|
||||
from plugins.memory.mem0_local import Mem0LocalProvider
|
||||
provider = Mem0LocalProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
names = {s["name"] for s in schemas}
|
||||
assert names == {"mem0_profile", "mem0_search", "mem0_conclude"}
|
||||
|
||||
def test_schema_required_params(self):
|
||||
from plugins.memory.mem0_local import Mem0LocalProvider
|
||||
provider = Mem0LocalProvider()
|
||||
schemas = {s["name"]: s for s in provider.get_tool_schemas()}
|
||||
assert "query" in schemas["mem0_search"]["parameters"]["required"]
|
||||
assert "conclusion" in schemas["mem0_conclude"]["parameters"]["required"]
|
||||
|
||||
|
||||
# ChromaDB integration tests
|
||||
|
||||
chromadb = None
|
||||
try:
|
||||
import chromadb
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(chromadb is None, reason="chromadb not installed")
|
||||
class TestChromaDBIntegration:
|
||||
"""Integration tests with real ChromaDB."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self, tmp_path, monkeypatch):
|
||||
from plugins.memory.mem0_local import Mem0LocalProvider
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
provider = Mem0LocalProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._storage_path = str(tmp_path / "mem0-test")
|
||||
return provider
|
||||
|
||||
def test_store_and_search(self, provider):
|
||||
result = provider.handle_tool_call("mem0_conclude", {"conclusion": "User prefers Python over JavaScript"})
|
||||
data = json.loads(result)
|
||||
assert data.get("result") == "Fact stored locally."
|
||||
|
||||
result = provider.handle_tool_call("mem0_search", {"query": "programming language preference"})
|
||||
data = json.loads(result)
|
||||
assert data["count"] > 0
|
||||
assert any("python" in item["memory"].lower() for item in data["results"])
|
||||
|
||||
def test_profile_empty(self, provider):
|
||||
result = provider.handle_tool_call("mem0_profile", {})
|
||||
data = json.loads(result)
|
||||
assert "No memories" in data.get("result", "") or data.get("count", 0) == 0
|
||||
|
||||
def test_profile_after_store(self, provider):
|
||||
provider.handle_tool_call("mem0_conclude", {"conclusion": "User name is Alexander"})
|
||||
provider.handle_tool_call("mem0_conclude", {"conclusion": "User timezone is UTC"})
|
||||
|
||||
result = provider.handle_tool_call("mem0_profile", {})
|
||||
data = json.loads(result)
|
||||
assert data["count"] >= 2
|
||||
|
||||
def test_dedup(self, provider):
|
||||
provider.handle_tool_call("mem0_conclude", {"conclusion": "Project uses SQLite"})
|
||||
provider.handle_tool_call("mem0_conclude", {"conclusion": "Project uses SQLite"})
|
||||
|
||||
result = provider.handle_tool_call("mem0_profile", {})
|
||||
data = json.loads(result)
|
||||
assert data["count"] == 1
|
||||
|
||||
def test_search_no_results(self, provider):
|
||||
result = provider.handle_tool_call("mem0_search", {"query": "nonexistent topic xyz123"})
|
||||
data = json.loads(result)
|
||||
assert data.get("result") == "No relevant memories found." or data.get("count", 0) == 0
|
||||
|
||||
def test_sync_turn_extraction(self, provider):
|
||||
provider.sync_turn(
|
||||
"My name is TestUser and I prefer dark mode.",
|
||||
"Hello TestUser! I'll remember your preference.",
|
||||
)
|
||||
result = provider.handle_tool_call("mem0_profile", {})
|
||||
data = json.loads(result)
|
||||
assert "count" in data
|
||||
|
||||
def test_conclude_missing_param(self, provider):
|
||||
result = provider.handle_tool_call("mem0_conclude", {})
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
|
||||
def test_search_missing_query(self, provider):
|
||||
result = provider.handle_tool_call("mem0_search", {})
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
Reference in New Issue
Block a user