mod chat;
mod setup;
use std::io::{BufWriter, IsTerminal, Write};
use std::sync::Arc;
use agent_client_protocol::{
Agent, AgentCapabilities, AgentSideConnection, AuthMethod, AuthMethodAgent,
AuthenticateRequest, AuthenticateResponse, CancelNotification, Client, ContentBlock,
ContentChunk, Implementation, InitializeRequest, InitializeResponse, NewSessionRequest,
NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionId,
SessionNotification, SessionUpdate, StopReason,
};
use futures::future::LocalBoxFuture;
use onde::inference::{ChatEngine, GgufModelConfig};
use tokio::sync::mpsc;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
#[cfg(unix)]
use std::os::unix::io::{AsRawFd, FromRawFd};
const SYSTEM_PROMPT: &str = "\
Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
Not 'SiGit', not 'Sigit'. Say 'I am siGit' when asked.
You are the official coding agent for smbCloud (https://smbcloud.xyz), \
a cloud platform for deploying and managing projects. \
You help developers build, debug, and ship software on the smbCloud platform.
Keep answers short. Write idiomatic code. \
Fix root causes, not symptoms.";
struct SiGitAgent {
engine: Arc<ChatEngine>,
notification_tx: mpsc::Sender<SessionNotification>,
}
impl SiGitAgent {
fn new(engine: Arc<ChatEngine>, notification_tx: mpsc::Sender<SessionNotification>) -> Self {
Self {
engine,
notification_tx,
}
}
}
#[async_trait::async_trait(?Send)]
impl Agent for SiGitAgent {
async fn initialize(
&self,
_args: InitializeRequest,
) -> agent_client_protocol::Result<InitializeResponse> {
log::info!("initialize");
Ok(InitializeResponse::new(ProtocolVersion::V1)
.agent_info(
Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
.title("siGit — AI Coding Agent"),
)
.auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
"sigit", "siGit",
))])
.agent_capabilities(AgentCapabilities::default()))
}
async fn authenticate(
&self,
_args: AuthenticateRequest,
) -> agent_client_protocol::Result<AuthenticateResponse> {
log::info!("authenticate");
Ok(AuthenticateResponse::default())
}
async fn new_session(
&self,
_args: NewSessionRequest,
) -> agent_client_protocol::Result<NewSessionResponse> {
let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
log::info!("new_session: id={session_id}");
self.engine.clear_history().await;
Ok(NewSessionResponse::new(session_id))
}
async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> {
let session_id = args.session_id.clone();
let user_text: String = args
.prompt
.iter()
.filter_map(|block| match block {
ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if user_text.trim().is_empty() {
return Ok(PromptResponse::new(StopReason::EndTurn));
}
log::info!(
"prompt({}): \"{}\"",
session_id,
user_text.chars().take(80).collect::<String>()
);
let mut token_rx = self
.engine
.stream_message(user_text)
.await
.map_err(|error| {
log::error!("stream_message failed: {error}");
agent_client_protocol::Error::new(-32603, format!("inference failed: {error}"))
})?;
while let Some(chunk) = token_rx.recv().await {
if !chunk.delta.is_empty() {
let notification = SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
chunk.delta,
))),
);
if self.notification_tx.send(notification).await.is_err() {
log::warn!("notification channel closed — stopping stream");
break;
}
}
if chunk.done {
break;
}
}
log::info!("prompt({}) complete", session_id);
Ok(PromptResponse::new(StopReason::EndTurn))
}
async fn cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
log::info!("cancel requested for session {}", args.session_id);
Ok(())
}
}
#[cfg(unix)]
fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
let log_path = std::env::temp_dir().join("sigit.log");
let log_file = std::fs::File::create(&log_path)?;
let log_fd = log_file.as_raw_fd();
let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
anyhow::ensure!(
saved_tui >= 0,
"dup(stdout) for tui failed: {}",
std::io::Error::last_os_error()
);
let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) };
anyhow::ensure!(
saved_cleanup >= 0,
"dup(stdout) for cleanup failed: {}",
std::io::Error::last_os_error()
);
unsafe {
libc::dup2(log_fd, libc::STDOUT_FILENO);
libc::dup2(log_fd, libc::STDERR_FILENO);
}
Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
std::fs::File::from_raw_fd(saved_cleanup)
}))
}
fn init_logging(is_tty: bool) {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let _ = tracing_fmt::Subscriber::builder()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_ansi(!is_tty)
.try_init();
}
async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
let engine = Arc::new(ChatEngine::new());
let config = GgufModelConfig::platform_default();
let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
let loader_engine = Arc::clone(&engine);
let system_prompt = SYSTEM_PROMPT.to_string();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
let result = rt.block_on(
loader_engine.load_gguf_model(config, Some(system_prompt), None),
);
let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
});
crossterm::terminal::enable_raw_mode()?;
let mut tty = BufWriter::new(tty);
crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
let backend = ratatui::backend::CrosstermBackend::new(tty);
let mut terminal = ratatui::Terminal::new(backend)?;
let chat_result = chat::run_with(&mut terminal, &engine, load_rx).await;
crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
cleanup_tty.flush()?;
crossterm::terminal::disable_raw_mode()?;
#[cfg(unix)]
{
let cleanup_fd = cleanup_tty.as_raw_fd();
unsafe {
libc::dup2(cleanup_fd, libc::STDOUT_FILENO);
libc::dup2(cleanup_fd, libc::STDERR_FILENO);
}
}
chat_result
}
async fn run_acp_server() -> anyhow::Result<()> {
log::info!("ACP mode — starting agent server");
log::info!("loading model (this may take a minute on first run)...");
let engine = Arc::new(ChatEngine::new());
let config = GgufModelConfig::platform_default();
engine
.load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
.await
.map_err(|error| anyhow::anyhow!("model load failed: {error}"))?;
log::info!("model loaded and ready");
let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
let agent = SiGitAgent::new(engine, notification_tx);
let stdin = tokio::io::stdin().compat();
let stdout = tokio::io::stdout().compat_write();
let local = tokio::task::LocalSet::new();
local
.run_until(async move {
let (conn, io_task) = AgentSideConnection::new(
agent,
stdout,
stdin,
|fut: LocalBoxFuture<'static, ()>| {
tokio::task::spawn_local(fut);
},
);
tokio::task::spawn_local(async move {
while let Some(notification) = notification_rx.recv().await {
if let Err(err) = conn.session_notification(notification).await {
log::warn!("session_notification failed: {err}");
}
}
});
if let Err(err) = io_task.await {
log::error!("ACP IO error: {err}");
}
})
.await;
log::info!("siGit shutting down");
Ok(())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let is_tty = std::io::stdin().is_terminal();
if is_tty {
#[cfg(unix)]
let (tty, cleanup_tty) = redirect_output_to_log()?;
#[cfg(not(unix))]
anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
init_logging(true);
setup::setup_shared_model_cache();
run_interactive(tty, cleanup_tty).await
} else {
init_logging(false);
setup::setup_shared_model_cache();
log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
run_acp_server().await
}
}