Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ COPY crates/ crates/

RUN cargo build --release

# Stage 4: Runtime
# Stage 4: Build marmotd (optional MLS messaging sidecar)
FROM chef AS marmotd-builder
ARG MARMOTD_VERSION=0.5.1
RUN git clone --depth 1 --branch marmotd-v${MARMOTD_VERSION} https://github.com/sledtools/pika.git /marmotd-src
WORKDIR /marmotd-src
RUN cargo build -p marmotd --release

# Stage 5: Runtime
FROM docker.io/debian:bookworm-slim

# Install runtime dependencies and comprehensive CLI toolset
Expand Down Expand Up @@ -140,6 +147,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
toml \
rich

# Copy marmotd binary (MLS messaging sidecar, built from pika source)
COPY --from=marmotd-builder /marmotd-src/target/release/marmotd /usr/local/bin/marmotd

# Create non-root user
RUN useradd -m -u 1000 sage

Expand All @@ -151,8 +161,8 @@ COPY --from=builder /app/target/release/sage /app/sage
# Copy migrations for diesel
COPY --from=builder /app/crates/sage-core/migrations /app/migrations

# Create workspace directory
RUN mkdir -p /workspace && chown sage:sage /workspace
# Create workspace and marmot state directories
RUN mkdir -p /workspace /data/marmot-state && chown sage:sage /workspace /data/marmot-state

# Run as non-root user
USER sage
Expand Down
57 changes: 51 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
Sage is an AI assistant that prioritizes **privacy** and **data sovereignty**. It's designed to be a trusted companion that remembers your conversations, learns about you over time, and can take actions on your behalf - all while keeping your data under your control.

**Key Features:**
- **End-to-end encrypted messaging** via Signal
- **End-to-end encrypted messaging** via Signal or [Pika](https://github.com/sledtools/pika) (MLS over Nostr)
- **Image understanding** - send photos and Sage can see and describe them
- **Long-term memory** that persists across conversations
- **Confidential compute** - LLM inference runs in a TEE (Trusted Execution Environment)
Expand All @@ -22,7 +22,7 @@ Most AI assistants are stateless - they forget everything after each conversatio

- Your conversations stay on **your** PostgreSQL instance
- LLM inference happens in **confidential compute** (Maple/TEE) - the inference provider can't see your prompts
- Communication happens over **Signal's E2E encryption**
- Communication happens over **Signal's E2E encryption** or **MLS-encrypted Nostr** via [Pika](https://github.com/sledtools/pika)
- The agent runs in **your container** on your infrastructure

## Technical Highlights
Expand Down Expand Up @@ -250,7 +250,7 @@ DSRs parses this back into a typed `AgentResponse` struct that Sage uses to exec
| LLM | **Kimi K2** (thinking variant) | Strong tool use, 128k context |
| Inference | **Maple** | TEE-based confidential compute |
| Embeddings | **nomic-embed-text** | Via Maple |
| Messaging | **Signal** (signal-cli) | E2E encrypted, works on mobile |
| Messaging | **Signal** (signal-cli) or **Pika** (marmotd) | E2E encrypted; Signal for mobile, Pika for decentralized Nostr |
| Database | **PostgreSQL + pgvector** | Structured data + vector search |
| Framework | **DSRs** (dspy-rs) | Typed signatures, BAML parsing |

Expand All @@ -266,12 +266,41 @@ DSRs parses this back into a typed `AgentResponse` struct that Sage uses to exec
| `schedule_task` | Reminders (cron or one-off) |
| `set_preference` | User preferences (timezone, etc.) |

## Messaging Providers

Sage supports two messaging backends. Set the `MESSENGER` environment variable to choose (`signal` is the default).

### Signal (Default)

Uses [signal-cli](https://github.com/AsamK/signal-cli) for E2E encrypted messaging over the Signal protocol. Requires a registered phone number and runs signal-cli as a sidecar container.

```bash
MESSENGER=signal
SIGNAL_PHONE_NUMBER=+1234567890
SIGNAL_ALLOWED_USERS=* # Or comma-separated Signal UUIDs
```

### Marmot / Pika (Decentralized)

Uses [marmotd](https://github.com/sledtools/pika) for MLS-encrypted messaging over Nostr relays. No phone number required — identity is a Nostr keypair. Message Sage from the [Pika](https://github.com/sledtools/pika) app.

```bash
MESSENGER=marmot
MARMOT_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
MARMOT_ALLOWED_PUBKEYS=npub1... # Or * for all
MARMOT_AUTO_ACCEPT_WELCOMES=true
```

On first startup, marmotd generates a Nostr keypair and prints its `npub` in the logs. Use this npub to start a conversation from Pika. The keypair and MLS state persist in a Docker volume (`sage-marmot-state`).

marmotd is built from source during `docker build` (included in the Dockerfile).

## Quick Start

### Prerequisites

- [Podman](https://podman.io/) or Docker
- signal-cli registered with a phone number
- signal-cli registered with a phone number (if using Signal) or [Pika](https://github.com/sledtools/pika) app (if using Marmot)
- Maple API access (or compatible OpenAI endpoint)

### Option 1: Docker (Recommended)
Expand Down Expand Up @@ -336,12 +365,22 @@ just start # Start all services
MAPLE_API_URL=https://your-maple-endpoint
MAPLE_API_KEY=your-api-key
MAPLE_MODEL=maple/kimi-k2-5

# Messenger (choose one)
MESSENGER=signal # "signal" (default) or "marmot"

# Signal config (when MESSENGER=signal)
SIGNAL_PHONE_NUMBER=+1234567890
SIGNAL_ALLOWED_USERS=* # Or comma-separated UUIDs

# Marmot config (when MESSENGER=marmot)
MARMOT_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
MARMOT_ALLOWED_PUBKEYS=npub1... # Or * for all
MARMOT_AUTO_ACCEPT_WELCOMES=true

# Optional
BRAVE_API_KEY=your-brave-key # For web search
MAPLE_VISION_MODEL=maple/kimi-k2-5 # For image understanding (defaults to MAPLE_MODEL)
SIGNAL_ALLOWED_USERS=* # Or comma-separated UUIDs
```

## Architecture
Expand All @@ -359,6 +398,11 @@ SIGNAL_ALLOWED_USERS=* # Or comma-separated UUIDs
│ │ Manager │ │ System │ │ │ │
│ └─────────────┘ └─────────────┘ └────────────┘ │
└─────────────────────────┬───────────────────────────┘
│ JSONL/stdio
┌─────────────────┐ MLS/Nostr ┌────────┴────────┐
│ Pika App │◄──────────────►│ marmotd │
└─────────────────┘ (encrypted) └─────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
Expand All @@ -372,7 +416,7 @@ SIGNAL_ALLOWED_USERS=* # Or comma-separated UUIDs

| Layer | Protection |
|-------|------------|
| **Transport** | Signal E2E encryption |
| **Transport** | Signal E2E encryption or MLS-encrypted Nostr (via Pika) |
| **Inference** | Maple TEE (confidential compute) |
| **Embeddings** | Maple TEE (memory vectors generated privately) |
| **Storage** | Local PostgreSQL (your machine) |
Expand Down Expand Up @@ -444,6 +488,7 @@ Current categories: first-time users, casual chat, web search, memory storage, t
- [Letta](https://github.com/letta-ai/letta) - Memory management inspiration
- [DSRs](https://github.com/krypticmouse/DSRs) - DSPy in Rust
- [signal-cli](https://github.com/AsamK/signal-cli) - Signal CLI interface
- [Pika](https://github.com/sledtools/pika) - MLS-encrypted messaging over Nostr
- [Maple](https://www.trymaple.ai/) - Confidential compute LLM inference

## License
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE chat_contexts DROP COLUMN reply_context;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE chat_contexts ADD COLUMN reply_context TEXT;
39 changes: 39 additions & 0 deletions crates/sage-core/src/agent_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub struct ChatContext {
pub context_type: String,
pub display_name: Option<String>,
pub created_at: chrono::DateTime<Utc>,
pub reply_context: Option<String>,
}

/// New chat context for insertion
Expand Down Expand Up @@ -217,6 +218,7 @@ impl AgentManager {
context_type: context_type.as_str().to_string(),
display_name: display_name.map(|s| s.to_string()),
created_at: Utc::now(),
reply_context: None,
})
}

Expand Down Expand Up @@ -322,6 +324,43 @@ impl AgentManager {
Ok(result)
}

/// Update the reply_context for a given identifier (e.g. Marmot group_id for a pubkey)
pub fn update_reply_context(&self, signal_identifier: &str, reply_ctx: &str) -> Result<()> {
let mut conn = self
.db_conn
.lock()
.map_err(|_| anyhow::anyhow!("Failed to acquire database lock"))?;

diesel::update(
chat_contexts::table.filter(chat_contexts::signal_identifier.eq(signal_identifier)),
)
.set(chat_contexts::reply_context.eq(Some(reply_ctx)))
.execute(&mut *conn)?;

Ok(())
}

/// Load all reply_context mappings (identifier -> reply_context) for route restoration
pub fn load_reply_contexts(&self) -> Result<Vec<(String, String)>> {
let mut conn = self
.db_conn
.lock()
.map_err(|_| anyhow::anyhow!("Failed to acquire database lock"))?;

let results: Vec<(String, Option<String>)> = chat_contexts::table
.select((
chat_contexts::signal_identifier,
chat_contexts::reply_context,
))
.filter(chat_contexts::reply_context.is_not_null())
.load(&mut *conn)?;

Ok(results
.into_iter()
.filter_map(|(id, ctx)| ctx.map(|c| (id, c)))
.collect())
}

/// Get all chat contexts
#[allow(dead_code)]
pub fn list_contexts(&self) -> Result<Vec<ChatContext>> {
Expand Down
75 changes: 75 additions & 0 deletions crates/sage-core/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
use anyhow::{Context, Result};

use crate::marmot::MarmotConfig;

#[derive(Debug, Clone, PartialEq)]
pub enum MessengerType {
Signal,
Marmot,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Config {
Expand All @@ -11,12 +19,23 @@ pub struct Config {

pub database_url: String,

/// Which messaging provider to use
pub messenger_type: MessengerType,

// Signal-specific config
pub signal_phone_number: Option<String>,
pub signal_allowed_users: Vec<String>,
/// If set, connect to signal-cli daemon via TCP instead of spawning subprocess
pub signal_cli_host: Option<String>,
pub signal_cli_port: u16,

// Marmot-specific config
pub marmot_binary: String,
pub marmot_relays: Vec<String>,
pub marmot_state_dir: String,
pub marmot_allowed_pubkeys: Vec<String>,
pub marmot_auto_accept_welcomes: bool,

pub brave_api_key: Option<String>,

/// Workspace directory for shell commands and file operations
Expand All @@ -40,6 +59,15 @@ impl Config {

database_url: std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?,

messenger_type: match std::env::var("MESSENGER")
.unwrap_or_else(|_| "signal".to_string())
.to_lowercase()
.as_str()
{
"marmot" => MessengerType::Marmot,
_ => MessengerType::Signal,
},

signal_phone_number: std::env::var("SIGNAL_PHONE_NUMBER").ok(),
signal_allowed_users: std::env::var("SIGNAL_ALLOWED_USERS")
.map(|s| s.split(',').map(|u| u.trim().to_string()).collect())
Expand All @@ -50,6 +78,36 @@ impl Config {
.parse()
.unwrap_or(7583),

marmot_binary: std::env::var("MARMOT_BINARY").unwrap_or_else(|_| "marmotd".to_string()),
marmot_relays: std::env::var("MARMOT_RELAYS")
.map(|s| {
s.split(',')
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect()
})
.unwrap_or_default(),
marmot_state_dir: std::env::var("MARMOT_STATE_DIR")
.unwrap_or_else(|_| "/data/marmot-state".to_string()),
marmot_allowed_pubkeys: std::env::var("MARMOT_ALLOWED_PUBKEYS")
.map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.map(|p| {
if p == "*" {
p
} else {
crate::marmot::normalize_pubkey(&p).unwrap_or(p)
}
})
.collect()
})
.unwrap_or_default(),
marmot_auto_accept_welcomes: std::env::var("MARMOT_AUTO_ACCEPT_WELCOMES")
.map(|s| s != "false" && s != "0")
.unwrap_or(true),

brave_api_key: std::env::var("BRAVE_API_KEY").ok(),

workspace_path: std::env::var("SAGE_WORKSPACE")
Expand All @@ -61,4 +119,21 @@ impl Config {
.context("HTTP_PORT must be a valid port number")?,
})
}

pub fn marmot_config(&self) -> MarmotConfig {
MarmotConfig {
binary_path: self.marmot_binary.clone(),
relays: self.marmot_relays.clone(),
state_dir: self.marmot_state_dir.clone(),
allowed_pubkeys: self.marmot_allowed_pubkeys.clone(),
auto_accept_welcomes: self.marmot_auto_accept_welcomes,
}
}

pub fn allowed_users(&self) -> &[String] {
match self.messenger_type {
MessengerType::Signal => &self.signal_allowed_users,
MessengerType::Marmot => &self.marmot_allowed_pubkeys,
}
}
}
2 changes: 2 additions & 0 deletions crates/sage-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

pub mod agent_manager;
pub mod config;
pub mod marmot;
pub mod memory;
pub mod messenger;
pub mod sage_agent;
pub mod scheduler;
pub mod scheduler_tools;
Expand Down
Loading