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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---
Expand Down
3 changes: 3 additions & 0 deletions crates/colonizer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod findings;
mod gateway;
mod github;
mod headroom;
mod mem0;
mod memory;
mod mesh;
mod modules;
Expand Down Expand Up @@ -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))
Expand Down
457 changes: 457 additions & 0 deletions crates/colonizer/src/mem0.rs

Large diffs are not rendered by default.

253 changes: 237 additions & 16 deletions crates/colonizer/src/memory.rs

Large diffs are not rendered by default.

27 changes: 19 additions & 8 deletions crates/colonizer/src/modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,25 @@ pub fn providers(kind: &str, agents: &[AgentModule]) -> Vec<Provider> {
"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",
Expand Down
35 changes: 29 additions & 6 deletions crates/colonizer/src/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 24 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<org>`, `colonizer:repo:<owner>/<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"):
Expand Down Expand Up @@ -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}`.
Expand Down
10 changes: 10 additions & 0 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
HarnessStatus,
Issue,
LoginView,
Mem0Check,
Mem0Status,
MemoryListing,
MemoryNote,
MemoryProposal,
Expand Down Expand Up @@ -92,6 +94,11 @@ export interface Api {
rejectProposal(id: string): Promise<unknown>;
createNote(body: NewNoteRequest): Promise<MemoryNote>;
deleteNote(note: Pick<MemoryNote, "id" | "scope" | "key">): Promise<unknown>;
mem0Status(): Promise<Mem0Status>;
/** Saves the key on the Mothership; an empty string removes it. */
saveMem0Key(apiKey: string): Promise<Mem0Status>;
/** Tries the saved key against the configured endpoint. */
checkMem0(): Promise<Mem0Check>;
openEvents(sessionId: string, since: number): SocketLike;
openTerminal(sessionId: string, cols: number, rows: number): SocketLike;
}
Expand Down Expand Up @@ -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}`)),
Expand Down
7 changes: 6 additions & 1 deletion web/src/components/MemoryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ function NotesSection({ selectedOrg, orgs, version }: { selectedOrg: string | nu
const [org, setOrg] = useState<string>(selectedOrg ?? "");
const [repo, setRepo] = useState<string>("");
const [notes, setNotes] = useState<MemoryNote[] | null>(null);
const [provider, setProvider] = useState("files");
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -413,7 +415,10 @@ function NotesSection({ selectedOrg, orgs, version }: { selectedOrg: string | nu
</select>
)}
</div>
<p className="text-[12.5px] text-muted">{description}</p>
<p className="text-[12.5px] text-muted">
{description}
{provider === "mem0" && " Stored in your mem0 project."}
</p>

{adding && ready && (
<NoteForm
Expand Down
98 changes: 98 additions & 0 deletions web/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { errorMessage, useApi, useToast } from "../context";
import type {
HarnessStatus,
HeadroomStatus,
Mem0Check,
Mem0Status,
LoginView,
ModelOption,
ModelProvider,
Expand Down Expand Up @@ -1364,12 +1366,108 @@ function ModulePane({
);
})}

{module.kind === "memory" && draft.provider === "mem0" && <Mem0KeyRow />}

{fields.length === 0 && module.providers.length <= 1 && <p className="py-3 text-[13px] text-faint">Nothing to configure.</p>}
</div>
</Pane>
);
}

/**
* 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<Mem0Status | null>(null);
const [key, setKey] = useState("");
const [busy, setBusy] = useState<"save" | "remove" | "check" | null>(null);
const [check, setCheck] = useState<Mem0Check | null>(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 (
<div className="space-y-2 py-2.5">
<label htmlFor={id} className="block text-[13px] font-medium">
mem0 API key
</label>
<p className="text-[12.5px] text-muted">{state} It stays on the Mothership: colonies never see it.</p>
<form
className="flex flex-wrap gap-2"
onSubmit={(e) => {
e.preventDefault();
if (key.trim()) void saveKey(key.trim(), "save");
}}
>
<input
id={id}
type="password"
autoComplete="off"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder={status?.has_key ? "Replace the key" : "m0-…"}
className={cx(inputClass, "min-w-48 flex-1")}
/>
<Button type="submit" variant="primary" disabled={!key.trim() || busy !== null}>
{busy === "save" && <Spinner />} Save
</Button>
{status?.source === "saved" && (
<Button disabled={busy !== null} onClick={() => void saveKey("", "remove")}>
{busy === "remove" && <Spinner />} Remove
</Button>
)}
<Button disabled={!status?.has_key || busy !== null} onClick={() => void runCheck()}>
{busy === "check" && <Spinner />} Check
</Button>
</form>
{check && (
<p role="status" className={cx("text-[12.5px]", check.ok ? "text-ok" : "text-err")}>
{check.ok ? "mem0 accepted the key." : check.error}
</p>
)}
</div>
);
}

function SettingField({
name,
field,
Expand Down
Loading