split discord modules
84 unmodified lines858687888984 unmodified lines{"id":"int-eabe816d","kind":"field_change","created_at":"2026-06-30T22:04:39.33696926Z","actor":"dawn","issue_id":"klbr-41z","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Changed daemon default websocket bind from 0.0.0.0:8765 to 127.0.0.1:8765 and replaced DumpMemories std::fs::write with tokio::fs::write(...).await. Verified rg for old patterns, cargo fmt --check, and cargo test -p klbr-daemon."}}{"id":"int-a150f81b","kind":"field_change","created_at":"2026-06-30T22:08:23.925241993Z","actor":"dawn","issue_id":"klbr-7e0","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Removed unregistered old memory tool modules and updated memory docs"}}{"id":"int-c575a8f2","kind":"field_change","created_at":"2026-06-30T22:20:51.577686369Z","actor":"dawn","issue_id":"klbr-x1i","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Replaced klbr-bench raw argv dispatch with clap-derived commands and structured runner options"}}{"id":"int-96f16ee3","kind":"field_change","created_at":"2026-06-30T22:27:11.180142339Z","actor":"dawn","issue_id":"klbr-rds","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Split klbr-bench main.rs into cli, continuous_loop, and longmem modules while preserving bench behavior"}}{"id":"int-5c780dfb","kind":"field_change","created_at":"2026-06-30T22:34:06.198966004Z","actor":"dawn","issue_id":"klbr-f8w","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Split Discord formatting, inbox storage, and tool schema builders into modules and deduplicated batch row rendering prep"}}43 unmodified lines4445464748484950515244 unmodified lines43 unmodified lines{"_type":"issue","id":"klbr-u03","title":"bridge: refactor hardcoded /home/mayer paths and make binary path configurable","description":"antigravity-bridge/src/auth.rs contains hardcoded /home/mayer home directories and hardcoded binary path BINARY_PATH. Use dirs::home_dir() and make the binary path configurable via environment variables or configuration.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:42:26Z","created_by":"dawn","updated_at":"2026-06-30T21:59:42Z","started_at":"2026-06-30T21:58:02Z","closed_at":"2026-06-30T21:59:42Z","close_reason":"Replaced hardcoded /home/mayer paths in antigravity bridge auth with dirs::home_dir()-based helpers, added ANTIGRAVITY_BRIDGE_CONFIG_DIR for token/cache files, and made the agy binary path configurable via ANTIGRAVITY_BRIDGE_BINARY_PATH or AGY_BINARY_PATH with ~/.local/bin/agy fallback. Verified cargo fmt --check and cargo test -p antigravity-bridge (9 passed).","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-noi","title":"nix: fix broken flake.nix referencing non-existent default.nix","description":"flake.nix references ./default.nix to build the default package, but default.nix does not exist in the repository. Refactor flake.nix to output the derivation directly using nix-cargo-integration outputs.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:42:19Z","created_by":"dawn","updated_at":"2026-06-30T22:01:41Z","started_at":"2026-06-30T22:00:06Z","closed_at":"2026-06-30T22:01:41Z","close_reason":"Replaced the missing ./default.nix package with config.nci.outputs.\"klbr-daemon\".packages.release. Verified nix eval .#packages.x86_64-linux.default.name returns klbr-daemon and nix flake show succeeds without the default.nix error.","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-x1i","title":"bench: replace manual CLI argument parsing with clap","description":"klbr-bench main.rs parses command-line arguments using raw std::env::args and index offsets. This is fragile and lacks auto-help or completions. Port CLI arguments parsing to clap.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:42:13Z","created_by":"dawn","updated_at":"2026-06-30T22:20:52Z","started_at":"2026-06-30T22:09:02Z","closed_at":"2026-06-30T22:20:52Z","close_reason":"Replaced klbr-bench raw argv dispatch with clap-derived commands and structured runner options","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-rds","title":"bench: split 6700-line main.rs monolith into logical submodules","description":"klbr-bench main.rs is currently over 6700 lines of code. It contains benchmark suites for passive recall, continuous loop, router, sweep, and command line parsing. Needs to be split into domain submodules (cli.rs, sweep.rs, passive_recall.rs, continuous_loop.rs, etc.).","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:42:03Z","created_by":"dawn","updated_at":"2026-06-30T22:27:11Z","started_at":"2026-06-30T22:21:17Z","closed_at":"2026-06-30T22:27:11Z","close_reason":"Split klbr-bench main.rs into cli, continuous_loop, and longmem modules while preserving bench behavior","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-f8w","title":"discord: decompose monolithic lib.rs and deduplicate batch formatting","description":"klbr-discord lib.rs is a 2150-line monolith containing gateway loop, state database, formatting styles, and tools. Split into logical submodules, and deduplicate bracket/xml string formatting loops.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:40:14Z","created_by":"dawn","updated_at":"2026-06-30T21:40:14Z","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-f8w","title":"discord: decompose monolithic lib.rs and deduplicate batch formatting","description":"klbr-discord lib.rs is a 2150-line monolith containing gateway loop, state database, formatting styles, and tools. Split into logical submodules, and deduplicate bracket/xml string formatting loops.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:40:14Z","created_by":"dawn","updated_at":"2026-06-30T22:34:06Z","started_at":"2026-06-30T22:27:44Z","closed_at":"2026-06-30T22:34:06Z","close_reason":"Split Discord formatting, inbox storage, and tool schema builders into modules and deduplicated batch row rendering prep","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-41z","title":"daemon: fix blocking std::fs calls and secure default WS bind address","description":"DumpMemories handler in daemon.rs blocks the tokio runtime thread using sync std::fs::write. Fix to tokio::fs::write. Also, change default WS bind address to 127.0.0.1 for local-only safety.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:40:08Z","created_by":"dawn","updated_at":"2026-06-30T22:04:39Z","started_at":"2026-06-30T22:03:35Z","closed_at":"2026-06-30T22:04:39Z","close_reason":"Changed daemon default websocket bind from 0.0.0.0:8765 to 127.0.0.1:8765 and replaced DumpMemories std::fs::write with tokio::fs::write(...).await. Verified rg for old patterns, cargo fmt --check, and cargo test -p klbr-daemon.","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-5h3","title":"ipc/daemon: eliminate duplicate DTO structs and field mapping boilerplate","description":"HistoryEntry, ToolCall, CompactionRecord, and ResolutionEventDto are identical duplicates between klbr-ipc and klbr-core. The daemon has extensive boilerplate mapping them field-by-field. Share or re-export these types.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:40:01Z","created_by":"dawn","updated_at":"2026-06-30T21:40:01Z","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-2kc","title":"core: reduce KDL config parser boilerplate in parser.rs","description":"parser.rs contains ~20 copy-pasted optional_*_node helpers that share identical structure. Simplify with macros or generic helpers. Also resolve duplicate deserialize_usize.","status":"open","priority":2,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:55Z","created_by":"dawn","updated_at":"2026-06-30T21:39:55Z","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-zue","title":"core: fix shell.rs unsafe byte-slice truncation","description":"shell.rs truncates command output using byte offsets on a lossy UTF-8 string (e.g. stdout[..20_000]), which can panic if slicing in the middle of a multi-byte character. Truncate by char count or indices instead.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T21:39:48Z","created_by":"dawn","updated_at":"2026-06-30T22:03:16Z","started_at":"2026-06-30T22:02:13Z","closed_at":"2026-06-30T22:03:16Z","close_reason":"Replaced shell stdout/stderr byte slicing with char-boundary truncation helper and added UTF-8 regression tests. Verified cargo test -p klbr-core shell_truncation, full cargo test -p klbr-core (168 passed, 1 ignored), cargo fmt --check, git diff --check, and rg confirms the unsafe stdout/stderr slices are gone.","dependency_count":0,"dependent_count":0,"comment_count":0}44 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272use super::*;
pub(super) fn format_history( channel_id: Id<ChannelMarker>, conversation_id: Option<&str>, messages: &[Message], style: FormatStyle,) -> String { match style { FormatStyle::Brackets => { let conversation = conversation_id .map(|id| format!("conv:{id}")) .unwrap_or_else(|| format!("channel:{channel_id}")); let mut lines = vec![ "[discord history]".to_string(), conversation, "messages:".to_string(), ];
for message in messages.iter().rev() { let author = message .author .global_name .as_deref() .unwrap_or(&message.author.name); lines.push(format!( "[msg:{} {}] {}: {}", message.id, format_discord_timestamp(message.timestamp), author, one_line(&message.content) )); }
lines.join("\n") } FormatStyle::Xml => { let mut lines = vec![]; let conversation_attr = conversation_id .map(|id| format!(" conversation_id=\"{id}\"")) .unwrap_or_else(|| format!(" channel_id=\"{channel_id}\"")); lines.push(format!("<discord_history{}>", conversation_attr));
for message in messages.iter().rev() { let author = message .author .global_name .as_deref() .unwrap_or(&message.author.name); lines.push(format!( " <message msg_id=\"{}\" timestamp=\"{}\" author=\"{}\">{}</message>", message.id, format_discord_timestamp(message.timestamp), author, one_line(&message.content) )); }
lines.push("</discord_history>".to_string()); lines.join("\n") } }}
pub(super) fn format_discord_batch( messages: &[DiscordIncomingMessage], pending: &[DiscordInboxItem], include_instructions: bool, style: FormatStyle,) -> String { let rows = batch_message_rows(messages, pending); let older_pending = older_pending_items(messages, pending);
match style { FormatStyle::Brackets => { let mut lines = vec!["[discord message batch]".to_string()]; if include_instructions { lines.push("messages are context, not direct requests".to_string()); lines.push("for dm: reply if a response is needed".to_string()); lines.push( "for guild: channel context only — replying is optional, use judgment" .to_string(), ); lines.push("use discord_send to respond (marks pending items acted); use discord_mark only when not sending".to_string()); } lines.push("messages:".to_string());
for row in &rows { let message = row.message; lines.push(format!( "[conv:{} msg:{} {} {}] {}: {}", message.conversation_id, message.message_id, row.context, format_discord_batch_time(message.timestamp), message.author_name, one_line(&message.content) )); }
if !older_pending.is_empty() { lines.push("pending:".to_string()); for item in older_pending { lines.push(format!( "[msg:{} {} conv:{} {}] {}: {}", item.item_id, item.bucket, item.conversation_id, format_discord_batch_time(item.timestamp), item.author_name, one_line(&item.content) )); } }
lines.join("\n") } FormatStyle::Xml => { let mut lines = vec!["<discord_message_batch>".to_string()]; if include_instructions { lines.push(" <instructions>".to_string()); lines.push(" messages are context, not direct requests".to_string()); lines.push(" for dm: reply if a response is needed".to_string()); lines.push( " for guild: channel context only — replying is optional, use judgment" .to_string(), ); lines.push(" use discord_send to respond (marks pending items acted); use discord_mark only when not sending".to_string()); lines.push(" </instructions>".to_string()); }
lines.push(" <messages>".to_string()); for row in &rows { let message = row.message; let item_attr = row .pending_item .map(|item| format!(" item_id=\"{}\"", item.item_id)) .unwrap_or_default();
lines.push(format!( " <message msg_id=\"{}\"{} conv_id=\"{}\" context=\"{}\" timestamp=\"{}\" author=\"{}\">{}</message>", message.message_id, item_attr, message.conversation_id, row.context, format_discord_batch_time(message.timestamp), message.author_name, one_line(&message.content) )); } lines.push(" </messages>".to_string());
if !older_pending.is_empty() { lines.push(" <pending>".to_string()); for item in older_pending { lines.push(format!( " <message msg_id=\"{}\" item_id=\"{}\" conv_id=\"{}\" context=\"{}\" timestamp=\"{}\" author=\"{}\">{}</message>", item.message_id, item.item_id, item.conversation_id, item.bucket, format_discord_batch_time(item.timestamp), item.author_name, one_line(&item.content) )); } lines.push(" </pending>".to_string()); }
lines.push("</discord_message_batch>".to_string()); lines.join("\n") } }}
struct BatchMessageRow<'a> { message: &'a DiscordIncomingMessage, pending_item: Option<&'a DiscordInboxItem>, context: &'a str,}
fn batch_message_rows<'a>( messages: &'a [DiscordIncomingMessage], pending: &'a [DiscordInboxItem],) -> Vec<BatchMessageRow<'a>> { let pending_by_message: HashMap<&str, &DiscordInboxItem> = pending .iter() .map(|item| (item.message_id.as_str(), item)) .collect();
messages .iter() .map(|message| { let pending_item = pending_by_message.get(message.message_id.as_str()).copied(); let context = pending_item .map(|item| item.bucket.as_str()) .unwrap_or(if message.is_dm { "dm" } else { "guild" }); BatchMessageRow { message, pending_item, context, } }) .collect()}
fn older_pending_items<'a>( messages: &[DiscordIncomingMessage], pending: &'a [DiscordInboxItem],) -> Vec<&'a DiscordInboxItem> { let batch_msg_ids: HashSet<&str> = messages.iter().map(|m| m.message_id.as_str()).collect(); pending .iter() .filter(|item| !batch_msg_ids.contains(item.message_id.as_str())) .collect()}
pub(super) fn format_discord_timestamp(timestamp: Timestamp) -> String { format_discord_timestamp_secs(timestamp.as_secs())}
pub(super) fn format_discord_timestamp_secs(secs: i64) -> String { let Some(datetime) = chrono::DateTime::from_timestamp(secs, 0) else { return secs.to_string(); }; datetime.format("%Y-%m-%d %H:%M:%S").to_string()}
pub(super) fn format_discord_batch_time(secs: i64) -> String { let Some(datetime) = chrono::DateTime::from_timestamp(secs, 0) else { return secs.to_string(); }; datetime.format("%Y-%m-%d %H:%M:%S").to_string()}
pub(super) fn one_line(text: &str) -> String { use std::sync::OnceLock; static DATA_URL_RE: OnceLock<regex::Regex> = OnceLock::new(); let re = DATA_URL_RE.get_or_init(|| { regex::Regex::new(r"data:([a-zA-Z0-9\-+\.]+/[a-zA-Z0-9\-+\.]+);base64,([a-zA-Z0-9/+=]+)") .unwrap() });
let mut media_urls = Vec::new(); let mut cleaned_text = text.to_string();
while let Some(mat) = re.find(&cleaned_text) { media_urls.push(mat.as_str().to_string()); cleaned_text.replace_range(mat.range(), ""); }
let normalized = cleaned_text .split_whitespace() .collect::<Vec<_>>() .join(" "); let mut out = if normalized.chars().count() <= 500 { normalized } else { let mut truncated = normalized.chars().take(500).collect::<String>(); truncated.push_str("..."); truncated };
for url in media_urls { if !out.is_empty() { out.push(' '); } out.push_str(&url); }
out}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237use super::*;
#[derive(Clone, Debug)]pub(super) struct DiscordInboxStore { pub(super) conn: Arc<Mutex<Connection>>,}
#[derive(Clone, Debug, PartialEq, Eq)]pub(super) struct DiscordInboxItem { pub(super) item_id: String, pub(super) conversation_id: String, pub(super) channel_id: String, pub(super) message_id: String, pub(super) author_name: String, pub(super) content: String, pub(super) bucket: String, pub(super) status: String, pub(super) timestamp: i64,}
impl DiscordInboxStore { pub(super) fn open(path: impl AsRef<std::path::Path>) -> Result<Self> { let conn = Connection::open(path)?; let store = Self { conn: Arc::new(Mutex::new(conn)), }; store.init_schema()?; Ok(store) }
pub(super) fn init_schema(&self) -> Result<()> { self.conn.lock().unwrap().execute_batch( "CREATE TABLE IF NOT EXISTS discord_inbox_items ( item_id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, channel_id TEXT NOT NULL, message_id TEXT NOT NULL UNIQUE, author_name TEXT NOT NULL, content TEXT NOT NULL, bucket TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', action TEXT NOT NULL DEFAULT 'none', note TEXT, message_ts INTEGER NOT NULL, first_seen_ts INTEGER NOT NULL DEFAULT (unixepoch()), last_seen_ts INTEGER NOT NULL DEFAULT (unixepoch()), decision_ts INTEGER ); CREATE INDEX IF NOT EXISTS idx_discord_inbox_status ON discord_inbox_items(status, last_seen_ts DESC); CREATE INDEX IF NOT EXISTS idx_discord_inbox_channel ON discord_inbox_items(channel_id, status, last_seen_ts DESC);", )?; Ok(()) }
pub(super) fn upsert_pending( &self, message: &DiscordIncomingMessage, ) -> Result<Option<String>> { let Some(bucket) = pending_bucket(message) else { return Ok(None); };
let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO discord_inbox_items ( item_id, conversation_id, channel_id, message_id, author_name, content, bucket, status, action, message_ts ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', 'none', ?8) ON CONFLICT(item_id) DO UPDATE SET conversation_id = excluded.conversation_id, channel_id = excluded.channel_id, author_name = excluded.author_name, content = excluded.content, bucket = excluded.bucket, last_seen_ts = unixepoch()", params![ message.message_id, message.conversation_id, message.channel_id, message.message_id, message.author_name, message.content, bucket, message.timestamp, ], )?; Ok(Some(message.message_id.clone())) }
pub(super) fn pending_items(&self, limit: usize) -> Result<Vec<DiscordInboxItem>> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT item_id, conversation_id, channel_id, message_id, author_name, content, bucket, status, message_ts FROM discord_inbox_items WHERE status = 'pending' ORDER BY message_ts DESC, last_seen_ts DESC LIMIT ?1", )?; let rows = stmt.query_map(params![limit as i64], inbox_item_from_row)?; rows.collect::<std::result::Result<Vec<_>, _>>() .map_err(Into::into) }
pub(super) fn demote_stale_pending(&self, older_than_secs: i64) -> Result<usize> { let changed = self.conn.lock().unwrap().execute( "UPDATE discord_inbox_items SET status = 'seen', action = CASE WHEN action = 'none' THEN 'noted' ELSE action END, decision_ts = unixepoch(), last_seen_ts = unixepoch() WHERE status = 'pending' AND message_ts <= unixepoch() - ?1", params![older_than_secs], )?; Ok(changed) }
pub(super) fn pending_item_for_message(&self, message_id: &str) -> Result<Option<String>> { self.conn .lock() .unwrap() .query_row( "SELECT item_id FROM discord_inbox_items WHERE message_id = ?1 AND status = 'pending' LIMIT 1", params![message_id], |row| row.get(0), ) .optional() .map_err(Into::into) }
pub(super) fn conversation_summaries(&self) -> Result<Vec<(String, String, String, i64)>> { // returns (conversation_id, channel_id, bucket, latest_ts) per conversation let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT conversation_id, channel_id, CASE WHEN SUM(bucket = 'dm') > 0 THEN 'dm' ELSE 'guild' END AS bucket, MAX(message_ts) AS latest_ts FROM discord_inbox_items GROUP BY conversation_id ORDER BY latest_ts DESC LIMIT 50", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, i64>(3)?, )) })?; rows.collect::<std::result::Result<Vec<_>, _>>() .map_err(Into::into) }
pub(super) fn pending_dm_item_for_channel(&self, channel_id: &str) -> Result<Option<String>> { self.conn .lock() .unwrap() .query_row( "SELECT item_id FROM discord_inbox_items WHERE channel_id = ?1 AND bucket = 'dm' AND status = 'pending' ORDER BY message_ts DESC LIMIT 1", params![channel_id], |row| row.get(0), ) .optional() .map_err(Into::into) }
pub(super) fn mark( &self, item_id: &str, status: &str, action: &str, note: Option<&str>, ) -> Result<()> { validate_inbox_status(status)?; validate_inbox_action(action)?; let item_id = normalize_item_id(item_id); let changed = self.conn.lock().unwrap().execute( "UPDATE discord_inbox_items SET status = ?1, action = ?2, note = ?3, decision_ts = unixepoch(), last_seen_ts = unixepoch() WHERE item_id = ?4", params![status, action, note, item_id], )?; if changed == 0 { anyhow::bail!("unknown Discord inbox item {item_id:?}"); } Ok(()) }}
fn inbox_item_from_row(row: &Row<'_>) -> rusqlite::Result<DiscordInboxItem> { Ok(DiscordInboxItem { item_id: row.get(0)?, conversation_id: row.get(1)?, channel_id: row.get(2)?, message_id: row.get(3)?, author_name: row.get(4)?, content: row.get(5)?, bucket: row.get(6)?, status: row.get(7)?, timestamp: row.get(8)?, })}
fn validate_inbox_status(status: &str) -> Result<()> { match status { "pending" | "seen" | "acted" | "ignored" => Ok(()), other => anyhow::bail!( "invalid Discord inbox status {other:?}; expected pending, seen, acted, or ignored" ), }}
fn validate_inbox_action(action: &str) -> Result<()> { match action { "none" | "replied" | "messaged" | "dismissed" | "noted" => Ok(()), other => anyhow::bail!( "invalid Discord inbox action {other:?}; expected none, replied, messaged, dismissed, or noted" ), }}6 unmodified lines7891011111213141615161718197 unmodified lines272829303132333435363738394041424344454647484945 unmodified lines959697988586878889909192939495969798991001011029910010110235 unmodified lines138139140141146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322142143144145666 unmodified linesExpand all81281381481599799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118816817818819124 unmodified linesExpand all94494594694712511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282948949950951137 unmodified linesExpand all10891090109110921428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791093109410951096467 unmodified linesExpand all6 unmodified linesuse anyhow::{anyhow, Context, Result};use klbr_core::{ harness_block::{FormatStyle, DEFAULT_FORMAT_STYLE}, interrupt::{ExternalEvent, Interrupt}, models::ToolDef, tools::{Subroutines, Tool, ToolFuture}, AgentEvent,};use rusqlite::{params, Connection, OptionalExtension, Row};use serde_json::{json, Value};use serde_json::Value;use tokio::{ sync::{broadcast, mpsc}, task::JoinHandle,};7 unmodified lines }, util::Timestamp,};
mod formatting;mod inbox;mod tool_defs;
use formatting::{ format_discord_batch, format_discord_batch_time, format_discord_timestamp, format_history,};#[cfg(test)]use formatting::{format_discord_timestamp_secs, one_line};use inbox::{DiscordInboxItem, DiscordInboxStore};use tool_defs::{ discord_fetch_history_def, discord_list_conversations_def, discord_mark_def, discord_react_def, discord_send_def,};
#[derive(Clone, Debug)]pub struct DiscordConfig { pub token: String, pub source: String,45 unmodified lines is_dm: bool, author_is_bot: bool,}
#[derive(Clone, Debug)]struct DiscordInboxStore { conn: Arc<Mutex<Connection>>,}
#[derive(Clone, Debug, PartialEq, Eq)]struct DiscordInboxItem { item_id: String, conversation_id: String, channel_id: String, message_id: String, author_name: String, content: String, bucket: String, status: String, timestamp: i64,}
impl DiscordConfig { pub fn from_env() -> Result<Option<Self>> { let enabled = env_bool("KLBR_DISCORD_ENABLED"); if enabled == Some(false) {35 unmodified lines })) }}
impl DiscordInboxStore { fn open(path: impl AsRef<std::path::Path>) -> Result<Self> { let conn = Connection::open(path)?; let store = Self { conn: Arc::new(Mutex::new(conn)), }; store.init_schema()?; Ok(store) }
fn init_schema(&self) -> Result<()> { self.conn.lock().unwrap().execute_batch( "CREATE TABLE IF NOT EXISTS discord_inbox_items ( item_id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, channel_id TEXT NOT NULL, message_id TEXT NOT NULL UNIQUE, author_name TEXT NOT NULL, content TEXT NOT NULL, bucket TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', action TEXT NOT NULL DEFAULT 'none', note TEXT, message_ts INTEGER NOT NULL, first_seen_ts INTEGER NOT NULL DEFAULT (unixepoch()), last_seen_ts INTEGER NOT NULL DEFAULT (unixepoch()), decision_ts INTEGER ); CREATE INDEX IF NOT EXISTS idx_discord_inbox_status ON discord_inbox_items(status, last_seen_ts DESC); CREATE INDEX IF NOT EXISTS idx_discord_inbox_channel ON discord_inbox_items(channel_id, status, last_seen_ts DESC);", )?; Ok(()) }
fn upsert_pending(&self, message: &DiscordIncomingMessage) -> Result<Option<String>> { let Some(bucket) = pending_bucket(message) else { return Ok(None); };
let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO discord_inbox_items ( item_id, conversation_id, channel_id, message_id, author_name, content, bucket, status, action, message_ts ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', 'none', ?8) ON CONFLICT(item_id) DO UPDATE SET conversation_id = excluded.conversation_id, channel_id = excluded.channel_id, author_name = excluded.author_name, content = excluded.content, bucket = excluded.bucket, last_seen_ts = unixepoch()", params![ message.message_id, message.conversation_id, message.channel_id, message.message_id, message.author_name, message.content, bucket, message.timestamp, ], )?; Ok(Some(message.message_id.clone())) }
fn pending_items(&self, limit: usize) -> Result<Vec<DiscordInboxItem>> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT item_id, conversation_id, channel_id, message_id, author_name, content, bucket, status, message_ts FROM discord_inbox_items WHERE status = 'pending' ORDER BY message_ts DESC, last_seen_ts DESC LIMIT ?1", )?; let rows = stmt.query_map(params![limit as i64], inbox_item_from_row)?; rows.collect::<std::result::Result<Vec<_>, _>>() .map_err(Into::into) }
fn demote_stale_pending(&self, older_than_secs: i64) -> Result<usize> { let changed = self.conn.lock().unwrap().execute( "UPDATE discord_inbox_items SET status = 'seen', action = CASE WHEN action = 'none' THEN 'noted' ELSE action END, decision_ts = unixepoch(), last_seen_ts = unixepoch() WHERE status = 'pending' AND message_ts <= unixepoch() - ?1", params![older_than_secs], )?; Ok(changed) }
fn pending_item_for_message(&self, message_id: &str) -> Result<Option<String>> { self.conn .lock() .unwrap() .query_row( "SELECT item_id FROM discord_inbox_items WHERE message_id = ?1 AND status = 'pending' LIMIT 1", params![message_id], |row| row.get(0), ) .optional() .map_err(Into::into) }
fn conversation_summaries(&self) -> Result<Vec<(String, String, String, i64)>> { // returns (conversation_id, channel_id, bucket, latest_ts) per conversation let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT conversation_id, channel_id, CASE WHEN SUM(bucket = 'dm') > 0 THEN 'dm' ELSE 'guild' END AS bucket, MAX(message_ts) AS latest_ts FROM discord_inbox_items GROUP BY conversation_id ORDER BY latest_ts DESC LIMIT 50", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, i64>(3)?, )) })?; rows.collect::<std::result::Result<Vec<_>, _>>() .map_err(Into::into) }
fn pending_dm_item_for_channel(&self, channel_id: &str) -> Result<Option<String>> { self.conn .lock() .unwrap() .query_row( "SELECT item_id FROM discord_inbox_items WHERE channel_id = ?1 AND bucket = 'dm' AND status = 'pending' ORDER BY message_ts DESC LIMIT 1", params![channel_id], |row| row.get(0), ) .optional() .map_err(Into::into) }
fn mark(&self, item_id: &str, status: &str, action: &str, note: Option<&str>) -> Result<()> { validate_inbox_status(status)?; validate_inbox_action(action)?; let item_id = normalize_item_id(item_id); let changed = self.conn.lock().unwrap().execute( "UPDATE discord_inbox_items SET status = ?1, action = ?2, note = ?3, decision_ts = unixepoch(), last_seen_ts = unixepoch() WHERE item_id = ?4", params![status, action, note, item_id], )?; if changed == 0 { anyhow::bail!("unknown Discord inbox item {item_id:?}"); } Ok(()) }}
impl DiscordRuntime { pub async fn new(config: DiscordConfig, db_path: impl AsRef<std::path::Path>) -> Result<Self> { let _ = rustls::crypto::ring::default_provider().install_default();
666 unmodified linesExpand all self.inbox.pending_dm_item_for_channel(channel_id) }}
fn discord_send_def() -> ToolDef { ToolDef::function( "discord_send", "send a Discord message. use this when a Discord response is useful; many ambient batches need no reply. plain assistant text only goes to the local chat. conversation_id is optional when the current Discord batch has a single conversation. pass source_item_id when responding to a pending item; successful sends mark that item acted, so do not call discord_mark afterward.", json!({ "type": "object", "properties": { "conversation_id": { "type": "string", "description": "Discord conversation id from the batch, like d1 or conv:d1; omit only for a single-conversation batch" }, "content": { "type": "string", "description": "message content to send, max 2000 characters" }, "source_item_id": { "type": "string", "description": "optional pending inbox item id shown as msg:<id>; marks it acted after sending" } }, "required": ["content"] }), )}
fn discord_fetch_history_def() -> ToolDef { ToolDef::function( "discord_fetch_history", "fetch recent Discord message history for a conversation. use this when the current batch lacks enough context. conversation_id is optional when the current batch has a single conversation.", json!({ "type": "object", "properties": { "conversation_id": { "type": "string", "description": "Discord conversation id from the batch, like d1 or conv:d1; omit only for a single-conversation batch" }, "before_message_id": { "type": "string", "description": "optional msg:<id> from the batch; fetch messages before it" }, "after_message_id": { "type": "string", "description": "optional msg:<id> from the batch; fetch messages after it" }, "limit": { "type": "integer", "description": "number of messages to fetch, 1-100; default 20" } }, "required": [] }), )}
fn discord_react_def() -> ToolDef { ToolDef::function( "discord_react", "add a reaction to a Discord message. prefer real unicode emoji like 😍, 👍, or ❤️. only use a custom emoji string like <:name:id> when the exact string came from Discord; never invent custom emoji ids. conversation_id is optional when the current Discord batch has a single conversation.", json!({ "type": "object", "properties": { "conversation_id": { "type": "string", "description": "Discord conversation id from the batch, like d1 or conv:d1; omit only for a single-conversation batch" }, "message_id": { "type": "string", "description": "msg:<id> of the message to react to, as shown in the batch" }, "emoji": { "type": "string", "description": "unicode emoji is preferred; custom emoji must be an exact Discord form <:name:id> / <a:name:id>, copied from Discord rather than guessed" } }, "required": ["message_id", "emoji"] }), )}
fn discord_list_conversations_def() -> ToolDef { ToolDef::function( "discord_list_conversations", "list known Discord conversations with their ids, type (dm/guild), and last activity. use this to find a conversation_id when it is not in the current batch.", json!({ "type": "object", "properties": {}, "required": [] }), )}
fn discord_mark_def() -> ToolDef { ToolDef::function( "discord_mark", "mark a pending Discord inbox item as seen, acted, or ignored so future batches remember the decision. use this when you are not sending a Discord response, especially ignored when you deliberately choose not to respond. successful discord_send already marks the item acted.", json!({ "type": "object", "properties": { "item_id": { "type": "string", "description": "pending Discord inbox message id, either raw snowflake or the displayed msg:<id>" }, "status": { "type": "string", "enum": ["pending", "seen", "acted", "ignored"], "description": "new item status" }, "action": { "type": "string", "enum": ["none", "replied", "messaged", "dismissed", "noted"], "description": "optional decision/action label" }, "note": { "type": "string", "description": "optional short decision note" } }, "required": ["item_id", "status"] }), )}
fn env_bool(name: &str) -> Option<bool> { std::env::var(name).ok().map(|value| { matches!( value.to_ascii_lowercase().as_str(),124 unmodified linesExpand all .or_else(|| s.strip_prefix("item:")) .unwrap_or(s)}
fn inbox_item_from_row(row: &Row<'_>) -> rusqlite::Result<DiscordInboxItem> { Ok(DiscordInboxItem { item_id: row.get(0)?, conversation_id: row.get(1)?, channel_id: row.get(2)?, message_id: row.get(3)?, author_name: row.get(4)?, content: row.get(5)?, bucket: row.get(6)?, status: row.get(7)?, timestamp: row.get(8)?, })}
fn validate_inbox_status(status: &str) -> Result<()> { match status { "pending" | "seen" | "acted" | "ignored" => Ok(()), other => anyhow::bail!( "invalid Discord inbox status {other:?}; expected pending, seen, acted, or ignored" ), }}
fn validate_inbox_action(action: &str) -> Result<()> { match action { "none" | "replied" | "messaged" | "dismissed" | "noted" => Ok(()), other => anyhow::bail!( "invalid Discord inbox action {other:?}; expected none, replied, messaged, dismissed, or noted" ), }}
fn resolve_channel_arg_from_state( state: &DiscordState, args: &Value, allow_active: bool,137 unmodified linesExpand all } Some(name.replace('-', "_").to_ascii_lowercase())}
fn format_history( channel_id: Id<ChannelMarker>, conversation_id: Option<&str>, messages: &[Message], style: FormatStyle,) -> String { match style { FormatStyle::Brackets => { let conversation = conversation_id .map(|id| format!("conv:{id}")) .unwrap_or_else(|| format!("channel:{channel_id}")); let mut lines = vec![ "[discord history]".to_string(), conversation, "messages:".to_string(), ];
for message in messages.iter().rev() { let author = message .author .global_name .as_deref() .unwrap_or(&message.author.name); lines.push(format!( "[msg:{} {}] {}: {}", message.id, format_discord_timestamp(message.timestamp), author, one_line(&message.content) )); }
lines.join("\n") } FormatStyle::Xml => { let mut lines = vec![]; let conversation_attr = conversation_id .map(|id| format!(" conversation_id=\"{id}\"")) .unwrap_or_else(|| format!(" channel_id=\"{channel_id}\"")); lines.push(format!("<discord_history{}>", conversation_attr));
for message in messages.iter().rev() { let author = message .author .global_name .as_deref() .unwrap_or(&message.author.name); lines.push(format!( " <message msg_id=\"{}\" timestamp=\"{}\" author=\"{}\">{}</message>", message.id, format_discord_timestamp(message.timestamp), author, one_line(&message.content) )); }
lines.push("</discord_history>".to_string()); lines.join("\n") } }}
fn format_discord_batch( messages: &[DiscordIncomingMessage], pending: &[DiscordInboxItem], include_instructions: bool, style: FormatStyle,) -> String { // pending items mapped by message_id let batch_pending: HashMap<&str, &DiscordInboxItem> = pending .iter() .map(|item| (item.message_id.as_str(), item)) .collect();
match style { FormatStyle::Brackets => { let mut lines = vec!["[discord message batch]".to_string()]; if include_instructions { lines.push("messages are context, not direct requests".to_string()); lines.push("for dm: reply if a response is needed".to_string()); lines.push( "for guild: channel context only — replying is optional, use judgment" .to_string(), ); lines.push("use discord_send to respond (marks pending items acted); use discord_mark only when not sending".to_string()); } lines.push("messages:".to_string());
for message in messages { let tag = batch_pending .get(message.message_id.as_str()) .map(|item| item.bucket.as_str()) .unwrap_or(if message.is_dm { "dm" } else { "guild" }); lines.push(format!( "[conv:{} msg:{} {} {}] {}: {}", message.conversation_id, message.message_id, tag, format_discord_batch_time(message.timestamp), message.author_name, one_line(&message.content) )); }
let batch_msg_ids: HashSet<&str> = messages.iter().map(|m| m.message_id.as_str()).collect(); let older_pending: Vec<&DiscordInboxItem> = pending .iter() .filter(|item| !batch_msg_ids.contains(item.message_id.as_str())) .collect();
if !older_pending.is_empty() { lines.push("pending:".to_string()); for item in older_pending { lines.push(format!( "[msg:{} {} conv:{} {}] {}: {}", item.item_id, item.bucket, item.conversation_id, format_discord_batch_time(item.timestamp), item.author_name, one_line(&item.content) )); } }
lines.join("\n") } FormatStyle::Xml => { let mut lines = vec!["<discord_message_batch>".to_string()]; if include_instructions { lines.push(" <instructions>".to_string()); lines.push(" messages are context, not direct requests".to_string()); lines.push(" for dm: reply if a response is needed".to_string()); lines.push( " for guild: channel context only — replying is optional, use judgment" .to_string(), ); lines.push(" use discord_send to respond (marks pending items acted); use discord_mark only when not sending".to_string()); lines.push(" </instructions>".to_string()); }
lines.push(" <messages>".to_string()); for message in messages { let pending_item = batch_pending.get(message.message_id.as_str()); let tag = pending_item .map(|item| item.bucket.as_str()) .unwrap_or(if message.is_dm { "dm" } else { "guild" });
let item_attr = pending_item .map(|item| format!(" item_id=\"{}\"", item.item_id)) .unwrap_or_default();
lines.push(format!( " <message msg_id=\"{}\"{} conv_id=\"{}\" context=\"{}\" timestamp=\"{}\" author=\"{}\">{}</message>", message.message_id, item_attr, message.conversation_id, tag, format_discord_batch_time(message.timestamp), message.author_name, one_line(&message.content) )); } lines.push(" </messages>".to_string());
let batch_msg_ids: HashSet<&str> = messages.iter().map(|m| m.message_id.as_str()).collect(); let older_pending: Vec<&DiscordInboxItem> = pending .iter() .filter(|item| !batch_msg_ids.contains(item.message_id.as_str())) .collect();
if !older_pending.is_empty() { lines.push(" <pending>".to_string()); for item in older_pending { lines.push(format!( " <message msg_id=\"{}\" item_id=\"{}\" conv_id=\"{}\" context=\"{}\" timestamp=\"{}\" author=\"{}\">{}</message>", item.message_id, item.item_id, item.conversation_id, item.bucket, format_discord_batch_time(item.timestamp), item.author_name, one_line(&item.content) )); } lines.push(" </pending>".to_string()); }
lines.push("</discord_message_batch>".to_string()); lines.join("\n") } }}
fn format_discord_timestamp(timestamp: Timestamp) -> String { format_discord_timestamp_secs(timestamp.as_secs())}
fn format_discord_timestamp_secs(secs: i64) -> String { let Some(datetime) = chrono::DateTime::from_timestamp(secs, 0) else { return secs.to_string(); }; datetime.format("%Y-%m-%d %H:%M:%S").to_string()}
fn format_discord_batch_time(secs: i64) -> String { let Some(datetime) = chrono::DateTime::from_timestamp(secs, 0) else { return secs.to_string(); }; datetime.format("%Y-%m-%d %H:%M:%S").to_string()}
fn one_line(text: &str) -> String { use std::sync::OnceLock; static DATA_URL_RE: OnceLock<regex::Regex> = OnceLock::new(); let re = DATA_URL_RE.get_or_init(|| { regex::Regex::new(r"data:([a-zA-Z0-9\-+\.]+/[a-zA-Z0-9\-+\.]+);base64,([a-zA-Z0-9/+=]+)") .unwrap() });
let mut media_urls = Vec::new(); let mut cleaned_text = text.to_string();
while let Some(mat) = re.find(&cleaned_text) { media_urls.push(mat.as_str().to_string()); cleaned_text.replace_range(mat.range(), ""); }
let normalized = cleaned_text .split_whitespace() .collect::<Vec<_>>() .join(" "); let mut out = if normalized.chars().count() <= 500 { normalized } else { let mut truncated = normalized.chars().take(500).collect::<String>(); truncated.push_str("..."); truncated };
for url in media_urls { if !out.is_empty() { out.push(' '); } out.push_str(&url); }
out}
#[cfg(test)]mod tests { use super::{ choose_reply_reference_from_state, conversation_id_for_channel_from_state, env_bool,467 unmodified linesExpand all