use std::env;
use std::fmt;
use std::io::Write as _;
use std::path::PathBuf;
use std::process;
use argh::FromArgs;
use serde::Serialize;
#[cfg(feature = "bench")]
use std::collections::HashSet;
#[cfg(feature = "bench")]
use super::{BenchExpertCacheArgs, BenchInferenceArgs, BenchMemoryArgs};
use super::{
CompactSessionArgs, CopySessionArgs, FindSessionArgs, GoosedumpArgs, GoosedumpCommand,
ListSessionsArgs, MemoryCommand, MemoryForgetArgs, MemoryLearnArgs, MemoryListArgs,
MemoryRecallArgs, MemoryShowArgs, MemoryStatusArgs, MemoryTarget, RemoveSessionArgs,
SearchSessionArgs, SessionCommand, ShowSessionArgs, Target,
};
#[cfg(feature = "bench")]
use crate::engine::model::{self, TextGen};
use crate::engine::{
Client, StorageKind, compact, context, display, import, index, memory, message, 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 learn_memory(args: &MemoryLearnArgs) -> 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!(
"Learned {}: {} new evidence, {} new claims, {} superseded, {} citations, {} relations, {} duplicates merged, {} contradictions",
args.target.qualified(),
report.evidence_added,
report.claims_added,
report.claims_superseded,
report.claim_evidence_added,
report.relations_added,
report.duplicates_merged,
report.contradictions_found,
));
if report.skipped_tombstones > 0 {
write_stdout(format_args!(
"; {} evidence items 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 reason in &hit.match_reasons {
write_stdout(format_args!(
" match: {} ({})\n",
reason.kind.as_str(),
reason.detail,
));
}
for relationship in &hit.relationships {
write_stdout(format_args!(
" relation: {} {} {} ({})\n {}\n",
relationship.direction.as_str(),
relationship.kind.as_str(),
relationship.display_id,
relationship.status.as_str(),
relationship.rationale,
));
}
for evidence in hit.evidence {
write_stdout(format_args!(
" evidence [{}]: {}:{}/{} ({})\n {}\n",
evidence.citation_id,
evidence.provider.as_str(),
evidence.session_id,
evidence.entry_id,
evidence.role,
evidence.snippet,
));
}
}
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: {} · {} evidence item(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 relationship in &record.relationships {
write_stdout(format_args!(
"\nRelationship: {} {} {} [{}] ({})\nRationale: {}\n{}\n",
relationship.direction.as_str(),
relationship.kind.as_str(),
relationship.display_id,
relationship.claim_id,
relationship.status.as_str(),
relationship.rationale,
relationship.text,
));
}
for evidence in record.evidence {
write_stdout(format_args!(
"\nEvidence: {}:{}/{} ({}, {})\nPath: {}\nRevision: {}\n{}\n",
evidence.provider.as_str(),
evidence.session_id,
evidence.entry_id,
evidence.role,
memory_time(Some(evidence.observed_at), "unknown"),
evidence.source_path.display(),
evidence.content_hash,
evidence.snippet,
));
}
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: {}\nEvidence: {}\nClaims: {}\nCitations: {}\nEntities: {}\nEntity links: {}\nRelations: {}\nEmbeddings: {}/{} ({})\nTombstones: {}\nLast learned: {}\nTypes: {} facts, {} decisions, {} preferences, {} procedures, {} lessons\n",
stats.database.display(),
stats.schema_version,
stats.projects,
stats.evidence,
stats.claims,
stats.claim_evidence,
stats.entities,
stats.entity_links,
stats.relations,
stats.embeddings,
stats.claims,
embedding_model,
stats.tombstones,
memory_time(stats.last_learned_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 {}: {} claims, {} evidence, {} citations.\n",
report.target, report.claims, report.evidence, report.claim_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 {}: {} claims, {} evidence, {} citations, {} new tombstones.\n",
report.target, report.claims, report.evidence, report.claim_evidence, report.tombstones,
));
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")
));
}
#[cfg(feature = "bench")]
fn benchmark_expert_cache(_args: &BenchExpertCacheArgs) -> CommandResult {
model::check_expert_store()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))
}
#[cfg(feature = "bench")]
fn benchmark_inference(args: &BenchInferenceArgs) -> CommandResult {
const SYSTEM: &str = "Reply with only the lowercase word ok.";
const PROMPT_FRAGMENT: &str =
"Measure deterministic memory mapped expert routing for this local inference workload. ";
if args.prompt_repetitions == 0 {
return Err(CommandError::Usage(
"goosedump: --prompt-repetitions must be greater than zero",
));
}
if args.max_tokens == 0 {
return Err(CommandError::Usage(
"goosedump: --max-tokens must be greater than zero",
));
}
let user = PROMPT_FRAGMENT.repeat(args.prompt_repetitions);
let textgen = TextGen::load_for_benchmark(args.cold)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let generation = textgen
.benchmark_complete(SYSTEM, &user, args.max_tokens)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
print_json(&generation)
}
#[cfg(feature = "bench")]
#[derive(Serialize)]
struct SequentialMemoryBenchmark {
supersession_precision: f64,
relationship_f1: f64,
duplicate_active_reduction: f64,
contradiction_recall: f64,
relation_expansion_recall: f64,
lexical_primary_preserved: bool,
relation_budget_preserved: bool,
claims: usize,
relations: usize,
passed: bool,
}
#[cfg(feature = "bench")]
#[expect(
clippy::too_many_lines,
clippy::cast_precision_loss,
reason = "the fixed five-case benchmark has bounded counts and reads linearly"
)]
fn benchmark_memory(_args: &BenchMemoryArgs) -> CommandResult {
const PROJECT: &str = "/goosedump/sequential-memory-benchmark";
const CASES: [&str; 5] = [
"Remember this durable project fact: the durable memory storage backend is SQLite.",
"Remember this durable project fact: SQLite is the backend for durable memory storage.",
"The project's durable memory storage backend is now PostgreSQL; it was previously SQLite.",
"Remember this durable project requirement: the minimum supported Rust version is 1.95.",
"Remember this separate conflicting report: the minimum supported Rust version is 1.85, not 1.95. It is not confirmed which report is current.",
];
let root = env::temp_dir().join(format!("goosedump-memory-bench-{}", process::id()));
if root.exists() {
std::fs::remove_dir_all(&root)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
}
std::fs::create_dir_all(&root)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
let result = (|| -> Result<SequentialMemoryBenchmark, CommandError> {
let database = root.join("memory.sqlite3");
let project = PathBuf::from(PROJECT);
let mut store = memory::Memory::open_path(&database)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
let mut ids: Vec<String> = Vec::new();
let mut duplicate_active = 2usize;
for (index, text) in CASES.iter().enumerate() {
let entry_id = format!("benchmark-{index}");
let mut conversation = message::ConversationMessage::new(
&entry_id,
"user",
vec![message::Part::Text((*text).to_string())],
);
conversation.timestamp = chrono::DateTime::from_timestamp_millis(
1_700_000_000_000 + i64::try_from(index).unwrap_or(0) * 1_000,
);
let context = message::Context {
entries: Vec::new(),
messages: vec![conversation],
cwd: Some(PROJECT.to_string()),
};
let source_path = root.join(format!("session-{index}.jsonl"));
let session_id = format!("memory-benchmark-{index}");
let report = store
.remember(&memory::RememberInput {
provider: Client::Pi,
session_id: &session_id,
project: &project,
source_path: &source_path,
context: &context,
})
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
let id = match report.added.as_slice() {
[added] => added.id.clone(),
[] if index == 1 && report.claim_evidence_added > 0 => ids[0].clone(),
added => {
return Err(CommandError::Runtime(format!(
"goosedump: sequential-memory case {index} produced {} claims; expected 1",
added.len()
)));
}
};
ids.push(id);
if index == 1 {
duplicate_active = ids[..=1]
.iter()
.collect::<HashSet<_>>()
.into_iter()
.map(|id| store.show(id))
.collect::<anyhow::Result<Vec<_>>>()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?
.iter()
.filter(|record| record.status == memory::MemoryStatus::Active)
.count();
}
}
let records = ids
.iter()
.map(|id| store.show(id))
.collect::<anyhow::Result<Vec<_>>>()
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
let predicted = records
.iter()
.enumerate()
.flat_map(|(index, record)| {
record
.relationships
.iter()
.filter(|relation| {
relation.direction == memory::RelationshipDirection::Outgoing
})
.map(move |relation| (index, relation.kind, relation.claim_id.clone()))
})
.collect::<HashSet<_>>();
let mut expected = HashSet::from([
(2usize, memory::RelationshipKind::Revises, ids[0].clone()),
(
4usize,
memory::RelationshipKind::Contradicts,
ids[3].clone(),
),
]);
if ids[1] != ids[0] {
expected.insert((1usize, memory::RelationshipKind::Duplicates, ids[0].clone()));
}
let true_positives = predicted.intersection(&expected).count();
let precision = true_positives as f64 / predicted.len().max(1) as f64;
let recall = true_positives as f64 / expected.len() as f64;
let relationship_f1 = if precision + recall == 0.0 {
0.0
} else {
2.0 * precision * recall / (precision + recall)
};
let predicted_superseded = records
.iter()
.enumerate()
.filter(|(_, record)| record.status == memory::MemoryStatus::Superseded)
.map(|(index, _)| index)
.collect::<HashSet<_>>();
let expected_superseded = HashSet::from([0usize, 1usize]);
let correct_superseded = predicted_superseded
.intersection(&expected_superseded)
.count();
let supersession_precision =
correct_superseded as f64 / predicted_superseded.len().max(1) as f64;
let duplicate_active_reduction = 1.0 - duplicate_active as f64 / 2.0;
let filter = memory::MemoryFilter {
project: Some(project),
memory_type: None,
};
let contradiction_hits = store
.recall("1.95", &filter, 10, 2_000, false)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
let detected_contradictions = predicted
.iter()
.filter(|(_, kind, _)| *kind == memory::RelationshipKind::Contradicts)
.count();
let exposed_contradictions = contradiction_hits
.iter()
.flat_map(|hit| &hit.relationships)
.filter(|relation| relation.kind == memory::RelationshipKind::Contradicts)
.count()
.min(detected_contradictions);
let contradiction_recall = if detected_contradictions == 0 {
1.0
} else {
exposed_contradictions as f64 / detected_contradictions as f64
};
let expanded_contradictions = contradiction_hits
.iter()
.filter(|hit| {
hit.id == ids[4]
&& hit.match_reasons.iter().any(|reason| {
reason.kind == memory::MatchKind::Relation
&& reason.detail.contains("OPEN CONTRADICTION")
})
})
.count();
let relation_expansion_recall = if detected_contradictions == 0 {
1.0
} else {
expanded_contradictions as f64 / detected_contradictions as f64
};
let lexical_primary_preserved = contradiction_hits.first().is_some_and(|hit| {
hit.id == ids[3]
&& hit
.match_reasons
.iter()
.any(|reason| reason.kind == memory::MatchKind::Lexical)
});
let budget_hits = store
.recall("1.95", &filter, 10, 1, false)
.map_err(|error| CommandError::Runtime(format!("goosedump: {error:#}")))?;
let relation_budget_preserved =
budget_hits.len() == 1 && budget_hits.first().is_some_and(|hit| hit.id == ids[3]);
let passed = supersession_precision >= 0.95
&& predicted_superseded == expected_superseded
&& relationship_f1 >= 0.90
&& duplicate_active_reduction >= 0.50
&& detected_contradictions == 1
&& exposed_contradictions == detected_contradictions
&& expanded_contradictions == detected_contradictions
&& lexical_primary_preserved
&& relation_budget_preserved;
Ok(SequentialMemoryBenchmark {
supersession_precision,
relationship_f1,
duplicate_active_reduction,
contradiction_recall,
relation_expansion_recall,
lexical_primary_preserved,
relation_budget_preserved,
claims: records.len(),
relations: predicted.len(),
passed,
})
})();
let cleanup = std::fs::remove_dir_all(&root);
let report = result?;
cleanup.map_err(|error| CommandError::Runtime(format!("goosedump: {error}")))?;
if !report.passed {
return Err(CommandError::Runtime(format!(
"goosedump: sequential-memory benchmark failed: {}",
serde_json::to_string(&report).unwrap_or_default()
)));
}
print_json(&report)
}
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::Learn(args) => learn_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),
},
#[cfg(feature = "bench")]
GoosedumpCommand::BenchExpertCache(args) => benchmark_expert_cache(args),
#[cfg(feature = "bench")]
GoosedumpCommand::BenchInference(args) => benchmark_inference(args),
#[cfg(feature = "bench")]
GoosedumpCommand::BenchMemory(args) => benchmark_memory(args),
};
if let Err(error) = result {
eprintln!("{error}");
process::exit(error.exit_code());
}
}