From b821298f50291b113d8bbff5125b200e325fe155 Mon Sep 17 00:00:00 2001 From: Nick <40026523+ParallelEntrepreneur@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:47:57 +0800 Subject: [PATCH] Offer mem0 as a place for shared memory to live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared memory gains a second provider beside `files`: approved notes are stored in a mem0 project through the Platform API (v3). Everything a colony can see or do is unchanged, which is the point of how it is built rather than a limitation of it. Review stays on the Mothership. Proposals queue locally as before, and mem0 only receives a note once a human approves it. Every note is written with `infer: false` and `immutable: true`: with inference on, mem0's extraction model rewrites what it stores and later consolidates it with other memories, so a colony could end up reading text no human reviewed — the one thing review exists to prevent. `add` also refuses to call it a success when mem0 answers without stored results, since that is what an inferred write looks like. Colonies never reach mem0 and never see the key. At boot the Mothership lists the colony's three scopes and writes them into its read-only session directory in exactly the layout `files` mounts, so `memory_search`, `memory_propose` and the prompt pointing at MEMORY.md work without a line changing in the guest. The index is ordered by mem0's relevance to the task — the issue title, instructions and body, not the full prompt, which is mostly boilerplate shared by every colony and would pull every ranking toward the same notes. A scope is a mem0 `user_id` (`colonizer:repo:/` and so on) and every memory carries `app_id: colonizer`. Listing filters on both, and delete first fetches the memory and checks both, because a mem0 project may hold other tools' memories and an id from the web UI proves neither. The key is stored like provider keys (config/memory-keys/mem0, 0600), falls back to MEM0_API_KEY, and is never written to modules.json nor returned by the API. Settings gets its own key row with Save, Remove and Check. Failure never costs a colony or a proposal. mem0 down at boot: the colony starts with an empty layout and a warning. An approval that cannot reach mem0 returns 502 and puts the proposal back. With review off, a note that cannot be stored is queued for review instead of dropped. Verified end to end on a separate instance against a local stand-in for the v3 API (no mem0 key was available): key saved through the UI and checked, three notes stored with infer false and immutable true, a real colony booted with "shared memory: 3 notes from mem0, most relevant to this task first", its agent read both indexes and found the mem0 note with memory_search, its proposal came back through review, an approval with mem0 stopped returned 502 and kept the proposal, and the same approval with mem0 back stored it with its provenance. 107 tests pass, 8 of them new; clippy's warning set is identical to main's. Self-hosted mem0 serves a different API and is out of scope, as is semantic search from inside a colony. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +- crates/colonizer/src/main.rs | 3 + crates/colonizer/src/mem0.rs | 457 ++++++++++++++++++++++++++ crates/colonizer/src/memory.rs | 253 +++++++++++++- crates/colonizer/src/modules.rs | 27 +- crates/colonizer/src/sessions.rs | 35 +- docs/architecture.md | 2 +- docs/protocol.md | 26 +- web/src/api.ts | 10 + web/src/components/MemoryView.tsx | 7 +- web/src/components/SettingsDialog.tsx | 98 ++++++ web/src/mock.ts | 24 +- web/src/types.ts | 15 + 13 files changed, 926 insertions(+), 37 deletions(-) create mode 100644 crates/colonizer/src/mem0.rs diff --git a/README.md b/README.md index fd145e0a..27f3feac 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ protocol on stdio, so an agent module can be written in anything. | `agent` | Claude Code, with subagents on any Anthropic-compatible provider (DeepSeek, a local model) | more agents behind the same protocol `PLANNED` | | `interfaces` | Chat with choice cards, terminal | dev-server previews `PLANNED` | | `publish` | GitHub pull request from the colony's own branch, opened automatically when the agent finishes (autopilot, on by default) | review-comment follow-ups `PLANNED` | -| `memory` | Shared notes per repository, org and globally; agents propose, you approve | semantic search `PLANNED` | +| `memory` | Shared notes per repository, org and globally; agents propose, you approve. Kept on the mothership, or in your [mem0](https://mem0.ai) project with each colony's index ordered by relevance to its task | semantic search inside a colony `PLANNED` | | `watchdog` | Nudges colonies that stop making progress, flags the ones that need you | automatic restarts `PLANNED` | Every GitHub org is a workspace with its own overrides for models, the parallel limit, memory and the @@ -231,7 +231,9 @@ Stated here rather than buried. real colonies and against a local `ds4-server` on the operator's tailnet, not against DeepSeek's hosted API, and Claude-specific request fields are forwarded as they are. The OpenAI translation (the `openai` wire) is exercised against real Claude Code and a stub gateway, not against OpenAI's hosted API. -- **Memory search is plain text matching**, not semantic search. +- **Memory search inside a colony is plain text matching.** With the mem0 provider, a colony's `MEMORY.md` + is ordered by mem0's relevance to the task, but `memory_search` still matches words in the notes it was + given. mem0's Platform API is supported; self-hosted mem0 serves a different API and is not. - **No CI yet**, and nothing is published to crates.io or npm. --- diff --git a/crates/colonizer/src/main.rs b/crates/colonizer/src/main.rs index 376683db..9755e5f3 100644 --- a/crates/colonizer/src/main.rs +++ b/crates/colonizer/src/main.rs @@ -13,6 +13,7 @@ mod findings; mod gateway; mod github; mod headroom; +mod mem0; mod memory; mod mesh; mod modules; @@ -390,6 +391,8 @@ async fn main() -> Result<()> { .route("/api/memory/proposals/{id}/reject", post(memory::reject)) .route("/api/memory/notes", post(memory::create_note)) .route("/api/memory/notes/{id}", delete(memory::delete_note)) + .route("/api/memory/mem0", get(memory::mem0_status).put(memory::put_mem0_key)) + .route("/api/memory/mem0/check", post(memory::check_mem0)) .route("/api/repos", get(github::list_repos)) .route("/api/repos/{owner}/{name}/issues", get(github::list_issues)) .route("/api/sessions", get(sessions::list).post(sessions::create)) diff --git a/crates/colonizer/src/mem0.rs b/crates/colonizer/src/mem0.rs new file mode 100644 index 00000000..04782580 --- /dev/null +++ b/crates/colonizer/src/mem0.rs @@ -0,0 +1,457 @@ +//! mem0 (https://mem0.ai) as the store behind shared memory, through its Platform API (v3). +//! +//! The `files` provider's guarantees carry over unchanged, and this module is shaped around keeping +//! them rather than around mem0's own model: +//! +//! - **Review stays on the Mothership.** Proposals queue locally exactly as before. mem0 only ever +//! receives a note a human approved, or one stored with review switched off. +//! - **Colonies never reach mem0 and never see the key.** At boot the Mothership writes a colony's +//! memories into the read-only layout `files` mounts, so `memory_search` and the prompt that +//! points the agent at `MEMORY.md` work without a line changing inside the colony. +//! +//! What mem0 adds is where the notes live — in your mem0 project, readable by anything else that +//! uses it — and an index that lists the notes most relevant to a colony's task first. +//! +//! Every note is written with `infer: false` and `immutable: true`. With inference on, mem0's +//! extraction model rewrites what it stores and later consolidates it with other memories, so a +//! colony could end up reading text no human approved: the one thing review exists to prevent. + +use crate::{ + memory::Note, + orgs::valid_org, + util::{truncate, valid_repo}, +}; +use anyhow::{bail, Context, Result}; +use chrono::{DateTime, Utc}; +use reqwest::{Method, StatusCode}; +use serde_json::{json, Value}; +use std::{collections::HashMap, time::Duration}; + +pub const DEFAULT_BASE_URL: &str = "https://api.mem0.ai"; +/// Every memory Colonizer writes carries this `app_id`. A mem0 project shared with other tools +/// keeps them apart, and nothing here can list or delete a memory it did not write. +pub const APP_ID: &str = "colonizer"; +/// Curated, reviewed notes stay small; a scope past this is listed up to the cap. +const MAX_PER_SCOPE: usize = 500; +const PAGE_SIZE: usize = 100; +/// A task description is a search query here, not a document, and mem0 does not need all of it. +const MAX_QUERY: usize = 2_000; + +pub struct Mem0 { + base_url: String, + key: String, + client: reqwest::Client, +} + +/// The mem0 `user_id` a Colonizer scope maps to. mem0 requires an entity id on every memory and +/// every query; a scope is the natural one, and filtering on it is what keeps one org's memory out +/// of another org's colony. +pub fn scope_id(scope: &str, key: &str) -> Result { + Ok(match scope { + "global" if key.is_empty() => "colonizer:global".to_string(), + "org" if valid_org(key) => format!("colonizer:org:{key}"), + "repo" if valid_repo(key) => format!("colonizer:repo:{key}"), + _ => bail!("invalid memory scope"), + }) +} + +/// A memory id as it may appear in a file name and in a URL path. mem0 ids are UUIDs; anything +/// else is refused rather than escaped. +pub fn safe_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +impl Mem0 { + pub fn new(base_url: &str, key: String) -> Result { + let base_url = base_url.trim().trim_end_matches('/'); + if !(base_url.starts_with("https://") || base_url.starts_with("http://")) { + bail!("the mem0 base URL must start with https://"); + } + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .user_agent(concat!("colonizer/", env!("CARGO_PKG_VERSION"))) + .build()?; + Ok(Self { base_url: base_url.to_string(), key, client }) + } + + async fn call(&self, method: Method, path: &str, body: Option) -> Result<(StatusCode, Value)> { + let mut request = self + .client + .request(method, format!("{}{path}", self.base_url)) + .header("Authorization", format!("Token {}", self.key)); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.context("mem0 is unreachable")?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + bail!("mem0 rejected the API key ({status})"); + } + let value = if text.trim().is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap_or(Value::String(text)) }; + Ok((status, value)) + } + + async fn ok(&self, method: Method, path: &str, body: Option) -> Result { + let (status, value) = self.call(method, path, body).await?; + if !status.is_success() { + bail!("mem0 returned {status}: {}", truncate(&value.to_string(), 300)); + } + Ok(value) + } + + /// Confirms the key and endpoint work, with the cheapest request that needs both. + pub async fn check(&self) -> Result<()> { + let body = json!({"filters": {"user_id": scope_id("global", "")?, "app_id": APP_ID}}); + self.ok(Method::POST, "/v3/memories/?page=1&page_size=1", Some(body)).await.map(|_| ()) + } + + /// Stores an approved note verbatim and returns mem0's id for it. + pub async fn add(&self, note: &Note) -> Result { + let body = json!({ + "messages": [{"role": "user", "content": format!("{}\n\n{}", note.title, note.content)}], + "user_id": scope_id(¬e.scope, ¬e.key)?, + "app_id": APP_ID, + "metadata": { + "colonizer_id": note.id, + "scope": note.scope, + "key": note.key, + "title": note.title, + "tags": note.tags, + "source": note.source, + "created_at": note.created_at, + }, + "infer": false, + "immutable": true, + }); + let response = self.ok(Method::POST, "/v3/memories/add/", Some(body)).await?; + // With `infer: false` mem0 stores synchronously and answers with what it stored. No + // results means the call went through the extraction pipeline instead, which would rewrite + // a reviewed note — so that is an error, not a success without an id. + response["results"] + .as_array() + .and_then(|results| results.first()) + .and_then(|stored| stored["id"].as_str()) + .map(str::to_string) + .context("mem0 did not store the note verbatim (no results for infer: false)") + } + + /// Every note in one scope, oldest first. + pub async fn list(&self, scope: &str, key: &str) -> Result> { + let body = json!({"filters": {"user_id": scope_id(scope, key)?, "app_id": APP_ID}}); + let mut notes = Vec::new(); + for page in 1.. { + let path = format!("/v3/memories/?page={page}&page_size={PAGE_SIZE}"); + let response = self.ok(Method::POST, &path, Some(body.clone())).await?; + let results = response["results"].as_array().cloned().unwrap_or_default(); + notes.extend(results.iter().filter_map(note_from).filter(|n| n.scope == scope && n.key == key)); + if results.is_empty() || response["next"].is_null() || notes.len() >= MAX_PER_SCOPE { + break; + } + } + notes.truncate(MAX_PER_SCOPE); + notes.sort_by_key(|n| n.created_at); + Ok(notes) + } + + /// mem0's relevance ranking for `query` across the given scopes: memory id → rank, 0 best. + pub async fn rank(&self, query: &str, scopes: &[(&str, &str)], top_k: usize) -> Result> { + let ids = scopes.iter().map(|(scope, key)| scope_id(scope, key)).collect::>>()?; + let body = json!({ + "query": truncate(query.trim(), MAX_QUERY), + "filters": {"user_id": {"in": ids}, "app_id": APP_ID}, + "top_k": top_k.max(1), + "threshold": 0.0, + }); + let response = self.ok(Method::POST, "/v3/memories/search/", Some(body)).await?; + Ok(response["results"] + .as_array() + .map(|results| { + results.iter().filter_map(|m| m["id"].as_str()).enumerate().map(|(rank, id)| (id.to_string(), rank)).collect() + }) + .unwrap_or_default()) + } + + /// Deletes a note, but only one Colonizer wrote into this scope. A mem0 project can hold other + /// tools' memories, and an id from the web UI is not proof of either. + pub async fn delete(&self, scope: &str, key: &str, id: &str) -> Result { + if !safe_id(id) { + return Ok(false); + } + let expected = scope_id(scope, key)?; + let (status, memory) = self.call(Method::GET, &format!("/v1/memories/{id}/"), None).await?; + if status == StatusCode::NOT_FOUND { + return Ok(false); + } + if !status.is_success() { + bail!("mem0 returned {status}: {}", truncate(&memory.to_string(), 300)); + } + if memory["user_id"].as_str() != Some(expected.as_str()) || memory["app_id"].as_str() != Some(APP_ID) { + return Ok(false); + } + self.ok(Method::DELETE, &format!("/v1/memories/{id}/"), None).await?; + Ok(true) + } +} + +/// A mem0 memory back as a note. Colonizer's own fields ride in `metadata`; the stored text is +/// `title\n\ncontent`, so the title is searchable, and it is split off again here. +fn note_from(memory: &Value) -> Option { + let id = memory["id"].as_str()?; + let meta = &memory["metadata"]; + let scope = meta["scope"].as_str()?; + let title = meta["title"].as_str().unwrap_or_default(); + let text = memory["memory"].as_str().unwrap_or_default(); + let content = text.strip_prefix(&format!("{title}\n\n")).unwrap_or(text); + let created_at = [&meta["created_at"], &memory["created_at"]] + .iter() + .find_map(|v| v.as_str().and_then(|s| DateTime::parse_from_rfc3339(s).ok())) + .map_or_else(Utc::now, |t| t.with_timezone(&Utc)); + Some(Note { + id: id.to_string(), + scope: scope.to_string(), + key: meta["key"].as_str().unwrap_or_default().to_string(), + title: title.to_string(), + content: content.to_string(), + tags: meta["tags"].as_array().map(|t| t.iter().filter_map(|t| t.as_str().map(String::from)).collect()).unwrap_or_default(), + created_at, + source: meta["source"].clone(), + }) +} + +#[cfg(test)] +pub mod mock { + //! A mem0 Platform stand-in for tests: the four v3/v1 endpoints Colonizer calls, with the + //! request checks that matter — the key, `infer: false`, `immutable: true`, and entity ids + //! inside `filters` rather than beside them, which the real API rejects with a 400. + + use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Json, Router, + }; + use serde_json::{json, Value}; + use std::sync::{Arc, Mutex}; + + pub const KEY: &str = "m0-test-key"; + + #[derive(Clone, Default)] + pub struct Mock { + pub memories: Arc>>, + pub adds: Arc>>, + } + + fn authorized(headers: &HeaderMap) -> bool { + headers.get("authorization").and_then(|v| v.to_str().ok()) == Some(&format!("Token {KEY}")) + } + + fn matches(memory: &Value, filters: &Value) -> bool { + let user = memory["user_id"].as_str().unwrap_or_default(); + let user_ok = match &filters["user_id"] { + Value::String(id) => id == user, + Value::Object(op) => op["in"].as_array().is_some_and(|ids| ids.iter().any(|id| id == user)), + _ => false, + }; + user_ok && memory["app_id"] == filters["app_id"] + } + + async fn add(State(mock): State, headers: HeaderMap, Json(body): Json) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({"detail": "Invalid API key"}))); + } + mock.adds.lock().unwrap().push(body.clone()); + let id = uuid::Uuid::new_v4().to_string(); + let memory = json!({ + "id": id, + "memory": body["messages"][0]["content"], + "user_id": body["user_id"], + "app_id": body["app_id"], + "metadata": body["metadata"], + "created_at": "2026-09-18T00:00:00Z", + }); + mock.memories.lock().unwrap().push(memory); + // The real API answers with results only when infer is false; mirror that. + let results = if body["infer"] == json!(false) { json!([{"id": id, "event": "ADD"}]) } else { Value::Null }; + (StatusCode::OK, Json(json!({"status": "ok", "results": results}))) + } + + async fn list(State(mock): State, headers: HeaderMap, Json(body): Json) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + if body.get("user_id").is_some() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "entity ids belong in filters"}))); + } + let results: Vec = mock.memories.lock().unwrap().iter().filter(|m| matches(m, &body["filters"])).cloned().collect(); + (StatusCode::OK, Json(json!({"count": results.len(), "next": null, "previous": null, "results": results}))) + } + + /// Ranks by how many query words a memory contains: crude, but deterministic. + async fn search(State(mock): State, headers: HeaderMap, Json(body): Json) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + let words: Vec = body["query"].as_str().unwrap_or_default().to_lowercase().split_whitespace().map(String::from).collect(); + let mut scored: Vec<(usize, Value)> = mock + .memories + .lock() + .unwrap() + .iter() + .filter(|m| matches(m, &body["filters"])) + .map(|m| { + let text = m["memory"].as_str().unwrap_or_default().to_lowercase(); + (words.iter().filter(|w| text.contains(w.as_str())).count(), m.clone()) + }) + .collect(); + scored.sort_by_key(|s| std::cmp::Reverse(s.0)); + let top_k = body["top_k"].as_u64().unwrap_or(10) as usize; + let results: Vec = scored.into_iter().take(top_k).map(|(_, m)| m).collect(); + (StatusCode::OK, Json(json!({"results": results}))) + } + + async fn get_one(State(mock): State, headers: HeaderMap, Path(id): Path) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + match mock.memories.lock().unwrap().iter().find(|m| m["id"] == id.as_str()) { + Some(memory) => (StatusCode::OK, Json(memory.clone())), + None => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))), + } + } + + async fn delete_one(State(mock): State, headers: HeaderMap, Path(id): Path) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + mock.memories.lock().unwrap().retain(|m| m["id"] != id.as_str()); + (StatusCode::OK, Json(json!({"message": "Memory deleted successfully!"}))) + } + + /// Serves the mock on a loopback port and returns its base URL. + pub async fn serve(mock: Mock) -> String { + let router = Router::new() + .route("/v3/memories/add/", post(add)) + .route("/v3/memories/", post(list)) + .route("/v3/memories/search/", post(search)) + .route("/v1/memories/{id}/", get(get_one).delete(delete_one)) + .with_state(mock); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{addr}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::draft; + + async fn client() -> (Mem0, mock::Mock) { + let mock = mock::Mock::default(); + let base = mock::serve(mock.clone()).await; + (Mem0::new(&base, mock::KEY.into()).unwrap(), mock) + } + + #[test] + fn scopes_map_to_entity_ids_and_bad_ones_are_refused() { + assert_eq!(scope_id("global", "").unwrap(), "colonizer:global"); + assert_eq!(scope_id("org", "Colonizer-dev").unwrap(), "colonizer:org:Colonizer-dev"); + assert_eq!(scope_id("repo", "Colonizer-dev/harness").unwrap(), "colonizer:repo:Colonizer-dev/harness"); + assert!(scope_id("global", "x").is_err()); + assert!(scope_id("org", "../x").is_err()); + assert!(scope_id("repo", "owner").is_err()); + assert!(safe_id("0b1c3e7a-9f2d-4c1a-8e5b-7d6f5a4b3c2d")); + assert!(!safe_id("../../v1/memories")); + assert!(!safe_id("")); + } + + #[tokio::test] + async fn notes_are_stored_verbatim_immutable_and_come_back_intact() { + let (mem0, mock) = client().await; + let note = draft( + "repo", + "Colonizer-dev/harness", + "Run tests locked", + "Use `cargo test --locked`.\n\nA second paragraph survives too.", + &["tests".into()], + json!({"session_id": "abc"}), + ) + .unwrap(); + let id = mem0.add(¬e).await.unwrap(); + + let sent = mock.adds.lock().unwrap()[0].clone(); + assert_eq!(sent["infer"], json!(false), "inference would rewrite a reviewed note"); + assert_eq!(sent["immutable"], json!(true), "consolidation would rewrite it later"); + assert_eq!(sent["app_id"], APP_ID); + assert_eq!(sent["user_id"], "colonizer:repo:Colonizer-dev/harness"); + + let notes = mem0.list("repo", "Colonizer-dev/harness").await.unwrap(); + assert_eq!(notes.len(), 1); + let back = ¬es[0]; + assert_eq!(back.id, id); + assert_eq!(back.title, note.title); + assert_eq!(back.content, note.content); + assert_eq!(back.tags, note.tags); + assert_eq!(back.source, note.source); + assert_eq!(back.created_at, note.created_at); + } + + #[tokio::test] + async fn a_scope_lists_only_its_own_notes_and_never_another_apps() { + let (mem0, mock) = client().await; + for (scope, key, title) in [("repo", "o/r", "repo note"), ("org", "o", "org note"), ("repo", "o/other", "other repo")] { + mem0.add(&draft(scope, key, title, "content", &[], Value::Null).unwrap()).await.unwrap(); + } + // Another tool's memory in the same mem0 project, under the very same user_id. + mock.memories.lock().unwrap().push(json!({ + "id": "foreign", "memory": "not ours", "user_id": "colonizer:repo:o/r", "app_id": "someone-else", + "metadata": {"scope": "repo", "key": "o/r", "title": "foreign"}, + })); + let titles: Vec = mem0.list("repo", "o/r").await.unwrap().into_iter().map(|n| n.title).collect(); + assert_eq!(titles, vec!["repo note"]); + } + + #[tokio::test] + async fn delete_refuses_a_memory_colonizer_did_not_write_in_that_scope() { + let (mem0, mock) = client().await; + let ours = mem0.add(&draft("repo", "o/r", "ours", "c", &[], Value::Null).unwrap()).await.unwrap(); + mock.memories.lock().unwrap().push(json!({"id": "11111111-2222-3333-4444-555555555555", "user_id": "colonizer:repo:o/r", "app_id": "someone-else"})); + + assert!(!mem0.delete("repo", "o/r", "11111111-2222-3333-4444-555555555555").await.unwrap(), "another app's memory"); + assert!(!mem0.delete("org", "o", &ours).await.unwrap(), "right memory, wrong scope"); + assert!(!mem0.delete("repo", "o/r", "../../v1/memories").await.unwrap(), "not an id"); + assert_eq!(mock.memories.lock().unwrap().len(), 2, "nothing was deleted yet"); + + assert!(mem0.delete("repo", "o/r", &ours).await.unwrap()); + assert_eq!(mock.memories.lock().unwrap().len(), 1); + assert!(!mem0.delete("repo", "o/r", &ours).await.unwrap(), "already gone"); + } + + #[tokio::test] + async fn ranking_follows_the_query() { + let (mem0, _mock) = client().await; + let deploy = mem0.add(&draft("repo", "o/r", "Deploys", "deploy with the staging workflow first", &[], Value::Null).unwrap()).await.unwrap(); + let style = mem0.add(&draft("org", "o", "Style", "prefer small commits", &[], Value::Null).unwrap()).await.unwrap(); + let ranks = mem0.rank("how do I deploy to staging", &[("global", ""), ("org", "o"), ("repo", "o/r")], 10).await.unwrap(); + assert!(ranks[&deploy] < ranks[&style]); + } + + #[tokio::test] + async fn a_wrong_key_says_so() { + let mock = mock::Mock::default(); + let base = mock::serve(mock).await; + let mem0 = Mem0::new(&base, "wrong".into()).unwrap(); + let error = format!("{:#}", mem0.check().await.unwrap_err()); + assert!(error.contains("rejected the API key"), "{error}"); + assert!(!error.contains("wrong"), "the key must never appear in an error: {error}"); + } + + #[test] + fn the_base_url_must_be_http() { + assert!(Mem0::new("api.mem0.ai", "k".into()).is_err()); + assert!(Mem0::new("https://api.mem0.ai/", "k".into()).is_ok()); + } +} diff --git a/crates/colonizer/src/memory.rs b/crates/colonizer/src/memory.rs index 4a7ffc6c..ef04a983 100644 --- a/crates/colonizer/src/memory.rs +++ b/crates/colonizer/src/memory.rs @@ -4,12 +4,19 @@ //! Colonies never write memory directly: a proposal arrives as an agent event over the existing //! colony link, and only an approved proposal becomes a note other colonies can read. That review //! step is what keeps one colony from injecting instructions into every future colony. +//! +//! Approved notes live in one of two places, picked as the memory module's provider: `files`, this +//! file's own store, or `mem0` (see `mem0.rs`). Proposals stay here either way, and a colony reads +//! the same layout either way. use crate::{ client_error, + config::setting_str, + mem0::{self, Mem0}, + modules::schema_for, orgs::valid_org, - util::{short_id, truncate, valid_repo}, - ApiResult, Shared, + util::{env_nonempty, read_trimmed, short_id, truncate, valid_repo, write_secret}, + ApiResult, App, Shared, }; use anyhow::{bail, Context, Result}; use axum::{ @@ -105,7 +112,7 @@ impl MemoryStore { let dir = self.scope_dir(scope, key)?; std::fs::create_dir_all(dir.join("notes"))?; if !dir.join("MEMORY.md").exists() { - write_index(&dir, scope, key, &[])?; + write_index(&dir, scope, key, &[], false)?; } Ok(dir) } @@ -118,7 +125,7 @@ impl MemoryStore { let tmp = dir.join("notes.json.tmp"); std::fs::write(&tmp, serde_json::to_vec_pretty(notes)?)?; std::fs::rename(&tmp, dir.join("notes.json"))?; - write_index(dir, scope, key, notes) + write_index(dir, scope, key, notes, false) } pub async fn notes(&self, scope: &str, key: &str) -> Result> { @@ -198,15 +205,16 @@ impl MemoryStore { } } -/// `MEMORY.md`: the index agents read first. -fn write_index(dir: &FsPath, scope: &str, key: &str, notes: &[Note]) -> Result<()> { +/// `MEMORY.md`: the index agents read first. `ranked` says the notes arrive most relevant first. +fn write_index(dir: &FsPath, scope: &str, key: &str, notes: &[Note], ranked: bool) -> Result<()> { let label = match scope { "global" => "every colony".to_string(), "org" => format!("colonies in the {key} org"), _ => format!("colonies on {key}"), }; + let order = if ranked { " They are listed most relevant to this colony's task first." } else { "" }; let mut index = format!( - "# Shared memory for {label}\n\nApproved notes from earlier colonies and the maintainer. Read the ones that matter for your task.\n\n" + "# Shared memory for {label}\n\nApproved notes from earlier colonies and the maintainer. Read the ones that matter for your task.{order}\n\n" ); if notes.is_empty() { index.push_str("No notes yet.\n"); @@ -220,6 +228,130 @@ fn write_index(dir: &FsPath, scope: &str, key: &str, notes: &[Note]) -> Result<( Ok(()) } +// --------------------------------------------------------------------------- +// Where approved notes live +// --------------------------------------------------------------------------- + +pub const MEM0: &str = "mem0"; + +fn mem0_key_file(app: &App) -> PathBuf { + app.cfg.config_dir.join("memory-keys").join(MEM0) +} + +/// The saved mem0 key, else `MEM0_API_KEY`. Lives beside the model provider keys and, like them, +/// is never written to modules.json, never returned by the API and never sent into a colony. +fn mem0_key(app: &App) -> Option<(String, &'static str)> { + read_trimmed(&mem0_key_file(app)) + .map(|key| (key, "saved")) + .or_else(|| env_nonempty("MEM0_API_KEY").map(|key| (key, "MEM0_API_KEY"))) +} + +pub async fn uses_mem0(app: &App) -> bool { + app.modules.read().await.memory.provider == MEM0 +} + +async fn mem0_client(app: &App) -> Result { + let base_url = { + let modules = app.modules.read().await; + setting_str(&modules.memory, &schema_for("memory", MEM0, &app.agents), "base_url") + }; + let (key, _) = mem0_key(app).context("add a mem0 API key in Settings → Modules → Memory")?; + Mem0::new(if base_url.is_empty() { mem0::DEFAULT_BASE_URL } else { &base_url }, key) +} + +pub async fn list_notes(app: &App, scope: &str, key: &str) -> Result> { + if uses_mem0(app).await { + mem0::scope_id(scope, key)?; + return mem0_client(app).await?.list(scope, key).await; + } + app.memory.notes(scope, key).await +} + +/// Stores an approved note in whichever place the memory module points at. +pub async fn store_note(app: &App, mut note: Note) -> Result { + if uses_mem0(app).await { + note.id = mem0_client(app).await?.add(¬e).await?; + return Ok(note); + } + app.memory.add_note(note).await +} + +pub async fn remove_note(app: &App, scope: &str, key: &str, id: &str) -> Result { + if uses_mem0(app).await { + return mem0_client(app).await?.delete(scope, key, id).await; + } + app.memory.delete_note(scope, key, id).await +} + +/// What a colony is for, as a relevance query: the issue and the instructions, never the prompt +/// around them. That prompt is mostly harness boilerplate identical for every colony, which would +/// pull every ranking toward the same notes, and it puts the task far enough in that a long preamble +/// could push it past the query length mem0 is sent. +pub fn task_query(title: &str, issue: Option<&Value>, instructions: &str) -> String { + let text = |v: &Value| v.as_str().unwrap_or_default().trim().to_string(); + let issue_title = issue.map(|i| text(&i["title"])).unwrap_or_default(); + let issue_body = issue.map(|i| text(&i["body"])).unwrap_or_default(); + let title = if issue_title.is_empty() { title.trim().to_string() } else { issue_title }; + [title, instructions.trim().to_string(), issue_body].into_iter().filter(|part| !part.is_empty()).collect::>().join("\n\n") +} + +pub struct Materialized { + pub notes: usize, + pub ranked: bool, +} + +/// Writes a colony's shared memory from mem0 into `root/{global,org,repo}`, in exactly the layout +/// the `files` provider mounts: `MEMORY.md` plus `notes/.md`. `root` is inside the colony's +/// read-only session directory, so this is the whole of what the colony sees — mem0 itself stays +/// on this side, with the key. +/// +/// Relevance to `task` orders each index. That ranking is a nicety, so a failed search keeps the +/// oldest-first order rather than costing the colony its memory. +pub async fn materialize_mem0(app: &App, root: &FsPath, org: &str, repo: &str, task: &str) -> Result { + materialize(&mem0_client(app).await?, root, org, repo, task).await +} + +async fn materialize(client: &Mem0, root: &FsPath, org: &str, repo: &str, task: &str) -> Result { + let scopes = [("global", ""), ("org", org), ("repo", repo)]; + let mut listed = Vec::new(); + for (scope, key) in scopes { + listed.push(client.list(scope, key).await?); + } + let total: usize = listed.iter().map(Vec::len).sum(); + let ranks = if total == 0 || task.trim().is_empty() { None } else { client.rank(task, &scopes, total.min(100)).await.ok() }; + for ((scope, key), mut notes) in scopes.into_iter().zip(listed) { + if let Some(ranks) = &ranks { + // Stable: notes mem0 did not rank keep their oldest-first order after the ranked ones. + notes.sort_by_key(|n| ranks.get(&n.id).copied().unwrap_or(usize::MAX)); + } + write_scope(&root.join(scope), scope, key, ¬es, ranks.is_some())?; + } + Ok(Materialized { notes: total, ranked: ranks.is_some() }) +} + +/// Replaces one scope directory with `notes`. A note whose id could not be a file name is left out +/// rather than written somewhere unexpected. +fn write_scope(dir: &FsPath, scope: &str, key: &str, notes: &[Note], ranked: bool) -> Result<()> { + if dir.exists() { + std::fs::remove_dir_all(dir)?; + } + std::fs::create_dir_all(dir.join("notes"))?; + let notes: Vec = notes.iter().filter(|n| mem0::safe_id(&n.id)).cloned().collect(); + for note in ¬es { + std::fs::write(dir.join("notes").join(format!("{}.md", note.id)), format!("# {}\n\n{}\n", note.title, note.content))?; + } + write_index(dir, scope, key, ¬es, ranked) +} + +/// An empty, valid layout, for a colony whose memory could not be fetched: the prompt tells the +/// agent to read each `MEMORY.md`, and a missing file would read as a broken colony. +pub fn write_empty_scopes(root: &FsPath, org: &str, repo: &str) -> Result<()> { + for (scope, key) in [("global", ""), ("org", org), ("repo", repo)] { + write_scope(&root.join(scope), scope, key, &[], false)?; + } + Ok(()) +} + // --------------------------------------------------------------------------- // HTTP handlers // --------------------------------------------------------------------------- @@ -231,8 +363,15 @@ pub struct ScopeQuery { key: String, } +/// A note store that could not answer: a bad scope is the caller's fault, anything else (mem0 down, +/// a rejected key) is upstream's. +fn store_error(scope: &str, key: &str, e: &anyhow::Error) -> crate::AppError { + let status = if mem0::scope_id(scope, key).is_err() { StatusCode::BAD_REQUEST } else { StatusCode::BAD_GATEWAY }; + client_error(status, &format!("{e:#}")) +} + pub async fn get(State(app): State, Query(query): Query) -> ApiResult { - let notes = app.memory.notes(&query.scope, &query.key).await.map_err(|e| client_error(StatusCode::BAD_REQUEST, &format!("{e:#}")))?; + let notes = list_notes(&app, &query.scope, &query.key).await.map_err(|e| store_error(&query.scope, &query.key, &e))?; let proposals: Vec = app .memory .proposals() @@ -240,7 +379,8 @@ pub async fn get(State(app): State, Query(query): Query) -> .into_iter() .filter(|p| p.note.scope == query.scope && p.note.key == query.key) .collect(); - Ok(Json(json!({"scope": query.scope, "key": query.key, "notes": notes, "proposals": proposals}))) + let provider = app.modules.read().await.memory.provider.clone(); + Ok(Json(json!({"scope": query.scope, "key": query.key, "provider": provider, "notes": notes, "proposals": proposals}))) } pub async fn list_proposals(State(app): State) -> Json> { @@ -280,7 +420,14 @@ pub async fn approve(State(app): State, Path(id): Path, body: By return Err(client_error(StatusCode::BAD_REQUEST, &format!("{e:#}"))); } }; - Ok(Json(app.memory.add_note(note).await?)) + match store_note(&app, note).await { + Ok(stored) => Ok(Json(stored)), + Err(e) => { + // Same reason: mem0 being down or refusing the key must not cost a reviewed proposal. + let _ = app.memory.add_proposal(original).await; + Err(client_error(StatusCode::BAD_GATEWAY, &format!("{e:#}"))) + } + } } pub async fn reject(State(app): State, Path(id): Path) -> ApiResult { @@ -302,7 +449,7 @@ pub struct NewNote { pub async fn create_note(State(app): State, Json(req): Json) -> ApiResult { let note = draft(&req.scope, &req.key, &req.title, &req.content, &req.tags, json!({"user": true})) .map_err(|e| client_error(StatusCode::BAD_REQUEST, &format!("{e:#}")))?; - Ok(Json(app.memory.add_note(note).await?)) + Ok(Json(store_note(&app, note).await.map_err(|e| client_error(StatusCode::BAD_GATEWAY, &format!("{e:#}")))?)) } pub async fn delete_note( @@ -310,17 +457,50 @@ pub async fn delete_note( Path(id): Path, Query(query): Query, ) -> ApiResult { - let removed = app - .memory - .delete_note(&query.scope, &query.key, &id) - .await - .map_err(|e| client_error(StatusCode::BAD_REQUEST, &format!("{e:#}")))?; + let removed = remove_note(&app, &query.scope, &query.key, &id).await.map_err(|e| store_error(&query.scope, &query.key, &e))?; if !removed { return Err(client_error(StatusCode::NOT_FOUND, "no such note")); } Ok(Json(json!({"ok": true}))) } +/// Whether a mem0 key is set, and where from. Never the key. +pub async fn mem0_status(State(app): State) -> Json { + let source = mem0_key(&app).map(|(_, source)| source); + Json(json!({"has_key": source.is_some(), "source": source, "active": uses_mem0(&app).await})) +} + +#[derive(Deserialize)] +pub struct Mem0Key { + api_key: String, +} + +/// Saves the mem0 key on this machine, or removes it when empty. +pub async fn put_mem0_key(State(app): State, Json(req): Json) -> ApiResult { + let key = req.api_key.trim(); + let path = mem0_key_file(&app); + if key.is_empty() { + let _ = std::fs::remove_file(&path); + } else if key.len() > 512 || !key.chars().all(|c| c.is_ascii_graphic()) { + return Err(client_error(StatusCode::BAD_REQUEST, "that doesn't look like a mem0 API key")); + } else { + write_secret(&path, key)?; + } + Ok(mem0_status(State(app)).await) +} + +/// Tries the saved key against the configured endpoint. +pub async fn check_mem0(State(app): State) -> Json { + let result = match mem0_client(&app).await { + Ok(client) => client.check().await, + Err(e) => Err(e), + }; + Json(match result { + Ok(()) => json!({"ok": true}), + Err(e) => json!({"ok": false, "error": format!("{e:#}")}), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -355,6 +535,47 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[tokio::test] + async fn a_colony_gets_mem0_notes_in_the_files_layout_most_relevant_first() { + let mock = crate::mem0::mock::Mock::default(); + let base = crate::mem0::mock::serve(mock.clone()).await; + let client = Mem0::new(&base, crate::mem0::mock::KEY.into()).unwrap(); + let older = client.add(&draft("repo", "o/r", "Commit style", "keep commits small", &[], Value::Null).unwrap()).await.unwrap(); + let newer = client.add(&draft("repo", "o/r", "Deploys", "deploy to staging before production", &[], Value::Null).unwrap()).await.unwrap(); + client.add(&draft("org", "o", "Org rule", "sign every commit", &[], Value::Null).unwrap()).await.unwrap(); + client.add(&draft("repo", "o/elsewhere", "Not this repo", "deploy something else entirely", &[], Value::Null).unwrap()).await.unwrap(); + + let root = temp_root(); + let result = materialize(&client, &root, "o", "o/r", "Fix the staging deploy").await.unwrap(); + assert_eq!(result.notes, 3, "global, this org and this repo; not another repo"); + assert!(result.ranked); + + let index = std::fs::read_to_string(root.join("repo/MEMORY.md")).unwrap(); + assert!(index.contains("most relevant to this colony's task first")); + let deploys = index.find(&format!("(notes/{newer}.md)")).unwrap(); + let commits = index.find(&format!("(notes/{older}.md)")).unwrap(); + assert!(deploys < commits, "the deploy note is the relevant one:\n{index}"); + assert_eq!(std::fs::read_to_string(root.join(format!("repo/notes/{newer}.md"))).unwrap(), "# Deploys\n\ndeploy to staging before production\n"); + assert!(std::fs::read_to_string(root.join("org/MEMORY.md")).unwrap().contains("Org rule")); + assert!(std::fs::read_to_string(root.join("global/MEMORY.md")).unwrap().contains("No notes yet.")); + + // A resume rewrites the scope rather than leaving a deleted note behind. + assert!(client.delete("repo", "o/r", &older).await.unwrap()); + materialize(&client, &root, "o", "o/r", "Fix the staging deploy").await.unwrap(); + assert!(!root.join(format!("repo/notes/{older}.md")).exists()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn the_relevance_query_is_the_task_not_the_prompt() { + let issue = json!({"title": "Fix the staging deploy", "body": "It fails on the migrate step."}); + let query = task_query("ignored when there is an issue", Some(&issue), "Keep the diff small."); + assert_eq!(query, "Fix the staging deploy\n\nKeep the diff small.\n\nIt fails on the migrate step."); + // Instructions come before the body: a long issue body is what gets cut, not what was asked. + assert_eq!(task_query("Tidy the README", None, ""), "Tidy the README"); + assert_eq!(task_query("", None, ""), ""); + } + #[test] fn scopes_and_sizes_are_validated() { assert!(draft("global", "", "t", "c", &[], Value::Null).is_ok()); diff --git a/crates/colonizer/src/modules.rs b/crates/colonizer/src/modules.rs index a3a510ff..43d71b20 100644 --- a/crates/colonizer/src/modules.rs +++ b/crates/colonizer/src/modules.rs @@ -146,14 +146,25 @@ pub fn providers(kind: &str, agents: &[AgentModule]) -> Vec { "file_findings": {"type": "boolean", "title": "File validated findings as issues", "description": "When a colony notices a problem outside its task, its orchestrator has it confirmed and files it as an issue on the same repository, labelled colonizer-finding. Open issues with the same title are not filed again, and one colony files at most five.", "default": true} }}), )], - "memory" => vec![p( - "files", - "Shared memory", - "Markdown notes per repository, org and globally, mounted read-only into colonies; agents propose new notes", - json!({"type": "object", "properties": { - "require_review": {"type": "boolean", "title": "Review proposals before they become memory", "description": "Recommended: an approved note becomes part of every future colony's context", "default": true} - }}), - )], + "memory" => vec![ + p( + "files", + "Shared memory", + "Markdown notes per repository, org and globally, mounted read-only into colonies; agents propose new notes", + json!({"type": "object", "properties": { + "require_review": {"type": "boolean", "title": "Review proposals before they become memory", "description": "Recommended: an approved note becomes part of every future colony's context", "default": true} + }}), + ), + p( + "mem0", + "mem0", + "Approved notes stored in your mem0 project. Colonies read them exactly as they read files, most relevant to the task first; the key never enters a colony", + json!({"type": "object", "properties": { + "require_review": {"type": "boolean", "title": "Review proposals before they become memory", "description": "Recommended: an approved note becomes part of every future colony's context", "default": true}, + "base_url": {"type": "string", "title": "API base URL", "description": "The mem0 Platform API. Self-hosted mem0 serves a different API and is not supported", "default": "https://api.mem0.ai"} + }}), + ), + ], "watchdog" => vec![p( "default", "Watchdog", diff --git a/crates/colonizer/src/sessions.rs b/crates/colonizer/src/sessions.rs index 2ee1a49a..4a0e4fa1 100644 --- a/crates/colonizer/src/sessions.rs +++ b/crates/colonizer/src/sessions.rs @@ -653,7 +653,22 @@ async fn boot_inner(app: &Shared, id: &str, resume: bool) -> Result<()> { std::fs::write(vm_dir.join("session.json"), serde_json::to_vec_pretty(&session_json)?)?; std::fs::write(vm_dir.join("boot.sh"), BOOT_SCRIPT)?; - if memory_on { + if memory_on && memory::uses_mem0(app).await { + // mem0's notes are written into the session directory, which is already the colony's + // read-only /colonizer, so there is nothing to mount and nothing of mem0's inside. + let root = vm_dir.join("memory"); + let task = memory::task_query(&s.issue_title, issue.as_ref(), &s.instructions); + match memory::materialize_mem0(app, &root, &s.org, &s.repo, &task).await { + Ok(m) => { + let order = if m.ranked { ", most relevant to this task first" } else { "" }; + app.session_log(id, "info", format!("shared memory: {} notes from mem0{order}", m.notes)).await; + } + Err(e) => { + app.session_log(id, "warn", format!("shared memory from mem0 is unavailable ({e:#}); this colony starts without it")).await; + memory::write_empty_scopes(&root, &s.org, &s.repo)?; + } + } + } else if memory_on { for (scope, key) in [("global", String::new()), ("org", s.org.clone()), ("repo", s.repo.clone())] { // Mount points must exist inside the read-only /colonizer mount. std::fs::create_dir_all(vm_dir.join("memory").join(scope))?; @@ -1027,11 +1042,19 @@ async fn memory_proposal(app: &Shared, id: &str, event: &Value) { let stored = if orgs::memory_requires_review(&modules) { app.memory.add_proposal(note).await.map(|proposal| json!(proposal)) } else { - app.memory.add_note(note).await.map(|note| { - let mut value = json!(note); - value["status"] = json!("approved"); - value - }) + match memory::store_note(app, note.clone()).await { + Ok(note) => { + let mut value = json!(note); + value["status"] = json!("approved"); + Ok(value) + } + // With review off there is no queue to fall back on, so make one: a store that is down + // (mem0 unreachable, a rejected key) must not cost the colony its proposal. + Err(e) => { + app.session_log(id, "warn", format!("could not store the note ({e:#}); queued it for review instead")).await; + app.memory.add_proposal(note).await.map(|proposal| json!(proposal)) + } + } }; match stored { Ok(proposal) => { diff --git a/docs/architecture.md b/docs/architecture.md index eaa880af..9c49c445 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ editable in Settings → Modules). A module kind has one active provider: | `agent` | `claude-code` | Runner that speaks the Colonizer agent protocol inside the VM | | `interfaces` | `default` | Panels in the session view; `chat` and `terminal` are its settings | | `publish` | `github-pr` | Commit on the host, push, open the pull request | -| `memory` | `files` | Shared notes per repository, org and globally; agents propose, the user approves | +| `memory` | `files`, `mem0` | Shared notes per repository, org and globally; agents propose, the user approves. `mem0` stores approved notes in a mem0 project and writes each colony's copy at boot | | `watchdog` | `default` | Nudges colonies that stop making progress and flags the ones that need the user | Two settings layers sit next to the modules: diff --git a/docs/protocol.md b/docs/protocol.md index 3f6bcdba..470e0fbf 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -569,6 +569,25 @@ The mothership records it as a pending proposal and broadcasts `{"type":"memory_ (no `seq`) on the colony's event stream. Approved proposals become notes and appear in every colony's mount immediately. +**Where approved notes live** is the memory module's provider. `files` keeps them on the mothership and +mounts each scope directory. `mem0` keeps them in a [mem0](https://mem0.ai) project through its Platform +API (v3), and the runner side is identical: + +- Proposals queue on the mothership either way. mem0 only receives a note once it is approved (or stored + with review off), written with `infer: false` and `immutable: true` so mem0's extraction model never + rewrites or later consolidates text a human reviewed. +- Each scope is a mem0 `user_id` — `colonizer:global`, `colonizer:org:`, `colonizer:repo:/` + — and every memory carries `app_id: "colonizer"`. Colonizer's own fields (`colonizer_id`, `scope`, `key`, + `title`, `tags`, `source`, `created_at`) ride in `metadata`. Listing and deleting are filtered on both, so a + mem0 project shared with other tools is safe to point at. +- At boot the mothership lists the colony's three scopes from mem0 and writes them into the colony's session + directory in the layout above. The colony never talks to mem0 and never sees the key, and a resume + rewrites the layout rather than keeping deleted notes. `MEMORY.md` is ordered by mem0's relevance to the + task (the issue title, the instructions, then the issue body — not the full prompt). +- If mem0 cannot be reached at boot, the colony still starts, with an empty layout and a `warn` in its log. + An approval that cannot reach mem0 fails with `502` and the proposal stays in the queue; with review off, a + note that cannot be stored is queued for review instead of dropped. + ### 6.3 Mothership API additions **Skillsets** (plugin directories a colony can load; see "Plugin directories"): @@ -619,12 +638,15 @@ global switch. Names are plain directory names, at most 64. An empty map is stor | Method & path | Purpose | | --- | --- | -| `GET /api/memory?scope=&key=` | `{scope, key, notes: [Note], proposals: [Proposal]}` | +| `GET /api/memory?scope=&key=` | `{scope, key, provider, notes: [Note], proposals: [Proposal]}`; `provider` is `files` or `mem0` | | `GET /api/memory/proposals` | Every pending proposal, newest first | | `POST /api/memory/proposals/{id}/approve` | Optional `{title, content}` edits; creates the note | | `POST /api/memory/proposals/{id}/reject` | Discard | | `POST /api/memory/notes` | `{scope, key, title, content}`: a note written by you | -| `DELETE /api/memory/notes/{id}?scope=&key=` | Remove a note | +| `DELETE /api/memory/notes/{id}?scope=&key=` | Remove a note. With mem0, only one Colonizer wrote into that scope | +| `GET /api/memory/mem0` | `{has_key, source, active}`: whether a key is set (`saved` or `MEM0_API_KEY`) and mem0 is the provider. Never the key | +| `PUT /api/memory/mem0` | `{api_key}`: save the key on the mothership (`config/memory-keys/mem0`, mode 0600); an empty string removes it | +| `POST /api/memory/mem0/check` | `{ok, error?}`: try the key against the configured base URL | `Note` = `{id, scope, key, title, content, tags, created_at, source}`; `Proposal` adds `status` (`pending`). `source` = `{session_id, repo}` or `{user: true}`. diff --git a/web/src/api.ts b/web/src/api.ts index d3782f1a..e3569140 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -3,6 +3,8 @@ import type { HarnessStatus, Issue, LoginView, + Mem0Check, + Mem0Status, MemoryListing, MemoryNote, MemoryProposal, @@ -92,6 +94,11 @@ export interface Api { rejectProposal(id: string): Promise; createNote(body: NewNoteRequest): Promise; deleteNote(note: Pick): Promise; + mem0Status(): Promise; + /** Saves the key on the Mothership; an empty string removes it. */ + saveMem0Key(apiKey: string): Promise; + /** Tries the saved key against the configured endpoint. */ + checkMem0(): Promise; openEvents(sessionId: string, since: number): SocketLike; openTerminal(sessionId: string, cols: number, rows: number): SocketLike; } @@ -178,6 +185,9 @@ export const httpApi: Api = { rejectProposal: (id) => post(`/api/memory/proposals/${enc(id)}/reject`), createNote: (body) => post("/api/memory/notes", body), deleteNote: ({ id, scope, key }) => del(`/api/memory/notes/${enc(id)}?scope=${enc(scope)}&key=${enc(key)}`), + mem0Status: () => request("/api/memory/mem0"), + saveMem0Key: (apiKey) => put("/api/memory/mem0", { api_key: apiKey }), + checkMem0: () => post("/api/memory/mem0/check"), openEvents: (id, since) => new WebSocket(wsUrl(`/api/sessions/${enc(id)}/events?since=${since}`)), openTerminal: (id, cols, rows) => new WebSocket(wsUrl(`/api/sessions/${enc(id)}/terminal?cols=${cols}&rows=${rows}`)), diff --git a/web/src/components/MemoryView.tsx b/web/src/components/MemoryView.tsx index 609646ec..000e2ee4 100644 --- a/web/src/components/MemoryView.tsx +++ b/web/src/components/MemoryView.tsx @@ -278,6 +278,7 @@ function NotesSection({ selectedOrg, orgs, version }: { selectedOrg: string | nu const [org, setOrg] = useState(selectedOrg ?? ""); const [repo, setRepo] = useState(""); const [notes, setNotes] = useState(null); + const [provider, setProvider] = useState("files"); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); @@ -317,6 +318,7 @@ function NotesSection({ selectedOrg, orgs, version }: { selectedOrg: string | nu try { const listing = await api.memory(scope, key); setNotes(listing.notes); + setProvider(listing.provider ?? "files"); setError(null); } catch (e) { setError(errorMessage(e)); @@ -413,7 +415,10 @@ function NotesSection({ selectedOrg, orgs, version }: { selectedOrg: string | nu )} -

{description}

+

+ {description} + {provider === "mem0" && " Stored in your mem0 project."} +

{adding && ready && ( } + {fields.length === 0 && module.providers.length <= 1 &&

Nothing to configure.

} ); } +/** + * The mem0 key has its own row and its own save because it is not a module setting: settings go + * to modules.json and come back from the API, and a key must do neither. Shown as soon as mem0 is + * picked, so the key can be in place before the switch is saved. + */ +function Mem0KeyRow() { + const api = useApi(); + const toast = useToast(); + const id = useId(); + const [status, setStatus] = useState(null); + const [key, setKey] = useState(""); + const [busy, setBusy] = useState<"save" | "remove" | "check" | null>(null); + const [check, setCheck] = useState(null); + + useEffect(() => { + api.mem0Status().then(setStatus, () => setStatus(null)); + }, [api]); + + const saveKey = async (value: string, kind: "save" | "remove") => { + setBusy(kind); + setCheck(null); + try { + setStatus(await api.saveMem0Key(value)); + setKey(""); + toast(kind === "save" ? "mem0 key saved" : "mem0 key removed"); + } catch (error) { + toast(errorMessage(error), "error"); + } finally { + setBusy(null); + } + }; + + const runCheck = async () => { + setBusy("check"); + try { + setCheck(await api.checkMem0()); + } catch (error) { + setCheck({ ok: false, error: errorMessage(error) }); + } finally { + setBusy(null); + } + }; + + const state = !status + ? "Checking…" + : !status.has_key + ? "Not set. Until it is, colonies start without shared memory." + : status.source === "MEM0_API_KEY" + ? "Read from MEM0_API_KEY." + : "Saved on this machine."; + + return ( +
+ +

{state} It stays on the Mothership: colonies never see it.

+
{ + e.preventDefault(); + if (key.trim()) void saveKey(key.trim(), "save"); + }} + > + setKey(e.target.value)} + placeholder={status?.has_key ? "Replace the key" : "m0-…"} + className={cx(inputClass, "min-w-48 flex-1")} + /> + + {status?.source === "saved" && ( + + )} + +
+ {check && ( +

+ {check.ok ? "mem0 accepted the key." : check.error} +

+ )} +
+ ); +} + function SettingField({ name, field, diff --git a/web/src/mock.ts b/web/src/mock.ts index e66f23bf..99ae85df 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -11,6 +11,7 @@ import type { Issue, LogLevel, LoginView, + Mem0Status, MemoryNote, MemoryProposal, ModelOption, @@ -790,6 +791,8 @@ export function createMockApi(): Api { sessions.set(failed.session.id, failed); sessions.set(old.session.id, old); + const mem0: Mem0Status = { has_key: false, source: null, active: false }; + // Shared memory: two proposals waiting for review and a few notes per scope. const proposals: MemoryProposal[] = [ { @@ -1063,7 +1066,14 @@ export function createMockApi(): Api { { kind: "memory", provider: "files", - providers: [{ id: "files", name: "Shared memory", description: "Markdown notes per repository, org and globally, mounted read-only into colonies; agents propose new notes" }], + providers: [ + { id: "files", name: "Shared memory", description: "Markdown notes per repository, org and globally, mounted read-only into colonies; agents propose new notes" }, + { + id: "mem0", + name: "mem0", + description: "Approved notes stored in your mem0 project. Colonies read them exactly as they read files, most relevant to the task first; the key never enters a colony", + }, + ], enabled: true, settings: { require_review: true }, schema: { @@ -1451,6 +1461,7 @@ export function createMockApi(): Api { later(() => ({ scope, key, + provider: "files", notes: notes.filter((n) => n.scope === scope && n.key === key).sort((a, b) => b.created_at.localeCompare(a.created_at)), proposals: proposals.filter((p) => p.scope === scope && p.key === key), })), @@ -1502,5 +1513,16 @@ export function createMockApi(): Api { notes.splice(index, 1); return { ok: true }; }, + mem0Status: () => later(() => ({ ...mem0 })), + saveMem0Key: async (apiKey) => { + await sleep(250); + mem0.has_key = apiKey.trim() !== ""; + mem0.source = mem0.has_key ? "saved" : null; + return { ...mem0 }; + }, + checkMem0: async () => { + await sleep(600); + return mem0.has_key ? { ok: true } : { ok: false, error: "add a mem0 API key in Settings → Modules → Memory" }; + }, }; } diff --git a/web/src/types.ts b/web/src/types.ts index 5b0fccf2..f7123c25 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -321,10 +321,25 @@ export interface MemoryProposal extends MemoryNote { export interface MemoryListing { scope: MemoryScope; key: string; + /** Where approved notes live: `files` on the Mothership, or `mem0`. */ + provider?: string; notes: MemoryNote[]; proposals: MemoryProposal[]; } +/** Whether a mem0 key is set and where from. The API never returns the key. */ +export interface Mem0Status { + has_key: boolean; + source: "saved" | "MEM0_API_KEY" | null; + /** mem0 is the memory module's saved provider. */ + active: boolean; +} + +export interface Mem0Check { + ok: boolean; + error?: string; +} + export interface NewNoteRequest { scope: MemoryScope; key: string;