refactor(agent): extract claude driver into hive-claude crate
This commit is contained in:
parent
e35acca814
commit
a3b66241d1
13 changed files with 907 additions and 442 deletions
95
hive-claude/src/store.rs
Normal file
95
hive-claude/src/store.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Locating and archiving on-disk claude sessions by title.
|
||||
|
||||
use std::io::BufRead as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// On-disk claude session store scoped to one working directory.
|
||||
///
|
||||
/// Claude Code keeps sessions under
|
||||
/// `<claude_home>/projects/<slug(cwd)>/<uuid>.jsonl`, one project dir per cwd.
|
||||
/// This type locates and archives those files by their display title. It is
|
||||
/// generic Claude Code layout knowledge — no application specifics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionStore {
|
||||
claude_home: PathBuf,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Build a store for `cwd`, with claude's home dir (normally `~/.claude`)
|
||||
/// at `claude_home`.
|
||||
pub fn new(claude_home: impl Into<PathBuf>, cwd: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
claude_home: claude_home.into(),
|
||||
cwd: cwd.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<claude_home>/projects/<slug>` for this cwd. Claude slugises the
|
||||
/// absolute cwd by replacing every `/` and `.` with `-` (verified against
|
||||
/// claude 2.1.197 — e.g. `/agents/iris/state` → `-agents-iris-state`).
|
||||
#[must_use]
|
||||
pub fn project_dir(&self) -> PathBuf {
|
||||
let slug: String = self
|
||||
.cwd
|
||||
.to_string_lossy()
|
||||
.chars()
|
||||
.map(|c| if c == '/' || c == '.' { '-' } else { c })
|
||||
.collect();
|
||||
self.claude_home.join("projects").join(slug)
|
||||
}
|
||||
|
||||
/// Find the `<uuid>.jsonl` in the project dir whose `customTitle` equals
|
||||
/// `title` (the value `--name` sets, stored in a `custom-title` event).
|
||||
///
|
||||
/// Reads each session file line by line and stops at the first match, so a
|
||||
/// huge transcript isn't slurped into memory. Returns `None` if no session
|
||||
/// carries the title or the project dir is absent. Non-`.jsonl` files
|
||||
/// (including anything already archived to `*.jsonl.archived`) are skipped.
|
||||
#[must_use]
|
||||
pub fn find_by_title(&self, title: &str) -> Option<PathBuf> {
|
||||
let marker = format!("\"customTitle\":\"{title}\"");
|
||||
for entry in std::fs::read_dir(self.project_dir()).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(&path) else {
|
||||
continue;
|
||||
};
|
||||
if std::io::BufReader::new(file)
|
||||
.lines()
|
||||
.map_while(std::result::Result::ok)
|
||||
.any(|line| line.contains(&marker))
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Archive the session titled `title` by renaming its backing file
|
||||
/// `<uuid>.jsonl` → `<uuid>.jsonl.archived`. That drops it out of claude's
|
||||
/// `*.jsonl` resolution glob (so a later `--resume <title>` misses) while
|
||||
/// preserving the full transcript on disk. Only the file with a matching
|
||||
/// `customTitle` is touched.
|
||||
///
|
||||
/// Returns the archived file's new path, or `None` if no session carried
|
||||
/// the title.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::Io`] if the rename fails.
|
||||
pub fn archive_by_title(&self, title: &str) -> Result<Option<PathBuf>> {
|
||||
let Some(path) = self.find_by_title(title) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut target = path.clone().into_os_string();
|
||||
target.push(".archived");
|
||||
let target = PathBuf::from(target);
|
||||
std::fs::rename(&path, &target).map_err(Error::Io)?;
|
||||
Ok(Some(target))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue