Skip to content

Commit d09293d

Browse files
authored
feat(PN-0): full conversation archival + EPHEMERAL disconnect (#51)
Archive complete conversations to archives/conversations/ on session end, server shutdown, and context compaction. EPHEMERAL.md becomes a standalone lightweight summary — no longer promoted to archives. Two parallel paths on session end: full conversation serialized to grep-searchable markdown archive, brief summary written to EPHEMERAL for next-session orientation. Fixes EPHEMERAL path to memory/EPHEMERAL.md (matching what the system prompt builder reads). New module: src/session.rs with conversation_to_markdown(), archive_conversation(), and end_session(). Adds root_dir to AppState for efficient access. 9 new tests.
1 parent 01c0e6a commit d09293d

9 files changed

Lines changed: 486 additions & 59 deletions

File tree

src/chat/repl.rs

Lines changed: 5 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ pub async fn run(
6666
provider,
6767
config.llm.context_budget,
6868
config.llm.max_tokens,
69+
root_dir,
70+
entity_name,
71+
"repl",
6972
)
7073
.await;
7174

@@ -180,68 +183,12 @@ pub async fn run(
180183
}
181184
}
182185

183-
// Save session to EPHEMERAL.md
184-
save_session(root_dir, entity_name, &conversation);
186+
// Archive full conversation + write EPHEMERAL summary
187+
crate::session::end_session(root_dir, entity_name, &conversation, "repl", "session-end");
185188

186189
Ok(())
187190
}
188191

189-
/// Save a brief session summary to EPHEMERAL.md.
190-
fn save_session(root_dir: &Path, entity_name: &str, conversation: &[Message]) {
191-
// Only save if there was actual conversation
192-
let message_count = conversation.len();
193-
if message_count == 0 {
194-
return;
195-
}
196-
197-
let ephemeral_path = root_dir.join("EPHEMERAL.md");
198-
199-
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
200-
201-
// Collect user messages for the summary
202-
let user_messages: Vec<&str> = conversation
203-
.iter()
204-
.filter_map(|m| {
205-
if matches!(m.role, Role::User) {
206-
if let MessageContent::Text(ref t) = m.content {
207-
Some(t.as_str())
208-
} else {
209-
None
210-
}
211-
} else {
212-
None
213-
}
214-
})
215-
.collect();
216-
217-
let topics: Vec<&str> = user_messages.iter().take(5).copied().collect();
218-
219-
let mut content = format!("## CLI Chat Session — {}\n\n", now);
220-
content.push_str(&format!(
221-
"Conversation with {} ({} messages)\n\n",
222-
entity_name, message_count
223-
));
224-
content.push_str("### Topics discussed\n\n");
225-
for topic in &topics {
226-
// Truncate long messages
227-
let display = if topic.len() > 80 {
228-
format!("{}...", &topic[..77])
229-
} else {
230-
topic.to_string()
231-
};
232-
content.push_str(&format!("- {}\n", display));
233-
}
234-
if user_messages.len() > 5 {
235-
content.push_str(&format!("- ...and {} more\n", user_messages.len() - 5));
236-
}
237-
238-
if let Err(e) = std::fs::write(&ephemeral_path, content) {
239-
eprintln!(" \x1b[33mwarning\x1b[0m could not save session: {}", e);
240-
} else {
241-
println!(" \x1b[2msession saved to EPHEMERAL.md\x1b[0m");
242-
}
243-
}
244-
245192
/// Print a tool execution indicator (dimmed).
246193
fn print_tool_indicator(name: &str, input: &serde_json::Value) {
247194
let detail = match name {

src/context.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::path::Path;
2+
13
use echo_system_types::llm::{ContentBlock, LmProvider, Message, MessageContent, Role};
24

35
/// Default context budget in estimated tokens (leaves room for system prompt + response).
@@ -89,6 +91,9 @@ pub async fn compact_if_needed(
8991
provider: &dyn LmProvider,
9092
context_budget: usize,
9193
max_tokens: u32,
94+
root_dir: &Path,
95+
entity_name: &str,
96+
channel: &str,
9297
) {
9398
let budget = if context_budget > 0 {
9499
context_budget
@@ -125,6 +130,17 @@ pub async fn compact_if_needed(
125130
}
126131

127132
let old_messages = &conversation[..split_at];
133+
134+
// Archive the messages about to be compacted
135+
let meta = crate::session::ArchiveMeta {
136+
trigger: "compaction".to_string(),
137+
channel: channel.to_string(),
138+
entity_name: entity_name.to_string(),
139+
};
140+
if let Err(e) = crate::session::archive_conversation(root_dir, old_messages, &meta) {
141+
tracing::warn!("Failed to archive compacted messages: {}", e);
142+
}
143+
128144
let summary_input = build_summary_prompt(old_messages);
129145

130146
let summarize_prompt = format!(

src/init/wizard.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ fn create_directory_structure(dir: &Path) -> Result<(), Box<dyn std::error::Erro
243243
"archives/curiosity",
244244
"archives/thoughts",
245245
"archives/praxis",
246+
"archives/conversations",
246247
"plugins",
247248
"logs",
248249
];

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod pidfile;
1111
mod plugins;
1212
mod scheduler;
1313
mod server;
14+
mod session;
1415
mod tools;
1516

1617
#[derive(Parser)]

src/server/auth.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ mod tests {
103103
system_prompt: RwLock::new(String::new()),
104104
tools: ToolRegistry::new(),
105105
event_bus: Arc::new(EventBus::new(16)),
106+
root_dir: std::env::temp_dir(),
106107
})
107108
}
108109

src/server/e2e_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ fn build_state(provider: MockProvider, tools: ToolRegistry) -> Arc<AppState> {
134134
system_prompt: RwLock::new("You are a test entity.".to_string()),
135135
tools,
136136
event_bus: Arc::new(EventBus::new(16)),
137+
root_dir: std::env::temp_dir(),
137138
})
138139
}
139140

src/server/handlers/chat.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ pub async fn chat(
8080
state.provider.as_ref(),
8181
state.config.llm.context_budget,
8282
state.config.llm.max_tokens,
83+
&state.root_dir,
84+
&state.config.entity.name,
85+
&req.channel,
8386
)
8487
.await;
8588

src/server/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod prompt;
77
pub mod rate_limit;
88
pub mod trust;
99

10+
use std::path::PathBuf;
1011
use std::sync::Arc;
1112

1213
use axum::middleware;
@@ -32,6 +33,7 @@ pub struct AppState {
3233
pub system_prompt: RwLock<String>,
3334
pub tools: ToolRegistry,
3435
pub event_bus: Arc<EventBus>,
36+
pub root_dir: PathBuf,
3537
}
3638

3739
pub async fn start(config: Config) -> Result<(), Box<dyn std::error::Error>> {
@@ -95,6 +97,7 @@ pub async fn start(config: Config) -> Result<(), Box<dyn std::error::Error>> {
9597
system_prompt: RwLock::new(system_prompt),
9698
tools,
9799
event_bus: Arc::clone(&event_bus),
100+
root_dir: root_dir.clone(),
98101
});
99102

100103
// Load schedule and intent queue, start scheduler
@@ -142,7 +145,7 @@ pub async fn start(config: Config) -> Result<(), Box<dyn std::error::Error>> {
142145
Arc::clone(&state),
143146
auth::require_auth,
144147
))
145-
.with_state(state)
148+
.with_state(Arc::clone(&state))
146149
.layer(middleware::from_fn_with_state(
147150
limiter,
148151
rate_limit::rate_limit,
@@ -171,6 +174,20 @@ pub async fn start(config: Config) -> Result<(), Box<dyn std::error::Error>> {
171174
.with_graceful_shutdown(shutdown)
172175
.await?;
173176

177+
// Archive conversation on shutdown
178+
{
179+
let conversation = state.conversation.read().await;
180+
if !conversation.is_empty() {
181+
crate::session::end_session(
182+
&root_dir,
183+
&config.entity.name,
184+
&conversation,
185+
"http",
186+
"server-shutdown",
187+
);
188+
}
189+
}
190+
174191
// Clean up plugins on shutdown
175192
plugin_manager.stop_all().await;
176193

0 commit comments

Comments
 (0)