feat: Add presence heartbeat for Matrix online status
- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
This commit is contained in:
24
services/memory-orchestrator/Dockerfile
Normal file
24
services/memory-orchestrator/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 7008
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:7008/health').raise_for_status()"
|
||||
|
||||
# Run
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7008"]
|
||||
|
||||
|
||||
|
||||
|
||||
317
services/memory-orchestrator/README.md
Normal file
317
services/memory-orchestrator/README.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Memory Orchestrator Service
|
||||
|
||||
**Port:** 7008
|
||||
**Purpose:** Unified memory API for DAARION agents
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Multi-layer memory:**
|
||||
- Short-term: Recent conversations & events (PostgreSQL)
|
||||
- Mid-term: Semantic memory with RAG (Vector store)
|
||||
- Long-term: Knowledge base (Docs, roadmaps)
|
||||
|
||||
✅ **Semantic search:**
|
||||
- Vector embeddings (BGE-M3 or similar)
|
||||
- Cosine similarity search
|
||||
- Fallback to text search if pgvector unavailable
|
||||
|
||||
✅ **Flexible storage:**
|
||||
- PostgreSQL for structured data
|
||||
- pgvector for embeddings (optional)
|
||||
- Filesystem for knowledge base (Phase 3 stub)
|
||||
|
||||
## API
|
||||
|
||||
### POST /internal/agent-memory/query
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"agent_id": "agent:sofia",
|
||||
"microdao_id": "microdao:7",
|
||||
"channel_id": "optional-channel-uuid",
|
||||
"query": "What were recent changes in this microDAO?",
|
||||
"limit": 5,
|
||||
"kind_filter": ["conversation", "dao-event"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "mem-uuid",
|
||||
"kind": "conversation",
|
||||
"score": 0.87,
|
||||
"content": "User discussed Phase 3 implementation...",
|
||||
"meta": {
|
||||
"source": "channel:...",
|
||||
"timestamp": "..."
|
||||
},
|
||||
"created_at": "2025-11-24T12:34:56Z"
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"query": "What were recent changes..."
|
||||
}
|
||||
```
|
||||
|
||||
### POST /internal/agent-memory/store
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"agent_id": "agent:sofia",
|
||||
"microdao_id": "microdao:7",
|
||||
"channel_id": "optional",
|
||||
"kind": "conversation",
|
||||
"content": {
|
||||
"user_message": "How do I start Phase 3?",
|
||||
"agent_reply": "First, copy PHASE3_MASTER_TASK.md..."
|
||||
},
|
||||
"metadata": {
|
||||
"channel_name": "dev-updates",
|
||||
"message_id": "msg-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"id": "mem-uuid-123"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /internal/agent-memory/summarize
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"agent_id": "agent:sofia",
|
||||
"microdao_id": "microdao:7",
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Phase 3 stub):**
|
||||
```json
|
||||
{
|
||||
"summary": "Recent activity summary: 10 memories retrieved. [Full summary in Phase 4]",
|
||||
"items_processed": 10
|
||||
}
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
**PostgreSQL with pgvector (optional):**
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
**Or without pgvector:**
|
||||
- Service will work with fallback text search
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/daarion
|
||||
EMBEDDING_ENDPOINT=http://localhost:8001/embed # Optional
|
||||
MEMORY_ORCHESTRATOR_SECRET=dev-secret-token
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
cd services/memory-orchestrator
|
||||
|
||||
# Install
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t memory-orchestrator .
|
||||
docker run -p 7008:7008 \
|
||||
-e DATABASE_URL="postgresql://..." \
|
||||
memory-orchestrator
|
||||
```
|
||||
|
||||
## Memory Types
|
||||
|
||||
### Short-term (7 days retention)
|
||||
- Recent channel messages
|
||||
- Event log
|
||||
- Quick lookup by time
|
||||
|
||||
**Storage:** `agent_memories_short` table
|
||||
|
||||
### Mid-term (90 days retention)
|
||||
- Semantic search
|
||||
- Conversation context
|
||||
- Task history
|
||||
|
||||
**Storage:** `agent_memories_vector` table (with embeddings)
|
||||
|
||||
### Long-term (permanent)
|
||||
- Knowledge base
|
||||
- Docs & roadmaps
|
||||
- Structured knowledge
|
||||
|
||||
**Storage:** Filesystem (Phase 3 stub)
|
||||
|
||||
## Integration with agent-runtime
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
async def get_agent_context(agent_id, query):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"http://memory-orchestrator:7008/internal/agent-memory/query",
|
||||
headers={"X-Internal-Secret": "dev-secret-token"},
|
||||
json={
|
||||
"agent_id": agent_id,
|
||||
"microdao_id": "microdao:7",
|
||||
"query": query,
|
||||
"limit": 5
|
||||
}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def save_conversation(agent_id, user_msg, agent_reply):
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(
|
||||
"http://memory-orchestrator:7008/internal/agent-memory/store",
|
||||
headers={"X-Internal-Secret": "dev-secret-token"},
|
||||
json={
|
||||
"agent_id": agent_id,
|
||||
"microdao_id": "microdao:7",
|
||||
"kind": "conversation",
|
||||
"content": {
|
||||
"user_message": user_msg,
|
||||
"agent_reply": agent_reply
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Embedding Service
|
||||
|
||||
### Option 1: Local Embedding (Recommended for Phase 3)
|
||||
|
||||
Use sentence-transformers or similar:
|
||||
|
||||
```python
|
||||
# Simple embedding server (Flask/FastAPI)
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
model = SentenceTransformer('BAAI/bge-m3')
|
||||
|
||||
@app.post("/embed")
|
||||
def embed(text: str):
|
||||
embedding = model.encode(text).tolist()
|
||||
return {"embedding": embedding}
|
||||
```
|
||||
|
||||
### Option 2: OpenAI Embeddings
|
||||
|
||||
```python
|
||||
# Update embedding_client.py to use OpenAI
|
||||
import openai
|
||||
|
||||
embedding = openai.embeddings.create(
|
||||
input=text,
|
||||
model="text-embedding-ada-002"
|
||||
)
|
||||
```
|
||||
|
||||
### Option 3: No Embedding (Fallback)
|
||||
|
||||
Service will use simple text search (ILIKE) if embedding service unavailable.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### agent_memories_short
|
||||
```sql
|
||||
CREATE TABLE agent_memories_short (
|
||||
id UUID PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
microdao_id TEXT NOT NULL,
|
||||
channel_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### agent_memories_vector
|
||||
```sql
|
||||
CREATE TABLE agent_memories_vector (
|
||||
id UUID PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
microdao_id TEXT NOT NULL,
|
||||
channel_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_json JSONB,
|
||||
embedding vector(1024), -- pgvector
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 3 (Current):
|
||||
- ✅ Short-term memory (PostgreSQL)
|
||||
- ✅ Mid-term memory (Vector search)
|
||||
- ✅ Long-term stub (KB filesystem)
|
||||
- ✅ Semantic search with embeddings
|
||||
- ✅ Fallback to text search
|
||||
|
||||
### Phase 3.5:
|
||||
- 🔜 LLM-based summarization
|
||||
- 🔜 Memory consolidation
|
||||
- 🔜 Context pruning
|
||||
- 🔜 Advanced RAG strategies
|
||||
|
||||
### Phase 4:
|
||||
- 🔜 Knowledge base indexing
|
||||
- 🔜 Multi-modal memory (images, files)
|
||||
- 🔜 Memory sharing across agents
|
||||
- 🔜 Forgetting mechanisms
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**pgvector not available?**
|
||||
- Service will work with text search fallback
|
||||
- To enable: `CREATE EXTENSION vector;` in PostgreSQL
|
||||
|
||||
**Embeddings not working?**
|
||||
- Check embedding service: `curl http://localhost:8001/embed`
|
||||
- Service will use fallback if unavailable
|
||||
|
||||
**Slow queries?**
|
||||
- Check indexes: `EXPLAIN ANALYZE SELECT ...`
|
||||
- Tune ivfflat index parameters
|
||||
- Consider increasing `lists` parameter
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Phase 3 Ready
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-11-24
|
||||
|
||||
|
||||
|
||||
|
||||
9
services/memory-orchestrator/backends/__init__.py
Normal file
9
services/memory-orchestrator/backends/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .short_term_pg import ShortTermBackend
|
||||
from .vector_store_pg import VectorStoreBackend
|
||||
from .kb_filesystem import KnowledgeBaseBackend
|
||||
|
||||
__all__ = ['ShortTermBackend', 'VectorStoreBackend', 'KnowledgeBaseBackend']
|
||||
|
||||
|
||||
|
||||
|
||||
75
services/memory-orchestrator/backends/kb_filesystem.py
Normal file
75
services/memory-orchestrator/backends/kb_filesystem.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import os
|
||||
import json
|
||||
from typing import Optional
|
||||
from models import MemoryItem
|
||||
from datetime import datetime
|
||||
|
||||
class KnowledgeBaseBackend:
|
||||
"""
|
||||
Long-term knowledge base (filesystem)
|
||||
|
||||
Phase 3: Stub implementation
|
||||
Stores docs, roadmaps, and structured knowledge
|
||||
"""
|
||||
|
||||
def __init__(self, kb_path: str = "/data/kb"):
|
||||
self.kb_path = kb_path
|
||||
|
||||
async def initialize(self):
|
||||
"""Create KB directory"""
|
||||
if not os.path.exists(self.kb_path):
|
||||
try:
|
||||
os.makedirs(self.kb_path, exist_ok=True)
|
||||
print(f"✅ KB directory created: {self.kb_path}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to create KB directory: {e}")
|
||||
print(" Using in-memory stub")
|
||||
|
||||
async def query(
|
||||
self,
|
||||
agent_id: str,
|
||||
query_text: str,
|
||||
limit: int = 5
|
||||
) -> list[MemoryItem]:
|
||||
"""
|
||||
Query knowledge base
|
||||
|
||||
Phase 3: Returns stub/empty results
|
||||
Phase 4: Implement proper KB indexing and search
|
||||
"""
|
||||
# Stub implementation for Phase 3
|
||||
print(f"ℹ️ KB query (stub): {query_text[:50]}...")
|
||||
|
||||
# Return empty results for now
|
||||
# In Phase 4, this would:
|
||||
# 1. Index docs/roadmaps with embeddings
|
||||
# 2. Perform semantic search
|
||||
# 3. Return relevant knowledge chunks
|
||||
|
||||
return []
|
||||
|
||||
async def store(
|
||||
self,
|
||||
agent_id: str,
|
||||
microdao_id: str,
|
||||
kind: str,
|
||||
content: dict,
|
||||
metadata: Optional[dict] = None
|
||||
) -> str:
|
||||
"""
|
||||
Store knowledge base entry
|
||||
|
||||
Phase 3: Stub implementation
|
||||
"""
|
||||
# Stub for Phase 3
|
||||
entry_id = f"kb-{datetime.now().timestamp()}"
|
||||
print(f"ℹ️ KB store (stub): {entry_id}")
|
||||
|
||||
# In Phase 4, would write to filesystem or DB
|
||||
# with proper indexing
|
||||
|
||||
return entry_id
|
||||
|
||||
|
||||
|
||||
|
||||
109
services/memory-orchestrator/backends/short_term_pg.py
Normal file
109
services/memory-orchestrator/backends/short_term_pg.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import asyncpg
|
||||
import json
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
from models import MemoryItem
|
||||
|
||||
class ShortTermBackend:
|
||||
"""
|
||||
Short-term memory backend (PostgreSQL)
|
||||
|
||||
Stores recent conversations and events for quick retrieval
|
||||
"""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool):
|
||||
self.pool = pool
|
||||
|
||||
async def initialize(self):
|
||||
"""Create tables if not exist"""
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_memories_short (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id TEXT NOT NULL,
|
||||
microdao_id TEXT NOT NULL,
|
||||
channel_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_short_agent_time
|
||||
ON agent_memories_short (agent_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_short_microdao
|
||||
ON agent_memories_short (microdao_id);
|
||||
""")
|
||||
print("✅ Short-term memory table initialized")
|
||||
|
||||
async def store(
|
||||
self,
|
||||
agent_id: str,
|
||||
microdao_id: str,
|
||||
kind: str,
|
||||
content: dict,
|
||||
channel_id: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
) -> str:
|
||||
"""Store a memory entry"""
|
||||
memory_id = str(uuid4())
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_memories_short
|
||||
(id, agent_id, microdao_id, channel_id, kind, content, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""",
|
||||
memory_id, agent_id, microdao_id, channel_id, kind,
|
||||
json.dumps(content), json.dumps(metadata or {}), datetime.now()
|
||||
)
|
||||
|
||||
return memory_id
|
||||
|
||||
async def query(
|
||||
self,
|
||||
agent_id: str,
|
||||
limit: int = 10,
|
||||
kind_filter: Optional[list[str]] = None
|
||||
) -> list[MemoryItem]:
|
||||
"""Query recent memories (simple time-based retrieval)"""
|
||||
query = """
|
||||
SELECT id, kind, content, metadata, created_at
|
||||
FROM agent_memories_short
|
||||
WHERE agent_id = $1
|
||||
"""
|
||||
params = [agent_id]
|
||||
|
||||
if kind_filter:
|
||||
query += f" AND kind = ANY($2)"
|
||||
params.append(kind_filter)
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT $" + str(len(params) + 1)
|
||||
params.append(limit)
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
content_dict = row['content']
|
||||
# Convert content dict to string for MemoryItem
|
||||
content_str = json.dumps(content_dict) if isinstance(content_dict, dict) else str(content_dict)
|
||||
|
||||
items.append(MemoryItem(
|
||||
id=str(row['id']),
|
||||
kind=row['kind'],
|
||||
score=1.0, # Time-based, no relevance score
|
||||
content=content_str,
|
||||
meta=row['metadata'] or {},
|
||||
created_at=row['created_at']
|
||||
))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
|
||||
|
||||
185
services/memory-orchestrator/backends/vector_store_pg.py
Normal file
185
services/memory-orchestrator/backends/vector_store_pg.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import asyncpg
|
||||
import json
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
from models import MemoryItem
|
||||
from embedding_client import EmbeddingClient
|
||||
|
||||
class VectorStoreBackend:
|
||||
"""
|
||||
Mid-term memory backend with vector search (PostgreSQL + pgvector)
|
||||
|
||||
For Phase 3: Uses simple stub if pgvector not available
|
||||
"""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool, embedding_client: EmbeddingClient):
|
||||
self.pool = pool
|
||||
self.embedding_client = embedding_client
|
||||
self.pgvector_available = False
|
||||
|
||||
async def initialize(self):
|
||||
"""Create tables if not exist"""
|
||||
async with self.pool.acquire() as conn:
|
||||
# Try to enable pgvector extension
|
||||
try:
|
||||
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
|
||||
self.pgvector_available = True
|
||||
print("✅ pgvector extension enabled")
|
||||
except Exception as e:
|
||||
print(f"⚠️ pgvector not available: {e}")
|
||||
print(" Will use fallback (simple text search)")
|
||||
|
||||
# Create table (with or without vector column)
|
||||
if self.pgvector_available:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_memories_vector (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id TEXT NOT NULL,
|
||||
microdao_id TEXT NOT NULL,
|
||||
channel_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_json JSONB,
|
||||
embedding vector(1024),
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_agent
|
||||
ON agent_memories_vector (agent_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_embedding
|
||||
ON agent_memories_vector USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
""")
|
||||
else:
|
||||
# Fallback table without vector column
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_memories_vector (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id TEXT NOT NULL,
|
||||
microdao_id TEXT NOT NULL,
|
||||
channel_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_json JSONB,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_agent
|
||||
ON agent_memories_vector (agent_id);
|
||||
""")
|
||||
|
||||
print("✅ Vector memory table initialized")
|
||||
|
||||
async def store(
|
||||
self,
|
||||
agent_id: str,
|
||||
microdao_id: str,
|
||||
kind: str,
|
||||
content: dict,
|
||||
channel_id: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
) -> str:
|
||||
"""Store a memory with embedding"""
|
||||
memory_id = str(uuid4())
|
||||
|
||||
# Convert content to text for embedding
|
||||
content_text = json.dumps(content)
|
||||
|
||||
# Generate embedding
|
||||
embedding = await self.embedding_client.embed(content_text)
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
if self.pgvector_available:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_memories_vector
|
||||
(id, agent_id, microdao_id, channel_id, kind, content, content_json, embedding, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::vector, $9)
|
||||
""",
|
||||
memory_id, agent_id, microdao_id, channel_id, kind,
|
||||
content_text, json.dumps(content), embedding, json.dumps(metadata or {})
|
||||
)
|
||||
else:
|
||||
# Fallback without embedding
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_memories_vector
|
||||
(id, agent_id, microdao_id, channel_id, kind, content, content_json, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""",
|
||||
memory_id, agent_id, microdao_id, channel_id, kind,
|
||||
content_text, json.dumps(content), json.dumps(metadata or {})
|
||||
)
|
||||
|
||||
return memory_id
|
||||
|
||||
async def query(
|
||||
self,
|
||||
agent_id: str,
|
||||
query_text: str,
|
||||
limit: int = 5,
|
||||
kind_filter: Optional[list[str]] = None
|
||||
) -> list[MemoryItem]:
|
||||
"""Query memories by semantic similarity"""
|
||||
|
||||
if self.pgvector_available:
|
||||
# Vector search
|
||||
query_embedding = await self.embedding_client.embed(query_text)
|
||||
|
||||
query_sql = """
|
||||
SELECT id, kind, content, metadata, created_at,
|
||||
1 - (embedding <=> $2::vector) as score
|
||||
FROM agent_memories_vector
|
||||
WHERE agent_id = $1
|
||||
"""
|
||||
params = [agent_id, query_embedding]
|
||||
|
||||
if kind_filter:
|
||||
query_sql += f" AND kind = ANY($3)"
|
||||
params.append(kind_filter)
|
||||
|
||||
query_sql += f" ORDER BY embedding <=> $2::vector LIMIT ${len(params) + 1}"
|
||||
params.append(limit)
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(query_sql, *params)
|
||||
|
||||
else:
|
||||
# Fallback: simple text search (ILIKE)
|
||||
query_sql = """
|
||||
SELECT id, kind, content, metadata, created_at, 0.5 as score
|
||||
FROM agent_memories_vector
|
||||
WHERE agent_id = $1 AND content ILIKE $2
|
||||
"""
|
||||
params = [agent_id, f"%{query_text}%"]
|
||||
|
||||
if kind_filter:
|
||||
query_sql += f" AND kind = ANY($3)"
|
||||
params.append(kind_filter)
|
||||
|
||||
query_sql += f" ORDER BY created_at DESC LIMIT ${len(params) + 1}"
|
||||
params.append(limit)
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(query_sql, *params)
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append(MemoryItem(
|
||||
id=str(row['id']),
|
||||
kind=row['kind'],
|
||||
score=float(row['score']),
|
||||
content=row['content'],
|
||||
meta=row['metadata'] or {},
|
||||
created_at=row['created_at']
|
||||
))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
|
||||
|
||||
32
services/memory-orchestrator/config.yaml
Normal file
32
services/memory-orchestrator/config.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
backends:
|
||||
short_term:
|
||||
type: "postgresql"
|
||||
table: "agent_memories_short"
|
||||
retention_days: 7
|
||||
|
||||
mid_term:
|
||||
type: "vector"
|
||||
table: "agent_memories_vector"
|
||||
embedding_dim: 1024 # BGE-M3 default
|
||||
retention_days: 90
|
||||
|
||||
long_term:
|
||||
type: "filesystem" # Phase 3 stub
|
||||
path: "/data/kb"
|
||||
|
||||
embedding:
|
||||
provider: "local" # or "openai"
|
||||
model: "bge-m3"
|
||||
endpoint: "http://localhost:8001/embed" # Placeholder
|
||||
|
||||
database:
|
||||
url_env: "DATABASE_URL"
|
||||
pool_size: 10
|
||||
|
||||
limits:
|
||||
max_memories_per_agent: 10000
|
||||
max_query_results: 50
|
||||
|
||||
|
||||
|
||||
|
||||
52
services/memory-orchestrator/embedding_client.py
Normal file
52
services/memory-orchestrator/embedding_client.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
class EmbeddingClient:
|
||||
"""Simple embedding client for Phase 3"""
|
||||
|
||||
def __init__(self, endpoint: str = "http://localhost:8001/embed", provider: str = "local"):
|
||||
self.endpoint = endpoint
|
||||
self.provider = provider
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding for text
|
||||
|
||||
For Phase 3: Returns stub embeddings if service unavailable
|
||||
"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
self.endpoint,
|
||||
json={"text": text}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("embedding", [])
|
||||
|
||||
except (httpx.ConnectError, httpx.HTTPStatusError):
|
||||
# Embedding service not available - return stub
|
||||
print(f"⚠️ Embedding service not available at {self.endpoint}, using stub")
|
||||
# Return zero vector as stub (1024 dimensions for BGE-M3)
|
||||
return [0.0] * 1024
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Embedding error: {e}")
|
||||
return [0.0] * 1024
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings for multiple texts"""
|
||||
# For Phase 3: simple sequential processing
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
emb = await self.embed(text)
|
||||
embeddings.append(emb)
|
||||
return embeddings
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
|
||||
|
||||
244
services/memory-orchestrator/main.py
Normal file
244
services/memory-orchestrator/main.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
DAARION Memory Orchestrator Service
|
||||
Port: 7008
|
||||
Unified memory API for agent intelligence (short/mid/long-term)
|
||||
"""
|
||||
import os
|
||||
import asyncpg
|
||||
from fastapi import FastAPI, HTTPException, Header
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from models import (
|
||||
MemoryQueryRequest, MemoryQueryResponse,
|
||||
MemoryStoreRequest, MemoryStoreResponse,
|
||||
MemorySummarizeRequest, MemorySummarizeResponse
|
||||
)
|
||||
from embedding_client import EmbeddingClient
|
||||
from backends import ShortTermBackend, VectorStoreBackend, KnowledgeBaseBackend
|
||||
|
||||
# ============================================================================
|
||||
# Global State
|
||||
# ============================================================================
|
||||
|
||||
db_pool: asyncpg.Pool | None = None
|
||||
embedding_client: EmbeddingClient | None = None
|
||||
short_term: ShortTermBackend | None = None
|
||||
mid_term: VectorStoreBackend | None = None
|
||||
long_term: KnowledgeBaseBackend | None = None
|
||||
|
||||
# ============================================================================
|
||||
# App Setup
|
||||
# ============================================================================
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown"""
|
||||
global db_pool, embedding_client, short_term, mid_term, long_term
|
||||
|
||||
# Startup
|
||||
print("🚀 Starting Memory Orchestrator service...")
|
||||
|
||||
# Database connection
|
||||
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
|
||||
db_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||
print("✅ Database pool created")
|
||||
|
||||
# Embedding client
|
||||
embedding_endpoint = os.getenv("EMBEDDING_ENDPOINT", "http://localhost:8001/embed")
|
||||
embedding_client = EmbeddingClient(endpoint=embedding_endpoint)
|
||||
print("✅ Embedding client initialized")
|
||||
|
||||
# Initialize backends
|
||||
short_term = ShortTermBackend(db_pool)
|
||||
await short_term.initialize()
|
||||
|
||||
mid_term = VectorStoreBackend(db_pool, embedding_client)
|
||||
await mid_term.initialize()
|
||||
|
||||
long_term = KnowledgeBaseBackend()
|
||||
await long_term.initialize()
|
||||
|
||||
print("✅ Memory Orchestrator ready")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print("🛑 Shutting down Memory Orchestrator...")
|
||||
if db_pool:
|
||||
await db_pool.close()
|
||||
if embedding_client:
|
||||
await embedding_client.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="DAARION Memory Orchestrator",
|
||||
version="1.0.0",
|
||||
description="Unified memory API for agent intelligence",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# API Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/internal/agent-memory/query", response_model=MemoryQueryResponse)
|
||||
async def query_memory(
|
||||
request: MemoryQueryRequest,
|
||||
x_internal_secret: str = Header(None, alias="X-Internal-Secret")
|
||||
):
|
||||
"""
|
||||
Query agent memory (semantic search)
|
||||
|
||||
Searches across short-term, mid-term, and long-term memory
|
||||
"""
|
||||
# Simple auth check
|
||||
expected_secret = os.getenv("MEMORY_ORCHESTRATOR_SECRET", "dev-secret-token")
|
||||
if x_internal_secret != expected_secret:
|
||||
raise HTTPException(401, "Invalid or missing X-Internal-Secret header")
|
||||
|
||||
all_items = []
|
||||
|
||||
try:
|
||||
# Query mid-term (vector search) - primary source
|
||||
mid_items = await mid_term.query(
|
||||
agent_id=request.agent_id,
|
||||
query_text=request.query,
|
||||
limit=request.limit,
|
||||
kind_filter=request.kind_filter
|
||||
)
|
||||
all_items.extend(mid_items)
|
||||
|
||||
# If not enough results, query short-term (recent context)
|
||||
if len(all_items) < request.limit:
|
||||
short_items = await short_term.query(
|
||||
agent_id=request.agent_id,
|
||||
limit=request.limit - len(all_items),
|
||||
kind_filter=request.kind_filter
|
||||
)
|
||||
all_items.extend(short_items)
|
||||
|
||||
# Query long-term (knowledge base) - optional
|
||||
if len(all_items) < request.limit:
|
||||
kb_items = await long_term.query(
|
||||
agent_id=request.agent_id,
|
||||
query_text=request.query,
|
||||
limit=request.limit - len(all_items)
|
||||
)
|
||||
all_items.extend(kb_items)
|
||||
|
||||
# Sort by score and limit
|
||||
all_items.sort(key=lambda x: x.score, reverse=True)
|
||||
all_items = all_items[:request.limit]
|
||||
|
||||
return MemoryQueryResponse(
|
||||
items=all_items,
|
||||
total=len(all_items),
|
||||
query=request.query
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Memory query failed: {str(e)}")
|
||||
|
||||
@app.post("/internal/agent-memory/store", response_model=MemoryStoreResponse)
|
||||
async def store_memory(
|
||||
request: MemoryStoreRequest,
|
||||
x_internal_secret: str = Header(None, alias="X-Internal-Secret")
|
||||
):
|
||||
"""
|
||||
Store a new memory entry
|
||||
|
||||
Stores in both short-term (quick access) and mid-term (vector search)
|
||||
"""
|
||||
expected_secret = os.getenv("MEMORY_ORCHESTRATOR_SECRET", "dev-secret-token")
|
||||
if x_internal_secret != expected_secret:
|
||||
raise HTTPException(401, "Invalid or missing X-Internal-Secret header")
|
||||
|
||||
try:
|
||||
# Store in short-term (always)
|
||||
memory_id = await short_term.store(
|
||||
agent_id=request.agent_id,
|
||||
microdao_id=request.microdao_id,
|
||||
kind=request.kind,
|
||||
content=request.content,
|
||||
channel_id=request.channel_id,
|
||||
metadata=request.metadata
|
||||
)
|
||||
|
||||
# Store in mid-term (with embedding) for conversations
|
||||
if request.kind in ["conversation", "task", "dao-event"]:
|
||||
await mid_term.store(
|
||||
agent_id=request.agent_id,
|
||||
microdao_id=request.microdao_id,
|
||||
kind=request.kind,
|
||||
content=request.content,
|
||||
channel_id=request.channel_id,
|
||||
metadata=request.metadata
|
||||
)
|
||||
|
||||
return MemoryStoreResponse(ok=True, id=memory_id)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Memory store failed: {str(e)}")
|
||||
|
||||
@app.post("/internal/agent-memory/summarize", response_model=MemorySummarizeResponse)
|
||||
async def summarize_memory(
|
||||
request: MemorySummarizeRequest,
|
||||
x_internal_secret: str = Header(None, alias="X-Internal-Secret")
|
||||
):
|
||||
"""
|
||||
Summarize recent memories (stub for Phase 3)
|
||||
|
||||
Phase 4: Will use LLM to generate summaries
|
||||
"""
|
||||
expected_secret = os.getenv("MEMORY_ORCHESTRATOR_SECRET", "dev-secret-token")
|
||||
if x_internal_secret != expected_secret:
|
||||
raise HTTPException(401, "Invalid or missing X-Internal-Secret header")
|
||||
|
||||
# Stub implementation for Phase 3
|
||||
# Phase 4: Call LLM Proxy to generate summary
|
||||
|
||||
try:
|
||||
items = await short_term.query(
|
||||
agent_id=request.agent_id,
|
||||
limit=request.limit
|
||||
)
|
||||
|
||||
summary = f"Recent activity summary for {request.agent_id}: {len(items)} memories retrieved. [Summary generation coming in Phase 4]"
|
||||
|
||||
return MemorySummarizeResponse(
|
||||
summary=summary,
|
||||
items_processed=len(items)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Memory summarize failed: {str(e)}")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "memory-orchestrator",
|
||||
"database": "connected" if db_pool else "disconnected",
|
||||
"embedding": "ready" if embedding_client else "not ready"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Run
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=7008)
|
||||
|
||||
|
||||
|
||||
|
||||
51
services/memory-orchestrator/models.py
Normal file
51
services/memory-orchestrator/models.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
class MemoryQueryRequest(BaseModel):
|
||||
agent_id: str = Field(..., description="Agent ID, e.g., agent:sofia")
|
||||
microdao_id: str = Field(..., description="MicroDAO ID")
|
||||
channel_id: Optional[str] = None
|
||||
query: str = Field(..., min_length=1, description="Query text for semantic search")
|
||||
limit: int = Field(5, ge=1, le=50, description="Max results to return")
|
||||
kind_filter: Optional[list[str]] = Field(None, description="Filter by memory kind")
|
||||
|
||||
class MemoryItem(BaseModel):
|
||||
id: str
|
||||
kind: Literal["conversation", "kb", "task", "dao-event", "channel-context"]
|
||||
score: float = Field(..., ge=0.0, le=1.0, description="Relevance score")
|
||||
content: str
|
||||
meta: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
class MemoryQueryResponse(BaseModel):
|
||||
items: list[MemoryItem]
|
||||
total: int
|
||||
query: str
|
||||
|
||||
class MemoryStoreRequest(BaseModel):
|
||||
agent_id: str
|
||||
microdao_id: str
|
||||
channel_id: Optional[str] = None
|
||||
kind: Literal["conversation", "kb", "task", "dao-event", "channel-context"]
|
||||
content: Dict[str, Any] = Field(..., description="Structured content to store")
|
||||
metadata: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
||||
|
||||
class MemoryStoreResponse(BaseModel):
|
||||
ok: bool
|
||||
id: str
|
||||
|
||||
class MemorySummarizeRequest(BaseModel):
|
||||
agent_id: str
|
||||
microdao_id: str
|
||||
channel_id: Optional[str] = None
|
||||
limit: int = Field(10, ge=1, le=100, description="Number of recent items to summarize")
|
||||
|
||||
class MemorySummarizeResponse(BaseModel):
|
||||
summary: str
|
||||
items_processed: int
|
||||
|
||||
|
||||
|
||||
|
||||
11
services/memory-orchestrator/requirements.txt
Normal file
11
services/memory-orchestrator/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.5.3
|
||||
asyncpg==0.29.0
|
||||
httpx==0.26.0
|
||||
pyyaml==6.0.1
|
||||
python-multipart==0.0.6
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user