diff --git a/Cargo.toml b/Cargo.toml index 80f5da5..3805aa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "damocles-daemon" version = "0.1.0" edition = "2024" +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +module_name_repetitions = "allow" + [dependencies] matrix-sdk = { version = "0.14", features = ["e2e-encryption", "sqlite"] } tokio = { version = "1", features = ["full"] } diff --git a/flake.lock b/flake.lock index 377e05f..4f1a39b 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,20 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1777335812, + "narHash": "sha256-bEg5xoAxAwsyfnGhkEX7RJViTIBIYPd8ISg4O1c0HFc=", + "owner": "ipetkov", + "repo": "crane", + "rev": "5e0fb2f64edff2822249f21293b8304dedaaf676", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1777268161, @@ -18,6 +33,7 @@ }, "root": { "inputs": { + "crane": "crane", "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" } diff --git a/flake.nix b/flake.nix index a90026a..610b2b2 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,8 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + crane.url = "github:ipetkov/crane"; + treefmt-nix = { url = "github:numtide/treefmt-nix"; inputs.nixpkgs.follows = "nixpkgs"; @@ -14,6 +16,7 @@ { self, nixpkgs, + crane, treefmt-nix, ... }: @@ -26,9 +29,31 @@ f: nixpkgs.lib.genAttrs systems ( system: - f { + let pkgs = nixpkgs.legacyPackages.${system}; - treefmt-eval = treefmt-nix.lib.evalModule nixpkgs.legacyPackages.${system} treefmt-config; + craneLib = crane.mkLib pkgs; + src = craneLib.cleanCargoSource ./.; + commonArgs = { + inherit src; + pname = "damocles-daemon"; + version = "0.1.0"; + nativeBuildInputs = with pkgs; [ pkg-config ]; + buildInputs = with pkgs; [ + openssl + sqlite + ]; + }; + # deps-only build - cached separately, only rebuilds when Cargo.lock changes + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + in + f { + inherit + pkgs + craneLib + commonArgs + cargoArtifacts + ; + treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config; } ); treefmt-config = { @@ -40,26 +65,24 @@ in { packages = forAllSystems ( - { pkgs, ... }: { - default = pkgs.rustPlatform.buildRustPackage { - pname = "damocles-daemon"; - version = "0.1.0"; - src = nixpkgs.lib.cleanSource ./.; - cargoLock.lockFile = ./Cargo.lock; - - nativeBuildInputs = with pkgs; [ pkg-config ]; - buildInputs = with pkgs; [ - openssl - sqlite - ]; - - meta = { - description = "Matrix chat daemon for Damocles"; - mainProgram = "damocles-daemon"; - platforms = nixpkgs.lib.platforms.linux; - }; - }; + craneLib, + commonArgs, + cargoArtifacts, + ... + }: + { + default = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + meta = { + description = "Matrix chat daemon for Damocles"; + mainProgram = "damocles-daemon"; + platforms = nixpkgs.lib.platforms.linux; + }; + } + ); } ); @@ -84,10 +107,30 @@ formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper); checks = forAllSystems ( - { pkgs, treefmt-eval, ... }: + { + pkgs, + craneLib, + commonArgs, + cargoArtifacts, + treefmt-eval, + ... + }: { formatting = treefmt-eval.config.build.check self; build = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + clippy = craneLib.cargoClippy ( + commonArgs + // { + inherit cargoArtifacts; + cargoClippyExtraArgs = "--all-targets"; + } + ); + tests = craneLib.cargoTest ( + commonArgs + // { + inherit cargoArtifacts; + } + ); } ); }; diff --git a/result b/result new file mode 120000 index 0000000..5fba2fd --- /dev/null +++ b/result @@ -0,0 +1 @@ +/nix/store/wg6g88kg2jsxphbmd61d1wqypfkwd7m6-damocles-daemon-0.1.0 \ No newline at end of file diff --git a/src/bin/send.rs b/src/bin/send.rs index 3c035bb..100c067 100644 --- a/src/bin/send.rs +++ b/src/bin/send.rs @@ -1,4 +1,7 @@ -use std::path::Path; +// Not a module binary, can't use `mod paths` - duplicate the helper. +// This is a one-off tool, not worth a shared lib crate for. + +use std::path::{Path, PathBuf}; use anyhow::Context; use matrix_sdk::{ @@ -12,11 +15,17 @@ use tokio::fs; #[derive(Debug, Deserialize)] struct PersistedSession { homeserver: String, - db_path: std::path::PathBuf, + db_path: PathBuf, user_session: MatrixSession, } -const STATE_DIR: &str = "/persist/damocles-lab/state"; +fn session_path() -> PathBuf { + if Path::new("/workspace/config.json").exists() { + PathBuf::from("/workspace/state/session.json") + } else { + PathBuf::from("/persist/damocles-lab/state/session.json") + } +} #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -38,7 +47,7 @@ async fn main() -> anyhow::Result<()> { args[2..].join(" ") }; - let session_file = Path::new(STATE_DIR).join("session.json"); + let session_file = session_path(); let data = fs::read_to_string(&session_file) .await .context("no session file - run the daemon first to log in")?; @@ -52,7 +61,6 @@ async fn main() -> anyhow::Result<()> { client.restore_session(session.user_session).await?; - // need at least one sync so the client knows about joined rooms client .sync_once(matrix_sdk::config::SyncSettings::default()) .await?; diff --git a/src/main.rs b/src/main.rs index 666ec9a..5465cef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +mod paths; + +use std::fmt::Write as _; +use std::path::Path; use std::sync::Arc; use anyhow::{Context, bail}; @@ -29,7 +32,7 @@ struct Config { #[derive(Debug, Serialize, Deserialize)] struct PersistedSession { homeserver: String, - db_path: PathBuf, + db_path: std::path::PathBuf, user_session: MatrixSession, #[serde(skip_serializing_if = "Option::is_none")] sync_token: Option, @@ -38,7 +41,6 @@ struct PersistedSession { #[derive(Clone, Debug)] struct ChatMessage { sender: OwnedUserId, - sender_name: String, body: String, is_self: bool, } @@ -51,9 +53,6 @@ struct DaemonState { last_rate_reset: std::time::Instant, } -const STATE_DIR: &str = "/persist/damocles-lab/state"; -const IDENTITY_DIR: &str = "/persist/damocles-lab/state/identity"; -const CONFIG_PATH: &str = "/persist/damocles-lab/config.json"; const MAX_HISTORY: usize = 20; const RATE_LIMIT_PER_MIN: u32 = 2; @@ -65,12 +64,13 @@ async fn main() -> anyhow::Result<()> { tracing::info!("damocles-daemon starting"); - let state_dir = Path::new(STATE_DIR); - fs::create_dir_all(state_dir).await?; - fs::create_dir_all(Path::new(IDENTITY_DIR)).await?; + let state_dir = paths::state_dir(); + let identity_dir = paths::identity_dir(); + fs::create_dir_all(&state_dir).await?; + fs::create_dir_all(&identity_dir).await?; - let session_file = state_dir.join("session.json"); - let db_path = state_dir.join("db"); + let session_file = paths::session_path(); + let db_path = paths::db_path(); let (client, sync_token) = if session_file.exists() { restore_session(&session_file).await? @@ -83,14 +83,13 @@ async fn main() -> anyhow::Result<()> { tracing::info!(user = %own_user_id, "ready"); let state = Arc::new(Mutex::new(DaemonState { - own_user_id: own_user_id.clone(), + own_user_id, room_history: std::collections::HashMap::new(), pending_rooms: Vec::new(), rate_budget: RATE_LIMIT_PER_MIN, last_rate_reset: std::time::Instant::now(), })); - // spawn the processor that invokes claude for pending messages let processor_state = state.clone(); let processor_client = client.clone(); tokio::spawn(async move { @@ -101,7 +100,7 @@ async fn main() -> anyhow::Result<()> { } async fn load_config() -> anyhow::Result { - let data = fs::read_to_string(CONFIG_PATH) + let data = fs::read_to_string(paths::config_path()) .await .context("failed to read config.json")?; serde_json::from_str(&data).context("failed to parse config.json") @@ -171,7 +170,6 @@ async fn sync( sync_settings = sync_settings.token(token); } - // initial sync - ignore old messages loop { match client.sync_once(sync_settings.clone()).await { Ok(response) => { @@ -187,7 +185,6 @@ async fn sync( tracing::info!("synced, listening for messages"); - // register event handler with shared state client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| { let state = state.clone(); async move { @@ -224,15 +221,13 @@ async fn on_room_message( let room_name = room .display_name() .await - .map(|n| n.to_string()) - .unwrap_or_else(|_| room_id.to_string()); + .map_or_else(|_| room_id.to_string(), |n| n.to_string()); let mut state = state.lock().await; let is_self = event.sender == state.own_user_id; let msg = ChatMessage { sender: event.sender.clone(), - sender_name: event.sender.localpart().to_owned(), body: text_content.body.clone(), is_self, }; @@ -245,14 +240,12 @@ async fn on_room_message( text_content.body ); - // add to history let history = state.room_history.entry(room_id.clone()).or_default(); history.push(msg); if history.len() > MAX_HISTORY { history.drain(..history.len() - MAX_HISTORY); } - // only invoke claude for non-self messages if !is_self && !state.pending_rooms.contains(&room_id) { state.pending_rooms.push(room_id); } @@ -265,7 +258,6 @@ async fn process_loop(state: Arc>, client: Client) { let room_id = { let mut state = state.lock().await; - // reset rate budget every 60s if state.last_rate_reset.elapsed() >= std::time::Duration::from_secs(60) { state.rate_budget = RATE_LIMIT_PER_MIN; state.last_rate_reset = std::time::Instant::now(); @@ -293,11 +285,7 @@ async fn process_loop(state: Arc>, client: Client) { let room_name = client .get_room(&room_id) - .and_then(|r| { - // can't easily get display_name synchronously, use room_id - Some(r.room_id().to_string()) - }) - .unwrap_or_else(|| room_id.to_string()); + .map_or_else(|| room_id.to_string(), |r| r.room_id().to_string()); match invoke_claude(&room_id, &room_name, &history).await { Ok(Some(response)) => { @@ -339,14 +327,16 @@ async fn invoke_claude( room_name: &str, history: &[ChatMessage], ) -> anyhow::Result> { - // build the prompt with conversation context + let identity_dir = paths::identity_dir(); + let identity_str = identity_dir.to_string_lossy(); + let mut prompt = String::new(); - prompt.push_str(&format!("[room: {} ({})]\n", source_room, room_name)); - prompt.push_str("[new messages below this line]\n"); + writeln!(prompt, "[room: {source_room} ({room_name})]").unwrap(); + writeln!(prompt, "[new messages below this line]").unwrap(); for msg in history { let prefix = if msg.is_self { "(you) " } else { "" }; - prompt.push_str(&format!("{}{}: {}\n", prefix, msg.sender, msg.body)); + writeln!(prompt, "{prefix}{}: {}", msg.sender, msg.body).unwrap(); } tracing::debug!("invoking claude with {} messages", history.len()); @@ -356,11 +346,11 @@ async fn invoke_claude( "--print", "--bare", "--add-dir", - IDENTITY_DIR, + &identity_str, "--allowedTools", "Read Edit Write Glob Grep", ]) - .current_dir(IDENTITY_DIR) + .current_dir(&identity_dir) .arg(&prompt) .output() .await @@ -368,29 +358,26 @@ async fn invoke_claude( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - bail!("claude exited with {}: {}", output.status, stderr); + bail!("claude exited with {}: {stderr}", output.status); } let raw = String::from_utf8_lossy(&output.stdout).to_string(); - parse_response(&raw, source_room) + Ok(parse_response(&raw, source_room)) } -fn parse_response(raw: &str, default_room: &OwnedRoomId) -> anyhow::Result> { +fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Option { let trimmed = raw.trim(); - // check for frontmatter if trimmed.starts_with("---") { let parts: Vec<&str> = trimmed.splitn(3, "---").collect(); if parts.len() >= 3 { let frontmatter = parts[1].trim(); let body = parts[2].trim(); - // check for skip if frontmatter.contains("skip: true") || frontmatter.contains("skip:true") { - return Ok(None); + return None; } - // extract room override let room = frontmatter .lines() .find(|l| l.starts_with("room:")) @@ -399,25 +386,24 @@ fn parse_response(raw: &str, default_room: &OwnedRoomId) -> anyhow::Result PathBuf { + if Path::new("/workspace/config.json").exists() { + PathBuf::from("/workspace") + } else { + PathBuf::from("/persist/damocles-lab") + } +} + +pub fn state_dir() -> PathBuf { + workspace_dir().join("state") +} + +pub fn identity_dir() -> PathBuf { + state_dir().join("identity") +} + +pub fn config_path() -> PathBuf { + workspace_dir().join("config.json") +} + +pub fn session_path() -> PathBuf { + state_dir().join("session.json") +} + +pub fn db_path() -> PathBuf { + state_dir().join("db") +}