polish + cli + helm + standalone

This commit is contained in:
Nicolò Boschi 2025-11-11 12:35:20 +01:00
parent a1c5f9847a
commit 588065182a
138 changed files with 4661 additions and 1658 deletions

View file

@ -1,21 +0,0 @@
# Development/Production environment
# Database
DATABASE_URL=postgresql://postgres.goflmrvzwagridonyxxn:OjUtCVVtoV0nGPPP@aws-1-us-east-1.pooler.supabase.com:6543/postgres
# Disable tokenizers parallelism warning (happens with forked processes)
TOKENIZERS_PARALLELISM=false
# Main LLM Configuration (for memory operations: put/think/opinions)
# Choose one: "openai", "groq", or "ollama"
MEMORY_LLM_PROVIDER=groq
MEMORY_LLM_API_KEY=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
MEMORY_LLM_MODEL=openai/gpt-oss-120b
# MEMORY_LLM_BASE_URL=http://localhost:11434/v1 # For ollama or custom endpoints
# Judge LLM Configuration (for benchmark evaluation)
# If not set, falls back to main LLM configuration
JUDGE_LLM_PROVIDER=groq
JUDGE_LLM_API_KEY=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
JUDGE_LLM_MODEL=openai/gpt-oss-120b
# JUDGE_LLM_BASE_URL=https://api.custom.com/v1 # Optional custom endpoint

39
.env.example Normal file
View file

@ -0,0 +1,39 @@
# =============================================================================
# MEMORA ENVIRONMENT CONFIGURATION
# =============================================================================
# Copy this file to .env and update with your values
# Both services (API and Control Plane) read from this single file
# =============================================================================
# API SERVICE (MEMORA_API_*)
# =============================================================================
# Database
MEMORA_API_DATABASE_URL=postgresql://memora:memora_dev@localhost:5432/memora
# LLM Provider: "openai", "groq", or "ollama"
MEMORA_API_LLM_PROVIDER=groq
# LLM Model (provider-specific)
MEMORA_API_LLM_MODEL=openai/gpt-oss-20b
# API Key (not needed for ollama)
MEMORA_API_LLM_API_KEY=your_api_key_here
# Optional: Custom base URL (for ollama or custom endpoints)
# MEMORA_API_LLM_BASE_URL=http://localhost:11434/v1
# API Server Configuration (optional)
# MEMORA_API_HOST=0.0.0.0
# MEMORA_API_PORT=8080
# =============================================================================
# CONTROL PLANE SERVICE (MEMORA_CP_*)
# =============================================================================
# Dataplane API URL (where the control plane connects to)
MEMORA_CP_DATAPLANE_API_URL=http://localhost:8080
# Control Plane Server Configuration (optional)
# MEMORA_CP_PORT=3000
# MEMORA_CP_HOSTNAME=0.0.0.0

225
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,225 @@
name: Build Release Artifacts
on:
push:
tags:
- 'v*'
jobs:
build-python-packages:
runs-on: ubuntu-latest
strategy:
matrix:
package: [memora, benchmarks, memora-dev]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build package
run: |
cd ${{ matrix.package }}
uv build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: python-${{ matrix.package }}-dist
path: ${{ matrix.package }}/dist/*
retention-days: 30
build-rust-cli:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: memora
asset_name: memora-linux-amd64
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: memora
asset_name: memora-darwin-amd64
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: memora
asset_name: memora-darwin-arm64
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v4
with:
path: memora-cli/target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build
working-directory: memora-cli
run: cargo build --release --target ${{ matrix.target }}
- name: Prepare artifact
run: |
mkdir -p artifacts
cp memora-cli/target/${{ matrix.target }}/release/${{ matrix.artifact_name }} artifacts/${{ matrix.asset_name }}
chmod +x artifacts/${{ matrix.asset_name }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: rust-cli-${{ matrix.asset_name }}
path: artifacts/${{ matrix.asset_name }}
retention-days: 30
build-control-plane:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: memora-control-plane/package-lock.json
- name: Install dependencies
working-directory: ./memora-control-plane
run: npm ci
- name: Build Next.js app
working-directory: ./memora-control-plane
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: control-plane-build
path: |
memora-control-plane/.next/standalone
memora-control-plane/.next/static
retention-days: 30
build-docker-images:
runs-on: ubuntu-latest
strategy:
matrix:
component: [standalone, control-plane]
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build Docker image (standalone)
if: matrix.component == 'standalone'
uses: docker/build-push-action@v6
with:
context: .
file: standalone/Dockerfile
push: false
tags: memora-standalone:${{ steps.get_version.outputs.VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/memora-standalone.tar
- name: Build Docker image (control-plane)
if: matrix.component == 'control-plane'
uses: docker/build-push-action@v6
with:
context: ./memora-control-plane
file: memora-control-plane/Dockerfile
push: false
tags: memora-control-plane:${{ steps.get_version.outputs.VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/memora-control-plane.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image-${{ matrix.component }}
path: /tmp/memora-${{ matrix.component }}.tar
retention-days: 30
package-helm-chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: 'latest'
- name: Lint Helm chart
run: |
helm lint helm/memora
- name: Package Helm chart
run: |
helm package helm/memora --destination ./helm-packages
- name: Upload Helm chart artifact
uses: actions/upload-artifact@v4
with:
name: helm-chart
path: helm-packages/*.tgz
retention-days: 30
create-release-summary:
runs-on: ubuntu-latest
needs: [build-python-packages, build-rust-cli, build-control-plane, build-docker-images, package-helm-chart]
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
- name: Create release summary
run: |
echo "# Release Artifacts Built Successfully" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Components" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Python packages (memora, benchmarks, memora-dev)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Rust CLI (Linux amd64, macOS amd64, macOS arm64)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Control Plane Next.js application" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Docker images (standalone, control-plane)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Helm chart" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All artifacts are available for download in the workflow artifacts." >> $GITHUB_STEP_SUMMARY

56
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: Run Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: memora_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
MEMORA_API_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/memora_test
MEMORA_API_LLM_PROVIDER: groq
MEMORA_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
MEMORA_API_LLM_MODEL: openai/gpt-oss-120b
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Run migrations
working-directory: ./memora
run: |
uv run alembic upgrade head
- name: Run tests
run: uv run pytest memora/tests -v

1
.gitignore vendored
View file

@ -11,7 +11,6 @@ wheels/
# Environment variables
.env
.env.local
# IDE
.idea/

View file

@ -201,39 +201,28 @@ Raw content is processed through an LLM (Groq by default) to extract meaningful
uv sync
```
2. Configure environment files:
2. Configure environment file:
Create `.env.local` for local development:
Create `.env` file:
```bash
cat > .env.local << 'EOF'
# Database
DATABASE_URL=postgresql://memora:memora_dev@localhost:5432/memora
cat > .env << 'EOF'
# API Service Configuration
MEMORA_API_DATABASE_URL=postgresql://memora:memora_dev@localhost:5432/memora
# LLM Provider: "openai", "groq", or "ollama"
LLM_PROVIDER=groq
MEMORA_API_LLM_PROVIDER=groq
# API Key (not needed for ollama)
LLM_API_KEY=your_api_key_here
MEMORA_API_LLM_API_KEY=your_api_key_here
# LLM Model
MEMORA_API_LLM_MODEL=openai/gpt-oss-120b
# Optional: Custom base URL (for ollama or custom endpoints)
# LLM_BASE_URL=http://localhost:11434/v1
EOF
```
# MEMORA_API_LLM_BASE_URL=http://localhost:11434/v1
Create `.env.dev` for dev/production environment:
```bash
cat > .env.dev << 'EOF'
# Database
DATABASE_URL=postgresql://user:password@host:5432/memora
# LLM Provider: "openai", "groq", or "ollama"
LLM_PROVIDER=groq
# API Key (not needed for ollama)
LLM_API_KEY=your_api_key_here
# Optional: Custom base URL
# LLM_BASE_URL=https://api.custom-provider.com/v1
# Control Plane Configuration
MEMORA_CP_DATAPLANE_API_URL=http://localhost:8080
EOF
```
@ -286,17 +275,23 @@ JUDGE_LLM_API_KEY=your_openai_key
### Local Development
```bash
# Start local PostgreSQL (with initialization)
./scripts/start-local-db.sh
# Start all services with Docker (PostgreSQL, API, Control Plane)
cd ../docker
./start.sh
# Start the server with local environment (default)
# Or start services individually:
# 1. Start PostgreSQL only
# (then migrations run automatically when API starts)
# 2. Start the server with local environment
./scripts/start-server.sh --env local
# Start the server with dev environment
./scripts/start-server.sh --env dev
# Stop all Docker services
cd ../docker
./stop.sh
# Erase local database (stop + cleanup)
./scripts/erase-local-db.sh
# Erase all data and containers
cd ../docker
./clean.sh
```
The server will start at http://localhost:8080

View file

@ -1,3 +0,0 @@
# Dataplane API Configuration
# URL of the Python FastAPI dataplane server (server-side only, not exposed to browser)
DATAPLANE_API_URL=http://localhost:8080

175
docker/README.md Normal file
View file

@ -0,0 +1,175 @@
# Memora Docker Setup
Complete Docker Compose setup for running all Memora services locally.
## Services
This setup includes:
- **PostgreSQL** with pgvector extension (port 5432)
- **API Service** - FastAPI backend (port 8080)
- **Control Plane** - Next.js web UI (port 3000)
## Quick Start
1. **Configure environment variables:**
```bash
cp .env.example .env
# Edit .env and set your API keys
```
2. **Start all services:**
```bash
./start.sh
```
3. **Access the services:**
- Control Plane: http://localhost:3000
- API: http://localhost:8080
- PostgreSQL: localhost:5432
## Scripts
### `./start.sh`
Build and start all services. Waits for all services to be healthy.
### `./stop.sh`
Stop all services (keeps data).
### `./clean.sh`
Stop all services and remove all data (destructive).
### `./logs.sh [service]`
View logs for all services or a specific service:
```bash
./logs.sh # All services
./logs.sh api # API only
./logs.sh postgres # PostgreSQL only
./logs.sh control-plane # Control plane only
```
## Manual Docker Compose Commands
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# Rebuild and start
docker-compose up --build -d
# View logs
docker-compose logs -f
# Remove everything including data
docker-compose down -v
```
## Database
### Connection Info
- **Host:** localhost
- **Port:** 5432
- **Database:** memora
- **User:** memora
- **Password:** memora_dev
### Migrations
Database migrations run automatically when the API service starts. The API uses Alembic to:
1. Check the current schema version
2. Run any pending migrations
3. Initialize the database if it's empty
Extensions (pgvector, uuid-ossp) are created automatically by the first migration.
## Environment Variables
Required in `.env` file:
```bash
# API Service Configuration
MEMORA_API_DATABASE_URL=postgresql://memora:memora_dev@localhost:5432/memora
MEMORA_API_LLM_PROVIDER=groq
MEMORA_API_LLM_API_KEY=your-api-key-here
MEMORA_API_LLM_MODEL=openai/gpt-oss-120b
# Optional: Custom LLM endpoint
# MEMORA_API_LLM_BASE_URL=http://localhost:11434/v1
# Control Plane Configuration
MEMORA_CP_DATAPLANE_API_URL=http://localhost:8080
```
## Troubleshooting
### Services won't start
Check logs for errors:
```bash
./logs.sh
```
### Database connection issues
Ensure PostgreSQL is healthy:
```bash
docker exec memora-postgres pg_isready -U memora
```
### API won't connect to database
Check if migrations ran successfully:
```bash
./logs.sh api
```
### Control plane can't reach API
Verify the API is running:
```bash
curl http://localhost:8080/
```
### Reset everything
```bash
./clean.sh
./start.sh
```
## Development
### Rebuilding after code changes
**API changes:**
```bash
docker-compose up --build -d api
```
**Control Plane changes:**
```bash
docker-compose up --build -d control-plane
```
### Accessing the database
```bash
docker exec -it memora-postgres psql -U memora -d memora
```
### Inspecting containers
```bash
docker-compose ps
docker-compose exec api bash
docker-compose exec control-plane sh
```
## Data Persistence
PostgreSQL data is persisted in a Docker volume named `postgres_data`. This data survives container restarts but not `docker-compose down -v`.
To backup data:
```bash
docker exec memora-postgres pg_dump -U memora memora > backup.sql
```
To restore data:
```bash
docker exec -i memora-postgres psql -U memora memora < backup.sql
```

27
docker/api.Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy project files
COPY memora /app/memora
# Install Python dependencies
WORKDIR /app/memora
RUN pip install --no-cache-dir -e .
# Expose API port
EXPOSE 8080
# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV DATABASE_URL=postgresql://memora:memora_dev@postgres:5432/memora
# Run the API server
CMD ["python", "-m", "memora.web.server", "--host", "0.0.0.0", "--port", "8080"]

27
docker/clean.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "🧹 Cleaning Memora Services"
echo "============================"
echo ""
echo "This will:"
echo " - Stop all services"
echo " - Remove containers"
echo " - Remove volumes (ALL DATA WILL BE LOST)"
echo ""
read -p "Are you sure? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "Cancelled."
exit 0
fi
echo ""
echo "🗑️ Removing services and data..."
docker-compose down -v
echo ""
echo "✅ All services and data removed"
echo ""

79
docker/docker-compose.yml Normal file
View file

@ -0,0 +1,79 @@
services:
postgres:
image: pgvector/pgvector:pg16
container_name: memora-postgres
environment:
POSTGRES_USER: memora
POSTGRES_PASSWORD: memora_dev
POSTGRES_DB: memora
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U memora"]
interval: 5s
timeout: 5s
retries: 5
networks:
- memora-network
api:
build:
context: ..
dockerfile: docker/api.Dockerfile
container_name: memora-api
environment:
MEMORA_API_DATABASE_URL: postgresql://memora:memora_dev@postgres:5432/memora
MEMORA_API_LLM_PROVIDER: ${MEMORA_API_LLM_PROVIDER:-groq}
MEMORA_API_LLM_API_KEY: ${MEMORA_API_LLM_API_KEY}
MEMORA_API_LLM_MODEL: ${MEMORA_API_LLM_MODEL:-openai/gpt-oss-120b}
MEMORA_API_LLM_BASE_URL: ${MEMORA_API_LLM_BASE_URL}
MEMORA_API_HOST: ${MEMORA_API_HOST:-0.0.0.0}
MEMORA_API_PORT: ${MEMORA_API_PORT:-8080}
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- memora-network
restart: unless-stopped
control-plane:
build:
context: ../memora-control-plane
dockerfile: ../docker/control-plane.Dockerfile
container_name: memora-control-plane
environment:
NODE_ENV: production
MEMORA_CP_HOSTNAME: ${MEMORA_CP_HOSTNAME:-0.0.0.0}
MEMORA_CP_PORT: ${MEMORA_CP_PORT:-3000}
MEMORA_CP_DATAPLANE_API_URL: ${MEMORA_CP_DATAPLANE_API_URL:-http://api:8080}
ports:
- "3000:3000"
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- memora-network
restart: unless-stopped
networks:
memora-network:
driver: bridge
volumes:
postgres_data:

16
docker/logs.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
SERVICE=$1
if [ -z "$SERVICE" ]; then
echo "📋 Showing logs for all services..."
echo ""
docker-compose logs -f
else
echo "📋 Showing logs for $SERVICE..."
echo ""
docker-compose logs -f "$SERVICE"
fi

65
docker/start.sh Executable file
View file

@ -0,0 +1,65 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "🚀 Starting Memora Services"
echo "============================"
echo ""
# Check if .env file exists in root
if [ ! -f ../.env ]; then
echo "⚠️ No .env file found in project root!"
echo ""
echo "Creating .env from .env.example..."
cp ../.env.example ../.env
echo ""
echo "⚠️ Please edit .env and set your API keys:"
echo " - MEMORY_LLM_API_KEY"
echo ""
echo "Then run this script again."
exit 1
fi
echo "📦 Building and starting services..."
docker-compose --env-file ../.env up --build -d
echo ""
echo "⏳ Waiting for services to be healthy..."
echo ""
# Wait for PostgreSQL
echo " Waiting for PostgreSQL..."
until docker exec memora-postgres pg_isready -U memora > /dev/null 2>&1; do
sleep 1
done
echo " ✅ PostgreSQL is ready"
# Wait for API
echo " Waiting for API..."
until curl -f http://localhost:8080/ > /dev/null 2>&1; do
sleep 2
done
echo " ✅ API is ready"
# Wait for Control Plane
echo " Waiting for Control Plane..."
until curl -f http://localhost:3000/ > /dev/null 2>&1; do
sleep 2
done
echo " ✅ Control Plane is ready"
echo ""
echo "✅ All services are running!"
echo ""
echo "📊 Service URLs:"
echo " Control Plane: http://localhost:3000"
echo " API: http://localhost:8080"
echo " PostgreSQL: localhost:5432"
echo ""
echo "🔍 View logs:"
echo " docker-compose logs -f"
echo ""
echo "🛑 Stop services:"
echo " ./stop.sh"
echo ""

17
docker/stop.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "🛑 Stopping Memora Services"
echo "============================"
echo ""
docker-compose down
echo ""
echo "✅ All services stopped"
echo ""
echo "💡 To remove data volumes as well, run:"
echo " docker-compose down -v"
echo ""

135
helm/INSTALL.txt Normal file
View file

@ -0,0 +1,135 @@
MEMORA HELM CHART INSTALLATION GUIDE
=====================================
PREREQUISITES
-------------
- Kubernetes cluster (1.19+)
- kubectl configured
- Helm 3.x installed
- PostgreSQL database with pgvector extension (if not using bundled PostgreSQL)
BASIC INSTALLATION
------------------
1. Install with default values (requires external PostgreSQL):
helm install memora ./memora \
--set postgresql.external.host=your-postgres-host \
--set postgresql.external.password=your-password \
--set api.secrets.MEMORY_LLM_API_KEY=your-api-key
2. Install with custom values file:
helm install memora ./memora -f memora/values-production.yaml
3. Install in a specific namespace:
kubectl create namespace memora
helm install memora ./memora -n memora
CONFIGURATION OPTIONS
---------------------
Development setup (using values-development.yaml):
helm install memora ./memora -f memora/values-development.yaml
Production setup (using values-production.yaml):
helm install memora ./memora -f memora/values-production.yaml
Custom LLM provider:
helm install memora ./memora \
--set api.env.MEMORY_LLM_PROVIDER=openai \
--set api.env.MEMORY_LLM_MODEL=gpt-4 \
--set api.secrets.MEMORY_LLM_API_KEY=sk-your-key
Enable ingress:
helm install memora ./memora \
--set ingress.enabled=true \
--set ingress.hosts[0].host=memora.example.com
Enable autoscaling:
helm install memora ./memora \
--set autoscaling.enabled=true \
--set autoscaling.minReplicas=2 \
--set autoscaling.maxReplicas=10
UPGRADE
-------
Upgrade existing installation:
helm upgrade memora ./memora
Upgrade with new values:
helm upgrade memora ./memora -f memora/values-production.yaml
UNINSTALL
---------
Remove the Helm release:
helm uninstall memora
Remove with namespace:
helm uninstall memora -n memora
TESTING
-------
Test the installation with dry-run:
helm install memora ./memora --dry-run --debug
Validate templates:
helm template memora ./memora
Lint the chart:
helm lint ./memora
ACCESSING THE SERVICES
----------------------
Port-forward control plane:
kubectl port-forward svc/memora-control-plane 3000:3000
Port-forward API:
kubectl port-forward svc/memora-api 8080:8080
Get service URLs:
helm status memora
DATABASE INITIALIZATION
-----------------------
NOTE: Database migrations now run automatically when the API service starts.
You typically don't need to run migrations manually.
If you want to pre-initialize the database before deploying (optional):
kubectl run memora-init --rm -it --restart=Never \
--image=memora/api:latest \
--env="DATABASE_URL=postgresql://user:pass@host:5432/memora" \
-- python -c "from memora.migrations import run_migrations; run_migrations()"
TROUBLESHOOTING
---------------
Check pod status:
kubectl get pods -l app.kubernetes.io/name=memora
View logs for API:
kubectl logs -l app.kubernetes.io/component=api
View logs for control plane:
kubectl logs -l app.kubernetes.io/component=control-plane
Describe a pod:
kubectl describe pod <pod-name>
Check configuration:
kubectl get configmap memora-config -o yaml
kubectl get secret memora-secret -o yaml
NOTES
-----
- Make sure PostgreSQL has pgvector extension enabled
- Run database migrations before first use
- Configure proper resource limits for production
- Use external secrets management for production
- Enable TLS/SSL for production deployments

23
helm/memora/.helmignore Normal file
View file

@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

13
helm/memora/Chart.yaml Normal file
View file

@ -0,0 +1,13 @@
apiVersion: v2
name: memora
description: A Helm chart for Memora - temporal-semantic-entity memory system for AI agents
type: application
version: 0.1.0
appVersion: "1.0.0"
keywords:
- ai
- memory
- llm
- agents
maintainers:
- name: Memora Team

View file

@ -0,0 +1,71 @@
Thank you for installing {{ .Chart.Name }}!
Your release is named {{ .Release.Name }}.
To learn more about the release, try:
$ helm status {{ .Release.Name }}
$ helm get all {{ .Release.Name }}
{{- if .Values.ingress.enabled }}
The application is accessible via the following URL(s):
{{- range .Values.ingress.hosts }}
- http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else }}
1. Get the Control Plane URL by running these commands:
{{- if contains "NodePort" .Values.controlPlane.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "memora.fullname" . }}-control-plane)
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo "Control Plane URL: http://$NODE_IP:$NODE_PORT"
{{- else if contains "LoadBalancer" .Values.controlPlane.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "memora.fullname" . }}-control-plane'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "memora.fullname" . }}-control-plane --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo "Control Plane URL: http://$SERVICE_IP:{{ .Values.controlPlane.service.port }}"
{{- else if contains "ClusterIP" .Values.controlPlane.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/component=control-plane,app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Control Plane URL: http://127.0.0.1:3000"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 3000:$CONTAINER_PORT
{{- end }}
2. Get the API URL by running these commands:
{{- if contains "NodePort" .Values.api.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "memora.fullname" . }}-api)
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo "API URL: http://$NODE_IP:$NODE_PORT"
{{- else if contains "LoadBalancer" .Values.api.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "memora.fullname" . }}-api'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "memora.fullname" . }}-api --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo "API URL: http://$SERVICE_IP:{{ .Values.api.service.port }}"
{{- else if contains "ClusterIP" .Values.api.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/component=api,app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "API URL: http://127.0.0.1:8080"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
{{- end }}
{{- if not .Values.postgresql.enabled }}
NOTE: You are using an external PostgreSQL database.
Please ensure that:
1. The database is accessible from the cluster
2. The pgvector extension is enabled
Database migrations run automatically when the API service starts.
If you want to pre-initialize the database before deploying (optional):
kubectl run --namespace {{ .Release.Namespace }} memora-init --rm -it --restart=Never \
--image={{ .Values.api.image.repository }}:{{ .Values.api.image.tag }} \
--env="DATABASE_URL={{ include "memora.databaseUrl" . }}" \
-- python -c "from memora.migrations import run_migrations; run_migrations()"
{{- end }}
For more information, visit: https://github.com/yourusername/memora

View file

@ -0,0 +1,112 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "memora.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "memora.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "memora.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "memora.labels" -}}
helm.sh/chart: {{ include "memora.chart" . }}
{{ include "memora.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "memora.selectorLabels" -}}
app.kubernetes.io/name: {{ include "memora.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
API labels
*/}}
{{- define "memora.api.labels" -}}
{{ include "memora.labels" . }}
app.kubernetes.io/component: api
{{- end }}
{{/*
API selector labels
*/}}
{{- define "memora.api.selectorLabels" -}}
{{ include "memora.selectorLabels" . }}
app.kubernetes.io/component: api
{{- end }}
{{/*
Control plane labels
*/}}
{{- define "memora.controlPlane.labels" -}}
{{ include "memora.labels" . }}
app.kubernetes.io/component: control-plane
{{- end }}
{{/*
Control plane selector labels
*/}}
{{- define "memora.controlPlane.selectorLabels" -}}
{{ include "memora.selectorLabels" . }}
app.kubernetes.io/component: control-plane
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "memora.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "memora.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Generate database URL
*/}}
{{- define "memora.databaseUrl" -}}
{{- if .Values.databaseUrl }}
{{- .Values.databaseUrl }}
{{- else if .Values.postgresql.enabled }}
{{- printf "postgresql://%s:%s@%s-postgresql:%d/%s" .Values.postgresql.auth.username .Values.postgresql.auth.password (include "memora.fullname" .) (.Values.postgresql.primary.service.port | int) .Values.postgresql.auth.database }}
{{- else }}
{{- printf "postgresql://%s:$(POSTGRES_PASSWORD)@%s:%d/%s" .Values.postgresql.external.username .Values.postgresql.external.host (.Values.postgresql.external.port | int) .Values.postgresql.external.database }}
{{- end }}
{{- end }}
{{/*
API URL for control plane
*/}}
{{- define "memora.apiUrl" -}}
{{- printf "http://%s-api:%d" (include "memora.fullname" .) (.Values.api.service.port | int) }}
{{- end }}

View file

@ -0,0 +1,93 @@
{{- if .Values.api.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "memora.fullname" . }}-api
labels:
{{- include "memora.api.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.api.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "memora.api.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "memora.api.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "memora.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: api
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.api.service.targetPort }}
protocol: TCP
env:
- name: MEMORA_API_DATABASE_URL
value: {{ include "memora.databaseUrl" . | quote }}
{{- if not .Values.postgresql.enabled }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "memora.fullname" . }}-secret
key: postgres-password
{{- end }}
- name: MEMORA_API_LLM_PROVIDER
valueFrom:
configMapKeyRef:
name: {{ include "memora.fullname" . }}-config
key: llm-provider
- name: MEMORA_API_LLM_MODEL
valueFrom:
configMapKeyRef:
name: {{ include "memora.fullname" . }}-config
key: llm-model
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORA_API_LLM_API_KEY") }}
- name: MEMORA_API_LLM_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "memora.fullname" . }}-secret
key: llm-api-key
{{- end }}
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORA_API_LLM_BASE_URL") }}
- name: MEMORA_API_LLM_BASE_URL
valueFrom:
secretKeyRef:
name: {{ include "memora.fullname" . }}-secret
key: llm-base-url
{{- end }}
livenessProbe:
{{- toYaml .Values.api.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.api.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.api.resources | nindent 10 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,17 @@
{{- if .Values.api.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "memora.fullname" . }}-api
labels:
{{- include "memora.api.labels" . | nindent 4 }}
spec:
type: {{ .Values.api.service.type }}
ports:
- port: {{ .Values.api.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "memora.api.selectorLabels" . | nindent 4 }}
{{- end }}

View file

@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "memora.fullname" . }}-config
labels:
{{- include "memora.labels" . | nindent 4 }}
data:
# API configuration
llm-provider: {{ .Values.api.env.MEMORA_API_LLM_PROVIDER | quote }}
llm-model: {{ .Values.api.env.MEMORA_API_LLM_MODEL | quote }}
# Control plane configuration
node-env: {{ .Values.controlPlane.env.NODE_ENV | quote }}
hostname: {{ .Values.controlPlane.env.MEMORA_CP_HOSTNAME | quote }}
control-plane-port: {{ .Values.controlPlane.env.MEMORA_CP_PORT | quote }}

View file

@ -0,0 +1,76 @@
{{- if .Values.controlPlane.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "memora.fullname" . }}-control-plane
labels:
{{- include "memora.controlPlane.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.controlPlane.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "memora.controlPlane.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "memora.controlPlane.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "memora.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: control-plane
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag }}"
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.controlPlane.service.targetPort }}
protocol: TCP
env:
- name: NODE_ENV
valueFrom:
configMapKeyRef:
name: {{ include "memora.fullname" . }}-config
key: node-env
- name: MEMORA_CP_HOSTNAME
valueFrom:
configMapKeyRef:
name: {{ include "memora.fullname" . }}-config
key: hostname
- name: MEMORA_CP_PORT
valueFrom:
configMapKeyRef:
name: {{ include "memora.fullname" . }}-config
key: control-plane-port
- name: MEMORA_CP_DATAPLANE_API_URL
value: {{ include "memora.apiUrl" . | quote }}
livenessProbe:
{{- toYaml .Values.controlPlane.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.controlPlane.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.controlPlane.resources | nindent 10 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,17 @@
{{- if .Values.controlPlane.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "memora.fullname" . }}-control-plane
labels:
{{- include "memora.controlPlane.labels" . | nindent 4 }}
spec:
type: {{ .Values.controlPlane.service.type }}
ports:
- port: {{ .Values.controlPlane.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "memora.controlPlane.selectorLabels" . | nindent 4 }}
{{- end }}

View file

@ -0,0 +1,64 @@
{{- if .Values.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "memora.fullname" . }}-api
labels:
{{- include "memora.api.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "memora.fullname" . }}-api
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "memora.fullname" . }}-control-plane
labels:
{{- include "memora.controlPlane.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "memora.fullname" . }}-control-plane
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,47 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "memora.fullname" . }}
labels:
{{- include "memora.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
{{- if eq .service "api" }}
name: {{ include "memora.fullname" $ }}-api
port:
number: {{ $.Values.api.service.port }}
{{- else if eq .service "controlPlane" }}
name: {{ include "memora.fullname" $ }}-control-plane
port:
number: {{ $.Values.controlPlane.service.port }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "memora.fullname" . }}-secret
labels:
{{- include "memora.labels" . | nindent 4 }}
type: Opaque
data:
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORY_LLM_API_KEY") }}
llm-api-key: {{ .Values.api.secrets.MEMORY_LLM_API_KEY | b64enc | quote }}
{{- end }}
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORY_LLM_BASE_URL") }}
llm-base-url: {{ .Values.api.secrets.MEMORY_LLM_BASE_URL | b64enc | quote }}
{{- end }}
{{- if not .Values.postgresql.enabled }}
{{- if .Values.postgresql.external.password }}
postgres-password: {{ .Values.postgresql.external.password | b64enc | quote }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "memora.serviceAccountName" . }}
labels:
{{- include "memora.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

185
helm/memora/values.yaml Normal file
View file

@ -0,0 +1,185 @@
# Default values for memora
# Global settings
replicaCount: 1
# Image settings for api
api:
enabled: true
replicaCount: 1
image:
repository: memora/api
pullPolicy: IfNotPresent
tag: "latest"
service:
type: ClusterIP
port: 8080
targetPort: 8080
# Resource limits and requests
resources:
limits:
cpu: 2000m
memory: 4Gi
requests:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Environment variables
env:
MEMORA_API_LLM_PROVIDER: "groq"
MEMORA_API_LLM_MODEL: "openai/gpt-oss-120b"
# Secret environment variables
secrets:
# MEMORA_API_LLM_API_KEY: "your-api-key"
# MEMORA_API_LLM_BASE_URL: "https://api.groq.com/openai/v1"
# Image settings for control plane
controlPlane:
enabled: true
replicaCount: 1
image:
repository: memora/memora-control-plane
pullPolicy: IfNotPresent
tag: "latest"
service:
type: ClusterIP
port: 3000
targetPort: 3000
# Resource limits and requests
resources:
limits:
cpu: 1000m
memory: 2Gi
requests:
cpu: 250m
memory: 512Mi
# Liveness and readiness probes
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Environment variables
env:
NODE_ENV: "production"
MEMORA_CP_HOSTNAME: "0.0.0.0"
MEMORA_CP_PORT: "3000"
# PostgreSQL configuration
postgresql:
# Set to true to deploy PostgreSQL as part of this chart
enabled: false
# External PostgreSQL connection details
# If postgresql.enabled is false, provide external database details
external:
host: "postgresql"
port: 5432
database: "memora"
username: "memora"
# Password should be provided via secret
# password: ""
# Database URL (auto-generated from postgresql config if not provided)
# databaseUrl: "postgresql://user:pass@host:5432/database"
# Ingress configuration
ingress:
enabled: false
className: "nginx"
annotations: {}
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
# nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts:
- host: memora.example.com
paths:
- path: /
pathType: Prefix
service: controlPlane
- path: /api
pathType: Prefix
service: api
tls: []
# - secretName: memora-tls
# hosts:
# - memora.example.com
# Service Account
serviceAccount:
create: true
annotations: {}
name: ""
# Pod annotations
podAnnotations: {}
# Pod security context
podSecurityContext:
fsGroup: 1000
# Security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
# Node selector
nodeSelector: {}
# Tolerations
tolerations: []
# Affinity
affinity: {}
# Autoscaling
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80

6
local-db/.gitignore vendored
View file

@ -1,6 +0,0 @@
# Environment files
.env
.env.local
# Docker volumes (data persistence)
postgres_data/

View file

@ -1,23 +0,0 @@
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: memora-postgres
environment:
POSTGRES_USER: memora
POSTGRES_PASSWORD: memora_dev
POSTGRES_DB: memora
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-extensions.sql:/docker-entrypoint-initdb.d/01-init-extensions.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U memora"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:

View file

@ -1,2 +0,0 @@
-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;

102
memora-cli/.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,102 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: memora
release_name: memora-linux-x86_64
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact_name: memora
release_name: memora-linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: memora
release_name: memora-macos-x86_64
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: memora
release_name: memora-macos-arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Strip binary (Linux)
if: runner.os == 'Linux'
run: strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }}
- name: Strip binary (macOS)
if: runner.os == 'macOS'
run: strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.release_name }}
path: target/${{ matrix.target }}/release/${{ matrix.artifact_name }}
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create checksums
run: |
cd artifacts
for dir in */; do
cd "$dir"
sha256sum * > SHA256SUMS
cd ..
done
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/memora-linux-x86_64/memora
artifacts/memora-linux-arm64/memora
artifacts/memora-macos-x86_64/memora
artifacts/memora-macos-arm64/memora
artifacts/*/SHA256SUMS
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

20
memora-cli/.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Rust
/target/
Cargo.lock
# Distribution
/dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Backup files
*.bak

47
memora-cli/Cargo.toml Normal file
View file

@ -0,0 +1,47 @@
[package]
name = "memora-cli-rust"
version = "0.1.0"
edition = "2021"
authors = ["Memora Team"]
description = "A beautiful CLI for Memora - semantic memory system"
license = "MIT"
[[bin]]
name = "memora"
path = "src/main.rs"
[dependencies]
# CLI framework
clap = { version = "4.5", features = ["derive", "env"] }
# HTTP client
reqwest = { version = "0.12", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
# TUI libraries
ratatui = "0.29"
crossterm = "0.28"
# Colors and styling
colored = "2.1"
indicatif = "0.17"
# Error handling
anyhow = "1.0"
thiserror = "1.0"
# Utilities
chrono = "0.4"
walkdir = "2.5"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

View file

@ -1,79 +0,0 @@
# 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

100
memora-cli/build.sh Executable file
View file

@ -0,0 +1,100 @@
#!/bin/bash
set -e
# Build script for memora-cli-rust
# This script builds optimized binaries for multiple platforms
# Source cargo environment if it exists
if [ -f "$HOME/.cargo/env" ]; then
source "$HOME/.cargo/env"
fi
# Add cargo to PATH if not already there
export PATH="$HOME/.cargo/bin:$PATH"
# Check if cargo is available
if ! command -v cargo &> /dev/null; then
echo "Error: Cargo not found. Please install Rust first:"
echo " curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
exit 1
fi
echo "Building Memora CLI for multiple platforms..."
# Ensure we're in the right directory
cd "$(dirname "$0")"
# Create dist directory if it doesn't exist
mkdir -p dist
# Get version from Cargo.toml
VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)
echo "Version: $VERSION"
# Function to build for a target
build_target() {
local target=$1
local output_name=$2
echo ""
echo "Building for $target..."
# Check if target is installed, install if not
if ! rustup target list | grep -q "$target (installed)"; then
echo "Installing target $target..."
rustup target add "$target"
fi
# Build
cargo build --release --target "$target"
# Copy to dist
if [[ "$target" == *"windows"* ]]; then
cp "target/$target/release/memora.exe" "dist/$output_name.exe"
echo "Created: dist/$output_name.exe"
else
cp "target/$target/release/memora" "dist/$output_name"
chmod +x "dist/$output_name"
echo "Created: dist/$output_name"
fi
}
# Detect current platform
OS=$(uname -s)
ARCH=$(uname -m)
echo "Detected platform: $OS $ARCH"
# Build for current platform
case "$OS" in
Darwin)
if [[ "$ARCH" == "arm64" ]]; then
echo "Building for macOS ARM64 (Apple Silicon)..."
build_target "aarch64-apple-darwin" "memora-macos-arm64"
else
echo "Building for macOS x86_64 (Intel)..."
build_target "x86_64-apple-darwin" "memora-macos-x86_64"
fi
;;
Linux)
if [[ "$ARCH" == "x86_64" ]]; then
echo "Building for Linux x86_64..."
build_target "x86_64-unknown-linux-gnu" "memora-linux-x86_64"
elif [[ "$ARCH" == "aarch64" ]]; then
echo "Building for Linux ARM64..."
build_target "aarch64-unknown-linux-gnu" "memora-linux-arm64"
fi
;;
*)
echo "Unsupported OS: $OS"
exit 1
;;
esac
echo ""
echo "Build complete! Binaries are in the dist/ directory:"
ls -lh dist/
echo ""
echo "To build for other platforms, run:"
echo " ./build.sh --all"

158
memora-cli/install.sh Executable file
View file

@ -0,0 +1,158 @@
#!/bin/bash
set -e
# Memora CLI installer
# Usage: curl -sSf https://your-domain.com/install.sh | sh
REPO_URL="https://github.com/your-org/memora-cli"
INSTALL_DIR="${MEMORA_INSTALL_DIR:-$HOME/.local/bin}"
BINARY_NAME="memora"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_info() {
echo -e "${BLUE}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
print_warning() {
echo -e "${YELLOW}${NC} $1"
}
print_banner() {
echo ""
echo -e "${BLUE}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ MEMORA CLI INSTALLER ║${NC}"
echo -e "${BLUE}╚══════════════════════════════════════════════════╝${NC}"
echo ""
}
# Detect platform
detect_platform() {
local os=$(uname -s)
local arch=$(uname -m)
case "$os" in
Darwin)
if [[ "$arch" == "arm64" ]] || [[ "$arch" == "aarch64" ]]; then
echo "macos-arm64"
elif [[ "$arch" == "x86_64" ]]; then
echo "macos-x86_64"
else
print_error "Unsupported macOS architecture: $arch"
exit 1
fi
;;
Linux)
if [[ "$arch" == "x86_64" ]]; then
echo "linux-x86_64"
elif [[ "$arch" == "aarch64" ]] || [[ "$arch" == "arm64" ]]; then
echo "linux-arm64"
else
print_error "Unsupported Linux architecture: $arch"
exit 1
fi
;;
*)
print_error "Unsupported operating system: $os"
exit 1
;;
esac
}
# Download binary
download_binary() {
local platform=$1
local download_url="${REPO_URL}/releases/latest/download/memora-${platform}"
local tmp_file="/tmp/memora-$$"
print_info "Downloading Memora CLI for $platform..."
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$download_url" -o "$tmp_file"
elif command -v wget > /dev/null 2>&1; then
wget -q "$download_url" -O "$tmp_file"
else
print_error "Neither curl nor wget found. Please install one of them."
exit 1
fi
echo "$tmp_file"
}
# Install binary
install_binary() {
local tmp_file=$1
# Create install directory if it doesn't exist
mkdir -p "$INSTALL_DIR"
# Move binary to install directory
mv "$tmp_file" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
print_success "Installed to: $INSTALL_DIR/$BINARY_NAME"
}
# Check if directory is in PATH
check_path() {
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
print_warning "$INSTALL_DIR is not in your PATH"
echo ""
echo "Add it to your PATH by adding this line to your shell profile:"
echo ""
# Detect shell
if [[ -n "$BASH_VERSION" ]]; then
echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc"
echo " source ~/.bashrc"
elif [[ -n "$ZSH_VERSION" ]]; then
echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc"
echo " source ~/.zshrc"
else
echo " export PATH=\"$INSTALL_DIR:\$PATH\""
fi
echo ""
fi
}
# Main installation flow
main() {
print_banner
# Detect platform
platform=$(detect_platform)
print_info "Detected platform: $platform"
# Download binary
tmp_file=$(download_binary "$platform")
# Install binary
install_binary "$tmp_file"
# Check PATH
check_path
print_success "Installation complete!"
echo ""
print_info "Try it out: $BINARY_NAME --help"
echo ""
print_info "Configure the API URL:"
echo " export MEMORA_API_URL=http://localhost:8080"
echo ""
}
# Run installation
main

View file

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

View file

@ -1,565 +0,0 @@
"""
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()

View file

@ -1,21 +0,0 @@
[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"]

279
memora-cli/src/api.rs Normal file
View file

@ -0,0 +1,279 @@
use anyhow::{Context, Result};
use reqwest::blocking::{Client, Response};
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub struct ApiError {
pub url: String,
pub request_body: String,
pub response_status: Option<u16>,
pub response_body: Option<String>,
pub error: anyhow::Error,
}
#[derive(Debug, Serialize)]
pub struct SearchRequest {
pub query: String,
pub fact_type: Vec<String>,
pub agent_id: String,
pub thinking_budget: i32,
pub max_tokens: i32,
pub trace: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResponse {
pub results: Vec<Fact>,
pub trace: Option<TraceInfo>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Fact {
#[serde(default)]
pub id: Option<String>,
pub text: String,
#[serde(rename = "type", default)]
pub fact_type: Option<String>,
pub activation: Option<f64>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub event_date: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TraceInfo {
pub total_time: Option<f64>,
pub activation_count: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct ThinkRequest {
pub query: String,
pub agent_id: String,
pub thinking_budget: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ThinkResponse {
pub text: String,
pub based_on: Vec<Fact>,
pub new_opinions: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct MemoryItem {
pub content: String,
pub context: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct BatchMemoryRequest {
pub agent_id: String,
pub items: Vec<MemoryItem>,
pub document_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchMemoryResponse {
pub success: bool,
pub stored_count: Option<i32>,
pub error: Option<String>,
pub job_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum AgentsResponse {
Success {
agents: Vec<String>,
},
Error {
error: String,
},
}
#[derive(Debug, Serialize)]
pub struct Agent {
pub agent_id: String,
}
pub struct ApiClient {
client: Client,
base_url: String,
}
impl ApiClient {
pub fn new(base_url: String) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(60))
.build()
.context("Failed to create HTTP client")?;
Ok(ApiClient { client, base_url })
}
pub fn search(&self, request: SearchRequest, verbose: bool) -> Result<SearchResponse> {
let url = format!("{}/api/search", self.base_url);
let request_body = serde_json::to_string_pretty(&request).unwrap_or_default();
if verbose {
eprintln!("Request URL: {}", url);
eprintln!("Request body:\n{}", request_body);
}
let response = self
.client
.post(&url)
.json(&request)
.timeout(Duration::from_secs(120))
.send()?;
let status = response.status();
if verbose {
eprintln!("Response status: {}", status);
}
if !status.is_success() {
let error_body = response.text().unwrap_or_default();
if verbose {
eprintln!("Error response body:\n{}", error_body);
}
anyhow::bail!("API returned error status {}: {}", status, error_body);
}
let response_text = response.text()?;
if verbose {
eprintln!("Response body:\n{}", response_text);
}
let result: SearchResponse = serde_json::from_str(&response_text)
.with_context(|| format!("Failed to parse API response. Response was: {}", response_text))?;
Ok(result)
}
pub fn think(&self, request: ThinkRequest, verbose: bool) -> Result<ThinkResponse> {
let url = format!("{}/api/think", self.base_url);
if verbose {
eprintln!("Request URL: {}", url);
eprintln!("Request body:\n{}", serde_json::to_string_pretty(&request).unwrap_or_default());
}
let response = self
.client
.post(&url)
.json(&request)
.timeout(Duration::from_secs(120))
.send()?;
let status = response.status();
if verbose {
eprintln!("Response status: {}", status);
}
if !status.is_success() {
let error_body = response.text().unwrap_or_default();
if verbose {
eprintln!("Error response body:\n{}", error_body);
}
anyhow::bail!("API returned error status {}: {}", status, error_body);
}
let response_text = response.text()?;
if verbose {
eprintln!("Response body:\n{}", response_text);
}
let result: ThinkResponse = serde_json::from_str(&response_text)
.with_context(|| format!("Failed to parse API response. Response was: {}", response_text))?;
Ok(result)
}
pub fn put_memories(&self, request: BatchMemoryRequest, async_mode: bool, verbose: bool) -> Result<BatchMemoryResponse> {
let endpoint = if async_mode {
"batch_async"
} else {
"batch"
};
let url = format!("{}/api/memories/{}", self.base_url, endpoint);
if verbose {
eprintln!("Request URL: {}", url);
eprintln!("Request body:\n{}", serde_json::to_string_pretty(&request).unwrap_or_default());
}
let response = self
.client
.post(&url)
.json(&request)
.timeout(Duration::from_secs(120))
.send()?;
let status = response.status();
if verbose {
eprintln!("Response status: {}", status);
}
if !status.is_success() {
let error_body = response.text().unwrap_or_default();
if verbose {
eprintln!("Error response body:\n{}", error_body);
}
anyhow::bail!("API returned error status {}: {}", status, error_body);
}
let response_text = response.text()?;
if verbose {
eprintln!("Response body:\n{}", response_text);
}
let result: BatchMemoryResponse = serde_json::from_str(&response_text)
.with_context(|| format!("Failed to parse API response. Response was: {}", response_text))?;
Ok(result)
}
pub fn list_agents(&self, verbose: bool) -> Result<Vec<Agent>> {
let url = format!("{}/api/agents", self.base_url);
if verbose {
eprintln!("Request URL: {}", url);
}
let response = self
.client
.get(&url)
.timeout(Duration::from_secs(30))
.send()?;
let status = response.status();
if verbose {
eprintln!("Response status: {}", status);
}
if !status.is_success() {
let error_body = response.text().unwrap_or_default();
if verbose {
eprintln!("Error response body:\n{}", error_body);
}
anyhow::bail!("API returned error status {}: {}", status, error_body);
}
let response_text = response.text()?;
if verbose {
eprintln!("Response body:\n{}", response_text);
}
let result: AgentsResponse = serde_json::from_str(&response_text)
.with_context(|| format!("Failed to parse API response. Response was: {}", response_text))?;
match result {
AgentsResponse::Success { agents } => {
Ok(agents.into_iter().map(|agent_id| Agent { agent_id }).collect())
}
AgentsResponse::Error { error } => {
anyhow::bail!("Failed to list agents: {}", error)
}
}
}
}

32
memora-cli/src/config.rs Normal file
View file

@ -0,0 +1,32 @@
use anyhow::{Context, Result};
use std::env;
pub struct Config {
pub api_url: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
let api_url = env::var("MEMORA_API_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
// Validate URL format
if !api_url.starts_with("http://") && !api_url.starts_with("https://") {
anyhow::bail!(
"Invalid API URL: {}. Must start with http:// or https://",
api_url
);
}
Ok(Config { api_url })
}
pub fn api_url(&self) -> &str {
&self.api_url
}
}
pub fn generate_doc_id() -> String {
let now = chrono::Local::now();
format!("cli_put_{}", now.format("%Y%m%d_%H%M%S"))
}

180
memora-cli/src/errors.rs Normal file
View file

@ -0,0 +1,180 @@
use anyhow::Result;
use colored::*;
pub fn handle_api_error(err: anyhow::Error, api_url: &str) -> ! {
eprintln!("{}", format_error_message(&err, api_url));
std::process::exit(1);
}
fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
let err_str = err.to_string();
// Connection refused
if err_str.contains("Connection refused") || err_str.contains("tcp connect error") || err_str.contains("error sending request") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n • {}\n\n{}\n {}",
"".bright_red().bold(),
"Cannot connect to Memora API".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The Memora API server is not running".bright_white(),
format!("The server is running on a different address than {}", api_url).bright_white(),
"A firewall is blocking the connection".bright_white(),
"Try:".bright_green(),
"Start the Memora API server and ensure it's accessible".bright_white()
);
}
// Timeout
if err_str.contains("timeout") || err_str.contains("Timeout") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n • {}\n • {}",
"".bright_red().bold(),
"Request timed out".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The API server is slow to respond".bright_white(),
"Network latency is too high".bright_white(),
"Try:".bright_green(),
"Check if the API server is healthy".bright_white(),
"Try again with a better network connection".bright_white()
);
}
// DNS/Host resolution
if err_str.contains("dns") || err_str.contains("DNS") || err_str.contains("failed to lookup") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
"".bright_red().bold(),
"Cannot resolve API hostname".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The hostname in the API URL is incorrect".bright_white(),
"DNS server is not responding".bright_white(),
"Try:".bright_green(),
"Check the MEMORA_API_URL environment variable".bright_white()
);
}
// 404 Not Found
if err_str.contains("404") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
"".bright_red().bold(),
"API endpoint not found (404)".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The API endpoint path has changed".bright_white(),
"You're using an incompatible API version".bright_white(),
"Try:".bright_green(),
"Check that you're using the correct Memora API version".bright_white()
);
}
// 401/403 Authentication
if err_str.contains("401") || err_str.contains("403") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
"".bright_red().bold(),
"Authentication failed".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"API requires authentication".bright_white(),
"Invalid or missing credentials".bright_white(),
"Try:".bright_green(),
"Check if the API requires an API key or token".bright_white()
);
}
// 500 Server Error
if err_str.contains("500") || err_str.contains("502") || err_str.contains("503") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n • {}\n • {}",
"".bright_red().bold(),
"API server error".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"The server encountered an error:".bright_yellow(),
"Internal server error (500)".bright_white(),
"Service temporarily unavailable".bright_white(),
"Try:".bright_green(),
"Check the API server logs for details".bright_white(),
"Try again in a few moments".bright_white()
);
}
// Invalid URL
if err_str.contains("invalid URL") || err_str.contains("InvalidUri") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n {}",
"".bright_red().bold(),
"Invalid API URL".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"The API URL format is invalid.".bright_yellow(),
"Ensure it starts with http:// or https://".bright_white(),
"Example:".bright_green(),
"export MEMORA_API_URL=http://localhost:8080".bright_white()
);
}
// JSON parsing error - show actual response
if err_str.contains("Failed to parse") || err_str.contains("error decoding") {
// Extract the actual response if available
let response_hint = if err_str.contains("Response was:") {
let parts: Vec<&str> = err_str.split("Response was:").collect();
if parts.len() > 1 {
format!("\n{}\n{}", "Actual response:".bright_yellow(), parts[1].trim().bright_white())
} else {
String::new()
}
} else {
String::new()
};
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n • {}{}\n\n{}\n • {}\n • {}",
"".bright_red().bold(),
"Invalid API response format".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The API returned an unexpected response format".bright_white(),
"Version mismatch between CLI and API".bright_white(),
"The API endpoint doesn't exist or returned HTML instead of JSON".bright_white(),
response_hint,
"Try:".bright_green(),
"Run with --verbose flag to see the full request/response".bright_white(),
"Ensure you're using a compatible Memora API version".bright_white()
);
}
// Generic error with the full error message
format!(
"{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n • {}",
"".bright_red().bold(),
"API request failed".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Error:".bright_yellow(),
err_str.bright_white(),
"Suggestions:".bright_green(),
"Check that MEMORA_API_URL is set correctly".bright_white(),
"Ensure the Memora API server is running".bright_white(),
"Verify network connectivity to the API server".bright_white()
)
}
pub fn print_config_help() {
println!("\n{}", "Configuration:".bright_cyan().bold());
println!(" Set the API URL using an environment variable:");
println!(" {}", "export MEMORA_API_URL=http://localhost:8080".bright_white());
println!("\n Add to your shell profile to make it permanent:");
println!(" {}", "echo 'export MEMORA_API_URL=http://localhost:8080' >> ~/.zshrc".bright_black());
println!();
}

439
memora-cli/src/main.rs Normal file
View file

@ -0,0 +1,439 @@
mod api;
mod config;
mod errors;
mod output;
mod ui;
use anyhow::{Context, Result};
use api::{ApiClient, BatchMemoryRequest, MemoryItem, SearchRequest, ThinkRequest};
use clap::{Parser, Subcommand, ValueEnum};
use config::Config;
use output::OutputFormat;
use std::fs;
use std::path::PathBuf;
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Format {
Pretty,
Json,
Yaml,
}
impl From<Format> for OutputFormat {
fn from(f: Format) -> Self {
match f {
Format::Pretty => OutputFormat::Pretty,
Format::Json => OutputFormat::Json,
Format::Yaml => OutputFormat::Yaml,
}
}
}
#[derive(Parser)]
#[command(name = "memora")]
#[command(about = "Memora CLI - Semantic memory system", long_about = None)]
#[command(version)]
struct Cli {
/// Output format (pretty, json, yaml)
#[arg(short = 'o', long, global = true, default_value = "pretty")]
output: Format,
/// Show verbose output including full requests and responses
#[arg(short = 'v', long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Search for memories using semantic search
Search {
/// Agent ID to search for
agent_id: String,
/// Search query
query: String,
/// Fact types to search (world, agent, opinion)
#[arg(short = 't', long, value_delimiter = ',', default_values = &["world", "agent", "opinion"])]
fact_type: Vec<String>,
/// Thinking budget for search
#[arg(short = 'b', long, default_value = "100")]
budget: i32,
/// Maximum tokens for results
#[arg(long, default_value = "4096")]
max_tokens: i32,
/// Show trace information (timing, activation counts)
#[arg(long)]
trace: bool,
},
/// Generate answers using agent identity and memories
Think {
/// Agent ID to think as
agent_id: String,
/// Query to think about
query: String,
/// Thinking budget
#[arg(short = 'b', long, default_value = "50")]
budget: i32,
},
/// Store a single memory
Put {
/// Agent ID to store memory for
agent_id: String,
/// Memory content to store
content: String,
/// Document ID (auto-generated if not provided)
#[arg(short = 'd', long)]
doc_id: Option<String>,
/// Context for the memory
#[arg(short = 'c', long)]
context: Option<String>,
/// Queue for background processing
#[arg(long)]
r#async: bool,
},
/// Bulk import memories from files
PutFiles {
/// Agent ID to store memories for
agent_id: String,
/// Path to file or directory
path: PathBuf,
/// Search directories recursively
#[arg(short = 'r', long, default_value = "true")]
recursive: bool,
/// Queue for background processing
#[arg(long)]
r#async: bool,
},
/// List all agents
Agents,
}
fn main() {
if let Err(e) = run() {
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
let output_format: OutputFormat = cli.output.into();
let verbose = cli.verbose;
// Load configuration
let config = Config::from_env().unwrap_or_else(|e| {
ui::print_error(&format!("Configuration error: {}", e));
errors::print_config_help();
std::process::exit(1);
});
let api_url = config.api_url().to_string();
// Create API client
let client = ApiClient::new(api_url.clone()).unwrap_or_else(|e| {
errors::handle_api_error(e, &api_url);
});
// Execute command and handle errors
let result: Result<()> = match cli.command {
Commands::Search {
agent_id,
query,
fact_type,
budget,
max_tokens,
trace,
} => {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Searching memories..."))
} else {
None
};
let request = SearchRequest {
query,
fact_type,
agent_id,
thinking_budget: budget,
max_tokens,
trace,
};
let response = client.search(request, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
}
match response {
Ok(resp) => {
if output_format == OutputFormat::Pretty {
ui::print_search_results(&resp, trace);
} else {
output::print_output(&resp, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
Commands::Think {
agent_id,
query,
budget,
} => {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Thinking..."))
} else {
None
};
let request = ThinkRequest {
query,
agent_id,
thinking_budget: budget,
};
let response = client.think(request, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
}
match response {
Ok(resp) => {
if output_format == OutputFormat::Pretty {
ui::print_think_response(&resp);
} else {
output::print_output(&resp, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
Commands::Put {
agent_id,
content,
doc_id,
context,
r#async,
} => {
let doc_id = doc_id.unwrap_or_else(config::generate_doc_id);
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Storing memory..."))
} else {
None
};
let item = MemoryItem {
content: content.clone(),
context,
};
let request = BatchMemoryRequest {
agent_id,
items: vec![item],
document_id: Some(doc_id.clone()),
};
let response = client.put_memories(request, r#async, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
}
match response {
Ok(resp) => {
if output_format == OutputFormat::Pretty {
ui::print_stored_memory(&doc_id, &content, r#async);
if let Some(job_id) = resp.job_id {
ui::print_info(&format!("Job ID: {}", job_id));
}
} else {
output::print_output(&resp, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
Commands::PutFiles {
agent_id,
path,
recursive,
r#async,
} => {
if !path.exists() {
anyhow::bail!("Path does not exist: {}", path.display());
}
let mut files = Vec::new();
if path.is_file() {
files.push(path);
} else if path.is_dir() {
if recursive {
for entry in WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "txt" || ext == "md" {
files.push(path.to_path_buf());
}
}
}
} else {
for entry in fs::read_dir(&path)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "txt" || ext == "md" {
files.push(path);
}
}
}
}
}
}
if files.is_empty() {
ui::print_warning("No .txt or .md files found");
return Ok(());
}
ui::print_info(&format!("Found {} files to import", files.len()));
let pb = ui::create_progress_bar(files.len() as u64, "Processing files");
let mut items = Vec::new();
let mut document_id = None;
for file_path in &files {
let content = fs::read_to_string(file_path)
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
let doc_id = file_path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(config::generate_doc_id);
// Use the first file's stem as the document_id for the batch
if document_id.is_none() {
document_id = Some(doc_id);
}
items.push(MemoryItem {
content,
context: None,
});
pb.inc(1);
}
pb.finish_with_message("Files processed");
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Uploading memories..."))
} else {
None
};
let request = BatchMemoryRequest {
agent_id,
items,
document_id,
};
let response = client.put_memories(request, r#async, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
}
match response {
Ok(resp) => {
if output_format == OutputFormat::Pretty {
if r#async {
ui::print_success(&format!(
"Queued {} files for background processing",
files.len()
));
if let Some(job_id) = resp.job_id {
ui::print_info(&format!("Job ID: {}", job_id));
}
} else {
ui::print_success(&format!("Successfully stored {} memories", files.len()));
}
} else {
output::print_output(&resp, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
Commands::Agents => {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching agents..."))
} else {
None
};
let response = client.list_agents(verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
}
match response {
Ok(agents) => {
if output_format == OutputFormat::Pretty {
ui::print_agents_table(&agents);
} else {
output::print_output(&agents, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
};
// Handle API errors with nice messages
if let Err(e) = result {
errors::handle_api_error(e, &api_url);
}
Ok(())
}

36
memora-cli/src/output.rs Normal file
View file

@ -0,0 +1,36 @@
use anyhow::Result;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OutputFormat {
Pretty,
Json,
Yaml,
}
impl OutputFormat {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"pretty" | "default" => Some(OutputFormat::Pretty),
"json" => Some(OutputFormat::Json),
"yaml" | "yml" => Some(OutputFormat::Yaml),
_ => None,
}
}
}
pub fn print_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<()> {
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(data)?);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(data)?);
}
OutputFormat::Pretty => {
// This should not be called - pretty printing is handled in ui.rs
unreachable!("Pretty format should be handled separately")
}
}
Ok(())
}

217
memora-cli/src/ui.rs Normal file
View file

@ -0,0 +1,217 @@
use crate::api::{Agent, Fact, SearchResponse, ThinkResponse, TraceInfo};
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use std::io::{self, Write};
pub fn print_banner() {
println!("{}", "╔══════════════════════════════════════════════════╗".bright_cyan());
println!("{}", "║ MEMORA - Memory CLI ║".bright_cyan());
println!("{}", "╚══════════════════════════════════════════════════╝".bright_cyan());
println!();
}
pub fn print_section_header(title: &str) {
println!();
println!("{}", format!("━━━ {} ━━━", title).bright_yellow().bold());
println!();
}
pub fn print_fact(fact: &Fact, show_activation: bool) {
let fact_type = fact.fact_type.as_deref().unwrap_or("unknown");
let type_color = match fact_type {
"world" => "cyan",
"agent" => "magenta",
"opinion" => "yellow",
_ => "white",
};
let prefix = match fact_type {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => "📝",
};
print!("{} ", prefix);
print!("{}", format!("[{}]", fact_type.to_uppercase()).color(type_color).bold());
if show_activation {
if let Some(activation) = fact.activation {
print!(" {}", format!("({:.2})", activation).bright_black());
}
}
println!();
println!(" {}", fact.text);
// Show context if available
if let Some(context) = &fact.context {
println!(" {}: {}", "Context".bright_black(), context.bright_black());
}
// Show event date if available
if let Some(event_date) = &fact.event_date {
println!(" {}: {}", "Date".bright_black(), event_date.bright_black());
}
println!();
}
pub fn print_search_results(response: &SearchResponse, show_trace: bool) {
let results = &response.results;
print_section_header(&format!("Search Results ({})", results.len()));
if results.is_empty() {
println!("{}", " No results found.".bright_black());
} else {
for (i, fact) in results.iter().enumerate() {
println!("{}", format!(" Result #{}", i + 1).bright_black());
print_fact(fact, true);
}
}
if show_trace {
if let Some(trace) = &response.trace {
print_trace_info(trace);
}
}
}
pub fn print_think_response(response: &ThinkResponse) {
print_section_header("Answer");
println!("{}", response.text.bright_white());
println!();
// Note: based_on facts are hidden in default output
// Use -o json to see the complete response including based_on facts
if !response.based_on.is_empty() {
println!(" {}", format!("(Based on {} facts - use -o json to see details)", response.based_on.len()).bright_black());
println!();
}
if !response.new_opinions.is_empty() {
print_section_header(&format!("New opinions formed ({})", response.new_opinions.len()));
for opinion in &response.new_opinions {
println!(" 💭 {}", opinion.bright_yellow());
}
println!();
}
}
pub fn print_trace_info(trace: &TraceInfo) {
print_section_header("Trace Information");
if let Some(time) = trace.total_time {
println!(" ⏱️ Total time: {}", format!("{:.2}ms", time).bright_green());
}
if let Some(count) = trace.activation_count {
println!(" 📊 Activation count: {}", count.to_string().bright_green());
}
println!();
}
pub fn print_agents_table(agents: &[Agent]) {
print_section_header(&format!("Agents ({})", agents.len()));
if agents.is_empty() {
println!("{}", " No agents found.".bright_black());
return;
}
// Calculate column width
let max_id_len = agents.iter().map(|a| a.agent_id.len()).max().unwrap_or(8);
let id_width = max_id_len.max(8);
// Print header
println!("{}",
"".repeat(id_width + 2));
println!("{:<width$}",
"Agent ID".bright_cyan().bold(),
width = id_width);
println!("{}",
"".repeat(id_width + 2));
// Print rows
for agent in agents {
println!("{:<width$}",
agent.agent_id,
width = id_width);
}
println!("{}",
"".repeat(id_width + 2));
println!();
}
pub fn print_success(message: &str) {
println!("{} {}", "".bright_green().bold(), message.bright_white());
}
pub fn print_error(message: &str) {
eprintln!("{} {}", "".bright_red().bold(), message.bright_red());
}
pub fn print_warning(message: &str) {
println!("{} {}", "".bright_yellow().bold(), message.bright_yellow());
}
pub fn print_info(message: &str) {
println!("{} {}", "".bright_blue().bold(), message.bright_white());
}
pub fn create_spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["", "", "", "", "", "", "", "", "", ""]),
);
pb.set_message(message.to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
}
pub fn create_progress_bar(total: u64, message: &str) -> ProgressBar {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::default_bar()
.template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)")
.unwrap()
.progress_chars("█▓▒░ "),
);
pb.set_message(message.to_string());
pb
}
pub fn print_stored_memory(doc_id: &str, content: &str, is_async: bool) {
if is_async {
println!("{} Queued for background processing", "".bright_yellow());
} else {
println!("{} Stored successfully", "".bright_green());
}
println!(" Document ID: {}", doc_id.bright_cyan());
let preview = if content.len() > 60 {
format!("{}...", &content[..57])
} else {
content.to_string()
};
println!(" Content: {}", preview.bright_black());
println!();
}
pub fn prompt_confirmation(message: &str) -> io::Result<bool> {
print!("{} {} [y/N]: ", "?".bright_blue().bold(), message);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes"))
}

View file

@ -3,8 +3,7 @@ node_modules
npm-debug.log
.git
.gitignore
.env.local
.env*.local
.env
README.md
Dockerfile
.dockerignore

View file

@ -0,0 +1,2 @@
# Dataplane API URL
MEMORA_CP_DATAPLANE_API_URL=http://localhost:8080

View file

@ -0,0 +1,39 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -75,16 +75,12 @@ npm install
### Configuration
Configure the dataplane URL in `.env.local`:
Configure the dataplane URL in `.env` (optional, defaults to http://localhost:8080):
```bash
cp .env.local.example .env.local
```
Edit `.env.local`:
```env
DATAPLANE_API_URL=http://localhost:8080
cat > .env << 'EOF'
MEMORA_CP_DATAPLANE_API_URL=http://localhost:8080
EOF
```
### Development
@ -157,7 +153,7 @@ control-plane/
│ ├── agent-context.tsx # Global agent state
│ ├── api.ts # API client
│ └── utils.ts # Utilities
├── .env.local # Environment config
├── .env # Environment config (optional)
└── package.json
```
@ -240,7 +236,7 @@ All control plane API routes proxy to the dataplane:
**CORS Errors**: The control plane should eliminate CORS issues. If you see them, ensure you're accessing the control plane at `http://localhost:3000` (not the dataplane directly).
**Connection Errors**: Verify the dataplane is running at the URL specified in `.env.local` (default: `http://localhost:8080`).
**Connection Errors**: Verify the dataplane is running at the URL specified in `.env` (default: `http://localhost:8080`).
**Graph Not Rendering**: Check browser console for errors. Ensure data is loading correctly from `/api/graph`.

View file

@ -62,11 +62,11 @@ echo "Image: ${FULL_IMAGE_NAME}"
echo ""
echo "To run the container:"
echo " docker run -p 3000:3000 \\"
echo " -e DATAPLANE_API_URL=http://your-api-url:8080 \\"
echo " -e MEMORA_CP_DATAPLANE_API_URL=http://your-api-url:8080 \\"
echo " ${FULL_IMAGE_NAME}"
echo ""
echo "Or with an env file:"
echo " docker run -p 3000:3000 --env-file .env.local ${FULL_IMAGE_NAME}"
echo " docker run -p 3000:3000 --env-file .env ${FULL_IMAGE_NAME}"
echo ""
echo "To push to registry (if registry specified):"
if [ -n "$REGISTRY" ]; then

View file

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function GET() {
try {

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function GET(
request: NextRequest,

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function GET(request: NextRequest) {
try {

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function GET(request: NextRequest) {
try {

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function GET(request: NextRequest) {
try {

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function POST(request: NextRequest) {
try {

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function POST(request: NextRequest) {
try {

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function GET(
request: NextRequest,

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function POST(request: NextRequest) {
try {

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function GET(
request: NextRequest,

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
const DATAPLANE_URL = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
const DATAPLANE_URL = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
export async function POST(request: NextRequest) {
try {

View file

@ -213,7 +213,7 @@ export class ServerDataplaneClient {
private baseUrl: string;
constructor() {
this.baseUrl = process.env.DATAPLANE_API_URL || 'http://localhost:8080';
this.baseUrl = process.env.MEMORA_CP_DATAPLANE_API_URL || 'http://localhost:8080';
}
async fetchDataplane<T>(

View file

@ -0,0 +1,10 @@
#!/bin/bash
# Wrapper script to map MEMORA_CP_* environment variables to Next.js standard variables
# Map prefixed env vars to standard Next.js env vars
export HOSTNAME="${MEMORA_CP_HOSTNAME:-0.0.0.0}"
export PORT="${MEMORA_CP_PORT:-3000}"
# Start the Next.js server
# The server.js is in the standalone output at the root
exec node server.js

View file

@ -230,11 +230,11 @@ class BenchmarkRunner:
self.answer_generator = answer_generator
self.answer_evaluator = answer_evaluator
self.memory = memory or TemporalSemanticMemory(
db_url=os.getenv("DATABASE_URL"),
memory_llm_provider=os.getenv("MEMORY_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("MEMORY_LLM_API_KEY"),
memory_llm_model=os.getenv("MEMORY_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("MEMORY_LLM_BASE_URL") or None, # Use None to get provider defaults
db_url=os.getenv("MEMORA_API_DATABASE_URL"),
memory_llm_provider=os.getenv("MEMORA_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("MEMORA_API_LLM_API_KEY"),
memory_llm_model=os.getenv("MEMORA_API_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("MEMORA_API_LLM_BASE_URL") or None, # Use None to get provider defaults
)
def calculate_data_stats(self, items: List[Dict[str, Any]]) -> Dict[str, Any]:

View file

@ -350,11 +350,11 @@ async def run_benchmark(
memory = RemoteMemoryClient(base_url=api_url)
else:
memory = TemporalSemanticMemory(
db_url=os.getenv("DATABASE_URL"),
memory_llm_provider=os.getenv("MEMORY_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("MEMORY_LLM_API_KEY"),
memory_llm_model=os.getenv("MEMORY_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("MEMORY_LLM_BASE_URL") or None, # Use None to get provider defaults
db_url=os.getenv("MEMORA_API_DATABASE_URL"),
memory_llm_provider=os.getenv("MEMORA_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("MEMORA_API_LLM_API_KEY"),
memory_llm_model=os.getenv("MEMORA_API_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("MEMORA_API_LLM_BASE_URL") or None, # Use None to get provider defaults
)
await memory.initialize()

Some files were not shown because too many files have changed in this diff Show more