Skip to main content

innate_core/
cli.rs

1//! CLI commands — thin wrapper over KnowledgeBase.
2
3use std::path::PathBuf;
4
5use clap::{Parser, Subcommand};
6use serde_json::json;
7
8pub use crate::backup::BackupCommands;
9pub use crate::daemon::DaemonCommands;
10pub use crate::hook::HookCommands;
11use crate::{AppraiseParams, RecallParams, RecordParams, Situation, APPRAISE_ADVISORY};
12
13fn default_db() -> PathBuf {
14    crate::paths::default_db_path()
15}
16
17#[derive(Parser)]
18#[command(name = "innate", version, about = "Self-growing knowledge layer")]
19pub struct Cli {
20    #[arg(long, global = true, env = "INNATE_DB")]
21    pub db: Option<PathBuf>,
22
23    #[command(subcommand)]
24    pub command: Commands,
25}
26
27#[derive(Subcommand)]
28pub enum Commands {
29    /// Search the knowledge base
30    Recall {
31        query: String,
32        #[arg(long, default_value = "6000")]
33        budget: usize,
34        #[arg(long)]
35        top: Option<usize>,
36        #[arg(long, default_value = "text")]
37        format: String,
38        #[arg(long)]
39        include_sparks: bool,
40        /// Dependency expansion: false (default) | direct | closure
41        #[arg(long, default_value = "false")]
42        expand_deps: String,
43        /// Allow Refiner to trim blocks that don't fit the budget
44        #[arg(long)]
45        allow_trim: bool,
46        /// Refine mode written to usage_trace: off (default) | trim | adapt
47        #[arg(long, default_value = "off")]
48        refine_mode: String,
49        /// Event source written to usage_trace (mcp | sdk | cli | hook | daemon | augmented)
50        #[arg(long, default_value = "cli")]
51        source: String,
52        /// Relevance gate: drop candidates whose fused score is below this value.
53        /// Keeps always-on hooks high-frequency without injecting noise.
54        #[arg(long)]
55        min_score: Option<f64>,
56        /// Session trace: open a trace for later record-correlation but record no
57        /// `selected`/`retrieved` events. For callers (e.g. the daemon) that do
58        /// not inject the recalled knowledge into a model context.
59        #[arg(long)]
60        session: bool,
61    },
62    /// Critic: judge how much footing exists for a candidate in a situation.
63    /// Returns {valence, strength, tier, flagged_points} — never an answer.
64    Appraise {
65        /// Explicit question / instruction (optional).
66        #[arg(long, default_value = "")]
67        query: String,
68        /// Current or last error text.
69        #[arg(long)]
70        last_error: Option<String>,
71        /// Recent actions, comma-separated.
72        #[arg(long)]
73        recent_actions: Option<String>,
74        /// Task stage (e.g. merge, implement, review).
75        #[arg(long)]
76        stage: Option<String>,
77        /// File type / path summary in scope.
78        #[arg(long)]
79        file_context: Option<String>,
80        /// Candidate answer under judgement (folded into resonance, sanitized, never echoed).
81        #[arg(long)]
82        candidate: Option<String>,
83        #[arg(long)]
84        top: Option<usize>,
85        #[arg(long)]
86        min_strength: Option<f64>,
87        #[arg(long, default_value = "cli")]
88        source: String,
89        #[arg(long, default_value = "json")]
90        format: String,
91    },
92    /// Close a trace with outcome
93    Record {
94        trace_id: String,
95        #[arg(long)]
96        query: Option<String>,
97        #[arg(long)]
98        outcome: Option<String>,
99        /// Comma-separated chunk ids. An explicit empty value means "known none".
100        #[arg(long)]
101        used: Option<String>,
102        #[arg(long, default_value = "explicit")]
103        used_attribution: String,
104        /// Treat --used as partial attribution; omitted selected chunks are not penalized.
105        #[arg(long)]
106        used_partial: bool,
107        #[arg(long)]
108        output: Option<String>,
109        #[arg(long)]
110        output_summary: Option<String>,
111        #[arg(long)]
112        nomination: Option<String>,
113        #[arg(long, default_value = "cli")]
114        source: String,
115        /// Explicit feedback: up or down (applied to --used chunks if provided)
116        #[arg(long)]
117        feedback: Option<String>,
118        #[arg(long, default_value = "user")]
119        feedback_kind: String,
120        #[arg(long)]
121        feedback_actor: Option<String>,
122        #[arg(long)]
123        feedback_reason: Option<String>,
124        #[arg(long)]
125        task_state: Option<String>,
126        #[arg(long, default_value = "0")]
127        priority: i64,
128        /// This trace came from an `appraise` whose caution was heeded — the
129        /// action was avoided, so the outcome is counterfactual and must NOT
130        /// count toward the critic's calibration (provenance=counterfactual_censored).
131        #[arg(long)]
132        verdict_heeded: bool,
133    },
134    /// Add a knowledge chunk
135    Add {
136        content: String,
137        #[arg(long, default_value = "note")]
138        kind: String,
139        #[arg(long)]
140        trigger: Option<String>,
141        #[arg(long)]
142        anti_trigger: Option<String>,
143        #[arg(long, default_value = "chat")]
144        source: String,
145        #[arg(long)]
146        skill_name: Option<String>,
147        /// Declare a dependency on another chunk id (repeatable).
148        #[arg(long = "depends-on")]
149        depends_on: Vec<String>,
150        /// Dependency kind for --depends-on: hard (fail-closed) or soft (bonus).
151        #[arg(long, default_value = "hard")]
152        dep_kind: String,
153    },
154    /// Capture a spark (idea)
155    Spark {
156        content: String,
157        #[arg(long)]
158        trigger: Option<String>,
159    },
160    /// Distil logs + curate
161    Evolve {
162        #[arg(long, default_value = "manual")]
163        trigger: String,
164        /// Rebuild embeddings for chunks with embed_version=0 or < meta.embed_version
165        #[arg(long)]
166        rebuild_embeddings: bool,
167    },
168    /// Health check — no arg = library summary; chunk_id or trace_id = detail view
169    Inspect { id: Option<String> },
170    /// Approve a pending chunk
171    Approve { chunk_id: String },
172    /// Archive a chunk
173    Archive {
174        chunk_id: String,
175        #[arg(long, default_value = "stale")]
176        reason: String,
177    },
178    /// Invalidate a chunk
179    Invalidate {
180        chunk_id: String,
181        #[arg(long, default_value = "")]
182        reason: String,
183    },
184    /// Restore an archived chunk
185    Restore { chunk_id: String },
186    /// Mature a spark
187    MatureSpark { spark_id: String, to: String },
188    /// Promote a spark to knowledge
189    PromoteSpark {
190        spark_id: String,
191        #[arg(long, default_value = "note")]
192        to: String,
193    },
194    /// Drop a spark
195    DropSpark {
196        spark_id: String,
197        #[arg(long, default_value = "")]
198        reason: String,
199    },
200    /// Backup the database to Cloudflare R2
201    Backup {
202        #[command(subcommand)]
203        action: BackupCommands,
204    },
205    /// Interactive setup wizard — configure agents to use Innate MCP server
206    Install,
207    /// Remove Innate from all configured agents and PATH
208    Uninstall {
209        /// Skip confirmation prompts
210        #[arg(long, short = 'y')]
211        yes: bool,
212        /// Also delete knowledge data (~/.innate/). Cannot be undone.
213        #[arg(long)]
214        purge_data: bool,
215    },
216    /// Upgrade database schema to current version
217    Migrate,
218    /// Reclaim disk space: checkpoint the WAL and VACUUM the database
219    Vacuum,
220    /// Repair pre-fix trace pollution: drop false daemon `selected` events,
221    /// recompute `selected_count`, and retire orphaned `open` episodic logs.
222    RepairTraces {
223        /// Report what would change without writing.
224        #[arg(long)]
225        dry_run: bool,
226    },
227    /// Upgrade the innate binary to the latest (or specified) release
228    Upgrade {
229        /// Install this specific version, e.g. 0.3.0 or v0.3.0 (default: latest)
230        #[arg(long, value_name = "VERSION")]
231        version: Option<String>,
232        /// Only report whether an upgrade is available; do not install
233        #[arg(long)]
234        check: bool,
235    },
236    /// Daemon control (Linux only)
237    Daemon {
238        #[command(subcommand)]
239        action: DaemonCommands,
240    },
241    /// Start MCP stdio server
242    Mcp,
243    /// Start a local web UI to view and govern the knowledge base
244    Web {
245        /// Address to bind (localhost only by default; exposing beyond is unsafe)
246        #[arg(long, default_value = "127.0.0.1")]
247        bind: String,
248        /// Port to listen on
249        #[arg(long, default_value_t = 8788)]
250        port: u16,
251        /// Disable the governance auth token (NOT recommended; leaves writes unauthenticated)
252        #[arg(long)]
253        no_token: bool,
254        /// Required to bind a non-loopback address. Exposes the knowledge base to
255        /// the network; the auth token then gates reads as well as writes.
256        #[arg(long)]
257        allow_remote: bool,
258    },
259    /// Agent hook handlers (called by agent hooks; reads payload from stdin)
260    Hook {
261        #[command(subcommand)]
262        action: HookCommands,
263    },
264}
265
266pub fn run() -> anyhow::Result<()> {
267    let cli = Cli::parse();
268    // Create the ~/.innate subdirectory layout and migrate any legacy flat files
269    // before any path is resolved.
270    crate::paths::ensure_layout();
271    let db_path = cli.db.unwrap_or_else(default_db);
272
273    if let Commands::Mcp = &cli.command {
274        return crate::mcp::run_server(db_path);
275    }
276
277    if let Commands::Install = &cli.command {
278        return crate::install::run_install();
279    }
280
281    if let Commands::Uninstall { yes, purge_data } = &cli.command {
282        return crate::install::run_uninstall(*yes, *purge_data);
283    }
284
285    if let Commands::Migrate = &cli.command {
286        let applied = crate::migrate::run_migrations(&db_path)?;
287        if applied.is_empty() {
288            println!("already at 4.14 — nothing to do");
289        } else {
290            for step in &applied {
291                println!("  applied: {step}");
292            }
293            println!("migration complete");
294        }
295        return Ok(());
296    }
297
298    if let Commands::Daemon { action } = &cli.command {
299        return crate::daemon::run_command(action, &db_path);
300    }
301
302    if let Commands::Backup { action } = &cli.command {
303        return crate::backup::run_command(action, &db_path);
304    }
305
306    if let Commands::Upgrade { version, check } = &cli.command {
307        return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
308    }
309
310    if let Commands::Hook { action } = &cli.command {
311        return crate::hook::run_command(action, &db_path);
312    }
313
314    let kb = crate::open_kb(&db_path)?;
315
316    match cli.command {
317        Commands::Recall {
318            query,
319            budget,
320            top,
321            format,
322            include_sparks,
323            expand_deps,
324            allow_trim,
325            refine_mode,
326            source,
327            min_score,
328            session,
329        } => {
330            let result = kb.recall(RecallParams {
331                query: &query,
332                budget,
333                trace: true,
334                include_sparks,
335                top,
336                source: &source,
337                expand_deps: &expand_deps,
338                allow_trim,
339                refine_mode: &refine_mode,
340                min_score,
341                session_only: session,
342            })?;
343            match format.as_str() {
344                "json" => println!(
345                    "{}",
346                    serde_json::to_string_pretty(&json!({
347                        "trace_id": result.trace_id,
348                        "knowledge": result.knowledge,
349                        "sparks": result.sparks,
350                        "empty": result.empty,
351                    }))?
352                ),
353                "prompt" => {
354                    for chunk in &result.knowledge {
355                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
356                        println!("{content}\n---");
357                    }
358                    // metadata at end (§九 CLI contract)
359                    println!("<!-- innate_trace_id: {} -->", result.trace_id);
360                    println!(
361                        "<!-- innate_selected: {} -->",
362                        result
363                            .knowledge
364                            .iter()
365                            .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
366                            .collect::<Vec<_>>()
367                            .join(",")
368                    );
369                }
370                _ => {
371                    for chunk in &result.knowledge {
372                        let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
373                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
374                        let conf = chunk
375                            .get("confidence")
376                            .and_then(|v| v.as_f64())
377                            .unwrap_or(0.5);
378                        println!("[{id}] (conf={conf:.2})\n{content}\n");
379                    }
380                    if result.empty {
381                        println!("(no results)");
382                    }
383                }
384            }
385        }
386        Commands::Appraise {
387            query,
388            last_error,
389            recent_actions,
390            stage,
391            file_context,
392            candidate,
393            top,
394            min_strength,
395            source,
396            format,
397        } => {
398            let actions: Vec<String> = recent_actions
399                .as_deref()
400                .map(|raw| {
401                    raw.split(',')
402                        .map(str::trim)
403                        .filter(|a| !a.is_empty())
404                        .map(str::to_string)
405                        .collect()
406                })
407                .unwrap_or_default();
408            let situation = Situation {
409                query: (!query.is_empty()).then_some(query.as_str()),
410                last_error: last_error.as_deref(),
411                recent_actions: &actions,
412                stage: stage.as_deref(),
413                file_context: file_context.as_deref(),
414            };
415            let verdict = kb.appraise(AppraiseParams {
416                situation,
417                candidate: candidate.as_deref(),
418                min_strength,
419                top,
420                trace: true,
421                source: &source,
422            })?;
423            match format.as_str() {
424                "text" => {
425                    println!("ℹ {APPRAISE_ADVISORY}");
426                    if verdict.abstained {
427                        println!(
428                            "ABSTAIN reason={:?} strength={:.3} trace_id={}",
429                            verdict.abstain_reason, verdict.strength, verdict.trace_id
430                        );
431                    } else {
432                        println!(
433                            "valence={:?} tier={:?} strength={:.3} confidence={:.3} dispersion={:.3} trace_id={}",
434                            verdict.valence, verdict.tier, verdict.strength,
435                            verdict.confidence, verdict.dispersion, verdict.trace_id
436                        );
437                    }
438                    for fp in &verdict.flagged_points {
439                        println!(
440                            "  ⚠ [{}] {} (s={:.3})",
441                            fp.chunk_id, fp.summary, fp.strength
442                        );
443                    }
444                }
445                _ => println!(
446                    "{}",
447                    serde_json::to_string_pretty(&json!({
448                        "advisory": APPRAISE_ADVISORY,
449                        "valence": verdict.valence,
450                        "strength": verdict.strength,
451                        "tier": verdict.tier,
452                        "confidence": verdict.confidence,
453                        "dispersion": verdict.dispersion,
454                        "abstained": verdict.abstained,
455                        "abstain_reason": verdict.abstain_reason,
456                        "flagged_points": verdict.flagged_points,
457                        "contributors": verdict.contributors,
458                        "trace_id": verdict.trace_id,
459                    }))?
460                ),
461            }
462        }
463        Commands::Record {
464            trace_id,
465            query,
466            outcome,
467            used,
468            used_attribution,
469            used_partial,
470            output,
471            output_summary,
472            nomination,
473            source,
474            feedback,
475            feedback_kind,
476            feedback_actor,
477            feedback_reason,
478            task_state,
479            priority,
480            verdict_heeded,
481        } => {
482            let used_ids = used.as_deref().map(|raw| {
483                raw.split(',')
484                    .map(str::trim)
485                    .filter(|id| !id.is_empty())
486                    .map(str::to_string)
487                    .collect::<Vec<_>>()
488            });
489            let used_ref = used_ids.as_deref();
490            // Per §二·五B: trace-level "up" applies only to explicitly used chunks.
491            let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
492                match feedback.as_deref() {
493                    Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
494                        (used_ids.clone(), None)
495                    }
496                    Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
497                        (None, used_ids.clone())
498                    }
499                    Some("up") => (None, None), // no used chunks — ignore per design
500                    Some("down") => (None, None),
501                    _ => (None, None),
502                };
503            let fb_up_ref = fb_up.as_deref();
504            let fb_down_ref = fb_down.as_deref();
505            kb.record(RecordParams {
506                trace_id: &trace_id,
507                query: query.as_deref(),
508                output: output.as_deref(),
509                output_summary: output_summary.as_deref(),
510                outcome: outcome.as_deref(),
511                used: used_ref,
512                used_attribution: &used_attribution,
513                used_complete: Some(!used_partial),
514                feedback_up: fb_up_ref,
515                feedback_down: fb_down_ref,
516                feedback_kind: &feedback_kind,
517                feedback_actor: feedback_actor.as_deref(),
518                feedback_reason: feedback_reason.as_deref(),
519                nomination: nomination.as_deref(),
520                priority,
521                task_state: task_state.as_deref(),
522                source: &source,
523                verdict_heeded,
524            })?;
525            println!("recorded");
526        }
527        Commands::Add {
528            content,
529            kind,
530            trigger,
531            anti_trigger,
532            source,
533            skill_name,
534            depends_on,
535            dep_kind,
536        } => {
537            // If kind=skill and content is a readable file path, load its content.
538            let content = if kind == "skill" {
539                let p = std::path::Path::new(&content);
540                if p.exists() && p.is_file() {
541                    std::fs::read_to_string(p).map_err(|e| {
542                        anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
543                    })?
544                } else {
545                    content
546                }
547            } else {
548                content
549            };
550            let deps: Vec<(String, String)> = depends_on
551                .iter()
552                .map(|d| (d.clone(), dep_kind.clone()))
553                .collect();
554            let id = kb.add_with_deps(
555                &content,
556                &kind,
557                trigger.as_deref(),
558                anti_trigger.as_deref(),
559                &source,
560                skill_name.as_deref(),
561                &deps,
562            )?;
563            println!("{id}");
564        }
565        Commands::Spark { content, trigger } => {
566            let id = kb.spark(&content, trigger.as_deref(), None)?;
567            println!("{id}");
568        }
569        Commands::Evolve {
570            trigger,
571            rebuild_embeddings,
572        } => {
573            if rebuild_embeddings {
574                let rebuilt = kb.rebuild_embeddings()?;
575                let report = kb.evolve(&trigger)?;
576                println!(
577                    "{}",
578                    serde_json::to_string_pretty(&json!({
579                        "rebuilt_embeddings": rebuilt,
580                        "evolve": report
581                    }))?
582                );
583            } else {
584                let report = kb.evolve(&trigger)?;
585                println!("{}", serde_json::to_string_pretty(&report)?);
586            }
587        }
588        Commands::Inspect { id } => match id.as_deref() {
589            None => {
590                let info = kb.inspect()?;
591                println!("{}", serde_json::to_string_pretty(&info)?);
592            }
593            Some(id) => {
594                let detail = kb.inspect_id(id)?;
595                println!("{}", serde_json::to_string_pretty(&detail)?);
596            }
597        },
598        Commands::Approve { chunk_id } => {
599            kb.approve(&chunk_id)?;
600            println!("approved");
601        }
602        Commands::Archive { chunk_id, reason } => {
603            kb.archive(&chunk_id, &reason)?;
604            println!("archived");
605        }
606        Commands::Invalidate { chunk_id, reason } => {
607            kb.invalidate(&chunk_id, &reason)?;
608            println!("invalidated");
609        }
610        Commands::Restore { chunk_id } => {
611            kb.restore(&chunk_id)?;
612            println!("restored");
613        }
614        Commands::MatureSpark { spark_id, to } => {
615            kb.mature_spark(&spark_id, &to)?;
616            println!("matured");
617        }
618        Commands::PromoteSpark { spark_id, to } => {
619            let id = kb.promote_spark(&spark_id, &to)?;
620            println!("{id}");
621        }
622        Commands::DropSpark { spark_id, reason } => {
623            kb.drop_spark(&spark_id, &reason)?;
624            println!("dropped");
625        }
626        Commands::Vacuum => {
627            let (before, after) = kb.storage.vacuum()?;
628            let mb = |b: i64| b as f64 / 1_048_576.0;
629            println!(
630                "vacuumed: {:.2} MB → {:.2} MB (reclaimed {:.2} MB)",
631                mb(before),
632                mb(after),
633                mb(before - after)
634            );
635        }
636        Commands::RepairTraces { dry_run } => {
637            let r = kb.repair_traces(dry_run)?;
638            let tag = if dry_run {
639                "[dry-run] would repair"
640            } else {
641                "repaired"
642            };
643            println!(
644                "{tag}: deleted {} false daemon selection events, retired {} orphaned open logs, \
645                 selected_count {} → {}",
646                r.daemon_events_deleted, r.open_logs_retired, r.selected_before, r.selected_after
647            );
648        }
649        Commands::Web {
650            bind,
651            port,
652            no_token,
653            allow_remote,
654        } => {
655            let loopback = crate::web::is_loopback(&bind);
656            if !loopback && !allow_remote {
657                anyhow::bail!(
658                    "refusing to bind non-loopback address {bind} without --allow-remote \
659                     (this exposes the knowledge base to the network)"
660                );
661            }
662            if !loopback && no_token {
663                anyhow::bail!(
664                    "--no-token cannot be combined with a non-loopback bind: a network-exposed \
665                     server must keep the auth token to gate reads and writes"
666                );
667            }
668            crate::web::serve(kb, &bind, port, !no_token)?;
669        }
670        Commands::Mcp
671        | Commands::Install
672        | Commands::Uninstall { .. }
673        | Commands::Migrate
674        | Commands::Upgrade { .. }
675        | Commands::Daemon { .. }
676        | Commands::Backup { .. }
677        | Commands::Hook { .. } => unreachable!(),
678    }
679    Ok(())
680}