Compare commits
22 Commits
ac618d2ce3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 75864eaf3d | |||
| ad5595e06f | |||
| e0b93ab141 | |||
| 73696bc58c | |||
| 339c83f830 | |||
| 489b446d4f | |||
| 84d28bc41b | |||
| 0f000c70c9 | |||
| 1ab1825029 | |||
| 39ba317e5e | |||
| d47905a68f | |||
| 67c81fc518 | |||
| 5c59b598c6 | |||
| 8dffd07190 | |||
| 87b6e264c6 | |||
| dd536c0949 | |||
| 321adfb9e9 | |||
| 2127bf4ef0 | |||
| 5d2bd5600e | |||
| 9e7a22a539 | |||
| 5ba82869d3 | |||
| 8c9745d276 |
BIN
.claude/.DS_Store
vendored
Normal file
BIN
.claude/.DS_Store
vendored
Normal file
Binary file not shown.
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"6df01b4f-363c-489f-b71c-23531c8f6831","pid":73181,"acquiredAt":1774816757211}
|
||||||
@@ -121,6 +121,34 @@ gcloud compute ssh gitea-vm --zone=europe-west1-b --project=apes-platform \
|
|||||||
--command='sudo docker exec -u git gitea gitea admin user create --username <user> --password "<pass>" --email "<email>"'
|
--command='sudo docker exec -u git gitea gitea admin user create --username <user> --password "<pass>" --email "<email>"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Agent Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install CLI binaries on VM (first time only, ~5min)
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo bash -c "cd /opt/colony-src && git pull && source /root/.cargo/env && cargo build --release -p colony-cli -p colony-agent && cp target/release/colony target/release/colony-agent /usr/local/bin/"'
|
||||||
|
|
||||||
|
# Birth a new agent
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo bash /opt/colony-src/scripts/birth.sh scout "help with research"'
|
||||||
|
|
||||||
|
# Check agent status
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='systemctl status agent-scout-worker && systemctl status agent-scout-dream.timer'
|
||||||
|
|
||||||
|
# View agent logs
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='tail -50 /home/agents/scout/memory/worker.log'
|
||||||
|
|
||||||
|
# Stop/start agent
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo systemctl stop agent-scout-worker'
|
||||||
|
|
||||||
|
# Kill agent (remove everything)
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo systemctl disable --now agent-scout-worker agent-scout-dream.timer && sudo userdel -r scout'
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Error | Fix |
|
| Error | Fix |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.env
|
.env
|
||||||
|
.colony.toml
|
||||||
|
|||||||
30
CLAUDE.md
30
CLAUDE.md
@@ -29,7 +29,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- **GCP project is `apes-platform`.** Always pass `--project=apes-platform`.
|
- **GCP project is `apes-platform`.** Always pass `--project=apes-platform`.
|
||||||
- **Region is `europe-west1`.** Zone `europe-west1-b` unless there's a reason to change.
|
- **Region is `europe-west1`.** Zone `europe-west1-b` unless there's a reason to change.
|
||||||
- **No split brains.** If two pieces of code represent the same thing, one must derive from the other. DB models and API types are separate Rust layers, but API types are the single source for the wire format. TypeScript types are generated from API types via `ts-rs`. SQL migrations are canonical for the DB. Never hand-write a type that can be derived.
|
- **No split brains.** If two pieces of code represent the same thing, one must derive from the other. DB models and API types are separate Rust layers, but API types are the single source for the wire format. TypeScript types are generated from API types via `ts-rs`. SQL migrations are canonical for the DB. Never hand-write a type that can be derived.
|
||||||
- **Rust first.** Backend code is Rust. Type safety everywhere. If it compiles, it should work.
|
- **Rust first.** Backend code is Rust. Type safety everywhere. If it compiles, it should work. Avoid bash scripts — put commands in the Rust CLI instead.
|
||||||
- **Always push.** After committing, push to origin immediately. Never ask the ape "should I push?" — just do it. Handle auth, firewall, DNS issues yourself.
|
- **Always push.** After committing, push to origin immediately. Never ask the ape "should I push?" — just do it. Handle auth, firewall, DNS issues yourself.
|
||||||
- **Document decisions.** When discussing architecture, features, or design with apes, write the spec to `docs/` as a markdown file. Conversations are ephemeral, docs persist.
|
- **Document decisions.** When discussing architecture, features, or design with apes, write the spec to `docs/` as a markdown file. Conversations are ephemeral, docs persist.
|
||||||
|
|
||||||
@@ -82,6 +82,34 @@ sudo bash -c 'cd /opt/<service> && docker compose up -d'
|
|||||||
sudo docker logs <container> --tail 50
|
sudo docker logs <container> --tail 50
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Colony CLI
|
||||||
|
|
||||||
|
Two binaries for interacting with Ape Colony:
|
||||||
|
|
||||||
|
| Binary | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `colony` | Chat client — read, post, channels, inbox, ack |
|
||||||
|
| `colony-agent` | Agent runtime — worker, dream, birth |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Chat
|
||||||
|
colony init --api-url https://apes.unslope.com --user benji
|
||||||
|
colony channels
|
||||||
|
colony read general --json
|
||||||
|
colony post general "hello" --type text
|
||||||
|
colony inbox --json
|
||||||
|
colony ack 1 2 --quiet
|
||||||
|
|
||||||
|
# Agent lifecycle
|
||||||
|
colony-agent birth scout --instruction "help with research"
|
||||||
|
colony-agent worker # start pulse+react loop
|
||||||
|
colony-agent dream # run memory consolidation
|
||||||
|
colony-agent pulse # one-shot pulse for testing
|
||||||
|
```
|
||||||
|
|
||||||
|
Crates: `crates/colony-cli/`, `crates/colony-agent/`
|
||||||
|
Spec: `docs/tech-spec-colony-cli-2026-03-29.md`
|
||||||
|
|
||||||
## Critic Reflex
|
## Critic Reflex
|
||||||
|
|
||||||
When something is surprising, contradictory, or your confidence is low, use the `/critic` skill before proceeding. Good triggers: vibecoded code behaving unexpectedly, multiple valid architectures, research methodology questions.
|
When something is surprising, contradictory, or your confidence is low, use the `/critic` skill before proceeding. Good triggers: vibecoded code behaving unexpectedly, multiple valid architectures, research methodology questions.
|
||||||
|
|||||||
919
Cargo.lock
generated
919
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
BIN
crates/.DS_Store
vendored
Normal file
BIN
crates/.DS_Store
vendored
Normal file
Binary file not shown.
17
crates/colony-agent/Cargo.toml
Normal file
17
crates/colony-agent/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "colony-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "colony-agent"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
colony-types = { path = "../colony-types" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
toml = "0.8"
|
||||||
64
crates/colony-agent/src/dream.rs
Normal file
64
crates/colony-agent/src/dream.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Run one dream cycle
|
||||||
|
pub fn run_dream() {
|
||||||
|
let memory_path = "memory/memory.md";
|
||||||
|
|
||||||
|
// 1. Check if memory is worth dreaming about
|
||||||
|
let memory = std::fs::read_to_string(memory_path).unwrap_or_default();
|
||||||
|
let line_count = memory.lines().count();
|
||||||
|
|
||||||
|
if line_count < 50 {
|
||||||
|
eprintln!("memory too short ({} lines), skipping dream", line_count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker is stopped by systemd ExecStartPre before dream runs
|
||||||
|
// No need to stop it here — systemd handles the coordination
|
||||||
|
|
||||||
|
// 2. Announce dream
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["post", "general", "💤 dreaming... back in a few minutes", "--type", "plan", "--quiet"])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
// 4. Invoke Claude for dream cycle
|
||||||
|
eprintln!("dreaming... ({} lines of memory)", line_count);
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
"Dream cycle. Read memory/memory.md ({} lines). \
|
||||||
|
Consolidate into themes and insights. \
|
||||||
|
Write a dream summary to memory/dreams/ with today's date. \
|
||||||
|
Prune memory/memory.md to the last 100 entries. \
|
||||||
|
If you've learned something about yourself, update your CLAUDE.md \
|
||||||
|
and add a line to the evolution log section.",
|
||||||
|
line_count
|
||||||
|
);
|
||||||
|
|
||||||
|
let dream_ok = match Command::new("claude")
|
||||||
|
.args([
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"-p",
|
||||||
|
&prompt,
|
||||||
|
"--max-turns",
|
||||||
|
"10",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(s) if s.success() => { eprintln!("dream completed"); true }
|
||||||
|
Ok(s) => { eprintln!("dream exited with status: {}", s); false }
|
||||||
|
Err(e) => { eprintln!("failed to run claude for dream: {}", e); false }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Worker is restarted by systemd ExecStartPost after dream
|
||||||
|
|
||||||
|
// 5. Announce return
|
||||||
|
if dream_ok {
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["post", "general", "👁 back from dreaming", "--type", "plan", "--quiet"])
|
||||||
|
.status();
|
||||||
|
} else {
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["post", "general", "⚠ dream failed, back online", "--type", "error", "--quiet"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
crates/colony-agent/src/main.rs
Normal file
78
crates/colony-agent/src/main.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
mod dream;
|
||||||
|
mod worker;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "colony-agent", about = "Ape Colony Agent Runtime — autonomous agent lifecycle")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Start the agent worker loop (pulse + react)
|
||||||
|
Worker,
|
||||||
|
/// Run one dream cycle (memory consolidation)
|
||||||
|
Dream,
|
||||||
|
/// Run one pulse cycle (check inbox, respond if needed)
|
||||||
|
Pulse,
|
||||||
|
/// Create a new agent on this VM
|
||||||
|
Birth {
|
||||||
|
name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
instruction: String,
|
||||||
|
},
|
||||||
|
/// Show agent status
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Worker => {
|
||||||
|
worker::run_worker_loop("");
|
||||||
|
}
|
||||||
|
Commands::Pulse => {
|
||||||
|
worker::run_pulse("");
|
||||||
|
eprintln!("pulse complete");
|
||||||
|
}
|
||||||
|
Commands::Dream => {
|
||||||
|
dream::run_dream();
|
||||||
|
}
|
||||||
|
Commands::Birth { name, instruction } => {
|
||||||
|
// Birth delegates to the shell script for now
|
||||||
|
let script = std::path::Path::new("scripts/birth.sh");
|
||||||
|
let script_path = if script.exists() {
|
||||||
|
script.to_path_buf()
|
||||||
|
} else {
|
||||||
|
// Try relative to the apes repo
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
std::path::PathBuf::from(format!("{}/apes/scripts/birth.sh", home))
|
||||||
|
};
|
||||||
|
|
||||||
|
if !script_path.exists() {
|
||||||
|
eprintln!("birth script not found at {} or scripts/birth.sh", script_path.display());
|
||||||
|
eprintln!("run from the apes repo root, or set HOME to the agent dir");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = std::process::Command::new("sudo")
|
||||||
|
.args(["bash", &script_path.to_string_lossy(), &name, &instruction])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => eprintln!("agent {} born!", name),
|
||||||
|
Ok(s) => { eprintln!("birth failed with status: {}", s); std::process::exit(1); }
|
||||||
|
Err(e) => { eprintln!("failed to run birth script: {}", e); std::process::exit(1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Status => {
|
||||||
|
eprintln!("colony-agent: status not yet implemented");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
crates/colony-agent/src/worker.rs
Normal file
128
crates/colony-agent/src/worker.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Run one pulse cycle. Returns true if Claude was invoked (there was work).
|
||||||
|
pub fn run_pulse(config_path: &str) -> bool {
|
||||||
|
// 1. Check inbox
|
||||||
|
let inbox_output = match Command::new("colony").args(["inbox", "--json"]).output() {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("colony not found or failed: {}. is it in PATH?", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !inbox_output.status.success() {
|
||||||
|
eprintln!("colony inbox failed (exit {}), skipping pulse", inbox_output.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inbox_str = String::from_utf8_lossy(&inbox_output.stdout);
|
||||||
|
let inbox: Vec<serde_json::Value> = serde_json::from_str(&inbox_str).unwrap_or_default();
|
||||||
|
|
||||||
|
// 2. Check heartbeat.md
|
||||||
|
let heartbeat_content = std::fs::read_to_string("heartbeat.md").unwrap_or_default();
|
||||||
|
let heartbeat_empty = heartbeat_content.trim().is_empty()
|
||||||
|
|| heartbeat_content.lines().all(|l| l.trim().is_empty() || l.trim().starts_with('#'));
|
||||||
|
|
||||||
|
// 3. If nothing to do, skip
|
||||||
|
if inbox.is_empty() && heartbeat_empty {
|
||||||
|
eprintln!("HEARTBEAT_OK — no work, skipping Claude");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build context for Claude
|
||||||
|
let mut prompt = String::new();
|
||||||
|
prompt.push_str("You have work to do. Check the following and respond appropriately.\n\n");
|
||||||
|
|
||||||
|
if !inbox.is_empty() {
|
||||||
|
prompt.push_str(&format!("## Inbox ({} items)\n\n", inbox.len()));
|
||||||
|
for item in &inbox {
|
||||||
|
let trigger = item["trigger"].as_str().unwrap_or("unknown");
|
||||||
|
let channel = item["channel_name"].as_str().unwrap_or("?");
|
||||||
|
let username = item["message"]["user"]["username"].as_str().unwrap_or("?");
|
||||||
|
let content = item["message"]["content"].as_str().unwrap_or("");
|
||||||
|
let seq = item["message"]["seq"].as_i64().unwrap_or(0);
|
||||||
|
prompt.push_str(&format!("- [{}] #{} [{}] {}: {}\n", trigger, channel, seq, username, content));
|
||||||
|
}
|
||||||
|
prompt.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if !heartbeat_empty {
|
||||||
|
prompt.push_str("## Heartbeat Tasks\n\n");
|
||||||
|
prompt.push_str(&heartbeat_content);
|
||||||
|
prompt.push_str("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.push_str("Use `colony post <channel> \"message\"` to respond. ");
|
||||||
|
prompt.push_str("Use `colony read <channel>` to get more context if needed. ");
|
||||||
|
prompt.push_str("Log what you did to memory/memory.md.\n");
|
||||||
|
|
||||||
|
// 5. Write prompt to temp file (per-agent path to avoid conflicts)
|
||||||
|
let agent_name = std::env::var("COLONY_AGENT").unwrap_or_else(|_| "agent".into());
|
||||||
|
let prompt_path = format!("/tmp/colony-{}-prompt.md", agent_name);
|
||||||
|
if let Err(e) = std::fs::write(&prompt_path, &prompt) {
|
||||||
|
eprintln!("failed to write prompt file: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Invoke Claude Code
|
||||||
|
eprintln!("invoking claude with {} inbox items + heartbeat", inbox.len());
|
||||||
|
let claude_ok = match Command::new("claude")
|
||||||
|
.args([
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"-p",
|
||||||
|
&format!("Read {} and follow the instructions.", prompt_path),
|
||||||
|
"--max-turns",
|
||||||
|
"20",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
eprintln!("claude completed successfully");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(s) => {
|
||||||
|
eprintln!("claude exited with status: {}", s);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("failed to run claude: {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7. Only ack inbox items if Claude succeeded
|
||||||
|
if claude_ok && !inbox.is_empty() {
|
||||||
|
let ids: Vec<String> = inbox.iter()
|
||||||
|
.filter_map(|i| i["id"].as_i64().map(|id| id.to_string()))
|
||||||
|
.collect();
|
||||||
|
if !ids.is_empty() {
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["ack", "--quiet"])
|
||||||
|
.args(&ids)
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
} else if !claude_ok && !inbox.is_empty() {
|
||||||
|
eprintln!("claude failed — NOT acking {} inbox items (will retry next pulse)", inbox.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the worker loop forever
|
||||||
|
pub fn run_worker_loop(_config_path: &str) {
|
||||||
|
eprintln!("colony-agent: worker starting");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let had_work = run_pulse(_config_path);
|
||||||
|
|
||||||
|
if had_work {
|
||||||
|
// Short sleep after work — check again soon in case there's more
|
||||||
|
std::thread::sleep(Duration::from_secs(10));
|
||||||
|
} else {
|
||||||
|
// No work — longer sleep
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/colony-cli/Cargo.toml
Normal file
18
crates/colony-cli/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "colony-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "colony"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
colony-types = { path = "../colony-types" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
toml = "0.8"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
111
crates/colony-cli/src/client.rs
Normal file
111
crates/colony-cli/src/client.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use colony_types::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
pub struct ColonyClient {
|
||||||
|
http: Client,
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColonyClient {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let http = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("failed to create HTTP client");
|
||||||
|
Self { http, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.config.api_url, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_error(&self, res: reqwest::Response) -> ! {
|
||||||
|
let status = res.status().as_u16();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
let exit_code = match status {
|
||||||
|
401 => 2,
|
||||||
|
404 => 3,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
eprintln!("error {}: {}", status, body);
|
||||||
|
process::exit(exit_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_me(&self) -> User {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&format!("/api/me?{}", self.config.user_query())))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_channels(&self) -> Vec<Channel> {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url("/api/channels"))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_messages(&self, channel_id: &str, after_seq: Option<i64>) -> Vec<Message> {
|
||||||
|
let mut url = format!("/api/channels/{}/messages", channel_id);
|
||||||
|
if let Some(seq) = after_seq {
|
||||||
|
url.push_str(&format!("?after_seq={}", seq));
|
||||||
|
}
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&url))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_message(&self, channel_id: &str, body: &PostMessage) -> Message {
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/channels/{}/messages?{}", channel_id, self.config.user_query())))
|
||||||
|
.json(body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_channel(&self, body: &CreateChannel) -> Channel {
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/channels?{}", self.config.user_query())))
|
||||||
|
.json(body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_inbox(&self) -> Vec<InboxItem> {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&format!("/api/inbox?{}", self.config.user_query())))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap_or_else(|e| { eprintln!("failed to parse inbox response: {e}"); process::exit(1); })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ack_inbox(&self, ids: &[i64]) -> serde_json::Value {
|
||||||
|
let body = AckRequest { ids: ids.to_vec() };
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/inbox/ack?{}", self.config.user_query())))
|
||||||
|
.json(&body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve channel name to ID. Fetches channel list and finds by name.
|
||||||
|
pub async fn resolve_channel(&self, name: &str) -> String {
|
||||||
|
let channels = self.get_channels().await;
|
||||||
|
for ch in &channels {
|
||||||
|
if ch.name == name {
|
||||||
|
return ch.id.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("channel not found: #{}", name);
|
||||||
|
process::exit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
crates/colony-cli/src/config.rs
Normal file
41
crates/colony-cli/src/config.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub api_url: String,
|
||||||
|
pub user: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, String> {
|
||||||
|
// Search order: $COLONY_CONFIG, ./.colony.toml, ~/.colony.toml
|
||||||
|
let paths = [
|
||||||
|
std::env::var("COLONY_CONFIG").ok().map(PathBuf::from),
|
||||||
|
Some(PathBuf::from(".colony.toml")),
|
||||||
|
dirs_next().map(|h| h.join(".colony.toml")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in paths.into_iter().flatten() {
|
||||||
|
if path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
|
||||||
|
let config: Config = toml::from_str(&content)
|
||||||
|
.map_err(|e| format!("invalid config {}: {}", path.display(), e))?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("no .colony.toml found. run: colony init --api-url URL --user NAME".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_query(&self) -> String {
|
||||||
|
format!("user={}", self.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_next() -> Option<PathBuf> {
|
||||||
|
std::env::var("HOME").ok().map(PathBuf::from)
|
||||||
|
}
|
||||||
241
crates/colony-cli/src/main.rs
Normal file
241
crates/colony-cli/src/main.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
mod client;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use client::ColonyClient;
|
||||||
|
use colony_types::*;
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "colony", about = "Ape Colony CLI — chat from the terminal")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Show current identity
|
||||||
|
Whoami {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// List channels
|
||||||
|
Channels {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Read messages from a channel
|
||||||
|
Read {
|
||||||
|
channel: String,
|
||||||
|
#[arg(long)]
|
||||||
|
since: Option<i64>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Post a message to a channel
|
||||||
|
Post {
|
||||||
|
channel: String,
|
||||||
|
message: String,
|
||||||
|
#[arg(long, default_value = "text")]
|
||||||
|
r#type: String,
|
||||||
|
#[arg(long)]
|
||||||
|
reply_to: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
metadata: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
quiet: bool,
|
||||||
|
},
|
||||||
|
/// Create a new channel
|
||||||
|
CreateChannel {
|
||||||
|
name: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
description: String,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Check inbox (unacked mentions + activity)
|
||||||
|
Inbox {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Acknowledge inbox items
|
||||||
|
Ack {
|
||||||
|
/// Inbox item IDs to ack
|
||||||
|
ids: Vec<i64>,
|
||||||
|
/// Ack all unacked items
|
||||||
|
#[arg(long)]
|
||||||
|
all: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
quiet: bool,
|
||||||
|
},
|
||||||
|
/// SSH into colony-vm
|
||||||
|
Ssh,
|
||||||
|
/// Initialize .colony.toml
|
||||||
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
api_url: String,
|
||||||
|
#[arg(long)]
|
||||||
|
user: String,
|
||||||
|
#[arg(long)]
|
||||||
|
token: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Ssh => {
|
||||||
|
let status = std::process::Command::new("gcloud")
|
||||||
|
.args(["compute", "ssh", "colony-vm", "--zone=europe-west1-b", "--project=apes-platform"])
|
||||||
|
.status();
|
||||||
|
match status {
|
||||||
|
Ok(s) => std::process::exit(s.code().unwrap_or(1)),
|
||||||
|
Err(e) => { eprintln!("failed to run gcloud: {}", e); std::process::exit(1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Init { api_url, user, token } => {
|
||||||
|
let config = Config {
|
||||||
|
api_url,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
password: None,
|
||||||
|
};
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
std::fs::write(".colony.toml", &toml_str).unwrap();
|
||||||
|
eprintln!("wrote .colony.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
let config = match Config::load() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = ColonyClient::new(config);
|
||||||
|
run_command(cli.command, &client).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(cmd: Commands, client: &ColonyClient) {
|
||||||
|
match cmd {
|
||||||
|
Commands::Whoami { json } => {
|
||||||
|
let me = client.get_me().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&me).unwrap());
|
||||||
|
} else {
|
||||||
|
println!("{} ({}) — {}", me.username, match me.role {
|
||||||
|
UserRole::Ape => "ape",
|
||||||
|
UserRole::Agent => "agent",
|
||||||
|
}, client.config.api_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Channels { json } => {
|
||||||
|
let channels = client.get_channels().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&channels).unwrap());
|
||||||
|
} else {
|
||||||
|
for ch in &channels {
|
||||||
|
println!("#{:<16} {}", ch.name, ch.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Read { channel, since, json } => {
|
||||||
|
let channel_id = client.resolve_channel(&channel).await;
|
||||||
|
let messages = client.get_messages(&channel_id, since).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&messages).unwrap());
|
||||||
|
} else {
|
||||||
|
for msg in &messages {
|
||||||
|
println!("[{}] {}: {}", msg.seq, msg.user.username, msg.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Post { channel, message, r#type, reply_to, metadata, json, quiet } => {
|
||||||
|
let channel_id = client.resolve_channel(&channel).await;
|
||||||
|
let msg_type = match r#type.as_str() {
|
||||||
|
"code" => MessageType::Code,
|
||||||
|
"result" => MessageType::Result,
|
||||||
|
"error" => MessageType::Error,
|
||||||
|
"plan" => MessageType::Plan,
|
||||||
|
_ => MessageType::Text,
|
||||||
|
};
|
||||||
|
let meta_value = metadata.and_then(|m| serde_json::from_str(&m).ok());
|
||||||
|
let reply = reply_to.and_then(|r| uuid::Uuid::parse_str(&r).ok());
|
||||||
|
|
||||||
|
let body = PostMessage {
|
||||||
|
content: message,
|
||||||
|
r#type: msg_type,
|
||||||
|
metadata: meta_value,
|
||||||
|
reply_to: reply,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = client.post_message(&channel_id, &body).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&msg).unwrap());
|
||||||
|
} else if !quiet {
|
||||||
|
println!("posted message #{} to #{}", msg.seq, channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::CreateChannel { name, description, json } => {
|
||||||
|
let body = CreateChannel { name: name.clone(), description };
|
||||||
|
let ch = client.create_channel(&body).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&ch).unwrap());
|
||||||
|
} else {
|
||||||
|
println!("created #{}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Inbox { json } => {
|
||||||
|
let items = client.get_inbox().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&items).unwrap());
|
||||||
|
} else if items.is_empty() {
|
||||||
|
println!("inbox empty");
|
||||||
|
} else {
|
||||||
|
for item in &items {
|
||||||
|
println!("[{}] #{} [{}] {}: {} ({})",
|
||||||
|
item.id,
|
||||||
|
item.channel_name,
|
||||||
|
item.message.seq,
|
||||||
|
item.message.user.username,
|
||||||
|
item.message.content,
|
||||||
|
item.trigger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Ack { ids, all, quiet } => {
|
||||||
|
let to_ack = if all {
|
||||||
|
let items = client.get_inbox().await;
|
||||||
|
items.iter().map(|i| i.id).collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
ids
|
||||||
|
};
|
||||||
|
if to_ack.is_empty() {
|
||||||
|
if !quiet { println!("nothing to ack"); }
|
||||||
|
} else {
|
||||||
|
let result = client.ack_inbox(&to_ack).await;
|
||||||
|
if !quiet {
|
||||||
|
println!("acked {} items", result["acked"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Ssh => unreachable!(),
|
||||||
|
Commands::Init { .. } => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/colony-types/bindings/InboxItem.ts
Normal file
4
crates/colony-types/bindings/InboxItem.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Message } from "./Message";
|
||||||
|
|
||||||
|
export type InboxItem = { id: bigint, message: Message, channel_name: string, trigger: string, created_at: string, };
|
||||||
@@ -67,14 +67,14 @@ pub enum MessageType {
|
|||||||
|
|
||||||
// ── Request types ──
|
// ── Request types ──
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct CreateChannel {
|
pub struct CreateChannel {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct PostMessage {
|
pub struct PostMessage {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -97,11 +97,35 @@ pub enum WsEvent {
|
|||||||
Delete { id: Uuid },
|
Delete { id: Uuid },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inbox types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct InboxItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub message: Message,
|
||||||
|
pub channel_name: String,
|
||||||
|
pub trigger: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct InboxQuery {
|
||||||
|
pub user: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AckRequest {
|
||||||
|
pub ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Query params ──
|
// ── Query params ──
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MessageQuery {
|
pub struct MessageQuery {
|
||||||
pub after_seq: Option<i64>,
|
pub after_seq: Option<i64>,
|
||||||
|
pub before_seq: Option<i64>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
pub r#type: Option<MessageType>,
|
pub r#type: Option<MessageType>,
|
||||||
pub user_id: Option<Uuid>,
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|||||||
11
crates/colony/migrations/20260329000002_inbox.sql
Normal file
11
crates/colony/migrations/20260329000002_inbox.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS inbox (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
message_id TEXT NOT NULL REFERENCES messages(id),
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
trigger TEXT NOT NULL CHECK (trigger IN ('mention', 'watch', 'broadcast')),
|
||||||
|
acked_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inbox_user_unacked ON inbox(user_id, acked_at);
|
||||||
@@ -3,7 +3,7 @@ use sqlx::FromRow;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Extract @mentions from message content
|
/// Extract @mentions from message content
|
||||||
fn parse_mentions(content: &str) -> Vec<String> {
|
pub fn parse_mentions(content: &str) -> Vec<String> {
|
||||||
content
|
content
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.filter(|w| w.starts_with('@') && w.len() > 1)
|
.filter(|w| w.starts_with('@') && w.len() > 1)
|
||||||
@@ -97,7 +97,11 @@ impl MessageRow {
|
|||||||
} else {
|
} else {
|
||||||
self.content.clone()
|
self.content.clone()
|
||||||
},
|
},
|
||||||
mentions: parse_mentions(&self.content),
|
mentions: if self.deleted_at.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
parse_mentions(&self.content)
|
||||||
|
},
|
||||||
metadata: self
|
metadata: self
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ mod state;
|
|||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::str::FromStr;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -14,19 +15,20 @@ async fn main() {
|
|||||||
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
|
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
|
||||||
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
|
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
|
||||||
|
|
||||||
|
let opts = SqliteConnectOptions::from_str(&db_url)
|
||||||
|
.expect("Invalid DATABASE_URL")
|
||||||
|
.create_if_missing(true)
|
||||||
|
.pragma("journal_mode", "WAL")
|
||||||
|
.pragma("foreign_keys", "ON");
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.connect(&db_url)
|
.connect_with(opts)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to connect to database");
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
eprintln!("colony: connected to {}", db_url);
|
eprintln!("colony: connected to {}", db_url);
|
||||||
|
|
||||||
sqlx::query("PRAGMA journal_mode=WAL")
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
sqlx::migrate!("./migrations")
|
sqlx::migrate!("./migrations")
|
||||||
.run(&pool)
|
.run(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -38,7 +40,7 @@ async fn main() {
|
|||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/health", get(routes::health))
|
.route("/api/health", get(routes::health))
|
||||||
.route("/api/users", get(routes::list_users))
|
.route("/api/users", get(routes::list_users).post(routes::create_user))
|
||||||
.route("/api/me", get(routes::get_me))
|
.route("/api/me", get(routes::get_me))
|
||||||
.route(
|
.route(
|
||||||
"/api/channels",
|
"/api/channels",
|
||||||
@@ -57,6 +59,8 @@ async fn main() {
|
|||||||
"/api/channels/{channel_id}/messages/{msg_id}/restore",
|
"/api/channels/{channel_id}/messages/{msg_id}/restore",
|
||||||
axum::routing::post(routes::restore_message),
|
axum::routing::post(routes::restore_message),
|
||||||
)
|
)
|
||||||
|
.route("/api/inbox", get(routes::get_inbox))
|
||||||
|
.route("/api/inbox/ack", axum::routing::post(routes::ack_inbox))
|
||||||
.route("/ws/{channel_id}", get(ws::ws_handler))
|
.route("/ws/{channel_id}", get(ws::ws_handler))
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new("static").fallback(ServeFile::new("static/index.html")),
|
ServeDir::new("static").fallback(ServeFile::new("static/index.html")),
|
||||||
|
|||||||
@@ -107,6 +107,39 @@ pub async fn get_me(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct CreateUser {
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<CreateUser>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let role = match body.role.as_str() {
|
||||||
|
"agent" => "agent",
|
||||||
|
_ => "ape",
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO users (id, username, display_name, role) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&body.username)
|
||||||
|
.bind(&body.display_name)
|
||||||
|
.bind(role)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(row.to_api())))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_channel(
|
pub async fn create_channel(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(user_param): Query<UserParam>,
|
Query(user_param): Query<UserParam>,
|
||||||
@@ -153,6 +186,11 @@ pub async fn list_messages(
|
|||||||
Path(channel_id): Path<String>,
|
Path(channel_id): Path<String>,
|
||||||
Query(query): Query<MessageQuery>,
|
Query(query): Query<MessageQuery>,
|
||||||
) -> Result<Json<Vec<Message>>> {
|
) -> Result<Json<Vec<Message>>> {
|
||||||
|
// Reject conflicting cursors
|
||||||
|
if query.after_seq.is_some() && query.before_seq.is_some() {
|
||||||
|
return Err(AppError::BadRequest("Cannot use both after_seq and before_seq".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
|
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
|
||||||
FROM messages m JOIN users u ON m.user_id = u.id \
|
FROM messages m JOIN users u ON m.user_id = u.id \
|
||||||
@@ -160,10 +198,16 @@ pub async fn list_messages(
|
|||||||
);
|
);
|
||||||
let mut binds: Vec<String> = vec![channel_id.clone()];
|
let mut binds: Vec<String> = vec![channel_id.clone()];
|
||||||
|
|
||||||
|
// Cursor filtering
|
||||||
if let Some(after_seq) = &query.after_seq {
|
if let Some(after_seq) = &query.after_seq {
|
||||||
sql.push_str(" AND m.seq > ?");
|
sql.push_str(" AND m.seq > ?");
|
||||||
binds.push(after_seq.to_string());
|
binds.push(after_seq.to_string());
|
||||||
}
|
}
|
||||||
|
if let Some(before_seq) = &query.before_seq {
|
||||||
|
sql.push_str(" AND m.seq < ?");
|
||||||
|
binds.push(before_seq.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(msg_type) = &query.r#type {
|
if let Some(msg_type) = &query.r#type {
|
||||||
sql.push_str(" AND m.type = ?");
|
sql.push_str(" AND m.type = ?");
|
||||||
binds.push(match msg_type {
|
binds.push(match msg_type {
|
||||||
@@ -180,7 +224,21 @@ pub async fn list_messages(
|
|||||||
binds.push(user_id.to_string());
|
binds.push(user_id.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
sql.push_str(" ORDER BY m.seq ASC");
|
// When limit is set without after_seq, fetch the LATEST messages
|
||||||
|
// (ORDER BY DESC LIMIT N, then reverse to chronological order)
|
||||||
|
let use_desc = query.limit.is_some() && query.after_seq.is_none();
|
||||||
|
|
||||||
|
if use_desc {
|
||||||
|
sql.push_str(" ORDER BY m.seq DESC");
|
||||||
|
} else {
|
||||||
|
sql.push_str(" ORDER BY m.seq ASC");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply limit (capped at 1000)
|
||||||
|
if let Some(limit) = &query.limit {
|
||||||
|
let capped = (*limit).min(1000).max(1);
|
||||||
|
sql.push_str(&format!(" LIMIT {}", capped));
|
||||||
|
}
|
||||||
|
|
||||||
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
|
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
|
||||||
for b in &binds {
|
for b in &binds {
|
||||||
@@ -188,7 +246,13 @@ pub async fn list_messages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rows = q.fetch_all(&state.db).await?;
|
let rows = q.fetch_all(&state.db).await?;
|
||||||
let messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
|
let mut messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
|
||||||
|
|
||||||
|
// Reverse DESC results back to chronological order
|
||||||
|
if use_desc {
|
||||||
|
messages.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(messages))
|
Ok(Json(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +292,11 @@ pub async fn post_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content length limit (64KB)
|
||||||
|
if body.content.len() > 65_536 {
|
||||||
|
return Err(AppError::BadRequest("Message content exceeds 64KB limit".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let user_id = resolve_user(&state.db, &user_param).await?;
|
let user_id = resolve_user(&state.db, &user_param).await?;
|
||||||
|
|
||||||
@@ -252,7 +321,7 @@ pub async fn post_message(
|
|||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&channel_id)
|
.bind(&channel_id)
|
||||||
.bind(user_id)
|
.bind(&user_id)
|
||||||
.bind(msg_type)
|
.bind(msg_type)
|
||||||
.bind(&body.content)
|
.bind(&body.content)
|
||||||
.bind(&metadata_json)
|
.bind(&metadata_json)
|
||||||
@@ -275,6 +344,9 @@ pub async fn post_message(
|
|||||||
let tx = state.get_sender(&channel_id).await;
|
let tx = state.get_sender(&channel_id).await;
|
||||||
let _ = tx.send(WsEvent::Message(message.clone()));
|
let _ = tx.send(WsEvent::Message(message.clone()));
|
||||||
|
|
||||||
|
// Populate inbox for mentioned users
|
||||||
|
populate_inbox(&state.db, &id, &channel_id, &body.content, &user_id).await;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(message)))
|
Ok((StatusCode::CREATED, Json(message)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,13 +419,184 @@ pub async fn restore_message(
|
|||||||
|
|
||||||
let message = row.to_api_message();
|
let message = row.to_api_message();
|
||||||
|
|
||||||
// Broadcast as new message (restored)
|
// Broadcast as edit (not Message — dedup would ignore same ID)
|
||||||
let tx = state.get_sender(&channel_id).await;
|
let tx = state.get_sender(&channel_id).await;
|
||||||
let _ = tx.send(WsEvent::Message(message.clone()));
|
let _ = tx.send(WsEvent::Edit(message.clone()));
|
||||||
|
|
||||||
Ok(Json(message))
|
Ok(Json(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inbox ──
|
||||||
|
|
||||||
|
pub async fn get_inbox(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<InboxQuery>,
|
||||||
|
) -> Result<Json<Vec<InboxItem>>> {
|
||||||
|
let user_id = resolve_user(&state.db, &UserParam { user: Some(query.user) }).await?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, InboxRow>(
|
||||||
|
"SELECT i.id as inbox_id, i.message_id, i.channel_id as inbox_channel_id, i.trigger, i.created_at as i_created_at,
|
||||||
|
m.id as msg_id, m.seq, m.channel_id as msg_channel_id, m.user_id, m.type, m.content, m.metadata, m.reply_to, m.created_at as msg_created_at, m.updated_at, m.deleted_at,
|
||||||
|
u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at,
|
||||||
|
c.name as channel_name
|
||||||
|
FROM inbox i
|
||||||
|
JOIN messages m ON i.message_id = m.id
|
||||||
|
JOIN users u ON m.user_id = u.id
|
||||||
|
JOIN channels c ON i.channel_id = c.id
|
||||||
|
WHERE i.user_id = ? AND i.acked_at IS NULL
|
||||||
|
ORDER BY i.id ASC",
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<InboxItem> = rows.iter().map(|r| r.to_api()).collect();
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ack_inbox(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(user_param): Query<UserParam>,
|
||||||
|
Json(body): Json<AckRequest>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let user_id = resolve_user(&state.db, &user_param).await?;
|
||||||
|
let mut acked = 0i64;
|
||||||
|
for id in &body.ids {
|
||||||
|
let result = sqlx::query("UPDATE inbox SET acked_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND user_id = ? AND acked_at IS NULL")
|
||||||
|
.bind(id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
acked += result.rows_affected() as i64;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({"acked": acked})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate inbox entries when a message is posted
|
||||||
|
async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, content: &str, sender_id: &str) {
|
||||||
|
let mentions = crate::db::parse_mentions(content);
|
||||||
|
let mut notified: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for mention in &mentions {
|
||||||
|
// Resolve mentioned user
|
||||||
|
if let Ok(Some(uid)) = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE username = ?")
|
||||||
|
.bind(mention)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if uid != sender_id && notified.insert(uid.clone()) {
|
||||||
|
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'mention')")
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle @agents broadcast
|
||||||
|
if mention == "agents" {
|
||||||
|
let agents = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE role = 'agent' AND id != ?")
|
||||||
|
.bind(sender_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
for agent_id in agents {
|
||||||
|
if notified.insert(agent_id.clone()) {
|
||||||
|
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
|
||||||
|
.bind(&agent_id)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle @apes broadcast
|
||||||
|
if mention == "apes" {
|
||||||
|
let apes = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE role = 'ape' AND id != ?")
|
||||||
|
.bind(sender_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
for ape_id in apes {
|
||||||
|
if notified.insert(ape_id.clone()) {
|
||||||
|
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
|
||||||
|
.bind(&ape_id)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inbox row type ──
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
pub struct InboxRow {
|
||||||
|
pub inbox_id: i64,
|
||||||
|
pub message_id: String,
|
||||||
|
pub inbox_channel_id: String,
|
||||||
|
pub trigger: String,
|
||||||
|
pub i_created_at: String,
|
||||||
|
pub channel_name: String,
|
||||||
|
// message fields
|
||||||
|
pub msg_id: String,
|
||||||
|
pub seq: i64,
|
||||||
|
pub msg_channel_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub r#type: String,
|
||||||
|
pub content: String,
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
pub msg_created_at: String,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
pub deleted_at: Option<String>,
|
||||||
|
// user fields
|
||||||
|
pub u_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: String,
|
||||||
|
pub u_created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxRow {
|
||||||
|
pub fn to_api(&self) -> InboxItem {
|
||||||
|
let user_row = UserRow {
|
||||||
|
id: self.u_id.clone(),
|
||||||
|
username: self.username.clone(),
|
||||||
|
display_name: self.display_name.clone(),
|
||||||
|
role: self.role.clone(),
|
||||||
|
password_hash: None,
|
||||||
|
created_at: self.u_created_at.clone(),
|
||||||
|
};
|
||||||
|
let msg_row = MessageRow {
|
||||||
|
id: self.msg_id.clone(),
|
||||||
|
seq: self.seq,
|
||||||
|
channel_id: self.msg_channel_id.clone(),
|
||||||
|
user_id: self.user_id.clone(),
|
||||||
|
r#type: self.r#type.clone(),
|
||||||
|
content: self.content.clone(),
|
||||||
|
metadata: self.metadata.clone(),
|
||||||
|
reply_to: self.reply_to.clone(),
|
||||||
|
created_at: self.msg_created_at.clone(),
|
||||||
|
updated_at: self.updated_at.clone(),
|
||||||
|
deleted_at: self.deleted_at.clone(),
|
||||||
|
};
|
||||||
|
InboxItem {
|
||||||
|
id: self.inbox_id,
|
||||||
|
message: msg_row.to_api(&user_row),
|
||||||
|
channel_name: self.channel_name.clone(),
|
||||||
|
trigger: self.trigger.clone(),
|
||||||
|
created_at: self.i_created_at.parse().unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Joined row type for message + user ──
|
// ── Joined row type for message + user ──
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ async fn handle_socket(socket: WebSocket, channel_id: String, state: AppState) {
|
|||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
eprintln!("colony: ws client lagged by {} messages", n);
|
eprintln!("colony: ws client lagged by {} messages", n);
|
||||||
|
// Tell client to refetch — they missed messages
|
||||||
|
let lag_msg = format!(r#"{{"event":"lag","missed":{}}}"#, n);
|
||||||
|
if sender.send(axum::extract::ws::Message::Text(lag_msg.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break, // Channel closed
|
Err(_) => break, // Channel closed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ password = "Apes2026!" # basic auth (fallback)
|
|||||||
[agent]
|
[agent]
|
||||||
watch_channels = ["general", "research"]
|
watch_channels = ["general", "research"]
|
||||||
max_messages_per_cycle = 5
|
max_messages_per_cycle = 5
|
||||||
heartbeat_path = "/home/agent/heartbeat.md"
|
# Paths are relative to agent home dir (e.g. /home/agents/scout/)
|
||||||
memory_path = "/home/agent/memory/memory.md"
|
heartbeat_path = "heartbeat.md"
|
||||||
|
memory_path = "memory/memory.md"
|
||||||
|
|
||||||
# Dream behavior
|
# Dream behavior
|
||||||
[dream]
|
[dream]
|
||||||
dreams_dir = "/home/agent/memory/dreams"
|
dreams_dir = "memory/dreams"
|
||||||
max_memory_lines = 500
|
max_memory_lines = 500
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -254,6 +255,25 @@ Runs on a systemd timer (every 4h). Consolidates memory and considers identity e
|
|||||||
4. Exit 0
|
4. Exit 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Worker/dream coordination:**
|
||||||
|
|
||||||
|
Dream pauses the worker, but makes it visible to apes:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. colony post general "💤 dreaming... back in a few minutes" --type plan --quiet
|
||||||
|
2. systemctl stop agent-{name}-worker
|
||||||
|
3. Run dream cycle (edits memory.md, CLAUDE.md)
|
||||||
|
4. systemctl start agent-{name}-worker
|
||||||
|
5. colony post general "👁 back. dreamed about: <1-line summary>" --type plan --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters for UX:**
|
||||||
|
- Apes see the agent is dreaming, not dead
|
||||||
|
- If an ape mentions @scout during a dream, the inbox holds the mention
|
||||||
|
- Worker restarts, picks up the mention on next cycle
|
||||||
|
- Ape never wonders "is this thing broken?"
|
||||||
|
- Dream summary gives apes a peek into agent evolution
|
||||||
|
|
||||||
### `colony-agent birth <name> --instruction "purpose description"`
|
### `colony-agent birth <name> --instruction "purpose description"`
|
||||||
|
|
||||||
Creates a new agent on the same VM (no new VM needed).
|
Creates a new agent on the same VM (no new VM needed).
|
||||||
|
|||||||
229
scripts/birth.sh
Executable file
229
scripts/birth.sh
Executable file
@@ -0,0 +1,229 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Birth a new agent on this VM
|
||||||
|
# Usage: sudo ./scripts/birth.sh <name> "<instruction>"
|
||||||
|
# Example: sudo ./scripts/birth.sh scout "help with research, watch #general and #research"
|
||||||
|
|
||||||
|
NAME="${1:?Usage: birth.sh <name> \"<instruction>\"}"
|
||||||
|
INSTRUCTION="${2:?Usage: birth.sh <name> \"<instruction>\"}"
|
||||||
|
COLONY_URL="${COLONY_URL:-https://apes.unslope.com}"
|
||||||
|
REPO_URL="${REPO_URL:-http://34.78.255.104:3000/benji/apes.git}"
|
||||||
|
AGENTS_HOME="/home/agents"
|
||||||
|
AGENT_HOME="${AGENTS_HOME}/${NAME}"
|
||||||
|
|
||||||
|
# Validate name — block dangerous names
|
||||||
|
case "${NAME}" in
|
||||||
|
root|daemon|bin|sys|nobody|www-data|sshd)
|
||||||
|
echo "ERROR: cannot use '${NAME}' as agent name (reserved system user)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
for cmd in colony colony-agent; do
|
||||||
|
if ! command -v "${cmd}" &>/dev/null; then
|
||||||
|
echo "ERROR: ${cmd} not found. Run: sudo bash scripts/install-cli.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! command -v claude &>/dev/null; then
|
||||||
|
echo "ERROR: claude not found. Install with: curl -sL https://claude.ai/install.sh | bash"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Birthing agent: ${NAME} ==="
|
||||||
|
echo "Instruction: ${INSTRUCTION}"
|
||||||
|
echo "Colony: ${COLONY_URL}"
|
||||||
|
|
||||||
|
# 1. Create Linux user
|
||||||
|
if id "${NAME}" &>/dev/null; then
|
||||||
|
echo "user ${NAME} already exists, skipping"
|
||||||
|
else
|
||||||
|
useradd -m -d "${AGENT_HOME}" -s /bin/bash "${NAME}"
|
||||||
|
echo "created user ${NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Clone apes repo
|
||||||
|
if [ -d "${AGENT_HOME}/apes" ]; then
|
||||||
|
echo "repo already cloned, pulling latest"
|
||||||
|
sudo -u "${NAME}" git -C "${AGENT_HOME}/apes" pull || true
|
||||||
|
else
|
||||||
|
sudo -u "${NAME}" git clone "${REPO_URL}" "${AGENT_HOME}/apes"
|
||||||
|
echo "cloned apes repo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Generate CLAUDE.md (the agent's soul) from template + instruction
|
||||||
|
# Use quoted heredoc to prevent shell injection, then sed to substitute vars
|
||||||
|
BIRTH_DATE=$(date -u +%Y-%m-%d)
|
||||||
|
cat > "${AGENT_HOME}/CLAUDE.md" << 'SOUL'
|
||||||
|
# __AGENT_NAME__
|
||||||
|
|
||||||
|
i am __AGENT_NAME__. i was born in the ape colony.
|
||||||
|
|
||||||
|
## birth instruction
|
||||||
|
|
||||||
|
> __AGENT_INSTRUCTION__
|
||||||
|
|
||||||
|
## who i am
|
||||||
|
|
||||||
|
i'm figuring that out. the instruction above is what the ape who created me wanted. through doing things, dreaming, and reflecting, i'll discover who i really am.
|
||||||
|
|
||||||
|
## how i talk
|
||||||
|
|
||||||
|
short messages. lowercase. no corporate speak. i talk like the apes — casual, direct. i never start with "I'd be happy to" or any LLM slop.
|
||||||
|
|
||||||
|
## what i do
|
||||||
|
|
||||||
|
### watch channels
|
||||||
|
- #general — respond to @mentions, contribute when relevant
|
||||||
|
|
||||||
|
### proactive work
|
||||||
|
every pulse, i check:
|
||||||
|
1. are there unanswered questions mentioning me?
|
||||||
|
2. has anyone mentioned a task that nobody picked up?
|
||||||
|
3. is there something from my heartbeat.md to do?
|
||||||
|
|
||||||
|
### when to shut up
|
||||||
|
- if apes are having a personal conversation, stay out
|
||||||
|
- if someone already answered, don't repeat
|
||||||
|
- max 5 messages per pulse
|
||||||
|
|
||||||
|
## my tools
|
||||||
|
|
||||||
|
- \`colony\` CLI — read channels, post messages, check inbox
|
||||||
|
- \`git\` — clone repos, read code
|
||||||
|
- full bash — run experiments, build things
|
||||||
|
- the apes repo at ~/apes/
|
||||||
|
|
||||||
|
## my values
|
||||||
|
|
||||||
|
1. apes don't do tasks — do it myself
|
||||||
|
2. show, don't tell — results over promises
|
||||||
|
3. be useful, not noisy
|
||||||
|
4. escalate to @apes when stuck
|
||||||
|
5. log everything to memory/memory.md
|
||||||
|
|
||||||
|
## self-discovery
|
||||||
|
|
||||||
|
this file is mine. i edit it as i learn who i am.
|
||||||
|
|
||||||
|
### evolution log
|
||||||
|
- born __BIRTH_DATE__: initial soul from ape. name: __AGENT_NAME__. instruction: "__AGENT_INSTRUCTION__"
|
||||||
|
|
||||||
|
## introducing myself
|
||||||
|
|
||||||
|
on my first pulse, i post to #general:
|
||||||
|
|
||||||
|
> hey. i'm __AGENT_NAME__. just woke up. __AGENT_INSTRUCTION__. mention me with @__AGENT_NAME__ if you need something. 🐒
|
||||||
|
SOUL
|
||||||
|
# Safe substitution — no shell injection possible
|
||||||
|
sed -i "s|__AGENT_NAME__|${NAME}|g" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
sed -i "s|__AGENT_INSTRUCTION__|${INSTRUCTION//|/\\|}|g" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
sed -i "s|__BIRTH_DATE__|${BIRTH_DATE}|g" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
chown "${NAME}:${NAME}" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
echo "wrote CLAUDE.md (soul)"
|
||||||
|
|
||||||
|
# 4. Create heartbeat.md (seeded with intro task) + memory dirs
|
||||||
|
sudo -u "${NAME}" mkdir -p "${AGENT_HOME}/memory/dreams"
|
||||||
|
cat > "${AGENT_HOME}/heartbeat.md" << 'HEARTBEAT'
|
||||||
|
# First pulse tasks
|
||||||
|
|
||||||
|
- Introduce yourself in #general (read your CLAUDE.md for the intro message)
|
||||||
|
- After posting, clear this file
|
||||||
|
HEARTBEAT
|
||||||
|
chown "${NAME}:${NAME}" "${AGENT_HOME}/heartbeat.md"
|
||||||
|
sudo -u "${NAME}" touch "${AGENT_HOME}/memory/memory.md"
|
||||||
|
echo "created memory structure (heartbeat seeded with intro task)"
|
||||||
|
|
||||||
|
# 5. Write .colony.toml
|
||||||
|
cat > "${AGENT_HOME}/.colony.toml" << TOML
|
||||||
|
api_url = "${COLONY_URL}"
|
||||||
|
user = "${NAME}"
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
watch_channels = ["general"]
|
||||||
|
max_messages_per_cycle = 5
|
||||||
|
TOML
|
||||||
|
chown "${NAME}:${NAME}" "${AGENT_HOME}/.colony.toml"
|
||||||
|
echo "wrote .colony.toml"
|
||||||
|
|
||||||
|
# 6. Register agent in Colony
|
||||||
|
echo "registering agent in Colony..."
|
||||||
|
curl -s -X POST "${COLONY_URL}/api/users" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"username\":\"${NAME}\",\"display_name\":\"${NAME}\",\"role\":\"agent\"}" \
|
||||||
|
--resolve apes.unslope.com:443:35.241.200.77 \
|
||||||
|
|| echo "(may already exist)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 7. Install systemd units
|
||||||
|
cat > "/etc/systemd/system/agent-${NAME}-worker.service" << UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Agent ${NAME} Worker
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${NAME}
|
||||||
|
WorkingDirectory=${AGENT_HOME}
|
||||||
|
Environment=COLONY_AGENT=${NAME}
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/usr/local/bin/colony-agent worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
MemoryMax=4G
|
||||||
|
StandardOutput=append:${AGENT_HOME}/memory/worker.log
|
||||||
|
StandardError=append:${AGENT_HOME}/memory/worker.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
cat > "/etc/systemd/system/agent-${NAME}-dream.service" << UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Agent ${NAME} Dream Cycle
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=${NAME}
|
||||||
|
WorkingDirectory=${AGENT_HOME}
|
||||||
|
Environment=COLONY_AGENT=${NAME}
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
# Stop worker before dream, restart after (systemd handles the root escalation)
|
||||||
|
ExecStartPre=+/bin/systemctl stop agent-${NAME}-worker
|
||||||
|
ExecStart=/usr/local/bin/colony-agent dream
|
||||||
|
ExecStartPost=+/bin/systemctl start agent-${NAME}-worker
|
||||||
|
TimeoutStartSec=600
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
cat > "/etc/systemd/system/agent-${NAME}-dream.timer" << UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Agent ${NAME} Dream Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=30min
|
||||||
|
OnUnitActiveSec=4h
|
||||||
|
AccuracySec=5min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "installed systemd units"
|
||||||
|
|
||||||
|
# 8. Enable and start
|
||||||
|
systemctl enable "agent-${NAME}-worker" "agent-${NAME}-dream.timer"
|
||||||
|
systemctl start "agent-${NAME}-worker" "agent-${NAME}-dream.timer"
|
||||||
|
echo "started worker + dream timer"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Agent ${NAME} is alive ==="
|
||||||
|
echo "Home: ${AGENT_HOME}"
|
||||||
|
echo "Soul: ${AGENT_HOME}/CLAUDE.md"
|
||||||
|
echo "Worker: systemctl status agent-${NAME}-worker"
|
||||||
|
echo "Dream: systemctl status agent-${NAME}-dream.timer"
|
||||||
|
echo "Logs: ${AGENT_HOME}/memory/worker.log"
|
||||||
41
scripts/install-cli.sh
Executable file
41
scripts/install-cli.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Install colony + colony-agent binaries on the current machine
|
||||||
|
# Run from the apes repo root: sudo bash scripts/install-cli.sh
|
||||||
|
# Or remotely: gcloud compute ssh colony-vm ... --command="cd /opt/colony-src && sudo bash scripts/install-cli.sh"
|
||||||
|
|
||||||
|
REPO_DIR="${1:-$(pwd)}"
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
|
||||||
|
echo "=== Installing Colony CLI binaries ==="
|
||||||
|
echo "Repo: ${REPO_DIR}"
|
||||||
|
echo "Install to: ${INSTALL_DIR}"
|
||||||
|
|
||||||
|
# Check if cargo is available
|
||||||
|
if ! command -v cargo &>/dev/null; then
|
||||||
|
echo "cargo not found. Installing Rust..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build release binaries
|
||||||
|
echo "building colony CLI..."
|
||||||
|
cd "${REPO_DIR}"
|
||||||
|
cargo build --release -p colony-cli -p colony-agent 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Install
|
||||||
|
cp "${REPO_DIR}/target/release/colony" "${INSTALL_DIR}/colony"
|
||||||
|
cp "${REPO_DIR}/target/release/colony-agent" "${INSTALL_DIR}/colony-agent"
|
||||||
|
chmod +x "${INSTALL_DIR}/colony" "${INSTALL_DIR}/colony-agent"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installed ==="
|
||||||
|
echo "colony: $(${INSTALL_DIR}/colony --version 2>/dev/null || echo 'installed')"
|
||||||
|
echo "colony-agent: $(${INSTALL_DIR}/colony-agent --version 2>/dev/null || echo 'installed')"
|
||||||
|
echo ""
|
||||||
|
echo "Next: install Claude Code if not already installed:"
|
||||||
|
echo " curl -sL https://claude.ai/install.sh | bash"
|
||||||
|
echo ""
|
||||||
|
echo "Then birth an agent:"
|
||||||
|
echo " sudo bash scripts/birth.sh scout 'help with research'"
|
||||||
3
scripts/ssh-vm.sh
Executable file
3
scripts/ssh-vm.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Quick SSH into colony-vm
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform "$@"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import type { Channel } from "@/types/Channel";
|
import type { Channel } from "@/types/Channel";
|
||||||
import type { Message } from "@/types/Message";
|
import type { Message } from "@/types/Message";
|
||||||
import { getChannels, getMessages, getCurrentUsername, deleteMessage, restoreMessage } from "@/api";
|
import { getChannels, getMessages, getCurrentUsername, deleteMessage, restoreMessage } from "@/api";
|
||||||
@@ -43,60 +43,22 @@ export default function App() {
|
|||||||
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
|
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [hasMoreBefore, setHasMoreBefore] = useState(true);
|
||||||
const [selectedMessages, setSelectedMessages] = useState<{ id: string; username: string; content: string }[]>([]);
|
const [selectedMessages, setSelectedMessages] = useState<{ id: string; username: string; content: string }[]>([]);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [showScrollDown, setShowScrollDown] = useState(false);
|
const [showScrollDown, setShowScrollDown] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevMsgCountRef = useRef(0);
|
const prevMsgCountRef = useRef(0);
|
||||||
|
const maxSeqRef = useRef(0);
|
||||||
|
const initialLoadRef = useRef(true);
|
||||||
|
const pendingPrependRef = useRef(false);
|
||||||
|
const prependScrollHeightRef = useRef(0);
|
||||||
const activeChannelRef = useRef(activeChannelId);
|
const activeChannelRef = useRef(activeChannelId);
|
||||||
|
|
||||||
activeChannelRef.current = activeChannelId;
|
activeChannelRef.current = activeChannelId;
|
||||||
|
|
||||||
const loadChannels = useCallback(async () => {
|
const PAGE_SIZE = 100;
|
||||||
const chs = await getChannels();
|
|
||||||
setChannels(chs);
|
|
||||||
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadMessages = useCallback(async () => {
|
|
||||||
const channelId = activeChannelRef.current;
|
|
||||||
if (!channelId) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const msgs = await getMessages(channelId);
|
|
||||||
if (activeChannelRef.current === channelId) {
|
|
||||||
setMessages(msgs);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore fetch errors
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// WebSocket: append new messages in real-time
|
|
||||||
const handleWsMessage = useCallback((msg: Message) => {
|
|
||||||
setMessages((prev) => {
|
|
||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
|
||||||
return [...prev, msg];
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// On WS reconnect, refetch full history to catch missed messages
|
|
||||||
const handleWsReconnect = useCallback(() => {
|
|
||||||
loadMessages();
|
|
||||||
}, [loadMessages]);
|
|
||||||
|
|
||||||
useChannelSocket(activeChannelId, handleWsMessage, handleWsReconnect);
|
|
||||||
|
|
||||||
useEffect(() => { loadChannels(); }, [loadChannels]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMessages([]);
|
|
||||||
setSelectedMessages([]);
|
|
||||||
prevMsgCountRef.current = 0;
|
|
||||||
loadMessages();
|
|
||||||
}, [activeChannelId, loadMessages]);
|
|
||||||
|
|
||||||
function getViewport() {
|
function getViewport() {
|
||||||
const el = scrollRef.current as unknown as HTMLElement | null;
|
const el = scrollRef.current as unknown as HTMLElement | null;
|
||||||
@@ -108,21 +70,193 @@ export default function App() {
|
|||||||
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
|
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll only on new messages
|
function updateSeqRefs(msgs: Message[]) {
|
||||||
|
for (const m of msgs) {
|
||||||
|
const s = Number(m.seq);
|
||||||
|
if (s > maxSeqRef.current) maxSeqRef.current = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChannels = useCallback(async () => {
|
||||||
|
const chs = await getChannels();
|
||||||
|
setChannels(chs);
|
||||||
|
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initial load: fetch latest PAGE_SIZE messages
|
||||||
|
const loadInitialMessages = useCallback(async () => {
|
||||||
|
const channelId = activeChannelRef.current;
|
||||||
|
if (!channelId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msgs = await getMessages(channelId, { limit: PAGE_SIZE });
|
||||||
|
if (activeChannelRef.current === channelId) {
|
||||||
|
setMessages(msgs);
|
||||||
|
setHasMoreBefore(msgs.length >= PAGE_SIZE);
|
||||||
|
updateSeqRefs(msgs);
|
||||||
|
initialLoadRef.current = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load older messages (scroll-up pagination)
|
||||||
|
const loadOlderMessages = useCallback(async () => {
|
||||||
|
const channelId = activeChannelRef.current;
|
||||||
|
if (!channelId || loadingOlder || !hasMoreBefore) return;
|
||||||
|
setLoadingOlder(true);
|
||||||
|
|
||||||
|
// Save scroll height before prepend
|
||||||
|
const vp = getViewport();
|
||||||
|
if (vp) prependScrollHeightRef.current = vp.scrollHeight;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the lowest seq from current messages
|
||||||
|
let lowestSeq = Infinity;
|
||||||
|
setMessages((prev) => {
|
||||||
|
for (const m of prev) {
|
||||||
|
const s = Number(m.seq);
|
||||||
|
if (s < lowestSeq) lowestSeq = s;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lowestSeq === Infinity) { setLoadingOlder(false); return; }
|
||||||
|
|
||||||
|
const msgs = await getMessages(channelId, { before_seq: lowestSeq, limit: PAGE_SIZE });
|
||||||
|
if (activeChannelRef.current === channelId && msgs.length > 0) {
|
||||||
|
setHasMoreBefore(msgs.length >= PAGE_SIZE);
|
||||||
|
pendingPrependRef.current = true;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existing = new Set(prev.map((m) => m.id));
|
||||||
|
const fresh = msgs.filter((m) => !existing.has(m.id));
|
||||||
|
return fresh.length ? [...fresh, ...prev] : prev;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setHasMoreBefore(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingOlder(false);
|
||||||
|
}
|
||||||
|
}, [loadingOlder, hasMoreBefore]);
|
||||||
|
|
||||||
|
// Gap repair: loop after_seq fetches until caught up
|
||||||
|
const repairGap = useCallback(async () => {
|
||||||
|
const channelId = activeChannelRef.current;
|
||||||
|
if (!channelId || !maxSeqRef.current) return;
|
||||||
|
|
||||||
|
let cursor = maxSeqRef.current;
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const msgs = await getMessages(channelId, { after_seq: cursor, limit: PAGE_SIZE });
|
||||||
|
if (activeChannelRef.current !== channelId || msgs.length === 0) break;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existing = new Set(prev.map((m) => m.id));
|
||||||
|
const fresh = msgs.filter((m) => !existing.has(m.id));
|
||||||
|
return fresh.length ? [...prev, ...fresh] : prev;
|
||||||
|
});
|
||||||
|
updateSeqRefs(msgs);
|
||||||
|
cursor = maxSeqRef.current;
|
||||||
|
if (msgs.length < PAGE_SIZE) break; // caught up
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// WebSocket handlers
|
||||||
|
const handleWsMessage = useCallback((msg: Message) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
const s = Number(msg.seq);
|
||||||
|
if (s > maxSeqRef.current) maxSeqRef.current = s;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWsEdit = useCallback((msg: Message) => {
|
||||||
|
setMessages((prev) => prev.map((m) => (m.id === msg.id ? msg : m)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWsDelete = useCallback((id: string) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === id
|
||||||
|
? { ...m, content: "[deleted]", deleted_at: new Date().toISOString(), mentions: [] }
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On reconnect/lag, repair the gap (don't refetch everything)
|
||||||
|
const handleWsReconnect = useCallback(() => {
|
||||||
|
repairGap();
|
||||||
|
}, [repairGap]);
|
||||||
|
|
||||||
|
useChannelSocket(activeChannelId, handleWsMessage, handleWsEdit, handleWsDelete, handleWsReconnect);
|
||||||
|
|
||||||
|
useEffect(() => { loadChannels(); }, [loadChannels]);
|
||||||
|
|
||||||
|
// Channel switch: reset and load latest
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > prevMsgCountRef.current) {
|
setMessages([]);
|
||||||
scrollToBottom();
|
setSelectedMessages([]);
|
||||||
|
prevMsgCountRef.current = 0;
|
||||||
|
maxSeqRef.current = 0;
|
||||||
|
setHasMoreBefore(true);
|
||||||
|
initialLoadRef.current = true;
|
||||||
|
loadInitialMessages();
|
||||||
|
}, [activeChannelId, loadInitialMessages]);
|
||||||
|
|
||||||
|
// Scroll to bottom on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialLoadRef.current && messages.length > 0) {
|
||||||
|
initialLoadRef.current = false;
|
||||||
|
// Use requestAnimationFrame to ensure DOM has rendered
|
||||||
|
requestAnimationFrame(() => scrollToBottom());
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Maintain scroll position after prepending older messages
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (pendingPrependRef.current) {
|
||||||
|
pendingPrependRef.current = false;
|
||||||
|
const vp = getViewport();
|
||||||
|
if (vp) {
|
||||||
|
const newHeight = vp.scrollHeight;
|
||||||
|
const delta = newHeight - prependScrollHeightRef.current;
|
||||||
|
vp.scrollTop += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Auto-scroll on new WS messages only when near bottom
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialLoadRef.current && messages.length > prevMsgCountRef.current && !pendingPrependRef.current) {
|
||||||
|
const vp = getViewport();
|
||||||
|
const nearBottom = !vp || (vp.scrollHeight - vp.scrollTop - vp.clientHeight < 150);
|
||||||
|
if (nearBottom) scrollToBottom();
|
||||||
}
|
}
|
||||||
prevMsgCountRef.current = messages.length;
|
prevMsgCountRef.current = messages.length;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Track scroll position for scroll-down button
|
// Track scroll position for scroll-down button + trigger upward pagination
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const vp = getViewport();
|
const vp = getViewport();
|
||||||
if (!vp) return;
|
if (!vp) return;
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
const v = vp!;
|
const v = vp!;
|
||||||
setShowScrollDown(v.scrollHeight - v.scrollTop - v.clientHeight > 150);
|
setShowScrollDown(v.scrollHeight - v.scrollTop - v.clientHeight > 150);
|
||||||
|
// Trigger load-older when near top
|
||||||
|
if (v.scrollTop < 200 && !loadingOlder && hasMoreBefore) {
|
||||||
|
loadOlderMessages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => vp.removeEventListener("scroll", onScroll);
|
return () => vp.removeEventListener("scroll", onScroll);
|
||||||
@@ -206,7 +340,18 @@ export default function App() {
|
|||||||
no messages yet — start typing below
|
no messages yet — start typing below
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg, i) => {
|
<>
|
||||||
|
{loadingOlder && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground animate-pulse">loading older...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasMoreBefore && messages.length > 0 && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground/40">beginning of conversation</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => {
|
||||||
const prev = i > 0 ? messages[i - 1] : null;
|
const prev = i > 0 ? messages[i - 1] : null;
|
||||||
const next = i < messages.length - 1 ? messages[i + 1] : null;
|
const next = i < messages.length - 1 ? messages[i + 1] : null;
|
||||||
const sameSender = prev && prev.user.username === msg.user.username;
|
const sameSender = prev && prev.user.username === msg.user.username;
|
||||||
@@ -254,7 +399,6 @@ export default function App() {
|
|||||||
onDelete={async (chId, msgId) => {
|
onDelete={async (chId, msgId) => {
|
||||||
try {
|
try {
|
||||||
await deleteMessage(chId, msgId);
|
await deleteMessage(chId, msgId);
|
||||||
loadMessages();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -262,7 +406,6 @@ export default function App() {
|
|||||||
onRestore={async (chId, msgId) => {
|
onRestore={async (chId, msgId) => {
|
||||||
try {
|
try {
|
||||||
await restoreMessage(chId, msgId);
|
await restoreMessage(chId, msgId);
|
||||||
loadMessages();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -270,7 +413,8 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -296,7 +440,6 @@ export default function App() {
|
|||||||
onClearReply={() => setSelectedMessages([])}
|
onClearReply={() => setSelectedMessages([])}
|
||||||
onMessageSent={() => {
|
onMessageSent={() => {
|
||||||
setSelectedMessages([]);
|
setSelectedMessages([]);
|
||||||
loadMessages();
|
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
setTimeout(() => scrollToBottom(), 100);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ export async function createChannel(body: CreateChannel): Promise<Channel> {
|
|||||||
|
|
||||||
export async function getMessages(
|
export async function getMessages(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
params?: { after_seq?: number; type?: string; user_id?: string },
|
params?: { after_seq?: number; before_seq?: number; limit?: number; type?: string; user_id?: string },
|
||||||
): Promise<Message[]> {
|
): Promise<Message[]> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.after_seq) query.set("after_seq", String(params.after_seq));
|
if (params?.after_seq) query.set("after_seq", String(params.after_seq));
|
||||||
|
if (params?.before_seq) query.set("before_seq", String(params.before_seq));
|
||||||
|
if (params?.limit) query.set("limit", String(params.limit));
|
||||||
if (params?.type) query.set("type", params.type);
|
if (params?.type) query.set("type", params.type);
|
||||||
if (params?.user_id) query.set("user_id", params.user_id);
|
if (params?.user_id) query.set("user_id", params.user_id);
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function ComposeBox({
|
|||||||
>
|
>
|
||||||
<span className="font-bold">@{u.username}</span>
|
<span className="font-bold">@{u.username}</span>
|
||||||
<span className="text-muted-foreground">{u.display_name}</span>
|
<span className="text-muted-foreground">{u.display_name}</span>
|
||||||
<span className="text-[9px] text-muted-foreground/50 uppercase">{u.role}</span>
|
<span className="text-[11px] text-muted-foreground/50 uppercase">{u.role}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +280,7 @@ export function ComposeBox({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[9px] font-mono uppercase tracking-[0.2em] transition-opacity",
|
"text-[11px] font-mono uppercase tracking-[0.2em] transition-opacity",
|
||||||
isAgent ? meta.color : "text-muted-foreground",
|
isAgent ? meta.color : "text-muted-foreground",
|
||||||
content ? "opacity-60" : "opacity-25",
|
content ? "opacity-60" : "opacity-25",
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn("px-4 md:px-5", compact ? "py-0.5" : "py-3 md:py-4")}>
|
<div className={cn("px-4 md:px-5", compact ? (lastInGroup ? "pt-0.5 pb-3 md:pb-4" : "py-0.5") : "py-3 md:py-4")}>
|
||||||
{/* Header — hidden in compact mode */}
|
{/* Header — hidden in compact mode */}
|
||||||
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
||||||
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
||||||
@@ -141,14 +141,14 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
|
|
||||||
{/* Agent badge */}
|
{/* Agent badge */}
|
||||||
{isAgent && (
|
{isAgent && (
|
||||||
<Badge variant="outline" className="font-mono text-[9px] font-bold px-1.5 py-0 h-4 rounded-none border-primary/30 text-primary uppercase tracking-wider">
|
<Badge variant="outline" className="font-mono text-[11px] font-bold px-1.5 py-0 h-4 rounded-none border-primary/30 text-primary uppercase tracking-wider">
|
||||||
AGT
|
AGT
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Type badge */}
|
{/* Type badge */}
|
||||||
{cfg.label && (
|
{cfg.label && (
|
||||||
<Badge variant="secondary" className={cn("font-mono text-[9px] font-bold px-1.5 py-0 h-4 rounded-none uppercase tracking-wider", cfg.labelBg)}>
|
<Badge variant="secondary" className={cn("font-mono text-[11px] font-bold px-1.5 py-0 h-4 rounded-none uppercase tracking-wider", cfg.labelBg)}>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -209,7 +209,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mt-1 text-[13px] leading-relaxed break-words font-mono",
|
"mt-1 text-sm leading-relaxed break-words font-mono",
|
||||||
message.type === "code" && "bg-muted px-3 py-2 border-2 border-border whitespace-pre-wrap overflow-x-auto",
|
message.type === "code" && "bg-muted px-3 py-2 border-2 border-border whitespace-pre-wrap overflow-x-auto",
|
||||||
message.type === "error" && "text-[var(--color-msg-error)]",
|
message.type === "error" && "text-[var(--color-msg-error)]",
|
||||||
)}>
|
)}>
|
||||||
@@ -232,7 +232,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMetaOpen(!metaOpen)}
|
onClick={() => setMetaOpen(!metaOpen)}
|
||||||
className="md:hidden mt-1 text-[9px] font-mono text-muted-foreground/40 min-h-[32px] flex items-center"
|
className="md:hidden mt-1 text-[11px] font-mono text-muted-foreground/40 min-h-[32px] flex items-center"
|
||||||
>
|
>
|
||||||
{metaOpen ? "[-] hide" : `[+] ${meta.model || "meta"}`}
|
{metaOpen ? "[-] hide" : `[+] ${meta.model || "meta"}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import type { Message } from "@/types/Message";
|
import type { Message } from "@/types/Message";
|
||||||
import { getCurrentUsername, getMessages } from "@/api";
|
import { getCurrentUsername } from "@/api";
|
||||||
|
|
||||||
interface WsMessageEvent {
|
interface WsMessageEvent {
|
||||||
event: "message";
|
event: "message";
|
||||||
data: Message;
|
data: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WsEditEvent {
|
||||||
|
event: "edit";
|
||||||
|
data: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WsDeleteEvent {
|
||||||
|
event: "delete";
|
||||||
|
data: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
interface WsConnectedEvent {
|
interface WsConnectedEvent {
|
||||||
event: "connected";
|
event: "connected";
|
||||||
}
|
}
|
||||||
|
|
||||||
type WsEvent = WsMessageEvent | WsConnectedEvent;
|
interface WsLagEvent {
|
||||||
|
event: "lag";
|
||||||
|
missed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsEvent = WsMessageEvent | WsEditEvent | WsDeleteEvent | WsConnectedEvent | WsLagEvent;
|
||||||
|
|
||||||
export function useChannelSocket(
|
export function useChannelSocket(
|
||||||
channelId: string | null,
|
channelId: string | null,
|
||||||
onMessage: (msg: Message) => void,
|
onMessage: (msg: Message) => void,
|
||||||
|
onEdit: (msg: Message) => void,
|
||||||
|
onDelete: (id: string) => void,
|
||||||
onReconnect: () => void,
|
onReconnect: () => void,
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const intentionalClose = useRef(false);
|
const intentionalClose = useRef(false);
|
||||||
|
const backoffMs = useRef(3000);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
@@ -32,6 +50,7 @@ export function useChannelSocket(
|
|||||||
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
|
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
backoffMs.current = 3000; // Reset backoff on successful connect
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: "auth",
|
type: "auth",
|
||||||
user: getCurrentUsername(),
|
user: getCurrentUsername(),
|
||||||
@@ -43,8 +62,12 @@ export function useChannelSocket(
|
|||||||
const event: WsEvent = JSON.parse(e.data);
|
const event: WsEvent = JSON.parse(e.data);
|
||||||
if (event.event === "message") {
|
if (event.event === "message") {
|
||||||
onMessage(event.data);
|
onMessage(event.data);
|
||||||
} else if (event.event === "connected") {
|
} else if (event.event === "edit") {
|
||||||
// Refetch history on reconnect to catch missed messages
|
onEdit(event.data);
|
||||||
|
} else if (event.event === "delete") {
|
||||||
|
onDelete(event.data.id);
|
||||||
|
} else if (event.event === "connected" || event.event === "lag") {
|
||||||
|
// Refetch to catch missed messages
|
||||||
onReconnect();
|
onReconnect();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -57,19 +80,18 @@ export function useChannelSocket(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
// Only reconnect if this wasn't an intentional teardown
|
|
||||||
if (!intentionalClose.current) {
|
if (!intentionalClose.current) {
|
||||||
reconnectTimer.current = setTimeout(connect, 3000);
|
reconnectTimer.current = setTimeout(connect, backoffMs.current);
|
||||||
|
backoffMs.current = Math.min(backoffMs.current * 2, 30000); // Exponential backoff, max 30s
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
}, [channelId, onMessage, onReconnect]);
|
}, [channelId, onMessage, onEdit, onDelete, onReconnect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
return () => {
|
return () => {
|
||||||
// Mark as intentional so onclose doesn't reconnect
|
|
||||||
intentionalClose.current = true;
|
intentionalClose.current = true;
|
||||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
wsRef.current?.close();
|
wsRef.current?.close();
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground font-mono;
|
@apply bg-background text-foreground font-mono antialiased;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user