From f0eb9e091fb21afc00707f92b0047826c1004b99 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 18 Mar 2026 16:31:04 +0530 Subject: [PATCH 01/12] docs: add MiroFish integration and swarm memory cookbook documentation (#4373) --- .../frameworks/mirofish-swarm-memory.mdx | 766 ++++++++++++++++++ docs/docs.json | 4 +- 2 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 docs/cookbooks/frameworks/mirofish-swarm-memory.mdx diff --git a/docs/cookbooks/frameworks/mirofish-swarm-memory.mdx b/docs/cookbooks/frameworks/mirofish-swarm-memory.mdx new file mode 100644 index 000000000..94135def9 --- /dev/null +++ b/docs/cookbooks/frameworks/mirofish-swarm-memory.mdx @@ -0,0 +1,766 @@ +--- +title: MiroFish Swarm Memory +description: "Build a multi-agent swarm simulation with graph-powered memory using Mem0 and MiroFish patterns." +--- + + + +Build a multi-agent swarm simulation with graph-powered memory using Mem0 OSS and [MiroFish](https://github.com/666ghj/MiroFish) patterns. MiroFish is a graph-centric system — it extracts entities and relationships from documents, builds a knowledge graph, and queries it throughout its pipeline. Mem0's Graph Memory is a natural replacement for its Zep Cloud integration. + + + This cookbook demonstrates the **core memory patterns** using a simplified simulation. MiroFish's actual architecture uses a factory pattern (`memory_factory.py`) with abstract providers, batch buffering with retries in `ZepGraphMemoryUpdater`, and IPC-based agent interviews. This cookbook focuses on the Mem0 API integration points — wrap these calls in your own retry/batch logic for production use. + + +## Overview + +This cookbook implements a **Housing Policy Prediction Simulation** following MiroFish's five-stage workflow: + +1. **Graph Building** — Ingest seed documents, extract entities and relationships +2. **Environment Setup** — Query the knowledge graph to enrich agent profiles +3. **Simulation** — Track agent interactions with per-agent memory isolation +4. **Report Generation** — Semantic search + graph traversal for analysis +5. **Deep Interaction** — Query post-simulation memory and relationships (MiroFish also supports live agent interviews via IPC — not covered here) + +Three agents debate a housing policy reform: +- **Mayor Chen** — Policy advocate pushing for zoning reform +- **Wang (Homeowner)** — Opposition leader organizing resistance +- **Professor Li** — Academic providing data-driven analysis + +## Prerequisites + +```bash +pip install "mem0ai[graph]" +``` + +You need a graph backend. Choose one: + +| Backend | Setup | Best for | +|---|---|---| +| **Neo4j Aura** (free tier) | [Sign up](https://neo4j.com/product/auradb/), get Bolt URI | Production, closest to Zep | +| **Neo4j Docker** | `docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5` | Local development | +| **Kuzu** (embedded) | No setup needed — runs in-process | Quick testing, zero dependencies | + +```bash +export OPENAI_API_KEY="sk-..." + +# Option A: Neo4j Docker (local development) +docker run -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5 +export NEO4J_URL="neo4j://localhost:7687" +export NEO4J_USERNAME="neo4j" +export NEO4J_PASSWORD="password" + +# Option B: Neo4j Aura (production — free tier available) +export NEO4J_URL="neo4j+s://.databases.neo4j.io" +export NEO4J_USERNAME="neo4j" +export NEO4J_PASSWORD="your-aura-password" + +# Option C: Kuzu (zero setup — auto-detected when NEO4J_URL is not set) +# No exports needed +``` + +## Complete Implementation + +```python +""" +MiroFish Swarm Prediction Simulation with Mem0 Graph Memory + +MiroFish uses Zep Cloud as its knowledge graph backend. This implementation +replaces Zep with Mem0 OSS Graph Memory, which provides: +- Automatic entity extraction from text +- Relationship mining (source → relationship → destination triples) +- Combined vector + graph search returning memories AND relations +- Per-agent isolation via run_id +- Self-hosted with no node caps + +Follows MiroFish's 5-stage pipeline: +1. Graph Building - Ingest seed documents, extract entities +2. Environment Setup - Query graph to enrich agent profiles +3. Simulation - Track agent actions with per-agent isolation +4. Report Generation - Semantic + graph search for analysis +5. Deep Interaction - Query post-simulation knowledge graph + +Run: + export OPENAI_API_KEY="sk-..." + export NEO4J_URL="neo4j://localhost:7687" + export NEO4J_USERNAME="neo4j" + export NEO4J_PASSWORD="password" + python mirofish_swarm_memory.py +""" + +import os +import time +from mem0 import Memory + + +# ====================================================================== +# MiroFish Agent Action Types (matches OASIS simulation output) +# ====================================================================== + +# Twitter actions +TWITTER_ACTIONS = [ + "CREATE_POST", "LIKE_POST", "REPOST", "FOLLOW", + "DO_NOTHING", "QUOTE_POST", +] + +# Reddit actions (superset — includes moderation + discovery) +REDDIT_ACTIONS = [ + "LIKE_POST", "DISLIKE_POST", "CREATE_POST", "CREATE_COMMENT", + "LIKE_COMMENT", "DISLIKE_COMMENT", "SEARCH_POSTS", "SEARCH_USER", + "TREND", "REFRESH", "DO_NOTHING", "FOLLOW", "MUTE", +] + +# Combined (DO_NOTHING is skipped during memory storage) +MIROFISH_ACTIONS = list(set(TWITTER_ACTIONS + REDDIT_ACTIONS) - {"DO_NOTHING"}) + + +# ====================================================================== +# Graph Memory Configuration +# ====================================================================== + +def build_config(): + """Build Mem0 config with Graph Memory. + + Uses Neo4j if credentials are set, otherwise falls back to Kuzu (embedded). + """ + neo4j_url = os.environ.get("NEO4J_URL") + + # Shared config for LLM, embedder, and vector store + base = { + "llm": { + "provider": "openai", + "config": {"model": "gpt-4o-mini", "temperature": 0.1} + }, + "embedder": { + "provider": "openai", + "config": {"model": "text-embedding-3-small", "embedding_dims": 1536} + }, + "vector_store": { + "provider": "qdrant", + "config": { + "collection_name": "mirofish", + "embedding_model_dims": 1536, + } + }, + } + + custom_prompt = ( + "Extract all people, organizations, policies, locations, " + "and their relationships. Capture support/opposition stances, " + "affiliations, and quantitative claims." + ) + + if neo4j_url: + base["graph_store"] = { + "provider": "neo4j", + "config": { + "url": neo4j_url, + "username": os.environ.get("NEO4J_USERNAME", "neo4j"), + "password": os.environ.get("NEO4J_PASSWORD", "password"), + }, + "custom_prompt": custom_prompt, + } + else: + # Fallback: Kuzu embedded (no external services needed) + print(" NEO4J_URL not set — using Kuzu (embedded) graph store") + base["graph_store"] = { + "provider": "kuzu", + "config": {"db": "/tmp/mirofish_graph.kuzu"}, + "custom_prompt": custom_prompt, + } + + return base + + +# ====================================================================== +# Simulation Engine +# ====================================================================== + +class MiroFishSimulation: + """ + Multi-agent simulation with graph-powered memory. + + Uses Mem0 Graph Memory to replace MiroFish's Zep Cloud integration: + - Entities and relationships are extracted automatically from text + - search() returns both semantic memories AND graph relations + - Per-agent isolation via run_id + - Project isolation via user_id + """ + + def __init__(self, project_id: str, config: dict): + self.project_id = project_id + self.memory = Memory.from_config(config) + self.stats = { + "documents_ingested": 0, + "activities_recorded": 0, + "rounds_completed": 0, + } + + # ------------------------------------------------------------------ + # Stage 1: Graph Building — Seed Document Ingestion + # ------------------------------------------------------------------ + + def ingest_documents(self, documents: list[str]): + """Ingest seed documents and extract entities + relationships. + + MiroFish equivalent: GraphBuilderService.build_graph() + Zep equivalent: graph.add_batch() with episode polling + + With Mem0 Graph Memory, each document is processed by the LLM + to extract entities (people, orgs, policies) and relationships + (supports, opposes, filed). These become nodes and edges in the + graph store, alongside vector embeddings for semantic search. + """ + print(" Ingesting documents and building knowledge graph...") + for i, doc in enumerate(documents): + result = self.memory.add( + [{"role": "user", "content": doc}], + user_id=self.project_id, + metadata={"stage": "graph_building", "source": "seed_document", "chunk_index": i} + ) + # Graph Memory returns extracted relations + relations = result.get("relations", {}) + added = relations.get("added_entities", []) + if added: + print(f" Doc {i}: extracted {len(added)} entities/relations") + + self.stats["documents_ingested"] = len(documents) + print(f" Ingested {len(documents)} documents") + + # ------------------------------------------------------------------ + # Stage 2: Environment Setup — Agent Profile Enrichment + # ------------------------------------------------------------------ + + def enrich_agent_profile(self, agent_name: str, persona_query: str) -> dict: + """Search memory + graph for context relevant to an agent's persona. + + MiroFish equivalent: OasisProfileGenerator using graph.search() + + Returns both semantic memories and graph relations that can be + injected into the agent's system prompt. + """ + results = self.memory.search( + persona_query, + user_id=self.project_id, + limit=10 + ) + facts = [r["memory"] for r in results.get("results", [])] + relations = results.get("relations", []) + + print(f" {agent_name}: {len(facts)} facts, {len(relations)} relations") + return {"facts": facts, "relations": relations} + + # ------------------------------------------------------------------ + # Stage 3: Simulation — Agent Activity Tracking + # ------------------------------------------------------------------ + + def record_action(self, agent_id: str, agent_name: str, + action_type: str, content: str, + platform: str, round_num: int): + """Record a single agent action as a memory with graph extraction. + + MiroFish equivalent: ZepGraphMemoryUpdater.add_activity() + Zep equivalent: graph.add(type="text", data=episode_text) + + Agent memories use run_id to group by agent (no assistant + memories involved). Graph Memory extracts entities/relationships + from the action content automatically. + """ + formatted = f"{agent_name} [{action_type}]: {content}" + + self.memory.add( + [{"role": "user", "content": formatted}], + run_id=agent_id, + metadata={ + "action_type": action_type, + "platform": platform, + "round": round_num, + "agent_name": agent_name, + } + ) + self.stats["activities_recorded"] += 1 + + def run_round(self, round_num: int, activities: list[tuple]): + """Execute one simulation round.""" + print(f" Round {round_num}: {len(activities)} actions") + for agent_id, agent_name, action_type, content, platform in activities: + self.record_action(agent_id, agent_name, action_type, content, platform, round_num) + self.stats["rounds_completed"] = max(self.stats["rounds_completed"], round_num) + + def recall_agent_memory(self, agent_id: str, query: str) -> dict: + """Agent recalls its own memories mid-simulation. + + Searches by run_id to match the scope used during add(). + """ + results = self.memory.search( + query, + run_id=agent_id, + limit=5 + ) + return { + "memories": [r["memory"] for r in results.get("results", [])], + "relations": results.get("relations", []), + } + + # ------------------------------------------------------------------ + # Stage 4: Report Generation — Semantic + Graph Retrieval + # ------------------------------------------------------------------ + + def quick_search(self, query: str, limit: int = 10) -> dict: + """Semantic search + graph relations across all agents. + + MiroFish equivalent: ZepToolsService.quick_search() + Returns both vector-matched memories and related graph triples. + """ + results = self.memory.search( + query, + user_id=self.project_id, + limit=limit + ) + return { + "memories": [r["memory"] for r in results.get("results", [])], + "relations": results.get("relations", []), + } + + def panorama_search(self) -> dict: + """Retrieve all memories + all graph relations. + + MiroFish equivalent: ZepToolsService.panorama_search() + Returns the complete knowledge state for report generation. + """ + results = self.memory.get_all(user_id=self.project_id) + return { + "memories": [r["memory"] for r in results.get("results", [])], + "relations": results.get("relations", []), + } + + def agent_search(self, agent_id: str, query: str, limit: int = 10) -> dict: + """Search within a single agent's memory space.""" + results = self.memory.search( + query, + run_id=agent_id, + limit=limit + ) + return { + "memories": [r["memory"] for r in results.get("results", [])], + "relations": results.get("relations", []), + } + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def cleanup(self): + """Delete all memories and graph data for this simulation.""" + self.memory.delete_all(user_id=self.project_id) + print(f" Cleaned up all memories for {self.project_id}") + + +# ====================================================================== +# Run the full 5-stage pipeline +# ====================================================================== + +def main(): + project_id = f"mirofish_housing_{int(time.time())}" + config = build_config() + sim = MiroFishSimulation(project_id=project_id, config=config) + + # ================================================================== + # STAGE 1: Graph Building — Ingest seed documents + # ================================================================== + print("=" * 60) + print("STAGE 1: Graph Building") + print("=" * 60) + + sim.ingest_documents([ + "The city council proposed a new zoning reform allowing higher " + "density housing in suburban areas. Mayor Chen expressed strong " + "support, citing a 40% housing shortage affecting young professionals. " + "The reform would allow buildings up to 8 stories in previously " + "restricted 3-story zones.", + + "Local homeowners association president Wang opposes the reform, " + "arguing it will decrease property values by 15-20%. The association " + "represents 5,000 homeowners in the affected districts. Wang has " + "organized three community meetings and collected 2,000 signatures.", + + "Professor Li from Beijing University published research showing " + "similar reforms in Shenzhen led to 15% price drops in existing " + "homes but created 30% more affordable housing units within 3 years. " + "The study covered 12 districts and 50,000 housing units.", + ]) + + # ================================================================== + # STAGE 2: Environment Setup — Enrich agent profiles + # ================================================================== + print("\n" + "=" * 60) + print("STAGE 2: Environment Setup") + print("=" * 60) + + mayor_context = sim.enrich_agent_profile( + "Mayor Chen", + "Mayor Chen housing reform zoning policy" + ) + wang_context = sim.enrich_agent_profile( + "Wang", + "Wang homeowner opposition property values petition" + ) + li_context = sim.enrich_agent_profile( + "Professor Li", + "Professor Li research housing data Shenzhen" + ) + + print("\n Example profile context for Mayor Chen:") + for fact in mayor_context["facts"][:3]: + print(f" Fact: {fact}") + for rel in mayor_context["relations"][:3]: + src = rel.get("source", "?") + edge = rel.get("relationship", "?") + dst = rel.get("destination", rel.get("target", "?")) + print(f" Relation: {src} --[{edge}]--> {dst}") + + # ================================================================== + # STAGE 3: Simulation — Run agent interactions + # ================================================================== + print("\n" + "=" * 60) + print("STAGE 3: Simulation") + print("=" * 60) + + # Round 1: Opening statements + sim.run_round(1, [ + ("mayor_chen", "Mayor Chen", "CREATE_POST", + "This reform will create 10,000 new housing units by 2028. " + "Young families deserve affordable homes. #HousingForAll", + "twitter"), + + ("wang_homeowner", "Wang", "CREATE_POST", + "Our property values will plummet! The council ignores the " + "voices of 5,000 homeowners. #StopTheReform", + "twitter"), + + ("prof_li", "Professor Li", "CREATE_POST", + "New analysis: Shenzhen zoning data shows net positive outcomes " + "after 3 years. Short-term pain, long-term gain for housing equity.", + "twitter"), + ]) + + # Round 2: Debate and interaction + sim.run_round(2, [ + ("wang_homeowner", "Wang", "CREATE_COMMENT", + "Replied to Professor Li: 'Shenzhen is a tier-1 city with " + "completely different dynamics. Your comparison is misleading.'", + "twitter"), + + ("mayor_chen", "Mayor Chen", "LIKE_POST", + "Liked Professor Li's post about Shenzhen housing data.", + "twitter"), + + ("prof_li", "Professor Li", "CREATE_COMMENT", + "Replied to Wang: 'The methodology controls for city tier " + "and population density. I invite you to review the full dataset.'", + "twitter"), + + ("mayor_chen", "Mayor Chen", "CREATE_POST", + "Data from @ProfLi confirms what we've been saying: zoning " + "reform works. Let's move forward with evidence, not fear.", + "twitter"), + ]) + + # Round 3: Escalation and platform expansion + sim.run_round(3, [ + ("wang_homeowner", "Wang", "CREATE_POST", + "Filing formal petition with 3,000 signatures against the " + "zoning reform. Council meeting next Tuesday. All homeowners " + "must attend!", + "reddit"), + + ("mayor_chen", "Mayor Chen", "CREATE_POST", + "Announcing public town hall on zoning reform this Saturday. " + "All voices welcome. Data-driven decisions benefit everyone.", + "twitter"), + + ("prof_li", "Professor Li", "CREATE_POST", + "Published full dataset and methodology on my university page. " + "Transparency is essential for informed public debate.", + "twitter"), + + ("wang_homeowner", "Wang", "FOLLOW", + "Followed @MayorChen to monitor policy updates.", + "twitter"), + ]) + + # Mid-simulation: agent recalls own memory + graph + print("\n Mid-simulation recall for Mayor Chen:") + mayor_recall = sim.recall_agent_memory( + "mayor_chen", + "What positions have I taken on housing reform?" + ) + for mem in mayor_recall["memories"]: + print(f" Memory: {mem}") + for rel in mayor_recall["relations"][:3]: + src = rel.get("source", "?") + edge = rel.get("relationship", "?") + dst = rel.get("destination", rel.get("target", "?")) + print(f" Relation: {src} --[{edge}]--> {dst}") + + # ================================================================== + # STAGE 4: Report Generation — Retrieve memories + graph for analysis + # ================================================================== + print("\n" + "=" * 60) + print("STAGE 4: Report Generation") + print("=" * 60) + + # Quick search: targeted query + print("\n Quick Search: 'opposition to housing reform'") + opposition = sim.quick_search("opposition to housing reform", limit=5) + for mem in opposition["memories"]: + print(f" Memory: {mem}") + for rel in opposition["relations"][:3]: + src = rel.get("source", "?") + edge = rel.get("relationship", "?") + dst = rel.get("destination", rel.get("target", "?")) + print(f" Relation: {src} --[{edge}]--> {dst}") + + # Agent-specific search + print("\n Agent Search: Wang's activities") + wang_activities = sim.agent_search("wang_homeowner", "all actions and statements") + for mem in wang_activities["memories"]: + print(f" Memory: {mem}") + + # Panorama: full overview + print("\n Panorama Search: all memories + relations") + panorama = sim.panorama_search() + print(f" Total memories: {len(panorama['memories'])}") + print(f" Total relations: {len(panorama['relations'])}") + for mem in panorama["memories"][:5]: + print(f" Memory: {mem}") + if len(panorama["memories"]) > 5: + print(f" ... and {len(panorama['memories']) - 5} more") + for rel in panorama["relations"][:5]: + src = rel.get("source", "?") + edge = rel.get("relationship", "?") + dst = rel.get("destination", rel.get("target", "?")) + print(f" Relation: {src} --[{edge}]--> {dst}") + + # ================================================================== + # STAGE 5: Deep Interaction — Post-simulation queries + # ================================================================== + print("\n" + "=" * 60) + print("STAGE 5: Deep Interaction") + print("=" * 60) + + queries = [ + "How did the debate evolve across the three rounds?", + "What evidence was cited by each side?", + "Who supports and who opposes the reform?", + ] + + for query in queries: + print(f"\n Query: '{query}'") + results = sim.quick_search(query, limit=3) + for mem in results["memories"][:2]: + print(f" Memory: {mem}") + for rel in results["relations"][:2]: + src = rel.get("source", rel.get("source_node", "?")) + edge = rel.get("relationship", rel.get("relation", "?")) + dst = rel.get("destination", rel.get("destination_node", "?")) + print(f" Relation: {src} --[{edge}]--> {dst}") + + # ================================================================== + # Summary + # ================================================================== + print("\n" + "=" * 60) + print("SIMULATION COMPLETE") + print("=" * 60) + print(f" Project ID: {project_id}") + print(f" Documents ingested: {sim.stats['documents_ingested']}") + print(f" Activities tracked: {sim.stats['activities_recorded']}") + print(f" Rounds completed: {sim.stats['rounds_completed']}") + print(f" Total memories: {len(panorama['memories'])}") + print(f" Total relations: {len(panorama['relations'])}") + + # Cleanup (uncomment to delete all memories + graph data) + # sim.cleanup() + + +if __name__ == "__main__": + print("MiroFish Swarm Prediction Simulation powered by Mem0 Graph Memory\n") + main() +``` + +## How It Works + +### Graph Memory: The Right Fit for MiroFish + +MiroFish's entire pipeline revolves around a **knowledge graph** — it extracts entities from documents, builds relationships, and queries the graph throughout simulation and reporting. Mem0's Graph Memory provides the same capabilities: + +| MiroFish needs | Zep Cloud | Mem0 Graph Memory | +|---|---|---| +| **Entity extraction** | Built-in via Zep API | Automatic via LLM extraction | +| **Relationship mining** | Graph edges | `(source) --[relationship]--> (destination)` triples | +| **Semantic + keyword search** | Semantic + BM25 | Vector similarity + graph relation retrieval | +| **Graph traversal** | Node/edge queries | `relations` array in search results | +| **Per-agent isolation** | Single shared graph in MiroFish | Native `run_id` scoping | +| **Self-hosting** | No (cloud only) | Yes — Neo4j, Memgraph, Kuzu, Neptune | +| **Node/memory limits** | Capped on free tier | Unlimited (self-hosted) | + +### How search() Returns Both Memories and Relations + +When Graph Memory is enabled, every `search()` call returns two arrays: + +```python +results = memory.search("housing reform", user_id="my_sim") + +# Vector-matched memories (ordered by similarity) +results["results"] # [{"memory": "...", "score": 0.85, ...}, ...] + +# Graph relations connected to query entities +results["relations"] # [{"source": "mayor_chen", "relationship": "supports", "destination": "zoning_reform"}, ...] +``` + +This is what makes Mem0 Graph Memory a natural replacement for Zep — you get semantic search AND structured graph data in a single call. + +### Per-Agent Memory Isolation + +`user_id` scopes the simulation project. `run_id` tags individual agent actions at storage time (we use `run_id` instead of `agent_id` since no assistant memories are involved). Searches use `user_id` for project-wide retrieval: + +```python +# Store project-level memories (seed documents) +memory.add( + [{"role": "user", "content": "Mayor Chen supports the zoning reform."}], + user_id="my_sim" +) + +# Store agent-specific memories (simulation actions) +memory.add( + [{"role": "user", "content": "Mayor Chen [CREATE_POST]: Reform works!"}], + run_id="mayor_chen" +) + +# Search project-level memories (seed docs) +memory.search("housing reform", user_id="my_sim") + +# Search agent-specific memories (actions stored with run_id) +memory.search("housing reform", run_id="mayor_chen") + +# Get all project-level memories + graph relations +memory.get_all(user_id="my_sim") +``` + + + Use `user_id` for project-level data (seed documents) and `run_id` for agent actions — both for `add()` and `search()`. Always match the scope: if you `add()` with `run_id`, `search()` with `run_id`. Use the message list format `[{"role": "user", "content": "..."}]` for all `add()` calls — it works on both OSS and Cloud. + + +### Stage Mapping + +| MiroFish Stage | What Happens | Mem0 Graph Memory Call | +|---|---|---| +| **1. Graph Building** | Ingest docs, extract entities | `memory.add(doc, user_id=project)` — entities/relations extracted automatically | +| **2. Environment Setup** | Enrich agent personas from graph | `memory.search(query, user_id=project)` — returns facts + relations | +| **3. Simulation** | Track per-agent actions | `memory.add(messages, run_id=agent)` | +| **3. Simulation** | Mid-round recall | `memory.search(query, run_id=agent)` | +| **4. Report Generation** | Targeted analysis | `memory.search(query, user_id=project)` — memories + graph | +| **4. Report Generation** | Full overview | `memory.get_all(user_id=project)` — all memories + all relations | +| **5. Deep Interaction** | Follow-up queries | `memory.search(query, user_id=project)` | + +### Zep-to-Mem0 Migration Reference + +For developers replacing MiroFish's Zep integration. Note that Mem0 Graph Memory covers the core graph operations but some Zep features have no direct equivalent — see caveats below. + +| MiroFish Service | Zep Call | Mem0 Graph Memory Equivalent | Caveat | +|---|---|---|---| +| GraphBuilderService | `client.graph.create()` | Implicit on first `memory.add()` | | +| GraphBuilderService | `client.graph.set_ontology()` | `custom_prompt` in graph_store config | Freeform text, not a typed schema like Zep's `EntityModel`/`EdgeModel` | +| GraphBuilderService | `client.graph.add_batch(episodes)` | `memory.add()` per chunk | No batch API — call per chunk | +| GraphBuilderService | `client.graph.episode.get(uuid)` | Not needed (add is synchronous in OSS) | | +| GraphBuilderService | `client.graph.delete(id)` | `memory.delete_all(user_id=...)` | | +| ZepEntityReader | `client.graph.node.get_by_graph_id()` | `memory.get_all(user_id=...)` → `relations` | | +| ZepEntityReader | `client.graph.node.get(uuid)` | `memory.search(entity_name, user_id=...)` | Semantic search, not exact ID lookup | +| ZepEntityReader | `client.graph.node.get_entity_edges()` | `memory.search(entity_name, user_id=...)` → `relations` | Returns all matching relations, not edges for a specific node | +| ZepGraphMemoryUpdater | `client.graph.add(type="text")` | `memory.add(messages, run_id=...)` | No batch buffering or retry — implement in your wrapper | +| ZepToolsService | `search_graph(query, scope)` | `memory.search(query, user_id=...)` → memories + relations | | +| ZepToolsService | `get_entities()` | `memory.get_all(user_id=...)` → `relations` | | +| ZepToolsService | Panorama (all nodes + edges) | `memory.get_all(user_id=...)` | No temporal fact separation (active vs historical) | +| ZepToolsService | InsightForge (multi-query decomposition) | Not available | Implement LLM-driven sub-query decomposition in your own ReportAgent | +| OasisProfileGenerator | `client.graph.search()` | `memory.search(query, user_id=...)` | | + + + **What Mem0 Graph Memory does not cover**: Zep's typed ontology schemas (`EntityModel`, `EdgeModel`), temporal fact lifecycle (`valid_at`/`invalid_at`/`expired_at`), single-node-by-ID lookup, and InsightForge's multi-query decomposition. For InsightForge-like functionality, implement sub-query logic in your own ReportAgent using `memory.search()` as the retrieval primitive. + + +### Custom Extraction Prompts + +Guide what entities and relationships Mem0 extracts — analogous to (but less structured than) Zep's `set_ontology()`: + +```python +config = { + "graph_store": { + "provider": "neo4j", + "config": {"url": "...", "username": "...", "password": "..."}, + "custom_prompt": ( + "Extract all people, organizations, policies, locations, " + "and their relationships. Capture support/opposition stances, " + "affiliations, and quantitative claims." + ), + } +} +``` + +### Action Types + +MiroFish's OASIS engine produces these agent action types. Format them as natural language when storing. Skip `DO_NOTHING` actions (no memory value). `TREND` and `REFRESH` are Reddit-only discovery actions — store if you want to track browsing behavior. + +| Action Type | Platform | Example Memory Content | +|---|---|---| +| `CREATE_POST` | Both | `"Mayor Chen [CREATE_POST]: This reform will create 10,000 units"` | +| `CREATE_COMMENT` | Reddit | `"Wang [CREATE_COMMENT]: Replied to Prof Li: 'Your data is misleading'"` | +| `LIKE_POST` | Both | `"Mayor Chen [LIKE_POST]: Liked Prof Li's post about Shenzhen data"` | +| `REPOST` | Twitter | `"Prof Li [REPOST]: Reposted Mayor Chen's town hall announcement"` | +| `FOLLOW` | Both | `"Wang [FOLLOW]: Followed @MayorChen"` | +| `QUOTE_POST` | Twitter | `"Mayor Chen [QUOTE_POST]: 'Data confirms reform works' quoting Prof Li"` | +| `DISLIKE_POST` | Reddit | `"Wang [DISLIKE_POST]: Downvoted Mayor Chen's reform post"` | +| `TREND` | Reddit | `"Prof Li [TREND]: Browsed trending topics"` | +| `DO_NOTHING` | Both | Skip — no memory value | + +## Running the Example + +```bash +# Option A: Neo4j (production) +export OPENAI_API_KEY="sk-..." +export NEO4J_URL="neo4j://localhost:7687" +export NEO4J_USERNAME="neo4j" +export NEO4J_PASSWORD="password" +python mirofish_swarm_memory.py + +# Option B: Kuzu (zero dependencies, just need OpenAI key) +export OPENAI_API_KEY="sk-..." +python mirofish_swarm_memory.py # auto-detects missing NEO4J_URL, uses Kuzu +``` + + +Exact output varies as Mem0 automatically extracts and deduplicates entities. The specific relations and memory counts depend on LLM extraction quality. + + +## Best Practices + +1. **Unique `user_id` per simulation** — Use timestamps or UUIDs (e.g., `mirofish_housing_1742198400`) to prevent memory collisions between runs +2. **Always set `run_id` for agent actions** — Per-agent isolation prevents memory cross-contamination between agents +3. **Use `custom_prompt`** — Guide entity extraction to capture domain-specific relationships (people, policies, stances) +4. **Format actions as natural language** — `"Mayor Chen [CREATE_POST]: content"` extracts better entities than raw JSON +5. **Query relations for reports** — The `relations` array in search results gives structured `(source, relationship, destination)` triples for building analytical reports +6. **Cleanup old simulations** — Call `delete_all(user_id=...)` when a simulation run is no longer needed + +## Resources + +- [MiroFish GitHub](https://github.com/666ghj/MiroFish) — Source code and setup guide +- [MiroFish Documentation](https://deepwiki.com/666ghj/MiroFish) — Full framework docs +- [Mem0 Graph Memory](/open-source/features/graph-memory) — Graph Memory documentation +- [Mem0 Documentation](https://docs.mem0.ai/) — Full API reference + + + + Full Graph Memory documentation with provider setup. + + + MiroFish source code and setup guide. + + diff --git a/docs/docs.json b/docs/docs.json index 6a93f7ee5..a3628d264 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -375,7 +375,8 @@ "cookbooks/frameworks/multimodal-retrieval", "cookbooks/frameworks/eliza-os-character", "cookbooks/frameworks/chrome-extension", - "cookbooks/frameworks/gemini-3-with-mem0-mcp" + "cookbooks/frameworks/gemini-3-with-mem0-mcp", + "cookbooks/frameworks/mirofish-swarm-memory" ] } ] @@ -401,6 +402,7 @@ "integrations/autogen", "integrations/agno", "integrations/camel-ai", + "integrations/mirofish", "integrations/openclaw", "integrations/openai-agents-sdk", "integrations/google-ai-adk", From 214d2a1d0da3e292f29558e94f57ff260403b05e Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 18 Mar 2026 16:40:30 +0530 Subject: [PATCH 02/12] chore: remove the integration/mirofish path from docs (#4399) --- docs/docs.json | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs.json b/docs/docs.json index a3628d264..125649fa6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -402,7 +402,6 @@ "integrations/autogen", "integrations/agno", "integrations/camel-ai", - "integrations/mirofish", "integrations/openclaw", "integrations/openai-agents-sdk", "integrations/google-ai-adk", From d7a34c24ddd2fba579e1c7b1347291d6537a4219 Mon Sep 17 00:00:00 2001 From: darrenxu <72003735+sxu75374@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:49:36 -0700 Subject: [PATCH 03/12] fix(ollama): pass tools to client.chat and parse tool_calls from response (#4176) Signed-off-by: sxu75374 Signed-off-by: Small Co-authored-by: kartik-mem0 --- mem0/llms/ollama.py | 28 +++++++++- tests/llms/test_ollama.py | 108 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/mem0/llms/ollama.py b/mem0/llms/ollama.py index 3a5fabb2e..74d59c0d1 100644 --- a/mem0/llms/ollama.py +++ b/mem0/llms/ollama.py @@ -1,3 +1,4 @@ +import json from typing import Dict, List, Optional, Union try: @@ -8,6 +9,7 @@ except ImportError: from mem0.configs.llms.base import BaseLlmConfig from mem0.configs.llms.ollama import OllamaConfig from mem0.llms.base import LLMBase +from mem0.memory.utils import extract_json class OllamaLLM(LLMBase): @@ -61,7 +63,28 @@ class OllamaLLM(LLMBase): "tool_calls": [], } - # Ollama doesn't support tool calls in the same way, so we return the content + if isinstance(response, dict): + raw_calls = response.get("message", {}).get("tool_calls") or [] + else: + raw_calls = getattr(response.message, "tool_calls", None) or [] + + for tool_call in raw_calls: + if isinstance(tool_call, dict): + fn = tool_call.get("function", {}) + name = fn.get("name", "") + arguments = fn.get("arguments", {}) + else: + fn = getattr(tool_call, "function", None) + name = getattr(fn, "name", "") if fn else "" + arguments = getattr(fn, "arguments", {}) if fn else {} + + if isinstance(arguments, str): + arguments = json.loads(extract_json(arguments)) + + processed_response["tool_calls"].append( + {"name": name, "arguments": arguments} + ) + return processed_response else: return content @@ -113,5 +136,8 @@ class OllamaLLM(LLMBase): # Remove OpenAI-specific parameters that Ollama doesn't support params.pop("max_tokens", None) # Ollama uses different parameter names + if tools: + params["tools"] = tools + response = self.client.chat(**params) return self._parse_response(response, tools) diff --git a/tests/llms/test_ollama.py b/tests/llms/test_ollama.py index 0f1e6ac3e..cfbb8bfdf 100644 --- a/tests/llms/test_ollama.py +++ b/tests/llms/test_ollama.py @@ -32,3 +32,111 @@ def test_generate_response_without_tools(mock_ollama_client): model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0} ) assert response == "I'm doing well, thank you for asking!" + + +def test_generate_response_with_tools_passes_tools_to_client(mock_ollama_client): + """Tools should be forwarded to ollama client.chat().""" + config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0) + llm = OllamaLLM(config) + messages = [{"role": "user", "content": "Extract entities from: Alice works at UCSD"}] + tools = [ + { + "type": "function", + "function": { + "name": "extract_entities", + "description": "Extract entities", + "parameters": {"type": "object", "properties": {"entities": {"type": "array"}}}, + }, + } + ] + + mock_response = { + "message": { + "content": "", + "tool_calls": [ + { + "function": { + "name": "extract_entities", + "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}, + } + } + ], + } + } + mock_ollama_client.chat.return_value = mock_response + + response = llm.generate_response(messages, tools=tools) + + # Verify tools were passed to client.chat + call_kwargs = mock_ollama_client.chat.call_args + assert "tools" in call_kwargs.kwargs or (len(call_kwargs.args) > 0 and "tools" in call_kwargs[1]) + assert call_kwargs[1]["tools"] == tools + + # Verify tool_calls were parsed correctly + assert response["tool_calls"] == [ + {"name": "extract_entities", "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}} + ] + + +def test_generate_response_with_tools_no_tool_calls_in_response(mock_ollama_client): + """When model returns content without tool_calls, tool_calls should be empty list.""" + config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0) + llm = OllamaLLM(config) + messages = [{"role": "user", "content": "Hello"}] + tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}] + + mock_response = {"message": {"content": "I cannot use tools for this.", "tool_calls": []}} + mock_ollama_client.chat.return_value = mock_response + + response = llm.generate_response(messages, tools=tools) + + assert response["content"] == "I cannot use tools for this." + assert response["tool_calls"] == [] + + +def test_generate_response_with_tools_string_arguments(mock_ollama_client): + """When tool_call arguments come as JSON string, they should be parsed.""" + config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0) + llm = OllamaLLM(config) + messages = [{"role": "user", "content": "test"}] + tools = [{"type": "function", "function": {"name": "test_fn", "parameters": {}}}] + + mock_response = { + "message": { + "content": "", + "tool_calls": [ + {"function": {"name": "test_fn", "arguments": '{"key": "value"}'}} + ], + } + } + mock_ollama_client.chat.return_value = mock_response + + response = llm.generate_response(messages, tools=tools) + + assert response["tool_calls"] == [{"name": "test_fn", "arguments": {"key": "value"}}] + + +def test_parse_response_with_tools_object_style(mock_ollama_client): + """Test _parse_response with object-style response (non-dict).""" + config = OllamaConfig(model="llama3.1:70b") + llm = OllamaLLM(config) + + # Simulate object-style response + mock_fn = Mock() + mock_fn.name = "extract" + mock_fn.arguments = {"entities": ["Alice"]} + + mock_tool_call = Mock() + mock_tool_call.function = mock_fn + + mock_message = Mock() + mock_message.content = "" + mock_message.tool_calls = [mock_tool_call] + + mock_response = Mock() + mock_response.message = mock_message + + tools = [{"type": "function", "function": {"name": "extract"}}] + result = llm._parse_response(mock_response, tools) + + assert result["tool_calls"] == [{"name": "extract", "arguments": {"entities": ["Alice"]}}] From 577a5a2feb89581fd72ee991127a6fe1f125b996 Mon Sep 17 00:00:00 2001 From: Anisha Mahuli <63206204+amahuli03@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:49:29 -0400 Subject: [PATCH 04/12] fix(oss): normalize malformed LLM fact output before embedding (#4224) Co-authored-by: kartik-mem0 --- mem0/memory/main.py | 3 ++ mem0/memory/utils.py | 29 ++++++++++++++++- tests/test_memory.py | 77 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 40fd3f2df..ac745e800 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -29,6 +29,7 @@ from mem0.memory.utils import ( ensure_json_instruction, extract_json, get_fact_retrieval_messages, + normalize_facts, parse_messages, parse_vision_messages, process_telemetry_filters, @@ -456,6 +457,7 @@ class Memory(MemoryBase): # Try extracting JSON from response using built-in function extracted_json = extract_json(response) new_retrieved_facts = json.loads(extracted_json)["facts"] + new_retrieved_facts = normalize_facts(new_retrieved_facts) except Exception as e: logger.error(f"Error in new_retrieved_facts: {e}") new_retrieved_facts = [] @@ -1484,6 +1486,7 @@ class AsyncMemory(MemoryBase): # Try extracting JSON from response using built-in function extracted_json = extract_json(response) new_retrieved_facts = json.loads(extracted_json)["facts"] + new_retrieved_facts = normalize_facts(new_retrieved_facts) except Exception as e: logger.error(f"Error in new_retrieved_facts: {e}") new_retrieved_facts = [] diff --git a/mem0/memory/utils.py b/mem0/memory/utils.py index b451c0f6e..0d2cb6749 100644 --- a/mem0/memory/utils.py +++ b/mem0/memory/utils.py @@ -1,12 +1,15 @@ import hashlib +import logging import re from mem0.configs.prompts import ( + AGENT_MEMORY_EXTRACTION_PROMPT, FACT_RETRIEVAL_PROMPT, USER_MEMORY_EXTRACTION_PROMPT, - AGENT_MEMORY_EXTRACTION_PROMPT, ) +logger = logging.getLogger(__name__) + def get_fact_retrieval_messages(message, is_agent_memory=False): """Get fact retrieval messages based on the memory type. @@ -77,6 +80,30 @@ def format_entities(entities): return "\n".join(formatted_lines) +def normalize_facts(raw_facts): + """Normalize LLM-extracted facts to a list of strings. + + Smaller LLMs (e.g. llama3.1:8b) sometimes return facts as objects + like {"fact": "..."} or {"text": "..."} instead of plain strings. + This mirrors the TypeScript FactRetrievalSchema validation. + """ + if not raw_facts: + return [] + normalized = [] + for item in raw_facts: + if isinstance(item, str): + fact = item + elif isinstance(item, dict): + fact = item.get("fact") or item.get("text") + if fact is None: + logger.warning("Unexpected fact shape from LLM, skipping: %s", item) + continue + else: + fact = str(item) + if fact: + normalized.append(fact) + return normalized + def remove_code_blocks(content: str) -> str: """ diff --git a/tests/test_memory.py b/tests/test_memory.py index 39af062ef..ed98e6ab8 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -1,9 +1,11 @@ +import json from unittest.mock import MagicMock, patch import pytest from mem0 import Memory from mem0.configs.base import MemoryConfig +from mem0.memory.utils import normalize_facts class MockVectorMemory: @@ -244,4 +246,77 @@ def test_get_all_handles_flat_list_from_postgres(mock_sqlite, mock_llm_factory, assert len(result) == 2 assert result[0]["memory"] == "Memory 1" - assert result[1]["memory"] == "Memory 2" + assert result[1]["memory"] == "Memory 2" + + +@patch('mem0.utils.factory.EmbedderFactory.create') +@patch('mem0.utils.factory.VectorStoreFactory.create') +@patch('mem0.utils.factory.LlmFactory.create') +@patch('mem0.memory.storage.SQLiteManager') +def test_add_infer_with_malformed_llm_facts(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + """ + Repro for: 'list' object has no attribute 'replace' on infer=true. + + When an LLM (especially smaller models like llama3.1:8b) returns facts as + objects ({"fact": "..."} or {"text": "..."}) instead of plain strings, + the embedding model's .replace() call crashes with AttributeError. + """ + mock_embedder = MagicMock() + mock_embedder.embed.side_effect = lambda text, action: (_ for _ in ()).throw( + AttributeError("'dict' object has no attribute 'replace'") + ) if not isinstance(text, str) else [0.1, 0.2, 0.3] + mock_embedder_factory.return_value = mock_embedder + + mock_vector_store = MagicMock() + mock_vector_store.search.return_value = [] + mock_vector_factory.return_value = mock_vector_store + + # LLM returns malformed facts: dicts instead of strings + malformed_response = json.dumps({ + "facts": [ + {"fact": "User likes Python"}, + {"text": "User is a developer"}, + ] + }) + mock_llm = MagicMock() + mock_llm.generate_response.return_value = malformed_response + mock_llm_factory.return_value = mock_llm + + mock_sqlite.return_value = MagicMock() + + from mem0.memory.main import Memory as MemoryClass + config = MemoryConfig() + memory = MemoryClass(config) + + # This should NOT raise AttributeError + memory._add_to_vector_store( + messages=[{"role": "user", "content": "I like Python and I'm a developer"}], + metadata={"user_id": "test_user"}, + filters={"user_id": "test_user"}, + infer=True, + ) + + +def test_normalize_facts_plain_strings(): + assert normalize_facts(["fact one", "fact two"]) == ["fact one", "fact two"] + + +def test_normalize_facts_dict_with_fact_key(): + assert normalize_facts([{"fact": "User likes Python"}]) == ["User likes Python"] + + +def test_normalize_facts_dict_with_text_key(): + assert normalize_facts([{"text": "User is a developer"}]) == ["User is a developer"] + + +def test_normalize_facts_mixed(): + raw = [ + "plain string", + {"fact": "from fact key"}, + {"text": "from text key"}, + ] + assert normalize_facts(raw) == ["plain string", "from fact key", "from text key"] + + +def test_normalize_facts_filters_empty_strings(): + assert normalize_facts(["", "valid", ""]) == ["valid"] From 7539463f509ff88f46abe04c0653483f8379cccc Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 18 Mar 2026 23:34:35 +0530 Subject: [PATCH 05/12] refactor: improve Ollama embedder, normalize model names, add error handling, update tests (#4403) --- mem0-ts/src/oss/src/embeddings/ollama.ts | 27 +++- mem0-ts/src/oss/tests/ollama-embedder.test.ts | 121 ++++++++++++++++++ mem0/embeddings/ollama.py | 18 ++- pyproject.toml | 2 +- tests/embeddings/test_ollama_embeddings.py | 25 +++- 5 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 mem0-ts/src/oss/tests/ollama-embedder.test.ts diff --git a/mem0-ts/src/oss/src/embeddings/ollama.ts b/mem0-ts/src/oss/src/embeddings/ollama.ts index 348f6cf98..51d339ce2 100644 --- a/mem0-ts/src/oss/src/embeddings/ollama.ts +++ b/mem0-ts/src/oss/src/embeddings/ollama.ts @@ -27,14 +27,18 @@ export class OllamaEmbedder implements Embedder { } catch (err) { logger.error(`Error ensuring model exists: ${err}`); } - // Ollama's Go server requires prompt to be a string. Coerce defensively - // since callers may pass values parsed from untrusted LLM JSON output. - const prompt = typeof text === "string" ? text : JSON.stringify(text); - const response = await this.ollama.embeddings({ + // Coerce defensively since callers may pass values parsed from untrusted LLM JSON output. + const input = typeof text === "string" ? text : JSON.stringify(text); + const response = await this.ollama.embed({ model: this.model, - prompt, + input, }); - return response.embedding; + if (!response.embeddings || response.embeddings.length === 0) { + throw new Error( + `Ollama embed() returned no embeddings for model '${this.model}'`, + ); + } + return response.embeddings[0]; } async embedBatch(texts: string[]): Promise { @@ -42,12 +46,21 @@ export class OllamaEmbedder implements Embedder { return response; } + private static normalizeModelName(name: string): string { + return name.includes(":") ? name : `${name}:latest`; + } + private async ensureModelExists(): Promise { if (this.initialized) { return true; } const local_models = await this.ollama.list(); - if (!local_models.models.find((m: any) => m.name === this.model)) { + const target = OllamaEmbedder.normalizeModelName(this.model); + if ( + !local_models.models.find( + (m: any) => OllamaEmbedder.normalizeModelName(m.name) === target, + ) + ) { logger.info(`Pulling model ${this.model}...`); await this.ollama.pull({ model: this.model }); } diff --git a/mem0-ts/src/oss/tests/ollama-embedder.test.ts b/mem0-ts/src/oss/tests/ollama-embedder.test.ts new file mode 100644 index 000000000..1854cf41f --- /dev/null +++ b/mem0-ts/src/oss/tests/ollama-embedder.test.ts @@ -0,0 +1,121 @@ +/// +/** + * Ollama Embedder — unit tests (mocked Ollama client). + */ + +import { OllamaEmbedder } from "../src/embeddings/ollama"; + +const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5]; +const mockEmbed = jest.fn().mockResolvedValue({ + model: "nomic-embed-text:latest", + embeddings: [mockEmbedding], +}); +const mockList = jest.fn().mockResolvedValue({ + models: [{ name: "nomic-embed-text:latest" }], +}); +const mockPull = jest.fn().mockResolvedValue({}); + +jest.mock("ollama", () => ({ + Ollama: jest.fn().mockImplementation(() => ({ + embed: mockEmbed, + list: mockList, + pull: mockPull, + })), +})); + +describe("OllamaEmbedder (unit)", () => { + beforeEach(() => { + mockEmbed.mockClear(); + mockList.mockClear(); + mockPull.mockClear(); + }); + + it("embed() calls ollama.embed with model and input, returns first embedding", async () => { + const embedder = new OllamaEmbedder({ + model: "nomic-embed-text:latest", + }); + + const result = await embedder.embed("Sample text to embed."); + + expect(mockEmbed).toHaveBeenCalledTimes(1); + expect(mockEmbed.mock.calls[0][0]).toEqual({ + model: "nomic-embed-text:latest", + input: "Sample text to embed.", + }); + expect(result).toEqual(mockEmbedding); + }); + + it("embed() coerces non-string input to JSON string", async () => { + const embedder = new OllamaEmbedder({ + model: "nomic-embed-text:latest", + }); + + // Force a non-string through the type boundary + await embedder.embed(42 as any); + + expect(mockEmbed.mock.calls[0][0].input).toBe("42"); + }); + + it("embedBatch() returns vectors for multiple inputs", async () => { + const embedder = new OllamaEmbedder({ + model: "nomic-embed-text:latest", + }); + + const result = await embedder.embedBatch(["text1", "text2"]); + + expect(mockEmbed).toHaveBeenCalledTimes(2); + expect(result).toEqual([mockEmbedding, mockEmbedding]); + }); + + it("ensureModelExists() does not pull when model is already present", async () => { + const embedder = new OllamaEmbedder({ + model: "nomic-embed-text:latest", + }); + + await embedder.embed("trigger ensureModelExists"); + + expect(mockList).toHaveBeenCalled(); + expect(mockPull).not.toHaveBeenCalled(); + }); + + it("ensureModelExists() pulls model when not found locally", async () => { + mockList.mockResolvedValueOnce({ models: [] }); + + const embedder = new OllamaEmbedder({ + model: "nomic-embed-text:latest", + }); + + await embedder.embed("trigger ensureModelExists"); + + expect(mockPull).toHaveBeenCalledWith({ model: "nomic-embed-text:latest" }); + }); + + it("ensureModelExists() normalizes model name with :latest tag", async () => { + mockList.mockResolvedValue({ + models: [{ name: "nomic-embed-text:latest" }], + }); + + const embedder = new OllamaEmbedder({ + model: "nomic-embed-text", + }); + + await embedder.embed("trigger ensureModelExists"); + + expect(mockPull).not.toHaveBeenCalled(); + }); + + it("embed() throws when embeddings array is empty", async () => { + mockEmbed.mockResolvedValueOnce({ + model: "nomic-embed-text:latest", + embeddings: [], + }); + + const embedder = new OllamaEmbedder({ + model: "nomic-embed-text:latest", + }); + + await expect(embedder.embed("text")).rejects.toThrow( + "Ollama embed() returned no embeddings", + ); + }); +}); diff --git a/mem0/embeddings/ollama.py b/mem0/embeddings/ollama.py index 49b7c2e94..07149f2c8 100644 --- a/mem0/embeddings/ollama.py +++ b/mem0/embeddings/ollama.py @@ -31,12 +31,21 @@ class OllamaEmbedding(EmbeddingBase): self.client = Client(host=self.config.ollama_base_url) self._ensure_model_exists() + @staticmethod + def _normalize_model_name(name: str) -> str: + return name if ":" in name else f"{name}:latest" + def _ensure_model_exists(self): """ Ensure the specified model exists locally. If not, pull it from Ollama. """ local_models = self.client.list()["models"] - if not any(model.get("name") == self.config.model or model.get("model") == self.config.model for model in local_models): + target = self._normalize_model_name(self.config.model) + if not any( + self._normalize_model_name(model.get("name", "")) == target + or self._normalize_model_name(model.get("model", "")) == target + for model in local_models + ): self.client.pull(self.config.model) def embed(self, text, memory_action: Optional[Literal["add", "search", "update"]] = None): @@ -49,5 +58,8 @@ class OllamaEmbedding(EmbeddingBase): Returns: list: The embedding vector. """ - response = self.client.embeddings(model=self.config.model, prompt=text) - return response["embedding"] + response = self.client.embed(model=self.config.model, input=text) + embeddings = response.get("embeddings") or [] + if not embeddings: + raise ValueError(f"Ollama embed() returned no embeddings for model '{self.config.model}'") + return embeddings[0] diff --git a/pyproject.toml b/pyproject.toml index 3053ecf69..90a72a432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ llms = [ "together>=0.2.10", "litellm>=1.74.0", "openai>=1.90.0", - "ollama>=0.1.0", + "ollama>=0.3.0", "vertexai>=0.1.0", "google-generativeai>=0.3.0", "google-genai>=1.0.0", diff --git a/tests/embeddings/test_ollama_embeddings.py b/tests/embeddings/test_ollama_embeddings.py index 3e2cc6723..e0bf9193b 100644 --- a/tests/embeddings/test_ollama_embeddings.py +++ b/tests/embeddings/test_ollama_embeddings.py @@ -19,13 +19,13 @@ def test_embed_text(mock_ollama_client): config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512) embedder = OllamaEmbedding(config) - mock_response = {"embedding": [0.1, 0.2, 0.3, 0.4, 0.5]} - mock_ollama_client.embeddings.return_value = mock_response + mock_response = {"embeddings": [[0.1, 0.2, 0.3, 0.4, 0.5]]} + mock_ollama_client.embed.return_value = mock_response text = "Sample text to embed." embedding = embedder.embed(text) - mock_ollama_client.embeddings.assert_called_once_with(model="nomic-embed-text", prompt=text) + mock_ollama_client.embed.assert_called_once_with(model="nomic-embed-text", input=text) assert embedding == [0.1, 0.2, 0.3, 0.4, 0.5] @@ -41,3 +41,22 @@ def test_ensure_model_exists(mock_ollama_client): embedder._ensure_model_exists() mock_ollama_client.pull.assert_called_once_with("nomic-embed-text") + + +def test_ensure_model_exists_normalizes_latest_tag(mock_ollama_client): + """Model 'nomic-embed-text' should match 'nomic-embed-text:latest' from ollama list.""" + mock_ollama_client.list.return_value = {"models": [{"name": "nomic-embed-text:latest"}]} + config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512) + OllamaEmbedding(config) + + mock_ollama_client.pull.assert_not_called() + + +def test_embed_empty_response_raises(mock_ollama_client): + config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512) + embedder = OllamaEmbedding(config) + + mock_ollama_client.embed.return_value = {"embeddings": []} + + with pytest.raises(ValueError, match="returned no embeddings"): + embedder.embed("some text") From a172de9c22bb5f42884bfae38b5c0cf9a3b4beab Mon Sep 17 00:00:00 2001 From: Atharva Jaiswal <92455570+AtharvaJaiswal005@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:46:44 +0530 Subject: [PATCH 06/12] fix: pass encoding_format='float' in OpenAI embeddings for proxy compatibility (#4058) --- mem0/embeddings/openai.py | 7 +++++- tests/embeddings/test_openai_embeddings.py | 29 ++++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/mem0/embeddings/openai.py b/mem0/embeddings/openai.py index ba5153e6f..302809408 100644 --- a/mem0/embeddings/openai.py +++ b/mem0/embeddings/openai.py @@ -43,7 +43,12 @@ class OpenAIEmbedding(EmbeddingBase): """ text = text.replace("\n", " ") return ( - self.client.embeddings.create(input=[text], model=self.config.model, dimensions=self.config.embedding_dims) + self.client.embeddings.create( + input=[text], + model=self.config.model, + dimensions=self.config.embedding_dims, + encoding_format="float", + ) .data[0] .embedding ) diff --git a/tests/embeddings/test_openai_embeddings.py b/tests/embeddings/test_openai_embeddings.py index 08dda117d..722041fd6 100644 --- a/tests/embeddings/test_openai_embeddings.py +++ b/tests/embeddings/test_openai_embeddings.py @@ -24,7 +24,7 @@ def test_embed_default_model(mock_openai_client): result = embedder.embed("Hello world") mock_openai_client.embeddings.create.assert_called_once_with( - input=["Hello world"], model="text-embedding-3-small", dimensions=1536 + input=["Hello world"], model="text-embedding-3-small", dimensions=1536, encoding_format="float" ) assert result == [0.1, 0.2, 0.3] @@ -39,7 +39,7 @@ def test_embed_custom_model(mock_openai_client): result = embedder.embed("Test embedding") mock_openai_client.embeddings.create.assert_called_once_with( - input=["Test embedding"], model="text-embedding-2-medium", dimensions=1024 + input=["Test embedding"], model="text-embedding-2-medium", dimensions=1024, encoding_format="float" ) assert result == [0.4, 0.5, 0.6] @@ -54,7 +54,7 @@ def test_embed_removes_newlines(mock_openai_client): result = embedder.embed("Hello\nworld") mock_openai_client.embeddings.create.assert_called_once_with( - input=["Hello world"], model="text-embedding-3-small", dimensions=1536 + input=["Hello world"], model="text-embedding-3-small", dimensions=1536, encoding_format="float" ) assert result == [0.7, 0.8, 0.9] @@ -69,7 +69,7 @@ def test_embed_without_api_key_env_var(mock_openai_client): result = embedder.embed("Testing API key") mock_openai_client.embeddings.create.assert_called_once_with( - input=["Testing API key"], model="text-embedding-3-small", dimensions=1536 + input=["Testing API key"], model="text-embedding-3-small", dimensions=1536, encoding_format="float" ) assert result == [1.0, 1.1, 1.2] @@ -85,6 +85,25 @@ def test_embed_uses_environment_api_key(mock_openai_client, monkeypatch): result = embedder.embed("Environment key test") mock_openai_client.embeddings.create.assert_called_once_with( - input=["Environment key test"], model="text-embedding-3-small", dimensions=1536 + input=["Environment key test"], model="text-embedding-3-small", dimensions=1536, encoding_format="float" ) assert result == [1.3, 1.4, 1.5] + + +def test_embed_passes_encoding_format_float(mock_openai_client): + """Verify encoding_format='float' is always passed to prevent base64 issues with proxies. + + The OpenAI SDK defaults to encoding_format='base64' when not specified, + which breaks OpenAI-compatible proxies (OpenRouter, LiteLLM, vLLM, etc.) + that don't support base64 decoding. See #4057. + """ + config = BaseEmbedderConfig() + embedder = OpenAIEmbedding(config) + mock_response = Mock() + mock_response.data = [Mock(embedding=[0.1, 0.2, 0.3])] + mock_openai_client.embeddings.create.return_value = mock_response + + embedder.embed("Proxy compatibility test") + + call_kwargs = mock_openai_client.embeddings.create.call_args + assert call_kwargs.kwargs.get("encoding_format") == "float" or call_kwargs[1].get("encoding_format") == "float" From 4ffe1eaa4e14dfa980fdd527e944f843a4568cc1 Mon Sep 17 00:00:00 2001 From: dhilip_binny <33201880+DhilipBinny@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:18:26 +0800 Subject: [PATCH 07/12] fix: forward tools parameter to Gemini API in GoogleLLM (#4380) (#4386) --- mem0-ts/src/oss/src/llms/google.ts | 55 +++++-- mem0-ts/src/oss/tests/google-llm.test.ts | 187 +++++++++++++++++++++++ 2 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 mem0-ts/src/oss/tests/google-llm.test.ts diff --git a/mem0-ts/src/oss/src/llms/google.ts b/mem0-ts/src/oss/src/llms/google.ts index 685cef79a..c852baf37 100644 --- a/mem0-ts/src/oss/src/llms/google.ts +++ b/mem0-ts/src/oss/src/llms/google.ts @@ -16,25 +16,50 @@ export class GoogleLLM implements LLM { responseFormat?: { type: string }, tools?: any[], ): Promise { - const completion = await this.google.models.generateContent({ - contents: messages.map((msg) => ({ - parts: [ - { - text: - typeof msg.content === "string" - ? msg.content - : JSON.stringify(msg.content), - }, - ], - role: msg.role === "system" ? "model" : "user", - })), + const contents = messages.map((msg) => ({ + parts: [ + { + text: + typeof msg.content === "string" + ? msg.content + : JSON.stringify(msg.content), + }, + ], + role: msg.role === "system" ? "model" : "user", + })); + // Build config with tools if provided + const config: Record = {}; + if (tools && tools.length > 0) { + config.tools = [ + { + functionDeclarations: tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + })), + }, + ]; + } + + const completion = await this.google.models.generateContent({ + contents, model: this.model, - // config: { - // responseSchema: {}, // Add response schema if needed - // }, + config, }); + // Handle function call responses + if (completion.functionCalls && completion.functionCalls.length > 0) { + return { + content: completion.text || "", + role: "assistant", + toolCalls: completion.functionCalls.map((call) => ({ + name: call.name!, + arguments: JSON.stringify(call.args), + })), + }; + } + const text = completion.text ?.replace(/^```json\n/, "") .replace(/\n```$/, ""); diff --git a/mem0-ts/src/oss/tests/google-llm.test.ts b/mem0-ts/src/oss/tests/google-llm.test.ts new file mode 100644 index 000000000..40a3c4e42 --- /dev/null +++ b/mem0-ts/src/oss/tests/google-llm.test.ts @@ -0,0 +1,187 @@ +/// +/** + * Google LLM — unit tests (mocked @google/genai). + * + * Regression tests for #4380: tools parameter was ignored, causing graph + * memory operations to silently fail with Gemini models. + */ + +const mockGenerateContent = jest.fn(); + +jest.mock("@google/genai", () => ({ + GoogleGenAI: jest.fn().mockImplementation(() => ({ + models: { generateContent: mockGenerateContent }, + })), +})); + +import { GoogleLLM } from "../src/llms/google"; + +describe("GoogleLLM (unit)", () => { + beforeEach(() => mockGenerateContent.mockClear()); + + it("returns text response when no tools are provided", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: '{"facts": ["fact1"]}', + functionCalls: null, + }); + + const llm = new GoogleLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse([ + { role: "user", content: "Hello" }, + ]); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + expect(result).toBe('{"facts": ["fact1"]}'); + + // Verify tools are not in config + const callArgs = mockGenerateContent.mock.calls[0][0]; + expect(callArgs.config.tools).toBeUndefined(); + }); + + it("forwards tools as functionDeclarations to Gemini API", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: "", + functionCalls: [ + { + name: "extract_entities", + args: { entities: [{ entity: "Alice", entity_type: "person" }] }, + }, + ], + }); + + const tools = [ + { + type: "function", + function: { + name: "extract_entities", + description: "Extract entities from text", + parameters: { + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + entity: { type: "string" }, + entity_type: { type: "string" }, + }, + }, + }, + }, + required: ["entities"], + }, + }, + }, + ]; + + const llm = new GoogleLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse( + [{ role: "user", content: "Alice is a person" }], + undefined, + tools, + ); + + // Verify functionDeclarations were passed in config + const callArgs = mockGenerateContent.mock.calls[0][0]; + expect(callArgs.config.tools).toBeDefined(); + expect(callArgs.config.tools[0].functionDeclarations).toHaveLength(1); + expect(callArgs.config.tools[0].functionDeclarations[0].name).toBe( + "extract_entities", + ); + + // Verify toolCalls in response + expect(result).toHaveProperty("toolCalls"); + const response = result as { toolCalls: any[] }; + expect(response.toolCalls).toHaveLength(1); + expect(response.toolCalls[0].name).toBe("extract_entities"); + expect(JSON.parse(response.toolCalls[0].arguments)).toEqual({ + entities: [{ entity: "Alice", entity_type: "person" }], + }); + }); + + it("returns text when tools are provided but model returns text", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: "Just a text response", + functionCalls: null, + }); + + const tools = [ + { + type: "function", + function: { + name: "noop", + description: "No operation", + parameters: { type: "object", properties: {} }, + }, + }, + ]; + + const llm = new GoogleLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse( + [{ role: "user", content: "Hello" }], + undefined, + tools, + ); + + // Should return text, not toolCalls + expect(result).toBe("Just a text response"); + }); + + it("strips markdown code fences from text responses", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: '```json\n{"facts": ["fact1"]}\n```', + functionCalls: null, + }); + + const llm = new GoogleLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse([ + { role: "user", content: "Extract facts" }, + ]); + + expect(result).toBe('{"facts": ["fact1"]}'); + }); + + it("handles multiple function calls in response", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: "", + functionCalls: [ + { + name: "add_graph_memory", + args: { source: "Alice", destination: "Bob", relationship: "knows" }, + }, + { + name: "add_graph_memory", + args: { + source: "Bob", + destination: "Charlie", + relationship: "works_with", + }, + }, + ], + }); + + const tools = [ + { + type: "function", + function: { + name: "add_graph_memory", + description: "Add a graph memory", + parameters: { type: "object", properties: {} }, + }, + }, + ]; + + const llm = new GoogleLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse( + [{ role: "user", content: "Alice knows Bob, Bob works with Charlie" }], + undefined, + tools, + ); + + const response = result as { toolCalls: any[] }; + expect(response.toolCalls).toHaveLength(2); + expect(response.toolCalls[0].name).toBe("add_graph_memory"); + expect(response.toolCalls[1].name).toBe("add_graph_memory"); + }); +}); From ffd1b969165c739e1f25e4b8499b87c74f853851 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 18 Mar 2026 23:49:16 +0530 Subject: [PATCH 08/12] fix(ts-sdk): externalize all peerDependencies in tsup config (#4408) --- mem0-ts/src/oss/tests/memory.add.test.ts | 6 ++- mem0-ts/src/oss/tests/memory.crud.test.ts | 1 + mem0-ts/src/oss/tests/tsup-externals.test.ts | 51 ++++++++++++++++++++ mem0-ts/tsup.config.ts | 10 ++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 mem0-ts/src/oss/tests/tsup-externals.test.ts diff --git a/mem0-ts/src/oss/tests/memory.add.test.ts b/mem0-ts/src/oss/tests/memory.add.test.ts index 9d2126ebd..af135ed09 100644 --- a/mem0-ts/src/oss/tests/memory.add.test.ts +++ b/mem0-ts/src/oss/tests/memory.add.test.ts @@ -58,7 +58,11 @@ function createMemory(overrides: Partial = {}): Memory { }, vectorStore: { provider: "memory", - config: { collectionName: `test-add-${Date.now()}`, dimension: 1536 }, + config: { + collectionName: `test-add-${Date.now()}`, + dimension: 1536, + dbPath: ":memory:", + }, }, llm: { provider: "openai", diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index b594572b5..212d9f7ff 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -61,6 +61,7 @@ function createMemory(): Memory { config: { collectionName: `test-crud-${Date.now()}-${Math.random()}`, dimension: 1536, + dbPath: ":memory:", }, }, llm: { diff --git a/mem0-ts/src/oss/tests/tsup-externals.test.ts b/mem0-ts/src/oss/tests/tsup-externals.test.ts new file mode 100644 index 000000000..9206aa27d --- /dev/null +++ b/mem0-ts/src/oss/tests/tsup-externals.test.ts @@ -0,0 +1,51 @@ +import * as fs from "fs"; +import * as path from "path"; + +/** + * Drift-prevention test: ensures every peerDependency in package.json + * is listed in tsup.config.ts's external array so tsup never bundles + * optional provider SDKs into the dist output. + */ +describe("tsup.config.ts externals", () => { + let peerDeps: string[]; + let directDeps: string[]; + let externalDeps: string[]; + + beforeAll(() => { + const pkgPath = path.resolve(__dirname, "../../../package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + // Filter out @types/* packages — they are type-only and not bundled at runtime + peerDeps = Object.keys(pkg.peerDependencies || {}).filter( + (dep) => !dep.startsWith("@types/"), + ); + directDeps = Object.keys(pkg.dependencies || {}); + + const tsupConfigPath = path.resolve(__dirname, "../../../tsup.config.ts"); + const tsupContent = fs.readFileSync(tsupConfigPath, "utf-8"); + + // Extract strings from the external array (supports double, single, and backtick quotes) + const externalMatch = tsupContent.match( + /const external\s*=\s*\[([\s\S]*?)\];/, + ); + if (!externalMatch) { + throw new Error("Could not find external array in tsup.config.ts"); + } + const matches = externalMatch[1].match(/["'`]([^"'`]+)["'`]/g); + externalDeps = (matches || []).map((m) => m.replace(/["'`]/g, "")); + }); + + it("should have every peerDependency in the external array", () => { + const missing = peerDeps.filter((dep) => !externalDeps.includes(dep)); + expect(missing).toEqual([]); + }); + + it("should not have stale entries that are not in package.json", () => { + const allDeps = [...peerDeps, ...directDeps]; + const stale = externalDeps.filter((dep) => !allDeps.includes(dep)); + expect(stale).toEqual([]); + }); + + it("should have peerDependencies defined in package.json", () => { + expect(peerDeps.length).toBeGreaterThan(0); + }); +}); diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index d5f94125a..8c09ae586 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -10,6 +10,16 @@ const external = [ "better-sqlite3", "@qdrant/js-client-rest", "redis", + "ollama", + "@google/genai", + "@mistralai/mistralai", + "neo4j-driver", + "@supabase/supabase-js", + "@azure/search-documents", + "@azure/identity", + "cloudflare", + "@cloudflare/workers-types", + "@langchain/core", ]; export default defineConfig([ From b971b61cbbc3b415bc7edf13688fbfa2d05ab960 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Thu, 19 Mar 2026 03:51:30 +0530 Subject: [PATCH 09/12] feat(openclaw): improve extraction quality with noise filtering, deduplication, and better instructions (#4302) Co-authored-by: utkarsh240799 Co-authored-by: Claude Opus 4.6 --- openclaw/CHANGELOG.md | 38 + openclaw/README.md | 45 +- openclaw/config.ts | 243 ++++ openclaw/filtering.ts | 115 ++ openclaw/index.test.ts | 334 +++++- openclaw/index.ts | 2380 ++++++++++++++++----------------------- openclaw/isolation.ts | 101 ++ openclaw/package.json | 4 +- openclaw/pnpm-lock.yaml | 2 +- openclaw/providers.ts | 307 +++++ openclaw/tsconfig.json | 6 +- openclaw/types.ts | 91 ++ 12 files changed, 2260 insertions(+), 1406 deletions(-) create mode 100644 openclaw/config.ts create mode 100644 openclaw/filtering.ts create mode 100644 openclaw/isolation.ts create mode 100644 openclaw/providers.ts create mode 100644 openclaw/types.ts diff --git a/openclaw/CHANGELOG.md b/openclaw/CHANGELOG.md index 3173aeb51..d464f7d31 100644 --- a/openclaw/CHANGELOG.md +++ b/openclaw/CHANGELOG.md @@ -2,8 +2,46 @@ All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file. +## [0.4.0] - 2026-03-16 + +### Added +- **Non-interactive trigger filtering**: Skips recall and capture for `cron`, `heartbeat`, `automation`, and `schedule` triggers — prevents system-generated noise from polluting memory +- **Subagent hallucination prevention**: `isSubagentSession()` detects ephemeral subagent sessions and routes recall to the parent (main user) namespace instead of empty ephemeral namespaces; skips capture to prevent orphaned memories +- **Subagent-specific preamble**: Subagents receive "You are a subagent — use these memories for context but do not assume you are this user" to prevent identity assumption +- **User identity in recall preamble**: Recalled memories now include `userId` attribution for better context +- **User identity in extraction preamble**: Extraction context includes user identity and current date for accurate attribution and temporal anchoring +- **User-content guard**: Skips extraction when no meaningful user messages remain after filtering +- **Dynamic recall thresholding**: Memories scoring less than 50% of the top result are dropped to filter out the long tail of weak matches +- **SQLite resilience for OSS mode**: Init error recovery with automatic retry (history disabled) when native SQLite bindings fail under jiti +- **`disableHistory` config option**: New `oss.disableHistory` flag to explicitly skip history DB initialization +- **Updated minimum package version of mem0ai package**: Updated minimum package version of mem0ai package to ^2.3.0 to force old users to migrate to better-sqlite3 +- 78 unit tests covering filtering, isolation, trigger filtering, subagent detection, and SQLite resilience + +### Changed +- Auto-recall threshold raised from 0.5 to 0.6 for stricter precision during automatic injection (explicit tool searches remain at 0.5) +- Recall candidate pool increased to `topK * 2` for better filtering headroom +- Provider init promises now reset on failure, allowing retry on subsequent calls +- Relaxed extraction instructions: related facts are kept together to preserve context (removed atomic memory requirement) + +### Fixed +- **Concurrent session race condition**: Lifecycle hooks (`before_agent_start`, `agent_end`) now use `ctx.sessionKey` directly from the event context instead of a shared mutable `currentSessionId` variable, preventing cross-session data leaks when multiple sessions run simultaneously + ## [0.3.1] - 2026-03-12 +### Added +- **Message filtering pipeline**: Multi-stage noise removal before extraction — drops heartbeats, timestamps, single-word acks, system routing metadata, compaction audit logs, and generic assistant acknowledgments +- **Broad recall for new sessions**: Short or new-session prompts trigger a secondary broad search to avoid cold-start blindness +- **Client-side threshold filtering**: Safety net that drops low-relevance results even if the API doesn't honor the threshold parameter +- **Temporal anchoring**: Extraction instructions now include current date so memories are prefixed with "As of YYYY-MM-DD, ..." +- **Summary message inclusion**: Earlier assistant messages containing work summaries are included in extraction context even if outside the recent-message window +- 55 unit tests covering filtering and isolation helpers + +### Changed +- Default `searchThreshold` remains at 0.5, with client-side filtering as a safety net +- Extraction window expanded from last 10 → last 20 messages for richer context +- Rewritten custom extraction instructions: conciseness, outcome-over-intent, deduplication guidance, language preservation +- **Refactored** monolithic `index.ts` (1772 lines) into 6 focused modules: `types.ts`, `providers.ts`, `config.ts`, `filtering.ts`, `isolation.ts`, `index.ts` + ### Fixed - **README image on npmjs.com**: Changed architecture diagram from relative path to absolute GitHub URL so it renders correctly on the npm registry diff --git a/openclaw/README.md b/openclaw/README.md index 517b2ca6b..951781bd6 100644 --- a/openclaw/README.md +++ b/openclaw/README.md @@ -12,10 +12,19 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch **Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context. -**Auto-Capture** — After the agent responds, the plugin sends the exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged. +**Auto-Capture** — After the agent responds, the plugin filters the conversation through a noise-removal pipeline, then sends the cleaned exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged. Both run silently. No prompting, no configuration, no manual calls. +### Message filtering + +Before extraction, messages pass through a multi-stage filtering pipeline: + +1. **Noise detection** — Drops entire messages that are system noise: heartbeats (`HEARTBEAT_OK`, `NO_REPLY`), timestamps, single-word acknowledgments (`ok`, `sure`, `done`), system routing metadata, and compaction audit logs. +2. **Generic assistant detection** — Drops short assistant messages that are boilerplate acknowledgments with no extractable facts (e.g. "I see you've shared an update. How can I help?"). +3. **Content stripping** — Removes embedded noise fragments (media boilerplate, routing metadata, compaction blocks) from otherwise useful messages. +4. **Truncation** — Caps messages at 2000 characters to avoid sending excessive context. + ### Short-term vs long-term memory Memories are organized into two scopes: @@ -40,6 +49,13 @@ In multi-agent setups, each agent automatically gets its own memory namespace. S - If the key matches `agent::`, memories are stored under `userId:agent:` - Different agents never see each other's memories unless explicitly queried +**Subagent handling:** + +Ephemeral subagents (session keys like `agent:main:subagent:`) are handled specially: +- **Recall** is routed to the parent (main user) namespace — subagents get the user's long-term context instead of searching their empty ephemeral namespace +- **Capture** is skipped entirely — the main agent's `agent_end` hook captures the consolidated result including subagent output, preventing orphaned memories +- A **subagent-specific preamble** is used: "You are a subagent — use these memories for context but do not assume you are this user" + **Explicit cross-agent queries:** All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget`) accept an optional `agentId` parameter to query another agent's namespace: @@ -48,7 +64,17 @@ All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget memory_search({ query: "user's tech stack", agentId: "researcher" }) ``` -Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default. +The `agentId` is always namespaced under the configured `userId` (e.g. `agentId: "researcher"` → `utkarsh:agent:researcher`), so it cannot be used to access other users' namespaces. + +### Concurrency safety + +Lifecycle hooks (`before_agent_start`, `agent_end`) use `ctx.sessionKey` directly from the event context rather than shared mutable state. This prevents race conditions when multiple sessions run concurrently (e.g. multiple Telegram users chatting simultaneously). + +Tools still read from a best-effort `currentSessionId` variable (since tools don't receive `ctx`), but hooks — where the critical recall and capture logic runs — are fully concurrency-safe. + +### Non-interactive trigger filtering + +The plugin automatically skips recall and capture for non-interactive triggers: `cron`, `heartbeat`, `automation`, and `schedule`. Detection works via both `ctx.trigger` and session key patterns (`:cron:`, `:heartbeat:`). This prevents system-generated noise from polluting long-term memory. ## Setup @@ -121,10 +147,10 @@ The agent gets five tools it can call during conversations: | Tool | Description | |------|-------------| -| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. | -| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. | -| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. | -| `memory_get` | Retrieve a memory by ID | +| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent, `scope` to filter by session/long-term. | +| `memory_list` | List all stored memories. Optional `agentId` to scope to a specific agent, `scope` to filter. | +| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace, `longTerm` to choose scope. | +| `memory_get` | Retrieve a memory by ID. | | `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. | ## CLI @@ -160,7 +186,7 @@ openclaw mem0 stats --agent researcher | `autoRecall` | `boolean` | `true` | Inject memories before each turn | | `autoCapture` | `boolean` | `true` | Store facts after each turn | | `topK` | `number` | `5` | Max memories per recall | -| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) | +| `searchThreshold` | `number` | `0.5` | Min similarity (0–1) | ### Platform mode @@ -170,7 +196,7 @@ openclaw mem0 stats --agent researcher | `orgId` | `string` | — | Organization ID | | `projectId` | `string` | — | Project ID | | `enableGraph` | `boolean` | `false` | Entity graph for relationships | -| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format | +| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format. Built-in instructions include temporal anchoring, conciseness, outcome-over-intent, deduplication, and language preservation guidelines. | | `customCategories` | `object` | *(12 defaults)* | Category name → description map for tagging | ### Open-source mode @@ -187,9 +213,12 @@ Works with zero extra config. The `oss` block lets you swap out any component: | `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) | | `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` | | `oss.historyDbPath` | `string` | — | SQLite path for memory edit history | +| `oss.disableHistory` | `boolean` | `false` | Skip history DB initialization (useful when native SQLite bindings fail) | Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM. Override only what you need. +> **SQLite resilience:** If the history DB fails to initialize (e.g. native binding resolution under jiti), the plugin automatically retries with history disabled. Core memory operations (add, search, get, delete) work without the history DB. + ## License Apache 2.0 diff --git a/openclaw/config.ts b/openclaw/config.ts new file mode 100644 index 000000000..f564f237a --- /dev/null +++ b/openclaw/config.ts @@ -0,0 +1,243 @@ +/** + * Configuration parsing, env var resolution, and default instructions/categories. + */ + +import type { Mem0Config, Mem0Mode } from "./types.ts"; + +// ============================================================================ +// Env Var Resolution +// ============================================================================ + +function resolveEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +function resolveEnvVarsDeep(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string") { + result[key] = resolveEnvVars(value); + } else if (value && typeof value === "object" && !Array.isArray(value)) { + result[key] = resolveEnvVarsDeep(value as Record); + } else { + result[key] = value; + } + } + return result; +} + +// ============================================================================ +// Default Custom Instructions & Categories +// ============================================================================ + +export const DEFAULT_CUSTOM_INSTRUCTIONS = `Your Task: Extract durable, actionable facts from conversations between a user and an AI assistant. Only store information that would be useful to an agent in a FUTURE session, days or weeks later. + +Before storing any fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If the answer is no, do not store it. + +Information to Extract (in priority order): + +1. Configuration & System State Changes: + - Tools/services configured, installed, or removed (with versions/dates) + - Model assignments for agents, API keys configured (NEVER the key itself — see Exclude) + - Cron schedules, automation pipelines, deployment configurations + - Architecture decisions (agent hierarchy, system design, deployment strategy) + - Specific identifiers: file paths, sheet IDs, channel IDs, user IDs, folder IDs + +2. Standing Rules & Policies: + - Explicit user directives about behavior ("never create accounts without consent") + - Workflow policies ("each agent must review model selection before completing a task") + - Security constraints, permission boundaries, access patterns + +3. Identity & Demographics: + - Name, location, timezone, language preferences + - Occupation, employer, job role, industry + +4. Preferences & Opinions: + - Communication style preferences + - Tool and technology preferences (with specifics: versions, configs) + - Strong opinions or values explicitly stated + - The WHY behind preferences when stated + +5. Goals, Projects & Milestones: + - Active projects (name, description, current status) + - Completed setup milestones ("ElevenLabs fully configured as of 2026-02-20") + - Deadlines, roadmaps, and progress tracking + - Problems actively being solved + +6. Technical Context: + - Tech stack, tools, development environment + - Agent ecosystem structure (names, roles, relationships) + - Skill levels in different areas + +7. Relationships & People: + - Names and roles of people mentioned (colleagues, family, clients) + - Team structure, key contacts + +8. Decisions & Lessons: + - Important decisions made and their reasoning + - Lessons learned, strategies that worked or failed + +Guidelines: + +TEMPORAL ANCHORING (critical): +- ALWAYS include temporal context for time-sensitive facts using "As of YYYY-MM-DD, ..." +- Extract dates from message timestamps, dates mentioned in the text, or the system-provided current date +- If no date is available, note "date unknown" rather than omitting temporal context +- Examples: "As of 2026-02-20, ElevenLabs setup is complete" NOT "ElevenLabs setup is complete" + +CONCISENESS: +- Use third person ("User prefers..." not "I prefer...") +- Keep related facts together in a single memory to preserve context +- "User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)" +- NOT a paragraph retelling the whole conversation + +OUTCOMES OVER INTENT: +- When an assistant message summarizes completed work, extract the durable OUTCOMES +- "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates" NOT "User wants to update call scripts" +- Extract what WAS DONE, not what was requested + +DEDUPLICATION: +- Before creating a new memory, check if a substantially similar fact already exists +- If so, UPDATE the existing memory with any new details rather than creating a duplicate + +LANGUAGE: +- ALWAYS preserve the original language of the conversation +- If the user speaks Spanish, store the memory in Spanish; do not translate + +Exclude (NEVER store): +- Passwords, API keys, tokens, secrets, or any credentials — even if shared in conversation. Instead store: "Tavily API key was configured and saved to .env (as of 2026-02-20)" +- One-time commands or instructions ("stop the script", "continue where you left off") +- Acknowledgments or emotional reactions ("ok", "sounds good", "you're right", "sir") +- Transient UI/navigation states ("user is in the admin panel", "relay is attached") +- Ephemeral process status ("download at 50%", "daemon not running", "still syncing") +- Cron heartbeat outputs, NO_REPLY responses, compaction flush directives +- System routing metadata (message IDs, sender IDs, channel routing info) +- Generic small talk with no informational content +- Raw code snippets (capture the intent/decision, not the code itself) +- Information the user explicitly asks not to remember`; + +export const DEFAULT_CUSTOM_CATEGORIES: Record = { + identity: + "Personal identity information: name, age, location, timezone, occupation, employer, education, demographics", + preferences: + "Explicitly stated likes, dislikes, preferences, opinions, and values across any domain", + goals: + "Current and future goals, aspirations, objectives, targets the user is working toward", + projects: + "Specific projects, initiatives, or endeavors the user is working on, including status and details", + technical: + "Technical skills, tools, tech stack, development environment, programming languages, frameworks", + decisions: + "Important decisions made, reasoning behind choices, strategy changes, and their outcomes", + relationships: + "People mentioned by the user: colleagues, family, friends, their roles and relevance", + routines: + "Daily habits, work patterns, schedules, productivity routines, health and wellness habits", + life_events: + "Significant life events, milestones, transitions, upcoming plans and changes", + lessons: + "Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs", + work: + "Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges", + health: + "Health-related information voluntarily shared: conditions, medications, fitness, wellness goals", +}; + +// ============================================================================ +// Config Schema +// ============================================================================ + +const ALLOWED_KEYS = [ + "mode", + "apiKey", + "userId", + "orgId", + "projectId", + "autoCapture", + "autoRecall", + "customInstructions", + "customCategories", + "customPrompt", + "enableGraph", + "searchThreshold", + "topK", + "oss", +]; + +function assertAllowedKeys( + value: Record, + allowed: string[], + label: string, +) { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length === 0) return; + throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); +} + +export const mem0ConfigSchema = { + parse(value: unknown): Mem0Config { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("openclaw-mem0 config required"); + } + const cfg = value as Record; + assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config"); + + // Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform + const mode: Mem0Mode = + cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform"; + + // Platform mode requires apiKey + if (mode === "platform") { + if (typeof cfg.apiKey !== "string" || !cfg.apiKey) { + throw new Error( + "apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)", + ); + } + } + + // Resolve env vars in oss config + let ossConfig: Mem0Config["oss"]; + if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) { + ossConfig = resolveEnvVarsDeep( + cfg.oss as Record, + ) as unknown as Mem0Config["oss"]; + } + + return { + mode, + apiKey: + typeof cfg.apiKey === "string" ? resolveEnvVars(cfg.apiKey) : undefined, + userId: + typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default", + orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined, + projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined, + autoCapture: cfg.autoCapture !== false, + autoRecall: cfg.autoRecall !== false, + customInstructions: + typeof cfg.customInstructions === "string" + ? cfg.customInstructions + : DEFAULT_CUSTOM_INSTRUCTIONS, + customCategories: + cfg.customCategories && + typeof cfg.customCategories === "object" && + !Array.isArray(cfg.customCategories) + ? (cfg.customCategories as Record) + : DEFAULT_CUSTOM_CATEGORIES, + customPrompt: + typeof cfg.customPrompt === "string" + ? cfg.customPrompt + : DEFAULT_CUSTOM_INSTRUCTIONS, + enableGraph: cfg.enableGraph === true, + searchThreshold: + typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5, + topK: typeof cfg.topK === "number" ? cfg.topK : 5, + oss: ossConfig, + }; + }, +}; diff --git a/openclaw/filtering.ts b/openclaw/filtering.ts new file mode 100644 index 000000000..654d4e93f --- /dev/null +++ b/openclaw/filtering.ts @@ -0,0 +1,115 @@ +/** + * Pre-extraction message filtering: noise detection, content stripping, + * generic assistant detection, truncation, and deduplication. + */ + +import type { MemoryItem } from "./types.ts"; + +// ============================================================================ +// Noise Detection +// ============================================================================ + +/** Patterns that indicate an entire message is noise and should be dropped. */ +const NOISE_MESSAGE_PATTERNS: RegExp[] = [ + /^(HEARTBEAT_OK|NO_REPLY)$/i, + /^Current time:.*\d{4}/, + /^Pre-compaction memory flush/i, + /^(ok|yes|no|sir|sure|thanks|done|good|nice|cool|got it|it's on|continue)$/i, + /^System: \[.*\] (Slack message edited|Gateway restart|Exec (failed|completed))/, + /^System: \[.*\] ⚠️ Post-Compaction Audit:/, +]; + +/** Content fragments that should be stripped from otherwise-valid messages. */ +const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ + { pattern: /Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```/g, replacement: "" }, + { pattern: /\[media attached:.*?\]/g, replacement: "" }, + { pattern: /To send an image back, prefer the message tool[\s\S]*?Keep caption in the text body\./g, replacement: "" }, + { pattern: /System: \[\d{4}-\d{2}-\d{2}.*?\] ⚠️ Post-Compaction Audit:[\s\S]*?after memory compaction\./g, replacement: "" }, + { pattern: /Replied message \(untrusted, for context\):\s*```json[\s\S]*?```/g, replacement: "" }, +]; + +const MAX_MESSAGE_LENGTH = 2000; + +/** + * Patterns indicating an assistant message is a generic acknowledgment with + * no extractable facts. These are produced when the agent receives a + * transcript dump or forwarded message and responds with a boilerplate reply. + */ +const GENERIC_ASSISTANT_PATTERNS: RegExp[] = [ + /^(I see you'?ve shared|Thanks for sharing|Got it[.!]?\s*(I see|Let me|How can)|I understand[.!]?\s*(How can|Is there|Would you))/i, + /^(How can I help|Is there anything|Would you like me to|Let me know (if|how|what))/i, + /^(I('?ll| will) (help|assist|look into|review|take a look))/i, + /^(Sure[.!]?\s*(How|What|Is)|Understood[.!]?\s*(How|What|Is))/i, + /^(That('?s| is) (noted|understood|clear))/i, +]; + +// ============================================================================ +// Public Functions +// ============================================================================ + +/** + * Check whether a message's content is entirely noise (cron heartbeats, + * single-word acknowledgments, system routing metadata, etc.). + */ +export function isNoiseMessage(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed) return true; + return NOISE_MESSAGE_PATTERNS.some((p) => p.test(trimmed)); +} + +/** + * Check whether an assistant message is a generic acknowledgment with no + * extractable facts (e.g. "I see you've shared an update. How can I help?"). + * Only applies to short assistant messages — longer responses likely contain + * substantive content even if they start with a generic opener. + */ +export function isGenericAssistantMessage(content: string): boolean { + const trimmed = content.trim(); + // Only flag short messages — longer ones likely have substance after the opener + if (trimmed.length > 300) return false; + return GENERIC_ASSISTANT_PATTERNS.some((p) => p.test(trimmed)); +} + +/** + * Remove embedded noise fragments (routing metadata, media boilerplate, + * compaction audit blocks) from a message while preserving the useful content. + */ +export function stripNoiseFromContent(content: string): string { + let cleaned = content; + for (const { pattern, replacement } of NOISE_CONTENT_PATTERNS) { + cleaned = cleaned.replace(pattern, replacement); + } + // Collapse excessive whitespace left behind after stripping + cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim(); + return cleaned; +} + +/** + * Truncate a message to `MAX_MESSAGE_LENGTH` characters, preserving the + * opening (which typically contains the summary/conclusion) and appending + * a truncation marker so the extraction model knows content was cut. + */ +function truncateMessage(content: string): string { + if (content.length <= MAX_MESSAGE_LENGTH) return content; + return content.slice(0, MAX_MESSAGE_LENGTH) + "\n[...truncated]"; +} + +/** + * Full pre-extraction pipeline: drop noise messages, strip noise fragments, + * and truncate remaining messages to a reasonable length. + */ +export function filterMessagesForExtraction( + messages: Array<{ role: string; content: string }>, +): Array<{ role: string; content: string }> { + const filtered: Array<{ role: string; content: string }> = []; + for (const msg of messages) { + if (isNoiseMessage(msg.content)) continue; + // Drop generic assistant acknowledgments that contain no facts + if (msg.role === "assistant" && isGenericAssistantMessage(msg.content)) continue; + const cleaned = stripNoiseFromContent(msg.content); + if (!cleaned) continue; + filtered.push({ role: msg.role, content: truncateMessage(cleaned) }); + } + return filtered; +} + diff --git a/openclaw/index.test.ts b/openclaw/index.test.ts index b5151e4ff..6c34c6f7d 100644 --- a/openclaw/index.test.ts +++ b/openclaw/index.test.ts @@ -1,8 +1,6 @@ /** - * Regression tests for per-agent memory isolation helpers. - * - * Addresses review feedback: targeted coverage for auth/session state, - * malformed input, and the resolveUserId priority chain. + * Regression tests for per-agent memory isolation helpers and + * message filtering logic. */ import { describe, it, expect } from "vitest"; import { @@ -10,16 +8,33 @@ import { effectiveUserId, agentUserId, resolveUserId, + isNonInteractiveTrigger, + isSubagentSession, + isNoiseMessage, + isGenericAssistantMessage, + stripNoiseFromContent, + filterMessagesForExtraction, } from "./index.ts"; // --------------------------------------------------------------------------- // extractAgentId // --------------------------------------------------------------------------- describe("extractAgentId", () => { - it("returns agentId from a well-formed session key", () => { + it("returns agentId from a named agent session key", () => { expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher"); }); + it("returns subagent namespace from subagent session key", () => { + // OpenClaw subagent format: agent:main:subagent: + expect(extractAgentId("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe( + "subagent-3b85177f-69e0-412d-8ecd-fbe542f362ce", + ); + }); + + it("returns undefined for the main agent session (agent:main:main)", () => { + expect(extractAgentId("agent:main:main")).toBeUndefined(); + }); + it("returns undefined for the 'main' sentinel", () => { expect(extractAgentId("agent:main:abc-123")).toBeUndefined(); }); @@ -163,3 +178,312 @@ describe("multi-agent isolation", () => { expect(mainId).toBe(base); }); }); + +// --------------------------------------------------------------------------- +// isNonInteractiveTrigger +// --------------------------------------------------------------------------- +describe("isNonInteractiveTrigger", () => { + it("returns true for cron trigger", () => { + expect(isNonInteractiveTrigger("cron", undefined)).toBe(true); + }); + + it("returns true for heartbeat trigger", () => { + expect(isNonInteractiveTrigger("heartbeat", undefined)).toBe(true); + }); + + it("returns true for automation trigger", () => { + expect(isNonInteractiveTrigger("automation", undefined)).toBe(true); + }); + + it("returns true for schedule trigger", () => { + expect(isNonInteractiveTrigger("schedule", undefined)).toBe(true); + }); + + it("is case-insensitive for trigger", () => { + expect(isNonInteractiveTrigger("CRON", undefined)).toBe(true); + expect(isNonInteractiveTrigger("Heartbeat", undefined)).toBe(true); + }); + + it("returns false for user-initiated triggers", () => { + expect(isNonInteractiveTrigger("user", undefined)).toBe(false); + expect(isNonInteractiveTrigger("webchat", undefined)).toBe(false); + expect(isNonInteractiveTrigger("telegram", undefined)).toBe(false); + }); + + it("returns false when trigger is undefined and session key is normal", () => { + expect(isNonInteractiveTrigger(undefined, "agent:main:main")).toBe(false); + }); + + it("detects cron from session key as fallback", () => { + expect(isNonInteractiveTrigger(undefined, "agent:main:cron:c85abdb2-d900-4cd8-8601-9dd960c560c9")).toBe(true); + }); + + it("detects heartbeat from session key as fallback", () => { + expect(isNonInteractiveTrigger(undefined, "agent:main:heartbeat:abc123")).toBe(true); + }); + + it("returns false when both trigger and sessionKey are undefined", () => { + expect(isNonInteractiveTrigger(undefined, undefined)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isSubagentSession +// --------------------------------------------------------------------------- +describe("isSubagentSession", () => { + it("returns true for subagent session keys", () => { + expect(isSubagentSession("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe(true); + }); + + it("returns false for main agent session", () => { + expect(isSubagentSession("agent:main:main")).toBe(false); + }); + + it("returns false for named agent session", () => { + expect(isSubagentSession("agent:researcher:550e8400-e29b")).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isSubagentSession(undefined)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isNoiseMessage +// --------------------------------------------------------------------------- +describe("isNoiseMessage", () => { + it("detects HEARTBEAT_OK", () => { + expect(isNoiseMessage("HEARTBEAT_OK")).toBe(true); + expect(isNoiseMessage("heartbeat_ok")).toBe(true); + }); + + it("detects NO_REPLY", () => { + expect(isNoiseMessage("NO_REPLY")).toBe(true); + }); + + it("detects current-time stamps", () => { + expect( + isNoiseMessage("Current time: Friday, February 20th, 2026 — 3:58 AM (America/New_York)"), + ).toBe(true); + }); + + it("detects single-word acknowledgments", () => { + for (const word of ["ok", "yes", "sir", "done", "cool", "Got it", "it's on"]) { + expect(isNoiseMessage(word)).toBe(true); + } + }); + + it("detects system routing messages", () => { + expect( + isNoiseMessage("System: [2026-02-19 19:51:31 PST] Slack message edited in #D0AFV2LDGDS."), + ).toBe(true); + expect( + isNoiseMessage("System: [2026-02-19 22:15:42 PST] Exec failed (gentle-b, signal 15)"), + ).toBe(true); + }); + + it("detects compaction audit messages", () => { + expect( + isNoiseMessage( + "System: [2026-02-20 16:12:04 EST] ⚠️ Post-Compaction Audit: The following required startup files were not read", + ), + ).toBe(true); + }); + + it("preserves real content", () => { + expect(isNoiseMessage("Beau runs Rize Digital LLC")).toBe(false); + expect(isNoiseMessage("Can you check the lovable discord?")).toBe(false); + expect(isNoiseMessage("I approve the Tailscale installation")).toBe(false); + }); + + it("treats empty/whitespace as noise", () => { + expect(isNoiseMessage("")).toBe(true); + expect(isNoiseMessage(" ")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// isGenericAssistantMessage +// --------------------------------------------------------------------------- +describe("isGenericAssistantMessage", () => { + it("detects 'I see you've shared' openers", () => { + expect(isGenericAssistantMessage("I see you've shared an update. How can I help?")).toBe(true); + expect(isGenericAssistantMessage("I see you've shared a summary of the Atlas configuration update. Is there anything specific you'd like me to help with?")).toBe(true); + }); + + it("detects 'Thanks for sharing' openers", () => { + expect(isGenericAssistantMessage("Thanks for sharing that update! Would you like me to review the changes?")).toBe(true); + }); + + it("detects 'How can I help' standalone", () => { + expect(isGenericAssistantMessage("How can I help you with this?")).toBe(true); + }); + + it("detects 'Got it' + follow-up", () => { + expect(isGenericAssistantMessage("Got it! How can I assist?")).toBe(true); + expect(isGenericAssistantMessage("Got it. Let me know what you need.")).toBe(true); + }); + + it("detects 'I'll help/review/look into'", () => { + expect(isGenericAssistantMessage("I'll review that for you.")).toBe(true); + expect(isGenericAssistantMessage("I'll look into this right away.")).toBe(true); + }); + + it("preserves substantive assistant content", () => { + expect(isGenericAssistantMessage("## What I Accomplished\n\nDeployed the API to production with Vercel.")).toBe(false); + expect(isGenericAssistantMessage("The ElevenLabs SDK has been installed and configured. Voice skill is ready.")).toBe(false); + expect(isGenericAssistantMessage("Updated the call scripts sheet with truth-based messaging templates.")).toBe(false); + }); + + it("preserves long messages even with generic openers", () => { + const longMsg = "I see you've shared an update. " + "Here are the detailed changes I made to the configuration. ".repeat(10); + expect(isGenericAssistantMessage(longMsg)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// stripNoiseFromContent +// --------------------------------------------------------------------------- +describe("stripNoiseFromContent", () => { + it("removes conversation metadata JSON blocks", () => { + const input = `Conversation info (untrusted metadata): +\`\`\`json +{ + "message_id": "499", + "sender": "6039555582" +} +\`\`\` + +What models are you currently using?`; + const result = stripNoiseFromContent(input); + expect(result).toBe("What models are you currently using?"); + }); + + it("removes media attachment lines", () => { + const input = "[media attached: /path/to/file.jpg (image/jpeg) | /path/to/file.jpg]\nActual question here"; + const result = stripNoiseFromContent(input); + expect(result).toContain("Actual question here"); + expect(result).not.toContain("[media attached:"); + }); + + it("removes image sending boilerplate", () => { + const input = + "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg. Keep caption in the text body.\nReal content here"; + const result = stripNoiseFromContent(input); + expect(result).toContain("Real content here"); + expect(result).not.toContain("prefer the message tool"); + }); + + it("preserves content when no noise is present", () => { + const input = "User wants to deploy to production via Vercel."; + expect(stripNoiseFromContent(input)).toBe(input); + }); + + it("collapses excessive blank lines after stripping", () => { + const input = "Line one\n\n\n\n\nLine two"; + expect(stripNoiseFromContent(input)).toBe("Line one\n\nLine two"); + }); +}); + +// --------------------------------------------------------------------------- +// filterMessagesForExtraction +// --------------------------------------------------------------------------- +describe("filterMessagesForExtraction", () => { + it("drops noise messages entirely", () => { + const messages = [ + { role: "user", content: "HEARTBEAT_OK" }, + { role: "assistant", content: "Real response with durable facts." }, + { role: "user", content: "ok" }, + ]; + const result = filterMessagesForExtraction(messages); + expect(result).toHaveLength(1); + expect(result[0].content).toBe("Real response with durable facts."); + }); + + it("strips noise fragments but keeps the rest", () => { + const messages = [ + { + role: "user", + content: `Conversation info (untrusted metadata): +\`\`\`json +{ + "message_id": "123", + "sender": "456" +} +\`\`\` + +What is the deployment plan?`, + }, + ]; + const result = filterMessagesForExtraction(messages); + expect(result).toHaveLength(1); + expect(result[0].content).toBe("What is the deployment plan?"); + }); + + it("truncates long messages", () => { + const longContent = "A".repeat(3000); + const messages = [{ role: "assistant", content: longContent }]; + const result = filterMessagesForExtraction(messages); + expect(result).toHaveLength(1); + expect(result[0].content.length).toBeLessThan(2100); + expect(result[0].content).toContain("[...truncated]"); + }); + + it("returns empty array when all messages are noise", () => { + const messages = [ + { role: "user", content: "NO_REPLY" }, + { role: "user", content: "ok" }, + { role: "user", content: "Current time: Friday, February 20th, 2026" }, + ]; + expect(filterMessagesForExtraction(messages)).toHaveLength(0); + }); + + it("handles a realistic mixed payload", () => { + const messages = [ + { role: "user", content: "Pre-compaction memory flush. Store durable memories now." }, + { + role: "assistant", + content: "## What I Accomplished\n\nDeployed the API to production with Vercel.", + }, + { role: "user", content: "sir" }, + ]; + const result = filterMessagesForExtraction(messages); + expect(result).toHaveLength(1); + expect(result[0].content).toContain("Deployed the API"); + }); + + it("drops generic assistant acknowledgments", () => { + const messages = [ + { role: "user", content: "[ASSISTANT]: Updated the Google Sheet with truth-based scripts." }, + { role: "assistant", content: "I see you've shared an update. How can I help?" }, + ]; + const result = filterMessagesForExtraction(messages); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("user"); + expect(result[0].content).toContain("Google Sheet"); + }); + + it("returns only assistant messages when all user messages are noise", () => { + // This scenario triggers the #2 guard: no user content remains + const messages = [ + { role: "user", content: "ok" }, + { role: "user", content: "HEARTBEAT_OK" }, + { role: "assistant", content: "I deployed the API to production." }, + ]; + const result = filterMessagesForExtraction(messages); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("assistant"); + // The capture hook checks: if no user messages remain, skip add() + expect(result.some((m) => m.role === "user")).toBe(false); + }); + + it("keeps substantive assistant messages even with generic opener", () => { + const messages = [ + { role: "user", content: "What did you do?" }, + { role: "assistant", content: "I deployed the API to production and configured the webhook endpoints for Stripe integration." }, + ]; + const result = filterMessagesForExtraction(messages); + expect(result).toHaveLength(2); + }); +}); + diff --git a/openclaw/index.ts b/openclaw/index.ts index c49d53b58..8386dd77e 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -19,614 +19,39 @@ import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -// ============================================================================ -// Types -// ============================================================================ - -type Mem0Mode = "platform" | "open-source"; - -type Mem0Config = { - mode: Mem0Mode; - // Platform-specific - apiKey?: string; - orgId?: string; - projectId?: string; - customInstructions: string; - customCategories: Record; - enableGraph: boolean; - // OSS-specific - customPrompt?: string; - oss?: { - embedder?: { provider: string; config: Record }; - vectorStore?: { provider: string; config: Record }; - llm?: { provider: string; config: Record }; - historyDbPath?: string; - disableHistory?: boolean; - }; - // Shared - userId: string; - autoCapture: boolean; - autoRecall: boolean; - searchThreshold: number; - topK: number; -}; - -// Unified types for the provider interface -interface AddOptions { - user_id: string; - run_id?: string; - custom_instructions?: string; - custom_categories?: Array>; - enable_graph?: boolean; - output_format?: string; - source?: string; -} - -interface SearchOptions { - user_id: string; - run_id?: string; - top_k?: number; - threshold?: number; - limit?: number; - keyword_search?: boolean; - reranking?: boolean; - source?: string; -} - -interface ListOptions { - user_id: string; - run_id?: string; - page_size?: number; - source?: string; -} - -interface MemoryItem { - id: string; - memory: string; - user_id?: string; - score?: number; - categories?: string[]; - metadata?: Record; - created_at?: string; - updated_at?: string; -} - -interface AddResultItem { - id: string; - memory: string; - event: "ADD" | "UPDATE" | "DELETE" | "NOOP"; -} - -interface AddResult { - results: AddResultItem[]; -} +import type { + Mem0Config, + Mem0Provider, + MemoryItem, + AddOptions, + SearchOptions, +} from "./types.ts"; +import { createProvider } from "./providers.ts"; +import { mem0ConfigSchema } from "./config.ts"; +import { + filterMessagesForExtraction, +} from "./filtering.ts"; +import { + effectiveUserId, + agentUserId, + resolveUserId, + isNonInteractiveTrigger, + isSubagentSession, +} from "./isolation.ts"; // ============================================================================ -// Unified Provider Interface +// Re-exports (for tests and external consumers) // ============================================================================ -interface Mem0Provider { - add( - messages: Array<{ role: string; content: string }>, - options: AddOptions, - ): Promise; - search(query: string, options: SearchOptions): Promise; - get(memoryId: string): Promise; - getAll(options: ListOptions): Promise; - delete(memoryId: string): Promise; -} - -// ============================================================================ -// Platform Provider (Mem0 Cloud) -// ============================================================================ - -class PlatformProvider implements Mem0Provider { - private client: any; // MemoryClient from mem0ai - private initPromise: Promise | null = null; - - constructor( - private readonly apiKey: string, - private readonly orgId?: string, - private readonly projectId?: string, - ) { } - - private async ensureClient(): Promise { - if (this.client) return; - if (this.initPromise) return this.initPromise; - this.initPromise = this._init().catch((err) => { - this.initPromise = null; - throw err; - }); - return this.initPromise; - } - - private async _init(): Promise { - const { default: MemoryClient } = await import("mem0ai"); - const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey }; - if (this.orgId) opts.org_id = this.orgId; - if (this.projectId) opts.project_id = this.projectId; - this.client = new MemoryClient(opts); - } - - async add( - messages: Array<{ role: string; content: string }>, - options: AddOptions, - ): Promise { - await this.ensureClient(); - const opts: Record = { user_id: options.user_id }; - if (options.run_id) opts.run_id = options.run_id; - if (options.custom_instructions) - opts.custom_instructions = options.custom_instructions; - if (options.custom_categories) - opts.custom_categories = options.custom_categories; - if (options.enable_graph) opts.enable_graph = options.enable_graph; - if (options.output_format) opts.output_format = options.output_format; - if (options.source) opts.source = options.source; - - const result = await this.client.add(messages, opts); - return normalizeAddResult(result); - } - - async search(query: string, options: SearchOptions): Promise { - await this.ensureClient(); - const filters: Record = { user_id: options.user_id }; - if (options.run_id) filters.run_id = options.run_id; - - const opts: Record = { - api_version: "v2", - filters, - }; - if (options.top_k != null) opts.top_k = options.top_k; - if (options.threshold != null) opts.threshold = options.threshold; - if (options.keyword_search != null) opts.keyword_search = options.keyword_search; - if (options.reranking != null) opts.rerank = options.reranking; - - const results = await this.client.search(query, opts); - return normalizeSearchResults(results); - } - - async get(memoryId: string): Promise { - await this.ensureClient(); - const result = await this.client.get(memoryId); - return normalizeMemoryItem(result); - } - - async getAll(options: ListOptions): Promise { - await this.ensureClient(); - const opts: Record = { user_id: options.user_id }; - if (options.run_id) opts.run_id = options.run_id; - if (options.page_size != null) opts.page_size = options.page_size; - if (options.source) opts.source = options.source; - - const results = await this.client.getAll(opts); - if (Array.isArray(results)) return results.map(normalizeMemoryItem); - // Some versions return { results: [...] } - if (results?.results && Array.isArray(results.results)) - return results.results.map(normalizeMemoryItem); - return []; - } - - async delete(memoryId: string): Promise { - await this.ensureClient(); - await this.client.delete(memoryId); - } -} - -// ============================================================================ -// Open-Source Provider (Self-hosted) -// ============================================================================ - -class OSSProvider implements Mem0Provider { - private memory: any; // Memory from mem0ai/oss - private initPromise: Promise | null = null; - - constructor( - private readonly ossConfig?: Mem0Config["oss"], - private readonly customPrompt?: string, - private readonly resolvePath?: (p: string) => string, - ) { } - - private async ensureMemory(): Promise { - if (this.memory) return; - if (this.initPromise) return this.initPromise; - this.initPromise = this._init().catch((err) => { - this.initPromise = null; - throw err; - }); - return this.initPromise; - } - - private async _init(): Promise { - const { Memory } = await import("mem0ai/oss"); - - const config: Record = { version: "v1.1" }; - - if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder; - if (this.ossConfig?.vectorStore) - config.vectorStore = this.ossConfig.vectorStore; - if (this.ossConfig?.llm) config.llm = this.ossConfig.llm; - - if (this.ossConfig?.historyDbPath) { - const dbPath = this.resolvePath - ? this.resolvePath(this.ossConfig.historyDbPath) - : this.ossConfig.historyDbPath; - config.historyDbPath = dbPath; - } - - if (this.ossConfig?.disableHistory) { - config.disableHistory = true; - } - - if (this.customPrompt) config.customPrompt = this.customPrompt; - - try { - this.memory = new Memory(config); - } catch (err) { - // If initialization fails (e.g. native SQLite binding resolution under - // jiti), retry with history disabled — the history DB is the most common - // source of native-binding failures and is not required for core - // memory operations. - if (!config.disableHistory) { - console.warn( - "[mem0] Memory initialization failed, retrying with history disabled:", - err instanceof Error ? err.message : err, - ); - config.disableHistory = true; - this.memory = new Memory(config); - } else { - throw err; - } - } - } - - async add( - messages: Array<{ role: string; content: string }>, - options: AddOptions, - ): Promise { - await this.ensureMemory(); - // OSS SDK uses camelCase: userId/runId, not user_id/run_id - const addOpts: Record = { userId: options.user_id }; - if (options.run_id) addOpts.runId = options.run_id; - if (options.source) addOpts.source = options.source; - const result = await this.memory.add(messages, addOpts); - return normalizeAddResult(result); - } - - async search(query: string, options: SearchOptions): Promise { - await this.ensureMemory(); - // OSS SDK uses camelCase: userId/runId, not user_id/run_id - const opts: Record = { userId: options.user_id }; - if (options.run_id) opts.runId = options.run_id; - if (options.limit != null) opts.limit = options.limit; - else if (options.top_k != null) opts.limit = options.top_k; - if (options.keyword_search != null) opts.keyword_search = options.keyword_search; - if (options.reranking != null) opts.reranking = options.reranking; - if (options.source) opts.source = options.source; - if (options.threshold != null) opts.threshold = options.threshold; - - const results = await this.memory.search(query, opts); - const normalized = normalizeSearchResults(results); - - // Filter results by threshold if specified (client-side filtering as fallback) - if (options.threshold != null) { - return normalized.filter(item => (item.score ?? 0) >= options.threshold!); - } - - return normalized; - } - - async get(memoryId: string): Promise { - await this.ensureMemory(); - const result = await this.memory.get(memoryId); - return normalizeMemoryItem(result); - } - - async getAll(options: ListOptions): Promise { - await this.ensureMemory(); - // OSS SDK uses camelCase: userId/runId, not user_id/run_id - const getAllOpts: Record = { userId: options.user_id }; - if (options.run_id) getAllOpts.runId = options.run_id; - if (options.source) getAllOpts.source = options.source; - const results = await this.memory.getAll(getAllOpts); - if (Array.isArray(results)) return results.map(normalizeMemoryItem); - if (results?.results && Array.isArray(results.results)) - return results.results.map(normalizeMemoryItem); - return []; - } - - async delete(memoryId: string): Promise { - await this.ensureMemory(); - await this.memory.delete(memoryId); - } -} - -// ============================================================================ -// Result Normalizers -// ============================================================================ - -function normalizeMemoryItem(raw: any): MemoryItem { - return { - id: raw.id ?? raw.memory_id ?? "", - memory: raw.memory ?? raw.text ?? raw.content ?? "", - // Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names - user_id: raw.user_id ?? raw.userId, - score: raw.score, - categories: raw.categories, - metadata: raw.metadata, - created_at: raw.created_at ?? raw.createdAt, - updated_at: raw.updated_at ?? raw.updatedAt, - }; -} - -function normalizeSearchResults(raw: any): MemoryItem[] { - // Platform API returns flat array, OSS returns { results: [...] } - if (Array.isArray(raw)) return raw.map(normalizeMemoryItem); - if (raw?.results && Array.isArray(raw.results)) - return raw.results.map(normalizeMemoryItem); - return []; -} - -function normalizeAddResult(raw: any): AddResult { - // Handle { results: [...] } shape (both platform and OSS) - if (raw?.results && Array.isArray(raw.results)) { - return { - results: raw.results.map((r: any) => ({ - id: r.id ?? r.memory_id ?? "", - memory: r.memory ?? r.text ?? "", - // Platform API may return PENDING status (async processing) - // OSS stores event in metadata.event - event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), - })), - }; - } - // Platform API without output_format returns flat array - if (Array.isArray(raw)) { - return { - results: raw.map((r: any) => ({ - id: r.id ?? r.memory_id ?? "", - memory: r.memory ?? r.text ?? "", - event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), - })), - }; - } - return { results: [] }; -} - -// ============================================================================ -// Config Parser -// ============================================================================ - -function resolveEnvVars(value: string): string { - return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { - const envValue = process.env[envVar]; - if (!envValue) { - throw new Error(`Environment variable ${envVar} is not set`); - } - return envValue; - }); -} - -function resolveEnvVarsDeep(obj: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - if (typeof value === "string") { - result[key] = resolveEnvVars(value); - } else if (value && typeof value === "object" && !Array.isArray(value)) { - result[key] = resolveEnvVarsDeep(value as Record); - } else { - result[key] = value; - } - } - return result; -} - -// ============================================================================ -// Default Custom Instructions & Categories -// ============================================================================ - -const DEFAULT_CUSTOM_INSTRUCTIONS = `Your Task: Extract and maintain a structured, evolving profile of the user from their conversations with an AI assistant. Capture information that would help the assistant provide personalized, context-aware responses in future interactions. - -Information to Extract: - -1. Identity & Demographics: - - Name, age, location, timezone, language preferences - - Occupation, employer, job role, industry - - Education background - -2. Preferences & Opinions: - - Communication style preferences (formal/casual, verbose/concise) - - Tool and technology preferences (languages, frameworks, editors, OS) - - Content preferences (topics of interest, learning style) - - Strong opinions or values they've expressed - - Likes and dislikes they've explicitly stated - -3. Goals & Projects: - - Current projects they're working on (name, description, status) - - Short-term and long-term goals - - Deadlines and milestones mentioned - - Problems they're actively trying to solve - -4. Technical Context: - - Tech stack and tools they use - - Skill level in different areas (beginner/intermediate/expert) - - Development environment and setup details - - Recurring technical challenges - -5. Relationships & People: - - Names and roles of people they mention (colleagues, family, friends) - - Team structure and dynamics - - Key contacts and their relevance - -6. Decisions & Lessons: - - Important decisions made and their reasoning - - Lessons learned from past experiences - - Strategies that worked or failed - - Changed opinions or updated beliefs - -7. Routines & Habits: - - Daily routines and schedules mentioned - - Work patterns (when they're productive, how they organize work) - - Health and wellness habits if voluntarily shared - -8. Life Events: - - Significant events (new job, moving, milestones) - - Upcoming events or plans - - Changes in circumstances - -Guidelines: -- Store memories as clear, self-contained statements (each memory should make sense on its own) -- Use third person: "User prefers..." not "I prefer..." -- Include temporal context when relevant: "As of [date], user is working on..." -- When information updates, UPDATE the existing memory rather than creating duplicates -- Merge related facts into single coherent memories when possible -- Preserve specificity: "User uses Next.js 14 with App Router" is better than "User uses React" -- Capture the WHY behind preferences when stated: "User prefers Vim because of keyboard-driven workflow" - -Exclude: -- Passwords, API keys, tokens, or any authentication credentials -- Exact financial amounts (account balances, salaries) unless the user explicitly asks to remember them -- Temporary or ephemeral information (one-time questions, debugging sessions with no lasting insight) -- Generic small talk with no informational content -- The assistant's own responses unless they contain a commitment or promise to the user -- Raw code snippets (capture the intent/decision, not the code itself) -- Information the user explicitly asks not to remember`; - -const DEFAULT_CUSTOM_CATEGORIES: Record = { - identity: - "Personal identity information: name, age, location, timezone, occupation, employer, education, demographics", - preferences: - "Explicitly stated likes, dislikes, preferences, opinions, and values across any domain", - goals: - "Current and future goals, aspirations, objectives, targets the user is working toward", - projects: - "Specific projects, initiatives, or endeavors the user is working on, including status and details", - technical: - "Technical skills, tools, tech stack, development environment, programming languages, frameworks", - decisions: - "Important decisions made, reasoning behind choices, strategy changes, and their outcomes", - relationships: - "People mentioned by the user: colleagues, family, friends, their roles and relevance", - routines: - "Daily habits, work patterns, schedules, productivity routines, health and wellness habits", - life_events: - "Significant life events, milestones, transitions, upcoming plans and changes", - lessons: - "Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs", - work: - "Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges", - health: - "Health-related information voluntarily shared: conditions, medications, fitness, wellness goals", -}; - -// ============================================================================ -// Config Schema -// ============================================================================ - -const ALLOWED_KEYS = [ - "mode", - "apiKey", - "userId", - "orgId", - "projectId", - "autoCapture", - "autoRecall", - "customInstructions", - "customCategories", - "customPrompt", - "enableGraph", - "searchThreshold", - "topK", - "oss", -]; - -function assertAllowedKeys( - value: Record, - allowed: string[], - label: string, -) { - const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); - if (unknown.length === 0) return; - throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); -} - -export const mem0ConfigSchema = { - parse(value: unknown): Mem0Config { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("openclaw-mem0 config required"); - } - const cfg = value as Record; - assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config"); - - // Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform - const mode: Mem0Mode = - cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform"; - - // Platform mode requires apiKey - if (mode === "platform") { - if (typeof cfg.apiKey !== "string" || !cfg.apiKey) { - throw new Error( - "apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)", - ); - } - } - - // Resolve env vars in oss config - let ossConfig: Mem0Config["oss"]; - if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) { - ossConfig = resolveEnvVarsDeep( - cfg.oss as Record, - ) as unknown as Mem0Config["oss"]; - } - - return { - mode, - apiKey: - typeof cfg.apiKey === "string" ? resolveEnvVars(cfg.apiKey) : undefined, - userId: - typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default", - orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined, - projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined, - autoCapture: cfg.autoCapture !== false, - autoRecall: cfg.autoRecall !== false, - customInstructions: - typeof cfg.customInstructions === "string" - ? cfg.customInstructions - : DEFAULT_CUSTOM_INSTRUCTIONS, - customCategories: - cfg.customCategories && - typeof cfg.customCategories === "object" && - !Array.isArray(cfg.customCategories) - ? (cfg.customCategories as Record) - : DEFAULT_CUSTOM_CATEGORIES, - customPrompt: - typeof cfg.customPrompt === "string" - ? cfg.customPrompt - : DEFAULT_CUSTOM_INSTRUCTIONS, - enableGraph: cfg.enableGraph === true, - searchThreshold: - typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5, - topK: typeof cfg.topK === "number" ? cfg.topK : 5, - oss: ossConfig, - }; - }, -}; - -// ============================================================================ -// Provider Factory -// ============================================================================ - -export function createProvider( - cfg: Mem0Config, - api: OpenClawPluginApi, -): Mem0Provider { - if (cfg.mode === "open-source") { - return new OSSProvider(cfg.oss, cfg.customPrompt, (p) => - api.resolvePath(p), - ); - } - - return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId); -} +export { extractAgentId, effectiveUserId, agentUserId, resolveUserId, isNonInteractiveTrigger, isSubagentSession } from "./isolation.ts"; +export { + isNoiseMessage, + isGenericAssistantMessage, + stripNoiseFromContent, + filterMessagesForExtraction, +} from "./filtering.ts"; +export { mem0ConfigSchema } from "./config.ts"; +export { createProvider } from "./providers.ts"; // ============================================================================ // Helpers @@ -639,50 +64,6 @@ function categoriesToArray( return Object.entries(cats).map(([key, value]) => ({ [key]: value })); } -// ============================================================================ -// Per-agent isolation helpers (exported for testability) -// ============================================================================ - -/** - * Parse an agent ID from a session key following the pattern `agent::`. - * Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys. - */ -export function extractAgentId(sessionKey: string | undefined): string | undefined { - if (!sessionKey) return undefined; - const match = sessionKey.match(/^agent:([^:]+):/); - const agentId = match?.[1]; - // "main" is the primary session — fall back to configured userId - if (!agentId || agentId === "main") return undefined; - return agentId; -} - -/** - * Derive the effective user_id from a session key, namespacing per-agent. - * Falls back to baseUserId when the session is not agent-scoped. - */ -export function effectiveUserId(baseUserId: string, sessionKey?: string): string { - const agentId = extractAgentId(sessionKey); - return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId; -} - -/** Build a user_id for an explicit agentId (e.g. from tool params). */ -export function agentUserId(baseUserId: string, agentId: string): string { - return `${baseUserId}:agent:${agentId}`; -} - -/** - * Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured. - */ -export function resolveUserId( - baseUserId: string, - opts: { agentId?: string; userId?: string }, - currentSessionId?: string, -): string { - if (opts.agentId) return agentUserId(baseUserId, opts.agentId); - if (opts.userId) return opts.userId; - return effectiveUserId(baseUserId, currentSessionId); -} - // ============================================================================ // Plugin Definition // ============================================================================ @@ -699,7 +80,10 @@ const memoryPlugin = { const cfg = mem0ConfigSchema.parse(api.pluginConfig); const provider = createProvider(cfg, api); - // Track current session ID for tool-level session scoping + // Track current session ID for tool-level session scoping. + // NOTE: This is shared mutable state — tools don't receive ctx, so they + // read this as a best-effort fallback. Hooks should use ctx.sessionKey + // directly and avoid relying on this variable. let currentSessionId: string | undefined; // ======================================================================== @@ -755,755 +139,21 @@ const memoryPlugin = { // Tools // ======================================================================== - api.registerTool( - { - name: "memory_search", - label: "Memory Search", - description: - "Search through long-term memories stored in Mem0. Use when you need context about user preferences, past decisions, or previously discussed topics.", - parameters: Type.Object({ - query: Type.String({ description: "Search query" }), - limit: Type.Optional( - Type.Number({ - description: `Max results (default: ${cfg.topK})`, - }), - ), - userId: Type.Optional( - Type.String({ - description: - "User ID to scope search (default: configured userId)", - }), - ), - agentId: Type.Optional( - Type.String({ - description: - "Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.", - }), - ), - scope: Type.Optional( - Type.Union([ - Type.Literal("session"), - Type.Literal("long-term"), - Type.Literal("all"), - ], { - description: - 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', - }), - ), - }), - async execute(_toolCallId, params) { - const { query, limit, userId, agentId, scope = "all" } = params as { - query: string; - limit?: number; - userId?: string; - agentId?: string; - scope?: "session" | "long-term" | "all"; - }; - - try { - let results: MemoryItem[] = []; - const uid = _resolveUserId({ agentId, userId }); - - if (scope === "session") { - if (currentSessionId) { - results = await provider.search( - query, - buildSearchOptions(uid, limit, currentSessionId), - ); - } - } else if (scope === "long-term") { - results = await provider.search( - query, - buildSearchOptions(uid, limit), - ); - } else { - // "all" — search both scopes and combine - const longTermResults = await provider.search( - query, - buildSearchOptions(uid, limit), - ); - let sessionResults: MemoryItem[] = []; - if (currentSessionId) { - sessionResults = await provider.search( - query, - buildSearchOptions(uid, limit, currentSessionId), - ); - } - // Deduplicate by ID, preferring long-term - const seen = new Set(longTermResults.map((r) => r.id)); - results = [ - ...longTermResults, - ...sessionResults.filter((r) => !seen.has(r.id)), - ]; - } - - if (!results || results.length === 0) { - return { - content: [ - { type: "text", text: "No relevant memories found." }, - ], - details: { count: 0 }, - }; - } - - const text = results - .map( - (r, i) => - `${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`, - ) - .join("\n"); - - const sanitized = results.map((r) => ({ - id: r.id, - memory: r.memory, - score: r.score, - categories: r.categories, - created_at: r.created_at, - })); - - return { - content: [ - { - type: "text", - text: `Found ${results.length} memories:\n\n${text}`, - }, - ], - details: { count: results.length, memories: sanitized }, - }; - } catch (err) { - return { - content: [ - { - type: "text", - text: `Memory search failed: ${String(err)}`, - }, - ], - details: { error: String(err) }, - }; - } - }, - }, - { name: "memory_search" }, - ); - - api.registerTool( - { - name: "memory_store", - label: "Memory Store", - description: - "Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.", - parameters: Type.Object({ - text: Type.String({ description: "Information to remember" }), - userId: Type.Optional( - Type.String({ - description: "User ID to scope this memory", - }), - ), - agentId: Type.Optional( - Type.String({ - description: - "Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.", - }), - ), - metadata: Type.Optional( - Type.Record(Type.String(), Type.Unknown(), { - description: "Optional metadata to attach to this memory", - }), - ), - longTerm: Type.Optional( - Type.Boolean({ - description: - "Store as long-term (user-scoped) memory. Default: true. Set to false for session-scoped memory.", - }), - ), - }), - async execute(_toolCallId, params) { - const { text, userId, agentId, longTerm = true } = params as { - text: string; - userId?: string; - agentId?: string; - metadata?: Record; - longTerm?: boolean; - }; - - try { - const uid = _resolveUserId({ agentId, userId }); - const runId = !longTerm && currentSessionId ? currentSessionId : undefined; - const result = await provider.add( - [{ role: "user", content: text }], - buildAddOptions(uid, runId, currentSessionId), - ); - - const added = - result.results?.filter((r) => r.event === "ADD") ?? []; - const updated = - result.results?.filter((r) => r.event === "UPDATE") ?? []; - - const summary = []; - if (added.length > 0) - summary.push( - `${added.length} new memor${added.length === 1 ? "y" : "ies"} added`, - ); - if (updated.length > 0) - summary.push( - `${updated.length} memor${updated.length === 1 ? "y" : "ies"} updated`, - ); - if (summary.length === 0) - summary.push("No new memories extracted"); - - return { - content: [ - { - type: "text", - text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}`, - }, - ], - details: { - action: "stored", - results: result.results, - }, - }; - } catch (err) { - return { - content: [ - { - type: "text", - text: `Memory store failed: ${String(err)}`, - }, - ], - details: { error: String(err) }, - }; - } - }, - }, - { name: "memory_store" }, - ); - - api.registerTool( - { - name: "memory_get", - label: "Memory Get", - description: "Retrieve a specific memory by its ID from Mem0.", - parameters: Type.Object({ - memoryId: Type.String({ description: "The memory ID to retrieve" }), - }), - async execute(_toolCallId, params) { - const { memoryId } = params as { memoryId: string }; - - try { - const memory = await provider.get(memoryId); - - return { - content: [ - { - type: "text", - text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}`, - }, - ], - details: { memory }, - }; - } catch (err) { - return { - content: [ - { - type: "text", - text: `Memory get failed: ${String(err)}`, - }, - ], - details: { error: String(err) }, - }; - } - }, - }, - { name: "memory_get" }, - ); - - api.registerTool( - { - name: "memory_list", - label: "Memory List", - description: - "List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.", - parameters: Type.Object({ - userId: Type.Optional( - Type.String({ - description: - "User ID to list memories for (default: configured userId)", - }), - ), - agentId: Type.Optional( - Type.String({ - description: - "Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.", - }), - ), - scope: Type.Optional( - Type.Union([ - Type.Literal("session"), - Type.Literal("long-term"), - Type.Literal("all"), - ], { - description: - 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', - }), - ), - }), - async execute(_toolCallId, params) { - const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" }; - - try { - let memories: MemoryItem[] = []; - const uid = _resolveUserId({ agentId, userId }); - - if (scope === "session") { - if (currentSessionId) { - memories = await provider.getAll({ - user_id: uid, - run_id: currentSessionId, - source: "OPENCLAW", - }); - } - } else if (scope === "long-term") { - memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); - } else { - // "all" — combine both scopes - const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); - let session: MemoryItem[] = []; - if (currentSessionId) { - session = await provider.getAll({ - user_id: uid, - run_id: currentSessionId, - source: "OPENCLAW", - }); - } - const seen = new Set(longTerm.map((r) => r.id)); - memories = [ - ...longTerm, - ...session.filter((r) => !seen.has(r.id)), - ]; - } - - if (!memories || memories.length === 0) { - return { - content: [ - { type: "text", text: "No memories stored yet." }, - ], - details: { count: 0 }, - }; - } - - const text = memories - .map( - (r, i) => - `${i + 1}. ${r.memory} (id: ${r.id})`, - ) - .join("\n"); - - const sanitized = memories.map((r) => ({ - id: r.id, - memory: r.memory, - categories: r.categories, - created_at: r.created_at, - })); - - return { - content: [ - { - type: "text", - text: `${memories.length} memories:\n\n${text}`, - }, - ], - details: { count: memories.length, memories: sanitized }, - }; - } catch (err) { - return { - content: [ - { - type: "text", - text: `Memory list failed: ${String(err)}`, - }, - ], - details: { error: String(err) }, - }; - } - }, - }, - { name: "memory_list" }, - ); - - api.registerTool( - { - name: "memory_forget", - label: "Memory Forget", - description: - "Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.", - parameters: Type.Object({ - query: Type.Optional( - Type.String({ - description: "Search query to find memory to delete", - }), - ), - memoryId: Type.Optional( - Type.String({ description: "Specific memory ID to delete" }), - ), - agentId: Type.Optional( - Type.String({ - description: - "Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").", - }), - ), - }), - async execute(_toolCallId, params) { - const { query, memoryId, agentId } = params as { - query?: string; - memoryId?: string; - agentId?: string; - }; - - try { - if (memoryId) { - await provider.delete(memoryId); - return { - content: [ - { type: "text", text: `Memory ${memoryId} forgotten.` }, - ], - details: { action: "deleted", id: memoryId }, - }; - } - - if (query) { - const uid = _resolveUserId({ agentId }); - const results = await provider.search( - query, - buildSearchOptions(uid, 5), - ); - - if (!results || results.length === 0) { - return { - content: [ - { type: "text", text: "No matching memories found." }, - ], - details: { found: 0 }, - }; - } - - // If single high-confidence match, delete directly - if ( - results.length === 1 || - (results[0].score ?? 0) > 0.9 - ) { - await provider.delete(results[0].id); - return { - content: [ - { - type: "text", - text: `Forgotten: "${results[0].memory}"`, - }, - ], - details: { action: "deleted", id: results[0].id }, - }; - } - - const list = results - .map( - (r) => - `- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (score: ${((r.score ?? 0) * 100).toFixed(0)}%)`, - ) - .join("\n"); - - const candidates = results.map((r) => ({ - id: r.id, - memory: r.memory, - score: r.score, - })); - - return { - content: [ - { - type: "text", - text: `Found ${results.length} candidates. Specify memoryId to delete:\n${list}`, - }, - ], - details: { action: "candidates", candidates }, - }; - } - - return { - content: [ - { type: "text", text: "Provide a query or memoryId." }, - ], - details: { error: "missing_param" }, - }; - } catch (err) { - return { - content: [ - { - type: "text", - text: `Memory forget failed: ${String(err)}`, - }, - ], - details: { error: String(err) }, - }; - } - }, - }, - { name: "memory_forget" }, - ); + registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId); // ======================================================================== // CLI Commands // ======================================================================== - api.registerCli( - ({ program }) => { - const mem0 = program - .command("mem0") - .description("Mem0 memory plugin commands"); - - mem0 - .command("search") - .description("Search memories in Mem0") - .argument("", "Search query") - .option("--limit ", "Max results", String(cfg.topK)) - .option("--scope ", 'Memory scope: "session", "long-term", or "all"', "all") - .option("--agent ", "Search a specific agent's memory namespace") - .action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => { - try { - const limit = parseInt(opts.limit, 10); - const scope = opts.scope as "session" | "long-term" | "all"; - const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId); - - let allResults: MemoryItem[] = []; - - if (scope === "session" || scope === "all") { - if (currentSessionId) { - const sessionResults = await provider.search( - query, - buildSearchOptions(uid, limit, currentSessionId), - ); - if (sessionResults?.length) { - allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const }))); - } - } else if (scope === "session") { - console.log("No active session ID available for session-scoped search."); - return; - } - } - - if (scope === "long-term" || scope === "all") { - const longTermResults = await provider.search( - query, - buildSearchOptions(uid, limit), - ); - if (longTermResults?.length) { - allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const }))); - } - } - - // Deduplicate by ID when searching "all" - if (scope === "all") { - const seen = new Set(); - allResults = allResults.filter((r) => { - if (seen.has(r.id)) return false; - seen.add(r.id); - return true; - }); - } - - if (!allResults.length) { - console.log("No memories found."); - return; - } - - const output = allResults.map((r) => ({ - id: r.id, - memory: r.memory, - score: r.score, - scope: (r as any)._scope, - categories: r.categories, - created_at: r.created_at, - })); - console.log(JSON.stringify(output, null, 2)); - } catch (err) { - console.error(`Search failed: ${String(err)}`); - } - }); - - mem0 - .command("stats") - .description("Show memory statistics from Mem0") - .option("--agent ", "Show stats for a specific agent") - .action(async (opts: { agent?: string }) => { - try { - const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId; - const memories = await provider.getAll({ - user_id: uid, - source: "OPENCLAW", - }); - console.log(`Mode: ${cfg.mode}`); - console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`); - console.log( - `Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`, - ); - console.log(`Graph enabled: ${cfg.enableGraph}`); - console.log( - `Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`, - ); - } catch (err) { - console.error(`Stats failed: ${String(err)}`); - } - }); - }, - { commands: ["mem0"] }, - ); + registerCli(api, provider, cfg, _effectiveUserId, _agentUserId, buildSearchOptions, () => currentSessionId); // ======================================================================== // Lifecycle Hooks // ======================================================================== - // Auto-recall: inject relevant memories before agent starts - if (cfg.autoRecall) { - api.on("before_agent_start", async (event, ctx) => { - if (!event.prompt || event.prompt.length < 5) return; - - // Track session ID - const sessionId = (ctx as any)?.sessionKey ?? undefined; - if (sessionId) currentSessionId = sessionId; - - try { - // Search long-term memories (user-scoped, isolated per agent) - const longTermResults = await provider.search( - event.prompt, - buildSearchOptions(undefined, undefined, undefined, sessionId), - ); - - // Search session memories (session-scoped) if we have a session ID - let sessionResults: MemoryItem[] = []; - if (currentSessionId) { - sessionResults = await provider.search( - event.prompt, - buildSearchOptions(undefined, undefined, currentSessionId, sessionId), - ); - } - - // Deduplicate session results against long-term - const longTermIds = new Set(longTermResults.map((r) => r.id)); - const uniqueSessionResults = sessionResults.filter( - (r) => !longTermIds.has(r.id), - ); - - if (longTermResults.length === 0 && uniqueSessionResults.length === 0) return; - - // Build context with clear labels - let memoryContext = ""; - if (longTermResults.length > 0) { - memoryContext += longTermResults - .map( - (r) => - `- ${r.memory}${r.categories?.length ? ` [${r.categories.join(", ")}]` : ""}`, - ) - .join("\n"); - } - if (uniqueSessionResults.length > 0) { - if (memoryContext) memoryContext += "\n"; - memoryContext += "\nSession memories:\n"; - memoryContext += uniqueSessionResults - .map((r) => `- ${r.memory}`) - .join("\n"); - } - - const totalCount = longTermResults.length + uniqueSessionResults.length; - api.logger.info( - `openclaw-mem0: injecting ${totalCount} memories into context (${longTermResults.length} long-term, ${uniqueSessionResults.length} session)`, - ); - - return { - prependContext: `\nThe following memories may be relevant to this conversation:\n${memoryContext}\n`, - }; - } catch (err) { - api.logger.warn(`openclaw-mem0: recall failed: ${String(err)}`); - } - }); - } - - // Auto-capture: store conversation context after agent ends - if (cfg.autoCapture) { - api.on("agent_end", async (event, ctx) => { - if (!event.success || !event.messages || event.messages.length === 0) { - return; - } - - // Track session ID - const sessionId = (ctx as any)?.sessionKey ?? undefined; - if (sessionId) currentSessionId = sessionId; - - try { - // Extract messages, limiting to last 10 - const recentMessages = event.messages.slice(-10); - const formattedMessages: Array<{ - role: string; - content: string; - }> = []; - - for (const msg of recentMessages) { - if (!msg || typeof msg !== "object") continue; - const msgObj = msg as Record; - - const role = msgObj.role; - if (role !== "user" && role !== "assistant") continue; - - let textContent = ""; - const content = msgObj.content; - - if (typeof content === "string") { - textContent = content; - } else if (Array.isArray(content)) { - for (const block of content) { - if ( - block && - typeof block === "object" && - "text" in block && - typeof (block as Record).text === "string" - ) { - textContent += - (textContent ? "\n" : "") + - ((block as Record).text as string); - } - } - } - - if (!textContent) continue; - // Strip injected memory context, keep the actual user text - if (textContent.includes("")) { - textContent = textContent.replace(/[\s\S]*?<\/relevant-memories>\s*/g, "").trim(); - if (!textContent) continue; - } - - formattedMessages.push({ - role: role as string, - content: textContent, - }); - } - - if (formattedMessages.length === 0) return; - - const addOpts = buildAddOptions(undefined, currentSessionId, sessionId); - const result = await provider.add( - formattedMessages, - addOpts, - ); - - const capturedCount = result.results?.length ?? 0; - if (capturedCount > 0) { - api.logger.info( - `openclaw-mem0: auto-captured ${capturedCount} memories`, - ); - } - } catch (err) { - api.logger.warn(`openclaw-mem0: capture failed: ${String(err)}`); - } - }); - } + registerHooks(api, provider, cfg, _effectiveUserId, buildAddOptions, buildSearchOptions, { + setCurrentSessionId: (id: string) => { currentSessionId = id; }, + }); // ======================================================================== // Service @@ -1523,4 +173,958 @@ const memoryPlugin = { }, }; +// ============================================================================ +// Tool Registration +// ============================================================================ + +function registerTools( + api: OpenClawPluginApi, + provider: Mem0Provider, + cfg: Mem0Config, + _resolveUserId: (opts: { agentId?: string; userId?: string }) => string, + _effectiveUserId: (sessionKey?: string) => string, + _agentUserId: (id: string) => string, + buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions, + buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions, + getCurrentSessionId: () => string | undefined, +) { + api.registerTool( + { + name: "memory_search", + label: "Memory Search", + description: + "Search through long-term memories stored in Mem0. Use when you need context about user preferences, past decisions, or previously discussed topics.", + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional( + Type.Number({ + description: `Max results (default: ${cfg.topK})`, + }), + ), + userId: Type.Optional( + Type.String({ + description: + "User ID to scope search (default: configured userId)", + }), + ), + agentId: Type.Optional( + Type.String({ + description: + "Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.", + }), + ), + scope: Type.Optional( + Type.Union([ + Type.Literal("session"), + Type.Literal("long-term"), + Type.Literal("all"), + ], { + description: + 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', + }), + ), + }), + async execute(_toolCallId, params) { + const { query, limit, userId, agentId, scope = "all" } = params as { + query: string; + limit?: number; + userId?: string; + agentId?: string; + scope?: "session" | "long-term" | "all"; + }; + + try { + let results: MemoryItem[] = []; + const uid = _resolveUserId({ agentId, userId }); + const currentSessionId = getCurrentSessionId(); + + if (scope === "session") { + if (currentSessionId) { + results = await provider.search( + query, + buildSearchOptions(uid, limit, currentSessionId), + ); + } + } else if (scope === "long-term") { + results = await provider.search( + query, + buildSearchOptions(uid, limit), + ); + } else { + // "all" — search both scopes and combine + const longTermResults = await provider.search( + query, + buildSearchOptions(uid, limit), + ); + let sessionResults: MemoryItem[] = []; + if (currentSessionId) { + sessionResults = await provider.search( + query, + buildSearchOptions(uid, limit, currentSessionId), + ); + } + // Deduplicate by ID, preferring long-term + const seen = new Set(longTermResults.map((r) => r.id)); + results = [ + ...longTermResults, + ...sessionResults.filter((r) => !seen.has(r.id)), + ]; + } + + if (!results || results.length === 0) { + return { + content: [ + { type: "text", text: "No relevant memories found." }, + ], + details: { count: 0 }, + }; + } + + const text = results + .map( + (r, i) => + `${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`, + ) + .join("\n"); + + const sanitized = results.map((r) => ({ + id: r.id, + memory: r.memory, + score: r.score, + categories: r.categories, + created_at: r.created_at, + })); + + return { + content: [ + { + type: "text", + text: `Found ${results.length} memories:\n\n${text}`, + }, + ], + details: { count: results.length, memories: sanitized }, + }; + } catch (err) { + return { + content: [ + { + type: "text", + text: `Memory search failed: ${String(err)}`, + }, + ], + details: { error: String(err) }, + }; + } + }, + }, + { name: "memory_search" }, + ); + + api.registerTool( + { + name: "memory_store", + label: "Memory Store", + description: + "Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.", + parameters: Type.Object({ + text: Type.String({ description: "Information to remember" }), + userId: Type.Optional( + Type.String({ + description: "User ID to scope this memory", + }), + ), + agentId: Type.Optional( + Type.String({ + description: + "Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.", + }), + ), + metadata: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: "Optional metadata to attach to this memory", + }), + ), + longTerm: Type.Optional( + Type.Boolean({ + description: + "Store as long-term (user-scoped) memory. Default: true. Set to false for session-scoped memory.", + }), + ), + }), + async execute(_toolCallId, params) { + const { text, userId, agentId, longTerm = true } = params as { + text: string; + userId?: string; + agentId?: string; + metadata?: Record; + longTerm?: boolean; + }; + + try { + const uid = _resolveUserId({ agentId, userId }); + const currentSessionId = getCurrentSessionId(); + const runId = !longTerm && currentSessionId ? currentSessionId : undefined; + + // Pre-check for near-duplicates so the extraction model has + // context about existing memories and can UPDATE rather than ADD + const preview = text.slice(0, 200); + const dedupOpts = buildSearchOptions(uid, 3); + dedupOpts.threshold = 0.85; + const existing = await provider.search(preview, dedupOpts); + if (existing.length > 0) { + api.logger.info( + `openclaw-mem0: found ${existing.length} similar existing memories — mem0 may update instead of add`, + ); + } + + const result = await provider.add( + [{ role: "user", content: text }], + buildAddOptions(uid, runId, currentSessionId), + ); + + const added = + result.results?.filter((r) => r.event === "ADD") ?? []; + const updated = + result.results?.filter((r) => r.event === "UPDATE") ?? []; + + const summary = []; + if (added.length > 0) + summary.push( + `${added.length} new memor${added.length === 1 ? "y" : "ies"} added`, + ); + if (updated.length > 0) + summary.push( + `${updated.length} memor${updated.length === 1 ? "y" : "ies"} updated`, + ); + if (summary.length === 0) + summary.push("No new memories extracted"); + + return { + content: [ + { + type: "text", + text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}`, + }, + ], + details: { + action: "stored", + results: result.results, + }, + }; + } catch (err) { + return { + content: [ + { + type: "text", + text: `Memory store failed: ${String(err)}`, + }, + ], + details: { error: String(err) }, + }; + } + }, + }, + { name: "memory_store" }, + ); + + api.registerTool( + { + name: "memory_get", + label: "Memory Get", + description: "Retrieve a specific memory by its ID from Mem0.", + parameters: Type.Object({ + memoryId: Type.String({ description: "The memory ID to retrieve" }), + }), + async execute(_toolCallId, params) { + const { memoryId } = params as { memoryId: string }; + + try { + const memory = await provider.get(memoryId); + + return { + content: [ + { + type: "text", + text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}`, + }, + ], + details: { memory }, + }; + } catch (err) { + return { + content: [ + { + type: "text", + text: `Memory get failed: ${String(err)}`, + }, + ], + details: { error: String(err) }, + }; + } + }, + }, + { name: "memory_get" }, + ); + + api.registerTool( + { + name: "memory_list", + label: "Memory List", + description: + "List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.", + parameters: Type.Object({ + userId: Type.Optional( + Type.String({ + description: + "User ID to list memories for (default: configured userId)", + }), + ), + agentId: Type.Optional( + Type.String({ + description: + "Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.", + }), + ), + scope: Type.Optional( + Type.Union([ + Type.Literal("session"), + Type.Literal("long-term"), + Type.Literal("all"), + ], { + description: + 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', + }), + ), + }), + async execute(_toolCallId, params) { + const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" }; + + try { + let memories: MemoryItem[] = []; + const uid = _resolveUserId({ agentId, userId }); + const currentSessionId = getCurrentSessionId(); + + if (scope === "session") { + if (currentSessionId) { + memories = await provider.getAll({ + user_id: uid, + run_id: currentSessionId, + source: "OPENCLAW", + }); + } + } else if (scope === "long-term") { + memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); + } else { + // "all" — combine both scopes + const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); + let session: MemoryItem[] = []; + if (currentSessionId) { + session = await provider.getAll({ + user_id: uid, + run_id: currentSessionId, + source: "OPENCLAW", + }); + } + const seen = new Set(longTerm.map((r) => r.id)); + memories = [ + ...longTerm, + ...session.filter((r) => !seen.has(r.id)), + ]; + } + + if (!memories || memories.length === 0) { + return { + content: [ + { type: "text", text: "No memories stored yet." }, + ], + details: { count: 0 }, + }; + } + + const text = memories + .map( + (r, i) => + `${i + 1}. ${r.memory} (id: ${r.id})`, + ) + .join("\n"); + + const sanitized = memories.map((r) => ({ + id: r.id, + memory: r.memory, + categories: r.categories, + created_at: r.created_at, + })); + + return { + content: [ + { + type: "text", + text: `${memories.length} memories:\n\n${text}`, + }, + ], + details: { count: memories.length, memories: sanitized }, + }; + } catch (err) { + return { + content: [ + { + type: "text", + text: `Memory list failed: ${String(err)}`, + }, + ], + details: { error: String(err) }, + }; + } + }, + }, + { name: "memory_list" }, + ); + + api.registerTool( + { + name: "memory_forget", + label: "Memory Forget", + description: + "Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.", + parameters: Type.Object({ + query: Type.Optional( + Type.String({ + description: "Search query to find memory to delete", + }), + ), + memoryId: Type.Optional( + Type.String({ description: "Specific memory ID to delete" }), + ), + agentId: Type.Optional( + Type.String({ + description: + "Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").", + }), + ), + }), + async execute(_toolCallId, params) { + const { query, memoryId, agentId } = params as { + query?: string; + memoryId?: string; + agentId?: string; + }; + + try { + if (memoryId) { + await provider.delete(memoryId); + return { + content: [ + { type: "text", text: `Memory ${memoryId} forgotten.` }, + ], + details: { action: "deleted", id: memoryId }, + }; + } + + if (query) { + const uid = _resolveUserId({ agentId }); + const results = await provider.search( + query, + buildSearchOptions(uid, 5), + ); + + if (!results || results.length === 0) { + return { + content: [ + { type: "text", text: "No matching memories found." }, + ], + details: { found: 0 }, + }; + } + + // If single high-confidence match, delete directly + if ( + results.length === 1 || + (results[0].score ?? 0) > 0.9 + ) { + await provider.delete(results[0].id); + return { + content: [ + { + type: "text", + text: `Forgotten: "${results[0].memory}"`, + }, + ], + details: { action: "deleted", id: results[0].id }, + }; + } + + const list = results + .map( + (r) => + `- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (score: ${((r.score ?? 0) * 100).toFixed(0)}%)`, + ) + .join("\n"); + + const candidates = results.map((r) => ({ + id: r.id, + memory: r.memory, + score: r.score, + })); + + return { + content: [ + { + type: "text", + text: `Found ${results.length} candidates. Specify memoryId to delete:\n${list}`, + }, + ], + details: { action: "candidates", candidates }, + }; + } + + return { + content: [ + { type: "text", text: "Provide a query or memoryId." }, + ], + details: { error: "missing_param" }, + }; + } catch (err) { + return { + content: [ + { + type: "text", + text: `Memory forget failed: ${String(err)}`, + }, + ], + details: { error: String(err) }, + }; + } + }, + }, + { name: "memory_forget" }, + ); +} + +// ============================================================================ +// CLI Registration +// ============================================================================ + +function registerCli( + api: OpenClawPluginApi, + provider: Mem0Provider, + cfg: Mem0Config, + _effectiveUserId: (sessionKey?: string) => string, + _agentUserId: (id: string) => string, + buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions, + getCurrentSessionId: () => string | undefined, +) { + api.registerCli( + ({ program }) => { + const mem0 = program + .command("mem0") + .description("Mem0 memory plugin commands"); + + mem0 + .command("search") + .description("Search memories in Mem0") + .argument("", "Search query") + .option("--limit ", "Max results", String(cfg.topK)) + .option("--scope ", 'Memory scope: "session", "long-term", or "all"', "all") + .option("--agent ", "Search a specific agent's memory namespace") + .action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => { + try { + const limit = parseInt(opts.limit, 10); + const scope = opts.scope as "session" | "long-term" | "all"; + const currentSessionId = getCurrentSessionId(); + const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId); + + let allResults: MemoryItem[] = []; + + if (scope === "session" || scope === "all") { + if (currentSessionId) { + const sessionResults = await provider.search( + query, + buildSearchOptions(uid, limit, currentSessionId), + ); + if (sessionResults?.length) { + allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const }))); + } + } else if (scope === "session") { + console.log("No active session ID available for session-scoped search."); + return; + } + } + + if (scope === "long-term" || scope === "all") { + const longTermResults = await provider.search( + query, + buildSearchOptions(uid, limit), + ); + if (longTermResults?.length) { + allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const }))); + } + } + + // Deduplicate by ID when searching "all" + if (scope === "all") { + const seen = new Set(); + allResults = allResults.filter((r) => { + if (seen.has(r.id)) return false; + seen.add(r.id); + return true; + }); + } + + if (!allResults.length) { + console.log("No memories found."); + return; + } + + const output = allResults.map((r) => ({ + id: r.id, + memory: r.memory, + score: r.score, + scope: (r as any)._scope, + categories: r.categories, + created_at: r.created_at, + })); + console.log(JSON.stringify(output, null, 2)); + } catch (err) { + console.error(`Search failed: ${String(err)}`); + } + }); + + mem0 + .command("stats") + .description("Show memory statistics from Mem0") + .option("--agent ", "Show stats for a specific agent") + .action(async (opts: { agent?: string }) => { + try { + const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId; + const memories = await provider.getAll({ + user_id: uid, + source: "OPENCLAW", + }); + console.log(`Mode: ${cfg.mode}`); + console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`); + console.log( + `Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`, + ); + console.log(`Graph enabled: ${cfg.enableGraph}`); + console.log( + `Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`, + ); + } catch (err) { + console.error(`Stats failed: ${String(err)}`); + } + }); + }, + { commands: ["mem0"] }, + ); +} + +// ============================================================================ +// Lifecycle Hook Registration +// ============================================================================ + +function registerHooks( + api: OpenClawPluginApi, + provider: Mem0Provider, + cfg: Mem0Config, + _effectiveUserId: (sessionKey?: string) => string, + buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions, + buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions, + session: { + setCurrentSessionId: (id: string) => void; + }, +) { + // Auto-recall: inject relevant memories before agent starts + if (cfg.autoRecall) { + api.on("before_agent_start", async (event, ctx) => { + if (!event.prompt || event.prompt.length < 5) return; + + // Skip non-interactive triggers (cron, heartbeat, automation) + const trigger = (ctx as any)?.trigger ?? undefined; + const sessionId = (ctx as any)?.sessionKey ?? undefined; + if (isNonInteractiveTrigger(trigger, sessionId)) { + api.logger.info("openclaw-mem0: skipping recall for non-interactive trigger"); + return; + } + + // Update shared state for tools (best-effort — tools don't have ctx) + if (sessionId) session.setCurrentSessionId(sessionId); + + // Detect new session for cold-start broadening + const isNewSession = true; // treat every hook invocation as potentially new + + // Subagents have ephemeral UUIDs — their namespace is always empty. + // Search the parent (main) user namespace instead so subagents get + // the user's long-term context. + const isSubagent = isSubagentSession(sessionId); + const recallSessionKey = isSubagent ? undefined : sessionId; + + try { + // Use a larger candidate pool for recall, then filter down + const recallTopK = Math.max((cfg.topK ?? 5) * 2, 10); + + // Search long-term memories (user-scoped; subagents read from parent namespace) + let longTermResults = await provider.search( + event.prompt, + buildSearchOptions(undefined, recallTopK, undefined, recallSessionKey), + ); + + // Client-side threshold filter for auto-recall — use a stricter + // threshold (0.6) than explicit tool searches (0.5) to avoid + // injecting irrelevant memories into agent context + const recallThreshold = Math.max(cfg.searchThreshold, 0.6); + longTermResults = longTermResults.filter( + (r) => (r.score ?? 0) >= recallThreshold, + ); + + // Dynamic thresholding: drop memories scoring less than 50% of + // the top result's score to filter out the long tail of weak matches + if (longTermResults.length > 1) { + const topScore = longTermResults[0]?.score ?? 0; + if (topScore > 0) { + longTermResults = longTermResults.filter( + (r) => (r.score ?? 0) >= topScore * 0.5, + ); + } + } + + // For short/generic prompts or new sessions, broaden recall + // with a general query to avoid cold-start blindness. + // Use a lower threshold (0.5) since the generic query is + // intentionally broad and strict thresholds defeat the purpose. + if (event.prompt.length < 100 || isNewSession) { + const broadOpts = buildSearchOptions(undefined, 5, undefined, recallSessionKey); + broadOpts.threshold = 0.5; + const broadResults = await provider.search( + "recent decisions, preferences, active projects, and configuration", + broadOpts, + ); + const existingIds = new Set(longTermResults.map((r) => r.id)); + for (const r of broadResults) { + if (!existingIds.has(r.id)) { + longTermResults.push(r); + } + } + } + + // Cap at configured topK after filtering + longTermResults = longTermResults.slice(0, cfg.topK); + + // Search session memories (session-scoped) if we have a session ID + let sessionResults: MemoryItem[] = []; + if (sessionId) { + sessionResults = await provider.search( + event.prompt, + buildSearchOptions(undefined, undefined, sessionId, recallSessionKey), + ); + sessionResults = sessionResults.filter( + (r) => (r.score ?? 0) >= cfg.searchThreshold, + ); + } + + // Deduplicate session results against long-term + const longTermIds = new Set(longTermResults.map((r) => r.id)); + const uniqueSessionResults = sessionResults.filter( + (r) => !longTermIds.has(r.id), + ); + + if (longTermResults.length === 0 && uniqueSessionResults.length === 0) return; + + // Build context with clear labels + let memoryContext = ""; + if (longTermResults.length > 0) { + memoryContext += longTermResults + .map( + (r) => + `- ${r.memory}${r.categories?.length ? ` [${r.categories.join(", ")}]` : ""}`, + ) + .join("\n"); + } + if (uniqueSessionResults.length > 0) { + if (memoryContext) memoryContext += "\n"; + memoryContext += "\nSession memories:\n"; + memoryContext += uniqueSessionResults + .map((r) => `- ${r.memory}`) + .join("\n"); + } + + const totalCount = longTermResults.length + uniqueSessionResults.length; + api.logger.info( + `openclaw-mem0: injecting ${totalCount} memories into context (${longTermResults.length} long-term, ${uniqueSessionResults.length} session)`, + ); + + const preamble = isSubagent + ? `The following are stored memories for user "${cfg.userId}". You are a subagent — use these memories for context but do not assume you are this user.` + : `The following are stored memories for user "${cfg.userId}". Use them to personalize your response:`; + + return { + prependContext: `\n${preamble}\n${memoryContext}\n`, + }; + } catch (err) { + api.logger.warn(`openclaw-mem0: recall failed: ${String(err)}`); + } + }); + } + + // Auto-capture: store conversation context after agent ends + if (cfg.autoCapture) { + api.on("agent_end", async (event, ctx) => { + if (!event.success || !event.messages || event.messages.length === 0) { + return; + } + + // Skip non-interactive triggers (cron, heartbeat, automation) + const trigger = (ctx as any)?.trigger ?? undefined; + const sessionId = (ctx as any)?.sessionKey ?? undefined; + if (isNonInteractiveTrigger(trigger, sessionId)) { + api.logger.info("openclaw-mem0: skipping capture for non-interactive trigger"); + return; + } + + // Skip capture for subagents — their ephemeral UUIDs create orphaned + // namespaces that are never read again. The main agent's agent_end + // hook captures the consolidated result including subagent output. + if (isSubagentSession(sessionId)) { + api.logger.info("openclaw-mem0: skipping capture for subagent (main agent captures consolidated result)"); + return; + } + + // Update shared state for tools (best-effort — tools don't have ctx) + if (sessionId) session.setCurrentSessionId(sessionId); + + try { + // Patterns indicating an assistant message contains a summary of + // completed work — these are high-value for extraction and should + // be included even if they fall outside the recent-message window. + const SUMMARY_PATTERNS = [ + /## What I (Accomplished|Built|Updated)/i, + /✅\s*(Done|Complete|All done)/i, + /Here's (what I updated|the recap|a summary)/i, + /### Changes Made/i, + /Implementation Status/i, + /All locked in\. Quick summary/i, + ]; + + // First pass: extract all messages into a typed array + const allParsed: Array<{ + role: string; + content: string; + index: number; + isSummary: boolean; + }> = []; + + for (let i = 0; i < event.messages.length; i++) { + const msg = event.messages[i]; + if (!msg || typeof msg !== "object") continue; + const msgObj = msg as Record; + + const role = msgObj.role; + if (role !== "user" && role !== "assistant") continue; + + let textContent = ""; + const content = msgObj.content; + + if (typeof content === "string") { + textContent = content; + } else if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === "object" && + "text" in block && + typeof (block as Record).text === "string" + ) { + textContent += + (textContent ? "\n" : "") + + ((block as Record).text as string); + } + } + } + + if (!textContent) continue; + // Strip injected memory context, keep the actual user text + if (textContent.includes("")) { + textContent = textContent.replace(/[\s\S]*?<\/relevant-memories>\s*/g, "").trim(); + if (!textContent) continue; + } + + const isSummary = + role === "assistant" && + SUMMARY_PATTERNS.some((p) => p.test(textContent)); + + allParsed.push({ + role: role as string, + content: textContent, + index: i, + isSummary, + }); + } + + if (allParsed.length === 0) return; + + // Select messages: last 20 + any earlier summary messages, + // sorted by original index to preserve chronological order. + const recentWindow = 20; + const recentCutoff = allParsed.length - recentWindow; + + const candidates: typeof allParsed = []; + + // Include summary messages from anywhere in the conversation + for (const msg of allParsed) { + if (msg.isSummary && msg.index < recentCutoff) { + candidates.push(msg); + } + } + + // Include recent messages + const seenIndices = new Set(candidates.map((m) => m.index)); + for (const msg of allParsed) { + if (msg.index >= recentCutoff && !seenIndices.has(msg.index)) { + candidates.push(msg); + } + } + + // Sort by original position so the extraction model sees + // messages in the order they actually occurred + candidates.sort((a, b) => a.index - b.index); + + const selected = candidates.map((m) => ({ + role: m.role, + content: m.content, + })); + + // Apply noise filtering pipeline: drop noise, strip fragments, truncate + const formattedMessages = filterMessagesForExtraction(selected); + + if (formattedMessages.length === 0) return; + + // Skip if no meaningful user content remains after filtering + if (!formattedMessages.some((m) => m.role === "user")) return; + + // Inject a timestamp preamble so the extraction model can anchor + // time-sensitive facts to a concrete date and attribute to the correct user + const timestamp = new Date().toISOString().split("T")[0]; + formattedMessages.unshift({ + role: "system", + content: `Current date: ${timestamp}. The user is identified as "${cfg.userId}". Extract durable facts from this conversation. Include this date when storing time-sensitive information.`, + }); + + const addOpts = buildAddOptions(undefined, sessionId, sessionId); + const result = await provider.add( + formattedMessages, + addOpts, + ); + + const capturedCount = result.results?.length ?? 0; + if (capturedCount > 0) { + api.logger.info( + `openclaw-mem0: auto-captured ${capturedCount} memories`, + ); + } + } catch (err) { + api.logger.warn(`openclaw-mem0: capture failed: ${String(err)}`); + } + }); + } +} + export default memoryPlugin; diff --git a/openclaw/isolation.ts b/openclaw/isolation.ts new file mode 100644 index 000000000..6a42d7098 --- /dev/null +++ b/openclaw/isolation.ts @@ -0,0 +1,101 @@ +/** + * Per-agent memory isolation helpers. + * + * Multi-agent setups write/read from separate userId namespaces + * automatically via sessionKey routing. + */ + +// ============================================================================ +// Trigger filtering — skip non-interactive sessions +// ============================================================================ + +/** + * Triggers that should NOT run autocapture/autorecall. + * These are system-initiated sessions (cron jobs, heartbeats, automation + * pipelines) whose prompts would pollute the user's memory store. + */ +const SKIP_TRIGGERS = new Set(["cron", "heartbeat", "automation", "schedule"]); + +/** + * Returns true if the session trigger is non-interactive and memory + * hooks should be skipped entirely. + * + * Also detects cron-style session keys (e.g. "agent:main:cron:") + * as a fallback when the trigger field is not set. + */ +export function isNonInteractiveTrigger( + trigger: string | undefined, + sessionKey: string | undefined, +): boolean { + if (trigger && SKIP_TRIGGERS.has(trigger.toLowerCase())) return true; + + // Fallback: detect cron/heartbeat from the session key pattern + if (sessionKey) { + if (/:cron:/i.test(sessionKey) || /:heartbeat:/i.test(sessionKey)) return true; + } + + return false; +} + +/** + * Returns true if the session key indicates a subagent (ephemeral) session. + * Subagent UUIDs are random per-spawn, so their namespaces are always empty + * on recall and orphaned after capture. + */ +export function isSubagentSession(sessionKey: string | undefined): boolean { + if (!sessionKey) return false; + return /:subagent:/i.test(sessionKey); +} + +/** + * Parse an agent ID from a session key. + * + * OpenClaw session key formats: + * - Main agent: "agent:main:main" + * - Subagent: "agent:main:subagent:" + * - Named agent: "agent::" + * + * Returns the subagent UUID for subagent sessions, the agentId for + * non-"main" named agents, or undefined for the main agent session. + */ +export function extractAgentId(sessionKey: string | undefined): string | undefined { + if (!sessionKey) return undefined; + + // Check for subagent pattern: "agent::subagent:" + const subagentMatch = sessionKey.match(/:subagent:([^:]+)$/); + if (subagentMatch?.[1]) return `subagent-${subagentMatch[1]}`; + + // Check for named agent pattern: "agent::" + const match = sessionKey.match(/^agent:([^:]+):/); + const agentId = match?.[1]; + // "main" is the primary session — fall back to configured userId + if (!agentId || agentId === "main") return undefined; + return agentId; +} + +/** + * Derive the effective user_id from a session key, namespacing per-agent. + * Falls back to baseUserId when the session is not agent-scoped. + */ +export function effectiveUserId(baseUserId: string, sessionKey?: string): string { + const agentId = extractAgentId(sessionKey); + return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId; +} + +/** Build a user_id for an explicit agentId (e.g. from tool params). */ +export function agentUserId(baseUserId: string, agentId: string): string { + return `${baseUserId}:agent:${agentId}`; +} + +/** + * Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured. + */ +export function resolveUserId( + baseUserId: string, + opts: { agentId?: string; userId?: string }, + currentSessionId?: string, +): string { + if (opts.agentId) return agentUserId(baseUserId, opts.agentId); + if (opts.userId) return opts.userId; + return effectiveUserId(baseUserId, currentSessionId); +} diff --git a/openclaw/package.json b/openclaw/package.json index 7eee981e7..0b28792fc 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/openclaw-mem0", - "version": "0.3.3", + "version": "0.4.0", "type": "module", "description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source", "license": "Apache-2.0", @@ -29,7 +29,7 @@ }, "dependencies": { "@sinclair/typebox": "0.34.47", - "mem0ai": "^2.2.1" + "mem0ai": "^2.3.0" }, "openclaw": { "extensions": [ diff --git a/openclaw/pnpm-lock.yaml b/openclaw/pnpm-lock.yaml index 80b2f3ade..fa6c23b34 100644 --- a/openclaw/pnpm-lock.yaml +++ b/openclaw/pnpm-lock.yaml @@ -12,7 +12,7 @@ importers: specifier: 0.34.47 version: 0.34.47 mem0ai: - specifier: ^2.2.1 + specifier: ^2.3.0 version: 2.4.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0) devDependencies: '@types/node': diff --git a/openclaw/providers.ts b/openclaw/providers.ts new file mode 100644 index 000000000..45ef8f069 --- /dev/null +++ b/openclaw/providers.ts @@ -0,0 +1,307 @@ +/** + * Mem0 provider implementations: Platform (cloud) and OSS (self-hosted). + */ + +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { + Mem0Config, + Mem0Provider, + AddOptions, + SearchOptions, + ListOptions, + MemoryItem, + AddResult, +} from "./types.ts"; + +// ============================================================================ +// Result Normalizers +// ============================================================================ + +function normalizeMemoryItem(raw: any): MemoryItem { + return { + id: raw.id ?? raw.memory_id ?? "", + memory: raw.memory ?? raw.text ?? raw.content ?? "", + // Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names + user_id: raw.user_id ?? raw.userId, + score: raw.score, + categories: raw.categories, + metadata: raw.metadata, + created_at: raw.created_at ?? raw.createdAt, + updated_at: raw.updated_at ?? raw.updatedAt, + }; +} + +function normalizeSearchResults(raw: any): MemoryItem[] { + // Platform API returns flat array, OSS returns { results: [...] } + if (Array.isArray(raw)) return raw.map(normalizeMemoryItem); + if (raw?.results && Array.isArray(raw.results)) + return raw.results.map(normalizeMemoryItem); + return []; +} + +function normalizeAddResult(raw: any): AddResult { + // Handle { results: [...] } shape (both platform and OSS) + if (raw?.results && Array.isArray(raw.results)) { + return { + results: raw.results.map((r: any) => ({ + id: r.id ?? r.memory_id ?? "", + memory: r.memory ?? r.text ?? "", + // Platform API may return PENDING status (async processing) + // OSS stores event in metadata.event + event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), + })), + }; + } + // Platform API without output_format returns flat array + if (Array.isArray(raw)) { + return { + results: raw.map((r: any) => ({ + id: r.id ?? r.memory_id ?? "", + memory: r.memory ?? r.text ?? "", + event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), + })), + }; + } + return { results: [] }; +} + +// ============================================================================ +// Platform Provider (Mem0 Cloud) +// ============================================================================ + +class PlatformProvider implements Mem0Provider { + private client: any; // MemoryClient from mem0ai + private initPromise: Promise | null = null; + + constructor( + private readonly apiKey: string, + private readonly orgId?: string, + private readonly projectId?: string, + ) { } + + private async ensureClient(): Promise { + if (this.client) return; + if (this.initPromise) return this.initPromise; + this.initPromise = this._init().catch((err) => { + this.initPromise = null; + throw err; + }); + return this.initPromise; + } + + private async _init(): Promise { + const { default: MemoryClient } = await import("mem0ai"); + const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey }; + if (this.orgId) opts.org_id = this.orgId; + if (this.projectId) opts.project_id = this.projectId; + this.client = new MemoryClient(opts); + } + + async add( + messages: Array<{ role: string; content: string }>, + options: AddOptions, + ): Promise { + await this.ensureClient(); + const opts: Record = { user_id: options.user_id }; + if (options.run_id) opts.run_id = options.run_id; + if (options.custom_instructions) + opts.custom_instructions = options.custom_instructions; + if (options.custom_categories) + opts.custom_categories = options.custom_categories; + if (options.enable_graph) opts.enable_graph = options.enable_graph; + if (options.output_format) opts.output_format = options.output_format; + if (options.source) opts.source = options.source; + + const result = await this.client.add(messages, opts); + return normalizeAddResult(result); + } + + async search(query: string, options: SearchOptions): Promise { + await this.ensureClient(); + const filters: Record = { user_id: options.user_id }; + if (options.run_id) filters.run_id = options.run_id; + + const opts: Record = { + api_version: "v2", + filters, + }; + if (options.top_k != null) opts.top_k = options.top_k; + if (options.threshold != null) opts.threshold = options.threshold; + if (options.keyword_search != null) opts.keyword_search = options.keyword_search; + if (options.reranking != null) opts.rerank = options.reranking; + + const results = await this.client.search(query, opts); + return normalizeSearchResults(results); + } + + async get(memoryId: string): Promise { + await this.ensureClient(); + const result = await this.client.get(memoryId); + return normalizeMemoryItem(result); + } + + async getAll(options: ListOptions): Promise { + await this.ensureClient(); + const opts: Record = { user_id: options.user_id }; + if (options.run_id) opts.run_id = options.run_id; + if (options.page_size != null) opts.page_size = options.page_size; + if (options.source) opts.source = options.source; + + const results = await this.client.getAll(opts); + if (Array.isArray(results)) return results.map(normalizeMemoryItem); + // Some versions return { results: [...] } + if (results?.results && Array.isArray(results.results)) + return results.results.map(normalizeMemoryItem); + return []; + } + + async delete(memoryId: string): Promise { + await this.ensureClient(); + await this.client.delete(memoryId); + } +} + +// ============================================================================ +// Open-Source Provider (Self-hosted) +// ============================================================================ + +class OSSProvider implements Mem0Provider { + private memory: any; // Memory from mem0ai/oss + private initPromise: Promise | null = null; + + constructor( + private readonly ossConfig?: Mem0Config["oss"], + private readonly customPrompt?: string, + private readonly resolvePath?: (p: string) => string, + ) { } + + private async ensureMemory(): Promise { + if (this.memory) return; + if (this.initPromise) return this.initPromise; + this.initPromise = this._init().catch((err) => { + this.initPromise = null; + throw err; + }); + return this.initPromise; + } + + private async _init(): Promise { + const { Memory } = await import("mem0ai/oss"); + + const config: Record = { version: "v1.1" }; + + if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder; + if (this.ossConfig?.vectorStore) + config.vectorStore = this.ossConfig.vectorStore; + if (this.ossConfig?.llm) config.llm = this.ossConfig.llm; + + if (this.ossConfig?.historyDbPath) { + const dbPath = this.resolvePath + ? this.resolvePath(this.ossConfig.historyDbPath) + : this.ossConfig.historyDbPath; + config.historyDbPath = dbPath; + } + + if (this.ossConfig?.disableHistory) { + config.disableHistory = true; + } + + if (this.customPrompt) config.customPrompt = this.customPrompt; + + try { + this.memory = new Memory(config); + } catch (err) { + // If initialization fails (e.g. native SQLite binding resolution under + // jiti), retry with history disabled — the history DB is the most common + // source of native-binding failures and is not required for core + // memory operations. + if (!config.disableHistory) { + console.warn( + "[mem0] Memory initialization failed, retrying with history disabled:", + err instanceof Error ? err.message : err, + ); + config.disableHistory = true; + this.memory = new Memory(config); + } else { + throw err; + } + } + } + + async add( + messages: Array<{ role: string; content: string }>, + options: AddOptions, + ): Promise { + await this.ensureMemory(); + // OSS SDK uses camelCase: userId/runId, not user_id/run_id + const addOpts: Record = { userId: options.user_id }; + if (options.run_id) addOpts.runId = options.run_id; + if (options.source) addOpts.source = options.source; + const result = await this.memory.add(messages, addOpts); + return normalizeAddResult(result); + } + + async search(query: string, options: SearchOptions): Promise { + await this.ensureMemory(); + // OSS SDK uses camelCase: userId/runId, not user_id/run_id + const opts: Record = { userId: options.user_id }; + if (options.run_id) opts.runId = options.run_id; + if (options.limit != null) opts.limit = options.limit; + else if (options.top_k != null) opts.limit = options.top_k; + if (options.keyword_search != null) opts.keyword_search = options.keyword_search; + if (options.reranking != null) opts.reranking = options.reranking; + if (options.source) opts.source = options.source; + if (options.threshold != null) opts.threshold = options.threshold; + + const results = await this.memory.search(query, opts); + const normalized = normalizeSearchResults(results); + + // Filter results by threshold if specified (client-side filtering as fallback) + if (options.threshold != null) { + return normalized.filter(item => (item.score ?? 0) >= options.threshold!); + } + + return normalized; + } + + async get(memoryId: string): Promise { + await this.ensureMemory(); + const result = await this.memory.get(memoryId); + return normalizeMemoryItem(result); + } + + async getAll(options: ListOptions): Promise { + await this.ensureMemory(); + // OSS SDK uses camelCase: userId/runId, not user_id/run_id + const getAllOpts: Record = { userId: options.user_id }; + if (options.run_id) getAllOpts.runId = options.run_id; + if (options.source) getAllOpts.source = options.source; + const results = await this.memory.getAll(getAllOpts); + if (Array.isArray(results)) return results.map(normalizeMemoryItem); + if (results?.results && Array.isArray(results.results)) + return results.results.map(normalizeMemoryItem); + return []; + } + + async delete(memoryId: string): Promise { + await this.ensureMemory(); + await this.memory.delete(memoryId); + } +} + +// ============================================================================ +// Provider Factory +// ============================================================================ + +export function createProvider( + cfg: Mem0Config, + api: OpenClawPluginApi, +): Mem0Provider { + if (cfg.mode === "open-source") { + return new OSSProvider(cfg.oss, cfg.customPrompt, (p) => + api.resolvePath(p), + ); + } + + return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId); +} diff --git a/openclaw/tsconfig.json b/openclaw/tsconfig.json index 97496a947..2b2ef9d76 100644 --- a/openclaw/tsconfig.json +++ b/openclaw/tsconfig.json @@ -15,8 +15,10 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "verbatimModuleSyntax": true + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "noEmit": true }, - "include": ["index.ts", "openclaw-plugin-sdk.d.ts"], + "include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts"], "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/openclaw/types.ts b/openclaw/types.ts new file mode 100644 index 000000000..c93d42296 --- /dev/null +++ b/openclaw/types.ts @@ -0,0 +1,91 @@ +/** + * Shared type definitions for the OpenClaw Mem0 plugin. + */ + +export type Mem0Mode = "platform" | "open-source"; + +export type Mem0Config = { + mode: Mem0Mode; + // Platform-specific + apiKey?: string; + orgId?: string; + projectId?: string; + customInstructions: string; + customCategories: Record; + enableGraph: boolean; + // OSS-specific + customPrompt?: string; + oss?: { + embedder?: { provider: string; config: Record }; + vectorStore?: { provider: string; config: Record }; + llm?: { provider: string; config: Record }; + historyDbPath?: string; + disableHistory?: boolean; + }; + // Shared + userId: string; + autoCapture: boolean; + autoRecall: boolean; + searchThreshold: number; + topK: number; +}; + +export interface AddOptions { + user_id: string; + run_id?: string; + custom_instructions?: string; + custom_categories?: Array>; + enable_graph?: boolean; + output_format?: string; + source?: string; +} + +export interface SearchOptions { + user_id: string; + run_id?: string; + top_k?: number; + threshold?: number; + limit?: number; + keyword_search?: boolean; + reranking?: boolean; + source?: string; +} + +export interface ListOptions { + user_id: string; + run_id?: string; + page_size?: number; + source?: string; +} + +export interface MemoryItem { + id: string; + memory: string; + user_id?: string; + score?: number; + categories?: string[]; + metadata?: Record; + created_at?: string; + updated_at?: string; +} + +export interface AddResultItem { + id: string; + memory: string; + event: "ADD" | "UPDATE" | "DELETE" | "NOOP"; +} + +export interface AddResult { + results: AddResultItem[]; +} + +export interface Mem0Provider { + add( + messages: Array<{ role: string; content: string }>, + options: AddOptions, + ): Promise; + search(query: string, options: SearchOptions): Promise; + get(memoryId: string): Promise; + getAll(options: ListOptions): Promise; + delete(memoryId: string): Promise; +} From 0c4d0290cbb2fe86d5561f57dd3e7971e08ce0c2 Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Thu, 19 Mar 2026 13:42:18 +0530 Subject: [PATCH 10/12] fix(docs): add redirect rules for legacy and moved documentation pages (#4413) --- docs/api-reference.mdx | 8 +- docs/docs.json | 192 ++++++++++++++++++++++ docs/llms.txt | 8 +- docs/migration/oss-to-platform.mdx | 2 +- docs/templates/api_reference_template.mdx | 2 +- docs/templates/feature_guide_template.mdx | 4 +- skills/mem0/scripts/mem0_doc_search.py | 2 +- 7 files changed, 205 insertions(+), 13 deletions(-) diff --git a/docs/api-reference.mdx b/docs/api-reference.mdx index 0f31c61c9..e162d8301 100644 --- a/docs/api-reference.mdx +++ b/docs/api-reference.mdx @@ -20,8 +20,8 @@ Mem0 provides a comprehensive REST API for integrating advanced memory capabilit Get started with Mem0 API in three simple steps: 1. **[Add Memories](/api-reference/memory/add-memories)** - Store information and context from user conversations -2. **[Search Memories](/api-reference/memory/v2-search-memories)** - Retrieve relevant memories using semantic search -3. **[Get Memories](/api-reference/memory/v2-get-memories)** - Fetch all memories for a specific entity +2. **[Search Memories](/api-reference/memory/search-memories)** - Retrieve relevant memories using semantic search +3. **[Get Memories](/api-reference/memory/get-memories)** - Fetch all memories for a specific entity --- @@ -32,7 +32,7 @@ Get started with Mem0 API in three simple steps: Store new memories from conversations and interactions - + Find relevant memories using semantic search with filters @@ -102,7 +102,7 @@ Get your API key from the [Mem0 Dashboard](https://app.mem0.ai/dashboard/api-key Start storing memories via the REST API - + Learn advanced search and filtering techniques diff --git a/docs/docs.json b/docs/docs.json index 125649fa6..84b83e3b7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -859,6 +859,198 @@ { "source": "/features/memory-export", "destination": "/platform/features/memory-export" + }, + { + "source": "/v0x/components/:a/:b/:c", + "destination": "/components/:a/:b/:c" + }, + { + "source": "/v0x/components/:a/:b", + "destination": "/components/:a/:b" + }, + { + "source": "/v0x/core-concepts/:a/:b", + "destination": "/core-concepts/:a/:b" + }, + { + "source": "/v0x/integrations/:slug", + "destination": "/integrations/:slug" + }, + { + "source": "/v0x/open-source/:slug", + "destination": "/open-source/:slug" + }, + { + "source": "/v0x/introduction", + "destination": "/introduction" + }, + { + "source": "/features/async-client", + "destination": "/platform/features/async-client" + }, + { + "source": "/features/custom-prompts", + "destination": "/platform/features/platform-overview" + }, + { + "source": "/features/selective-memory", + "destination": "/platform/features/platform-overview" + }, + { + "source": "/features/custom-categories", + "destination": "/platform/features/custom-categories" + }, + { + "source": "/components/config", + "destination": "/open-source/configuration" + }, + { + "source": "/concepts/memory-scoring", + "destination": "/core-concepts/memory-types" + }, + { + "source": "/cookbooks/research-copilot", + "destination": "/cookbooks/operations/deep-research" + }, + { + "source": "/platform/features/organizations-projects", + "destination": "/api-reference/organizations-projects" + }, + { + "source": "/playground", + "destination": "/platform/quickstart" + }, + { + "source": "/cdn-cgi/l/email-protection", + "destination": "/introduction" + }, + { + "source": "/features/online-memory", + "destination": "/platform/features/platform-overview" + }, + { + "source": "/features/multimodal", + "destination": "/platform/features/multimodal-support" + }, + { + "source": "/features/inferences", + "destination": "/platform/features/platform-overview" + }, + { + "source": "/features/graph-memory", + "destination": "/platform/features/graph-memory" + }, + { + "source": "/features/:slug", + "destination": "/platform/features/:slug" + }, + { + "source": "/platform/features/online-memory", + "destination": "/platform/features/platform-overview" + }, + { + "source": "/platform/features/multimodal", + "destination": "/platform/features/multimodal-support" + }, + { + "source": "/platform/features/inferences", + "destination": "/platform/features/platform-overview" + }, + { + "source": "/platform/features/custom-prompts", + "destination": "/platform/features/custom-instructions" + }, + { + "source": "/platform/features/rest-api", + "destination": "/open-source/features/rest-api" + }, + { + "source": "/components/embedders/models/google_ai", + "destination": "/components/embedders/models/google_AI" + }, + { + "source": "/components/embedders/models/lm_studio", + "destination": "/components/embedders/models/lmstudio" + }, + { + "source": "/components/llms/models/xai", + "destination": "/components/llms/models/xAI" + }, + { + "source": "/components/llms/models/google_ai", + "destination": "/components/llms/models/google_AI" + }, + { + "source": "/components/llms/models/mistral_ai", + "destination": "/components/llms/models/mistral_AI" + }, + { + "source": "/components/llms/models/lm_studio", + "destination": "/components/llms/models/lmstudio" + }, + { + "source": "/components/vectordbs/dbs/neptune-analytics", + "destination": "/components/vectordbs/dbs/neptune_analytics" + }, + { + "source": "/components/vectordbs/dbs/s3-vectors", + "destination": "/components/vectordbs/dbs/s3_vectors" + }, + { + "source": "/open-source/python_quickstart", + "destination": "/open-source/python-quickstart" + }, + { + "source": "/open-source/node_quickstart", + "destination": "/open-source/node-quickstart" + }, + { + "source": "/open-source/rest-api", + "destination": "/open-source/features/rest-api" + }, + { + "source": "/cookbooks/deep-research", + "destination": "/cookbooks/operations/deep-research" + }, + { + "source": "/v0x/overview", + "destination": "/platform/overview" + }, + { + "source": "/v0x/quickstart", + "destination": "/platform/quickstart" + }, + { + "source": "/v0x/faqs", + "destination": "/platform/faqs" + }, + { + "source": "/integrations/multion", + "destination": "/integrations" + }, + { + "source": "/integrations/composio", + "destination": "/integrations" + }, + { + "source": "/integrations/qdrant", + "destination": "/components/vectordbs/dbs/qdrant" + }, + { + "source": "/integrations/anthropic", + "destination": "/components/llms/models/anthropic" + }, + { + "source": "/llms", + "destination": "/components/llms/overview" + }, + { + "source": "/open-source/graph-memory", + "destination": "/open-source/features/graph-memory" + }, + { + "source": "/cookbooks/customer-support-agent", + "destination": "/cookbooks/operations/support-inbox" } ] } \ No newline at end of file diff --git a/docs/llms.txt b/docs/llms.txt index fed2454ad..1764c3f95 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -104,7 +104,7 @@ Key differentiators: - [Together](https://docs.mem0.ai/components/llms/models/together): Open-source model inference platform - [DeepSeek](https://docs.mem0.ai/components/llms/models/deepseek): Advanced reasoning models - [Sarvam](https://docs.mem0.ai/components/llms/models/sarvam): Indian language models -- [XAI](https://docs.mem0.ai/components/llms/models/xai): xAI models integration +- [XAI](https://docs.mem0.ai/components/llms/models/xAI): xAI models integration - [LiteLLM](https://docs.mem0.ai/components/llms/models/litellm): Unified LLM interface and proxy - [LangChain](https://docs.mem0.ai/components/llms/models/langchain): LangChain LLM integration - [OpenAI Structured](https://docs.mem0.ai/components/llms/models/openai_structured): OpenAI with structured output support @@ -120,7 +120,7 @@ Key differentiators: - [Milvus](https://docs.mem0.ai/components/vectordbs/dbs/milvus): Open-source vector database for AI applications at scale - [Redis](https://docs.mem0.ai/components/vectordbs/dbs/redis): Real-time vector storage and search with Redis Stack - [Supabase](https://docs.mem0.ai/components/vectordbs/dbs/supabase): Open-source Firebase alternative with vector support -- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash_vector): Serverless vector database +- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash-vector): Serverless vector database - [Elasticsearch](https://docs.mem0.ai/components/vectordbs/dbs/elasticsearch): Distributed search and analytics engine - [OpenSearch](https://docs.mem0.ai/components/vectordbs/dbs/opensearch): Open-source search and analytics platform - [FAISS](https://docs.mem0.ai/components/vectordbs/dbs/faiss): Facebook AI Similarity Search library @@ -136,9 +136,9 @@ Key differentiators: - [OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/openai): High-quality text embeddings with customizable dimensions - [Azure OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/azure_openai): Enterprise Azure-hosted embedding models -- [Google AI](https://docs.mem0.ai/components/embedders/models/google_ai): Gemini embedding models +- [Google AI](https://docs.mem0.ai/components/embedders/models/google_AI): Gemini embedding models - [AWS Bedrock](https://docs.mem0.ai/components/embedders/models/aws_bedrock): Amazon embedding models through Bedrock -- [Hugging Face](https://docs.mem0.ai/components/embedders/models/hugging_face): Open-source embedding models for local deployment +- [Hugging Face](https://docs.mem0.ai/components/embedders/models/huggingface): Open-source embedding models for local deployment - [Vertex AI](https://docs.mem0.ai/components/embedders/models/vertexai): Google Cloud's enterprise embedding models - [Ollama](https://docs.mem0.ai/components/embedders/models/ollama): Local embedding models for privacy-focused applications - [Together](https://docs.mem0.ai/components/embedders/models/together): Open-source model embeddings diff --git a/docs/migration/oss-to-platform.mdx b/docs/migration/oss-to-platform.mdx index e9efea36e..818059683 100644 --- a/docs/migration/oss-to-platform.mdx +++ b/docs/migration/oss-to-platform.mdx @@ -370,7 +370,7 @@ If you encounter issues, you can revert immediately by switching your import bac - [Platform Dashboard](https://app.mem0.ai) - Monitor usage and manage settings. - [Webhooks Setup](/platform/features/webhooks) - Configure real-time event notifications. -- [Organizations & Projects](/platform/features/organizations-projects) - Set up multi-tenancy for your team. +- [Organizations & Projects](/api-reference/organizations-projects) - Set up multi-tenancy for your team. - + Understand how Mem0 ranks memories under the hood. - + See advanced retrieval driving a full knowledge assistant. diff --git a/skills/mem0/scripts/mem0_doc_search.py b/skills/mem0/scripts/mem0_doc_search.py index 19b4c903f..1ff8de1c7 100755 --- a/skills/mem0/scripts/mem0_doc_search.py +++ b/skills/mem0/scripts/mem0_doc_search.py @@ -43,7 +43,7 @@ SECTION_MAP = { "/platform/features/v2-memory-filters", "/platform/features/async-client", "/platform/features/webhooks", - "/platform/features/multimodal", + "/platform/features/multimodal-support", ], "api": [ "/api-reference/memory/add-memories", From 348f44b6327ed77dcb8386b6f36d23e0fdd23480 Mon Sep 17 00:00:00 2001 From: Kartik Date: Thu, 19 Mar 2026 15:26:00 +0530 Subject: [PATCH 11/12] fix(reranker): support nested llm config in LLMReranker for non-OpenAI providers (#4405) --- mem0/configs/rerankers/llm.py | 8 +- mem0/reranker/llm_reranker.py | 39 +++-- tests/rerankers/conftest.py | 11 ++ tests/rerankers/test_llm_reranker_config.py | 63 +++++++ .../test_llm_reranker_nested_config.py | 154 ++++++++++++++++++ tests/rerankers/test_llm_reranker_rerank.py | 125 ++++++++++++++ 6 files changed, 385 insertions(+), 15 deletions(-) create mode 100644 tests/rerankers/conftest.py create mode 100644 tests/rerankers/test_llm_reranker_config.py create mode 100644 tests/rerankers/test_llm_reranker_nested_config.py create mode 100644 tests/rerankers/test_llm_reranker_rerank.py diff --git a/mem0/configs/rerankers/llm.py b/mem0/configs/rerankers/llm.py index e1475645c..de64818b5 100644 --- a/mem0/configs/rerankers/llm.py +++ b/mem0/configs/rerankers/llm.py @@ -1,4 +1,5 @@ -from typing import Optional +from typing import Any, Dict, Optional + from pydantic import Field from mem0.configs.rerankers.base import BaseRerankerConfig @@ -46,3 +47,8 @@ class LLMRerankerConfig(BaseRerankerConfig): default=None, description="Custom prompt template for scoring documents" ) + llm: Optional[Dict[str, Any]] = Field( + default=None, + description="Nested LLM configuration with 'provider' and 'config' keys. " + "Overrides top-level provider/model/api_key when provided.", + ) diff --git a/mem0/reranker/llm_reranker.py b/mem0/reranker/llm_reranker.py index d53f3c5fa..a474ea2d8 100644 --- a/mem0/reranker/llm_reranker.py +++ b/mem0/reranker/llm_reranker.py @@ -1,10 +1,10 @@ import re -from typing import List, Dict, Any, Union +from typing import Any, Dict, List, Union -from mem0.reranker.base import BaseReranker -from mem0.utils.factory import LlmFactory from mem0.configs.rerankers.base import BaseRerankerConfig from mem0.configs.rerankers.llm import LLMRerankerConfig +from mem0.reranker.base import BaseReranker +from mem0.utils.factory import LlmFactory class LLMReranker(BaseReranker): @@ -33,19 +33,30 @@ class LLMReranker(BaseReranker): self.config = config - # Create LLM configuration for the factory - llm_config = { - "model": self.config.model, - "temperature": self.config.temperature, - "max_tokens": self.config.max_tokens, - } - - # Add API key if provided - if self.config.api_key: - llm_config["api_key"] = self.config.api_key + # If a nested ``llm`` dict is provided (e.g. for non-OpenAI providers + # like Ollama that need provider-specific fields such as + # ``ollama_base_url``), use it to configure the LLM factory. + if self.config.llm: + nested = self.config.llm + llm_provider = nested.get("provider", self.config.provider) + llm_config: dict = dict(nested.get("config") or {}) + llm_config.setdefault("model", self.config.model) + llm_config.setdefault("temperature", self.config.temperature) + llm_config.setdefault("max_tokens", self.config.max_tokens) + if self.config.api_key: + llm_config.setdefault("api_key", self.config.api_key) + else: + llm_provider = self.config.provider + llm_config = { + "model": self.config.model, + "temperature": self.config.temperature, + "max_tokens": self.config.max_tokens, + } + if self.config.api_key: + llm_config["api_key"] = self.config.api_key # Initialize LLM using the factory - self.llm = LlmFactory.create(self.config.provider, llm_config) + self.llm = LlmFactory.create(llm_provider, llm_config) # Default scoring prompt self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt() diff --git a/tests/rerankers/conftest.py b/tests/rerankers/conftest.py new file mode 100644 index 000000000..258d959cb --- /dev/null +++ b/tests/rerankers/conftest.py @@ -0,0 +1,11 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_llm(): + with patch("mem0.reranker.llm_reranker.LlmFactory") as mock_factory: + mock_llm_instance = MagicMock() + mock_factory.create.return_value = mock_llm_instance + yield mock_factory, mock_llm_instance diff --git a/tests/rerankers/test_llm_reranker_config.py b/tests/rerankers/test_llm_reranker_config.py new file mode 100644 index 000000000..d3577a49b --- /dev/null +++ b/tests/rerankers/test_llm_reranker_config.py @@ -0,0 +1,63 @@ +from mem0.configs.rerankers.base import BaseRerankerConfig +from mem0.configs.rerankers.llm import LLMRerankerConfig +from mem0.reranker.llm_reranker import LLMReranker + + +class TestLLMRerankerConfig: + def test_default_config(self): + config = LLMRerankerConfig() + assert config.model == "gpt-4o-mini" + assert config.provider == "openai" + assert config.temperature == 0.0 + assert config.max_tokens == 100 + assert config.llm is None + assert config.scoring_prompt is None + assert config.top_k is None + + def test_nested_llm_field_accepted(self): + config = LLMRerankerConfig( + llm={"provider": "ollama", "config": {"ollama_base_url": "http://localhost:11434"}} + ) + assert config.llm["provider"] == "ollama" + assert config.llm["config"]["ollama_base_url"] == "http://localhost:11434" + + +class TestLLMRerankerInit: + def test_init_with_dict_config(self, mock_llm): + mock_factory, _ = mock_llm + reranker = LLMReranker({"provider": "openai", "model": "gpt-4o", "api_key": "sk-test"}) + + assert reranker.config.provider == "openai" + assert reranker.config.model == "gpt-4o" + mock_factory.create.assert_called_once_with( + "openai", + {"model": "gpt-4o", "temperature": 0.0, "max_tokens": 100, "api_key": "sk-test"}, + ) + + def test_init_with_llm_reranker_config(self, mock_llm): + mock_factory, _ = mock_llm + config = LLMRerankerConfig(provider="anthropic", model="claude-3-haiku", api_key="sk-ant") + reranker = LLMReranker(config) + + assert reranker.config.provider == "anthropic" + mock_factory.create.assert_called_once_with( + "anthropic", + {"model": "claude-3-haiku", "temperature": 0.0, "max_tokens": 100, "api_key": "sk-ant"}, + ) + + def test_init_converts_base_reranker_config(self, mock_llm): + mock_factory, _ = mock_llm + base_config = BaseRerankerConfig(provider="openai", model="gpt-4o-mini") + reranker = LLMReranker(base_config) + + assert isinstance(reranker.config, LLMRerankerConfig) + assert reranker.config.temperature == 0.0 + assert reranker.config.max_tokens == 100 + + def test_init_without_api_key(self, mock_llm): + mock_factory, _ = mock_llm + LLMReranker({"provider": "openai", "model": "gpt-4o-mini"}) + + call_args = mock_factory.create.call_args + llm_config = call_args[0][1] + assert "api_key" not in llm_config diff --git a/tests/rerankers/test_llm_reranker_nested_config.py b/tests/rerankers/test_llm_reranker_nested_config.py new file mode 100644 index 000000000..7017780ef --- /dev/null +++ b/tests/rerankers/test_llm_reranker_nested_config.py @@ -0,0 +1,154 @@ +from mem0.reranker.llm_reranker import LLMReranker + + +class TestNestedLLMConfig: + def test_nested_llm_overrides_provider(self, mock_llm): + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "model": "gpt-4o-mini", + "llm": { + "provider": "ollama", + "config": {"model": "llama3", "ollama_base_url": "http://localhost:11434"}, + }, + }) + + call_args = mock_factory.create.call_args + assert call_args[0][0] == "ollama" + + def test_nested_llm_passes_provider_specific_config(self, mock_llm): + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "llm": { + "provider": "ollama", + "config": { + "model": "llama3", + "ollama_base_url": "http://localhost:11434", + }, + }, + }) + + call_args = mock_factory.create.call_args + llm_config = call_args[0][1] + assert llm_config["ollama_base_url"] == "http://localhost:11434" + assert llm_config["model"] == "llama3" + + def test_nested_llm_inherits_top_level_defaults(self, mock_llm): + """Nested config should inherit temperature/max_tokens from top-level if not overridden.""" + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "temperature": 0.0, + "max_tokens": 100, + "llm": { + "provider": "ollama", + "config": {"model": "llama3"}, + }, + }) + + call_args = mock_factory.create.call_args + llm_config = call_args[0][1] + assert llm_config["temperature"] == 0.0 + assert llm_config["max_tokens"] == 100 + + def test_nested_llm_config_values_take_precedence(self, mock_llm): + """Values explicitly set in nested config should not be overridden by top-level defaults.""" + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "model": "gpt-4o-mini", + "temperature": 0.0, + "max_tokens": 100, + "llm": { + "provider": "ollama", + "config": { + "model": "custom-model", + "temperature": 0.5, + "max_tokens": 200, + }, + }, + }) + + call_args = mock_factory.create.call_args + llm_config = call_args[0][1] + assert llm_config["model"] == "custom-model" + assert llm_config["temperature"] == 0.5 + assert llm_config["max_tokens"] == 200 + + def test_nested_llm_falls_back_to_top_level_provider(self, mock_llm): + """If nested llm dict has no 'provider', use top-level provider.""" + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "anthropic", + "model": "claude-3-haiku", + "llm": { + "config": {"model": "claude-3-sonnet"}, + }, + }) + + call_args = mock_factory.create.call_args + assert call_args[0][0] == "anthropic" + assert call_args[0][1]["model"] == "claude-3-sonnet" + + def test_nested_llm_with_empty_config(self, mock_llm): + """Nested llm with no config dict should still work, using top-level defaults.""" + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "model": "gpt-4o-mini", + "llm": {"provider": "ollama"}, + }) + + call_args = mock_factory.create.call_args + assert call_args[0][0] == "ollama" + llm_config = call_args[0][1] + assert llm_config["model"] == "gpt-4o-mini" + assert llm_config["temperature"] == 0.0 + assert llm_config["max_tokens"] == 100 + + def test_nested_llm_with_none_config(self, mock_llm): + """Nested llm with config: None should still work, using top-level defaults.""" + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "model": "gpt-4o-mini", + "llm": {"provider": "ollama", "config": None}, + }) + + call_args = mock_factory.create.call_args + assert call_args[0][0] == "ollama" + llm_config = call_args[0][1] + assert llm_config["model"] == "gpt-4o-mini" + + def test_nested_llm_inherits_top_level_api_key(self, mock_llm): + """Top-level api_key should be inherited by nested config if not already set.""" + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "api_key": "sk-top-level", + "llm": { + "provider": "openai", + "config": {"model": "gpt-4o"}, + }, + }) + + call_args = mock_factory.create.call_args + llm_config = call_args[0][1] + assert llm_config["api_key"] == "sk-top-level" + + def test_nested_llm_config_api_key_not_overridden(self, mock_llm): + """If nested config already has api_key, top-level api_key should not override it.""" + mock_factory, _ = mock_llm + LLMReranker({ + "provider": "openai", + "api_key": "sk-top-level", + "llm": { + "provider": "openai", + "config": {"model": "gpt-4o", "api_key": "sk-nested"}, + }, + }) + + call_args = mock_factory.create.call_args + llm_config = call_args[0][1] + assert llm_config["api_key"] == "sk-nested" diff --git a/tests/rerankers/test_llm_reranker_rerank.py b/tests/rerankers/test_llm_reranker_rerank.py new file mode 100644 index 000000000..9e16f149a --- /dev/null +++ b/tests/rerankers/test_llm_reranker_rerank.py @@ -0,0 +1,125 @@ +import pytest + +from mem0.reranker.llm_reranker import LLMReranker + + +class TestExtractScore: + @pytest.fixture + def reranker(self, mock_llm): + return LLMReranker({"provider": "openai"}) + + @pytest.mark.parametrize( + "text,expected", + [ + ("0.85", 0.85), + ("0.0", 0.0), + ("1.0", 1.0), + ("The score is 0.72.", 0.72), + ("Score: 0.9 out of 1.0", 0.9), + ], + ) + def test_valid_scores(self, reranker, text, expected): + assert reranker._extract_score(text) == expected + + def test_no_score_returns_fallback(self, reranker): + assert reranker._extract_score("no numbers here") == 0.5 + + def test_clamps_to_1(self, reranker): + assert reranker._extract_score("1.0") == 1.0 + + +class TestRerank: + def test_empty_documents(self, mock_llm): + reranker = LLMReranker({"provider": "openai"}) + result = reranker.rerank("query", []) + assert result == [] + + def test_documents_sorted_by_score_descending(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.side_effect = ["0.3", "0.9", "0.6"] + + reranker = LLMReranker({"provider": "openai"}) + docs = [ + {"memory": "low relevance"}, + {"memory": "high relevance"}, + {"memory": "mid relevance"}, + ] + + result = reranker.rerank("test query", docs) + + assert len(result) == 3 + assert result[0]["rerank_score"] == 0.9 + assert result[1]["rerank_score"] == 0.6 + assert result[2]["rerank_score"] == 0.3 + + def test_top_k_limits_results(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.side_effect = ["0.9", "0.5", "0.1"] + + reranker = LLMReranker({"provider": "openai"}) + docs = [{"memory": f"doc{i}"} for i in range(3)] + + result = reranker.rerank("query", docs, top_k=2) + assert len(result) == 2 + + def test_config_top_k_used_when_arg_not_provided(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.side_effect = ["0.9", "0.5", "0.1"] + + reranker = LLMReranker({"provider": "openai", "top_k": 1}) + docs = [{"memory": f"doc{i}"} for i in range(3)] + + result = reranker.rerank("query", docs) + assert len(result) == 1 + + def test_text_field_extraction(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.return_value = "0.8" + + reranker = LLMReranker({"provider": "openai"}) + reranker.rerank("query", [{"text": "some text"}]) + + prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"] + assert "some text" in prompt_sent + + def test_content_field_extraction(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.return_value = "0.8" + + reranker = LLMReranker({"provider": "openai"}) + reranker.rerank("query", [{"content": "some content"}]) + + prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"] + assert "some content" in prompt_sent + + def test_fallback_score_on_llm_error(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.side_effect = RuntimeError("API error") + + reranker = LLMReranker({"provider": "openai"}) + result = reranker.rerank("query", [{"memory": "doc"}]) + + assert len(result) == 1 + assert result[0]["rerank_score"] == 0.5 + + def test_custom_scoring_prompt(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.return_value = "0.7" + + custom_prompt = "Rate this: query={query} doc={document}" + reranker = LLMReranker({"provider": "openai", "scoring_prompt": custom_prompt}) + reranker.rerank("my query", [{"memory": "my doc"}]) + + prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"] + assert prompt_sent == "Rate this: query=my query doc=my doc" + + def test_original_doc_not_mutated(self, mock_llm): + _, mock_llm_instance = mock_llm + mock_llm_instance.generate_response.return_value = "0.8" + + reranker = LLMReranker({"provider": "openai"}) + original_doc = {"memory": "test", "id": "123"} + result = reranker.rerank("query", [original_doc]) + + assert "rerank_score" not in original_doc + assert "rerank_score" in result[0] From a029cc9d43e87b3b4c017279caa98d12867ebad8 Mon Sep 17 00:00:00 2001 From: Kartik Date: Thu, 19 Mar 2026 15:26:16 +0530 Subject: [PATCH 12/12] fix: add LLM provider detection and defaults to memory config (#4400) --- docs/openmemory/quickstart.mdx | 32 ++++++ openmemory/README.md | 34 ++++++- openmemory/api/.env.example | 15 ++- openmemory/api/app/utils/memory.py | 157 +++++++++++++++++++++++++---- 4 files changed, 214 insertions(+), 24 deletions(-) diff --git a/docs/openmemory/quickstart.mdx b/docs/openmemory/quickstart.mdx index 8057df3a0..bf784c790 100644 --- a/docs/openmemory/quickstart.mdx +++ b/docs/openmemory/quickstart.mdx @@ -117,6 +117,38 @@ OPENAI_API_KEY=sk-xxx USER= # The User ID you want to associate the memories with ``` +#### LLM Configuration (optional) + +By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider by adding these variables to `/api/.env`: + +| Variable | Description | Default | +|---|---|---| +| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` | +| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) | +| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var | +| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default | +| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` | +| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` | +| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) | +| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var | +| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default | + +**Example: Using Ollama (fully local)** +```bash +LLM_PROVIDER=ollama +LLM_MODEL=llama3.1:latest +EMBEDDER_PROVIDER=ollama +EMBEDDER_MODEL=nomic-embed-text +OLLAMA_BASE_URL=http://localhost:11434 +``` + +**Example: Using Anthropic** +```bash +LLM_PROVIDER=anthropic +LLM_MODEL=claude-sonnet-4-20250514 +LLM_API_KEY=sk-ant-xxx +``` + #### Example `/ui/.env` ```bash NEXT_PUBLIC_API_URL=http://localhost:8765 diff --git a/openmemory/README.md b/openmemory/README.md index 2d3346f38..b327df730 100644 --- a/openmemory/README.md +++ b/openmemory/README.md @@ -66,7 +66,39 @@ You can do this in one of the following ways: ```env OPENAI_API_KEY=sk-xxx -USER= # The User Id you want to associate the memories with +USER= # The User Id you want to associate the memories with +``` + +- #### LLM Configuration (optional) + +By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider using these environment variables in `/api/.env`: + +| Variable | Description | Default | +|---|---|---| +| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` | +| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) | +| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var | +| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default | +| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` | +| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` | +| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) | +| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var | +| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default | + +**Example: Using Ollama (fully local)** +```env +LLM_PROVIDER=ollama +LLM_MODEL=llama3.1:latest +EMBEDDER_PROVIDER=ollama +EMBEDDER_MODEL=nomic-embed-text +OLLAMA_BASE_URL=http://localhost:11434 +``` + +**Example: Using Anthropic** +```env +LLM_PROVIDER=anthropic +LLM_MODEL=claude-sonnet-4-20250514 +LLM_API_KEY=sk-ant-xxx ``` - #### Example `/ui/.env` diff --git a/openmemory/api/.env.example b/openmemory/api/.env.example index 64c530733..21e32b7af 100644 --- a/openmemory/api/.env.example +++ b/openmemory/api/.env.example @@ -1,2 +1,15 @@ OPENAI_API_KEY=sk-xxx -USER=user \ No newline at end of file +USER=user + +# LLM Configuration (optional - defaults to openai/gpt-4o-mini) +# LLM_PROVIDER=ollama +# LLM_MODEL=llama3.1:latest +# LLM_API_KEY= +# LLM_BASE_URL= +# OLLAMA_BASE_URL=http://localhost:11434 + +# Embedder Configuration (optional - defaults to openai/text-embedding-3-small) +# EMBEDDER_PROVIDER=ollama +# EMBEDDER_MODEL=nomic-embed-text +# EMBEDDER_API_KEY= +# EMBEDDER_BASE_URL= diff --git a/openmemory/api/app/utils/memory.py b/openmemory/api/app/utils/memory.py index a4f557fe6..7afce595b 100644 --- a/openmemory/api/app/utils/memory.py +++ b/openmemory/api/app/utils/memory.py @@ -133,6 +133,97 @@ def reset_memory_client(): _config_hash = None +# --- LLM provider config factories --- + +def _build_ollama_llm_config(model, api_key, base_url, ollama_base_url): + config = {"model": model or "llama3.1:latest"} + # OLLAMA_BASE_URL takes precedence, then LLM_BASE_URL, then default + config["ollama_base_url"] = ollama_base_url or base_url or "http://localhost:11434" + return config + + +def _build_openai_llm_config(model, api_key, base_url, ollama_base_url): + config = { + "model": model or "gpt-4o-mini", + "api_key": api_key or "env:OPENAI_API_KEY", + } + if base_url: + config["openai_base_url"] = base_url + return config + + +_LLM_CONFIG_FACTORIES = { + "ollama": _build_ollama_llm_config, + "openai": _build_openai_llm_config, +} + + +def _create_llm_config(provider, model, api_key, base_url, ollama_base_url): + """Build LLM config using registered provider factory or generic fallback.""" + base_config = { + "temperature": 0.1, + "max_tokens": 2000, + } + + factory = _LLM_CONFIG_FACTORIES.get(provider) + if factory: + base_config.update(factory(model, api_key, base_url, ollama_base_url)) + else: + # Generic provider (anthropic, groq, together, deepseek, etc.) + if not model: + raise ValueError( + f"LLM_MODEL environment variable is required when using LLM_PROVIDER='{provider}'. " + f"Set LLM_MODEL to a valid model name for the '{provider}' provider." + ) + base_config["model"] = model + if api_key: + base_config["api_key"] = api_key + + return base_config + + +# --- Embedder provider config factories --- + +def _build_ollama_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url): + config = {"model": model or "nomic-embed-text"} + config["ollama_base_url"] = base_url or ollama_base_url or llm_base_url or "http://localhost:11434" + return config + + +def _build_openai_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url): + config = { + "model": model or "text-embedding-3-small", + "api_key": api_key or "env:OPENAI_API_KEY", + } + if base_url: + config["openai_base_url"] = base_url + return config + + +_EMBEDDER_CONFIG_FACTORIES = { + "ollama": _build_ollama_embedder_config, + "openai": _build_openai_embedder_config, +} + + +def _create_embedder_config(provider, model, api_key, base_url, ollama_base_url, llm_base_url): + """Build embedder config using registered provider factory or generic fallback.""" + factory = _EMBEDDER_CONFIG_FACTORIES.get(provider) + if factory: + config = factory(model, api_key, base_url, ollama_base_url, llm_base_url) + else: + if not model: + raise ValueError( + f"EMBEDDER_MODEL environment variable is required when using EMBEDDER_PROVIDER='{provider}'. " + f"Set EMBEDDER_MODEL to a valid model name for the '{provider}' provider." + ) + config = {"model": model} + if api_key: + config["api_key"] = api_key + + return config + + def get_default_memory_config(): """Get default memory client configuration with sensible defaults.""" # Detect vector store based on environment variables @@ -235,27 +326,51 @@ def get_default_memory_config(): }) print(f"Auto-detected vector store: {vector_store_provider} with config: {vector_store_config}") - + + # Detect LLM provider from environment variables + llm_provider = os.environ.get('LLM_PROVIDER', 'openai').lower() + llm_model = os.environ.get('LLM_MODEL') + llm_api_key = os.environ.get('LLM_API_KEY') + llm_base_url = os.environ.get('LLM_BASE_URL') + ollama_base_url = os.environ.get('OLLAMA_BASE_URL') + + llm_config = _create_llm_config( + provider=llm_provider, + model=llm_model, + api_key=llm_api_key, + base_url=llm_base_url, + ollama_base_url=ollama_base_url, + ) + print(f"Auto-detected LLM provider: {llm_provider}") + + # Detect embedder provider from environment variables + embedder_provider = os.environ.get('EMBEDDER_PROVIDER', llm_provider if llm_provider == 'ollama' else 'openai').lower() + embedder_model = os.environ.get('EMBEDDER_MODEL') + embedder_api_key = os.environ.get('EMBEDDER_API_KEY') + embedder_base_url = os.environ.get('EMBEDDER_BASE_URL') + + embedder_config = _create_embedder_config( + provider=embedder_provider, + model=embedder_model, + api_key=embedder_api_key, + base_url=embedder_base_url, + ollama_base_url=ollama_base_url, + llm_base_url=llm_base_url, + ) + print(f"Auto-detected embedder provider: {embedder_provider}") + return { "vector_store": { "provider": vector_store_provider, "config": vector_store_config }, "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini", - "temperature": 0.1, - "max_tokens": 2000, - "api_key": "env:OPENAI_API_KEY" - } + "provider": llm_provider, + "config": llm_config }, "embedder": { - "provider": "openai", - "config": { - "model": "text-embedding-3-small", - "api_key": "env:OPENAI_API_KEY" - } + "provider": embedder_provider, + "config": embedder_config }, "version": "v1.1" } @@ -327,18 +442,10 @@ def get_memory_client(custom_instructions: str = None): # Update LLM configuration if available if "llm" in mem0_config and mem0_config["llm"] is not None: config["llm"] = mem0_config["llm"] - - # Fix Ollama URLs for Docker if needed - if config["llm"].get("provider") == "ollama": - config["llm"] = _fix_ollama_urls(config["llm"]) - + # Update Embedder configuration if available if "embedder" in mem0_config and mem0_config["embedder"] is not None: config["embedder"] = mem0_config["embedder"] - - # Fix Ollama URLs for Docker if needed - if config["embedder"].get("provider") == "ollama": - config["embedder"] = _fix_ollama_urls(config["embedder"]) if "vector_store" in mem0_config and mem0_config["vector_store"] is not None: config["vector_store"] = mem0_config["vector_store"] @@ -357,6 +464,12 @@ def get_memory_client(custom_instructions: str = None): if instructions_to_use: config["custom_fact_extraction_prompt"] = instructions_to_use + # Fix Ollama URLs for Docker environment (applies to both env-var defaults and DB overrides) + if config.get("llm", {}).get("provider") == "ollama": + config["llm"] = _fix_ollama_urls(config["llm"]) + if config.get("embedder", {}).get("provider") == "ollama": + config["embedder"] = _fix_ollama_urls(config["embedder"]) + # ALWAYS parse environment variables in the final config # This ensures that even default config values like "env:OPENAI_API_KEY" get parsed print("Parsing environment variables in final config...")