use std::env;
use std::fmt;
use std::io::Write as _;
use std::path::PathBuf;
use std::process;
use argh::FromArgs;
use serde::Serialize;
use super::{
CompactSessionArgs, CopySessionArgs, FindSessionArgs, GoosedumpArgs, GoosedumpCommand,
ListSessionsArgs, MemoryCommand, MemoryForgetArgs, MemoryListArgs, MemoryRecallArgs,
MemoryRememberArgs, MemoryShowArgs, MemoryStatusArgs, MemoryTarget, ModelCommand, ModelKind,
PullModelArgs, RemoveSessionArgs, SearchSessionArgs, SessionCommand, ShowSessionArgs, Target,
};
use crate::engine::{
Client, StorageKind, compact, context, display, import, index, memory, message, model, query,
resolver, search,
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Lineage,
All,
}
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 exit_stdout_error(error: &std::io::Error) -> ! {
let code = i32::from(error.kind() != std::io::ErrorKind::BrokenPipe);
process::exit(code);
}
fn write_stdout(arguments: fmt::Arguments<'_>) {
let mut out = std::io::stdout().lock();
if let Err(error) = out.write_fmt(arguments) {
exit_stdout_error(&error);
}
}
fn print_native(text: &str) {
write_stdout(format_args!("{text}"));
}
fn is_broken_pipe(error: &anyhow::Error) -> bool {
error.chain().any(|source| {
source
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::BrokenPipe)
|| source
.downcast_ref::<serde_json::Error>()
.is_some_and(|error| error.io_error_kind() == Some(std::io::ErrorKind::BrokenPipe))
})
}
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 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 {
let stdout = std::io::stdout();
let mut out = stdout.lock();
match display::print_sessions(&matched, &mut out) {
Ok(()) => Ok(()),
Err(error) if is_broken_pipe(&error) => process::exit(0),
Err(error) => Err(CommandError::Runtime(format!("goosedump: {error}"))),
}
} else {
for entry in &matched {
write_stdout(format_args!("{}:{}\n", 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 {
for entry in &refs {
write_stdout(format_args!("{}:{}\n", entry.provider.as_str(), entry.id));
}
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}")))?;
for entry in &refs {
write_stdout(format_args!("{}:{}\n", entry.provider.as_str(), entry.id));
}
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 {
write_stdout(format_args!(
"{} -> {}\n",
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}")))?;
write_stdout(format_args!("{}:{destination}\n", args.to.as_str()));
Ok(())
}
fn destination_fingerprints(dst: Client) -> anyhow::Result<std::collections::HashSet<String>> {
let mut seen = std::collections::HashSet::new();
if let StorageKind::File = dst.storage() {
let store = match dst {
Client::Claude => resolver::claude_projects_base(),
Client::Codex => resolver::codex_sessions_base(),
Client::Gemini => resolver::gemini_tmp_base(),
Client::Pi => resolver::pi_sessions_base(),
Client::Crush | Client::Goose | Client::Opencode => {
anyhow::bail!("database storage provider was classified as file storage");
}
};
if !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 = args
.render_as
.unwrap_or(entry.provider)
.behavior()
.render_context(&ctx.entries, &ctx.messages, &entry.id, ctx.cwd.as_deref());
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);
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 = compact::summarize_with_previous_and_budget(
&ctx.messages,
args.previous_summary.as_deref(),
args.summary_max_tokens,
)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let estimated_tokens = compact::summary_token_estimate(&summary);
if args.summary_max_tokens > 0 && estimated_tokens > args.summary_max_tokens {
eprintln!(
"goosedump: warning: preserved summary core is approximately {estimated_tokens} tokens, exceeding the {budget}-token budget",
budget = args.summary_max_tokens,
);
}
let output = if args.plain {
client.behavior().print_summary(&summary)
} else {
client
.behavior()
.render_summary(&summary, &entry.id, ctx.cwd.as_deref())
};
print_native(&output);
Ok(())
}
fn memory_filter(
project: Option<&PathBuf>,
all_projects: bool,
memory_type: Option<memory::MemoryType>,
) -> Result<memory::MemoryFilter, CommandError> {
if all_projects && project.is_some() {
return Err(CommandError::Usage(
"goosedump: --project and --all-projects cannot be used together",
));
}
let project = if all_projects {
None
} else if let Some(project) = project {
Some(project.clone())
} else {
Some(env::current_dir().map_err(|error| {
CommandError::Runtime(format!("goosedump: current directory: {error}"))
})?)
};
Ok(memory::MemoryFilter {
project,
memory_type,
})
}
fn print_json(value: &impl Serialize) -> CommandResult {
let output = serde_json::to_string(value)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
write_stdout(format_args!("{output}\n"));
Ok(())
}
fn memory_time(value: Option<i64>, missing: &str) -> String {
value.map_or_else(
|| missing.to_string(),
|millis| {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
.map_or_else(|| millis.to_string(), |timestamp| timestamp.to_rfc3339())
},
)
}
fn remember_memory(args: &MemoryRememberArgs) -> CommandResult {
let index = index::Index::load_or_refresh()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let entry = resolve_target(&index, &args.target)?;
let ctx = load_context(&entry, selection_scope(args.all))
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let project = ctx
.cwd
.as_deref()
.map_or_else(|| entry.provider_id.cwd.clone(), PathBuf::from);
let mut store = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let report = store
.remember(&memory::RememberInput {
provider: entry.provider,
session_id: &entry.id,
project: &project,
source_path: &entry.path,
context: &ctx,
})
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.json {
print_json(&report)
} else {
write_stdout(format_args!(
"Remembered {}: {} new sources, {} new memories, {} superseded, {} evidence links",
args.target.qualified(),
report.sources_added,
report.memories_added,
report.memories_superseded,
report.evidence_added,
));
if report.skipped_tombstones > 0 {
write_stdout(format_args!(
"; {} sources skipped because the session was forgotten",
report.skipped_tombstones
));
}
write_stdout(format_args!(".\n"));
for memory in &report.added {
write_stdout(format_args!(
"+ {} [{}] {}: {}\n",
memory.display_id,
memory.id,
memory.memory_type.as_str(),
memory.text,
));
if !memory.supersedes.is_empty() {
write_stdout(format_args!(
" supersedes: {}\n",
memory.supersedes.join(", "),
));
}
}
for memory in &report.superseded {
write_stdout(format_args!(
"~ {} [{}] {}: {}\n superseded by: {}\n",
memory.display_id,
memory.id,
memory.memory_type.as_str(),
memory.text,
memory.superseded_by,
));
}
Ok(())
}
}
fn recall_memory(args: &MemoryRecallArgs) -> CommandResult {
if args.limit == 0 || args.max_tokens == 0 || args.query.trim().is_empty() {
if args.json {
write_stdout(format_args!("[]\n"));
}
return Ok(());
}
let filter = memory_filter(args.project.as_ref(), args.all_projects, args.memory_type)?;
let store = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let hits = store
.recall(
&args.query,
&filter,
args.limit,
args.max_tokens,
args.history,
)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
if args.json {
return print_json(&hits);
}
for hit in hits {
write_stdout(format_args!(
"[{}] {} ({}) {}\n project: {}\n",
hit.display_id,
hit.memory_type.as_str(),
hit.status.as_str(),
hit.text,
hit.project.display(),
));
for source in hit.sources {
write_stdout(format_args!(
" source: {}:{}/{} ({})\n",
source.provider.as_str(),
source.session_id,
source.entry_id,
source.role,
));
}
}
Ok(())
}
fn list_memory(args: &MemoryListArgs) -> CommandResult {
let filter = memory_filter(args.project.as_ref(), args.all_projects, args.memory_type)?;
let store = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let items = store
.list(&filter, args.limit, args.history)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.json {
return print_json(&items);
}
for item in items {
write_stdout(format_args!(
"[{}] {} ({}) {}\n project: {} · {} source(s)\n",
item.display_id,
item.memory_type.as_str(),
item.status.as_str(),
item.text,
item.project.display(),
item.evidence_count,
));
}
Ok(())
}
fn show_memory(args: &MemoryShowArgs) -> CommandResult {
let store = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let record = store
.show(&args.id)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.json {
return print_json(&record);
}
let entities = if record.entities.is_empty() {
"-".to_string()
} else {
record
.entities
.iter()
.map(|entity| format!("{}:{}", entity.kind, entity.value))
.collect::<Vec<_>>()
.join(", ")
};
write_stdout(format_args!(
"{} [{}]\nType: {}\nStatus: {}\nProject: {}\nValid: {} .. {}\nKeywords: {}\nEntities: {}\nSuperseded by: {}\nSupersedes: {}\n\n{}\n",
record.display_id,
record.id,
record.memory_type.as_str(),
record.status.as_str(),
record.project.display(),
memory_time(record.valid_from, "unknown"),
memory_time(record.valid_until, "present"),
record.keywords.join(", "),
entities,
record.superseded_by.as_deref().unwrap_or("-"),
if record.supersedes.is_empty() {
"-".to_string()
} else {
record.supersedes.join(", ")
},
record.text,
));
for source in record.sources {
write_stdout(format_args!(
"\nSource: {}:{}/{} ({}, {})\nPath: {}\nRevision: {}\n",
source.provider.as_str(),
source.session_id,
source.entry_id,
source.role,
memory_time(Some(source.observed_at), "unknown"),
source.source_path.display(),
source.content_hash,
));
}
Ok(())
}
fn memory_status(args: &MemoryStatusArgs) -> CommandResult {
let store = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let stats = store
.stats()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.json {
return print_json(&stats);
}
let embedding_model = stats
.embedding_model
.split_once('@')
.map_or(stats.embedding_model.as_str(), |(name, _)| name);
write_stdout(format_args!(
"Database: {}\nSchema: {}\nProjects: {}\nSources: {}\nMemories: {}\nEvidence links: {}\nEntities: {}\nEntity links: {}\nEmbeddings: {}/{} ({})\nTombstones: {}\nLast remembered: {}\nTypes: {} facts, {} decisions, {} preferences, {} procedures, {} lessons\n",
stats.database.display(),
stats.schema_version,
stats.projects,
stats.sources,
stats.memories,
stats.evidence,
stats.entities,
stats.entity_links,
stats.embeddings,
stats.memories,
embedding_model,
stats.tombstones,
memory_time(stats.last_remembered_at, "never"),
stats.types.facts,
stats.types.decisions,
stats.types.preferences,
stats.types.procedures,
stats.types.lessons,
));
Ok(())
}
fn forget_memory(args: &MemoryForgetArgs) -> CommandResult {
let mut store = memory::Memory::open()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let run = |store: &mut memory::Memory, apply| match &args.target {
MemoryTarget::Id(id) => store.forget_memory(id, apply),
MemoryTarget::Session(target) => store.forget_session(target.provider, &target.id, apply),
};
if !args.yes {
let report = run(&mut store, false)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.json {
print_json(&report)?;
} else {
write_stdout(format_args!(
"Would forget {}: {} memories, {} sources, {} evidence links.\n",
report.target, report.memories, report.sources, report.evidence,
));
}
eprintln!("goosedump: re-run with --yes to forget this target");
return Ok(());
}
let report = run(&mut store, true)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if args.json {
print_json(&report)
} else {
write_stdout(format_args!(
"Forgot {}: {} memories, {} sources, {} evidence links, {} new tombstones.\n",
report.target, report.memories, report.sources, report.evidence, report.tombstones,
));
Ok(())
}
}
fn pull_model(args: &PullModelArgs) -> CommandResult {
let kind = args.kind.unwrap_or(ModelKind::All);
if matches!(kind, ModelKind::All | ModelKind::Embedding) {
let dest = model::pull_embedding_model()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
write_stdout(format_args!("{}\n", dest.display()));
}
if matches!(kind, ModelKind::All | ModelKind::Text) {
let dest = model::pull_text_model()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
write_stdout(format_args!("{}\n", 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 (entries, matched) = if ids.is_empty() {
(Vec::new(), Vec::new())
} else {
(
context::filter_entries(ctx.entries.clone(), &ids),
context::filter_messages(ctx.messages.clone(), &ids),
)
};
client
.behavior()
.render_context(&entries, &matched, session_id, ctx.cwd.as_deref())
}
fn print_version() {
write_stdout(format_args!(
"{} {}\n",
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"]) {
write_stdout(format_args!("{}", early_exit.output));
}
process::exit(2);
}
pub(crate) fn run() {
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() {
write_stdout(format_args!("{}", 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::Remember(args) => remember_memory(args),
MemoryCommand::Recall(args) => recall_memory(args),
MemoryCommand::List(args) => list_memory(args),
MemoryCommand::Show(args) => show_memory(args),
MemoryCommand::Forget(args) => forget_memory(args),
MemoryCommand::Status(args) => memory_status(args),
},
GoosedumpCommand::Model(args) => match &args.command {
ModelCommand::Pull(args) => pull_model(args),
},
};
if let Err(error) = result {
eprintln!("{error}");
process::exit(error.exit_code());
}
}