crane for cached dep builds, clippy pedantic, extract paths module
This commit is contained in:
parent
4c17146b6f
commit
888eddf093
7 changed files with 167 additions and 80 deletions
|
|
@ -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
16
flake.lock
generated
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
flake.nix
77
flake.nix
|
|
@ -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 = {
|
meta = {
|
||||||
description = "Matrix chat daemon for Damocles";
|
description = "Matrix chat daemon for Damocles";
|
||||||
mainProgram = "damocles-daemon";
|
mainProgram = "damocles-daemon";
|
||||||
platforms = nixpkgs.lib.platforms.linux;
|
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
1
result
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/nix/store/wg6g88kg2jsxphbmd61d1wqypfkwd7m6-damocles-daemon-0.1.0
|
||||||
|
|
@ -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?;
|
||||||
|
|
|
||||||
92
src/main.rs
92
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 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
29
src/paths.rs
Normal 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")
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue