use anyhow::{Context, Result};
use chrono::{NaiveDate, Utc};
use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, ValueEnum};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use aicx::dashboard::{self, DashboardConfig};
use aicx::dashboard_server::{self, DashboardServerConfig};
use aicx::intents;
use aicx::mcp::{self, McpTransport};
use aicx::memex::{self, MemexConfig, SyncProgress, SyncProgressPhase};
use aicx::output::{self, OutputConfig, OutputFormat, OutputMode, ReportMetadata};
use aicx::rank;
use aicx::reports_extractor::{self, ReportsExtractorConfig};
use aicx::sources::{self, ExtractionConfig};
use aicx::state::StateManager;
use aicx::store;
#[derive(Debug, Parser)]
#[command(name = "aicx")]
#[command(author = "M&K (c)2026 VetCoders")]
#[command(version)]
#[command(verbatim_doc_comment)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Clone, Copy, Debug, Args)]
struct RedactionArgs {
#[arg(
long = "no-redact-secrets",
action = ArgAction::SetFalse,
default_value_t = true
)]
redact_secrets: bool,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum StdoutEmit {
Paths,
Json,
None,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum RefsEmit {
Summary,
Paths,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ExtractInputFormat {
Claude,
Codex,
Gemini,
GeminiAntigravity,
Junie,
}
#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
enum SortOrder {
Newest,
Oldest,
Score,
}
#[derive(Debug, Args, Clone)]
struct RetrievalFilters {
#[arg(long, default_value_t = 10)]
limit: usize,
#[arg(long, value_enum)]
sort: Option<SortOrder>,
#[arg(long)]
score: Option<u8>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long, value_enum)]
frame_kind: Option<aicx::types::FrameKind>,
}
#[derive(Debug, Subcommand)]
enum Commands {
#[command(display_order = 2)]
Claude {
#[command(flatten)]
redaction: RedactionArgs,
#[arg(short, long, num_args = 1..)]
project: Vec<String>,
#[arg(short = 'H', long, default_value = "48")]
hours: u64,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long, default_value = "both")]
format: String,
#[arg(long)]
append_to: Option<PathBuf>,
#[arg(long, default_value = "0")]
rotate: usize,
#[arg(long)]
incremental: bool,
#[arg(long)]
user_only: bool,
#[arg(long, hide = true, conflicts_with = "user_only")]
include_assistant: bool,
#[arg(long)]
loctree: bool,
#[arg(long)]
project_root: Option<PathBuf>,
#[arg(long)]
memex: bool,
#[arg(long)]
force: bool,
#[arg(long, value_enum, default_value_t = StdoutEmit::None)]
emit: StdoutEmit,
#[arg(long)]
conversation: bool,
},
#[command(display_order = 3)]
Codex {
#[command(flatten)]
redaction: RedactionArgs,
#[arg(short, long, num_args = 1..)]
project: Vec<String>,
#[arg(short = 'H', long, default_value = "48")]
hours: u64,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long, default_value = "both")]
format: String,
#[arg(long)]
append_to: Option<PathBuf>,
#[arg(long, default_value = "0")]
rotate: usize,
#[arg(long)]
incremental: bool,
#[arg(long)]
user_only: bool,
#[arg(long, hide = true, conflicts_with = "user_only")]
include_assistant: bool,
#[arg(long)]
loctree: bool,
#[arg(long)]
project_root: Option<PathBuf>,
#[arg(long)]
memex: bool,
#[arg(long)]
force: bool,
#[arg(long, value_enum, default_value_t = StdoutEmit::None)]
emit: StdoutEmit,
#[arg(long)]
conversation: bool,
},
#[command(display_order = 1)]
All {
#[command(flatten)]
redaction: RedactionArgs,
#[arg(short, long, num_args = 1..)]
project: Vec<String>,
#[arg(short = 'H', long, default_value = "48")]
hours: u64,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
append_to: Option<PathBuf>,
#[arg(long, default_value = "0")]
rotate: usize,
#[arg(long)]
incremental: bool,
#[arg(long)]
user_only: bool,
#[arg(long, hide = true, conflicts_with = "user_only")]
include_assistant: bool,
#[arg(long)]
loctree: bool,
#[arg(long)]
project_root: Option<PathBuf>,
#[arg(long)]
memex: bool,
#[arg(long)]
force: bool,
#[arg(long, value_enum, default_value_t = StdoutEmit::None)]
emit: StdoutEmit,
#[arg(long)]
conversation: bool,
},
#[command(display_order = 5)]
Extract {
#[command(flatten)]
redaction: RedactionArgs,
#[arg(long, value_enum, alias = "input-format")]
format: ExtractInputFormat,
#[arg(short, long)]
project: Option<String>,
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
user_only: bool,
#[arg(long, hide = true, conflicts_with = "user_only")]
include_assistant: bool,
#[arg(long, default_value = "0")]
max_message_chars: usize,
#[arg(long)]
conversation: bool,
},
#[command(display_order = 4)]
Store {
#[command(flatten)]
redaction: RedactionArgs,
#[arg(short, long, num_args = 1..)]
project: Vec<String>,
#[arg(short, long)]
agent: Option<String>,
#[arg(short = 'H', long, default_value = "48")]
hours: u64,
#[arg(long)]
user_only: bool,
#[arg(long, hide = true, conflicts_with = "user_only")]
include_assistant: bool,
#[arg(long)]
memex: bool,
#[arg(long, value_enum, default_value_t = StdoutEmit::None)]
emit: StdoutEmit,
},
#[command(display_order = 20, verbatim_doc_comment)]
MemexSync {
#[arg(short, long, default_value = "ai-contexts")]
namespace: String,
#[arg(long)]
per_chunk: bool,
#[arg(long)]
db_path: Option<PathBuf>,
#[arg(long)]
reindex: bool,
},
#[command(display_order = 10)]
List,
#[command(display_order = 11)]
Refs {
#[arg(short = 'H', long, default_value = "48")]
hours: u64,
#[arg(short, long)]
project: Option<String>,
#[arg(long, value_enum, default_value_t = RefsEmit::Summary)]
emit: RefsEmit,
#[arg(short, long, hide = true)]
summary: bool,
#[arg(long)]
strict: bool,
},
State {
#[arg(long)]
reset: bool,
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
info: bool,
},
Dashboard {
#[arg(long)]
store_root: Option<PathBuf>,
#[arg(short, long, default_value = "aicx-dashboard.html")]
output: PathBuf,
#[arg(long, default_value = "AI Contexters Dashboard")]
title: String,
#[arg(long, default_value = "320")]
preview_chars: usize,
},
ReportsExtractor {
#[arg(long)]
artifacts_root: Option<PathBuf>,
#[arg(long, default_value = "VetCoders")]
org: String,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
workflow: Option<String>,
#[arg(long)]
date_from: Option<String>,
#[arg(long)]
date_to: Option<String>,
#[arg(short, long, default_value = "aicx-reports.html")]
output: PathBuf,
#[arg(long)]
bundle_output: Option<PathBuf>,
#[arg(long, default_value = "AI Contexters Report Explorer")]
title: String,
#[arg(long, default_value = "280")]
preview_chars: usize,
},
DashboardServe {
#[arg(long)]
store_root: Option<PathBuf>,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value = "9478")]
port: u16,
#[arg(long)]
no_open: bool,
#[arg(long, default_value = "aicx-dashboard.html", hide = true)]
artifact: PathBuf,
#[arg(long, default_value = "AI Contexters Dashboard")]
title: String,
#[arg(long, default_value = "320")]
preview_chars: usize,
},
Intents {
#[arg(short, long)]
project: String,
#[arg(short = 'H', long, default_value = "720")]
hours: u64,
#[command(flatten)]
filters: RetrievalFilters,
#[arg(long)]
unresolved: bool,
#[arg(long)]
collapse_session: bool,
#[arg(long, default_value = "markdown", value_parser = ["markdown", "json"])]
emit: String,
#[arg(long)]
strict: bool,
#[arg(long, value_parser = ["decision", "intent", "outcome", "task"])]
kind: Option<String>,
},
Tail {
#[arg(short, long)]
project: String,
#[arg(short = 'H', long, default_value = "48")]
hours: u64,
#[arg(long)]
follow: bool,
#[arg(short, long)]
kind: Option<String>,
#[command(flatten)]
filters: RetrievalFilters,
},
#[command(verbatim_doc_comment)]
Serve {
#[arg(long, value_enum, default_value_t = McpTransport::Stdio)]
transport: McpTransport,
#[arg(long, default_value = "8044")]
port: u16,
},
#[command(
hide = true,
about = "Retired compatibility shim; prints migration guidance",
long_about = "aicx init has been retired.\n\nContext initialisation is now handled by /vc-init inside Claude Code.\nSee: https://vibecrafted.io/\n\nLegacy flags are still accepted for compatibility, but they have no effect."
)]
Init {
#[arg(short, long, hide = true)]
project: Option<String>,
#[arg(short, long, hide = true)]
agent: Option<String>,
#[arg(long, hide = true)]
model: Option<String>,
#[arg(short = 'H', long, default_value = "4800", hide = true)]
hours: u64,
#[arg(long, default_value = "1200", hide = true)]
max_lines: usize,
#[arg(long, hide = true)]
user_only: bool,
#[arg(long, hide = true, conflicts_with = "user_only")]
include_assistant: bool,
#[arg(long, hide = true)]
action: Option<String>,
#[arg(long, hide = true)]
agent_prompt: Option<String>,
#[arg(long, hide = true)]
agent_prompt_file: Option<PathBuf>,
#[arg(long, hide = true)]
no_run: bool,
#[arg(long, hide = true)]
no_confirm: bool,
#[arg(long, hide = true)]
no_gitignore: bool,
},
#[command(display_order = 12)]
Search {
query: String,
#[arg(short, long)]
project: Option<String>,
#[arg(short = 'H', long, default_value = "0")]
hours: u64,
#[arg(short, long)]
date: Option<String>,
#[command(flatten)]
filters: RetrievalFilters,
#[arg(short = 'j', long)]
json: bool,
},
#[command(verbatim_doc_comment)]
Steer {
#[arg(long)]
run_id: Option<String>,
#[arg(long)]
prompt_id: Option<String>,
#[arg(short, long)]
kind: Option<String>,
#[arg(short, long)]
project: Option<String>,
#[arg(short, long)]
date: Option<String>,
#[command(flatten)]
filters: RetrievalFilters,
},
Migrate {
#[arg(long)]
dry_run: bool,
#[arg(long)]
legacy_root: Option<PathBuf>,
#[arg(long)]
store_root: Option<PathBuf>,
},
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("ai_contexters=info".parse().unwrap()),
)
.init();
let cli = Cli::parse();
match cli.command {
Some(Commands::Claude {
redaction,
project,
hours,
output,
format,
append_to,
rotate,
incremental,
user_only,
include_assistant: include_assistant_flag,
loctree,
project_root,
memex,
force,
emit,
conversation,
}) => {
let include_assistant = include_assistant_flag || !user_only;
run_extraction(ExtractionParams {
agents: &["claude"],
project,
hours,
output_dir: output.as_deref(),
format: &format,
append_to,
rotate,
incremental,
include_assistant,
include_loctree: loctree,
project_root,
sync_memex: memex,
force,
redact_secrets: redaction.redact_secrets,
emit,
conversation,
})?;
}
Some(Commands::Codex {
redaction,
project,
hours,
output,
format,
append_to,
rotate,
incremental,
user_only,
include_assistant: include_assistant_flag,
loctree,
project_root,
memex,
force,
emit,
conversation,
}) => {
let include_assistant = include_assistant_flag || !user_only;
run_extraction(ExtractionParams {
agents: &["codex"],
project,
hours,
output_dir: output.as_deref(),
format: &format,
append_to,
rotate,
incremental,
include_assistant,
include_loctree: loctree,
project_root,
sync_memex: memex,
force,
redact_secrets: redaction.redact_secrets,
emit,
conversation,
})?;
}
Some(Commands::All {
redaction,
project,
hours,
output,
append_to,
rotate,
incremental,
user_only,
include_assistant: include_assistant_flag,
loctree,
project_root,
memex,
force,
emit,
conversation,
}) => {
let include_assistant = include_assistant_flag || !user_only;
run_extraction(ExtractionParams {
agents: &["claude", "codex", "gemini", "junie"],
project,
hours,
output_dir: output.as_deref(),
format: "both",
append_to,
rotate,
incremental,
include_assistant,
include_loctree: loctree,
project_root,
sync_memex: memex,
force,
redact_secrets: redaction.redact_secrets,
emit,
conversation,
})?;
}
Some(Commands::Extract {
redaction,
format,
project,
input,
output,
user_only,
include_assistant: include_assistant_flag,
max_message_chars,
conversation,
}) => {
let include_assistant = include_assistant_flag || !user_only;
run_extract_file(
format,
project,
input,
output,
include_assistant,
max_message_chars,
redaction.redact_secrets,
conversation,
)?;
}
Some(Commands::Store {
redaction,
project,
agent,
hours,
user_only,
include_assistant: include_assistant_flag,
memex,
emit,
}) => {
let include_assistant = include_assistant_flag || !user_only;
run_store(
project,
agent,
hours,
include_assistant,
memex,
emit,
redaction.redact_secrets,
)?;
}
Some(Commands::MemexSync {
namespace,
per_chunk,
db_path,
reindex,
}) => {
run_memex_sync(&namespace, per_chunk, db_path, reindex)?;
}
Some(Commands::List) => {
let sources = sources::list_available_sources()?;
if sources.is_empty() {
println!("No AI agent session sources found.");
} else {
println!("=== Available Sources ===\n");
for info in &sources {
let size_mb = info.size_bytes as f64 / 1024.0 / 1024.0;
println!(
" [{:>7}] {} ({} sessions, {:.1} MB)",
info.agent,
info.path.display(),
info.sessions,
size_mb,
);
}
}
}
Some(Commands::Init { .. }) => {
eprintln!("aicx init has been retired.");
eprintln!("Context initialisation is now handled by /vc-init inside Claude Code.");
eprintln!("See: https://vibecrafted.io/");
}
Some(Commands::Refs {
hours,
project,
emit,
summary,
strict,
}) => {
let emit = if summary { RefsEmit::Summary } else { emit };
run_refs(hours, project, emit, strict)?;
}
Some(Commands::State {
reset,
project,
info,
}) => {
run_state(reset, project, info)?;
}
Some(Commands::Dashboard {
store_root,
output,
title,
preview_chars,
}) => {
run_dashboard(DashboardRunArgs {
store_root,
output,
title,
preview_chars,
})?;
}
Some(Commands::ReportsExtractor {
artifacts_root,
org,
repo,
workflow,
date_from,
date_to,
output,
bundle_output,
title,
preview_chars,
}) => {
run_reports_extractor(ReportsExtractorRunArgs {
artifacts_root,
org,
repo,
workflow,
date_from,
date_to,
output,
bundle_output,
title,
preview_chars,
})?;
}
Some(Commands::DashboardServe {
store_root,
host,
port,
no_open,
artifact,
title,
preview_chars,
}) => {
run_dashboard_server(DashboardServerRunArgs {
store_root,
host,
port,
no_open,
artifact,
title,
preview_chars,
})?;
}
Some(Commands::Intents {
project,
hours,
filters,
unresolved,
collapse_session,
emit,
strict,
kind,
}) => {
run_intents(
&project,
hours,
&emit,
strict,
kind.as_deref(),
filters,
unresolved,
collapse_session,
)?;
}
Some(Commands::Tail {
project,
hours,
follow,
kind,
filters,
}) => {
run_tail(&project, hours, follow, kind.as_deref(), filters)?;
}
Some(Commands::Serve { transport, port }) => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { mcp::run_transport(transport, port).await })?;
}
Some(Commands::Search {
query,
project,
hours,
date,
filters,
json,
}) => {
run_search(
&query,
project.as_deref(),
hours,
date.as_deref(),
json,
filters,
)?;
}
Some(Commands::Steer {
run_id,
prompt_id,
kind,
project,
date,
filters,
}) => {
run_steer(
run_id.as_deref(),
prompt_id.as_deref(),
kind.as_deref(),
project.as_deref(),
date.as_deref(),
filters,
)?;
}
Some(Commands::Migrate {
dry_run,
legacy_root,
store_root,
}) => {
aicx::store::run_migration_with_paths(dry_run, legacy_root, store_root)?;
}
None => {
Cli::command().print_help()?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_intents(
project: &str,
hours: u64,
emit: &str,
strict: bool,
kind: Option<&str>,
filters: RetrievalFilters,
unresolved: bool,
collapse_session: bool,
) -> Result<()> {
let kind_filter = kind.map(|k| match k {
"decision" => intents::IntentKind::Decision,
"intent" => intents::IntentKind::Intent,
"outcome" => intents::IntentKind::Outcome,
"task" => intents::IntentKind::Task,
_ => unreachable!("clap validates this"),
});
let config = intents::IntentsConfig {
project: project.to_string(),
hours,
strict,
kind_filter,
frame_kind: filters.frame_kind,
};
let mut records = intents::extract_intents(&config)?;
if unresolved {
use std::collections::HashSet;
let mut resolved_sessions = HashSet::new();
for rec in &records {
if rec.kind == intents::IntentKind::Outcome {
resolved_sessions.insert(rec.session_id.clone());
}
}
records.retain(|r| {
r.kind != intents::IntentKind::Intent || !resolved_sessions.contains(&r.session_id)
});
}
if collapse_session {
use std::collections::HashMap;
let mut map = HashMap::new();
let mut order = Vec::new();
for rec in records {
let key = rec.session_id.clone();
match map.entry(key.clone()) {
std::collections::hash_map::Entry::Vacant(entry) => {
order.push(key);
let mut clone = rec.clone();
clone.count = Some(1);
entry.insert(clone);
}
std::collections::hash_map::Entry::Occupied(mut entry) => {
let existing = entry.get_mut();
*existing.count.as_mut().unwrap() += 1;
if !existing.evidence.contains(&rec.summary) {
existing.evidence.push(rec.summary);
}
if !existing.source_chunk.contains(&rec.source_chunk) {
existing.source_chunk =
format!("{}, {}", existing.source_chunk, rec.source_chunk);
}
}
}
}
records = order.into_iter().filter_map(|k| map.remove(&k)).collect();
}
if let Some(agent_filter) = &filters.agent {
records.retain(|r| r.agent == *agent_filter);
}
let (lo, hi) = if let Some(ref d) = filters.since {
let bounds = parse_date_filter(d)?;
(bounds.0, bounds.1)
} else {
(None, filters.until.clone())
};
if lo.is_some() || hi.is_some() {
records.retain(|r| {
lo.as_ref().is_none_or(|lo| r.date.as_str() >= lo.as_str())
&& hi.as_ref().is_none_or(|hi| r.date.as_str() <= hi.as_str())
});
}
if let Some(sort_order) = filters.sort {
records.sort_by(|a, b| {
let t_a = a.timestamp.as_deref().unwrap_or(a.date.as_str());
let t_b = b.timestamp.as_deref().unwrap_or(b.date.as_str());
match sort_order {
SortOrder::Newest => t_b.cmp(t_a),
SortOrder::Oldest => t_a.cmp(t_b),
SortOrder::Score => std::cmp::Ordering::Equal, }
});
}
records.truncate(filters.limit);
if records.is_empty() {
eprintln!(
"No intents found for project '{}' in last {} hours.",
project, hours
);
return Ok(());
}
match emit {
"json" => {
let json = intents::format_intents_json(&records)?;
println!("{}", json);
}
_ => {
let md = intents::format_intents_markdown(&records);
print!("{}", md);
}
}
Ok(())
}
fn run_tail(
project: &str,
hours: u64,
follow: bool,
kind: Option<&str>,
mut filters: RetrievalFilters,
) -> Result<()> {
if !follow {
if filters.limit == 10 {
filters.limit = 20; }
filters.sort = Some(SortOrder::Newest);
return run_intents(
project, hours, "markdown", false, kind, filters, false, false,
);
}
let kind_filter = kind.map(|k| match k {
"decision" => intents::IntentKind::Decision,
"intent" => intents::IntentKind::Intent,
"outcome" => intents::IntentKind::Outcome,
"task" => intents::IntentKind::Task,
_ => unreachable!("clap validates this"),
});
let mut config = intents::IntentsConfig {
project: project.to_string(),
hours,
strict: false,
kind_filter,
frame_kind: filters.frame_kind,
};
let mut last_seen = std::collections::HashSet::new();
eprintln!("Watching for new intents in project '{}'...", project);
loop {
if let Ok(mut records) = intents::extract_intents(&config) {
if let Some(agent_filter) = &filters.agent {
records.retain(|r| r.agent == *agent_filter);
}
let (lo, hi) = if let Some(ref d) = filters.since {
(
parse_date_filter(d).ok().and_then(|b| b.0),
parse_date_filter(d).ok().and_then(|b| b.1),
)
} else {
(None, filters.until.clone())
};
if lo.is_some() || hi.is_some() {
records.retain(|r| {
lo.as_ref().is_none_or(|lo| r.date.as_str() >= lo.as_str())
&& hi.as_ref().is_none_or(|hi| r.date.as_str() <= hi.as_str())
});
}
records.sort_by(|a, b| {
let t_a = a.timestamp.as_deref().unwrap_or(a.date.as_str());
let t_b = b.timestamp.as_deref().unwrap_or(b.date.as_str());
t_a.cmp(t_b) });
let mut new_records = Vec::new();
for rec in records {
let key = format!(
"{}|{}|{}|{}",
rec.source_chunk,
rec.timestamp.as_deref().unwrap_or(""),
rec.summary,
rec.agent
);
if last_seen.insert(key) {
new_records.push(rec);
}
}
if !new_records.is_empty() {
for rec in new_records {
let mut out = String::new();
out.push_str(&format!("### {} | {}\n", rec.kind.heading(), rec.agent));
out.push_str(&format!("{}: {}\n", rec.kind.heading(), rec.summary));
out.push_str(&format!(
"WHY: {}\n",
rec.context.as_deref().unwrap_or("not captured")
));
out.push_str("EVIDENCE:\n");
out.push_str(&format!("- source_chunk: {}\n", rec.source_chunk));
for evidence in &rec.evidence {
out.push_str(&format!("- {}\n", evidence));
}
println!("{}\n", out);
}
}
}
std::thread::sleep(std::time::Duration::from_secs(2));
config.hours = 1; }
}
#[allow(clippy::too_many_arguments)]
fn run_extract_file(
format: ExtractInputFormat,
explicit_project: Option<String>,
input: PathBuf,
output_path: PathBuf,
include_assistant: bool,
max_message_chars: usize,
redact_secrets: bool,
conversation: bool,
) -> Result<()> {
let cutoff = Utc::now() - chrono::Duration::days(365 * 200);
let config = ExtractionConfig {
project_filter: vec![],
cutoff,
include_assistant,
watermark: None,
};
let mut entries = match format {
ExtractInputFormat::Claude => sources::extract_claude_file(&input, &config)?,
ExtractInputFormat::Codex => sources::extract_codex_file(&input, &config)?,
ExtractInputFormat::Gemini => sources::extract_gemini_file(&input, &config)?,
ExtractInputFormat::GeminiAntigravity => {
sources::extract_gemini_antigravity_file(&input, &config)?
}
ExtractInputFormat::Junie => sources::extract_junie_file(&input, &config)?,
};
entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
if redact_secrets {
for e in &mut entries {
e.message = aicx::redact::redact_secrets(&e.message);
}
}
let mut sessions: Vec<String> = entries.iter().map(|e| e.session_id.clone()).collect();
sessions.sort();
sessions.dedup();
let file_label = input
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "(unknown)".to_string());
let inferred_repos = sources::repo_labels_from_entries(&entries, &[]);
let project_identity = explicit_project.unwrap_or_else(|| {
if inferred_repos.is_empty() {
format!("file: {file_label}")
} else {
inferred_repos.join("+")
}
});
let hours_back = entries
.first()
.map(|e| (Utc::now() - e.timestamp).num_hours().max(0) as u64)
.unwrap_or(0);
let output_entries = entries;
let metadata = ReportMetadata {
generated_at: Utc::now(),
project_filter: Some(project_identity),
hours_back,
total_entries: output_entries.len(),
sessions,
};
if conversation {
let project_filter = metadata
.project_filter
.as_ref()
.map(|p| vec![p.clone()])
.unwrap_or_default();
let conv_msgs = sources::to_conversation(&output_entries, &project_filter);
let ext = output_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md")
.to_lowercase();
if ext == "json" {
output::write_conversation_json(&output_path, &conv_msgs, &metadata)?;
} else {
output::write_conversation_markdown(&output_path, &conv_msgs, &metadata)?;
}
} else {
let ext = output_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md")
.to_lowercase();
if ext == "json" {
output::write_json_report_to_path(&output_path, &output_entries, &metadata)?;
} else {
output::write_markdown_report_to_path(
&output_path,
&output_entries,
&metadata,
max_message_chars,
None,
)?;
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize)]
struct StoreScopeSurface {
requested_source_filters: Option<Vec<String>>,
resolved_repositories: Vec<String>,
includes_non_repository_contexts: bool,
resolved_store_buckets: BTreeMap<String, BTreeMap<String, usize>>,
}
impl StoreScopeSurface {
fn empty(requested_filters: &[String]) -> Self {
Self {
requested_source_filters: normalized_requested_source_filters(requested_filters),
resolved_repositories: Vec::new(),
includes_non_repository_contexts: false,
resolved_store_buckets: BTreeMap::new(),
}
}
fn from_store_summary(
requested_filters: &[String],
store_summary: &store::StoreWriteSummary,
) -> Self {
Self {
requested_source_filters: normalized_requested_source_filters(requested_filters),
resolved_repositories: store_summary
.project_summary
.keys()
.filter(|bucket| bucket.as_str() != store::NON_REPOSITORY_CONTEXTS)
.cloned()
.collect(),
includes_non_repository_contexts: store_summary
.project_summary
.contains_key(store::NON_REPOSITORY_CONTEXTS),
resolved_store_buckets: store_summary.project_summary.clone(),
}
}
fn repository_buckets(&self) -> BTreeMap<String, BTreeMap<String, usize>> {
self.resolved_store_buckets
.iter()
.filter(|(bucket, _)| bucket.as_str() != store::NON_REPOSITORY_CONTEXTS)
.map(|(bucket, counts)| (bucket.clone(), counts.clone()))
.collect()
}
}
fn normalized_requested_source_filters(requested_filters: &[String]) -> Option<Vec<String>> {
if requested_filters.is_empty() {
None
} else {
Some(requested_filters.to_vec())
}
}
fn render_requested_source_filters(requested_filters: &[String]) -> String {
if requested_filters.is_empty() {
"(all sources)".to_string()
} else {
requested_filters.join(", ")
}
}
fn render_resolved_store_buckets(scope: &StoreScopeSurface) -> String {
if scope.resolved_store_buckets.is_empty() {
"(none written)".to_string()
} else {
scope
.resolved_store_buckets
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
}
}
struct ExtractionParams<'a> {
agents: &'a [&'a str],
project: Vec<String>,
hours: u64,
output_dir: Option<&'a Path>,
format: &'a str,
append_to: Option<PathBuf>,
rotate: usize,
incremental: bool,
include_assistant: bool,
include_loctree: bool,
project_root: Option<PathBuf>,
sync_memex: bool,
force: bool,
conversation: bool,
redact_secrets: bool,
emit: StdoutEmit,
}
struct MemexProgressPrinter {
enabled: bool,
width: usize,
}
impl MemexProgressPrinter {
fn new() -> Self {
Self {
enabled: io::stderr().is_terminal(),
width: 0,
}
}
fn update(&mut self, progress: &SyncProgress) {
if !self.enabled {
return;
}
let message = render_memex_progress(progress);
let width = self.width.max(message.len());
self.width = width;
eprint!("\r{message:<width$}");
let _ = io::stderr().flush();
}
fn finish(&mut self) {
if self.enabled && self.width > 0 {
eprint!("\r{:<width$}\r", "", width = self.width);
let _ = io::stderr().flush();
self.width = 0;
}
}
}
fn render_memex_progress(progress: &SyncProgress) -> String {
match progress.phase {
SyncProgressPhase::Discovering => {
format!(
" Memex scan... {}/{}",
progress.done.max(1),
progress.total.max(1)
)
}
SyncProgressPhase::Embedding => {
format!(
" Memex embed... {}/{}",
progress.done.max(1),
progress.total.max(1)
)
}
SyncProgressPhase::Writing => {
format!(
" Memex index... {}/{}",
progress.done.max(1),
progress.total.max(1)
)
}
SyncProgressPhase::Completed => format!(" {}", progress.detail),
}
}
fn sync_memex_paths(config: &MemexConfig, chunk_paths: &[PathBuf]) -> Result<memex::SyncResult> {
let mut printer = MemexProgressPrinter::new();
let enabled = printer.enabled;
let result = if enabled {
memex::sync_new_chunk_paths_with_progress(chunk_paths, config, |progress| {
printer.update(&progress);
})
} else {
memex::sync_new_chunk_paths(chunk_paths, config)
};
printer.finish();
result
}
fn sync_memex_if_requested(sync_memex: bool, all_written_paths: &[PathBuf]) -> Result<()> {
if sync_memex && !all_written_paths.is_empty() {
let memex_config = MemexConfig::default();
let result = sync_memex_paths(&memex_config, all_written_paths)
.context("Failed to materialize canonical chunks into memex semantic index")?;
eprintln!(
" Memex: {} materialized, {} skipped, {} ignored",
result.chunks_materialized, result.chunks_skipped, result.chunks_ignored
);
for err in &result.errors {
eprintln!(" Memex error: {}", err);
}
}
Ok(())
}
fn run_extraction(params: ExtractionParams<'_>) -> Result<()> {
let ExtractionParams {
agents,
project,
hours,
output_dir,
format,
append_to,
rotate,
incremental,
include_assistant,
include_loctree,
project_root,
sync_memex,
force,
conversation,
redact_secrets,
emit,
} = params;
let mut state = StateManager::load();
let project_name = if project.is_empty() {
"_global".to_string()
} else {
project.join("+")
};
let cutoff = Utc::now() - chrono::Duration::hours(hours as i64);
let watermark = if incremental {
let source_key = format!(
"{}:{}",
agents.join("+"),
if project.is_empty() {
"all".to_string()
} else {
project.join("+")
}
);
state.get_watermark(&source_key)
} else {
None
};
let config = ExtractionConfig {
project_filter: project.clone(),
cutoff,
include_assistant,
watermark,
};
eprintln!(
" Requested source filters: {}",
render_requested_source_filters(&project)
);
let mut entries = Vec::new();
for &agent in agents {
let agent_entries = match agent {
"claude" => sources::extract_claude(&config)?,
"codex" => sources::extract_codex(&config)?,
"gemini" => sources::extract_gemini(&config)?,
"junie" => sources::extract_junie(&config)?,
_ => Vec::new(),
};
eprintln!(" [{}] {} entries", agent, agent_entries.len());
entries.extend(agent_entries);
}
let pre_dedup = entries.len();
let overlap_project = format!("_overlap:{project_name}");
if !force {
let mut deduped = Vec::with_capacity(entries.len());
for e in entries {
let exact = StateManager::content_hash(&e.agent, e.timestamp.timestamp(), &e.message);
if !state.is_new(&project_name, exact) {
continue; }
let overlap = StateManager::overlap_hash(e.timestamp.timestamp(), &e.message);
if !state.is_new(&overlap_project, overlap) {
continue; }
state.mark_seen(&project_name, exact);
state.mark_seen(&overlap_project, overlap);
deduped.push(e);
}
entries = deduped;
}
if pre_dedup != entries.len() {
eprintln!(
" Dedup: {} → {} entries (skipped {} seen)",
pre_dedup,
entries.len(),
pre_dedup - entries.len(),
);
}
entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
let pre_echo = entries.len();
entries.retain(|e| !aicx::sanitize::is_self_echo(&e.message));
let echo_filtered = pre_echo - entries.len();
if echo_filtered > 0 {
eprintln!(" Filtered {echo_filtered} self-echo entries");
}
if redact_secrets {
for e in &mut entries {
e.message = aicx::redact::redact_secrets(&e.message);
}
}
let mut sessions: Vec<String> = entries.iter().map(|e| e.session_id.clone()).collect();
sessions.sort();
sessions.dedup();
let output_entries = entries;
let metadata = ReportMetadata {
generated_at: Utc::now(),
project_filter: if project.is_empty() {
None
} else {
Some(project.join(", "))
},
hours_back: hours,
total_entries: output_entries.len(),
sessions,
};
let chunker_config = aicx::chunker::ChunkerConfig::default();
let mut all_written_paths: Vec<std::path::PathBuf> = Vec::new();
let mut scope_surface = StoreScopeSurface::empty(&project);
if !output_entries.is_empty() {
let store_summary = store::store_semantic_segments(&output_entries, &chunker_config)?;
scope_surface = StoreScopeSurface::from_store_summary(&project, &store_summary);
let newly_written_paths = store_summary.written_paths.clone();
all_written_paths.extend(newly_written_paths.iter().cloned());
if let Ok(rt) = tokio::runtime::Runtime::new() {
let path_refs: Vec<&PathBuf> = newly_written_paths.iter().collect();
if let Err(e) = rt.block_on(aicx::steer_index::sync_steer_index(&path_refs)) {
eprintln!("âš steer index sync failed (search may be stale): {e}");
}
}
eprintln!(
"✓ {} entries → {} chunks",
output_entries.len(),
all_written_paths.len(),
);
for (repo, agents_map) in &store_summary.project_summary {
let total: usize = agents_map.values().sum();
let detail: Vec<String> = agents_map
.iter()
.map(|(a, c)| format!("{}: {}", a, c))
.collect();
eprintln!(" {}: {} entries ({})", repo, total, detail.join(", "));
}
eprintln!(
" Resolved store buckets: {}",
render_resolved_store_buckets(&scope_surface)
);
sync_memex_if_requested(sync_memex, &newly_written_paths)?;
}
match emit {
StdoutEmit::Paths => {
for path in &all_written_paths {
println!("{}", path.display());
}
}
StdoutEmit::Json => {
let store_paths: Vec<String> = all_written_paths
.iter()
.map(|p| p.display().to_string())
.collect();
if conversation {
#[derive(Serialize)]
struct JsonConvStdout<'a> {
generated_at: chrono::DateTime<Utc>,
project_filter: &'a Option<String>,
hours_back: u64,
total_messages: usize,
sessions: &'a [String],
#[serde(flatten)]
scope: &'a StoreScopeSurface,
messages: Vec<sources::ConversationMessage>,
store_paths: Vec<String>,
}
let conv_msgs = sources::to_conversation(&output_entries, &project);
let report = JsonConvStdout {
generated_at: metadata.generated_at,
project_filter: &metadata.project_filter,
hours_back: metadata.hours_back,
total_messages: conv_msgs.len(),
sessions: &metadata.sessions,
scope: &scope_surface,
messages: conv_msgs,
store_paths,
};
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
#[derive(Serialize)]
struct JsonStdoutReport<'a> {
generated_at: chrono::DateTime<Utc>,
project_filter: &'a Option<String>,
hours_back: u64,
total_entries: usize,
sessions: &'a [String],
#[serde(flatten)]
scope: &'a StoreScopeSurface,
entries: &'a [output::TimelineEntry],
store_paths: Vec<String>,
}
let report = JsonStdoutReport {
generated_at: metadata.generated_at,
project_filter: &metadata.project_filter,
hours_back: metadata.hours_back,
total_entries: metadata.total_entries,
sessions: &metadata.sessions,
scope: &scope_surface,
entries: &output_entries,
store_paths,
};
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
StdoutEmit::None => {}
}
if let Some(local_dir) = output_dir {
if conversation {
let conv_msgs = sources::to_conversation(&output_entries, &project);
let date_str = metadata.generated_at.format("%Y%m%d_%H%M%S");
let prefix = metadata.project_filter.as_deref().unwrap_or("all");
let out_format = match format {
"md" => OutputFormat::Markdown,
"json" => OutputFormat::Json,
_ => OutputFormat::Both,
};
fs::create_dir_all(local_dir)?;
if out_format == OutputFormat::Markdown || out_format == OutputFormat::Both {
let md_path = local_dir.join(format!("{}_conversation_{}.md", prefix, date_str));
output::write_conversation_markdown(&md_path, &conv_msgs, &metadata)?;
}
if out_format == OutputFormat::Json || out_format == OutputFormat::Both {
let json_path =
local_dir.join(format!("{}_conversation_{}.json", prefix, date_str));
output::write_conversation_json(&json_path, &conv_msgs, &metadata)?;
}
} else {
let out_format = match format {
"md" => OutputFormat::Markdown,
"json" => OutputFormat::Json,
_ => OutputFormat::Both,
};
let mode = if let Some(ref path) = append_to {
OutputMode::AppendTimeline(path.clone())
} else {
OutputMode::NewFile
};
let out_config = OutputConfig {
dir: local_dir.to_path_buf(),
format: out_format,
mode,
max_files: rotate,
max_message_chars: 0,
include_loctree,
project_root,
};
let written = output::write_report(&out_config, &output_entries, &metadata)?;
for path in &written {
eprintln!(" → {}", path.display());
}
if rotate > 0 {
let prefix = agents.join("_");
let deleted = output::rotate_outputs(local_dir, &prefix, rotate)?;
if deleted > 0 {
eprintln!(" Rotated: deleted {} old files", deleted);
}
}
}
}
if !output_entries.is_empty() {
if force {
for e in &output_entries {
let exact =
StateManager::content_hash(&e.agent, e.timestamp.timestamp(), &e.message);
let overlap = StateManager::overlap_hash(e.timestamp.timestamp(), &e.message);
state.mark_seen(&project_name, exact);
state.mark_seen(&overlap_project, overlap);
}
}
if incremental {
let source_key = format!(
"{}:{}",
agents.join("+"),
if project.is_empty() {
"all".to_string()
} else {
project.join("+")
}
);
if let Some(latest) = output_entries.last() {
state.update_watermark(&source_key, latest.timestamp);
}
}
state.record_run(
output_entries.len(),
agents.iter().map(|s| s.to_string()).collect(),
);
state.prune_old_hashes(50_000);
state.save()?;
}
if output_entries.is_empty() {
eprintln!(
"✓ 0 entries from {} sessions ({})",
metadata.sessions.len(),
agents.join("+"),
);
}
Ok(())
}
fn run_store(
project: Vec<String>,
agent: Option<String>,
hours: u64,
include_assistant: bool,
sync_memex: bool,
emit: StdoutEmit,
redact_secrets: bool,
) -> Result<()> {
let cutoff = Utc::now() - chrono::Duration::hours(hours as i64);
let agents: Vec<&str> = match agent.as_deref() {
Some("claude") => vec!["claude"],
Some("codex") => vec!["codex"],
Some("gemini") => vec!["gemini"],
Some("junie") => vec!["junie"],
_ => vec!["claude", "codex", "gemini", "junie"],
};
let config = ExtractionConfig {
project_filter: project.clone(),
cutoff,
include_assistant,
watermark: None,
};
eprintln!(
" Requested source filters: {}",
render_requested_source_filters(&project)
);
let mut all_entries = Vec::new();
for &ag in &agents {
let agent_entries = match ag {
"claude" => sources::extract_claude(&config)?,
"codex" => sources::extract_codex(&config)?,
"gemini" => sources::extract_gemini(&config)?,
"junie" => sources::extract_junie(&config)?,
_ => Vec::new(),
};
eprintln!(" [{}] {} entries", ag, agent_entries.len());
all_entries.extend(agent_entries);
}
all_entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
let pre_echo = all_entries.len();
all_entries.retain(|e| !aicx::sanitize::is_self_echo(&e.message));
let echo_filtered = pre_echo - all_entries.len();
if echo_filtered > 0 {
eprintln!(" Filtered {echo_filtered} self-echo entries");
}
if all_entries.is_empty() {
eprintln!("No entries found.");
return Ok(());
}
if redact_secrets {
for e in &mut all_entries {
e.message = aicx::redact::redact_secrets(&e.message);
}
}
let chunker_config = aicx::chunker::ChunkerConfig::default();
let stderr_is_tty = io::stderr().is_terminal();
let mut progress_width = 0usize;
let store_result = if stderr_is_tty {
store::store_semantic_segments_with_progress(
&all_entries,
&chunker_config,
|done, total| {
let message = format!(" Chunking... {done}/{total} segments");
let width = progress_width.max(message.len());
progress_width = width;
eprint!("\r{message:<width$}");
let _ = io::stderr().flush();
},
)
} else {
store::store_semantic_segments(&all_entries, &chunker_config)
};
if stderr_is_tty && progress_width > 0 {
eprint!("\r{:<width$}\r", "", width = progress_width);
let _ = io::stderr().flush();
}
let store_summary = store_result?;
let stored_count = store_summary.total_entries;
let all_written_paths = store_summary.written_paths.clone();
let scope_surface = StoreScopeSurface::from_store_summary(&project, &store_summary);
if let Ok(rt) = tokio::runtime::Runtime::new() {
let path_refs: Vec<&PathBuf> = all_written_paths.iter().collect();
if let Err(e) = rt.block_on(aicx::steer_index::sync_steer_index(&path_refs)) {
eprintln!("âš steer index sync failed (search may be stale): {e}");
}
}
eprintln!(
"✓ {} entries → {} chunks",
stored_count,
all_written_paths.len(),
);
for (repo, agents_map) in &store_summary.project_summary {
let total: usize = agents_map.values().sum();
let detail: Vec<String> = agents_map
.iter()
.map(|(a, c)| format!("{}: {}", a, c))
.collect();
eprintln!(" {}: {} entries ({})", repo, total, detail.join(", "));
}
eprintln!(
" Resolved store buckets: {}",
render_resolved_store_buckets(&scope_surface)
);
sync_memex_if_requested(sync_memex, &all_written_paths)?;
match emit {
StdoutEmit::Paths => {
for path in &all_written_paths {
println!("{}", path.display());
}
}
StdoutEmit::Json => {
let store_paths: Vec<String> = all_written_paths
.iter()
.map(|path| path.display().to_string())
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"total_entries": stored_count,
"total_chunks": all_written_paths.len(),
"requested_source_filters": scope_surface.requested_source_filters,
"resolved_repositories": scope_surface.resolved_repositories,
"includes_non_repository_contexts": scope_surface.includes_non_repository_contexts,
"resolved_store_buckets": scope_surface.resolved_store_buckets,
"repos": scope_surface.repository_buckets(),
"store_paths": store_paths,
}))?
);
}
StdoutEmit::None => {}
}
Ok(())
}
fn is_noise_artifact(path: &std::path::Path) -> bool {
if !path.is_file() || path.extension().is_none_or(|ext| ext != "md") {
return false;
}
let Ok(content) = aicx::sanitize::read_to_string_validated(path) else {
return false;
};
let lines: Vec<&str> = content.lines().collect();
if lines.len() >= 15 {
return false; }
let mut is_noise = true;
for line in &lines {
let l = line.trim().to_lowercase();
if l.is_empty()
|| l.starts_with("[project:")
|| l.starts_with("[signals")
|| l.starts_with("[/signals")
|| l.starts_with("-") || (l.starts_with("[") && l.contains("] ") && l.contains("tool:")) || l.contains("task-notification")
|| l.contains("background command")
|| l.contains("task killed")
|| l.contains("task update")
|| l.contains("ran command")
|| l.contains("ran find")
|| l.contains("called loctree")
|| l.contains("killed process")
{
continue;
} else {
is_noise = false;
break;
}
}
is_noise
}
fn month_number(s: &str) -> Option<u32> {
match s {
"january" | "jan" | "styczen" | "stycznia" | "styczeń" => Some(1),
"february" | "feb" | "luty" | "lutego" => Some(2),
"march" | "mar" | "marzec" | "marca" => Some(3),
"april" | "apr" | "kwiecien" | "kwietnia" | "kwiecień" => Some(4),
"may" | "maj" | "maja" => Some(5),
"june" | "jun" | "czerwiec" | "czerwca" => Some(6),
"july" | "jul" | "lipiec" | "lipca" => Some(7),
"august" | "aug" | "sierpien" | "sierpnia" | "sierpień" => Some(8),
"september" | "sep" | "wrzesien" | "września" | "wrzesień" => Some(9),
"october" | "oct" | "pazdziernik" | "października" | "październik" => Some(10),
"november" | "nov" | "listopad" | "listopada" => Some(11),
"december" | "dec" | "grudzien" | "grudnia" | "grudzień" => Some(12),
_ => None,
}
}
fn extract_date_from_query(query: &str) -> (String, Option<String>) {
let words: Vec<&str> = query.split_whitespace().collect();
let lower: Vec<String> = words.iter().map(|w| w.to_lowercase()).collect();
let mut used = vec![false; words.len()];
let mut date_filter: Option<String> = None;
for i in 0..words.len().saturating_sub(1) {
if let Some(m) = month_number(&lower[i])
&& let Ok(y) = lower[i + 1].parse::<u32>()
&& (2020..=2099).contains(&y)
{
let days = days_in_month(y, m);
let lo = format!("{y:04}-{m:02}-01");
let hi = format!("{y:04}-{m:02}-{days:02}");
date_filter = Some(format!("{lo}..{hi}"));
used[i] = true;
used[i + 1] = true;
}
}
if date_filter.is_none() {
for i in 0..words.len().saturating_sub(1) {
if let Ok(y) = lower[i].parse::<u32>()
&& (2020..=2099).contains(&y)
&& let Some(m) = month_number(&lower[i + 1])
{
let days = days_in_month(y, m);
let lo = format!("{y:04}-{m:02}-01");
let hi = format!("{y:04}-{m:02}-{days:02}");
date_filter = Some(format!("{lo}..{hi}"));
used[i] = true;
used[i + 1] = true;
}
}
}
if date_filter.is_none() {
let re_ym = regex::Regex::new(r"^(\d{4})-(\d{2})$").unwrap();
for (i, w) in lower.iter().enumerate() {
if let Some(caps) = re_ym.captures(w) {
let y: u32 = caps[1].parse().unwrap();
let m: u32 = caps[2].parse().unwrap();
if (1..=12).contains(&m) {
let days = days_in_month(y, m);
let lo = format!("{y:04}-{m:02}-01");
let hi = format!("{y:04}-{m:02}-{days:02}");
date_filter = Some(format!("{lo}..{hi}"));
used[i] = true;
}
}
}
}
if date_filter.is_none() {
let re_ymd = regex::Regex::new(r"^(\d{4}-\d{2}-\d{2})$").unwrap();
for (i, w) in lower.iter().enumerate() {
if re_ymd.is_match(w) {
date_filter = Some(w.clone());
used[i] = true;
}
}
}
let cleaned: Vec<&str> = words
.iter()
.enumerate()
.filter(|(i, _)| !used[*i])
.map(|(_, w)| *w)
.collect();
(cleaned.join(" "), date_filter)
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) {
29
} else {
28
}
}
_ => 30,
}
}
fn parse_date_filter(s: &str) -> Result<(Option<String>, Option<String>)> {
if let Some((left, right)) = s.split_once("..") {
let lo = if left.is_empty() {
None
} else {
Some(left.to_string())
};
let hi = if right.is_empty() {
None
} else {
Some(right.to_string())
};
Ok((lo, hi))
} else {
Ok((Some(s.to_string()), Some(s.to_string())))
}
}
fn run_search(
query: &str,
project: Option<&str>,
hours: u64,
date: Option<&str>,
json: bool,
filters: RetrievalFilters,
) -> Result<()> {
let (effective_query, inline_date) = if date.is_none() {
extract_date_from_query(query)
} else {
(query.to_string(), None)
};
let effective_date = date.map(String::from).or(inline_date);
let search_query = if effective_date.is_some() && effective_query.is_empty() {
"*".to_string()
} else if !effective_query.is_empty() {
effective_query
} else {
query.to_string()
};
let root = store::store_base_dir()?;
let fetch_limit = if effective_date.is_some()
|| filters.score.is_some()
|| hours > 0
|| filters.since.is_some()
|| filters.until.is_some()
{
filters.limit.saturating_mul(5).max(50)
} else {
filters.limit
};
let (results, scanned) = if let Ok(rt) = tokio::runtime::Runtime::new() {
match rt.block_on(memex::fast_memex_search(
&search_query,
fetch_limit,
project,
filters.frame_kind,
)) {
Ok((res, scan)) if !res.is_empty() => (res, scan),
Err(err) if memex::is_compatibility_error(&err) => return Err(err),
_ => rank::fuzzy_search_store(
&root,
&search_query,
fetch_limit,
project,
filters.frame_kind,
)?,
}
} else {
rank::fuzzy_search_store(
&root,
&search_query,
fetch_limit,
project,
filters.frame_kind,
)?
};
let mut results = results;
if let Some(min_score) = filters.score {
results.retain(|r| r.score >= min_score);
}
if let Some(agent_filter) = &filters.agent {
results.retain(|r| r.agent == *agent_filter);
}
let (lo, hi) = if let Some(ref d) = effective_date {
let bounds = parse_date_filter(d)?;
(bounds.0, bounds.1)
} else {
(filters.since.clone(), filters.until.clone())
};
let mut results: Vec<_> = if lo.is_some() || hi.is_some() {
results
.into_iter()
.filter(|r| {
lo.as_ref().is_none_or(|lo| r.date.as_str() >= lo.as_str())
&& hi.as_ref().is_none_or(|hi| r.date.as_str() <= hi.as_str())
})
.collect()
} else if hours > 0 {
let cutoff = chrono::Utc::now() - chrono::Duration::hours(hours as i64);
let cutoff_date = cutoff.format("%Y-%m-%d").to_string();
results
.into_iter()
.filter(|r| r.date >= cutoff_date)
.collect()
} else {
results
};
if let Some(sort_order) = filters.sort {
results.sort_by(|a, b| {
let t_a = a.timestamp.as_deref().unwrap_or(a.date.as_str());
let t_b = b.timestamp.as_deref().unwrap_or(b.date.as_str());
match sort_order {
SortOrder::Newest => t_b.cmp(t_a),
SortOrder::Oldest => t_a.cmp(t_b),
SortOrder::Score => b.score.cmp(&a.score).then(t_b.cmp(t_a)),
}
});
} else {
results.sort_by(|a, b| b.score.cmp(&a.score));
}
let results: Vec<_> = results.into_iter().take(filters.limit).collect();
if json {
println!("{}", rank::render_search_json(&results, scanned)?);
return Ok(());
}
if results.is_empty() {
eprintln!("No matches for {:?} (scanned {} chunks).", query, scanned);
return Ok(());
}
print!(
"{}",
rank::render_search_text(&results, io::stdout().is_terminal())
);
let _ = io::stdout().flush();
if io::stderr().is_terminal() {
eprintln!(
"\n{} result(s) from {} scanned chunks.",
results.len(),
scanned
);
}
Ok(())
}
fn run_steer(
run_id: Option<&str>,
prompt_id: Option<&str>,
kind: Option<&str>,
project: Option<&str>,
date: Option<&str>,
filters: RetrievalFilters,
) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
let effective_date = date;
let (date_lo, date_hi) = if let Some(d) = effective_date {
let bounds = parse_date_filter(d)?;
(bounds.0, bounds.1)
} else {
(filters.since.clone(), filters.until.clone())
};
let mut metadatas = rt.block_on(aicx::steer_index::search_steer_index(
run_id,
prompt_id,
filters.agent.as_deref(),
kind,
filters.frame_kind,
project,
date_lo.as_deref(),
date_hi.as_deref(),
filters.limit,
))?;
if let Some(sort_order) = filters.sort {
metadatas.sort_by(|a, b| {
let t_a = a
.get("timestamp")
.and_then(|v| v.as_str())
.or_else(|| a.get("date").and_then(|v| v.as_str()))
.unwrap_or("");
let t_b = b
.get("timestamp")
.and_then(|v| v.as_str())
.or_else(|| b.get("date").and_then(|v| v.as_str()))
.unwrap_or("");
match sort_order {
SortOrder::Newest => t_b.cmp(t_a),
SortOrder::Oldest => t_a.cmp(t_b),
SortOrder::Score => std::cmp::Ordering::Equal, }
});
}
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
let color = stdout.is_terminal();
let matched = metadatas.len();
for meta in metadatas {
let path = meta.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let p = meta.get("project").and_then(|v| v.as_str()).unwrap_or("?");
let a = meta.get("agent").and_then(|v| v.as_str()).unwrap_or("?");
let d = meta.get("date").and_then(|v| v.as_str()).unwrap_or("?");
let k = meta.get("kind").and_then(|v| v.as_str()).unwrap_or("?");
let run_str = meta.get("run_id").and_then(|v| v.as_str()).unwrap_or("-");
let prompt_str = meta
.get("prompt_id")
.and_then(|v| v.as_str())
.unwrap_or("-");
let model_str = meta
.get("agent_model")
.and_then(|v| v.as_str())
.unwrap_or("-");
if color {
let _ = writeln!(
out,
"\x1b[1;36m{}\x1b[0m | \x1b[35m{}\x1b[0m | \x1b[90m{}\x1b[0m | {}",
p, a, d, k
);
let _ = writeln!(
out,
" run_id: \x1b[33m{run_str}\x1b[0m prompt_id: \x1b[33m{prompt_str}\x1b[0m model: \x1b[90m{model_str}\x1b[0m"
);
let _ = writeln!(out, " \x1b[90;4m{}\x1b[0m", path);
let _ = writeln!(out);
} else {
let _ = writeln!(out, "{} | {} | {} | {}", p, a, d, k);
let _ = writeln!(
out,
" run_id: {run_str} prompt_id: {prompt_str} model: {model_str}"
);
let _ = writeln!(out, " {}", path);
let _ = writeln!(out);
}
}
let _ = out.flush();
if io::stderr().is_terminal() {
eprintln!("{matched} match(es) from steer index.");
}
Ok(())
}
fn run_refs(hours: u64, project: Option<String>, emit: RefsEmit, strict: bool) -> Result<()> {
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(hours * 3600);
let mut files = store::context_files_since(cutoff, project.as_deref())?;
if strict {
files.retain(|file| !is_noise_artifact(&file.path));
}
if files.is_empty() {
eprintln!("No context files found within last {} hours.", hours);
} else {
match emit {
RefsEmit::Summary => print_refs_summary(&files)?,
RefsEmit::Paths => {
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
for f in &files {
if let Err(err) = writeln!(out, "{}", f.path.display()) {
if err.kind() == io::ErrorKind::BrokenPipe {
return Ok(());
}
return Err(err.into());
}
}
if let Err(err) = out.flush() {
if err.kind() == io::ErrorKind::BrokenPipe {
return Ok(());
}
return Err(err.into());
}
if io::stderr().is_terminal() {
eprintln!("({} files)", files.len());
}
}
}
}
Ok(())
}
#[derive(Default)]
struct RefsAgentSummary {
files: usize,
days: BTreeSet<String>,
}
#[derive(Default)]
struct RefsProjectSummary {
total_files: usize,
min_date: Option<String>,
max_date: Option<String>,
latest: Option<String>,
agents: BTreeMap<String, RefsAgentSummary>,
}
fn print_refs_summary(files: &[store::StoredContextFile]) -> Result<()> {
let mut by_project: BTreeMap<String, RefsProjectSummary> = BTreeMap::new();
for path in files {
let file_name = path
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown-file")
.to_string();
let date = path.date_iso.clone();
let project = path.project.clone();
let latest_rel = format!("{}/{}/{}", date, path.kind.dir_name(), file_name);
let agent = path.agent.to_ascii_lowercase();
let project_summary = by_project.entry(project).or_default();
project_summary.total_files += 1;
if project_summary
.min_date
.as_ref()
.is_none_or(|min_date| &date < min_date)
{
project_summary.min_date = Some(date.clone());
}
if project_summary
.max_date
.as_ref()
.is_none_or(|max_date| &date > max_date)
{
project_summary.max_date = Some(date.clone());
}
if project_summary
.latest
.as_ref()
.is_none_or(|latest| &latest_rel > latest)
{
project_summary.latest = Some(latest_rel);
}
let agent_summary = project_summary.agents.entry(agent).or_default();
agent_summary.files += 1;
agent_summary.days.insert(date);
}
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
for (project, summary) in &by_project {
let date_range = match (&summary.min_date, &summary.max_date) {
(Some(min), Some(max)) => format!("{min} .. {max}"),
_ => "unknown".to_string(),
};
let agent_details = summary
.agents
.iter()
.map(|(agent, data)| format!("{agent}: {} files/{} days", data.files, data.days.len()))
.collect::<Vec<_>>()
.join(", ");
let latest = summary.latest.as_deref().unwrap_or("unknown");
if let Err(err) = writeln!(
out,
"{}: {} files ({}) [{}] latest: {}",
project, summary.total_files, date_range, agent_details, latest
) {
if err.kind() == io::ErrorKind::BrokenPipe {
return Ok(());
}
return Err(err.into());
}
}
if let Err(err) = out.flush() {
if err.kind() == io::ErrorKind::BrokenPipe {
return Ok(());
}
return Err(err.into());
}
Ok(())
}
fn run_state(reset: bool, project: Option<String>, info: bool) -> Result<()> {
let mut state = StateManager::load();
if info {
eprintln!("=== State Info ===");
eprintln!(" Total hashes: {}", state.total_hashes());
eprintln!(" Projects: {}", state.seen_hashes.len());
for (proj, set) in &state.seen_hashes {
eprintln!(" {}: {} hashes", proj, set.len());
}
eprintln!(" Watermarks: {}", state.last_processed.len());
for (src, ts) in &state.last_processed {
eprintln!(" {}: {}", src, ts);
}
eprintln!(" Runs: {}", state.runs.len());
return Ok(());
}
if reset {
if let Some(ref p) = project {
state.reset_project(p);
state.save()?;
eprintln!("Reset hashes for project: {}", p);
} else {
state.reset_all();
state.save()?;
eprintln!("Reset all dedup hashes.");
}
return Ok(());
}
eprintln!("Use --info to show state or --reset to clear. See --help.");
Ok(())
}
fn run_memex_sync(
namespace: &str,
per_chunk: bool,
db_path: Option<PathBuf>,
reindex: bool,
) -> Result<()> {
let truth = memex::resolve_runtime_truth(db_path.as_deref())?;
let store_root = store::store_base_dir()?;
let canonical_root = store::canonical_store_dir()?;
let chunk_paths: Vec<PathBuf> = store::scan_context_files_raw()?
.into_iter()
.map(|file| file.path)
.collect();
if chunk_paths.is_empty() {
eprintln!(
"No canonical stored chunks found under: {}",
canonical_root.display()
);
eprintln!("Run `aicx store`, `aicx all`, or another extractor first.");
return Ok(());
}
let config = MemexConfig {
namespace: namespace.to_string(),
db_path: db_path.clone(),
batch_mode: !per_chunk,
preprocess: true,
};
eprintln!(
"Syncing canonical chunks from: {}",
canonical_root.display()
);
eprintln!(" Chunk files: {}", chunk_paths.len());
eprintln!(" Namespace: {}", config.namespace);
eprintln!(" Embedding model: {}", truth.embedding_model);
eprintln!(" Embedding dims: {}", truth.embedding_dimension);
eprintln!(" LanceDB path: {}", truth.db_path.display());
eprintln!(" BM25 path: {}", truth.bm25_path.display());
if let Some(path) = truth.config_path.as_ref() {
eprintln!(" Config: {}", path.display());
}
let ignore_path = store_root.join(store::AICX_IGNORE_FILENAME);
if ignore_path.is_file() {
eprintln!(" Ignore file: {}", ignore_path.display());
}
eprintln!(
" Mode: {}",
if config.batch_mode {
"batch store (library-backed, metadata-rich)"
} else {
"per-chunk store (library-backed)"
}
);
if reindex {
eprintln!(" Reindex: wiping current rmcp-memex store before rebuild");
eprintln!(
" Warning: Lance vector schema is shared across the whole store, so other namespaces in {} will need a rebuild too.",
truth.db_path.display()
);
memex::reset_semantic_index(namespace, db_path.as_deref())?;
}
let result = sync_memex_paths(&config, &chunk_paths)?;
eprintln!(
"✓ Memex sync: {} materialized, {} skipped, {} ignored",
result.chunks_materialized, result.chunks_skipped, result.chunks_ignored,
);
for err in &result.errors {
eprintln!(" Error: {}", err);
}
Ok(())
}
struct DashboardServerRunArgs {
store_root: Option<PathBuf>,
host: String,
port: u16,
no_open: bool,
artifact: PathBuf,
title: String,
preview_chars: usize,
}
fn run_dashboard_server(args: DashboardServerRunArgs) -> Result<()> {
let root = if let Some(path) = args.store_root {
path
} else {
store::store_base_dir()?
};
let host: std::net::IpAddr = args.host.parse().with_context(|| {
format!(
"Invalid --host IP address '{}'. Example valid value: 127.0.0.1",
args.host
)
})?;
if !host.is_loopback() {
return Err(anyhow::anyhow!(
"Refusing non-loopback --host '{}'. Dashboard server is local-only for safety.",
host
));
}
let artifact_path = args.artifact;
let config = DashboardServerConfig {
store_root: root,
title: args.title,
preview_chars: args.preview_chars,
artifact_path,
host,
port: args.port,
};
if !args.no_open {
let url = format!("http://{}:{}", host, args.port);
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open").arg(&url).spawn();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
}
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("Failed to create tokio runtime for dashboard server")?;
runtime.block_on(dashboard_server::run_dashboard_server(config))
}
struct DashboardRunArgs {
store_root: Option<PathBuf>,
output: PathBuf,
title: String,
preview_chars: usize,
}
fn run_dashboard(args: DashboardRunArgs) -> Result<()> {
let root = if let Some(path) = args.store_root {
path
} else {
store::store_base_dir()?
};
let config = DashboardConfig {
store_root: root.clone(),
title: args.title,
preview_chars: args.preview_chars,
};
let artifact = dashboard::build_dashboard(&config)?;
let mut output_path = aicx::sanitize::validate_write_path(&args.output)?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create output directory: {}", parent.display()))?;
}
output_path = aicx::sanitize::validate_write_path(&output_path)?;
fs::write(&output_path, artifact.html)
.with_context(|| format!("Failed to write dashboard: {}", output_path.display()))?;
eprintln!("✓ Dashboard generated");
eprintln!(" Output: {}", output_path.display());
eprintln!(" Store: {}", root.display());
eprintln!(
" Stats: {} projects, {} days, {} files, {} agents",
artifact.stats.total_projects,
artifact.stats.total_days,
artifact.stats.total_files,
artifact.stats.agents_detected
);
eprintln!(" Backend: {}", artifact.stats.search_backend);
eprintln!(
" Estimated timeline entries: {}",
artifact.stats.total_entries_estimate
);
if !artifact.assumptions.is_empty() {
eprintln!(" Assumptions:");
for assumption in artifact.assumptions.iter().take(8) {
eprintln!(" - {}", assumption);
}
}
println!("{}", output_path.display());
Ok(())
}
struct ReportsExtractorRunArgs {
artifacts_root: Option<PathBuf>,
org: String,
repo: Option<String>,
workflow: Option<String>,
date_from: Option<String>,
date_to: Option<String>,
output: PathBuf,
bundle_output: Option<PathBuf>,
title: String,
preview_chars: usize,
}
fn run_reports_extractor(args: ReportsExtractorRunArgs) -> Result<()> {
let artifacts_root = if let Some(path) = args.artifacts_root {
path
} else {
default_vibecrafted_artifacts_root()?
};
let repo = if let Some(repo) = args.repo {
repo
} else {
infer_repo_name_from_cwd()?
};
let date_from = parse_cli_date(args.date_from.as_deref(), "--date-from")?;
let date_to = parse_cli_date(args.date_to.as_deref(), "--date-to")?;
let bundle_output = args
.bundle_output
.clone()
.unwrap_or_else(|| default_reports_bundle_path(&args.output));
let config = ReportsExtractorConfig {
artifacts_root: artifacts_root.clone(),
org: args.org,
repo: repo.clone(),
date_from,
date_to,
workflow: args.workflow,
title: args.title,
preview_chars: args.preview_chars,
};
let artifact = reports_extractor::build_reports_explorer(&config)?;
write_text_output(&args.output, &artifact.html, "report explorer HTML")?;
write_text_output(
&bundle_output,
&artifact.bundle_json,
"report explorer JSON bundle",
)?;
eprintln!("✓ Vibecrafted reports extracted");
eprintln!(" Repo: {}/{}", config.org, repo);
eprintln!(" Artifacts: {}", artifacts_root.display());
eprintln!(" HTML: {}", args.output.display());
eprintln!(" Bundle: {}", bundle_output.display());
eprintln!(
" Stats: {} records, {} completed, {} incomplete, {} workflows",
artifact.stats.total_records,
artifact.stats.completed_records,
artifact.stats.incomplete_records,
artifact.stats.total_workflows
);
println!("{}", args.output.display());
Ok(())
}
fn default_vibecrafted_artifacts_root() -> Result<PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
Ok(home.join(".vibecrafted").join("artifacts"))
}
fn default_reports_bundle_path(output: &Path) -> PathBuf {
let parent = output.parent().unwrap_or_else(|| Path::new("."));
let stem = output
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("aicx-reports");
parent.join(format!("{stem}.bundle.json"))
}
fn infer_repo_name_from_cwd() -> Result<String> {
let cwd = std::env::current_dir().context("Cannot determine current directory")?;
let mut probe = cwd.as_path();
loop {
if probe.join(".git").exists() {
let repo = probe
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("Could not infer --repo from git root"))?;
return Ok(repo.to_string());
}
let Some(parent) = probe.parent() else {
break;
};
probe = parent;
}
let repo = cwd
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("Could not infer --repo from the current directory"))?;
Ok(repo.to_string())
}
fn parse_cli_date(value: Option<&str>, flag_name: &str) -> Result<Option<NaiveDate>> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let formats = ["%Y-%m-%d", "%Y_%m%d"];
for format in formats {
if let Ok(date) = NaiveDate::parse_from_str(value, format) {
return Ok(Some(date));
}
}
Err(anyhow::anyhow!(
"Invalid {} value '{}'. Use YYYY-MM-DD or YYYY_MMDD.",
flag_name,
value
))
}
fn write_text_output(path: &Path, content: &str, label: &str) -> Result<()> {
let mut validated = aicx::sanitize::validate_write_path(path)?;
if let Some(parent) = validated.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create output directory: {}", parent.display()))?;
}
validated = aicx::sanitize::validate_write_path(&validated)?;
fs::write(&validated, content)
.with_context(|| format!("Failed to write {}: {}", label, validated.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use filetime::{FileTime, set_file_mtime};
use std::fs;
fn unique_test_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"aicx-main-{name}-{}-{}",
std::process::id(),
Utc::now().timestamp_nanos_opt().unwrap_or_default()
))
}
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn set_mtime(path: &Path, unix_seconds: i64) {
set_file_mtime(path, FileTime::from_unix_time(unix_seconds, 0)).unwrap();
}
#[test]
fn render_memex_progress_formats_live_stages() {
assert_eq!(
render_memex_progress(&SyncProgress {
phase: SyncProgressPhase::Discovering,
done: 12,
total: 48,
detail: String::new(),
}),
" Memex scan... 12/48"
);
assert_eq!(
render_memex_progress(&SyncProgress {
phase: SyncProgressPhase::Embedding,
done: 64,
total: 256,
detail: String::new(),
}),
" Memex embed... 64/256"
);
assert_eq!(
render_memex_progress(&SyncProgress {
phase: SyncProgressPhase::Writing,
done: 128,
total: 256,
detail: String::new(),
}),
" Memex index... 128/256"
);
}
#[test]
fn render_memex_progress_passes_completed_detail_through() {
assert_eq!(
render_memex_progress(&SyncProgress {
phase: SyncProgressPhase::Completed,
done: 0,
total: 0,
detail: "Completed: 10 materialized, 2 skipped, 3 ignored".to_string(),
}),
" Completed: 10 materialized, 2 skipped, 3 ignored"
);
}
#[test]
fn claude_defaults_to_silent_stdout() {
let cli = Cli::try_parse_from(["aicx", "claude"]).expect("claude command should parse");
match cli.command {
Some(Commands::Claude { emit, .. }) => {
assert!(matches!(emit, StdoutEmit::None));
}
_ => panic!("expected claude command"),
}
}
#[test]
fn codex_defaults_to_silent_stdout() {
let cli = Cli::try_parse_from(["aicx", "codex"]).expect("codex command should parse");
match cli.command {
Some(Commands::Codex { emit, .. }) => {
assert!(matches!(emit, StdoutEmit::None));
}
_ => panic!("expected codex command"),
}
}
#[test]
fn all_defaults_to_silent_stdout() {
let cli = Cli::try_parse_from(["aicx", "all"]).expect("all command should parse");
match cli.command {
Some(Commands::All { emit, .. }) => {
assert!(matches!(emit, StdoutEmit::None));
}
_ => panic!("expected all command"),
}
}
#[test]
fn store_defaults_to_silent_stdout() {
let cli = Cli::try_parse_from(["aicx", "store"]).expect("store command should parse");
match cli.command {
Some(Commands::Store { emit, .. }) => {
assert!(matches!(emit, StdoutEmit::None));
}
other => panic!("expected store command, got {:?}", other.map(|_| "other")),
}
}
#[test]
fn store_accepts_explicit_paths_emit() {
let cli = Cli::try_parse_from(["aicx", "store", "--emit", "paths"])
.expect("store command with explicit emit should parse");
match cli.command {
Some(Commands::Store { emit, .. }) => {
assert!(matches!(emit, StdoutEmit::Paths));
}
other => panic!("expected store command, got {:?}", other.map(|_| "other")),
}
}
#[test]
fn refs_default_to_summary_stdout() {
let cli = Cli::try_parse_from(["aicx", "refs"]).expect("refs command should parse");
match cli.command {
Some(Commands::Refs { emit, .. }) => {
assert!(matches!(emit, RefsEmit::Summary));
}
_ => panic!("expected refs command"),
}
}
#[test]
fn refs_accept_explicit_paths_emit() {
let cli = Cli::try_parse_from(["aicx", "refs", "--emit", "paths"])
.expect("refs command with explicit emit should parse");
match cli.command {
Some(Commands::Refs { emit, .. }) => {
assert!(matches!(emit, RefsEmit::Paths));
}
_ => panic!("expected refs command"),
}
}
#[test]
fn search_accepts_score_and_json_flags() {
let cli = Cli::try_parse_from(["aicx", "search", "dashboard", "--score", "60", "--json"])
.expect("search command with score/json should parse");
match cli.command {
Some(Commands::Search { filters, json, .. }) => {
assert_eq!(filters.score, Some(60));
assert!(json);
}
_ => panic!("expected search command"),
}
}
#[test]
fn search_accepts_frame_kind_filter() {
let cli = Cli::try_parse_from([
"aicx",
"search",
"dashboard",
"--frame-kind",
"internal_thought",
])
.expect("search command with frame-kind should parse");
match cli.command {
Some(Commands::Search { filters, .. }) => {
assert_eq!(
filters.frame_kind,
Some(aicx::types::FrameKind::InternalThought)
);
}
_ => panic!("expected search command"),
}
}
#[test]
fn steer_accepts_frame_kind_filter() {
let cli = Cli::try_parse_from(["aicx", "steer", "--frame-kind", "user_msg"])
.expect("steer command with frame-kind should parse");
match cli.command {
Some(Commands::Steer { filters, .. }) => {
assert_eq!(filters.frame_kind, Some(aicx::types::FrameKind::UserMsg));
}
_ => panic!("expected steer command"),
}
}
#[test]
fn intents_accepts_frame_kind_filter() {
let cli = Cli::try_parse_from([
"aicx",
"intents",
"--project",
"ai-contexters",
"--frame-kind",
"tool_call",
])
.expect("intents command with frame-kind should parse");
match cli.command {
Some(Commands::Intents { filters, .. }) => {
assert_eq!(filters.frame_kind, Some(aicx::types::FrameKind::ToolCall));
}
_ => panic!("expected intents command"),
}
}
#[test]
fn rank_subcommand_is_rejected() {
let err = Cli::try_parse_from(["aicx", "rank", "-p", "foo"])
.expect_err("rank subcommand should be rejected");
let rendered = err.to_string();
assert!(rendered.contains("unrecognized subcommand"));
assert!(rendered.contains("rank"));
}
#[test]
fn top_level_help_hides_retired_init_from_primary_surface() {
let mut cmd = Cli::command();
let rendered = cmd.render_help().to_string();
assert!(!rendered.contains("\n init "));
assert!(!rendered.contains("Retired compatibility shim"));
assert!(!rendered.contains("Initialize repo context and run an agent"));
}
#[test]
fn top_level_help_does_not_advertise_dead_root_flags() {
let mut cmd = Cli::command();
let rendered = cmd.render_long_help().to_string();
assert!(!rendered.contains("used if no subcommand is provided"));
assert!(!rendered.contains("Project filter (used if no subcommand is provided)"));
assert!(!rendered.contains("Hours to look back (used if no subcommand is provided)"));
}
#[test]
fn top_level_help_uses_semantic_index_language() {
let mut cmd = Cli::command();
let rendered = cmd.render_long_help().to_string();
assert!(rendered.contains("Layer 2 (optional semantic index)"));
assert!(!rendered.contains("retrieval kernel"));
}
#[test]
fn init_help_explains_retirement_and_hides_legacy_flags() {
let mut cmd = Cli::command();
let init = cmd
.find_subcommand_mut("init")
.expect("init subcommand should exist for compatibility");
let rendered = init.render_long_help().to_string();
assert!(rendered.contains("aicx init has been retired."));
assert!(rendered.contains("/vc-init inside Claude Code."));
assert!(!rendered.contains("--agent"));
assert!(!rendered.contains("--action"));
assert!(!rendered.contains("--no-run"));
assert!(!rendered.contains("Initialize repo context and run an agent"));
}
#[test]
fn serve_accepts_http_and_legacy_sse_transport_names() {
let http = Cli::try_parse_from(["aicx", "serve", "--transport", "http"])
.expect("http transport should parse");
let legacy = Cli::try_parse_from(["aicx", "serve", "--transport", "sse"])
.expect("legacy sse alias should parse");
match http.command {
Some(Commands::Serve { transport, .. }) => {
assert_eq!(transport, McpTransport::Http);
}
_ => panic!("expected serve command for http transport"),
}
match legacy.command {
Some(Commands::Serve { transport, .. }) => {
assert_eq!(transport, McpTransport::Http);
}
_ => panic!("expected serve command for legacy sse transport"),
}
}
#[test]
fn serve_help_prefers_http_name_and_explains_search_fallback() {
let mut cmd = Cli::command();
let serve = cmd
.find_subcommand_mut("serve")
.expect("serve subcommand should exist");
let rendered = serve.render_long_help().to_string();
assert!(rendered.contains("Transport: stdio (default) or http."));
assert!(!rendered.contains("Transport: stdio (default) or sse"));
assert!(rendered.contains("falls back to canonical-store fuzzy search"));
assert!(!rendered.contains("embedding mode"));
}
#[test]
fn search_help_explains_semantic_path_without_embedding_jargon() {
let mut cmd = Cli::command();
let search = cmd
.find_subcommand_mut("search")
.expect("search subcommand should exist");
let rendered = search.render_long_help().to_string();
assert!(rendered.contains("semantic retrieval through MCP tools"));
assert!(!rendered.contains("embedding-aware"));
}
#[test]
fn steer_help_keeps_examples_split() {
let mut cmd = Cli::command();
let steer = cmd
.find_subcommand_mut("steer")
.expect("steer subcommand should exist");
let rendered = steer.render_long_help().to_string();
assert!(rendered.contains("aicx steer --run-id mrbl-001"));
assert!(
rendered
.contains("aicx steer --project ai-contexters --kind reports --date 2026-03-28")
);
assert!(!rendered.contains("mrbl-001 aicx steer"));
assert!(!rendered.contains("--no-redact-secrets"));
assert!(!rendered.contains("--hours <HOURS>"));
}
#[test]
fn dashboard_serve_help_hides_legacy_artifact_flag() {
let mut cmd = Cli::command();
let dashboard_serve = cmd
.find_subcommand_mut("dashboard-serve")
.expect("dashboard-serve subcommand should exist");
let rendered = dashboard_serve.render_long_help().to_string();
assert!(!rendered.contains("--artifact"));
assert!(rendered.contains("Run a local dashboard server"));
}
#[test]
fn reports_extractor_help_describes_embedded_html_and_bundle() {
let mut cmd = Cli::command();
let reports = cmd
.find_subcommand_mut("reports-extractor")
.expect("reports-extractor subcommand should exist");
let rendered = reports.render_long_help().to_string();
assert!(rendered.contains("standalone HTML explorer"));
assert!(rendered.contains("~/.vibecrafted/artifacts"));
assert!(rendered.contains("--bundle-output"));
assert!(rendered.contains("--date-from"));
assert!(rendered.contains("--date-to"));
assert!(!rendered.contains("canonical store"));
}
#[test]
fn root_only_shortcuts_without_subcommand_are_rejected() {
let err = Cli::try_parse_from(["aicx", "-H", "24"])
.expect_err("root-only shortcut mode should not parse");
let rendered = err.to_string();
assert!(rendered.contains("unexpected argument '-H'"));
}
#[test]
fn non_corpus_commands_reject_redaction_flags() {
let err = Cli::try_parse_from(["aicx", "search", "dashboard", "--no-redact-secrets"])
.expect_err("search should not accept corpus-building-only redaction flags");
let rendered = err.to_string();
assert!(rendered.contains("--no-redact-secrets"));
}
#[test]
fn corpus_builders_accept_redaction_flags() {
let cli = Cli::try_parse_from(["aicx", "claude", "--no-redact-secrets"])
.expect("claude should accept corpus-building redaction flags");
match cli.command {
Some(Commands::Claude { redaction, .. }) => {
assert!(!redaction.redact_secrets);
}
_ => panic!("expected claude command"),
}
}
#[test]
fn extract_accepts_gemini_antigravity_format() {
let cli = Cli::try_parse_from([
"aicx",
"extract",
"--format",
"gemini-antigravity",
"/tmp/brain/uuid",
"-o",
"/tmp/report.md",
])
.expect("extract command with gemini-antigravity should parse");
match cli.command {
Some(Commands::Extract { format, .. }) => {
assert!(matches!(format, ExtractInputFormat::GeminiAntigravity));
}
_ => panic!("expected extract command"),
}
}
#[test]
fn extract_accepts_junie_format() {
let cli = Cli::try_parse_from([
"aicx",
"extract",
"--format",
"junie",
"/tmp/session/events.jsonl",
"-o",
"/tmp/report.md",
])
.expect("extract command with junie should parse");
match cli.command {
Some(Commands::Extract { format, .. }) => {
assert!(matches!(format, ExtractInputFormat::Junie));
}
_ => panic!("expected extract command"),
}
}
#[test]
fn migrate_accepts_custom_roots() {
let cli = Cli::try_parse_from([
"aicx",
"migrate",
"--dry-run",
"--legacy-root",
"/tmp/legacy",
"--store-root",
"/tmp/aicx",
])
.expect("migrate command with explicit roots should parse");
match cli.command {
Some(Commands::Migrate {
dry_run,
legacy_root,
store_root,
}) => {
assert!(dry_run);
assert_eq!(legacy_root, Some(PathBuf::from("/tmp/legacy")));
assert_eq!(store_root, Some(PathBuf::from("/tmp/aicx")));
}
_ => panic!("expected migrate command"),
}
}
#[test]
fn run_extract_file_uses_repo_identity_over_file_provenance() {
let root = unique_test_dir("extract-repo-identity");
let brain = root.join("brain").join("conv-9");
let step_output = brain
.join(".system_generated")
.join("steps")
.join("001")
.join("output.txt");
let report = root.join("report.md");
write_file(
&step_output,
r#"{"project":"/Users/tester/workspace/RepoDelta","decision":"Group by repo identity."}"#,
);
set_mtime(&step_output, 1_706_745_900);
run_extract_file(
ExtractInputFormat::GeminiAntigravity,
None,
brain,
report.clone(),
true,
0,
false,
false,
)
.unwrap();
let output = fs::read_to_string(&report).unwrap();
assert!(output.contains("| Filter | RepoDelta |"));
assert!(output.contains("Gemini Antigravity recovery report"));
assert!(!output.contains("| Filter | file:"));
let _ = fs::remove_dir_all(&root);
}
}