#![deny(clippy::all)]
#![deny(clippy::pedantic)]
mod compact;
mod context;
mod display;
mod emit;
mod engine;
mod format;
mod import;
mod index;
#[allow(dead_code)]
mod memory;
mod message;
mod model;
mod query;
mod resolver;
mod search;
mod text;
use std::env;
use std::fmt;
use std::io::Write as _;
use std::process;
use std::str::FromStr;
use argh::FromArgs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Client {
Claude,
Codex,
Crush,
Gemini,
Goose,
Opencode,
Pi,
}
impl Client {
pub const ALL: [Self; 7] = [
Self::Claude,
Self::Codex,
Self::Crush,
Self::Gemini,
Self::Goose,
Self::Opencode,
Self::Pi,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Crush => "crush",
Self::Gemini => "gemini",
Self::Goose => "goose",
Self::Opencode => "opencode",
Self::Pi => "pi",
}
}
}
impl FromStr for Client {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::ALL
.into_iter()
.find(|client| client.as_str() == value)
.ok_or_else(|| {
format!(
"provider must be one of: {}",
Self::ALL.map(Self::as_str).join(", ")
)
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Lineage,
All,
}
#[derive(Debug, Clone)]
struct Target {
provider: Client,
id: String,
}
impl Target {
fn qualified(&self) -> String {
format!("{}:{}", self.provider.as_str(), self.id)
}
}
impl FromStr for Target {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (provider, id) = value
.split_once(':')
.ok_or_else(|| "session must be qualified as provider:id".to_string())?;
if id.is_empty() {
return Err("session id must not be empty".to_string());
}
Ok(Self {
provider: provider.parse()?,
id: id.to_string(),
})
}
}
#[derive(Debug, Clone)]
enum MemoryTarget {
Hash(String),
Session(Target),
}
impl FromStr for MemoryTarget {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.starts_with('v') && value.contains(':') {
return Ok(Self::Hash(value.to_string()));
}
Target::from_str(value)
.map(Self::Session)
.map_err(|_| "memory must be a content hash or provider:id session".to_string())
}
}
#[derive(FromArgs)]
#[argh(description = "coding-agent session browser")]
struct GoosedumpArgs {
#[argh(subcommand)]
command: GoosedumpCommand,
}
#[derive(FromArgs)]
#[argh(subcommand)]
enum GoosedumpCommand {
Session(SessionArgs),
Memory(MemoryArgs),
Model(ModelArgs),
}
#[derive(FromArgs)]
#[argh(subcommand, name = "session", description = "session commands")]
struct SessionArgs {
#[argh(subcommand)]
command: SessionCommand,
}
#[derive(FromArgs)]
#[argh(subcommand)]
enum SessionCommand {
List(ListSessionsArgs),
Show(ShowSessionArgs),
Find(FindSessionArgs),
Search(SearchSessionArgs),
Compact(CompactSessionArgs),
Copy(CopySessionArgs),
Remove(RemoveSessionArgs),
}
#[derive(FromArgs)]
#[argh(subcommand, name = "list", description = "list sessions")]
struct ListSessionsArgs {
#[argh(option, short = 'q', description = "JSON query filter")]
query: Option<String>,
#[argh(switch, description = "output in JSON format")]
json: bool,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "show", description = "show a session")]
struct ShowSessionArgs {
#[argh(positional)]
target: Target,
#[argh(option, short = 'e', long = "entry", description = "entry ID")]
entries: Vec<String>,
#[argh(switch, description = "all branches")]
all: bool,
#[argh(option, description = "first entry ID")]
from: Option<String>,
#[argh(option, description = "exclusive final entry ID")]
before: Option<String>,
#[argh(option, long = "as", description = "render provider")]
render_as: Option<Client>,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "find", description = "find session messages")]
struct FindSessionArgs {
#[argh(positional)]
target: Target,
#[argh(positional)]
pattern: String,
#[argh(option, short = 'e', long = "entry", description = "entry ID")]
entries: Vec<String>,
#[argh(switch, description = "all branches")]
all: bool,
#[argh(option, description = "first entry ID")]
from: Option<String>,
#[argh(option, description = "exclusive final entry ID")]
before: Option<String>,
#[argh(option, long = "as", description = "render provider")]
render_as: Option<Client>,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "search", description = "search session messages")]
struct SearchSessionArgs {
#[argh(positional)]
target: Target,
#[argh(positional)]
query: String,
#[argh(option, short = 'e', long = "entry", description = "entry ID")]
entries: Vec<String>,
#[argh(switch, description = "all branches")]
all: bool,
#[argh(option, description = "first entry ID")]
from: Option<String>,
#[argh(option, description = "exclusive final entry ID")]
before: Option<String>,
#[argh(option, description = "result page")]
page: Option<usize>,
#[argh(option, long = "as", description = "render provider")]
render_as: Option<Client>,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "compact", description = "compact a session")]
struct CompactSessionArgs {
#[argh(positional)]
target: Target,
#[argh(option, short = 'e', long = "entry", description = "entry ID")]
entries: Vec<String>,
#[argh(switch, description = "all branches")]
all: bool,
#[argh(option, description = "first entry ID")]
from: Option<String>,
#[argh(option, description = "exclusive final entry ID")]
before: Option<String>,
#[argh(option, description = "prior summary")]
previous_summary: Option<String>,
#[argh(option, long = "as", description = "render provider")]
render_as: Option<Client>,
#[argh(switch, description = "screen-readable output")]
plain: bool,
#[argh(switch, description = "learn durable memory")]
remember: bool,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "copy", description = "copy a session")]
struct CopySessionArgs {
#[argh(positional)]
target: Target,
#[argh(option, description = "destination provider")]
to: Client,
#[argh(switch, description = "perform the copy")]
yes: bool,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "remove", description = "remove a session")]
struct RemoveSessionArgs {
#[argh(positional)]
target: Target,
#[argh(switch, description = "remove the session")]
yes: bool,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "memory", description = "memory commands")]
struct MemoryArgs {
#[argh(subcommand)]
command: MemoryCommand,
}
#[derive(FromArgs)]
#[argh(subcommand)]
enum MemoryCommand {
Search(MemorySearchArgs),
Status(MemoryStatusArgs),
Forget(MemoryForgetArgs),
}
#[derive(FromArgs)]
#[argh(subcommand, name = "search", description = "search memory")]
struct MemorySearchArgs {
#[argh(positional)]
query: String,
#[argh(option, description = "provider")]
provider: Option<Client>,
#[argh(option, description = "working directory")]
path: Option<String>,
#[argh(option, long = "type", description = "memory type")]
memory_type: Option<memory::MemoryType>,
#[argh(option, default = "10", description = "result limit")]
limit: usize,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "status", description = "show memory status")]
struct MemoryStatusArgs {}
#[derive(FromArgs)]
#[argh(subcommand, name = "forget", description = "forget memory")]
struct MemoryForgetArgs {
#[argh(positional)]
target: MemoryTarget,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "model", description = "model commands")]
struct ModelArgs {
#[argh(subcommand)]
command: ModelCommand,
}
#[derive(FromArgs)]
#[argh(subcommand)]
enum ModelCommand {
Pull(PullModelArgs),
}
#[derive(FromArgs)]
#[argh(subcommand, name = "pull", description = "download the model")]
struct PullModelArgs {}
const DEFAULT_PAGE_SIZE: usize = 5;
type CommandResult = Result<(), CommandError>;
enum CommandError {
Usage(&'static str),
Runtime(String),
}
impl CommandError {
const fn exit_code(&self) -> i32 {
match self {
Self::Usage(_) => 2,
Self::Runtime(_) => 1,
}
}
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage(msg) => f.write_str(msg),
Self::Runtime(msg) => f.write_str(msg),
}
}
}
impl From<&'static str> for CommandError {
fn from(value: &'static str) -> Self {
Self::Usage(value)
}
}
fn validate_page(page: Option<usize>) -> CommandResult {
if page.is_some_and(|page| page == 0) {
return Err(CommandError::Usage(
"goosedump: --page must be a positive integer",
));
}
Ok(())
}
fn print_native(text: &str) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
if let Err(error) = write!(out, "{text}") {
if error.kind() == std::io::ErrorKind::BrokenPipe {
process::exit(0);
}
process::exit(1);
}
}
fn selection_scope(all: bool) -> Scope {
if all { Scope::All } else { Scope::Lineage }
}
fn resolve_target(
index: &index::Index,
target: &Target,
) -> Result<index::IndexEntry, CommandError> {
index
.lookup_entry(target.provider, &target.id)
.cloned()
.ok_or_else(|| {
CommandError::Runtime(format!(
"goosedump: session '{}' not found",
target.qualified()
))
})
}
fn load_context(entry: &index::IndexEntry, scope: Scope) -> anyhow::Result<message::Context> {
let reader = resolver::open_indexed_context(entry);
let mut ctx = reader.read_context(&entry.id)?;
if scope == Scope::Lineage {
let lineage_ids = context::active_lineage_ids(&ctx.entries);
ctx.entries = context::filter_entries(ctx.entries, &lineage_ids);
ctx.messages = context::filter_messages(ctx.messages, &lineage_ids);
}
Ok(ctx)
}
fn load_context_for_command(
target: &Target,
entries: &[String],
all: bool,
from: Option<&str>,
before: Option<&str>,
) -> Result<(index::IndexEntry, message::Context), CommandError> {
let index = index::Index::load_or_refresh()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let entry = resolve_target(&index, target)?;
let mut ctx = load_context(&entry, selection_scope(all))
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if from.is_some() || before.is_some() {
ctx = context::filter_context_range(ctx, from, before).map_err(CommandError::Runtime)?;
}
if !entries.is_empty() {
ctx.entries = context::filter_entries(ctx.entries, entries);
ctx.messages = context::filter_messages(ctx.messages, entries);
}
Ok((entry, ctx))
}
fn print_index_entries(entries: &[&index::IndexEntry]) -> CommandResult {
let stdout = std::io::stdout();
let mut out = stdout.lock();
display::print_sessions(entries, &mut out)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))
}
fn list_sessions(args: &ListSessionsArgs) -> CommandResult {
let index = index::Index::load_or_refresh()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let query_filter = args
.query
.as_deref()
.map(query::QueryFilter::parse)
.transpose()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let matched = index.filter(query_filter.as_ref());
if args.json {
print_index_entries(&matched)
} else {
for entry in &matched {
println!("{}:{}", entry.provider.as_str(), entry.id);
}
Ok(())
}
}
fn remove_session(args: &RemoveSessionArgs) -> CommandResult {
let mut index = index::Index::load_or_refresh()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let session = resolve_target(&index, &args.target)?;
let roots = vec![(session.provider, session.id.clone())];
let descendant_ids = index.descendants_of(&roots);
let mut sessions = vec![session];
for (provider, id) in descendant_ids {
if let Some(session) = index.lookup_entry(provider, &id) {
sessions.push(session.clone());
}
}
let refs: Vec<&index::IndexEntry> = sessions.iter().collect();
if !args.yes {
print_index_entries(&refs)?;
eprint!("remove session(s)? [y/N] ");
let _ = std::io::stderr().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return Ok(());
}
let trimmed = input.trim();
if !trimmed.eq_ignore_ascii_case("y") && !trimmed.eq_ignore_ascii_case("yes") {
return Ok(());
}
}
for session in &sessions {
resolver::open_indexed_context(session)
.delete_context(&session.id)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
index.remove_entry(session.provider, &session.id);
}
index
.save_default()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.yes {
print_index_entries(&refs)?;
}
Ok(())
}
fn copy_session(args: &CopySessionArgs) -> CommandResult {
if args.target.provider == args.to {
return Err(CommandError::Runtime(
"goosedump: source and destination providers must differ".to_string(),
));
}
let index = index::Index::load_or_refresh()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let source = resolve_target(&index, &args.target)?;
let ctx = resolver::open_indexed_context(&source)
.read_context(&source.id)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let fingerprint = context::content_fingerprint(&ctx);
let known = destination_fingerprints(args.to)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if known.contains(&fingerprint) {
return Err(CommandError::Runtime(format!(
"goosedump: session content already exists in {}",
args.to.as_str()
)));
}
if !args.yes {
println!("{} -> {}", args.target.qualified(), args.to.as_str());
eprintln!("goosedump: re-run with --yes to copy this session");
return Ok(());
}
let destination = String::try_from(import::Importer {
client: args.to,
ctx: &ctx,
})
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
println!("{}:{destination}", args.to.as_str());
Ok(())
}
fn destination_fingerprints(dst: Client) -> anyhow::Result<std::collections::HashSet<String>> {
let mut seen = std::collections::HashSet::new();
let file_store = match dst {
Client::Claude => Some(resolver::claude_projects_base()),
Client::Codex => Some(resolver::codex_sessions_base()),
Client::Gemini => Some(resolver::gemini_tmp_base()),
Client::Pi => Some(resolver::pi_sessions_base()),
Client::Crush | Client::Goose | Client::Opencode => None,
};
if let Some(store) = file_store
&& !store.try_exists()?
{
return Ok(seen);
}
let listings = resolver::list_provider_contexts(dst)?;
for listing in listings {
let reader = resolver::open_listed_context(dst, &listing);
let ctx = reader.read_context(&listing.id)?;
seen.insert(context::content_fingerprint(&ctx));
}
Ok(seen)
}
fn show_session(args: &ShowSessionArgs) -> CommandResult {
let (entry, ctx) = load_context_for_command(
&args.target,
&args.entries,
args.all,
args.from.as_deref(),
args.before.as_deref(),
)?;
let output = format::render(
args.render_as.unwrap_or(entry.provider),
&ctx.entries,
&ctx.messages,
&entry.id,
ctx.cwd.as_deref(),
);
if !args.entries.is_empty()
&& let Ok(mut memory) = memory::Memory::open()
&& let Err(error) = memory.mark_context_expanded(entry.provider, &entry.id, &args.entries)
{
eprintln!("goosedump: warning: record memory expansion: {error}");
}
print_native(&output);
Ok(())
}
fn find_session(args: &FindSessionArgs) -> CommandResult {
let (entry, ctx) = load_context_for_command(
&args.target,
&args.entries,
args.all,
args.from.as_deref(),
args.before.as_deref(),
)?;
let hits = search::grep(&ctx.messages, &args.pattern);
print_native(&render_hits(
args.render_as.unwrap_or(entry.provider),
&ctx,
&hits,
&entry.id,
));
Ok(())
}
fn search_session(args: &SearchSessionArgs) -> CommandResult {
validate_page(args.page)?;
let (entry, ctx) = load_context_for_command(
&args.target,
&args.entries,
args.all,
args.from.as_deref(),
args.before.as_deref(),
)?;
let page = args.page.unwrap_or(1);
let (hits, _) = search::query(&ctx.messages, &args.query, page, DEFAULT_PAGE_SIZE);
let ids: Vec<String> = hits.iter().map(|hit| hit.entry_id.clone()).collect();
if let Ok(mut memory) = memory::Memory::open()
&& let Err(error) = memory.log_context_search(&args.query, &entry, &ctx, &ids)
{
eprintln!("goosedump: warning: log memory search: {error}");
}
print_native(&render_hits(
args.render_as.unwrap_or(entry.provider),
&ctx,
&hits,
&entry.id,
));
Ok(())
}
fn compact_session(args: &CompactSessionArgs) -> CommandResult {
let (entry, ctx) = load_context_for_command(
&args.target,
&args.entries,
args.all,
args.from.as_deref(),
args.before.as_deref(),
)?;
let client = args.render_as.unwrap_or(entry.provider);
let summary = if let Some(previous_summary) = args.previous_summary.as_deref() {
compact::summarize_with_previous(&ctx.messages, Some(previous_summary))
} else {
compact::summarize(&ctx.messages)
}
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.remember {
match memory::Memory::open() {
Ok(mut memory) => {
if let Err(error) = memory.harvest(&entry, &ctx) {
eprintln!("goosedump: warning: harvest memory: {error}");
} else if let Err(error) = memory.attribute_summary(&summary.memory_text(), &ctx) {
eprintln!("goosedump: warning: attribute memory: {error}");
}
}
Err(error) => eprintln!("goosedump: warning: open memory: {error}"),
}
}
let output = if args.plain {
emit::print(client, &summary)
} else {
emit::render(client, &summary, &entry.id, ctx.cwd.as_deref())
};
print_native(&output);
Ok(())
}
fn search_memory(args: &MemorySearchArgs) -> CommandResult {
if args.query.trim().is_empty() || args.limit == 0 {
println!("[]");
return Ok(());
}
if i64::try_from(args.limit).is_err() {
return Err(CommandError::Usage("goosedump: --limit is too large"));
}
let mut memory = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if !memory
.has_entries()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?
{
println!("[]");
return Ok(());
}
let hits = memory
.recall_hybrid(
&args.query,
&memory::RecallFilter {
provider: args.provider,
path: args.path.as_ref().map(std::path::PathBuf::from),
memory_type: args.memory_type,
},
args.limit,
)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let output = serde_json::to_string(&hits)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
println!("{output}");
Ok(())
}
fn memory_status() -> CommandResult {
let memory = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let stats = memory
.stats()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let output = serde_json::to_string(&stats)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
println!("{output}");
Ok(())
}
fn forget_memory(args: &MemoryForgetArgs) -> CommandResult {
let mut memory = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let report = match &args.target {
MemoryTarget::Hash(hash) => memory.forget_hash(hash),
MemoryTarget::Session(target) => memory.forget_context(target.provider, &target.id),
}
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let output = serde_json::to_string(&report)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
println!("{output}");
Ok(())
}
fn pull_model() -> CommandResult {
let dest = model::model_cache_dir(model::TEXTGEN_NAME);
model::pull_files(
model::TEXTGEN_WEIGHTS_REPO,
&[model::TEXTGEN_WEIGHTS_FILE],
&dest,
)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
println!("{}", dest.display());
Ok(())
}
fn render_hits(
client: Client,
ctx: &message::Context,
hits: &[message::SearchHit],
session_id: &str,
) -> String {
let ids: Vec<String> = hits.iter().map(|hit| hit.entry_id.clone()).collect();
let matched = if ids.is_empty() {
Vec::new()
} else {
context::filter_messages(ctx.messages.clone(), &ids)
};
format::render(
client,
&ctx.entries,
&matched,
session_id,
ctx.cwd.as_deref(),
)
}
fn print_version() {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
}
fn requested_version(tokens: &[&str]) -> bool {
matches!(tokens, ["-V" | "--version"])
}
fn print_usage_and_exit() -> ! {
if let Err(early_exit) = GoosedumpArgs::from_args(&["goosedump"], &["help"]) {
print!("{}", early_exit.output);
}
process::exit(2);
}
fn main() {
let cmdline: Vec<String> = env::args().collect();
if cmdline.len() <= 1 {
print_usage_and_exit();
}
let tokens: Vec<&str> = cmdline[1..].iter().map(String::as_str).collect();
if requested_version(&tokens) {
print_version();
return;
}
let args = match GoosedumpArgs::from_args(&["goosedump"], &tokens) {
Ok(args) => args,
Err(early) => {
if early.status.is_ok() {
print!("{}", early.output);
return;
}
eprint!("{}", early.output);
process::exit(2);
}
};
let result = match &args.command {
GoosedumpCommand::Session(args) => match &args.command {
SessionCommand::List(args) => list_sessions(args),
SessionCommand::Show(args) => show_session(args),
SessionCommand::Find(args) => find_session(args),
SessionCommand::Search(args) => search_session(args),
SessionCommand::Compact(args) => compact_session(args),
SessionCommand::Copy(args) => copy_session(args),
SessionCommand::Remove(args) => remove_session(args),
},
GoosedumpCommand::Memory(args) => match &args.command {
MemoryCommand::Search(args) => search_memory(args),
MemoryCommand::Status(_) => memory_status(),
MemoryCommand::Forget(args) => forget_memory(args),
},
GoosedumpCommand::Model(args) => match &args.command {
ModelCommand::Pull(_) => pull_model(),
},
};
if let Err(error) = result {
eprintln!("{error}");
process::exit(error.exit_code());
}
}