use anyhow::Result;
use serde::Deserialize;
use std::time::{Duration, Instant};
use crate::hook_emit::{post_hook_event, HookEventPayload};
use crate::prompt_log::{PromptLogEntry, PromptLogger};
use crate::{hook_prompt_excerpt, HookType, InjectionKind};
const HTTP_TIMEOUT: Duration = Duration::from_millis(2500);
#[derive(Deserialize)]
struct ServerMessage {
id: String,
#[serde(default)]
formatted: Option<String>,
#[serde(default)]
from_palace: Option<String>,
#[serde(default)]
to_palace: Option<String>,
#[serde(default)]
purpose: Option<String>,
#[serde(default)]
sent_at: Option<String>,
#[serde(default)]
content: Option<String>,
}
pub async fn handle_inbox_check(palace: Option<String>) -> Result<()> {
let start = Instant::now();
let trigger_prompt = read_stdin_best_effort();
let recipient = palace
.clone()
.or_else(|| palace_slug_from_stdin_cwd(&trigger_prompt))
.or_else(|| crate::messaging::cwd_palace_slug().ok())
.unwrap_or_else(|| "<unknown>".to_string());
let injection = run_inbox_fetch(&trigger_prompt, &recipient, start).await;
emit_hook_event(&trigger_prompt, &injection, &recipient, start).await;
Ok(())
}
async fn run_inbox_fetch(trigger_prompt: &str, recipient: &str, start: Instant) -> String {
let addr = match trusty_common::read_daemon_addr("trusty-memory") {
Ok(Some(addr)) => addr,
_ => {
log_entry(trigger_prompt, "", 0, recipient, start);
return String::new();
}
};
let base = if addr.starts_with("http://") || addr.starts_with("https://") {
addr
} else {
format!("http://{addr}")
};
let client = match reqwest::Client::builder()
.timeout(HTTP_TIMEOUT)
.connect_timeout(HTTP_TIMEOUT)
.build()
{
Ok(c) => c,
Err(_) => {
log_entry(trigger_prompt, "", 0, recipient, start);
return String::new();
}
};
let list_url = format!("{base}/api/v1/messages?palace={recipient}&unread_only=true");
let resp = match client.get(&list_url).send().await {
Ok(r) => r,
Err(_) => {
log_entry(trigger_prompt, "", 0, recipient, start);
return String::new();
}
};
if !resp.status().is_success() {
log_entry(trigger_prompt, "", 0, recipient, start);
return String::new();
}
let messages: Vec<ServerMessage> = match resp.json().await {
Ok(v) => v,
Err(_) => {
log_entry(trigger_prompt, "", 0, recipient, start);
return String::new();
}
};
if messages.is_empty() {
log_entry(trigger_prompt, "", 0, recipient, start);
return String::new();
}
let mut injection = String::new();
injection.push_str(&format!(
"# Inter-project inbox (trusty-memory, palace `{recipient}`)\n\n"
));
for m in &messages {
let block = match &m.formatted {
Some(s) => s.clone(),
None => render_fallback(m),
};
injection.push_str(&block);
injection.push('\n');
injection.push('\n');
}
print!("{injection}");
let mark_url = format!("{base}/api/v1/messages/mark_read");
for m in &messages {
let body = serde_json::json!({"palace": recipient, "drawer_id": m.id});
let _ = client.post(&mark_url).json(&body).send().await;
}
log_entry(trigger_prompt, &injection, messages.len(), recipient, start);
injection
}
async fn emit_hook_event(trigger_prompt: &str, injection: &str, recipient: &str, start: Instant) {
let palace_id = if recipient == "<unknown>" || recipient.is_empty() {
None
} else {
Some(recipient.to_string())
};
let payload = HookEventPayload {
palace_id: palace_id.clone(),
palace_name: palace_id,
hook_type: HookType::SessionStart,
injection_kind: InjectionKind::InboxCheck,
injection_length: injection.len() as u64,
trigger_prompt_excerpt: hook_prompt_excerpt(trigger_prompt),
duration_ms: start.elapsed().as_millis() as u64,
};
post_hook_event(payload).await;
}
fn read_stdin_best_effort() -> String {
use std::io::Read;
const STDIN_CAP_BYTES: usize = 64 * 1024;
let stdin = std::io::stdin();
if std::io::IsTerminal::is_terminal(&stdin) {
return String::new();
}
let mut buf = String::new();
let _ = stdin
.lock()
.take(STDIN_CAP_BYTES as u64)
.read_to_string(&mut buf);
buf
}
fn palace_slug_from_stdin_cwd(stdin_payload: &str) -> Option<String> {
if stdin_payload.trim().is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(stdin_payload).ok()?;
let cwd = value.get("cwd")?.as_str()?;
if cwd.is_empty() {
return None;
}
crate::messaging::cwd_palace_slug_at(std::path::Path::new(cwd)).ok()
}
fn log_entry(
trigger_prompt: &str,
injection: &str,
unread_count: usize,
palace: &str,
start: Instant,
) {
let logger = PromptLogger::from_env();
let entry = PromptLogEntry::new(
"SessionStart",
"inbox-check-messages",
palace,
trigger_prompt,
injection,
)
.with_unread_messages_count(unread_count)
.with_duration_ms(start.elapsed().as_millis() as u64);
logger.log(entry);
}
fn render_fallback(m: &ServerMessage) -> String {
let from = m.from_palace.as_deref().unwrap_or("<unknown>");
let to = m.to_palace.as_deref().unwrap_or("<unknown>");
let purpose = m.purpose.as_deref().unwrap_or("<unknown>");
let sent_at = m.sent_at.as_deref().unwrap_or("<unknown>");
let content = m.content.as_deref().unwrap_or("<missing body>");
format!(
"## Message from {from} (purpose: {purpose})\n\
_sent {sent_at} → {to}_\n\
\n\
{content}\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn palace_slug_from_stdin_cwd_uses_stdin_path() {
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path().join("inbox-stdin-project");
std::fs::create_dir_all(&project).expect("create project dir");
let payload = serde_json::json!({
"hook_event_name": "SessionStart",
"cwd": project.to_string_lossy(),
})
.to_string();
let expected =
crate::messaging::cwd_palace_slug_at(&project).expect("derive slug from stdin cwd");
let got = palace_slug_from_stdin_cwd(&payload).expect("slug from stdin");
assert_eq!(got, expected);
assert!(
got.contains("inbox-stdin-project"),
"expected slug derived from stdin path, got {got:?}"
);
}
#[test]
fn palace_slug_from_stdin_cwd_returns_none_on_bad_input() {
assert_eq!(palace_slug_from_stdin_cwd(""), None);
assert_eq!(palace_slug_from_stdin_cwd("not json"), None);
assert_eq!(palace_slug_from_stdin_cwd("{\"foo\":\"bar\"}"), None);
assert_eq!(palace_slug_from_stdin_cwd("{\"cwd\":\"\"}"), None);
}
#[tokio::test]
async fn inbox_check_returns_ok_without_daemon() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
}
let res = handle_inbox_check(Some("test-palace".to_string())).await;
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(
res.is_ok(),
"missing daemon lockfile must degrade to Ok(()), got {res:?}"
);
}
#[tokio::test]
async fn inbox_check_logs_attempt_without_daemon() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
std::env::remove_var(crate::prompt_log::ENV_ENABLED);
std::env::remove_var(crate::prompt_log::ENV_DIR);
std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
}
let res = handle_inbox_check(Some("explicit-palace".to_string())).await;
let logs_dir = trusty_common::resolve_data_dir("trusty-memory")
.expect("resolve data dir")
.join("logs");
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(res.is_ok());
let files: Vec<_> = std::fs::read_dir(&logs_dir)
.expect("logs dir should exist")
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("enriched-prompts."))
})
.collect();
assert_eq!(
files.len(),
1,
"expected one enriched-prompts log file, got {files:?}"
);
let content = std::fs::read_to_string(&files[0]).expect("read log");
let line = content.lines().next().expect("at least one line");
let parsed: crate::prompt_log::PromptLogEntry =
serde_json::from_str(line).expect("parse JSONL");
assert_eq!(parsed.hook_type, "SessionStart");
assert_eq!(parsed.injection_kind, "inbox-check-messages");
assert_eq!(parsed.palace, "explicit-palace");
}
}