use std::path::Path;
use anyhow::{Context, Result, anyhow, bail};
use regex::RegexBuilder;
use serde_json::{Value, json};
use crate::llm::{Message, Role};
use crate::session::Record;
use crate::tools::ToolDefinition;
pub const SEARCH_TOOL: &str = "history_search";
pub const READ_TOOL: &str = "history_read";
const DEFAULT_LIMIT: usize = 20;
const MAX_LIMIT: usize = 100;
const SNIPPET_CHARS: usize = 240;
pub fn is_history_tool(name: &str) -> bool {
name == SEARCH_TOOL || name == READ_TOOL
}
pub fn definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition::new(
SEARCH_TOOL,
"Search the original messages of this session, including those folded into a compaction summary. \
Returns matching messages as `#N role: snippet` lines, where #N is the ID the summary cites; read one \
whole with history_read. Results are history, not the current state of files.",
json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regular expression (case-insensitive); plain text works too" },
"role": { "type": "string", "enum": ["user", "assistant", "tool"], "description": "Only messages with this role" },
"before": { "type": "integer", "description": "Only messages with an ID below this" },
"after": { "type": "integer", "description": "Only messages with an ID above this" },
"limit": { "type": "integer", "description": "Maximum results, newest first (default 20, max 100)" }
},
"required": ["pattern"]
}),
),
ToolDefinition::new(
READ_TOOL,
"Read one original message of this session in full by its ID (#N from a summary or history_search).",
json!({
"type": "object",
"properties": {
"id": { "type": "integer", "description": "Message ID (the N in #N)" },
"max_output_length": { "type": "integer", "description": "Maximum characters to return (default 40000)" }
},
"required": ["id"]
}),
),
]
}
pub fn load(path: &Path) -> Result<Vec<(u64, Message)>> {
let bytes = std::fs::read(path).with_context(|| format!("read session log {}", path.display()))?;
let mut messages = Vec::new();
for (index, line) in bytes.split(|&b| b == b'\n').enumerate() {
if line.is_empty() {
continue;
}
if let Ok(Record::Message(message)) = serde_json::from_slice::<Record>(line) {
messages.push((index as u64 + 1, message));
}
}
Ok(messages)
}
fn role_name(role: &Role) -> &'static str {
match role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
}
}
fn label(id: u64, message: &Message) -> String {
let mut label = format!("#{id} {}", role_name(&message.role));
if let Some(name) = &message.name {
label.push_str(&format!(" {name}"));
}
if message.is_error {
label.push_str(" (failed)");
}
if let Some(time) = message.timestamp {
label.push_str(&format!(" ({})", time.format("%Y-%m-%d %H:%M:%S")));
}
label
}
fn searchable(message: &Message) -> String {
if message.name.as_deref().is_some_and(is_history_tool) {
return String::new();
}
let mut text = message.content.clone();
for call in message.tool_calls.iter().filter(|c| !is_history_tool(&c.name)) {
text.push_str(&format!("\n[called {}({})]", call.name, call.arguments));
}
text
}
fn id_arg(args: &Value, key: &str) -> Result<Option<u64>> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| anyhow!("{key} must be a positive integer")),
Some(Value::String(s)) => s
.trim()
.trim_start_matches('#')
.parse()
.map(Some)
.map_err(|_| anyhow!("{key} must be a positive integer")),
Some(_) => bail!("{key} must be a positive integer"),
}
}
fn snippet(text: &str, start: usize, end: usize) -> String {
let lead = SNIPPET_CHARS / 3;
let from = text[..start].char_indices().rev().nth(lead - 1).map_or(0, |(i, _)| i);
let room = SNIPPET_CHARS.saturating_sub(text[from..start].chars().count());
let to = text[end..].char_indices().nth(room.saturating_sub(text[start..end].chars().count())).map_or(text.len(), |(i, _)| end + i);
let mut out = text[from..to].split_whitespace().collect::<Vec<_>>().join(" ");
if from > 0 {
out.insert(0, '…');
}
if to < text.len() {
out.push('…');
}
out
}
pub fn search(path: &Path, args: &Value) -> Result<String> {
let pattern = args.get("pattern").and_then(Value::as_str).filter(|p| !p.is_empty())
.ok_or_else(|| anyhow!("pattern must be a non-empty string"))?;
let regex = RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
.or_else(|_| RegexBuilder::new(®ex::escape(pattern)).case_insensitive(true).build())?;
let role = args.get("role").and_then(Value::as_str);
let before = id_arg(args, "before")?;
let after = id_arg(args, "after")?;
let limit = match args.get("limit").and_then(Value::as_u64) {
Some(0) | None => DEFAULT_LIMIT,
Some(n) => (n as usize).min(MAX_LIMIT),
};
let messages = load(path)?;
let mut hits = Vec::new();
let mut total = 0;
for (id, message) in messages.iter().rev() {
if message.role == Role::System
|| role.is_some_and(|r| r != role_name(&message.role))
|| before.is_some_and(|b| *id >= b)
|| after.is_some_and(|a| *id <= a)
{
continue;
}
let text = searchable(message);
let Some(found) = regex.find(&text) else { continue };
total += 1;
if hits.len() < limit {
hits.push(format!("{}: {}", label(*id, message), snippet(&text, found.start(), found.end())));
}
}
if hits.is_empty() {
return Ok(format!("No messages match {pattern:?}."));
}
let mut out = hits.join("\n");
if total > hits.len() {
out.push_str(&format!("\n[{} more matches; narrow the pattern or use before/after]", total - hits.len()));
}
Ok(out)
}
pub fn read(path: &Path, args: &Value, spill_dir: &Path) -> Result<String> {
let id = id_arg(args, "id")?.ok_or_else(|| anyhow!("id is required"))?;
let limit = crate::output::parse_max_output_length(args.get("max_output_length")).map_err(|e| anyhow!(e))?;
let messages = load(path)?;
let Some((_, message)) = messages.iter().find(|(line, _)| *line == id) else {
let last = messages.last().map_or(0, |(line, _)| *line);
bail!("no message #{id} in this session (IDs are log lines; the latest is #{last})");
};
if message.role == Role::System {
bail!("message #{id} is a system message and cannot be read");
}
let mut text = format!("{}\n", label(id, message));
for block in &message.thinking_blocks {
if let Some(thought) = block.get("thinking").and_then(Value::as_str) {
text.push_str(&format!("[thinking] {thought}\n"));
}
}
text.push_str(&message.content);
for call in &message.tool_calls {
text.push_str(&format!("\n[called {} id={} {}]", call.name, call.id, call.arguments));
}
Ok(crate::output::bound_and_spill(&text, limit, spill_dir, &format!("history-{id}.txt")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{SessionLog, now};
fn log(dir: &Path) -> std::path::PathBuf {
let mut log = SessionLog::create(dir, "h").unwrap();
log.append(&Record::Message(Message::system("sys"))).unwrap();
log.append(&Record::Message(Message::user("fix the flaky test in auth.rs"))).unwrap();
let call = crate::llm::ToolCall { id: "c1".into(), name: "bash".into(), arguments: json!({"command": "cargo test auth"}), item_id: None };
log.append(&Record::Message(Message::assistant_with_tools("", vec![call]))).unwrap();
let long = format!("{}error[E0308]: mismatched types at auth.rs:42{}", "a ".repeat(400), " b".repeat(400));
log.append(&Record::Message(Message::tool_error("c1", "bash", &long))).unwrap();
log.append(&Record::Message(Message::assistant("fixed"))).unwrap();
let replace = Record::Replace {
messages: vec![Message::system("sys"), Message::user("summary"), Message::assistant("fixed")],
pending_position: None,
summarized: Some((3, 5)),
mode: Some(crate::config::CompactionMode::Smart),
model: None,
recorded_at: now(),
};
log.append(&replace).unwrap();
log.path().to_path_buf()
}
#[test]
fn ids_are_log_lines_and_replace_records_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let ids: Vec<u64> = load(&log(dir.path())).unwrap().into_iter().map(|(id, _)| id).collect();
assert_eq!(ids, vec![2, 3, 4, 5, 6]);
}
#[test]
fn search_finds_text_and_tool_calls_newest_first() {
let dir = tempfile::tempdir().unwrap();
let path = log(dir.path());
let out = search(&path, &json!({"pattern": "auth"})).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3, "{out}");
assert!(lines[0].starts_with("#5 tool bash (failed)") && lines[0].contains("error[E0308]"), "{out}");
assert!(lines[0].contains('…') && lines[0].chars().count() < 400, "snippets are short: {out}");
assert!(lines[1].starts_with("#4 assistant") && lines[1].contains("cargo test auth"), "{out}");
assert!(lines[2].starts_with("#3 user"), "{out}");
let out = search(&path, &json!({"pattern": "auth", "role": "user"})).unwrap();
assert_eq!(out.lines().count(), 1);
let out = search(&path, &json!({"pattern": "auth", "limit": 1})).unwrap();
assert!(out.contains("[2 more matches"), "{out}");
let out = search(&path, &json!({"pattern": "auth", "before": 4})).unwrap();
assert!(out.starts_with("#3 user") && out.lines().count() == 1, "{out}");
assert!(search(&path, &json!({"pattern": "E0308]"})).unwrap().starts_with("#5"));
assert!(search(&path, &json!({"pattern": "nothing-like-this"})).unwrap().starts_with("No messages match"));
assert!(search(&path, &json!({})).is_err());
}
#[test]
fn read_returns_one_message_whole_or_bounded() {
let dir = tempfile::tempdir().unwrap();
let path = log(dir.path());
let out = read(&path, &json!({"id": 5}), dir.path()).unwrap();
assert!(out.starts_with("#5 tool bash (failed)") && out.contains("auth.rs:42") && out.ends_with(" b"), "{out}");
let out = read(&path, &json!({"id": "#4"}), dir.path()).unwrap();
assert!(out.contains("[called bash id=c1 {\"command\":\"cargo test auth\"}]"), "{out}");
let out = read(&path, &json!({"id": 5, "max_output_length": 100}), dir.path()).unwrap();
assert!(out.contains("complete output in"), "{out}");
let err = read(&path, &json!({"id": 99}), dir.path()).unwrap_err();
assert!(err.to_string().contains("latest is #6"), "{err}");
let err = read(&path, &json!({"id": 2}), dir.path()).unwrap_err();
assert!(err.to_string().contains("system message"), "{err}");
}
#[test]
fn read_includes_thinking_blocks() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "t").unwrap();
let mut thinker = Message::assistant("");
thinker.thinking_blocks = vec![json!({"type": "thinking", "thinking": "weigh the options", "signature": "sig"})];
log.append(&Record::Message(thinker)).unwrap();
let out = read(log.path(), &json!({"id": 2}), dir.path()).unwrap();
assert!(out.contains("[thinking] weigh the options"), "{out}");
}
}