use std::fmt::Write;
use anyhow::Result;
use crate::board::BOARD;
use crate::prompt::{build_workspace_context, format_ticket_block, load_prompt, substitute};
use crate::session::summarization::PREVIOUS_CONVERSATION_SUMMARY_PREFIX;
use crate::skills;
use crate::{ChatMessage, Role, Workspace};
#[derive(Default)]
pub struct Session {
history: Vec<ChatMessage>,
}
impl Session {
pub async fn reset(session_key: &str) -> String {
let _ = crate::session::store().delete(session_key).await;
"Session cleared. Starting fresh.".to_string()
}
pub async fn init(
&mut self,
session_key: &str,
msg: &str,
ws: &Workspace,
role: &Role,
ticket: Option<&crate::board::Ticket>,
) -> Result<()> {
let mut history = crate::session::store().load(session_key).await;
let is_new = history.is_empty();
if is_new {
let msgs = Self::build_turn_messages(msg, ws, role, ticket).await;
crate::session::store()
.batch_append(session_key, &msgs)
.await?;
history.extend(msgs);
} else {
let user_msg = user_msg_with_datetime(msg);
crate::session::store()
.append(session_key, &user_msg)
.await?;
history.push(user_msg);
}
self.history = history;
Ok(())
}
#[must_use]
pub fn history(&self) -> &[ChatMessage] {
&self.history
}
pub fn push_assistant(&mut self, content: String) {
self.history.push(ChatMessage::assistant(content));
}
pub fn push_messages(&mut self, messages: &[ChatMessage]) {
self.history.extend_from_slice(messages);
}
pub(crate) async fn apply_summary(
&mut self,
session_key: &str,
msg: &str,
summary_text: &str,
ws: &Workspace,
role: &Role,
ticket: Option<&crate::board::Ticket>,
) -> Result<()> {
let mut compacted = Self::build_context_messages(ws, role, ticket).await;
compacted.push(ChatMessage::system(format!(
"{PREVIOUS_CONVERSATION_SUMMARY_PREFIX}{summary_text}"
)));
compacted.push(user_msg_with_datetime(msg));
if let Err(e) = crate::session::store()
.replace_messages(session_key, &compacted)
.await
{
tracing::error!(
session = %session_key,
error = %e,
"Failed to persist compacted session after summarization"
);
} else {
self.history = compacted;
}
Ok(())
}
pub async fn finalize(&self, session_key: &str) -> Result<()> {
let Some(final_msg) = self.history.last().filter(|m| m.role == "assistant") else {
tracing::warn!("finalize called but no assistant message in history");
return Ok(());
};
crate::session::store()
.append(session_key, final_msg)
.await?;
Ok(())
}
async fn build_context_messages(
ws: &Workspace,
role: &Role,
ticket: Option<&crate::board::Ticket>,
) -> Vec<ChatMessage> {
let (stored_context, board_context) = tokio::join!(
lookup_workspace_context(ws, role),
build_board_context(ws, role),
);
let workspace_context = match stored_context.as_deref() {
Some(ctx) => ctx.to_owned(),
None => build_workspace_context(ws.as_path()).await,
};
let workspace_context = if workspace_context.trim().is_empty() {
String::new()
} else {
format!("\n<workspace-context>\n{workspace_context}\n</workspace-context>\n")
};
let workspace_boilerplate = substitute(
&load_prompt("workspace.md"),
&[
("{{operating_system}}", std::env::consts::OS),
("{{workspace}}", &ws.as_path().display().to_string()),
("{{workspace_context}}", &workspace_context),
],
);
let role_description = role.role_description();
let skills = skills::load_skills(ws).await;
let mut msgs = Vec::with_capacity(5);
msgs.push(ChatMessage::system(&role_description));
msgs.push(ChatMessage::system(&workspace_boilerplate));
if !skills.is_empty() {
msgs.push(ChatMessage::system(skills::skills_to_prompt(&skills, ws)));
}
if let Some(board_context) = board_context {
msgs.push(ChatMessage::system(&board_context));
}
if let Some(t) = ticket {
msgs.push(ChatMessage::system(format_ticket_block(t)));
}
msgs
}
async fn build_turn_messages(
msg: &str,
ws: &Workspace,
role: &Role,
ticket: Option<&crate::board::Ticket>,
) -> Vec<ChatMessage> {
let mut msgs = Self::build_context_messages(ws, role, ticket).await;
msgs.push(user_msg_with_datetime(msg));
msgs
}
}
async fn build_board_context(ws: &Workspace, role: &Role) -> Option<String> {
if !matches!(role, Role::Manager) {
return None;
}
let board = BOARD.get()?;
let tickets = board.list_all_tickets(Some(&ws.name), None).await.ok()?;
let active: Vec<_> = tickets
.into_iter()
.filter(|t| !t.is_archived && !t.status.is_unblocking())
.collect();
if active.is_empty() {
return None;
}
let count = active.len();
let mut output = format!(
"<workspace-board>\nTickets in {} ({count} active):\n",
ws.name
);
for t in &active {
let _ = writeln!(output, "{}", t.short_display());
}
output.push_str("</workspace-board>");
Some(output)
}
async fn lookup_workspace_context(ws: &Workspace, role: &Role) -> Option<String> {
let workspaces = crate::workspace::store();
workspaces.get_context(&ws.name, role.as_str()).await.ok()?
}
fn user_msg_with_datetime(content: &str) -> ChatMessage {
let now = chrono::Local::now();
ChatMessage::user(format!(
"<timestamp>{} ({})</timestamp>\n\n{}",
now.format("%Y-%m-%d %H:%M:%S"),
now.format("%Z"),
content
))
}