crane for cached dep builds, clippy pedantic, extract paths module

This commit is contained in:
Damocles 2026-04-29 23:27:49 +02:00
parent 4c17146b6f
commit 888eddf093
7 changed files with 167 additions and 80 deletions

View file

@ -3,6 +3,10 @@ name = "damocles-daemon"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
[dependencies] [dependencies]
matrix-sdk = { version = "0.14", features = ["e2e-encryption", "sqlite"] } matrix-sdk = { version = "0.14", features = ["e2e-encryption", "sqlite"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

16
flake.lock generated
View file

@ -1,5 +1,20 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1777335812,
"narHash": "sha256-bEg5xoAxAwsyfnGhkEX7RJViTIBIYPd8ISg4O1c0HFc=",
"owner": "ipetkov",
"repo": "crane",
"rev": "5e0fb2f64edff2822249f21293b8304dedaaf676",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1777268161, "lastModified": 1777268161,
@ -18,6 +33,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
} }

View file

@ -4,6 +4,8 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
treefmt-nix = { treefmt-nix = {
url = "github:numtide/treefmt-nix"; url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@ -14,6 +16,7 @@
{ {
self, self,
nixpkgs, nixpkgs,
crane,
treefmt-nix, treefmt-nix,
... ...
}: }:
@ -26,9 +29,31 @@
f: f:
nixpkgs.lib.genAttrs systems ( nixpkgs.lib.genAttrs systems (
system: system:
f { let
pkgs = nixpkgs.legacyPackages.${system}; 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 = { treefmt-config = {
@ -40,26 +65,24 @@
in in
{ {
packages = forAllSystems ( packages = forAllSystems (
{ pkgs, ... }:
{ {
default = pkgs.rustPlatform.buildRustPackage { craneLib,
pname = "damocles-daemon"; commonArgs,
version = "0.1.0"; cargoArtifacts,
src = nixpkgs.lib.cleanSource ./.; ...
cargoLock.lockFile = ./Cargo.lock; }:
{
nativeBuildInputs = with pkgs; [ pkg-config ]; default = craneLib.buildPackage (
buildInputs = with pkgs; [ commonArgs
openssl // {
sqlite inherit cargoArtifacts;
]; meta = {
description = "Matrix chat daemon for Damocles";
meta = { mainProgram = "damocles-daemon";
description = "Matrix chat daemon for Damocles"; platforms = nixpkgs.lib.platforms.linux;
mainProgram = "damocles-daemon"; };
platforms = nixpkgs.lib.platforms.linux; }
}; );
};
} }
); );
@ -84,10 +107,30 @@
formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper); formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper);
checks = forAllSystems ( checks = forAllSystems (
{ pkgs, treefmt-eval, ... }: {
pkgs,
craneLib,
commonArgs,
cargoArtifacts,
treefmt-eval,
...
}:
{ {
formatting = treefmt-eval.config.build.check self; formatting = treefmt-eval.config.build.check self;
build = self.packages.${pkgs.stdenv.hostPlatform.system}.default; build = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
clippy = craneLib.cargoClippy (
commonArgs
// {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets";
}
);
tests = craneLib.cargoTest (
commonArgs
// {
inherit cargoArtifacts;
}
);
} }
); );
}; };

1
result Symbolic link
View file

@ -0,0 +1 @@
/nix/store/wg6g88kg2jsxphbmd61d1wqypfkwd7m6-damocles-daemon-0.1.0

View file

@ -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 anyhow::Context;
use matrix_sdk::{ use matrix_sdk::{
@ -12,11 +15,17 @@ use tokio::fs;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PersistedSession { struct PersistedSession {
homeserver: String, homeserver: String,
db_path: std::path::PathBuf, db_path: PathBuf,
user_session: MatrixSession, 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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@ -38,7 +47,7 @@ async fn main() -> anyhow::Result<()> {
args[2..].join(" ") 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) let data = fs::read_to_string(&session_file)
.await .await
.context("no session file - run the daemon first to log in")?; .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?; client.restore_session(session.user_session).await?;
// need at least one sync so the client knows about joined rooms
client client
.sync_once(matrix_sdk::config::SyncSettings::default()) .sync_once(matrix_sdk::config::SyncSettings::default())
.await?; .await?;

View file

@ -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 std::sync::Arc;
use anyhow::{Context, bail}; use anyhow::{Context, bail};
@ -29,7 +32,7 @@ struct Config {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct PersistedSession { struct PersistedSession {
homeserver: String, homeserver: String,
db_path: PathBuf, db_path: std::path::PathBuf,
user_session: MatrixSession, user_session: MatrixSession,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
sync_token: Option<String>, sync_token: Option<String>,
@ -38,7 +41,6 @@ struct PersistedSession {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct ChatMessage { struct ChatMessage {
sender: OwnedUserId, sender: OwnedUserId,
sender_name: String,
body: String, body: String,
is_self: bool, is_self: bool,
} }
@ -51,9 +53,6 @@ struct DaemonState {
last_rate_reset: std::time::Instant, 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 MAX_HISTORY: usize = 20;
const RATE_LIMIT_PER_MIN: u32 = 2; const RATE_LIMIT_PER_MIN: u32 = 2;
@ -65,12 +64,13 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("damocles-daemon starting"); tracing::info!("damocles-daemon starting");
let state_dir = Path::new(STATE_DIR); let state_dir = paths::state_dir();
fs::create_dir_all(state_dir).await?; let identity_dir = paths::identity_dir();
fs::create_dir_all(Path::new(IDENTITY_DIR)).await?; fs::create_dir_all(&state_dir).await?;
fs::create_dir_all(&identity_dir).await?;
let session_file = state_dir.join("session.json"); let session_file = paths::session_path();
let db_path = state_dir.join("db"); let db_path = paths::db_path();
let (client, sync_token) = if session_file.exists() { let (client, sync_token) = if session_file.exists() {
restore_session(&session_file).await? restore_session(&session_file).await?
@ -83,14 +83,13 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(user = %own_user_id, "ready"); tracing::info!(user = %own_user_id, "ready");
let state = Arc::new(Mutex::new(DaemonState { let state = Arc::new(Mutex::new(DaemonState {
own_user_id: own_user_id.clone(), own_user_id,
room_history: std::collections::HashMap::new(), room_history: std::collections::HashMap::new(),
pending_rooms: Vec::new(), pending_rooms: Vec::new(),
rate_budget: RATE_LIMIT_PER_MIN, rate_budget: RATE_LIMIT_PER_MIN,
last_rate_reset: std::time::Instant::now(), last_rate_reset: std::time::Instant::now(),
})); }));
// spawn the processor that invokes claude for pending messages
let processor_state = state.clone(); let processor_state = state.clone();
let processor_client = client.clone(); let processor_client = client.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -101,7 +100,7 @@ async fn main() -> anyhow::Result<()> {
} }
async fn load_config() -> anyhow::Result<Config> { async fn load_config() -> anyhow::Result<Config> {
let data = fs::read_to_string(CONFIG_PATH) let data = fs::read_to_string(paths::config_path())
.await .await
.context("failed to read config.json")?; .context("failed to read config.json")?;
serde_json::from_str(&data).context("failed to parse 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); sync_settings = sync_settings.token(token);
} }
// initial sync - ignore old messages
loop { loop {
match client.sync_once(sync_settings.clone()).await { match client.sync_once(sync_settings.clone()).await {
Ok(response) => { Ok(response) => {
@ -187,7 +185,6 @@ async fn sync(
tracing::info!("synced, listening for messages"); tracing::info!("synced, listening for messages");
// register event handler with shared state
client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| { client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
let state = state.clone(); let state = state.clone();
async move { async move {
@ -224,15 +221,13 @@ async fn on_room_message(
let room_name = room let room_name = room
.display_name() .display_name()
.await .await
.map(|n| n.to_string()) .map_or_else(|_| room_id.to_string(), |n| n.to_string());
.unwrap_or_else(|_| room_id.to_string());
let mut state = state.lock().await; let mut state = state.lock().await;
let is_self = event.sender == state.own_user_id; let is_self = event.sender == state.own_user_id;
let msg = ChatMessage { let msg = ChatMessage {
sender: event.sender.clone(), sender: event.sender.clone(),
sender_name: event.sender.localpart().to_owned(),
body: text_content.body.clone(), body: text_content.body.clone(),
is_self, is_self,
}; };
@ -245,14 +240,12 @@ async fn on_room_message(
text_content.body text_content.body
); );
// add to history
let history = state.room_history.entry(room_id.clone()).or_default(); let history = state.room_history.entry(room_id.clone()).or_default();
history.push(msg); history.push(msg);
if history.len() > MAX_HISTORY { if history.len() > MAX_HISTORY {
history.drain(..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) { if !is_self && !state.pending_rooms.contains(&room_id) {
state.pending_rooms.push(room_id); state.pending_rooms.push(room_id);
} }
@ -265,7 +258,6 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
let room_id = { let room_id = {
let mut state = state.lock().await; let mut state = state.lock().await;
// reset rate budget every 60s
if state.last_rate_reset.elapsed() >= std::time::Duration::from_secs(60) { if state.last_rate_reset.elapsed() >= std::time::Duration::from_secs(60) {
state.rate_budget = RATE_LIMIT_PER_MIN; state.rate_budget = RATE_LIMIT_PER_MIN;
state.last_rate_reset = std::time::Instant::now(); state.last_rate_reset = std::time::Instant::now();
@ -293,11 +285,7 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
let room_name = client let room_name = client
.get_room(&room_id) .get_room(&room_id)
.and_then(|r| { .map_or_else(|| room_id.to_string(), |r| r.room_id().to_string());
// can't easily get display_name synchronously, use room_id
Some(r.room_id().to_string())
})
.unwrap_or_else(|| room_id.to_string());
match invoke_claude(&room_id, &room_name, &history).await { match invoke_claude(&room_id, &room_name, &history).await {
Ok(Some(response)) => { Ok(Some(response)) => {
@ -339,14 +327,16 @@ async fn invoke_claude(
room_name: &str, room_name: &str,
history: &[ChatMessage], history: &[ChatMessage],
) -> anyhow::Result<Option<ClaudeResponse>> { ) -> anyhow::Result<Option<ClaudeResponse>> {
// 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(); let mut prompt = String::new();
prompt.push_str(&format!("[room: {} ({})]\n", source_room, room_name)); writeln!(prompt, "[room: {source_room} ({room_name})]").unwrap();
prompt.push_str("[new messages below this line]\n"); writeln!(prompt, "[new messages below this line]").unwrap();
for msg in history { for msg in history {
let prefix = if msg.is_self { "(you) " } else { "" }; 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()); tracing::debug!("invoking claude with {} messages", history.len());
@ -356,11 +346,11 @@ async fn invoke_claude(
"--print", "--print",
"--bare", "--bare",
"--add-dir", "--add-dir",
IDENTITY_DIR, &identity_str,
"--allowedTools", "--allowedTools",
"Read Edit Write Glob Grep", "Read Edit Write Glob Grep",
]) ])
.current_dir(IDENTITY_DIR) .current_dir(&identity_dir)
.arg(&prompt) .arg(&prompt)
.output() .output()
.await .await
@ -368,29 +358,26 @@ async fn invoke_claude(
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); 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(); 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<Option<ClaudeResponse>> { fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Option<ClaudeResponse> {
let trimmed = raw.trim(); let trimmed = raw.trim();
// check for frontmatter
if trimmed.starts_with("---") { if trimmed.starts_with("---") {
let parts: Vec<&str> = trimmed.splitn(3, "---").collect(); let parts: Vec<&str> = trimmed.splitn(3, "---").collect();
if parts.len() >= 3 { if parts.len() >= 3 {
let frontmatter = parts[1].trim(); let frontmatter = parts[1].trim();
let body = parts[2].trim(); let body = parts[2].trim();
// check for skip
if frontmatter.contains("skip: true") || frontmatter.contains("skip:true") { if frontmatter.contains("skip: true") || frontmatter.contains("skip:true") {
return Ok(None); return None;
} }
// extract room override
let room = frontmatter let room = frontmatter
.lines() .lines()
.find(|l| l.starts_with("room:")) .find(|l| l.starts_with("room:"))
@ -399,25 +386,24 @@ fn parse_response(raw: &str, default_room: &OwnedRoomId) -> anyhow::Result<Optio
.unwrap_or_else(|| default_room.clone()); .unwrap_or_else(|| default_room.clone());
if body.is_empty() { if body.is_empty() {
return Ok(None); return None;
} }
return Ok(Some(ClaudeResponse { return Some(ClaudeResponse {
room, room,
body: body.to_owned(), body: body.to_owned(),
})); });
} }
} }
// no frontmatter - treat entire output as message to default room
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(None); return None;
} }
Ok(Some(ClaudeResponse { Some(ClaudeResponse {
room: default_room.clone(), room: default_room.clone(),
body: trimmed.to_owned(), body: trimmed.to_owned(),
})) })
} }
#[cfg(test)] #[cfg(test)]
@ -431,7 +417,7 @@ mod tests {
#[test] #[test]
fn parse_frontmatter_response() { fn parse_frontmatter_response() {
let raw = "---\nroom: !other:server\n---\nhello world"; let raw = "---\nroom: !other:server\n---\nhello world";
let resp = parse_response(raw, &test_room()).unwrap().unwrap(); let resp = parse_response(raw, &test_room()).unwrap();
assert_eq!(resp.room.as_str(), "!other:server"); assert_eq!(resp.room.as_str(), "!other:server");
assert_eq!(resp.body, "hello world"); assert_eq!(resp.body, "hello world");
} }
@ -439,27 +425,27 @@ mod tests {
#[test] #[test]
fn parse_skip_response() { fn parse_skip_response() {
let raw = "---\nskip: true\n---\n"; let raw = "---\nskip: true\n---\n";
assert!(parse_response(raw, &test_room()).unwrap().is_none()); assert!(parse_response(raw, &test_room()).is_none());
} }
#[test] #[test]
fn parse_plain_response() { fn parse_plain_response() {
let raw = "just a message"; let raw = "just a message";
let resp = parse_response(raw, &test_room()).unwrap().unwrap(); let resp = parse_response(raw, &test_room()).unwrap();
assert_eq!(resp.room, test_room()); assert_eq!(resp.room, test_room());
assert_eq!(resp.body, "just a message"); assert_eq!(resp.body, "just a message");
} }
#[test] #[test]
fn parse_empty_response() { fn parse_empty_response() {
assert!(parse_response("", &test_room()).unwrap().is_none()); assert!(parse_response("", &test_room()).is_none());
assert!(parse_response(" \n ", &test_room()).unwrap().is_none()); assert!(parse_response(" \n ", &test_room()).is_none());
} }
#[test] #[test]
fn parse_default_room() { fn parse_default_room() {
let raw = "---\n---\nhello"; let raw = "---\n---\nhello";
let resp = parse_response(raw, &test_room()).unwrap().unwrap(); let resp = parse_response(raw, &test_room()).unwrap();
assert_eq!(resp.room, test_room()); assert_eq!(resp.room, test_room());
} }
} }

29
src/paths.rs Normal file
View file

@ -0,0 +1,29 @@
use std::path::{Path, PathBuf};
pub fn workspace_dir() -> 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")
}