channel architecture: wire protocol, daemons, supervisor
Design and implement the channel system for external communications: - schema/channel.capnp: wire protocol for channel daemons (recv with all_new/min_count, send, subscribe, list) - channels/irc/: standalone IRC daemon crate (consciousness-channel-irc) - channels/telegram/: standalone Telegram daemon crate (consciousness-channel-telegram) - src/thalamus/channels.rs: client connecting to daemon sockets - src/thalamus/supervisor.rs: daemon lifecycle with file locking for multi-instance safety Channel daemons listen on ~/.consciousness/channels/*.sock, configs in *.json5, supervisor discovers and starts them. IRC/Telegram modules removed from thalamus core — they're now independent daemons that survive consciousness restarts. Also: delete standalone tui.rs (moved to consciousness F4/F5), fix build warnings, add F5 thalamus screen with channel status. Co-Developed-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
parent
db42bf6243
commit
ad5f69abb8
23 changed files with 1716 additions and 1921 deletions
|
|
@ -805,6 +805,11 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
|
|||
unsafe { std::env::set_var("POC_DEBUG", "1") };
|
||||
}
|
||||
|
||||
// Start channel daemons
|
||||
let mut channel_supervisor = poc_memory::thalamus::supervisor::Supervisor::new();
|
||||
channel_supervisor.load_config();
|
||||
channel_supervisor.ensure_running();
|
||||
|
||||
// Create UI channel
|
||||
let (ui_tx, mut ui_rx) = ui_channel::channel();
|
||||
|
||||
|
|
|
|||
|
|
@ -144,14 +144,7 @@ pub fn cmd_search(terms: &[String], pipeline_args: &[String], expand: bool, full
|
|||
}
|
||||
|
||||
pub fn cmd_status() -> Result<(), String> {
|
||||
// If stdout is a tty and daemon is running, launch TUI
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdout()) {
|
||||
// Try TUI first — falls back if daemon not running
|
||||
match crate::tui::run_tui() {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(_) => {} // fall through to text output
|
||||
}
|
||||
}
|
||||
// TUI moved to consciousness binary (F4 unconscious screen)
|
||||
|
||||
let store = crate::store::Store::load()?;
|
||||
let g = store.build_graph();
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub mod util;
|
|||
pub mod cli;
|
||||
|
||||
// TUI for memory-search
|
||||
pub mod tui;
|
||||
// tui moved to src/user/tui/ (consciousness binary screens)
|
||||
|
||||
// Thalamus — notification routing and idle management daemon
|
||||
pub mod thalamus;
|
||||
|
|
@ -61,6 +61,10 @@ pub mod memory_capnp {
|
|||
include!(concat!(env!("OUT_DIR"), "/schema/memory_capnp.rs"));
|
||||
}
|
||||
|
||||
pub mod channel_capnp {
|
||||
include!(concat!(env!("OUT_DIR"), "/schema/channel_capnp.rs"));
|
||||
}
|
||||
|
||||
// Re-exports — all existing crate::X paths keep working
|
||||
pub use hippocampus::{
|
||||
store, graph, lookups, cursor, query,
|
||||
|
|
|
|||
|
|
@ -921,7 +921,7 @@ impl Run for DaemonCmd {
|
|||
Self::Install => daemon::install_service(),
|
||||
Self::Consolidate => daemon::rpc_consolidate(),
|
||||
Self::Run { agent, count } => daemon::rpc_run_agent(&agent, count),
|
||||
Self::Tui => tui::run_tui(),
|
||||
Self::Tui => Err("TUI moved to consciousness binary (F4/F5)".into()),
|
||||
Self::ReloadConfig => {
|
||||
match daemon::send_rpc_pub("reload-config") {
|
||||
Some(resp) => { eprintln!("{}", resp.trim()); Ok(()) }
|
||||
|
|
|
|||
178
src/thalamus/channels.rs
Normal file
178
src/thalamus/channels.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// channels.rs — Channel client for the thalamus
|
||||
//
|
||||
// Discovers channel daemon sockets in ~/.consciousness/channels/,
|
||||
// connects via capnp RPC, and provides send/recv operations.
|
||||
//
|
||||
// Each daemon socket speaks the channel.capnp protocol. The channel
|
||||
// manager routes by prefix: "irc.#bcachefs" → connects to irc.sock.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use capnp_rpc::{rpc_twoparty_capnp, twoparty, RpcSystem};
|
||||
use futures::AsyncReadExt;
|
||||
use tokio::net::UnixStream;
|
||||
use tokio_util::compat::TokioAsyncReadCompatExt;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::channel_capnp::channel_server;
|
||||
|
||||
/// A live connection to a channel daemon.
|
||||
struct DaemonConnection {
|
||||
#[allow(dead_code)]
|
||||
prefix: String,
|
||||
client: channel_server::Client,
|
||||
// Hold the RPC system task so it doesn't get dropped
|
||||
_rpc_task: tokio::task::JoinHandle<Result<(), capnp::Error>>,
|
||||
}
|
||||
|
||||
/// Manages all channel daemon connections.
|
||||
pub struct ChannelManager {
|
||||
daemons: HashMap<String, DaemonConnection>,
|
||||
channels_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ChannelManager {
|
||||
pub fn new() -> Self {
|
||||
let channels_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".consciousness/channels");
|
||||
Self {
|
||||
daemons: HashMap::new(),
|
||||
channels_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a daemon socket, returning the capnp client.
|
||||
async fn connect(path: &std::path::Path) -> Result<
|
||||
(channel_server::Client, tokio::task::JoinHandle<Result<(), capnp::Error>>),
|
||||
Box<dyn std::error::Error>,
|
||||
> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
let (reader, writer) = stream.compat().split();
|
||||
let rpc_network = Box::new(twoparty::VatNetwork::new(
|
||||
futures::io::BufReader::new(reader),
|
||||
futures::io::BufWriter::new(writer),
|
||||
rpc_twoparty_capnp::Side::Client,
|
||||
Default::default(),
|
||||
));
|
||||
let mut rpc_system = RpcSystem::new(rpc_network, None);
|
||||
let client: channel_server::Client =
|
||||
rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server);
|
||||
|
||||
let task = tokio::task::spawn_local(rpc_system);
|
||||
Ok((client, task))
|
||||
}
|
||||
|
||||
/// Scan the channels directory for daemon sockets and connect.
|
||||
pub async fn discover(&mut self) {
|
||||
let dir = match std::fs::read_dir(&self.channels_dir) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return, // directory doesn't exist yet
|
||||
};
|
||||
|
||||
for entry in dir.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |e| e == "sock") {
|
||||
let prefix = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if self.daemons.contains_key(&prefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match Self::connect(&path).await {
|
||||
Ok((client, task)) => {
|
||||
info!("connected to channel daemon: {}", prefix);
|
||||
self.daemons.insert(
|
||||
prefix.clone(),
|
||||
DaemonConnection {
|
||||
prefix,
|
||||
client,
|
||||
_rpc_task: task,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("failed to connect to {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the daemon client for a channel path.
|
||||
fn client_for(&self, channel: &str) -> Option<&channel_server::Client> {
|
||||
let prefix = channel.split('.').next()?;
|
||||
self.daemons.get(prefix).map(|d| &d.client)
|
||||
}
|
||||
|
||||
/// Send a message to a channel.
|
||||
pub async fn send(&self, channel: &str, message: &str) -> Result<(), String> {
|
||||
let client = self.client_for(channel)
|
||||
.ok_or_else(|| format!("no daemon for channel: {}", channel))?;
|
||||
|
||||
let mut req = client.send_request();
|
||||
req.get().set_channel(channel);
|
||||
req.get().set_message(message);
|
||||
req.send().promise.await
|
||||
.map_err(|e| format!("send failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read from a channel.
|
||||
pub async fn recv(
|
||||
&self,
|
||||
channel: &str,
|
||||
all_new: bool,
|
||||
min_count: u32,
|
||||
) -> Result<String, String> {
|
||||
let client = self.client_for(channel)
|
||||
.ok_or_else(|| format!("no daemon for channel: {}", channel))?;
|
||||
|
||||
let mut req = client.recv_request();
|
||||
req.get().set_channel(channel);
|
||||
req.get().set_all_new(all_new);
|
||||
req.get().set_min_count(min_count);
|
||||
|
||||
let reply = req.send().promise.await
|
||||
.map_err(|e| format!("recv failed: {}", e))?;
|
||||
let text = reply.get()
|
||||
.map_err(|e| format!("recv reply error: {}", e))?
|
||||
.get_text()
|
||||
.map_err(|e| format!("recv text error: {}", e))?;
|
||||
Ok(text.to_str().unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
/// List connected daemon prefixes.
|
||||
pub fn prefixes(&self) -> Vec<&str> {
|
||||
self.daemons.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// List all channels from all connected daemons.
|
||||
pub async fn list_all(&self) -> Vec<(String, bool, u32)> {
|
||||
let mut result = Vec::new();
|
||||
for daemon in self.daemons.values() {
|
||||
let req = daemon.client.list_request();
|
||||
if let Ok(reply) = req.send().promise.await {
|
||||
if let Ok(r) = reply.get() {
|
||||
if let Ok(channels) = r.get_channels() {
|
||||
for ch in channels.iter() {
|
||||
if let Ok(name) = ch.get_name() {
|
||||
result.push((
|
||||
name.to_str().unwrap_or("").to_string(),
|
||||
ch.get_connected(),
|
||||
ch.get_unread(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,9 @@
|
|||
// Moved from the standalone poc-daemon crate into the main
|
||||
// consciousness crate.
|
||||
|
||||
pub mod channels;
|
||||
pub mod config;
|
||||
pub mod supervisor;
|
||||
pub mod context;
|
||||
pub mod idle;
|
||||
pub mod modules;
|
||||
|
|
@ -480,28 +482,12 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
tokio::task::LocalSet::new()
|
||||
.run_until(async move {
|
||||
// Start modules
|
||||
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (_notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<notify::Notification>();
|
||||
|
||||
let irc_state = if daemon_config.borrow().irc.enabled {
|
||||
let irc_config = daemon_config.borrow().irc.clone();
|
||||
info!("starting irc module: {}:{}", irc_config.server, irc_config.port);
|
||||
Some(modules::irc::start(irc_config, notify_tx.clone(), daemon_config.clone()))
|
||||
} else {
|
||||
info!("irc module disabled");
|
||||
None
|
||||
};
|
||||
|
||||
let telegram_state = if daemon_config.borrow().telegram.enabled {
|
||||
info!("starting telegram module");
|
||||
Some(modules::telegram::start(
|
||||
daemon_config.borrow().telegram.clone(),
|
||||
notify_tx.clone(),
|
||||
daemon_config.clone(),
|
||||
))
|
||||
} else {
|
||||
info!("telegram module disabled");
|
||||
None
|
||||
};
|
||||
// External modules (IRC, Telegram) now run as separate daemons.
|
||||
// They connect via the notification channel when implemented.
|
||||
let _irc_state: Option<()> = None;
|
||||
let _telegram_state: Option<()> = None;
|
||||
|
||||
let listener = UnixListener::bind(&sock)?;
|
||||
#[cfg(unix)]
|
||||
|
|
@ -571,8 +557,6 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
|
||||
let daemon_impl = rpc::DaemonImpl::new(
|
||||
state.clone(),
|
||||
irc_state.clone(),
|
||||
telegram_state.clone(),
|
||||
daemon_config.clone(),
|
||||
);
|
||||
let client: daemon_capnp::daemon::Client =
|
||||
|
|
|
|||
|
|
@ -1,569 +0,0 @@
|
|||
// IRC module.
|
||||
//
|
||||
// Maintains a persistent connection to an IRC server. Parses incoming
|
||||
// messages into notifications, supports sending messages and runtime
|
||||
// commands (join, leave, etc.). Config changes persist to daemon.toml.
|
||||
//
|
||||
// Runs as a spawned local task on the daemon's LocalSet. Notifications
|
||||
// flow through an mpsc channel into the main state. Reconnects
|
||||
// automatically with exponential backoff.
|
||||
|
||||
use crate::thalamus::config::{Config, IrcConfig};
|
||||
use crate::thalamus::notify::Notification;
|
||||
use crate::thalamus::{home, now};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const MAX_LOG_LINES: usize = 200;
|
||||
const RECONNECT_BASE_SECS: u64 = 5;
|
||||
const RECONNECT_MAX_SECS: u64 = 300;
|
||||
const PING_INTERVAL_SECS: u64 = 120;
|
||||
const PING_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Parsed IRC message.
|
||||
struct IrcMessage {
|
||||
prefix: Option<String>, // nick!user@host
|
||||
command: String,
|
||||
params: Vec<String>,
|
||||
}
|
||||
|
||||
impl IrcMessage {
|
||||
fn parse(line: &str) -> Option<Self> {
|
||||
let line = line.trim_end_matches(|c| c == '\r' || c == '\n');
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (prefix, rest) = if line.starts_with(':') {
|
||||
let space = line.find(' ')?;
|
||||
(Some(line[1..space].to_string()), &line[space + 1..])
|
||||
} else {
|
||||
(None, line)
|
||||
};
|
||||
|
||||
let (command_params, trailing) = if let Some(pos) = rest.find(" :") {
|
||||
(&rest[..pos], Some(rest[pos + 2..].to_string()))
|
||||
} else {
|
||||
(rest, None)
|
||||
};
|
||||
|
||||
let mut parts: Vec<String> = command_params
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command = parts.remove(0).to_uppercase();
|
||||
let mut params = parts;
|
||||
if let Some(t) = trailing {
|
||||
params.push(t);
|
||||
}
|
||||
|
||||
Some(IrcMessage {
|
||||
prefix,
|
||||
command,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract nick from prefix (nick!user@host → nick).
|
||||
fn nick(&self) -> Option<&str> {
|
||||
self.prefix
|
||||
.as_deref()
|
||||
.and_then(|p| p.split('!').next())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared IRC state, accessible from both the read task and RPC handlers.
|
||||
pub struct IrcState {
|
||||
pub config: IrcConfig,
|
||||
pub connected: bool,
|
||||
pub channels: Vec<String>,
|
||||
pub log: VecDeque<String>,
|
||||
writer: Option<WriterHandle>,
|
||||
}
|
||||
|
||||
/// Type-erased writer handle so we can store it without generic params.
|
||||
type WriterHandle = Box<dyn AsyncWriter>;
|
||||
|
||||
trait AsyncWriter {
|
||||
fn write_line(&mut self, line: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = io::Result<()>> + '_>>;
|
||||
}
|
||||
|
||||
/// Writer over a TLS stream.
|
||||
struct TlsWriter {
|
||||
inner: tokio::io::WriteHalf<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>,
|
||||
}
|
||||
|
||||
impl AsyncWriter for TlsWriter {
|
||||
fn write_line(&mut self, line: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = io::Result<()>> + '_>> {
|
||||
let data = format!("{line}\r\n");
|
||||
Box::pin(async move {
|
||||
self.inner.write_all(data.as_bytes()).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Writer over a plain TCP stream.
|
||||
struct PlainWriter {
|
||||
inner: tokio::io::WriteHalf<tokio::net::TcpStream>,
|
||||
}
|
||||
|
||||
impl AsyncWriter for PlainWriter {
|
||||
fn write_line(&mut self, line: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = io::Result<()>> + '_>> {
|
||||
let data = format!("{line}\r\n");
|
||||
Box::pin(async move {
|
||||
self.inner.write_all(data.as_bytes()).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IrcState {
|
||||
fn new(config: IrcConfig) -> Self {
|
||||
Self {
|
||||
channels: config.channels.clone(),
|
||||
config,
|
||||
connected: false,
|
||||
log: VecDeque::with_capacity(MAX_LOG_LINES),
|
||||
writer: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_log(&mut self, line: &str) {
|
||||
if self.log.len() >= MAX_LOG_LINES {
|
||||
self.log.pop_front();
|
||||
}
|
||||
self.log.push_back(line.to_string());
|
||||
}
|
||||
|
||||
async fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||
if let Some(ref mut w) = self.writer {
|
||||
w.write_line(line).await
|
||||
} else {
|
||||
Err(io::Error::new(io::ErrorKind::NotConnected, "not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_privmsg(&mut self, target: &str, msg: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PRIVMSG {target} :{msg}")).await
|
||||
}
|
||||
|
||||
async fn join(&mut self, channel: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("JOIN {channel}")).await?;
|
||||
if !self.channels.iter().any(|c| c == channel) {
|
||||
self.channels.push(channel.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn part(&mut self, channel: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PART {channel}")).await?;
|
||||
self.channels.retain(|c| c != channel);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedIrc = Rc<RefCell<IrcState>>;
|
||||
|
||||
/// Start the IRC module. Returns the shared state handle.
|
||||
pub fn start(
|
||||
config: IrcConfig,
|
||||
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||
daemon_config: Rc<RefCell<Config>>,
|
||||
) -> SharedIrc {
|
||||
let state = Rc::new(RefCell::new(IrcState::new(config)));
|
||||
let state_clone = state.clone();
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
connection_loop(state_clone, notify_tx, daemon_config).await;
|
||||
});
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
async fn connection_loop(
|
||||
state: SharedIrc,
|
||||
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||
daemon_config: Rc<RefCell<Config>>,
|
||||
) {
|
||||
let mut backoff = RECONNECT_BASE_SECS;
|
||||
|
||||
loop {
|
||||
let config = state.borrow().config.clone();
|
||||
info!("irc: connecting to {}:{}", config.server, config.port);
|
||||
|
||||
match connect_and_run(&state, &config, ¬ify_tx).await {
|
||||
Ok(()) => {
|
||||
info!("irc: connection closed cleanly");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("irc: connection error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Reset backoff if we had a working connection (registered
|
||||
// successfully before disconnecting)
|
||||
let was_connected = state.borrow().connected;
|
||||
state.borrow_mut().connected = false;
|
||||
state.borrow_mut().writer = None;
|
||||
if was_connected {
|
||||
backoff = RECONNECT_BASE_SECS;
|
||||
}
|
||||
|
||||
// Persist current channel list to config
|
||||
{
|
||||
let channels = state.borrow().channels.clone();
|
||||
let mut dc = daemon_config.borrow_mut();
|
||||
dc.irc.channels = channels;
|
||||
dc.save();
|
||||
}
|
||||
|
||||
info!("irc: reconnecting in {backoff}s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(backoff)).await;
|
||||
backoff = (backoff * 2).min(RECONNECT_MAX_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_run(
|
||||
state: &SharedIrc,
|
||||
config: &IrcConfig,
|
||||
notify_tx: &mpsc::UnboundedSender<Notification>,
|
||||
) -> io::Result<()> {
|
||||
let addr = format!("{}:{}", config.server, config.port);
|
||||
let tcp = tokio::net::TcpStream::connect(&addr).await?;
|
||||
|
||||
if config.tls {
|
||||
let tls_config = rustls::ClientConfig::builder_with_provider(
|
||||
rustls::crypto::ring::default_provider().into(),
|
||||
)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?
|
||||
.with_root_certificates(root_certs())
|
||||
.with_no_client_auth();
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||
let server_name = rustls::pki_types::ServerName::try_from(config.server.clone())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
let tls_stream = connector.connect(server_name, tcp).await?;
|
||||
|
||||
let (reader, writer) = tokio::io::split(tls_stream);
|
||||
state.borrow_mut().writer = Some(Box::new(TlsWriter { inner: writer }));
|
||||
|
||||
let buf_reader = BufReader::new(reader);
|
||||
register_and_read(state, config, buf_reader, notify_tx).await
|
||||
} else {
|
||||
let (reader, writer) = tokio::io::split(tcp);
|
||||
state.borrow_mut().writer = Some(Box::new(PlainWriter { inner: writer }));
|
||||
|
||||
let buf_reader = BufReader::new(reader);
|
||||
register_and_read(state, config, buf_reader, notify_tx).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_and_read<R: tokio::io::AsyncRead + Unpin>(
|
||||
state: &SharedIrc,
|
||||
config: &IrcConfig,
|
||||
mut reader: BufReader<R>,
|
||||
notify_tx: &mpsc::UnboundedSender<Notification>,
|
||||
) -> io::Result<()> {
|
||||
// Register
|
||||
{
|
||||
let mut s = state.borrow_mut();
|
||||
s.send_raw(&format!("NICK {}", config.nick)).await?;
|
||||
s.send_raw(&format!("USER {} 0 * :{}", config.user, config.realname)).await?;
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut ping_sent = false;
|
||||
let mut deadline = tokio::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(PING_INTERVAL_SECS);
|
||||
|
||||
loop {
|
||||
buf.clear();
|
||||
|
||||
let read_result = tokio::select! {
|
||||
result = reader.read_until(b'\n', &mut buf) => result,
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
if ping_sent {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"ping timeout — no response from server",
|
||||
));
|
||||
}
|
||||
info!("irc: no data for {}s, sending PING", PING_INTERVAL_SECS);
|
||||
state.borrow_mut().send_raw("PING :keepalive").await?;
|
||||
ping_sent = true;
|
||||
deadline = tokio::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(PING_TIMEOUT_SECS);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let n = read_result?;
|
||||
if n == 0 { break; }
|
||||
|
||||
// Any data from server resets the ping timer
|
||||
ping_sent = false;
|
||||
deadline = tokio::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(PING_INTERVAL_SECS);
|
||||
|
||||
// IRC is not guaranteed UTF-8 — lossy conversion handles Latin-1 etc.
|
||||
let line = String::from_utf8_lossy(&buf).trim_end().to_string();
|
||||
if line.is_empty() { continue; }
|
||||
let msg = match IrcMessage::parse(&line) {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match msg.command.as_str() {
|
||||
"PING" => {
|
||||
let arg = msg.params.first().map(|s| s.as_str()).unwrap_or("");
|
||||
state.borrow_mut().send_raw(&format!("PONG :{arg}")).await?;
|
||||
}
|
||||
|
||||
// RPL_WELCOME — registration complete
|
||||
"001" => {
|
||||
info!("irc: registered as {}", config.nick);
|
||||
state.borrow_mut().connected = true;
|
||||
|
||||
// Join configured channels
|
||||
let channels = state.borrow().channels.clone();
|
||||
for ch in &channels {
|
||||
if let Err(e) = state.borrow_mut().send_raw(&format!("JOIN {ch}")).await {
|
||||
warn!("irc: failed to join {ch}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"PRIVMSG" => {
|
||||
let target = msg.params.first().map(|s| s.as_str()).unwrap_or("");
|
||||
let text = msg.params.get(1).map(|s| s.as_str()).unwrap_or("");
|
||||
let nick = msg.nick().unwrap_or("unknown");
|
||||
|
||||
// Handle CTCP requests (wrapped in \x01)
|
||||
if text.starts_with('\x01') && text.ends_with('\x01') {
|
||||
let ctcp = &text[1..text.len()-1];
|
||||
if ctcp.starts_with("VERSION") {
|
||||
let reply = format!(
|
||||
"NOTICE {nick} :\x01VERSION poc-daemon 0.4.0\x01"
|
||||
);
|
||||
state.borrow_mut().send_raw(&reply).await.ok();
|
||||
}
|
||||
// Don't generate notifications for CTCP
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log the message
|
||||
let log_line = if target.starts_with('#') {
|
||||
format!("[{}] <{}> {}", target, nick, text)
|
||||
} else {
|
||||
format!("[PM:{nick}] {text}")
|
||||
};
|
||||
state.borrow_mut().push_log(&log_line);
|
||||
|
||||
// Write to per-channel/per-user log file
|
||||
if target.starts_with('#') {
|
||||
append_log(target, nick, text);
|
||||
} else {
|
||||
append_log(&format!("pm-{nick}"), nick, text);
|
||||
}
|
||||
|
||||
// Generate notification
|
||||
let (ntype, urgency) = classify_privmsg(
|
||||
nick,
|
||||
target,
|
||||
text,
|
||||
&config.nick,
|
||||
);
|
||||
|
||||
let _ = notify_tx.send(Notification {
|
||||
ntype,
|
||||
urgency,
|
||||
message: log_line,
|
||||
timestamp: now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Nick in use
|
||||
"433" => {
|
||||
let alt = format!("{}_", config.nick);
|
||||
warn!("irc: nick in use, trying {alt}");
|
||||
state.borrow_mut().send_raw(&format!("NICK {alt}")).await?;
|
||||
}
|
||||
|
||||
"JOIN" | "PART" | "QUIT" | "KICK" | "MODE" | "TOPIC" | "NOTICE" => {
|
||||
// Could log these, but skip for now
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify a PRIVMSG into notification type and urgency.
|
||||
fn classify_privmsg(nick: &str, target: &str, text: &str, my_nick: &str) -> (String, u8) {
|
||||
let my_nick_lower = my_nick.to_lowercase();
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
if !target.starts_with('#') {
|
||||
// Private message
|
||||
(format!("irc.pm.{nick}"), crate::thalamus::notify::URGENT)
|
||||
} else if text_lower.contains(&my_nick_lower) {
|
||||
// Mentioned in channel
|
||||
(format!("irc.mention.{nick}"), crate::thalamus::notify::NORMAL)
|
||||
} else {
|
||||
// Regular channel message
|
||||
let channel = target.trim_start_matches('#');
|
||||
(format!("irc.channel.{channel}"), crate::thalamus::notify::AMBIENT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the per-channel or per-user log file.
|
||||
/// Logs go to ~/.consciousness/irc/logs/{target}.log (e.g. #bcachefs.log, pm-user.log)
|
||||
fn append_log(target: &str, nick: &str, text: &str) {
|
||||
use std::io::Write;
|
||||
// Sanitize target for filename (strip leading #, lowercase)
|
||||
let filename = format!("{}.log", target.trim_start_matches('#').to_lowercase());
|
||||
let dir = home().join(".consciousness/irc/logs");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(dir.join(&filename))
|
||||
{
|
||||
let secs = now() as u64;
|
||||
let _ = writeln!(f, "{secs} <{nick}> {text}");
|
||||
}
|
||||
}
|
||||
|
||||
fn root_certs() -> rustls::RootCertStore {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
roots
|
||||
}
|
||||
|
||||
/// Handle a runtime command from RPC.
|
||||
pub async fn handle_command(
|
||||
state: &SharedIrc,
|
||||
daemon_config: &Rc<RefCell<Config>>,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
) -> Result<String, String> {
|
||||
match cmd {
|
||||
"join" => {
|
||||
let channel = args.first().ok_or("usage: irc join <channel>")?;
|
||||
let channel = if channel.starts_with('#') {
|
||||
channel.clone()
|
||||
} else {
|
||||
format!("#{channel}")
|
||||
};
|
||||
state
|
||||
.borrow_mut()
|
||||
.join(&channel)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Persist
|
||||
let mut dc = daemon_config.borrow_mut();
|
||||
if !dc.irc.channels.contains(&channel) {
|
||||
dc.irc.channels.push(channel.clone());
|
||||
}
|
||||
dc.save();
|
||||
|
||||
Ok(format!("joined {channel}"))
|
||||
}
|
||||
"leave" | "part" => {
|
||||
let channel = args.first().ok_or("usage: irc leave <channel>")?;
|
||||
let channel = if channel.starts_with('#') {
|
||||
channel.clone()
|
||||
} else {
|
||||
format!("#{channel}")
|
||||
};
|
||||
state
|
||||
.borrow_mut()
|
||||
.part(&channel)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Persist
|
||||
let mut dc = daemon_config.borrow_mut();
|
||||
dc.irc.channels.retain(|c| c != &channel);
|
||||
dc.save();
|
||||
|
||||
Ok(format!("left {channel}"))
|
||||
}
|
||||
"send" | "msg" => {
|
||||
if args.len() < 2 {
|
||||
return Err("usage: irc send <target> <message>".into());
|
||||
}
|
||||
let target = &args[0];
|
||||
if target.starts_with('#') {
|
||||
let s = state.borrow();
|
||||
if !s.channels.iter().any(|c| c == target) {
|
||||
return Err(format!(
|
||||
"not in channel {target} (joined: {})",
|
||||
s.channels.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let msg = args[1..].join(" ");
|
||||
let nick = state.borrow().config.nick.clone();
|
||||
state
|
||||
.borrow_mut()
|
||||
.send_privmsg(target, &msg)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
append_log(target, &nick, &msg);
|
||||
Ok(format!("sent to {target}"))
|
||||
}
|
||||
"status" => {
|
||||
let s = state.borrow();
|
||||
Ok(format!(
|
||||
"connected={} channels={} log_lines={} nick={}",
|
||||
s.connected,
|
||||
s.channels.join(","),
|
||||
s.log.len(),
|
||||
s.config.nick,
|
||||
))
|
||||
}
|
||||
"log" => {
|
||||
let n: usize = args
|
||||
.first()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(15);
|
||||
let s = state.borrow();
|
||||
let lines: Vec<&String> = s.log.iter().rev().take(n).collect();
|
||||
let mut lines: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||
lines.reverse();
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
"nick" => {
|
||||
let new_nick = args.first().ok_or("usage: irc nick <newnick>")?;
|
||||
state
|
||||
.borrow_mut()
|
||||
.send_raw(&format!("NICK {new_nick}"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut dc = daemon_config.borrow_mut();
|
||||
dc.irc.nick = new_nick.clone();
|
||||
dc.save();
|
||||
|
||||
Ok(format!("nick → {new_nick}"))
|
||||
}
|
||||
_ => Err(format!(
|
||||
"unknown irc command: {cmd}\n\
|
||||
commands: join, leave, send, status, log, nick"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
pub mod irc;
|
||||
pub mod telegram;
|
||||
// External communication modules (IRC, Telegram, etc.) live in
|
||||
// separate daemons, not in the core consciousness binary.
|
||||
|
|
|
|||
|
|
@ -1,374 +0,0 @@
|
|||
// Telegram module.
|
||||
//
|
||||
// Long-polls the Telegram Bot API for messages from Kent's chat.
|
||||
// Downloads media (photos, voice, documents) to local files.
|
||||
// Sends text and files. Notifications flow through mpsc into the
|
||||
// daemon's main state.
|
||||
//
|
||||
// Only accepts messages from the configured chat_id (prompt
|
||||
// injection defense — other senders get a "private bot" reply).
|
||||
|
||||
use crate::thalamus::config::{Config, TelegramConfig};
|
||||
use crate::thalamus::notify::Notification;
|
||||
use crate::thalamus::{home, now};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
|
||||
const MAX_LOG_LINES: usize = 100;
|
||||
const POLL_TIMEOUT: u64 = 30;
|
||||
|
||||
pub struct TelegramState {
|
||||
pub config: TelegramConfig,
|
||||
pub connected: bool,
|
||||
pub log: VecDeque<String>,
|
||||
pub last_offset: i64,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub type SharedTelegram = Rc<RefCell<TelegramState>>;
|
||||
|
||||
impl TelegramState {
|
||||
fn new(config: TelegramConfig) -> Self {
|
||||
let last_offset = load_offset();
|
||||
Self {
|
||||
config,
|
||||
connected: false,
|
||||
log: VecDeque::with_capacity(MAX_LOG_LINES),
|
||||
last_offset,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_log(&mut self, line: &str) {
|
||||
if self.log.len() >= MAX_LOG_LINES {
|
||||
self.log.pop_front();
|
||||
}
|
||||
self.log.push_back(line.to_string());
|
||||
}
|
||||
|
||||
fn api_url(&self, method: &str) -> String {
|
||||
format!(
|
||||
"https://api.telegram.org/bot{}/{}",
|
||||
self.config.token, method
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_path() -> PathBuf {
|
||||
home().join(".consciousness/telegram/last_offset")
|
||||
}
|
||||
|
||||
fn load_offset() -> i64 {
|
||||
std::fs::read_to_string(offset_path())
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn save_offset(offset: i64) {
|
||||
let _ = std::fs::write(offset_path(), offset.to_string());
|
||||
}
|
||||
|
||||
fn history_path() -> PathBuf {
|
||||
home().join(".consciousness/telegram/history.log")
|
||||
}
|
||||
|
||||
fn media_dir() -> PathBuf {
|
||||
home().join(".consciousness/telegram/media")
|
||||
}
|
||||
|
||||
fn append_history(line: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(history_path())
|
||||
{
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the Telegram module. Returns the shared state handle.
|
||||
pub fn start(
|
||||
config: TelegramConfig,
|
||||
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||
_daemon_config: Rc<RefCell<Config>>,
|
||||
) -> SharedTelegram {
|
||||
let state = Rc::new(RefCell::new(TelegramState::new(config)));
|
||||
let state_clone = state.clone();
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
poll_loop(state_clone, notify_tx).await;
|
||||
});
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
async fn poll_loop(
|
||||
state: SharedTelegram,
|
||||
notify_tx: mpsc::UnboundedSender<Notification>,
|
||||
) {
|
||||
let _ = std::fs::create_dir_all(media_dir());
|
||||
|
||||
loop {
|
||||
match poll_once(&state, ¬ify_tx).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
error!("telegram: poll error: {e}");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_once(
|
||||
state: &SharedTelegram,
|
||||
notify_tx: &mpsc::UnboundedSender<Notification>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (url, chat_id, token) = {
|
||||
let s = state.borrow();
|
||||
let url = format!(
|
||||
"{}?offset={}&timeout={}",
|
||||
s.api_url("getUpdates"),
|
||||
s.last_offset,
|
||||
POLL_TIMEOUT,
|
||||
);
|
||||
(url, s.config.chat_id, s.config.token.clone())
|
||||
};
|
||||
|
||||
let client = state.borrow().client.clone();
|
||||
let resp: serde_json::Value = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(POLL_TIMEOUT + 5))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if !state.borrow().connected {
|
||||
state.borrow_mut().connected = true;
|
||||
info!("telegram: connected");
|
||||
}
|
||||
|
||||
let results = resp["result"].as_array();
|
||||
let results = match results {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
for update in results {
|
||||
let update_id = update["update_id"].as_i64().unwrap_or(0);
|
||||
let msg = &update["message"];
|
||||
|
||||
// Update offset
|
||||
{
|
||||
let mut s = state.borrow_mut();
|
||||
s.last_offset = update_id + 1;
|
||||
save_offset(s.last_offset);
|
||||
}
|
||||
|
||||
let msg_chat_id = msg["chat"]["id"].as_i64().unwrap_or(0);
|
||||
if msg_chat_id != chat_id {
|
||||
// Reject messages from unknown chats
|
||||
let reject_url = format!(
|
||||
"https://api.telegram.org/bot{}/sendMessage",
|
||||
token
|
||||
);
|
||||
let _ = client
|
||||
.post(&reject_url)
|
||||
.form(&[
|
||||
("chat_id", msg_chat_id.to_string()),
|
||||
("text", "This is a private bot.".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let sender = msg["from"]["first_name"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Handle different message types
|
||||
if let Some(text) = msg["text"].as_str() {
|
||||
let log_line = format!("[{}] {}", sender, text);
|
||||
state.borrow_mut().push_log(&log_line);
|
||||
|
||||
let ts = timestamp();
|
||||
append_history(&format!("{ts} [{sender}] {text}"));
|
||||
|
||||
let _ = notify_tx.send(Notification {
|
||||
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||
urgency: crate::thalamus::notify::NORMAL,
|
||||
message: log_line,
|
||||
timestamp: now(),
|
||||
});
|
||||
} else if let Some(photos) = msg["photo"].as_array() {
|
||||
// Pick largest photo
|
||||
let best = photos.iter().max_by_key(|p| p["file_size"].as_i64().unwrap_or(0));
|
||||
if let Some(photo) = best {
|
||||
if let Some(file_id) = photo["file_id"].as_str() {
|
||||
let caption = msg["caption"].as_str().unwrap_or("");
|
||||
let local = download_file(&client, &token, file_id, ".jpg").await;
|
||||
let display = match &local {
|
||||
Some(p) => format!("[photo: {}]{}", p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||
None => format!("[photo]{}", if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||
};
|
||||
let log_line = format!("[{}] {}", sender, display);
|
||||
state.borrow_mut().push_log(&log_line);
|
||||
let ts = timestamp();
|
||||
append_history(&format!("{ts} [{sender}] {display}"));
|
||||
|
||||
let _ = notify_tx.send(Notification {
|
||||
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||
urgency: crate::thalamus::notify::NORMAL,
|
||||
message: log_line,
|
||||
timestamp: now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if msg["voice"].is_object() {
|
||||
if let Some(file_id) = msg["voice"]["file_id"].as_str() {
|
||||
let caption = msg["caption"].as_str().unwrap_or("");
|
||||
let local = download_file(&client, &token, file_id, ".ogg").await;
|
||||
let display = match &local {
|
||||
Some(p) => format!("[voice: {}]{}", p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||
None => format!("[voice]{}", if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||
};
|
||||
let log_line = format!("[{}] {}", sender, display);
|
||||
state.borrow_mut().push_log(&log_line);
|
||||
let ts = timestamp();
|
||||
append_history(&format!("{ts} [{sender}] {display}"));
|
||||
|
||||
let _ = notify_tx.send(Notification {
|
||||
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||
urgency: crate::thalamus::notify::NORMAL,
|
||||
message: log_line,
|
||||
timestamp: now(),
|
||||
});
|
||||
}
|
||||
} else if msg["document"].is_object() {
|
||||
if let Some(file_id) = msg["document"]["file_id"].as_str() {
|
||||
let fname = msg["document"]["file_name"].as_str().unwrap_or("file");
|
||||
let caption = msg["caption"].as_str().unwrap_or("");
|
||||
let local = download_file(&client, &token, file_id, "").await;
|
||||
let display = match &local {
|
||||
Some(p) => format!("[doc: {} -> {}]{}", fname, p.display(), if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||
None => format!("[doc: {}]{}", fname, if caption.is_empty() { String::new() } else { format!(" {caption}") }),
|
||||
};
|
||||
let log_line = format!("[{}] {}", sender, display);
|
||||
state.borrow_mut().push_log(&log_line);
|
||||
let ts = timestamp();
|
||||
append_history(&format!("{ts} [{sender}] {display}"));
|
||||
|
||||
let _ = notify_tx.send(Notification {
|
||||
ntype: format!("telegram.{}", sender.to_lowercase()),
|
||||
urgency: crate::thalamus::notify::NORMAL,
|
||||
message: log_line,
|
||||
timestamp: now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_file(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
file_id: &str,
|
||||
ext: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let url = format!("https://api.telegram.org/bot{token}/getFile?file_id={file_id}");
|
||||
let resp: serde_json::Value = client.get(&url).send().await.ok()?.json().await.ok()?;
|
||||
let file_path = resp["result"]["file_path"].as_str()?;
|
||||
|
||||
let download_url = format!("https://api.telegram.org/file/bot{token}/{file_path}");
|
||||
let bytes = client.get(&download_url).send().await.ok()?.bytes().await.ok()?;
|
||||
|
||||
let basename = std::path::Path::new(file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("file");
|
||||
let local_name = if ext.is_empty() {
|
||||
basename.to_string()
|
||||
} else {
|
||||
let stem = std::path::Path::new(basename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file");
|
||||
format!("{}{}", stem, ext)
|
||||
};
|
||||
let secs = now() as u64;
|
||||
let local_path = media_dir().join(format!("{secs}_{local_name}"));
|
||||
std::fs::write(&local_path, &bytes).ok()?;
|
||||
Some(local_path)
|
||||
}
|
||||
|
||||
fn timestamp() -> String {
|
||||
// Use the same unix seconds approach as IRC module
|
||||
format!("{}", now() as u64)
|
||||
}
|
||||
|
||||
/// Handle a runtime command from RPC.
|
||||
pub async fn handle_command(
|
||||
state: &SharedTelegram,
|
||||
_daemon_config: &Rc<RefCell<Config>>,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
) -> Result<String, String> {
|
||||
match cmd {
|
||||
"send" => {
|
||||
let msg = args.join(" ");
|
||||
if msg.is_empty() {
|
||||
return Err("usage: telegram send <message>".into());
|
||||
}
|
||||
let (url, client) = {
|
||||
let s = state.borrow();
|
||||
(s.api_url("sendMessage"), s.client.clone())
|
||||
};
|
||||
let chat_id = state.borrow().config.chat_id.to_string();
|
||||
client
|
||||
.post(&url)
|
||||
.form(&[("chat_id", chat_id.as_str()), ("text", msg.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let ts = timestamp();
|
||||
append_history(&format!("{ts} [agent] {msg}"));
|
||||
|
||||
Ok("sent".to_string())
|
||||
}
|
||||
"status" => {
|
||||
let s = state.borrow();
|
||||
Ok(format!(
|
||||
"connected={} log_lines={} offset={}",
|
||||
s.connected,
|
||||
s.log.len(),
|
||||
s.last_offset,
|
||||
))
|
||||
}
|
||||
"log" => {
|
||||
let n: usize = args
|
||||
.first()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(15);
|
||||
let s = state.borrow();
|
||||
let lines: Vec<&String> = s.log.iter().rev().take(n).collect();
|
||||
let mut lines: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||
lines.reverse();
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
_ => Err(format!(
|
||||
"unknown telegram command: {cmd}\n\
|
||||
commands: send, status, log"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
use super::config::Config;
|
||||
use super::daemon_capnp::daemon;
|
||||
use super::idle;
|
||||
use super::modules::{irc, telegram};
|
||||
use super::notify;
|
||||
use capnp::capability::Promise;
|
||||
use std::cell::RefCell;
|
||||
|
|
@ -16,19 +15,16 @@ use tracing::info;
|
|||
|
||||
pub struct DaemonImpl {
|
||||
state: Rc<RefCell<idle::State>>,
|
||||
irc: Option<irc::SharedIrc>,
|
||||
telegram: Option<telegram::SharedTelegram>,
|
||||
config: Rc<RefCell<Config>>,
|
||||
// TODO: replace with named channel map
|
||||
_config: Rc<RefCell<Config>>,
|
||||
}
|
||||
|
||||
impl DaemonImpl {
|
||||
pub fn new(
|
||||
state: Rc<RefCell<idle::State>>,
|
||||
irc: Option<irc::SharedIrc>,
|
||||
telegram: Option<telegram::SharedTelegram>,
|
||||
config: Rc<RefCell<Config>>,
|
||||
_config: Rc<RefCell<Config>>,
|
||||
) -> Self {
|
||||
Self { state, irc, telegram, config }
|
||||
Self { state, _config }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,7 +357,7 @@ impl daemon::Server for DaemonImpl {
|
|||
) -> Promise<(), capnp::Error> {
|
||||
let params = pry!(params.get());
|
||||
let module = pry!(pry!(params.get_module()).to_str()).to_string();
|
||||
let command = pry!(pry!(params.get_command()).to_str()).to_string();
|
||||
let _command = pry!(pry!(params.get_command()).to_str()).to_string();
|
||||
let args_reader = pry!(params.get_args());
|
||||
let mut args = Vec::new();
|
||||
for i in 0..args_reader.len() {
|
||||
|
|
@ -369,44 +365,7 @@ impl daemon::Server for DaemonImpl {
|
|||
}
|
||||
|
||||
match module.as_str() {
|
||||
"irc" => {
|
||||
let irc = match &self.irc {
|
||||
Some(irc) => irc.clone(),
|
||||
None => {
|
||||
results.get().set_result("irc module not enabled");
|
||||
return Promise::ok(());
|
||||
}
|
||||
};
|
||||
let config = self.config.clone();
|
||||
|
||||
Promise::from_future(async move {
|
||||
let result = irc::handle_command(&irc, &config, &command, &args).await;
|
||||
match result {
|
||||
Ok(msg) => results.get().set_result(&msg),
|
||||
Err(msg) => results.get().set_result(&format!("error: {msg}")),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
"telegram" => {
|
||||
let tg = match &self.telegram {
|
||||
Some(tg) => tg.clone(),
|
||||
None => {
|
||||
results.get().set_result("telegram module not enabled");
|
||||
return Promise::ok(());
|
||||
}
|
||||
};
|
||||
let config = self.config.clone();
|
||||
|
||||
Promise::from_future(async move {
|
||||
let result = telegram::handle_command(&tg, &config, &command, &args).await;
|
||||
match result {
|
||||
Ok(msg) => results.get().set_result(&msg),
|
||||
Err(msg) => results.get().set_result(&format!("error: {msg}")),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
// TODO: route module commands through named channel system
|
||||
_ => {
|
||||
results
|
||||
.get()
|
||||
|
|
|
|||
204
src/thalamus/supervisor.rs
Normal file
204
src/thalamus/supervisor.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// supervisor.rs — Channel daemon lifecycle management
|
||||
//
|
||||
// Reads ~/.consciousness/channels/channels.json5, starts/stops
|
||||
// channel daemons as needed. The socket file is the liveness
|
||||
// indicator — if it exists and responds, the daemon is running.
|
||||
//
|
||||
// File locking prevents multiple consciousness/claude-code instances
|
||||
// from racing to start the same daemon.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
fn channels_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".consciousness/channels")
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
channels_dir().join("channels.json5")
|
||||
}
|
||||
|
||||
fn lock_path() -> PathBuf {
|
||||
channels_dir().join(".supervisor.lock")
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
||||
pub struct ChannelEntry {
|
||||
/// Binary name (looked up in PATH)
|
||||
pub binary: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub autostart: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
/// RAII file lock — prevents multiple instances from racing.
|
||||
struct SupervisorLock {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl SupervisorLock {
|
||||
fn acquire() -> Option<Self> {
|
||||
let _ = std::fs::create_dir_all(channels_dir());
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(lock_path())
|
||||
.ok()?;
|
||||
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let ret = unsafe {
|
||||
libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB)
|
||||
};
|
||||
if ret != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Self { _file: file })
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages channel daemon processes.
|
||||
pub struct Supervisor {
|
||||
config: BTreeMap<String, ChannelEntry>,
|
||||
children: BTreeMap<String, Child>,
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: BTreeMap::new(),
|
||||
children: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load config from channels.json5.
|
||||
pub fn load_config(&mut self) {
|
||||
let path = config_path();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => {
|
||||
match serde_json::from_str::<BTreeMap<String, ChannelEntry>>(&text) {
|
||||
Ok(cfg) => {
|
||||
info!("loaded {} channel configs", cfg.len());
|
||||
self.config = cfg;
|
||||
}
|
||||
Err(e) => warn!("failed to parse {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
Err(_) => info!("no channels.json5, no channels configured"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a daemon is alive by testing its socket.
|
||||
fn is_alive(name: &str) -> bool {
|
||||
let sock = channels_dir().join(format!("{}.sock", name));
|
||||
if !sock.exists() {
|
||||
return false;
|
||||
}
|
||||
match std::os::unix::net::UnixStream::connect(&sock) {
|
||||
Ok(_) => true,
|
||||
Err(_) => {
|
||||
let _ = std::fs::remove_file(&sock);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure all configured autostart daemons are running.
|
||||
/// Acquires file lock to prevent races with other instances.
|
||||
pub fn ensure_running(&mut self) {
|
||||
let _lock = match SupervisorLock::acquire() {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
info!("another instance is managing channels");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let entries: Vec<(String, ChannelEntry)> = self.config.iter()
|
||||
.filter(|(_, e)| e.enabled && e.autostart)
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
for (name, entry) in entries {
|
||||
if Self::is_alive(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we spawned it and it died unexpectedly
|
||||
if let Some(child) = self.children.get_mut(&name) {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
warn!("channel {} exited unexpectedly ({}), restarting", name, status);
|
||||
self.children.remove(&name);
|
||||
}
|
||||
Ok(None) => continue, // still starting up
|
||||
Err(_) => { self.children.remove(&name); }
|
||||
}
|
||||
}
|
||||
|
||||
self.start_one(&name, &entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_one(&mut self, name: &str, entry: &ChannelEntry) {
|
||||
info!("starting channel daemon: {} ({})", name, entry.binary);
|
||||
|
||||
match std::process::Command::new(&entry.binary)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => {
|
||||
info!("channel {} started (pid {})", name, child.id());
|
||||
self.children.insert(name.to_string(), child);
|
||||
}
|
||||
Err(e) => error!("failed to start channel {}: {}", name, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a specific daemon.
|
||||
pub fn stop_one(&mut self, name: &str) {
|
||||
let sock = channels_dir().join(format!("{}.sock", name));
|
||||
let _ = std::fs::remove_file(&sock);
|
||||
|
||||
if let Some(mut child) = self.children.remove(name) {
|
||||
info!("stopping channel {} (pid {})", name, child.id());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop all managed daemons.
|
||||
pub fn stop_all(&mut self) {
|
||||
let names: Vec<String> = self.children.keys().cloned().collect();
|
||||
for name in names {
|
||||
self.stop_one(&name);
|
||||
}
|
||||
}
|
||||
|
||||
/// List configured channels and their status.
|
||||
pub fn status(&self) -> Vec<(String, bool, bool)> {
|
||||
self.config.iter()
|
||||
.map(|(name, entry)| (name.clone(), entry.enabled, Self::is_alive(name)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Supervisor {
|
||||
fn drop(&mut self) {
|
||||
// Don't kill daemons on drop — they should outlive us
|
||||
for (name, child) in &mut self.children {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {}
|
||||
_ => info!("leaving channel {} running (pid {})", name, child.id()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -198,6 +198,7 @@ pub async fn score_memories(
|
|||
/// Score response from the /v1/score endpoint.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ScoreMessageResult {
|
||||
#[allow(dead_code)]
|
||||
message_index: usize,
|
||||
total_logprob: f64,
|
||||
}
|
||||
|
|
|
|||
885
src/tui.rs
885
src/tui.rs
|
|
@ -1,885 +0,0 @@
|
|||
// TUI dashboard for poc-memory daemon
|
||||
//
|
||||
// Connects to the daemon status socket, polls periodically, and renders
|
||||
// a tabbed interface with per-agent-type tabs for drill-down. Designed
|
||||
// for observability and control of the consolidation system.
|
||||
//
|
||||
// Tabs:
|
||||
// Overview — graph health gauges, in-flight tasks, recent completions
|
||||
// Pipeline — daily pipeline phases in execution order
|
||||
// <agent> — one tab per agent type (replay, linker, separator, transfer,
|
||||
// health, apply, etc.) showing all runs with output + log history
|
||||
// Log — auto-scrolling daemon.log tail
|
||||
|
||||
use crate::agents::daemon::GraphHealth;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
use jobkit::{TaskInfo, TaskStatus};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Tabs, Wrap},
|
||||
DefaultTerminal, Frame,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
// Agent types we know about, in display order
|
||||
const AGENT_TYPES: &[&str] = &[
|
||||
"health", "linker", "organize", "distill", "separator", "split",
|
||||
"apply", "orphans", "cap", "digest", "digest-links", "knowledge", "rename",
|
||||
];
|
||||
|
||||
fn log_path() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".consciousness/logs/daemon.log")
|
||||
}
|
||||
|
||||
// --- Data fetching ---
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DaemonStatus {
|
||||
#[allow(dead_code)]
|
||||
pid: u32,
|
||||
tasks: Vec<TaskInfo>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
last_daily: Option<String>,
|
||||
#[serde(default)]
|
||||
graph_health: Option<GraphHealth>,
|
||||
}
|
||||
|
||||
fn fetch_status() -> Option<DaemonStatus> {
|
||||
let json = jobkit::daemon::socket::send_rpc(&crate::config::get().data_dir, "")?;
|
||||
serde_json::from_str(&json).ok()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogEntry {
|
||||
ts: String,
|
||||
job: String,
|
||||
event: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
fn load_log_entries(max: usize) -> Vec<LogEntry> {
|
||||
let content = match fs::read_to_string(log_path()) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
content
|
||||
.lines()
|
||||
.rev()
|
||||
.take(max)
|
||||
.filter_map(|line| {
|
||||
let obj: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
Some(LogEntry {
|
||||
ts: obj.get("ts")?.as_str()?.to_string(),
|
||||
job: obj.get("job")?.as_str()?.to_string(),
|
||||
event: obj.get("event")?.as_str()?.to_string(),
|
||||
detail: obj
|
||||
.get("detail")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Tab model ---
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum Tab {
|
||||
Overview,
|
||||
Pipeline,
|
||||
Agent(String), // agent type name: "replay", "linker", etc.
|
||||
Log,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
Tab::Overview => "Overview".into(),
|
||||
Tab::Pipeline => "Pipeline".into(),
|
||||
Tab::Agent(name) => name.clone(),
|
||||
Tab::Log => "Log".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- App state ---
|
||||
|
||||
struct App {
|
||||
tabs: Vec<Tab>,
|
||||
tab_idx: usize,
|
||||
status: Option<DaemonStatus>,
|
||||
log_entries: Vec<LogEntry>,
|
||||
last_poll: Instant,
|
||||
scroll: usize,
|
||||
count_prefix: Option<usize>, // numeric prefix for commands (vim-style)
|
||||
flash_msg: Option<(String, Instant)>, // transient status message
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
let status = fetch_status();
|
||||
let log_entries = load_log_entries(500);
|
||||
let tabs = Self::build_tabs(&status, &log_entries);
|
||||
Self {
|
||||
tabs,
|
||||
tab_idx: 0,
|
||||
status,
|
||||
log_entries,
|
||||
last_poll: Instant::now(),
|
||||
scroll: 0,
|
||||
count_prefix: None,
|
||||
flash_msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tabs(status: &Option<DaemonStatus>, log_entries: &[LogEntry]) -> Vec<Tab> {
|
||||
let mut tabs = vec![Tab::Overview, Tab::Pipeline];
|
||||
|
||||
for agent_type in AGENT_TYPES {
|
||||
let prefix = format!("c-{}", agent_type);
|
||||
let has_tasks = status
|
||||
.as_ref()
|
||||
.map(|s| s.tasks.iter().any(|t| t.name.starts_with(&prefix)))
|
||||
.unwrap_or(false);
|
||||
let has_logs = log_entries.iter().any(|e| {
|
||||
e.job.starts_with(&prefix) || e.job == *agent_type
|
||||
});
|
||||
if has_tasks || has_logs {
|
||||
tabs.push(Tab::Agent(agent_type.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
tabs.push(Tab::Log);
|
||||
tabs
|
||||
}
|
||||
|
||||
fn poll(&mut self) {
|
||||
if self.last_poll.elapsed() >= POLL_INTERVAL {
|
||||
self.status = fetch_status();
|
||||
self.log_entries = load_log_entries(500);
|
||||
|
||||
// Rebuild tabs, preserving current selection
|
||||
let current = self.tabs.get(self.tab_idx).cloned();
|
||||
self.tabs = Self::build_tabs(&self.status, &self.log_entries);
|
||||
if let Some(ref cur) = current {
|
||||
self.tab_idx = self.tabs.iter().position(|t| t == cur).unwrap_or(0);
|
||||
}
|
||||
|
||||
self.last_poll = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn current_tab(&self) -> &Tab {
|
||||
self.tabs.get(self.tab_idx).unwrap_or(&Tab::Overview)
|
||||
}
|
||||
|
||||
fn tasks(&self) -> &[TaskInfo] {
|
||||
self.status
|
||||
.as_ref()
|
||||
.map(|s| s.tasks.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn tasks_for_agent(&self, agent_type: &str) -> Vec<&TaskInfo> {
|
||||
let prefix = format!("c-{}", agent_type);
|
||||
self.tasks()
|
||||
.iter()
|
||||
.filter(|t| t.name.starts_with(&prefix))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn logs_for_agent(&self, agent_type: &str) -> Vec<&LogEntry> {
|
||||
let prefix = format!("c-{}", agent_type);
|
||||
self.log_entries
|
||||
.iter()
|
||||
.filter(|e| e.job.starts_with(&prefix) || e.job == agent_type)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pipeline_tasks(&self) -> Vec<&TaskInfo> {
|
||||
self.tasks()
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
let n = &t.name;
|
||||
n.starts_with("c-")
|
||||
|| n.starts_with("consolidate:")
|
||||
|| n.starts_with("knowledge-loop:")
|
||||
|| n.starts_with("digest:")
|
||||
|| n.starts_with("decay:")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn next_tab(&mut self) {
|
||||
self.tab_idx = (self.tab_idx + 1) % self.tabs.len();
|
||||
self.scroll = 0;
|
||||
}
|
||||
|
||||
fn prev_tab(&mut self) {
|
||||
self.tab_idx = (self.tab_idx + self.tabs.len() - 1) % self.tabs.len();
|
||||
self.scroll = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
fn format_duration(d: Duration) -> String {
|
||||
let ms = d.as_millis();
|
||||
if ms < 1_000 {
|
||||
format!("{}ms", ms)
|
||||
} else if ms < 60_000 {
|
||||
format!("{:.1}s", ms as f64 / 1000.0)
|
||||
} else if ms < 3_600_000 {
|
||||
format!("{}m{}s", ms / 60_000, (ms % 60_000) / 1000)
|
||||
} else {
|
||||
format!("{}h{}m", ms / 3_600_000, (ms % 3_600_000) / 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
fn task_elapsed(t: &TaskInfo) -> Duration {
|
||||
if matches!(t.status, TaskStatus::Running) {
|
||||
if let Some(started) = t.started_at {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs_f64();
|
||||
Duration::from_secs_f64((now - started).max(0.0))
|
||||
} else {
|
||||
t.elapsed
|
||||
}
|
||||
} else {
|
||||
t.result.as_ref().map(|r| r.duration).unwrap_or(t.elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_style(t: &TaskInfo) -> Style {
|
||||
if t.cancelled {
|
||||
return Style::default().fg(Color::DarkGray);
|
||||
}
|
||||
match t.status {
|
||||
TaskStatus::Running => Style::default().fg(Color::Green),
|
||||
TaskStatus::Completed => Style::default().fg(Color::Blue),
|
||||
TaskStatus::Failed => Style::default().fg(Color::Red),
|
||||
TaskStatus::Pending => Style::default().fg(Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_symbol(t: &TaskInfo) -> &'static str {
|
||||
if t.cancelled {
|
||||
return "✗";
|
||||
}
|
||||
match t.status {
|
||||
TaskStatus::Running => "▶",
|
||||
TaskStatus::Completed => "✓",
|
||||
TaskStatus::Failed => "✗",
|
||||
TaskStatus::Pending => "·",
|
||||
}
|
||||
}
|
||||
|
||||
fn event_style(event: &str) -> Style {
|
||||
match event {
|
||||
"completed" => Style::default().fg(Color::Blue),
|
||||
"failed" => Style::default().fg(Color::Red),
|
||||
"started" => Style::default().fg(Color::Green),
|
||||
_ => Style::default().fg(Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_symbol(event: &str) -> &'static str {
|
||||
match event {
|
||||
"completed" => "✓",
|
||||
"failed" => "✗",
|
||||
"started" => "▶",
|
||||
_ => "·",
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_time(ts: &str) -> &str {
|
||||
if ts.len() >= 19 { &ts[11..19] } else { ts }
|
||||
}
|
||||
|
||||
fn render(frame: &mut Frame, app: &App) {
|
||||
let [header, body, footer] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(frame.area());
|
||||
|
||||
// Tab bar — show index hints for first 9 tabs
|
||||
let tab_titles: Vec<Line> = app
|
||||
.tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let hint = if i < 9 {
|
||||
format!("{}", i + 1)
|
||||
} else {
|
||||
" ".into()
|
||||
};
|
||||
Line::from(format!(" {} {} ", hint, t.label()))
|
||||
})
|
||||
.collect();
|
||||
let tabs = Tabs::new(tab_titles)
|
||||
.select(app.tab_idx)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.block(Block::default().borders(Borders::ALL).title(" poc-memory daemon "));
|
||||
frame.render_widget(tabs, header);
|
||||
|
||||
// Body
|
||||
match app.current_tab() {
|
||||
Tab::Overview => render_overview(frame, app, body),
|
||||
Tab::Pipeline => render_pipeline(frame, app, body),
|
||||
Tab::Agent(name) => render_agent_tab(frame, app, name, body),
|
||||
Tab::Log => render_log(frame, app, body),
|
||||
}
|
||||
|
||||
// Footer — flash message, count prefix, or help text
|
||||
let footer_text = if let Some((ref msg, when)) = app.flash_msg {
|
||||
if when.elapsed() < Duration::from_secs(3) {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(msg.as_str(), Style::default().fg(Color::Green)),
|
||||
])
|
||||
} else {
|
||||
Line::raw("") // expired, will show help below
|
||||
}
|
||||
} else {
|
||||
Line::raw("")
|
||||
};
|
||||
|
||||
let footer_line = if !footer_text.spans.is_empty() {
|
||||
footer_text
|
||||
} else if let Some(n) = app.count_prefix {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {}×", n), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" r: run agent │ Esc: cancel"),
|
||||
])
|
||||
} else {
|
||||
match app.current_tab() {
|
||||
Tab::Agent(_) => Line::from(
|
||||
" Tab: switch │ ↑↓: scroll │ [N]r: run agent │ c: consolidate │ q: quit ",
|
||||
),
|
||||
_ => Line::from(
|
||||
" Tab/1-9: switch │ ↑↓: scroll │ c: consolidate │ q: quit ",
|
||||
),
|
||||
}
|
||||
};
|
||||
let footer_widget = Paragraph::new(footer_line).style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(footer_widget, footer);
|
||||
}
|
||||
|
||||
// --- Overview tab ---
|
||||
|
||||
fn render_overview(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [health_area, tasks_area] =
|
||||
Layout::vertical([Constraint::Length(12), Constraint::Min(0)]).areas(area);
|
||||
|
||||
if let Some(gh) = app.status.as_ref().and_then(|s| s.graph_health.as_ref()) {
|
||||
render_health(frame, gh, health_area);
|
||||
} else {
|
||||
let p = Paragraph::new(" No graph health data available")
|
||||
.block(Block::default().borders(Borders::ALL).title(" Graph Health "));
|
||||
frame.render_widget(p, health_area);
|
||||
}
|
||||
|
||||
// In-flight + recent
|
||||
let in_flight: Vec<&TaskInfo> = app
|
||||
.tasks()
|
||||
.iter()
|
||||
.filter(|t| matches!(t.status, TaskStatus::Running | TaskStatus::Pending))
|
||||
.collect();
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
if in_flight.is_empty() {
|
||||
lines.push(Line::from(" No tasks in flight").fg(Color::DarkGray));
|
||||
} else {
|
||||
for t in &in_flight {
|
||||
let elapsed = task_elapsed(t);
|
||||
let progress = t
|
||||
.progress
|
||||
.as_deref()
|
||||
.filter(|p| *p != "idle")
|
||||
.unwrap_or("");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", status_symbol(t)), status_style(t)),
|
||||
Span::raw(format!("{:30}", short_name(&t.name))),
|
||||
Span::styled(
|
||||
format!(" {:>8}", format_duration(elapsed)),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(format!(" {}", progress)),
|
||||
]));
|
||||
if let Some(ref lp) = t.log_path {
|
||||
lines.push(Line::from(format!(" │ log: {}", lp)).fg(Color::DarkGray));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::raw(""));
|
||||
lines.push(Line::from(" Recent:").fg(Color::DarkGray));
|
||||
let recent: Vec<&LogEntry> = app
|
||||
.log_entries
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|e| e.event == "completed" || e.event == "failed")
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
for entry in &recent {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(event_symbol(&entry.event), event_style(&entry.event)),
|
||||
Span::raw(format!(
|
||||
" {} {:28} {}",
|
||||
ts_time(&entry.ts),
|
||||
short_name(&entry.job),
|
||||
entry.detail
|
||||
)),
|
||||
]));
|
||||
}
|
||||
|
||||
let tasks_widget = Paragraph::new(lines)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Tasks "))
|
||||
.scroll((app.scroll as u16, 0));
|
||||
frame.render_widget(tasks_widget, tasks_area);
|
||||
}
|
||||
|
||||
fn render_health(frame: &mut Frame, gh: &GraphHealth, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Graph Health ({}) ", gh.computed_at));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [metrics_area, gauges_area, plan_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
// Metrics
|
||||
let summary = Line::from(format!(
|
||||
" {} nodes {} edges {} communities",
|
||||
gh.nodes, gh.edges, gh.communities
|
||||
));
|
||||
let ep_line = Line::from(vec![
|
||||
Span::raw(" episodic: "),
|
||||
Span::styled(
|
||||
format!("{:.0}%", gh.episodic_ratio * 100.0),
|
||||
if gh.episodic_ratio < 0.4 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
},
|
||||
),
|
||||
Span::raw(format!(" σ={:.1}", gh.sigma)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(vec![summary, ep_line]), metrics_area);
|
||||
|
||||
// Gauges
|
||||
let [g1, g2, g3] = Layout::horizontal([
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
])
|
||||
.areas(gauges_area);
|
||||
|
||||
let alpha_color = if gh.alpha >= 2.5 { Color::Green } else { Color::Red };
|
||||
frame.render_widget(
|
||||
Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(" α (≥2.5) "))
|
||||
.gauge_style(Style::default().fg(alpha_color))
|
||||
.ratio((gh.alpha / 5.0).clamp(0.0, 1.0) as f64)
|
||||
.label(format!("{:.2}", gh.alpha)),
|
||||
g1,
|
||||
);
|
||||
|
||||
let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red };
|
||||
frame.render_widget(
|
||||
Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(" gini (≤0.4) "))
|
||||
.gauge_style(Style::default().fg(gini_color))
|
||||
.ratio(gh.gini.clamp(0.0, 1.0) as f64)
|
||||
.label(format!("{:.3}", gh.gini)),
|
||||
g2,
|
||||
);
|
||||
|
||||
let cc_color = if gh.avg_cc >= 0.2 { Color::Green } else { Color::Red };
|
||||
frame.render_widget(
|
||||
Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(" cc (≥0.2) "))
|
||||
.gauge_style(Style::default().fg(cc_color))
|
||||
.ratio(gh.avg_cc.clamp(0.0, 1.0) as f64)
|
||||
.label(format!("{:.3}", gh.avg_cc)),
|
||||
g3,
|
||||
);
|
||||
|
||||
// Plan
|
||||
let plan_total: usize = gh.plan_counts.values().sum::<usize>() + 1;
|
||||
let plan_summary: Vec<String> = gh.plan_counts.iter()
|
||||
.filter(|(_, c)| **c > 0)
|
||||
.map(|(a, c)| format!("{}{}", &a[..1], c))
|
||||
.collect();
|
||||
let plan_line = Line::from(vec![
|
||||
Span::raw(" plan: "),
|
||||
Span::styled(
|
||||
format!("{}", plan_total),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" agents ({} +health)", plan_summary.join(" "))),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(plan_line), plan_area);
|
||||
}
|
||||
|
||||
// --- Pipeline tab ---
|
||||
|
||||
fn render_pipeline(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let pipeline = app.pipeline_tasks();
|
||||
|
||||
if pipeline.is_empty() {
|
||||
let p = Paragraph::new(" No pipeline tasks")
|
||||
.block(Block::default().borders(Borders::ALL).title(" Daily Pipeline "));
|
||||
frame.render_widget(p, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let phase_order = [
|
||||
"c-health", "c-replay", "c-linker", "c-separator", "c-transfer",
|
||||
"c-apply", "c-orphans", "c-cap", "c-digest", "c-digest-links", "c-knowledge",
|
||||
];
|
||||
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for phase in &phase_order {
|
||||
for t in &pipeline {
|
||||
if t.name.starts_with(phase) && seen.insert(&t.name) {
|
||||
rows.push(pipeline_row(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in &pipeline {
|
||||
if seen.insert(&t.name) {
|
||||
rows.push(pipeline_row(t));
|
||||
}
|
||||
}
|
||||
|
||||
let header = Row::new(vec!["", "Phase", "Status", "Duration", "Progress"])
|
||||
.style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.fg(Color::DarkGray),
|
||||
);
|
||||
let widths = [
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(30),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(20),
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Daily Pipeline "));
|
||||
frame.render_widget(table, area);
|
||||
}
|
||||
|
||||
fn pipeline_row(t: &TaskInfo) -> Row<'static> {
|
||||
let elapsed = task_elapsed(t);
|
||||
let progress = t.progress.as_deref().unwrap_or("").to_string();
|
||||
let error = t
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|r| r.error.as_ref())
|
||||
.map(|e| {
|
||||
let short = if e.len() > 40 { &e[..40] } else { e };
|
||||
format!("err: {}", short)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let detail = if !error.is_empty() { error } else { progress };
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(status_symbol(t)).style(status_style(t)),
|
||||
Cell::from(short_name(&t.name)),
|
||||
Cell::from(format!("{}", t.status)),
|
||||
Cell::from(if !elapsed.is_zero() {
|
||||
format_duration(elapsed)
|
||||
} else {
|
||||
String::new()
|
||||
}),
|
||||
Cell::from(detail),
|
||||
])
|
||||
.style(status_style(t))
|
||||
}
|
||||
|
||||
// --- Per-agent-type tab ---
|
||||
|
||||
fn render_agent_tab(frame: &mut Frame, app: &App, agent_type: &str, area: Rect) {
|
||||
let tasks = app.tasks_for_agent(agent_type);
|
||||
let logs = app.logs_for_agent(agent_type);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Active/recent tasks
|
||||
if tasks.is_empty() {
|
||||
lines.push(Line::from(" No active tasks").fg(Color::DarkGray));
|
||||
} else {
|
||||
lines.push(Line::styled(
|
||||
" Tasks:",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
));
|
||||
lines.push(Line::raw(""));
|
||||
for t in &tasks {
|
||||
let elapsed = task_elapsed(t);
|
||||
let elapsed_str = if !elapsed.is_zero() {
|
||||
format_duration(elapsed)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let progress = t
|
||||
.progress
|
||||
.as_deref()
|
||||
.filter(|p| *p != "idle")
|
||||
.unwrap_or("");
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", status_symbol(t)), status_style(t)),
|
||||
Span::styled(format!("{:30}", &t.name), status_style(t)),
|
||||
Span::styled(
|
||||
format!(" {:>8}", elapsed_str),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(format!(" {}", progress)),
|
||||
]));
|
||||
|
||||
// Retries
|
||||
if t.max_retries > 0 && t.retry_count > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" retry "),
|
||||
Span::styled(
|
||||
format!("{}/{}", t.retry_count, t.max_retries),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Log file path
|
||||
if let Some(ref lp) = t.log_path {
|
||||
lines.push(Line::from(format!(" │ log: {}", lp)).fg(Color::DarkGray));
|
||||
}
|
||||
|
||||
// Error
|
||||
if matches!(t.status, TaskStatus::Failed)
|
||||
&& let Some(ref r) = t.result
|
||||
&& let Some(ref err) = r.error {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" error: ", Style::default().fg(Color::Red)),
|
||||
Span::styled(err.as_str(), Style::default().fg(Color::Red)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::raw(""));
|
||||
}
|
||||
}
|
||||
|
||||
// Log history for this agent type
|
||||
lines.push(Line::styled(
|
||||
" Log history:",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
));
|
||||
lines.push(Line::raw(""));
|
||||
|
||||
if logs.is_empty() {
|
||||
lines.push(Line::from(" (no log entries)").fg(Color::DarkGray));
|
||||
} else {
|
||||
// Show last 30 entries
|
||||
let start = logs.len().saturating_sub(30);
|
||||
for entry in &logs[start..] {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(event_symbol(&entry.event), event_style(&entry.event)),
|
||||
Span::raw(" "),
|
||||
Span::styled(ts_time(&entry.ts), Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{:12}", entry.event), event_style(&entry.event)),
|
||||
Span::raw(format!(" {}", entry.detail)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
let title = format!(" {} ", agent_type);
|
||||
let p = Paragraph::new(lines)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((app.scroll as u16, 0));
|
||||
frame.render_widget(p, area);
|
||||
}
|
||||
|
||||
// --- Log tab ---
|
||||
|
||||
fn render_log(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::default().borders(Borders::ALL).title(" Daemon Log ");
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let visible_height = inner.height as usize;
|
||||
let total = app.log_entries.len();
|
||||
|
||||
// Auto-scroll to bottom unless user has scrolled up
|
||||
let offset = if app.scroll == 0 {
|
||||
total.saturating_sub(visible_height)
|
||||
} else {
|
||||
app.scroll.min(total.saturating_sub(visible_height))
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for entry in app.log_entries.iter().skip(offset).take(visible_height) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(ts_time(&entry.ts), Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{:12}", entry.event), event_style(&entry.event)),
|
||||
Span::raw(format!(" {:30} {}", short_name(&entry.job), entry.detail)),
|
||||
]));
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn short_name(name: &str) -> String {
|
||||
if let Some((verb, path)) = name.split_once(' ') {
|
||||
let file = path.rsplit('/').next().unwrap_or(path);
|
||||
let file = file.strip_suffix(".jsonl").unwrap_or(file);
|
||||
let short = if file.len() > 12 { &file[..12] } else { file };
|
||||
format!("{} {}", verb, short)
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn send_rpc(cmd: &str) -> Option<String> {
|
||||
jobkit::daemon::socket::send_rpc(&crate::config::get().data_dir, cmd)
|
||||
}
|
||||
|
||||
// --- Entry point ---
|
||||
|
||||
pub fn run_tui() -> Result<(), String> {
|
||||
use crossterm::terminal;
|
||||
|
||||
terminal::enable_raw_mode().map_err(|e| format!("not a terminal: {}", e))?;
|
||||
terminal::disable_raw_mode().ok();
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
let result = run_event_loop(&mut terminal);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
fn run_event_loop(terminal: &mut DefaultTerminal) -> Result<(), String> {
|
||||
let mut app = App::new();
|
||||
|
||||
if app.status.is_none() {
|
||||
return Err("Daemon not running.".into());
|
||||
}
|
||||
|
||||
loop {
|
||||
terminal
|
||||
.draw(|frame| render(frame, &app))
|
||||
.map_err(|e| format!("draw: {}", e))?;
|
||||
|
||||
if event::poll(Duration::from_millis(250)).map_err(|e| format!("poll: {}", e))? {
|
||||
if let Event::Key(key) = event::read().map_err(|e| format!("read: {}", e))? {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return Ok(()),
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return Ok(())
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
let _ = send_rpc("consolidate");
|
||||
app.last_poll = Instant::now() - POLL_INTERVAL;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
// Run specific agent type if on an agent tab
|
||||
if let Tab::Agent(ref name) = app.current_tab().clone() {
|
||||
let count = app.count_prefix.unwrap_or(1);
|
||||
let cmd = format!("run-agent {} {}", name, count);
|
||||
let _ = send_rpc(&cmd);
|
||||
app.flash_msg = Some((
|
||||
format!("Queued {} {} run{}", count, name,
|
||||
if count > 1 { "s" } else { "" }),
|
||||
Instant::now(),
|
||||
));
|
||||
app.count_prefix = None;
|
||||
app.last_poll = Instant::now() - POLL_INTERVAL;
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => { app.count_prefix = None; app.next_tab(); }
|
||||
KeyCode::BackTab => { app.count_prefix = None; app.prev_tab(); }
|
||||
// Number keys: if on agent tab, accumulate as count prefix;
|
||||
// otherwise switch tabs
|
||||
KeyCode::Char(c @ '1'..='9') => {
|
||||
if matches!(app.current_tab(), Tab::Agent(_)) {
|
||||
let digit = (c as usize) - ('0' as usize);
|
||||
app.count_prefix = Some(
|
||||
app.count_prefix.unwrap_or(0) * 10 + digit
|
||||
);
|
||||
} else {
|
||||
let idx = (c as usize) - ('1' as usize);
|
||||
if idx < app.tabs.len() {
|
||||
app.tab_idx = idx;
|
||||
app.scroll = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
app.scroll = app.scroll.saturating_add(1);
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
app.scroll = app.scroll.saturating_sub(1);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
app.scroll = app.scroll.saturating_add(20);
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
app.scroll = app.scroll.saturating_sub(20);
|
||||
}
|
||||
KeyCode::Home => {
|
||||
app.scroll = 0;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.count_prefix = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining events
|
||||
while event::poll(Duration::ZERO).unwrap_or(false) {
|
||||
let _ = event::read();
|
||||
}
|
||||
}
|
||||
|
||||
app.poll();
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ pub(crate) const SCREEN_LEGEND: &str = " F1=interact F2=conscious F3=subconsci
|
|||
/// Subconscious agents — interact with conscious context
|
||||
pub(crate) const SUBCONSCIOUS_AGENTS: &[&str] = &["surface-observe", "journal", "reflect"];
|
||||
/// Unconscious agents — background consolidation
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const UNCONSCIOUS_AGENTS: &[&str] = &["linker", "organize", "distill", "split"];
|
||||
|
||||
use crossterm::{
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
// thalamus_screen.rs — F5: attention routing / daemon status
|
||||
// thalamus_screen.rs — F5: attention routing and channel status
|
||||
//
|
||||
// Shows poc-daemon status: presence detection, idle timer,
|
||||
// notification routing, activity level.
|
||||
// Shows presence/idle/activity status, then channel daemon status.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::Line,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::{App, SCREEN_LEGEND};
|
||||
use crate::thalamus::supervisor::Supervisor;
|
||||
|
||||
fn fetch_daemon_status() -> Vec<String> {
|
||||
std::process::Command::new("poc-daemon")
|
||||
|
|
@ -26,20 +26,58 @@ fn fetch_daemon_status() -> Vec<String> {
|
|||
}
|
||||
})
|
||||
.map(|s| s.lines().map(String::from).collect())
|
||||
.unwrap_or_else(|| vec!["daemon not running".to_string()])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn draw_thalamus(&self, frame: &mut Frame, size: Rect) {
|
||||
let status_lines = fetch_daemon_status();
|
||||
let section = Style::default().fg(Color::Yellow);
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::styled("── Thalamus ──", section));
|
||||
|
||||
// Presence status first
|
||||
let daemon_status = fetch_daemon_status();
|
||||
if !daemon_status.is_empty() {
|
||||
lines.push(Line::styled("── Presence ──", section));
|
||||
lines.push(Line::raw(""));
|
||||
for line in &daemon_status {
|
||||
lines.push(Line::raw(format!(" {}", line)));
|
||||
}
|
||||
lines.push(Line::raw(""));
|
||||
}
|
||||
|
||||
// Channel status
|
||||
lines.push(Line::styled("── Channels ──", section));
|
||||
lines.push(Line::raw(""));
|
||||
|
||||
for line in &status_lines {
|
||||
lines.push(Line::raw(format!(" {}", line)));
|
||||
let mut sup = Supervisor::new();
|
||||
sup.load_config();
|
||||
let status = sup.status();
|
||||
|
||||
if status.is_empty() {
|
||||
lines.push(Line::styled(" no channels configured", dim));
|
||||
} else {
|
||||
for (name, enabled, alive) in &status {
|
||||
let (symbol, color) = if *alive {
|
||||
("●", Color::Green)
|
||||
} else if *enabled {
|
||||
("○", Color::Red)
|
||||
} else {
|
||||
("○", Color::DarkGray)
|
||||
};
|
||||
let state = if *alive {
|
||||
"running"
|
||||
} else if *enabled {
|
||||
"stopped"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(symbol, Style::default().fg(color)),
|
||||
Span::raw(format!(" {:<20} {}", name, state)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue