Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac9b473a9 | ||
|
|
ce05224bad |
@@ -1,100 +0,0 @@
|
||||
name: openclaw checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'openclaw/**'
|
||||
- '.github/workflows/openclaw-checks.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openclaw/**'
|
||||
- '.github/workflows/openclaw-checks.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: cd openclaw && pnpm exec tsc --noEmit
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd openclaw && pnpm exec vitest run --coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.node-version == 20
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
flags: openclaw
|
||||
directory: openclaw/coverage
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd openclaw && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: cd openclaw && pnpm build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
@@ -1,75 +0,0 @@
|
||||
name: TypeScript SDK CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'mem0-ts/**'
|
||||
- '.github/workflows/ts-sdk-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'mem0-ts/**'
|
||||
|
||||
jobs:
|
||||
check_changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ts_sdk_changed: ${{ steps.filter.outputs.ts_sdk }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
ts_sdk:
|
||||
- 'mem0-ts/**'
|
||||
|
||||
build_ts_sdk:
|
||||
needs: check_changes
|
||||
if: needs.check_changes.outputs.ts_sdk_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: mem0-ts/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mem0-ts
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
working-directory: mem0-ts
|
||||
run: npx prettier --check .
|
||||
|
||||
- name: Build
|
||||
working-directory: mem0-ts
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run tests
|
||||
working-directory: mem0-ts
|
||||
run: pnpm run test:ci
|
||||
|
||||
- name: Verify package exports
|
||||
working-directory: mem0-ts
|
||||
run: |
|
||||
node -e "const m = require('./dist/index.js'); console.log('Client exports:', Object.keys(m).length)"
|
||||
node -e "const m = require('./dist/oss/index.js'); console.log('OSS exports:', Object.keys(m).length)"
|
||||
|
||||
- name: Upload coverage
|
||||
if: matrix.node-version == 20
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: mem0-ts/coverage/
|
||||
@@ -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/search-memories)** - Retrieve relevant memories using semantic search
|
||||
3. **[Get Memories](/api-reference/memory/get-memories)** - Fetch all memories for a specific entity
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -32,7 +32,7 @@ Get started with Mem0 API in three simple steps:
|
||||
Store new memories from conversations and interactions
|
||||
</Card>
|
||||
|
||||
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/search-memories">
|
||||
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/v2-search-memories">
|
||||
Find relevant memories using semantic search with filters
|
||||
</Card>
|
||||
|
||||
@@ -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
|
||||
</Card>
|
||||
|
||||
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/search-memories">
|
||||
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/v2-search-memories">
|
||||
Learn advanced search and filtering techniques
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -7,27 +7,6 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-03-16" description="v1.0.6">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Fixed telemetry vector store initialization still running when `MEM0_TELEMETRY` is disabled (#4351)
|
||||
- **Core:** Removed destructive `vector_store.reset()` call from `delete_all()` that was wiping the entire vector store instead of deleting only the target memories (#4349)
|
||||
- **OSS:** `OllamaLLM` now respects the configured URL instead of always falling back to localhost (#4320)
|
||||
- **Core:** Fixed `KeyError` when LLM omits the `entities` key in tool call response (#4313)
|
||||
- **Prompts:** Ensured JSON instruction is included in prompts when using `json_object` response format (#4271)
|
||||
- **Core:** Fixed incorrect database parameter handling (#3913)
|
||||
|
||||
**Dependencies:**
|
||||
- Updated LangChain dependencies to v1.0.0 (#4353)
|
||||
- Bumped protobuf dependency to 5.29.6 and extended upper bound to `<7.0.0` (#4326)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-03" description="v1.0.5">
|
||||
- **Telemetry Fix**
|
||||
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
|
||||
</Update>
|
||||
|
||||
<Update label="2026-02-17" description="v1.0.4">
|
||||
|
||||
**New Features & Updates:**
|
||||
@@ -745,46 +724,6 @@ mode: "wide"
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-03-16" description="v2.4.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** Fixed code block content extraction — content inside code blocks is now properly extracted instead of being deleted (#4317)
|
||||
|
||||
**Improvements:**
|
||||
- **Code Quality:** Fixed linting issues across the SDK (#4334)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-14" description="v2.4.0">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **OSS Storage:** Fixed `SQLITE_CANTOPEN` errors when running as a LaunchAgent, systemd service, or in containers where `process.cwd()` is read-only (e.g. `/`). Default `vector_store.db` location changed from `process.cwd()/vector_store.db` to `~/.mem0/vector_store.db`.
|
||||
- **OSS Storage:** Fixed `historyDbPath` config being silently ignored — config merging always overwrote it with defaults. Top-level `historyDbPath` is now correctly propagated into `historyStore.config` with proper precedence.
|
||||
- **OSS Storage:** Added `ensureSQLiteDirectory()` — parent directories for SQLite database files are now auto-created before opening, preventing `SQLITE_CANTOPEN` when using nested paths.
|
||||
|
||||
**Improvements:**
|
||||
- **Migration:** Added deprecation warning when an existing `vector_store.db` is found at the old `process.cwd()` location, guiding users to move it or set `vectorStore.config.dbPath` explicitly.
|
||||
- **Config:** Limited default SQLite config spreading to only SQLite history providers, preventing config leaking into Supabase or other providers.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-03-09" description="v2.3.0">
|
||||
|
||||
**Breaking Changes:**
|
||||
- **Dependencies:** Minimum Node.js version for OSS sqlite features is now Node 20+ (due to `better-sqlite3` v12)
|
||||
|
||||
**Bug Fixes:**
|
||||
- **OSS Storage:** Replaced `sqlite3` with `better-sqlite3` to fix native binding resolution failures under jiti-based loaders (e.g. OpenClaw plugin system). Fixes issues where the `bindings` module walked V8 stack frames with synthetic filenames, failing to locate the native `.node` addon.
|
||||
- **OSS Storage:** Fixed async init race condition in `SQLiteManager` — `init()` is now synchronous
|
||||
- **OSS Vector Store:** Migrated `MemoryVectorStore` from `sqlite3` to `better-sqlite3` with transactional batch inserts
|
||||
|
||||
**Improvements:**
|
||||
- **Performance:** Cached prepared statements in `SQLiteManager` for faster history operations
|
||||
- **Performance:** Batch `insert()` in `MemoryVectorStore` wrapped in a transaction for atomicity
|
||||
- **Build:** Updated `tsup.config.ts` externals from `sqlite3` to `better-sqlite3`
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-02-17" description="v2.2.3">
|
||||
|
||||
**New Features & Updates:**
|
||||
|
||||
@@ -1,766 +0,0 @@
|
||||
---
|
||||
title: MiroFish Swarm Memory
|
||||
description: "Build a multi-agent swarm simulation with graph-powered memory using Mem0 and MiroFish patterns."
|
||||
---
|
||||
|
||||
<Snippet file="blank-notif.mdx" />
|
||||
|
||||
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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## 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://<your-instance>.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")
|
||||
```
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
### 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=...)` | |
|
||||
|
||||
<Note>
|
||||
**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.
|
||||
</Note>
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
<Note>
|
||||
Exact output varies as Mem0 automatically extracts and deduplicates entities. The specific relations and memory counts depend on LLM extraction quality.
|
||||
</Note>
|
||||
|
||||
## 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
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Graph Memory" icon="network-wired" href="/open-source/features/graph-memory">
|
||||
Full Graph Memory documentation with provider setup.
|
||||
</Card>
|
||||
<Card title="MiroFish GitHub" icon="fish" href="https://github.com/666ghj/MiroFish">
|
||||
MiroFish source code and setup guide.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -107,12 +107,6 @@ client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Delete all memories for a specific user
|
||||
client.delete_all(user_id="alice")
|
||||
|
||||
# Delete all memories for a specific agent
|
||||
client.delete_all(agent_id="support-bot")
|
||||
|
||||
# Delete all memories for a specific run
|
||||
client.delete_all(run_id="session-xyz")
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
@@ -133,48 +127,7 @@ You can also filter by other parameters such as:
|
||||
- `metadata` (as JSON string)
|
||||
|
||||
<Warning>
|
||||
**Breaking change:** `delete_all` previously wiped all project memories when called with no filters. It now **raises an error** if no filters are provided. Use `"*"` wildcards for intentional bulk deletion (see below).
|
||||
</Warning>
|
||||
|
||||
### Wildcard deletes
|
||||
|
||||
Setting a filter to `"*"` deletes **all memories** for that entity type across the entire project. This is an intentionally explicit opt-in to bulk deletion.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Delete all memories across every user in the project
|
||||
client.delete_all(user_id="*")
|
||||
|
||||
# Delete all memories across every agent in the project
|
||||
client.delete_all(agent_id="*")
|
||||
|
||||
# Full project wipe — all four filters must be explicitly set to "*"
|
||||
client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
import MemoryClient from 'mem0ai';
|
||||
|
||||
const client = new MemoryClient({ apiKey: "your-api-key" });
|
||||
|
||||
// Delete all memories across every user in the project
|
||||
client.deleteAll({ user_id: "*" })
|
||||
.then(result => console.log(result))
|
||||
.catch(error => console.error(error));
|
||||
|
||||
// Full project wipe — all four filters must be explicitly set to "*"
|
||||
client.deleteAll({ user_id: "*", agent_id: "*", app_id: "*", run_id: "*" })
|
||||
.then(result => console.log(result))
|
||||
.catch(error => console.error(error));
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Warning>
|
||||
A full project wipe requires **all four** filters set to `"*"`. Setting only some to `"*"` deletes memories only for those entity types, not the entire project.
|
||||
`delete_all` requires at least one filter (user, agent, run, or metadata). Calling it with no filters raises an error to prevent accidental data loss.
|
||||
</Warning>
|
||||
|
||||
## Delete with Mem0 OSS
|
||||
|
||||
+1
-194
@@ -375,8 +375,7 @@
|
||||
"cookbooks/frameworks/multimodal-retrieval",
|
||||
"cookbooks/frameworks/eliza-os-character",
|
||||
"cookbooks/frameworks/chrome-extension",
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp",
|
||||
"cookbooks/frameworks/mirofish-swarm-memory"
|
||||
"cookbooks/frameworks/gemini-3-with-mem0-mcp"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -859,198 +858,6 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -114,7 +114,7 @@ class MemoryEnabledAgent(Agent):
|
||||
logger.info("About to await mem0_client.search for RAG context")
|
||||
search_results = await mem0_client.search(
|
||||
new_message.text_content,
|
||||
filters={"user_id": RAG_USER_ID},
|
||||
user_id=RAG_USER_ID,
|
||||
)
|
||||
logger.info(f"mem0_client.search returned: {search_results}")
|
||||
if search_results and search_results.get('results', []):
|
||||
|
||||
@@ -25,20 +25,6 @@ openclaw plugins install @mem0/openclaw-mem0
|
||||
|
||||
## Setup and Configuration
|
||||
|
||||
### Understanding `userId`
|
||||
|
||||
The `userId` field is a **string you choose** to uniquely identify the user whose memories are being stored. It is **not** something you look up in the Mem0 dashboard — you define it yourself.
|
||||
|
||||
Pick any stable, unique identifier for the user. Common choices:
|
||||
|
||||
- Your application's internal user ID (e.g. `"user_123"`, `"alice@example.com"`)
|
||||
- A UUID (e.g. `"550e8400-e29b-41d4-a716-446655440000"`)
|
||||
- A simple username (e.g. `"alice"`)
|
||||
|
||||
All memories are scoped to this `userId` — different values create separate memory namespaces. If you don't set it, it defaults to `"default"`, which means all users share the same memory space.
|
||||
|
||||
<Tip>In a multi-user application, set `userId` dynamically per user (e.g. from your auth system) rather than hardcoding a single value.</Tip>
|
||||
|
||||
### Platform Mode (Mem0 Cloud)
|
||||
|
||||
<Note>Get your API key from [app.mem0.ai](https://app.mem0.ai).</Note>
|
||||
@@ -51,7 +37,7 @@ Add to your `openclaw.json`:
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice" // any unique identifier you choose for this user
|
||||
"userId": "your-user-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -65,7 +51,7 @@ No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"mode": "open-source",
|
||||
"userId": "alice" // any unique identifier you choose for this user
|
||||
"userId": "your-user-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+4
-4
@@ -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/huggingface): Open-source embedding models for local deployment
|
||||
- [Hugging Face](https://docs.mem0.ai/components/embedders/models/hugging_face): 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
|
||||
|
||||
@@ -255,28 +255,13 @@ def delete(
|
||||
|
||||
### delete_all() Method
|
||||
|
||||
#### Breaking Change — Empty filter no longer silently deletes everything
|
||||
|
||||
**Before:** calling `delete_all()` with no filters silently deleted **all memories in the project**.
|
||||
|
||||
**After:**
|
||||
- No filters → raises a validation error (prevents accidental full-project wipe).
|
||||
- Concrete ID (e.g. `user_id="alice"`) → deletes memories for that entity (unchanged).
|
||||
- `"*"` for a filter → deletes all memories for that entity type across the project (new).
|
||||
- All four filters set to `"*"` → explicit full project wipe (new, requires opt-in on every parameter).
|
||||
|
||||
This change replaces the silent full-project delete (triggered by an empty or missing filter) with a validation error, and introduces `"*"` wildcards as the intentional path for bulk deletion.
|
||||
|
||||
#### No Breaking Changes
|
||||
```python
|
||||
# v0.x — no filter silently wiped all project memories
|
||||
m.delete_all() # DANGER: deleted everything
|
||||
m.delete_all(user_id="alice") # deleted alice's memories
|
||||
|
||||
# v1.x — no filter now raises an error; use "*" for intentional bulk deletes
|
||||
m.delete_all() # ERROR: at least one filter required
|
||||
m.delete_all(user_id="alice") # unchanged
|
||||
m.delete_all(user_id="*") # NEW — delete all users' memories
|
||||
m.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") # NEW — full project wipe
|
||||
# Same signature in both versions
|
||||
def delete_all(
|
||||
self,
|
||||
user_id: str
|
||||
) -> dict
|
||||
```
|
||||
|
||||
## Platform Client (MemoryClient) Changes
|
||||
|
||||
@@ -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](/api-reference/organizations-projects) - Set up multi-tenancy for your team.
|
||||
- [Organizations & Projects](/platform/features/organizations-projects) - Set up multi-tenancy for your team.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
|
||||
@@ -168,6 +168,6 @@ memory = Memory.from_config_file("config.yaml")
|
||||
title="Deploy with Docker Compose"
|
||||
description="Follow the end-to-end OSS deployment walkthrough."
|
||||
icon="server"
|
||||
href="/open-source/features/rest-api"
|
||||
href="/cookbooks/companions/local-companion-ollama"
|
||||
/>
|
||||
</CardGroup>
|
||||
|
||||
@@ -46,7 +46,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
|
||||
</CardGroup>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Deploy with Docker Compose" icon="server" href="/open-source/features/rest-api">
|
||||
<Card title="Deploy with Docker Compose" icon="server" href="/cookbooks/companions/local-companion-ollama">
|
||||
Reference deployment with REST endpoints.
|
||||
</Card>
|
||||
<Card title="Use the REST API" icon="code" href="/open-source/features/rest-api">
|
||||
|
||||
+13
-13
@@ -1180,7 +1180,7 @@
|
||||
"tags": [
|
||||
"memories"
|
||||
],
|
||||
"description": "Delete memories by filter. At least one filter is required — previously omitting all filters silently deleted everything; now it returns a validation error.",
|
||||
"description": "Delete memories.",
|
||||
"operationId": "memories_delete",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1189,7 +1189,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by user ID. Pass `*` to delete memories for all users."
|
||||
"description": "Filter memories by user ID."
|
||||
},
|
||||
{
|
||||
"name": "agent_id",
|
||||
@@ -1197,7 +1197,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by agent ID. Pass `*` to delete memories for all agents."
|
||||
"description": "Filter memories by agent ID."
|
||||
},
|
||||
{
|
||||
"name": "app_id",
|
||||
@@ -1205,7 +1205,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by app ID. Pass `*` to delete memories for all apps."
|
||||
"description": "Filter memories by app ID."
|
||||
},
|
||||
{
|
||||
"name": "run_id",
|
||||
@@ -1213,7 +1213,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by run ID. Pass `*` to delete memories for all runs."
|
||||
"description": "Filter memories by run ID."
|
||||
},
|
||||
{
|
||||
"name": "metadata",
|
||||
@@ -1263,15 +1263,15 @@
|
||||
"x-code-samples": [
|
||||
{
|
||||
"lang": "Python",
|
||||
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe — all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss."
|
||||
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")"
|
||||
},
|
||||
{
|
||||
"lang": "JavaScript",
|
||||
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe — all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
|
||||
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
|
||||
},
|
||||
{
|
||||
"lang": "cURL",
|
||||
"source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=<user_id>' \\\n --header 'Authorization: Token <api-key>'\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token <api-key>'\n\n# Full project wipe — all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token <api-key>'"
|
||||
"source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/memories/ \\\n --header 'Authorization: Token <api-key>'"
|
||||
},
|
||||
{
|
||||
"lang": "Go",
|
||||
@@ -5570,26 +5570,26 @@
|
||||
},
|
||||
"DeleteMemoriesInput": {
|
||||
"type": "object",
|
||||
"description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.",
|
||||
"description": "Input for deleting memories associated with a specific user, agent, app, or run.",
|
||||
"properties": {
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "User ID to delete memories for. Pass \"*\" for all users.",
|
||||
"description": "The unique identifier of the user whose memories should be deleted.",
|
||||
"nullable": true
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "Agent ID to delete memories for. Pass \"*\" for all agents.",
|
||||
"description": "The unique identifier of the agent whose memories should be deleted.",
|
||||
"nullable": true
|
||||
},
|
||||
"app_id": {
|
||||
"type": "string",
|
||||
"description": "App ID to delete memories for. Pass \"*\" for all apps.",
|
||||
"description": "The unique identifier of the application whose memories should be deleted.",
|
||||
"nullable": true
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"description": "Run ID to delete memories for. Pass \"*\" for all runs.",
|
||||
"description": "The unique identifier of the run whose memories should be deleted.",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -117,38 +117,6 @@ OPENAI_API_KEY=sk-xxx
|
||||
USER=<user-id> # 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
|
||||
|
||||
@@ -123,10 +123,6 @@ await client.deleteAll({ user_id: "alice" });
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required — calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`.
|
||||
</Note>
|
||||
|
||||
### History
|
||||
|
||||
Get the history of a specific memory asynchronously.
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ const response = await fetch("https://api.mem0.ai/v1/memories", {
|
||||
|
||||
## Sample workflow
|
||||
|
||||
- [Build a Customer Support Agent](/cookbooks/operations/support-inbox)
|
||||
- [Build a Customer Support Agent](/cookbooks/customer-support-agent)
|
||||
|
||||
{/* DEBUG: verify CTA targets */}
|
||||
|
||||
|
||||
+2
-2
@@ -124,10 +124,10 @@ Walk through a real request/response. Include sample payloads and highlight nota
|
||||
{/* DEBUG: verify CTA targets */}
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/memory-types">
|
||||
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/concepts/memory-scoring">
|
||||
Understand how Mem0 ranks memories under the hood.
|
||||
</Card>
|
||||
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/operations/deep-research">
|
||||
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/research-copilot">
|
||||
See advanced retrieval driving a full knowledge assistant.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.db
|
||||
.env
|
||||
.env.*
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
pnpm-lock.yaml
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "2.4.1",
|
||||
"version": "2.2.3",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -37,7 +37,6 @@
|
||||
"start": "pnpm run example memory",
|
||||
"example": "ts-node src/oss/examples/vector-stores/index.ts",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --coverage --ci",
|
||||
"test:ts": "jest --config jest.config.js",
|
||||
"test:watch": "jest --config jest.config.js --watch",
|
||||
"format": "npm run clean && prettier --write .",
|
||||
@@ -78,7 +77,6 @@
|
||||
"author": "Deshraj Yadav",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.7.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -93,7 +91,7 @@
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.7.7",
|
||||
"openai": "^4.93.0",
|
||||
"uuid": "9.0.1",
|
||||
"zod": "^3.24.1"
|
||||
@@ -104,19 +102,20 @@
|
||||
"@azure/search-documents": "^12.0.0",
|
||||
"@cloudflare/workers-types": "^4.20250504.0",
|
||||
"@google/genai": "^1.2.0",
|
||||
"@langchain/core": "^1.0.0",
|
||||
"@langchain/core": "^0.3.44",
|
||||
"@mistralai/mistralai": "^1.5.2",
|
||||
"@qdrant/js-client-rest": "1.13.0",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/pg": "8.11.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"@types/sqlite3": "3.1.11",
|
||||
"cloudflare": "^4.2.0",
|
||||
"groq-sdk": "0.3.0",
|
||||
"neo4j-driver": "^5.28.1",
|
||||
"ollama": "^0.5.14",
|
||||
"pg": "8.11.3",
|
||||
"redis": "^4.6.13"
|
||||
"redis": "^4.6.13",
|
||||
"sqlite3": "5.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -128,7 +127,7 @@
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"better-sqlite3"
|
||||
"sqlite3"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+820
-119
File diff suppressed because it is too large
Load Diff
@@ -18,26 +18,9 @@ export type {
|
||||
AllUsers,
|
||||
User,
|
||||
FeedbackPayload,
|
||||
Feedback,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
export { Feedback, WebhookEvent } from "./mem0.types";
|
||||
|
||||
// Export the main client
|
||||
export { MemoryClient };
|
||||
export default MemoryClient;
|
||||
|
||||
// Export structured exceptions
|
||||
export {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
} from "../common/exceptions";
|
||||
|
||||
export type { MemoryErrorOptions } from "../common/exceptions";
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
GetMemoryExportPayload,
|
||||
} from "./mem0.types";
|
||||
import { captureClientEvent, generateHash } from "./telemetry";
|
||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||
|
||||
class APIError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -156,7 +155,7 @@ export default class MemoryClient {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw createExceptionFromResponse(response.status, errorData);
|
||||
throw new APIError(`API request failed: ${errorData}`);
|
||||
}
|
||||
const jsonResponse = await response.json();
|
||||
return jsonResponse;
|
||||
@@ -201,8 +200,8 @@ export default class MemoryClient {
|
||||
if (project_id && !this.projectId) this.projectId = project_id;
|
||||
if (user_email) this.telemetryId = user_email;
|
||||
} catch (error: any) {
|
||||
// Pass through structured exceptions and APIError
|
||||
if (error instanceof MemoryError || error instanceof APIError) {
|
||||
// Convert generic errors to APIError with meaningful messages
|
||||
if (error instanceof APIError) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new APIError(
|
||||
@@ -311,7 +310,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("get_all", [payloadKeys]);
|
||||
const { api_version, page, page_size, ...otherOptions } = options ?? {};
|
||||
const { api_version, page, page_size, ...otherOptions } = options!;
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
otherOptions.org_name = this.organizationName;
|
||||
otherOptions.project_name = this.projectName;
|
||||
@@ -362,7 +361,7 @@ export default class MemoryClient {
|
||||
this._validateOrgProject();
|
||||
const payloadKeys = Object.keys(options || {});
|
||||
this._captureEvent("search", [payloadKeys]);
|
||||
const { api_version, ...otherOptions } = options ?? {};
|
||||
const { api_version, ...otherOptions } = options!;
|
||||
const payload = { query, ...otherOptions };
|
||||
if (this.organizationName != null && this.projectName != null) {
|
||||
payload.org_name = this.organizationName;
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface PromptUpdatePayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export enum WebhookEvent {
|
||||
enum WebhookEvent {
|
||||
MEMORY_ADDED = "memory_add",
|
||||
MEMORY_UPDATED = "memory_update",
|
||||
MEMORY_DELETED = "memory_delete",
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Test helpers for MemoryClient unit tests.
|
||||
* Provides mock fetch, factory functions, and constants.
|
||||
*/
|
||||
|
||||
// ─── Mock Fetch ──────────────────────────────────────────
|
||||
|
||||
interface MockResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock fetch function that matches URL patterns to responses.
|
||||
* Patterns are matched using string includes, sorted longest-first
|
||||
* so more specific routes (e.g. /v1/memories/search/) win over
|
||||
* broader ones (e.g. /v1/memories/) regardless of insertion order.
|
||||
*/
|
||||
export function createMockFetch(
|
||||
responses: Map<string, MockResponse>,
|
||||
): jest.Mock {
|
||||
return jest.fn(
|
||||
async (url: string | URL | Request, _options?: RequestInit) => {
|
||||
const urlStr =
|
||||
typeof url === "string"
|
||||
? url
|
||||
: url instanceof URL
|
||||
? url.toString()
|
||||
: url.url;
|
||||
|
||||
// Sort patterns longest-first so specific routes match before broad ones
|
||||
const sortedPatterns = [...responses.entries()].sort(
|
||||
(a, b) => b[0].length - a[0].length,
|
||||
);
|
||||
|
||||
for (const [pattern, response] of sortedPatterns) {
|
||||
if (urlStr.includes(pattern)) {
|
||||
return {
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
statusText: response.status === 200 ? "OK" : "Error",
|
||||
json: async () => response.body,
|
||||
text: async () =>
|
||||
typeof response.body === "string"
|
||||
? response.body
|
||||
: JSON.stringify(response.body),
|
||||
} as Response;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: async () => ({ error: "Not found" }),
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Factory Functions ───────────────────────────────────
|
||||
|
||||
export interface MockMemory {
|
||||
id: string;
|
||||
memory?: string;
|
||||
data?: { memory: string } | null;
|
||||
event?: string;
|
||||
user_id?: string;
|
||||
agent_id?: string | null;
|
||||
app_id?: string | null;
|
||||
run_id?: string | null;
|
||||
hash?: string;
|
||||
categories?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
score?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
export function createMockMemory(
|
||||
overrides: Partial<MockMemory> = {},
|
||||
): MockMemory {
|
||||
return {
|
||||
id: "mem_test_123",
|
||||
memory: "Test memory content",
|
||||
user_id: "user_test",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
categories: [],
|
||||
metadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockMemoryHistory {
|
||||
id: string;
|
||||
memory_id: string;
|
||||
input: Array<{ role: string; content: string }>;
|
||||
old_memory: string | null;
|
||||
new_memory: string | null;
|
||||
user_id: string;
|
||||
categories: string[];
|
||||
event: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function createMockMemoryHistory(
|
||||
overrides: Partial<MockMemoryHistory> = {},
|
||||
): MockMemoryHistory {
|
||||
return {
|
||||
id: "hist_test_123",
|
||||
memory_id: "mem_test_123",
|
||||
input: [{ role: "user", content: "test" }],
|
||||
old_memory: null,
|
||||
new_memory: "Test memory",
|
||||
user_id: "user_test",
|
||||
categories: [],
|
||||
event: "ADD",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total_memories: number;
|
||||
owner: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function createMockUser(overrides: Partial<MockUser> = {}): MockUser {
|
||||
return {
|
||||
id: "user_123",
|
||||
name: "test_user",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
total_memories: 5,
|
||||
owner: "owner_123",
|
||||
type: "user",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockAllUsers {
|
||||
count: number;
|
||||
results: MockUser[];
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
export function createMockAllUsers(users: MockUser[] = []): MockAllUsers {
|
||||
return {
|
||||
count: users.length,
|
||||
results: users,
|
||||
next: null,
|
||||
previous: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
export const TEST_API_KEY = "test-api-key-12345";
|
||||
export const TEST_HOST = "https://api.test.mem0.ai";
|
||||
export const TEST_ORG_ID = "org_test_123";
|
||||
export const TEST_PROJECT_ID = "proj_test_456";
|
||||
|
||||
export const MOCK_PING_RESPONSE = {
|
||||
status: "ok",
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
user_email: "test@example.com",
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a standard set of mock responses for common MemoryClient operations.
|
||||
* Returns a Map that can be extended with additional patterns before passing to createMockFetch.
|
||||
*/
|
||||
export function createStandardMockResponses(): Map<string, MockResponse> {
|
||||
const responses = new Map<string, MockResponse>();
|
||||
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
|
||||
return responses;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — batchUpdate, batchDelete.
|
||||
* Tests verify payload transformation (memoryId → memory_id, string → object).
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── batchUpdate() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchUpdate()", () => {
|
||||
test("sends PUT to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([{ memoryId: "mem_1", text: "updated 1" }]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("transforms memoryId to memory_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([
|
||||
{ memoryId: "mem_1", text: "updated 1" },
|
||||
{ memoryId: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memories).toEqual([
|
||||
{ memory_id: "mem_1", text: "updated 1" },
|
||||
{ memory_id: "mem_2", text: "updated 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchUpdate([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "PUT");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── batchDelete() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - batchDelete()", () => {
|
||||
test("sends DELETE to /v1/batch/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1"]);
|
||||
|
||||
expect(findFetchCall(mock, "/v1/batch/", "DELETE")).toBeDefined();
|
||||
});
|
||||
|
||||
test("wraps string IDs into {memory_id} objects", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete(["mem_1", "mem_2", "mem_3"]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([
|
||||
{ memory_id: "mem_1" },
|
||||
{ memory_id: "mem_2" },
|
||||
{ memory_id: "mem_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/batch/", { status: 200, body: { message: "OK" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.batchDelete([]);
|
||||
|
||||
const call = findFetchCall(mock, "/v1/batch/", "DELETE");
|
||||
expect(getFetchBody(call!).memories).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,380 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — add, get, getAll, update, delete, deleteAll, history.
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory, MemoryHistory } from "../mem0.types";
|
||||
import {
|
||||
createMockMemory,
|
||||
createMockMemoryHistory,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── add() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - add()", () => {
|
||||
test("sends POST to /v1/memories/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "Hello" }], { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes messages in request body", async () => {
|
||||
const messages = [{ role: "user" as const, content: "Hello, I am Alex" }];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add(messages, { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual(messages);
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([{ role: "user", content: "test" }], {
|
||||
user_id: "user_1",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("user_1");
|
||||
});
|
||||
|
||||
test("attaches org_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("attaches project_id from constructor to payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.add([{ role: "user", content: "test" }], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sends empty messages array without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.add([], { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/", "POST");
|
||||
expect(getFetchBody(call!).messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - get()", () => {
|
||||
test("sends GET to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("throws on 404 with error message from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/nonexistent/", {
|
||||
status: 404,
|
||||
body: "Memory not found",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("nonexistent")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getAll()", () => {
|
||||
test("uses v2 POST endpoint when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("uses v1 GET endpoint by default with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("appends page and page_size to URL as query params", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getAll({
|
||||
user_id: "u1",
|
||||
api_version: "v2",
|
||||
page: 2,
|
||||
page_size: 25,
|
||||
});
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("page="),
|
||||
);
|
||||
expect(call![0]).toContain("page=2");
|
||||
expect(call![0]).toContain("page_size=25");
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.getAll();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - update()", () => {
|
||||
test("sends PUT to /v1/memories/:id/ with text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { text: "Updated text" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(call).toBeDefined();
|
||||
expect(getFetchBody(call!).text).toBe("Updated text");
|
||||
});
|
||||
|
||||
test("sends metadata in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { metadata: { priority: "high" } });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).metadata).toEqual({ priority: "high" });
|
||||
});
|
||||
|
||||
test("sends timestamp in PUT body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", { timestamp: 1710600000 });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
expect(getFetchBody(call!).timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("includes all fields when text + metadata + timestamp provided", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: createMockMemory(),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem_123", {
|
||||
text: "Updated",
|
||||
metadata: { source: "test" },
|
||||
timestamp: 1710600000,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.text).toBe("Updated");
|
||||
expect(body.metadata).toEqual({ source: "test" });
|
||||
expect(body.timestamp).toBe(1710600000);
|
||||
});
|
||||
|
||||
test("throws when no fields provided", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.update("mem_123", {})).rejects.toThrow(
|
||||
"At least one of text, metadata, or timestamp must be provided",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - delete()", () => {
|
||||
test("sends DELETE to /v1/memories/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/", {
|
||||
status: 200,
|
||||
body: { message: "Memory deleted successfully" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.delete("mem_123");
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/memories/mem_123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteAll()", () => {
|
||||
test("sends DELETE to /v1/memories/ with user_id as query param", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "u1" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=u1");
|
||||
});
|
||||
|
||||
test("URL-encodes special characters in user_id", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/", { status: 200, body: { message: "Deleted" } });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteAll({ user_id: "user@email.com" });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/?") && c[1]?.method === "DELETE",
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("user_id=");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - history()", () => {
|
||||
test("sends GET to /v1/memories/:id/history/", async () => {
|
||||
const historyEntries = [
|
||||
createMockMemoryHistory({
|
||||
memory_id: "mem_123",
|
||||
event: "ADD",
|
||||
old_memory: null,
|
||||
new_memory: "I am Alex",
|
||||
}),
|
||||
];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", {
|
||||
status: 200,
|
||||
body: historyEntries,
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.history("mem_123");
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_123/history/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles empty history without crashing", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_123/history/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: MemoryHistory[] = await client.history("mem_123");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — constructor, validation, ping.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
MemoryNotFoundError,
|
||||
ValidationError,
|
||||
MemoryError,
|
||||
} from "../../common/exceptions";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_HOST,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
installConsoleSuppression,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Initialization", () => {
|
||||
beforeEach(() => setupMockFetch());
|
||||
|
||||
test("throws when API key is empty string", () => {
|
||||
expect(() => new MemoryClient({ apiKey: "" })).toThrow(
|
||||
"Mem0 API key is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is whitespace only", () => {
|
||||
expect(() => new MemoryClient({ apiKey: " " })).toThrow(
|
||||
"Mem0 API key cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when API key is not a string", () => {
|
||||
expect(
|
||||
() => new MemoryClient({ apiKey: 123 as unknown as string }),
|
||||
).toThrow("Mem0 API key must be a string");
|
||||
});
|
||||
|
||||
test("sets default host to https://api.mem0.ai", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.host).toBe("https://api.mem0.ai");
|
||||
});
|
||||
|
||||
test("uses custom host when provided", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY, host: TEST_HOST });
|
||||
expect(client.host).toBe(TEST_HOST);
|
||||
});
|
||||
|
||||
test("sets organizationId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from constructor", () => {
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets Authorization header with Token prefix", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.headers["Authorization"]).toBe(`Token ${TEST_API_KEY}`);
|
||||
});
|
||||
|
||||
test("creates axios client with 60s timeout", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
expect(client.client.defaults.timeout).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ping ────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - ping()", () => {
|
||||
test("sets organizationId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe(TEST_ORG_ID);
|
||||
});
|
||||
|
||||
test("sets projectId from ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("sets telemetryId from user_email in response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.ping();
|
||||
expect(client.telemetryId).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("preserves constructor organizationId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.organizationId).toBe("my_org");
|
||||
});
|
||||
|
||||
test("preserves constructor projectId over ping response", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: "my_org",
|
||||
projectId: "my_proj",
|
||||
});
|
||||
await client.ping();
|
||||
expect(client.projectId).toBe("my_proj");
|
||||
});
|
||||
|
||||
test("throws AuthenticationError on 401 response", async () => {
|
||||
const { AuthenticationError } = await import("../../common/exceptions");
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 401,
|
||||
body: "Invalid API key",
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: "bad-key" });
|
||||
await expect(client.ping()).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
test("throws on invalid (non-object) response format", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: "not an object" });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("Invalid response format");
|
||||
});
|
||||
|
||||
test("throws on status !== ok in response", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", {
|
||||
status: 200,
|
||||
body: { status: "error", message: "API Key is invalid" },
|
||||
});
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.ping()).rejects.toThrow("API Key is invalid");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Handling ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Error Handling", () => {
|
||||
test("404 throws MemoryNotFoundError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/gone/", { status: 404, body: "Memory not found" });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("gone")).rejects.toThrow(MemoryNotFoundError);
|
||||
await expect(client.get("gone")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
|
||||
test("500 throws MemoryError with server response text", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/err/", {
|
||||
status: 500,
|
||||
body: "Internal server error",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("err")).rejects.toThrow(MemoryError);
|
||||
await expect(client.get("err")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
test("400 throws ValidationError with details from server", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/bad/", {
|
||||
status: 400,
|
||||
body: "Invalid request: user_id is required",
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("bad")).rejects.toThrow(ValidationError);
|
||||
await expect(client.get("bad")).rejects.toThrow(
|
||||
"Invalid request: user_id is required",
|
||||
);
|
||||
});
|
||||
|
||||
test("Authorization header is included in fetch calls", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem_1/", {
|
||||
status: 200,
|
||||
body: { id: "mem_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem_1");
|
||||
|
||||
const call = mock.mock.calls.find((c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/memories/mem_1/"),
|
||||
);
|
||||
const headers = call![1].headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toContain(TEST_API_KEY);
|
||||
});
|
||||
|
||||
test("network failure (fetch throws) is propagated", async () => {
|
||||
global.fetch = jest.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
if (urlStr.includes("/v1/memories/net_err/")) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (urlStr.includes("/v1/ping/")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => MOCK_PING_RESPONSE,
|
||||
text: async () => JSON.stringify(MOCK_PING_RESPONSE),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "Not found",
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("net_err")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getProject, updateProject, exports, feedback.
|
||||
* Tests verify request construction and validation behavior.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { Feedback } from "../mem0.types";
|
||||
import {
|
||||
createMockFetch,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── getProject() ───────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - getProject()", () => {
|
||||
test("throws when organizationId and projectId not set", async () => {
|
||||
const responses = new Map<string, { status: number; body: unknown }>();
|
||||
responses.set("/v1/ping/", { status: 200, body: { status: "ok" } });
|
||||
global.fetch = createMockFetch(responses);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
try {
|
||||
await client.ping();
|
||||
} catch {
|
||||
// ping might throw — but orgId stays null
|
||||
}
|
||||
|
||||
await expect(
|
||||
client.getProject({ fields: ["custom_instructions"] }),
|
||||
).rejects.toThrow("organizationId and projectId must be set");
|
||||
});
|
||||
|
||||
test("sends GET to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Be helpful" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getProject({ fields: ["custom_instructions"] });
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/orgs/organizations/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("fields=custom_instructions");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProject() ────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - updateProject()", () => {
|
||||
test("sends PATCH to /api/v1/orgs/organizations/:orgId/projects/:projId/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes custom_instructions in PATCH body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/orgs/organizations/", {
|
||||
status: 200,
|
||||
body: { custom_instructions: "Updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateProject({
|
||||
custom_instructions: "Updated instructions",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
|
||||
expect(getFetchBody(call!).custom_instructions).toBe(
|
||||
"Updated instructions",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── feedback() ─────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - feedback()", () => {
|
||||
test("sends POST to /v1/feedback/ with payload", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes memory_id, feedback, and reason in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/feedback/", {
|
||||
status: 200,
|
||||
body: { message: "Feedback recorded" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.feedback({
|
||||
memory_id: "mem_123",
|
||||
feedback: Feedback.POSITIVE,
|
||||
feedback_reason: "Very helpful",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/feedback/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.memory_id).toBe("mem_123");
|
||||
expect(body.feedback).toBe("POSITIVE");
|
||||
expect(body.feedback_reason).toBe("Very helpful");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Memory Exports ─────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - Memory Exports", () => {
|
||||
test("createMemoryExport throws when missing filters or schema", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(
|
||||
client.createMemoryExport({
|
||||
filters: null as never,
|
||||
schema: null as never,
|
||||
}),
|
||||
).rejects.toThrow("Missing filters or schema");
|
||||
});
|
||||
|
||||
test("createMemoryExport sends POST to /v1/exports/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Export created", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory", "user_id"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createMemoryExport attaches org_id and project_id to body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/", {
|
||||
status: 200,
|
||||
body: { message: "Created", id: "exp_1" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createMemoryExport({
|
||||
schema: { fields: ["memory"] },
|
||||
filters: { user_id: "u1" },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v1/exports/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.org_id).toBe(TEST_ORG_ID);
|
||||
expect(body.project_id).toBe(TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
test("getMemoryExport throws when missing both id and filters", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await expect(client.getMemoryExport({} as never)).rejects.toThrow(
|
||||
"Missing memory_export_id or filters",
|
||||
);
|
||||
});
|
||||
|
||||
test("getMemoryExport sends POST to /v1/exports/get/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/exports/get/", {
|
||||
status: 200,
|
||||
body: { message: "Export data", id: "exp_123" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getMemoryExport({ memory_export_id: "exp_123" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/exports/get/", "POST")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — search (v1/v2 routing, filters).
|
||||
* Tests verify request construction, not mock response echo.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import type { Memory } from "../mem0.types";
|
||||
import { createMockMemory, TEST_API_KEY } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - search()", () => {
|
||||
test("sends POST to /v1/memories/search/ by default", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("includes query in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("What is my name?", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).query).toBe("What is my name?");
|
||||
});
|
||||
|
||||
test("includes user_id in request body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1" });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/search/", "POST");
|
||||
expect(getFetchBody(call!).user_id).toBe("u1");
|
||||
});
|
||||
|
||||
test("uses /v2/memories/search/ when api_version=v2", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("test", { user_id: "u1", api_version: "v2" });
|
||||
|
||||
expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("passes filters through to the v2 API body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/memories/search/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.search("query", {
|
||||
api_version: "v2",
|
||||
filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] },
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.filters).toEqual({
|
||||
OR: [{ user_id: "u1" }, { agent_id: "a1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not crash when called without options", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("query");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty results array", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/search/", { status: 200, body: [] });
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result: Memory[] = await client.search("nonexistent query", {
|
||||
user_id: "u1",
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { MemoryClient } from "../mem0";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const apiKey = process.env.MEM0_API_KEY || "";
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationId: "org_gRNd1RrQa4y52iK4tG8o59hXyVbaULikgq4kethC", projectId: "proj_7RfMkWs0PMgXYweGUNKqV9M9mgIRNt5XcupE7mSP" });
|
||||
// const client = new MemoryClient({ apiKey, host: 'https://api.mem0.ai', organizationName: "saket-default-org", projectName: "default-project" });
|
||||
const client = new MemoryClient({ apiKey, host: "https://api.mem0.ai" });
|
||||
|
||||
// Generate a random string
|
||||
const randomString = () => {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
};
|
||||
|
||||
describe("MemoryClient API", () => {
|
||||
let userId: string, memoryId: string;
|
||||
|
||||
beforeAll(() => {
|
||||
userId = randomString();
|
||||
});
|
||||
|
||||
const messages1 = [
|
||||
{ role: "user", content: "Hey, I am Alex. I'm now a vegetarian." },
|
||||
{ role: "assistant", content: "Hello Alex! Glad to hear!" },
|
||||
];
|
||||
|
||||
it("should add messages successfully", async () => {
|
||||
const res = await client.add(messages1, { user_id: userId || "" });
|
||||
|
||||
// Validate the response contains an iterable list
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
|
||||
// Validate the fields of the first message in the response
|
||||
const message = res[0];
|
||||
expect(typeof message.id).toBe("string");
|
||||
expect(typeof message.data?.memory).toBe("string");
|
||||
expect(typeof message.event).toBe("string");
|
||||
|
||||
// Store the memory ID for later use
|
||||
memoryId = message.id;
|
||||
});
|
||||
|
||||
it("should retrieve the specific memory by ID", async () => {
|
||||
const memory = await client.get(memoryId);
|
||||
|
||||
// Validate that the memory fields have the correct types and values
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or any object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(Array.isArray(memory.categories) || memory.categories === null).toBe(
|
||||
true,
|
||||
);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
});
|
||||
|
||||
it("should retrieve all users successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
|
||||
// Validate the number of users is a number
|
||||
expect(typeof allUsers.count).toBe("number");
|
||||
|
||||
// Validate the structure of the first user
|
||||
const firstUser = allUsers.results[0];
|
||||
expect(typeof firstUser.id).toBe("string");
|
||||
expect(typeof firstUser.name).toBe("string");
|
||||
expect(typeof firstUser.created_at).toBe("string");
|
||||
expect(typeof firstUser.updated_at).toBe("string");
|
||||
expect(typeof firstUser.total_memories).toBe("number");
|
||||
expect(typeof firstUser.type).toBe("string");
|
||||
|
||||
// Find the user with the name matching userId
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
expect(entity).not.toBeUndefined();
|
||||
|
||||
// Store the entity ID for later use
|
||||
const entity_id = entity?.id;
|
||||
expect(typeof entity_id).toBe("string");
|
||||
});
|
||||
|
||||
it("should retrieve all memories for the user", async () => {
|
||||
const res3 = await client.getAll({ user_id: userId });
|
||||
|
||||
// Validate that res3 is an iterable list (array)
|
||||
expect(Array.isArray(res3)).toBe(true);
|
||||
|
||||
if (res3.length > 0) {
|
||||
// Iterate through the first memory for validation (you can loop through all if needed)
|
||||
const memory = res3[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to the userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
} else {
|
||||
// If there are no memories, assert that the list is empty
|
||||
expect(res3.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query and filters (API version 2)", async () => {
|
||||
const searchOptionsV2 = {
|
||||
query: "What do you know about me?",
|
||||
filters: {
|
||||
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
|
||||
},
|
||||
threshold: 0.1,
|
||||
api_version: "v2",
|
||||
};
|
||||
|
||||
const searchResultV2 = await client.search(
|
||||
"What do you know about me?",
|
||||
searchOptionsV2,
|
||||
);
|
||||
|
||||
// Validate that searchResultV2 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV2)).toBe(true);
|
||||
|
||||
if (searchResultV2.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV2[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
if (memory.user_id) {
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
}
|
||||
|
||||
if (memory.agent_id) {
|
||||
// Should be a string (agent_id)
|
||||
expect(typeof memory.agent_id).toBe("string");
|
||||
expect(memory.agent_id).toBe("shopping-assistant");
|
||||
}
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV2.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should search and return results based on provided query (API version 1)", async () => {
|
||||
const searchResultV1 = await client.search("What is my name?", {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
// Validate that searchResultV1 is an iterable list (array)
|
||||
expect(Array.isArray(searchResultV1)).toBe(true);
|
||||
|
||||
if (searchResultV1.length > 0) {
|
||||
// Iterate through the first search result for validation (you can loop through all if needed)
|
||||
const memory = searchResultV1[0];
|
||||
|
||||
// Should be a string (memory id)
|
||||
expect(typeof memory.id).toBe("string");
|
||||
|
||||
// Should be a string (the actual memory content)
|
||||
expect(typeof memory.memory).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof memory.user_id).toBe("string");
|
||||
expect(memory.user_id).toBe(userId);
|
||||
|
||||
// Should be null or an object (metadata)
|
||||
expect(
|
||||
memory.metadata === null || typeof memory.metadata === "object",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(memory.categories) || memory.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(memory.categories)) {
|
||||
memory.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(memory.created_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(memory.updated_at || "").toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a number (score)
|
||||
expect(typeof memory.score).toBe("number");
|
||||
} else {
|
||||
// If no search results, assert that the list is empty
|
||||
expect(searchResultV1.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should retrieve history of a specific memory and validate the fields", async () => {
|
||||
const res22 = await client.history(memoryId);
|
||||
|
||||
// Validate that res22 is an iterable list (array)
|
||||
expect(Array.isArray(res22)).toBe(true);
|
||||
|
||||
if (res22.length > 0) {
|
||||
// Iterate through the first history entry for validation (you can loop through all if needed)
|
||||
const historyEntry = res22[0];
|
||||
|
||||
// Should be a string (history entry id)
|
||||
expect(typeof historyEntry.id).toBe("string");
|
||||
|
||||
// Should be a string (memory id related to the history entry)
|
||||
expect(typeof historyEntry.memory_id).toBe("string");
|
||||
|
||||
// Should be a string and equal to userId
|
||||
expect(typeof historyEntry.user_id).toBe("string");
|
||||
expect(historyEntry.user_id).toBe(userId);
|
||||
|
||||
// Should be a string or null (old memory)
|
||||
expect(
|
||||
historyEntry.old_memory === null ||
|
||||
typeof historyEntry.old_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be a string or null (new memory)
|
||||
expect(
|
||||
historyEntry.new_memory === null ||
|
||||
typeof historyEntry.new_memory === "string",
|
||||
).toBe(true);
|
||||
|
||||
// Should be an array of strings or null (categories)
|
||||
expect(
|
||||
Array.isArray(historyEntry.categories) ||
|
||||
historyEntry.categories === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.categories)) {
|
||||
historyEntry.categories.forEach((category) => {
|
||||
expect(typeof category).toBe("string");
|
||||
});
|
||||
}
|
||||
|
||||
// Should be a valid date (created_at)
|
||||
expect(new Date(historyEntry.created_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a valid date (updated_at)
|
||||
expect(new Date(historyEntry.updated_at).toString()).not.toBe(
|
||||
"Invalid Date",
|
||||
);
|
||||
|
||||
// Should be a string, one of: ADD, UPDATE, DELETE, NOOP
|
||||
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(historyEntry.event);
|
||||
|
||||
// Validate conditions based on event type
|
||||
if (historyEntry.event === "ADD") {
|
||||
expect(historyEntry.old_memory).toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "UPDATE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).not.toBeNull();
|
||||
} else if (historyEntry.event === "DELETE") {
|
||||
expect(historyEntry.old_memory).not.toBeNull();
|
||||
expect(historyEntry.new_memory).toBeNull();
|
||||
}
|
||||
|
||||
// Should be a list of objects or null (input)
|
||||
expect(
|
||||
Array.isArray(historyEntry.input) || historyEntry.input === null,
|
||||
).toBe(true);
|
||||
if (Array.isArray(historyEntry.input)) {
|
||||
historyEntry.input.forEach((input) => {
|
||||
// Each input should be an object
|
||||
expect(typeof input).toBe("object");
|
||||
|
||||
// Should have string content
|
||||
expect(typeof input.content).toBe("string");
|
||||
|
||||
// Should have a role that is either 'user' or 'assistant'
|
||||
expect(["user", "assistant"]).toContain(input.role);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If no history entries, assert that the list is empty
|
||||
expect(res22.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should delete the user successfully", async () => {
|
||||
const allUsers = await client.users();
|
||||
const entity = allUsers.results.find((user) => user.name === userId);
|
||||
|
||||
if (entity) {
|
||||
const deletedUser = await client.deleteUser(entity.id);
|
||||
|
||||
// Validate the deletion message
|
||||
expect(deletedUser.message).toBe("Entity deleted successfully!");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — users, deleteUser, deleteUsers.
|
||||
* Tests verify entity type routing and request construction.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import {
|
||||
createMockUser,
|
||||
createMockAllUsers,
|
||||
TEST_API_KEY,
|
||||
TEST_ORG_ID,
|
||||
TEST_PROJECT_ID,
|
||||
} from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
// ─── users() ────────────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - users()", () => {
|
||||
test("sends GET to /v1/entities/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser()]),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.users();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/v1/entities/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUsers() ──────────────────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUsers()", () => {
|
||||
function createClientWithMockedAxios() {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
const axiosDeleteMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
client.client.delete = axiosDeleteMock;
|
||||
return { client, axiosDeleteMock };
|
||||
}
|
||||
|
||||
test("routes user_id to DELETE /v2/entities/user/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ user_id: "u1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith("/v2/entities/user/u1/", {
|
||||
params: expect.objectContaining({
|
||||
org_id: TEST_ORG_ID,
|
||||
project_id: TEST_PROJECT_ID,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("routes agent_id to DELETE /v2/entities/agent/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ agent_id: "agent_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/agent/agent_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes app_id to DELETE /v2/entities/app/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ app_id: "app_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/app/app_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("routes run_id to DELETE /v2/entities/run/:name/", async () => {
|
||||
const { client, axiosDeleteMock } = createClientWithMockedAxios();
|
||||
await client.deleteUsers({ run_id: "run_1" });
|
||||
|
||||
expect(axiosDeleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/run/run_1/",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 'Entity deleted successfully.' for single entity", async () => {
|
||||
const { client } = createClientWithMockedAxios();
|
||||
const result = await client.deleteUsers({ user_id: "u1" });
|
||||
expect(result.message).toBe("Entity deleted successfully.");
|
||||
});
|
||||
|
||||
test("returns 'All users, agents, apps and runs deleted.' when no params given", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([createMockUser({ name: "u1", type: "user" })]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { message: "Deleted" } });
|
||||
|
||||
const result = await client.deleteUsers();
|
||||
expect(result.message).toBe("All users, agents, apps and runs deleted.");
|
||||
});
|
||||
|
||||
test("throws when no entities exist to delete", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/", {
|
||||
status: 200,
|
||||
body: createMockAllUsers([]),
|
||||
});
|
||||
setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
client.client.delete = jest.fn();
|
||||
|
||||
await expect(client.deleteUsers()).rejects.toThrow("No entities to delete");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteUser() (deprecated) ──────────────────────────
|
||||
|
||||
describe("MemoryClient - deleteUser() (deprecated)", () => {
|
||||
test("sends DELETE to /v1/entities/:type/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/123/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({
|
||||
entity_id: 123 as never,
|
||||
entity_type: "user",
|
||||
});
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/123/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("defaults entity_type to 'user' when empty", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/user/456/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({ entity_id: 456 as never, entity_type: "" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/entities/user/456/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook.
|
||||
* Tests verify request URL and HTTP method, not mock response values.
|
||||
*/
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { WebhookEvent } from "../mem0.types";
|
||||
import { TEST_API_KEY, TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
|
||||
import {
|
||||
setupMockFetch,
|
||||
findFetchCall,
|
||||
getFetchBody,
|
||||
installConsoleSuppression,
|
||||
} from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
describe("MemoryClient - Webhooks", () => {
|
||||
test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.getWebhooks();
|
||||
|
||||
const call = mock.mock.calls.find(
|
||||
(c: [string, RequestInit]) =>
|
||||
c[0].includes("/api/v1/webhooks/projects/") && !c[1]?.method,
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined();
|
||||
});
|
||||
|
||||
test("createWebhook includes webhook payload in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/projects/", {
|
||||
status: 200,
|
||||
body: { webhook_id: "wh_new" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.createWebhook({
|
||||
name: "new-hook",
|
||||
url: "https://example.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
webhookId: "",
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/", "POST");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("new-hook");
|
||||
expect(body.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined();
|
||||
});
|
||||
|
||||
test("updateWebhook includes updated fields in body", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook updated" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey: TEST_API_KEY,
|
||||
organizationId: TEST_ORG_ID,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
await client.updateWebhook({
|
||||
webhookId: "wh_1",
|
||||
name: "updated-hook",
|
||||
url: "https://new-url.com",
|
||||
eventTypes: [WebhookEvent.MEMORY_ADDED],
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
|
||||
const call = findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT");
|
||||
const body = getFetchBody(call!);
|
||||
expect(body.name).toBe("updated-hook");
|
||||
expect(body.url).toBe("https://new-url.com");
|
||||
});
|
||||
|
||||
test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/api/v1/webhooks/wh_1/", {
|
||||
status: 200,
|
||||
body: { message: "Webhook deleted" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteWebhook({ webhookId: "wh_1" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Shared test setup for MemoryClient unit tests.
|
||||
* Provides mock fetch wiring, console suppression, and utility finders.
|
||||
*/
|
||||
import {
|
||||
createMockFetch,
|
||||
createStandardMockResponses,
|
||||
MOCK_PING_RESPONSE,
|
||||
} from "./helpers";
|
||||
|
||||
// ─── Global fetch mock + telemetry suppression ───────────
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
export function setupMockFetch(
|
||||
extraResponses?: Map<string, { status: number; body: unknown }>,
|
||||
): jest.Mock {
|
||||
const responses = createStandardMockResponses();
|
||||
if (extraResponses) {
|
||||
for (const [key, value] of extraResponses) {
|
||||
responses.set(key, value);
|
||||
}
|
||||
}
|
||||
const mockFetch = createMockFetch(responses);
|
||||
global.fetch = mockFetch;
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
export function installConsoleSuppression(): void {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (
|
||||
msg.includes("Telemetry") ||
|
||||
msg.includes("Failed to initialize") ||
|
||||
msg.includes("Failed to capture")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
|
||||
const msg = String(args[0] ?? "");
|
||||
if (msg.includes("telemetry") || msg.includes("Telemetry")) {
|
||||
return;
|
||||
}
|
||||
originalConsoleWarn(...args);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helper: find specific fetch calls ───────────────────
|
||||
|
||||
export function findFetchCall(
|
||||
mock: jest.Mock,
|
||||
urlPattern: string,
|
||||
method?: string,
|
||||
): [string, RequestInit] | undefined {
|
||||
return mock.mock.calls.find((call: [string, RequestInit]) => {
|
||||
const urlMatch = call[0].includes(urlPattern);
|
||||
if (!method) return urlMatch;
|
||||
return urlMatch && call[1]?.method === method;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFetchBody(
|
||||
call: [string, RequestInit],
|
||||
): Record<string, unknown> {
|
||||
return JSON.parse(call[1].body as string);
|
||||
}
|
||||
|
||||
export { MOCK_PING_RESPONSE };
|
||||
@@ -1,246 +0,0 @@
|
||||
import {
|
||||
MemoryError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
MemoryNotFoundError,
|
||||
NetworkError,
|
||||
ConfigurationError,
|
||||
MemoryQuotaExceededError,
|
||||
createExceptionFromResponse,
|
||||
HTTP_STATUS_TO_EXCEPTION,
|
||||
} from "./exceptions";
|
||||
|
||||
describe("MemoryError", () => {
|
||||
const error = new MemoryError("test error", "MEM_001", {
|
||||
details: { operation: "add" },
|
||||
suggestion: "Try again",
|
||||
debugInfo: { requestId: "req_123" },
|
||||
});
|
||||
|
||||
test("is an instance of Error", () => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test("has correct message", () => {
|
||||
expect(error.message).toBe("test error");
|
||||
});
|
||||
|
||||
test("has correct errorCode", () => {
|
||||
expect(error.errorCode).toBe("MEM_001");
|
||||
});
|
||||
|
||||
test("has correct details", () => {
|
||||
expect(error.details).toEqual({ operation: "add" });
|
||||
});
|
||||
|
||||
test("has correct suggestion", () => {
|
||||
expect(error.suggestion).toBe("Try again");
|
||||
});
|
||||
|
||||
test("has correct debugInfo", () => {
|
||||
expect(error.debugInfo).toEqual({ requestId: "req_123" });
|
||||
});
|
||||
|
||||
test("defaults details to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.details).toEqual({});
|
||||
});
|
||||
|
||||
test("defaults suggestion to undefined", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.suggestion).toBeUndefined();
|
||||
});
|
||||
|
||||
test("defaults debugInfo to empty object", () => {
|
||||
const err = new MemoryError("test error", "MEM_001");
|
||||
expect(err.debugInfo).toEqual({});
|
||||
});
|
||||
|
||||
test("is throwable and catchable", () => {
|
||||
expect(() => {
|
||||
throw new MemoryError("fail", "MEM_001");
|
||||
}).toThrow("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Exception subclasses", () => {
|
||||
const subclasses = [
|
||||
{ Class: AuthenticationError, name: "AuthenticationError" },
|
||||
{ Class: RateLimitError, name: "RateLimitError" },
|
||||
{ Class: ValidationError, name: "ValidationError" },
|
||||
{ Class: MemoryNotFoundError, name: "MemoryNotFoundError" },
|
||||
{ Class: NetworkError, name: "NetworkError" },
|
||||
{ Class: ConfigurationError, name: "ConfigurationError" },
|
||||
{ Class: MemoryQuotaExceededError, name: "MemoryQuotaExceededError" },
|
||||
] as const;
|
||||
|
||||
test.each(subclasses)("$name extends MemoryError", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name extends Error", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name has correct name", ({ Class, name }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error.name).toBe(name);
|
||||
});
|
||||
|
||||
test.each(subclasses)("$name supports instanceof checks", ({ Class }) => {
|
||||
const error = new Class("test", "CODE_001");
|
||||
expect(error instanceof Class).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createExceptionFromResponse", () => {
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 401 to errorCode HTTP_401", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.errorCode).toBe("HTTP_401");
|
||||
});
|
||||
|
||||
test("maps 401 to authentication suggestion", () => {
|
||||
const error = createExceptionFromResponse(401, "Unauthorized");
|
||||
expect(error.suggestion).toBe(
|
||||
"Please check your API key and authentication credentials",
|
||||
);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error).toBeInstanceOf(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 429 passes debugInfo through", () => {
|
||||
const error = createExceptionFromResponse(429, "Too many requests", {
|
||||
debugInfo: { retryAfter: 60 },
|
||||
});
|
||||
expect(error.debugInfo).toEqual({ retryAfter: 60 });
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
const error = createExceptionFromResponse(404, "Not found");
|
||||
expect(error).toBeInstanceOf(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 400 to ValidationError", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request");
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
const error = createExceptionFromResponse(413, "Quota exceeded");
|
||||
expect(error).toBeInstanceOf(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test.each([502, 503, 504])("maps %i to NetworkError", (code) => {
|
||||
const error = createExceptionFromResponse(code, "Service unavailable");
|
||||
expect(error).toBeInstanceOf(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 500 to errorCode HTTP_500", () => {
|
||||
const error = createExceptionFromResponse(500, "Internal error");
|
||||
expect(error.errorCode).toBe("HTTP_500");
|
||||
});
|
||||
|
||||
test("maps unknown status to MemoryError", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error).toBeInstanceOf(MemoryError);
|
||||
});
|
||||
|
||||
test("maps unknown status to correct errorCode", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.errorCode).toBe("HTTP_418");
|
||||
});
|
||||
|
||||
test("maps unknown status to retry suggestion", () => {
|
||||
const error = createExceptionFromResponse(418, "I am a teapot");
|
||||
expect(error.suggestion).toBe("Please try again later");
|
||||
});
|
||||
|
||||
test("uses response text as message", () => {
|
||||
const error = createExceptionFromResponse(400, "Invalid user_id format");
|
||||
expect(error.message).toBe("Invalid user_id format");
|
||||
});
|
||||
|
||||
test("falls back to generic message when response text is empty", () => {
|
||||
const error = createExceptionFromResponse(500, "");
|
||||
expect(error.message).toBe("HTTP 500 error");
|
||||
});
|
||||
|
||||
test("passes details through", () => {
|
||||
const error = createExceptionFromResponse(400, "Bad request", {
|
||||
details: { field: "user_id", value: "" },
|
||||
});
|
||||
expect(error.details).toEqual({ field: "user_id", value: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP_STATUS_TO_EXCEPTION", () => {
|
||||
test("maps 400 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[400]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 401 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[401]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 403 to AuthenticationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[403]).toBe(AuthenticationError);
|
||||
});
|
||||
|
||||
test("maps 404 to MemoryNotFoundError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[404]).toBe(MemoryNotFoundError);
|
||||
});
|
||||
|
||||
test("maps 408 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[408]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 409 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[409]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 413 to MemoryQuotaExceededError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[413]).toBe(MemoryQuotaExceededError);
|
||||
});
|
||||
|
||||
test("maps 422 to ValidationError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[422]).toBe(ValidationError);
|
||||
});
|
||||
|
||||
test("maps 429 to RateLimitError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[429]).toBe(RateLimitError);
|
||||
});
|
||||
|
||||
test("maps 500 to MemoryError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[500]).toBe(MemoryError);
|
||||
});
|
||||
|
||||
test("maps 502 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[502]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 503 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[503]).toBe(NetworkError);
|
||||
});
|
||||
|
||||
test("maps 504 to NetworkError", () => {
|
||||
expect(HTTP_STATUS_TO_EXCEPTION[504]).toBe(NetworkError);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Structured exception classes for mem0 TypeScript SDK.
|
||||
*
|
||||
* Provides specific, actionable exceptions with error codes, suggestions,
|
||||
* and debug information. Maps HTTP status codes to appropriate exception types.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { RateLimitError, MemoryNotFoundError } from 'mem0ai'
|
||||
*
|
||||
* try {
|
||||
* await client.get(memoryId)
|
||||
* } catch (e) {
|
||||
* if (e instanceof MemoryNotFoundError) {
|
||||
* console.log(e.suggestion) // "The requested resource was not found"
|
||||
* } else if (e instanceof RateLimitError) {
|
||||
* await sleep(e.debugInfo.retryAfter ?? 60)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface MemoryErrorOptions {
|
||||
details?: Record<string, unknown>;
|
||||
suggestion?: string;
|
||||
debugInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base exception for all memory-related errors.
|
||||
*
|
||||
* Every mem0 exception includes an error code for programmatic handling,
|
||||
* optional details, a user-friendly suggestion, and debug information.
|
||||
*/
|
||||
export class MemoryError extends Error {
|
||||
readonly errorCode: string;
|
||||
readonly details: Record<string, unknown>;
|
||||
readonly suggestion?: string;
|
||||
readonly debugInfo: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options: MemoryErrorOptions = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MemoryError";
|
||||
this.errorCode = errorCode;
|
||||
this.details = options.details ?? {};
|
||||
this.suggestion = options.suggestion;
|
||||
this.debugInfo = options.debugInfo ?? {};
|
||||
|
||||
// Fix prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when authentication fails (401, 403). */
|
||||
export class AuthenticationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when rate limits are exceeded (429). */
|
||||
export class RateLimitError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when input validation fails (400, 409, 422). */
|
||||
export class ValidationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when a memory is not found (404). */
|
||||
export class MemoryNotFoundError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when network connectivity issues occur (408, 502, 503, 504). */
|
||||
export class NetworkError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when client configuration is invalid. */
|
||||
export class ConfigurationError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "ConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when memory quota is exceeded (413). */
|
||||
export class MemoryQuotaExceededError extends MemoryError {
|
||||
constructor(
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) {
|
||||
super(message, errorCode, options);
|
||||
this.name = "MemoryQuotaExceededError";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP Status → Exception Mapping ─────────────────────
|
||||
|
||||
type MemoryErrorConstructor = new (
|
||||
message: string,
|
||||
errorCode: string,
|
||||
options?: MemoryErrorOptions,
|
||||
) => MemoryError;
|
||||
|
||||
export const HTTP_STATUS_TO_EXCEPTION: Record<number, MemoryErrorConstructor> =
|
||||
{
|
||||
400: ValidationError,
|
||||
401: AuthenticationError,
|
||||
403: AuthenticationError,
|
||||
404: MemoryNotFoundError,
|
||||
408: NetworkError,
|
||||
409: ValidationError,
|
||||
413: MemoryQuotaExceededError,
|
||||
422: ValidationError,
|
||||
429: RateLimitError,
|
||||
500: MemoryError,
|
||||
502: NetworkError,
|
||||
503: NetworkError,
|
||||
504: NetworkError,
|
||||
};
|
||||
|
||||
const HTTP_SUGGESTIONS: Record<number, string> = {
|
||||
400: "Please check your request parameters and try again",
|
||||
401: "Please check your API key and authentication credentials",
|
||||
403: "You don't have permission to perform this operation",
|
||||
404: "The requested resource was not found",
|
||||
408: "Request timed out. Please try again",
|
||||
409: "Resource conflict. Please check your request",
|
||||
413: "Request too large. Please reduce the size of your request",
|
||||
422: "Invalid request data. Please check your input",
|
||||
429: "Rate limit exceeded. Please wait before making more requests",
|
||||
500: "Internal server error. Please try again later",
|
||||
502: "Service temporarily unavailable. Please try again later",
|
||||
503: "Service unavailable. Please try again later",
|
||||
504: "Gateway timeout. Please try again later",
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an appropriate exception based on HTTP response status code.
|
||||
*
|
||||
* @param statusCode - HTTP status code from the response
|
||||
* @param responseText - Response body text
|
||||
* @param options - Additional error context (details, debugInfo)
|
||||
* @returns An instance of the appropriate MemoryError subclass
|
||||
*/
|
||||
export function createExceptionFromResponse(
|
||||
statusCode: number,
|
||||
responseText: string,
|
||||
options: Omit<MemoryErrorOptions, "suggestion"> = {},
|
||||
): MemoryError {
|
||||
const ExceptionClass = HTTP_STATUS_TO_EXCEPTION[statusCode] ?? MemoryError;
|
||||
const errorCode = `HTTP_${statusCode}`;
|
||||
const suggestion = HTTP_SUGGESTIONS[statusCode] ?? "Please try again later";
|
||||
|
||||
return new ExceptionClass(
|
||||
responseText || `HTTP ${statusCode} error`,
|
||||
errorCode,
|
||||
{ ...options, suggestion },
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"@types/node": "^20.11.19",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/redis": "^4.0.10",
|
||||
"@types/sqlite3": "^3.1.11",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"cloudflare": "^4.2.0",
|
||||
"dotenv": "^16.4.4",
|
||||
@@ -26,7 +27,7 @@
|
||||
"openai": "^4.28.0",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.7.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -20,28 +20,14 @@ export class ConfigManager {
|
||||
finalModel = userConf.model;
|
||||
}
|
||||
|
||||
// Normalize snake_case keys from Python SDK / OpenClaw configs
|
||||
const baseURL =
|
||||
userConf?.baseURL ??
|
||||
((userConf as Record<string, unknown>)?.lmstudio_base_url as
|
||||
| string
|
||||
| undefined) ??
|
||||
userConf?.url;
|
||||
const embeddingDims =
|
||||
userConf?.embeddingDims ??
|
||||
((userConf as Record<string, unknown>)?.embedding_dims as
|
||||
| number
|
||||
| undefined);
|
||||
|
||||
return {
|
||||
apiKey:
|
||||
userConf?.apiKey !== undefined
|
||||
? userConf.apiKey
|
||||
: defaultConf.apiKey,
|
||||
model: finalModel,
|
||||
baseURL,
|
||||
url: userConf?.url,
|
||||
embeddingDims,
|
||||
embeddingDims: userConf?.embeddingDims,
|
||||
modelProperties:
|
||||
userConf?.modelProperties !== undefined
|
||||
? userConf.modelProperties
|
||||
@@ -57,23 +43,13 @@ export class ConfigManager {
|
||||
const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config;
|
||||
const userConf = userConfig.vectorStore?.config;
|
||||
|
||||
// Resolve the vector store dimension. If the user explicitly
|
||||
// provided one, use it. Otherwise leave it undefined so that
|
||||
// Memory._autoInitialize() can auto-detect it by running a
|
||||
// probe embedding at startup — this makes *any* embedder work
|
||||
// out of the box without the user needing to know or set the
|
||||
// dimension manually.
|
||||
const explicitDimension =
|
||||
userConf?.dimension ||
|
||||
userConfig.embedder?.config?.embeddingDims ||
|
||||
undefined;
|
||||
|
||||
// Prioritize user-provided client instance
|
||||
if (userConf?.client && typeof userConf.client === "object") {
|
||||
return {
|
||||
client: userConf.client,
|
||||
collectionName: userConf.collectionName,
|
||||
dimension: explicitDimension,
|
||||
// Include other fields from userConf if necessary, or omit defaults
|
||||
collectionName: userConf.collectionName, // Can be undefined
|
||||
dimension: userConf.dimension || defaultConf.dimension, // Merge dimension
|
||||
...userConf, // Include any other passthrough fields from user
|
||||
};
|
||||
} else {
|
||||
@@ -81,7 +57,7 @@ export class ConfigManager {
|
||||
return {
|
||||
collectionName:
|
||||
userConf?.collectionName || defaultConf.collectionName,
|
||||
dimension: explicitDimension,
|
||||
dimension: userConf?.dimension || defaultConf.dimension,
|
||||
// Ensure client is not carried over from defaults if not provided by user
|
||||
client: undefined,
|
||||
// Include other passthrough fields from userConf even if no client
|
||||
@@ -104,17 +80,8 @@ export class ConfigManager {
|
||||
finalModel = userConf.model;
|
||||
}
|
||||
|
||||
// Normalize snake_case keys from Python SDK / OpenClaw configs
|
||||
const llmBaseURL =
|
||||
userConf?.baseURL ??
|
||||
((userConf as Record<string, unknown>)?.lmstudio_base_url as
|
||||
| string
|
||||
| undefined) ??
|
||||
defaultConf.baseURL;
|
||||
|
||||
return {
|
||||
baseURL: llmBaseURL,
|
||||
url: userConf?.url,
|
||||
baseURL: userConf?.baseURL || defaultConf.baseURL,
|
||||
apiKey:
|
||||
userConf?.apiKey !== undefined
|
||||
? userConf.apiKey
|
||||
@@ -128,34 +95,16 @@ export class ConfigManager {
|
||||
})(),
|
||||
},
|
||||
historyDbPath:
|
||||
userConfig.historyDbPath ||
|
||||
userConfig.historyStore?.config?.historyDbPath ||
|
||||
DEFAULT_MEMORY_CONFIG.historyStore?.config?.historyDbPath,
|
||||
userConfig.historyDbPath || DEFAULT_MEMORY_CONFIG.historyDbPath,
|
||||
customPrompt: userConfig.customPrompt,
|
||||
graphStore: {
|
||||
...DEFAULT_MEMORY_CONFIG.graphStore,
|
||||
...userConfig.graphStore,
|
||||
},
|
||||
historyStore: (() => {
|
||||
const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!;
|
||||
const historyProvider =
|
||||
userConfig.historyStore?.provider || defaultHistoryStore.provider;
|
||||
const isSqlite = historyProvider.toLowerCase() === "sqlite";
|
||||
|
||||
// Precedence: explicit historyStore.config > top-level historyDbPath > default
|
||||
return {
|
||||
...defaultHistoryStore,
|
||||
...userConfig.historyStore,
|
||||
provider: historyProvider,
|
||||
config: {
|
||||
...(isSqlite ? defaultHistoryStore.config : {}),
|
||||
...(isSqlite && userConfig.historyDbPath
|
||||
? { historyDbPath: userConfig.historyDbPath }
|
||||
: {}),
|
||||
...userConfig.historyStore?.config,
|
||||
},
|
||||
};
|
||||
})(),
|
||||
historyStore: {
|
||||
...DEFAULT_MEMORY_CONFIG.historyStore,
|
||||
...userConfig.historyStore,
|
||||
},
|
||||
disableHistory:
|
||||
userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory,
|
||||
enableGraph: userConfig.enableGraph || DEFAULT_MEMORY_CONFIG.enableGraph,
|
||||
|
||||
@@ -28,7 +28,7 @@ export class GoogleEmbedder implements Embedder {
|
||||
const response = await this.google.models.embedContent({
|
||||
model: this.model,
|
||||
contents: texts,
|
||||
config: { outputDimensionality: this.embeddingDims },
|
||||
config: { outputDimensionality: 768 },
|
||||
});
|
||||
return response.embeddings!.map((item) => item.values!);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import OpenAI from "openai";
|
||||
import { Embedder } from "./base";
|
||||
import { EmbeddingConfig } from "../types";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
|
||||
const DEFAULT_MODEL =
|
||||
"nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf";
|
||||
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
|
||||
|
||||
export class LMStudioEmbedder implements Embedder {
|
||||
private openai: OpenAI;
|
||||
private model: string;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
const baseURL = config.baseURL ?? config.url ?? DEFAULT_BASE_URL;
|
||||
const apiKey = config.apiKey || DEFAULT_LMSTUDIO_API_KEY;
|
||||
this.openai = new OpenAI({ apiKey, baseURL: String(baseURL) });
|
||||
this.model = config.model || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const normalized =
|
||||
typeof text === "string" ? text.replace(/\n/g, " ") : String(text);
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: normalized,
|
||||
encoding_format: "float",
|
||||
});
|
||||
return response.data[0].embedding;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio embedder failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
const normalized = texts.map((t) =>
|
||||
typeof t === "string" ? t.replace(/\n/g, " ") : String(t),
|
||||
);
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.model,
|
||||
input: normalized,
|
||||
encoding_format: "float",
|
||||
});
|
||||
return response.data.map((item) => item.embedding);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio embedder failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class OllamaEmbedder implements Embedder {
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.ollama = new Ollama({
|
||||
host: config.url || config.baseURL || "http://localhost:11434",
|
||||
host: config.url || "http://localhost:11434",
|
||||
});
|
||||
this.model = config.model || "nomic-embed-text:latest";
|
||||
this.embeddingDims = config.embeddingDims || 768;
|
||||
@@ -27,18 +27,11 @@ export class OllamaEmbedder implements Embedder {
|
||||
} catch (err) {
|
||||
logger.error(`Error ensuring model exists: ${err}`);
|
||||
}
|
||||
// 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({
|
||||
const response = await this.ollama.embeddings({
|
||||
model: this.model,
|
||||
input,
|
||||
prompt: text,
|
||||
});
|
||||
if (!response.embeddings || response.embeddings.length === 0) {
|
||||
throw new Error(
|
||||
`Ollama embed() returned no embeddings for model '${this.model}'`,
|
||||
);
|
||||
}
|
||||
return response.embeddings[0];
|
||||
return response.embedding;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
@@ -46,21 +39,12 @@ export class OllamaEmbedder implements Embedder {
|
||||
return response;
|
||||
}
|
||||
|
||||
private static normalizeModelName(name: string): string {
|
||||
return name.includes(":") ? name : `${name}:latest`;
|
||||
}
|
||||
|
||||
private async ensureModelExists(): Promise<boolean> {
|
||||
if (this.initialized) {
|
||||
return true;
|
||||
}
|
||||
const local_models = await this.ollama.list();
|
||||
const target = OllamaEmbedder.normalizeModelName(this.model);
|
||||
if (
|
||||
!local_models.models.find(
|
||||
(m: any) => OllamaEmbedder.normalizeModelName(m.name) === target,
|
||||
)
|
||||
) {
|
||||
if (!local_models.models.find((m: any) => m.name === this.model)) {
|
||||
logger.info(`Pulling model ${this.model}...`);
|
||||
await this.ollama.pull({ model: this.model });
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ export class OpenAIEmbedder implements Embedder {
|
||||
private embeddingDims?: number;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL || config.url,
|
||||
});
|
||||
this.openai = new OpenAI({ apiKey: config.apiKey });
|
||||
this.model = config.model || "text-embedding-3-small";
|
||||
this.embeddingDims = config.embeddingDims || 1536;
|
||||
}
|
||||
|
||||
@@ -88,8 +88,6 @@ Memory Format:
|
||||
source -- relationship -- destination
|
||||
|
||||
Provide a list of deletion instructions, each specifying the relationship to be deleted.
|
||||
|
||||
Respond in JSON format.
|
||||
`;
|
||||
|
||||
export function getDeleteMessages(
|
||||
|
||||
@@ -4,7 +4,6 @@ export * from "./types";
|
||||
export * from "./embeddings/base";
|
||||
export * from "./embeddings/openai";
|
||||
export * from "./embeddings/ollama";
|
||||
export * from "./embeddings/lmstudio";
|
||||
export * from "./embeddings/google";
|
||||
export * from "./embeddings/azure";
|
||||
export * from "./embeddings/langchain";
|
||||
@@ -15,7 +14,6 @@ export * from "./llms/openai_structured";
|
||||
export * from "./llms/anthropic";
|
||||
export * from "./llms/groq";
|
||||
export * from "./llms/ollama";
|
||||
export * from "./llms/lmstudio";
|
||||
export * from "./llms/mistral";
|
||||
export * from "./llms/langchain";
|
||||
export * from "./vector_stores/base";
|
||||
|
||||
@@ -16,49 +16,24 @@ export class GoogleLLM implements LLM {
|
||||
responseFormat?: { type: string },
|
||||
tools?: any[],
|
||||
): Promise<string | LLMResponse> {
|
||||
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<string, any> = {};
|
||||
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,
|
||||
});
|
||||
contents: messages.map((msg) => ({
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content),
|
||||
},
|
||||
],
|
||||
role: msg.role === "system" ? "model" : "user",
|
||||
})),
|
||||
|
||||
// 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
model: this.model,
|
||||
// config: {
|
||||
// responseSchema: {}, // Add response schema if needed
|
||||
// },
|
||||
});
|
||||
|
||||
const text = completion.text
|
||||
?.replace(/^```json\n/, "")
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { OpenAILLM } from "./openai";
|
||||
import { LLMConfig, Message } from "../types";
|
||||
import { LLMResponse } from "./base";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
|
||||
const DEFAULT_MODEL =
|
||||
"lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/Meta-Llama-3.1-70B-Instruct-IQ2_M.gguf";
|
||||
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
|
||||
|
||||
export class LMStudioLLM extends OpenAILLM {
|
||||
constructor(config: LLMConfig) {
|
||||
super({
|
||||
...config,
|
||||
apiKey: config.apiKey || DEFAULT_LMSTUDIO_API_KEY,
|
||||
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
|
||||
model: config.model || DEFAULT_MODEL,
|
||||
});
|
||||
}
|
||||
|
||||
async generateResponse(
|
||||
messages: Message[],
|
||||
responseFormat?: { type: string },
|
||||
tools?: any[],
|
||||
): Promise<string | LLMResponse> {
|
||||
try {
|
||||
return await super.generateResponse(messages, responseFormat, tools);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio LLM failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async generateChat(messages: Message[]): Promise<LLMResponse> {
|
||||
try {
|
||||
return await super.generateChat(messages);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`LM Studio LLM failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export class OllamaLLM implements LLM {
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
this.ollama = new Ollama({
|
||||
host: config.url || config.baseURL || "http://localhost:11434",
|
||||
host: config.config?.url || "http://localhost:11434",
|
||||
});
|
||||
this.model = config.model || "llama3.1:8b";
|
||||
this.ensureModelExists().catch((err) => {
|
||||
|
||||
@@ -212,7 +212,7 @@ export class MemoryGraph {
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`,
|
||||
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.`,
|
||||
},
|
||||
{ role: "user", content: data },
|
||||
],
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
HistoryManagerFactory,
|
||||
} from "../utils/factory";
|
||||
import {
|
||||
FactRetrievalSchema,
|
||||
getFactRetrievalMessages,
|
||||
getUpdateMemoryMessages,
|
||||
parseMessages,
|
||||
@@ -41,7 +40,7 @@ export class Memory {
|
||||
private config: MemoryConfig;
|
||||
private customPrompt: string | undefined;
|
||||
private embedder: Embedder;
|
||||
private vectorStore!: VectorStore;
|
||||
private vectorStore: VectorStore;
|
||||
private llm: LLM;
|
||||
private db: HistoryManager;
|
||||
private collectionName: string | undefined;
|
||||
@@ -49,8 +48,6 @@ export class Memory {
|
||||
private graphMemory?: MemoryGraph;
|
||||
private enableGraph: boolean;
|
||||
telemetryId: string;
|
||||
private _initPromise: Promise<void>;
|
||||
private _initError?: Error;
|
||||
|
||||
constructor(config: Partial<MemoryConfig> = {}) {
|
||||
// Merge and validate config
|
||||
@@ -61,9 +58,10 @@ export class Memory {
|
||||
this.config.embedder.provider,
|
||||
this.config.embedder.config,
|
||||
);
|
||||
// Vector store creation is deferred to _autoInitialize() so that
|
||||
// the embedding dimension can be auto-detected first when not
|
||||
// explicitly configured.
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config,
|
||||
);
|
||||
this.llm = LLMFactory.create(
|
||||
this.config.llm.provider,
|
||||
this.config.llm.config,
|
||||
@@ -71,10 +69,20 @@ export class Memory {
|
||||
if (this.config.disableHistory) {
|
||||
this.db = new DummyHistoryManager();
|
||||
} else {
|
||||
this.db = HistoryManagerFactory.create(
|
||||
this.config.historyStore!.provider,
|
||||
this.config.historyStore!,
|
||||
);
|
||||
const defaultConfig = {
|
||||
provider: "sqlite",
|
||||
config: {
|
||||
historyDbPath: this.config.historyDbPath || ":memory:",
|
||||
},
|
||||
};
|
||||
|
||||
this.db =
|
||||
this.config.historyStore && !this.config.disableHistory
|
||||
? HistoryManagerFactory.create(
|
||||
this.config.historyStore.provider,
|
||||
this.config.historyStore,
|
||||
)
|
||||
: HistoryManagerFactory.create("sqlite", defaultConfig);
|
||||
}
|
||||
|
||||
this.collectionName = this.config.vectorStore.config.collectionName;
|
||||
@@ -87,67 +95,8 @@ export class Memory {
|
||||
this.graphMemory = new MemoryGraph(this.config);
|
||||
}
|
||||
|
||||
// Auto-detect embedding dimension (if needed), create vector store,
|
||||
// and initialize it. All public methods await this before proceeding.
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If no explicit dimension was provided, runs a probe embedding to
|
||||
* detect it. Then creates and initializes the vector store.
|
||||
*/
|
||||
private async _autoInitialize(): Promise<void> {
|
||||
if (!this.config.vectorStore.config.dimension) {
|
||||
try {
|
||||
const probe = await this.embedder.embed("dimension probe");
|
||||
this.config.vectorStore.config.dimension = probe.length;
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`Failed to auto-detect embedding dimension from provider '${this.config.embedder.provider}': ${error.message}. ` +
|
||||
`Please set 'dimension' in vectorStore.config or 'embeddingDims' in embedder.config explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config,
|
||||
);
|
||||
|
||||
// The vector store constructor may fire initialize() asynchronously
|
||||
// (e.g. Qdrant). Explicitly await it here to guarantee the backing
|
||||
// store (collections, tables, etc.) is ready before any public method
|
||||
// attempts to read or write.
|
||||
await this.vectorStore.initialize();
|
||||
|
||||
await this._initializeTelemetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that auto-initialization (dimension detection + vector store
|
||||
* creation) has completed before any public method proceeds.
|
||||
* If a previous init attempt failed, retries automatically.
|
||||
*/
|
||||
private async _ensureInitialized(): Promise<void> {
|
||||
await this._initPromise;
|
||||
if (this._initError) {
|
||||
// Clear failed state and retry — the embedder or vector store
|
||||
// may have been transiently unavailable at startup.
|
||||
this._initError = undefined;
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
await this._initPromise;
|
||||
if (this._initError) {
|
||||
throw this._initError;
|
||||
}
|
||||
}
|
||||
// Initialize telemetry if vector store is initialized
|
||||
this._initializeTelemetry();
|
||||
}
|
||||
|
||||
private async _initializeTelemetry() {
|
||||
@@ -207,7 +156,6 @@ export class Memory {
|
||||
messages: string | Message[],
|
||||
config: AddMemoryOptions,
|
||||
): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("add", {
|
||||
message_count: Array.isArray(messages) ? messages.length : 1,
|
||||
has_metadata: !!config.metadata,
|
||||
@@ -313,8 +261,7 @@ export class Memory {
|
||||
const cleanResponse = removeCodeBlocks(response as string);
|
||||
let facts: string[] = [];
|
||||
try {
|
||||
const parsed = FactRetrievalSchema.parse(JSON.parse(cleanResponse));
|
||||
facts = parsed.facts;
|
||||
facts = JSON.parse(cleanResponse).facts || [];
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Failed to parse facts from LLM response:",
|
||||
@@ -433,7 +380,6 @@ export class Memory {
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<MemoryItem | null> {
|
||||
await this._ensureInitialized();
|
||||
const memory = await this.vectorStore.get(memoryId);
|
||||
if (!memory) return null;
|
||||
|
||||
@@ -475,7 +421,6 @@ export class Memory {
|
||||
query: string,
|
||||
config: SearchMemoryOptions,
|
||||
): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("search", {
|
||||
query_length: query.length,
|
||||
limit: config.limit,
|
||||
@@ -542,7 +487,6 @@ export class Memory {
|
||||
}
|
||||
|
||||
async update(memoryId: string, data: string): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("update", { memory_id: memoryId });
|
||||
const embedding = await this.embedder.embed(data);
|
||||
await this.updateMemory(memoryId, data, { [data]: embedding });
|
||||
@@ -550,7 +494,6 @@ export class Memory {
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("delete", { memory_id: memoryId });
|
||||
await this.deleteMemory(memoryId);
|
||||
return { message: "Memory deleted successfully!" };
|
||||
@@ -559,7 +502,6 @@ export class Memory {
|
||||
async deleteAll(
|
||||
config: DeleteAllMemoryOptions,
|
||||
): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("delete_all", {
|
||||
has_user_id: !!config.userId,
|
||||
has_agent_id: !!config.agentId,
|
||||
@@ -587,12 +529,10 @@ export class Memory {
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<any[]> {
|
||||
await this._ensureInitialized();
|
||||
return this.db.getHistory(memoryId);
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("reset");
|
||||
await this.db.reset();
|
||||
|
||||
@@ -617,30 +557,28 @@ export class Memory {
|
||||
await this.graphMemory.deleteAll({ userId: "default" }); // Assuming this is okay, or needs similar check?
|
||||
}
|
||||
|
||||
// Re-initialize factories/clients based on the original config.
|
||||
// Dimension is already set in this.config from the initial probe,
|
||||
// so _autoInitialize will skip the probe and just re-create the store.
|
||||
// Re-initialize factories/clients based on the original config
|
||||
this.embedder = EmbedderFactory.create(
|
||||
this.config.embedder.provider,
|
||||
this.config.embedder.config,
|
||||
);
|
||||
// Re-create vector store instance - crucial for Langchain to reset wrapper state if needed
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config, // This will pass the original client instance back
|
||||
);
|
||||
this.llm = LLMFactory.create(
|
||||
this.config.llm.provider,
|
||||
this.config.llm.config,
|
||||
);
|
||||
// Re-init DB if needed (though db.reset() likely handles its state)
|
||||
// Re-init Graph if needed
|
||||
|
||||
// Re-create vector store via _autoInitialize (which handles dimension + creation)
|
||||
this._initError = undefined;
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
await this._initPromise;
|
||||
// Re-initialize telemetry
|
||||
this._initializeTelemetry();
|
||||
}
|
||||
|
||||
async getAll(config: GetAllMemoryOptions): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("get_all", {
|
||||
limit: config.limit,
|
||||
has_user_id: !!config.userId,
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Accepts a string directly, or an object with a "fact" or "text" key
|
||||
// (common malformed shapes from smaller LLMs like llama3.1:8b).
|
||||
const factItem = z.union([
|
||||
z.string(),
|
||||
z.object({ fact: z.string() }).transform((o) => o.fact),
|
||||
z.object({ text: z.string() }).transform((o) => o.text),
|
||||
]);
|
||||
|
||||
// Define Zod schema for fact retrieval output
|
||||
export const FactRetrievalSchema = z.object({
|
||||
facts: z
|
||||
.array(factItem)
|
||||
.transform((arr) => arr.filter((s) => s.length > 0))
|
||||
.array(z.string())
|
||||
.describe("An array of distinct facts extracted from the conversation."),
|
||||
});
|
||||
|
||||
@@ -278,9 +269,5 @@ export function parseMessages(messages: string[]): string {
|
||||
}
|
||||
|
||||
export function removeCodeBlocks(text: string): string {
|
||||
// Extract content inside code fences instead of deleting it.
|
||||
// The old regex /```[^`]*```/g replaced the entire block (including
|
||||
// its content) with an empty string, so when an LLM returned JSON
|
||||
// wrapped in ```json ... ``` the actual payload was discarded.
|
||||
return text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, "$1").trim();
|
||||
return text.replace(/```[^`]*```/g, "");
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import Database from "better-sqlite3";
|
||||
import sqlite3 from "sqlite3";
|
||||
import { HistoryManager } from "./base";
|
||||
import { ensureSQLiteDirectory } from "../utils/sqlite";
|
||||
|
||||
export class SQLiteManager implements HistoryManager {
|
||||
private db: Database.Database;
|
||||
private stmtInsert!: Database.Statement;
|
||||
private stmtSelect!: Database.Statement;
|
||||
private db: sqlite3.Database;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
ensureSQLiteDirectory(dbPath);
|
||||
this.db = new Database(dbPath);
|
||||
this.init();
|
||||
this.db = new sqlite3.Database(dbPath);
|
||||
this.init().catch(console.error);
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.db.exec(`
|
||||
private async init() {
|
||||
await this.run(`
|
||||
CREATE TABLE IF NOT EXISTS memory_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memory_id TEXT NOT NULL,
|
||||
@@ -26,14 +22,24 @@ export class SQLiteManager implements HistoryManager {
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
this.stmtInsert = this.db.prepare(
|
||||
`INSERT INTO memory_history
|
||||
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
this.stmtSelect = this.db.prepare(
|
||||
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
|
||||
);
|
||||
}
|
||||
|
||||
private async run(sql: string, params: any[] = []): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, params, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async all(sql: string, params: any[] = []): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addHistory(
|
||||
@@ -45,24 +51,32 @@ export class SQLiteManager implements HistoryManager {
|
||||
updatedAt?: string,
|
||||
isDeleted: number = 0,
|
||||
): Promise<void> {
|
||||
this.stmtInsert.run(
|
||||
memoryId,
|
||||
previousValue,
|
||||
newValue,
|
||||
action,
|
||||
createdAt ?? null,
|
||||
updatedAt ?? null,
|
||||
isDeleted,
|
||||
await this.run(
|
||||
`INSERT INTO memory_history
|
||||
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
memoryId,
|
||||
previousValue,
|
||||
newValue,
|
||||
action,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
isDeleted,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async getHistory(memoryId: string): Promise<any[]> {
|
||||
return this.stmtSelect.all(memoryId) as any[];
|
||||
return this.all(
|
||||
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
|
||||
[memoryId],
|
||||
);
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
this.db.exec("DROP TABLE IF EXISTS memory_history");
|
||||
this.init();
|
||||
await this.run("DROP TABLE IF EXISTS memory_history");
|
||||
await this.init();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
/**
|
||||
* Tests for the sqlite3 → better-sqlite3 migration.
|
||||
*
|
||||
* Covers:
|
||||
* - SQLiteManager: all HistoryManager interface methods
|
||||
* - MemoryVectorStore: insert, search, get, update, delete, list, userId mgmt
|
||||
* - File-based persistence and in-memory mode
|
||||
* - Backward compatibility: same schema, same data shapes
|
||||
*/
|
||||
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLiteManager tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SQLiteManager (better-sqlite3)", () => {
|
||||
let mgr: SQLiteManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mgr = new SQLiteManager(":memory:");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mgr.close();
|
||||
});
|
||||
|
||||
it("creates an in-memory database without errors", () => {
|
||||
expect(mgr).toBeDefined();
|
||||
});
|
||||
|
||||
it("addHistory inserts a row that getHistory returns", async () => {
|
||||
await mgr.addHistory(
|
||||
"mem-001",
|
||||
null,
|
||||
"User likes TypeScript",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
|
||||
const history = await mgr.getHistory("mem-001");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem-001");
|
||||
expect(history[0].new_value).toBe("User likes TypeScript");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
expect(history[0].previous_value).toBeNull();
|
||||
expect(history[0].is_deleted).toBe(0);
|
||||
});
|
||||
|
||||
it("returns history in DESC order (most recent first)", async () => {
|
||||
await mgr.addHistory("mem-002", null, "First", "ADD", "2026-01-01");
|
||||
await mgr.addHistory(
|
||||
"mem-002",
|
||||
"First",
|
||||
"Updated",
|
||||
"UPDATE",
|
||||
"2026-01-01",
|
||||
"2026-01-02",
|
||||
);
|
||||
await mgr.addHistory(
|
||||
"mem-002",
|
||||
"Updated",
|
||||
null,
|
||||
"DELETE",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
|
||||
const history = await mgr.getHistory("mem-002");
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0].action).toBe("DELETE");
|
||||
expect(history[1].action).toBe("UPDATE");
|
||||
expect(history[2].action).toBe("ADD");
|
||||
expect(history[0].is_deleted).toBe(1);
|
||||
});
|
||||
|
||||
it("isolates history by memory_id", async () => {
|
||||
await mgr.addHistory("mem-A", null, "Fact A", "ADD", "2026-01-01");
|
||||
await mgr.addHistory("mem-B", null, "Fact B", "ADD", "2026-01-01");
|
||||
|
||||
expect(await mgr.getHistory("mem-A")).toHaveLength(1);
|
||||
expect(await mgr.getHistory("mem-B")).toHaveLength(1);
|
||||
expect((await mgr.getHistory("mem-A"))[0].new_value).toBe("Fact A");
|
||||
expect((await mgr.getHistory("mem-B"))[0].new_value).toBe("Fact B");
|
||||
});
|
||||
|
||||
it("handles NULL/undefined optional fields correctly", async () => {
|
||||
await mgr.addHistory(
|
||||
"mem-null",
|
||||
null,
|
||||
null,
|
||||
"DELETE",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
|
||||
const history = await mgr.getHistory("mem-null");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].previous_value).toBeNull();
|
||||
expect(history[0].new_value).toBeNull();
|
||||
expect(history[0].created_at).toBeNull();
|
||||
expect(history[0].updated_at).toBeNull();
|
||||
});
|
||||
|
||||
it("reset() clears all history and allows re-insertion", async () => {
|
||||
await mgr.addHistory("mem-003", null, "Fact", "ADD", "2026-01-01");
|
||||
expect(await mgr.getHistory("mem-003")).toHaveLength(1);
|
||||
|
||||
await mgr.reset();
|
||||
expect(await mgr.getHistory("mem-003")).toHaveLength(0);
|
||||
|
||||
await mgr.addHistory("mem-004", null, "New fact", "ADD", "2026-02-01");
|
||||
expect(await mgr.getHistory("mem-004")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("works with file-based database and persists data", async () => {
|
||||
const dbPath = path.join(os.tmpdir(), `mem0-test-history-${Date.now()}.db`);
|
||||
|
||||
try {
|
||||
const fmgr = new SQLiteManager(dbPath);
|
||||
await fmgr.addHistory(
|
||||
"mem-file",
|
||||
null,
|
||||
"Persistent",
|
||||
"ADD",
|
||||
"2026-01-01",
|
||||
);
|
||||
fmgr.close();
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
expect(fs.statSync(dbPath).size).toBeGreaterThan(0);
|
||||
|
||||
// Reopen and verify
|
||||
const fmgr2 = new SQLiteManager(dbPath);
|
||||
const history = await fmgr2.getHistory("mem-file");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].new_value).toBe("Persistent");
|
||||
fmgr2.close();
|
||||
} finally {
|
||||
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles many rapid insertions", async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await mgr.addHistory(
|
||||
`mem-rapid-${i}`,
|
||||
null,
|
||||
`Fact ${i}`,
|
||||
"ADD",
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const h = await mgr.getHistory(`mem-rapid-${i}`);
|
||||
expect(h).toHaveLength(1);
|
||||
expect(h[0].new_value).toBe(`Fact ${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryVectorStore tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MemoryVectorStore (better-sqlite3)", () => {
|
||||
const DIM = 4;
|
||||
let store: MemoryVectorStore;
|
||||
let dbPath: string;
|
||||
|
||||
function normalize(v: number[]): number[] {
|
||||
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
||||
return v.map((x) => x / norm);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = path.join(os.tmpdir(), `mem0-test-vectors-${Date.now()}.db`);
|
||||
store = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
||||
});
|
||||
|
||||
it("insert + get returns the stored payload", async () => {
|
||||
const v = normalize([1, 0, 0, 0]);
|
||||
await store.insert([v], ["id-1"], [{ data: "hello", userId: "u1" }]);
|
||||
|
||||
const result = await store.get("id-1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe("id-1");
|
||||
expect(result!.payload.data).toBe("hello");
|
||||
expect(result!.payload.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("get returns null for non-existent id", async () => {
|
||||
const result = await store.get("nope");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("search returns results sorted by cosine similarity", async () => {
|
||||
const v1 = normalize([1, 0, 0, 0]);
|
||||
const v2 = normalize([0, 1, 0, 0]);
|
||||
const v3 = normalize([1, 1, 0, 0]);
|
||||
|
||||
await store.insert(
|
||||
[v1, v2, v3],
|
||||
["id-1", "id-2", "id-3"],
|
||||
[{ data: "exact" }, { data: "orthogonal" }, { data: "close" }],
|
||||
);
|
||||
|
||||
const results = await store.search(v1, 3);
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0].id).toBe("id-1");
|
||||
expect(results[0].score).toBeCloseTo(1.0, 5);
|
||||
expect(results[1].id).toBe("id-3");
|
||||
expect(results[2].id).toBe("id-2");
|
||||
expect(results[2].score).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("search respects limit", async () => {
|
||||
const vectors = [];
|
||||
const ids = [];
|
||||
const payloads = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const v = [0, 0, 0, 0];
|
||||
v[i % DIM] = 1;
|
||||
vectors.push(normalize(v));
|
||||
ids.push(`id-${i}`);
|
||||
payloads.push({ data: `item-${i}` });
|
||||
}
|
||||
await store.insert(vectors, ids, payloads);
|
||||
|
||||
const results = await store.search(normalize([1, 0, 0, 0]), 3);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("search respects filters", async () => {
|
||||
const v = normalize([1, 0, 0, 0]);
|
||||
await store.insert(
|
||||
[v, v],
|
||||
["id-1", "id-2"],
|
||||
[
|
||||
{ data: "a", userId: "alice" },
|
||||
{ data: "b", userId: "bob" },
|
||||
],
|
||||
);
|
||||
|
||||
const results = await store.search(v, 10, { userId: "alice" });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe("id-1");
|
||||
});
|
||||
|
||||
it("search throws on dimension mismatch", async () => {
|
||||
await expect(store.search([1, 0, 0], 10)).rejects.toThrow(
|
||||
"dimension mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("insert throws on dimension mismatch", async () => {
|
||||
await expect(
|
||||
store.insert([[1, 0, 0]], ["id-1"], [{ data: "x" }]),
|
||||
).rejects.toThrow("dimension mismatch");
|
||||
});
|
||||
|
||||
it("update modifies the stored vector and payload", async () => {
|
||||
const v1 = normalize([1, 0, 0, 0]);
|
||||
const v2 = normalize([0, 1, 0, 0]);
|
||||
await store.insert([v1], ["id-1"], [{ data: "original" }]);
|
||||
|
||||
await store.update("id-1", v2, { data: "updated" });
|
||||
|
||||
const result = await store.get("id-1");
|
||||
expect(result!.payload.data).toBe("updated");
|
||||
|
||||
const results = await store.search(v2, 1);
|
||||
expect(results[0].id).toBe("id-1");
|
||||
expect(results[0].score).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it("delete removes the vector", async () => {
|
||||
const v = normalize([1, 0, 0, 0]);
|
||||
await store.insert([v], ["id-1"], [{ data: "doomed" }]);
|
||||
|
||||
await store.delete("id-1");
|
||||
expect(await store.get("id-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteCol drops and recreates table", async () => {
|
||||
const v = normalize([1, 0, 0, 0]);
|
||||
await store.insert([v], ["id-1"], [{ data: "will be gone" }]);
|
||||
|
||||
await store.deleteCol();
|
||||
expect(await store.get("id-1")).toBeNull();
|
||||
|
||||
await store.insert([v], ["id-2"], [{ data: "fresh" }]);
|
||||
expect(await store.get("id-2")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("list returns all vectors with optional filters", async () => {
|
||||
const v = normalize([1, 0, 0, 0]);
|
||||
await store.insert(
|
||||
[v, v, v],
|
||||
["id-1", "id-2", "id-3"],
|
||||
[
|
||||
{ data: "a", userId: "alice" },
|
||||
{ data: "b", userId: "bob" },
|
||||
{ data: "c", userId: "alice" },
|
||||
],
|
||||
);
|
||||
|
||||
const [all, totalAll] = await store.list();
|
||||
expect(all).toHaveLength(3);
|
||||
expect(totalAll).toBe(3);
|
||||
|
||||
const [filtered, totalFiltered] = await store.list({ userId: "alice" });
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(totalFiltered).toBe(2);
|
||||
});
|
||||
|
||||
it("list respects limit", async () => {
|
||||
const v = normalize([1, 0, 0, 0]);
|
||||
await store.insert(
|
||||
[v, v, v],
|
||||
["id-1", "id-2", "id-3"],
|
||||
[{ data: "a" }, { data: "b" }, { data: "c" }],
|
||||
);
|
||||
|
||||
const [results] = await store.list(undefined, 2);
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("getUserId generates and persists a userId", async () => {
|
||||
const id1 = await store.getUserId();
|
||||
expect(typeof id1).toBe("string");
|
||||
expect(id1.length).toBeGreaterThan(0);
|
||||
|
||||
const id2 = await store.getUserId();
|
||||
expect(id2).toBe(id1);
|
||||
});
|
||||
|
||||
it("setUserId overrides the stored userId", async () => {
|
||||
await store.setUserId("custom-user-123");
|
||||
const id = await store.getUserId();
|
||||
expect(id).toBe("custom-user-123");
|
||||
});
|
||||
|
||||
it("INSERT OR REPLACE upserts on id conflict", async () => {
|
||||
const v1 = normalize([1, 0, 0, 0]);
|
||||
const v2 = normalize([0, 1, 0, 0]);
|
||||
await store.insert([v1], ["id-1"], [{ data: "original" }]);
|
||||
await store.insert([v2], ["id-1"], [{ data: "replaced" }]);
|
||||
|
||||
const result = await store.get("id-1");
|
||||
expect(result!.payload.data).toBe("replaced");
|
||||
|
||||
const [all] = await store.list();
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("file-based database persists across reopens", async () => {
|
||||
const v = normalize([1, 0, 0, 0]);
|
||||
await store.insert([v], ["id-persist"], [{ data: "persistent" }]);
|
||||
|
||||
const store2 = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
|
||||
const result = await store2.get("id-persist");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.payload.data).toBe("persistent");
|
||||
});
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
/**
|
||||
* Backward-compatibility tests for SQLite path handling changes.
|
||||
*
|
||||
* These tests verify that every documented and common usage pattern
|
||||
* from before the fix continues to work identically after the change.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { ConfigManager } from "../config/manager";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
function normalize(vector: number[]): number[] {
|
||||
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||
return vector.map((value) => value / norm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Config merging – existing patterns must keep working
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: ConfigManager.mergeConfig", () => {
|
||||
it("empty config returns all expected defaults", () => {
|
||||
const cfg = ConfigManager.mergeConfig({});
|
||||
|
||||
expect(cfg.version).toBe("v1.1");
|
||||
expect(cfg.embedder.provider).toBe("openai");
|
||||
expect(cfg.vectorStore.provider).toBe("memory");
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("memories");
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
expect(cfg.llm.provider).toBe("openai");
|
||||
expect(cfg.historyStore).toBeDefined();
|
||||
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore!.config.historyDbPath).toBe("memory.db");
|
||||
expect(cfg.disableHistory).toBe(false);
|
||||
expect(cfg.enableGraph).toBe(false);
|
||||
});
|
||||
|
||||
it("workaround: explicit historyStore still works (existing user pattern)", () => {
|
||||
// This is the documented workaround from all three issues
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/workaround.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore!.config.historyDbPath).toBe("/tmp/workaround.db");
|
||||
});
|
||||
|
||||
it("disableHistory: true still works", () => {
|
||||
const cfg = ConfigManager.mergeConfig({ disableHistory: true });
|
||||
expect(cfg.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("supabase historyStore config is preserved", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "supabase",
|
||||
config: {
|
||||
supabaseUrl: "https://abc.supabase.co",
|
||||
supabaseKey: "secret-key",
|
||||
tableName: "custom_history",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore!.provider).toBe("supabase");
|
||||
expect(cfg.historyStore!.config.supabaseUrl).toBe(
|
||||
"https://abc.supabase.co",
|
||||
);
|
||||
expect(cfg.historyStore!.config.supabaseKey).toBe("secret-key");
|
||||
expect(cfg.historyStore!.config.tableName).toBe("custom_history");
|
||||
});
|
||||
|
||||
it("custom embedder, llm, vectorStore configs pass through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", url: "http://localhost:11434" },
|
||||
},
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.1:8b" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
collectionName: "test",
|
||||
dimension: 768,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.embedder.provider).toBe("ollama");
|
||||
expect(cfg.embedder.config.model).toBe("nomic-embed-text");
|
||||
expect(cfg.llm.provider).toBe("ollama");
|
||||
expect(cfg.llm.config.model).toBe("llama3.1:8b");
|
||||
expect(cfg.vectorStore.provider).toBe("qdrant");
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("graphStore config passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
enableGraph: true,
|
||||
graphStore: {
|
||||
provider: "neo4j",
|
||||
config: {
|
||||
url: "neo4j://custom:7687",
|
||||
username: "admin",
|
||||
password: "pass",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.enableGraph).toBe(true);
|
||||
expect(cfg.graphStore!.config.url).toBe("neo4j://custom:7687");
|
||||
});
|
||||
|
||||
it("customPrompt passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
customPrompt: "You are a helpful assistant",
|
||||
});
|
||||
expect(cfg.customPrompt).toBe("You are a helpful assistant");
|
||||
});
|
||||
|
||||
it("version override passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({ version: "v1.0" });
|
||||
expect(cfg.version).toBe("v1.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. SQLiteManager – existing behavior preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: SQLiteManager", () => {
|
||||
it("relative path still works (resolves from CWD)", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
const manager = new SQLiteManager("memory.db");
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
const history = await manager.getHistory("m1");
|
||||
|
||||
expect(history).toHaveLength(1);
|
||||
expect(fs.existsSync(path.join(tempDir, "memory.db"))).toBe(true);
|
||||
manager.close();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("absolute path still works", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||
const dbPath = path.join(tempDir, "history.db");
|
||||
|
||||
try {
|
||||
const manager = new SQLiteManager(dbPath);
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
manager.close();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(":memory: still works", async () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
const history = await manager.getHistory("m1");
|
||||
expect(history).toHaveLength(1);
|
||||
manager.close();
|
||||
});
|
||||
|
||||
it("reset clears history and allows re-use", async () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
await manager.addHistory("m1", null, "val", "ADD");
|
||||
await manager.reset();
|
||||
const history = await manager.getHistory("m1");
|
||||
expect(history).toHaveLength(0);
|
||||
await manager.addHistory("m2", null, "new-val", "ADD");
|
||||
const history2 = await manager.getHistory("m2");
|
||||
expect(history2).toHaveLength(1);
|
||||
manager.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. MemoryVectorStore – existing API preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: MemoryVectorStore", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("explicit dbPath still works (the existing config.dbPath feature)", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "my_vectors.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await store.insert([normalize([1, 0, 0])], ["id1"], [{ text: "hello" }]);
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
|
||||
const result = await store.get("id1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.payload.text).toBe("hello");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("insert, search, get, update, delete, list all work", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
const v1 = normalize([1, 0, 0]);
|
||||
const v2 = normalize([0, 1, 0]);
|
||||
|
||||
// insert
|
||||
await store.insert([v1, v2], ["a", "b"], [{ t: "a" }, { t: "b" }]);
|
||||
|
||||
// get
|
||||
const a = await store.get("a");
|
||||
expect(a!.payload.t).toBe("a");
|
||||
|
||||
// search
|
||||
const results = await store.search(v1, 2);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].id).toBe("a"); // closest to v1
|
||||
|
||||
// update
|
||||
await store.update("a", v2, { t: "updated" });
|
||||
const updated = await store.get("a");
|
||||
expect(updated!.payload.t).toBe("updated");
|
||||
|
||||
// list
|
||||
const [listed, count] = await store.list();
|
||||
expect(count).toBe(2);
|
||||
expect(listed).toHaveLength(2);
|
||||
|
||||
// delete
|
||||
await store.delete("a");
|
||||
const deleted = await store.get("a");
|
||||
expect(deleted).toBeNull();
|
||||
|
||||
// deleteCol
|
||||
await store.deleteCol();
|
||||
const [afterDrop] = await store.list();
|
||||
expect(afterDrop).toHaveLength(0);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("dimension mismatch on insert still throws", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await expect(
|
||||
store.insert([[1, 0]], ["id1"], [{ t: "x" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("dimension mismatch on search still throws", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await expect(store.search([1, 0], 1)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("default dimension is 1536 when not specified", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
try {
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
const store = new MemoryVectorStore({});
|
||||
// Verify by trying to insert a 1536-dim vector
|
||||
const vec = new Array(1536).fill(0);
|
||||
vec[0] = 1;
|
||||
expect(store.insert([vec], ["id1"], [{ t: "x" }])).resolves.not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("search with filters still works", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await store.insert(
|
||||
[normalize([1, 0, 0]), normalize([0, 1, 0])],
|
||||
["a", "b"],
|
||||
[
|
||||
{ text: "hello", userId: "user1" },
|
||||
{ text: "world", userId: "user2" },
|
||||
],
|
||||
);
|
||||
|
||||
const results = await store.search(normalize([1, 0, 0]), 10, {
|
||||
userId: "user2",
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe("b");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. VectorStoreConfig type – dbPath is optional, existing configs work
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: VectorStoreConfig type", () => {
|
||||
it("config without dbPath still works (no required field breakage)", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 512 },
|
||||
},
|
||||
});
|
||||
expect(cfg.vectorStore.config.dbPath).toBeUndefined();
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(512);
|
||||
});
|
||||
|
||||
it("config with client instance passes through unchanged", () => {
|
||||
const fakeClient = { connect: () => {} };
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { client: fakeClient, dimension: 768 },
|
||||
},
|
||||
});
|
||||
expect(cfg.vectorStore.config.client).toBe(fakeClient);
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. ensureSQLiteDirectory – does not break existing paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: ensureSQLiteDirectory", () => {
|
||||
it("no-ops for already existing directory", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-existing-"));
|
||||
try {
|
||||
// Should not throw even though directory already exists
|
||||
expect(() =>
|
||||
ensureSQLiteDirectory(path.join(tempDir, "test.db")),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("handles path with trailing slash gracefully", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-trailing-"));
|
||||
try {
|
||||
// path.dirname of "dir/sub/" is "dir/sub", mkdirSync should handle it
|
||||
expect(() =>
|
||||
ensureSQLiteDirectory(path.join(tempDir, "sub", "test.db")),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,290 +0,0 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { ConfigManager } from "../config/manager";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
function normalize(vector: number[]): number[] {
|
||||
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||
return vector.map((value) => value / norm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config merging – historyDbPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ConfigManager.mergeConfig – historyDbPath handling", () => {
|
||||
it("propagates top-level historyDbPath into historyStore.config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/custom/history.db",
|
||||
});
|
||||
expect(cfg.historyDbPath).toBe("/tmp/custom/history.db");
|
||||
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe(
|
||||
"/tmp/custom/history.db",
|
||||
);
|
||||
});
|
||||
|
||||
it("explicit historyStore.config.historyDbPath takes precedence over top-level", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/shorthand.db",
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/explicit.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/explicit.db");
|
||||
});
|
||||
|
||||
it("preserves default memory.db when nothing is provided", () => {
|
||||
const cfg = ConfigManager.mergeConfig({});
|
||||
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("memory.db");
|
||||
});
|
||||
|
||||
it("respects only historyStore.config when top-level is absent", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/nested-only.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/nested-only.db");
|
||||
});
|
||||
|
||||
it("does not leak historyDbPath into non-sqlite providers", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/should-not-apply.db",
|
||||
historyStore: {
|
||||
provider: "supabase",
|
||||
config: {
|
||||
supabaseUrl: "https://x.supabase.co",
|
||||
supabaseKey: "key",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.provider).toBe("supabase");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disableHistory does not prevent historyStore config from merging", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
disableHistory: true,
|
||||
historyDbPath: "/tmp/disabled.db",
|
||||
});
|
||||
expect(cfg.disableHistory).toBe(true);
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/disabled.db");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLiteManager – directory creation & DB operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SQLiteManager – directory auto-creation", () => {
|
||||
it("creates nested parent directories and writes to the DB", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-sqlite-"));
|
||||
const dbPath = path.join(tempDir, "a", "b", "c", "history.db");
|
||||
let manager: SQLiteManager | undefined;
|
||||
|
||||
try {
|
||||
manager = new SQLiteManager(dbPath);
|
||||
await manager.addHistory("mem-1", null, "test value", "ADD");
|
||||
const history = await manager.getHistory("mem-1");
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].new_value).toBe("test value");
|
||||
} finally {
|
||||
manager?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("end-to-end: mergeConfig + SQLiteManager at configured path", async () => {
|
||||
const tempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "mem0-history-path-"),
|
||||
);
|
||||
const historyDbPath = path.join(tempDir, "nested", "history.db");
|
||||
let manager: SQLiteManager | undefined;
|
||||
|
||||
try {
|
||||
const mergedConfig = ConfigManager.mergeConfig({ historyDbPath });
|
||||
|
||||
manager = new SQLiteManager(
|
||||
mergedConfig.historyStore!.config.historyDbPath!,
|
||||
);
|
||||
await manager.addHistory("memory-1", null, "remember me", "ADD");
|
||||
|
||||
expect(fs.existsSync(historyDbPath)).toBe(true);
|
||||
} finally {
|
||||
manager?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("works with :memory: without attempting directory creation", () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
expect(manager).toBeDefined();
|
||||
manager.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryVectorStore – path handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MemoryVectorStore – path handling", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses ~/.mem0/vector_store.db by default", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
try {
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
new MemoryVectorStore({ dimension: 4 });
|
||||
expect(
|
||||
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects explicit dbPath config", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||
const dbPath = path.join(tempDir, "custom", "vectors.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 4, dbPath });
|
||||
await store.insert(
|
||||
[normalize([1, 0, 0, 0])],
|
||||
["v1"],
|
||||
[{ text: "hello" }],
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
const results = await store.search(normalize([1, 0, 0, 0]), 1);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].payload.text).toBe("hello");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("works when CWD is read-only", async () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
const readOnlyCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ro-"));
|
||||
|
||||
try {
|
||||
fs.chmodSync(readOnlyCwd, 0o555);
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
process.chdir(readOnlyCwd);
|
||||
|
||||
const store = new MemoryVectorStore({ dimension: 4 });
|
||||
await store.insert(
|
||||
[normalize([0, 1, 0, 0])],
|
||||
["v2"],
|
||||
[{ text: "works" }],
|
||||
);
|
||||
|
||||
expect(
|
||||
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(readOnlyCwd, "vector_store.db"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.chmodSync(readOnlyCwd, 0o755);
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(readOnlyCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits migration warning when old CWD-based vector_store.db exists", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.chdir(tempCwd);
|
||||
|
||||
new MemoryVectorStore({ dimension: 4 });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Default vector_store.db location changed"),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT emit migration warning when dbPath is explicitly set", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.chdir(tempCwd);
|
||||
|
||||
new MemoryVectorStore({
|
||||
dimension: 4,
|
||||
dbPath: path.join(tempDir, "explicit.db"),
|
||||
});
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utils
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ensureSQLiteDirectory", () => {
|
||||
it("creates nested directories", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ensure-"));
|
||||
const target = path.join(tempDir, "x", "y", "z", "test.db");
|
||||
try {
|
||||
ensureSQLiteDirectory(target);
|
||||
expect(fs.existsSync(path.join(tempDir, "x", "y", "z"))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips :memory:", () => {
|
||||
expect(() => ensureSQLiteDirectory(":memory:")).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips file: URIs", () => {
|
||||
expect(() => ensureSQLiteDirectory("file::memory:")).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips empty string", () => {
|
||||
expect(() => ensureSQLiteDirectory("")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultVectorStoreDbPath", () => {
|
||||
it("returns path under homedir/.mem0", () => {
|
||||
const result = getDefaultVectorStoreDbPath();
|
||||
expect(result).toBe(path.join(os.homedir(), ".mem0", "vector_store.db"));
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ export interface Message {
|
||||
export interface EmbeddingConfig {
|
||||
apiKey?: string;
|
||||
model?: string | any;
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
embeddingDims?: number;
|
||||
modelProperties?: Record<string, any>;
|
||||
@@ -24,7 +23,6 @@ export interface EmbeddingConfig {
|
||||
export interface VectorStoreConfig {
|
||||
collectionName?: string;
|
||||
dimension?: number;
|
||||
dbPath?: string;
|
||||
client?: any;
|
||||
instance?: any;
|
||||
[key: string]: any;
|
||||
@@ -43,7 +41,6 @@ export interface HistoryStoreConfig {
|
||||
export interface LLMConfig {
|
||||
provider?: string;
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
config?: Record<string, any>;
|
||||
apiKey?: string;
|
||||
model?: string | any;
|
||||
@@ -132,7 +129,6 @@ export const MemoryConfigSchema = z.object({
|
||||
.object({
|
||||
collectionName: z.string().optional(),
|
||||
dimension: z.number().optional(),
|
||||
dbPath: z.string().optional(),
|
||||
client: z.any().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
@@ -144,7 +140,6 @@ export const MemoryConfigSchema = z.object({
|
||||
model: z.union([z.string(), z.any()]).optional(),
|
||||
modelProperties: z.record(z.string(), z.any()).optional(),
|
||||
baseURL: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
historyDbPath: z.string().optional(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { OpenAIEmbedder } from "../embeddings/openai";
|
||||
import { OllamaEmbedder } from "../embeddings/ollama";
|
||||
import { LMStudioEmbedder } from "../embeddings/lmstudio";
|
||||
import { OpenAILLM } from "../llms/openai";
|
||||
import { OpenAIStructuredLLM } from "../llms/openai_structured";
|
||||
import { AnthropicLLM } from "../llms/anthropic";
|
||||
@@ -20,7 +19,6 @@ import { Qdrant } from "../vector_stores/qdrant";
|
||||
import { VectorizeDB } from "../vector_stores/vectorize";
|
||||
import { RedisDB } from "../vector_stores/redis";
|
||||
import { OllamaLLM } from "../llms/ollama";
|
||||
import { LMStudioLLM } from "../llms/lmstudio";
|
||||
import { SupabaseDB } from "../vector_stores/supabase";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryHistoryManager } from "../storage/MemoryHistoryManager";
|
||||
@@ -42,8 +40,6 @@ export class EmbedderFactory {
|
||||
return new OpenAIEmbedder(config);
|
||||
case "ollama":
|
||||
return new OllamaEmbedder(config);
|
||||
case "lmstudio":
|
||||
return new LMStudioEmbedder(config);
|
||||
case "google":
|
||||
case "gemini":
|
||||
return new GoogleEmbedder(config);
|
||||
@@ -70,8 +66,6 @@ export class LLMFactory {
|
||||
return new GroqLLM(config);
|
||||
case "ollama":
|
||||
return new OllamaLLM(config);
|
||||
case "lmstudio":
|
||||
return new LMStudioLLM(config);
|
||||
case "google":
|
||||
case "gemini":
|
||||
return new GoogleLLM(config);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
export function getDefaultVectorStoreDbPath(): string {
|
||||
return path.join(os.homedir(), ".mem0", "vector_store.db");
|
||||
}
|
||||
|
||||
export function ensureSQLiteDirectory(dbPath: string): void {
|
||||
if (!dbPath || dbPath === ":memory:" || dbPath.startsWith("file:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
@@ -81,7 +81,6 @@ export class AzureAISearch implements VectorStore {
|
||||
private readonly hybridSearch: boolean;
|
||||
private readonly vectorFilterMode: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: AzureAISearchConfig) {
|
||||
this.serviceName = config.serviceName;
|
||||
@@ -118,13 +117,6 @@ export class AzureAISearch implements VectorStore {
|
||||
* Initialize the Azure AI Search index if it doesn't exist
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
const collections = await this.listCols();
|
||||
if (!collections.includes(this.indexName)) {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import sqlite3 from "sqlite3";
|
||||
import path from "path";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
interface MemoryVector {
|
||||
id: string;
|
||||
@@ -15,31 +10,22 @@ interface MemoryVector {
|
||||
}
|
||||
|
||||
export class MemoryVectorStore implements VectorStore {
|
||||
private db: Database.Database;
|
||||
private db: sqlite3.Database;
|
||||
private dimension: number;
|
||||
private dbPath: string;
|
||||
|
||||
constructor(config: VectorStoreConfig) {
|
||||
this.dimension = config.dimension || 1536; // Default OpenAI dimension
|
||||
this.dbPath = config.dbPath || getDefaultVectorStoreDbPath();
|
||||
|
||||
if (!config.dbPath) {
|
||||
const oldDefault = path.join(process.cwd(), "vector_store.db");
|
||||
if (fs.existsSync(oldDefault) && oldDefault !== this.dbPath) {
|
||||
console.warn(
|
||||
`[mem0] Default vector_store.db location changed from ${oldDefault} to ${this.dbPath}. ` +
|
||||
`Move your existing file or set vectorStore.config.dbPath explicitly.`,
|
||||
);
|
||||
}
|
||||
this.dbPath = path.join(process.cwd(), "vector_store.db");
|
||||
if (config.dbPath) {
|
||||
this.dbPath = config.dbPath;
|
||||
}
|
||||
|
||||
ensureSQLiteDirectory(this.dbPath);
|
||||
this.db = new Database(this.dbPath);
|
||||
this.init();
|
||||
this.db = new sqlite3.Database(this.dbPath);
|
||||
this.init().catch(console.error);
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.db.exec(`
|
||||
private async init() {
|
||||
await this.run(`
|
||||
CREATE TABLE IF NOT EXISTS vectors (
|
||||
id TEXT PRIMARY KEY,
|
||||
vector BLOB NOT NULL,
|
||||
@@ -47,7 +33,7 @@ export class MemoryVectorStore implements VectorStore {
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
await this.run(`
|
||||
CREATE TABLE IF NOT EXISTS memory_migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE
|
||||
@@ -55,6 +41,33 @@ export class MemoryVectorStore implements VectorStore {
|
||||
`);
|
||||
}
|
||||
|
||||
private async run(sql: string, params: any[] = []): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, params, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async all(sql: string, params: any[] = []): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getOne(sql: string, params: any[] = []): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private cosineSimilarity(a: number[], b: number[]): number {
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
@@ -79,23 +92,18 @@ export class MemoryVectorStore implements VectorStore {
|
||||
ids: string[],
|
||||
payloads: Record<string, any>[],
|
||||
): Promise<void> {
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
|
||||
);
|
||||
const insertMany = this.db.transaction(
|
||||
(vecs: number[][], vIds: string[], vPayloads: Record<string, any>[]) => {
|
||||
for (let i = 0; i < vecs.length; i++) {
|
||||
if (vecs[i].length !== this.dimension) {
|
||||
throw new Error(
|
||||
`Vector dimension mismatch. Expected ${this.dimension}, got ${vecs[i].length}`,
|
||||
);
|
||||
}
|
||||
const vectorBuffer = Buffer.from(new Float32Array(vecs[i]).buffer);
|
||||
stmt.run(vIds[i], vectorBuffer, JSON.stringify(vPayloads[i]));
|
||||
}
|
||||
},
|
||||
);
|
||||
insertMany(vectors, ids, payloads);
|
||||
for (let i = 0; i < vectors.length; i++) {
|
||||
if (vectors[i].length !== this.dimension) {
|
||||
throw new Error(
|
||||
`Vector dimension mismatch. Expected ${this.dimension}, got ${vectors[i].length}`,
|
||||
);
|
||||
}
|
||||
const vectorBuffer = Buffer.from(new Float32Array(vectors[i]).buffer);
|
||||
await this.run(
|
||||
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
|
||||
[ids[i], vectorBuffer, JSON.stringify(payloads[i])],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async search(
|
||||
@@ -109,15 +117,11 @@ export class MemoryVectorStore implements VectorStore {
|
||||
);
|
||||
}
|
||||
|
||||
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
|
||||
const rows = await this.all(`SELECT * FROM vectors`);
|
||||
const results: VectorStoreResult[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const vector = new Float32Array(
|
||||
row.vector.buffer,
|
||||
row.vector.byteOffset,
|
||||
row.vector.byteLength / 4,
|
||||
);
|
||||
const vector = new Float32Array(row.vector.buffer);
|
||||
const payload = JSON.parse(row.payload);
|
||||
const memoryVector: MemoryVector = {
|
||||
id: row.id,
|
||||
@@ -140,9 +144,9 @@ export class MemoryVectorStore implements VectorStore {
|
||||
}
|
||||
|
||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM vectors WHERE id = ?`)
|
||||
.get(vectorId) as any;
|
||||
const row = await this.getOne(`SELECT * FROM vectors WHERE id = ?`, [
|
||||
vectorId,
|
||||
]);
|
||||
if (!row) return null;
|
||||
|
||||
const payload = JSON.parse(row.payload);
|
||||
@@ -163,38 +167,34 @@ export class MemoryVectorStore implements VectorStore {
|
||||
);
|
||||
}
|
||||
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
|
||||
this.db
|
||||
.prepare(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`)
|
||||
.run(vectorBuffer, JSON.stringify(payload), vectorId);
|
||||
await this.run(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`, [
|
||||
vectorBuffer,
|
||||
JSON.stringify(payload),
|
||||
vectorId,
|
||||
]);
|
||||
}
|
||||
|
||||
async delete(vectorId: string): Promise<void> {
|
||||
this.db.prepare(`DELETE FROM vectors WHERE id = ?`).run(vectorId);
|
||||
await this.run(`DELETE FROM vectors WHERE id = ?`, [vectorId]);
|
||||
}
|
||||
|
||||
async deleteCol(): Promise<void> {
|
||||
this.db.exec(`DROP TABLE IF EXISTS vectors`);
|
||||
this.init();
|
||||
await this.run(`DROP TABLE IF EXISTS vectors`);
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async list(
|
||||
filters?: SearchFilters,
|
||||
limit: number = 100,
|
||||
): Promise<[VectorStoreResult[], number]> {
|
||||
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
|
||||
const rows = await this.all(`SELECT * FROM vectors`);
|
||||
const results: VectorStoreResult[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const payload = JSON.parse(row.payload);
|
||||
const memoryVector: MemoryVector = {
|
||||
id: row.id,
|
||||
vector: Array.from(
|
||||
new Float32Array(
|
||||
row.vector.buffer,
|
||||
row.vector.byteOffset,
|
||||
row.vector.byteLength / 4,
|
||||
),
|
||||
),
|
||||
vector: Array.from(new Float32Array(row.vector.buffer)),
|
||||
payload,
|
||||
};
|
||||
|
||||
@@ -210,9 +210,9 @@ export class MemoryVectorStore implements VectorStore {
|
||||
}
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
const row = this.db
|
||||
.prepare(`SELECT user_id FROM memory_migrations LIMIT 1`)
|
||||
.get() as any;
|
||||
const row = await this.getOne(
|
||||
`SELECT user_id FROM memory_migrations LIMIT 1`,
|
||||
);
|
||||
if (row) {
|
||||
return row.user_id;
|
||||
}
|
||||
@@ -221,20 +221,20 @@ export class MemoryVectorStore implements VectorStore {
|
||||
const randomUserId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
this.db
|
||||
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
|
||||
.run(randomUserId);
|
||||
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
|
||||
randomUserId,
|
||||
]);
|
||||
return randomUserId;
|
||||
}
|
||||
|
||||
async setUserId(userId: string): Promise<void> {
|
||||
this.db.prepare(`DELETE FROM memory_migrations`).run();
|
||||
this.db
|
||||
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
|
||||
.run(userId);
|
||||
await this.run(`DELETE FROM memory_migrations`);
|
||||
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.init();
|
||||
await this.init();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export class Qdrant implements VectorStore {
|
||||
private client: QdrantClient;
|
||||
private readonly collectionName: string;
|
||||
private dimension: number;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: QdrantConfig) {
|
||||
if (config.client) {
|
||||
@@ -212,8 +211,22 @@ export class Qdrant implements VectorStore {
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
try {
|
||||
// Ensure collection exists (idempotent — handles race conditions)
|
||||
await this.ensureCollection("memory_migrations", 1);
|
||||
// First check if the collection exists
|
||||
const collections = await this.client.getCollections();
|
||||
const userCollectionExists = collections.collections.some(
|
||||
(col: { name: string }) => col.name === "memory_migrations",
|
||||
);
|
||||
|
||||
if (!userCollectionExists) {
|
||||
// Create the collection if it doesn't exist
|
||||
await this.client.createCollection("memory_migrations", {
|
||||
vectors: {
|
||||
size: 1,
|
||||
distance: "Cosine",
|
||||
on_disk: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Now try to get the user ID
|
||||
const result = await this.client.scroll("memory_migrations", {
|
||||
@@ -273,62 +286,66 @@ export class Qdrant implements VectorStore {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCollection(name: string, size: number): Promise<void> {
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.client.createCollection(name, {
|
||||
vectors: {
|
||||
size,
|
||||
distance: "Cosine",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error?.status === 409 ||
|
||||
error?.status === 401 ||
|
||||
error?.status === 403
|
||||
) {
|
||||
// Collection already exists — verify configuration for the main collection
|
||||
if (name === this.collectionName) {
|
||||
try {
|
||||
const collectionInfo = await this.client.getCollection(name);
|
||||
// Create collection if it doesn't exist
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(
|
||||
(c) => c.name === this.collectionName,
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.client.createCollection(this.collectionName, {
|
||||
vectors: {
|
||||
size: this.dimension,
|
||||
distance: "Cosine",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Handle case where collection was created between our check and create
|
||||
if (error?.status === 409) {
|
||||
// Collection already exists - verify it has the correct configuration
|
||||
const collectionInfo = await this.client.getCollection(
|
||||
this.collectionName,
|
||||
);
|
||||
const vectorConfig = collectionInfo.config?.params?.vectors;
|
||||
|
||||
if (vectorConfig && vectorConfig.size !== size) {
|
||||
if (!vectorConfig || vectorConfig.size !== this.dimension) {
|
||||
throw new Error(
|
||||
`Collection ${name} exists but has wrong vector size. ` +
|
||||
`Expected: ${size}, got: ${vectorConfig.size}`,
|
||||
`Collection ${this.collectionName} exists but has wrong configuration. ` +
|
||||
`Expected vector size: ${this.dimension}, got: ${vectorConfig?.size}`,
|
||||
);
|
||||
}
|
||||
} catch (verifyError: any) {
|
||||
// Re-throw dimension mismatch errors
|
||||
if (verifyError?.message?.includes("wrong vector size")) {
|
||||
throw verifyError;
|
||||
}
|
||||
// Transient errors (e.g. 500 while collection is being committed)
|
||||
// are non-fatal — the collection exists per the 409.
|
||||
console.warn(
|
||||
`Collection '${name}' exists (409) but dimension verification failed: ${verifyError?.message || verifyError}. Proceeding anyway.`,
|
||||
);
|
||||
// Collection exists with correct configuration - we can proceed
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Otherwise collection exists and is fine — proceed
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
// Create memory_migrations collection if it doesn't exist
|
||||
const userExists = collections.collections.some(
|
||||
(c) => c.name === "memory_migrations",
|
||||
);
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
await this.ensureCollection(this.collectionName, this.dimension);
|
||||
await this.ensureCollection("memory_migrations", 1);
|
||||
if (!userExists) {
|
||||
try {
|
||||
await this.client.createCollection("memory_migrations", {
|
||||
vectors: {
|
||||
size: 1, // Minimal size since we only store user_id
|
||||
distance: "Cosine",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Handle case where collection was created between our check and create
|
||||
if (error?.status === 409) {
|
||||
// Collection already exists - we can proceed
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing Qdrant:", error);
|
||||
throw error;
|
||||
|
||||
@@ -139,7 +139,6 @@ export class RedisDB implements VectorStore {
|
||||
private readonly indexName: string;
|
||||
private readonly indexPrefix: string;
|
||||
private readonly schema: RedisSchema;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: RedisConfig) {
|
||||
this.indexName = config.collectionName;
|
||||
@@ -241,13 +240,6 @@ export class RedisDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
await this.client.connect();
|
||||
console.log("Connected to Redis");
|
||||
|
||||
@@ -86,7 +86,6 @@ export class SupabaseDB implements VectorStore {
|
||||
private readonly tableName: string;
|
||||
private readonly embeddingColumnName: string;
|
||||
private readonly metadataColumnName: string;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: SupabaseConfig) {
|
||||
this.client = createClient(config.supabaseUrl, config.supabaseKey);
|
||||
@@ -101,13 +100,6 @@ export class SupabaseDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// Verify table exists and vector operations work by attempting a test insert
|
||||
const testVector = Array(1536).fill(0);
|
||||
|
||||
@@ -20,7 +20,6 @@ export class VectorizeDB implements VectorStore {
|
||||
private dimensions: number;
|
||||
private indexName: string;
|
||||
private accountId: string;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: VectorizeConfig) {
|
||||
this.client = new Cloudflare({ apiToken: config.apiKey });
|
||||
@@ -344,13 +343,6 @@ export class VectorizeDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// Check if the index already exists
|
||||
let indexFound = false;
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
import { ConfigManager } from "../src/config/manager";
|
||||
|
||||
describe("ConfigManager", () => {
|
||||
describe("mergeConfig - dimension handling", () => {
|
||||
const baseLlm = {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
};
|
||||
|
||||
it("should leave dimension undefined when no explicit dimension or embeddingDims provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "test-key" } },
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
// Dimension should be undefined so Memory._autoInitialize() will
|
||||
// auto-detect it via a probe embedding at runtime.
|
||||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use embeddingDims from embedder config when provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("should prefer explicit vector store dimension over embedder dims", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", dimension: 1024 },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(1024);
|
||||
});
|
||||
|
||||
it("should leave dimension undefined when using a custom client without explicit dims", () => {
|
||||
const mockClient = { someMethod: () => {} };
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", client: mockClient },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
// No embeddingDims and no explicit dimension → should be undefined
|
||||
// for auto-detection at runtime.
|
||||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use embeddingDims when using a custom client", () => {
|
||||
const mockClient = { someMethod: () => {} };
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", client: mockClient },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - LLM url passthrough for Ollama", () => {
|
||||
const baseEmbedder = {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
};
|
||||
const baseVectorStore = {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test" },
|
||||
};
|
||||
|
||||
it("should preserve url in LLM config when provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
|
||||
});
|
||||
|
||||
it("should prefer baseURL over url when both are provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: {
|
||||
model: "llama3.2:3b",
|
||||
baseURL: "http://custom:11434",
|
||||
url: "http://fallback:11434",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.baseURL).toBe("http://custom:11434");
|
||||
expect(config.llm.config.url).toBe("http://fallback:11434");
|
||||
});
|
||||
|
||||
it("should use default baseURL when no url or baseURL provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.llm.config.url).toBeUndefined();
|
||||
expect(config.llm.config.baseURL).toBe("https://api.openai.com/v1");
|
||||
});
|
||||
|
||||
it("should preserve url in embedder config (existing behavior)", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: {
|
||||
model: "nomic-embed-text",
|
||||
url: "http://10.0.0.100:11434",
|
||||
},
|
||||
},
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.embedder.config.url).toBe("http://10.0.0.100:11434");
|
||||
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// LM Studio snake_case normalization
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
describe("mergeConfig - LM Studio embedder config", () => {
|
||||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("normalizes lmstudio_base_url to baseURL for embedder", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
lmstudio_base_url: "http://192.168.1.1:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("lmstudio");
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://192.168.1.1:1234/v1");
|
||||
expect(cfg.embedder.config.model).toBe("nomic-embed-text-v1.5");
|
||||
});
|
||||
|
||||
it("normalizes embedding_dims to embeddingDims for embedder", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(768);
|
||||
});
|
||||
|
||||
it("prefers camelCase baseURL over snake_case lmstudio_base_url", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "test",
|
||||
baseURL: "http://camel:1234/v1",
|
||||
lmstudio_base_url: "http://snake:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://camel:1234/v1");
|
||||
});
|
||||
|
||||
it("prefers camelCase embeddingDims over snake_case embedding_dims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "test",
|
||||
embeddingDims: 1536,
|
||||
embedding_dims: 768,
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
});
|
||||
|
||||
it("passes through camelCase config without issues", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1");
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - LM Studio LLM config", () => {
|
||||
const baseEmbedder = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("normalizes lmstudio_base_url to baseURL for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1",
|
||||
lmstudio_base_url: "http://192.168.1.1:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
expect(cfg.llm.config.baseURL).toBe("http://192.168.1.1:1234/v1");
|
||||
expect(cfg.llm.config.model).toBe("meta-llama-3.1");
|
||||
});
|
||||
|
||||
it("prefers camelCase baseURL over lmstudio_base_url for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
baseURL: "http://camel:1234/v1",
|
||||
lmstudio_base_url: "http://snake:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.llm.config.baseURL).toBe("http://camel:1234/v1");
|
||||
});
|
||||
|
||||
it("falls back to default baseURL when neither is provided for LLM", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: { provider: "lmstudio", config: { model: "test-model" } },
|
||||
});
|
||||
|
||||
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig - full OpenClaw-style LM Studio config", () => {
|
||||
it("handles the exact config from issue #4235", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
embedding_dims: 1536,
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: "192.168.200.12",
|
||||
port: 6333,
|
||||
checkCompatibility: false,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.embedder.provider).toBe("lmstudio");
|
||||
expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1");
|
||||
expect(cfg.embedder.config.model).toBe(
|
||||
"text-embedding-gte-qwen2-1.5b-instruct",
|
||||
);
|
||||
expect(cfg.embedder.config.embeddingDims).toBe(1536);
|
||||
|
||||
expect(cfg.llm.provider).toBe("lmstudio");
|
||||
expect(cfg.llm.config.baseURL).toBe("http://192.168.200.83:1234/v1");
|
||||
expect(cfg.llm.config.model).toBe("openai/gpt-oss-20b");
|
||||
|
||||
expect(cfg.vectorStore.provider).toBe("qdrant");
|
||||
expect(cfg.vectorStore.config.host).toBe("192.168.200.12");
|
||||
expect(cfg.vectorStore.config.port).toBe(6333);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Memory class – LM Studio end-to-end flow (mocked factories)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe("Memory – LM Studio end-to-end flow", () => {
|
||||
let MemoryClass: any;
|
||||
let mockEmbedderFactory: any;
|
||||
let mockVectorStoreFactory: any;
|
||||
let mockLlmFactory: any;
|
||||
let mockHistoryFactory: any;
|
||||
let mockEmbedder: any;
|
||||
let mockVStore: any;
|
||||
let mockLlm: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
mockEmbedder = {
|
||||
embed: jest.fn().mockResolvedValue(new Array(768).fill(0.1)),
|
||||
embedBatch: jest.fn().mockResolvedValue([new Array(768).fill(0.1)]),
|
||||
};
|
||||
mockVStore = {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
deleteCol: jest.fn().mockResolvedValue(undefined),
|
||||
list: jest.fn().mockResolvedValue([[], 0]),
|
||||
getUserId: jest.fn().mockResolvedValue("test-user-id"),
|
||||
setUserId: jest.fn().mockResolvedValue(undefined),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockLlm = {
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
};
|
||||
|
||||
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
|
||||
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
|
||||
mockLlmFactory = { create: jest.fn().mockReturnValue(mockLlm) };
|
||||
mockHistoryFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
jest.doMock("../src/utils/factory", () => ({
|
||||
EmbedderFactory: mockEmbedderFactory,
|
||||
VectorStoreFactory: mockVectorStoreFactory,
|
||||
LLMFactory: mockLlmFactory,
|
||||
HistoryManagerFactory: mockHistoryFactory,
|
||||
}));
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
MemoryClass = require("../src/memory").Memory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("creates Memory with lmstudio embedder and llm providers", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-detects embedding dimension via probe with lmstudio", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
const vsCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("handles snake_case OpenClaw config through full Memory stack", async () => {
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
embedding_dims: 1536,
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "openai/gpt-oss-20b",
|
||||
lmstudio_base_url: "http://192.168.200.83:1234/v1",
|
||||
} as any,
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "text-embedding-gte-qwen2-1.5b-instruct",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
expect(mockLlmFactory.create).toHaveBeenCalledWith(
|
||||
"lmstudio",
|
||||
expect.objectContaining({
|
||||
model: "openai/gpt-oss-20b",
|
||||
baseURL: "http://192.168.200.83:1234/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("search flow works with lmstudio embedder", async () => {
|
||||
mockVStore.search.mockResolvedValueOnce([
|
||||
{
|
||||
id: "mem-1",
|
||||
payload: {
|
||||
data: "User likes hiking",
|
||||
user_id: "u1",
|
||||
hash: "abc123",
|
||||
created_at: "2026-01-01",
|
||||
},
|
||||
score: 0.95,
|
||||
},
|
||||
]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: { baseURL: "http://localhost:1234/v1" },
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
const result = await mem.search("What does the user like?", {
|
||||
userId: "u1",
|
||||
});
|
||||
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?");
|
||||
expect(mockVStore.search).toHaveBeenCalled();
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].memory).toBe("User likes hiking");
|
||||
});
|
||||
|
||||
it("add flow works with lmstudio LLM for fact extraction", async () => {
|
||||
mockLlm.generateResponse.mockResolvedValueOnce(
|
||||
'{"facts":["User loves sushi"]}',
|
||||
);
|
||||
mockVStore.search.mockResolvedValue([]);
|
||||
mockVStore.list.mockResolvedValue([[], 0]);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "nomic-embed-text-v1.5",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
embeddingDims: 768,
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 768 },
|
||||
},
|
||||
llm: {
|
||||
provider: "lmstudio",
|
||||
config: {
|
||||
model: "meta-llama-3.1-70b",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
},
|
||||
},
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.add("I love sushi", { userId: "u1" });
|
||||
|
||||
expect(mockLlm.generateResponse).toHaveBeenCalled();
|
||||
expect(mockEmbedder.embed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,519 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Tests for embedding dimension auto-detection.
|
||||
*
|
||||
* Covers:
|
||||
* - ConfigManager: dimension resolution logic
|
||||
* - Memory class: probe-based auto-detection, lazy init gate, backward compat
|
||||
* - MemoryVectorStore: backward compat with explicit dimensions
|
||||
* - Explicit error messages on probe failure
|
||||
*/
|
||||
|
||||
import { ConfigManager } from "../src/config/manager";
|
||||
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. ConfigManager – dimension resolution
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("ConfigManager – dimension resolution", () => {
|
||||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("leaves dimension undefined when nothing explicit is set", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: { provider: "memory", config: { collectionName: "t" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses embeddingDims from embedder config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("prefers explicit vectorStore.dimension over embeddingDims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", dimension: 1024 },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(1024);
|
||||
});
|
||||
|
||||
it("leaves dimension undefined for custom client without explicit dims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", client: {} },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses embeddingDims with a custom client", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", client: {} },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("preserves all other vectorStore config fields", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
collectionName: "my-coll",
|
||||
host: "my-host",
|
||||
port: 6333,
|
||||
apiKey: "qdrant-key",
|
||||
},
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("my-coll");
|
||||
expect(cfg.vectorStore.config.host).toBe("my-host");
|
||||
expect(cfg.vectorStore.config.port).toBe(6333);
|
||||
expect(cfg.vectorStore.config.apiKey).toBe("qdrant-key");
|
||||
});
|
||||
|
||||
it("leaves dimension undefined with empty config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: {} },
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. MemoryVectorStore – backward compat with explicit dimensions
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("MemoryVectorStore – backward compat", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("defaults to dimension 1536 when not specified", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const vector = new Array(1536).fill(0.1);
|
||||
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||
const result = await store.get("id-1");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("explicit dimension=1536 still works", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const vector = new Array(1536).fill(0.1);
|
||||
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||
const result = await store.get("id-1");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("explicit dimension rejects mismatched vectors", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const wrongVector = new Array(768).fill(0.1);
|
||||
await expect(
|
||||
store.insert([wrongVector], ["id-1"], [{ data: "hello" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
|
||||
it("search validates dimension", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await expect(store.search([1, 2, 3], 1)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("custom dimension=768 works end-to-end", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 768,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await store.insert(
|
||||
[
|
||||
[1, ...new Array(767).fill(0)],
|
||||
[0, 1, ...new Array(766).fill(0)],
|
||||
],
|
||||
["a", "b"],
|
||||
[{ data: "alpha" }, { data: "beta" }],
|
||||
);
|
||||
|
||||
const results = await store.search([1, ...new Array(767).fill(0)], 2);
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].id).toBe("a");
|
||||
});
|
||||
|
||||
it("getUserId and setUserId still work", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const userId = await store.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(userId.length).toBeGreaterThan(0);
|
||||
|
||||
await store.setUserId("custom-user");
|
||||
const newUserId = await store.getUserId();
|
||||
expect(newUserId).toBe("custom-user");
|
||||
});
|
||||
|
||||
it("initialize() is idempotent", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await store.initialize();
|
||||
await store.initialize();
|
||||
await store.initialize();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. Memory class – auto-init with probe, lazy gate, backward compat
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("Memory – auto-initialization", () => {
|
||||
let mockEmbedderFactory: any;
|
||||
let mockVectorStoreFactory: any;
|
||||
let mockLlmFactory: any;
|
||||
let mockHistoryFactory: any;
|
||||
let MemoryClass: any;
|
||||
|
||||
function createMockEmbedder(dims: number) {
|
||||
return {
|
||||
embed: jest.fn().mockResolvedValue(new Array(dims).fill(0)),
|
||||
embedBatch: jest.fn().mockResolvedValue([new Array(dims).fill(0)]),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockVectorStore() {
|
||||
return {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
deleteCol: jest.fn().mockResolvedValue(undefined),
|
||||
list: jest.fn().mockResolvedValue([[], 0]),
|
||||
getUserId: jest.fn().mockResolvedValue("test-user-id"),
|
||||
setUserId: jest.fn().mockResolvedValue(undefined),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
|
||||
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
|
||||
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
|
||||
mockLlmFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
};
|
||||
mockHistoryFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
jest.doMock("../src/utils/factory", () => ({
|
||||
EmbedderFactory: mockEmbedderFactory,
|
||||
VectorStoreFactory: mockVectorStoreFactory,
|
||||
LLMFactory: mockLlmFactory,
|
||||
HistoryManagerFactory: mockHistoryFactory,
|
||||
}));
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
MemoryClass = require("../src/memory").Memory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("probes embedder to detect dimension when none set", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// Should have called embed("dimension probe") to detect dimension
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// VectorStoreFactory should have been called with detected dimension
|
||||
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCreateCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("skips probe when explicit dimension provided", async () => {
|
||||
const mockEmbedder = createMockEmbedder(1536);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// embed should NOT have been called for probing
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// VectorStoreFactory gets the explicit dimension
|
||||
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCreateCall[1].dimension).toBe(1536);
|
||||
});
|
||||
|
||||
it("skips probe when embeddingDims provided", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// ConfigManager resolves dimension from embeddingDims → no probe needed
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
});
|
||||
|
||||
it("all public methods wait for initialization", async () => {
|
||||
let resolveProbe: () => void;
|
||||
let probeCallCount = 0;
|
||||
const mockEmbedder = {
|
||||
embed: jest.fn().mockImplementation(() => {
|
||||
probeCallCount++;
|
||||
if (probeCallCount === 1) {
|
||||
// First call is the dimension probe — hang until manually resolved
|
||||
return new Promise<number[]>((resolve) => {
|
||||
resolveProbe = () => resolve(new Array(768).fill(0));
|
||||
});
|
||||
}
|
||||
// Subsequent calls (from search, etc.) resolve immediately
|
||||
return Promise.resolve(new Array(768).fill(0));
|
||||
}),
|
||||
embedBatch: jest.fn(),
|
||||
};
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "test" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
let getAllDone = false;
|
||||
let searchDone = false;
|
||||
let getDone = false;
|
||||
|
||||
const getAllP = mem.getAll({ userId: "u" }).then(() => (getAllDone = true));
|
||||
const searchP = mem
|
||||
.search("q", { userId: "u" })
|
||||
.then(() => (searchDone = true));
|
||||
const getP = mem.get("id").then(() => (getDone = true));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(getAllDone).toBe(false);
|
||||
expect(searchDone).toBe(false);
|
||||
expect(getDone).toBe(false);
|
||||
|
||||
// Resolve the probe — init completes — methods unblock
|
||||
resolveProbe!();
|
||||
await Promise.all([getAllP, searchP, getP]);
|
||||
expect(getAllDone).toBe(true);
|
||||
expect(searchDone).toBe(true);
|
||||
expect(getDone).toBe(true);
|
||||
});
|
||||
|
||||
it("reset re-creates vector store with correct dimension", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset should re-create vector store
|
||||
const mockVStore2 = createMockVectorStore();
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore2);
|
||||
await mem.reset();
|
||||
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second creation should still have dimension=768 (cached from first probe)
|
||||
const secondCall = mockVectorStoreFactory.create.mock.calls[1];
|
||||
expect(secondCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("backward compat: full explicit config works without probe", async () => {
|
||||
const mockEmbedder = createMockEmbedder(1536);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "sk-fake", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "sk-fake", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
});
|
||||
|
||||
it("throws explicit error when probe fails", async () => {
|
||||
const mockEmbedder = {
|
||||
embed: jest.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
embedBatch: jest.fn(),
|
||||
};
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
// getAll should reject with the init error
|
||||
await expect(mem.getAll({ userId: "u1" })).rejects.toThrow(
|
||||
"auto-detect embedding dimension",
|
||||
);
|
||||
|
||||
// Verify the error was logged and contains helpful information
|
||||
const errorCall = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[0] instanceof Error &&
|
||||
call[0].message.includes("auto-detect embedding dimension"),
|
||||
);
|
||||
expect(errorCall).toBeDefined();
|
||||
const errorMsg = (errorCall![0] as Error).message;
|
||||
expect(errorMsg).toContain("ollama");
|
||||
expect(errorMsg).toContain("Connection refused");
|
||||
expect(errorMsg).toContain("dimension");
|
||||
expect(errorMsg).toContain("embeddingDims");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/// <reference types="jest" />
|
||||
import { VectorStoreFactory } from "../src/utils/factory";
|
||||
import { AzureAISearch } from "../src/vector_stores/azure_ai_search";
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
describe("create", () => {
|
||||
it("should create Azure AI Search vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
serviceName: "test-service",
|
||||
apiKey: "test-api-key",
|
||||
embeddingModelDims: 1536,
|
||||
compressionType: "none" as const,
|
||||
useFloat16: false,
|
||||
hybridSearch: false,
|
||||
vectorFilterMode: "preFilter" as const,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("azure-ai-search", config);
|
||||
|
||||
expect(vectorStore).toBeInstanceOf(AzureAISearch);
|
||||
});
|
||||
|
||||
it("should create memory vector store", () => {
|
||||
const config = {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
const vectorStore = VectorStoreFactory.create("memory", config);
|
||||
|
||||
expect(vectorStore).toBeDefined();
|
||||
expect(vectorStore.constructor.name).toBe("MemoryVectorStore");
|
||||
});
|
||||
|
||||
it("should throw error for unsupported provider", () => {
|
||||
const config = {};
|
||||
|
||||
expect(() => {
|
||||
VectorStoreFactory.create("unsupported-provider", config);
|
||||
}).toThrow("Unsupported vector store provider: unsupported-provider");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* Factory unit tests — EmbedderFactory, LLMFactory, VectorStoreFactory, HistoryManagerFactory.
|
||||
* Mocks all provider modules to avoid external dependency crashes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
|
||||
// Mock all provider modules before importing factory
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/ollama", () => ({
|
||||
OllamaEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/azure", () => ({
|
||||
AzureOpenAIEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/langchain", () => ({
|
||||
LangchainEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-embedder", config })),
|
||||
}));
|
||||
jest.mock("../src/embeddings/lmstudio", () => ({
|
||||
LMStudioEmbedder: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-embedder", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "openai-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/openai_structured", () => ({
|
||||
OpenAIStructuredLLM: jest.fn().mockImplementation((config) => ({
|
||||
type: "openai-structured-llm",
|
||||
config,
|
||||
})),
|
||||
}));
|
||||
jest.mock("../src/llms/anthropic", () => ({
|
||||
AnthropicLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "anthropic-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/groq", () => ({
|
||||
GroqLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "groq-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/ollama", () => ({
|
||||
OllamaLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "ollama-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "google-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/azure", () => ({
|
||||
AzureOpenAILLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/mistral", () => ({
|
||||
MistralLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "mistral-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/langchain", () => ({
|
||||
LangchainLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-llm", config })),
|
||||
}));
|
||||
jest.mock("../src/llms/lmstudio", () => ({
|
||||
LMStudioLLM: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "lmstudio-llm", config })),
|
||||
}));
|
||||
|
||||
jest.mock("../src/vector_stores/qdrant", () => ({
|
||||
Qdrant: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "qdrant", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/redis", () => ({
|
||||
RedisDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "redis", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/supabase", () => ({
|
||||
SupabaseDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/langchain", () => ({
|
||||
LangchainVectorStore: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "langchain-vs", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/vectorize", () => ({
|
||||
VectorizeDB: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "vectorize", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/azure_ai_search", () => ({
|
||||
AzureAISearch: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-ai-search", config })),
|
||||
}));
|
||||
jest.mock("../src/storage/SupabaseHistoryManager", () => ({
|
||||
SupabaseHistoryManager: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "supabase-history", config })),
|
||||
}));
|
||||
|
||||
import {
|
||||
EmbedderFactory,
|
||||
LLMFactory,
|
||||
VectorStoreFactory,
|
||||
HistoryManagerFactory,
|
||||
} from "../src/utils/factory";
|
||||
import type {
|
||||
EmbeddingConfig,
|
||||
LLMConfig,
|
||||
VectorStoreConfig,
|
||||
HistoryStoreConfig,
|
||||
} from "../src/types";
|
||||
|
||||
const dummyEmbedConfig: EmbeddingConfig = { apiKey: "test" };
|
||||
const dummyLLMConfig: LLMConfig = { apiKey: "test" };
|
||||
const dummyVSConfig: VectorStoreConfig = {
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
};
|
||||
|
||||
// ─── EmbedderFactory ────────────────────────────────────
|
||||
|
||||
describe("EmbedderFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates embedder for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create(provider, dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("OpenAI", dummyEmbedConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
EmbedderFactory.create("nonexistent", dummyEmbedConfig),
|
||||
).toThrow("Unsupported embedder provider: nonexistent");
|
||||
});
|
||||
|
||||
test("passes config to created embedder", () => {
|
||||
const config: EmbeddingConfig = { apiKey: "my-key", model: "my-model" };
|
||||
const result = EmbedderFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LLMFactory ─────────────────────────────────────────
|
||||
|
||||
describe("LLMFactory", () => {
|
||||
test.each([
|
||||
["openai"],
|
||||
["openai_structured"],
|
||||
["anthropic"],
|
||||
["groq"],
|
||||
["ollama"],
|
||||
["google"],
|
||||
["gemini"],
|
||||
["azure_openai"],
|
||||
["mistral"],
|
||||
["langchain"],
|
||||
["lmstudio"],
|
||||
])("creates LLM for provider '%s'", (provider) => {
|
||||
expect(() => LLMFactory.create(provider, dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(() => LLMFactory.create("Anthropic", dummyLLMConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() => LLMFactory.create("nonexistent", dummyLLMConfig)).toThrow(
|
||||
"Unsupported LLM provider: nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes config to created LLM", () => {
|
||||
const config: LLMConfig = { apiKey: "my-key", model: "gpt-4" };
|
||||
const result = LLMFactory.create("openai", config) as any;
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VectorStoreFactory ─────────────────────────────────
|
||||
|
||||
describe("VectorStoreFactory", () => {
|
||||
test("creates memory vector store", () => {
|
||||
// MemoryVectorStore is real (not mocked) — needs valid config
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("memory", {
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["qdrant"],
|
||||
["redis"],
|
||||
["supabase"],
|
||||
["langchain"],
|
||||
["vectorize"],
|
||||
["azure-ai-search"],
|
||||
])("creates vector store for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create(provider, dummyVSConfig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create("nonexistent", dummyVSConfig),
|
||||
).toThrow("Unsupported vector store provider: nonexistent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HistoryManagerFactory ──────────────────────────────
|
||||
|
||||
describe("HistoryManagerFactory", () => {
|
||||
test("creates SQLite history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: ":memory:" },
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("sqlite", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates supabase history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "supabase",
|
||||
config: { supabaseUrl: "http://test", supabaseKey: "key" },
|
||||
};
|
||||
expect(() =>
|
||||
HistoryManagerFactory.create("supabase", config),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("creates memory history manager", () => {
|
||||
const config: HistoryStoreConfig = {
|
||||
provider: "memory",
|
||||
config: {},
|
||||
};
|
||||
expect(() => HistoryManagerFactory.create("memory", config)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for unsupported provider", () => {
|
||||
const config: HistoryStoreConfig = { provider: "bad", config: {} };
|
||||
expect(() => HistoryManagerFactory.create("bad", config)).toThrow(
|
||||
"Unsupported history store provider: bad",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -1,548 +0,0 @@
|
||||
/**
|
||||
* Regression tests for graph_memory.ts response parsing (issue #4248).
|
||||
*
|
||||
* Exercises the three json_object call sites in MemoryGraph with a mocked LLM:
|
||||
* 1. _retrieveNodesFromData → entity extraction
|
||||
* 2. _establishNodesRelationsFromData → relation extraction
|
||||
* 3. _getDeleteEntitiesFromSearchOutput → deletion identification
|
||||
*
|
||||
* Covers: malformed LLM responses, missing fields, bad JSON in toolCalls,
|
||||
* string-only responses, empty tool calls, and prompt construction.
|
||||
*
|
||||
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||
*/
|
||||
|
||||
import { MemoryGraph } from "../src/memory/graph_memory";
|
||||
import {
|
||||
EXTRACT_RELATIONS_PROMPT,
|
||||
getDeleteMessages,
|
||||
} from "../src/graphs/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock neo4j-driver: provides a fake Driver with a no-op session
|
||||
jest.mock("neo4j-driver", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
driver: jest.fn(() => ({
|
||||
session: () => ({
|
||||
run: jest.fn().mockResolvedValue({ records: [] }),
|
||||
close: jest.fn(),
|
||||
}),
|
||||
})),
|
||||
auth: { basic: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock factory so constructor doesn't try to instantiate real LLMs / embedders
|
||||
const mockGenerateResponse = jest.fn();
|
||||
const mockGenerateChat = jest.fn();
|
||||
const mockEmbed = jest.fn().mockResolvedValue([0.1, 0.2, 0.3]);
|
||||
|
||||
jest.mock("../src/utils/factory", () => ({
|
||||
LLMFactory: {
|
||||
create: jest.fn(() => ({
|
||||
generateResponse: mockGenerateResponse,
|
||||
generateChat: mockGenerateChat,
|
||||
})),
|
||||
},
|
||||
EmbedderFactory: {
|
||||
create: jest.fn(() => ({
|
||||
embed: mockEmbed,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Minimal config that satisfies the MemoryGraph constructor
|
||||
function makeConfig(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
graphStore: {
|
||||
config: {
|
||||
url: "bolt://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "test",
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
embedder: { provider: "openai", config: {} },
|
||||
llm: { provider: "openai", config: {} },
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Helper to access private methods via `any` cast
|
||||
function graph(overrides: Record<string, any> = {}): any {
|
||||
return new MemoryGraph(makeConfig(overrides));
|
||||
}
|
||||
|
||||
const FILTERS = { userId: "test-user" };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. _retrieveNodesFromData – entity extraction
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_retrieveNodesFromData", () => {
|
||||
it("parses a well-formed extract_entities tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{ entity: "Alice", entity_type: "person" },
|
||||
{ entity: "Pizza", entity_type: "food" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData(
|
||||
"Alice likes pizza",
|
||||
FILTERS,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ alice: "person", pizza: "food" });
|
||||
});
|
||||
|
||||
it("returns empty map when LLM returns a plain string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("I am a string, not an object");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty map when toolCalls is undefined", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty map when toolCalls is an empty array", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles malformed JSON in tool call arguments gracefully", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{ name: "extract_entities", arguments: "NOT VALID JSON {{{" },
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// Should not throw — the catch block in the source logs the error
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles missing entities array in arguments", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({ wrong_key: [] }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// args.entities is undefined → for..of on undefined throws → caught
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("skips tool calls with unrelated names", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "some_other_tool",
|
||||
arguments: JSON.stringify({
|
||||
entities: [{ entity: "X", entity_type: "Y" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("normalises entity names to lowercase with underscores", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({
|
||||
entities: [{ entity: "New York City", entity_type: "City Name" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({ new_york_city: "city_name" });
|
||||
});
|
||||
|
||||
it("passes json_object response format and the correct system prompt", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._retrieveNodesFromData("test data", FILTERS);
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemMsg = messages[0].content as string;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
expect(systemMsg).toContain("test-user");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. _establishNodesRelationsFromData – relation extraction
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_establishNodesRelationsFromData", () => {
|
||||
it("parses a well-formed establish_relationships tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{ source: "Alice", relationship: "likes", destination: "Pizza" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData(
|
||||
"Alice likes pizza",
|
||||
FILTERS,
|
||||
{ alice: "person", pizza: "food" },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when LLM returns a string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("just a string");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when toolCalls is empty", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when entities key is missing from arguments", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({ not_entities: [] }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
// args.entities is undefined → falls back to []
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [{ name: "establish_relationships", arguments: "<<BROKEN>>" }],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// _establishNodesRelationsFromData does JSON.parse without try/catch
|
||||
await expect(
|
||||
mg._establishNodesRelationsFromData("x", FILTERS, {}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("appends JSON format suffix to system prompt (no custom prompt)", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._establishNodesRelationsFromData("data", FILTERS, { a: "b" });
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("test-user");
|
||||
expect(systemContent).not.toContain("USER_ID");
|
||||
// CUSTOM_PROMPT placeholder stays when no custom prompt is configured
|
||||
// (only replaced when config.graphStore.customPrompt is set)
|
||||
});
|
||||
|
||||
it("appends JSON format suffix and custom prompt when configured", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph({ customPrompt: "Focus on food relationships only." });
|
||||
await mg._establishNodesRelationsFromData("data", FILTERS, {});
|
||||
|
||||
const [messages] = mockGenerateResponse.mock.calls[0];
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("Focus on food relationships only.");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. _getDeleteEntitiesFromSearchOutput – deletion identification
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_getDeleteEntitiesFromSearchOutput", () => {
|
||||
const SEARCH_OUTPUT = [
|
||||
{
|
||||
source: "alice",
|
||||
source_id: "1",
|
||||
relationship: "likes",
|
||||
relation_id: "r1",
|
||||
destination: "pizza",
|
||||
destination_id: "2",
|
||||
similarity: 0.95,
|
||||
},
|
||||
];
|
||||
|
||||
it("parses a well-formed delete_graph_memory tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "Alice",
|
||||
relationship: "likes",
|
||||
destination: "Pizza",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"Alice hates pizza",
|
||||
FILTERS,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when LLM returns a string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("string response");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when no tool calls are present", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips non-delete_graph_memory tool calls", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "noop",
|
||||
arguments: JSON.stringify({}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("collects multiple delete tool calls", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "A",
|
||||
relationship: "r1",
|
||||
destination: "B",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "C",
|
||||
relationship: "r2",
|
||||
destination: "D",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].source).toBe("a");
|
||||
expect(result[1].source).toBe("c");
|
||||
});
|
||||
|
||||
it("passes json_object format and includes 'json' in system prompt", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "data", FILTERS);
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("test-user");
|
||||
expect(systemContent).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("handles empty searchOutput array", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
[],
|
||||
"data",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Prompt construction — JSON keyword present in every json_object site
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Prompt construction — all json_object sites include 'json'", () => {
|
||||
it("_retrieveNodesFromData system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars", "ユーザー"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._retrieveNodesFromData("test", { userId });
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("_establishNodesRelationsFromData system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._establishNodesRelationsFromData("test", { userId }, {});
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("_getDeleteEntitiesFromSearchOutput system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._getDeleteEntitiesFromSearchOutput([], "test", { userId });
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => {
|
||||
it("normalises spaces and case in entity source/relationship/destination", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{
|
||||
source: "New York",
|
||||
relationship: "Capital Of",
|
||||
destination: "United States",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData(
|
||||
"test",
|
||||
FILTERS,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: "new_york",
|
||||
relationship: "capital_of",
|
||||
destination: "united_states",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import {
|
||||
DELETE_RELATIONS_SYSTEM_PROMPT,
|
||||
EXTRACT_RELATIONS_PROMPT,
|
||||
UPDATE_GRAPH_PROMPT,
|
||||
getDeleteMessages,
|
||||
formatEntities,
|
||||
} from "../src/graphs/utils";
|
||||
|
||||
/**
|
||||
* Regression tests for graph prompts (issue #4248).
|
||||
*
|
||||
* When response_format: { type: "json_object" } is used, OpenAI requires
|
||||
* the word "json" (case-insensitive) to appear in at least one message.
|
||||
* Missing it produces a 400 error.
|
||||
*
|
||||
* Three call sites use json_object today:
|
||||
* 1. _getDeleteEntitiesFromSearchOutput → DELETE_RELATIONS_SYSTEM_PROMPT
|
||||
* 2. _retrieveNodesFromData → inline prompt (graph_memory.ts)
|
||||
* 3. _getRelatedEntities → EXTRACT_RELATIONS_PROMPT + suffix
|
||||
*
|
||||
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||
*/
|
||||
|
||||
// ─── JSON keyword presence ────────────────────────────────────────────────────
|
||||
|
||||
describe("Graph prompts — JSON keyword requirement", () => {
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT contains 'json'", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => {
|
||||
// graph_memory.ts appends "\nPlease provide your response in JSON format."
|
||||
const withSuffix =
|
||||
EXTRACT_RELATIONS_PROMPT +
|
||||
"\nPlease provide your response in JSON format.";
|
||||
expect(withSuffix.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("getDeleteMessages system message contains 'json' after USER_ID substitution", () => {
|
||||
const [systemContent] = getDeleteMessages(
|
||||
"alice -- loves -- pizza",
|
||||
"Alice now hates pizza",
|
||||
"user-42",
|
||||
);
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("entity extraction inline prompt contains 'json' (simulated from graph_memory.ts)", () => {
|
||||
// Mirrors the template in _retrieveNodesFromData()
|
||||
const userId = "user-1";
|
||||
const prompt = `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${userId} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`;
|
||||
expect(prompt.toLowerCase()).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDeleteMessages ────────────────────────────────────────────────────────
|
||||
|
||||
describe("getDeleteMessages", () => {
|
||||
it("replaces USER_ID with the provided userId in the system prompt", () => {
|
||||
const [system] = getDeleteMessages("mem", "data", "alice-123");
|
||||
expect(system).toContain("alice-123");
|
||||
expect(system).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("includes existing memories and new data in the user prompt", () => {
|
||||
const existing = "bob -- knows -- carol";
|
||||
const newData = "Bob no longer knows Carol";
|
||||
const [, user] = getDeleteMessages(existing, newData, "u1");
|
||||
expect(user).toContain(existing);
|
||||
expect(user).toContain(newData);
|
||||
});
|
||||
|
||||
it("returns a 2-tuple [system, user]", () => {
|
||||
const result = getDeleteMessages("a", "b", "c");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(typeof result[0]).toBe("string");
|
||||
expect(typeof result[1]).toBe("string");
|
||||
});
|
||||
|
||||
// — Malformed / edge-case inputs —
|
||||
|
||||
it("handles empty strings without throwing", () => {
|
||||
expect(() => getDeleteMessages("", "", "")).not.toThrow();
|
||||
const [system, user] = getDeleteMessages("", "", "");
|
||||
expect(system.toLowerCase()).toContain("json");
|
||||
expect(typeof user).toBe("string");
|
||||
});
|
||||
|
||||
it("handles special characters in userId (e.g. angle brackets, quotes)", () => {
|
||||
const [system] = getDeleteMessages(
|
||||
"mem",
|
||||
"data",
|
||||
'<script>alert("xss")</script>',
|
||||
);
|
||||
expect(system).toContain('<script>alert("xss")</script>');
|
||||
expect(system).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("handles unicode input", () => {
|
||||
const [system, user] = getDeleteMessages(
|
||||
"日本語メモリ",
|
||||
"新しい情報",
|
||||
"ユーザー1",
|
||||
);
|
||||
expect(system).toContain("ユーザー1");
|
||||
expect(user).toContain("日本語メモリ");
|
||||
expect(user).toContain("新しい情報");
|
||||
});
|
||||
|
||||
it("handles very long input strings", () => {
|
||||
const longStr = "x".repeat(100_000);
|
||||
expect(() => getDeleteMessages(longStr, longStr, "u")).not.toThrow();
|
||||
const [system] = getDeleteMessages(longStr, longStr, "u");
|
||||
expect(system.toLowerCase()).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatEntities ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("formatEntities", () => {
|
||||
it("formats a single entity triplet", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "Alice", relationship: "knows", destination: "Bob" },
|
||||
]);
|
||||
expect(result).toBe("Alice -- knows -- Bob");
|
||||
});
|
||||
|
||||
it("joins multiple entities with newlines", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "A", relationship: "r1", destination: "B" },
|
||||
{ source: "C", relationship: "r2", destination: "D" },
|
||||
]);
|
||||
expect(result).toBe("A -- r1 -- B\nC -- r2 -- D");
|
||||
});
|
||||
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(formatEntities([])).toBe("");
|
||||
});
|
||||
|
||||
it("preserves special characters in entity fields", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "O'Brien", relationship: 'said "hello"', destination: "café" },
|
||||
]);
|
||||
expect(result).toContain("O'Brien");
|
||||
expect(result).toContain('said "hello"');
|
||||
expect(result).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prompt structural invariants ─────────────────────────────────────────────
|
||||
|
||||
describe("Prompt structural invariants", () => {
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT contains USER_ID placeholder", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT).toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT contains USER_ID placeholder", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT).toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT contains CUSTOM_PROMPT placeholder", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT).toContain("CUSTOM_PROMPT");
|
||||
});
|
||||
|
||||
it("UPDATE_GRAPH_PROMPT contains memory template placeholders", () => {
|
||||
expect(UPDATE_GRAPH_PROMPT).toContain("{existing_memories}");
|
||||
expect(UPDATE_GRAPH_PROMPT).toContain("{new_memories}");
|
||||
});
|
||||
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT is non-empty and reasonably sized", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT is non-empty and reasonably sized", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* LM Studio Embedder — unit tests (mocked OpenAI).
|
||||
*/
|
||||
|
||||
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
const mockCreate = jest.fn().mockResolvedValue({
|
||||
data: [{ embedding: mockEmbedding }],
|
||||
});
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
embeddings: { create: mockCreate },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("LMStudioEmbedder (unit)", () => {
|
||||
beforeEach(() => mockCreate.mockClear());
|
||||
|
||||
it("embed() calls OpenAI with encoding_format float and returns vector", async () => {
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "nomic-embed-text-v1.5-GGUF",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
const result = await embedder.embed("Sample text to embed.");
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreate.mock.calls[0][0]).toEqual({
|
||||
model: "nomic-embed-text-v1.5-GGUF",
|
||||
input: "Sample text to embed.",
|
||||
encoding_format: "float",
|
||||
});
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
});
|
||||
|
||||
it("embed() normalizes newlines", async () => {
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
await embedder.embed("Line one\nLine two");
|
||||
|
||||
expect(mockCreate.mock.calls[0][0].input).toBe("Line one Line two");
|
||||
});
|
||||
|
||||
it("embed() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
await expect(embedder.embed("text")).rejects.toThrow(
|
||||
"LM Studio embedder failed: Connection refused",
|
||||
);
|
||||
});
|
||||
|
||||
it("embedBatch() returns vectors for multiple inputs", async () => {
|
||||
const mockBatch = [
|
||||
[0.1, 0.2],
|
||||
[0.3, 0.4],
|
||||
];
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
data: [{ embedding: mockBatch[0] }, { embedding: mockBatch[1] }],
|
||||
});
|
||||
|
||||
const embedder = new LMStudioEmbedder({
|
||||
model: "test-model",
|
||||
baseURL: "http://localhost:1234/v1",
|
||||
});
|
||||
|
||||
const result = await embedder.embedBatch(["text1", "text2"]);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreate.mock.calls[0][0].input).toEqual(["text1", "text2"]);
|
||||
expect(result).toEqual(mockBatch);
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* LM Studio LLM — unit tests (mocked OpenAI).
|
||||
*/
|
||||
|
||||
import { LMStudioLLM } from "../src/llms/lmstudio";
|
||||
|
||||
const mockCreate = jest.fn();
|
||||
|
||||
jest.mock("openai", () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
chat: { completions: { create: mockCreate } },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("LMStudioLLM (unit)", () => {
|
||||
beforeEach(() => mockCreate.mockClear());
|
||||
|
||||
it("generateResponse() returns a text response", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "Hello, world!",
|
||||
role: "assistant",
|
||||
tool_calls: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateResponse([
|
||||
{ role: "user", content: "Hi" },
|
||||
]);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("generateResponse() handles tool calls", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "",
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "London"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateResponse(
|
||||
[{ role: "user", content: "What is the weather?" }],
|
||||
undefined,
|
||||
[{ type: "function", function: { name: "get_weather" } }],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "",
|
||||
role: "assistant",
|
||||
toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }],
|
||||
});
|
||||
});
|
||||
|
||||
it("generateResponse() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
|
||||
await expect(
|
||||
llm.generateResponse([{ role: "user", content: "Hi" }]),
|
||||
).rejects.toThrow("LM Studio LLM failed: Connection refused");
|
||||
});
|
||||
|
||||
it("generateChat() returns LLMResponse shape", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: "I can help with that.", role: "assistant" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
const result = await llm.generateChat([
|
||||
{ role: "user", content: "Help me" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
content: "I can help with that.",
|
||||
role: "assistant",
|
||||
});
|
||||
});
|
||||
|
||||
it("generateChat() wraps API errors with a clear message", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("Timeout"));
|
||||
|
||||
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
|
||||
|
||||
await expect(
|
||||
llm.generateChat([{ role: "user", content: "Hi" }]),
|
||||
).rejects.toThrow("LM Studio LLM failed: Timeout");
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* OSS Memory unit tests — add() with inference, without inference, filter validation, metadata.
|
||||
* Content-based LLM mock: system-prompt calls → facts, user-only calls → memory actions.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["extracted fact from input"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "extracted fact from input",
|
||||
old_memory: "",
|
||||
new_memory: "extracted fact from input",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-add-${Date.now()}`,
|
||||
dimension: 1536,
|
||||
dbPath: ":memory:",
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - add()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `add_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array for string input", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns at least one result with an id", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
test("result item has a memory string field", async () => {
|
||||
const result: SearchResult = await memory.add("I am a software engineer", {
|
||||
userId,
|
||||
});
|
||||
expect(typeof result.results[0].memory).toBe("string");
|
||||
});
|
||||
|
||||
test("accepts Message[] input", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris." },
|
||||
];
|
||||
const result: SearchResult = await memory.add(messages, { userId });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with agentId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", {
|
||||
agentId: "agent_1",
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("works with runId filter instead of userId", async () => {
|
||||
const result: SearchResult = await memory.add("test", { runId: "run_1" });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.add("test", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes metadata through to stored memory", async () => {
|
||||
const result: SearchResult = await memory.add("I love TypeScript", {
|
||||
userId,
|
||||
metadata: { source: "chat", tag: "programming" },
|
||||
});
|
||||
const stored: MemoryItem | null = await memory.get(result.results[0].id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.metadata).toEqual(
|
||||
expect.objectContaining({ source: "chat", tag: "programming" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("with infer=false skips LLM and stores messages directly", async () => {
|
||||
const result: SearchResult = await memory.add("Direct storage content", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
// When infer=false, the literal message text is stored
|
||||
expect(result.results[0].memory).toBe("Direct storage content");
|
||||
});
|
||||
|
||||
test("with infer=false marks event as ADD in metadata", async () => {
|
||||
const result: SearchResult = await memory.add("Direct fact", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
expect(result.results[0].metadata).toEqual(
|
||||
expect.objectContaining({ event: "ADD" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,359 +0,0 @@
|
||||
/**
|
||||
* OSS Memory unit tests — get, update, delete, deleteAll, getAll, search, history.
|
||||
* Content-based LLM mock. Tests verify real behavior, not mock echoes.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryItem, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["stored fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "stored fact",
|
||||
old_memory: "",
|
||||
new_memory: "stored fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: `test-crud-${Date.now()}-${Math.random()}`,
|
||||
dimension: 1536,
|
||||
dbPath: ":memory:",
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── get() ───────────────────────────────────────────────
|
||||
|
||||
describe("Memory - get()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `get_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns the memory matching the ID from add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("I love AI", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.id).toBe(id);
|
||||
});
|
||||
|
||||
test("returns a string for the memory field", async () => {
|
||||
const addResult: SearchResult = await memory.add("Testing get", {
|
||||
userId,
|
||||
});
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.memory).toBe("string");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const item = await memory.get("nonexistent-uuid-12345");
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
test("returns hash and createdAt on stored memory", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash test", { userId });
|
||||
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
|
||||
expect(typeof item!.hash).toBe("string");
|
||||
expect(item!.createdAt).toBeDefined();
|
||||
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - update()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `update_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
// Use infer: false for update tests — bypasses LLM, gives us a stable ID
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Original", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const result = await memory.update(id, "Updated");
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
});
|
||||
|
||||
test("persists the updated text", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before update", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After update");
|
||||
const item: MemoryItem | null = await memory.get(id);
|
||||
expect(item!.memory).toBe("After update");
|
||||
});
|
||||
|
||||
test("preserves createdAt and sets updatedAt", async () => {
|
||||
const addResult: SearchResult = await memory.add("Timestamp test", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
const originalCreatedAt = before!.createdAt;
|
||||
|
||||
await memory.update(id, "New text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.createdAt).toBe(originalCreatedAt);
|
||||
expect(after!.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test("updates the hash", async () => {
|
||||
const addResult: SearchResult = await memory.add("Hash change", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
const before: MemoryItem | null = await memory.get(id);
|
||||
await memory.update(id, "Completely different text");
|
||||
const after: MemoryItem | null = await memory.get(id);
|
||||
expect(after!.hash).not.toBe(before!.hash);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - delete()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `delete_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns success message", async () => {
|
||||
const addResult: SearchResult = await memory.add("Delete me", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const result = await memory.delete(addResult.results[0].id);
|
||||
expect(result.message).toBe("Memory deleted successfully!");
|
||||
});
|
||||
|
||||
test("get() returns null after deletion", async () => {
|
||||
const addResult: SearchResult = await memory.add("Temporary", {
|
||||
userId,
|
||||
infer: false,
|
||||
});
|
||||
const id = addResult.results[0].id;
|
||||
await memory.delete(id);
|
||||
expect(await memory.get(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
|
||||
describe("Memory - deleteAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `deleteall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("removes all memories for the user and returns success", async () => {
|
||||
await memory.add("Fact A", { userId });
|
||||
await memory.add("Fact B", { userId });
|
||||
const result = await memory.deleteAll({ userId });
|
||||
expect(result.message).toBe("Memories deleted successfully!");
|
||||
const remaining: SearchResult = await memory.getAll({ userId });
|
||||
expect(remaining.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws when no filter is provided", async () => {
|
||||
await expect(memory.deleteAll({} as any)).rejects.toThrow(
|
||||
"At least one filter is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAll() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - getAll()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `getall_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns all stored memories for the user", async () => {
|
||||
await memory.add("First", { userId });
|
||||
await memory.add("Second", { userId });
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("each result has id and memory fields", async () => {
|
||||
const result: SearchResult = await memory.getAll({ userId });
|
||||
for (const item of result.results) {
|
||||
expect(item.id).toBeDefined();
|
||||
expect(typeof item.memory).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty array when no memories exist", async () => {
|
||||
const result: SearchResult = await memory.getAll({
|
||||
userId: "no_such_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── search() ────────────────────────────────────────────
|
||||
|
||||
describe("Memory - search()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `search_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
await memory.add("I love TypeScript", { userId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("returns SearchResult with results array", async () => {
|
||||
const result: SearchResult = await memory.search("TypeScript", { userId });
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns results with score field", async () => {
|
||||
const result: SearchResult = await memory.search("content", { userId });
|
||||
if (result.results.length > 0) {
|
||||
expect(typeof result.results[0].score).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when no userId/agentId/runId provided", async () => {
|
||||
await expect(memory.search("query", {} as any)).rejects.toThrow(
|
||||
"One of the filters: userId, agentId or runId is required!",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty results for user with no memories", async () => {
|
||||
const result: SearchResult = await memory.search("query", {
|
||||
userId: "empty_user",
|
||||
});
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── history() ───────────────────────────────────────────
|
||||
|
||||
describe("Memory - history()", () => {
|
||||
let memory: Memory;
|
||||
const userId = `history_test_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
memory = createMemory();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
test("records ADD event after add()", async () => {
|
||||
const addResult: SearchResult = await memory.add("New fact", { userId });
|
||||
const history = await memory.history(addResult.results[0].id);
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("records additional entry after update()", async () => {
|
||||
const addResult: SearchResult = await memory.add("Before", { userId });
|
||||
const id = addResult.results[0].id;
|
||||
await memory.update(id, "After");
|
||||
const history = await memory.history(id);
|
||||
expect(history.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty array for non-existent memory ID", async () => {
|
||||
const history = await memory.history("nonexistent-id");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* OSS Memory unit tests — constructor, initialization, config validation, reset.
|
||||
* Mocks LLM/Embedder at module level. No API keys needed.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src/memory";
|
||||
import type { MemoryConfig, SearchResult } from "../src/types";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// Mock Google modules to prevent @google/genai crash in CI
|
||||
jest.mock("../src/embeddings/google", () => ({
|
||||
GoogleEmbedder: jest.fn(),
|
||||
}));
|
||||
jest.mock("../src/llms/google", () => ({
|
||||
GoogleLLM: jest.fn(),
|
||||
}));
|
||||
|
||||
// ─── Content-based LLM mock (reviewer #9) ────────────────
|
||||
// Returns facts for system-prompt calls, memory actions for user-only calls.
|
||||
jest.mock("../src/llms/openai", () => ({
|
||||
OpenAILLM: jest.fn().mockImplementation(() => ({
|
||||
generateResponse: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(messages: Array<{ role: string; content: string }>) => {
|
||||
const hasSystemRole = messages.some((m) => m.role === "system");
|
||||
if (hasSystemRole) {
|
||||
return JSON.stringify({ facts: ["test fact"] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
memory: [
|
||||
{
|
||||
id: "new",
|
||||
event: "ADD",
|
||||
text: "test fact",
|
||||
old_memory: "",
|
||||
new_memory: "test fact",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../src/embeddings/openai", () => ({
|
||||
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
|
||||
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
|
||||
embeddingDims: 1536,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
|
||||
return new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-init", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Memory - Initialization", () => {
|
||||
test("constructs without throwing with valid config", () => {
|
||||
expect(() => createMemory()).not.toThrow();
|
||||
});
|
||||
|
||||
test("fromConfig creates instance from config dict", () => {
|
||||
const config = {
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key", model: "gpt-4" },
|
||||
},
|
||||
};
|
||||
const mem = Memory.fromConfig(config);
|
||||
expect(mem).toBeInstanceOf(Memory);
|
||||
});
|
||||
|
||||
test("fromConfig throws on invalid config", () => {
|
||||
expect(() => Memory.fromConfig({ invalid: true } as any)).toThrow();
|
||||
});
|
||||
|
||||
test("disableHistory=true uses DummyHistoryManager (no crash on history)", async () => {
|
||||
const mem = createMemory({ disableHistory: true });
|
||||
// If DummyHistoryManager is used, history returns [] without error
|
||||
const result = await mem.history("nonexistent-id");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory - reset()", () => {
|
||||
test("reset clears all stored memories", async () => {
|
||||
const mem = createMemory();
|
||||
const userId = `reset_test_${Date.now()}`;
|
||||
|
||||
await mem.add("Remember this fact", { userId });
|
||||
const before: SearchResult = await mem.getAll({ userId });
|
||||
expect(before.results.length).toBeGreaterThan(0);
|
||||
|
||||
await mem.reset();
|
||||
|
||||
const after: SearchResult = await mem.getAll({ userId });
|
||||
expect(after.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/// <reference types="jest" />
|
||||
import { Memory } from "../src";
|
||||
import { MemoryItem, SearchResult } from "../src/types";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
jest.setTimeout(30000); // Increase timeout to 30 seconds
|
||||
|
||||
describe("Memory Class", () => {
|
||||
let memory: Memory;
|
||||
const userId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Initialize with default configuration
|
||||
memory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
// Reset all memories before each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up after each test
|
||||
await memory.reset();
|
||||
});
|
||||
|
||||
describe("Basic Memory Operations", () => {
|
||||
it("should add a single memory", async () => {
|
||||
const result = (await memory.add(
|
||||
"Hi, my name is John and I am a software engineer.",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0]?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should add multiple messages", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "What is your favorite city?" },
|
||||
{ role: "assistant", content: "I love Paris, it is my favorite city." },
|
||||
];
|
||||
|
||||
const result = (await memory.add(messages, userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get a single memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I am a big advocate of using AI to make the world a better place",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const result = (await memory.get(memoryId)) as MemoryItem;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(memoryId);
|
||||
expect(result.memory).toBeDefined();
|
||||
expect(typeof result.memory).toBe("string");
|
||||
});
|
||||
|
||||
it("should update a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love speaking foreign languages especially Spanish",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
const updatedContent = "Updated content";
|
||||
const result = await memory.update(memoryId, updatedContent);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.message).toBe("Memory updated successfully!");
|
||||
|
||||
// Verify the update by getting the memory
|
||||
const updatedMemory = (await memory.get(memoryId)) as MemoryItem;
|
||||
expect(updatedMemory.memory).toBe(updatedContent);
|
||||
});
|
||||
|
||||
it("should get all memories for a user", async () => {
|
||||
// Add a few memories
|
||||
await memory.add("I love visiting new places in the winters", userId);
|
||||
await memory.add("I like to rule the world", userId);
|
||||
|
||||
const result = (await memory.getAll(userId)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should search memories", async () => {
|
||||
// Add some test memories
|
||||
await memory.add("I love programming in Python", userId);
|
||||
await memory.add("JavaScript is my favorite language", userId);
|
||||
|
||||
const result = (await memory.search(
|
||||
"What programming languages do I know?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should get memory history", async () => {
|
||||
// Add and update a memory to create history
|
||||
const addResult = (await memory.add(
|
||||
"I like swimming in warm water",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
await memory.update(memoryId, "Updated content");
|
||||
|
||||
const history = await memory.history(memoryId);
|
||||
|
||||
expect(history).toBeDefined();
|
||||
expect(Array.isArray(history)).toBe(true);
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should delete a memory", async () => {
|
||||
// First add a memory
|
||||
const addResult = (await memory.add(
|
||||
"I love to drink vodka in summers",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
if (!addResult.results?.[0]?.id) {
|
||||
throw new Error("Failed to create test memory");
|
||||
}
|
||||
|
||||
const memoryId = addResult.results[0].id;
|
||||
|
||||
// Delete the memory
|
||||
await memory.delete(memoryId);
|
||||
|
||||
// Try to get the deleted memory - should throw or return null
|
||||
const result = await memory.get(memoryId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory with Custom Configuration", () => {
|
||||
let customMemory: Memory;
|
||||
|
||||
beforeEach(() => {
|
||||
customMemory = new Memory({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: {
|
||||
collectionName: "test-memories",
|
||||
dimension: 1536,
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
historyDbPath: ":memory:", // Use in-memory SQLite for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await customMemory.reset();
|
||||
});
|
||||
|
||||
it("should work with custom configuration", async () => {
|
||||
const result = (await customMemory.add(
|
||||
"I love programming in Python",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should perform semantic search with custom embeddings", async () => {
|
||||
// Add test memories
|
||||
await customMemory.add("The weather in London is rainy today", userId);
|
||||
await customMemory.add("The temperature in Paris is 25 degrees", userId);
|
||||
|
||||
const result = (await customMemory.search(
|
||||
"What is the weather like?",
|
||||
userId,
|
||||
)) as SearchResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
// Results should be ordered by relevance
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { removeCodeBlocks } from "../src/prompts";
|
||||
|
||||
describe("removeCodeBlocks", () => {
|
||||
it("extracts JSON from ```json code fence", () => {
|
||||
const input = '```json\n{"facts": ["hello"]}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
|
||||
it("extracts content from bare ``` code fence", () => {
|
||||
const input = '```\n{"key": "value"}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"key": "value"}');
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", () => {
|
||||
const input = '{"facts": ["hello"]}';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
|
||||
it("handles multiple code blocks", () => {
|
||||
const input = '```json\n{"a":1}\n```\nsome text\n```json\n{"b":2}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"a":1}\n\nsome text\n{"b":2}');
|
||||
});
|
||||
|
||||
it("handles Claude-style response with surrounding text", () => {
|
||||
const input =
|
||||
'Here is the JSON:\n```json\n{"facts": ["user likes TypeScript"]}\n```';
|
||||
expect(removeCodeBlocks(input)).toContain('"facts"');
|
||||
expect(removeCodeBlocks(input)).not.toContain("```");
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* Storage manager unit tests — SQLiteManager, DummyHistoryManager.
|
||||
* Uses real in-memory SQLite, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { SQLiteManager } from "../src/storage/SQLiteManager";
|
||||
import { DummyHistoryManager } from "../src/storage/DummyHistoryManager";
|
||||
import { MemoryHistoryManager } from "../src/storage/MemoryHistoryManager";
|
||||
|
||||
// ─── SQLiteManager ──────────────────────────────────────
|
||||
|
||||
describe("SQLiteManager", () => {
|
||||
let db: SQLiteManager;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new SQLiteManager(":memory:");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory inserts a record retrievable by getHistory", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns records in reverse chronological order", async () => {
|
||||
await db.addHistory("mem1", null, "first", "ADD", "2026-01-01");
|
||||
await db.addHistory("mem1", "first", "second", "UPDATE", "2026-01-02");
|
||||
await db.addHistory("mem1", "second", "third", "UPDATE", "2026-01-03");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
// DESC order by id: most recent first
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
const history = await db.getHistory("nonexistent");
|
||||
expect(history).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("addHistory stores previous_value for UPDATE", async () => {
|
||||
await db.addHistory("mem1", "old text", "new text", "UPDATE");
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].previous_value).toBe("old text");
|
||||
expect(history[0].new_value).toBe("new text");
|
||||
});
|
||||
|
||||
test("addHistory stores null new_value for DELETE", async () => {
|
||||
await db.addHistory(
|
||||
"mem1",
|
||||
"deleted text",
|
||||
null,
|
||||
"DELETE",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].action).toBe("DELETE");
|
||||
expect(history[0].new_value).toBeNull();
|
||||
expect(history[0].is_deleted).toBe(1);
|
||||
});
|
||||
|
||||
test("reset clears all history and recreates table", async () => {
|
||||
await db.addHistory("mem1", null, "data", "ADD");
|
||||
await db.addHistory("mem2", null, "data", "ADD");
|
||||
await db.reset();
|
||||
expect(await db.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(0);
|
||||
// Table still works after reset
|
||||
await db.addHistory("mem3", null, "after reset", "ADD");
|
||||
expect(await db.getHistory("mem3")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("stores createdAt and updatedAt timestamps", async () => {
|
||||
const created = "2026-03-17T10:00:00Z";
|
||||
const updated = "2026-03-17T11:00:00Z";
|
||||
await db.addHistory("mem1", null, "data", "ADD", created, updated);
|
||||
const history = await db.getHistory("mem1");
|
||||
expect(history[0].created_at).toBe(created);
|
||||
expect(history[0].updated_at).toBe(updated);
|
||||
});
|
||||
|
||||
test("handles multiple memories independently", async () => {
|
||||
await db.addHistory("mem1", null, "data1", "ADD");
|
||||
await db.addHistory("mem2", null, "data2", "ADD");
|
||||
expect(await db.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await db.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DummyHistoryManager ────────────────────────────────
|
||||
|
||||
describe("DummyHistoryManager", () => {
|
||||
let dummy: DummyHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
dummy = new DummyHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(dummy).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory is a no-op that resolves", async () => {
|
||||
await expect(
|
||||
dummy.addHistory("id", null, "val", "ADD"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("getHistory returns empty array", async () => {
|
||||
const result = await dummy.getHistory("any-id");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("reset resolves without throwing", async () => {
|
||||
await expect(dummy.reset()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => dummy.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MemoryHistoryManager ───────────────────────────────
|
||||
|
||||
describe("MemoryHistoryManager", () => {
|
||||
let mgr: MemoryHistoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mgr = new MemoryHistoryManager();
|
||||
});
|
||||
|
||||
test("constructs without throwing", () => {
|
||||
expect(mgr).toBeDefined();
|
||||
});
|
||||
|
||||
test("addHistory + getHistory round-trips correctly", async () => {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
"new value",
|
||||
"ADD",
|
||||
"2026-01-01T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].memory_id).toBe("mem1");
|
||||
expect(history[0].new_value).toBe("new value");
|
||||
expect(history[0].action).toBe("ADD");
|
||||
});
|
||||
|
||||
test("getHistory returns entries sorted by date descending", async () => {
|
||||
await mgr.addHistory("mem1", null, "first", "ADD", "2026-01-01T00:00:00Z");
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"first",
|
||||
"second",
|
||||
"UPDATE",
|
||||
"2026-01-02T00:00:00Z",
|
||||
);
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
"second",
|
||||
"third",
|
||||
"UPDATE",
|
||||
"2026-01-03T00:00:00Z",
|
||||
);
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0].new_value).toBe("third");
|
||||
expect(history[2].new_value).toBe("first");
|
||||
});
|
||||
|
||||
test("getHistory returns empty array for non-existent memory", async () => {
|
||||
expect(await mgr.getHistory("nonexistent")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("getHistory caps at 100 entries", async () => {
|
||||
for (let i = 0; i < 110; i++) {
|
||||
await mgr.addHistory(
|
||||
"mem1",
|
||||
null,
|
||||
`entry-${i}`,
|
||||
"ADD",
|
||||
`2026-01-01T00:${String(i).padStart(2, "0")}:00Z`,
|
||||
);
|
||||
}
|
||||
const history = await mgr.getHistory("mem1");
|
||||
expect(history).toHaveLength(100);
|
||||
});
|
||||
|
||||
test("reset clears all entries", async () => {
|
||||
await mgr.addHistory("mem1", null, "data", "ADD");
|
||||
await mgr.addHistory("mem2", null, "data", "ADD");
|
||||
await mgr.reset();
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(0);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("close does not throw", () => {
|
||||
expect(() => mgr.close()).not.toThrow();
|
||||
});
|
||||
|
||||
test("isolates history by memory_id", async () => {
|
||||
await mgr.addHistory("mem1", null, "d1", "ADD");
|
||||
await mgr.addHistory("mem2", null, "d2", "ADD");
|
||||
expect(await mgr.getHistory("mem1")).toHaveLength(1);
|
||||
expect(await mgr.getHistory("mem2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* MemoryVectorStore unit tests — insert, search, get, update, delete, list, cosine similarity.
|
||||
* Uses real SQLite in-memory DB, no external dependencies.
|
||||
*/
|
||||
/// <reference types="jest" />
|
||||
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||
import type { VectorStoreResult } from "../src/types";
|
||||
|
||||
const DIM = 4; // Small dimension for fast tests
|
||||
|
||||
function createStore(): MemoryVectorStore {
|
||||
return new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: DIM,
|
||||
dbPath: ":memory:",
|
||||
});
|
||||
}
|
||||
|
||||
function vec(values: number[]): number[] {
|
||||
return values;
|
||||
}
|
||||
|
||||
describe("MemoryVectorStore - insert + get", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(() => {
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
test("inserts and retrieves a vector by ID", async () => {
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0])],
|
||||
["id1"],
|
||||
[{ data: "hello", userId: "u1" }],
|
||||
);
|
||||
const result: VectorStoreResult | null = await store.get("id1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe("id1");
|
||||
expect(result!.payload.data).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns null for non-existent ID", async () => {
|
||||
const result = await store.get("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during insert", async () => {
|
||||
await expect(
|
||||
store.insert([vec([1, 0, 0])], ["bad"], [{ data: "x" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - search", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0.9, 0.1, 0, 0])],
|
||||
["a", "b", "c"],
|
||||
[
|
||||
{ data: "north", userId: "u1" },
|
||||
{ data: "east", userId: "u1" },
|
||||
{ data: "north-ish", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns results sorted by cosine similarity descending", async () => {
|
||||
const results: VectorStoreResult[] = await store.search(
|
||||
vec([1, 0, 0, 0]),
|
||||
10,
|
||||
);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].id).toBe("a"); // exact match
|
||||
// scores should be descending
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score!).toBeGreaterThanOrEqual(results[i].score!);
|
||||
}
|
||||
});
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, { userId: "u2" });
|
||||
expect(results.every((r) => r.payload.userId === "u2")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns empty when filter matches nothing", async () => {
|
||||
const results = await store.search(vec([1, 0, 0, 0]), 10, {
|
||||
userId: "nobody",
|
||||
});
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws on query dimension mismatch", async () => {
|
||||
await expect(store.search(vec([1, 0]), 10)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - update", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["upd1"], [{ data: "original" }]);
|
||||
});
|
||||
|
||||
test("updates payload and vector", async () => {
|
||||
await store.update("upd1", vec([0, 1, 0, 0]), { data: "updated" });
|
||||
const result = await store.get("upd1");
|
||||
expect(result!.payload.data).toBe("updated");
|
||||
});
|
||||
|
||||
test("throws on dimension mismatch during update", async () => {
|
||||
await expect(
|
||||
store.update("upd1", vec([1, 0]), { data: "bad" }),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - delete + deleteCol", () => {
|
||||
test("delete removes a vector", async () => {
|
||||
const store = createStore();
|
||||
await store.insert([vec([1, 0, 0, 0])], ["del1"], [{ data: "bye" }]);
|
||||
await store.delete("del1");
|
||||
expect(await store.get("del1")).toBeNull();
|
||||
});
|
||||
|
||||
test("deleteCol clears all vectors", async () => {
|
||||
const store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0])],
|
||||
["x", "y"],
|
||||
[{ data: "a" }, { data: "b" }],
|
||||
);
|
||||
await store.deleteCol();
|
||||
const [results] = await store.list();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - list", () => {
|
||||
let store: MemoryVectorStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
await store.insert(
|
||||
[vec([1, 0, 0, 0]), vec([0, 1, 0, 0]), vec([0, 0, 1, 0])],
|
||||
["l1", "l2", "l3"],
|
||||
[
|
||||
{ data: "a", userId: "u1" },
|
||||
{ data: "b", userId: "u1" },
|
||||
{ data: "c", userId: "u2" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns all vectors without filter", async () => {
|
||||
const [results, count] = await store.list();
|
||||
expect(count).toBe(3);
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("filters by userId", async () => {
|
||||
const [results, count] = await store.list({ userId: "u1" });
|
||||
expect(count).toBe(2);
|
||||
expect(results.every((r) => r.payload.userId === "u1")).toBe(true);
|
||||
});
|
||||
|
||||
test("respects limit", async () => {
|
||||
const [results] = await store.list(undefined, 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryVectorStore - userId tracking", () => {
|
||||
test("getUserId generates and persists a random ID", async () => {
|
||||
const store = createStore();
|
||||
const id = await store.getUserId();
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
// Calling again returns same ID
|
||||
expect(await store.getUserId()).toBe(id);
|
||||
});
|
||||
|
||||
test("setUserId overrides the stored ID", async () => {
|
||||
const store = createStore();
|
||||
await store.setUserId("custom-id");
|
||||
expect(await store.getUserId()).toBe("custom-id");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+1
-11
@@ -7,19 +7,9 @@ const external = [
|
||||
"uuid",
|
||||
"pg",
|
||||
"zod",
|
||||
"better-sqlite3",
|
||||
"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([
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import Field
|
||||
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
@@ -47,8 +46,3 @@ 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.",
|
||||
)
|
||||
|
||||
@@ -31,21 +31,12 @@ 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"]
|
||||
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
|
||||
):
|
||||
if not any(model.get("name") == self.config.model or model.get("model") == self.config.model for model in local_models):
|
||||
self.client.pull(self.config.model)
|
||||
|
||||
def embed(self, text, memory_action: Optional[Literal["add", "search", "update"]] = None):
|
||||
@@ -58,8 +49,5 @@ class OllamaEmbedding(EmbeddingBase):
|
||||
Returns:
|
||||
list: The embedding vector.
|
||||
"""
|
||||
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]
|
||||
response = self.client.embeddings(model=self.config.model, prompt=text)
|
||||
return response["embedding"]
|
||||
|
||||
@@ -43,12 +43,7 @@ class OpenAIEmbedding(EmbeddingBase):
|
||||
"""
|
||||
text = text.replace("\n", " ")
|
||||
return (
|
||||
self.client.embeddings.create(
|
||||
input=[text],
|
||||
model=self.config.model,
|
||||
dimensions=self.config.embedding_dims,
|
||||
encoding_format="float",
|
||||
)
|
||||
self.client.embeddings.create(input=[text], model=self.config.model, dimensions=self.config.embedding_dims)
|
||||
.data[0]
|
||||
.embedding
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@ class NeptuneBase(ABC):
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
@@ -144,7 +144,7 @@ class NeptuneBase(ABC):
|
||||
|
||||
entities = []
|
||||
if extracted_entities["tool_calls"]:
|
||||
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
|
||||
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
|
||||
|
||||
entities = self._remove_spaces_from_entities(entities)
|
||||
logger.debug(f"Extracted entities: {entities}")
|
||||
|
||||
+1
-27
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
@@ -9,7 +8,6 @@ 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):
|
||||
@@ -63,28 +61,7 @@ class OllamaLLM(LLMBase):
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
# Ollama doesn't support tool calls in the same way, so we return the content
|
||||
return processed_response
|
||||
else:
|
||||
return content
|
||||
@@ -136,8 +113,5 @@ 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)
|
||||
|
||||
@@ -30,10 +30,10 @@ class MemoryGraph:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.graph = Neo4jGraph(
|
||||
url=self.config.graph_store.config.url,
|
||||
username=self.config.graph_store.config.username,
|
||||
password=self.config.graph_store.config.password,
|
||||
database=self.config.graph_store.config.database,
|
||||
self.config.graph_store.config.url,
|
||||
self.config.graph_store.config.username,
|
||||
self.config.graph_store.config.password,
|
||||
self.config.graph_store.config.database,
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
@@ -215,7 +215,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
@@ -241,7 +241,7 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
+35
-45
@@ -24,12 +24,10 @@ from mem0.exceptions import ValidationError as Mem0ValidationError
|
||||
from mem0.memory.base import MemoryBase
|
||||
from mem0.memory.setup import mem0_dir, setup_config
|
||||
from mem0.memory.storage import SQLiteManager
|
||||
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
|
||||
from mem0.memory.telemetry import capture_event
|
||||
from mem0.memory.utils import (
|
||||
ensure_json_instruction,
|
||||
extract_json,
|
||||
get_fact_retrieval_messages,
|
||||
normalize_facts,
|
||||
parse_messages,
|
||||
parse_vision_messages,
|
||||
process_telemetry_filters,
|
||||
@@ -39,8 +37,8 @@ from mem0.utils.factory import (
|
||||
EmbedderFactory,
|
||||
GraphStoreFactory,
|
||||
LlmFactory,
|
||||
RerankerFactory,
|
||||
VectorStoreFactory,
|
||||
RerankerFactory,
|
||||
)
|
||||
|
||||
# Suppress SWIG deprecation warnings globally
|
||||
@@ -206,33 +204,32 @@ class Memory(MemoryBase):
|
||||
self.enable_graph = True
|
||||
else:
|
||||
self.graph = None
|
||||
if MEM0_TELEMETRY:
|
||||
# Create telemetry config manually to avoid deepcopy issues with thread locks
|
||||
telemetry_config_dict = {}
|
||||
if hasattr(self.config.vector_store.config, 'model_dump'):
|
||||
# For pydantic models
|
||||
telemetry_config_dict = self.config.vector_store.config.model_dump()
|
||||
else:
|
||||
# For other objects, manually copy common attributes
|
||||
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
|
||||
if hasattr(self.config.vector_store.config, attr):
|
||||
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
|
||||
# Create telemetry config manually to avoid deepcopy issues with thread locks
|
||||
telemetry_config_dict = {}
|
||||
if hasattr(self.config.vector_store.config, 'model_dump'):
|
||||
# For pydantic models
|
||||
telemetry_config_dict = self.config.vector_store.config.model_dump()
|
||||
else:
|
||||
# For other objects, manually copy common attributes
|
||||
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
|
||||
if hasattr(self.config.vector_store.config, attr):
|
||||
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
|
||||
|
||||
# Override collection name for telemetry
|
||||
telemetry_config_dict['collection_name'] = "mem0migrations"
|
||||
# Override collection name for telemetry
|
||||
telemetry_config_dict['collection_name'] = "mem0migrations"
|
||||
|
||||
# Set path for file-based vector stores
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
|
||||
# Set path for file-based vector stores
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
|
||||
|
||||
# Create the config object using the same class as the original
|
||||
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(
|
||||
self.config.vector_store.provider, telemetry_config
|
||||
)
|
||||
# Create the config object using the same class as the original
|
||||
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(
|
||||
self.config.vector_store.provider, telemetry_config
|
||||
)
|
||||
capture_event("mem0.init", self, {"sync_type": "sync"})
|
||||
|
||||
@classmethod
|
||||
@@ -434,9 +431,6 @@ class Memory(MemoryBase):
|
||||
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
|
||||
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
|
||||
|
||||
# Ensure 'json' appears in prompts for json_object response format compatibility
|
||||
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
|
||||
|
||||
response = self.llm.generate_response(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -457,7 +451,6 @@ 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 = []
|
||||
@@ -1053,10 +1046,11 @@ class Memory(MemoryBase):
|
||||
|
||||
keys, encoded_ids = process_telemetry_filters(filters)
|
||||
capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"})
|
||||
# delete matching vector memories individually (do NOT reset the collection)
|
||||
# delete all vector memories and reset the collections
|
||||
memories = self.vector_store.list(filters=filters)[0]
|
||||
for memory in memories:
|
||||
self._delete_memory(memory.id)
|
||||
self.vector_store.reset()
|
||||
|
||||
logger.info(f"Deleted {len(memories)} memories")
|
||||
|
||||
@@ -1278,14 +1272,14 @@ class AsyncMemory(MemoryBase):
|
||||
else:
|
||||
self.graph = None
|
||||
|
||||
if MEM0_TELEMETRY:
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
telemetry_config.collection_name = "mem0migrations"
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config.path = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config.path, exist_ok=True)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
|
||||
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
|
||||
telemetry_config.collection_name = "mem0migrations"
|
||||
if self.config.vector_store.provider in ["faiss", "qdrant"]:
|
||||
provider_path = f"migrations_{self.config.vector_store.provider}"
|
||||
telemetry_config.path = os.path.join(mem0_dir, provider_path)
|
||||
os.makedirs(telemetry_config.path, exist_ok=True)
|
||||
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
|
||||
|
||||
capture_event("mem0.init", self, {"sync_type": "async"})
|
||||
|
||||
@classmethod
|
||||
@@ -1466,9 +1460,6 @@ class AsyncMemory(MemoryBase):
|
||||
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
|
||||
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
|
||||
|
||||
# Ensure 'json' appears in prompts for json_object response format compatibility
|
||||
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
|
||||
|
||||
response = await asyncio.to_thread(
|
||||
self.llm.generate_response,
|
||||
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
|
||||
@@ -1486,7 +1477,6 @@ 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 = []
|
||||
|
||||
@@ -218,9 +218,8 @@ class MemoryGraph:
|
||||
for tool_call in search_results["tool_calls"]:
|
||||
if tool_call["name"] != "extract_entities":
|
||||
continue
|
||||
for item in tool_call.get("arguments", {}).get("entities", []):
|
||||
if "entity" in item and "entity_type" in item:
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
for item in tool_call["arguments"]["entities"]:
|
||||
entity_type_map[item["entity"]] = item["entity_type"]
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}"
|
||||
@@ -264,8 +263,8 @@ class MemoryGraph:
|
||||
)
|
||||
|
||||
entities = []
|
||||
if extracted_entities and extracted_entities.get("tool_calls"):
|
||||
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
|
||||
if extracted_entities["tool_calls"]:
|
||||
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
|
||||
|
||||
entities = self._remove_spaces_from_entities(entities)
|
||||
logger.debug(f"Extracted entities: {entities}")
|
||||
|
||||
@@ -24,18 +24,14 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
|
||||
|
||||
class AnonymousTelemetry:
|
||||
def __init__(self, vector_store=None):
|
||||
if not MEM0_TELEMETRY:
|
||||
self.posthog = None
|
||||
self.user_id = None
|
||||
return
|
||||
|
||||
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
|
||||
|
||||
self.user_id = get_or_create_user_id(vector_store)
|
||||
|
||||
def capture_event(self, event_name, properties=None, user_email=None):
|
||||
if self.posthog is None:
|
||||
return
|
||||
if not MEM0_TELEMETRY:
|
||||
self.posthog.disabled = True
|
||||
|
||||
def capture_event(self, event_name, properties=None, user_email=None):
|
||||
if properties is None:
|
||||
properties = {}
|
||||
properties = {
|
||||
@@ -53,17 +49,13 @@ class AnonymousTelemetry:
|
||||
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
|
||||
|
||||
def close(self):
|
||||
if self.posthog is not None:
|
||||
self.posthog.shutdown()
|
||||
self.posthog.shutdown()
|
||||
|
||||
|
||||
client_telemetry = AnonymousTelemetry()
|
||||
|
||||
|
||||
def capture_event(event_name, memory_instance, additional_data=None):
|
||||
if not MEM0_TELEMETRY:
|
||||
return
|
||||
|
||||
oss_telemetry = AnonymousTelemetry(
|
||||
vector_store=memory_instance._telemetry_vector_store
|
||||
if hasattr(memory_instance, "_telemetry_vector_store")
|
||||
@@ -89,9 +81,6 @@ def capture_event(event_name, memory_instance, additional_data=None):
|
||||
|
||||
|
||||
def capture_client_event(event_name, instance, additional_data=None):
|
||||
if not MEM0_TELEMETRY:
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
|
||||
}
|
||||
|
||||
+1
-53
@@ -1,15 +1,12 @@
|
||||
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.
|
||||
@@ -32,31 +29,6 @@ def get_fact_retrieval_messages_legacy(message):
|
||||
return FACT_RETRIEVAL_PROMPT, f"Input:\n{message}"
|
||||
|
||||
|
||||
def ensure_json_instruction(system_prompt, user_prompt):
|
||||
"""Ensure the word 'json' appears in the prompts when using json_object response format.
|
||||
|
||||
OpenAI's API requires the word 'json' to appear in the messages when
|
||||
response_format is set to {"type": "json_object"}. When users provide a
|
||||
custom_fact_extraction_prompt that doesn't include 'json', this causes a
|
||||
400 error. This function appends a JSON format instruction to the system
|
||||
prompt if 'json' is not already present in either prompt.
|
||||
|
||||
Args:
|
||||
system_prompt: The system prompt string
|
||||
user_prompt: The user prompt string
|
||||
|
||||
Returns:
|
||||
tuple: (system_prompt, user_prompt) with JSON instruction added if needed
|
||||
"""
|
||||
combined = (system_prompt + user_prompt).lower()
|
||||
if "json" not in combined:
|
||||
system_prompt += (
|
||||
"\n\nYou must return your response in valid JSON format "
|
||||
"with a 'facts' key containing an array of strings."
|
||||
)
|
||||
return system_prompt, user_prompt
|
||||
|
||||
|
||||
def parse_messages(messages):
|
||||
response = ""
|
||||
for msg in messages:
|
||||
@@ -80,30 +52,6 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import re
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import List, Dict, Any, Union
|
||||
|
||||
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
|
||||
from mem0.configs.rerankers.base import BaseRerankerConfig
|
||||
from mem0.configs.rerankers.llm import LLMRerankerConfig
|
||||
|
||||
|
||||
class LLMReranker(BaseReranker):
|
||||
@@ -33,30 +33,19 @@ class LLMReranker(BaseReranker):
|
||||
|
||||
self.config = config
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Initialize LLM using the factory
|
||||
self.llm = LlmFactory.create(llm_provider, llm_config)
|
||||
self.llm = LlmFactory.create(self.config.provider, llm_config)
|
||||
|
||||
# Default scoring prompt
|
||||
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
|
||||
|
||||
@@ -159,7 +159,7 @@ class RedisDB(VectorStoreBase):
|
||||
return [
|
||||
MemoryResult(
|
||||
id=result["memory_id"],
|
||||
score=float(result["vector_distance"]),
|
||||
score=result["vector_distance"],
|
||||
payload={
|
||||
"hash": result["hash"],
|
||||
"data": result["memory"],
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
package-manager-strict-version=false
|
||||
approve-builds=esbuild
|
||||
@@ -1,81 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
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
|
||||
|
||||
## [0.3.0] - 2026-03-10
|
||||
|
||||
### Fixed
|
||||
- Updated `mem0ai` dependency which includes the sqlite3 to better-sqlite3 migration for native binding resolution (#4270)
|
||||
|
||||
## [0.2.0] - 2026-03-09
|
||||
|
||||
### Added
|
||||
- "Understanding userId" section in docs clarifying that `userId` is user-defined
|
||||
- Per-agent memory isolation for multi-agent setups via `agentId`
|
||||
- Regression tests for per-agent isolation helpers
|
||||
|
||||
### Changed
|
||||
- Updated config examples to use concrete `userId` values instead of placeholders
|
||||
|
||||
### Fixed
|
||||
- Migrated platform search to Mem0 v2 API
|
||||
|
||||
## [0.1.2] - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Source field for openclaw memory entries
|
||||
|
||||
### Fixed
|
||||
- Auto-recall injection and auto-capture message drop
|
||||
|
||||
## [0.1.0] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- Initial release of the OpenClaw Mem0 plugin
|
||||
- Platform mode (Mem0 Cloud) and open-source mode support
|
||||
- Auto-recall: inject relevant memories before each turn
|
||||
- Auto-capture: store facts after each turn
|
||||
- Configurable `topK`, `threshold`, and `apiVersion` options
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user