use anyhow::{bail, Context, Result};
use clap::Parser;
use precc_core::{db, gdb, metrics, mining, rewrites, skills};
mod embedded_skills;
#[derive(Parser)]
#[command(name = "precc", about = "Predictive Error Correction for Claude Code")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand)]
enum Commands {
Ingest {
file: Option<String>,
#[arg(long)]
all: bool,
},
Skills {
#[command(subcommand)]
action: Option<SkillsAction>,
},
Debug {
binary: String,
args: Vec<String>,
},
Report,
Savings,
Init,
}
#[derive(clap::Subcommand)]
enum SkillsAction {
List,
Show { name: String },
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::Init) => cmd_init(),
Some(Commands::Ingest { file, all }) => cmd_ingest(file, all),
Some(Commands::Skills { action }) => cmd_skills(action),
Some(Commands::Debug { binary, args }) => cmd_debug(binary, args),
Some(Commands::Report) => cmd_report(),
Some(Commands::Savings) => cmd_savings(),
None => {
println!("precc — Predictive Error Correction for Claude Code");
println!();
println!("Run `precc --help` for available commands.");
Ok(())
}
}
}
fn cmd_init() -> Result<()> {
let data_dir = db::data_dir()?;
println!("Initializing databases in {}...", data_dir.display());
db::open_heuristics(&data_dir).context("failed to initialize heuristics.db")?;
println!(" heuristics.db — OK");
db::open_history(&data_dir).context("failed to initialize history.db")?;
println!(" history.db — OK");
db::open_metrics(&data_dir).context("failed to initialize metrics.db")?;
println!(" metrics.db — OK");
let skills_dir = data_dir.join("skills/builtin");
let written = embedded_skills::materialize(&skills_dir)
.context("failed to write embedded builtin skills")?;
if written > 0 {
println!(
" Wrote {} builtin skill(s) to {}",
written,
skills_dir.display()
);
}
let heuristics_conn = db::open_heuristics(&data_dir)?;
let loaded = skills::load_builtin_skills(&heuristics_conn, &skills_dir)?;
if loaded > 0 {
println!(" Loaded {loaded} builtin skill(s) into heuristics.db");
} else {
println!(" Builtin skills already in heuristics.db");
}
println!();
println!("Hook setup:");
println!(" Add to ~/.claude/settings.json:");
println!();
println!(" {{");
println!(" \"hooks\": {{");
println!(" \"PreToolUse\": [");
println!(" {{");
println!(" \"matcher\": \"Bash\",");
println!(" \"hooks\": [");
println!(" {{");
println!(" \"type\": \"command\",");
if let Ok(exe) = std::env::current_exe() {
let hook_path = exe
.parent()
.map(|p| p.join("precc-hook"))
.unwrap_or_else(|| std::path::PathBuf::from("precc-hook"));
println!(" \"command\": \"{}\"", hook_path.display());
} else {
println!(" \"command\": \"precc-hook\"");
}
println!(" }}");
println!(" ]");
println!(" }}");
println!(" ]");
println!(" }}");
println!(" }}");
println!();
println!("Init complete.");
Ok(())
}
fn cmd_ingest(file: Option<String>, all: bool) -> Result<()> {
let data_dir = db::data_dir()?;
let conn = db::open_history(&data_dir)?;
if let Some(path) = file {
let path = std::path::PathBuf::from(&path);
if !path.exists() {
bail!("session file not found: {}", path.display());
}
println!("Mining {}...", path.display());
match mining::mine_session(&conn, &path)? {
mining::MineResult::Skipped => println!(" Session already mined or has no events"),
mining::MineResult::Processed { pairs, events } => {
println!(" Found {} event(s), {} failure-fix pair(s)", events, pairs);
}
}
} else if all {
println!("Scanning for unmined sessions...");
let summary = mining::mine_all(&conn)?;
println!();
println!("Mining summary:");
println!(" Sessions processed: {}", summary.sessions_processed);
println!(" Sessions skipped: {}", summary.sessions_skipped);
println!(" Events found: {}", summary.events_found);
println!(" Pairs found: {}", summary.pairs_found);
} else {
let files = mining::find_session_files()?;
if files.is_empty() {
println!("No session files found in ~/.claude/projects/");
println!("Run Claude Code to generate session logs first.");
} else {
println!("Found {} session file(s):", files.len());
let mut mined = 0;
let mut unmined = 0;
for file in &files {
let session_id = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let already: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM sessions WHERE session_id = ?1",
[session_id],
|r| r.get(0),
)
.unwrap_or(false);
if already {
mined += 1;
} else {
unmined += 1;
}
}
println!(" {} already mined, {} new", mined, unmined);
if unmined > 0 {
println!();
println!("Run `precc ingest --all` to mine new sessions.");
}
}
}
Ok(())
}
fn cmd_skills(action: Option<SkillsAction>) -> Result<()> {
let data_dir = db::data_dir()?;
let conn = db::open_heuristics(&data_dir)?;
let _ = skills::replay_activations(&conn);
match action {
Some(SkillsAction::List) | None => cmd_skills_list(&conn),
Some(SkillsAction::Show { name }) => cmd_skills_show(&conn, &name),
}
}
fn cmd_skills_list(conn: &rusqlite::Connection) -> Result<()> {
let mut stmt = conn.prepare(
"SELECT s.id, s.name, s.description, s.source, s.enabled, s.priority,
COALESCE(st.activated, 0), COALESCE(st.succeeded, 0), COALESCE(st.failed, 0),
st.last_used
FROM skills s
LEFT JOIN skill_stats st ON st.skill_id = s.id
ORDER BY s.priority ASC, s.name ASC",
)?;
let rows: Vec<SkillRow> = stmt
.query_map([], |row: &rusqlite::Row| {
Ok(SkillRow {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
source: row.get(3)?,
enabled: row.get(4)?,
priority: row.get(5)?,
activated: row.get(6)?,
succeeded: row.get(7)?,
failed: row.get(8)?,
last_used: row.get(9)?,
})
})?
.filter_map(Result::ok)
.collect();
if rows.is_empty() {
println!("No skills registered.");
println!(
"Run `precc init` to load builtin skills, or `precc ingest --all` to mine patterns."
);
return Ok(());
}
println!(
"{:<4} {:<25} {:<8} {:<8} {:<6} {:<6} {:<6}",
"ID", "Name", "Source", "Enabled", "Pri", "Acts", "Succ"
);
println!("{}", "-".repeat(70));
for row in &rows {
println!(
"{:<4} {:<25} {:<8} {:<8} {:<6} {:<6} {:<6}",
row.id,
truncate_str(&row.name, 24),
truncate_str(&row.source, 7),
if row.enabled { "yes" } else { "no" },
row.priority,
row.activated,
row.succeeded,
);
}
println!();
println!("{} skill(s) total", rows.len());
Ok(())
}
struct SkillRow {
id: i64,
name: String,
description: String,
source: String,
enabled: bool,
priority: i64,
activated: i64,
succeeded: i64,
failed: i64,
last_used: Option<String>,
}
fn cmd_skills_show(conn: &rusqlite::Connection, name: &str) -> Result<()> {
let row: Option<SkillRow> = conn
.query_row(
"SELECT s.id, s.name, s.description, s.source, s.enabled, s.priority,
COALESCE(st.activated, 0), COALESCE(st.succeeded, 0), COALESCE(st.failed, 0),
st.last_used
FROM skills s
LEFT JOIN skill_stats st ON st.skill_id = s.id
WHERE s.name = ?1",
[name],
|row: &rusqlite::Row| {
Ok(SkillRow {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
source: row.get(3)?,
enabled: row.get(4)?,
priority: row.get(5)?,
activated: row.get(6)?,
succeeded: row.get(7)?,
failed: row.get(8)?,
last_used: row.get(9)?,
})
},
)
.ok();
let row = match row {
Some(r) => r,
None => {
bail!("skill '{}' not found", name);
}
};
println!("Skill: {}", row.name);
println!(" Description: {}", row.description);
println!(" Source: {}", row.source);
println!(" Priority: {}", row.priority);
println!(" Enabled: {}", if row.enabled { "yes" } else { "no" });
println!();
let mut stmt = conn
.prepare("SELECT trigger_type, pattern, weight FROM skill_triggers WHERE skill_id = ?1")?;
let triggers: Vec<(String, String, f64)> = stmt
.query_map([row.id], |r: &rusqlite::Row| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})?
.filter_map(Result::ok)
.collect();
if !triggers.is_empty() {
println!(" Triggers:");
for (ttype, pattern, weight) in &triggers {
println!(" [{ttype}] {pattern} (weight={weight:.1})");
}
println!();
}
let mut stmt = conn.prepare(
"SELECT action_type, template, confidence FROM skill_actions WHERE skill_id = ?1",
)?;
let actions: Vec<(String, String, f64)> = stmt
.query_map([row.id], |r: &rusqlite::Row| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})?
.filter_map(Result::ok)
.collect();
if !actions.is_empty() {
println!(" Actions:");
for (atype, template, conf) in &actions {
println!(" [{atype}] {template} (confidence={conf:.1})");
}
println!();
}
println!(" Stats:");
println!(" Activated: {}", row.activated);
println!(" Succeeded: {}", row.succeeded);
println!(" Failed: {}", row.failed);
if let Some(last) = &row.last_used {
println!(" Last used: {}", last);
}
Ok(())
}
fn cmd_debug(binary: String, args: Vec<String>) -> Result<()> {
if !gdb::gdb_available() {
bail!("GDB is not available. Install GDB first: sudo apt install gdb");
}
let binary_path = std::path::Path::new(&binary);
if !binary_path.exists() {
bail!("binary not found: {}", binary);
}
let gdbinit_content = generate_gdbinit(&binary, &args);
let gdbinit_path = std::env::current_dir()?.join(".gdbinit-precc");
std::fs::write(&gdbinit_path, &gdbinit_content).context("failed to write .gdbinit-precc")?;
println!("Generated {}", gdbinit_path.display());
println!();
println!("GDB commands file created with:");
println!(" - Breakpoints on common error paths (panic, abort)");
println!(" - Backtrace on stop");
println!(" - Auto-display of local variables");
println!();
let mut gdb_args = vec![
"-x".to_string(),
gdbinit_path.to_string_lossy().to_string(),
"--args".to_string(),
binary.clone(),
];
gdb_args.extend(args.iter().cloned());
println!("Running: gdb {}", gdb_args.join(" "));
println!();
let status = std::process::Command::new("gdb")
.args(&gdb_args)
.status()
.context("failed to launch GDB")?;
let _ = std::fs::remove_file(&gdbinit_path);
if !status.success() {
bail!("GDB exited with status {}", status);
}
Ok(())
}
fn generate_gdbinit(binary: &str, _args: &[String]) -> String {
let is_rust = binary.contains("target/debug")
|| binary.contains("target/release")
|| std::path::Path::new("Cargo.toml").exists();
let mut script = String::new();
script.push_str("# Generated by precc debug\n");
script.push_str("set pagination off\n");
script.push_str("set print pretty on\n");
script.push_str("set print array on\n");
script.push_str("set confirm off\n");
script.push('\n');
if is_rust {
script.push_str("# Rust panic/abort breakpoints\n");
script.push_str("break rust_panic\n");
script.push_str("break rust_begin_unwind\n");
script.push_str("break std::panicking::begin_panic\n");
script.push_str("break std::panicking::rust_panic_with_hook\n");
} else {
script.push_str("# Error breakpoints\n");
script.push_str("break abort\n");
script.push_str("break exit\n");
}
script.push('\n');
script.push_str("# Show backtrace on stop\n");
script.push_str("define hook-stop\n");
script.push_str(" bt 10\n");
script.push_str(" info locals\n");
script.push_str("end\n");
script.push('\n');
script.push_str("run\n");
script
}
fn cmd_report() -> Result<()> {
let data_dir = db::data_dir()?;
println!("PRECC Analytics Report");
println!("======================");
println!();
if let Ok(metrics_conn) = db::open_metrics(&data_dir) {
report_section(
&metrics_conn,
"Hook Latency (ms)",
metrics::MetricType::HookLatency,
)?;
report_section(
&metrics_conn,
"Skill Activations",
metrics::MetricType::SkillActivation,
)?;
report_section(&metrics_conn, "CD Prepends", metrics::MetricType::CdPrepend)?;
report_section(
&metrics_conn,
"GDB Suggestions",
metrics::MetricType::GdbSuggestion,
)?;
report_section(
&metrics_conn,
"RTK Rewrites",
metrics::MetricType::CompressorWrap,
)?;
} else {
println!(" (metrics.db not available)");
println!();
}
if let Ok(heuristics_conn) = db::open_heuristics(&data_dir) {
let _ = skills::replay_activations(&heuristics_conn);
let skill_count: i64 = heuristics_conn
.query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0))
.unwrap_or(0);
let enabled_count: i64 = heuristics_conn
.query_row("SELECT COUNT(*) FROM skills WHERE enabled = 1", [], |r| {
r.get(0)
})
.unwrap_or(0);
let total_activations: i64 = heuristics_conn
.query_row(
"SELECT COALESCE(SUM(activated), 0) FROM skill_stats",
[],
|r| r.get(0),
)
.unwrap_or(0);
println!("Skills");
println!("------");
println!(" Total: {}", skill_count);
println!(" Enabled: {}", enabled_count);
println!(" Activations: {}", total_activations);
println!();
let mut stmt = heuristics_conn.prepare(
"SELECT s.name, st.activated FROM skills s
JOIN skill_stats st ON st.skill_id = s.id
WHERE st.activated > 0
ORDER BY st.activated DESC LIMIT 5",
)?;
let top_skills: Vec<(String, i64)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
if !top_skills.is_empty() {
println!(" Top skills:");
for (name, count) in &top_skills {
println!(" {name:<25} {count} activations");
}
println!();
}
}
if let Ok(history_conn) = db::open_history(&data_dir) {
let session_count: i64 = history_conn
.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))
.unwrap_or(0);
let pair_count: i64 = history_conn
.query_row("SELECT COUNT(*) FROM failure_fix_pairs", [], |r| r.get(0))
.unwrap_or(0);
let top_pair_count: i64 = history_conn
.query_row(
"SELECT COALESCE(MAX(occurrences), 0) FROM failure_fix_pairs",
[],
|r| r.get(0),
)
.unwrap_or(0);
println!("History Mining");
println!("--------------");
println!(" Sessions mined: {}", session_count);
println!(" Failure-fix pairs: {}", pair_count);
println!(" Max occurrences: {}", top_pair_count);
println!();
if pair_count > 0 {
let mut stmt = history_conn.prepare(
"SELECT failure_command, fix_command, occurrences, project_type
FROM failure_fix_pairs
ORDER BY occurrences DESC LIMIT 5",
)?;
let top_patterns: Vec<(String, String, i64, Option<String>)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
.filter_map(|r| r.ok())
.collect();
if !top_patterns.is_empty() {
println!(" Top failure patterns:");
for (fail_cmd, fix_cmd, occ, proj) in &top_patterns {
let proj_tag = proj.as_deref().unwrap_or("?");
println!(
" [{proj_tag}] {} -> {} ({occ}x)",
truncate_str(fail_cmd, 30),
truncate_str(fix_cmd, 30)
);
}
println!();
}
}
}
println!("Database Sizes");
println!("--------------");
for name in &["heuristics.db", "history.db", "metrics.db"] {
let path = data_dir.join(name);
if path.exists() {
if let Ok(meta) = std::fs::metadata(&path) {
let size_kb = meta.len() as f64 / 1024.0;
println!(" {name:<16} {size_kb:>8.1} KB");
}
} else {
println!(" {name:<16} (not created)");
}
}
Ok(())
}
fn report_section(
conn: &rusqlite::Connection,
label: &str,
metric_type: metrics::MetricType,
) -> Result<()> {
match metrics::summary(conn, metric_type)? {
Some(s) => {
println!("{label}");
println!("{}", "-".repeat(label.len()));
println!(" Count: {}", s.count);
println!(" Avg: {:.2}", s.avg);
println!(" Min: {:.2}", s.min);
println!(" Max: {:.2}", s.max);
println!(" Total: {:.2}", s.total);
println!();
}
None => {
println!("{label}: no data");
println!();
}
}
Ok(())
}
struct TokenModel {
per_wrap_avg: f64,
precc_per_cd_prepend: f64,
precc_per_skill_activation: f64,
precc_per_mined_occurrence: f64,
}
impl Default for TokenModel {
fn default() -> Self {
let avg = weighted_avg_tokens();
Self {
per_wrap_avg: avg,
precc_per_cd_prepend: 300.0,
precc_per_skill_activation: 250.0,
precc_per_mined_occurrence: 200.0,
}
}
}
fn weighted_avg_tokens() -> f64 {
let samples: &[(&str, u32)] = &[
("cargo build", 420),
("cargo test", 420),
("cargo clippy", 420),
("cargo check", 300),
("cargo run", 200),
("cargo fmt", 60),
("git status", 160),
("git diff", 160),
("git log", 160),
("git add", 60),
("git commit", 60),
("git push", 60),
("git pull", 60),
("git branch", 60),
("git fetch", 60),
("git stash", 60),
("git show", 60),
("gh pr", 120),
("gh issue", 120),
("gh run", 120),
("npm test", 420),
("npm run", 180),
("npm install", 150),
("yarn test", 420),
("yarn add", 100),
("pytest", 380),
("python -m pytest", 380),
("pip install", 150),
("go test", 380),
("go build", 300),
("cat", 50),
("ls", 40),
("rg", 90),
("grep", 90),
("pnpm test", 180),
("vitest", 180),
("tsc", 180),
("eslint", 180),
("prettier", 180),
("playwright", 180),
("prisma", 180),
("docker build", 500),
("docker run", 200),
("docker ps", 150),
("docker images", 150),
("docker logs", 150),
("kubectl describe", 300),
("kubectl apply", 150),
("kubectl get", 180),
("kubectl logs", 180),
("curl", 200),
("pnpm list", 100),
("pnpm ls", 100),
("pnpm outdated", 100),
("make", 400),
];
let total: u64 = samples.iter().map(|(_, t)| *t as u64).sum();
let count = samples.len() as f64;
let _ = rewrites::tokens_saved("cargo build");
total as f64 / count
}
fn cmd_savings() -> Result<()> {
let data_dir = db::data_dir()?;
let model = TokenModel::default();
println!("PRECC Token Savings Estimate");
println!("============================");
println!();
let wrap_count: i64 = if let Ok(conn) = db::open_metrics(&data_dir) {
metrics::summary(&conn, metrics::MetricType::CompressorWrap)?
.map(|s| s.count as i64)
.unwrap_or(0)
} else {
0
};
let wrap_tokens = wrap_count as f64 * model.per_wrap_avg;
println!("Compressor wraps");
println!("----------------");
println!(" Wraps recorded : {:>8}", wrap_count);
println!(
" Est. tokens/wrap : {:>8.0} (per-category weighted avg)",
model.per_wrap_avg
);
println!(" Wrap gain (tokens) : {:>8.0}", wrap_tokens);
println!();
let cd_count: i64 = if let Ok(conn) = db::open_metrics(&data_dir) {
metrics::summary(&conn, metrics::MetricType::CdPrepend)?
.map(|s| s.count as i64)
.unwrap_or(0)
} else {
0
};
let skill_activations: i64 = if let Ok(conn) = db::open_heuristics(&data_dir) {
let _ = skills::replay_activations(&conn);
conn.query_row(
"SELECT COALESCE(SUM(activated), 0) FROM skill_stats",
[],
|r| r.get(0),
)
.unwrap_or(0)
} else {
0
};
let mined_preventions: i64 = if let Ok(conn) = db::open_history(&data_dir) {
conn.query_row(
"SELECT COALESCE(SUM(occurrences - 1), 0) + COALESCE(SUM(precc_prevented), 0)
FROM failure_fix_pairs
WHERE occurrences > 1 OR precc_prevented > 0",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| {
conn.query_row(
"SELECT COALESCE(SUM(occurrences - 1), 0) FROM failure_fix_pairs WHERE occurrences > 1",
[],
|r| r.get(0),
)
.unwrap_or(0)
})
} else {
0
};
let cd_tokens = cd_count as f64 * model.precc_per_cd_prepend;
let skill_tokens = skill_activations as f64 * model.precc_per_skill_activation;
let mined_tokens = mined_preventions as f64 * model.precc_per_mined_occurrence;
let prevention_tokens = cd_tokens + skill_tokens + mined_tokens;
println!("Error prevention");
println!("----------------");
println!(
" CD prepends (Pillar 1): {:>8} × {:>4.0} tok = {:>8.0} tok",
cd_count, model.precc_per_cd_prepend, cd_tokens
);
println!(
" Skill activations (P4): {:>8} × {:>4.0} tok = {:>8.0} tok",
skill_activations, model.precc_per_skill_activation, skill_tokens
);
println!(
" Mined preventions (P3): {:>8} × {:>4.0} tok = {:>8.0} tok",
mined_preventions, model.precc_per_mined_occurrence, mined_tokens
);
println!();
println!(
" Prevention total : {:>8.0} tokens",
prevention_tokens
);
println!();
let grand_total = wrap_tokens + prevention_tokens;
let prevention_pct = if grand_total > 0.0 {
prevention_tokens / grand_total * 100.0
} else {
0.0
};
println!("Summary");
println!("-------");
println!(" Compressor wraps : {:>8.0} tokens", wrap_tokens);
println!(
" Error prevention : {:>8.0} tokens",
prevention_tokens
);
println!(" Grand total saved : {:>8.0} tokens", grand_total);
if grand_total > 0.0 {
println!(" Prevention share : {:>7.1}%", prevention_pct);
}
println!();
println!("Note: figures are estimates based on conservative medians per event.");
println!(
" Wrap ~{:.0} tok/wrap (weighted avg), CD-miss ~{:.0} tok, skill ~{:.0} tok, pattern ~{:.0} tok.",
model.per_wrap_avg,
model.precc_per_cd_prepend,
model.precc_per_skill_activation,
model.precc_per_mined_occurrence,
);
Ok(())
}
fn truncate_str(s: &str, max_len: usize) -> &str {
if s.len() <= max_len {
s
} else {
&s[..max_len]
}
}