1use 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 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 #[arg(long, default_value = "false")]
42 expand_deps: String,
43 #[arg(long)]
45 allow_trim: bool,
46 #[arg(long, default_value = "off")]
48 refine_mode: String,
49 #[arg(long, default_value = "cli")]
51 source: String,
52 #[arg(long)]
55 min_score: Option<f64>,
56 #[arg(long)]
60 session: bool,
61 #[arg(long)]
64 rerank: bool,
65 },
66 Appraise {
69 #[arg(long, default_value = "")]
71 query: String,
72 #[arg(long)]
74 last_error: Option<String>,
75 #[arg(long)]
77 recent_actions: Option<String>,
78 #[arg(long)]
80 stage: Option<String>,
81 #[arg(long)]
83 file_context: Option<String>,
84 #[arg(long)]
86 candidate: Option<String>,
87 #[arg(long)]
88 top: Option<usize>,
89 #[arg(long)]
90 min_strength: Option<f64>,
91 #[arg(long, default_value = "cli")]
92 source: String,
93 #[arg(long, default_value = "json")]
94 format: String,
95 },
96 Record {
98 trace_id: String,
99 #[arg(long)]
100 query: Option<String>,
101 #[arg(long)]
102 outcome: Option<String>,
103 #[arg(long)]
105 used: Option<String>,
106 #[arg(long, default_value = "explicit")]
107 used_attribution: String,
108 #[arg(long)]
110 used_partial: bool,
111 #[arg(long)]
112 output: Option<String>,
113 #[arg(long)]
114 output_summary: Option<String>,
115 #[arg(long)]
116 nomination: Option<String>,
117 #[arg(long, default_value = "cli")]
118 source: String,
119 #[arg(long)]
121 feedback: Option<String>,
122 #[arg(long, default_value = "user")]
123 feedback_kind: String,
124 #[arg(long)]
125 feedback_actor: Option<String>,
126 #[arg(long)]
127 feedback_reason: Option<String>,
128 #[arg(long)]
129 task_state: Option<String>,
130 #[arg(long, default_value = "0")]
131 priority: i64,
132 #[arg(long)]
136 verdict_heeded: bool,
137 },
138 Add {
140 content: String,
141 #[arg(long, default_value = "note")]
142 kind: String,
143 #[arg(long)]
144 trigger: Option<String>,
145 #[arg(long)]
146 anti_trigger: Option<String>,
147 #[arg(long, default_value = "chat")]
148 source: String,
149 #[arg(long)]
150 skill_name: Option<String>,
151 #[arg(long = "depends-on")]
153 depends_on: Vec<String>,
154 #[arg(long, default_value = "hard")]
156 dep_kind: String,
157 },
158 Spark {
160 content: String,
161 #[arg(long)]
162 trigger: Option<String>,
163 },
164 Evolve {
166 #[arg(long, default_value = "manual")]
167 trigger: String,
168 #[arg(long)]
170 rebuild_embeddings: bool,
171 },
172 Inspect { id: Option<String> },
174 Approve { chunk_id: String },
176 Archive {
178 chunk_id: String,
179 #[arg(long, default_value = "stale")]
180 reason: String,
181 },
182 Invalidate {
184 chunk_id: String,
185 #[arg(long, default_value = "")]
186 reason: String,
187 },
188 Restore { chunk_id: String },
190 MatureSpark { spark_id: String, to: String },
192 PromoteSpark {
194 spark_id: String,
195 #[arg(long, default_value = "note")]
196 to: String,
197 },
198 DropSpark {
200 spark_id: String,
201 #[arg(long, default_value = "")]
202 reason: String,
203 },
204 Backup {
206 #[command(subcommand)]
207 action: BackupCommands,
208 },
209 Install,
211 Uninstall {
213 #[arg(long, short = 'y')]
215 yes: bool,
216 #[arg(long)]
218 purge_data: bool,
219 },
220 Migrate,
222 Vacuum,
224 RepairTraces {
227 #[arg(long)]
229 dry_run: bool,
230 },
231 RecallEval {
236 labels: PathBuf,
238 #[arg(long, default_value = "10")]
240 k: usize,
241 },
242 Upgrade {
244 #[arg(long, value_name = "VERSION")]
246 version: Option<String>,
247 #[arg(long)]
249 check: bool,
250 },
251 Daemon {
253 #[command(subcommand)]
254 action: DaemonCommands,
255 },
256 Mcp,
258 Web {
260 #[arg(long, default_value = "127.0.0.1")]
262 bind: String,
263 #[arg(long, default_value_t = 8788)]
265 port: u16,
266 #[arg(long)]
268 no_token: bool,
269 #[arg(long)]
272 allow_remote: bool,
273 },
274 Hook {
276 #[command(subcommand)]
277 action: HookCommands,
278 },
279}
280
281pub fn run() -> anyhow::Result<()> {
282 let cli = Cli::parse();
283 crate::paths::ensure_layout();
286 let db_path = cli.db.unwrap_or_else(default_db);
287
288 if let Commands::Mcp = &cli.command {
289 return crate::mcp::run_server(db_path);
290 }
291
292 if let Commands::Install = &cli.command {
293 return crate::install::run_install();
294 }
295
296 if let Commands::Uninstall { yes, purge_data } = &cli.command {
297 return crate::install::run_uninstall(*yes, *purge_data);
298 }
299
300 if let Commands::Migrate = &cli.command {
301 let applied = crate::migrate::run_migrations(&db_path)?;
302 if applied.is_empty() {
303 println!("already at 4.14 — nothing to do");
304 } else {
305 for step in &applied {
306 println!(" applied: {step}");
307 }
308 println!("migration complete");
309 }
310 return Ok(());
311 }
312
313 if let Commands::Daemon { action } = &cli.command {
314 return crate::daemon::run_command(action, &db_path);
315 }
316
317 if let Commands::Backup { action } = &cli.command {
318 return crate::backup::run_command(action, &db_path);
319 }
320
321 if let Commands::Upgrade { version, check } = &cli.command {
322 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
323 }
324
325 if let Commands::Hook { action } = &cli.command {
326 return crate::hook::run_command(action, &db_path);
327 }
328
329 let kb = crate::open_kb(&db_path)?;
330
331 match cli.command {
332 Commands::Recall {
333 query,
334 budget,
335 top,
336 format,
337 include_sparks,
338 expand_deps,
339 allow_trim,
340 refine_mode,
341 source,
342 min_score,
343 session,
344 rerank,
345 } => {
346 let result = kb.recall(RecallParams {
347 query: &query,
348 budget,
349 trace: true,
350 include_sparks,
351 top,
352 source: &source,
353 expand_deps: &expand_deps,
354 allow_trim,
355 refine_mode: &refine_mode,
356 min_score,
357 session_only: session,
358 rerank,
359 })?;
360 match format.as_str() {
361 "json" => println!(
362 "{}",
363 serde_json::to_string_pretty(&json!({
364 "trace_id": result.trace_id,
365 "knowledge": result.knowledge,
366 "sparks": result.sparks,
367 "empty": result.empty,
368 }))?
369 ),
370 "prompt" => {
371 for chunk in &result.knowledge {
372 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
373 println!("{content}\n---");
374 }
375 println!("<!-- innate_trace_id: {} -->", result.trace_id);
377 println!(
378 "<!-- innate_selected: {} -->",
379 result
380 .knowledge
381 .iter()
382 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
383 .collect::<Vec<_>>()
384 .join(",")
385 );
386 }
387 _ => {
388 for chunk in &result.knowledge {
389 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
390 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
391 let conf = chunk
392 .get("confidence")
393 .and_then(|v| v.as_f64())
394 .unwrap_or(0.5);
395 println!("[{id}] (conf={conf:.2})\n{content}\n");
396 }
397 if result.empty {
398 println!("(no results)");
399 }
400 }
401 }
402 }
403 Commands::Appraise {
404 query,
405 last_error,
406 recent_actions,
407 stage,
408 file_context,
409 candidate,
410 top,
411 min_strength,
412 source,
413 format,
414 } => {
415 let actions: Vec<String> = recent_actions
416 .as_deref()
417 .map(|raw| {
418 raw.split(',')
419 .map(str::trim)
420 .filter(|a| !a.is_empty())
421 .map(str::to_string)
422 .collect()
423 })
424 .unwrap_or_default();
425 let situation = Situation {
426 query: (!query.is_empty()).then_some(query.as_str()),
427 last_error: last_error.as_deref(),
428 recent_actions: &actions,
429 stage: stage.as_deref(),
430 file_context: file_context.as_deref(),
431 };
432 let verdict = kb.appraise(AppraiseParams {
433 situation,
434 candidate: candidate.as_deref(),
435 min_strength,
436 top,
437 trace: true,
438 source: &source,
439 })?;
440 match format.as_str() {
441 "text" => {
442 println!("ℹ {APPRAISE_ADVISORY}");
443 if verdict.abstained {
444 println!(
445 "ABSTAIN reason={:?} strength={:.3} trace_id={}",
446 verdict.abstain_reason, verdict.strength, verdict.trace_id
447 );
448 } else {
449 println!(
450 "valence={:?} tier={:?} strength={:.3} confidence={:.3} dispersion={:.3} trace_id={}",
451 verdict.valence, verdict.tier, verdict.strength,
452 verdict.confidence, verdict.dispersion, verdict.trace_id
453 );
454 }
455 for fp in &verdict.flagged_points {
456 println!(
457 " ⚠ [{}] {} (s={:.3})",
458 fp.chunk_id, fp.summary, fp.strength
459 );
460 }
461 }
462 _ => println!(
463 "{}",
464 serde_json::to_string_pretty(&json!({
465 "advisory": APPRAISE_ADVISORY,
466 "valence": verdict.valence,
467 "strength": verdict.strength,
468 "tier": verdict.tier,
469 "confidence": verdict.confidence,
470 "dispersion": verdict.dispersion,
471 "abstained": verdict.abstained,
472 "abstain_reason": verdict.abstain_reason,
473 "flagged_points": verdict.flagged_points,
474 "contributors": verdict.contributors,
475 "trace_id": verdict.trace_id,
476 }))?
477 ),
478 }
479 }
480 Commands::Record {
481 trace_id,
482 query,
483 outcome,
484 used,
485 used_attribution,
486 used_partial,
487 output,
488 output_summary,
489 nomination,
490 source,
491 feedback,
492 feedback_kind,
493 feedback_actor,
494 feedback_reason,
495 task_state,
496 priority,
497 verdict_heeded,
498 } => {
499 let used_ids = used.as_deref().map(|raw| {
500 raw.split(',')
501 .map(str::trim)
502 .filter(|id| !id.is_empty())
503 .map(str::to_string)
504 .collect::<Vec<_>>()
505 });
506 let used_ref = used_ids.as_deref();
507 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
509 match feedback.as_deref() {
510 Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
511 (used_ids.clone(), None)
512 }
513 Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
514 (None, used_ids.clone())
515 }
516 Some("up") => (None, None), Some("down") => (None, None),
518 _ => (None, None),
519 };
520 let fb_up_ref = fb_up.as_deref();
521 let fb_down_ref = fb_down.as_deref();
522 kb.record(RecordParams {
523 trace_id: &trace_id,
524 query: query.as_deref(),
525 output: output.as_deref(),
526 output_summary: output_summary.as_deref(),
527 outcome: outcome.as_deref(),
528 used: used_ref,
529 used_attribution: &used_attribution,
530 used_complete: Some(!used_partial),
531 feedback_up: fb_up_ref,
532 feedback_down: fb_down_ref,
533 feedback_kind: &feedback_kind,
534 feedback_actor: feedback_actor.as_deref(),
535 feedback_reason: feedback_reason.as_deref(),
536 nomination: nomination.as_deref(),
537 priority,
538 task_state: task_state.as_deref(),
539 source: &source,
540 verdict_heeded,
541 })?;
542 println!("recorded");
543 }
544 Commands::Add {
545 content,
546 kind,
547 trigger,
548 anti_trigger,
549 source,
550 skill_name,
551 depends_on,
552 dep_kind,
553 } => {
554 let content = if kind == "skill" {
556 let p = std::path::Path::new(&content);
557 if p.exists() && p.is_file() {
558 std::fs::read_to_string(p).map_err(|e| {
559 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
560 })?
561 } else {
562 content
563 }
564 } else {
565 content
566 };
567 let deps: Vec<(String, String)> = depends_on
568 .iter()
569 .map(|d| (d.clone(), dep_kind.clone()))
570 .collect();
571 let id = kb.add_with_deps(
572 &content,
573 &kind,
574 trigger.as_deref(),
575 anti_trigger.as_deref(),
576 &source,
577 skill_name.as_deref(),
578 &deps,
579 )?;
580 println!("{id}");
581 }
582 Commands::Spark { content, trigger } => {
583 let id = kb.spark(&content, trigger.as_deref(), None)?;
584 println!("{id}");
585 }
586 Commands::Evolve {
587 trigger,
588 rebuild_embeddings,
589 } => {
590 if rebuild_embeddings {
591 let rebuilt = kb.rebuild_embeddings()?;
592 let report = kb.evolve(&trigger)?;
593 println!(
594 "{}",
595 serde_json::to_string_pretty(&json!({
596 "rebuilt_embeddings": rebuilt,
597 "evolve": report
598 }))?
599 );
600 } else {
601 let report = kb.evolve(&trigger)?;
602 println!("{}", serde_json::to_string_pretty(&report)?);
603 }
604 }
605 Commands::Inspect { id } => match id.as_deref() {
606 None => {
607 let info = kb.inspect()?;
608 println!("{}", serde_json::to_string_pretty(&info)?);
609 }
610 Some(id) => {
611 let detail = kb.inspect_id(id)?;
612 println!("{}", serde_json::to_string_pretty(&detail)?);
613 }
614 },
615 Commands::Approve { chunk_id } => {
616 kb.approve(&chunk_id)?;
617 println!("approved");
618 }
619 Commands::Archive { chunk_id, reason } => {
620 kb.archive(&chunk_id, &reason)?;
621 println!("archived");
622 }
623 Commands::Invalidate { chunk_id, reason } => {
624 kb.invalidate(&chunk_id, &reason)?;
625 println!("invalidated");
626 }
627 Commands::Restore { chunk_id } => {
628 kb.restore(&chunk_id)?;
629 println!("restored");
630 }
631 Commands::MatureSpark { spark_id, to } => {
632 kb.mature_spark(&spark_id, &to)?;
633 println!("matured");
634 }
635 Commands::PromoteSpark { spark_id, to } => {
636 let id = kb.promote_spark(&spark_id, &to)?;
637 println!("{id}");
638 }
639 Commands::DropSpark { spark_id, reason } => {
640 kb.drop_spark(&spark_id, &reason)?;
641 println!("dropped");
642 }
643 Commands::Vacuum => {
644 let (before, after) = kb.storage.vacuum()?;
645 let mb = |b: i64| b as f64 / 1_048_576.0;
646 println!(
647 "vacuumed: {:.2} MB → {:.2} MB (reclaimed {:.2} MB)",
648 mb(before),
649 mb(after),
650 mb(before - after)
651 );
652 }
653 Commands::RepairTraces { dry_run } => {
654 let r = kb.repair_traces(dry_run)?;
655 let tag = if dry_run {
656 "[dry-run] would repair"
657 } else {
658 "repaired"
659 };
660 println!(
661 "{tag}: deleted {} false daemon selection events, retired {} orphaned open logs, \
662 selected_count {} → {}",
663 r.daemon_events_deleted, r.open_logs_retired, r.selected_before, r.selected_after
664 );
665 }
666 Commands::RecallEval { labels, k } => {
667 let text = std::fs::read_to_string(&labels)
668 .map_err(|e| anyhow::anyhow!("read labels {}: {e}", labels.display()))?;
669 let mut n = 0usize;
670 let (mut sum_p1, mut sum_recall, mut sum_mrr, mut sum_ndcg) = (0.0, 0.0, 0.0, 0.0);
671 for (lineno, line) in text.lines().enumerate() {
672 let line = line.trim();
673 if line.is_empty() {
674 continue;
675 }
676 let row: serde_json::Value = serde_json::from_str(line)
677 .map_err(|e| anyhow::anyhow!("labels line {}: {e}", lineno + 1))?;
678 let query = row.get("query").and_then(|v| v.as_str()).unwrap_or("");
679 let relevant: std::collections::HashSet<String> = row
680 .get("relevant_ids")
681 .and_then(|v| v.as_array())
682 .map(|a| {
683 a.iter()
684 .filter_map(|v| v.as_str().map(str::to_string))
685 .collect()
686 })
687 .unwrap_or_default();
688 if query.is_empty() || relevant.is_empty() {
689 continue;
690 }
691 let result = kb.recall(RecallParams {
692 query,
693 budget: 100_000,
694 trace: false,
695 top: Some(k),
696 source: "cli",
697 ..Default::default()
698 })?;
699 let ranked: Vec<String> = result
700 .knowledge
701 .iter()
702 .filter_map(|c| c.get("id").and_then(|v| v.as_str()).map(str::to_string))
703 .collect();
704 let (p1, recall_k, mrr, ndcg) = recall_metrics(&ranked, &relevant, k);
705 sum_p1 += p1;
706 sum_recall += recall_k;
707 sum_mrr += mrr;
708 sum_ndcg += ndcg;
709 n += 1;
710 }
711 if n == 0 {
712 return Err(anyhow::anyhow!(
713 "no usable labeled queries (need lines with non-empty query + relevant_ids)"
714 ));
715 }
716 let nf = n as f64;
717 let out = json!({
718 "queries": n,
719 "k": k,
720 "p_at_1": (sum_p1 / nf * 1000.0).round() / 1000.0,
721 "recall_at_k": (sum_recall / nf * 1000.0).round() / 1000.0,
722 "mrr": (sum_mrr / nf * 1000.0).round() / 1000.0,
723 "ndcg_at_k": (sum_ndcg / nf * 1000.0).round() / 1000.0,
724 });
725 println!("{}", serde_json::to_string_pretty(&out)?);
726 }
727 Commands::Web {
728 bind,
729 port,
730 no_token,
731 allow_remote,
732 } => {
733 let loopback = crate::web::is_loopback(&bind);
734 if !loopback && !allow_remote {
735 anyhow::bail!(
736 "refusing to bind non-loopback address {bind} without --allow-remote \
737 (this exposes the knowledge base to the network)"
738 );
739 }
740 if !loopback && no_token {
741 anyhow::bail!(
742 "--no-token cannot be combined with a non-loopback bind: a network-exposed \
743 server must keep the auth token to gate reads and writes"
744 );
745 }
746 crate::web::serve(kb, &bind, port, !no_token)?;
747 }
748 Commands::Mcp
749 | Commands::Install
750 | Commands::Uninstall { .. }
751 | Commands::Migrate
752 | Commands::Upgrade { .. }
753 | Commands::Daemon { .. }
754 | Commands::Backup { .. }
755 | Commands::Hook { .. } => unreachable!(),
756 }
757 Ok(())
758}
759
760pub(crate) fn recall_metrics(
765 ranked: &[String],
766 relevant: &std::collections::HashSet<String>,
767 k: usize,
768) -> (f64, f64, f64, f64) {
769 let topk = &ranked[..ranked.len().min(k)];
770 let p_at_1 = topk
771 .first()
772 .map(|id| relevant.contains(id) as u8 as f64)
773 .unwrap_or(0.0);
774 let hits = topk.iter().filter(|id| relevant.contains(*id)).count();
775 let recall_at_k = hits as f64 / relevant.len() as f64;
776 let mrr = ranked
778 .iter()
779 .position(|id| relevant.contains(id))
780 .map(|pos| 1.0 / (pos as f64 + 1.0))
781 .unwrap_or(0.0);
782 let dcg: f64 = topk
784 .iter()
785 .enumerate()
786 .filter(|(_, id)| relevant.contains(*id))
787 .map(|(i, _)| 1.0 / ((i as f64 + 2.0).log2()))
788 .sum();
789 let ideal_hits = relevant.len().min(k);
790 let idcg: f64 = (0..ideal_hits)
791 .map(|i| 1.0 / ((i as f64 + 2.0).log2()))
792 .sum();
793 let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 };
794 (p_at_1, recall_at_k, mrr, ndcg)
795}
796
797#[cfg(test)]
798mod metric_tests {
799 use super::recall_metrics;
800 use std::collections::HashSet;
801
802 fn rel(ids: &[&str]) -> HashSet<String> {
803 ids.iter().map(|s| s.to_string()).collect()
804 }
805 fn ranked(ids: &[&str]) -> Vec<String> {
806 ids.iter().map(|s| s.to_string()).collect()
807 }
808
809 #[test]
810 fn perfect_ranking_scores_one() {
811 let (p1, r, mrr, ndcg) = recall_metrics(&ranked(&["a", "b", "x"]), &rel(&["a", "b"]), 5);
812 assert!((p1 - 1.0).abs() < 1e-9);
813 assert!((r - 1.0).abs() < 1e-9);
814 assert!((mrr - 1.0).abs() < 1e-9);
815 assert!((ndcg - 1.0).abs() < 1e-9);
816 }
817
818 #[test]
819 fn missed_first_lowers_p1_and_mrr() {
820 let (p1, r, mrr, _ndcg) = recall_metrics(&ranked(&["x", "a"]), &rel(&["a"]), 5);
822 assert_eq!(p1, 0.0);
823 assert!((mrr - 0.5).abs() < 1e-9);
824 assert!((r - 1.0).abs() < 1e-9);
825 }
826
827 #[test]
828 fn k_cutoff_limits_recall() {
829 let (_p1, r, _mrr, ndcg) = recall_metrics(&ranked(&["x", "a"]), &rel(&["a"]), 1);
831 assert_eq!(r, 0.0);
832 assert_eq!(ndcg, 0.0);
833 }
834
835 #[test]
836 fn no_hits_is_all_zero() {
837 let (p1, r, mrr, ndcg) = recall_metrics(&ranked(&["x", "y"]), &rel(&["a"]), 5);
838 assert_eq!((p1, r, mrr, ndcg), (0.0, 0.0, 0.0, 0.0));
839 }
840}