use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use crate::config;
use crate::conversation::{self, Conversation};
use crate::ephemeral::{self, EphemeralEntry};
use crate::error::RecallError;
use crate::frontmatter::Frontmatter;
use crate::summarize;
use crate::tags;
use crate::transcript::{ClaudeCodeTranscripts, Transcript};
#[derive(Debug, Clone)]
pub struct SessionMetadata {
pub session_id: String,
pub started_at: Option<String>,
pub ended_at: Option<String>,
pub pulse_name: String,
}
pub struct ArchiveResult {
pub log_number: u32,
pub full_content: String,
pub session_id: String,
}
#[must_use]
pub fn highest_conversation_number(conversations_dir: &Path) -> u32 {
let entries = match fs::read_dir(conversations_dir) {
Ok(e) => e,
Err(_) => return 0,
};
let mut max = 0u32;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(num_str) = name
.strip_prefix("conversation-")
.and_then(|s| s.strip_suffix(".md"))
{
if let Ok(n) = num_str.parse::<u32>() {
if n > max {
max = n;
}
}
}
}
max
}
pub fn append_index(
archive_path: &Path,
log_num: u32,
date: &str,
session_id: &str,
topics: &[String],
message_count: u32,
duration: &str,
) -> Result<(), RecallError> {
use std::io::Write;
let needs_header = if archive_path.exists() {
fs::read_to_string(archive_path)
.unwrap_or_default()
.trim()
.is_empty()
} else {
true
};
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(archive_path)?;
if needs_header {
writeln!(file, "# Conversation Archive\n")?;
writeln!(
file,
"| # | Date | Session | Topics | Messages | Duration |"
)?;
writeln!(
file,
"|---|------|---------|--------|----------|----------|"
)?;
}
let topics_str = if topics.is_empty() {
"\u{2014}".to_string()
} else {
topics.join(", ")
};
writeln!(
file,
"| {log_num:03} | {date} | {session_id} | {topics_str} | {message_count} | {duration} |"
)?;
Ok(())
}
pub fn archive_conversation(
memory_dir: &Path,
conv: &Conversation,
summary: &summarize::ConversationSummary,
source: &str,
) -> Result<ArchiveResult, RecallError> {
write_archive(memory_dir, conv, summary, source, None)
}
pub fn archive_pulse_conversation(
memory_dir: &Path,
conv: &Conversation,
summary: &summarize::ConversationSummary,
source: &str,
pulse_name: &str,
) -> Result<ArchiveResult, RecallError> {
let pulse = Some(pulse_name).filter(|name| !name.trim().is_empty());
write_archive(memory_dir, conv, summary, source, pulse)
}
fn write_archive(
memory_dir: &Path,
conv: &Conversation,
summary: &summarize::ConversationSummary,
source: &str,
pulse: Option<&str>,
) -> Result<ArchiveResult, RecallError> {
let conversations_dir = memory_dir.join("conversations");
let archive_index = memory_dir.join("ARCHIVE.md");
let ephemeral_path = memory_dir.join("EPHEMERAL.md");
if !conversations_dir.exists() {
return Err(RecallError::NotInitialized(
"conversations/ directory not found. Run init first.".into(),
));
}
if conv.user_message_count == 0 {
return Ok(ArchiveResult {
log_number: 0,
full_content: String::new(),
session_id: conv.session_id.clone(),
});
}
let next_num = highest_conversation_number(&conversations_dir) + 1;
let now = conversation::utc_now();
let date = conversation::date_from_timestamp(&now);
let duration = match (&conv.first_timestamp, &conv.last_timestamp) {
(Some(start), Some(end)) => conversation::calculate_duration(start, end),
_ => "unknown".to_string(),
};
let total_messages = conv.total_messages();
let fm = Frontmatter {
log: next_num,
date: now.clone(),
session_id: conv.session_id.clone(),
message_count: total_messages,
duration: duration.clone(),
source: source.to_string(),
pulse: pulse.map(String::from),
topics: summary.topics.clone(),
};
let md_body = conversation::conversation_to_markdown(conv, next_num);
let conv_tags = tags::extract_tags(&conv.entries);
let tags_section = tags::format_tags_section(&conv_tags);
let summary_section = if !summary.summary.is_empty() {
let mut s = format!("## Summary\n\n{}\n\n", summary.summary);
if !summary.decisions.is_empty() {
s.push_str("**Decisions**:\n");
for d in &summary.decisions {
let _ = writeln!(s, "- {d}");
}
s.push('\n');
}
if !summary.action_items.is_empty() {
s.push_str("**Action Items**:\n");
for a in &summary.action_items {
let _ = writeln!(s, "- {a}");
}
s.push('\n');
}
s
} else {
String::new()
};
let full_content = format!(
"{}\n\n{}{}\n{}",
fm.render(),
summary_section,
md_body,
tags_section
);
let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
fs::write(&conv_file, &full_content)?;
append_index(
&archive_index,
next_num,
&date,
&conv.session_id,
&summary.topics,
total_messages,
&duration,
)?;
let entry = EphemeralEntry {
session_id: conv.session_id.clone(),
date: now,
duration,
message_count: total_messages,
archive_file: format!("conversation-{next_num:03}.md"),
summary: summary.summary.clone(),
};
ephemeral::append_entry(&ephemeral_path, &entry)?;
let cfg = config::load_from_dir(memory_dir);
ephemeral::trim_to_limit(&ephemeral_path, cfg.ephemeral.max_entries)?;
eprintln!("recall-echo: archived conversation-{next_num:03}.md ({total_messages} messages)");
Ok(ArchiveResult {
log_number: next_num,
full_content,
session_id: conv.session_id.clone(),
})
}
pub fn graph_ingest(memory_dir: &Path, result: &ArchiveResult) {
if result.log_number == 0 {
return;
}
let rt = match client_runtime() {
Ok(rt) => rt,
Err(e) => {
eprintln!("recall-echo: graph runtime error: {e}");
return;
}
};
if let Err(e) = rt.block_on(crate::graph_bridge::ingest_into_graph(
memory_dir,
&result.full_content,
&result.session_id,
Some(result.log_number),
)) {
eprintln!("recall-echo: graph ingestion warning: {e}");
}
}
fn client_runtime() -> std::io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
}
fn pipeline_docs_to_sync(memory_dir: &Path) -> Option<crate::graph::types::PipelineDocuments> {
let cfg = config::load_from_dir(memory_dir);
let pipeline = match cfg.pipeline {
Some(ref p) if p.auto_sync == Some(true) => p,
_ => return None,
};
let docs_dir = match pipeline.docs_dir {
Some(ref d) => {
let path = std::path::PathBuf::from(shellexpand_path(d));
if !path.exists() {
eprintln!(
"recall-echo: pipeline docs_dir not found: {}",
path.display()
);
return None;
}
path
}
None => {
eprintln!("recall-echo: pipeline auto_sync enabled but no docs_dir configured");
return None;
}
};
if !memory_dir.join("graph").exists() {
return None;
}
Some(read_pipeline_docs(&docs_dir))
}
pub fn pipeline_sync_on_archive(memory_dir: &Path) {
let Some(docs) = pipeline_docs_to_sync(memory_dir) else {
return;
};
let rt = match client_runtime() {
Ok(rt) => rt,
Err(e) => {
eprintln!("recall-echo: pipeline sync runtime error: {e}");
return;
}
};
report_pipeline_sync(rt.block_on(crate::graph_bridge::sync_pipeline_into_graph(
memory_dir, docs,
)));
}
#[cfg(feature = "pulse-null")]
async fn pipeline_sync_on_archive_async(memory_dir: &Path) {
let Some(docs) = pipeline_docs_to_sync(memory_dir) else {
return;
};
report_pipeline_sync(crate::graph_bridge::sync_pipeline_into_graph(memory_dir, docs).await);
}
fn report_pipeline_sync(result: Result<crate::graph::types::PipelineSyncReport, RecallError>) {
match result {
Ok(report) => {
if report.entities_created > 0
|| report.entities_updated > 0
|| report.entities_archived > 0
{
eprintln!(
"recall-echo: pipeline synced — +{} created, ~{} updated, -{} archived",
report.entities_created, report.entities_updated, report.entities_archived
);
}
}
Err(e) => eprintln!("recall-echo: pipeline sync warning: {e}"),
}
}
fn read_pipeline_docs(docs_dir: &Path) -> crate::graph::types::PipelineDocuments {
crate::graph::types::PipelineDocuments {
learning: read_opt_file(docs_dir, "LEARNING.md"),
thoughts: read_opt_file(docs_dir, "THOUGHTS.md"),
curiosity: read_opt_file(docs_dir, "CURIOSITY.md"),
reflections: read_opt_file(docs_dir, "REFLECTIONS.md"),
praxis: read_opt_file(docs_dir, "PRAXIS.md"),
}
}
fn read_opt_file(dir: &Path, name: &str) -> String {
fs::read_to_string(dir.join(name)).unwrap_or_default()
}
fn shellexpand_path(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{home}/{rest}");
}
}
path.to_string()
}
pub fn archive_from_jsonl(
base_dir: &Path,
session_id: &str,
transcript_path: &str,
) -> Result<u32, RecallError> {
let conv = crate::jsonl::parse_transcript(transcript_path, session_id)?;
archive_and_ingest(base_dir, &conv, "jsonl")
}
fn archive_and_ingest(
base_dir: &Path,
conv: &Conversation,
source: &str,
) -> Result<u32, RecallError> {
let summary = summarize::algorithmic_summary(conv);
let result = archive_conversation(base_dir, conv, &summary, source)?;
let log_number = result.log_number;
graph_ingest(base_dir, &result);
pipeline_sync_on_archive(base_dir);
Ok(log_number)
}
#[derive(Debug)]
enum HookTarget {
Unnamed,
Absent,
Subagent,
ClaudeJsonl,
GeminiSession(Box<Conversation>),
Unreadable,
}
fn classify(transcript_path: &str, session_id: &str) -> HookTarget {
if transcript_path.is_empty() {
return HookTarget::Unnamed;
}
if !Path::new(transcript_path).exists() {
return HookTarget::Absent;
}
if crate::jsonl::is_subagent_transcript(Path::new(transcript_path)) {
return HookTarget::Subagent;
}
if crate::jsonl::is_jsonl_transcript(transcript_path) {
return HookTarget::ClaudeJsonl;
}
fs::read_to_string(transcript_path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|document| crate::transcript::gemini::parse_session(&document, session_id))
.map_or(HookTarget::Unreadable, |conv| {
HookTarget::GeminiSession(Box::new(conv))
})
}
pub fn run_from_hook(pulse_root: Option<&Path>) -> Result<(), RecallError> {
let hook_input = crate::jsonl::read_hook_input()?;
let base_dir = crate::paths::resolved_hook_base_dir(pulse_root)?;
run_with_hook_input(&hook_input, &base_dir)
}
pub fn run_with_hook_input(
hook_input: &crate::jsonl::HookInput,
base_dir: &Path,
) -> Result<(), RecallError> {
let transcript_path = hook_input.transcript_path.trim();
match classify(transcript_path, &hook_input.session_id) {
HookTarget::Unnamed => {
eprintln!(
"recall-echo: the hook payload names no transcript, so there is nothing to \
archive. `archive-session` expects a SessionEnd payload on stdin, shaped \
{{\"session_id\": …, \"transcript_path\": …}}."
);
}
HookTarget::Absent => {
eprintln!(
"recall-echo: no transcript at {transcript_path} (session not persisted), \
nothing to archive"
);
}
HookTarget::Subagent => {
crate::jsonl::report_declined_subagent(transcript_path, "nothing archived");
}
HookTarget::ClaudeJsonl => {
archive_from_jsonl(base_dir, &hook_input.session_id, transcript_path)?;
}
HookTarget::GeminiSession(conv) => {
archive_and_ingest(base_dir, &conv, "gemini")?;
}
HookTarget::Unreadable => {
eprintln!(
"recall-echo: {transcript_path} is neither a Claude Code JSONL transcript nor \
a Gemini chat session, so nothing was archived. If this hook came from \
`gemini hooks migrate --from-claude`, recall-echo reads Gemini sessions at \
~/.gemini/tmp/<hash>/chats/session-*.json — please report this file's shape \
at https://github.com/dnacenta/recall-echo/issues so it can be read too."
);
}
}
Ok(())
}
pub fn archive_all_unarchived() -> Result<(), RecallError> {
archive_all_unarchived_at(None)
}
pub fn archive_all_unarchived_at(pulse_root: Option<&Path>) -> Result<(), RecallError> {
let (memory_dir, why) = crate::paths::batch_archive_base_dir(pulse_root)?;
eprintln!("recall-echo: archiving into {memory_dir:?} ({why})");
let transcripts = ClaudeCodeTranscripts::detect()
.ok_or_else(|| RecallError::Other("Could not determine home directory".into()))?;
archive_all_with(&memory_dir, &transcripts).map(|_| ())
}
#[deprecated(
since = "4.7.0",
note = "use `archive_all_with`, which takes the store and the transcripts separately"
)]
pub fn archive_all_with_base(base: &Path) -> Result<(), RecallError> {
archive_all_with(base, &ClaudeCodeTranscripts::new(base.join("projects"))).map(|_| ())
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct BatchArchiveReport {
pub archived: u32,
pub already_archived: u32,
pub subagents_skipped: u32,
}
impl BatchArchiveReport {
#[must_use]
pub fn summary(&self) -> String {
format!(
"archived {} conversation{}, skipped {} already archived and {} subagent run{}",
self.archived,
if self.archived == 1 { "" } else { "s" },
self.already_archived,
self.subagents_skipped,
if self.subagents_skipped == 1 { "" } else { "s" }
)
}
}
pub fn archive_all_with(
memory_dir: &Path,
transcripts: &ClaudeCodeTranscripts,
) -> Result<BatchArchiveReport, RecallError> {
let conversations_dir = memory_dir.join("conversations");
if !conversations_dir.exists() {
return Err(RecallError::NotInitialized(
"conversations/ directory not found. Run `recall-echo init` first.".into(),
));
}
let archived_sessions = collect_archived_sessions(&conversations_dir);
if !transcripts.is_installed() {
eprintln!("No projects directory found \u{2014} nothing to archive.");
return Ok(BatchArchiveReport::default());
}
let (found, by_layout) = transcripts.discover_with_skipped(None)?;
let mut report = BatchArchiveReport {
subagents_skipped: by_layout,
..BatchArchiveReport::default()
};
for transcript in found {
let session_id = &transcript.session_id;
if archived_sessions.contains(session_id) {
report.already_archived += 1;
continue;
}
if transcripts.is_sub_run(&transcript) {
crate::jsonl::debug_skipped_subagent(&transcript.path, "isSidechain flag");
report.subagents_skipped += 1;
continue;
}
let path_str = transcript.path.to_string_lossy().to_string();
match archive_from_jsonl(memory_dir, session_id, &path_str) {
Ok(_) => report.archived += 1,
Err(e) => {
eprintln!("recall-echo: skipping {session_id} \u{2014} {e}");
}
}
}
eprintln!("recall-echo: {}", report.summary());
Ok(report)
}
#[must_use]
pub fn collect_archived_sessions(conversations_dir: &Path) -> std::collections::HashSet<String> {
let mut sessions = std::collections::HashSet::new();
if let Ok(entries) = fs::read_dir(conversations_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("conversation-") && name.ends_with(".md") {
if let Ok(content) = fs::read_to_string(entry.path()) {
for line in content.lines().take(15) {
if let Some(sid) = line.strip_prefix("session_id: ") {
sessions.insert(sid.trim().trim_matches('"').to_string());
break;
}
}
}
}
}
}
sessions
}
#[cfg(feature = "pulse-null")]
pub async fn archive_session(
memory_dir: &Path,
messages: &[pulse_system_types::llm::Message],
metadata: &SessionMetadata,
provider: Option<&dyn pulse_system_types::llm::LmProvider>,
) -> Result<u32, RecallError> {
let mut conv = crate::pulse_null::messages_to_conversation(messages, &metadata.session_id);
conv.first_timestamp = metadata.started_at.clone();
conv.last_timestamp = metadata.ended_at.clone();
let summary = summarize::extract_with_fallback(provider, &conv).await;
let result =
archive_pulse_conversation(memory_dir, &conv, &summary, "session", &metadata.pulse_name)?;
let log_number = result.log_number;
if log_number > 0 {
if let Err(e) = crate::graph_bridge::ingest_into_graph(
memory_dir,
&result.full_content,
&result.session_id,
Some(log_number),
)
.await
{
eprintln!("recall-echo: graph ingestion warning: {e}");
}
pipeline_sync_on_archive_async(memory_dir).await;
}
Ok(log_number)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highest_from_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(highest_conversation_number(tmp.path()), 0);
}
#[test]
fn highest_from_sequential_files() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
fs::write(tmp.path().join("conversation-002.md"), "").unwrap();
fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
assert_eq!(highest_conversation_number(tmp.path()), 3);
}
#[test]
fn highest_with_gaps() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
fs::write(tmp.path().join("conversation-010.md"), "").unwrap();
assert_eq!(highest_conversation_number(tmp.path()), 10);
}
#[test]
fn highest_ignores_non_matching() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
fs::write(tmp.path().join("notes.md"), "").unwrap();
fs::write(tmp.path().join("conversation-bad.md"), "").unwrap();
assert_eq!(highest_conversation_number(tmp.path()), 3);
}
#[test]
fn append_index_creates_header_and_appends() {
let tmp = tempfile::tempdir().unwrap();
let index = tmp.path().join("ARCHIVE.md");
append_index(
&index,
1,
"2026-03-05",
"abc123",
&["auth".to_string()],
34,
"45m",
)
.unwrap();
append_index(
&index,
2,
"2026-03-05",
"def456",
&["ci".to_string(), "tests".to_string()],
22,
"20m",
)
.unwrap();
let content = fs::read_to_string(&index).unwrap();
assert!(content.contains("# Conversation Archive"));
assert!(content.contains("| 001 | 2026-03-05 | abc123 | auth | 34 | 45m |"));
assert!(content.contains("| 002 | 2026-03-05 | def456 | ci, tests | 22 | 20m |"));
}
#[test]
fn append_index_to_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let index = tmp.path().join("ARCHIVE.md");
fs::write(
&index,
"# Conversation Archive\n\n| # | Date | Session | Topics | Messages | Duration |\n|---|------|---------|--------|----------|----------|\n| 001 | 2026-03-05 | abc | test | 10 | 5m |\n",
)
.unwrap();
append_index(&index, 2, "2026-03-05", "def", &[], 20, "10m").unwrap();
let content = fs::read_to_string(&index).unwrap();
assert!(content.contains("| 002 | 2026-03-05 | def | \u{2014} | 20 | 10m |"));
assert_eq!(content.matches("# Conversation Archive").count(), 1);
}
#[test]
fn archive_conversation_basic() {
let tmp = tempfile::tempdir().unwrap();
let memory = tmp.path();
fs::create_dir_all(memory.join("conversations")).unwrap();
let conv = Conversation {
session_id: "test-abc".to_string(),
first_timestamp: Some("2026-03-05T14:30:00Z".to_string()),
last_timestamp: Some("2026-03-05T15:00:00Z".to_string()),
user_message_count: 1,
assistant_message_count: 1,
entries: vec![
conversation::ConversationEntry::UserMessage("Let's build something".to_string()),
conversation::ConversationEntry::AssistantText("Sure, let's do it.".to_string()),
],
};
let summary = summarize::ConversationSummary {
summary: "Built something cool".to_string(),
topics: vec!["building".to_string()],
decisions: vec![],
action_items: vec![],
};
let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
assert_eq!(result.log_number, 1);
assert!(memory.join("conversations/conversation-001.md").exists());
let content = fs::read_to_string(memory.join("conversations/conversation-001.md")).unwrap();
assert!(content.contains("session_id: \"test-abc\""));
assert!(content.contains("source: \"test\""));
assert!(content.contains("Built something cool"));
}
#[test]
fn a_pulse_archive_records_the_pulse_in_frontmatter() {
let tmp = tempfile::tempdir().unwrap();
let memory = tmp.path();
fs::create_dir_all(memory.join("conversations")).unwrap();
let mut conv = Conversation::new("pulse-session");
conv.user_message_count = 1;
conv.entries
.push(conversation::ConversationEntry::UserMessage(
"hi".to_string(),
));
let summary = summarize::ConversationSummary::default();
archive_pulse_conversation(memory, &conv, &summary, "session", "Echo").unwrap();
archive_conversation(memory, &conv, &summary, "jsonl").unwrap();
let named = fs::read_to_string(memory.join("conversations/conversation-001.md")).unwrap();
assert!(named.contains("\npulse: \"Echo\"\n"), "{named}");
assert!(!named.contains("entity:"), "{named}");
let parsed = crate::frontmatter::parse(&named).unwrap();
assert_eq!(parsed.pulse.as_deref(), Some("Echo"));
let anonymous =
fs::read_to_string(memory.join("conversations/conversation-002.md")).unwrap();
assert!(!anonymous.contains("pulse:"), "{anonymous}");
}
#[test]
fn archive_conversation_skips_empty() {
let tmp = tempfile::tempdir().unwrap();
let memory = tmp.path();
fs::create_dir_all(memory.join("conversations")).unwrap();
let conv = Conversation::new("empty");
let summary = summarize::ConversationSummary::default();
let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
assert_eq!(result.log_number, 0);
}
#[test]
fn hook_missing_transcript_exits_ok() {
let hook_input = crate::jsonl::HookInput {
session_id: "no-persist".into(),
transcript_path: "/nonexistent/path/transcript.jsonl".into(),
_cwd: None,
_hook_event_name: None,
};
assert!(run_with_hook_input(&hook_input, Path::new("/nonexistent-base")).is_ok());
}
#[test]
fn hook_without_a_transcript_path_exits_ok() {
let hook_input = crate::jsonl::HookInput::default();
assert!(run_with_hook_input(&hook_input, Path::new("/nonexistent-base")).is_ok());
assert!(matches!(classify("", ""), HookTarget::Unnamed));
}
fn written(name: &str, body: &str) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(name);
fs::write(&path, body).unwrap();
let path = path.to_string_lossy().to_string();
(dir, path)
}
const CLAUDE_JSONL: &str = concat!(
r#"{"type":"user","message":{"role":"user","content":"hello"}}"#,
"\n",
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#,
"\n",
);
#[test]
fn a_claude_code_transcript_is_recognised() {
let (_dir, path) = written("session.jsonl", CLAUDE_JSONL);
assert!(matches!(classify(&path, "sess"), HookTarget::ClaudeJsonl));
}
#[test]
fn a_gemini_session_document_is_read_rather_than_skipped() {
let document = serde_json::json!({
"sessionId": "sess-1",
"startTime": "2026-08-06T10:00:00Z",
"messages": [
{"type": "user", "content": "Where does recall-echo live?"},
{"type": "gemini", "content": "Under /opt/recall-echo."},
],
});
for body in [
serde_json::to_string_pretty(&document).unwrap(),
serde_json::to_string(&document).unwrap(),
] {
let (_dir, path) = written("session-1.json", &body);
let HookTarget::GeminiSession(conv) = classify(&path, "hook-session") else {
panic!("not recognised as a Gemini session: {body}");
};
assert_eq!(conv.user_message_count, 1);
assert_eq!(conv.assistant_message_count, 1);
}
}
#[test]
fn a_transcript_in_no_known_format_is_declined() {
let (_dir, path) = written("notes.txt", "not a transcript at all\n");
assert!(matches!(classify(&path, "sess"), HookTarget::Unreadable));
let (_dir, path) = written("empty.jsonl", "");
assert!(matches!(classify(&path, "sess"), HookTarget::Unreadable));
let hook_input = crate::jsonl::HookInput {
session_id: "sess".into(),
transcript_path: path,
_cwd: None,
_hook_event_name: None,
};
assert!(
run_with_hook_input(&hook_input, Path::new("/nonexistent-base")).is_ok(),
"an unreadable transcript must not fail the session"
);
}
#[test]
fn classify_subagent_transcript() {
let dir = tempfile::tempdir().unwrap();
let subagents = dir.path().join("sess/subagents");
fs::create_dir_all(&subagents).unwrap();
let by_path = subagents.join("agent-a1.jsonl");
fs::write(&by_path, CLAUDE_JSONL).unwrap();
assert!(matches!(
classify(&by_path.to_string_lossy(), "agent-a1"),
HookTarget::Subagent
));
let fork = concat!(
r#"{"type":"fork-context-ref","agentId":"a2"}"#,
"\n",
r#"{"type":"user","isSidechain":true,"message":{"role":"user","content":"hi"}}"#,
"\n",
);
let (_dir, by_flag) = written("agent-a2.jsonl", fork);
assert!(matches!(
classify(&by_flag, "agent-a2"),
HookTarget::Subagent
));
}
#[test]
fn hook_on_subagent_transcript_archives_nothing() {
let tmp = tempfile::tempdir().unwrap();
let memory = tmp.path().join("memory");
fs::create_dir_all(memory.join("conversations")).unwrap();
let subagents = tmp.path().join("projects/-p/sess/subagents");
fs::create_dir_all(&subagents).unwrap();
let path = subagents.join("agent-a1.jsonl");
fs::write(&path, CLAUDE_JSONL).unwrap();
let hook_input = crate::jsonl::HookInput {
session_id: "agent-a1".into(),
transcript_path: path.to_string_lossy().to_string(),
_cwd: None,
_hook_event_name: Some("SessionEnd".into()),
};
assert!(run_with_hook_input(&hook_input, &memory).is_ok());
assert_eq!(
fs::read_dir(memory.join("conversations")).unwrap().count(),
0
);
assert!(!memory.join("ARCHIVE.md").exists());
assert!(!memory.join("EPHEMERAL.md").exists());
}
#[test]
fn archive_all_skips_a_fork_beside_its_parent() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join(".claude/projects/-home-dev-app");
fs::create_dir_all(project.join("sess-1/subagents")).unwrap();
fs::write(project.join("sess-1.jsonl"), CLAUDE_JSONL).unwrap();
fs::write(
project.join("sess-1/subagents/agent-a1.jsonl"),
CLAUDE_JSONL,
)
.unwrap();
let fork = concat!(
r#"{"type":"fork-context-ref","agentId":"a2","parentSessionId":"sess-1"}"#,
"\n",
r#"{"type":"user","isSidechain":true,"message":{"role":"user","content":"worker prompt"}}"#,
"\n",
r#"{"type":"assistant","isSidechain":true,"message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}"#,
"\n",
);
fs::write(project.join("agent-a2.jsonl"), fork).unwrap();
let memory = tmp.path().join("root/memory");
fs::create_dir_all(memory.join("conversations")).unwrap();
let transcripts = ClaudeCodeTranscripts::new(tmp.path().join(".claude/projects"));
let report = archive_all_with(&memory, &transcripts).unwrap();
let archived = collect_archived_sessions(&memory.join("conversations"));
assert_eq!(archived.len(), 1, "{archived:?}");
assert!(archived.contains("sess-1"));
assert!(!archived.contains("agent-a2"));
assert_eq!(report.archived, 1);
assert_eq!(report.subagents_skipped, 2, "one by layout, one by flag");
assert!(
report.summary().ends_with("and 2 subagent runs"),
"{}",
report.summary()
);
}
#[test]
fn archive_all_writes_into_memory_store_and_skips_subagents() {
let tmp = tempfile::tempdir().unwrap();
let claude = tmp.path().join(".claude");
let project = claude.join("projects/-home-dev-app");
fs::create_dir_all(project.join("sess-1/subagents")).unwrap();
fs::write(project.join("sess-1.jsonl"), CLAUDE_JSONL).unwrap();
fs::write(
project.join("sess-1/subagents/agent-a1.jsonl"),
CLAUDE_JSONL,
)
.unwrap();
let memory = tmp.path().join("root/memory");
fs::create_dir_all(memory.join("conversations")).unwrap();
fs::create_dir_all(claude.join("conversations")).unwrap();
let transcripts = ClaudeCodeTranscripts::new(claude.join("projects"));
archive_all_with(&memory, &transcripts).unwrap();
let archived = collect_archived_sessions(&memory.join("conversations"));
assert_eq!(archived.len(), 1, "{archived:?}");
assert!(archived.contains("sess-1"));
assert_eq!(
fs::read_dir(claude.join("conversations")).unwrap().count(),
0,
"nothing may land in the legacy store beside projects/"
);
archive_all_with(&memory, &transcripts).unwrap();
assert_eq!(
fs::read_dir(memory.join("conversations")).unwrap().count(),
1
);
}
}