session: --session flag, journal agent cycle, conversation bytes config

- memory-search: add --session flag for multi-session support
- config: add surface_conversation_bytes option
- journal_agent_cycle: trigger journal agent every 10KB of conversation
- Session::from_id() constructor

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-26 14:22:29 -04:00
parent 7c0c376e0f
commit 84c78f7ae1
4 changed files with 91 additions and 18 deletions

View file

@ -18,6 +18,10 @@ struct Args {
#[arg(long)] #[arg(long)]
hook: bool, hook: bool,
/// Session ID (overrides stash file; for multiple concurrent sessions)
#[arg(long)]
session: Option<String>,
#[command(subcommand)] #[command(subcommand)]
command: Option<Cmd>, command: Option<Cmd>,
} }
@ -30,13 +34,19 @@ enum Cmd {
Reflect, Reflect,
} }
fn show_seen() { fn resolve_session(session_arg: &Option<String>) -> Option<poc_memory::memory_search::Session> {
let input = match fs::read_to_string(STASH_PATH) { use poc_memory::memory_search::Session;
Ok(s) => s,
Err(_) => { eprintln!("No session state available"); return; } if let Some(id) = session_arg {
}; return Session::from_id(id.clone());
let Some(session) = poc_memory::memory_search::Session::from_json(&input) else { }
eprintln!("No session state available"); let input = fs::read_to_string(STASH_PATH).ok()?;
Session::from_json(&input)
}
fn show_seen(session_arg: &Option<String>) {
let Some(session) = resolve_session(session_arg) else {
eprintln!("No session state available (use --session ID)");
return; return;
}; };
@ -75,18 +85,18 @@ fn show_seen() {
} }
} }
fn run_agent_and_parse(agent: &str) { fn run_agent_and_parse(agent: &str, session_arg: &Option<String>) {
let session_id = std::env::var("CLAUDE_SESSION_ID") let session_id = session_arg.clone()
.or_else(|_| { .or_else(|| std::env::var("CLAUDE_SESSION_ID").ok())
.or_else(|| {
fs::read_to_string(STASH_PATH).ok() fs::read_to_string(STASH_PATH).ok()
.and_then(|s| poc_memory::memory_search::Session::from_json(&s)) .and_then(|s| poc_memory::memory_search::Session::from_json(&s))
.map(|s| s.session_id) .map(|s| s.session_id)
.ok_or(std::env::VarError::NotPresent)
}) })
.unwrap_or_default(); .unwrap_or_default();
if session_id.is_empty() { if session_id.is_empty() {
eprintln!("No session ID available (set CLAUDE_SESSION_ID or run --hook first)"); eprintln!("No session ID available (use --session ID, set CLAUDE_SESSION_ID, or run --hook first)");
std::process::exit(1); std::process::exit(1);
} }
@ -180,8 +190,8 @@ fn main() {
if let Some(cmd) = args.command { if let Some(cmd) = args.command {
match cmd { match cmd {
Cmd::Surface => run_agent_and_parse("surface"), Cmd::Surface => run_agent_and_parse("surface", &args.session),
Cmd::Reflect => run_agent_and_parse("reflect"), Cmd::Reflect => run_agent_and_parse("reflect", &args.session),
} }
return; return;
} }
@ -203,6 +213,6 @@ fn main() {
let output = poc_memory::memory_search::run_hook(&input); let output = poc_memory::memory_search::run_hook(&input);
print!("{}", output); print!("{}", output);
} else { } else {
show_seen() show_seen(&args.session)
} }
} }

View file

@ -88,6 +88,9 @@ pub struct Config {
/// Surface agent timeout in seconds. /// Surface agent timeout in seconds.
#[serde(default)] #[serde(default)]
pub surface_timeout_secs: Option<u32>, pub surface_timeout_secs: Option<u32>,
/// Max conversation bytes to include in surface agent context.
#[serde(default)]
pub surface_conversation_bytes: Option<usize>,
/// Hook events that trigger the surface agent. /// Hook events that trigger the surface agent.
#[serde(default)] #[serde(default)]
pub surface_hooks: Vec<String>, pub surface_hooks: Vec<String>,
@ -132,6 +135,7 @@ impl Default for Config {
"separator".into(), "split".into(), "separator".into(), "split".into(),
], ],
surface_timeout_secs: None, surface_timeout_secs: None,
surface_conversation_bytes: None,
surface_hooks: vec![], surface_hooks: vec![],
} }
} }

View file

@ -231,6 +231,60 @@ fn surface_agent_cycle(session: &Session, out: &mut String, log_f: &mut File) {
} }
} }
const JOURNAL_INTERVAL_BYTES: u64 = 10_000;
fn journal_agent_cycle(session: &Session, log_f: &mut File) {
let offset_path = session.path("journal-offset");
let pid_path = session.path("journal-pid");
// Check if a previous run is still going
if let Ok(content) = fs::read_to_string(&pid_path) {
let pid: u32 = content.split('\t').next()
.and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if pid != 0 && unsafe { libc::kill(pid as i32, 0) == 0 } {
let _ = writeln!(log_f, "journal: still running (pid {})", pid);
return;
}
}
fs::remove_file(&pid_path).ok();
// Check transcript size vs last run
let transcript_size = fs::metadata(&session.transcript_path)
.map(|m| m.len()).unwrap_or(0);
let last_offset: u64 = fs::read_to_string(&offset_path).ok()
.and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if transcript_size.saturating_sub(last_offset) < JOURNAL_INTERVAL_BYTES {
return;
}
let _ = writeln!(log_f, "journal: spawning (transcript {}, last {})",
transcript_size, last_offset);
// Save current offset
fs::write(&offset_path, transcript_size.to_string()).ok();
// Spawn journal agent — it writes directly to the store via memory tools
let log_dir = crate::store::memory_dir().join("logs");
fs::create_dir_all(&log_dir).ok();
let journal_log = fs::File::create(log_dir.join("journal-agent.log"))
.unwrap_or_else(|_| fs::File::create("/dev/null").unwrap());
if let Ok(child) = Command::new("poc-memory")
.args(["agent", "run", "journal", "--count", "1", "--local"])
.env("POC_SESSION_ID", &session.session_id)
.stdout(journal_log.try_clone().unwrap_or_else(|_| fs::File::create("/dev/null").unwrap()))
.stderr(journal_log)
.spawn()
{
let pid = child.id();
let ts = now_secs();
if let Ok(mut f) = fs::File::create(&pid_path) {
write!(f, "{}\t{}", pid, ts).ok();
}
}
}
fn cleanup_stale_files(dir: &Path, max_age: Duration) { fn cleanup_stale_files(dir: &Path, max_age: Duration) {
let entries = match fs::read_dir(dir) { let entries = match fs::read_dir(dir) {
Ok(e) => e, Ok(e) => e,
@ -308,6 +362,7 @@ fn hook(session: &Session) -> String {
let cfg = crate::config::get(); let cfg = crate::config::get();
if cfg.surface_hooks.iter().any(|h| h == &session.hook_event) { if cfg.surface_hooks.iter().any(|h| h == &session.hook_event) {
surface_agent_cycle(session, &mut out, &mut log_f); surface_agent_cycle(session, &mut out, &mut log_f);
journal_agent_cycle(session, &mut log_f);
} }
} }

View file

@ -32,9 +32,8 @@ impl Session {
self.state_dir.join(format!("{}-{}", prefix, self.session_id)) self.state_dir.join(format!("{}-{}", prefix, self.session_id))
} }
/// Load from POC_SESSION_ID environment variable /// Load from a session ID string
pub fn from_env() -> Option<Self> { pub fn from_id(session_id: String) -> Option<Self> {
let session_id = std::env::var("POC_SESSION_ID").ok()?;
if session_id.is_empty() { return None; } if session_id.is_empty() { return None; }
let state_dir = PathBuf::from("/tmp/claude-memory-search"); let state_dir = PathBuf::from("/tmp/claude-memory-search");
Some(Session { Some(Session {
@ -45,6 +44,11 @@ impl Session {
}) })
} }
/// Load from POC_SESSION_ID environment variable
pub fn from_env() -> Option<Self> {
Self::from_id(std::env::var("POC_SESSION_ID").ok()?)
}
/// Get the seen set for this session /// Get the seen set for this session
pub fn seen(&self) -> HashSet<String> { pub fn seen(&self) -> HashSet<String> {
super::hippocampus::memory_search::load_seen(&self.state_dir, &self.session_id) super::hippocampus::memory_search::load_seen(&self.state_dir, &self.session_id)