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 },
62 Appraise {
65 #[arg(long, default_value = "")]
67 query: String,
68 #[arg(long)]
70 last_error: Option<String>,
71 #[arg(long)]
73 recent_actions: Option<String>,
74 #[arg(long)]
76 stage: Option<String>,
77 #[arg(long)]
79 file_context: Option<String>,
80 #[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 Record {
94 trace_id: String,
95 #[arg(long)]
96 query: Option<String>,
97 #[arg(long)]
98 outcome: Option<String>,
99 #[arg(long)]
101 used: Option<String>,
102 #[arg(long, default_value = "explicit")]
103 used_attribution: String,
104 #[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 #[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 #[arg(long)]
132 verdict_heeded: bool,
133 },
134 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 #[arg(long = "depends-on")]
149 depends_on: Vec<String>,
150 #[arg(long, default_value = "hard")]
152 dep_kind: String,
153 },
154 Spark {
156 content: String,
157 #[arg(long)]
158 trigger: Option<String>,
159 },
160 Evolve {
162 #[arg(long, default_value = "manual")]
163 trigger: String,
164 #[arg(long)]
166 rebuild_embeddings: bool,
167 },
168 Inspect { id: Option<String> },
170 Approve { chunk_id: String },
172 Archive {
174 chunk_id: String,
175 #[arg(long, default_value = "stale")]
176 reason: String,
177 },
178 Invalidate {
180 chunk_id: String,
181 #[arg(long, default_value = "")]
182 reason: String,
183 },
184 Restore { chunk_id: String },
186 MatureSpark { spark_id: String, to: String },
188 PromoteSpark {
190 spark_id: String,
191 #[arg(long, default_value = "note")]
192 to: String,
193 },
194 DropSpark {
196 spark_id: String,
197 #[arg(long, default_value = "")]
198 reason: String,
199 },
200 Backup {
202 #[command(subcommand)]
203 action: BackupCommands,
204 },
205 Install,
207 Uninstall {
209 #[arg(long, short = 'y')]
211 yes: bool,
212 #[arg(long)]
214 purge_data: bool,
215 },
216 Migrate,
218 Vacuum,
220 RepairTraces {
223 #[arg(long)]
225 dry_run: bool,
226 },
227 Upgrade {
229 #[arg(long, value_name = "VERSION")]
231 version: Option<String>,
232 #[arg(long)]
234 check: bool,
235 },
236 Daemon {
238 #[command(subcommand)]
239 action: DaemonCommands,
240 },
241 Mcp,
243 Web {
245 #[arg(long, default_value = "127.0.0.1")]
247 bind: String,
248 #[arg(long, default_value_t = 8788)]
250 port: u16,
251 #[arg(long)]
253 no_token: bool,
254 #[arg(long)]
257 allow_remote: bool,
258 },
259 Hook {
261 #[command(subcommand)]
262 action: HookCommands,
263 },
264}
265
266pub fn run() -> anyhow::Result<()> {
267 let cli = Cli::parse();
268 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 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 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), 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 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}