standalone and fix

This commit is contained in:
Nicolò Boschi 2025-11-10 17:48:23 +01:00
parent 646dea89d9
commit a1c5f9847a
25 changed files with 1509 additions and 184 deletions

BIN
benchmarks/.DS_Store vendored

Binary file not shown.

View file

@ -62,7 +62,7 @@ export function DocumentsView() {
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search documents (ID, metadata)..." placeholder="Search documents (ID)..."
className="w-full max-w-2xl px-2.5 py-2 mb-4 mx-5 border-2 border-border bg-background text-foreground rounded text-sm focus:outline-none focus:ring-2 focus:ring-ring" className="w-full max-w-2xl px-2.5 py-2 mb-4 mx-5 border-2 border-border bg-background text-foreground rounded text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/> />
@ -75,7 +75,6 @@ export function DocumentsView() {
<th className="p-2.5 text-left border border-border bg-card text-card-foreground">Updated</th> <th className="p-2.5 text-left border border-border bg-card text-card-foreground">Updated</th>
<th className="p-2.5 text-left border border-border bg-card text-card-foreground">Text Length</th> <th className="p-2.5 text-left border border-border bg-card text-card-foreground">Text Length</th>
<th className="p-2.5 text-left border border-border bg-card text-card-foreground">Memory Units</th> <th className="p-2.5 text-left border border-border bg-card text-card-foreground">Memory Units</th>
<th className="p-2.5 text-left border border-border bg-card text-card-foreground">Metadata</th>
<th className="p-2.5 text-left border border-border bg-card text-card-foreground">Actions</th> <th className="p-2.5 text-left border border-border bg-card text-card-foreground">Actions</th>
</tr> </tr>
</thead> </thead>
@ -94,11 +93,6 @@ export function DocumentsView() {
</td> </td>
<td className="p-2 border border-border">{doc.text_length?.toLocaleString()} chars</td> <td className="p-2 border border-border">{doc.text_length?.toLocaleString()} chars</td>
<td className="p-2 border border-border">{doc.memory_unit_count}</td> <td className="p-2 border border-border">{doc.memory_unit_count}</td>
<td className="p-2 border border-border" title={JSON.stringify(doc.metadata)}>
{Object.keys(doc.metadata || {}).length > 0
? JSON.stringify(doc.metadata).substring(0, 50) + '...'
: 'None'}
</td>
<td className="p-2 border border-border"> <td className="p-2 border border-border">
<button <button
onClick={() => viewDocumentText(doc.id)} onClick={() => viewDocumentText(doc.id)}
@ -112,7 +106,7 @@ export function DocumentsView() {
)) ))
) : ( ) : (
<tr> <tr>
<td colSpan={7} className="p-10 text-center text-muted-foreground bg-muted"> <td colSpan={6} className="p-10 text-center text-muted-foreground bg-muted">
Click "Load Documents" to view data Click "Load Documents" to view data
</td> </td>
</tr> </tr>

79
memora-cli/README.md Normal file
View file

@ -0,0 +1,79 @@
# Memora CLI
Modern command-line interface for the Memora Temporal Semantic Memory System.
## Installation
```bash
pip install memora-cli
```
## Configuration
Set the API endpoint URL (defaults to `http://localhost:8080`):
```bash
export MEMORA_API_URL="http://localhost:8080"
```
## Commands
### Search Memories
```bash
memora search alice "What did she say about AI?"
memora search alice "hiking activities" --type world --max-tokens 8000
memora search alice "recent events" --budget 150 --trace
```
### Think (Generate Answers)
```bash
memora think alice "What do you think about machine learning?"
```
### Store Memories
Store a single memory:
```bash
memora put alice "Alice loves machine learning and AI"
memora put alice "Today we discussed neural networks" --context "team meeting"
# Async mode - returns immediately, processes in background
memora put alice "Important note" --async
```
### Import Files
Import memories from local files (.txt and .md):
```bash
# Import a single file
memora put-files alice meeting-notes.txt
# Import all files from a directory
memora put-files alice ./documents/
# Async mode - queue files for background processing
memora put-files alice ./documents/ --async
```
### List Agents
```bash
memora agents
```
## Features
- Beautiful TUI with Rich formatting (panels, tables, syntax highlighting)
- Color-coded fact types (cyan=world, magenta=agent, yellow=opinion)
- Progress bars and spinners for async operations
- Tree views for file hierarchies
- HTTP client (no direct database access needed)
## Requirements
- Python >= 3.11
- Memora API server running

View file

@ -0,0 +1,5 @@
"""
Memora CLI - Modern command-line interface for the Memora Memory System.
"""
__version__ = "0.1.0"

View file

@ -0,0 +1,565 @@
"""
Memora CLI - HTTP client for Memora API.
"""
import os
from pathlib import Path
from typing import Optional, List
from datetime import datetime
import typer
import httpx
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
from rich.markdown import Markdown
from rich import box
from rich.tree import Tree
app = typer.Typer(
name="memora",
help="Modern CLI for Memora - Temporal Semantic Memory System",
add_completion=False,
)
console = Console()
def get_api_url():
"""Get API URL from environment variable."""
api_url = os.getenv("MEMORA_API_URL", "http://localhost:8080")
return api_url.rstrip("/")
def make_api_request(
method: str,
endpoint: str,
json_data: Optional[dict] = None,
timeout: float = 60.0,
) -> dict:
"""
Make an API request with proper error handling.
Args:
method: HTTP method (GET, POST, etc.)
endpoint: API endpoint path (e.g., "/api/search")
json_data: Optional JSON payload for POST requests
timeout: Request timeout in seconds
Returns:
Response data as dict
Raises:
typer.Exit on any error
"""
api_url = get_api_url()
full_url = f"{api_url}{endpoint}"
try:
with httpx.Client(timeout=timeout) as client:
if method.upper() == "GET":
response = client.get(full_url)
elif method.upper() == "POST":
response = client.post(full_url, json=json_data)
else:
console.print(f"[red]Error: Unsupported HTTP method: {method}[/red]")
raise typer.Exit(1)
# Check HTTP status
response.raise_for_status()
# Parse response
data = response.json()
# Check for success field in response (if present)
if "success" in data and not data["success"]:
error_msg = data.get("message", "Unknown error")
console.print(f"[red]API Error: {error_msg}[/red]")
if "detail" in data:
console.print(f"[yellow]Details: {data['detail']}[/yellow]")
raise typer.Exit(1)
return data
except httpx.HTTPStatusError as e:
console.print(f"[red]HTTP Error {e.response.status_code}[/red]")
try:
error_data = e.response.json()
if "detail" in error_data:
console.print(f"[red]Error: {error_data['detail']}[/red]")
else:
console.print(f"[red]Error: {error_data}[/red]")
except Exception:
console.print(f"[red]Error: {e.response.text}[/red]")
console.print(f"[yellow]Make sure the API is running at {api_url}[/yellow]")
raise typer.Exit(1)
except httpx.ConnectError as e:
console.print(f"[red]Connection Error: Failed to connect to API at {api_url}[/red]")
console.print(f"[yellow]Make sure the API server is running[/yellow]")
raise typer.Exit(1)
except httpx.TimeoutException:
console.print(f"[red]Timeout Error: Request took too long[/red]")
console.print(f"[yellow]Try increasing the timeout or check the API server[/yellow]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Unexpected Error: {e}[/red]")
raise typer.Exit(1)
@app.command()
def search(
agent_id: str = typer.Argument(..., help="Agent ID to search for"),
query: str = typer.Argument(..., help="Search query"),
fact_type: List[str] = typer.Option(
["world", "agent", "opinion"],
"--type",
"-t",
help="Fact types to search (world/agent/opinion)",
),
thinking_budget: int = typer.Option(
100, "--budget", "-b", help="Thinking budget for search"
),
max_tokens: int = typer.Option(
4096, "--max-tokens", help="Maximum tokens for search results"
),
trace: bool = typer.Option(False, "--trace", help="Show trace information"),
):
"""
Search memory using semantic similarity.
Example:
memora search alice "What did she say about AI?"
"""
with console.status(f"[bold blue]Searching memories for {agent_id}...", spinner="dots"):
data = make_api_request(
method="POST",
endpoint="/api/search",
json_data={
"query": query,
"fact_type": list(fact_type),
"agent_id": agent_id,
"thinking_budget": thinking_budget,
"max_tokens": max_tokens,
"trace": trace,
},
timeout=60.0,
)
results = data.get("results", [])
trace_data = data.get("trace")
# Display results
if not results:
console.print("[yellow]No results found.[/yellow]")
return
console.print(f"\n[bold green]Found {len(results)} results:[/bold green]\n")
for i, result in enumerate(results, 1):
# Create a panel for each result
score = result.get("score", 0.0)
text = result.get("text", "")
fact_type_val = result.get("fact_type", "unknown")
context = result.get("context", "")
date = result.get("date", "")
# Color code based on fact type
type_colors = {
"world": "cyan",
"agent": "magenta",
"opinion": "yellow"
}
color = type_colors.get(fact_type_val, "white")
# Build info line
info_parts = [f"[{color}]{fact_type_val.upper()}[/{color}]"]
if context:
info_parts.append(f"Context: {context}")
if date:
info_parts.append(f"Date: {date}")
info_parts.append(f"Score: {score:.3f}")
info_line = " | ".join(info_parts)
panel = Panel(
f"{text}\n\n[dim]{info_line}[/dim]",
title=f"[bold]Result {i}[/bold]",
border_style=color,
box=box.ROUNDED,
)
console.print(panel)
# Show trace if requested
if trace and trace_data:
console.print("\n[bold blue]Trace Information:[/bold blue]")
trace_table = Table(show_header=True, box=box.SIMPLE)
trace_table.add_column("Metric", style="cyan")
trace_table.add_column("Value", style="green")
if "search_time_seconds" in trace_data:
trace_table.add_row("Search Time", f"{trace_data['search_time_seconds']:.3f}s")
if "total_activated" in trace_data:
trace_table.add_row("Total Activated", str(trace_data["total_activated"]))
if "results_returned" in trace_data:
trace_table.add_row("Results Returned", str(trace_data["results_returned"]))
console.print(trace_table)
@app.command()
def think(
agent_id: str = typer.Argument(..., help="Agent ID"),
query: str = typer.Argument(..., help="Question to think about"),
thinking_budget: int = typer.Option(
50, "--budget", "-b", help="Thinking budget"
),
):
"""
Think and generate an answer using agent identity and memories.
Example:
memora think alice "What do you think about machine learning?"
"""
with console.status(f"[bold blue]Thinking...", spinner="dots"):
result = make_api_request(
method="POST",
endpoint="/api/think",
json_data={
"query": query,
"agent_id": agent_id,
"thinking_budget": thinking_budget,
},
timeout=60.0,
)
# Display answer
console.print(Panel(
Markdown(result["text"]),
title=f"[bold cyan]Answer for {agent_id}[/bold cyan]",
border_style="cyan",
box=box.DOUBLE,
))
# Display what the answer was based on
based_on = result.get("based_on", {})
if based_on:
console.print("\n[bold blue]Based on:[/bold blue]\n")
for fact_type, facts in based_on.items():
if facts:
type_colors = {
"world": "cyan",
"agent": "magenta",
"opinion": "yellow"
}
color = type_colors.get(fact_type, "white")
table = Table(
title=f"[{color}]{fact_type.upper()}[/{color}]",
show_header=True,
box=box.ROUNDED,
border_style=color,
)
table.add_column("Text", style="white", width=80)
table.add_column("Score", justify="right", style="green", width=10)
for fact in facts[:5]: # Show top 5
text = fact.get("text", "")
score = fact.get("score", 0.0)
table.add_row(text, f"{score:.3f}")
console.print(table)
# Display new opinions formed
new_opinions = result.get("new_opinions", [])
if new_opinions:
console.print("\n[bold yellow]New Opinions Formed:[/bold yellow]\n")
for opinion in new_opinions:
console.print(Panel(
f"{opinion['text']}\n\n[dim]Confidence: {opinion['confidence']:.2f}[/dim]",
border_style="yellow",
box=box.ROUNDED,
))
@app.command()
def put(
agent_id: str = typer.Argument(..., help="Agent ID"),
content: str = typer.Argument(..., help="Memory content to store"),
document_id: Optional[str] = typer.Option(
None, "--doc-id", "-d", help="Document ID (auto-generated if not provided)"
),
context: Optional[str] = typer.Option(
None, "--context", "-c", help="Context for the memory"
),
use_async: bool = typer.Option(
False, "--async", help="Use async batch put (returns immediately, processes in background)"
),
):
"""
Store a memory from text input.
Example:
memora put alice "Alice loves machine learning and AI"
memora put alice "Today we discussed neural networks" --context "team meeting"
memora put alice "Important note" --async
"""
# Generate document_id if not provided
if not document_id:
document_id = f"cli_put_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
# Prepare content
item = {"content": content}
if context:
item["context"] = context
# Choose endpoint based on async flag
endpoint = "/api/memories/batch_async" if use_async else "/api/memories/batch"
status_msg = "Queueing memory" if use_async else "Storing memory"
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=console,
) as progress:
task = progress.add_task(f"[cyan]{status_msg} for {agent_id}...", total=None)
result = make_api_request(
method="POST",
endpoint=endpoint,
json_data={
"agent_id": agent_id,
"items": [item],
"document_id": document_id,
},
timeout=120.0,
)
progress.update(task, completed=True)
# Check if the result indicates success
if not result.get("success", False):
console.print(Panel(
f"[red]✗[/red] Failed to store memory\n"
f"[dim]Error:[/dim] {result.get('message', 'Unknown error')}",
title="[bold red]Storage Failed[/bold red]",
border_style="red",
box=box.ROUNDED,
))
raise typer.Exit(1)
# Display result based on async vs sync
if use_async and result.get("queued", False):
console.print(Panel(
f"[green]✓[/green] Memory queued for background processing\n"
f"[dim]Agent ID:[/dim] {agent_id}\n"
f"[dim]Document ID:[/dim] {document_id}\n"
f"[dim]Content length:[/dim] {len(content)} characters\n"
f"[dim]Items queued:[/dim] {result.get('items_count', 1)}\n"
f"[yellow]Processing in background...[/yellow]",
title="[bold green]Memory Queued[/bold green]",
border_style="green",
box=box.ROUNDED,
))
else:
console.print(Panel(
f"[green]✓[/green] Successfully stored memory\n"
f"[dim]Agent ID:[/dim] {agent_id}\n"
f"[dim]Document ID:[/dim] {document_id}\n"
f"[dim]Content length:[/dim] {len(content)} characters\n"
f"[dim]Items processed:[/dim] {result.get('items_count', 1)}",
title="[bold green]Memory Stored[/bold green]",
border_style="green",
box=box.ROUNDED,
))
@app.command(name="put-files")
def put_files(
agent_id: str = typer.Argument(..., help="Agent ID"),
path: str = typer.Argument(..., help="File or directory path"),
recursive: bool = typer.Option(
True, "--recursive/--no-recursive", "-r", help="Search directories recursively"
),
use_async: bool = typer.Option(
False, "--async", help="Use async batch put (returns immediately, processes in background)"
),
):
"""
Store memories from local files (.txt and .md only).
Each file becomes a separate document with the filename as doc_id.
Example:
memora put-files alice ./documents/
memora put-files alice meeting-notes.txt
memora put-files alice ./documents/ --async
"""
path_obj = Path(path)
if not path_obj.exists():
console.print(f"[red]Error: Path '{path}' does not exist[/red]")
raise typer.Exit(1)
# Collect files to process
files_to_process = []
if path_obj.is_file():
if path_obj.suffix.lower() in ['.txt', '.md']:
files_to_process.append(path_obj)
else:
console.print(f"[yellow]Warning: Skipping '{path}' - only .txt and .md files are supported[/yellow]")
raise typer.Exit(0)
else:
# Directory - find all .txt and .md files
pattern = "**/*" if recursive else "*"
for ext in ['.txt', '.md']:
files_to_process.extend(path_obj.glob(f"{pattern}{ext}"))
if not files_to_process:
console.print(f"[yellow]No .txt or .md files found in '{path}'[/yellow]")
raise typer.Exit(0)
# Display files to be processed
console.print(f"\n[bold]Found {len(files_to_process)} files to process:[/bold]\n")
tree = Tree(f"[bold cyan]{path}[/bold cyan]")
for file_path in sorted(files_to_process):
size = file_path.stat().st_size
size_str = f"{size:,} bytes" if size < 1024 else f"{size/1024:.1f} KB"
tree.add(f"{file_path.name} [dim]({size_str})[/dim]")
console.print(tree)
console.print()
# Process files
successful = 0
failed = 0
queued = 0
# Choose endpoint based on async flag
endpoint = "/api/memories/batch_async" if use_async else "/api/memories/batch"
status_msg = "Queueing files" if use_async else "Processing files"
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=console,
) as progress:
main_task = progress.add_task(
f"[cyan]{status_msg} for {agent_id}...",
total=len(files_to_process)
)
for file_path in files_to_process:
try:
# Read file content
content = file_path.read_text(encoding='utf-8')
# Use filename (without extension) as document_id
doc_id = file_path.stem
# Prepare content
item = {
"content": content,
"context": f"File: {file_path.name}"
}
# Store memory via API
result = make_api_request(
method="POST",
endpoint=endpoint,
json_data={
"agent_id": agent_id,
"items": [item],
"document_id": doc_id,
},
timeout=120.0,
)
# Check if the result indicates success
if not result.get("success", False):
raise Exception(result.get("message", "Unknown error"))
if use_async and result.get("queued", False):
queued += 1
else:
successful += 1
progress.update(main_task, advance=1)
except typer.Exit:
# Re-raise typer.Exit to stop execution
raise
except Exception as e:
console.print(f"[red]Failed to process {file_path.name}: {str(e)}[/red]")
failed += 1
progress.update(main_task, advance=1)
# Summary
console.print()
if use_async and queued > 0:
console.print(Panel(
f"[green]✓[/green] Successfully queued {queued} file(s) for background processing\n"
f"[red]✗[/red] Failed: {failed}\n"
f"[dim]Agent ID:[/dim] {agent_id}\n"
f"[yellow]Processing in background...[/yellow]",
title="[bold green]Files Queued[/bold green]",
border_style="green" if failed == 0 else "yellow",
box=box.ROUNDED,
))
elif successful > 0:
console.print(Panel(
f"[green]✓[/green] Successfully processed {successful} file(s)\n"
f"[red]✗[/red] Failed: {failed}\n"
f"[dim]Agent ID:[/dim] {agent_id}",
title="[bold green]Files Processed[/bold green]",
border_style="green" if failed == 0 else "yellow",
box=box.ROUNDED,
))
else:
console.print("[red]No files were successfully processed[/red]")
@app.command()
def agents():
"""
List all agents in the memory system.
Example:
memora agents
"""
with console.status("[bold blue]Fetching agents...", spinner="dots"):
data = make_api_request(
method="GET",
endpoint="/api/agents",
timeout=30.0,
)
agent_list = data.get("agents", [])
if not agent_list:
console.print("[yellow]No agents found in the system.[/yellow]")
return
console.print(f"\n[bold green]Found {len(agent_list)} agent(s):[/bold green]\n")
table = Table(show_header=True, box=box.ROUNDED, border_style="cyan")
table.add_column("#", style="dim", width=6)
table.add_column("Agent ID", style="cyan")
for i, agent in enumerate(agent_list, 1):
table.add_row(str(i), agent)
console.print(table)
def main():
"""Main entry point for the CLI."""
app()
if __name__ == "__main__":
main()

21
memora-cli/pyproject.toml Normal file
View file

@ -0,0 +1,21 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "memora-cli"
version = "0.1.0"
description = "Modern CLI for Memora - Temporal Semantic Memory System"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"rich>=13.0.0",
"typer>=0.20.0",
"httpx>=0.27.0",
]
[project.scripts]
memora = "memora_cli.main:main"
[tool.hatch.build.targets.wheel]
packages = ["memora_cli"]

View file

@ -21,7 +21,7 @@ from memora import TemporalSemanticMemory
class SearchRequest(BaseModel): class SearchRequest(BaseModel):
"""Request model for search endpoint.""" """Request model for search endpoint."""
query: str query: str
fact_type: List[str] # List of fact types to search fact_type: Optional[List[str]] = None # List of fact types to search (defaults to all if not specified)
agent_id: str = "default" agent_id: str = "default"
thinking_budget: int = 100 thinking_budget: int = 100
max_tokens: int = 4096 max_tokens: int = 4096
@ -274,7 +274,6 @@ class ListDocumentsResponse(BaseModel):
"id": "session_1", "id": "session_1",
"agent_id": "user123", "agent_id": "user123",
"content_hash": "abc123", "content_hash": "abc123",
"metadata": {"source": "conversation"},
"created_at": "2024-01-15T10:30:00Z", "created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:00Z",
"text_length": 5420, "text_length": 5420,
@ -294,7 +293,6 @@ class DocumentResponse(BaseModel):
agent_id: str agent_id: str
original_text: str original_text: str
content_hash: Optional[str] content_hash: Optional[str]
metadata: Dict[str, Any]
created_at: str created_at: str
updated_at: str updated_at: str
memory_unit_count: int memory_unit_count: int
@ -306,7 +304,6 @@ class DocumentResponse(BaseModel):
"agent_id": "user123", "agent_id": "user123",
"original_text": "Full document text here...", "original_text": "Full document text here...",
"content_hash": "abc123", "content_hash": "abc123",
"metadata": {"source": "conversation"},
"created_at": "2024-01-15T10:30:00Z", "created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:00Z",
"memory_unit_count": 15 "memory_unit_count": 15
@ -380,15 +377,6 @@ The system uses:
def _register_routes(app: FastAPI): def _register_routes(app: FastAPI):
"""Register all API routes on the given app instance.""" """Register all API routes on the given app instance."""
@app.get("/", include_in_schema=False)
async def index():
"""Root endpoint - directs to control plane."""
return {
"message": "Memory Control Plane API",
"docs": "/docs",
"control_plane": "The web UI has moved to the Next.js control plane. Please use the control-plane directory."
}
@app.get( @app.get(
"/api/graph", "/api/graph",
@ -472,18 +460,16 @@ def _register_routes(app: FastAPI):
# Validate fact_type(s) # Validate fact_type(s)
valid_fact_types = ["world", "agent", "opinion"] valid_fact_types = ["world", "agent", "opinion"]
# Default to all fact types if not specified
if not request.fact_type: if not request.fact_type:
raise HTTPException( request.fact_type = valid_fact_types
status_code=400, else:
detail="fact_type must be a non-empty list" for ft in request.fact_type:
) if ft not in valid_fact_types:
raise HTTPException(
for ft in request.fact_type: status_code=400,
if ft not in valid_fact_types: detail=f"Invalid fact_type '{ft}'. Must be one of: {', '.join(valid_fact_types)}"
raise HTTPException( )
status_code=400,
detail=f"Invalid fact_type '{ft}'. Must be one of: {', '.join(valid_fact_types)}"
)
# Parse question_date if provided # Parse question_date if provided
question_date = None question_date = None
@ -508,11 +494,22 @@ def _register_routes(app: FastAPI):
question_date=question_date question_date=question_date
) )
# Filter results to only include specific fields
filtered_results = [
{
"id": result.get("id"),
"text": result.get("text"),
"context": result.get("context"),
"event_date": result.get("event_date")
}
for result in results
]
# Convert trace to dict # Convert trace to dict
trace_dict = trace.to_dict() if trace else None trace_dict = trace.to_dict() if trace else None
return SearchResponse( return SearchResponse(
results=results, results=filtered_results,
trace=trace_dict trace=trace_dict
) )
except HTTPException: except HTTPException:
@ -672,7 +669,7 @@ def _register_routes(app: FastAPI):
description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted." description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted."
) )
async def api_list_documents( async def api_list_documents(
agent_id: Optional[str] = None, agent_id: str,
q: Optional[str] = None, q: Optional[str] = None,
limit: int = 100, limit: int = 100,
offset: int = 0 offset: int = 0
@ -681,7 +678,7 @@ def _register_routes(app: FastAPI):
List documents for an agent with optional search. List documents for an agent with optional search.
Args: Args:
agent_id: Filter by agent ID agent_id: Agent ID (required)
q: Search query (searches document ID and metadata) q: Search query (searches document ID and metadata)
limit: Maximum number of results (default: 100) limit: Maximum number of results (default: 100)
offset: Offset for pagination (default: 0) offset: Offset for pagination (default: 0)
@ -760,14 +757,6 @@ def _register_routes(app: FastAPI):
) )
async def api_batch_put(request: BatchPutRequest): async def api_batch_put(request: BatchPutRequest):
try: try:
# Validate agent_id - prevent writing to reserved agents
RESERVED_AGENT_IDS = {"locomo"}
if request.agent_id in RESERVED_AGENT_IDS:
raise HTTPException(
status_code=403,
detail=f"Cannot write to reserved agent_id '{request.agent_id}'. Reserved agents: {', '.join(RESERVED_AGENT_IDS)}"
)
# Prepare contents for put_batch_async # Prepare contents for put_batch_async
contents = [] contents = []
for item in request.items: for item in request.items:
@ -784,7 +773,7 @@ def _register_routes(app: FastAPI):
contents=contents, contents=contents,
document_id=request.document_id document_id=request.document_id
) )
logging.info(f"Batch put result: {result}")
return BatchPutResponse( return BatchPutResponse(
success=True, success=True,
@ -830,14 +819,6 @@ def _register_routes(app: FastAPI):
) )
async def api_batch_put_async(request: BatchPutRequest): async def api_batch_put_async(request: BatchPutRequest):
try: try:
# Validate agent_id - prevent writing to reserved agents
RESERVED_AGENT_IDS = {"locomo"}
if request.agent_id in RESERVED_AGENT_IDS:
raise HTTPException(
status_code=403,
detail=f"Cannot write to reserved agent_id '{request.agent_id}'. Reserved agents: {', '.join(RESERVED_AGENT_IDS)}"
)
# Prepare contents for put_batch_async # Prepare contents for put_batch_async
contents = [] contents = []
for item in request.items: for item in request.items:

View file

@ -57,11 +57,15 @@ class ThinkOperationsMixin:
fact_type=['agent', 'world', 'opinion'] fact_type=['agent', 'world', 'opinion']
) )
logger.info(f"[THINK] Search returned {len(all_results)} results")
# Split results by fact type for structured response # Split results by fact type for structured response
agent_results = [r for r in all_results if r.get('fact_type') == 'agent'] agent_results = [r for r in all_results if r.get('fact_type') == 'agent']
world_results = [r for r in all_results if r.get('fact_type') == 'world'] world_results = [r for r in all_results if r.get('fact_type') == 'world']
opinion_results = [r for r in all_results if r.get('fact_type') == 'opinion'] opinion_results = [r for r in all_results if r.get('fact_type') == 'opinion']
logger.info(f"[THINK] Split results - agent: {len(agent_results)}, world: {len(world_results)}, opinion: {len(opinion_results)}")
# Step 4: Format facts for LLM with full details as JSON # Step 4: Format facts for LLM with full details as JSON
import json import json
@ -99,6 +103,8 @@ class ThinkOperationsMixin:
world_facts_text = format_facts(world_results) world_facts_text = format_facts(world_results)
opinion_facts_text = format_facts(opinion_results) opinion_facts_text = format_facts(opinion_results)
logger.info(f"[THINK] Formatted facts - agent: {len(agent_facts_text)} chars, world: {len(world_facts_text)} chars, opinion: {len(opinion_facts_text)} chars")
# Step 5: Call Groq to formulate answer # Step 5: Call Groq to formulate answer
prompt = f"""You are an AI assistant answering a question based on retrieved facts provided in JSON format. prompt = f"""You are an AI assistant answering a question based on retrieved facts provided in JSON format.
@ -123,6 +129,9 @@ Provide a helpful, accurate answer based on the facts above. Be consistent with
If you form any new opinions while thinking about this question, state them clearly in your answer.""" If you form any new opinions while thinking about this question, state them clearly in your answer."""
logger.info(f"[THINK] Full prompt length: {len(prompt)} chars")
logger.debug(f"[THINK] Prompt preview (first 500 chars): {prompt[:500]}")
answer_text = await self._llm_config.call( answer_text = await self._llm_config.call(
messages=[ messages=[
{"role": "system", "content": "You are a helpful AI assistant. Always respond in plain text without markdown formatting. You can form and express opinions based on facts."}, {"role": "system", "content": "You are a helpful AI assistant. Always respond in plain text without markdown formatting. You can form and express opinions based on facts."},

View file

@ -1281,6 +1281,7 @@ class TemporalSemanticMemory(
"text": data["text"], "text": data["text"],
"context": data.get("context", ""), "context": data.get("context", ""),
"event_date": data["event_date"], # Keep as datetime for now "event_date": data["event_date"], # Keep as datetime for now
"fact_type": data.get("fact_type"), # Include fact type for filtering
"access_count": data.get("access_count", 0), "access_count": data.get("access_count", 0),
"semantic_similarity": semantic_sim, "semantic_similarity": semantic_sim,
"bm25_score": bm25_score, "bm25_score": bm25_score,
@ -1447,12 +1448,12 @@ class TemporalSemanticMemory(
async with pool.acquire() as conn: async with pool.acquire() as conn:
doc = await conn.fetchrow( doc = await conn.fetchrow(
""" """
SELECT d.id, d.agent_id, d.original_text, d.content_hash, d.metadata, SELECT d.id, d.agent_id, d.original_text, d.content_hash,
d.created_at, d.updated_at, COUNT(mu.id) as unit_count d.created_at, d.updated_at, COUNT(mu.id) as unit_count
FROM documents d FROM documents d
LEFT JOIN memory_units mu ON mu.document_id = d.id LEFT JOIN memory_units mu ON mu.document_id = d.id
WHERE d.id = $1 AND d.agent_id = $2 WHERE d.id = $1 AND d.agent_id = $2
GROUP BY d.id, d.agent_id, d.original_text, d.content_hash, d.metadata, d.created_at, d.updated_at GROUP BY d.id, d.agent_id, d.original_text, d.content_hash, d.created_at, d.updated_at
""", """,
document_id, agent_id document_id, agent_id
) )
@ -1460,13 +1461,11 @@ class TemporalSemanticMemory(
if not doc: if not doc:
return None return None
import json
return { return {
"id": doc["id"], "id": doc["id"],
"agent_id": doc["agent_id"], "agent_id": doc["agent_id"],
"original_text": doc["original_text"], "original_text": doc["original_text"],
"content_hash": doc["content_hash"], "content_hash": doc["content_hash"],
"metadata": json.loads(doc["metadata"]) if doc["metadata"] else {},
"unit_count": doc["unit_count"], "unit_count": doc["unit_count"],
"created_at": doc["created_at"], "created_at": doc["created_at"],
"updated_at": doc["updated_at"] "updated_at": doc["updated_at"]
@ -1871,7 +1870,7 @@ class TemporalSemanticMemory(
async def list_documents( async def list_documents(
self, self,
agent_id: Optional[str] = None, agent_id: str,
search_query: Optional[str] = None, search_query: Optional[str] = None,
limit: int = 100, limit: int = 100,
offset: int = 0 offset: int = 0
@ -1880,8 +1879,8 @@ class TemporalSemanticMemory(
List documents with optional search and pagination. List documents with optional search and pagination.
Args: Args:
agent_id: Filter by agent ID agent_id: Agent ID (required)
search_query: Search in metadata (JSON text search) search_query: Search in document ID
limit: Maximum number of results limit: Maximum number of results
offset: Offset for pagination offset: Offset for pagination
@ -1895,15 +1894,14 @@ class TemporalSemanticMemory(
query_params = [] query_params = []
param_count = 0 param_count = 0
if agent_id: param_count += 1
param_count += 1 query_conditions.append(f"agent_id = ${param_count}")
query_conditions.append(f"agent_id = ${param_count}") query_params.append(agent_id)
query_params.append(agent_id)
if search_query: if search_query:
# Search in document ID and metadata (as text) # Search in document ID
param_count += 1 param_count += 1
query_conditions.append(f"(id ILIKE ${param_count} OR metadata::text ILIKE ${param_count})") query_conditions.append(f"id ILIKE ${param_count}")
query_params.append(f"%{search_query}%") query_params.append(f"%{search_query}%")
where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else ""
@ -1931,7 +1929,6 @@ class TemporalSemanticMemory(
id, id,
agent_id, agent_id,
content_hash, content_hash,
metadata,
created_at, created_at,
updated_at, updated_at,
LENGTH(original_text) as text_length LENGTH(original_text) as text_length
@ -1979,7 +1976,6 @@ class TemporalSemanticMemory(
"id": doc_id, "id": doc_id,
"agent_id": agent_id_val, "agent_id": agent_id_val,
"content_hash": row['content_hash'], "content_hash": row['content_hash'],
"metadata": row['metadata'] if row['metadata'] else {},
"created_at": row['created_at'].isoformat() if row['created_at'] else "", "created_at": row['created_at'].isoformat() if row['created_at'] else "",
"updated_at": row['updated_at'].isoformat() if row['updated_at'] else "", "updated_at": row['updated_at'].isoformat() if row['updated_at'] else "",
"text_length": row['text_length'] or 0, "text_length": row['text_length'] or 0,
@ -2016,7 +2012,6 @@ class TemporalSemanticMemory(
agent_id, agent_id,
original_text, original_text,
content_hash, content_hash,
metadata,
created_at, created_at,
updated_at updated_at
FROM documents FROM documents
@ -2038,7 +2033,6 @@ class TemporalSemanticMemory(
"agent_id": doc['agent_id'], "agent_id": doc['agent_id'],
"original_text": doc['original_text'], "original_text": doc['original_text'],
"content_hash": doc['content_hash'], "content_hash": doc['content_hash'],
"metadata": doc['metadata'] if doc['metadata'] else {},
"created_at": doc['created_at'].isoformat() if doc['created_at'] else "", "created_at": doc['created_at'].isoformat() if doc['created_at'] else "",
"updated_at": doc['updated_at'].isoformat() if doc['updated_at'] else "", "updated_at": doc['updated_at'].isoformat() if doc['updated_at'] else "",
"memory_unit_count": unit_count_row['unit_count'] if unit_count_row else 0 "memory_unit_count": unit_count_row['unit_count'] if unit_count_row else 0

View file

@ -1,114 +0,0 @@
"""
Performance tuning test using real LoComo conversation.
This test loads a long conversation (419 dialogues across 19 sessions),
ingests it into memory, and runs searches to measure performance.
"""
import logging
import json
import pytest
from datetime import datetime, timezone
from pathlib import Path
# Configure logging to show performance metrics
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s:%(name)s: %(message)s'
)
@pytest.mark.asyncio
@pytest.mark.timeout(300) # 5 minute timeout for performance test
async def test_batch_ingestion_single_call(memory):
"""
Test ingesting entire conversation in ONE batch call.
This is the most efficient way - all sessions in one put_batch_async.
"""
# Load conversation fixture
fixture_path = Path(__file__).parent / "fixtures" / "locomo_conversation_sample.json"
with open(fixture_path) as f:
conversation_data = json.load(f)
sample_id = conversation_data['sample_id']
logging.info(f"\n{'='*80}")
logging.info(f"BATCH INGESTION TEST: {sample_id}")
logging.info(f"{'='*80}")
agent_id = f"batch_test_{sample_id}_{datetime.now(timezone.utc).timestamp()}"
try:
# Parse all sessions into batch format
# LIMIT to first 5 sessions for faster iteration during perf tuning
MAX_SESSIONS = 5
logging.info(f"\nPreparing batch contents (limiting to {MAX_SESSIONS} sessions for perf tuning)...")
conversation = conversation_data['conversation']
batch_contents = []
for i in range(1, MAX_SESSIONS + 1):
session_key = f'session_{i}'
session_date_key = f'session_{i}_date_time'
if session_key not in conversation or not conversation[session_key]:
break
session_dialogues = conversation[session_key]
session_date = conversation.get(session_date_key, datetime.now(timezone.utc).isoformat())
# Combine dialogues
session_text = "\n".join([
f"{d['speaker']}: {d['text']}"
for d in session_dialogues
])
# Parse date
try:
from dateutil import parser as date_parser
event_date = date_parser.isoparse(session_date)
except:
event_date = datetime.now(timezone.utc)
batch_contents.append({
'content': session_text,
'context': f'session_{i}',
'event_date': event_date
})
logging.info(f"Prepared {len(batch_contents)} sessions for batch ingestion")
# Single batch call
logging.info(f"\nIngesting all {len(batch_contents)} sessions in ONE batch call...")
result_ids = await memory.put_batch_async(
agent_id=agent_id,
contents=batch_contents,
document_id=f"{agent_id}_full_conversation"
)
total_units = sum(len(ids) for ids in result_ids)
logging.info(f"\n{'='*80}")
logging.info(f"BATCH INGESTION COMPLETE: {total_units} memory units created")
logging.info(f"{'='*80}")
# Run one sample search
logging.info(f"\nRunning sample search...")
question = conversation_data['qa'][0]['question']
logging.info(f"Question: {question}")
results, _ = await memory.search_async(
agent_id=agent_id,
query=question,
fact_type=["world"],
thinking_budget=100,
top_k=5,
enable_trace=False
)
logging.info(f"Found {len(results)} results")
if results:
logging.info(f"Top result: {results[0]['text'][:100]}...")
finally:
# Cleanup
logging.info("\nCleaning up...")
await memory.delete_agent(agent_id)

View file

@ -1,5 +1,5 @@
[tool.uv.workspace] [tool.uv.workspace]
members = ["memora", "benchmarks", "memora-dev"] members = ["memora", "benchmarks", "memora-dev", "memora-cli"]
[tool.uv] [tool.uv]
dev-dependencies = [] dev-dependencies = []

56
scripts/start-control-plane.sh Executable file
View file

@ -0,0 +1,56 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/../control-plane"
# Parse arguments
PORT=3000
while [[ $# -gt 0 ]]; do
case $1 in
--port|-p)
PORT="$2"
shift 2
;;
--help|-h)
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " --port, -p PORT Port to run on (default: 3000)"
echo " --help, -h Show this help message"
echo ""
echo "Example:"
echo " $0 --port 3001"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Check if .env.local exists
if [ ! -f ".env.local" ]; then
echo "⚠️ Warning: .env.local not found"
echo "Creating from .env.local.example..."
if [ -f ".env.local.example" ]; then
cp .env.local.example .env.local
echo "✅ Created .env.local"
echo "📝 Please edit .env.local if you need to change the DATAPLANE_API_URL"
echo ""
else
echo "❌ Error: .env.local.example not found"
exit 1
fi
fi
echo "🚀 Starting Control Plane (Next.js dev server)..."
echo "📄 Loading environment from .env.local"
echo ""
echo "Control plane will be available at: http://localhost:${PORT}"
echo ""
# Set the port and run dev server
PORT=$PORT npm run dev

46
standalone/.dockerignore Normal file
View file

@ -0,0 +1,46 @@
# Build artifacts
**/*.pyc
**/__pycache__/
**/.pytest_cache/
**/.venv/
**/venv/
**/*.egg-info/
**/dist/
**/build/
# Node
**/node_modules/
**/.next/
**/npm-debug.log
**/.turbo/
# Environment files
.env
.env.*
!standalone/.env.standalone
# Git
.git/
.gitignore
.gitattributes
# IDE
.vscode/
.idea/
*.swp
*.swo
# Test and dev files
**/tests/
local-db/
logs/
# Documentation (except standalone README)
README.md
!standalone/README.md
# Standalone files
standalone/build-docker.sh
standalone/.dockerignore
standalone/.env.example
standalone/docker-compose.yml

9
standalone/.env.example Normal file
View file

@ -0,0 +1,9 @@
# Environment variables for docker-compose
# Copy this file to .env and customize as needed
# Optional: OpenAI API key
# OPENAI_API_KEY=your-api-key-here
# Optional: Custom embedding model
# EMBEDDING_MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
# EMBEDDING_DIM=384

View file

@ -0,0 +1,15 @@
# Standalone environment configuration
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/memora
DATAPLANE_API_URL=http://localhost:8080
# Embedding configuration
EMBEDDING_MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
EMBEDDING_DIM=384
# LLM Provider (set to "none" to disable LLM features)
LLM_PROVIDER=none
# Optional: LLM API Keys
# OPENAI_API_KEY=your-openai-key-here
# ANTHROPIC_API_KEY=your-anthropic-key-here
# GROQ_API_KEY=your-groq-key-here

5
standalone/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Environment files
.env
# Docker volumes
*.log

87
standalone/Dockerfile Normal file
View file

@ -0,0 +1,87 @@
FROM node:20-alpine AS control-plane-builder
# Build control plane
WORKDIR /app/control-plane
COPY control-plane/package*.json ./
RUN npm ci
COPY control-plane/ ./
# Set env to skip font optimization during build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build || (echo "Build failed, retrying..." && npm run build)
# Python source stage - just copy files, don't build venv yet
FROM python:3.12-slim AS dataplane-source
WORKDIR /build
COPY pyproject.toml uv.lock ./
COPY memora/ ./memora/
COPY benchmarks/ ./benchmarks/
COPY memora-dev/ ./memora-dev/
COPY memora-cli/ ./memora-cli/
# Final runtime image
FROM python:3.12-slim
# Install system dependencies and PostgreSQL
RUN apt-get update && apt-get install -y \
gnupg \
lsb-release \
wget \
curl \
ca-certificates \
&& mkdir -p /etc/apt/keyrings \
&& wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/keyrings/pgdg.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update && apt-get install -y \
postgresql-15 \
postgresql-15-pgvector \
postgresql-contrib-15 \
nodejs \
npm \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# Install uv
RUN pip install uv
# Create app directory
WORKDIR /app
# Copy dataplane source from builder
COPY --from=dataplane-source /build /app
# Build venv in the final stage to ensure compatibility
RUN cd /app && uv sync --frozen
# Copy control plane from builder
COPY --from=control-plane-builder /app/control-plane/.next/standalone /app/control-plane
COPY --from=control-plane-builder /app/control-plane/.next/static /app/control-plane/.next/static
# Copy standalone configuration
COPY standalone/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY standalone/init.sh /app/init.sh
COPY standalone/.env.standalone /app/.env
RUN chmod +x /app/init.sh
# PostgreSQL setup
RUN mkdir -p /var/lib/postgresql/data && \
chown -R postgres:postgres /var/lib/postgresql && \
mkdir -p /var/run/postgresql && \
chown -R postgres:postgres /var/run/postgresql
# Initialize PostgreSQL as postgres user
USER postgres
RUN /usr/lib/postgresql/15/bin/initdb -D /var/lib/postgresql/data
USER root
# Expose ports
# 5432: PostgreSQL
# 8080: Dataplane API
# 3000: Control Plane
EXPOSE 5432 8080 3000
# Start supervisor
CMD ["/app/init.sh"]

104
standalone/QUICKSTART.md Normal file
View file

@ -0,0 +1,104 @@
# Memora Standalone - Quick Start
## What is this?
A single Docker image containing everything you need to run Memora:
- ✅ PostgreSQL database
- ✅ Dataplane API (FastAPI backend)
- ✅ Control Plane (Next.js web UI)
## Fastest Start (Docker Compose)
```bash
cd standalone
docker-compose up -d
```
Access the UI at: **http://localhost:3000**
## Manual Docker Build & Run
### Build the image:
```bash
./standalone/build-docker.sh
```
### Run with the helper script:
```bash
./standalone/run-docker.sh --persist
```
### Or run directly:
```bash
docker run -d \
--name memora \
-p 3000:3000 \
-p 8080:8080 \
-p 5432:5432 \
-v memora-data:/var/lib/postgresql/data \
memora-standalone:latest
```
## Access Points
| Service | URL | Purpose |
|---------|-----|---------|
| **Control Plane** | http://localhost:3000 | Web UI |
| **Dataplane API** | http://localhost:8080 | REST API |
| **PostgreSQL** | localhost:5432 | Database |
## View Logs
```bash
docker logs -f memora-standalone
```
## Stop & Remove
```bash
# Stop
docker-compose down
# Stop and remove data
docker-compose down -v
```
## Environment Variables
Set in `docker-compose.yml` or pass with `-e`:
- `EMBEDDING_MODEL_NAME` - Sentence transformer model (default: sentence-transformers/all-MiniLM-L6-v2)
- `EMBEDDING_DIM` - Embedding dimension (default: 384)
- `OPENAI_API_KEY` - Optional OpenAI API key
## Troubleshooting
**Container won't start:**
```bash
docker logs memora-standalone
```
**Database issues:**
```bash
docker exec -it memora-standalone su - postgres -c "psql memora"
```
**Reset everything:**
```bash
docker-compose down -v
docker-compose up -d
```
## Production Notes
This standalone image is ideal for:
- ✅ Development
- ✅ Demos
- ✅ Testing
- ✅ Small deployments
For production, consider:
- Separate containers for each service
- External PostgreSQL database
- Kubernetes/Docker Swarm orchestration
- Environment-specific configurations

124
standalone/README.md Normal file
View file

@ -0,0 +1,124 @@
# Memora Standalone Docker Image
This directory contains the configuration to build a standalone Docker image that includes all Memora components in a single container:
- **PostgreSQL**: Database backend
- **Dataplane**: FastAPI backend service
- **Control Plane**: Next.js web interface
## Quick Start with Docker Compose
The easiest way to run the standalone image:
```bash
cd standalone
docker-compose up -d
```
This will build and start all services with persistent data storage.
To stop:
```bash
docker-compose down
```
To remove data and start fresh:
```bash
docker-compose down -v
```
## Building Manually
```bash
./standalone/build-docker.sh
```
With custom options:
```bash
./standalone/build-docker.sh --name my-memora --tag v1.0.0
./standalone/build-docker.sh --registry docker.io/myuser --tag latest
```
## Running Manually
Using the run script (recommended):
```bash
./standalone/run-docker.sh --persist
```
With custom ports:
```bash
./standalone/run-docker.sh --persist --port-control 3001 --port-api 8081
```
Direct docker run:
```bash
docker run -p 3000:3000 -p 8080:8080 memora-standalone:latest
```
With persistent data:
```bash
docker run -p 3000:3000 -p 8080:8080 \
-v memora-data:/var/lib/postgresql/data \
memora-standalone:latest
```
With custom environment variables:
```bash
docker run -p 3000:3000 -p 8080:8080 \
-e OPENAI_API_KEY=your-key \
-e EMBEDDING_MODEL_NAME=custom-model \
memora-standalone:latest
```
## Accessing Services
Once running, services are available at:
- **Control Plane**: http://localhost:3000
- **Dataplane API**: http://localhost:8080
- **PostgreSQL**: localhost:5432 (username: postgres, password: postgres, database: memora)
## Architecture
The container uses `supervisord` to manage three processes:
1. PostgreSQL (started first)
2. Dataplane API (started after PostgreSQL)
3. Control Plane (started after dataplane)
The `init.sh` script handles:
- PostgreSQL initialization
- Database creation
- Running migrations
- Starting all services via supervisord
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql://postgres:postgres@localhost:5432/memora` | PostgreSQL connection string |
| `DATAPLANE_API_URL` | `http://localhost:8080` | Dataplane API URL for control plane |
| `EMBEDDING_MODEL_NAME` | `sentence-transformers/all-MiniLM-L6-v2` | Sentence transformer model |
| `EMBEDDING_DIM` | `384` | Embedding dimension |
| `OPENAI_API_KEY` | - | Optional OpenAI API key |
## Logs
View logs from all services:
```bash
docker logs -f <container-id>
```
## Production Considerations
This standalone image is designed for:
- Development environments
- Demos and testing
- Small deployments
For production use, consider:
- Using separate containers for each service
- External PostgreSQL database
- Load balancing for the control plane
- Persistent volume for PostgreSQL data
- Environment-specific configurations

87
standalone/build-docker.sh Executable file
View file

@ -0,0 +1,87 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/.."
# Default values
IMAGE_NAME="memora-standalone"
IMAGE_TAG="latest"
REGISTRY=""
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--name)
IMAGE_NAME="$2"
shift 2
;;
--tag)
IMAGE_TAG="$2"
shift 2
;;
--registry)
REGISTRY="$2"
shift 2
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --name NAME Docker image name (default: memora-standalone)"
echo " --tag TAG Docker image tag (default: latest)"
echo " --registry REG Docker registry URL (optional)"
echo " --help Show this help message"
echo ""
echo "Example:"
echo " $0 --name myapp --tag v1.0.0"
echo " $0 --registry docker.io/myuser --name memora-standalone --tag v1.0.0"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Construct full image name
if [ -n "$REGISTRY" ]; then
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
else
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
fi
echo "Building Memora Standalone Docker image: ${FULL_IMAGE_NAME}"
echo "============================================================="
echo "This image includes:"
echo " - PostgreSQL database"
echo " - Dataplane API (FastAPI)"
echo " - Control Plane (Next.js)"
echo ""
# Build the Docker image
docker build -f standalone/Dockerfile -t "${FULL_IMAGE_NAME}" .
echo ""
echo "Build completed successfully!"
echo "Image: ${FULL_IMAGE_NAME}"
echo ""
echo "To run the container:"
echo " docker run -p 3000:3000 -p 8080:8080 ${FULL_IMAGE_NAME}"
echo ""
echo "Services will be available at:"
echo " - Control Plane: http://localhost:3000"
echo " - Dataplane API: http://localhost:8080"
echo " - PostgreSQL: localhost:5432"
echo ""
echo "For persistent data, mount a volume:"
echo " docker run -p 3000:3000 -p 8080:8080 \\"
echo " -v memora-data:/var/lib/postgresql/data \\"
echo " ${FULL_IMAGE_NAME}"
echo ""
if [ -n "$REGISTRY" ]; then
echo "To push to registry:"
echo " docker push ${FULL_IMAGE_NAME}"
echo ""
fi

View file

@ -0,0 +1,21 @@
version: '3.8'
services:
memora-standalone:
build:
context: ..
dockerfile: standalone/Dockerfile
ports:
- "3000:3000" # Control Plane
- "8080:8080" # Dataplane API
- "5432:5432" # PostgreSQL
environment:
- EMBEDDING_MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
- EMBEDDING_DIM=384
# - OPENAI_API_KEY=${OPENAI_API_KEY} # Uncomment if needed
volumes:
- memora-data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
memora-data:

55
standalone/init.sh Executable file
View file

@ -0,0 +1,55 @@
#!/bin/bash
set -e
echo "🚀 Starting Memora Standalone Container..."
echo "==========================================="
# Start PostgreSQL temporarily for initialization
echo "📦 Starting PostgreSQL for initialization..."
su - postgres -c "/usr/lib/postgresql/15/bin/pg_ctl -D /var/lib/postgresql/data -l /tmp/postgresql-init.log start"
# Wait for PostgreSQL to be ready
echo "⏳ Waiting for PostgreSQL to be ready..."
for i in {1..30}; do
if su - postgres -c "psql -lqt" &>/dev/null; then
echo "✅ PostgreSQL is ready"
break
fi
if [ $i -eq 30 ]; then
echo "❌ PostgreSQL failed to start"
cat /tmp/postgresql-init.log
exit 1
fi
sleep 1
done
# Create database if it doesn't exist
echo "📊 Setting up database..."
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname = 'memora'\" | grep -q 1 || psql -c 'CREATE DATABASE memora;'"
# Run migrations
echo "🔄 Running database migrations..."
cd /app/memora
# Export environment variables
set -a
source /app/.env
set +a
/app/.venv/bin/python -m alembic upgrade head
# Stop PostgreSQL so supervisord can start it cleanly
echo "🔄 Stopping PostgreSQL to hand off to supervisord..."
su - postgres -c "/usr/lib/postgresql/15/bin/pg_ctl -D /var/lib/postgresql/data stop -m fast"
sleep 2
echo "✅ Initialization complete"
echo ""
echo "Starting services via supervisord..."
echo " - PostgreSQL: localhost:5432"
echo " - Dataplane API: http://localhost:8080"
echo " - Control Plane: http://localhost:3000"
echo ""
# Start supervisor to manage all services
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

122
standalone/run-docker.sh Executable file
View file

@ -0,0 +1,122 @@
#!/bin/bash
set -e
# Default values
IMAGE_NAME="memora-standalone:latest"
CONTAINER_NAME="memora-standalone"
PERSIST_DATA=false
PORT_CONTROL=3000
PORT_API=8080
PORT_DB=5432
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--image)
IMAGE_NAME="$2"
shift 2
;;
--name)
CONTAINER_NAME="$2"
shift 2
;;
--persist)
PERSIST_DATA=true
shift
;;
--port-control)
PORT_CONTROL="$2"
shift 2
;;
--port-api)
PORT_API="$2"
shift 2
;;
--port-db)
PORT_DB="$2"
shift 2
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --image NAME Docker image name (default: memora-standalone:latest)"
echo " --name NAME Container name (default: memora-standalone)"
echo " --persist Use persistent volume for data"
echo " --port-control PORT Control plane port (default: 3000)"
echo " --port-api PORT Dataplane API port (default: 8080)"
echo " --port-db PORT PostgreSQL port (default: 5432)"
echo " --help Show this help message"
echo ""
echo "Example:"
echo " $0 --persist --port-control 3001"
echo ""
echo "To stop the container:"
echo " docker stop ${CONTAINER_NAME}"
echo ""
echo "To remove the container:"
echo " docker rm ${CONTAINER_NAME}"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Check if container already exists
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "⚠️ Container '${CONTAINER_NAME}' already exists"
echo ""
read -p "Do you want to remove it and create a new one? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🗑️ Removing existing container..."
docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
else
echo "Exiting..."
exit 0
fi
fi
echo "🚀 Starting Memora Standalone Container"
echo "========================================"
echo "Image: ${IMAGE_NAME}"
echo "Container: ${CONTAINER_NAME}"
echo ""
# Build docker run command
DOCKER_CMD="docker run -d --name ${CONTAINER_NAME}"
DOCKER_CMD="${DOCKER_CMD} -p ${PORT_CONTROL}:3000"
DOCKER_CMD="${DOCKER_CMD} -p ${PORT_API}:8080"
DOCKER_CMD="${DOCKER_CMD} -p ${PORT_DB}:5432"
if [ "$PERSIST_DATA" = true ]; then
DOCKER_CMD="${DOCKER_CMD} -v memora-data:/var/lib/postgresql/data"
echo "📦 Using persistent volume: memora-data"
fi
DOCKER_CMD="${DOCKER_CMD} ${IMAGE_NAME}"
# Run the container
eval $DOCKER_CMD
echo ""
echo "✅ Container started successfully!"
echo ""
echo "Services are available at:"
echo " - Control Plane: http://localhost:${PORT_CONTROL}"
echo " - Dataplane API: http://localhost:${PORT_API}"
echo " - PostgreSQL: localhost:${PORT_DB}"
echo ""
echo "View logs:"
echo " docker logs -f ${CONTAINER_NAME}"
echo ""
echo "Stop container:"
echo " docker stop ${CONTAINER_NAME}"
echo ""
echo "Remove container:"
echo " docker rm -f ${CONTAINER_NAME}"
echo ""

View file

@ -0,0 +1,42 @@
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:postgresql]
command=/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/data
user=postgres
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=1
[program:dataplane]
command=/app/.venv/bin/python -m memora.web.server --host 0.0.0.0 --port 8080
directory=/app/memora
environment=PATH="/app/.venv/bin:%(ENV_PATH)s",DATABASE_URL="postgresql://postgres:postgres@localhost:5432/memora",EMBEDDING_MODEL_NAME="sentence-transformers/all-MiniLM-L6-v2",EMBEDDING_DIM="384"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
startsecs=10
priority=10
[program:control-plane]
command=/usr/bin/node /app/control-plane/server.js
directory=/app/control-plane
environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0",DATAPLANE_API_URL="http://localhost:8080"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
startsecs=5
priority=20

18
uv.lock
View file

@ -10,6 +10,7 @@ resolution-markers = [
members = [ members = [
"benchmarks", "benchmarks",
"memora", "memora",
"memora-cli",
"memora-dev", "memora-dev",
] ]
@ -1093,6 +1094,23 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.38.0" }, { name = "uvicorn", specifier = ">=0.38.0" },
] ]
[[package]]
name = "memora-cli"
version = "0.1.0"
source = { editable = "memora-cli" }
dependencies = [
{ name = "httpx" },
{ name = "rich" },
{ name = "typer" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "rich", specifier = ">=13.0.0" },
{ name = "typer", specifier = ">=0.20.0" },
]
[[package]] [[package]]
name = "memora-dev" name = "memora-dev"
version = "0.1.0" version = "0.1.0"