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
878 changes: 855 additions & 23 deletions Cargo.lock

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- **Self-hosted, free forever.** No license gating, no per-seat pricing. BYOK (bring your own keys) for LLM and embedding providers — you own your data end to end.
- **Ships as a single static binary.** Written in Rust, no runtime dependencies. Runs comfortably on a $5 VPS or a Raspberry Pi.
- **Auto-detects embedding dimension mismatches.** No more `expected 1536, got 768` debugging sessions. Memayu probes your embedder on startup and configures itself.
- **Local embedding (no API key).** An in-process Candle backend runs a multilingual model fully on-device with zero network calls at inference time — a pure-Rust single-binary alternative to BYOK, ideal for Raspberry Pi and offline VPS setups. The default model is `paraphrase-multilingual-MiniLM-L12-v2` (384-d, covers Bahasa Indonesia + English).
- **ADD vs UPDATE extraction.** New facts replace conflicting old facts automatically — your agent's memory stays a coherent knowledge base, not a growing append-only log.
- **Raw mode.** Set `MEMAYU_EXTRACTION_MODE=raw` (or `behavior.extraction_mode = "raw"` in the config file) to skip LLM extraction and store memories verbatim, with aggressive (0.98) deduplication — ideal for notes, logs, and low-latency ingestion.
- **Hybrid search.** Vector similarity fused with full-text retrieval (libSQL FTS5 / Postgres tsvector) via Reciprocal Rank Fusion (RRF), so exact keyword matches and semantic matches both surface.
Expand Down Expand Up @@ -173,6 +174,25 @@ Memayu follows a ports-and-adapters architecture with a strict dependency rule:

Configuration loads from an optional TOML file at `~/.config/memayu/config.toml` (or `$XDG_CONFIG_HOME/memayu/config.toml`), overridable via the `MEMAYU_CONFIG` env var. Environment variables (prefixed `MEMAYU_`) remain supported and override the file. See **[.env.example](.env.example)** for the complete reference with defaults and descriptions.

### Local embedding (no API key)

The embedder can run fully on-device instead of calling a remote API. Set the embedder backend to `local` in the config file:

```toml
[embedder]
backend = "local" # "local" (on-device Candle) or "http" (BYOK, default)
model = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
```

or via environment variables: `MEMAYU_EMBEDDER_BACKEND=local` and `MEMAYU_EMBEDDER_MODEL=<HF model id>`.

- **No API key.** A local backend needs no `base_url` or `api_key`; nothing is sent over the network at inference time.
- **One-time download.** The model weights are downloaded from Hugging Face on first use and cached under the local data directory (override with `MEMAYU_MODEL_DIR`). Subsequent runs are fully offline.
- **Default model.** `paraphrase-multilingual-MiniLM-L12-v2` (384-d) is multilingual, so it handles a Bahasa Indonesia + English technical mix out of the box — measured recall@3 of 5/5 (100%) on a fixed mixed corpus in the local e2e suite. `all-MiniLM-L6-v2` or another HF sentence-transformer id can be substituted for English-only users.
- **Dimension auto-detect.** The local model's output dimension is probed the same way as remote providers, so no manual dimension config is needed.
- **Wizard & dashboard.** `memayu setup` offers `local` as the default embedder backend, and the web dashboard (`/providers`) exposes a backend selector. `memayu doctor` reports the active backend and skips HTTP probes when the backend is local.
- **Build.** The Candle backend is compiled in by default (`memayu-llm-client`'s `local-embedding` feature). Use `--no-default-features` on that crate for a smaller HTTP-only build.

## Contributing

Issues and pull requests are welcome. Before submitting structural changes, read **[CONTRIBUTING.md](CONTRIBUTING.md)** for guidelines.
Expand Down
37 changes: 26 additions & 11 deletions bin/memayu/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,30 @@ pub async fn cmd_doctor(config: &Config) -> i32 {
}

println!("\n{} Checking embedder", section("embedder"));
let (passed, warned) = check_provider(
"embedder",
&config.embedder.base_url,
config.embedder.api_key.as_deref(),
&config.embedder.model,
)
.await;
ok &= passed;
warnings += usize::from(warned);
if memayu_llm_client::is_local_backend(&config.embedder) {
// On-device backend: no network endpoint to probe. Confirm the model
// cache location (the model is downloaded on first embed).
let model_id = if config.embedder.model.is_empty() {
memayu_llm_client::local_embedder::DEFAULT_MODEL_ID.to_string()
} else {
config.embedder.model.clone()
};
println!(
" {} on-device backend (local Candle), model: {model_id}",
style("✔").green().bold()
);
println!(" model dir: {}", memayu_config::model_dir().display());
} else {
let (passed, warned) = check_provider(
"embedder",
&config.embedder.base_url,
config.embedder.api_key.as_deref(),
&config.embedder.model,
)
.await;
ok &= passed;
warnings += usize::from(warned);
}

// ── Summary ──
println!();
Expand Down Expand Up @@ -128,8 +143,8 @@ fn print_config(config: &Config) {
);
println!(" llm key: {}", redact(&config.llm.api_key));
println!(
" embedder: {} ({})",
config.embedder.base_url, config.embedder.model
" embedder: {} ({}) [{}]",
config.embedder.base_url, config.embedder.model, config.embedder.backend
);
println!(" emb key: {}", redact(&config.embedder.api_key));
if let Some(d) = config.dimension {
Expand Down
8 changes: 4 additions & 4 deletions bin/memayu/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//! not pull in the Axum web stack.

use memayu_config::{Config, StorageBackend};
use memayu_core::{EmbedderProvider, MemoryService, StorageProvider};
use memayu_llm_client::{HttpEmbedderProvider, HttpLlmProvider};
use memayu_core::{MemoryService, StorageProvider};
use memayu_llm_client::{build_embedder, HttpLlmProvider};
use std::sync::Arc;

/// Probe the embedder for its output dimension unless explicitly configured.
Expand All @@ -16,7 +16,7 @@ async fn detect_dimension(config: &Config) -> Result<usize, Box<dyn std::error::
if let Some(dim) = config.dimension {
return Ok(dim);
}
let embedder = HttpEmbedderProvider::new(config.embedder.clone());
let embedder = build_embedder(&config.embedder);
Ok(embedder.embed("dimension probe").await?.len())
}

Expand Down Expand Up @@ -57,7 +57,7 @@ pub async fn build_service(
let service = Arc::new(
MemoryService::new(
storage,
Arc::new(HttpEmbedderProvider::new(config.embedder.clone())),
build_embedder(&config.embedder),
Arc::new(HttpLlmProvider::new(config.llm.clone())),
)
.with_extraction_mode(config.extraction_mode),
Expand Down
110 changes: 83 additions & 27 deletions bin/memayu/src/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,25 @@ pub fn run_wizard() -> Result<String, Box<dyn std::error::Error>> {

// ── Embedder ──
println!("{}", console::style("── Embedder Provider ──").bold().dim());
let emb_base_url = prompt("Base URL", "https://api.openai.com/v1");
let emb_api_key = prompt("API key (optional, press enter to skip)", "");
let emb_model = prompt("Model", "text-embedding-3-small");
let emb_backends = vec!["local", "http"];
let emb_backend_idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Embedding backend (local = on-device Candle, http = bring-your-own-key)")
.items(&emb_backends)
.default(0)
.interact()?;
let emb_backend = emb_backends[emb_backend_idx];
let (emb_base_url, emb_api_key, emb_model) = if emb_backend == "local" {
(
String::new(),
String::new(),
memayu_llm_client::local_embedder::DEFAULT_MODEL_ID.to_string(),
)
} else {
let base = prompt("Base URL", "https://api.openai.com/v1");
let key = prompt("API key (optional, press enter to skip)", "");
let model = prompt("Model", "text-embedding-3-small");
(base, key, model)
};

println!();

Expand Down Expand Up @@ -96,6 +112,7 @@ pub fn run_wizard() -> Result<String, Box<dyn std::error::Error>> {
},
}),
llm: Some(memayu_config::ProviderConfigFile {
backend: Some("http".to_string()),
base_url: Some(llm_base_url),
api_key: if llm_api_key.is_empty() {
None
Expand All @@ -105,7 +122,12 @@ pub fn run_wizard() -> Result<String, Box<dyn std::error::Error>> {
model: Some(llm_model),
}),
embedder: Some(memayu_config::ProviderConfigFile {
base_url: Some(emb_base_url),
backend: Some(emb_backend.to_string()),
base_url: if emb_base_url.is_empty() {
None
} else {
Some(emb_base_url)
},
api_key: if emb_api_key.is_empty() {
None
} else {
Expand Down Expand Up @@ -245,30 +267,58 @@ pub fn run_wizard_preseed(skip_intro: bool) -> Result<String, Box<dyn std::error

// ── Embedder ──
println!("{}", console::style("── Embedder Provider ──").bold().dim());
let emb_base_url = prompt(
"Base URL",
&existing
.as_ref()
.and_then(|e| e.embedder.as_ref())
.and_then(|l| l.base_url.clone())
.unwrap_or_else(|| "https://api.openai.com/v1".into()),
);
let emb_api_key = prompt(
"API key (optional, press enter to skip)",
&existing
.as_ref()
.and_then(|e| e.embedder.as_ref())
.and_then(|l| l.api_key.clone())
.unwrap_or_default(),
);
let emb_model = prompt(
"Model",
&existing
let emb_backend = {
let default_backend = existing
.as_ref()
.and_then(|e| e.embedder.as_ref())
.and_then(|l| l.model.clone())
.unwrap_or_else(|| "text-embedding-3-small".into()),
);
.and_then(|l| l.backend.clone())
.unwrap_or_else(|| "local".into());
let default_idx = if default_backend == "http" { 1 } else { 0 };
let emb_backends = vec!["local", "http"];
let idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Embedding backend (local = on-device Candle, http = bring-your-own-key)")
.items(&emb_backends)
.default(default_idx)
.interact()?;
emb_backends[idx]
};
let (emb_base_url, emb_api_key, emb_model) = if emb_backend == "local" {
(
String::new(),
String::new(),
existing
.as_ref()
.and_then(|e| e.embedder.as_ref())
.and_then(|l| l.model.clone())
.unwrap_or_else(|| memayu_llm_client::local_embedder::DEFAULT_MODEL_ID.into()),
)
} else {
let base = prompt(
"Base URL",
&existing
.as_ref()
.and_then(|e| e.embedder.as_ref())
.and_then(|l| l.base_url.clone())
.unwrap_or_else(|| "https://api.openai.com/v1".into()),
);
let key = prompt(
"API key (optional, press enter to skip)",
&existing
.as_ref()
.and_then(|e| e.embedder.as_ref())
.and_then(|l| l.api_key.clone())
.unwrap_or_default(),
);
let model = prompt(
"Model",
&existing
.as_ref()
.and_then(|e| e.embedder.as_ref())
.and_then(|l| l.model.clone())
.unwrap_or_else(|| "text-embedding-3-small".into()),
);
(base, key, model)
};

println!();

Expand Down Expand Up @@ -337,6 +387,7 @@ pub fn run_wizard_preseed(skip_intro: bool) -> Result<String, Box<dyn std::error
},
}),
llm: Some(memayu_config::ProviderConfigFile {
backend: Some("http".to_string()),
base_url: Some(llm_base_url),
api_key: if llm_api_key.is_empty() {
None
Expand All @@ -346,7 +397,12 @@ pub fn run_wizard_preseed(skip_intro: bool) -> Result<String, Box<dyn std::error
model: Some(llm_model),
}),
embedder: Some(memayu_config::ProviderConfigFile {
base_url: Some(emb_base_url),
backend: Some(emb_backend.to_string()),
base_url: if emb_base_url.is_empty() {
None
} else {
Some(emb_base_url)
},
api_key: if emb_api_key.is_empty() {
None
} else {
Expand Down
17 changes: 13 additions & 4 deletions crates/memayu-api/src/modules/providers/service.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use async_trait::async_trait;
use memayu_config::ProviderConfig;
use memayu_config::{EmbedderBackend, ProviderConfig};
use memayu_core::{EmbedError, EmbedderProvider, ExtractionResult, LlmError, LlmProvider, Message};
use memayu_llm_client::{HttpEmbedderProvider, HttpLlmProvider};
use memayu_llm_client::HttpLlmProvider;
use std::sync::{Arc, RwLock};

/// Shared, mutable provider configuration. Written on dashboard save, read per call.
Expand Down Expand Up @@ -45,8 +45,17 @@ pub async fn load_registry(
) -> Result<ConfigRegistry, String> {
let mut llm = fallback_llm;
let mut embedder = fallback_embedder;
let fallback_emb_backend = embedder.backend;
for (provider, (base_url, api_key, model)) in db.provider_configs().await? {
let cfg = ProviderConfig {
// A DB row stores only the HTTP triple; keep the config-selected
// backend (e.g. local Candle) for the embedder so a config-driven
// local install survives DB rows. The LLM is always HTTP.
backend: if provider == "embedder" {
fallback_emb_backend
} else {
EmbedderBackend::Http
},
base_url,
api_key: Some(api_key),
model,
Expand Down Expand Up @@ -91,8 +100,8 @@ impl EmbedderConfigProvider {
Self { registry }
}

fn fresh(&self) -> HttpEmbedderProvider {
HttpEmbedderProvider::new(self.registry.embedder())
fn fresh(&self) -> Arc<dyn EmbedderProvider> {
memayu_llm_client::build_embedder(&self.registry.embedder())
}
}

Expand Down
3 changes: 2 additions & 1 deletion crates/memayu-api/tests/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ mod tests {
use async_trait::async_trait;
use axum::Router;
use memayu_api::{build_api_router, open_db, ConfigRegistry};
use memayu_config::{ProviderConfig, StorageBackend, StorageConfig};
use memayu_config::{EmbedderBackend, ProviderConfig, StorageBackend, StorageConfig};
use memayu_core::{
EmbedError, EmbedderProvider, ExtractionDecision, ExtractionResult, LlmError, LlmProvider,
MemoryPage, Message, MetadataFilter, StorageError, StorageProvider,
Expand Down Expand Up @@ -163,6 +163,7 @@ mod tests {

fn test_config() -> ProviderConfig {
ProviderConfig {
backend: EmbedderBackend::Http,
base_url: "http://localhost:11434".into(),
api_key: None,
model: "test-model".into(),
Expand Down
Loading
Loading